From e1917ac88ce3dc0d45c77679523387dd4c68a2fb Mon Sep 17 00:00:00 2001 From: Grzegorz Godlewski Date: Sat, 14 Mar 2026 14:09:40 +0100 Subject: [PATCH 01/16] variants: add linux portduino native firmware support --- boards/linux.json | 21 ++++ src/helpers/radiolib/LinuxSX1262.h | 48 ++++++++ src/helpers/radiolib/LinuxSX1262Wrapper.h | 22 ++++ variants/linux/LinuxBoard.cpp | 137 ++++++++++++++++++++++ variants/linux/LinuxBoard.h | 92 +++++++++++++++ variants/linux/meshcored.ini | 28 +++++ variants/linux/meshcored.service | 29 +++++ variants/linux/platformio.ini | 47 ++++++++ variants/linux/target.cpp | 54 +++++++++ variants/linux/target.h | 32 +++++ variants/portduino/platformio.ini | 41 +++++++ 11 files changed, 551 insertions(+) create mode 100644 boards/linux.json create mode 100644 src/helpers/radiolib/LinuxSX1262.h create mode 100644 src/helpers/radiolib/LinuxSX1262Wrapper.h create mode 100644 variants/linux/LinuxBoard.cpp create mode 100644 variants/linux/LinuxBoard.h create mode 100644 variants/linux/meshcored.ini create mode 100644 variants/linux/meshcored.service create mode 100644 variants/linux/platformio.ini create mode 100644 variants/linux/target.cpp create mode 100644 variants/linux/target.h create mode 100644 variants/portduino/platformio.ini diff --git a/boards/linux.json b/boards/linux.json new file mode 100644 index 0000000000..21b5cdc33a --- /dev/null +++ b/boards/linux.json @@ -0,0 +1,21 @@ +{ + "build": { + "arduino": { + }, + "core": "linux", + "extra_flags": [ + ], + "hwids": [], + "mcu": "arm64", + "variant": "linux" + }, + "connectivity": ["wifi", "bluetooth"], + "debug": {}, + "frameworks": ["portduino", "linux"], + "name": "Linux", + "upload": { + "maximum_ram_size": 0, + "maximum_size": 0 + }, + "vendor": "Linux" +} diff --git a/src/helpers/radiolib/LinuxSX1262.h b/src/helpers/radiolib/LinuxSX1262.h new file mode 100644 index 0000000000..8d5977e8b0 --- /dev/null +++ b/src/helpers/radiolib/LinuxSX1262.h @@ -0,0 +1,48 @@ +#pragma once + +#include + +#define SX126X_IRQ_HEADER_VALID 0b0000010000 // 4 4 valid LoRa header received +#define SX126X_IRQ_PREAMBLE_DETECTED 0x04 +#define SX126X_PREAMBLE_LENGTH 16 + +extern LinuxBoard board; + +class LinuxSX1262 : public SX1262 { + public: + LinuxSX1262(Module *mod) : SX1262(mod) { } + + bool std_init(SPIClass* spi = NULL) + { + LinuxConfig config = board.config; + + Serial.printf("Radio begin %f %f %d %d %f\n", config.lora_freq, config.lora_bw, config.lora_sf, config.lora_cr, config.lora_tcxo); + int status = begin(config.lora_freq, config.lora_bw, config.lora_sf, config.lora_cr, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, config.lora_tx_power, SX126X_PREAMBLE_LENGTH, config.lora_tcxo); + // if radio init fails with -707/-706, try again with tcxo voltage set to 0.0f + if (status == RADIOLIB_ERR_SPI_CMD_FAILED || status == RADIOLIB_ERR_SPI_CMD_INVALID) { + status = begin(config.lora_freq, config.lora_bw, config.lora_sf, config.lora_cr, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, config.lora_tx_power, SX126X_PREAMBLE_LENGTH, 0.0f); + } + if (status != RADIOLIB_ERR_NONE) { + Serial.print("ERROR: radio init failed: "); + Serial.println(status); + return false; // fail + } + + setCRC(1); + + setCurrentLimit(config.current_limit); + setDio2AsRfSwitch(config.dio2_as_rf_switch); + setRxBoostedGainMode(config.rx_boosted_gain); + if (config.lora_rxen_pin != RADIOLIB_NC || config.lora_txen_pin != RADIOLIB_NC) { + setRfSwitchPins(config.lora_rxen_pin, config.lora_txen_pin); + } + + return true; + } + + bool isReceiving() { + uint16_t irq = getIrqFlags(); + bool detected = (irq & SX126X_IRQ_HEADER_VALID) || (irq & SX126X_IRQ_PREAMBLE_DETECTED); + return detected; + } +}; diff --git a/src/helpers/radiolib/LinuxSX1262Wrapper.h b/src/helpers/radiolib/LinuxSX1262Wrapper.h new file mode 100644 index 0000000000..fbbfd19c9b --- /dev/null +++ b/src/helpers/radiolib/LinuxSX1262Wrapper.h @@ -0,0 +1,22 @@ +#pragma once + +#include "LinuxSX1262.h" +#include "RadioLibWrappers.h" + +class LinuxSX1262Wrapper : public RadioLibWrapper { +public: + LinuxSX1262Wrapper(LinuxSX1262& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } + bool isReceivingPacket() override { + return ((LinuxSX1262 *)_radio)->isReceiving(); + } + float getCurrentRSSI() override { + return ((LinuxSX1262 *)_radio)->getRSSI(false); + } + float getLastRSSI() const override { return ((LinuxSX1262 *)_radio)->getRSSI(); } + float getLastSNR() const override { return ((LinuxSX1262 *)_radio)->getSNR(); } + + float packetScore(float snr, int packet_len) override { + int sf = ((LinuxSX1262 *)_radio)->spreadingFactor; + return packetScoreInt(snr, sf, packet_len); + } +}; diff --git a/variants/linux/LinuxBoard.cpp b/variants/linux/LinuxBoard.cpp new file mode 100644 index 0000000000..0feb95d2bc --- /dev/null +++ b/variants/linux/LinuxBoard.cpp @@ -0,0 +1,137 @@ +#include +#include +#include +#include +#include "linux/gpio/LinuxGPIOPin.h" +#include "LinuxBoard.h" + +int initGPIOPin(uint8_t pinNum, const std::string gpioChipName, uint8_t line) +{ +#ifdef PORTDUINO_LINUX_HARDWARE + char gpio_name[32]; + snprintf(gpio_name, sizeof(gpio_name), "GPIO%d", pinNum); + + try { + GPIOPin *csPin; + csPin = new LinuxGPIOPin(pinNum, gpioChipName.c_str(), line, gpio_name); + csPin->setSilent(); + gpioBind(csPin); + return 0; + } catch (...) { + MESH_DEBUG_PRINTLN("Warning, cannot claim pin %d", pinNum); + return 1; + } +#else + return 0; +#endif +} + +void portduinoSetup() { +} + +void LinuxBoard::begin() { + config.load("/etc/meshcored/meshcored.ini"); + + Serial.printf("SPI begin %s\n", config.spidev); + SPI.begin(config.spidev); + + Serial.printf("LoRa pins NSS=%d BUSY=%d IRQ=%d RESET=%d TX=%d RX=%d\n", + (int)config.lora_nss_pin, + (int)config.lora_busy_pin, + (int)config.lora_irq_pin, + (int)config.lora_reset_pin, + (int)config.lora_rxen_pin, + (int)config.lora_txen_pin); + + if (config.lora_nss_pin != RADIOLIB_NC) { + initGPIOPin(config.lora_nss_pin, "gpiochip0", config.lora_nss_pin); + } + if (config.lora_busy_pin != RADIOLIB_NC) { + initGPIOPin(config.lora_busy_pin, "gpiochip0", config.lora_busy_pin); + } + if (config.lora_irq_pin != RADIOLIB_NC) { + initGPIOPin(config.lora_irq_pin, "gpiochip0", config.lora_irq_pin); + } + if (config.lora_reset_pin != RADIOLIB_NC) { + initGPIOPin(config.lora_reset_pin, "gpiochip0", config.lora_reset_pin); + } + if (config.lora_rxen_pin != RADIOLIB_NC) { + initGPIOPin(config.lora_rxen_pin, "gpiochip0", config.lora_rxen_pin); + } + if (config.lora_txen_pin != RADIOLIB_NC) { + initGPIOPin(config.lora_txen_pin, "gpiochip0", config.lora_txen_pin); + } +} + +void trim(char *str) { + char *end; + while (isspace((unsigned char)*str)) str++; + if (*str == 0) { *str = 0; return; } + end = str + strlen(str) - 1; + while (end > str && isspace((unsigned char)*end)) end--; + end[1] = '\0'; +} + +char *safe_copy(char *value, size_t maxlen) { + char *retval; + size_t length = strlen(value) + 1; + if (length > maxlen) length = maxlen; + + retval = (char *)malloc(length); + strncpy(retval, value, length - 1); + retval[length - 1] = '\0'; + return retval; +} + +int LinuxConfig::load(const char *filename) { + FILE *f = fopen(filename, "r"); + if (!f) return -1; + + char line[512]; + while (fgets(line, sizeof(line), f)) { + char *p = line; + // skip whitespace + while (isspace(*p)) p++; + // skip empty lines and comments + if (*p == '\0' || *p == '#' || *p == ';') continue; + + char *key = p; + while (*p && !isspace(*p) && *p != '=') p++; + if (*p == '\0') continue; + *p++ = '\0'; + + while (*p && (isspace(*p) || *p == '=')) p++; + char *value = p; + p = value; + while (*p && *p != '\n' && *p != '\r' && *p != '#' && *p != ';') p++; + *p = '\0'; + + trim(key); + trim(value); + + if (strcmp(key, "spidev") == 0) spidev = safe_copy(value, 32); + else if (strcmp(key, "lora_freq") == 0) lora_freq = atof(value); + else if (strcmp(key, "lora_bw") == 0) lora_bw = atof(value); + else if (strcmp(key, "lora_sf") == 0) lora_sf = (uint8_t)atoi(value); + else if (strcmp(key, "lora_cr") == 0) lora_cr = (uint8_t)atoi(value); + else if (strcmp(key, "lora_tcxo") == 0) lora_tcxo = atof(value); + else if (strcmp(key, "lora_tx_power") == 0) lora_tx_power = atoi(value); + else if (strcmp(key, "current_limit") == 0) current_limit = atof(value); + else if (strcmp(key, "dio2_as_rf_switch") == 0) dio2_as_rf_switch = value != 0; + else if (strcmp(key, "rx_boosted_gain") == 0) rx_boosted_gain = value != 0; + + else if (strcmp(key, "lora_irq_pin") == 0) lora_irq_pin = atoi(value); + else if (strcmp(key, "lora_reset_pin") == 0) lora_reset_pin = atoi(value); + else if (strcmp(key, "lora_nss_pin") == 0) lora_nss_pin = atoi(value); + else if (strcmp(key, "lora_busy_pin") == 0) lora_busy_pin = atoi(value); + else if (strcmp(key, "lora_rxen_pin") == 0) lora_rxen_pin = atoi(value); + else if (strcmp(key, "lora_txen_pin") == 0) lora_txen_pin = atoi(value); + + else if (strcmp(key, "advert_name") == 0) advert_name = safe_copy(value, 100); + else if (strcmp(key, "admin_password") == 0) admin_password = safe_copy(value, 100); + else if (strcmp(key, "lat") == 0) lat = atof(value); + else if (strcmp(key, "lon") == 0) lon = atof(value); + } + fclose(f); + return 0; +} diff --git a/variants/linux/LinuxBoard.h b/variants/linux/LinuxBoard.h new file mode 100644 index 0000000000..c7ae7501c8 --- /dev/null +++ b/variants/linux/LinuxBoard.h @@ -0,0 +1,92 @@ +#pragma once + +#include +#include +#include +#include + +class LinuxConfig { +public: + float lora_freq = LORA_FREQ; + float lora_bw = LORA_BW; + uint8_t lora_sf = LORA_SF; +#ifdef LORA_CR + uint8_t lora_cr = LORA_CR; +#else + uint8_t lora_cr = 5; +#endif + + uint32_t lora_irq_pin = RADIOLIB_NC; + uint32_t lora_reset_pin = RADIOLIB_NC; + uint32_t lora_nss_pin = RADIOLIB_NC; + uint32_t lora_busy_pin = RADIOLIB_NC; + uint32_t lora_rxen_pin = RADIOLIB_NC; + uint32_t lora_txen_pin = RADIOLIB_NC; + + int8_t lora_tx_power = 22; + float current_limit = 140; + bool dio2_as_rf_switch = false; + bool rx_boosted_gain = true; + + char* spidev = "/dev/spidev0.0"; + + float lora_tcxo = 1.8f; + + char *advert_name = "Linux Repeater"; + char *admin_password = "password"; + float lat = 0.0f; + float lon = 0.0f; + + int load(const char *filename); +}; + +class LinuxBoard : public mesh::MainBoard { +protected: + uint8_t startup_reason; + uint8_t btn_prev_state; + +public: + void begin(); + + uint16_t getBattMilliVolts() override { + return 0; + } + + uint8_t getStartupReason() const override { return startup_reason; } + + const char* getManufacturerName() const override { + return "Linux"; + } + + int buttonStateChanged() { + return 0; + } + + void powerOff() override { + exit(0); + } + + void reboot() override { + exit(0); + } + + LinuxConfig config; +}; + +class LinuxRTCClock : public mesh::RTCClock { +public: + LinuxRTCClock() { } + void begin() { + } + uint32_t getCurrentTime() override { + struct timeval tv; + gettimeofday(&tv, NULL); + return tv.tv_sec; + } + void setCurrentTime(uint32_t time) override { + struct timeval tv; + tv.tv_sec = time; + tv.tv_usec = 0; + settimeofday(&tv, NULL); + } +}; diff --git a/variants/linux/meshcored.ini b/variants/linux/meshcored.ini new file mode 100644 index 0000000000..6fb346fbbb --- /dev/null +++ b/variants/linux/meshcored.ini @@ -0,0 +1,28 @@ +advert_name = "Sample Router" +admin_password = "password" +lat = 0.0 +lon = 0.0 + +# Waveshare LoRa hat +#lora_irq_pin = 16 +#lora_reset_pin = 18 +#lora_nss_pin = 21 +#lora_busy_pin = 20 + +lora_irq_pin = 22 +lora_reset_pin = 13 +#lora_nss_pin = # SS pin handled by RPI +#lora_busy_pin = # Seems to be unused? +#lora_rxen_pin +#lora_txen_pin + +spidev = /dev/spidev0.0 +lora_freq = 869.618 +lora_bw = 62.5 +lora_sf = 8 +lora_cr = 8 +lora_tcxo = 1.8 +#lora_tx_power = 22 +#current_limit = 140 +#dio2_as_rf_switch = 1 +#rx_boosted_gain = 1 diff --git a/variants/linux/meshcored.service b/variants/linux/meshcored.service new file mode 100644 index 0000000000..345a10724b --- /dev/null +++ b/variants/linux/meshcored.service @@ -0,0 +1,29 @@ +# /var/lib/systemd/system/meshcored.service +[Unit] +Description=Meshcore Daemon (meshcored) +After=network.target +Wants=network.target + +[Service] +Type=simple +User=meshcore +Group=meshcore +ExecStart=/usr/bin/stdbuf -oL /usr/bin/meshcored --fsdir /var/lib/meshcore +WorkingDirectory=/var/lib/meshcore +Restart=on-failure +RestartSec=5 +LimitNOFILE=65535 + +# Security hardening +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +NoNewPrivileges=yes +ReadWritePaths=/var/lib/meshcore # allow writing only to its own data dir + +# Create data dir with correct ownership if it doesn't exist +ExecStartPre=/bin/mkdir -p /var/lib/meshcore +ExecStartPre=/bin/chown meshcore:meshcore /var/lib/meshcore + +[Install] +WantedBy=multi-user.target diff --git a/variants/linux/platformio.ini b/variants/linux/platformio.ini new file mode 100644 index 0000000000..d60a50fe71 --- /dev/null +++ b/variants/linux/platformio.ini @@ -0,0 +1,47 @@ +[linux_base] +extends = portduino_base +build_flags = ${portduino_base.build_flags} + -I variants/linux + -I /usr/include +board = cross_platform +board_level = extra +lib_deps = + ${portduino_base.lib_deps} + melopero/Melopero RV3028@^1.1.0 + +build_src_filter = ${portduino_base.build_src_filter} + +<../variants/linux> + - + - + - + - + - + +[env:linux] +extends = linux_base +; The pkg-config commands below optionally add link flags. +; the || : is just a "or run the null command" to avoid returning an error code +build_flags = ${linux_base.build_flags} + !pkg-config --cflags --libs libbsd-overlay --silence-errors || : + +[env:linux_repeater] +extends = linux_base +build_flags = + ${linux_base.build_flags} + -D RADIO_CLASS=LinuxSX1262 + -D WRAPPER_CLASS=LinuxSX1262Wrapper + -D USE_CUSTOM_SX1262_WRAPPER + -D SKIP_CONFIG_OVERWRITE=1 + -D ADVERT_NAME='"Linux Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=100 + -D LORA_TX_POWER=22 + -D MESH_DEBUG=1 + +build_src_filter = ${linux_base.build_src_filter} + +<../examples/simple_repeater> + +lib_deps = + ${linux_base.lib_deps} diff --git a/variants/linux/target.cpp b/variants/linux/target.cpp new file mode 100644 index 0000000000..0f5941f890 --- /dev/null +++ b/variants/linux/target.cpp @@ -0,0 +1,54 @@ +#include +#include "target.h" + +class PortduinoHal : public ArduinoHal +{ +public: + PortduinoHal(SPIClass &spi, SPISettings spiSettings) : ArduinoHal(spi, spiSettings){}; + + void spiTransfer(uint8_t *out, size_t len, uint8_t *in) { + spi->transfer(out, in, len); + } +}; + +LinuxBoard board; + +SPISettings spiSettings = SPISettings(2000000, MSBFIRST, SPI_MODE0); +ArduinoHal *hal = new PortduinoHal(SPI, spiSettings); +RADIO_CLASS radio = new Module(hal, RADIOLIB_NC, RADIOLIB_NC, RADIOLIB_NC, RADIOLIB_NC); +WRAPPER_CLASS radio_driver(radio, board); + +LinuxRTCClock rtc_clock; +EnvironmentSensorManager sensors; + +#ifdef DISPLAY_CLASS + DISPLAY_CLASS display; + MomentaryButton user_btn(PIN_USER_BTN, 1000, true); +#endif + +bool radio_init() { + rtc_clock.begin(); + + radio = new Module(hal, board.config.lora_nss_pin, board.config.lora_irq_pin, board.config.lora_reset_pin, board.config.lora_busy_pin); + return radio.std_init(&SPI); +} + +uint32_t radio_get_rng_seed() { + return radio.random(0x7FFFFFFF); +} + +void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr) { + radio.setFrequency(freq); + radio.setSpreadingFactor(sf); + radio.setBandwidth(bw); + radio.setCodingRate(cr); +} + +void radio_set_tx_power(uint8_t dbm) { + radio.setOutputPower(dbm); +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); // create new random identity +} diff --git a/variants/linux/target.h b/variants/linux/target.h new file mode 100644 index 0000000000..1f5539ca94 --- /dev/null +++ b/variants/linux/target.h @@ -0,0 +1,32 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include +#include +#include +#include +#include +#ifdef DISPLAY_CLASS + #include + #include +#endif + +#if (USE_CUSTOM_SX1262_WRAPPER) +#include +#endif + +extern LinuxBoard board; +extern WRAPPER_CLASS radio_driver; +extern LinuxRTCClock rtc_clock; +extern EnvironmentSensorManager sensors; + +#ifdef DISPLAY_CLASS + extern DISPLAY_CLASS display; + extern MomentaryButton user_btn; +#endif + +bool radio_init(); +uint32_t radio_get_rng_seed(); +void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr); +void radio_set_tx_power(uint8_t dbm); +mesh::LocalIdentity radio_new_identity(); diff --git a/variants/portduino/platformio.ini b/variants/portduino/platformio.ini new file mode 100644 index 0000000000..5785e965aa --- /dev/null +++ b/variants/portduino/platformio.ini @@ -0,0 +1,41 @@ +[portduino_base] +platform = + # renovate: datasource=git-refs depName=platform-native packageName=https://github.com/meshtastic/platform-native gitBranch=develop + https://github.com/meshtastic/platform-native/archive/f566d364204416cdbf298e349213f7d551f793d9.zip +framework = arduino + +build_src_filter = + ${env.build_src_filter} + - + - + - + - + - + - + - + - + - + +lib_deps = + ${env.lib_deps} + rweather/Crypto@0.4.0 + adafruit/Adafruit seesaw Library@1.7.9 + electroniccats/CayenneLPP @ 1.6.1 + adafruit/RTClib @ ^2.1.3 + jgromes/RadioLib@7.4.0 + +build_flags = + ${arduino_base.build_flags} + -DARCH_PORTDUINO + -DPORTDUINO_PLATFORM + -DRADIOLIB_EEPROM_UNSUPPORTED + -DPORTDUINO_LINUX_HARDWARE + -fPIC + -lpthread + -lstdc++fs + -lbluetooth + -lgpiod + -li2c + -luv + -std=gnu17 + -std=c++17 From bafc6d18540558723232ae53734a7c4a08a9630e Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Sat, 14 Mar 2026 14:38:31 +0100 Subject: [PATCH 02/16] variants: add scaffolding for linux native (#1) * variants: add scaffolding for linux native * address review comments * address review comments --- examples/simple_repeater/MyMesh.cpp | 8 +-- examples/simple_repeater/MyMesh.h | 2 + examples/simple_repeater/main.cpp | 8 +++ src/helpers/ClientACL.cpp | 2 +- src/helpers/CommonCLI.cpp | 2 +- src/helpers/IdentityStore.cpp | 4 +- src/helpers/IdentityStore.h | 2 +- src/helpers/RegionMap.cpp | 2 +- src/helpers/TxtDataHelpers.cpp | 7 +++ variants/linux/LinuxBoard.cpp | 1 + variants/linux/LinuxBoard.h | 3 + variants/linux/README.md | 92 +++++++++++++++++++++++++++++ variants/linux/meshcored.ini | 1 + variants/linux/meshcored.service | 2 +- 14 files changed, 125 insertions(+), 11 deletions(-) create mode 100644 variants/linux/README.md diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index ca6a3e607e..6fcf2ca586 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -386,7 +386,7 @@ mesh::Packet *MyMesh::createSelfAdvert() { File MyMesh::openAppend(const char *fname) { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) return _fs->open(fname, FILE_O_WRITE); -#elif defined(RP2040_PLATFORM) +#elif defined(RP2040_PLATFORM) || defined(ARCH_PORTDUINO) return _fs->open(fname, "a"); #else return _fs->open(fname, "a", true); @@ -1023,6 +1023,8 @@ bool MyMesh::formatFileSystem() { return LittleFS.format(); #elif defined(ESP32) return SPIFFS.format(); +#elif defined(ARCH_PORTDUINO) + return false; // not supported on Linux #else #error "need to implement file system erase" return false; @@ -1178,9 +1180,7 @@ void MyMesh::formatPacketStatsReply(char *reply) { void MyMesh::saveIdentity(const mesh::LocalIdentity &new_id) { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) IdentityStore store(*_fs, ""); -#elif defined(ESP32) - IdentityStore store(*_fs, "/identity"); -#elif defined(RP2040_PLATFORM) +#elif defined(ESP32) || defined(RP2040_PLATFORM) || defined(ARCH_PORTDUINO) IdentityStore store(*_fs, "/identity"); #else #error "need to define saveIdentity()" diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index cac6c4a281..ef599d8793 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -12,6 +12,8 @@ #elif defined(ESP32) #include using File = fs::File; +#elif defined(ARCH_PORTDUINO) + #include #endif #ifdef WITH_RS232_BRIDGE diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index a714db68ec..4ce860d6d7 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -81,6 +81,14 @@ void setup() { fs = &LittleFS; IdentityStore store(LittleFS, "/identity"); store.begin(); +#elif defined(ARCH_PORTDUINO) + if (::mkdir(board.config.data_dir, 0755) != 0 && errno != EEXIST) { + Serial.printf("WARNING: could not create data_dir '%s': %s\n", board.config.data_dir, strerror(errno)); + } + portduinoVFS->mountpoint(board.config.data_dir); + fs = &PortduinoFS; + IdentityStore store(PortduinoFS, "/identity"); + store.begin(); #else #error "need to define filesystem" #endif diff --git a/src/helpers/ClientACL.cpp b/src/helpers/ClientACL.cpp index 1282382737..40a98a5b7a 100644 --- a/src/helpers/ClientACL.cpp +++ b/src/helpers/ClientACL.cpp @@ -4,7 +4,7 @@ static File openWrite(FILESYSTEM* _fs, const char* filename) { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) _fs->remove(filename); return _fs->open(filename, FILE_O_WRITE); - #elif defined(RP2040_PLATFORM) + #elif defined(RP2040_PLATFORM) || defined(ARCH_PORTDUINO) return _fs->open(filename, "w"); #else return _fs->open(filename, "w", true); diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 4930e81e9a..7c4631db95 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -144,7 +144,7 @@ bool CommonCLI::savePrefs(FILESYSTEM* fs) { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) fs->remove("/prefs.json"); File file = fs->open("/prefs.json", FILE_O_WRITE); -#elif defined(RP2040_PLATFORM) +#elif defined(RP2040_PLATFORM) || defined(ARCH_PORTDUINO) File file = fs->open("/prefs.json", "w"); #else File file = fs->open("/prefs.json", "w", true); diff --git a/src/helpers/IdentityStore.cpp b/src/helpers/IdentityStore.cpp index dc85d69cdd..0a9700f0aa 100644 --- a/src/helpers/IdentityStore.cpp +++ b/src/helpers/IdentityStore.cpp @@ -49,7 +49,7 @@ bool IdentityStore::save(const char *name, const mesh::LocalIdentity& id) { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) _fs->remove(filename); File file = _fs->open(filename, FILE_O_WRITE); -#elif defined(RP2040_PLATFORM) +#elif defined(RP2040_PLATFORM) || defined(ARCH_PORTDUINO) File file = _fs->open(filename, "w"); #else File file = _fs->open(filename, "w", true); @@ -71,7 +71,7 @@ bool IdentityStore::save(const char *name, const mesh::LocalIdentity& id, const #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) _fs->remove(filename); File file = _fs->open(filename, FILE_O_WRITE); -#elif defined(RP2040_PLATFORM) +#elif defined(RP2040_PLATFORM) || defined(ARCH_PORTDUINO) File file = _fs->open(filename, "w"); #else File file = _fs->open(filename, "w", true); diff --git a/src/helpers/IdentityStore.h b/src/helpers/IdentityStore.h index d0d7ee457e..a96aa536cc 100644 --- a/src/helpers/IdentityStore.h +++ b/src/helpers/IdentityStore.h @@ -1,6 +1,6 @@ #pragma once -#if defined(ESP32) || defined(RP2040_PLATFORM) +#if defined(ESP32) || defined(RP2040_PLATFORM) || defined(ARCH_PORTDUINO) #include #define FILESYSTEM fs::FS #elif defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) diff --git a/src/helpers/RegionMap.cpp b/src/helpers/RegionMap.cpp index 4667e0038e..51cfe10e9f 100644 --- a/src/helpers/RegionMap.cpp +++ b/src/helpers/RegionMap.cpp @@ -62,7 +62,7 @@ static File openWrite(FILESYSTEM* _fs, const char* filename) { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) _fs->remove(filename); return _fs->open(filename, FILE_O_WRITE); - #elif defined(RP2040_PLATFORM) + #elif defined(RP2040_PLATFORM) || defined(ARCH_PORTDUINO) return _fs->open(filename, "w"); #else return _fs->open(filename, "w", true); diff --git a/src/helpers/TxtDataHelpers.cpp b/src/helpers/TxtDataHelpers.cpp index d327931fde..60832f6653 100644 --- a/src/helpers/TxtDataHelpers.cpp +++ b/src/helpers/TxtDataHelpers.cpp @@ -1,4 +1,7 @@ #include "TxtDataHelpers.h" +#if defined(ARCH_PORTDUINO) + #include +#endif void StrHelper::strncpy(char* dest, const char* src, size_t buf_sz) { while (buf_sz > 1 && *src) { @@ -102,7 +105,11 @@ static void _ftoa(float f, char *p, int *status) *p++ = '0'; else { +#if defined(ARCH_PORTDUINO) + sprintf(p, "%" PRId32, int_part); +#else ltoa(int_part, p, 10); +#endif while (*p) p++; } diff --git a/variants/linux/LinuxBoard.cpp b/variants/linux/LinuxBoard.cpp index 0feb95d2bc..dee65baed1 100644 --- a/variants/linux/LinuxBoard.cpp +++ b/variants/linux/LinuxBoard.cpp @@ -131,6 +131,7 @@ int LinuxConfig::load(const char *filename) { else if (strcmp(key, "admin_password") == 0) admin_password = safe_copy(value, 100); else if (strcmp(key, "lat") == 0) lat = atof(value); else if (strcmp(key, "lon") == 0) lon = atof(value); + else if (strcmp(key, "data_dir") == 0) data_dir = safe_copy(value, 256); } fclose(f); return 0; diff --git a/variants/linux/LinuxBoard.h b/variants/linux/LinuxBoard.h index c7ae7501c8..584ef195f6 100644 --- a/variants/linux/LinuxBoard.h +++ b/variants/linux/LinuxBoard.h @@ -3,6 +3,8 @@ #include #include #include +#include +#include #include class LinuxConfig { @@ -36,6 +38,7 @@ class LinuxConfig { char *admin_password = "password"; float lat = 0.0f; float lon = 0.0f; + char *data_dir = "/var/lib/meshcore"; int load(const char *filename); }; diff --git a/variants/linux/README.md b/variants/linux/README.md new file mode 100644 index 0000000000..68d9416a0c --- /dev/null +++ b/variants/linux/README.md @@ -0,0 +1,92 @@ +# MeshCore Linux Variant + +Native Linux support for MeshCore, targeting Raspberry Pi (Zero, 3, 4, 5) and similar SBCs with an SX1262 LoRa radio attached over SPI. Uses the [Portduino](https://github.com/meshtastic/platform-native) Arduino-compatibility layer to run the same firmware codebase on Linux without modification to the core library. + +## Hardware + +- Raspberry Pi (any model with SPI) +- SX1262-based LoRa module wired to the Pi's SPI bus (e.g. Waveshare SX1262 HAT, RAK2287, similar) +- SPI, IRQ, RESET, and optionally BUSY/RXEN/TXEN GPIO pins + +## Build + +**Dependencies** (install on the build machine and on the Pi): + +```sh +# Arch Linux +sudo pacman -S libgpiod i2c-tools + +# Debian/Raspberry Pi OS +sudo apt install libgpiod-dev libi2c-dev +``` + +**Build with PlatformIO:** + +```sh +FIRMWARE_VERSION=dev pio run -e linux_repeater +# binary: .pio/build/linux_repeater/program +``` + +## Configuration + +The binary reads `/etc/meshcored/meshcored.ini` on startup. Copy and edit the sample: + +```sh +sudo mkdir -p /etc/meshcored +sudo cp variants/linux/meshcored.ini /etc/meshcored/meshcored.ini +sudo nano /etc/meshcored/meshcored.ini +``` + +Key settings: + +| Key | Default | Notes | +|-----|---------|-------| +| `spidev` | `/dev/spidev0.0` | SPI device node | +| `lora_irq_pin` | (none) | GPIO pin number for IRQ | +| `lora_reset_pin` | (none) | GPIO pin number for RESET | +| `lora_nss_pin` | (none) | GPIO pin number for NSS/CS (if not handled by SPI driver) | +| `lora_busy_pin` | (none) | GPIO pin number for BUSY | +| `lora_freq` | `869.618` | Frequency in MHz | +| `lora_bw` | `62.5` | Bandwidth in kHz | +| `lora_sf` | `8` | Spreading factor | +| `lora_cr` | `8` | Coding rate | +| `lora_tcxo` | `1.8` | TCXO voltage (V); set to `0.0` if no TCXO | +| `lora_tx_power` | `22` | TX power in dBm | +| `advert_name` | `"Linux Repeater"` | Node name broadcast to the mesh | +| `admin_password` | `"password"` | Change this | +| `lat` / `lon` | `0.0` | GPS coordinates for advertisement | +| `data_dir` | `/var/lib/meshcore` | Where identity and prefs are stored | + +## Running + +Enable SPI and set GPIO permissions on the Pi: + +```sh +# Raspberry Pi OS +sudo raspi-config # Interface Options → SPI → Enable +sudo usermod -aG spi,gpio $USER +``` + +Run directly: + +```sh +sudo .pio/build/linux_repeater/program +``` + +## Systemd Service + +```sh +sudo cp variants/linux/meshcored.service /etc/systemd/system/ +sudo useradd -r -s /sbin/nologin meshcore +sudo install -d -o meshcore -g meshcore /var/lib/meshcore +# copy binary to /usr/bin/meshcored +sudo systemctl enable --now meshcored +sudo journalctl -u meshcored -f +``` + +## Known Gaps / TODO + +- **No CLI argument parsing** — config path is hardcoded to `/etc/meshcored/meshcored.ini`; `data_dir` is only configurable via the INI file. +- **Only repeater firmware** — there is no `linux_companion` target yet; companion radio support (BLE/serial interface to a phone app) is not implemented for Linux. +- **`formatFileSystem()`** returns `false` (not implemented) — the CLI `format` command will report failure on Linux. +- **No power management** — `board.sleep()` is a no-op; the power-saving loop in `main.cpp` never actually sleeps. diff --git a/variants/linux/meshcored.ini b/variants/linux/meshcored.ini index 6fb346fbbb..1a10bb147d 100644 --- a/variants/linux/meshcored.ini +++ b/variants/linux/meshcored.ini @@ -2,6 +2,7 @@ advert_name = "Sample Router" admin_password = "password" lat = 0.0 lon = 0.0 +#data_dir = /var/lib/meshcore # Waveshare LoRa hat #lora_irq_pin = 16 diff --git a/variants/linux/meshcored.service b/variants/linux/meshcored.service index 345a10724b..c81ca4776f 100644 --- a/variants/linux/meshcored.service +++ b/variants/linux/meshcored.service @@ -8,7 +8,7 @@ Wants=network.target Type=simple User=meshcore Group=meshcore -ExecStart=/usr/bin/stdbuf -oL /usr/bin/meshcored --fsdir /var/lib/meshcore +ExecStart=/usr/bin/stdbuf -oL /usr/bin/meshcored WorkingDirectory=/var/lib/meshcore Restart=on-failure RestartSec=5 From 6ea1c6e5c298f2bea3d8af1aa5003fe3b003e278 Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Sat, 14 Mar 2026 19:12:11 +0100 Subject: [PATCH 03/16] variants: allow linux repeater to be configured at runtime (#2) * variants: allow linux repeater to be configured at runtime * address review comments * address review comments * address review comments --- examples/simple_repeater/MyMesh.cpp | 13 +++ variants/linux/99-meshcore.rules | 6 ++ variants/linux/LinuxBoard.cpp | 9 ++ variants/linux/README.md | 108 ++++++++++++++++++------ variants/linux/meshcored.ini | 4 +- variants/linux/meshcored.ini.pow-sx1262 | 24 ++++++ variants/linux/meshcored.ini.waveshare | 24 ++++++ variants/linux/meshcored.service | 5 +- variants/linux/platformio.ini | 4 - 9 files changed, 165 insertions(+), 32 deletions(-) create mode 100644 variants/linux/99-meshcore.rules create mode 100644 variants/linux/meshcored.ini.pow-sx1262 create mode 100644 variants/linux/meshcored.ini.waveshare diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 6fcf2ca586..743db17f58 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -944,6 +944,19 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc void MyMesh::begin(FILESYSTEM *fs) { mesh::Mesh::begin(); _fs = fs; +#if defined(ARCH_PORTDUINO) + // Apply runtime INI config as first-run defaults before loading persisted prefs. + // If /com_prefs exists, loadPrefs() below will overwrite these with the saved values. + StrHelper::strncpy(_prefs.node_name, board.config.advert_name, sizeof(_prefs.node_name)); + _prefs.node_lat = board.config.lat; + _prefs.node_lon = board.config.lon; + StrHelper::strncpy(_prefs.password, board.config.admin_password, sizeof(_prefs.password)); + _prefs.freq = board.config.lora_freq; + _prefs.bw = board.config.lora_bw; + _prefs.sf = board.config.lora_sf; + _prefs.cr = board.config.lora_cr; + _prefs.tx_power_dbm = board.config.lora_tx_power; +#endif // load persisted prefs _cli.loadPrefs(_fs); acl.load(_fs, self_id); diff --git a/variants/linux/99-meshcore.rules b/variants/linux/99-meshcore.rules new file mode 100644 index 0000000000..4c3df92e47 --- /dev/null +++ b/variants/linux/99-meshcore.rules @@ -0,0 +1,6 @@ +# udev rules for meshcored — grant the meshcore group access to SPI and GPIO devices. +# Works on Arch Linux, Debian/Raspberry Pi OS, and other distributions. +# Install: sudo cp 99-meshcore.rules /etc/udev/rules.d/ + +SUBSYSTEM=="spidev", GROUP="meshcore", MODE="0660" +KERNEL=="gpiochip*", GROUP="meshcore", MODE="0660" diff --git a/variants/linux/LinuxBoard.cpp b/variants/linux/LinuxBoard.cpp index dee65baed1..e975884c15 100644 --- a/variants/linux/LinuxBoard.cpp +++ b/variants/linux/LinuxBoard.cpp @@ -109,6 +109,15 @@ int LinuxConfig::load(const char *filename) { trim(key); trim(value); + // strip optional surrounding quotes from string values + { + size_t vlen = strlen(value); + if (vlen >= 2 && (value[0] == '"' || value[0] == '\'') && value[vlen-1] == value[0]) { + value[vlen-1] = '\0'; + value++; + } + } + if (strcmp(key, "spidev") == 0) spidev = safe_copy(value, 32); else if (strcmp(key, "lora_freq") == 0) lora_freq = atof(value); else if (strcmp(key, "lora_bw") == 0) lora_bw = atof(value); diff --git a/variants/linux/README.md b/variants/linux/README.md index 68d9416a0c..276fd8e6c7 100644 --- a/variants/linux/README.md +++ b/variants/linux/README.md @@ -5,7 +5,7 @@ Native Linux support for MeshCore, targeting Raspberry Pi (Zero, 3, 4, 5) and si ## Hardware - Raspberry Pi (any model with SPI) -- SX1262-based LoRa module wired to the Pi's SPI bus (e.g. Waveshare SX1262 HAT, RAK2287, similar) +- SX1262-based LoRa module wired to the Pi's SPI bus (e.g. Waveshare SX1262 HAT, PoW SX1262 HAT) - SPI, IRQ, RESET, and optionally BUSY/RXEN/TXEN GPIO pins ## Build @@ -20,73 +20,133 @@ sudo pacman -S libgpiod i2c-tools sudo apt install libgpiod-dev libi2c-dev ``` -**Build with PlatformIO:** +**Build with `build.sh`** (recommended — embeds version and commit hash): ```sh -FIRMWARE_VERSION=dev pio run -e linux_repeater +FIRMWARE_VERSION=dev ./build.sh build-firmware linux_repeater # binary: .pio/build/linux_repeater/program ``` -## Configuration +Alternatively, build directly with PlatformIO (no version metadata): + +```sh +FIRMWARE_VERSION=dev pio run -e linux_repeater +``` + +## Setup + +### 1. Install the binary + +```sh +sudo cp .pio/build/linux_repeater/program /usr/bin/meshcored +``` + +### 2. Create the config file + +Two ready-made templates are provided in `variants/linux/`: -The binary reads `/etc/meshcored/meshcored.ini` on startup. Copy and edit the sample: +| Template | Hardware | +|----------|----------| +| `meshcored.ini.pow-sx1262` | RPi Zero 2W + PoW SX1262 HAT | +| `meshcored.ini.waveshare` | RPi 3/4/5 + Waveshare SX1262 LoRa HAT | ```sh sudo mkdir -p /etc/meshcored -sudo cp variants/linux/meshcored.ini /etc/meshcored/meshcored.ini +# Pick the template that matches your hardware: +sudo cp variants/linux/meshcored.ini.waveshare /etc/meshcored/meshcored.ini sudo nano /etc/meshcored/meshcored.ini ``` +The config file has two roles: + +- **Hardware config** (always read on every startup): SPI device, GPIO pin numbers, LoRa radio parameters. +- **First-run node defaults**: `advert_name`, `admin_password`, `lat`, `lon`. On the first boot these are saved to `data_dir`. After that, use the serial CLI to change them (`set name`, `set password`, etc.) — the INI values are no longer consulted for these fields. + Key settings: | Key | Default | Notes | |-----|---------|-------| | `spidev` | `/dev/spidev0.0` | SPI device node | -| `lora_irq_pin` | (none) | GPIO pin number for IRQ | -| `lora_reset_pin` | (none) | GPIO pin number for RESET | -| `lora_nss_pin` | (none) | GPIO pin number for NSS/CS (if not handled by SPI driver) | -| `lora_busy_pin` | (none) | GPIO pin number for BUSY | +| `lora_irq_pin` | (none) | GPIO line number for IRQ | +| `lora_reset_pin` | (none) | GPIO line number for RESET | +| `lora_nss_pin` | (none) | GPIO line number for NSS/CS (if not handled by the SPI driver) | +| `lora_busy_pin` | (none) | GPIO line number for BUSY | | `lora_freq` | `869.618` | Frequency in MHz | | `lora_bw` | `62.5` | Bandwidth in kHz | | `lora_sf` | `8` | Spreading factor | | `lora_cr` | `8` | Coding rate | -| `lora_tcxo` | `1.8` | TCXO voltage (V); set to `0.0` if no TCXO | +| `lora_tcxo` | `1.8` | TCXO voltage (V); set to `0.0` if your module has no TCXO | | `lora_tx_power` | `22` | TX power in dBm | -| `advert_name` | `"Linux Repeater"` | Node name broadcast to the mesh | -| `admin_password` | `"password"` | Change this | -| `lat` / `lon` | `0.0` | GPS coordinates for advertisement | -| `data_dir` | `/var/lib/meshcore` | Where identity and prefs are stored | +| `advert_name` | `"Linux Repeater"` | Node name — first-run default only | +| `admin_password` | `"password"` | Admin password — **change this**, first-run default only | +| `lat` / `lon` | `0.0` | GPS coordinates for advertisement — first-run default only | +| `data_dir` | `/var/lib/meshcore` | Where identity and node prefs are persisted | -## Running - -Enable SPI and set GPIO permissions on the Pi: +### 3. Enable SPI and GPIO access ```sh # Raspberry Pi OS -sudo raspi-config # Interface Options → SPI → Enable +sudo raspi-config # Interface Options → SPI → Enable sudo usermod -aG spi,gpio $USER ``` -Run directly: +On Arch Linux and other distributions without `spi`/`gpio` groups, use the provided udev rules instead (also works on Raspberry Pi OS): ```sh -sudo .pio/build/linux_repeater/program +sudo cp variants/linux/99-meshcore.rules /etc/udev/rules.d/ +sudo udevadm control --reload-rules && sudo udevadm trigger ``` -## Systemd Service +### 4. Run + +**Directly** (for testing): + +```sh +sudo /usr/bin/meshcored +``` + +`sudo` is needed on first run to create `data_dir` if it doesn't exist. Once the directory is created and owned appropriately, it can run as a non-root user. + +**As a systemd service** (recommended for production): ```sh sudo cp variants/linux/meshcored.service /etc/systemd/system/ +sudo cp variants/linux/99-meshcore.rules /etc/udev/rules.d/ +sudo udevadm control --reload-rules && sudo udevadm trigger sudo useradd -r -s /sbin/nologin meshcore -sudo install -d -o meshcore -g meshcore /var/lib/meshcore -# copy binary to /usr/bin/meshcored +sudo mkdir -p /var/lib/meshcore +sudo chown meshcore:meshcore /var/lib/meshcore +sudo chmod 640 /etc/meshcored/meshcored.ini +sudo chown root:meshcore /etc/meshcored/meshcored.ini +sudo systemctl daemon-reload sudo systemctl enable --now meshcored sudo journalctl -u meshcored -f ``` +### 5. Reconfiguring after first run + +Node name, password, and location can be changed via the serial CLI after first boot: + +``` +set name +set password +set lat +set lon +``` + +To reset all node prefs and re-apply the INI file defaults, delete the saved prefs and restart: + +```sh +sudo rm /var/lib/meshcore/com_prefs +sudo systemctl restart meshcored +``` + +> **Note:** LoRa radio parameters (`lora_freq`, `lora_bw`, `lora_sf`, `lora_cr`, `lora_tx_power`) are also first-run defaults. After first boot they are saved in `com_prefs` and the INI values are no longer read for those fields. To apply a changed radio parameter, use the CLI (`set freq`, `set sf`, etc.) or reset prefs as above. + ## Known Gaps / TODO - **No CLI argument parsing** — config path is hardcoded to `/etc/meshcored/meshcored.ini`; `data_dir` is only configurable via the INI file. - **Only repeater firmware** — there is no `linux_companion` target yet; companion radio support (BLE/serial interface to a phone app) is not implemented for Linux. - **`formatFileSystem()`** returns `false` (not implemented) — the CLI `format` command will report failure on Linux. - **No power management** — `board.sleep()` is a no-op; the power-saving loop in `main.cpp` never actually sleeps. +- **Portduino branding** — on startup the binary identifies itself as "An application written with portduino" with a Meshtastic bug URL. This is hardcoded in the Portduino framework and cannot be changed without patching the framework. diff --git a/variants/linux/meshcored.ini b/variants/linux/meshcored.ini index 1a10bb147d..2c57adbbb9 100644 --- a/variants/linux/meshcored.ini +++ b/variants/linux/meshcored.ini @@ -1,5 +1,5 @@ -advert_name = "Sample Router" -admin_password = "password" +advert_name = Sample Router +admin_password = password lat = 0.0 lon = 0.0 #data_dir = /var/lib/meshcore diff --git a/variants/linux/meshcored.ini.pow-sx1262 b/variants/linux/meshcored.ini.pow-sx1262 new file mode 100644 index 0000000000..1f2e03000f --- /dev/null +++ b/variants/linux/meshcored.ini.pow-sx1262 @@ -0,0 +1,24 @@ +# meshcored.ini — PoW SX1262 HAT on Raspberry Pi Zero 2W +# GPIO numbering is BCM (the number after "GPIO", e.g. GPIO22 = 22). +# +# Hardware config (read on every startup): +spidev = /dev/spidev0.0 +lora_irq_pin = 22 +lora_reset_pin = 13 +# lora_nss_pin — SS is handled by the SPI driver; not needed +# lora_busy_pin — not wired on this HAT +lora_freq = 869.618 +lora_bw = 62.5 +lora_sf = 8 +lora_cr = 8 +lora_tcxo = 1.8 +lora_tx_power = 22 + +# First-run node defaults (ignored after first boot; use CLI to change): +advert_name = PoW Linux Repeater +admin_password = changeme +lat = 0.0 +lon = 0.0 + +# data_dir — where identity and node prefs are persisted (default shown): +#data_dir = /var/lib/meshcore diff --git a/variants/linux/meshcored.ini.waveshare b/variants/linux/meshcored.ini.waveshare new file mode 100644 index 0000000000..84df7f3acd --- /dev/null +++ b/variants/linux/meshcored.ini.waveshare @@ -0,0 +1,24 @@ +# meshcored.ini — Waveshare SX1262 LoRa HAT on Raspberry Pi 3/4/5 +# GPIO numbering is BCM (the number after "GPIO", e.g. GPIO16 = 16). +# +# Hardware config (read on every startup): +spidev = /dev/spidev0.0 +lora_irq_pin = 16 +lora_reset_pin = 18 +lora_nss_pin = 21 +lora_busy_pin = 20 +lora_freq = 869.618 +lora_bw = 62.5 +lora_sf = 8 +lora_cr = 8 +lora_tcxo = 1.8 +lora_tx_power = 22 + +# First-run node defaults (ignored after first boot; use CLI to change): +advert_name = Waveshare Linux Repeater +admin_password = changeme +lat = 0.0 +lon = 0.0 + +# data_dir — where identity and node prefs are persisted (default shown): +#data_dir = /var/lib/meshcore diff --git a/variants/linux/meshcored.service b/variants/linux/meshcored.service index c81ca4776f..b68ff2a62c 100644 --- a/variants/linux/meshcored.service +++ b/variants/linux/meshcored.service @@ -8,7 +8,7 @@ Wants=network.target Type=simple User=meshcore Group=meshcore -ExecStart=/usr/bin/stdbuf -oL /usr/bin/meshcored +ExecStart=/usr/bin/stdbuf -oL /usr/bin/meshcored --fsdir /var/lib/meshcore WorkingDirectory=/var/lib/meshcore Restart=on-failure RestartSec=5 @@ -19,7 +19,8 @@ ProtectSystem=strict ProtectHome=yes PrivateTmp=yes NoNewPrivileges=yes -ReadWritePaths=/var/lib/meshcore # allow writing only to its own data dir +# Allow writing only to its own data dir +ReadWritePaths=/var/lib/meshcore # Create data dir with correct ownership if it doesn't exist ExecStartPre=/bin/mkdir -p /var/lib/meshcore diff --git a/variants/linux/platformio.ini b/variants/linux/platformio.ini index d60a50fe71..3fc49b2cfd 100644 --- a/variants/linux/platformio.ini +++ b/variants/linux/platformio.ini @@ -32,10 +32,6 @@ build_flags = -D WRAPPER_CLASS=LinuxSX1262Wrapper -D USE_CUSTOM_SX1262_WRAPPER -D SKIP_CONFIG_OVERWRITE=1 - -D ADVERT_NAME='"Linux Repeater"' - -D ADVERT_LAT=0.0 - -D ADVERT_LON=0.0 - -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=100 -D LORA_TX_POWER=22 -D MESH_DEBUG=1 From 5255930f3d5d5875129510b15233cba34379e876 Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Tue, 17 Mar 2026 10:24:14 +0100 Subject: [PATCH 04/16] Fix boolean config parsing in LinuxBoard (#6) --- variants/linux/LinuxBoard.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/variants/linux/LinuxBoard.cpp b/variants/linux/LinuxBoard.cpp index e975884c15..4c25da0a11 100644 --- a/variants/linux/LinuxBoard.cpp +++ b/variants/linux/LinuxBoard.cpp @@ -126,8 +126,8 @@ int LinuxConfig::load(const char *filename) { else if (strcmp(key, "lora_tcxo") == 0) lora_tcxo = atof(value); else if (strcmp(key, "lora_tx_power") == 0) lora_tx_power = atoi(value); else if (strcmp(key, "current_limit") == 0) current_limit = atof(value); - else if (strcmp(key, "dio2_as_rf_switch") == 0) dio2_as_rf_switch = value != 0; - else if (strcmp(key, "rx_boosted_gain") == 0) rx_boosted_gain = value != 0; + else if (strcmp(key, "dio2_as_rf_switch") == 0) dio2_as_rf_switch = atoi(value) != 0; + else if (strcmp(key, "rx_boosted_gain") == 0) rx_boosted_gain = atoi(value) != 0; else if (strcmp(key, "lora_irq_pin") == 0) lora_irq_pin = atoi(value); else if (strcmp(key, "lora_reset_pin") == 0) lora_reset_pin = atoi(value); From ab8c4578b55376c1e5742adc3cc7d37ea57ce14b Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Sun, 31 May 2026 23:05:58 +0200 Subject: [PATCH 05/16] replace portduino with ardulinux (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * replace portduino with ardulinux * replace portduino with ardulinux * replace portduino with ardulinux * Remove flags now owned by the ardulinux platform framework ARDULINUX_LINUX_HARDWARE, -lgpiod, and -li2c are detected and injected by builder/frameworks/arduino.py via pkg-config. Hardcoding them here caused linker failures on machines without libgpiod even though the framework would have gracefully omitted them. Also switch variants/ardulinux to the git+ platform URL (dropping the platform-native + platform_packages indirection), update the linux variant board name, and add arduino to the frameworks list in linux.json. * address review comments * Remove stale portduino branding note The startup string was already fixed in l5yth/ardulinux — main.cpp says "An application written with ardulinux". Remove the pending-fix note and update the description to match the current behaviour. * replace portduino with ardulinux * Wire up ardulinux platform and fix SPI/VFS/printf for Linux target variants/ardulinux/platformio.ini: revert local symlink:// back to git+ URL — the symlink only works in a co-located checkout and would break CI. variants/linux/LinuxBoard.cpp: - Add empty ardulinuxSetup() to satisfy the weak symbol; without it the default prints a noisy "No ardulinuxSetup() found" message on startup. - Replace Serial.printf with printf — Serial.printf is not available until after Serial.begin(); using stdio printf is safe at this init-time call site. - Pass 2MHz frequency to SPI.begin() to match the expected SPI clock. variants/linux/target.cpp: fix spiTransfer — ArduLinux's SPI only has a 2-arg transfer(buf, len) that operates in-place; copy out→in first then call the 2-arg form. examples/simple_repeater/main.cpp: fix arduLinuxVFS → ardulinuxVFS (case was wrong; symbol is defined as ardulinuxVFS in ArduLinuxFS.cpp). * set app info to meshcored * fix linux sx1262 wrapper * address review: fix GPIO hardware guard, document spiTransfer, use printf * docs: fix linux variant README (binary name, deps, SPI setup, config keys; use install(1)) --- boards/linux.json | 3 +- examples/simple_repeater/MyMesh.cpp | 8 +-- examples/simple_repeater/MyMesh.h | 4 +- examples/simple_repeater/main.cpp | 10 +-- src/helpers/ClientACL.cpp | 2 +- src/helpers/CommonCLI.cpp | 2 +- src/helpers/IdentityStore.cpp | 4 +- src/helpers/IdentityStore.h | 2 +- src/helpers/RegionMap.cpp | 2 +- src/helpers/TxtDataHelpers.cpp | 4 +- src/helpers/radiolib/LinuxSX1262Wrapper.h | 10 +++ .../{portduino => ardulinux}/platformio.ini | 12 ++-- variants/linux/LinuxBoard.cpp | 29 ++++---- variants/linux/README.md | 66 ++++++++++++++----- variants/linux/platformio.ini | 11 ++-- variants/linux/target.cpp | 13 ++-- 16 files changed, 119 insertions(+), 63 deletions(-) rename variants/{portduino => ardulinux}/platformio.ini (63%) diff --git a/boards/linux.json b/boards/linux.json index 21b5cdc33a..8a120faaca 100644 --- a/boards/linux.json +++ b/boards/linux.json @@ -11,8 +11,9 @@ }, "connectivity": ["wifi", "bluetooth"], "debug": {}, - "frameworks": ["portduino", "linux"], + "frameworks": ["arduino", "ardulinux", "linux"], "name": "Linux", + "url": "https://github.com/l5yth/ardulinux", "upload": { "maximum_ram_size": 0, "maximum_size": 0 diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 743db17f58..7d7ebe3e43 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -386,7 +386,7 @@ mesh::Packet *MyMesh::createSelfAdvert() { File MyMesh::openAppend(const char *fname) { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) return _fs->open(fname, FILE_O_WRITE); -#elif defined(RP2040_PLATFORM) || defined(ARCH_PORTDUINO) +#elif defined(RP2040_PLATFORM) || defined(ARDULINUX_PLATFORM) return _fs->open(fname, "a"); #else return _fs->open(fname, "a", true); @@ -944,7 +944,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc void MyMesh::begin(FILESYSTEM *fs) { mesh::Mesh::begin(); _fs = fs; -#if defined(ARCH_PORTDUINO) +#if defined(ARDULINUX_PLATFORM) // Apply runtime INI config as first-run defaults before loading persisted prefs. // If /com_prefs exists, loadPrefs() below will overwrite these with the saved values. StrHelper::strncpy(_prefs.node_name, board.config.advert_name, sizeof(_prefs.node_name)); @@ -1036,7 +1036,7 @@ bool MyMesh::formatFileSystem() { return LittleFS.format(); #elif defined(ESP32) return SPIFFS.format(); -#elif defined(ARCH_PORTDUINO) +#elif defined(ARDULINUX_PLATFORM) return false; // not supported on Linux #else #error "need to implement file system erase" @@ -1193,7 +1193,7 @@ void MyMesh::formatPacketStatsReply(char *reply) { void MyMesh::saveIdentity(const mesh::LocalIdentity &new_id) { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) IdentityStore store(*_fs, ""); -#elif defined(ESP32) || defined(RP2040_PLATFORM) || defined(ARCH_PORTDUINO) +#elif defined(ESP32) || defined(RP2040_PLATFORM) || defined(ARDULINUX_PLATFORM) IdentityStore store(*_fs, "/identity"); #else #error "need to define saveIdentity()" diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index ef599d8793..3b7f364557 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -12,8 +12,8 @@ #elif defined(ESP32) #include using File = fs::File; -#elif defined(ARCH_PORTDUINO) - #include +#elif defined(ARDULINUX_PLATFORM) + #include #endif #ifdef WITH_RS232_BRIDGE diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 4ce860d6d7..67c38f58bc 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -81,13 +81,13 @@ void setup() { fs = &LittleFS; IdentityStore store(LittleFS, "/identity"); store.begin(); -#elif defined(ARCH_PORTDUINO) +#elif defined(ARDULINUX_PLATFORM) if (::mkdir(board.config.data_dir, 0755) != 0 && errno != EEXIST) { - Serial.printf("WARNING: could not create data_dir '%s': %s\n", board.config.data_dir, strerror(errno)); + printf("WARNING: could not create data_dir '%s': %s\n", board.config.data_dir, strerror(errno)); } - portduinoVFS->mountpoint(board.config.data_dir); - fs = &PortduinoFS; - IdentityStore store(PortduinoFS, "/identity"); + ardulinuxVFS->mountpoint(board.config.data_dir); + fs = &ArduLinuxFS; + IdentityStore store(ArduLinuxFS, "/identity"); store.begin(); #else #error "need to define filesystem" diff --git a/src/helpers/ClientACL.cpp b/src/helpers/ClientACL.cpp index 40a98a5b7a..f848ff432b 100644 --- a/src/helpers/ClientACL.cpp +++ b/src/helpers/ClientACL.cpp @@ -4,7 +4,7 @@ static File openWrite(FILESYSTEM* _fs, const char* filename) { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) _fs->remove(filename); return _fs->open(filename, FILE_O_WRITE); - #elif defined(RP2040_PLATFORM) || defined(ARCH_PORTDUINO) + #elif defined(RP2040_PLATFORM) || defined(ARDULINUX_PLATFORM) return _fs->open(filename, "w"); #else return _fs->open(filename, "w", true); diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 7c4631db95..96c7fa92f9 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -144,7 +144,7 @@ bool CommonCLI::savePrefs(FILESYSTEM* fs) { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) fs->remove("/prefs.json"); File file = fs->open("/prefs.json", FILE_O_WRITE); -#elif defined(RP2040_PLATFORM) || defined(ARCH_PORTDUINO) +#elif defined(RP2040_PLATFORM) || defined(ARDULINUX_PLATFORM) File file = fs->open("/prefs.json", "w"); #else File file = fs->open("/prefs.json", "w", true); diff --git a/src/helpers/IdentityStore.cpp b/src/helpers/IdentityStore.cpp index 0a9700f0aa..3d29299098 100644 --- a/src/helpers/IdentityStore.cpp +++ b/src/helpers/IdentityStore.cpp @@ -49,7 +49,7 @@ bool IdentityStore::save(const char *name, const mesh::LocalIdentity& id) { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) _fs->remove(filename); File file = _fs->open(filename, FILE_O_WRITE); -#elif defined(RP2040_PLATFORM) || defined(ARCH_PORTDUINO) +#elif defined(RP2040_PLATFORM) || defined(ARDULINUX_PLATFORM) File file = _fs->open(filename, "w"); #else File file = _fs->open(filename, "w", true); @@ -71,7 +71,7 @@ bool IdentityStore::save(const char *name, const mesh::LocalIdentity& id, const #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) _fs->remove(filename); File file = _fs->open(filename, FILE_O_WRITE); -#elif defined(RP2040_PLATFORM) || defined(ARCH_PORTDUINO) +#elif defined(RP2040_PLATFORM) || defined(ARDULINUX_PLATFORM) File file = _fs->open(filename, "w"); #else File file = _fs->open(filename, "w", true); diff --git a/src/helpers/IdentityStore.h b/src/helpers/IdentityStore.h index a96aa536cc..4a1e22cccf 100644 --- a/src/helpers/IdentityStore.h +++ b/src/helpers/IdentityStore.h @@ -1,6 +1,6 @@ #pragma once -#if defined(ESP32) || defined(RP2040_PLATFORM) || defined(ARCH_PORTDUINO) +#if defined(ESP32) || defined(RP2040_PLATFORM) || defined(ARDULINUX_PLATFORM) #include #define FILESYSTEM fs::FS #elif defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) diff --git a/src/helpers/RegionMap.cpp b/src/helpers/RegionMap.cpp index 51cfe10e9f..2be232ba3e 100644 --- a/src/helpers/RegionMap.cpp +++ b/src/helpers/RegionMap.cpp @@ -62,7 +62,7 @@ static File openWrite(FILESYSTEM* _fs, const char* filename) { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) _fs->remove(filename); return _fs->open(filename, FILE_O_WRITE); - #elif defined(RP2040_PLATFORM) || defined(ARCH_PORTDUINO) + #elif defined(RP2040_PLATFORM) || defined(ARDULINUX_PLATFORM) return _fs->open(filename, "w"); #else return _fs->open(filename, "w", true); diff --git a/src/helpers/TxtDataHelpers.cpp b/src/helpers/TxtDataHelpers.cpp index 60832f6653..6da0d741ab 100644 --- a/src/helpers/TxtDataHelpers.cpp +++ b/src/helpers/TxtDataHelpers.cpp @@ -1,5 +1,5 @@ #include "TxtDataHelpers.h" -#if defined(ARCH_PORTDUINO) +#if defined(ARDULINUX_PLATFORM) #include #endif @@ -105,7 +105,7 @@ static void _ftoa(float f, char *p, int *status) *p++ = '0'; else { -#if defined(ARCH_PORTDUINO) +#if defined(ARDULINUX_PLATFORM) sprintf(p, "%" PRId32, int_part); #else ltoa(int_part, p, 10); diff --git a/src/helpers/radiolib/LinuxSX1262Wrapper.h b/src/helpers/radiolib/LinuxSX1262Wrapper.h index fbbfd19c9b..9a75d37e69 100644 --- a/src/helpers/radiolib/LinuxSX1262Wrapper.h +++ b/src/helpers/radiolib/LinuxSX1262Wrapper.h @@ -6,6 +6,15 @@ class LinuxSX1262Wrapper : public RadioLibWrapper { public: LinuxSX1262Wrapper(LinuxSX1262& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } + + void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override { + ((LinuxSX1262 *)_radio)->setFrequency(freq); + ((LinuxSX1262 *)_radio)->setSpreadingFactor(sf); + ((LinuxSX1262 *)_radio)->setBandwidth(bw); + ((LinuxSX1262 *)_radio)->setCodingRate(cr); + updatePreamble(sf); + } + bool isReceivingPacket() override { return ((LinuxSX1262 *)_radio)->isReceiving(); } @@ -19,4 +28,5 @@ class LinuxSX1262Wrapper : public RadioLibWrapper { int sf = ((LinuxSX1262 *)_radio)->spreadingFactor; return packetScoreInt(snr, sf, packet_len); } + uint8_t getSpreadingFactor() const override { return ((LinuxSX1262 *)_radio)->spreadingFactor; } }; diff --git a/variants/portduino/platformio.ini b/variants/ardulinux/platformio.ini similarity index 63% rename from variants/portduino/platformio.ini rename to variants/ardulinux/platformio.ini index 5785e965aa..549f34429d 100644 --- a/variants/portduino/platformio.ini +++ b/variants/ardulinux/platformio.ini @@ -1,7 +1,7 @@ -[portduino_base] +[ardulinux_base] platform = - # renovate: datasource=git-refs depName=platform-native packageName=https://github.com/meshtastic/platform-native gitBranch=develop - https://github.com/meshtastic/platform-native/archive/f566d364204416cdbf298e349213f7d551f793d9.zip + # renovate: datasource=git-refs depName=ardulinux packageName=https://github.com/l5yth/ardulinux gitBranch=main + git+https://github.com/l5yth/ardulinux.git framework = arduino build_src_filter = @@ -26,16 +26,12 @@ lib_deps = build_flags = ${arduino_base.build_flags} - -DARCH_PORTDUINO - -DPORTDUINO_PLATFORM + -DARDULINUX_PLATFORM -DRADIOLIB_EEPROM_UNSUPPORTED - -DPORTDUINO_LINUX_HARDWARE -fPIC -lpthread -lstdc++fs -lbluetooth - -lgpiod - -li2c -luv -std=gnu17 -std=c++17 diff --git a/variants/linux/LinuxBoard.cpp b/variants/linux/LinuxBoard.cpp index 4c25da0a11..40c33acf37 100644 --- a/variants/linux/LinuxBoard.cpp +++ b/variants/linux/LinuxBoard.cpp @@ -2,12 +2,19 @@ #include #include #include +#ifdef ARDULINUX_HARDWARE #include "linux/gpio/LinuxGPIOPin.h" +#endif #include "LinuxBoard.h" +#include "AppInfo.h" + +const char *ardulinuxAppName = "meshcored"; +const char *ardulinuxAppDescription = "a meshcore daemon for linux"; +const char *ardulinuxAppBugAddress = "https://github.com/meshcore-dev/MeshCore"; int initGPIOPin(uint8_t pinNum, const std::string gpioChipName, uint8_t line) { -#ifdef PORTDUINO_LINUX_HARDWARE +#ifdef ARDULINUX_HARDWARE char gpio_name[32]; snprintf(gpio_name, sizeof(gpio_name), "GPIO%d", pinNum); @@ -26,22 +33,22 @@ int initGPIOPin(uint8_t pinNum, const std::string gpioChipName, uint8_t line) #endif } -void portduinoSetup() { +void ardulinuxSetup() { } void LinuxBoard::begin() { config.load("/etc/meshcored/meshcored.ini"); - Serial.printf("SPI begin %s\n", config.spidev); - SPI.begin(config.spidev); + printf("SPI begin %s\n", config.spidev); + SPI.begin(config.spidev, 2000000); - Serial.printf("LoRa pins NSS=%d BUSY=%d IRQ=%d RESET=%d TX=%d RX=%d\n", - (int)config.lora_nss_pin, - (int)config.lora_busy_pin, - (int)config.lora_irq_pin, - (int)config.lora_reset_pin, - (int)config.lora_rxen_pin, - (int)config.lora_txen_pin); + printf("LoRa pins NSS=%d BUSY=%d IRQ=%d RESET=%d TX=%d RX=%d\n", + (int)config.lora_nss_pin, + (int)config.lora_busy_pin, + (int)config.lora_irq_pin, + (int)config.lora_reset_pin, + (int)config.lora_rxen_pin, + (int)config.lora_txen_pin); if (config.lora_nss_pin != RADIOLIB_NC) { initGPIOPin(config.lora_nss_pin, "gpiochip0", config.lora_nss_pin); diff --git a/variants/linux/README.md b/variants/linux/README.md index 276fd8e6c7..a597ddf2de 100644 --- a/variants/linux/README.md +++ b/variants/linux/README.md @@ -1,6 +1,6 @@ # MeshCore Linux Variant -Native Linux support for MeshCore, targeting Raspberry Pi (Zero, 3, 4, 5) and similar SBCs with an SX1262 LoRa radio attached over SPI. Uses the [Portduino](https://github.com/meshtastic/platform-native) Arduino-compatibility layer to run the same firmware codebase on Linux without modification to the core library. +Native Linux support for MeshCore, targeting Raspberry Pi (Zero, 3, 4, 5) and similar SBCs with an SX1262 LoRa radio attached over SPI. Uses [ArduLinux — Arduino API for Linux](https://github.com/l5yth/ardulinux) to run the same firmware codebase on Linux without modification to the core library. ## Hardware @@ -14,17 +14,33 @@ Native Linux support for MeshCore, targeting Raspberry Pi (Zero, 3, 4, 5) and si ```sh # Arch Linux -sudo pacman -S libgpiod i2c-tools +sudo pacman -S libgpiod i2c-tools bluez-libs libuv # Debian/Raspberry Pi OS -sudo apt install libgpiod-dev libi2c-dev +sudo apt install libgpiod-dev libi2c-dev libbluetooth-dev libuv1-dev +``` + +The ArduLinux platform always links `bluetooth`, `uv`, `pthread`, and +`stdc++fs`; `gpiod`/`i2c` are added automatically when libgpiod is detected via +`pkg-config` (without it the build falls back to simulated GPIO/I2C). Missing +`bluez-libs`/`libbluetooth-dev` shows up as a `cannot find -lbluetooth` link +error. + +You also need **PlatformIO Core** (`pio`) to build: + +```sh +# Arch Linux +sudo pacman -S platformio # or: pipx install platformio + +# Debian/Raspberry Pi OS +pipx install platformio # or: pip install --user platformio ``` **Build with `build.sh`** (recommended — embeds version and commit hash): ```sh FIRMWARE_VERSION=dev ./build.sh build-firmware linux_repeater -# binary: .pio/build/linux_repeater/program +# binary: .pio/build/linux_repeater/meshcored ``` Alternatively, build directly with PlatformIO (no version metadata): @@ -38,7 +54,7 @@ FIRMWARE_VERSION=dev pio run -e linux_repeater ### 1. Install the binary ```sh -sudo cp .pio/build/linux_repeater/program /usr/bin/meshcored +sudo install -m 755 .pio/build/linux_repeater/meshcored /usr/bin/meshcored ``` ### 2. Create the config file @@ -51,9 +67,8 @@ Two ready-made templates are provided in `variants/linux/`: | `meshcored.ini.waveshare` | RPi 3/4/5 + Waveshare SX1262 LoRa HAT | ```sh -sudo mkdir -p /etc/meshcored -# Pick the template that matches your hardware: -sudo cp variants/linux/meshcored.ini.waveshare /etc/meshcored/meshcored.ini +# Pick the template that matches your hardware (install -D creates /etc/meshcored): +sudo install -D -m 644 variants/linux/meshcored.ini.waveshare /etc/meshcored/meshcored.ini sudo nano /etc/meshcored/meshcored.ini ``` @@ -71,12 +86,17 @@ Key settings: | `lora_reset_pin` | (none) | GPIO line number for RESET | | `lora_nss_pin` | (none) | GPIO line number for NSS/CS (if not handled by the SPI driver) | | `lora_busy_pin` | (none) | GPIO line number for BUSY | +| `lora_rxen_pin` | (none) | GPIO line number for RX enable (RF switch); omit if unused | +| `lora_txen_pin` | (none) | GPIO line number for TX enable (RF switch); omit if unused | | `lora_freq` | `869.618` | Frequency in MHz | | `lora_bw` | `62.5` | Bandwidth in kHz | | `lora_sf` | `8` | Spreading factor | | `lora_cr` | `8` | Coding rate | | `lora_tcxo` | `1.8` | TCXO voltage (V); set to `0.0` if your module has no TCXO | | `lora_tx_power` | `22` | TX power in dBm | +| `current_limit` | `140` | Radio over-current protection limit in mA | +| `dio2_as_rf_switch` | `0` | Set to `1` to use DIO2 as the RF switch control (depends on module wiring) | +| `rx_boosted_gain` | `1` | `1` enables the SX126x RX boosted-gain mode; `0` disables | | `advert_name` | `"Linux Repeater"` | Node name — first-run default only | | `admin_password` | `"password"` | Admin password — **change this**, first-run default only | | `lat` / `lon` | `0.0` | GPS coordinates for advertisement — first-run default only | @@ -84,16 +104,32 @@ Key settings: ### 3. Enable SPI and GPIO access +First make sure the SPI interface is actually enabled — the radio needs a +`/dev/spidev*` node. Check with `ls /dev/spidev*`; if there is none: + ```sh # Raspberry Pi OS -sudo raspi-config # Interface Options → SPI → Enable +sudo raspi-config # Interface Options → SPI → Enable, then reboot + +# Arch Linux ARM (no raspi-config): enable the SPI device-tree overlay +echo 'dtparam=spi=on' | sudo tee -a /boot/config.txt # then reboot +``` + +> The boot config path varies by image — it is `/boot/config.txt` on most +> Raspberry Pi images but `/boot/firmware/config.txt` on some. After rebooting, +> confirm `/dev/spidev0.0` exists. + +Then grant access to the SPI and GPIO devices. On Raspberry Pi OS you can use the +`spi`/`gpio` groups: + +```sh sudo usermod -aG spi,gpio $USER ``` -On Arch Linux and other distributions without `spi`/`gpio` groups, use the provided udev rules instead (also works on Raspberry Pi OS): +On Arch Linux and other distributions without `spi`/`gpio` groups, use the provided udev rules instead (also works on Raspberry Pi OS); these grant access to the `meshcore` group used by the systemd service: ```sh -sudo cp variants/linux/99-meshcore.rules /etc/udev/rules.d/ +sudo install -m 644 variants/linux/99-meshcore.rules /etc/udev/rules.d/ sudo udevadm control --reload-rules && sudo udevadm trigger ``` @@ -110,8 +146,8 @@ sudo /usr/bin/meshcored **As a systemd service** (recommended for production): ```sh -sudo cp variants/linux/meshcored.service /etc/systemd/system/ -sudo cp variants/linux/99-meshcore.rules /etc/udev/rules.d/ +sudo install -m 644 variants/linux/meshcored.service /etc/systemd/system/ +sudo install -m 644 variants/linux/99-meshcore.rules /etc/udev/rules.d/ sudo udevadm control --reload-rules && sudo udevadm trigger sudo useradd -r -s /sbin/nologin meshcore sudo mkdir -p /var/lib/meshcore @@ -145,8 +181,8 @@ sudo systemctl restart meshcored ## Known Gaps / TODO -- **No CLI argument parsing** — config path is hardcoded to `/etc/meshcored/meshcored.ini`; `data_dir` is only configurable via the INI file. +- **Config path is hardcoded** — meshcored always loads `/etc/meshcored/meshcored.ini`; there is no flag to point it elsewhere. (The ArduLinux runtime itself accepts some flags such as `--fsdir`; how that interacts with the INI's `data_dir` is not yet verified on hardware — TODO.) - **Only repeater firmware** — there is no `linux_companion` target yet; companion radio support (BLE/serial interface to a phone app) is not implemented for Linux. - **`formatFileSystem()`** returns `false` (not implemented) — the CLI `format` command will report failure on Linux. - **No power management** — `board.sleep()` is a no-op; the power-saving loop in `main.cpp` never actually sleeps. -- **Portduino branding** — on startup the binary identifies itself as "An application written with portduino" with a Meshtastic bug URL. This is hardcoded in the Portduino framework and cannot be changed without patching the framework. +- **Upstream-sync fragility** — the radio wrapper (`LinuxSX1262Wrapper`) implements the `RadioLibWrapper` interface by hand, so an upstream change that adds a pure-virtual method (e.g. `setParams()`) breaks the Linux build until the override is added. Mirror `CustomSX1262Wrapper` when this happens. diff --git a/variants/linux/platformio.ini b/variants/linux/platformio.ini index 3fc49b2cfd..c8634b5e4b 100644 --- a/variants/linux/platformio.ini +++ b/variants/linux/platformio.ini @@ -1,15 +1,16 @@ [linux_base] -extends = portduino_base -build_flags = ${portduino_base.build_flags} +extends = ardulinux_base +build_flags = ${ardulinux_base.build_flags} -I variants/linux -I /usr/include -board = cross_platform +board = linux board_level = extra +board_build.progname = meshcored lib_deps = - ${portduino_base.lib_deps} + ${ardulinux_base.lib_deps} melopero/Melopero RV3028@^1.1.0 -build_src_filter = ${portduino_base.build_src_filter} +build_src_filter = ${ardulinux_base.build_src_filter} +<../variants/linux> - - diff --git a/variants/linux/target.cpp b/variants/linux/target.cpp index 0f5941f890..a69218e2ee 100644 --- a/variants/linux/target.cpp +++ b/variants/linux/target.cpp @@ -1,20 +1,25 @@ #include #include "target.h" -class PortduinoHal : public ArduinoHal +class ArduLinuxHal : public ArduinoHal { public: - PortduinoHal(SPIClass &spi, SPISettings spiSettings) : ArduinoHal(spi, spiSettings){}; + ArduLinuxHal(SPIClass &spi, SPISettings spiSettings) : ArduinoHal(spi, spiSettings){}; + // ArduLinux's SPIClass exposes only an in-place transfer(buf, len) that + // overwrites buf with the received bytes. RadioLib's HAL expects a + // full-duplex transfer(out, len, in), so copy out -> in first, then run the + // in-place exchange (out and in are distinct, non-overlapping buffers). void spiTransfer(uint8_t *out, size_t len, uint8_t *in) { - spi->transfer(out, in, len); + memcpy(in, out, len); + spi->transfer(in, len); } }; LinuxBoard board; SPISettings spiSettings = SPISettings(2000000, MSBFIRST, SPI_MODE0); -ArduinoHal *hal = new PortduinoHal(SPI, spiSettings); +ArduinoHal *hal = new ArduLinuxHal(SPI, spiSettings); RADIO_CLASS radio = new Module(hal, RADIOLIB_NC, RADIOLIB_NC, RADIOLIB_NC, RADIOLIB_NC); WRAPPER_CLASS radio_driver(radio, board); From 5d9de0fd0c82f7391884bb603801e9df01a8d049 Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Mon, 1 Jun 2026 08:19:54 +0200 Subject: [PATCH 06/16] pin ardulinux to 0.2.0 (#7) --- variants/ardulinux/platformio.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/variants/ardulinux/platformio.ini b/variants/ardulinux/platformio.ini index 549f34429d..b11f85c015 100644 --- a/variants/ardulinux/platformio.ini +++ b/variants/ardulinux/platformio.ini @@ -1,7 +1,7 @@ [ardulinux_base] platform = - # renovate: datasource=git-refs depName=ardulinux packageName=https://github.com/l5yth/ardulinux gitBranch=main - git+https://github.com/l5yth/ardulinux.git + # renovate: datasource=git-tags depName=ardulinux packageName=https://github.com/l5yth/ardulinux + git+https://github.com/l5yth/ardulinux.git#v0.2.0 framework = arduino build_src_filter = From ee66e1555559d19be50fe793c9aaea8d86f7bfc6 Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Wed, 3 Jun 2026 22:24:01 +0200 Subject: [PATCH 07/16] =?UTF-8?q?variants/linux:=20on-hardware=20smoke-tes?= =?UTF-8?q?t=20fixes=20(rx=5Fboost,=20dio2,=20--fsdir=E2=80=A6=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * variants/linux: on-hardware smoke-test fixes (rx_boost, dio2, --fsdir, docs) * address review comments --- examples/simple_repeater/MyMesh.cpp | 1 + examples/simple_repeater/main.cpp | 7 ++- src/helpers/radiolib/LinuxSX1262.h | 6 +++ src/helpers/radiolib/LinuxSX1262Wrapper.h | 7 +++ variants/linux/LinuxBoard.cpp | 1 - variants/linux/LinuxBoard.h | 1 - variants/linux/README.md | 59 +++++++++++++++-------- variants/linux/meshcored.ini | 3 +- variants/linux/meshcored.ini.pow-sx1262 | 4 +- variants/linux/meshcored.ini.waveshare | 7 ++- 10 files changed, 66 insertions(+), 30 deletions(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 7d7ebe3e43..89c31c0958 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -956,6 +956,7 @@ void MyMesh::begin(FILESYSTEM *fs) { _prefs.sf = board.config.lora_sf; _prefs.cr = board.config.lora_cr; _prefs.tx_power_dbm = board.config.lora_tx_power; + _prefs.rx_boosted_gain = board.config.rx_boosted_gain; #endif // load persisted prefs _cli.loadPrefs(_fs); diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 67c38f58bc..a1520dca0f 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -82,10 +82,9 @@ void setup() { IdentityStore store(LittleFS, "/identity"); store.begin(); #elif defined(ARDULINUX_PLATFORM) - if (::mkdir(board.config.data_dir, 0755) != 0 && errno != EEXIST) { - printf("WARNING: could not create data_dir '%s': %s\n", board.config.data_dir, strerror(errno)); - } - ardulinuxVFS->mountpoint(board.config.data_dir); + // The VFS root is established by the ArduLinux core from --fsdir (default: the + // XDG data dir, e.g. ~/.local/share/meshcored/default), so there is no per-app + // mountpoint override here — --fsdir is the single source of truth for the data path. fs = &ArduLinuxFS; IdentityStore store(ArduLinuxFS, "/identity"); store.begin(); diff --git a/src/helpers/radiolib/LinuxSX1262.h b/src/helpers/radiolib/LinuxSX1262.h index 8d5977e8b0..bed50ee92e 100644 --- a/src/helpers/radiolib/LinuxSX1262.h +++ b/src/helpers/radiolib/LinuxSX1262.h @@ -45,4 +45,10 @@ class LinuxSX1262 : public SX1262 { bool detected = (irq & SX126X_IRQ_HEADER_VALID) || (irq & SX126X_IRQ_PREAMBLE_DETECTED); return detected; } + + bool getRxBoostedGainMode() { + uint8_t rxGain = 0; + readRegister(RADIOLIB_SX126X_REG_RX_GAIN, &rxGain, 1); + return (rxGain == RADIOLIB_SX126X_RX_GAIN_BOOSTED); + } }; diff --git a/src/helpers/radiolib/LinuxSX1262Wrapper.h b/src/helpers/radiolib/LinuxSX1262Wrapper.h index 9a75d37e69..17e168c70f 100644 --- a/src/helpers/radiolib/LinuxSX1262Wrapper.h +++ b/src/helpers/radiolib/LinuxSX1262Wrapper.h @@ -29,4 +29,11 @@ class LinuxSX1262Wrapper : public RadioLibWrapper { return packetScoreInt(snr, sf, packet_len); } uint8_t getSpreadingFactor() const override { return ((LinuxSX1262 *)_radio)->spreadingFactor; } + + void setRxBoostedGainMode(bool en) override { + ((LinuxSX1262 *)_radio)->setRxBoostedGainMode(en); + } + bool getRxBoostedGainMode() const override { + return ((LinuxSX1262 *)_radio)->getRxBoostedGainMode(); + } }; diff --git a/variants/linux/LinuxBoard.cpp b/variants/linux/LinuxBoard.cpp index 40c33acf37..9e9c79de48 100644 --- a/variants/linux/LinuxBoard.cpp +++ b/variants/linux/LinuxBoard.cpp @@ -147,7 +147,6 @@ int LinuxConfig::load(const char *filename) { else if (strcmp(key, "admin_password") == 0) admin_password = safe_copy(value, 100); else if (strcmp(key, "lat") == 0) lat = atof(value); else if (strcmp(key, "lon") == 0) lon = atof(value); - else if (strcmp(key, "data_dir") == 0) data_dir = safe_copy(value, 256); } fclose(f); return 0; diff --git a/variants/linux/LinuxBoard.h b/variants/linux/LinuxBoard.h index 584ef195f6..0a18f85c16 100644 --- a/variants/linux/LinuxBoard.h +++ b/variants/linux/LinuxBoard.h @@ -38,7 +38,6 @@ class LinuxConfig { char *admin_password = "password"; float lat = 0.0f; float lon = 0.0f; - char *data_dir = "/var/lib/meshcore"; int load(const char *filename); }; diff --git a/variants/linux/README.md b/variants/linux/README.md index a597ddf2de..ac8f1b36b8 100644 --- a/variants/linux/README.md +++ b/variants/linux/README.md @@ -75,7 +75,7 @@ sudo nano /etc/meshcored/meshcored.ini The config file has two roles: - **Hardware config** (always read on every startup): SPI device, GPIO pin numbers, LoRa radio parameters. -- **First-run node defaults**: `advert_name`, `admin_password`, `lat`, `lon`. On the first boot these are saved to `data_dir`. After that, use the serial CLI to change them (`set name`, `set password`, etc.) — the INI values are no longer consulted for these fields. +- **First-run node defaults**: `advert_name`, `admin_password`, `lat`, `lon`. On the first boot these are saved to the node's persisted prefs (`com_prefs`). After that, use the serial CLI to change them (`set name`, `set password`, etc.) — the INI values are no longer consulted for these fields. Key settings: @@ -95,12 +95,11 @@ Key settings: | `lora_tcxo` | `1.8` | TCXO voltage (V); set to `0.0` if your module has no TCXO | | `lora_tx_power` | `22` | TX power in dBm | | `current_limit` | `140` | Radio over-current protection limit in mA | -| `dio2_as_rf_switch` | `0` | Set to `1` to use DIO2 as the RF switch control (depends on module wiring) | +| `dio2_as_rf_switch` | `0` | `1` = use DIO2 to drive the TX/RX RF switch. **Required for the Waveshare Core1262** (without it the radio inits but TX/RX are dead); depends on module wiring | | `rx_boosted_gain` | `1` | `1` enables the SX126x RX boosted-gain mode; `0` disables | | `advert_name` | `"Linux Repeater"` | Node name — first-run default only | | `admin_password` | `"password"` | Admin password — **change this**, first-run default only | | `lat` / `lon` | `0.0` | GPS coordinates for advertisement — first-run default only | -| `data_dir` | `/var/lib/meshcore` | Where identity and node prefs are persisted | ### 3. Enable SPI and GPIO access @@ -118,40 +117,57 @@ echo 'dtparam=spi=on' | sudo tee -a /boot/config.txt # then reboot > The boot config path varies by image — it is `/boot/config.txt` on most > Raspberry Pi images but `/boot/firmware/config.txt` on some. After rebooting, > confirm `/dev/spidev0.0` exists. - -Then grant access to the SPI and GPIO devices. On Raspberry Pi OS you can use the -`spi`/`gpio` groups: +> +> **Arch Linux kernel caveat:** `dtparam=spi=on` is only honored by the Raspberry +> Pi `linux-rpi` (vendor) kernel. The mainline `linux-aarch64` kernel boots via +> U-Boot, which loads its own device tree and ignores `config.txt` overlays — so +> `/dev/spidev*` never appears regardless of `config.txt`. If SPI is missing after +> enabling it and rebooting, switch to the vendor kernel +> (`sudo pacman -S linux-rpi`, remove `linux-aarch64`) and reboot. + +Then grant non-root access to the SPI and GPIO devices using the provided udev +rules, which place `/dev/spidev*` and `/dev/gpiochip*` in a `meshcore` group. +Create the group, add yourself to it, and install the rules: ```sh -sudo usermod -aG spi,gpio $USER +sudo groupadd -f -r meshcore +sudo usermod -aG meshcore "$USER" # log out/in afterwards for this to take effect +sudo install -m 644 variants/linux/99-meshcore.rules /etc/udev/rules.d/ +sudo udevadm control --reload-rules && sudo udevadm trigger ``` -On Arch Linux and other distributions without `spi`/`gpio` groups, use the provided udev rules instead (also works on Raspberry Pi OS); these grant access to the `meshcore` group used by the systemd service: +Confirm the device nodes are now group-owned by `meshcore`: ```sh -sudo install -m 644 variants/linux/99-meshcore.rules /etc/udev/rules.d/ -sudo udevadm control --reload-rules && sudo udevadm trigger +ls -l /dev/gpiochip* /dev/spidev* # → crw-rw---- root meshcore ``` +Your current login session won't pick up the new group until you log out and +back in. To use it immediately in one shell, prefix the command with +`sg meshcore -c '…'`. (On Raspberry Pi OS you can instead use the built-in +`spi`/`gpio` groups: `sudo usermod -aG spi,gpio $USER`.) + ### 4. Run -**Directly** (for testing): +**Directly** (for testing). With the udev rules in place you can run as your own +user — no `sudo`. Data is persisted under the VFS root, which defaults to the XDG +data dir; pass `--fsdir` to choose another location: ```sh -sudo /usr/bin/meshcored +meshcored # VFS root: ~/.local/share/meshcored/default +meshcored --fsdir /var/lib/meshcore # explicit location +# before re-logging in (group not yet active in this shell): +sg meshcore -c 'meshcored --fsdir /var/lib/meshcore' ``` -`sudo` is needed on first run to create `data_dir` if it doesn't exist. Once the directory is created and owned appropriately, it can run as a non-root user. - -**As a systemd service** (recommended for production): +**As a systemd service** (recommended for production). The unit runs as the +`meshcore` user and passes `--fsdir /var/lib/meshcore`: ```sh sudo install -m 644 variants/linux/meshcored.service /etc/systemd/system/ sudo install -m 644 variants/linux/99-meshcore.rules /etc/udev/rules.d/ sudo udevadm control --reload-rules && sudo udevadm trigger sudo useradd -r -s /sbin/nologin meshcore -sudo mkdir -p /var/lib/meshcore -sudo chown meshcore:meshcore /var/lib/meshcore sudo chmod 640 /etc/meshcored/meshcored.ini sudo chown root:meshcore /etc/meshcored/meshcored.ini sudo systemctl daemon-reload @@ -159,6 +175,11 @@ sudo systemctl enable --now meshcored sudo journalctl -u meshcored -f ``` +> The unit's `ExecStartPre` creates and chowns `/var/lib/meshcore`, so you don't +> need to pre-create it. If you smoke-tested by running directly first, clear any +> stale state so the service first-boots with the INI defaults: +> `sudo rm -rf /var/lib/meshcore/*` + ### 5. Reconfiguring after first run Node name, password, and location can be changed via the serial CLI after first boot: @@ -181,8 +202,8 @@ sudo systemctl restart meshcored ## Known Gaps / TODO -- **Config path is hardcoded** — meshcored always loads `/etc/meshcored/meshcored.ini`; there is no flag to point it elsewhere. (The ArduLinux runtime itself accepts some flags such as `--fsdir`; how that interacts with the INI's `data_dir` is not yet verified on hardware — TODO.) +- **Config path is hardcoded** — meshcored always loads `/etc/meshcored/meshcored.ini`; there is no flag to point it elsewhere. (The data *path* is separate and configurable: it is the ArduLinux VFS root, set with `--fsdir`.) - **Only repeater firmware** — there is no `linux_companion` target yet; companion radio support (BLE/serial interface to a phone app) is not implemented for Linux. - **`formatFileSystem()`** returns `false` (not implemented) — the CLI `format` command will report failure on Linux. - **No power management** — `board.sleep()` is a no-op; the power-saving loop in `main.cpp` never actually sleeps. -- **Upstream-sync fragility** — the radio wrapper (`LinuxSX1262Wrapper`) implements the `RadioLibWrapper` interface by hand, so an upstream change that adds a pure-virtual method (e.g. `setParams()`) breaks the Linux build until the override is added. Mirror `CustomSX1262Wrapper` when this happens. +- **Upstream-sync fragility** — the radio wrapper (`LinuxSX1262Wrapper`) implements the `RadioLibWrapper` interface by hand, so it can drift from upstream in two ways: a new **pure-virtual** method breaks the Linux build (e.g. `setParams()`), and a new **virtual-with-default** method silently no-ops on Linux until overridden (e.g. `set`/`getRxBoostedGainMode()`, which reported and applied the wrong state until added). Mirror `CustomSX1262Wrapper` when syncing. diff --git a/variants/linux/meshcored.ini b/variants/linux/meshcored.ini index 2c57adbbb9..794ffc490b 100644 --- a/variants/linux/meshcored.ini +++ b/variants/linux/meshcored.ini @@ -2,7 +2,8 @@ advert_name = Sample Router admin_password = password lat = 0.0 lon = 0.0 -#data_dir = /var/lib/meshcore +# Data path: set via the --fsdir CLI flag (the systemd unit uses +# /var/lib/meshcore), not in this file. # Waveshare LoRa hat #lora_irq_pin = 16 diff --git a/variants/linux/meshcored.ini.pow-sx1262 b/variants/linux/meshcored.ini.pow-sx1262 index 1f2e03000f..024c5d77cf 100644 --- a/variants/linux/meshcored.ini.pow-sx1262 +++ b/variants/linux/meshcored.ini.pow-sx1262 @@ -20,5 +20,5 @@ admin_password = changeme lat = 0.0 lon = 0.0 -# data_dir — where identity and node prefs are persisted (default shown): -#data_dir = /var/lib/meshcore +# Data is persisted under the VFS root set by the --fsdir CLI flag (default +# ~/.local/share/meshcored/default; the systemd unit uses /var/lib/meshcore). diff --git a/variants/linux/meshcored.ini.waveshare b/variants/linux/meshcored.ini.waveshare index 84df7f3acd..5aeb28e1d7 100644 --- a/variants/linux/meshcored.ini.waveshare +++ b/variants/linux/meshcored.ini.waveshare @@ -13,6 +13,9 @@ lora_sf = 8 lora_cr = 8 lora_tcxo = 1.8 lora_tx_power = 22 +# DIO2 drives the TX/RX RF switch on the Waveshare Core1262 — required for this HAT +# (without it the radio inits fine but TX/RX stay dead). 1 = enabled, 0 = disabled. +dio2_as_rf_switch = 1 # First-run node defaults (ignored after first boot; use CLI to change): advert_name = Waveshare Linux Repeater @@ -20,5 +23,5 @@ admin_password = changeme lat = 0.0 lon = 0.0 -# data_dir — where identity and node prefs are persisted (default shown): -#data_dir = /var/lib/meshcore +# Data is persisted under the VFS root set by the --fsdir CLI flag (default +# ~/.local/share/meshcored/default; the systemd unit uses /var/lib/meshcore). From ee2694bd6bbb5a50ce1d709467059e092681f04f Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:32:10 +0200 Subject: [PATCH 08/16] proofread README (#9) * proofread README * proofread README --- variants/linux/README.md | 37 +++++++++++++++++++++++++------- variants/linux/meshcored.service | 11 +++++----- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/variants/linux/README.md b/variants/linux/README.md index ac8f1b36b8..c949b1708b 100644 --- a/variants/linux/README.md +++ b/variants/linux/README.md @@ -30,7 +30,7 @@ You also need **PlatformIO Core** (`pio`) to build: ```sh # Arch Linux -sudo pacman -S platformio # or: pipx install platformio +sudo pacman -S platformio-core # or: pipx install platformio # Debian/Raspberry Pi OS pipx install platformio # or: pip install --user platformio @@ -149,6 +149,15 @@ back in. To use it immediately in one shell, prefix the command with ### 4. Run +`meshcored` takes a small set of options, parsed by the ArduLinux core: + +| Flag | Description | +|------|-------------| +| `-d`, `--fsdir=DIR` | Directory to use as the VFS root, where all data is persisted. Default: `~/.local/share/meshcored/default` | +| `-e`, `--erase` | Recursively wipe the VFS root, then start. This is a **full reset**: it also removes the node identity, so the node comes back with a new Repeater ID (see step 5). Never put this in the systemd unit. | +| `--usage`, `-?` / `--help` | Short usage / full option list | +| `-V`, `--version` | Print the firmware version | + **Directly** (for testing). With the udev rules in place you can run as your own user — no `sudo`. Data is persisted under the VFS root, which defaults to the XDG data dir; pass `--fsdir` to choose another location: @@ -167,7 +176,7 @@ sg meshcore -c 'meshcored --fsdir /var/lib/meshcore' sudo install -m 644 variants/linux/meshcored.service /etc/systemd/system/ sudo install -m 644 variants/linux/99-meshcore.rules /etc/udev/rules.d/ sudo udevadm control --reload-rules && sudo udevadm trigger -sudo useradd -r -s /sbin/nologin meshcore +sudo useradd -r -g meshcore -s /sbin/nologin meshcore # -g: reuse the existing meshcore group (its udev rules grant device access) sudo chmod 640 /etc/meshcored/meshcored.ini sudo chown root:meshcore /etc/meshcored/meshcored.ini sudo systemctl daemon-reload @@ -175,10 +184,10 @@ sudo systemctl enable --now meshcored sudo journalctl -u meshcored -f ``` -> The unit's `ExecStartPre` creates and chowns `/var/lib/meshcore`, so you don't -> need to pre-create it. If you smoke-tested by running directly first, clear any -> stale state so the service first-boots with the INI defaults: -> `sudo rm -rf /var/lib/meshcore/*` +> The unit's `StateDirectory=meshcore` makes systemd create `/var/lib/meshcore` +> owned by `meshcore:meshcore` before startup, so you don't need to pre-create it. +> If you smoke-tested by running directly first, clear any stale state so the +> service first-boots with the INI defaults: `sudo rm -rf /var/lib/meshcore/*` ### 5. Reconfiguring after first run @@ -191,19 +200,31 @@ set lat set lon ``` -To reset all node prefs and re-apply the INI file defaults, delete the saved prefs and restart: +There are two levels of reset: + +**Prefs only** — keeps the node identity (same Repeater ID). Delete the saved prefs so the INI first-run defaults are re-applied on the next boot: ```sh sudo rm /var/lib/meshcore/com_prefs sudo systemctl restart meshcored ``` +**Full reset** — also discards the identity, so the node returns with a **new** Repeater ID. This wipes the whole VFS root. The built-in `-e`/`--erase` flag does exactly that before starting, but for the managed service just clear the directory while it is stopped (keep `--erase` out of the unit — see the note below): + +```sh +sudo systemctl stop meshcored +sudo rm -rf /var/lib/meshcore/* +sudo systemctl start meshcored +``` + +> When running **directly** (not under systemd), `meshcored --fsdir /var/lib/meshcore --erase` is the equivalent one-shot full reset. Do **not** add `--erase` to the service unit: systemd re-runs `ExecStart` on every restart, so it would wipe the filesystem and regenerate the identity each time. (The firmware's own `reboot()` strips `--erase` to avoid self-wiping, but that protection does not extend to a systemd restart.) + > **Note:** LoRa radio parameters (`lora_freq`, `lora_bw`, `lora_sf`, `lora_cr`, `lora_tx_power`) are also first-run defaults. After first boot they are saved in `com_prefs` and the INI values are no longer read for those fields. To apply a changed radio parameter, use the CLI (`set freq`, `set sf`, etc.) or reset prefs as above. ## Known Gaps / TODO - **Config path is hardcoded** — meshcored always loads `/etc/meshcored/meshcored.ini`; there is no flag to point it elsewhere. (The data *path* is separate and configurable: it is the ArduLinux VFS root, set with `--fsdir`.) - **Only repeater firmware** — there is no `linux_companion` target yet; companion radio support (BLE/serial interface to a phone app) is not implemented for Linux. -- **`formatFileSystem()`** returns `false` (not implemented) — the CLI `format` command will report failure on Linux. +- **Serial `erase` command is a no-op** — `formatFileSystem()` returns `false` on Linux, so the interactive serial `erase` command reports failure. To wipe the filesystem, use the `--erase` *startup* flag (or clear the VFS dir) instead — see step 5. - **No power management** — `board.sleep()` is a no-op; the power-saving loop in `main.cpp` never actually sleeps. - **Upstream-sync fragility** — the radio wrapper (`LinuxSX1262Wrapper`) implements the `RadioLibWrapper` interface by hand, so it can drift from upstream in two ways: a new **pure-virtual** method breaks the Linux build (e.g. `setParams()`), and a new **virtual-with-default** method silently no-ops on Linux until overridden (e.g. `set`/`getRxBoostedGainMode()`, which reported and applied the wrong state until added). Mirror `CustomSX1262Wrapper` when syncing. diff --git a/variants/linux/meshcored.service b/variants/linux/meshcored.service index b68ff2a62c..d95be0ba4c 100644 --- a/variants/linux/meshcored.service +++ b/variants/linux/meshcored.service @@ -19,12 +19,13 @@ ProtectSystem=strict ProtectHome=yes PrivateTmp=yes NoNewPrivileges=yes -# Allow writing only to its own data dir -ReadWritePaths=/var/lib/meshcore -# Create data dir with correct ownership if it doesn't exist -ExecStartPre=/bin/mkdir -p /var/lib/meshcore -ExecStartPre=/bin/chown meshcore:meshcore /var/lib/meshcore +# systemd creates /var/lib/meshcore owned by User:Group before the namespace is +# set up, and makes it the unit's only writable path. This must come from +# StateDirectory rather than ExecStartPre+ReadWritePaths: with ProtectSystem=strict +# the read-write path has to exist when the mount namespace is built, which happens +# before ExecStartPre runs (a mkdir there fails with status=226/NAMESPACE). +StateDirectory=meshcore [Install] WantedBy=multi-user.target From e8fc39533468025f8704432d4b737c725d743035 Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Fri, 5 Jun 2026 22:39:07 +0200 Subject: [PATCH 09/16] variants/linux: use linux_base for platformio (#10) * variants/linux: use linux_base for platformio * variants/linux: use linux_base for platformio --- examples/simple_repeater/MyMesh.cpp | 7 ++-- examples/simple_repeater/main.cpp | 5 +-- platformio.ini | 49 +++++++++++++++++++++++++ variants/ardulinux/platformio.ini | 37 ------------------- variants/linux/99-meshcore.rules | 5 +-- variants/linux/README.md | 34 ++++++++--------- variants/linux/meshcored.ini | 2 - variants/linux/meshcored.ini.pow-sx1262 | 10 ++--- variants/linux/meshcored.ini.waveshare | 9 ++--- variants/linux/meshcored.service | 8 ---- variants/linux/platformio.ini | 22 ----------- variants/linux/target.cpp | 5 --- 12 files changed, 81 insertions(+), 112 deletions(-) delete mode 100644 variants/ardulinux/platformio.ini diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 89c31c0958..ab568a22e7 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -945,8 +945,8 @@ void MyMesh::begin(FILESYSTEM *fs) { mesh::Mesh::begin(); _fs = fs; #if defined(ARDULINUX_PLATFORM) - // Apply runtime INI config as first-run defaults before loading persisted prefs. - // If /com_prefs exists, loadPrefs() below will overwrite these with the saved values. + // apply runtime INI config as first-run defaults before loading persisted prefs + // if /com_prefs exists, loadPrefs() below will overwrite these with the saved values StrHelper::strncpy(_prefs.node_name, board.config.advert_name, sizeof(_prefs.node_name)); _prefs.node_lat = board.config.lat; _prefs.node_lon = board.config.lon; @@ -1038,7 +1038,8 @@ bool MyMesh::formatFileSystem() { #elif defined(ESP32) return SPIFFS.format(); #elif defined(ARDULINUX_PLATFORM) - return false; // not supported on Linux + // not supported on linux + return false; #else #error "need to implement file system erase" return false; diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index a1520dca0f..2484ca9ba5 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -82,9 +82,8 @@ void setup() { IdentityStore store(LittleFS, "/identity"); store.begin(); #elif defined(ARDULINUX_PLATFORM) - // The VFS root is established by the ArduLinux core from --fsdir (default: the - // XDG data dir, e.g. ~/.local/share/meshcored/default), so there is no per-app - // mountpoint override here — --fsdir is the single source of truth for the data path. + // the VFS root is established by the ArduLinux core from --fsdir + // (default: the XDG data dir, e.g. ~/.local/share/meshcored/default) fs = &ArduLinuxFS; IdentityStore store(ArduLinuxFS, "/identity"); store.begin(); diff --git a/platformio.ini b/platformio.ini index 2219c97862..8458f9d52d 100644 --- a/platformio.ini +++ b/platformio.ini @@ -122,6 +122,55 @@ lib_deps = ${arduino_base.lib_deps} file://arch/stm32/Adafruit_LittleFS_stm32 SubGhz +; ----------------- LINUX --------------------- + +[linux_base] +platform = + # renovate: datasource=git-tags depName=ardulinux packageName=https://github.com/l5yth/ardulinux + git+https://github.com/l5yth/ardulinux.git#v0.2.0 +framework = arduino +board = linux +board_level = extra +board_build.progname = meshcored +build_src_filter = + ${env.build_src_filter} + - + - + - + - + - + - + - + - + - + +<../variants/linux> + - + - + - + - + - +lib_deps = + ${env.lib_deps} + rweather/Crypto@0.4.0 + adafruit/Adafruit seesaw Library@1.7.9 + electroniccats/CayenneLPP @ 1.6.1 + adafruit/RTClib @ ^2.1.3 + jgromes/RadioLib@7.4.0 + melopero/Melopero RV3028@^1.1.0 +build_flags = + ${arduino_base.build_flags} + -DARDULINUX_PLATFORM + -DRADIOLIB_EEPROM_UNSUPPORTED + -fPIC + -lpthread + -lstdc++fs + -lbluetooth + -luv + -std=gnu17 + -std=c++17 + -I variants/linux + -I /usr/include + [sensor_base] build_flags = -D ENV_INCLUDE_GPS=1 diff --git a/variants/ardulinux/platformio.ini b/variants/ardulinux/platformio.ini deleted file mode 100644 index b11f85c015..0000000000 --- a/variants/ardulinux/platformio.ini +++ /dev/null @@ -1,37 +0,0 @@ -[ardulinux_base] -platform = - # renovate: datasource=git-tags depName=ardulinux packageName=https://github.com/l5yth/ardulinux - git+https://github.com/l5yth/ardulinux.git#v0.2.0 -framework = arduino - -build_src_filter = - ${env.build_src_filter} - - - - - - - - - - - - - - - - - - - -lib_deps = - ${env.lib_deps} - rweather/Crypto@0.4.0 - adafruit/Adafruit seesaw Library@1.7.9 - electroniccats/CayenneLPP @ 1.6.1 - adafruit/RTClib @ ^2.1.3 - jgromes/RadioLib@7.4.0 - -build_flags = - ${arduino_base.build_flags} - -DARDULINUX_PLATFORM - -DRADIOLIB_EEPROM_UNSUPPORTED - -fPIC - -lpthread - -lstdc++fs - -lbluetooth - -luv - -std=gnu17 - -std=c++17 diff --git a/variants/linux/99-meshcore.rules b/variants/linux/99-meshcore.rules index 4c3df92e47..4071c847f6 100644 --- a/variants/linux/99-meshcore.rules +++ b/variants/linux/99-meshcore.rules @@ -1,6 +1,5 @@ -# udev rules for meshcored — grant the meshcore group access to SPI and GPIO devices. -# Works on Arch Linux, Debian/Raspberry Pi OS, and other distributions. -# Install: sudo cp 99-meshcore.rules /etc/udev/rules.d/ +# udev rules for meshcored +# Grant the meshcore group access to SPI and GPIO devices. SUBSYSTEM=="spidev", GROUP="meshcore", MODE="0660" KERNEL=="gpiochip*", GROUP="meshcore", MODE="0660" diff --git a/variants/linux/README.md b/variants/linux/README.md index c949b1708b..c47b8eee15 100644 --- a/variants/linux/README.md +++ b/variants/linux/README.md @@ -1,6 +1,6 @@ # MeshCore Linux Variant -Native Linux support for MeshCore, targeting Raspberry Pi (Zero, 3, 4, 5) and similar SBCs with an SX1262 LoRa radio attached over SPI. Uses [ArduLinux — Arduino API for Linux](https://github.com/l5yth/ardulinux) to run the same firmware codebase on Linux without modification to the core library. +Native Linux support for MeshCore, targeting Raspberry Pi (Zero, 3, 4, 5) and similar SBCs with an SX1262 LoRa radio attached over SPI. Uses [ArduLinux, Arduino API for Linux](https://github.com/l5yth/ardulinux) to run the same firmware codebase on Linux without modification to the core library. ## Hardware @@ -36,7 +36,7 @@ sudo pacman -S platformio-core # or: pipx install platformio pipx install platformio # or: pip install --user platformio ``` -**Build with `build.sh`** (recommended — embeds version and commit hash): +**Build with `build.sh`** (recommended, embeds version and commit hash): ```sh FIRMWARE_VERSION=dev ./build.sh build-firmware linux_repeater @@ -75,7 +75,7 @@ sudo nano /etc/meshcored/meshcored.ini The config file has two roles: - **Hardware config** (always read on every startup): SPI device, GPIO pin numbers, LoRa radio parameters. -- **First-run node defaults**: `advert_name`, `admin_password`, `lat`, `lon`. On the first boot these are saved to the node's persisted prefs (`com_prefs`). After that, use the serial CLI to change them (`set name`, `set password`, etc.) — the INI values are no longer consulted for these fields. +- **First-run node defaults**: `advert_name`, `admin_password`, `lat`, `lon`. On the first boot these are saved to the node's persisted prefs (`com_prefs`). After that, use the serial CLI to change them (`set name`, `set password`, etc.), the INI values are no longer consulted for these fields. Key settings: @@ -97,13 +97,13 @@ Key settings: | `current_limit` | `140` | Radio over-current protection limit in mA | | `dio2_as_rf_switch` | `0` | `1` = use DIO2 to drive the TX/RX RF switch. **Required for the Waveshare Core1262** (without it the radio inits but TX/RX are dead); depends on module wiring | | `rx_boosted_gain` | `1` | `1` enables the SX126x RX boosted-gain mode; `0` disables | -| `advert_name` | `"Linux Repeater"` | Node name — first-run default only | -| `admin_password` | `"password"` | Admin password — **change this**, first-run default only | -| `lat` / `lon` | `0.0` | GPS coordinates for advertisement — first-run default only | +| `advert_name` | `"Linux Repeater"` | Node name, first-run default only | +| `admin_password` | `"password"` | Admin password, **change this**, first-run default only | +| `lat` / `lon` | `0.0` | GPS coordinates for advertisement, first-run default only | ### 3. Enable SPI and GPIO access -First make sure the SPI interface is actually enabled — the radio needs a +First make sure the SPI interface is actually enabled, the radio needs a `/dev/spidev*` node. Check with `ls /dev/spidev*`; if there is none: ```sh @@ -114,13 +114,13 @@ sudo raspi-config # Interface Options → SPI → Enable, then reboot echo 'dtparam=spi=on' | sudo tee -a /boot/config.txt # then reboot ``` -> The boot config path varies by image — it is `/boot/config.txt` on most +> The boot config path varies by image, it is `/boot/config.txt` on most > Raspberry Pi images but `/boot/firmware/config.txt` on some. After rebooting, > confirm `/dev/spidev0.0` exists. > > **Arch Linux kernel caveat:** `dtparam=spi=on` is only honored by the Raspberry > Pi `linux-rpi` (vendor) kernel. The mainline `linux-aarch64` kernel boots via -> U-Boot, which loads its own device tree and ignores `config.txt` overlays — so +> U-Boot, which loads its own device tree and ignores `config.txt` overlays, so > `/dev/spidev*` never appears regardless of `config.txt`. If SPI is missing after > enabling it and rebooting, switch to the vendor kernel > (`sudo pacman -S linux-rpi`, remove `linux-aarch64`) and reboot. @@ -159,7 +159,7 @@ back in. To use it immediately in one shell, prefix the command with | `-V`, `--version` | Print the firmware version | **Directly** (for testing). With the udev rules in place you can run as your own -user — no `sudo`. Data is persisted under the VFS root, which defaults to the XDG +user, no `sudo`. Data is persisted under the VFS root, which defaults to the XDG data dir; pass `--fsdir` to choose another location: ```sh @@ -202,14 +202,14 @@ set lon There are two levels of reset: -**Prefs only** — keeps the node identity (same Repeater ID). Delete the saved prefs so the INI first-run defaults are re-applied on the next boot: +**Prefs only**, keeps the node identity (same Repeater ID). Delete the saved prefs so the INI first-run defaults are re-applied on the next boot: ```sh sudo rm /var/lib/meshcore/com_prefs sudo systemctl restart meshcored ``` -**Full reset** — also discards the identity, so the node returns with a **new** Repeater ID. This wipes the whole VFS root. The built-in `-e`/`--erase` flag does exactly that before starting, but for the managed service just clear the directory while it is stopped (keep `--erase` out of the unit — see the note below): +**Full reset**, also discards the identity, so the node returns with a **new** Repeater ID. This wipes the whole VFS root. The built-in `-e`/`--erase` flag does exactly that before starting, but for the managed service just clear the directory while it is stopped (keep `--erase` out of the unit, see the note below): ```sh sudo systemctl stop meshcored @@ -223,8 +223,8 @@ sudo systemctl start meshcored ## Known Gaps / TODO -- **Config path is hardcoded** — meshcored always loads `/etc/meshcored/meshcored.ini`; there is no flag to point it elsewhere. (The data *path* is separate and configurable: it is the ArduLinux VFS root, set with `--fsdir`.) -- **Only repeater firmware** — there is no `linux_companion` target yet; companion radio support (BLE/serial interface to a phone app) is not implemented for Linux. -- **Serial `erase` command is a no-op** — `formatFileSystem()` returns `false` on Linux, so the interactive serial `erase` command reports failure. To wipe the filesystem, use the `--erase` *startup* flag (or clear the VFS dir) instead — see step 5. -- **No power management** — `board.sleep()` is a no-op; the power-saving loop in `main.cpp` never actually sleeps. -- **Upstream-sync fragility** — the radio wrapper (`LinuxSX1262Wrapper`) implements the `RadioLibWrapper` interface by hand, so it can drift from upstream in two ways: a new **pure-virtual** method breaks the Linux build (e.g. `setParams()`), and a new **virtual-with-default** method silently no-ops on Linux until overridden (e.g. `set`/`getRxBoostedGainMode()`, which reported and applied the wrong state until added). Mirror `CustomSX1262Wrapper` when syncing. +- **Config path is hardcoded**, meshcored always loads `/etc/meshcored/meshcored.ini`; there is no flag to point it elsewhere. (The data *path* is separate and configurable: it is the ArduLinux VFS root, set with `--fsdir`.) +- **Only repeater firmware**, there is no `linux_companion` target yet; companion radio support (BLE/serial interface to a phone app) is not implemented for Linux. +- **Serial `erase` command is a no-op**, `formatFileSystem()` returns `false` on Linux, so the interactive serial `erase` command reports failure. To wipe the filesystem, use the `--erase` *startup* flag (or clear the VFS dir) instead, see step 5. +- **No power management**, `board.sleep()` is a no-op; the power-saving loop in `main.cpp` never actually sleeps. +- **Upstream-sync fragility**, the radio wrapper (`LinuxSX1262Wrapper`) implements the `RadioLibWrapper` interface by hand, so it can drift from upstream in two ways: a new **pure-virtual** method breaks the Linux build (e.g. `setParams()`), and a new **virtual-with-default** method silently no-ops on Linux until overridden (e.g. `set`/`getRxBoostedGainMode()`, which reported and applied the wrong state until added). Mirror `CustomSX1262Wrapper` when syncing. diff --git a/variants/linux/meshcored.ini b/variants/linux/meshcored.ini index 794ffc490b..76a7cf6b01 100644 --- a/variants/linux/meshcored.ini +++ b/variants/linux/meshcored.ini @@ -2,8 +2,6 @@ advert_name = Sample Router admin_password = password lat = 0.0 lon = 0.0 -# Data path: set via the --fsdir CLI flag (the systemd unit uses -# /var/lib/meshcore), not in this file. # Waveshare LoRa hat #lora_irq_pin = 16 diff --git a/variants/linux/meshcored.ini.pow-sx1262 b/variants/linux/meshcored.ini.pow-sx1262 index 024c5d77cf..8efb7ce2cf 100644 --- a/variants/linux/meshcored.ini.pow-sx1262 +++ b/variants/linux/meshcored.ini.pow-sx1262 @@ -1,12 +1,12 @@ -# meshcored.ini — PoW SX1262 HAT on Raspberry Pi Zero 2W +# meshcored.ini - PoW SX1262 HAT on Raspberry Pi Zero 2W # GPIO numbering is BCM (the number after "GPIO", e.g. GPIO22 = 22). -# + # Hardware config (read on every startup): spidev = /dev/spidev0.0 lora_irq_pin = 22 lora_reset_pin = 13 -# lora_nss_pin — SS is handled by the SPI driver; not needed -# lora_busy_pin — not wired on this HAT +# lora_nss_pin - SS is handled by the SPI driver; not needed +# lora_busy_pin - not wired on this HAT lora_freq = 869.618 lora_bw = 62.5 lora_sf = 8 @@ -20,5 +20,3 @@ admin_password = changeme lat = 0.0 lon = 0.0 -# Data is persisted under the VFS root set by the --fsdir CLI flag (default -# ~/.local/share/meshcored/default; the systemd unit uses /var/lib/meshcore). diff --git a/variants/linux/meshcored.ini.waveshare b/variants/linux/meshcored.ini.waveshare index 5aeb28e1d7..54139c8b35 100644 --- a/variants/linux/meshcored.ini.waveshare +++ b/variants/linux/meshcored.ini.waveshare @@ -1,6 +1,6 @@ -# meshcored.ini — Waveshare SX1262 LoRa HAT on Raspberry Pi 3/4/5 +# meshcored.ini - Waveshare SX1262 LoRa HAT on Raspberry Pi 3/4/5 # GPIO numbering is BCM (the number after "GPIO", e.g. GPIO16 = 16). -# + # Hardware config (read on every startup): spidev = /dev/spidev0.0 lora_irq_pin = 16 @@ -13,8 +13,7 @@ lora_sf = 8 lora_cr = 8 lora_tcxo = 1.8 lora_tx_power = 22 -# DIO2 drives the TX/RX RF switch on the Waveshare Core1262 — required for this HAT -# (without it the radio inits fine but TX/RX stay dead). 1 = enabled, 0 = disabled. +# DIO2 drives the TX/RX RF switch on the Waveshare Core1262 dio2_as_rf_switch = 1 # First-run node defaults (ignored after first boot; use CLI to change): @@ -23,5 +22,3 @@ admin_password = changeme lat = 0.0 lon = 0.0 -# Data is persisted under the VFS root set by the --fsdir CLI flag (default -# ~/.local/share/meshcored/default; the systemd unit uses /var/lib/meshcore). diff --git a/variants/linux/meshcored.service b/variants/linux/meshcored.service index d95be0ba4c..72d42f0a76 100644 --- a/variants/linux/meshcored.service +++ b/variants/linux/meshcored.service @@ -13,18 +13,10 @@ WorkingDirectory=/var/lib/meshcore Restart=on-failure RestartSec=5 LimitNOFILE=65535 - -# Security hardening ProtectSystem=strict ProtectHome=yes PrivateTmp=yes NoNewPrivileges=yes - -# systemd creates /var/lib/meshcore owned by User:Group before the namespace is -# set up, and makes it the unit's only writable path. This must come from -# StateDirectory rather than ExecStartPre+ReadWritePaths: with ProtectSystem=strict -# the read-write path has to exist when the mount namespace is built, which happens -# before ExecStartPre runs (a mkdir there fails with status=226/NAMESPACE). StateDirectory=meshcore [Install] diff --git a/variants/linux/platformio.ini b/variants/linux/platformio.ini index c8634b5e4b..8f42ce46b1 100644 --- a/variants/linux/platformio.ini +++ b/variants/linux/platformio.ini @@ -1,27 +1,5 @@ -[linux_base] -extends = ardulinux_base -build_flags = ${ardulinux_base.build_flags} - -I variants/linux - -I /usr/include -board = linux -board_level = extra -board_build.progname = meshcored -lib_deps = - ${ardulinux_base.lib_deps} - melopero/Melopero RV3028@^1.1.0 - -build_src_filter = ${ardulinux_base.build_src_filter} - +<../variants/linux> - - - - - - - - - - - [env:linux] extends = linux_base -; The pkg-config commands below optionally add link flags. -; the || : is just a "or run the null command" to avoid returning an error code build_flags = ${linux_base.build_flags} !pkg-config --cflags --libs libbsd-overlay --silence-errors || : diff --git a/variants/linux/target.cpp b/variants/linux/target.cpp index a69218e2ee..41609fc32e 100644 --- a/variants/linux/target.cpp +++ b/variants/linux/target.cpp @@ -5,11 +5,6 @@ class ArduLinuxHal : public ArduinoHal { public: ArduLinuxHal(SPIClass &spi, SPISettings spiSettings) : ArduinoHal(spi, spiSettings){}; - - // ArduLinux's SPIClass exposes only an in-place transfer(buf, len) that - // overwrites buf with the received bytes. RadioLib's HAL expects a - // full-duplex transfer(out, len, in), so copy out -> in first, then run the - // in-place exchange (out and in are distinct, non-overlapping buffers). void spiTransfer(uint8_t *out, size_t len, uint8_t *in) { memcpy(in, out, len); spi->transfer(in, len); From 39141e0d200e0ab9fdf436c7d722f88cca609cb8 Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Mon, 8 Jun 2026 21:55:49 +0200 Subject: [PATCH 10/16] pin ardulinux to wire-fix branch, document pkg-config (#12) * pin ardulinux to wire-fix branch, document pkg-config * pin ardulinux 0.2.1 --- platformio.ini | 2 +- variants/linux/README.md | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/platformio.ini b/platformio.ini index 8458f9d52d..d72492cd7f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -127,7 +127,7 @@ lib_deps = ${arduino_base.lib_deps} [linux_base] platform = # renovate: datasource=git-tags depName=ardulinux packageName=https://github.com/l5yth/ardulinux - git+https://github.com/l5yth/ardulinux.git#v0.2.0 + git+https://github.com/l5yth/ardulinux.git#v0.2.1 framework = arduino board = linux board_level = extra diff --git a/variants/linux/README.md b/variants/linux/README.md index c47b8eee15..33ba96c45a 100644 --- a/variants/linux/README.md +++ b/variants/linux/README.md @@ -14,17 +14,20 @@ Native Linux support for MeshCore, targeting Raspberry Pi (Zero, 3, 4, 5) and si ```sh # Arch Linux -sudo pacman -S libgpiod i2c-tools bluez-libs libuv +sudo pacman -S pkgconf libgpiod i2c-tools bluez-libs libuv # Debian/Raspberry Pi OS -sudo apt install libgpiod-dev libi2c-dev libbluetooth-dev libuv1-dev +sudo apt install pkg-config libgpiod-dev libi2c-dev libbluetooth-dev libuv1-dev ``` +> On DietPi the base image does not include `pkg-config`; without it the build +> silently falls back to simulated GPIO/I2C even when `libgpiod-dev` is +> installed, and the resulting `meshcored` won't talk to the radio. + The ArduLinux platform always links `bluetooth`, `uv`, `pthread`, and `stdc++fs`; `gpiod`/`i2c` are added automatically when libgpiod is detected via -`pkg-config` (without it the build falls back to simulated GPIO/I2C). Missing -`bluez-libs`/`libbluetooth-dev` shows up as a `cannot find -lbluetooth` link -error. +`pkg-config`. Missing `bluez-libs`/`libbluetooth-dev` shows up as a `cannot +find -lbluetooth` link error. You also need **PlatformIO Core** (`pio`) to build: From 9070b0c20e8123fa2076d8dda412074a40f5da76 Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Tue, 9 Jun 2026 12:22:10 +0200 Subject: [PATCH 11/16] variants/linux: fail loud when libgpiod is missing or pin claim fails (#14) * variants/linux: fail loud when libgpiod is missing or pin claim fails * variants/linux: exit when configured GPIO pins fail to bind --- variants/linux/LinuxBoard.cpp | 37 ++++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/variants/linux/LinuxBoard.cpp b/variants/linux/LinuxBoard.cpp index 9e9c79de48..24683c3b1d 100644 --- a/variants/linux/LinuxBoard.cpp +++ b/variants/linux/LinuxBoard.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #ifdef ARDULINUX_HARDWARE #include "linux/gpio/LinuxGPIOPin.h" #endif @@ -24,8 +25,13 @@ int initGPIOPin(uint8_t pinNum, const std::string gpioChipName, uint8_t line) csPin->setSilent(); gpioBind(csPin); return 0; + } catch (const std::exception& e) { + printf("ERROR: cannot claim GPIO line %d on %s for pin %d: %s\n", + (int)line, gpioChipName.c_str(), (int)pinNum, e.what()); + return 1; } catch (...) { - MESH_DEBUG_PRINTLN("Warning, cannot claim pin %d", pinNum); + printf("ERROR: cannot claim GPIO line %d on %s for pin %d (unknown exception)\n", + (int)line, gpioChipName.c_str(), (int)pinNum); return 1; } #else @@ -37,6 +43,17 @@ void ardulinuxSetup() { } void LinuxBoard::begin() { +#ifndef ARDULINUX_HARDWARE + printf("FATAL: meshcored was built without libgpiod support; all GPIO/I2C\n" + " operations would be simulated and the radio cannot be driven.\n" + " Install pkg-config and libgpiod-dev on the build machine, clear\n" + " the PlatformIO cache, and rebuild:\n" + " sudo apt install -y pkg-config libgpiod-dev\n" + " rm -rf ~/.platformio/platforms/ardulinux* .pio\n" + " pio run -e linux_repeater\n"); + exit(1); +#endif + config.load("/etc/meshcored/meshcored.ini"); printf("SPI begin %s\n", config.spidev); @@ -50,23 +67,29 @@ void LinuxBoard::begin() { (int)config.lora_rxen_pin, (int)config.lora_txen_pin); + int failures = 0; if (config.lora_nss_pin != RADIOLIB_NC) { - initGPIOPin(config.lora_nss_pin, "gpiochip0", config.lora_nss_pin); + failures += initGPIOPin(config.lora_nss_pin, "gpiochip0", config.lora_nss_pin); } if (config.lora_busy_pin != RADIOLIB_NC) { - initGPIOPin(config.lora_busy_pin, "gpiochip0", config.lora_busy_pin); + failures += initGPIOPin(config.lora_busy_pin, "gpiochip0", config.lora_busy_pin); } if (config.lora_irq_pin != RADIOLIB_NC) { - initGPIOPin(config.lora_irq_pin, "gpiochip0", config.lora_irq_pin); + failures += initGPIOPin(config.lora_irq_pin, "gpiochip0", config.lora_irq_pin); } if (config.lora_reset_pin != RADIOLIB_NC) { - initGPIOPin(config.lora_reset_pin, "gpiochip0", config.lora_reset_pin); + failures += initGPIOPin(config.lora_reset_pin, "gpiochip0", config.lora_reset_pin); } if (config.lora_rxen_pin != RADIOLIB_NC) { - initGPIOPin(config.lora_rxen_pin, "gpiochip0", config.lora_rxen_pin); + failures += initGPIOPin(config.lora_rxen_pin, "gpiochip0", config.lora_rxen_pin); } if (config.lora_txen_pin != RADIOLIB_NC) { - initGPIOPin(config.lora_txen_pin, "gpiochip0", config.lora_txen_pin); + failures += initGPIOPin(config.lora_txen_pin, "gpiochip0", config.lora_txen_pin); + } + + if (failures > 0) { + printf("FATAL: %d GPIO pin(s) failed to bind; cannot start radio.\n", failures); + exit(1); } } From bf47689af6a71c1282350c910781108b62048149 Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Tue, 9 Jun 2026 12:56:01 +0200 Subject: [PATCH 12/16] variants/linux: make lora_gpiochip configurable, refresh docs (#15) * variants/linux: make lora_gpiochip configurable, refresh docs * ci: variants/linux: add lora_gpiochip hint to base meshcored.ini too * bump ardulinux to 0.2.2 --- platformio.ini | 2 +- variants/linux/LinuxBoard.cpp | 13 +++++++------ variants/linux/LinuxBoard.h | 1 + variants/linux/README.md | 13 +++++++------ variants/linux/meshcored.ini | 1 + variants/linux/meshcored.ini.pow-sx1262 | 1 + variants/linux/meshcored.ini.waveshare | 1 + 7 files changed, 19 insertions(+), 13 deletions(-) diff --git a/platformio.ini b/platformio.ini index d72492cd7f..cf382db8de 100644 --- a/platformio.ini +++ b/platformio.ini @@ -127,7 +127,7 @@ lib_deps = ${arduino_base.lib_deps} [linux_base] platform = # renovate: datasource=git-tags depName=ardulinux packageName=https://github.com/l5yth/ardulinux - git+https://github.com/l5yth/ardulinux.git#v0.2.1 + git+https://github.com/l5yth/ardulinux.git#v0.2.2 framework = arduino board = linux board_level = extra diff --git a/variants/linux/LinuxBoard.cpp b/variants/linux/LinuxBoard.cpp index 24683c3b1d..c218a2f113 100644 --- a/variants/linux/LinuxBoard.cpp +++ b/variants/linux/LinuxBoard.cpp @@ -69,22 +69,22 @@ void LinuxBoard::begin() { int failures = 0; if (config.lora_nss_pin != RADIOLIB_NC) { - failures += initGPIOPin(config.lora_nss_pin, "gpiochip0", config.lora_nss_pin); + failures += initGPIOPin(config.lora_nss_pin, config.lora_gpiochip, config.lora_nss_pin); } if (config.lora_busy_pin != RADIOLIB_NC) { - failures += initGPIOPin(config.lora_busy_pin, "gpiochip0", config.lora_busy_pin); + failures += initGPIOPin(config.lora_busy_pin, config.lora_gpiochip, config.lora_busy_pin); } if (config.lora_irq_pin != RADIOLIB_NC) { - failures += initGPIOPin(config.lora_irq_pin, "gpiochip0", config.lora_irq_pin); + failures += initGPIOPin(config.lora_irq_pin, config.lora_gpiochip, config.lora_irq_pin); } if (config.lora_reset_pin != RADIOLIB_NC) { - failures += initGPIOPin(config.lora_reset_pin, "gpiochip0", config.lora_reset_pin); + failures += initGPIOPin(config.lora_reset_pin, config.lora_gpiochip, config.lora_reset_pin); } if (config.lora_rxen_pin != RADIOLIB_NC) { - failures += initGPIOPin(config.lora_rxen_pin, "gpiochip0", config.lora_rxen_pin); + failures += initGPIOPin(config.lora_rxen_pin, config.lora_gpiochip, config.lora_rxen_pin); } if (config.lora_txen_pin != RADIOLIB_NC) { - failures += initGPIOPin(config.lora_txen_pin, "gpiochip0", config.lora_txen_pin); + failures += initGPIOPin(config.lora_txen_pin, config.lora_gpiochip, config.lora_txen_pin); } if (failures > 0) { @@ -149,6 +149,7 @@ int LinuxConfig::load(const char *filename) { } if (strcmp(key, "spidev") == 0) spidev = safe_copy(value, 32); + else if (strcmp(key, "lora_gpiochip") == 0) lora_gpiochip = safe_copy(value, 32); else if (strcmp(key, "lora_freq") == 0) lora_freq = atof(value); else if (strcmp(key, "lora_bw") == 0) lora_bw = atof(value); else if (strcmp(key, "lora_sf") == 0) lora_sf = (uint8_t)atoi(value); diff --git a/variants/linux/LinuxBoard.h b/variants/linux/LinuxBoard.h index 0a18f85c16..f3044d7d98 100644 --- a/variants/linux/LinuxBoard.h +++ b/variants/linux/LinuxBoard.h @@ -31,6 +31,7 @@ class LinuxConfig { bool rx_boosted_gain = true; char* spidev = "/dev/spidev0.0"; + char* lora_gpiochip = "gpiochip0"; float lora_tcxo = 1.8f; diff --git a/variants/linux/README.md b/variants/linux/README.md index 33ba96c45a..581aedfca0 100644 --- a/variants/linux/README.md +++ b/variants/linux/README.md @@ -20,14 +20,14 @@ sudo pacman -S pkgconf libgpiod i2c-tools bluez-libs libuv sudo apt install pkg-config libgpiod-dev libi2c-dev libbluetooth-dev libuv1-dev ``` -> On DietPi the base image does not include `pkg-config`; without it the build -> silently falls back to simulated GPIO/I2C even when `libgpiod-dev` is -> installed, and the resulting `meshcored` won't talk to the radio. - The ArduLinux platform always links `bluetooth`, `uv`, `pthread`, and `stdc++fs`; `gpiod`/`i2c` are added automatically when libgpiod is detected via -`pkg-config`. Missing `bluez-libs`/`libbluetooth-dev` shows up as a `cannot -find -lbluetooth` link error. +`pkg-config`. If `pkg-config` is missing (e.g. on DietPi, which does not ship +it in the base image), libgpiod goes undetected and the build falls back to +simulated GPIO/I2C — the resulting `meshcored` will refuse to start with a +`FATAL: meshcored was built without libgpiod support` message pointing back at +the missing dep. Missing `bluez-libs`/`libbluetooth-dev` shows up at link time +as `cannot find -lbluetooth`. You also need **PlatformIO Core** (`pio`) to build: @@ -85,6 +85,7 @@ Key settings: | Key | Default | Notes | |-----|---------|-------| | `spidev` | `/dev/spidev0.0` | SPI device node | +| `lora_gpiochip` | `gpiochip0` | Name of the `/dev/gpiochip*` device (or kernel label). `gpiochip0` is correct for Pi 3/4/Zero 2W; Pi 5 may need `gpiochip4` or `pinctrl-rp1` depending on kernel | | `lora_irq_pin` | (none) | GPIO line number for IRQ | | `lora_reset_pin` | (none) | GPIO line number for RESET | | `lora_nss_pin` | (none) | GPIO line number for NSS/CS (if not handled by the SPI driver) | diff --git a/variants/linux/meshcored.ini b/variants/linux/meshcored.ini index 76a7cf6b01..d7b17d790c 100644 --- a/variants/linux/meshcored.ini +++ b/variants/linux/meshcored.ini @@ -17,6 +17,7 @@ lora_reset_pin = 13 #lora_txen_pin spidev = /dev/spidev0.0 +# lora_gpiochip = gpiochip0 # Pi 3/4/Zero 2W default; Pi 5 may need gpiochip4 or pinctrl-rp1 lora_freq = 869.618 lora_bw = 62.5 lora_sf = 8 diff --git a/variants/linux/meshcored.ini.pow-sx1262 b/variants/linux/meshcored.ini.pow-sx1262 index 8efb7ce2cf..96b5040a9f 100644 --- a/variants/linux/meshcored.ini.pow-sx1262 +++ b/variants/linux/meshcored.ini.pow-sx1262 @@ -3,6 +3,7 @@ # Hardware config (read on every startup): spidev = /dev/spidev0.0 +# lora_gpiochip = gpiochip0 # Pi 3/4/Zero 2W default; Pi 5 may need gpiochip4 or pinctrl-rp1 lora_irq_pin = 22 lora_reset_pin = 13 # lora_nss_pin - SS is handled by the SPI driver; not needed diff --git a/variants/linux/meshcored.ini.waveshare b/variants/linux/meshcored.ini.waveshare index 54139c8b35..45b03bf82f 100644 --- a/variants/linux/meshcored.ini.waveshare +++ b/variants/linux/meshcored.ini.waveshare @@ -3,6 +3,7 @@ # Hardware config (read on every startup): spidev = /dev/spidev0.0 +# lora_gpiochip = gpiochip0 # Pi 3/4/Zero 2W default; Pi 5 may need gpiochip4 or pinctrl-rp1 lora_irq_pin = 16 lora_reset_pin = 18 lora_nss_pin = 21 From 6a7f54d0e21a0a434c5df25982620e321c5594ad Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:23:47 +0200 Subject: [PATCH 13/16] variants/linux: adapt to upstream API changes, bump ardulinux to v0.2.3 (#22) --- platformio.ini | 3 ++- src/helpers/radiolib/LinuxSX1262Wrapper.h | 4 ++-- variants/linux/LinuxBoard.h | 6 ++++++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/platformio.ini b/platformio.ini index cf382db8de..b45cdcf404 100644 --- a/platformio.ini +++ b/platformio.ini @@ -127,7 +127,7 @@ lib_deps = ${arduino_base.lib_deps} [linux_base] platform = # renovate: datasource=git-tags depName=ardulinux packageName=https://github.com/l5yth/ardulinux - git+https://github.com/l5yth/ardulinux.git#v0.2.2 + git+https://github.com/l5yth/ardulinux.git#v0.2.3 framework = arduino board = linux board_level = extra @@ -147,6 +147,7 @@ build_src_filter = - - - + - - - lib_deps = diff --git a/src/helpers/radiolib/LinuxSX1262Wrapper.h b/src/helpers/radiolib/LinuxSX1262Wrapper.h index 17e168c70f..78c1e7cefb 100644 --- a/src/helpers/radiolib/LinuxSX1262Wrapper.h +++ b/src/helpers/radiolib/LinuxSX1262Wrapper.h @@ -30,8 +30,8 @@ class LinuxSX1262Wrapper : public RadioLibWrapper { } uint8_t getSpreadingFactor() const override { return ((LinuxSX1262 *)_radio)->spreadingFactor; } - void setRxBoostedGainMode(bool en) override { - ((LinuxSX1262 *)_radio)->setRxBoostedGainMode(en); + bool setRxBoostedGainMode(bool en) override { + return ((LinuxSX1262 *)_radio)->setRxBoostedGainMode(en) == RADIOLIB_ERR_NONE; } bool getRxBoostedGainMode() const override { return ((LinuxSX1262 *)_radio)->getRxBoostedGainMode(); diff --git a/variants/linux/LinuxBoard.h b/variants/linux/LinuxBoard.h index f3044d7d98..81b481400c 100644 --- a/variants/linux/LinuxBoard.h +++ b/variants/linux/LinuxBoard.h @@ -6,6 +6,7 @@ #include #include #include +#include class LinuxConfig { public: @@ -73,6 +74,11 @@ class LinuxBoard : public mesh::MainBoard { exit(0); } + // Upstream attaches variant-specific prefs to the 'custom' Json object; the + // linux target carries its runtime config in meshcored.ini instead, so this + // is a no-op, matching ESP32Board/NRF52Board/STM32Board. + void attachDynamicPrefs(KeyValueStore* prefs) { } + LinuxConfig config; }; From dc8702273c20b41846eab65a9b04d83b593c9414 Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:48:12 +0200 Subject: [PATCH 14/16] linux_base: track arduino_base's lib_deps instead of copying them (#23) --- platformio.ini | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/platformio.ini b/platformio.ini index b45cdcf404..a075929a48 100644 --- a/platformio.ini +++ b/platformio.ini @@ -151,13 +151,8 @@ build_src_filter = - - lib_deps = - ${env.lib_deps} - rweather/Crypto@0.4.0 + ${arduino_base.lib_deps} adafruit/Adafruit seesaw Library@1.7.9 - electroniccats/CayenneLPP @ 1.6.1 - adafruit/RTClib @ ^2.1.3 - jgromes/RadioLib@7.4.0 - melopero/Melopero RV3028@^1.1.0 build_flags = ${arduino_base.build_flags} -DARDULINUX_PLATFORM From 9a1474d95af7374b75df01e5e144a5eadb8514a2 Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:57:39 +0200 Subject: [PATCH 15/16] Implement LinuxBoard::sleep() to stop the main loop busy-spinning (#24) `mesh::MainBoard::sleep()` is a no-op by default and `LinuxBoard` never overrode it, so `board.sleep(0)`/`sleep(30)` returned immediately and the repeater loop spun a core at 100%. Only `NRF52Board` and `ESP32Board` implement it. Implement it with `sleep()`/`usleep()`, and add a `delay(1)` in the main loop's non-powersaving branch so platforms without power management do not spin either. Reported and fixed by brianhealey, measured at 99% -> 0-9% CPU on a Pi Compute Module 5 with an SX1262. Cherry-picked from l5yth/meshcore-linux#21, which could not be merged. (cherry picked from commit 11d6ddfc) Co-authored-by: brianhealey --- examples/simple_repeater/main.cpp | 3 +++ variants/linux/LinuxBoard.h | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 2484ca9ba5..a556062881 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -209,5 +209,8 @@ void loop() { board.sleep(30); // Sleep. Wake up after a while or when receiving a LoRa packet } #endif + } else { + // Small delay to prevent busy loop on platforms without power saving + delay(1); } } diff --git a/variants/linux/LinuxBoard.h b/variants/linux/LinuxBoard.h index 81b481400c..9aabb09156 100644 --- a/variants/linux/LinuxBoard.h +++ b/variants/linux/LinuxBoard.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -79,6 +80,14 @@ class LinuxBoard : public mesh::MainBoard { // is a no-op, matching ESP32Board/NRF52Board/STM32Board. void attachDynamicPrefs(KeyValueStore* prefs) { } + void sleep(uint32_t secs) override { + if (secs > 0) { + ::sleep(secs); + } else { + usleep(10000); // 10ms delay to prevent busy loop + } + } + LinuxConfig config; }; From da0e692545051114cdf71edcc0a3be9a28956e42 Mon Sep 17 00:00:00 2001 From: Robert Grizzell Date: Sun, 30 Aug 2026 21:48:12 -0500 Subject: [PATCH 16/16] variants/linux: local CLI console over a PTY (#25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The linux repeater's CLI reads Serial, but on ardulinux Serial is a stdout-only stub, so the CLI was unreachable — the node could only be configured over the mesh, with no way to fix a bad radio preset locally without a second node. Add a console on a pseudo-terminal (PTY), in MeshCore application code (not the ardulinux platform layer, so it survives a platform swap) with logs and the CLI on separate streams. A PTY rather than a socket so meshcore-cli's repeater mode can attach directly: meshcore-cli -r -s /run/meshcored/console (meshcore-cli -r drives a raw-text serial CLI via pyserial, which needs a tty.) - src/helpers/PtyConsole.{h,cpp}: pure-POSIX PTY engine (no Arduino dependency, so it is host-unit-testable). posix_openpt + a stable symlink to /dev/pts/N; raw termios; maps '\n'->'\r' (1:1) so tools that send newline work with the CR-terminated CLI. The pts device is chmod 0600 -- the unauthenticated local CLI's access gate. The master persists across client attach/detach, so there is no accept/reap and a write to a closed peer returns EIO (never SIGPIPE). - src/helpers/LinuxConsole.h: thin Arduino Stream adapter; write() mirrors to stdout (so commands/replies reach journald) and the PTY. - variants/linux: LinuxConfig.console_path INI key (default: a per-user path); systemd unit + README document /run/meshcored/console and meshcore-cli. User docs avoid the PTY/serial internals. - examples/simple_repeater: route the CLI loop to the console stream; logs (MESH_DEBUG) and the boot banner stay on Serial. MCU targets keep console == &Serial, so their behavior is unchanged. - platformio.ini [env:native]: define MESHCORE_HOST_TEST and add PtyConsole.cpp to build_src_filter. PtyConsole is compiled out without the macro and the suite does not link without the source, so both are required for CI to build test_console at all. - test/test_console: 18 googletest cases for the engine. Client I/O, newline mapping, peek, reconnect, write-after-close and the 0600 char-device perms; begin() idempotency; default link resolution via XDG_RUNTIME_DIR and the /tmp/meshcore- fallback; path() falling back to the pts device when the symlink cannot be published; available() accounting for a peeked byte alongside the queue; end() idempotency and post-close inertness; destructor cleanup. All three begin() failure paths are exercised: posix_openpt via RLIMIT_NOFILE, and grantpt/ptsname_r (unreachable once posix_openpt has succeeded) via strong definitions that forward to libc unless a test arms them. Refs #25 --- examples/simple_repeater/main.cpp | 32 +- platformio.ini | 2 + src/helpers/LinuxConsole.h | 45 +++ src/helpers/PtyConsole.cpp | 136 +++++++++ src/helpers/PtyConsole.h | 57 ++++ test/test_console/test_pty_console.cpp | 393 +++++++++++++++++++++++++ variants/linux/LinuxBoard.cpp | 1 + variants/linux/LinuxBoard.h | 5 + variants/linux/README.md | 37 ++- variants/linux/meshcored.ini | 7 + variants/linux/meshcored.service | 11 +- 11 files changed, 717 insertions(+), 9 deletions(-) create mode 100644 src/helpers/LinuxConsole.h create mode 100644 src/helpers/PtyConsole.cpp create mode 100644 src/helpers/PtyConsole.h create mode 100644 test/test_console/test_pty_console.cpp diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index a556062881..18eed9506c 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -18,6 +18,15 @@ SimpleMeshTables tables; MyMesh the_mesh(board, radio_driver, *new ArduinoMillis(), fast_rng, rtc_clock, tables); +// CLI console stream. On Linux the interactive CLI runs over a Unix-domain +// socket (so the socket carries only the CLI while Serial keeps the logs); on +// MCU targets, and as the Linux fallback, it is just Serial. +#if defined(ARDULINUX_PLATFORM) || defined(LINUX_PLATFORM) + #include + static LinuxConsole linux_console; +#endif +static Stream* console = &Serial; + void halt() { while (1) ; } @@ -45,6 +54,19 @@ void setup() { external_watchdog.begin(); #endif +#if defined(ARDULINUX_PLATFORM) || defined(LINUX_PLATFORM) + // Bring up the local CLI console (config path, or a per-user default). Log + // which console is active so a failure (e.g. an unwritable configured path) is + // diagnosable rather than a silent no-CLI daemon; on failure the CLI stays on + // Serial (stdin/stdout). + if (linux_console.begin(board.config.console_path)) { + console = &linux_console; + Serial.print("CLI console on "); Serial.println(linux_console.path()); + } else { + Serial.println("CLI console: unavailable (see stderr), using stdio"); + } +#endif + #if defined(MESH_DEBUG) && defined(NRF52_PLATFORM) // give some extra time for serial to settle so // boot debug messages can be seen on terminal @@ -131,12 +153,12 @@ void setup() { void loop() { // Handle Serial CLI int len = strlen(command); - while (Serial.available() && len < sizeof(command)-1) { - char c = Serial.read(); + while (console->available() && len < sizeof(command)-1) { + char c = console->read(); if (c != '\n') { command[len++] = c; command[len] = 0; - Serial.print(c); + console->print(c); } if (c == '\r') break; } @@ -145,7 +167,7 @@ void loop() { } if (len > 0 && command[len - 1] == '\r') { // received complete line - Serial.print('\n'); + console->print('\n'); command[len - 1] = 0; // replace newline with C string null terminator char reply[160]; reply[0] = 0; @@ -157,7 +179,7 @@ void loop() { the_mesh.handleCommand(0, command, reply); // NOTE: there is no sender_timestamp via serial! #endif if (reply[0]) { - Serial.print(" -> "); Serial.println(reply); + console->print(" -> "); console->println(reply); } command[0] = 0; // reset command buffer diff --git a/platformio.ini b/platformio.ini index a075929a48..ca2f9e6907 100644 --- a/platformio.ini +++ b/platformio.ini @@ -209,6 +209,7 @@ test_framework = googletest build_flags = -std=c++17 -I src -I test/mocks + -D MESHCORE_HOST_TEST test_build_src = yes test_ignore = test_kiss_modem build_src_filter = @@ -217,6 +218,7 @@ build_src_filter = +<../src/Packet.cpp> +<../src/helpers/ConfigSerializer.cpp> +<../src/helpers/DynamicConfigSerializer.cpp> + +<../src/helpers/PtyConsole.cpp> lib_deps = google/googletest @ 1.17.0 diff --git a/src/helpers/LinuxConsole.h b/src/helpers/LinuxConsole.h new file mode 100644 index 0000000000..e2977818bd --- /dev/null +++ b/src/helpers/LinuxConsole.h @@ -0,0 +1,45 @@ +#pragma once + +#if defined(ARDULINUX_PLATFORM) || defined(LINUX_PLATFORM) + +#include +#include +#include "PtyConsole.h" + +// Arduino Stream adapter over PtyConsole. +// +// The repeater's text CLI reads from / writes to this instead of Serial, so the +// PTY carries only the CLI while Serial (stdout) keeps the debug logs — the +// clean log/CLI split. write() also mirrors each byte to stdout, so admin +// commands and their replies are still recorded in the log (journald) alongside +// the debug output, while the console stays free of log noise. +// +// A client attaches to the published PTY symlink, e.g.: +// meshcore-cli -r -s /run/meshcored/console +// +// All the PTY mechanics live in PtyConsole (host-unit-tested); this adapter is a +// thin delegating shim. +class LinuxConsole : public Stream { + PtyConsole _pty; + +public: + // Open the PTY and publish the symlink (see PtyConsole::begin). Returns true + // on success; on failure the caller should fall back to Serial. + bool begin(const char *link) { return _pty.begin(link); } + void end() { _pty.end(); } + bool isOpen() const { return _pty.isOpen(); } + const char *path() const { return _pty.path(); } + + int available() override { return _pty.available(); } + int read() override { return _pty.read(); } + int peek() override { return _pty.peek(); } + size_t write(uint8_t c) override { + putchar(c); // mirror to stdout so commands/replies reach journald + _pty.write(c); // and to the attached console client + return 1; + } + void flush() override { fflush(stdout); _pty.flush(); } + using Print::write; // pull in write(str) / write(buf, size) +}; + +#endif // ARDULINUX_PLATFORM || LINUX_PLATFORM diff --git a/src/helpers/PtyConsole.cpp b/src/helpers/PtyConsole.cpp new file mode 100644 index 0000000000..2a3f813eb7 --- /dev/null +++ b/src/helpers/PtyConsole.cpp @@ -0,0 +1,136 @@ +#ifndef _GNU_SOURCE +#define _GNU_SOURCE // ptsname_r() +#endif + +#include "PtyConsole.h" + +#if defined(ARDULINUX_PLATFORM) || defined(LINUX_PLATFORM) || defined(MESHCORE_HOST_TEST) + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +// Resolve the symlink path to publish for the PTY slave. Non-empty `link` is +// used verbatim; otherwise $XDG_RUNTIME_DIR/meshcore/console, else +// /tmp/meshcore-/console, with the parent directory created mode 0700. +std::string resolveLinkPath(const char *link) { + if (link && *link) return std::string(link); + + const char *xdg = getenv("XDG_RUNTIME_DIR"); + std::string dir = (xdg && *xdg) + ? std::string(xdg) + "/meshcore" + : std::string("/tmp/meshcore-") + std::to_string((unsigned)getuid()); + mkdir(dir.c_str(), 0700); // best effort + return dir + "/console"; +} + +} // namespace + +PtyConsole::~PtyConsole() { end(); } + +bool PtyConsole::begin(const char *link) { + if (master_fd != -1) return true; // already open + + int fd = posix_openpt(O_RDWR | O_NOCTTY); + if (fd < 0) { + fprintf(stderr, "meshcore: console posix_openpt() failed: %s\n", strerror(errno)); + return false; + } + fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) | O_NONBLOCK); + + if (grantpt(fd) != 0 || unlockpt(fd) != 0) { + fprintf(stderr, "meshcore: console grantpt/unlockpt failed: %s\n", strerror(errno)); + close(fd); + return false; + } + + char buf[128]; + if (ptsname_r(fd, buf, sizeof(buf)) != 0) { + fprintf(stderr, "meshcore: console ptsname_r failed: %s\n", strerror(errno)); + close(fd); + return false; + } + pts_path = buf; + + // Raw line discipline: no echo / canonical / CR-NL translation, so bytes + // pass through unchanged (our read() does the '\n'->'\r' mapping itself). + struct termios t; + if (tcgetattr(fd, &t) == 0) { + cfmakeraw(&t); + tcsetattr(fd, TCSANOW, &t); + } + + // Owner-only: attaching to the console grants the privileged local CLI. + chmod(pts_path.c_str(), 0600); + + // Publish a stable symlink so clients have a fixed path across restarts + // (the /dev/pts/N number varies). If the symlink can't be made, clients can + // still use the raw pts path (path() falls back to it). + std::string lp = resolveLinkPath(link); + unlink(lp.c_str()); + if (symlink(pts_path.c_str(), lp.c_str()) == 0) { + link_path = lp; + } else { + fprintf(stderr, "meshcore: console symlink(%s) failed: %s; use %s\n", + lp.c_str(), strerror(errno), pts_path.c_str()); + } + + master_fd = fd; + return true; +} + +void PtyConsole::end() { + if (!link_path.empty()) { unlink(link_path.c_str()); link_path.clear(); } + if (master_fd != -1) { close(master_fd); master_fd = -1; } + pts_path.clear(); + peeked = -1; +} + +int PtyConsole::available() { + int n = (peeked >= 0) ? 1 : 0; + if (master_fd != -1) { + int q = 0; + if (ioctl(master_fd, FIONREAD, &q) == 0 && q > 0) n += q; + } + return n; +} + +int PtyConsole::peek() { + if (peeked < 0) peeked = read(); + return peeked; +} + +int PtyConsole::read() { + if (peeked >= 0) { int c = peeked; peeked = -1; return c; } + if (master_fd == -1) return -1; + unsigned char b; + ssize_t n = ::read(master_fd, &b, 1); + // Map '\n' -> '\r' (1:1) so line-oriented CLIs that terminate on '\r' work + // with tools that send '\n'. Kept 1:1 so available()/read() stay consistent + // (the repeater's read() is unchecked). + if (n == 1) return (b == '\n') ? '\r' : b; + // n == 0 (no slave open) or n < 0 (EAGAIN, or EIO after the client closed): + // no data right now. The master persists; a client can reattach. + return -1; +} + +size_t PtyConsole::write(uint8_t c) { + if (master_fd != -1) { + // A PTY master write with no reader just buffers (or EAGAIN/EIO under + // O_NONBLOCK) — no SIGPIPE — so unwritten console output is simply + // dropped, never fatal. + ssize_t r = ::write(master_fd, &c, 1); + (void)r; + } + return 1; +} + +#endif // ARDULINUX_PLATFORM || LINUX_PLATFORM || MESHCORE_HOST_TEST diff --git a/src/helpers/PtyConsole.h b/src/helpers/PtyConsole.h new file mode 100644 index 0000000000..b58c07b1c3 --- /dev/null +++ b/src/helpers/PtyConsole.h @@ -0,0 +1,57 @@ +#pragma once + +// MESHCORE_HOST_TEST enables this engine in the host unit-test env without +// claiming a real platform macro (LINUX_PLATFORM is used by the native-shim +// port). +#if defined(ARDULINUX_PLATFORM) || defined(LINUX_PLATFORM) || defined(MESHCORE_HOST_TEST) + +#include +#include +#include + +// A local console carried over a pseudo-terminal (PTY), for the Linux repeater's +// text CLI. +// +// meshcored opens a PTY master and publishes a stable symlink to the slave +// device (e.g. /run/meshcored/console -> /dev/pts/N) so a serial client can +// attach to it directly: meshcore-cli -r -s /run/meshcored/console +// (meshcore-cli's repeater mode drives a raw-text serial CLI via pyserial, which +// needs a tty — hence a PTY rather than a socket). +// +// Pure POSIX (no Arduino dependency) so the accept-free read/write/newline logic +// is unit-testable on the host; LinuxConsole wraps it in an Arduino Stream. +// +// The PTY master persists for the daemon's life; a client just opens/closes the +// slave, so there is no accept/reap. The slave device is chmod'd 0600 (the +// unauthenticated local CLI's access gate). +class PtyConsole { + int master_fd = -1; // PTY master; -1 when closed + int peeked = -1; // one-byte pushback for peek(); -1 when empty + std::string link_path; // published symlink to the slave (unlinked on end()) + std::string pts_path; // the slave device path (/dev/pts/N) + +public: + PtyConsole() = default; + ~PtyConsole(); + + // Open the PTY and publish a symlink at `link` (empty => a per-user default: + // $XDG_RUNTIME_DIR/meshcore/console, else /tmp/meshcore-/console). + // Returns true on success; on failure logs to stderr and returns false so + // the caller can fall back to the stdout console. + bool begin(const char* link); + void end(); + + int available(); // bytes available from the client (0 if none) + int peek(); // peek one byte without consuming (-1 if none) + int read(); // read one byte (-1 if none); maps '\n' -> '\r' + size_t write(uint8_t c); // write one byte to the client; -> 1 + void flush() {} + + bool isOpen() const { return master_fd != -1; } + // Path a client should open: the published symlink, else the raw pts device. + const char* path() const { + return link_path.empty() ? pts_path.c_str() : link_path.c_str(); + } +}; + +#endif // ARDULINUX_PLATFORM || LINUX_PLATFORM || MESHCORE_HOST_TEST diff --git a/test/test_console/test_pty_console.cpp b/test/test_console/test_pty_console.cpp new file mode 100644 index 0000000000..f8b2a08885 --- /dev/null +++ b/test/test_console/test_pty_console.cpp @@ -0,0 +1,393 @@ +// Unit tests for PtyConsole (src/helpers/PtyConsole.cpp). +// +// Wired into [env:native] via -D MESHCORE_HOST_TEST and the PtyConsole.cpp entry +// in build_src_filter; both are required, since the engine is compiled out +// without the macro and the suite would not link without the source. +// +// Run: pio test -e native -f test_console + +#include + +#include "helpers/PtyConsole.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +std::string unique_link(const char *tag) { + return std::string("/tmp/meshcore-test-pty-") + tag + "-" + + std::to_string(getpid()); +} + +// Open the PTY slave (via the console's published path) as a raw serial client. +int open_client(const char *path) { + int fd = ::open(path, O_RDWR | O_NOCTTY); + EXPECT_GE(fd, 0); + if (fd >= 0) { + termios t{}; + if (tcgetattr(fd, &t) == 0) { cfmakeraw(&t); tcsetattr(fd, TCSANOW, &t); } + } + return fd; +} + +int wait_available(PtyConsole &c, int want) { + int avail = 0; + for (int i = 0; i < 200 && avail < want; i++) { + avail = c.available(); + if (avail < want) usleep(1000); + } + return avail; +} + +// Read like the repeater's loop(): only read() while available() reports bytes. +std::string read_like_consumer(PtyConsole &c, char terminator) { + std::string out; + for (int i = 0; i < 400 && out.find(terminator) == std::string::npos; i++) { + while (c.available() > 0) { + int ch = c.read(); + if (ch < 0) break; + out += (char)ch; + } + if (out.find(terminator) == std::string::npos) usleep(1000); + } + return out; +} + +} // namespace + +TEST(PtyConsole, DeliversClientBytesToRead) { + PtyConsole c; + ASSERT_TRUE(c.begin(unique_link("read").c_str())); + int client = open_client(c.path()); + ASSERT_GE(client, 0); + ASSERT_EQ(::write(client, "hi", 2), 2); + + EXPECT_GE(wait_available(c, 2), 2); + EXPECT_EQ(c.read(), 'h'); + EXPECT_EQ(c.read(), 'i'); + + ::close(client); + c.end(); +} + +TEST(PtyConsole, MapsNewlineToCarriageReturn) { + // The CLI terminates a command on '\r'; tools send '\n'. Map 1:1 so it works. + PtyConsole c; + ASSERT_TRUE(c.begin(unique_link("nl").c_str())); + int client = open_client(c.path()); + ASSERT_GE(client, 0); + ASSERT_EQ(::write(client, "hi\n", 3), 3); + EXPECT_GE(wait_available(c, 3), 3); + + EXPECT_EQ(c.read(), 'h'); + EXPECT_EQ(c.read(), 'i'); + EXPECT_EQ(c.read(), '\r'); + + ::close(client); + c.end(); +} + +TEST(PtyConsole, WriteReachesConnectedClient) { + PtyConsole c; + ASSERT_TRUE(c.begin(unique_link("write").c_str())); + int client = open_client(c.path()); + ASSERT_GE(client, 0); + + c.write((uint8_t)'O'); + c.write((uint8_t)'K'); + + char buf[2] = {0, 0}; + ssize_t got = 0; + for (int i = 0; i < 200 && got < 2; i++) { + ssize_t n = ::read(client, buf + got, 2 - got); + if (n > 0) got += n; else usleep(1000); + } + EXPECT_EQ(got, 2); + EXPECT_EQ(buf[0], 'O'); + EXPECT_EQ(buf[1], 'K'); + + ::close(client); + c.end(); +} + +TEST(PtyConsole, PeekDoesNotConsume) { + PtyConsole c; + ASSERT_TRUE(c.begin(unique_link("peek").c_str())); + int client = open_client(c.path()); + ASSERT_GE(client, 0); + ASSERT_EQ(::write(client, "Z", 1), 1); + EXPECT_GE(wait_available(c, 1), 1); + + EXPECT_EQ(c.peek(), 'Z'); + EXPECT_EQ(c.peek(), 'Z'); + EXPECT_EQ(c.read(), 'Z'); + EXPECT_EQ(c.read(), -1); + + ::close(client); + c.end(); +} + +TEST(PtyConsole, NoInputBeforeClientWrites) { + PtyConsole c; + ASSERT_TRUE(c.begin(unique_link("noinput").c_str())); + + EXPECT_EQ(c.available(), 0); + EXPECT_EQ(c.read(), -1); + EXPECT_EQ(c.peek(), -1); + + c.end(); +} + +TEST(PtyConsole, ServesNewClientAfterCloseConsumerLoopShape) { + // The master persists across client open/close; the consumer only read()s + // when available()>0. + PtyConsole c; + ASSERT_TRUE(c.begin(unique_link("reconnect").c_str())); + + int c1 = open_client(c.path()); + ASSERT_GE(c1, 0); + ASSERT_EQ(::write(c1, "a\n", 2), 2); + EXPECT_EQ(read_like_consumer(c, '\r'), "a\r"); + ::close(c1); + + for (int i = 0; i < 20; i++) { c.available(); usleep(1000); } + + int c2 = open_client(c.path()); + ASSERT_GE(c2, 0); + ASSERT_EQ(::write(c2, "b\n", 2), 2); + EXPECT_EQ(read_like_consumer(c, '\r'), "b\r"); + + ::close(c2); + c.end(); +} + +TEST(PtyConsole, WriteAfterClientCloseDoesNotCrash) { + PtyConsole c; + ASSERT_TRUE(c.begin(unique_link("wclose").c_str())); + int client = open_client(c.path()); + ASSERT_GE(client, 0); + c.write((uint8_t)'x'); + ::close(client); + + usleep(20000); + for (int i = 0; i < 200; i++) c.write((uint8_t)'y'); + SUCCEED(); // no signal / crash + + c.end(); +} + +TEST(PtyConsole, SlaveIsOwnerOnlyCharDevAndSymlinkUnlinkedOnEnd) { + std::string link = unique_link("perms"); + PtyConsole c; + ASSERT_TRUE(c.begin(link.c_str())); + + struct stat st{}; + ASSERT_EQ(::stat(c.path(), &st), 0); // follows the symlink to the pts device + EXPECT_TRUE(S_ISCHR(st.st_mode)); + EXPECT_EQ(st.st_mode & 0777, 0600u); // owner-only: the access gate + + c.end(); + struct stat ls{}; + EXPECT_NE(::lstat(link.c_str(), &ls), 0); // published symlink removed +} + +// --- lifecycle, default link resolution, and degraded paths -------------------- + +TEST(PtyConsole, BeginIsIdempotent) { + // A second begin() on an open console is a no-op: the original PTY and + // published link stay put, and the new link argument is ignored. + PtyConsole c; + std::string link = unique_link("idem"); + std::string ignored = unique_link("idem-ignored"); + ASSERT_TRUE(c.begin(link.c_str())); + std::string first = c.path(); + + EXPECT_TRUE(c.begin(ignored.c_str())); + EXPECT_EQ(std::string(c.path()), first); + struct stat ls{}; + EXPECT_NE(::lstat(ignored.c_str(), &ls), 0); // second link never published + + c.end(); +} + +TEST(PtyConsole, DefaultLinkUsesXdgRuntimeDir) { + char dir[] = "/tmp/meshcore-test-xdg-XXXXXX"; + ASSERT_NE(mkdtemp(dir), nullptr); + const char *prev = getenv("XDG_RUNTIME_DIR"); + const bool had = prev != nullptr; + const std::string saved = had ? prev : ""; + setenv("XDG_RUNTIME_DIR", dir, 1); + + { + PtyConsole c; + ASSERT_TRUE(c.begin(nullptr)); // null link => default resolution + EXPECT_EQ(std::string(c.path()), std::string(dir) + "/meshcore/console"); + struct stat st{}; + EXPECT_EQ(::stat(c.path(), &st), 0); + EXPECT_TRUE(S_ISCHR(st.st_mode)); + c.end(); + } + + rmdir((std::string(dir) + "/meshcore").c_str()); + rmdir(dir); + if (had) setenv("XDG_RUNTIME_DIR", saved.c_str(), 1); else unsetenv("XDG_RUNTIME_DIR"); +} + +TEST(PtyConsole, DefaultLinkFallsBackToTmpWithoutXdgRuntimeDir) { + const char *prev = getenv("XDG_RUNTIME_DIR"); + const bool had = prev != nullptr; + const std::string saved = had ? prev : ""; + unsetenv("XDG_RUNTIME_DIR"); + + { + PtyConsole c; + ASSERT_TRUE(c.begin("")); // empty link => default resolution + EXPECT_EQ(std::string(c.path()), + "/tmp/meshcore-" + std::to_string((unsigned)getuid()) + "/console"); + c.end(); + } + + if (had) setenv("XDG_RUNTIME_DIR", saved.c_str(), 1); +} + +TEST(PtyConsole, PathFallsBackToPtsDeviceWhenSymlinkFails) { + // An unpublishable link is not fatal: begin() still succeeds and path() + // hands the client the raw pts device instead. + PtyConsole c; + ASSERT_TRUE(c.begin("/nonexistent-meshcore-dir/console")); + EXPECT_EQ(std::string(c.path()).rfind("/dev/pts/", 0), 0u); + struct stat st{}; + EXPECT_EQ(::stat(c.path(), &st), 0); + EXPECT_TRUE(S_ISCHR(st.st_mode)); + + c.end(); // no link_path to unlink + EXPECT_FALSE(c.isOpen()); +} + +TEST(PtyConsole, AvailableCountsPeekedByteAlongsideQueue) { + PtyConsole c; + ASSERT_TRUE(c.begin(unique_link("avail").c_str())); + int client = open_client(c.path()); + ASSERT_GE(client, 0); + ASSERT_EQ(::write(client, "ab", 2), 2); + EXPECT_GE(wait_available(c, 2), 2); + + EXPECT_EQ(c.peek(), 'a'); // pulls one byte into the pushback slot + EXPECT_EQ(c.available(), 2); // 1 pushed back + 1 still queued in the PTY + EXPECT_EQ(c.read(), 'a'); + EXPECT_EQ(c.read(), 'b'); + + ::close(client); + c.end(); +} + +TEST(PtyConsole, IsOpenTracksLifecycleAndEndIsIdempotent) { + PtyConsole c; + EXPECT_FALSE(c.isOpen()); + ASSERT_TRUE(c.begin(unique_link("life").c_str())); + EXPECT_TRUE(c.isOpen()); + c.flush(); // no-op; writes are unbuffered + + c.end(); + EXPECT_FALSE(c.isOpen()); + c.end(); // idempotent: both guards already false + + // Every accessor stays inert rather than faulting once closed. + EXPECT_EQ(c.available(), 0); + EXPECT_EQ(c.read(), -1); + EXPECT_EQ(c.peek(), -1); + EXPECT_EQ(c.write((uint8_t)'x'), 1u); // reports the byte consumed, drops it + c.flush(); +} + +TEST(PtyConsole, DestructorUnpublishesSymlink) { + std::string link = unique_link("dtor"); + { + PtyConsole c; + ASSERT_TRUE(c.begin(link.c_str())); + struct stat ls{}; + ASSERT_EQ(::lstat(link.c_str(), &ls), 0); + } // ~PtyConsole() -> end() + + struct stat ls{}; + EXPECT_NE(::lstat(link.c_str(), &ls), 0); +} + +TEST(PtyConsole, BeginFailsCleanlyWhenNoDescriptorIsAvailable) { + // Exhausting the fd table makes posix_openpt() fail. begin() must report the + // failure rather than half-open, so the caller can fall back to Serial. + rlimit orig{}; + ASSERT_EQ(getrlimit(RLIMIT_NOFILE, &orig), 0); + rlimit tight = orig; + tight.rlim_cur = 3; // stdin/stdout/stderr hold 0..2; nothing left to hand out + ASSERT_EQ(setrlimit(RLIMIT_NOFILE, &tight), 0); + + bool opened = true; + { + PtyConsole c; + opened = c.begin(unique_link("nofd").c_str()); + EXPECT_FALSE(c.isOpen()); + c.end(); // safe on a console that never opened + } + ASSERT_EQ(setrlimit(RLIMIT_NOFILE, &orig), 0); + EXPECT_FALSE(opened); +} + +// --- syscall interposition ---------------------------------------------------- +// +// grantpt() and ptsname_r() cannot fail once posix_openpt() has handed back a +// valid master, so their error paths are unreachable from the outside. These +// strong definitions win over libc's at link time, letting a test force each +// failure; when the flag is clear they forward to the real implementation. + +namespace { +bool fail_grantpt = false; +bool fail_ptsname_r = false; +} // namespace + +extern "C" int grantpt(int fd) { + using fn_t = int (*)(int); + static fn_t real = reinterpret_cast(dlsym(RTLD_NEXT, "grantpt")); + if (fail_grantpt) { errno = EACCES; return -1; } + return real(fd); +} + +extern "C" int ptsname_r(int fd, char *buf, size_t buflen) { + using fn_t = int (*)(int, char *, size_t); + static fn_t real = reinterpret_cast(dlsym(RTLD_NEXT, "ptsname_r")); + if (fail_ptsname_r) { errno = ERANGE; return -1; } + return real(fd, buf, buflen); +} + +TEST(PtyConsole, BeginFailsCleanlyWhenGrantptFails) { + fail_grantpt = true; + { + PtyConsole c; + EXPECT_FALSE(c.begin(unique_link("grantpt").c_str())); + EXPECT_FALSE(c.isOpen()); + } + fail_grantpt = false; +} + +TEST(PtyConsole, BeginFailsCleanlyWhenSlaveNameCannotBeResolved) { + fail_ptsname_r = true; + { + PtyConsole c; + EXPECT_FALSE(c.begin(unique_link("ptsname").c_str())); + EXPECT_FALSE(c.isOpen()); + } + fail_ptsname_r = false; +} + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/variants/linux/LinuxBoard.cpp b/variants/linux/LinuxBoard.cpp index c218a2f113..8788758ae1 100644 --- a/variants/linux/LinuxBoard.cpp +++ b/variants/linux/LinuxBoard.cpp @@ -150,6 +150,7 @@ int LinuxConfig::load(const char *filename) { if (strcmp(key, "spidev") == 0) spidev = safe_copy(value, 32); else if (strcmp(key, "lora_gpiochip") == 0) lora_gpiochip = safe_copy(value, 32); + else if (strcmp(key, "console_path") == 0) console_path = safe_copy(value, 108); else if (strcmp(key, "lora_freq") == 0) lora_freq = atof(value); else if (strcmp(key, "lora_bw") == 0) lora_bw = atof(value); else if (strcmp(key, "lora_sf") == 0) lora_sf = (uint8_t)atoi(value); diff --git a/variants/linux/LinuxBoard.h b/variants/linux/LinuxBoard.h index 9aabb09156..ccfe11ccf6 100644 --- a/variants/linux/LinuxBoard.h +++ b/variants/linux/LinuxBoard.h @@ -35,6 +35,11 @@ class LinuxConfig { char* spidev = "/dev/spidev0.0"; char* lora_gpiochip = "gpiochip0"; + // Local CLI console path. Empty => a per-user default + // ($XDG_RUNTIME_DIR/meshcore/console, else /tmp/meshcore-/console). + // Connect with `meshcore-cli -r -s `. + char* console_path = ""; + float lora_tcxo = 1.8f; char *advert_name = "Linux Repeater"; diff --git a/variants/linux/README.md b/variants/linux/README.md index 581aedfca0..7b25c58fe9 100644 --- a/variants/linux/README.md +++ b/variants/linux/README.md @@ -78,7 +78,7 @@ sudo nano /etc/meshcored/meshcored.ini The config file has two roles: - **Hardware config** (always read on every startup): SPI device, GPIO pin numbers, LoRa radio parameters. -- **First-run node defaults**: `advert_name`, `admin_password`, `lat`, `lon`. On the first boot these are saved to the node's persisted prefs (`com_prefs`). After that, use the serial CLI to change them (`set name`, `set password`, etc.), the INI values are no longer consulted for these fields. +- **First-run node defaults**: `advert_name`, `admin_password`, `lat`, `lon`. On the first boot these are saved to the node's persisted prefs (`com_prefs`). After that, use the console CLI to change them (`set name`, `set password`, etc.; see [§5](#5-reconfiguring-after-first-run)), the INI values are no longer consulted for these fields. Key settings: @@ -193,17 +193,48 @@ sudo journalctl -u meshcored -f > If you smoke-tested by running directly first, clear any stale state so the > service first-boots with the INI defaults: `sudo rm -rf /var/lib/meshcore/*` +> For the local CLI console under systemd, uncomment +> `console_path = /run/meshcored/console` in `/etc/meshcored/meshcored.ini` +> (the unit's `RuntimeDirectory` provides `/run/meshcored`). See +> [§5](#5-reconfiguring-after-first-run). + ### 5. Reconfiguring after first run -Node name, password, and location can be changed via the serial CLI after first boot: +`meshcored` exposes a local CLI at the path set by `console_path` in +`meshcored.ini`, kept separate from the logs (which go to stdout / journald). +Under the systemd unit, uncomment `console_path = /run/meshcored/console` — the +unit's `RuntimeDirectory` creates that directory, owned by the `meshcore` user. + +Connect with [`meshcore-cli`](https://github.com/fdlamotte/meshcore-cli): + +```sh +sudo meshcore-cli -r -s /run/meshcored/console +``` ``` set name set password set lat set lon +set freq 910.525 +set sf 7 ``` +> **Logs are separate from the console.** Debug logs (`MESH_DEBUG`, on in this +> experimental build) go to stdout → journald, *not* to the console, so it shows +> only your commands and their replies. Those commands/replies are also copied to +> the log, so `journalctl -u meshcored -f` still records what was run. + +> **Security:** connecting to the console grants the privileged, *unauthenticated* +> local CLI — it can change the radio, read the private key (`get prv.key`), erase +> state, etc. The console is owner-only (mode `0600`), so keep the daemon's user +> (`root`/`meshcore`) trusted. When `console_path` is unset (e.g. running +> `meshcored` **directly**, not under systemd) it defaults to +> `$XDG_RUNTIME_DIR/meshcore/console` (else `/tmp/meshcore-/console`). + +Logs stream to journald (`sudo journalctl -u meshcored -f`); the daemon +line-buffers stdout itself, so no `stdbuf` wrapper is needed. + There are two levels of reset: **Prefs only**, keeps the node identity (same Repeater ID). Delete the saved prefs so the INI first-run defaults are re-applied on the next boot: @@ -223,7 +254,7 @@ sudo systemctl start meshcored > When running **directly** (not under systemd), `meshcored --fsdir /var/lib/meshcore --erase` is the equivalent one-shot full reset. Do **not** add `--erase` to the service unit: systemd re-runs `ExecStart` on every restart, so it would wipe the filesystem and regenerate the identity each time. (The firmware's own `reboot()` strips `--erase` to avoid self-wiping, but that protection does not extend to a systemd restart.) -> **Note:** LoRa radio parameters (`lora_freq`, `lora_bw`, `lora_sf`, `lora_cr`, `lora_tx_power`) are also first-run defaults. After first boot they are saved in `com_prefs` and the INI values are no longer read for those fields. To apply a changed radio parameter, use the CLI (`set freq`, `set sf`, etc.) or reset prefs as above. +> **Note:** LoRa radio parameters (`lora_freq`, `lora_bw`, `lora_sf`, `lora_cr`, `lora_tx_power`) are also first-run defaults. After first boot they are saved in `com_prefs` and the INI values are no longer read for those fields. To apply a changed radio parameter on a running node, use the console CLI (`set freq`, `set sf`, etc.; see [§5](#5-reconfiguring-after-first-run)) or reset prefs as above. ## Known Gaps / TODO diff --git a/variants/linux/meshcored.ini b/variants/linux/meshcored.ini index d7b17d790c..836bdc2be4 100644 --- a/variants/linux/meshcored.ini +++ b/variants/linux/meshcored.ini @@ -3,6 +3,13 @@ admin_password = password lat = 0.0 lon = 0.0 +# Local CLI console. Connect with `meshcore-cli -r -s `. Leave unset for a +# per-user default ($XDG_RUNTIME_DIR/meshcore/console, else +# /tmp/meshcore-/console). Under the systemd unit, uncomment the /run path +# below (its RuntimeDirectory provides and cleans up that directory). The console +# is owner-only (mode 0600) and grants the privileged, unauthenticated CLI. +#console_path = /run/meshcored/console + # Waveshare LoRa hat #lora_irq_pin = 16 #lora_reset_pin = 18 diff --git a/variants/linux/meshcored.service b/variants/linux/meshcored.service index 72d42f0a76..8fdfe44aa3 100644 --- a/variants/linux/meshcored.service +++ b/variants/linux/meshcored.service @@ -8,7 +8,16 @@ Wants=network.target Type=simple User=meshcore Group=meshcore -ExecStart=/usr/bin/stdbuf -oL /usr/bin/meshcored --fsdir /var/lib/meshcore +# Local CLI console. Set its path in meshcored.ini +# (console_path = /run/meshcored/console); RuntimeDirectory creates /run/meshcored +# owned by meshcore, mode 0700, and removes it on stop. /run (not /tmp) is used +# because PrivateTmp=yes gives the service a private /tmp a client outside the +# unit could not reach. The console is owner-only (mode 0600) and grants the +# privileged, unauthenticated CLI. Connect with: +# meshcore-cli -r -s /run/meshcored/console +RuntimeDirectory=meshcored +RuntimeDirectoryMode=0700 +ExecStart=/usr/bin/meshcored --fsdir /var/lib/meshcore WorkingDirectory=/var/lib/meshcore Restart=on-failure RestartSec=5