From c690f653769cd1e16f6bdd400d1a8390902b1157 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 8 Sep 2026 22:07:30 -0700 Subject: [PATCH] Allow frequency changes over authorized LoRa CLI --- docs/cli_command_availability.md | 7 +- docs/cli_commands.md | 19 ++++-- docs/companion_protocol.md | 6 +- docs/terminal_chat_cli.md | 18 +++-- examples/companion_radio/MyMesh.cpp | 46 ++++++------- src/helpers/CommonCLI.cpp | 2 +- test/test_local_cli_access.py | 101 +++++++++++++++++++++++++--- 7 files changed, 143 insertions(+), 56 deletions(-) diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md index af191bad4..dbe1da90b 100644 --- a/docs/cli_command_availability.md +++ b/docs/cli_command_availability.md @@ -69,7 +69,8 @@ over the normal binary USB, BLE, or TCP connection: | Command | Availability | |---|---| | `stats-core`, `stats-radio`, `stats-radio-diag`, `stats-packets` | Local terminal and binary command `0x42` | -| `erase`, `set freq ` | Local terminal and binary command `0x42`; stored changes apply on reboot | +| `erase` | Local terminal and binary command `0x42`; reboot after erasing stored settings | +| `set freq ` | Local terminal, binary command `0x42`, and authorized LoRa CLI; reboot to apply | | `get prv.key` | Local terminal and binary command `0x42`; requires private key export enabled | | `get password` | Reports that Companion has no admin password; infrastructure returns its password locally | | `get wifi.pwd`, `get mqttN.password`, `get mqttN.token` | Local terminal and binary command `0x42`; corresponding WiFi/MQTT feature required | @@ -125,7 +126,7 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto | Radio | [`get tx`; `set tx `](cli_commands.md#view-or-change-this-nodes-transmit-power) | Board TX-power limits apply | Yes | Yes | Yes | | Radio | [`tempradio ...`; `normalradio`](cli_commands.md#change-the-radio-parameters-for-a-set-duration) | Full parser | Yes | Yes | Yes | | Radio | [`get/set/del radioat`; `get/set/del tempradioat`](cli_commands.md#schedule-radio-parameter-changes) | Full parser | Yes | Yes | Yes | -| Radio | [`get freq`; `set freq `](cli_commands.md#view-or-change-this-nodes-frequency) | `set` requires a local connection | Yes | Yes | Yes | +| Radio | [`get freq`; `set freq `](cli_commands.md#view-or-change-this-nodes-frequency) | Local or authorized LoRa CLI | Yes | Yes | Yes | | Radio | [`get/set radio.rxgain`](cli_commands.md#view-or-change-this-nodes-rx-boosted-gain-mode-sx12xx-and-lr1110-v1141) | Supported radio | Feature | Feature | Feature | | Radio | [`get/set radio.fem.rxgain`](cli_commands.md#view-or-change-the-lora-fem-receive-path-gain-state-on-supported-boards) | Controllable LoRa FEM | Feature | Feature | Feature | | Radio | [`get/set radio.fem.txgain`](cli_commands.md#view-or-change-the-lora-fem-transmit-path-gain-state-on-supported-boards) | Controllable LoRa FEM | Feature | Feature | Feature | @@ -291,7 +292,7 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto | Radio | [`get tx`; `set tx `](cli_commands.md#view-or-change-this-nodes-transmit-power) | Board TX-power limits apply | Yes | Yes | Yes | Yes | Yes | | Radio | [`tempradio ...`; `normalradio`](cli_commands.md#change-the-radio-parameters-for-a-set-duration) | Full parser | Yes | Yes | Yes | Yes | Yes | | Radio | [`get/set/del radioat`; `get/set/del tempradioat`](cli_commands.md#schedule-radio-parameter-changes) | Full parser | Yes | Yes | Yes | Yes | Yes | -| Radio | [`get freq`; `set freq `](cli_commands.md#view-or-change-this-nodes-frequency) | `set` requires a local connection | Yes | Yes | Yes | Yes | Yes | +| Radio | [`get freq`; `set freq `](cli_commands.md#view-or-change-this-nodes-frequency) | Local or authorized LoRa CLI | Yes | Yes | Yes | Yes | Yes | | Radio | [`get/set radio.rxgain`](cli_commands.md#view-or-change-this-nodes-rx-boosted-gain-mode-sx12xx-and-lr1110-v1141) | Supported radio | Feature | Feature | Feature | Feature | Feature | | Radio | [`get/set espnow.channel`](cli_commands.md#view-or-change-the-primary-esp-nowwifi-channel) | Primary ESP-NOW mesh radio | Feature | Feature | Feature | Feature | Feature | | Radio | [`get/set radio.fem.rxgain`](cli_commands.md#view-or-change-the-lora-fem-receive-path-gain-state-on-supported-boards) | Controllable LoRa FEM | Feature | Feature | Feature | Feature | Feature | diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 484f639b8..0446a452e 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -269,11 +269,12 @@ the **CLI** tab. The saved setting becomes active only in station/LAN mode while the WiFi client is connected. It is deliberately unavailable on the open setup access point. The tab displays each reply in the browser, including local diagnostics, -`get password`, private-key and WiFi/MQTT credential reads, `erase`, and -`set freq`. Infrastructure `get acl` and raw `log` stream to the requesting -browser, with backpressure for slow clients. LoRa callers retain their -restrictions. The bounded `/api/cli` endpoint accepts local maintenance -commands; use `/api/terminal` for complete ACL and log listings. +`get password`, private-key and WiFi/MQTT credential reads, and `erase`. +Infrastructure `get acl` and raw `log` stream to the requesting browser, with +backpressure for slow clients. These maintenance commands retain their LoRa +restrictions. `set freq` works both here and through authorized LoRa CLI. +The bounded `/api/cli` endpoint accepts local maintenance commands; use +`/api/terminal` for complete ACL and log listings. Select **Command block** to paste up to 100 commands with one command per line. Blank lines are ignored, and every nonblank line must fit the normal 159-byte CLI command limit. The browser sends the lines sequentially and waits for each @@ -1067,8 +1068,12 @@ automation treats that version as the wire-format capability boundary. **Default:** `869.525` **Note:** Requires reboot to apply -**Local connection only:** `set freq `. USB, browser/TCP/Ethernet -CLI, and Companion binary command `0x42` can set it; LoRa cannot. + +**Connections:** USB, browser/TCP/Ethernet CLI, Companion binary command +`0x42` (USB/BLE/TCP), and authorized LoRa CLI. `set freq` saves the frequency; +bandwidth, spreading factor, and coding rate stay as configured. Infrastructure +roles also clamp transmit power to the board's limit for that frequency. Like +`set radio`, it requires permission to configure the destination node. --- diff --git a/docs/companion_protocol.md b/docs/companion_protocol.md index 11a18adf1..236fb0df4 100644 --- a/docs/companion_protocol.md +++ b/docs/companion_protocol.md @@ -253,11 +253,13 @@ example, `A7|get radio.rxgain`); the reply preserves that prefix. Direct USB, BLE, WiFi TCP, and Ethernet clients can also use this command for `stats-core`, `stats-radio`, `stats-radio-diag`, `stats-packets`, `erase`, -`set freq `, and `get prv.key` (when private key export is enabled). +and `get prv.key` (when private key export is enabled). WiFi/MQTT builds accept `get wifi.pwd`, `get mqttN.password`, and `get mqttN.token`. `get password` reports that Companion has no admin password; infrastructure roles return their own admin password on a local CLI connection. -These privileges do not apply to CLI commands relayed over LoRa. See +These privileges do not apply to CLI commands relayed over LoRa. +`set freq ` works through command `0x42` and authorized LoRa CLI, like +`set radio`; it saves the frequency and requires reboot to apply. See [local maintenance commands](terminal_chat_cli.md#local-maintenance-commands) for role differences and the text-terminal equivalents. diff --git a/docs/terminal_chat_cli.md b/docs/terminal_chat_cli.md index c9d4fa483..204c57b30 100644 --- a/docs/terminal_chat_cli.md +++ b/docs/terminal_chat_cli.md @@ -53,7 +53,6 @@ the corresponding feature: | `stats-core`, `stats-radio`, `stats-radio-diag`, `stats-packets` | Runtime diagnostics | | `get prv.key` | Node identity private key; Companion requires private key export enabled in the build | | `get password` | Infrastructure admin password; Companion reports that it has no admin password | -| `set freq ` | Save the frequency; reboot to apply | | `erase` | Erase stored identity and settings; reboot for a fresh node | | `get wifi.pwd` | Stored WiFi password on WiFi builds | | `get mqttN.password`, `get mqttN.token` | Stored credentials for MQTT slot N | @@ -66,11 +65,17 @@ followed by the reply text. For example, payload `42 67 65 74 20 70 61 73 73 77 6f 72 64` runs `get password`. USB framing remains the normal Companion frame; BLE and TCP use their existing Companion transport framing. +`set freq ` is available on these local connections and through authorized +LoRa administration, just like `set radio`. It saves the frequency without +changing bandwidth, spreading factor, or coding rate and requires a reboot to +apply. Infrastructure roles also apply the board's transmit-power limit. + Commands relayed to another node using `cmd` travel over LoRa and retain the -remote restrictions. These local permissions do not add an ACL or packet-log -store to Companion. Use `list` or the binary contacts operations for Companion -contacts. Interactive chat commands still use the text terminal or their -corresponding binary protocol operations. +remote restrictions on local-only maintenance and secret reads. These local +permissions do not add an ACL or packet-log store to Companion. Use `list` or +the binary contacts operations for Companion contacts. Interactive chat +commands still use the text terminal or their corresponding binary protocol +operations. The browser CLI requires station/LAN mode and `wifi.cli on`. Infrastructure uses its admin login; Companion uses the trusted LAN. Configuration forms keep @@ -147,7 +152,8 @@ source, supported build profiles and memory costs. ``` set freq {frequency} ``` -Set the LoRa frequency. Example: set freq 915.8 +Set the saved LoRa frequency. Example: `set freq 915.8`. Available over local +connections and authorized LoRa CLI; reboot to apply. ``` set tx {tx-power-dbm} diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 7bc3fc7a1..3bfaf2355 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -7826,15 +7826,8 @@ void MyMesh::handleTerminalCommand(char* command) { savePrefs(); terminalOutput().print(" OK - reboot to apply\r\n"); } else if (strncmp(config, "freq ", 5) == 0) { - float parsed = 0.0f; - if (!mesh::cli::parseDecimalStrict(config + 5, parsed) - || parsed < 150.0f || parsed > 2500.0f) { - terminalOutput().print(" ERROR: frequency must be 150-2500 MHz\r\n"); - } else { - _prefs.freq = parsed; - savePrefs(); - terminalOutput().print(" OK - reboot to apply\r\n"); - } + handleCommand(command, 0, local_reply); + terminalOutput().printf(" %s\r\n", local_reply); } else if (strncmp(config, "radio.fem.rxgain", 16) == 0 && (config[16] == 0 || config[16] == ' ' || config[16] == '\t')) { @@ -8067,23 +8060,6 @@ bool MyMesh::handleDirectCommand(const char* command, char* reply, size_t reply_ } return true; } - if (strncmp(command, "set freq ", 9) == 0) { - float frequency; - if (!mesh::cli::parseDecimalStrict(command + 9, frequency) - || frequency < 150.0f || frequency > 2500.0f) { - snprintf(reply, reply_size, "Error: frequency must be 150-2500 MHz"); - } else { - const float previous = _prefs.freq; - _prefs.freq = frequency; - if (savePrefs()) { - snprintf(reply, reply_size, "OK - reboot to apply"); - } else { - _prefs.freq = previous; - snprintf(reply, reply_size, "Error: frequency could not be saved"); - } - } - return true; - } if (strcmp(command, "stats-core") == 0) { StatsFormatHelper::formatCoreStats(reply, board, *_ms, _err_flags, _mgr); return true; @@ -8170,6 +8146,24 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, return true; } + if (strncmp(command, "set freq ", 9) == 0) { + float frequency; + if (!mesh::cli::parseDecimalStrict(command + 9, frequency) + || frequency < 150.0f || frequency > 2500.0f) { + snprintf(reply, reply_capacity, "Error: frequency must be 150-2500 MHz"); + } else { + const float previous = _prefs.freq; + _prefs.freq = frequency; + if (savePrefs()) { + snprintf(reply, reply_capacity, "OK - reboot to apply"); + } else { + _prefs.freq = previous; + snprintf(reply, reply_capacity, "Error: frequency could not be saved"); + } + } + return true; + } + if (handleLocalControlCommand(command, reply, reply_capacity)) return true; if (strncmp(command, "set tx ", 7) == 0) { diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 30ef83cc4..1bdcdcb80 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -4027,7 +4027,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep savePrefs(); strcpy(reply, "OK"); } - } else if (sender_timestamp == 0 && memcmp(config, "freq ", 5) == 0) { + } else if (memcmp(config, "freq ", 5) == 0) { float freq = 0.0f; if (mesh::cli::parseDecimalStrict(&config[5], freq) && freq >= 150.0f && freq <= 2500.0f) { diff --git a/test/test_local_cli_access.py b/test/test_local_cli_access.py index 0e7a8dd6e..4907bb0b9 100644 --- a/test/test_local_cli_access.py +++ b/test/test_local_cli_access.py @@ -12,6 +12,7 @@ PREAMBLE = r''' #include #include #include +#include #include #define ESP32 1 #define WIFI_SSID "test" @@ -29,7 +30,10 @@ struct Utils { } }; } -struct ContactInfo { bool isRemoteCLIAllowed() const { return true; } }; +struct ContactInfo { + bool allowed=true; + bool isRemoteCLIAllowed() const { return allowed; } +}; struct WiFiSetupPortal { static bool loadStoredCredentials(char*,size_t,char* password,size_t size) { snprintf(password,size,"wifi-secret");return true; @@ -82,7 +86,7 @@ struct MyMesh { int value=0; int* _ms=&value; int* _radio=&value; int* _mgr=&value; uint16_t _err_flags=0; bool _radio_available=false, save_ok=true; - int resets=0; + int resets=0, saves=0; bool handleCommand(const char*,uint32_t,char*); bool handleDirectCommand(const char*,char*,size_t); void onCLICommandRecv(const ContactInfo&,mesh::Packet*,uint32_t,const char*,char*); @@ -94,7 +98,7 @@ struct MyMesh { } void stopContactsIterator(){} void resetContacts(){++resets;} - bool savePrefs(){return save_ok;} + bool savePrefs(){++saves;return save_ok;} int getTotalAirTime(){return 0;} int getReceiveAirTime(){return 0;} int getNumSentFlood(){return 0;} @@ -138,15 +142,30 @@ int main() { assert(!strstr(reply,"secret") && !strstr(reply,"token") && !strstr(reply,"A5A5")); } assert(node.handleCommand("set freq 915.25",0,reply)); - assert(node._prefs.freq==915.25f); - assert(!node.handleCommand("set freq 920",100,reply)); - node.onCLICommandRecv(ContactInfo{},nullptr,0,"set freq 920",reply); - assert(node._prefs.freq==915.25f); + assert(node._prefs.freq==915.25f && strstr(reply,"reboot to apply")); + assert(node.handleCommand("set freq 920",100,reply)); + assert(node._prefs.freq==920.0f && strstr(reply,"reboot to apply")); + for(uint32_t stamp:{0u,100u}) { + reply[0]=0; + node.onCLICommandRecv(ContactInfo{},nullptr,stamp,"A7|set freq 921.125",reply); + assert(node._prefs.freq==921.125f && !strcmp(reply,"A7|OK - reboot to apply")); + assert(node._prefs.bw==125.0f && node._prefs.sf==7 && node._prefs.cr==5); + for(const char* bad:{"set freq 149.9","set freq 2500.1","set freq 920oops", + "set freq NaN","set freq inf","set freq 1e3","set freq "}) { + const int saves=node.saves; + reply[0]=0; + node.onCLICommandRecv(ContactInfo{},nullptr,stamp,bad,reply); + assert(strstr(reply,"Error") && node._prefs.freq==921.125f && node.saves==saves); + } + } + const int saves=node.saves; + node.onCLICommandRecv(ContactInfo{false},nullptr,100,"set freq 930",reply); + assert(node._prefs.freq==921.125f && node.saves==saves); node.save_ok=false; - assert(node.handleCommand("set freq 920",0,reply)); - assert(node._prefs.freq==915.25f && strstr(reply,"Error")); - assert(node.handleCommand("set freq 920oops",0,reply)); - assert(node._prefs.freq==915.25f && strstr(reply,"Error")); + for(uint32_t stamp:{0u,100u}) { + assert(node.handleCommand("set freq 920",stamp,reply)); + assert(node._prefs.freq==921.125f && strstr(reply,"Error")); + } assert(!node.handleCommand("erase",100,reply)); node.onCLICommandRecv(ContactInfo{},nullptr,0,"erase",reply); assert(node.store.erased==0); @@ -161,6 +180,66 @@ int main() { class LocalCliAccessTest(unittest.TestCase): + def test_infrastructure_frequency_accepts_local_and_lora_callers(self): + source = (ROOT / "src/helpers/CommonCLI.cpp").read_text() + anchor = source.index('memcmp(config, "freq ", 5)') + start = source.rfind('} else if (', 0, anchor) + len('} else ') + setter = extract_braced(source[start:], "if (") + # The following optional else-if puts its preprocessor guard just + # before this branch's closing brace. It is not part of this handler. + setter = setter.replace("#ifdef WITH_BRIDGE\n", "") + code = r''' +#include +#include +#include +#include +#include +#define MAX_LORA_TX_POWER 22 +#include +#include +struct Prefs { + float freq=910.525f,bw=62.5f; + uint8_t sf=7,cr=5; + int8_t tx_power_dbm=30; +} prefs; +Prefs* _prefs=&prefs; +int saves=0; +void savePrefs(){++saves;} +void set(const char* config,uint32_t sender_timestamp,char* reply) { +''' + setter + r''' +} +int main() { + char reply[160]={}; + for(uint32_t stamp:{0u,100u,0xFFFFFFFFu}) { + prefs=Prefs{}; + set("freq 915.25",stamp,reply); + assert(prefs.freq==915.25f && prefs.tx_power_dbm==22); + assert(strstr(reply,"reboot to apply") && strstr(reply,"TX power limited")); + assert(prefs.bw==62.5f && prefs.sf==7 && prefs.cr==5); + for(const char* bad:{"freq 149.9","freq 2500.1","freq 920oops", + "freq NaN","freq inf","freq 1e3","freq "}) { + const int writes=saves; + set(bad,stamp,reply); + assert(strstr(reply,"Error") && prefs.freq==915.25f && saves==writes); + } + for(const char* boundary:{"freq 150","freq 2500"}) { + set(boundary,stamp,reply); + assert(strstr(reply,"OK - reboot to apply")); + } + } + assert(saves==9); +} +''' + with tempfile.TemporaryDirectory() as temp: + binary = Path(temp) / "test" + result = subprocess.run([ + "c++", "-std=c++17", "-x", "c++", "-", "-I"+str(ROOT / "src"), + "-fsanitize=address,undefined", "-fno-pie", "-no-pie", "-o", str(binary), + ], input=code, text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + result = subprocess.run([str(binary)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout+result.stderr) + def test_infrastructure_password_read_and_browser_transport_limits(self): source = (ROOT / "src/helpers/CommonCLI.cpp").read_text() getter = extract_braced(source, "void CommonCLI::handleGetCmd(")