diff --git a/boards/linux.json b/boards/linux.json new file mode 100644 index 0000000000..8a120faaca --- /dev/null +++ b/boards/linux.json @@ -0,0 +1,22 @@ +{ + "build": { + "arduino": { + }, + "core": "linux", + "extra_flags": [ + ], + "hwids": [], + "mcu": "arm64", + "variant": "linux" + }, + "connectivity": ["wifi", "bluetooth"], + "debug": {}, + "frameworks": ["arduino", "ardulinux", "linux"], + "name": "Linux", + "url": "https://github.com/l5yth/ardulinux", + "upload": { + "maximum_ram_size": 0, + "maximum_size": 0 + }, + "vendor": "Linux" +} diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index ca6a3e607e..ab568a22e7 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(ARDULINUX_PLATFORM) return _fs->open(fname, "a"); #else return _fs->open(fname, "a", true); @@ -944,6 +944,20 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc 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 + 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; + _prefs.rx_boosted_gain = board.config.rx_boosted_gain; +#endif // load persisted prefs _cli.loadPrefs(_fs); acl.load(_fs, self_id); @@ -1023,6 +1037,9 @@ bool MyMesh::formatFileSystem() { return LittleFS.format(); #elif defined(ESP32) return SPIFFS.format(); +#elif defined(ARDULINUX_PLATFORM) + // not supported on linux + return false; #else #error "need to implement file system erase" return false; @@ -1178,9 +1195,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(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 cac6c4a281..3b7f364557 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(ARDULINUX_PLATFORM) + #include #endif #ifdef WITH_RS232_BRIDGE diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index a714db68ec..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 @@ -81,6 +103,12 @@ void setup() { fs = &LittleFS; 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) + fs = &ArduLinuxFS; + IdentityStore store(ArduLinuxFS, "/identity"); + store.begin(); #else #error "need to define filesystem" #endif @@ -125,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; } @@ -139,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; @@ -151,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 @@ -203,5 +231,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/platformio.ini b/platformio.ini index 2219c97862..a075929a48 100644 --- a/platformio.ini +++ b/platformio.ini @@ -122,6 +122,51 @@ 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.3 +framework = arduino +board = linux +board_level = extra +board_build.progname = meshcored +build_src_filter = + ${env.build_src_filter} + - + - + - + - + - + - + - + - + - + +<../variants/linux> + - + - + - + - + - + - +lib_deps = + ${arduino_base.lib_deps} + adafruit/Adafruit seesaw Library@1.7.9 +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/src/helpers/ClientACL.cpp b/src/helpers/ClientACL.cpp index 1282382737..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) + #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 4930e81e9a..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) +#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 dc85d69cdd..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) +#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) +#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 d0d7ee457e..4a1e22cccf 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(ARDULINUX_PLATFORM) #include #define FILESYSTEM fs::FS #elif defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) 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..15217347ed --- /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) + +#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 diff --git a/src/helpers/PtyConsole.h b/src/helpers/PtyConsole.h new file mode 100644 index 0000000000..0a560d3f53 --- /dev/null +++ b/src/helpers/PtyConsole.h @@ -0,0 +1,54 @@ +#pragma once + +#if defined(ARDULINUX_PLATFORM) || defined(LINUX_PLATFORM) + +#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 diff --git a/src/helpers/RegionMap.cpp b/src/helpers/RegionMap.cpp index 4667e0038e..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) + #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 d327931fde..6da0d741ab 100644 --- a/src/helpers/TxtDataHelpers.cpp +++ b/src/helpers/TxtDataHelpers.cpp @@ -1,4 +1,7 @@ #include "TxtDataHelpers.h" +#if defined(ARDULINUX_PLATFORM) + #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(ARDULINUX_PLATFORM) + sprintf(p, "%" PRId32, int_part); +#else ltoa(int_part, p, 10); +#endif while (*p) p++; } diff --git a/src/helpers/radiolib/LinuxSX1262.h b/src/helpers/radiolib/LinuxSX1262.h new file mode 100644 index 0000000000..bed50ee92e --- /dev/null +++ b/src/helpers/radiolib/LinuxSX1262.h @@ -0,0 +1,54 @@ +#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; + } + + 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 new file mode 100644 index 0000000000..78c1e7cefb --- /dev/null +++ b/src/helpers/radiolib/LinuxSX1262Wrapper.h @@ -0,0 +1,39 @@ +#pragma once + +#include "LinuxSX1262.h" +#include "RadioLibWrappers.h" + +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(); + } + 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); + } + uint8_t getSpreadingFactor() const override { return ((LinuxSX1262 *)_radio)->spreadingFactor; } + + 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/99-meshcore.rules b/variants/linux/99-meshcore.rules new file mode 100644 index 0000000000..4071c847f6 --- /dev/null +++ b/variants/linux/99-meshcore.rules @@ -0,0 +1,5 @@ +# 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/LinuxBoard.cpp b/variants/linux/LinuxBoard.cpp new file mode 100644 index 0000000000..8788758ae1 --- /dev/null +++ b/variants/linux/LinuxBoard.cpp @@ -0,0 +1,178 @@ +#include +#include +#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 ARDULINUX_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 (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 (...) { + 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 + return 0; +#endif +} + +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); + SPI.begin(config.spidev, 2000000); + + 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); + + int failures = 0; + if (config.lora_nss_pin != RADIOLIB_NC) { + 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, config.lora_gpiochip, config.lora_busy_pin); + } + if (config.lora_irq_pin != RADIOLIB_NC) { + 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, config.lora_gpiochip, config.lora_reset_pin); + } + if (config.lora_rxen_pin != RADIOLIB_NC) { + 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, config.lora_gpiochip, config.lora_txen_pin); + } + + if (failures > 0) { + printf("FATAL: %d GPIO pin(s) failed to bind; cannot start radio.\n", failures); + exit(1); + } +} + +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); + + // 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_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); + 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 = 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); + 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..ccfe11ccf6 --- /dev/null +++ b/variants/linux/LinuxBoard.h @@ -0,0 +1,115 @@ +#pragma once + +#include +#include +#include +#include +#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"; + 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"; + 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); + } + + // 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) { } + + void sleep(uint32_t secs) override { + if (secs > 0) { + ::sleep(secs); + } else { + usleep(10000); // 10ms delay to prevent busy loop + } + } + + 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/README.md b/variants/linux/README.md new file mode 100644 index 0000000000..7b25c58fe9 --- /dev/null +++ b/variants/linux/README.md @@ -0,0 +1,265 @@ +# 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. + +## Hardware + +- Raspberry Pi (any model with SPI) +- 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 + +**Dependencies** (install on the build machine and on the Pi): + +```sh +# Arch Linux +sudo pacman -S pkgconf libgpiod i2c-tools bluez-libs libuv + +# Debian/Raspberry Pi OS +sudo apt install pkg-config 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`. 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: + +```sh +# Arch Linux +sudo pacman -S platformio-core # 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/meshcored +``` + +Alternatively, build directly with PlatformIO (no version metadata): + +```sh +FIRMWARE_VERSION=dev pio run -e linux_repeater +``` + +## Setup + +### 1. Install the binary + +```sh +sudo install -m 755 .pio/build/linux_repeater/meshcored /usr/bin/meshcored +``` + +### 2. Create the config file + +Two ready-made templates are provided in `variants/linux/`: + +| Template | Hardware | +|----------|----------| +| `meshcored.ini.pow-sx1262` | RPi Zero 2W + PoW SX1262 HAT | +| `meshcored.ini.waveshare` | RPi 3/4/5 + Waveshare SX1262 LoRa HAT | + +```sh +# 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 +``` + +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 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: + +| 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) | +| `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` | `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 | + +### 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, 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. +> +> **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 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 +``` + +Confirm the device nodes are now group-owned by `meshcore`: + +```sh +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 + +`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: + +```sh +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' +``` + +**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 -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 +sudo systemctl enable --now meshcored +sudo journalctl -u meshcored -f +``` + +> 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/*` + +> 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 + +`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: + +```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 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 + +- **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 new file mode 100644 index 0000000000..836bdc2be4 --- /dev/null +++ b/variants/linux/meshcored.ini @@ -0,0 +1,36 @@ +advert_name = Sample Router +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 +#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_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 +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.ini.pow-sx1262 b/variants/linux/meshcored.ini.pow-sx1262 new file mode 100644 index 0000000000..96b5040a9f --- /dev/null +++ b/variants/linux/meshcored.ini.pow-sx1262 @@ -0,0 +1,23 @@ +# 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_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 +# 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 + diff --git a/variants/linux/meshcored.ini.waveshare b/variants/linux/meshcored.ini.waveshare new file mode 100644 index 0000000000..45b03bf82f --- /dev/null +++ b/variants/linux/meshcored.ini.waveshare @@ -0,0 +1,25 @@ +# 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_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 +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 +# 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): +advert_name = Waveshare Linux Repeater +admin_password = changeme +lat = 0.0 +lon = 0.0 + diff --git a/variants/linux/meshcored.service b/variants/linux/meshcored.service new file mode 100644 index 0000000000..8fdfe44aa3 --- /dev/null +++ b/variants/linux/meshcored.service @@ -0,0 +1,32 @@ +# /var/lib/systemd/system/meshcored.service +[Unit] +Description=Meshcore Daemon (meshcored) +After=network.target +Wants=network.target + +[Service] +Type=simple +User=meshcore +Group=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 +LimitNOFILE=65535 +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +NoNewPrivileges=yes +StateDirectory=meshcore + +[Install] +WantedBy=multi-user.target diff --git a/variants/linux/platformio.ini b/variants/linux/platformio.ini new file mode 100644 index 0000000000..8f42ce46b1 --- /dev/null +++ b/variants/linux/platformio.ini @@ -0,0 +1,22 @@ +[env:linux] +extends = linux_base +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 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..41609fc32e --- /dev/null +++ b/variants/linux/target.cpp @@ -0,0 +1,54 @@ +#include +#include "target.h" + +class ArduLinuxHal : public ArduinoHal +{ +public: + ArduLinuxHal(SPIClass &spi, SPISettings spiSettings) : ArduinoHal(spi, spiSettings){}; + void spiTransfer(uint8_t *out, size_t len, uint8_t *in) { + memcpy(in, out, len); + spi->transfer(in, len); + } +}; + +LinuxBoard board; + +SPISettings spiSettings = SPISettings(2000000, MSBFIRST, SPI_MODE0); +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); + +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();