diff --git a/variants/linux/LinuxBoard.cpp b/variants/linux/LinuxBoard.cpp index c218a2f113..d48f16a0eb 100644 --- a/variants/linux/LinuxBoard.cpp +++ b/variants/linux/LinuxBoard.cpp @@ -1,7 +1,9 @@ #include #include #include +#include #include +#include #include #ifdef ARDULINUX_HARDWARE #include "linux/gpio/LinuxGPIOPin.h" @@ -9,6 +11,10 @@ #include "LinuxBoard.h" #include "AppInfo.h" +// Still hardcoded -- see "Known Gaps" in variants/linux/README.md -- but named, +// so the loader and the diagnostics that mention it cannot disagree. +#define CONFIG_PATH "/etc/meshcored/meshcored.ini" + const char *ardulinuxAppName = "meshcored"; const char *ardulinuxAppDescription = "a meshcore daemon for linux"; const char *ardulinuxAppBugAddress = "https://github.com/meshcore-dev/MeshCore"; @@ -43,6 +49,10 @@ void ardulinuxSetup() { } void LinuxBoard::begin() { + // Only NORMAL applies here -- this board never wakes from a radio IRQ or + // deep sleep the way an MCU variant can, so nothing else ever sets this. + startup_reason = BD_STARTUP_NORMAL; + #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" @@ -54,7 +64,46 @@ void LinuxBoard::begin() { exit(1); #endif - config.load("/etc/meshcored/meshcored.ini"); + // Nothing about a bad config is silent any more -- that was the actual defect + // -- but the two failure kinds get opposite responses. + // + // An INVALID VALUE is fatal. The operator wrote a specific setting and it + // could not be honoured, so continuing means running hardware differently + // from how it was asked to be run: `lora_irq_pin = 260` leaves the IRQ line + // unconfigured, and the node then fails at RX in a way that reads as a wiring + // fault. There is no sensible fallback for "which GPIO drives the radio". + // + // An UNKNOWN KEY only warns. It is inert by definition -- nothing consumes it + // -- and it may be a key from a newer build, a leftover from an older one, or + // a typo. Ignoring unrecognised keys is what every INI parser does, and + // refusing to boot over one would take a working repeater off the air for a + // line it was already ignoring. It still gets said out loud, because the + // radio parameters here are FIRST-RUN defaults: MyMesh::begin() persists them + // to prefs.json on the first boot, so `lora_frequency` for `lora_freq` does + // not merely fail to apply -- correcting the INI later will not undo it. + LinuxConfig::LoadResult cfg = config.load(CONFIG_PATH); + + if (!cfg.opened) { + printf("WARNING: cannot read %s (%s); continuing on built-in defaults.\n" + " Expect the radio to fail to start -- no GPIO pins are configured.\n" + " Start from a template in variants/linux/ (meshcored.ini.waveshare,\n" + " meshcored.ini.pow-sx1262).\n", + CONFIG_PATH, strerror(errno)); + } + if (cfg.unknown_keys > 0) { + printf("WARNING: %d unrecognised key(s) in %s (listed above) were ignored.\n" + " If one of them was meant to be a radio parameter, note that first\n" + " boot persists the DEFAULT to prefs.json and correcting the INI\n" + " afterwards will NOT change it -- check the lines above now.\n", + cfg.unknown_keys, CONFIG_PATH); + } + if (cfg.bad_values > 0) { + printf("FATAL: %d invalid value(s) in %s (listed above).\n" + " Refusing to start rather than drive the hardware differently from\n" + " how it was configured. Fix the listed line(s) and restart.\n", + cfg.bad_values, CONFIG_PATH); + exit(1); + } printf("SPI begin %s\n", config.spidev); SPI.begin(config.spidev, 2000000); @@ -93,51 +142,273 @@ void LinuxBoard::begin() { } } -void trim(char *str) { - char *end; +// The ardulinux core's global ::reboot() (cores/ardulinux/main.cpp) re-execs +// this same process via execv(), with --erase stripped from argv so a reboot +// cannot re-trigger a filesystem wipe. exit(0) here would be wrong: the +// shipped systemd unit uses Restart=on-failure, not Restart=always, so a clean +// exit is a stop, not a restart -- and both the `reboot` and `clkreboot` CLI +// commands reach this from an authenticated remote admin over the mesh +// (CommonCLI::handleCommand), so that would take an unattended repeater +// off-air. Qualified as ::reboot() to resolve to the global one and not recurse +// into this member of the same name. +void LinuxBoard::reboot() { + ::reboot(); +} + +// Trim whitespace from both ends, returning the trimmed string. +// +// Returns rather than trimming in place because the leading trim cannot be done +// in place without moving bytes: advancing the local pointer is invisible to the +// caller, which is exactly the bug this signature prevents. Callers must use the +// return value. +// +// static, like safe_copy() below: `trim` is the kind of name another translation +// unit is entitled to define at file scope, and a variant has no business +// exporting it. +static char *trim(char *str) { while (isspace((unsigned char)*str)) str++; - if (*str == 0) { *str = 0; return; } - end = str + strlen(str) - 1; + if (*str == 0) return str; + char *end = str + strlen(str) - 1; while (end > str && isspace((unsigned char)*end)) end--; end[1] = '\0'; + return str; } -char *safe_copy(char *value, size_t maxlen) { +// *ok reports whether `retval` is a malloc() this function made (and so is +// safe to free() later) as opposed to the literal fallback below -- assign_string() +// below needs that distinction to avoid ever handing a string literal to free(). +static char *safe_copy(char *value, size_t maxlen, bool *ok) { char *retval; size_t length = strlen(value) + 1; if (length > maxlen) length = maxlen; retval = (char *)malloc(length); + if (!retval) { + // Cannot return NULL: every caller assigns straight into a char* the rest of + // the daemon dereferences. An empty string is the one answer that is both + // safe and visibly wrong. (Same char*-from-literal the field defaults use.) + printf("ERROR: meshcored.ini: out of memory copying a value; using \"\"\n"); + *ok = false; + return (char *) ""; + } strncpy(retval, value, length - 1); retval[length - 1] = '\0'; + *ok = true; return retval; } -int LinuxConfig::load(const char *filename) { +static const bool PIN_REQUIRED = false; // names for parse_pin()'s `optional` +static const bool PIN_OPTIONAL = true; + +// Parse a GPIO pin number. +// +// Pin numbers reach the Arduino API as pin_size_t (uint8_t), so anything outside +// 0..255 wraps silently -- `lora_irq_pin = 260` would bind line 4 and then fail +// in a way that reads as a wiring fault rather than a typo. On a bad value the +// caller's field is left at its default and *bad_values is bumped, which +// LinuxBoard::begin() treats as fatal -- there is no sensible fallback for +// "which GPIO drives the radio". +// +// `optional` marks a pin that may be explicitly unset, which is only ever the +// RF-switch pair: there is no such thing as an unset NSS line. It accepts the +// clear spelling `none` and also `-1`, which is what the previous atoi() turned +// into RADIOLIB_NC by accident and therefore what deployed configs contain -- +// rejecting it would take a working node off the air on upgrade. +static bool parse_pin(const char *key, const char *value, bool optional, long *out, int *bad_values) { + if (optional && (strcmp(value, "-1") == 0 || strcasecmp(value, "none") == 0)) { + *out = (long) RADIOLIB_NC; + return true; + } + char *end = NULL; + long v = strtol(value, &end, 10); + if (end == value || *end != '\0' || v < 0 || v > 255) { + printf("ERROR: meshcored.ini: %s = '%s' is not a valid GPIO pin (expected 0..255%s)\n", + key, value, optional ? ", or -1/none for unused" : ""); + (*bad_values)++; + return false; + } + *out = v; + return true; +} + +// Parse a float config value. atof() silently stops at the first +// non-numeric character (`lora_freq = 868,5` -> 868.0) and returns 0.0 for +// anything that parses as nothing at all (`lora_freq = abc` -> 0.0) with no +// diagnostic either way -- exactly the silent-misconfiguration class +// LinuxBoard::begin()'s FATAL branch exists to rule out. +static bool parse_float(const char *key, const char *value, float *out, int *bad_values) { + char *end = NULL; + float v = strtof(value, &end); + if (end == value || *end != '\0') { + printf("ERROR: meshcored.ini: %s = '%s' is not a valid number\n", key, value); + (*bad_values)++; + return false; + } + *out = v; + return true; +} + +// Parse a bounded integer config value. Shares parse_pin()'s rationale: `lo` +// and `hi` are the field's own type width (e.g. -128..127 for an int8_t), so +// a value atoi() would otherwise truncate into something silently different +// -- `lora_tx_power = 300` becoming 44 -- is caught here instead. +static bool parse_int(const char *key, const char *value, long lo, long hi, long *out, int *bad_values) { + char *end = NULL; + long v = strtol(value, &end, 10); + if (end == value || *end != '\0' || v < lo || v > hi) { + printf("ERROR: meshcored.ini: %s = '%s' is not a valid integer (expected %ld..%ld)\n", + key, value, lo, hi); + (*bad_values)++; + return false; + } + *out = v; + return true; +} + +// Parse a boolean config value. The bug this replaces: `atoi(value) != 0` +// treats anything not starting with a digit as false, so +// `dio2_as_rf_switch = true` -- the spelling every other bool in this file +// invites -- silently becomes false, with the DIO2 RF switch left unset and +// TX dead on boards that need it. Accept the spellings the shipped +// meshcored.ini.* templates use and reject everything else as a bad value. +static bool parse_bool(const char *key, const char *value, bool *out, int *bad_values) { + if (strcasecmp(value, "1") == 0 || strcasecmp(value, "true") == 0 || + strcasecmp(value, "on") == 0 || strcasecmp(value, "yes") == 0) { + *out = true; + return true; + } + if (strcasecmp(value, "0") == 0 || strcasecmp(value, "false") == 0 || + strcasecmp(value, "off") == 0 || strcasecmp(value, "no") == 0) { + *out = false; + return true; + } + printf("ERROR: meshcored.ini: %s = '%s' is not a valid boolean " + "(expected 1/0, true/false, on/off, yes/no)\n", key, value); + (*bad_values)++; + return false; +} + +// Copy `value` into a string field, freeing whatever it pointed to if this +// field has already been assigned once during this load() call. The +// compile-time default is a string literal and not ours to free; `*owned` +// tracks the transition from "still the default" to "a safe_copy() +// allocation" (and stays false across an out-of-memory copy, which is the +// literal fallback again), so a duplicate key does not leak the previous +// allocation or free() something that was never malloc()'d. +// +// An empty value is a bad value, exactly like the numeric helpers treat one +// (`strtof`/`strtol` with `end == value`). Without this, a truncated line such +// as a bare `admin_password` -- key, no `=`, no value -- would set the field to +// "" with nothing said, and MyMesh::begin() would then persist that empty +// password to prefs.json on the first boot. The compile-time default is left in +// place, and begin() refuses to start on the count. +static void assign_string(const char *key, char *value, const char **field, bool *owned, + size_t maxlen, int *bad_values) { + if (*value == '\0') { + printf("ERROR: meshcored.ini: %s = '%s' is empty (expected a value)\n", key, value); + (*bad_values)++; + return; + } + if (*owned) free((void *) *field); + bool ok = false; + *field = safe_copy(value, maxlen, &ok); + *owned = ok; +} + +LinuxConfig::LoadResult LinuxConfig::load(const char *filename) { + LoadResult result; + FILE *f = fopen(filename, "r"); - if (!f) return -1; + if (!f) return result; // result.opened stays false + result.opened = true; + // String fields start out pointing at their compile-time literal default; + // *_owned flips true the first time this load() call replaces it with a + // safe_copy() allocation, so a duplicate key knows there is something of + // its own to free before overwriting it again. + bool spidev_owned = false, lora_gpiochip_owned = false, + advert_name_owned = false, admin_password_owned = false; + + bool first_line = true; char line[512]; while (fgets(line, sizeof(line), f)) { + // fgets() stops at sizeof(line)-1 bytes even mid-line, with no '\n' to + // show it. Left unhandled, the unread remainder is read as its own line + // next iteration and parsed as a fresh key=value pair -- silently + // splitting one overlong line into a truncated setting plus a bogus + // "unknown key". Nothing this loop writes is anywhere near this long, so + // discard the whole line rather than guess where it was meant to end. + // + // Swallow the remainder here, but hold the verdict: whether this is fatal + // depends on what the line was, and that is only known after the + // comment/blank/section test below. A comment or a section header long + // enough to hit the limit is still inert, and taking a repeater off the air + // over a long *comment* would be indefensible. + // + // Note the limit is reached, not necessarily exceeded: a line of exactly + // sizeof(line)-1 characters followed by its newline is indistinguishable + // from a longer one without reading ahead, and is discarded too. The + // message says "reached" for that reason. + bool overlong = false; + size_t raw_len = strlen(line); + if (raw_len == sizeof(line) - 1 && line[raw_len - 1] != '\n' && !feof(f)) { + int c; + while ((c = fgetc(f)) != EOF && c != '\n') { } + overlong = true; + } + char *p = line; - // skip whitespace - while (isspace(*p)) p++; - // skip empty lines and comments - if (*p == '\0' || *p == '#' || *p == ';') continue; + + // Strip a UTF-8 BOM. An editor that writes one would otherwise glue it to + // the first key, which both drops that setting and reports it as "unknown + // key 'advert_name'" against a line that reads exactly right -- the least + // actionable message this loop could produce. + if (first_line && (unsigned char)p[0] == 0xEF + && (unsigned char)p[1] == 0xBB && (unsigned char)p[2] == 0xBF) { + p += 3; + } + first_line = false; + + // skip whitespace. isspace() is undefined for negative char values, which a + // UTF-8 advert_name supplies, so every call here casts. + while (isspace((unsigned char)*p)) p++; + // skip empty lines, comments, and INI section headers. Nothing here is + // sectioned, but the format invites them, and a "[general]" reported as an + // unknown key is noise that trains the operator to ignore the warning. + if (*p == '\0' || *p == '#' || *p == ';' || *p == '[') continue; + + // Not a comment, a blank or a section header, so this was meant to be a + // setting -- and the part of it that was read cannot be trusted to be the + // whole of it. + if (overlong) { + printf("ERROR: meshcored.ini: line reached the %zu-byte limit; discarding it\n", + sizeof(line) - 1); + result.bad_values++; + continue; + } char *key = p; - while (*p && !isspace(*p) && *p != '=') p++; - if (*p == '\0') continue; + while (*p && !isspace((unsigned char)*p) && *p != '=') p++; + if (*p == '\0') { + // No '=' anywhere on the line, and no key/value split possible either -- + // was silently dropped before. Inert like an unknown key (nothing here + // consumes it), so counted -- and labelled -- the same way rather than + // treated as fatal. + printf("WARNING: meshcored.ini: line '%s' has no '=' (ignored)\n", trim(key)); + result.unknown_keys++; + continue; + } *p++ = '\0'; - while (*p && (isspace(*p) || *p == '=')) p++; + while (*p && (isspace((unsigned char)*p) || *p == '=')) p++; char *value = p; p = value; while (*p && *p != '\n' && *p != '\r' && *p != '#' && *p != ';') p++; *p = '\0'; - trim(key); - trim(value); + key = trim(key); + value = trim(value); + long pin = 0; // strip optional surrounding quotes from string values { @@ -148,30 +419,62 @@ 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); - 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); + long ival = 0; + float fval = 0.0f; + bool bval = false; + + if (strcmp(key, "spidev") == 0) { + assign_string(key, value, &spidev, &spidev_owned, 32, &result.bad_values); + } else if (strcmp(key, "lora_gpiochip") == 0) { + assign_string(key, value, &lora_gpiochip, &lora_gpiochip_owned, 32, &result.bad_values); + } else if (strcmp(key, "lora_freq") == 0) { + if (parse_float(key, value, &fval, &result.bad_values)) lora_freq = fval; + } else if (strcmp(key, "lora_bw") == 0) { + if (parse_float(key, value, &fval, &result.bad_values)) lora_bw = fval; + } else if (strcmp(key, "lora_sf") == 0) { + if (parse_int(key, value, 0, 255, &ival, &result.bad_values)) lora_sf = (uint8_t) ival; + } else if (strcmp(key, "lora_cr") == 0) { + if (parse_int(key, value, 0, 255, &ival, &result.bad_values)) lora_cr = (uint8_t) ival; + } else if (strcmp(key, "lora_tcxo") == 0) { + if (parse_float(key, value, &fval, &result.bad_values)) lora_tcxo = fval; + } else if (strcmp(key, "lora_tx_power") == 0) { + if (parse_int(key, value, -128, 127, &ival, &result.bad_values)) lora_tx_power = (int8_t) ival; + } else if (strcmp(key, "current_limit") == 0) { + if (parse_float(key, value, &fval, &result.bad_values)) current_limit = fval; + } else if (strcmp(key, "dio2_as_rf_switch") == 0) { + if (parse_bool(key, value, &bval, &result.bad_values)) dio2_as_rf_switch = bval; + } else if (strcmp(key, "rx_boosted_gain") == 0) { + if (parse_bool(key, value, &bval, &result.bad_values)) rx_boosted_gain = bval; + } else if (strcmp(key, "lora_irq_pin") == 0) { + if (parse_pin(key, value, PIN_REQUIRED, &pin, &result.bad_values)) lora_irq_pin = (uint32_t) pin; + } else if (strcmp(key, "lora_reset_pin") == 0) { + if (parse_pin(key, value, PIN_REQUIRED, &pin, &result.bad_values)) lora_reset_pin = (uint32_t) pin; + } else if (strcmp(key, "lora_nss_pin") == 0) { + if (parse_pin(key, value, PIN_REQUIRED, &pin, &result.bad_values)) lora_nss_pin = (uint32_t) pin; + } else if (strcmp(key, "lora_busy_pin") == 0) { + if (parse_pin(key, value, PIN_REQUIRED, &pin, &result.bad_values)) lora_busy_pin = (uint32_t) pin; + } else if (strcmp(key, "lora_rxen_pin") == 0) { + if (parse_pin(key, value, PIN_OPTIONAL, &pin, &result.bad_values)) lora_rxen_pin = (uint32_t) pin; + } else if (strcmp(key, "lora_txen_pin") == 0) { + if (parse_pin(key, value, PIN_OPTIONAL, &pin, &result.bad_values)) lora_txen_pin = (uint32_t) pin; + } else if (strcmp(key, "advert_name") == 0) { + assign_string(key, value, &advert_name, &advert_name_owned, 100, &result.bad_values); + } else if (strcmp(key, "admin_password") == 0) { + assign_string(key, value, &admin_password, &admin_password_owned, 100, &result.bad_values); + } else if (strcmp(key, "lat") == 0) { + if (parse_float(key, value, &fval, &result.bad_values)) lat = fval; + } else if (strcmp(key, "lon") == 0) { + if (parse_float(key, value, &fval, &result.bad_values)) lon = fval; + } else { + // Nothing below this chain consumes leftovers, so an unrecognised key is + // a key that does nothing -- inert, and possibly just a key from a newer + // build. Counted separately from a bad value for that reason: the caller + // warns about these and refuses to start over those. WARNING, not ERROR, + // so this line and the summary two lines later agree about what happened. + printf("WARNING: meshcored.ini: unknown key '%s' (ignored)\n", key); + result.unknown_keys++; + } } fclose(f); - return 0; + return result; } diff --git a/variants/linux/LinuxBoard.h b/variants/linux/LinuxBoard.h index 9aabb09156..8c2916d0b8 100644 --- a/variants/linux/LinuxBoard.h +++ b/variants/linux/LinuxBoard.h @@ -6,6 +6,8 @@ #include #include #include +#include +#include #include #include @@ -32,17 +34,26 @@ class LinuxConfig { bool dio2_as_rf_switch = false; bool rx_boosted_gain = true; - char* spidev = "/dev/spidev0.0"; - char* lora_gpiochip = "gpiochip0"; + const char* spidev = "/dev/spidev0.0"; + const char* lora_gpiochip = "gpiochip0"; float lora_tcxo = 1.8f; - char *advert_name = "Linux Repeater"; - char *admin_password = "password"; + const char *advert_name = "Linux Repeater"; + const char *admin_password = "password"; float lat = 0.0f; float lon = 0.0f; - int load(const char *filename); + // Outcome of parsing meshcored.ini. Two failure kinds, kept apart because + // they deserve opposite responses (see LinuxBoard::begin()): a value the + // operator wrote that could not be honoured, versus a key nothing consumes. + struct LoadResult { + bool opened = false; // false: the file could not be read at all + int bad_values = 0; // values that failed validation + int unknown_keys = 0; // keys nothing consumes; ignored + }; + + LoadResult load(const char *filename); }; class LinuxBoard : public mesh::MainBoard { @@ -71,13 +82,9 @@ class LinuxBoard : public mesh::MainBoard { 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. + // Upstream lets a variant hang its own prefs off the 'custom' JSON object; + // this 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 { @@ -88,10 +95,18 @@ class LinuxBoard : public mesh::MainBoard { } } + // Re-exec this process image rather than exit. Defined in LinuxBoard.cpp. + void reboot() override; + LinuxConfig config; }; class LinuxRTCClock : public mesh::RTCClock { + // Latches the first settimeofday() failure. The callers are on timers (GPS + // time sync, the mesh clock correction), so an unlatched report would repeat + // for the life of the daemon. + bool _settime_warned = false; + public: LinuxRTCClock() { } void begin() { @@ -105,6 +120,28 @@ class LinuxRTCClock : public mesh::RTCClock { struct timeval tv; tv.tv_sec = time; tv.tv_usec = 0; - settimeofday(&tv, NULL); + if (settimeofday(&tv, NULL) == 0) { + // Deliberately not deduped like the warning below: this line's whole + // value is showing *every* time GPS/mesh sync steps the clock, so a + // clock fighting something else on the host (NTP, a process with + // CAP_SYS_TIME) is visible in the journal instead of silently winning + // or losing against it. + printf("NOTE: system clock set to %u by mesh/GPS time sync.\n", (unsigned) time); + return; + } + + // Unlike an MCU, this is the whole host's clock, and setting it needs + // CAP_SYS_TIME. The shipped unit runs as an unprivileged `meshcore` user + // with NoNewPrivileges=yes, so it does not have it and this always fails -- + // `clock sync` and GPS time sync would otherwise report success and do + // nothing at all. Not fatal: the host's clock is NTP's job on a Linux box, + // and getCurrentTime() reads it correctly either way. + if (!_settime_warned) { + _settime_warned = true; + printf("WARNING: cannot set the system clock (%s); mesh/GPS time sync will\n" + " not take effect. This is expected under the shipped systemd\n" + " unit (unprivileged, no CAP_SYS_TIME) -- keep the host on NTP.\n", + strerror(errno)); + } } }; diff --git a/variants/linux/README.md b/variants/linux/README.md index 087401080b..0c8912bc0b 100644 --- a/variants/linux/README.md +++ b/variants/linux/README.md @@ -96,7 +96,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 (`prefs.json`). 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: @@ -108,8 +108,8 @@ 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_rxen_pin` | (none) | GPIO line number for RX enable (RF switch); omit, or set `-1`/`none`, if unused | +| `lora_txen_pin` | (none) | GPIO line number for TX enable (RF switch); omit, or set `-1`/`none`, if unused | | `lora_freq` | `869.618` | Frequency in MHz | | `lora_bw` | `62.5` | Bandwidth in kHz | | `lora_sf` | `8` | Spreading factor | @@ -123,6 +123,42 @@ Key settings: | `admin_password` | `"password"` | Admin password, **change this**, first-run default only | | `lat` / `lon` | `0.0` | GPS coordinates for advertisement, first-run default only | +Comments (`#`, `;`), blank lines and `[section]` headers are ignored. Boolean +settings (`dio2_as_rf_switch`, `rx_boosted_gain`) accept `1`/`0`, +`true`/`false`, `on`/`off` or `yes`/`no`, case-insensitively; anything else is +a fatal invalid value. + +#### Config validation + +Every problem is reported on its own line at startup: + +| Problem | Response | +|---------|----------| +| **Invalid value** — a GPIO pin outside `0..255`, a malformed or out-of-range number, an unrecognised boolean spelling, or empty | **Fatal**, the daemon refuses to start | +| **Line at or past the 511-byte limit** — the rest of it was never read, so the setting cannot be trusted. A comment, blank line or `[section]` that long is ignored instead | **Fatal**, the daemon refuses to start | +| **Unrecognised key** — e.g. `lora_frequency` for `lora_freq` | **Warning**, key ignored, startup continues | +| **File missing or unreadable** | **Warning**, built-in defaults used. The radio then fails to start, since no pins are configured | + +``` +WARNING: meshcored.ini: unknown key 'lora_frequency' (ignored) +WARNING: 1 unrecognised key(s) in /etc/meshcored/meshcored.ini ... + +ERROR: meshcored.ini: lora_irq_pin = '260' is not a valid GPIO pin (expected 0..255) +FATAL: 1 invalid value(s) in /etc/meshcored/meshcored.ini ... +``` + +> **Upgrading an existing node?** This validation is stricter than what earlier +> builds did, so a file they accepted may now be rejected: values with a unit +> suffix (`lora_tcxo = 1.8V`, `lora_tx_power = 22 dBm`, `current_limit = 140mA`) +> used to be parsed loosely and are now fatal. Since the shipped unit uses +> `Restart=on-failure`, a rejected file means a restart loop and a node off the +> air, so check `journalctl -u meshcored` after upgrading. (`-1` on +> `lora_rxen_pin`/`lora_txen_pin` still works and still means "not wired".) + +**Read the warnings after editing.** An ignored key does not merely fail to +apply: for a first-run default, the built-in value is persisted on the first +boot, and fixing the INI afterwards changes nothing. + ### 3. Enable SPI and GPIO access First make sure the SPI interface is actually enabled, the radio needs a @@ -227,7 +263,7 @@ 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 rm /var/lib/meshcore/prefs.json sudo systemctl restart meshcored ``` @@ -241,7 +277,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 `prefs.json` 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 diff --git a/variants/linux/target.cpp b/variants/linux/target.cpp index 41609fc32e..98774eaac2 100644 --- a/variants/linux/target.cpp +++ b/variants/linux/target.cpp @@ -15,7 +15,20 @@ 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); + +// A Module's pins are fixed at construction -- RadioLib keeps them private with +// only getters -- and this variant does not learn them until meshcored.ini has +// been read, which happens in board.begin(), long after static init. So the +// radio built here is a placeholder whose only job is to exist at static-init +// time, because radio_driver binds a reference to it; radio_init() rebuilds it +// with the real pins. +// +// Keeping the Module in a named pointer rather than inlining `new Module(...)` +// is what makes that rebuild releasable instead of a leak: getMod() is +// protected, so once the placeholder is overwritten there is otherwise no way +// left to reach it. +static Module *radio_module = new Module(hal, RADIOLIB_NC, RADIOLIB_NC, RADIOLIB_NC, RADIOLIB_NC); +RADIO_CLASS radio = radio_module; WRAPPER_CLASS radio_driver(radio, board); LinuxRTCClock rtc_clock; @@ -29,7 +42,14 @@ EnvironmentSensorManager sensors; 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); + // Rebuild the radio on a Module carrying the configured pins. Assigning over + // the object rather than replacing it is deliberate and required: radio_driver + // holds a reference to `radio`, so the address has to stay put. Only the + // Module underneath is swapped, and the placeholder one is freed rather than + // orphaned. + delete radio_module; + radio_module = new Module(hal, board.config.lora_nss_pin, board.config.lora_irq_pin, board.config.lora_reset_pin, board.config.lora_busy_pin); + radio = radio_module; return radio.std_init(&SPI); }