diff --git a/README.md b/README.md index 68b1e23..986211b 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,9 @@ All of this is local behaviour. Nothing here changes the wire protocol, so mixed - **LEDs you control.** Turn them all off with `set leds off`, or choose what each one shows: blink on transmit, on receive or both, and a heartbeat that can signal unread messages. The receive blink tells you at a glance whether a repeater is hearing anything. +- **Off-grid mode from the device.** A companion can relay for a group of handhelds out of reach of any + repeater. Switch it on from the display or the CLI and the node moves to the off-grid frequency of its + band and back by itself. ([OFFGRID.md](docs/OFFGRID.md)) - **2.4 GHz on LR2021 boards.** Set a 2.4 GHz frequency and the radio switches to its high-band path on its own. - **Runs on Linux too.** The same code runs as a normal process on a Raspberry Pi or Femtofox with a real @@ -169,6 +172,7 @@ ZephCore is a plain west workspace application, and it tries to be a well-behave | [adr/](docs/adr/README.md) | Decisions and why they were made | | [BUILDING.md](docs/BUILDING.md) | Every build variant, platform notes, Kconfig options | | [CLI_commands.md](docs/CLI_commands.md) | Every CLI command | +| [OFFGRID.md](docs/OFFGRID.md) | Off-grid mode: a companion that relays, and the frequencies it runs on | | [supported_boards.md](docs/supported_boards.md) | Boards and build strings | | [LINUX_NATIVE.md](docs/LINUX_NATIVE.md) | Running on a Pi or Femtofox | | [releasenotes/](releasenotes/) | What changed in each release | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bb3829a..bfdcfb0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -604,6 +604,13 @@ Handles the binary BLE protocol with ~50 command opcodes. Key features: - **Protocol versioning**: V2/V3 frame format negotiation with phone app - **Ed25519 signing**: 3-phase flow (start→data→finish) for signing up to 8KB - **Flood scope**: Transport key filtering for region-scoped sends +- **Off-grid mode** (upstream "client repeat", user view in `OFFGRID.md`): the companion forwards, unfiltered, only + while `client_repeat` is set **and** `prefs.freq` is one of upstream's off-grid frequencies (433.000 / 869.495 / + 918.000 MHz). `helpers/OffgridPolicy.h` holds that rule and the prefs transitions; `allowPacketForward()`, the + boot-time load, `CMD_DEVICE_QUERY` byte 80 and both device UIs go through it. The app's `CMD_SET_RADIO_PARAMS` + sets frequency and flag together, as upstream. The device toggle and the companion's `set repeat` call + `CompanionMesh::setOffgridMode()`: on = nearest off-grid frequency, the one left kept in `zc.og_home`; off = back + to it. BW, SF, CR and TX power are never touched ### 6.2.1 V-Contact (Loopback Admin Contact) diff --git a/docs/CLI_commands.md b/docs/CLI_commands.md index 6f76ecf..3001610 100644 --- a/docs/CLI_commands.md +++ b/docs/CLI_commands.md @@ -337,7 +337,7 @@ The observer role (ESP32, `boards/common/observer.conf`) has its own CLI and, ap |---------|---------| | `get name` | Node name | | `get role` | Firmware role: `repeater` or `room_server` (companion builds report `companion`) | -| `get repeat` | Forwarding enabled: `on` or `off` | +| `get repeat` | Forwarding enabled: `on` or `off`. On a companion this is off-grid mode ([OFFGRID.md](OFFGRID.md)): `on` only while repeat is set and the node is on an off-grid frequency | | `get radio` | Radio params as `freq,bw,sf,cr` — the same comma-separated form `set radio` takes, so a reply can be edited and sent straight back | | `get freq` | Frequency in MHz | | `get freqerr` | *(ZephCore only)* Carrier frequency error measured on received packets: `mean N Hz, min A, max B, K pkts`. **LR2021 only** — other radios answer `not available`. Purely diagnostic; nothing acts on it. **The mean only approximates *this* node's reference error once it is averaged over many different peers** — their individual errors cancel, ours does not — so read `K` and the min/max spread before believing it: a tight spread over a handful of packets is one chatty neighbour, not a population. Small values are the expected answer and mean there is nothing to do; LoRa tolerates carrier error up to roughly a quarter of the bandwidth before sensitivity suffers, so at BW 62.5 kHz a few hundred Hz is noise. If it is kHz-scale the correction is board-dependent: XTAL parts have `SetXoscCpTrim`, but **TCXO parts have no chip-side trim at all** (DS §6.11.4: "If a TCXO is configured, this command has no effect"), leaving only a software offset to the programmed frequency. Values beyond ±200 kHz are discarded by the driver and warn once — the field is decoded from three `GetLoraPacketStatus` bytes that DS rev 2.1 does not document, so implausible readings are evidence the field is not real on that firmware rather than a genuine measurement. Reset by `clear stats`. | @@ -421,7 +421,7 @@ four radio parameters together, since they are one interop-critical set. | Command | Constraints | Description | |---------|-------------|-------------| | `set name ` | No `[ ] \ : , ? *` | Set node name | -| `set repeat ` | | Enable or disable packet forwarding | +| `set repeat ` | | Enable or disable packet forwarding. **Companion:** off-grid mode, the same as the display toggle ([OFFGRID.md](OFFGRID.md)), local only and applied at once. `on` moves the node to the nearest of 433.000 / 869.495 / 918.000 MHz and starts forwarding; `off` returns to the frequency it left. A node already on an off-grid frequency stays there both ways. BW, SF, CR and TX power are not touched; the frequency move resets the learned CAD offset. Replies `OK - repeat is now ON (869.495 MHz)`, or `Error: no off-grid frequency for this band` on a 2.4 GHz preset. `set radio` / `set freq` to any other frequency turns it off | | `set radio ,,,` | freq 150–2500, bw 7–500 (7–1000 on LR2021), sf 5–12, cr 5–8 | **Comma-separated**, not space-separated — spaces parse as a single argument and the command is rejected. Set radio params *(reboot required)*. If freq, bw or sf actually changed, this also performs a full `set cad.reset` and replies `OK - reboot to apply (CAD reset)` — the learned detPeak offset and its probe statistics belong to the old preset. The radio keeps running the old preset, at its learned offset, until the reboot; CAD adaptation is suspended in the meantime so it cannot re-dirty the reset. A cr-only change resets nothing. | | `set freq ` | 150–2500 *(USB only)* | Set frequency alone *(reboot required)*. Same CAD reset as `set radio` when the frequency actually changes. On LR2021 boards a frequency at or above 1500 MHz also moves the radio to its 2.4 GHz front end — see the note below the table. | | `set tx ` | −9 to board max (default 30) | Set TX power | diff --git a/docs/OFFGRID.md b/docs/OFFGRID.md new file mode 100644 index 0000000..80a11df --- /dev/null +++ b/docs/OFFGRID.md @@ -0,0 +1,49 @@ +# Off-grid mode + +Off-grid mode makes a companion forward packets for others, like a small repeater. It is for a group +of handhelds that is out of reach of any repeater: each node relays for the rest, so messages get +further than one radio hop. + +It is MeshCore's "client repeat". ZephCore nodes and stock MeshCore nodes in off-grid mode work +together. + +## It runs on its own frequencies + +A companion forwards only on one of three frequencies set aside for this: + +| Band | Off-grid frequency | +|---|---| +| 433 MHz | 433.000 MHz | +| EU 868 MHz | 869.495 MHz | +| US 915 MHz | 918.000 MHz | + +Turning off-grid mode on therefore **moves the node off your normal mesh**. While it is on you reach +only other nodes on the same off-grid frequency with the same bandwidth, spreading factor and coding +rate. Those three, and the TX power, are left as they were, so a group that switches from the same +mesh preset ends up on matching settings. + +A companion never forwards on a normal mesh frequency. It forwards everything it hears, without the +hop limits, region filters and loop detection a repeater applies, which is fine for a handful of +handhelds and harmful on a busy mesh. + +## Turning it on and off + +**From the app.** Choose one of the off-grid frequencies in the radio settings and enable repeat. +When you turn repeat off in the app the node stays on that frequency: select your mesh preset again +to return. + +**From the display.** The Offgrid page (button boards) or System > Device > Offgrid (joystick boards) +asks for confirmation, then moves to the off-grid frequency nearest to the one you are on and starts +forwarding. Turning it off there returns to the frequency you came from. If the node was already on an +off-grid frequency when you turned it on, it stays there. + +**From the CLI** (USB or the app's command line, not over the mesh): `set repeat on` and +`set repeat off` do the same as the display, and `get repeat` shows the state. + +On a 2.4 GHz preset there is no off-grid frequency, and the display and the CLI refuse. + +## After an update from 1.17.7 or earlier + +Up to 1.17.7 the display toggle turned forwarding on without changing the frequency, so a companion +could end up repeating on the live mesh. A node left in that state stops forwarding when it boots +1.17.8 or later, and shows off-grid mode as off. diff --git a/releasenotes/RELEASE_NOTES_1.17.8-zephcore.md b/releasenotes/RELEASE_NOTES_1.17.8-zephcore.md index de81a0a..ef9d7eb 100644 --- a/releasenotes/RELEASE_NOTES_1.17.8-zephcore.md +++ b/releasenotes/RELEASE_NOTES_1.17.8-zephcore.md @@ -1,6 +1,7 @@ # ZephCore 1.17.8-zephcore -Button fixes on four boards with a display: the Station G2's button now drives the pages, and +Off-grid mode from the display no longer turns a companion into a repeater on the live mesh. Button +fixes on four boards with a display: the Station G2's button now drives the pages, and Power Off, DFU and Offgrid can be confirmed on the Heltec Mesh Node T1 and both LR2021 LoRa Plus EVK kits. @@ -9,6 +10,28 @@ kits. --- +## Off-grid mode: only on the off-grid frequencies + +Off-grid mode makes a companion forward packets for others, for a group of handhelds out of reach of +any repeater. It belongs on three frequencies set aside for it: 433.000, 869.495 and 918.000 MHz. The +app only allows it there, but the Offgrid toggle on the display switched forwarding on without +changing the frequency, so the node repeated everything it heard on the live mesh. + +- **The display toggle now moves the node.** Turning Offgrid on goes to the off-grid frequency nearest + to the one you are on and starts forwarding; turning it off returns to the frequency you came from. + Bandwidth, spreading factor, coding rate and TX power stay as they are. +- **A companion never forwards on any other frequency.** If you had switched Offgrid on from the + display on an earlier version, the node stops repeating after this update and shows Offgrid as off. + Nothing else changes; turn it on again if you want off-grid mode. +- **`set repeat on` / `set repeat off` work on a companion** and do the same as the display toggle; + `get repeat` shows the real state. Before, they answered but did nothing. +- **The display follows the app.** Offgrid switched from the app or the CLI now shows on the display + page within a few seconds. + +While off-grid mode is on, the node is off your normal mesh. How it works: [OFFGRID.md](../docs/OFFGRID.md). + +Thanks to N8HOP for the report. + ## Station G2: the user button drives the display Until now a press of the user button only woke the display: the pages could not be turned, and diff --git a/tests/catalog.json b/tests/catalog.json index 3f456a5..caaf746 100644 --- a/tests/catalog.json +++ b/tests/catalog.json @@ -352,6 +352,21 @@ "name": "Scoped unscoped and advert hop boundaries", "source": "tests/unit/policy_tests.cpp" }, + { + "id": "UNIT-OFFGRID-001", + "name": "Off-grid frequency: the three allowed, nearest per band, none above sub-GHz", + "source": "tests/unit/policy_tests.cpp" + }, + { + "id": "UNIT-OFFGRID-002", + "name": "Off-grid toggle: to the off-grid frequency and back, preset otherwise untouched", + "source": "tests/unit/policy_tests.cpp" + }, + { + "id": "UNIT-OFFGRID-003", + "name": "Off-grid: a repeat flag on a mesh frequency is dropped, app and CLI edits drop the home frequency", + "source": "tests/unit/policy_tests.cpp" + }, { "id": "UNIT-POOL-001", "name": "Exhaust reclaim and reuse all pool slots", diff --git a/tests/unit/policy_tests.cpp b/tests/unit/policy_tests.cpp index 9762b45..d44c5ac 100644 --- a/tests/unit/policy_tests.cpp +++ b/tests/unit/policy_tests.cpp @@ -1,5 +1,6 @@ #include "test.h" #include +#include #include #include using namespace mesh; @@ -47,3 +48,73 @@ TEST(maintenance_wrap, "UNIT-TIME-001", "Maintenance deadlines across uint32 wra CHECK(maintenanceSooner(MAINTENANCE_IDLE, 32) == 32); CHECK(maintenanceSooner(0, MAINTENANCE_IDLE) == 0); } + +namespace { +NodePrefs offgridPrefs(float freq, uint8_t repeat = 0, float home = 0.0f) { + NodePrefs p{}; + p.freq = freq; p.bw = 62.5f; p.sf = 7; p.cr = 5; p.tx_power_dbm = 22; + p.client_repeat = repeat; p.offgrid_home_freq = home; + return p; +} +} // namespace + +TEST(offgrid_freq_pick, "UNIT-OFFGRID-001", "Off-grid frequency: the three allowed, nearest per band, none above sub-GHz") { + CHECK(isOffgridFreq(433.0f) && isOffgridFreq(869.495f) && isOffgridFreq(918.0f)); + CHECK(!isOffgridFreq(869.618f) && !isOffgridFreq(869.494f) && !isOffgridFreq(910.525f)); + CHECK(isOffgridFreqKhz(869495) && !isOffgridFreqKhz(869496)); + CHECK(nearestOffgridFreqKhz(869.618f) == 869495); + CHECK(nearestOffgridFreqKhz(868.1f) == 869495); + CHECK(nearestOffgridFreqKhz(910.525f) == 918000); + CHECK(nearestOffgridFreqKhz(927.875f) == 918000); + CHECK(nearestOffgridFreqKhz(433.875f) == 433000); + CHECK(nearestOffgridFreqKhz(150.0f) == 433000); + CHECK(nearestOffgridFreqKhz(869.495f) == 869495); + CHECK(nearestOffgridFreqKhz(2445.0f) == 0); + CHECK(nearestOffgridFreqKhz(0.0f) == 0); +} +TEST(offgrid_toggle, "UNIT-OFFGRID-002", "Off-grid toggle: to the off-grid frequency and back, preset otherwise untouched") { + NodePrefs p = offgridPrefs(869.618f); + CHECK(!offgridActive(p)); + CHECK(offgridSet(p, true) == OFFGRID_RETUNED); + CHECK(offgridActive(p) && p.freq == 869.495f && p.offgrid_home_freq == 869.618f); + CHECK(p.bw == 62.5f && p.sf == 7 && p.cr == 5 && p.tx_power_dbm == 22); + CHECK(offgridSet(p, true) == OFFGRID_UNCHANGED); + CHECK(offgridSet(p, false) == OFFGRID_RETUNED); + CHECK(!offgridActive(p) && p.freq == 869.618f && p.offgrid_home_freq == 0.0f && p.client_repeat == 0); + CHECK(offgridSet(p, false) == OFFGRID_UNCHANGED); + + // Already on an off-grid frequency (the app left it there): flag only, both ways. + p = offgridPrefs(918.0f); + CHECK(offgridSet(p, true) == OFFGRID_SET); + CHECK(offgridActive(p) && p.freq == 918.0f && p.offgrid_home_freq == 0.0f); + CHECK(offgridSet(p, false) == OFFGRID_SET); + CHECK(!offgridActive(p) && p.freq == 918.0f); + + // No off-grid frequency on 2.4 GHz: refused, nothing changes. + p = offgridPrefs(2445.0f); + CHECK(offgridSet(p, true) == OFFGRID_NO_BAND); + CHECK(p.client_repeat == 0 && p.freq == 2445.0f && p.offgrid_home_freq == 0.0f); +} +TEST(offgrid_normalize, "UNIT-OFFGRID-003", "Off-grid: a repeat flag on a mesh frequency is dropped, app and CLI edits drop the home frequency") { + // What the pre-1.17.8 device toggle left behind. + NodePrefs p = offgridPrefs(869.618f, 1); + CHECK(!offgridActive(p)); + CHECK(offgridNormalize(p) && p.client_repeat == 0 && p.freq == 869.618f); + CHECK(!offgridNormalize(p)); + // Off-grid mode on is left alone, home frequency included. + p = offgridPrefs(869.495f, 1, 869.618f); + CHECK(!offgridNormalize(p) && offgridActive(p) && p.offgrid_home_freq == 869.618f); + // The app turned repeat off and stayed on the off-grid frequency. + p = offgridPrefs(869.495f, 0, 869.618f); + offgridFreqEdited(p); + CHECK(p.offgrid_home_freq == 0.0f && p.freq == 869.495f && p.client_repeat == 0); + // The app moved to another off-grid frequency with repeat on: stays on, home gone. + p = offgridPrefs(433.0f, 1, 869.618f); + offgridFreqEdited(p); + CHECK(offgridActive(p) && p.offgrid_home_freq == 0.0f); + CHECK(offgridSet(p, false) == OFFGRID_SET && p.freq == 433.0f); + // The CLI set a mesh frequency while off-grid mode was on: off, and nothing to return to. + p = offgridPrefs(868.0f, 1, 869.618f); + offgridFreqEdited(p); + CHECK(p.client_repeat == 0 && p.offgrid_home_freq == 0.0f && p.freq == 868.0f); +} diff --git a/tests/unit/prefs_json_tests.cpp b/tests/unit/prefs_json_tests.cpp index faa8a75..1c5a8d2 100644 --- a/tests/unit/prefs_json_tests.cpp +++ b/tests/unit/prefs_json_tests.cpp @@ -47,7 +47,7 @@ NodePrefs sample() { for (int i = 0; i < 16; i++) p.default_scope_key[i] = (uint8_t)(i + 1); p.ble_disabled = 1; p.display_brightness = 50; p.wake_on_msg = 0; p.screen_off_secs = 60; p.auto_shutdown_mv = 3300; p.v_contact_enabled = 0; p.v_battery_alert_mv = 3500; - p.v_contact_flags = 0x81; p.wifi_enabled = 0; + p.v_contact_flags = 0x81; p.wifi_enabled = 0; p.offgrid_home_freq = 869.618f; strcpy(p.wifi_ssid, "MyNet"); strcpy(p.wifi_pwd, "secret"); return p; } @@ -75,6 +75,7 @@ TEST(pjson_companion_roundtrip, "UNIT-PJSON-001", "Companion prefs survive prefs CHECK(companionBytes(q) == companionBytes(p)); // prefs.json only: not in the binary layout. CHECK(q.led_brightness == 42); + CHECK(q.offgrid_home_freq == 869.618f); } TEST(pjson_server_roundtrip, "UNIT-PJSON-002", "Server prefs survive prefs.json field for field") { diff --git a/zephcore/app/CompanionCLI.h b/zephcore/app/CompanionCLI.h index a7b8321..cf57c1d 100644 --- a/zephcore/app/CompanionCLI.h +++ b/zephcore/app/CompanionCLI.h @@ -129,6 +129,18 @@ public: (void)freq; (void)bw; (void)sf; (void)cr; (void)timeout_mins; } + /* `get repeat` / `set repeat`: off-grid mode, the same call as the device toggle. */ + bool hasOffgridMode() const override { return true; } + bool isOffgridActive() const override { return _mesh.isOffgridActive(); } + void setOffgridMode(bool on, char* reply) override { + if (_mesh.setOffgridMode(on) == mesh::OFFGRID_NO_BAND) { + strcpy(reply, "Error: no off-grid frequency for this band"); + } else { + snprintf(reply, CLI_REPLY_SIZE, "OK - repeat is now %s (%.3f MHz)", + _mesh.isOffgridActive() ? "ON" : "OFF", (double)_mesh.prefs.freq); + } + } + MeshTimeSync* getMeshTimeSync() override { return _mesh.getMeshTimeSync(); } diff --git a/zephcore/app/CompanionMesh.cpp b/zephcore/app/CompanionMesh.cpp index 9e8f24d..6d13452 100644 --- a/zephcore/app/CompanionMesh.cpp +++ b/zephcore/app/CompanionMesh.cpp @@ -39,27 +39,6 @@ static void notify_contact_msg_ui( } #endif -/* Allowed client repeat frequency ranges (matches Arduino) */ -struct FreqRange { - uint32_t lower_freq, upper_freq; -}; - -static const FreqRange repeat_freq_ranges[] = { - { 433000, 433000 }, /* 433 MHz (ISM band) */ - { 869495, 869495 }, /* 869.495 MHz (EU 869.4-869.65 band, 10% DC, 500mW ERP) */ - { 918000, 918000 }, /* 918 MHz (US ISM band) */ -}; - -static bool isValidClientRepeatFreq(uint32_t f) -{ - for (size_t i = 0; i < sizeof(repeat_freq_ranges) / sizeof(repeat_freq_ranges[0]); i++) { - if (f >= repeat_freq_ranges[i].lower_freq && f <= repeat_freq_ranges[i].upper_freq) { - return true; - } - } - return false; -} - CompanionMesh::CompanionMesh(mesh::Radio &radio, mesh::MillisecondClock &ms, mesh::RNG &rng, mesh::RTCClock &rtc, mesh::PacketManager &mgr, mesh::MeshTables &tables, ZephyrDataStore &store) @@ -143,7 +122,27 @@ void CompanionMesh::begin() bool CompanionMesh::allowPacketForward(const mesh::Packet *packet) { (void)packet; - return prefs.client_repeat != 0; + return mesh::offgridActive(prefs); +} + +bool CompanionMesh::isOffgridActive() const +{ + return mesh::offgridActive(prefs); +} + +mesh::OffgridResult CompanionMesh::setOffgridMode(bool on) +{ + mesh::OffgridResult r = mesh::offgridSet(prefs, on); + + if (r == mesh::OFFGRID_SET || r == mesh::OFFGRID_RETUNED) { + _store->savePrefs(prefs); + } + if (r == mesh::OFFGRID_RETUNED && _radio_reconfig_cb) { + _radio_reconfig_cb(true); + } + LOG_INF("offgrid %s: result=%d freq=%u kHz", on ? "on" : "off", (int)r, + (unsigned)mesh::offgridFreqKhz(prefs.freq)); + return r; } void CompanionMesh::sendFloodScoped(const TransportKey &scope, mesh::Packet *pkt, uint32_t delay_millis) @@ -1789,7 +1788,7 @@ bool CompanionMesh::handleCmdDeviceQuery(const uint8_t *data, size_t len) memcpy(&rsp[8], fw_build, 12); memcpy(&rsp[20], model, 40); memcpy(&rsp[60], version, 20); - rsp[80] = prefs.client_repeat; // v9+: offgrid mode state + rsp[80] = mesh::offgridActive(prefs) ? 1 : 0; // v9+: offgrid mode state rsp[81] = prefs.path_hash_mode; // v10+: path hash mode writeFrame(rsp, sizeof(rsp)); return true; @@ -2439,7 +2438,7 @@ bool CompanionMesh::handleCmdSetRadioParams(const uint8_t *data, size_t len) repeat = data[11]; // v9+: client repeat flag } // If repeat requested, validate frequency against allowed bands - if (repeat && !isValidClientRepeatFreq(freq)) { + if (repeat && !mesh::isOffgridFreqKhz(freq)) { writeErrFrame(ERR_ILLEGAL_ARG); /* freq arrives in kHz, bw in Hz. Same limits as the USB * CLI and the prefs loader — see NodePrefs.h. Accepting @@ -2464,6 +2463,7 @@ bool CompanionMesh::handleCmdSetRadioParams(const uint8_t *data, size_t len) prefs.sf = sf; prefs.cr = cr; prefs.client_repeat = repeat; + mesh::offgridFreqEdited(prefs); _store->savePrefs(prefs); if (_radio_reconfig_cb) _radio_reconfig_cb(preset_changed); LOG_INF("SET_RADIO_PARAMS: client_repeat=%d", repeat); @@ -3392,12 +3392,12 @@ bool CompanionMesh::handleCmdGetAutoaddConfig(const uint8_t *data, size_t len) bool CompanionMesh::handleCmdGetAllowedRepeatFreq(const uint8_t *data, size_t len) { - uint8_t rsp[1 + sizeof(repeat_freq_ranges)]; + uint8_t rsp[1 + sizeof(mesh::OFFGRID_FREQ_RANGES)]; int i = 0; rsp[i++] = PACKET_ALLOWED_REPEAT_FREQ; - for (size_t k = 0; k < sizeof(repeat_freq_ranges) / sizeof(repeat_freq_ranges[0]); k++) { - put_le32(&rsp[i], repeat_freq_ranges[k].lower_freq); i += 4; - put_le32(&rsp[i], repeat_freq_ranges[k].upper_freq); i += 4; + for (const mesh::OffgridFreqRange &r : mesh::OFFGRID_FREQ_RANGES) { + put_le32(&rsp[i], r.lower_khz); i += 4; + put_le32(&rsp[i], r.upper_khz); i += 4; } writeFrame(rsp, i); return true; diff --git a/zephcore/app/CompanionMesh.h b/zephcore/app/CompanionMesh.h index 61f09cf..99eb850 100644 --- a/zephcore/app/CompanionMesh.h +++ b/zephcore/app/CompanionMesh.h @@ -11,6 +11,7 @@ #include #include #include +#include #include /* Push notification codes */ @@ -201,6 +202,13 @@ public: * zephcore_persist_before_off(). */ void flushPendingWrites() { flushDirtyContacts(); flushDirtyChannels(); } + /* Off-grid mode (upstream "client repeat"): forwarding, allowed only on the + * off-grid frequencies. setOffgridMode() is the device toggle and the CLI's + * `set repeat`: it retunes to the nearest off-grid frequency and back, saves, + * and reconfigures the radio. Main thread only. */ + bool isOffgridActive() const; + mesh::OffgridResult setOffgridMode(bool on); + NodePrefs prefs; protected: diff --git a/zephcore/helpers/CommonCLI.cpp b/zephcore/helpers/CommonCLI.cpp index af12f3c..309e91d 100644 --- a/zephcore/helpers/CommonCLI.cpp +++ b/zephcore/helpers/CommonCLI.cpp @@ -13,6 +13,7 @@ #include #endif #include +#include #include extern "C" void power_diag_line(int n, char *out, size_t cap); /* TEMPORARY: helpers/power_diag.c */ #include @@ -855,6 +856,7 @@ bool CommonCLI::handleRadioCmd(uint32_t sender_timestamp, const char* command, c _prefs->bw = bw; _prefs->sf = sf; _prefs->cr = cr; + mesh::offgridFreqEdited(*_prefs); _callbacks->savePrefs(); _callbacks->freezeRadioParams(old_freq, old_bw, old_sf, old_cr); /* Anything that moves the channel or the detPeak base table @@ -920,6 +922,7 @@ bool CommonCLI::handleRadioCmd(uint32_t sender_timestamp, const char* command, c f >= 150.0f && f <= 2500.0f) { float old_freq = _prefs->freq; _prefs->freq = f; + mesh::offgridFreqEdited(*_prefs); savePrefs(); /* Keep _prefs->freq = f in RAM so a later savePrefs() (from any * other "set" command before reboot) can't rewrite the old freq @@ -1024,7 +1027,8 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, const char* command, cha } else if (memcmp(config, "name", 4) == 0) { snprintf(reply, CLI_REPLY_SIZE, "> %s", _prefs->node_name); } else if (memcmp(config, "repeat", 6) == 0) { - snprintf(reply, CLI_REPLY_SIZE, "> %s", _prefs->disable_fwd ? "off" : "on"); + bool on = _callbacks->hasOffgridMode() ? _callbacks->isOffgridActive() : !_prefs->disable_fwd; + snprintf(reply, CLI_REPLY_SIZE, "> %s", on ? "on" : "off"); } else if (memcmp(config, "lat", 3) == 0) { snprintf(reply, CLI_REPLY_SIZE, "> %.6f", _prefs->node_lat); } else if (memcmp(config, "lon", 3) == 0) { @@ -1473,7 +1477,15 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, const char* command, cha strcpy(reply, "Error: name cannot contain [ ] \\ : , ? *"); } } else if (memcmp(config, "repeat ", 7) == 0) { - if (memcmp(&config[7], "on", 2) == 0) { + bool on = memcmp(&config[7], "on", 2) == 0; + if (_callbacks->hasOffgridMode() && (on || memcmp(&config[7], "off", 3) == 0)) { + /* Local only: it moves the node to another frequency. */ + if (sender_timestamp == 0) { + _callbacks->setOffgridMode(on, reply); + } else { + strcpy(reply, "Error: local only"); + } + } else if (on) { _prefs->disable_fwd = 0; savePrefs(); strcpy(reply, "OK - repeat is now ON"); diff --git a/zephcore/helpers/CommonCLI.h b/zephcore/helpers/CommonCLI.h index 948094a..d95230e 100644 --- a/zephcore/helpers/CommonCLI.h +++ b/zephcore/helpers/CommonCLI.h @@ -79,6 +79,12 @@ public: * _prefs takes the new ones for the next boot. No-op under tempradio. */ virtual void freezeRadioParams(float freq, float bw, uint8_t sf, uint8_t cr) { (void)freq; (void)bw; (void)sf; (void)cr; } + /* Companion: `get repeat` / `set repeat` are off-grid mode, not the + * servers' repeat pref. setOffgridMode() writes the reply. */ + virtual bool hasOffgridMode() const { return false; } + virtual bool isOffgridActive() const { return false; } + virtual void setOffgridMode(bool on, char* reply) { (void)on; (void)reply; } + // Adaptive contention window virtual float getContentionEstimate() const { return -1.0f; } virtual float getFloodDelayFactor() const { return 0.5f; } diff --git a/zephcore/helpers/NodePrefs.h b/zephcore/helpers/NodePrefs.h index a9816f1..84af596 100644 --- a/zephcore/helpers/NodePrefs.h +++ b/zephcore/helpers/NodePrefs.h @@ -168,6 +168,9 @@ struct NodePrefs { uint8_t buzzer_quiet; uint8_t autoadd_config; uint8_t client_repeat; // 1 = offgrid mode (forward packets) + /* MHz the device toggle left when it turned off-grid mode on, 0 = none. + * Only meaningful while off-grid mode is on (helpers/OffgridPolicy.h). */ + float offgrid_home_freq; uint8_t path_hash_mode; // path mode 0-2 uint8_t autoadd_max_hops; // 0 = no limit, N = up to N-1 hops uint8_t loop_detect; // LOOP_DETECT_{OFF,MINIMAL,MODERATE,STRICT} @@ -277,6 +280,8 @@ static inline void sanitizeNodePrefs(NodePrefs* p) { p->airtime_factor = sanePrefFloat(p->airtime_factor, 0.0f, 9.0f, 9.0f); p->rx_delay_base = sanePrefFloat(p->rx_delay_base, 0.0f, 3600.0f, 0.0f); p->adc_multiplier = sanePrefFloat(p->adc_multiplier, 0.0f, 30000.0f, 0.0f); + p->offgrid_home_freq = sanePrefFloat(p->offgrid_home_freq, ZC_RADIO_FREQ_MIN_MHZ, + ZC_RADIO_FREQ_MAX_MHZ, 0.0f); /* Range matches the CLI (0.0-2.0). 0.0 is a legal value meaning "reactive * backoff off" and must survive: this guard exists for NaN and corruption * only. RepeaterDataStore used to coerce 0.0 -> 0.2 here, which silently diff --git a/zephcore/helpers/OffgridPolicy.h b/zephcore/helpers/OffgridPolicy.h new file mode 100644 index 0000000..fb3ff28 --- /dev/null +++ b/zephcore/helpers/OffgridPolicy.h @@ -0,0 +1,132 @@ +/* + * SPDX-License-Identifier: MIT + * ZephCore OffgridPolicy - companion off-grid mode (upstream "client repeat") + * + * The frequencies a companion may forward on, and the prefs transitions behind + * the app command, the device toggle and the CLI. Pure decision logic, no I/O. + * Design: devdocs/lld/06-companion.md. + */ + +#pragma once + +#include +#include "NodePrefs.h" + +namespace mesh { + +struct OffgridFreqRange { + uint32_t lower_khz, upper_khz; +}; + +/* Upstream's allowed client-repeat frequencies; CMD_GET_ALLOWED_REPEAT_FREQ + * reports this table to the app. */ +static const OffgridFreqRange OFFGRID_FREQ_RANGES[] = { + { 433000, 433000 }, /* 433 MHz ISM */ + { 869495, 869495 }, /* EU 869.4-869.65 MHz */ + { 918000, 918000 }, /* US 902-928 MHz */ +}; +#define OFFGRID_FREQ_RANGE_COUNT (sizeof(mesh::OFFGRID_FREQ_RANGES) / sizeof(mesh::OFFGRID_FREQ_RANGES[0])) + +/* Top of the sub-GHz LoRa range; above it no off-grid frequency applies. */ +#define OFFGRID_SUBGHZ_MAX_MHZ 960.0f + +inline uint32_t offgridFreqKhz(float mhz) +{ + return mhz > 0.0f ? (uint32_t)(mhz * 1000.0f + 0.5f) : 0; +} + +inline bool isOffgridFreqKhz(uint32_t khz) +{ + for (const OffgridFreqRange &r : OFFGRID_FREQ_RANGES) { + if (khz >= r.lower_khz && khz <= r.upper_khz) return true; + } + return false; +} + +inline bool isOffgridFreq(float mhz) { return isOffgridFreqKhz(offgridFreqKhz(mhz)); } + +/* The off-grid frequency (kHz) nearest to `mhz`; 0 above the sub-GHz range. */ +inline uint32_t nearestOffgridFreqKhz(float mhz) +{ + if (!(mhz > 0.0f && mhz <= OFFGRID_SUBGHZ_MAX_MHZ)) return 0; + + uint32_t khz = offgridFreqKhz(mhz); + uint32_t best = 0, best_dist = UINT32_MAX; + for (const OffgridFreqRange &r : OFFGRID_FREQ_RANGES) { + uint32_t c = khz < r.lower_khz ? r.lower_khz : (khz > r.upper_khz ? r.upper_khz : khz); + uint32_t dist = c > khz ? c - khz : khz - c; + if (dist < best_dist) { + best_dist = dist; + best = c; + } + } + return best; +} + +/* Off-grid mode is on: repeat is set AND the node is on an off-grid frequency. */ +inline bool offgridActive(const NodePrefs &p) +{ + return p.client_repeat != 0 && isOffgridFreq(p.freq); +} + +/* Drops a repeat flag left on any other frequency, and the remembered home + * frequency with it (it only means something while off-grid mode is on). + * True if prefs changed. */ +inline bool offgridNormalize(NodePrefs &p) +{ + if (offgridActive(p)) return false; + + bool changed = p.client_repeat != 0 || p.offgrid_home_freq != 0.0f; + p.client_repeat = 0; + p.offgrid_home_freq = 0.0f; + return changed; +} + +/* The frequency was set by the app or the CLI: whoever set it owns it, so the + * remembered home frequency goes. */ +inline void offgridFreqEdited(NodePrefs &p) +{ + p.offgrid_home_freq = 0.0f; + offgridNormalize(p); +} + +enum OffgridResult : uint8_t { + OFFGRID_UNCHANGED, /* already in the requested state */ + OFFGRID_SET, /* repeat flag changed, frequency kept */ + OFFGRID_RETUNED, /* repeat flag and frequency changed: reconfigure the radio */ + OFFGRID_NO_BAND, /* refused: no off-grid frequency for the current band */ +}; + +/* The device toggle and `set repeat`. On: move to the nearest off-grid + * frequency and remember the one left. Off: return to it. A node already on + * an off-grid frequency stays there both ways. BW, SF, CR and TX power are + * never touched (as the app). */ +inline OffgridResult offgridSet(NodePrefs &p, bool on) +{ + if (on) { + if (offgridActive(p)) return OFFGRID_UNCHANGED; + if (isOffgridFreq(p.freq)) { + p.client_repeat = 1; + p.offgrid_home_freq = 0.0f; + return OFFGRID_SET; + } + uint32_t khz = nearestOffgridFreqKhz(p.freq); + if (khz == 0) return OFFGRID_NO_BAND; + p.offgrid_home_freq = p.freq; + p.freq = (float)khz / 1000.0f; + p.client_repeat = 1; + return OFFGRID_RETUNED; + } + + bool was_set = p.client_repeat != 0; + float home = offgridActive(p) ? p.offgrid_home_freq : 0.0f; + p.client_repeat = 0; + p.offgrid_home_freq = 0.0f; + if (home != 0.0f) { + p.freq = home; + return OFFGRID_RETUNED; + } + return was_set ? OFFGRID_SET : OFFGRID_UNCHANGED; +} + +} /* namespace mesh */ diff --git a/zephcore/helpers/PrefsJson.cpp b/zephcore/helpers/PrefsJson.cpp index 960d19c..e996562 100644 --- a/zephcore/helpers/PrefsJson.cpp +++ b/zephcore/helpers/PrefsJson.cpp @@ -144,6 +144,7 @@ protected: def("vbat_mv", _p->v_battery_alert_mv); def("adc_mult", _p->adc_multiplier); def("pwr_sav_en", _p->powersaving_enabled); + def("og_home", _p->offgrid_home_freq); } public: diff --git a/zephcore/helpers/ui-button/ui_pages.c b/zephcore/helpers/ui-button/ui_pages.c index ba4a487..3ab6f93 100644 --- a/zephcore/helpers/ui-button/ui_pages.c +++ b/zephcore/helpers/ui-button/ui_pages.c @@ -16,6 +16,7 @@ #include "ui_pages.h" #include "ui_task.h" +#include "ui_mesh_actions.h" #include "display.h" #include @@ -1391,8 +1392,9 @@ static void render_offgrid_mono(void) draw_centered(centered_row(2, 3), "Press to confirm"); } else { draw_centered(centered_row(2, 3), - state.offgrid_enabled ? "Press to Disable" - : "Press to Enable"); + state.offgrid_enabled ? "Press to Disable" : + mesh_offgrid_available() ? "Press to Enable" + : "Not on this band"); } } @@ -1417,8 +1419,9 @@ static void render_offgrid_color(void) draw_centered_color(y, state.offgrid_confirm_time != 0 ? "Press to confirm" : - (state.offgrid_enabled ? "Press to disable" - : "Press to enable"), + (state.offgrid_enabled ? "Press to disable" : + mesh_offgrid_available() ? "Press to enable" + : "Not on this band"), state.offgrid_confirm_time != 0 ? UI_COLOR_WARN : UI_COLOR_VALUE); } diff --git a/zephcore/helpers/ui-button/ui_task.c b/zephcore/helpers/ui-button/ui_task.c index 61c1533..242d06b 100644 --- a/zephcore/helpers/ui-button/ui_task.c +++ b/zephcore/helpers/ui-button/ui_task.c @@ -305,7 +305,7 @@ static void action_page_enter(void) st_og->offgrid_confirm_time = 0; mesh_set_offgrid_mode(new_state); LOG_INF("offgrid mode %s (button)", new_state ? "on" : "off"); - } else { + } else if (st_og->offgrid_enabled || mesh_offgrid_available()) { /* First press — enter confirmation state */ st_og->offgrid_confirm_time = now_og; } @@ -1135,8 +1135,12 @@ void ui_set_buzzer_mode(uint8_t mode) void ui_set_offgrid_mode(bool enabled) { struct ui_state *s = get_state(); + bool changed = s->offgrid_enabled != enabled; s->offgrid_enabled = enabled; + if (changed) { + schedule_render_auto(); + } } /* Schedule a redraw for an automatic (non-interactive) state update, e.g. a diff --git a/zephcore/helpers/ui-joystick/joystick_ui_task.cpp b/zephcore/helpers/ui-joystick/joystick_ui_task.cpp index 4b119bc..5b9cde4 100644 --- a/zephcore/helpers/ui-joystick/joystick_ui_task.cpp +++ b/zephcore/helpers/ui-joystick/joystick_ui_task.cpp @@ -1050,7 +1050,7 @@ void JoystickUITask::disableSerial() { _ble_enabled = false; mesh_ble_set_enable /* ===== Offgrid / LEDs / DFU ===== */ void JoystickUITask::setOffgridMode(bool enable) { - if (_prefs) _prefs->client_repeat = enable ? 1 : 0; + /* CompanionMesh::setOffgridMode() does the prefs and the retune. */ mesh_set_offgrid_mode(enable); } diff --git a/zephcore/helpers/ui-joystick/joystick_ui_task.h b/zephcore/helpers/ui-joystick/joystick_ui_task.h index 7449702..f3ba78f 100644 --- a/zephcore/helpers/ui-joystick/joystick_ui_task.h +++ b/zephcore/helpers/ui-joystick/joystick_ui_task.h @@ -24,6 +24,7 @@ #include #include #include +#include #include #include @@ -125,7 +126,10 @@ public: bool isSerialEnabled() const { return _ble_enabled; } void disableSerial(); void enableSerial(); - bool isOffgridEnabled() const { return _prefs && _prefs->client_repeat != 0; } + bool isOffgridEnabled() const { return _prefs && mesh::offgridActive(*_prefs); } + /* kHz that turning it on leads to (the current one if already off-grid); + * 0 = no off-grid frequency for this band. */ + uint32_t offgridTargetKhz() const { return _prefs ? mesh::nearestOffgridFreqKhz(_prefs->freq) : 0; } void setOffgridMode(bool enable); bool isLedsDisabled() const { return _prefs && _prefs->leds_disabled != 0; } void toggleLeds(); diff --git a/zephcore/helpers/ui-joystick/screens/system.cpp b/zephcore/helpers/ui-joystick/screens/system.cpp index f8c1c5a..4fa909e 100644 --- a/zephcore/helpers/ui-joystick/screens/system.cpp +++ b/zephcore/helpers/ui-joystick/screens/system.cpp @@ -169,6 +169,8 @@ bool SystemScreen::handleInput(char c) if (_task->isOffgridEnabled()) { _task->setOffgridMode(false); _task->showAlert("Offgrid: OFF", 1000); + } else if (_task->offgridTargetKhz() == 0) { + _task->showAlert("No offgrid band", 1500); } else { _mode = SYSMODE_OFFGRID_CONFIRM; } @@ -278,8 +280,12 @@ bool SystemScreen::handleInput(char c) } if (_mode == SYSMODE_OFFGRID_CONFIRM) { if (c == KEY_ENTER) { + char og_alert[24]; + uint32_t og_khz = _task->offgridTargetKhz(); + snprintf(og_alert, sizeof(og_alert), "Offgrid ON %u.%03u", + (unsigned)(og_khz / 1000), (unsigned)(og_khz % 1000)); _task->setOffgridMode(true); - _task->showAlert("Offgrid: ON", 1000); + _task->showAlert(og_alert, 1500); _mode = SYSMODE_DEVICE; return true; } diff --git a/zephcore/helpers/ui/ui_mesh_actions.cpp b/zephcore/helpers/ui/ui_mesh_actions.cpp index 2db59ab..ebd12be 100644 --- a/zephcore/helpers/ui/ui_mesh_actions.cpp +++ b/zephcore/helpers/ui/ui_mesh_actions.cpp @@ -190,9 +190,14 @@ extern "C" void mesh_set_buzzer_mode(uint8_t mode) k_event_post(s_mesh_events, s_mesh_event_ui_action); } +extern "C" bool mesh_offgrid_available(void) +{ + return s_mesh && mesh::nearestOffgridFreqKhz(s_mesh->prefs.freq) != 0; +} + extern "C" void mesh_set_offgrid_mode(bool enable) { - /* Defer the flash write (savePrefs) to mesh thread */ + /* Defer the retune and the flash write to mesh thread */ atomic_set(&pending_offgrid_enabled, enable ? 1 : 0); atomic_or(&pending_ui_actions, UI_ACTION_OFFGRID_TOGGLE); k_event_post(s_mesh_events, s_mesh_event_ui_action); @@ -254,9 +259,10 @@ extern "C" void mesh_handle_ui_actions(void) if (actions & UI_ACTION_OFFGRID_TOGGLE) { bool og = atomic_get(&pending_offgrid_enabled) != 0; - s_mesh->prefs.client_repeat = og ? 1 : 0; - LOG_INF("client_repeat=%d (button)", og); - need_save = true; + /* Saves and retunes by itself; the page shows what it ended up as. */ + s_mesh->setOffgridMode(og); + ui_set_offgrid_mode(s_mesh->isOffgridActive()); + ui_push_radio_state(*s_lora_radio); } if (actions & UI_ACTION_LEDS_TOGGLE) { @@ -349,6 +355,8 @@ extern "C" void mesh_housekeeping_ui_refresh(void) ui_set_tz(s_mesh->prefs.tz_offset); ui_push_radio_state(*s_lora_radio); + /* The app and the CLI change it too. */ + ui_set_offgrid_mode(s_mesh->isOffgridActive()); /* Update GPS satellite count even without fix. When GPS is disabled, push * a zeroed count — gps_enable(false) already zeros the internal count, but diff --git a/zephcore/helpers/ui/ui_mesh_actions.h b/zephcore/helpers/ui/ui_mesh_actions.h index 9016ea5..fa3ba14 100644 --- a/zephcore/helpers/ui/ui_mesh_actions.h +++ b/zephcore/helpers/ui/ui_mesh_actions.h @@ -57,6 +57,8 @@ void mesh_gps_set_enabled(bool enable); void mesh_ble_set_enabled(bool enable); void mesh_set_buzzer_mode(uint8_t mode); void mesh_set_offgrid_mode(bool enable); +/* False when the current band has no off-grid frequency (2.4 GHz). */ +bool mesh_offgrid_available(void); void mesh_set_leds_disabled(bool disabled); void mesh_reboot_to_ota_dfu(void); void mesh_save_brightness(uint8_t brightness); diff --git a/zephcore/helpers/ui/ui_mesh_actions_stubs.c b/zephcore/helpers/ui/ui_mesh_actions_stubs.c index a68552b..2f59590 100644 --- a/zephcore/helpers/ui/ui_mesh_actions_stubs.c +++ b/zephcore/helpers/ui/ui_mesh_actions_stubs.c @@ -19,6 +19,7 @@ __attribute__((weak)) void mesh_gps_set_enabled(bool enable) { ARG_UNUSED(enable __attribute__((weak)) void mesh_ble_set_enabled(bool enable) { ARG_UNUSED(enable); } __attribute__((weak)) void mesh_set_buzzer_mode(uint8_t mode) { ARG_UNUSED(mode); } __attribute__((weak)) void mesh_set_offgrid_mode(bool enable) { ARG_UNUSED(enable); } +__attribute__((weak)) bool mesh_offgrid_available(void) { return false; } __attribute__((weak)) void mesh_set_leds_disabled(bool disabled) { ARG_UNUSED(disabled); } __attribute__((weak)) void mesh_reboot_to_ota_dfu(void) {} /* Repeater builds persist orientation through RepeaterDataStore in diff --git a/zephcore/src/main_companion.cpp b/zephcore/src/main_companion.cpp index c2ad256..39dab93 100644 --- a/zephcore/src/main_companion.cpp +++ b/zephcore/src/main_companion.cpp @@ -982,6 +982,11 @@ int main(void) companion_mesh.prefs.gps_interval = CONFIG_ZEPHCORE_GPS_POLL_INTERVAL_SEC; /* 0 = always on */ data_store.loadPrefs(companion_mesh.prefs); + /* A repeat flag stored on a non-off-grid frequency is dropped. */ + if (mesh::offgridNormalize(companion_mesh.prefs)) { + LOG_WRN("offgrid mode dropped: not on an off-grid frequency"); + data_store.savePrefs(companion_mesh.prefs); + } /* ESP32 light sleep: `powersaving off` holds a sleep lock. No-op elsewhere. */ zc_pm_set_powersaving(companion_mesh.prefs.powersaving_enabled != 0); /* Until the first fix, the position is the configured one. */ @@ -1071,9 +1076,9 @@ int main(void) ui_set_gps_enabled(companion_mesh.prefs.gps_enabled != 0); ui_set_ble_enabled(companion_mesh.prefs.ble_disabled != 1); /* BLE starts advertising at boot */ - ui_set_offgrid_mode(companion_mesh.prefs.client_repeat != 0); + ui_set_offgrid_mode(companion_mesh.isOffgridActive()); LOG_INF("offgrid mode: %s (from prefs)", - companion_mesh.prefs.client_repeat ? "on" : "off"); + companion_mesh.isOffgridActive() ? "on" : "off"); /* buzzer_init() starts quiet, so the saved mode is always applied. */ #if IS_ENABLED(CONFIG_ZEPHCORE_UI_BUZZER)