Allow local CLI maintenance across direct connections

This commit is contained in:
mikecarper committed 2026-09-08 21:44:57 -07:00
1 parent 001b4985f7
commit 06c51fdef5
39 files changed
+3338 -2312

No files matched your search

+4 -2
View File
@@ -432,6 +432,8 @@ Each slot (1-6) supports the following commands:
- `get mqttN.audience` - Get JWT audience for slot N (custom slots only)
- `get mqttN.filter` - Get the slot's packet-type allowlist (`all`, `none`, or numeric CSV)
- `get mqttN.password` / `get mqttN.token` - Read stored slot credentials from a local USB, TCP/Ethernet, or LAN browser CLI; Companion also accepts binary command `0x42`. LoRa callers cannot read their values.
#### Set Commands
- `set mqttN.preset <name>` - Set slot N to a built-in preset. Use any `name` from [Broker Presets](#broker-presets), which also lists the few presets needing extra setup.
- `set mqttN.preset custom` - Set slot N to custom broker (configure server/port/username/password)
@@ -605,8 +607,8 @@ These settings apply across all MQTT slots:
- `get mqtt.neighbors.interval` - Get neighbors publish interval in hours (neighbors-enabled builds)
- `get mqtt.ntp` - Get effective NTP server hostname
- `get mqtt.ntp.diag` - Probe every configured NTP server for connectivity (does not change the clock; serial console shows each server's reported time, LoRa shows a compact `<server> ok|fail` list)
- `get mqtt.owner` - Get owner public key (serial console only)
- `get mqtt.email` - Get owner email address (serial console only)
- `get mqtt.owner` - Get owner public key (local connections only)
- `get mqtt.email` - Get owner email address (local connections only)
#### Set Commands
- `set mqtt.origin <name>` - Set device origin name
+13 -6
View File
@@ -372,7 +372,8 @@ On a `companion_radio_full` build, port 5002 is the same role-specific text
terminal available over USB, including chat, remote administration, radio and
power settings, WiFi/WebConfig management, `tempradio`, and the source-only
`ota` commands. Other
OTA-enabled Companion builds keep the bounded `ota ...` console. LoRa staging
OTA-enabled Companion builds expose the same local maintenance commands as
binary command `0x42`, plus `ota ...` commands. LoRa staging
and installation on the Full Companion itself remain disabled.
Port 5002 is plaintext and has no independent login gate. Use it only on a
@@ -468,8 +469,8 @@ An inactive status is normal when `webui` is off: run `start webconfig` to
connect temporarily. Standalone credentials can be changed through WebConfig
or with the listed CLI commands. Changing the SSID or password stops an active
WebConfig session; start it again to connect with the new values. Use
`set wifi.pwd` with no value for an open network. The password is write-only
and is never returned by `get`. Standalone WiFi accepts ordinary passphrases up
`set wifi.pwd` with no value for an open network. `get wifi.pwd` returns the saved password on a local connection; LoRa
callers cannot read it. Standalone WiFi accepts ordinary passphrases up
to 63 characters and exact 64-character hexadecimal WPA/WPA2 PSKs; other
64-character values and all longer values are rejected. MQTT observer WiFi
passwords remain limited to 63 characters by their fixed persisted layout.
@@ -492,8 +493,14 @@ get wifi.cli
The saved `on` setting becomes active only when the WiFi station client is
connected and WebConfig is running in LAN mode. It is never exposed on the open
setup access point. When active, the WebConfig page has a **CLI** tab. Repeater
and room-server commands use the authenticated administrator parser; commands
restricted to a physical serial connection remain unavailable there.
and room-server commands use their local CLI after admin authentication.
The browser can run `get password`, `get prv.key`, `erase`, `set freq`,
`stats-core`, `stats-radio`, `stats-radio-diag`, `stats-packets`, and explicit
WiFi/MQTT credential getters. It also streams `get acl` and raw `log` dumps
back to the browser. Log reads pause for slow clients and resume as output is
read, using a fixed 160-byte file chunk. A dump snapshots the file length, so
live logging cannot extend it indefinitely. The browser keeps the complete
requested dump, including files larger than 64 KiB.
Companion uses the same complete terminal as USB and Full Companion TCP port
5002, including `card`, `import meshcore://...`, `list`, `to`, messages, remote
@@ -503,7 +510,7 @@ for contact import, terminal ownership, and disconnect behavior.
Type a command at the prompt, or paste multiple lines and confirm the sequence.
Blank lines and comments are ignored. The device reports the supported batch
limit. Companion sends each line after the previous command finishes and
limit. The browser sends each line after the previous command finishes and
continues polling for incoming text after the sequence. `help` lists the
device's actual terminal commands; Tab completes command names and settings.
+5
View File
@@ -63,6 +63,11 @@ limits reduce selected Generic E22, Heltec V2, Meshadventurer, T-Beam OTA/MQTT,
and TLora MQTT profiles to 50. Check the artifact capability manifest and
[ESP32 memory budget](esp32_memory_budget.md) for the exact target.
See [local maintenance commands](terminal_chat_cli.md#local-maintenance-commands)
for USB, BLE/binary Companion, TCP/Ethernet, and browser CLI privileges.
A direct connection can access local diagnostics and explicit credential
getters; relaying a command over LoRa keeps its remote restrictions.
## Profile matrix
| Build/profile | Command availability |
+36 -25
View File
@@ -9,7 +9,11 @@ sensor, and bridge firmware. Full Companion also has its own ASCII USB/TCP
terminal; see [feature switches by role](role_feature_switches.md). Companion
apps use the companion protocol,
KISS firmware uses framed KISS/TNC commands, and terminal-chat firmware has its
own interface, so those build roles are not represented here.
own interface, so those build roles are not represented in the main matrices.
The Companion section below lists its framed maintenance commands.
Infrastructure `get password` is available locally in every profile; LoRa
requests cannot read it. `get acl` and stored packet `log` are infrastructure
features, not Companion contact or live USB-logging commands.
Build columns mean:
@@ -45,7 +49,9 @@ Cell values mean:
- **No** - the profile does not expose the command.
- **Feature** - available only when the target compiles the feature or hardware
named in Scope.
- **Serial** - available only from the local serial console.
- **Local** - a direct USB, BLE/binary Companion, TCP/Ethernet, or LAN browser
connection, when that transport and command exist on the role; unavailable
over LoRa.
- **Manifest** - available only when the MQTT target defines
`OTA_MANIFEST_BASE`.
- **Limited** - the family exists, but the limitation in Scope applies.
@@ -62,6 +68,11 @@ 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 <MHz>` | Local terminal and binary command `0x42`; stored changes apply on reboot |
| `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 |
| [`board`](cli_commands.md#show-the-hardware-name) | Every Companion text terminal and command `0x42` |
| [`version`](cli_commands.md#get-the-version) | Every Companion text terminal and protocol-v14 command `0x42`; returns the untruncated build identity |
| [`get storage.layout`](cli_commands.md#show-the-storage-layout) | Every Companion text terminal and command `0x42`; reports the running platform's storage layout |
@@ -82,14 +93,14 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto
|---|---|---|---|---|---|
| Operational | [`reboot`](cli_commands.md#reboot-the-node) | All text CLI roles | Yes | Yes | Yes |
| Operational | [`poweroff`; `shutdown`](cli_commands.md#power-off-the-node) | Board power-off support | Yes | Yes | Yes |
| Operational | [`uf2reset`](cli_commands.md#enter-the-uf2-bootloader-nrf52-only) | Local serial; supported UF2 boards | Serial | Serial | Serial |
| Operational | [`uf2reset`](cli_commands.md#enter-the-uf2-bootloader-nrf52-only) | Local connection; supported UF2 boards | Local | Local | Local |
| Operational | [`clkreboot`](cli_commands.md#reset-the-clock-and-reboot) | All text CLI roles | Yes | Yes | Yes |
| Operational | [`clock sync`](cli_commands.md#sync-the-clock-with-the-remote-device) | All text CLI roles | Yes | Yes | Yes |
| Operational | [`clock`](cli_commands.md#display-current-time-in-utc) | All text CLI roles | Yes | Yes | Yes |
| Operational | [`time <epoch_seconds>`](cli_commands.md#set-the-time-to-a-specific-timestamp) | Clock only moves forward | Yes | Yes | Yes |
| Operational | [`advert`](cli_commands.md#send-a-flood-advert) | Advert-capable role | Yes | Yes | Yes |
| Operational | [`advert.zerohop`](cli_commands.md#send-a-zero-hop-advert) | Advert-capable role | Yes | Yes | Yes |
| Operational | [`erase`](cli_commands.md#erasefactory-reset) | Local serial | Serial | Serial | Serial |
| Operational | [`erase`](cli_commands.md#erasefactory-reset) | Local connection | Local | Local | Local |
| Information | [`ver`](cli_commands.md#get-the-version) | All text CLI roles | Yes | Yes | Yes |
| Information | [`board`](cli_commands.md#show-the-hardware-name) | All text CLI roles | Yes | Yes | Yes |
| Diagnostics | [`get storage.layout`](cli_commands.md#show-the-storage-layout) | Internal layout and configured QSPI or SD storage | Yes | Yes | Yes |
@@ -101,20 +112,20 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto
| Neighbors | [`discover.neighbors`](cli_commands.md#discover-zero-hop-neighbors) | Repeater; some MQTT room servers | Yes | Yes | Yes |
| Neighbors | [`discover.scopes`](cli_commands.md#discover-neighbor-scopes-mqtt-observer-neighbors-feature) | MQTT observer with compiled neighbor support | No | No | No |
| Statistics | [`clear stats`](cli_commands.md#clear-stats) | All full-parser text CLI roles | Yes | Yes | Yes |
| Statistics | [`stats-core`](cli_commands.md#stats-core) | Local serial | Serial | Serial | Serial |
| Statistics | [`stats-radio`](cli_commands.md#stats-radio) | Local serial | Serial | Serial | Serial |
| Statistics | [`stats-radio-diag`](#stats-radio-diag) | Local serial | Serial | Serial | Serial |
| Statistics | [`stats-packets`](cli_commands.md#stats-packets) | Local serial | Serial | Serial | Serial |
| Statistics | [`stats-core`](cli_commands.md#stats-core) | Local connection | Local | Local | Local |
| Statistics | [`stats-radio`](cli_commands.md#stats-radio) | Local connection | Local | Local | Local |
| Statistics | [`stats-radio-diag`](#stats-radio-diag) | Local connection | Local | Local | Local |
| Statistics | [`stats-packets`](cli_commands.md#stats-packets) | Local connection | Local | Local | Local |
| Statistics | [`get telemetry.temp/volt/volt.i2c`; optional GPS history](cli_commands.md#read-repeater-telemetry-history) | Non-STM32 repeater or room server; I2C command requires a detected voltage monitor; GPS requires a provider; remote access requires administrator | Yes | Yes | Yes |
| Statistics | [`set telemetry.gps`; `get/set/send telemetry.tx`](cli_commands.md#read-repeater-telemetry-history) | Non-STM32 repeater; GPS setting requires a provider; remote access requires administrator | Yes | Yes | Yes |
| Logging | [`log start`; `log stop`; `log erase`](cli_commands.md#logging) | Storage-backed roles retain data; other roles can return empty data | Yes | Yes | Yes |
| Logging | [`log`](cli_commands.md#print-the-captured-log-to-the-serial-terminal) | Local serial | Serial | Serial | Serial |
| Logging | [`log`](cli_commands.md#print-the-captured-log-to-the-serial-terminal) | Local connection | Local | Local | Local |
| Logging | [`get/set usb.logging`; unified FULL `get/set logging.output`](cli_commands.md#control-live-usb-logging) | Ordinary safe-USB artifacts; CommonCLI USB gate is persistent; unified ESP32 FULL selects off/USB/WiFi/both; nRF52 Full Companion can add a reboot-controlled second CDC | Yes | Yes | No |
| Radio | [`get radio`; `set radio ...`](cli_commands.md#view-or-change-this-nodes-radio-parameters) | All text CLI roles | Yes | Yes | Yes |
| Radio | [`get tx`; `set tx <dbm>`](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 <mhz>`](cli_commands.md#view-or-change-this-nodes-frequency) | `set` is local serial only | Yes | Yes | Yes |
| Radio | [`get freq`; `set freq <mhz>`](cli_commands.md#view-or-change-this-nodes-frequency) | `set` requires a local connection | 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 |
@@ -122,8 +133,8 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto
| System | [`get/set name`](cli_commands.md#view-or-change-this-nodes-name) | All text CLI roles | Yes | Yes | Yes |
| System | [`get/set lat`](cli_commands.md#view-or-change-this-nodes-latitude) | All text CLI roles | Yes | Yes | Yes |
| System | [`get/set lon`](cli_commands.md#view-or-change-this-nodes-longitude) | All text CLI roles | Yes | Yes | Yes |
| System | [`get/set prv.key`](cli_commands.md#view-or-change-this-nodes-identity-private-key) | `get` is local serial only | Yes | Yes | Yes |
| System | [`password <new_password>`](cli_commands.md#change-this-nodes-admin-password) | Administrator | Yes | Yes | Yes |
| System | [`get/set prv.key`](cli_commands.md#view-or-change-this-nodes-identity-private-key) | `get` requires a local connection | Yes | Yes | Yes |
| System | [`get password`; `password <new_password>`](cli_commands.md#change-this-nodes-admin-password) | `get` requires a local connection; setter requires administrator | Yes | Yes | Yes |
| System | [`get/set guest.password`](cli_commands.md#view-or-change-this-nodes-guest-password) | Role with guest administration | Yes | Yes | Yes |
| System | [`get/set owner.info`](cli_commands.md#view-or-change-this-nodes-owner-info) | All text CLI roles | Yes | Yes | Yes |
| System | [`get/set adc.multiplier`](cli_commands.md#fine-tune-the-battery-reading) | Board ADC override support | Feature | Feature | Feature |
@@ -164,7 +175,7 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto
| Routing | [`get/set outpath`](halo_keymind_settings.md#direct-path-overrides) | Repeater remote-client context | Yes | Yes | Yes |
| Routing | [`get/set altpath`](halo_keymind_settings.md#direct-path-overrides) | Repeater remote-client context | Yes | Yes | Yes |
| ACL | [`setperm <pubkey> <permissions>`](cli_commands.md#add-update-or-remove-permissions-for-a-companion) | Repeater, room server, or sensor | Yes | Yes | Yes |
| ACL | [`get acl`](cli_commands.md#view-the-current-acl) | Local serial | Serial | Serial | Serial |
| ACL | [`get acl`](cli_commands.md#view-the-current-acl) | Local connection | Local | Local | Local |
| ACL | [`get/set allow.read.only`](cli_commands.md#view-or-change-this-room-servers-read-only-flag) | Room server | Yes | Yes | No |
| Regions | [`region load`](cli_commands.md#bulk-load-region-lists); [`region save`](cli_commands.md#save-any-changes-to-regions-made-since-reboot) | Role with region storage | Yes | Yes | Yes |
| Regions | [`region allowf`](cli_commands.md#allow-a-region); [`region denyf`](cli_commands.md#block-a-region) | Role with region storage | Yes | Yes | Yes |
@@ -232,7 +243,7 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto
| MQTT | [`get mqtt.ntp.diag`](cli_commands.md#diagnose-ntp-server-connectivity-mqtt-observer-only) | Full MQTT observer | No | No | No |
| MQTT | [`get/set timezone`; `get/set timezone.offset`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#timezone-commands) | MQTT observer | No | No | No |
| MQTT | [`get/set mqtt.analyzer.us`; `get/set mqtt.analyzer.eu`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#migration-from-old-configuration) | Legacy MQTT aliases | No | No | No |
| MQTT | [`get/set mqtt.owner`; `get/set mqtt.email`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#mqtt-shared-commands) | MQTT observer; `get` is local serial only | No | No | No |
| MQTT | [`get/set mqtt.owner`; `get/set mqtt.email`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#mqtt-shared-commands) | MQTT observer; `get` requires a local connection | No | No | No |
| MQTT | [`get mqtt.config.valid`](#mqtt-config-valid) | MQTT observer | No | No | No |
| SNMP | [`get/set snmp`; `get/set snmp.community`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_SNMP.md#cli-commands) | MQTT target compiled with SNMP | No | No | No |
| Alerts | [`get/set alert`; `get/set alert.psk`; `get/set alert.hashtag`; `get/set alert.region`; `get/set alert.wifi`; `get/set alert.mqtt`; `get/set alert.interval`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/ALERTS.md#cli) | MQTT observer | No | No | No |
@@ -254,7 +265,7 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto
| Operational | [`time <epoch_seconds>`](cli_commands.md#set-the-time-to-a-specific-timestamp) | Clock only moves forward | Yes | Yes | Yes | Yes | Yes |
| Operational | [`advert`](cli_commands.md#send-a-flood-advert) | Advert-capable role | Yes | Yes | Yes | Yes | Yes |
| Operational | [`advert.zerohop`](cli_commands.md#send-a-zero-hop-advert) | Advert-capable role | Yes | Yes | Yes | Yes | Yes |
| Operational | [`erase`](cli_commands.md#erasefactory-reset) | Local serial | Serial | Serial | Serial | Serial | Serial |
| Operational | [`erase`](cli_commands.md#erasefactory-reset) | Local connection | Local | Local | Local | Local | Local |
| Display | [`get/set display.timeout`; `get/set display.flip`](cli_commands.md#set-mqtt-observer-display-timeout-and-flip) | MQTT observer with display; flip needs a supported driver | No | No | No | Feature | No |
| Information | [`ver`](cli_commands.md#get-the-version) | All text CLI roles | Yes | Yes | Yes | Yes | Yes |
| Information | [`board`](cli_commands.md#show-the-hardware-name) | All text CLI roles | Yes | Yes | Yes | Yes | Yes |
@@ -267,20 +278,20 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto
| Neighbors | [`discover.neighbors`](cli_commands.md#discover-zero-hop-neighbors) | Repeater; some MQTT room servers | Yes | Yes | Yes | Yes | Yes |
| Neighbors | [`discover.scopes`](cli_commands.md#discover-neighbor-scopes-mqtt-observer-neighbors-feature) | MQTT observer with compiled neighbor support | No | No | No | Feature | No |
| Statistics | [`clear stats`](cli_commands.md#clear-stats) | Full parser | Yes | Yes | Yes | Yes | Yes |
| Statistics | [`stats-core`](cli_commands.md#stats-core) | Local serial | Serial | Serial | Serial | Serial | Serial |
| Statistics | [`stats-radio`](cli_commands.md#stats-radio) | Local serial | Serial | Serial | Serial | Serial | Serial |
| Statistics | [`stats-radio-diag`](#stats-radio-diag) | Local serial | Serial | Serial | Serial | Serial | Serial |
| Statistics | [`stats-packets`](cli_commands.md#stats-packets) | Local serial | Serial | Serial | Serial | Serial | Serial |
| Statistics | [`stats-core`](cli_commands.md#stats-core) | Local connection | Local | Local | Local | Local | Local |
| Statistics | [`stats-radio`](cli_commands.md#stats-radio) | Local connection | Local | Local | Local | Local | Local |
| Statistics | [`stats-radio-diag`](#stats-radio-diag) | Local connection | Local | Local | Local | Local | Local |
| Statistics | [`stats-packets`](cli_commands.md#stats-packets) | Local connection | Local | Local | Local | Local | Local |
| Statistics | [`get telemetry.temp/volt/volt.i2c`; optional GPS history](cli_commands.md#read-repeater-telemetry-history) | Non-STM32 repeater or room server; I2C command requires a detected voltage monitor; GPS requires a provider; remote access requires administrator | Yes | Yes | Yes | Yes | Yes |
| Statistics | [`set telemetry.gps`; `get/set/send telemetry.tx`](cli_commands.md#read-repeater-telemetry-history) | Non-STM32 repeater; GPS setting requires a provider; remote access requires administrator | Yes | Yes | Yes | Yes | Yes |
| Logging | [`log start`; `log stop`; `log erase`](cli_commands.md#logging) | Storage-backed roles retain data | Yes | Yes | Yes | Yes | Yes |
| Logging | [`log`](cli_commands.md#print-the-captured-log-to-the-serial-terminal) | Local serial | Serial | Serial | Serial | Serial | Serial |
| Logging | [`log`](cli_commands.md#print-the-captured-log-to-the-serial-terminal) | Local connection | Local | Local | Local | Local | Local |
| Logging | [`get/set usb.logging`; unified FULL `get/set logging.output`](cli_commands.md#control-live-usb-logging) | Ordinary safe-USB artifacts; CommonCLI USB gate is persistent; unified ESP32 FULL selects off/USB/WiFi/both; every ESP32 Full Companion uses an input-capable single-TTY logging terminal with framed USB Companion disabled while logging | Yes | Yes | No | No | Yes |
| Radio | [`get radio`; `set radio ...`](cli_commands.md#view-or-change-this-nodes-radio-parameters) | All text CLI roles | Yes | Yes | Yes | Yes | Yes |
| Radio | [`get tx`; `set tx <dbm>`](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 <mhz>`](cli_commands.md#view-or-change-this-nodes-frequency) | `set` is local serial only | Yes | Yes | Yes | Yes | Yes |
| Radio | [`get freq`; `set freq <mhz>`](cli_commands.md#view-or-change-this-nodes-frequency) | `set` requires a local connection | 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 |
@@ -289,8 +300,8 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto
| System | [`get/set name`](cli_commands.md#view-or-change-this-nodes-name) | All full-parser text CLI roles | Yes | Yes | Yes | Yes | Yes |
| System | [`get/set lat`](cli_commands.md#view-or-change-this-nodes-latitude) | All full-parser text CLI roles | Yes | Yes | Yes | Yes | Yes |
| System | [`get/set lon`](cli_commands.md#view-or-change-this-nodes-longitude) | All full-parser text CLI roles | Yes | Yes | Yes | Yes | Yes |
| System | [`get/set prv.key`](cli_commands.md#view-or-change-this-nodes-identity-private-key) | `get` is local serial only | Yes | Yes | Yes | Yes | Yes |
| System | [`password <new_password>`](cli_commands.md#change-this-nodes-admin-password) | Administrator | Yes | Yes | Yes | Yes | Yes |
| System | [`get/set prv.key`](cli_commands.md#view-or-change-this-nodes-identity-private-key) | `get` requires a local connection | Yes | Yes | Yes | Yes | Yes |
| System | [`get password`; `password <new_password>`](cli_commands.md#change-this-nodes-admin-password) | `get` requires a local connection; setter requires administrator | Yes | Yes | Yes | Yes | Yes |
| System | [`get/set guest.password`](cli_commands.md#view-or-change-this-nodes-guest-password) | Role with guest administration | Yes | Yes | Yes | Yes | Yes |
| System | [`get/set owner.info`](cli_commands.md#view-or-change-this-nodes-owner-info) | Full parser | Yes | Yes | Yes | Yes | Yes |
| System | [`get/set adc.multiplier`](cli_commands.md#fine-tune-the-battery-reading) | Board ADC override support | Feature | Feature | Feature | Feature | Feature |
@@ -331,7 +342,7 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto
| Routing | [`get/set outpath`](halo_keymind_settings.md#direct-path-overrides) | Repeater remote-client context | Yes | Yes | Yes | Yes | Yes |
| Routing | [`get/set altpath`](halo_keymind_settings.md#direct-path-overrides) | Repeater remote-client context | Yes | Yes | Yes | Yes | Yes |
| ACL | [`setperm <pubkey> <permissions>`](cli_commands.md#add-update-or-remove-permissions-for-a-companion) | Repeater, room server, or sensor | Yes | Yes | Yes | Yes | Yes |
| ACL | [`get acl`](cli_commands.md#view-the-current-acl) | Local serial | Serial | Serial | Serial | Serial | Serial |
| ACL | [`get acl`](cli_commands.md#view-the-current-acl) | Local connection | Local | Local | Local | Local | Local |
| ACL | [`get/set allow.read.only`](cli_commands.md#view-or-change-this-room-servers-read-only-flag) | Room server | Yes | Yes | No | Yes | Yes |
| Regions | [`region load`](cli_commands.md#bulk-load-region-lists); [`region save`](cli_commands.md#save-any-changes-to-regions-made-since-reboot) | Role with region storage | Yes | Yes | Yes | Yes | Yes |
| Regions | [`region allowf`](cli_commands.md#allow-a-region); [`region denyf`](cli_commands.md#block-a-region) | Role with region storage | Yes | Yes | Yes | Yes | Yes |
@@ -399,7 +410,7 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto
| MQTT | [`get mqtt.ntp.diag`](cli_commands.md#diagnose-ntp-server-connectivity-mqtt-observer-only) | MQTT observer | No | No | No | Yes | No |
| MQTT | [`get/set timezone`; `get/set timezone.offset`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#timezone-commands) | MQTT observer | No | No | No | Yes | No |
| MQTT | [`get/set mqtt.analyzer.us`; `get/set mqtt.analyzer.eu`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#migration-from-old-configuration) | Legacy MQTT aliases | No | No | No | Yes | No |
| MQTT | [`get/set mqtt.owner`; `get/set mqtt.email`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#mqtt-shared-commands) | MQTT observer; `get` is local serial only | No | No | No | Yes | No |
| MQTT | [`get/set mqtt.owner`; `get/set mqtt.email`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#mqtt-shared-commands) | MQTT observer; `get` requires a local connection | No | No | No | Yes | No |
| MQTT | [`get mqtt.config.valid`](#mqtt-config-valid) | MQTT observer | No | No | No | Yes | No |
| SNMP | [`get/set snmp`; `get/set snmp.community`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_SNMP.md#cli-commands) | MQTT target compiled with SNMP | No | No | No | Feature | No |
| Alerts | [`get/set alert`; `get/set alert.psk`; `get/set alert.hashtag`; `get/set alert.region`; `get/set alert.wifi`; `get/set alert.mqtt`; `get/set alert.interval`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/ALERTS.md#cli) | MQTT observer | No | No | No | Yes | No |
+45 -21
View File
@@ -80,7 +80,8 @@ command aliases are listed there explicitly.
**Usage:**
- `uf2reset`
**Serial Only:** Yes
**Local connection only:** USB, direct network CLI, or Companion binary
command `0x42` where the role supports the command; unavailable over LoRa.
**Note:** Reboots directly into the UF2 bootloader on supported nRF52 boards.
This includes the Repeater, Room Server, Sensor, Companion, and Terminal Chat
@@ -213,6 +214,7 @@ folder transport.
- `set webui off`
- `get webui`
- `get wifi.ssid`
- `get wifi.pwd`
- `get wifi.status`
- `get wifi.powersave`
- `get wifi.cli`
@@ -252,8 +254,8 @@ their profile's idle timeout.
Every ESP32 build with WebConfig supports the browser command terminal,
including WiFi Companion and Full Companion. Companions use their complete
USB/TCP terminal on the trusted LAN, including contact import, chat, recipient
selection, and delayed replies. Repeater and Room Server use the authenticated
admin parser.
selection, and delayed replies. Repeater and Room Server use their local CLI
after admin authentication.
To add a Companion contact, run `import meshcore://<full-contact-card-data>`,
then `list` to confirm it appears. Get the complete card with `card` on the
@@ -266,8 +268,12 @@ the **CLI** tab.
`get wifi.cli` reports `off`, `on, waiting for WiFi client`, or `on, active`.
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. Commands explicitly restricted to
a physical serial connection remain unavailable.
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.
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
@@ -302,8 +308,9 @@ ordinary passphrases remain limited to 63 characters. Other 64-character values
and all longer values are rejected. MQTT observer WiFi passwords retain their
fixed 63-character limit.
Power-save changes are applied immediately when WiFi is running and otherwise
take effect on the next connection. `get wifi.pwd` is intentionally unavailable
so the standalone password is never returned by the CLI.
take effect on the next connection. `get wifi.pwd` returns the saved password
on a local connection. It remains unavailable or masked over LoRa, and the
configuration form continues to mask stored secrets.
ESP32 WiFi Companion WebConfig exposes the same `wifi.powersave` values in its
WiFi card. Every ESP32 WiFi Companion with WebConfig exposes the standalone
@@ -314,7 +321,9 @@ restarting the WiFi station, so a TCP client should expect to reconnect at the
new address; USB password input is masked. Binary Companion clients can use
USB, BLE, or TCP port 5000 without the terminal-start token: send command
`0x42` (`CMD_RUN_CLI_COMMAND`) followed by the same CLI text, such as
`get wifi.powersave` or `set wifi.powersave min`. WiFi-only Companions accept
`get wifi.powersave`, `get wifi.pwd`, `stats-core`, or `set wifi.powersave min`.
See [local maintenance commands](terminal_chat_cli.md#local-maintenance-commands)
for the complete access rules. WiFi-only Companions accept
all three modes. A Full Companion that runs BLE and infrastructure WiFi
simultaneously rejects `none` because coexistence requires modem sleep.
Companion device power saving and LoRa `radio.rxps` remain independent. On an
@@ -378,7 +387,8 @@ remain available.
**Usage:**
- `erase`
**Serial Only:** Yes
**Local connection only:** USB, direct network CLI, or Companion binary
command `0x42` where the role supports the command; unavailable over LoRa.
**Warning:** _**This is destructive!**_
@@ -446,7 +456,8 @@ Elsewhere it replies `Err - neighbors not enabled in this build`. If a
**Usage:**
- `stats-core`
**Serial Only:** Yes
**Local connection only:** USB, direct network CLI, or Companion binary
command `0x42` where the role supports the command; unavailable over LoRa.
---
@@ -457,7 +468,8 @@ Elsewhere it replies `Err - neighbors not enabled in this build`. If a
**Usage:** `stats-radio`
**Serial Only:** Yes
**Local connection only:** USB, direct network CLI, or Companion binary
command `0x42` where the role supports the command; unavailable over LoRa.
---
@@ -465,7 +477,8 @@ Elsewhere it replies `Err - neighbors not enabled in this build`. If a
### Packet stats - Packet counters: Received, Sent
**Usage:** `stats-packets`
**Serial Only:** Yes
**Local connection only:** USB, direct network CLI, or Companion binary
command `0x42` where the role supports the command; unavailable over LoRa.
---
@@ -855,10 +868,12 @@ only `get mqtt.enabled`. WiFi modem power saving is a separate setting.
---
### Print the captured log to the serial terminal
<a id="print-the-captured-log-to-the-serial-terminal"></a>
### Print the captured log to the requesting terminal
**Usage:** `log`
**Serial Only:** Yes
**Local connection only:** USB, direct network CLI, or Companion binary
command `0x42` where the role supports the command; unavailable over LoRa.
---
@@ -1052,7 +1067,8 @@ automation treats that version as the wire-format capability boundary.
**Default:** `869.525`
**Note:** Requires reboot to apply
**Serial Only:** `set freq <frequency>`
**Local connection only:** `set freq <frequency>`. USB, browser/TCP/Ethernet
CLI, and Companion binary command `0x42` can set it; LoRa cannot.
---
@@ -1315,16 +1331,18 @@ for the tested XIAO handoff and host-cache caveats.
**Parameters:**
- `private_key`: Private key in hex format (64 hex characters)
**Serial Only:**
- `get prv.key`: Yes
- `set prv.key`: No
**Local connection only:** `get prv.key`. Companion also requires
`ENABLE_PRIVATE_KEY_EXPORT=1`; use binary command `0x42` or the text terminal.
Infrastructure `set prv.key` retains its administrator access rules.
**Note:** Requires reboot to take effect after setting
---
#### Change this node's admin password
<a id="change-this-nodes-admin-password"></a>
#### View or change this node's admin password
**Usage:**
- `get password`
- `password <new_password>`
**Parameters:**
@@ -1334,7 +1352,12 @@ for the tested XIAO handoff and host-cache caveats.
**Default:** `password`
**Note:** Command reply echoes the updated password for confirmation.
`get password` returns the current admin password only on a local connection:
USB, Ethernet, or the authenticated LAN browser CLI. It is rejected over LoRa.
Companion has no local admin password and reports that explicitly, including
through binary command `0x42`; this command does not report the Bluetooth PIN.
**Note:** The password setter replies with confirmation.
**Note:** Any node using this password will be added to the admin ACL list.
@@ -2804,7 +2827,8 @@ eviction like administrators.
**Usage:**
- `get acl`
**Serial Only:** Yes
**Local connection only:** USB, direct network CLI, or Companion binary
command `0x42` where the role supports the command; unavailable over LoRa.
---
+10
View File
@@ -251,6 +251,16 @@ as the normal CLI reply text `Unknown command`, not as an error frame. Clients
may prefix the CLI text with any two-character correlation tag and `|` (for
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 <MHz>`, 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
[local maintenance commands](terminal_chat_cli.md#local-maintenance-commands)
for role differences and the text-terminal equivalents.
Full Companion clients can send `version` through this command to receive the
untruncated build identity, for example `Companion 1.17.1.5-... (protocol 14,
build 31-Aug-2026)`. This deliberately supplements rather than changes the
+2 -1
View File
@@ -245,7 +245,8 @@ persisted and requires a reboot, unlike a WiFi power-save change.
SSID and password writes return their reply first, then restart the Companion
WiFi station with the saved credentials. A TCP terminal therefore disconnects
shortly after either write; reconnect to the IP reported by the new network.
The password is write-only and is masked while it is entered over USB. It may
Password entry is masked over USB. `get wifi.pwd` reads the saved password from
any local connection, including binary command `0x42`; LoRa cannot read it. It may
be empty for an open network, an ordinary passphrase of up to 63 characters, or
an exact 64-character hexadecimal WPA/WPA2 PSK. Other 64-character values and
all longer values are rejected.
+35
View File
@@ -41,6 +41,41 @@ available buffer while unread, the browser reports the missing output.
USB protocol-switch and USB MOTA ownership commands still apply to the USB
connection. Use the WiFi MOTA seeder on port 5001 for a host folder over WiFi.
## Local maintenance commands
Local means a connection directly to the node: USB, BLE or WiFi/Ethernet
Companion protocol, a TCP terminal, or the browser CLI on the LAN. These
connections can use the following commands when the role and build include
the corresponding feature:
| Command | Result |
|---|---|
| `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 <MHz>` | 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 |
| `get acl`, `log` | Infrastructure ACL or captured packet log; these stores do not exist on Companion |
The command text is the same for direct connections. A binary Companion app
can send `0x42` (`CMD_RUN_CLI_COMMAND`) followed by the ASCII command, without
switching into terminal mode. The response is `0x1D` (`RESP_CODE_CLI_REPLY`)
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.
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.
The browser CLI requires station/LAN mode and `wifi.cli on`. Infrastructure
uses its admin login; Companion uses the trusted LAN. Configuration forms keep
password fields masked; explicit local CLI getters return their values.
## Companion USB mode
An ordinary Companion USB build starts in the normal binary Companion protocol
+120 -4
View File
@@ -8,6 +8,7 @@
#include <helpers/IdentityGeneration.h>
#include <helpers/LazyPersistence.h>
#include <helpers/StorageLayout.h>
#include <helpers/StatsFormatHelper.h>
#include <helpers/UsbAsciiBinarySwitch.h>
#include <helpers/UsbLogging.h>
#include "helpers/radiolib/RXPowerSaving.h"
@@ -54,8 +55,10 @@
#include <helpers/MQTTDefaults.h>
#endif
#ifdef WITH_WEBCONFIG
#if defined(ESP32) && defined(WIFI_SSID)
#include <helpers/WiFiSetupPortal.h>
#endif
#ifdef WITH_WEBCONFIG
#include <WiFi.h>
#include <esp_wifi.h>
#endif
@@ -1071,7 +1074,8 @@ void MyMesh::onCLICommandRecv(const ContactInfo &from, mesh::Packet *pkt, uint32
const char *text, char* reply) {
markConnectionActive(from); // in case this is from a server, and we have a connection
if (from.isRemoteCLIAllowed()) {
if (!handleCommand(text, sender_timestamp, reply)) {
// A timestamp received over the radio must never grant local-console access.
if (!handleCommand(text, sender_timestamp ? sender_timestamp : 1, reply)) {
strcat(reply, "Unknown command"); // reply may have cmd prefix from 'text'
}
} else {
@@ -3378,7 +3382,7 @@ void MyMesh::execAdminCommand(char* cmd, char* reply) {
strcpy(reply, "Error: enter a plain CLI command");
return;
}
if (handleCommand(cmd, 1, reply)) return;
if (handleCommand(cmd, 0, reply)) return;
if (strcmp(cmd, "get radio.rxps") == 0 || strcmp(cmd, "get radio.rxps.config") == 0) {
if (!radio_driver.supportsRxPowerSaving()) {
strcpy(reply, "Error: RX power saving is unsupported on this radio");
@@ -7410,6 +7414,10 @@ void MyMesh::handleTerminalCommand(char* command) {
#endif
char local_reply[160];
if (handleDirectCommand(command, local_reply, sizeof(local_reply))) {
terminalOutput().printf(" %s\r\n", local_reply);
return;
}
#if COMPANION_FEATURE_USB_MOTA_SOURCE
const bool usb_mota_owner_transition =
mesh::isUsbMotaOwnerTransitionCommand(command);
@@ -7880,6 +7888,10 @@ void MyMesh::handleTerminalCommand(char* command) {
FIRMWARE_VERSION, (unsigned)FIRMWARE_VER_CODE, FIRMWARE_BUILD_DATE);
} else if (strcmp(command, "help") == 0) {
terminalOutput().print("Commands:\r\n");
terminalOutput().print(" stats-core / stats-radio / stats-radio-diag / stats-packets\r\n");
terminalOutput().print(" get prv.key (when private key export is enabled)\r\n");
terminalOutput().print(" get password (this role has no admin password)\r\n");
terminalOutput().print(" erase (erase stored settings and identity)\r\n");
terminalOutput().print(" board\r\n");
terminalOutput().print(" version\r\n");
terminalOutput().print(" get storage.layout\r\n");
@@ -7920,7 +7932,7 @@ void MyMesh::handleTerminalCommand(char* command) {
terminalOutput().print(" get wifi.powersave\r\n");
terminalOutput().print(" set wifi.powersave <none|min|max>\r\n");
#ifdef WITH_WEBCONFIG
terminalOutput().print(" get wifi.{ssid|status}\r\n");
terminalOutput().print(" get wifi.{ssid|pwd|status}\r\n");
terminalOutput().print(" set wifi.ssid <network name>\r\n");
terminalOutput().print(" set wifi.pwd <password>\r\n");
terminalOutput().print(" get webui\r\n");
@@ -8023,6 +8035,108 @@ static bool isCompanionRadioPrefsCommand(const char* command) {
return false;
}
// Called only for directly attached USB/BLE/TCP/browser clients. Commands
// received over LoRa use handleCommand() with a nonzero sender timestamp.
bool MyMesh::handleDirectCommand(const char* command, char* reply, size_t reply_size) {
if (strcmp(command, "get password") == 0) {
snprintf(reply, reply_size, "> (no admin password on Companion)");
return true;
}
if (strcmp(command, "get prv.key") == 0) {
#if ENABLE_PRIVATE_KEY_EXPORT
uint8_t key[PRV_KEY_SIZE];
char hex[PRV_KEY_SIZE * 2 + 1];
const int length = self_id.writeTo(key, sizeof(key));
mesh::Utils::toHex(hex, key, length);
snprintf(reply, reply_size, "> %s", hex);
memset(key, 0, sizeof(key));
memset(hex, 0, sizeof(hex));
#else
snprintf(reply, reply_size, "Error: private key export disabled in this build");
#endif
return true;
}
if (strcmp(command, "erase") == 0) {
if (_store->formatFileSystem()) {
stopContactsIterator();
resetContacts();
mesh::resetLazyPersistenceAfterSuccess(dirty_contacts_expiry, dirty_contacts_failures);
snprintf(reply, reply_size, "File system erase: OK");
} else {
snprintf(reply, reply_size, "File system erase: Err");
}
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;
}
if (strcmp(command, "stats-radio") == 0) {
StatsFormatHelper::formatRadioStats(reply, _radio, radio_driver,
getTotalAirTime(), getReceiveAirTime());
return true;
}
if (strcmp(command, "stats-radio-diag") == 0) {
StatsFormatHelper::formatRadioDiag(reply, _radio, radio_driver, *_ms,
_err_flags, hasOutbound());
return true;
}
if (strcmp(command, "stats-packets") == 0) {
StatsFormatHelper::formatPacketStats(reply, radio_driver, getNumSentFlood(),
getNumSentDirect(), getNumRecvFlood(), getNumRecvDirect());
return true;
}
#if defined(ESP32) && defined(WIFI_SSID)
if (strcmp(command, "get wifi.pwd") == 0) {
#ifdef WITH_WEBCONFIG
WebConfigServer::formatWiFiPassword(reply, reply_size);
#else
char ssid[32] = {}, password[65] = {};
if (!WiFiSetupPortal::loadStoredCredentials(ssid, sizeof(ssid), password, sizeof(password))) {
#ifdef WIFI_PWD
snprintf(password, sizeof(password), "%s", WIFI_PWD);
#endif
}
snprintf(reply, reply_size, "> %s", password);
memset(password, 0, sizeof(password));
#endif
return true;
}
#ifdef WITH_MQTT_BRIDGE
if (strncmp(command, "get mqtt", 8) == 0 && command[8] >= '1'
&& command[8] <= '0' + MAX_MQTT_SLOTS && command[9] == '.') {
const int slot = command[8] - '1';
const char* field = command + 10;
const char* value = nullptr;
if (strcmp(field, "password") == 0) value = _mqtt_prefs.mqtt_slot_password[slot];
if (strcmp(field, "token") == 0) value = _mqtt_prefs.mqtt_slot_token[slot];
if (value) {
snprintf(reply, reply_size, "> %s", value);
return true;
}
}
#endif
#endif
return false;
}
bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp,
char* reply) {
if (command == NULL || reply == NULL) return false;
@@ -8039,6 +8153,8 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp,
while (*command == ' ' || *command == '\t') command++;
}
if (sender_timestamp == 0 && handleDirectCommand(command, reply, reply_capacity)) return true;
#if COMPANION_FEATURE_USB_MOTA_SOURCE
if (mesh::isUsbMotaOwnerTransitionCommand(command)) {
snprintf(reply, reply_capacity,
+5
View File
@@ -132,6 +132,10 @@ class MyMesh : public BaseChatMesh, public DataStoreHost, public UIShutdownGuard
public:
MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store, AbstractUITask* ui=NULL);
bool handleLocalCommand(const char* command, char* reply) {
return handleCommand(command, 0, reply);
}
void begin(bool has_display, bool radio_available = true);
void activateRadio();
bool isRadioReady() const { return _radio_available; }
@@ -370,6 +374,7 @@ private:
void checkCLIRescueCmd();
bool handleCommand(const char* text, uint32_t sender_timestamp, char* reply);
bool handleDirectCommand(const char* command, char* reply, size_t reply_size);
void checkSerialInterface();
bool applyAndSaveFemRxGain(bool enabled);
bool applyAndSaveFemTxGain(bool enabled);
+7 -6
View File
@@ -1674,7 +1674,7 @@ void halt() {
/* WIFI TEXT CONSOLE - one client at a time on a dedicated port, separate from
Binary Companion (5000) and the mOTA seeder (5001). A Full Companion exposes
the same role CLI here as its USB terminal. Other OTA-enabled Companion
builds retain the bounded `ota ...` management console. */
builds expose local maintenance commands and `ota ...` commands. */
#if defined(ESP32) && defined(WIFI_SSID) && defined(ENABLE_OTA)
#include <helpers/ota/OtaCli.h> // mesh::ota::handle_ota_command(line, reply, board)
#include <helpers/esp32/WiFiOtaSeeder.h>
@@ -1701,7 +1701,7 @@ void halt() {
WIFI_DEBUG_PRINTLN("Full Companion terminal listening on :%d (nc <ip> %d)",
OTA_CONSOLE_TCP_PORT, OTA_CONSOLE_TCP_PORT);
#else
WIFI_DEBUG_PRINTLN("OTA console listening on :%d (nc <ip> %d, type `ota ...`)",
WIFI_DEBUG_PRINTLN("Local CLI and OTA console listening on :%d (nc <ip> %d)",
OTA_CONSOLE_TCP_PORT, OTA_CONSOLE_TCP_PORT);
#endif
}
@@ -1767,7 +1767,7 @@ void halt() {
ota_console_client.stop();
}
#else
ota_console_client.print("OTA console - type `ota ...`\r\n> ");
ota_console_client.print("Local CLI and OTA console - type `version` or `ota status`\r\n> ");
#endif
}
return;
@@ -1810,8 +1810,9 @@ void halt() {
ota_console_client.print("> ");
#else
char reply[160]; reply[0] = 0;
if (!mesh::ota::handle_ota_command(ota_console_line, reply, board))
strcpy(reply, "only `ota ...` commands are supported on this console");
if (!the_mesh.handleLocalCommand(ota_console_line, reply)
&& !mesh::ota::handle_ota_command(ota_console_line, reply, board))
strcpy(reply, "Unknown local or OTA command");
ota_console_client.print(" -> "); ota_console_client.print(reply); ota_console_client.print("\r\n> ");
#endif
ota_console_clear_line();
@@ -2830,7 +2831,7 @@ void loop() {
serviceCompanionWiFiState();
if (companion_wifi_requested && companion_wifi_active) {
#ifdef ENABLE_OTA
ota_console_loop(); // service the OTA text console (port 5002)
ota_console_loop(); // service the local text console (port 5002)
#endif
const unsigned long wifi_now = millis();
const bool station_channel_ok = mesh::wifi::enforceStationChannel();
+58 -1
View File
@@ -2396,6 +2396,22 @@ void MyMesh::formatRecentRepeatersReply(char *reply, int page,
}
void MyMesh::printRecentRepeatersSerial() {
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
if (_command_output) {
_local_cli_output.startRows(*_command_output,
[](void* context, size_t& row, char* out, size_t capacity) -> size_t {
auto& owner = *static_cast<MyMesh*>(context);
const auto* tables = static_cast<const SimpleMeshTables*>(owner.getTables());
const auto* info = tables ? tables->getRecentRepeaterBySortedIdx(row++) : nullptr;
if (!info) return 0;
char prefix[MAX_ROUTE_HASH_BYTES * 2 + 1], snr[12];
mesh::Utils::toHex(prefix, info->prefix, info->prefix_len);
formatLocalSnrX4(snr, sizeof(snr), info->snr_x4);
return snprintf(out, capacity, "%s,%s%s\r\n", prefix, snr[0] == '-' ? "" : " ", snr);
}, this, "Recent repeaters:\r\n");
return;
}
#endif
const SimpleMeshTables* tables = static_cast<const SimpleMeshTables*>(getTables());
if (tables == NULL) {
mesh::usbConsolePort().printf("Error: unsupported\r\n");
@@ -4795,6 +4811,9 @@ bool MyMesh::scheduleNormalRadio() {
}
bool MyMesh::formatFileSystem() {
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
_local_cli_output.cancel();
#endif
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
return InternalFS.format();
#elif defined(RP2040_PLATFORM)
@@ -4845,6 +4864,13 @@ void MyMesh::updateFloodAdvertTimer() {
}
void MyMesh::dumpLogFile() {
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
if (_command_output) {
_local_cli_output.startFile(*_command_output,
mesh::openFileRead(_fs, PACKET_LOG_FILE));
return;
}
#endif
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
if (hasPendingSerialOutput()) {
mesh::usbConsolePort().printf("Err - USB output busy\r\n");
@@ -11100,6 +11126,7 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
_gpio_reply_tracker.beginCommand(gpio_client_index, gpio_path_hash_size,
sender == NULL ? NULL : sender->id.pub_key);
#endif
if (sender != nullptr && sender_timestamp == 0) sender_timestamp = 1;
char* reply_start = reply;
// Parse the original wire text exactly once, also before region-load mode.
// Parsing again after stripping a prefix would let nested prefixes bypass
@@ -11537,6 +11564,9 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
// Compatibility path for manually defined legacy portable builds. Current
// release builds use FULL and do not enter this branch.
_cli.handleCommand(sender_timestamp, command, reply);
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
if (_command_output && _local_cli_output.owns(*_command_output)) reply[0] = 0;
#endif
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
if (sender_timestamp == 0 && serial_log_eof_pending
&& strcmp(reply, " EOF") == 0) reply[0] = 0;
@@ -11780,6 +11810,24 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
printRecentRepeatersSerial();
reply_start[0] = 0;
} else if (sender_timestamp == 0 && strcmp(command, "get acl") == 0) {
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
if (_command_output) {
_local_cli_output.startRows(*_command_output,
[](void* context, size_t& row, char* out, size_t capacity) -> size_t {
auto& owner = *static_cast<MyMesh*>(context);
while (row < static_cast<size_t>(owner.acl.getNumClients())) {
auto* client = owner.acl.getClientByIdx(row++);
if (!client->permissions) continue;
char key[PUB_KEY_SIZE * 2 + 1];
mesh::Utils::toHex(key, client->id.pub_key, PUB_KEY_SIZE);
return snprintf(out, capacity, "%02X %s\r\n", client->permissions, key);
}
return 0;
}, this, "ACL:\r\n");
reply[0] = 0;
return;
}
#endif
mesh::usbConsolePort().printf("ACL:\r\n");
for (int i = 0; i < acl.getNumClients(); i++) {
auto c = acl.getClientByIdx(i);
@@ -11942,7 +11990,10 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
strcpy(reply, "Err - neighbors not enabled in this build");
#endif
} else{
_cli.handleCommand(sender_timestamp, command, reply); // common CLI commands
_cli.handleCommand(sender_timestamp, command, reply);
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
if (_command_output && _local_cli_output.owns(*_command_output)) reply[0] = 0;
#endif
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
if (sender_timestamp == 0 && serial_log_eof_pending
&& strcmp(reply, " EOF") == 0) reply[0] = 0;
@@ -11951,6 +12002,9 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
}
void MyMesh::loop() {
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
_local_cli_output.service();
#endif
#ifdef WITH_MQTT_BRIDGE
if (mqtt_bridge) mqtt_bridge->servicePendingClockCorrection();
#endif
@@ -12937,6 +12991,9 @@ bool MyMesh::startNeighborDiscover(char* reply) {
// To check if there is pending work
bool MyMesh::hasPendingWork() const {
if (hasPendingOtaApply()) return true;
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
if (_local_cli_output.busy()) return true;
#endif
if (deferred_cli_command.pending || pending_self_advert || _cli.hasActiveUserGpioTimer()) return true;
#if defined(WITH_BRIDGE)
const AbstractBridge* active_bridge = activeBridge();
+59 -1
View File
@@ -110,6 +110,7 @@
#include <helpers/SimpleMeshTables.h>
#include <helpers/StaticPoolPacketManager.h>
#include <helpers/StatsFormatHelper.h>
#include <helpers/LocalCliOutput.h>
#if MESH_ENABLE_TELEMETRY_HISTORY
#include <helpers/ExternalVoltageHistory.h>
#include <helpers/TelemetryHistory.h>
@@ -298,6 +299,16 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
};
FILESYSTEM* _fs;
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
#ifdef NRF52_PLATFORM
mesh::LocalCliOutput<File> _local_cli_output{File(InternalFS)};
#else
mesh::LocalCliOutput<File> _local_cli_output;
#endif
Stream* _command_output = nullptr;
Stream* _web_terminal = nullptr;
#endif
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
File serial_log_dump;
size_t serial_log_remaining = 0;
@@ -1023,6 +1034,22 @@ public:
void saveIdentity(const mesh::LocalIdentity& new_id) override;
void clearStats() override;
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
void handleLocalCommand(char* command, char* reply, Stream& output) {
if (_local_cli_output.busy()) {
strcpy(reply, "Err - local listing in progress");
return;
}
_command_output = &output;
handleCommand(0, command, reply);
_command_output = nullptr;
}
bool hasPendingLocalOutput() const { return _local_cli_output.busy(); }
void cancelLocalOutput(Stream& output) {
if (_local_cli_output.owns(output)) _local_cli_output.cancel();
}
#endif
void handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char* command,
char* reply, int gpio_client_index = -1,
uint8_t gpio_path_hash_size = 1, bool usb_origin = false);
@@ -1351,6 +1378,9 @@ public:
bool setWebUIEnabled(bool enabled, char* reply) override;
bool getWebUIStatus(char* reply) const override;
bool getWiFiSSID(char* reply) const override;
bool getWiFiPassword(char* reply) const override {
return WebConfigServer::formatWiFiPassword(reply, 160);
}
bool getWiFiStatus(char* reply) const override;
bool getWiFiPowerSave(char* reply) const override;
bool getWiFiCLI(char* reply) const override;
@@ -1365,7 +1395,35 @@ public:
void execCommand(char* cmd, char* reply) override { handleCommand(0, cmd, reply); }
bool supportsCliTerminal() const override { return true; }
void execAdminCommand(char* cmd, char* reply) override {
handleCommand(1, cmd, reply);
if (strcmp(cmd, "get acl") == 0 || strcmp(cmd, "log") == 0
|| strcmp(cmd, "get recent.repeater") == 0
|| strcmp(cmd, "get recent.repeaters") == 0) {
strcpy(reply, "Error: use /api/terminal for streamed listings");
return;
}
handleCommand(0, cmd, reply);
}
bool supportsStreamTerminal() const override { return true; }
bool beginStreamTerminal(Stream& output) override {
if (_web_terminal || _local_cli_output.busy()) return false;
_web_terminal = &output;
return true;
}
bool ownsStreamTerminal(const Stream& output) const override {
return _web_terminal == &output;
}
void runStreamTerminal(char* command) override {
if (!_web_terminal) return;
char reply[160] = {};
handleLocalCommand(command, reply, *_web_terminal);
if (reply[0]) _web_terminal->println(reply);
}
bool streamTerminalBusy() const override {
return _web_terminal && _local_cli_output.owns(*_web_terminal);
}
void endStreamTerminal(Stream& output) override {
cancelLocalOutput(output);
if (_web_terminal == &output) _web_terminal = nullptr;
}
void rebootNow() override { _cli.getBoard()->reboot(); }
void onConfigBatchStart() override {
+6 -2
View File
@@ -313,11 +313,15 @@ static void __attribute__((noinline)) serviceCommandInterfaces() {
#ifdef ETHERNET_ENABLED
ethernet_loop_maintain();
if (ethernet_read_line(ethernet_command, sizeof(ethernet_command))) {
if (ethernet_take_session_reset() || !ethernet_client.connected()) {
the_mesh.cancelLocalOutput(ethernet_client);
ethernet_command[0] = 0;
}
if (!the_mesh.hasPendingLocalOutput() && ethernet_read_line(ethernet_command, sizeof(ethernet_command))) {
char reply[160];
reply[0] = 0;
if (!ethernet_handle_command(ethernet_command, reply)) {
the_mesh.handleCommand(0, ethernet_command, reply);
the_mesh.handleLocalCommand(ethernet_command, reply, ethernet_client);
}
ethernet_send_reply(reply);
ethernet_command[0] = 0;
+39 -1
View File
@@ -1536,6 +1536,9 @@ bool MyMesh::scheduleNormalRadio() {
}
bool MyMesh::formatFileSystem() {
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
_local_cli_output.cancel();
#endif
#if defined(NRF52_PLATFORM)
return InternalFS.format();
#elif defined(RP2040_PLATFORM)
@@ -1577,6 +1580,13 @@ void MyMesh::updateFloodAdvertTimer() {
}
void MyMesh::dumpLogFile() {
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
if (_command_output) {
_local_cli_output.startFile(*_command_output,
mesh::openFileRead(_fs, PACKET_LOG_FILE));
return;
}
#endif
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
if (hasPendingSerialOutput()) {
mesh::usbConsolePort().printf("Err - USB output busy\r\n");
@@ -2254,6 +2264,7 @@ void MyMesh::sampleTelemetryHistory() {
void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply,
int gpio_client_index,
uint8_t gpio_path_hash_size) {
if (gpio_client_index >= 0 && sender_timestamp == 0) sender_timestamp = 1;
#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL)
const uint8_t* gpio_client_key = gpio_client_index >= 0 &&
gpio_client_index < acl.getNumClients()
@@ -2346,6 +2357,24 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply
}
}
} else if (sender_timestamp == 0 && strcmp(command, "get acl") == 0) {
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
if (_command_output) {
_local_cli_output.startRows(*_command_output,
[](void* context, size_t& row, char* out, size_t capacity) -> size_t {
auto& owner = *static_cast<MyMesh*>(context);
while (row < static_cast<size_t>(owner.acl.getNumClients())) {
auto* client = owner.acl.getClientByIdx(row++);
if (!client->permissions) continue;
char key[PUB_KEY_SIZE * 2 + 1];
mesh::Utils::toHex(key, client->id.pub_key, PUB_KEY_SIZE);
return snprintf(out, capacity, "%02X %s\r\n", client->permissions, key);
}
return 0;
}, this, "ACL:\r\n");
reply[0] = 0;
return;
}
#endif
mesh::usbConsolePort().printf("ACL:\r\n");
for (int i = 0; i < acl.getNumClients(); i++) {
auto c = acl.getClientByIdx(i);
@@ -2415,7 +2444,10 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply
else if (_clock_sync.handleCommand(command, reply)) {
// handled by the role-independent mesh clock synchronizer
} else {
_cli.handleCommand(sender_timestamp, command, reply); // common CLI commands
_cli.handleCommand(sender_timestamp, command, reply);
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
if (_command_output && _local_cli_output.owns(*_command_output)) reply[0] = 0;
#endif
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
if (sender_timestamp == 0 && serial_log_eof_pending
&& strcmp(reply, " EOF") == 0) reply[0] = 0;
@@ -2428,6 +2460,9 @@ bool MyMesh::saveFilter(ClientInfo* client) {
}
void MyMesh::loop() {
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
_local_cli_output.service();
#endif
#ifdef WITH_MQTT_BRIDGE
if (bridge) bridge->servicePendingClockCorrection();
#endif
@@ -2793,6 +2828,9 @@ uint32_t MyMesh::getPowerSaveSleepSeconds(uint32_t max_secs) const {
// To check if there is pending work
bool MyMesh::hasPendingWork() const {
if (hasPendingOtaApply()) return true;
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
if (_local_cli_output.busy()) return true;
#endif
#if defined(WITH_BRIDGE)
if (bridge && bridge->isRunning()) return true; // bridge needs WiFi radio, can't sleep
#endif
+59 -1
View File
@@ -28,6 +28,7 @@
#include <helpers/UserGpioReplyTracker.h>
#endif
#include <helpers/StatsFormatHelper.h>
#include <helpers/LocalCliOutput.h>
#include <helpers/ClientACL.h>
#include <helpers/LogicalMessageCache.h>
#include <helpers/RemoteCliReplyCache.h>
@@ -158,6 +159,16 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks,
#endif
{
FILESYSTEM* _fs;
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
#ifdef NRF52_PLATFORM
mesh::LocalCliOutput<File> _local_cli_output{File(InternalFS)};
#else
mesh::LocalCliOutput<File> _local_cli_output;
#endif
Stream* _command_output = nullptr;
Stream* _web_terminal = nullptr;
#endif
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
File serial_log_dump;
size_t serial_log_remaining = 0;
@@ -491,6 +502,22 @@ public:
void saveIdentity(const mesh::LocalIdentity& new_id) override;
void clearStats() override;
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
void handleLocalCommand(char* command, char* reply, Stream& output) {
if (_local_cli_output.busy()) {
strcpy(reply, "Err - local listing in progress");
return;
}
_command_output = &output;
handleCommand(0, command, reply);
_command_output = nullptr;
}
bool hasPendingLocalOutput() const { return _local_cli_output.busy(); }
void cancelLocalOutput(Stream& output) {
if (_local_cli_output.owns(output)) _local_cli_output.cancel();
}
#endif
void handleCommand(uint32_t sender_timestamp, char* command, char* reply,
int gpio_client_index = -1,
uint8_t gpio_path_hash_size = 1);
@@ -645,6 +672,9 @@ public:
bool setWebUIEnabled(bool enabled, char* reply) override;
bool getWebUIStatus(char* reply) const override;
bool getWiFiSSID(char* reply) const override;
bool getWiFiPassword(char* reply) const override {
return WebConfigServer::formatWiFiPassword(reply, 160);
}
bool getWiFiStatus(char* reply) const override;
bool getWiFiPowerSave(char* reply) const override;
bool getWiFiCLI(char* reply) const override;
@@ -659,7 +689,35 @@ public:
void execCommand(char* cmd, char* reply) override { handleCommand(0, cmd, reply); }
bool supportsCliTerminal() const override { return true; }
void execAdminCommand(char* cmd, char* reply) override {
handleCommand(1, cmd, reply);
if (strcmp(cmd, "get acl") == 0 || strcmp(cmd, "log") == 0
|| strcmp(cmd, "get recent.repeater") == 0
|| strcmp(cmd, "get recent.repeaters") == 0) {
strcpy(reply, "Error: use /api/terminal for streamed listings");
return;
}
handleCommand(0, cmd, reply);
}
bool supportsStreamTerminal() const override { return true; }
bool beginStreamTerminal(Stream& output) override {
if (_web_terminal || _local_cli_output.busy()) return false;
_web_terminal = &output;
return true;
}
bool ownsStreamTerminal(const Stream& output) const override {
return _web_terminal == &output;
}
void runStreamTerminal(char* command) override {
if (!_web_terminal) return;
char reply[160] = {};
handleLocalCommand(command, reply, *_web_terminal);
if (reply[0]) _web_terminal->println(reply);
}
bool streamTerminalBusy() const override {
return _web_terminal && _local_cli_output.owns(*_web_terminal);
}
void endStreamTerminal(Stream& output) override {
cancelLocalOutput(output);
if (_web_terminal == &output) _web_terminal = nullptr;
}
void rebootNow() override { _cli.getBoard()->reboot(); }
void onConfigBatchStart() override {
+6 -2
View File
@@ -203,11 +203,15 @@ void loop() {
#ifdef ETHERNET_ENABLED
ethernet_loop_maintain();
if (ethernet_read_line(ethernet_command, sizeof(ethernet_command))) {
if (ethernet_take_session_reset() || !ethernet_client.connected()) {
the_mesh.cancelLocalOutput(ethernet_client);
ethernet_command[0] = 0;
}
if (!the_mesh.hasPendingLocalOutput() && ethernet_read_line(ethernet_command, sizeof(ethernet_command))) {
char reply[160];
reply[0] = 0;
if (!ethernet_handle_command(ethernet_command, reply)) {
the_mesh.handleCommand(0, ethernet_command, reply);
the_mesh.handleLocalCommand(ethernet_command, reply, ethernet_client);
}
ethernet_send_reply(reply);
ethernet_command[0] = 0;
+1
View File
@@ -574,6 +574,7 @@ void SensorMesh::onUserGpioTimerCompleted(uint8_t pin, uint8_t state,
void SensorMesh::handleCommand(uint32_t sender_timestamp, char* command, char* reply,
int gpio_client_index,
uint8_t gpio_path_hash_size) {
if (gpio_client_index >= 0 && sender_timestamp == 0) sender_timestamp = 1;
#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL)
const uint8_t* gpio_client_key = gpio_client_index >= 0 &&
gpio_client_index < acl.getNumClients()
+3 -2
View File
@@ -75,8 +75,9 @@ def requirements(platform, defines, target):
parts["mqtt_connections_buffers"] = 24576
if "ENABLE_OTA" in defines:
parts["ota_source_scratch"] = 8192
if (companion and "WIFI_SSID" in defines
and "WEBCONFIG_DISABLED" not in defines):
if ("WEBCONFIG_DISABLED" not in defines
and ((companion and "WIFI_SSID" in defines)
or (not companion and "ADMIN_PASSWORD" in defines))):
parts["browser_terminal_session"] = 2048
if "BOARD_HAS_PSRAM" not in defines:
# Larger replies grow on demand only while preserving 32 KiB
+5 -7
View File
@@ -41,7 +41,8 @@ EXPECTED_FAILURES = {
# Commands that change the node out from under the audit.
SKIP = {"reboot", "clkreboot", "poweroff", "shutdown", "erase", "start ota",
"stop webconfig", "ota update", "start webconfig", "start webconfig ap"}
"stop webconfig", "ota update", "start webconfig", "start webconfig ap",
"log", "get acl"} # Listings use /api/terminal, not this bounded API audit.
def table():
@@ -80,8 +81,6 @@ NOT_OFFERED = {
"tls.bundletest", # TLS debugging, not an operator command
"start ota", # binds port 80, which the portal is already using
"clock sync", # takes its time from the caller; a web request has none
"log", # streams to Serial and stalls the radio ("log start" is offered)
"get acl", # streams to Serial, returns nothing
}
@@ -121,10 +120,9 @@ ROUND_TRIPS = [
("set path.hash.mode 2", "get path.hash.mode", "2"),
("set mqtt.iata den", "get mqtt.iata", "DEN"),
("set radio.rxps 70000 60000", "get radio.rxps", "on,70000,60000"),
# Secret reads are masked back down for an HTTP caller, in CommonCLI's own
# words for a non-serial one (wcIsSecretReadCommand).
("set guest.password hunter2", "get guest.password", "******** (serial only)"),
("set wifi.pwd hunter2", "get wifi.pwd", "******** (serial only)"),
# Explicit authenticated LAN getters have local-connection privileges.
("set guest.password hunter2", "get guest.password", "hunter2"),
("set wifi.pwd hunter2", "get wifi.pwd", "hunter2"),
]
+10 -24
View File
@@ -358,9 +358,9 @@ def apply_set(cfg, key, val):
return True, "OK"
if key == "prv.key":
# write-only by design: the identity goes in, nothing reads it back
if not _hex64(val):
return False, "Error: private key must be 64 hex characters"
if len(val) != 128 or not all(c in "0123456789abcdefABCDEF" for c in val):
return False, "Error: private key must be 128 hex characters"
cfg["identity_private_key"] = val
return True, "OK - identity restored, reboot to apply"
if key == "mqtt.owner":
@@ -518,9 +518,8 @@ CLI_UNAVAILABLE = [
"Run it from the serial console, or use `ota update`."),
("clock sync", True, "clock sync takes its time from the caller, which a web request "
"has no way to supply. Use `time <epoch-seconds>` instead."),
("log", False, "log writes the packet log to the serial console, not here, and "
"blocks the radio while it does. Use `log start` / `log stop`."),
("get acl", False, "get acl writes to the serial console, not here."),
("log", False, "Use /api/terminal for streamed listings."),
("get acl", False, "Use /api/terminal for streamed listings."),
]
@@ -534,17 +533,6 @@ def cli_unavailable(cmd):
# Failure replies CommonCLI emits that do NOT start with "Err" -- the shapes that
# made a naive prefix test call them success. Mirrors
# WebConfigBatch::cliReplyIsFailure.
def cli_reads_secret(cmd):
"""Commands that READ a secret. CommonCLI gates these on the caller being
the serial console; the portal is not, so the value is masked here the way
CommonCLI masks it for remote callers. Mirrors wcCliReadsSecret()."""
if not cmd.startswith("get "):
return False
key = cmd[4:].strip()
return key in ("prv.key", "guest.password", "alert.psk", "bridge.secret") \
or is_secret_key(key)
def cli_reply_is_failure(reply):
if not reply:
return False
@@ -560,6 +548,7 @@ GETTERS = {
"sf": lambda c: str(c["radio"]["sf"]),
"cr": lambda c: str(c["radio"]["cr"]),
"public.key": lambda c: "a1b2c3d4" * 8,
"prv.key": lambda c: c.get("identity_private_key", "a5" * 64),
"wifi.status": lambda c: (
"SSID: %s\nIP: 192.168.1.42\nRSSI: -58 dBm\nUptime: %dm"
% (c["wifi"]["ssid"] or "(not set)", int(time.time() - ST.start) // 60)),
@@ -613,10 +602,11 @@ def _cli_get_value(cfg, key):
if key in GETTERS:
val = GETTERS[key](cfg)
return (True, val) if val is not None else (False, "Error: unsupported")
if key == "password":
return True, ADMIN_PASSWORD
if is_secret_key(key):
# The serial console prints these; the portal is reachable over the LAN,
# so it masks them the same way /api/config does.
return True, SENTINEL if cli_read_key(cfg, key) else "(not set)"
# Explicit LAN CLI getters return values; /api/config remains masked.
return True, cli_read_key(cfg, key) or "(not set)"
val = cli_read_key(cfg, key)
if val is None:
return False, "??: %s" % key # CommonCLI::handleGetCmd fallthrough
@@ -1035,10 +1025,6 @@ class Handler(BaseHTTPRequestHandler):
if cmd.startswith("password "):
reply = "OK" # never echo the new password back
ST.admin_pwd_set = True
elif cli_reads_secret(cmd):
val = reply[2:] if reply.startswith("> ") else reply
reply = ("> (not set)" if val in ("", "(not set)")
else "> ******** (serial only)")
ok = not cli_reply_is_failure(reply)
# Only writes gate the reboot, and only on the "OK" convention every
# setter keeps (WebConfigBatch::cliReplyGatesReboot).
+19 -1
View File
@@ -4301,6 +4301,24 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* reply) {
const char* config = &command[4];
if (strcmp(config, "password") == 0) {
if (sender_timestamp == 0) {
snprintf(reply, 160, "> %s", _prefs->password);
} else {
strcpy(reply, "Error: get password requires a local connection");
}
return;
}
#if defined(ESP_PLATFORM) && defined(ADMIN_PASSWORD) && !defined(WEBCONFIG_DISABLED) && !defined(WITH_MQTT_BRIDGE)
if (strcmp(config, "wifi.pwd") == 0) {
if (sender_timestamp != 0) {
strcpy(reply, "> ******** (local connection only)");
} else if (!_callbacks->getWiFiPassword(reply)) {
strcpy(reply, "Error: WiFi password unavailable on this build");
}
return;
}
#endif
if (strcmp(config, "powersaving") == 0) {
snprintf(reply, 160, "> %s", _prefs->powersaving_enabled ? "on" : "off");
return;
@@ -4444,7 +4462,7 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep
sprintf(reply, "> %d", ((uint32_t) _prefs->advert_interval) * 2);
} else if (configKeyEquals(config, "guest.password")) {
sprintf(reply, "> %s", _prefs->guest_password);
} else if (sender_timestamp == 0 && configKeyEquals(config, "prv.key")) { // from serial command line only
} else if (sender_timestamp == 0 && configKeyEquals(config, "prv.key")) { // local connections only
uint8_t prv_key[PRV_KEY_SIZE];
int len = _callbacks->getSelfId().writeTo(prv_key, PRV_KEY_SIZE);
mesh::Utils::toHex(tmp, prv_key, len);
+4
View File
@@ -598,6 +598,10 @@ public:
(void)reply;
return false;
};
virtual bool getWiFiPassword(char* reply) const {
(void)reply;
return false;
};
virtual bool getWiFiStatus(char* reply) const {
(void)reply;
return false;
+7 -7
View File
@@ -988,20 +988,20 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf
} else if (memcmp(subcmd, "username", 8) == 0) {
sprintf(reply, "> %s", _mqtt_prefs.mqtt_slot_username[slot]);
} else if (memcmp(subcmd, "password", 8) == 0) {
// Serial only; remote sees set/unset.
// Local connections only; remote sees set/unset.
if (sender_timestamp == 0) {
sprintf(reply, "> %s", _mqtt_prefs.mqtt_slot_password[slot]);
} else {
strcpy(reply, _mqtt_prefs.mqtt_slot_password[slot][0] ? "> ******** (serial only)" : "> (not set)");
strcpy(reply, _mqtt_prefs.mqtt_slot_password[slot][0] ? "> ******** (local connection only)" : "> (not set)");
}
} else if (memcmp(subcmd, "token", 5) == 0) {
// Serial only; remote sees set/unset.
// Local connections only; remote sees set/unset.
if (_mqtt_prefs.mqtt_slot_token[slot][0] == '\0') {
strcpy(reply, "> (not set)");
} else if (sender_timestamp == 0) {
sprintf(reply, "> %s", _mqtt_prefs.mqtt_slot_token[slot]);
} else {
strcpy(reply, "> ******** (serial only)");
strcpy(reply, "> ******** (local connection only)");
}
} else if (memcmp(subcmd, "topic", 5) == 0) {
if (_mqtt_prefs.mqtt_slot_topic[slot][0] != '\0') {
@@ -1031,11 +1031,11 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf
} else if (memcmp(config, "wifi.ssid", 9) == 0) {
sprintf(reply, "> %s", _mqtt_prefs.wifi_ssid);
} else if (memcmp(config, "wifi.pwd", 8) == 0) {
// Serial only (WiFi creds grant LAN access); remote sees set/unset.
// Local connections only (WiFi creds grant LAN access); remote sees set/unset.
if (sender_timestamp == 0) {
sprintf(reply, "> %s", _mqtt_prefs.wifi_password);
} else {
strcpy(reply, _mqtt_prefs.wifi_password[0] ? "> ******** (serial only)" : "> (not set)");
strcpy(reply, _mqtt_prefs.wifi_password[0] ? "> ******** (local connection only)" : "> (not set)");
}
} else if (memcmp(config, "wifi.status", 11) == 0) {
wl_status_t status = WiFi.status();
@@ -1135,7 +1135,7 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf
#endif
} else if (memcmp(config, "alert.hashtag", 13) == 0) {
sprintf(reply, "> %s", _mqtt_prefs.alert_hashtag[0] ? _mqtt_prefs.alert_hashtag : "(unset)");
} else if (sender_timestamp == 0 && memcmp(config, "alert.psk", 9) == 0) { // from serial command line only
} else if (sender_timestamp == 0 && memcmp(config, "alert.psk", 9) == 0) { // local connections only
sprintf(reply, "> %s", _mqtt_prefs.alert_psk_hex[0] ? _mqtt_prefs.alert_psk_hex : "(unset)");
} else if (memcmp(config, "alert.region", 12) == 0) {
sprintf(reply, "> %s", _mqtt_prefs.alert_region[0] ? _mqtt_prefs.alert_region : "(unset, using default scope)");
+89
View File
@@ -0,0 +1,89 @@
#pragma once
#include <Arduino.h>
#include <string.h>
namespace mesh {
// A local listing belongs to its requesting connection. Advance one bounded
// chunk per mesh loop and retain unwritten bytes when the client is slow.
template<class FileHandle> class LocalCliOutput {
public:
using RowReader = size_t (*)(void*, size_t&, char*, size_t);
private:
Stream* _output = nullptr;
FileHandle _file;
size_t _remaining = 0;
RowReader _rows = nullptr;
void* _context = nullptr;
size_t _row = 0;
char _pending[160] = {};
size_t _size = 0, _offset = 0;
bool _ending = false;
public:
LocalCliOutput() = default;
explicit LocalCliOutput(FileHandle file) : _file(file) {}
bool busy() const { return _output != nullptr; }
bool owns(const Stream& output) const { return _output == &output; }
void cancel() {
if (_file) _file.close();
_output = nullptr;
_rows = nullptr;
_context = nullptr;
_remaining = _size = _offset = _row = 0;
_ending = false;
memset(_pending, 0, sizeof(_pending));
}
bool startFile(Stream& output, FileHandle file) {
if (busy()) { if (file) file.close(); return false; }
_output = &output;
_file = file;
// Snapshot the length: live logging cannot extend this dump indefinitely.
_remaining = _file ? _file.size() : 0;
return true;
}
bool startRows(Stream& output, RowReader rows, void* context,
const char* header) {
if (busy()) return false;
_output = &output;
_rows = rows;
_context = context;
_size = snprintf(_pending, sizeof(_pending), "%s", header);
if (_size >= sizeof(_pending)) _size = sizeof(_pending) - 1;
return true;
}
void service() {
if (!busy()) return;
if (_offset == _size) {
_size = _offset = 0;
if (_ending) { cancel(); return; }
if (_rows) {
_size = _rows(_context, _row, _pending, sizeof(_pending));
if (_size == 0) { cancel(); return; }
if (_size >= sizeof(_pending)) { cancel(); return; }
} else if (_remaining) {
const size_t count = _remaining < sizeof(_pending)
? _remaining : sizeof(_pending);
const int got = _file.read(reinterpret_cast<uint8_t*>(_pending), count);
if (got <= 0) {
_size = snprintf(_pending, sizeof(_pending), "\r\nError: log read failed\r\n");
_ending = true;
} else {
_size = static_cast<size_t>(got);
_remaining -= _size;
}
} else {
_size = snprintf(_pending, sizeof(_pending), "\r\n EOF\r\n");
_ending = true;
}
}
const int room = _output->availableForWrite();
if (room <= 0) return;
const size_t count = _size - _offset < static_cast<size_t>(room)
? _size - _offset : static_cast<size_t>(room);
_offset += _output->write(
reinterpret_cast<const uint8_t*>(_pending + _offset), count);
}
};
} // namespace mesh
+3 -11
View File
@@ -98,22 +98,14 @@ static inline bool wcIsSecretKey(const char* key) {
return false;
}
// CommonCLI answers a secret getter in plaintext only for the serial console
// (sender_timestamp 0) and masks it for remote callers. The web CLI executes
// with sender_timestamp 0 -- that is what makes `erase`, `stats-*` and `set freq`
// reachable -- so it would otherwise inherit the serial console's plaintext
// answers for an HTTP request. This says which `get` commands must be masked
// back down, restoring the distinction for a caller not at the serial port.
//
// Writing these has always been possible from the portal; reading them never
// was, because handleConfigGet masks them (wcIsSecretKey). The two are different
// capabilities: replacing a WiFi password does not reveal the current one, and
// replacing an identity does not reveal the existing private key.
// Identify explicit secret getters separately from config-form fields. Local
// CLI connections may read these values; configuration snapshots remain masked.
static inline bool wcIsSecretReadCommand(const char* cmd) {
if (strncmp(cmd, "get ", 4) != 0) return false;
const char* key = cmd + 4;
while (*key == ' ') key++;
if (strcmp(key, "prv.key") == 0) return true; // this node's identity
if (strcmp(key, "password") == 0) return true;
if (strcmp(key, "guest.password") == 0) return true;
if (strcmp(key, "alert.psk") == 0) return true;
if (strcmp(key, "bridge.secret") == 0) return true;
File diff suppressed because it is too large. Load diff
+43 -40
View File
@@ -239,7 +239,7 @@ static inline bool wcIsDeferredReboot(const char* cmd) {
// Commands the CLI reaches but the portal cannot honestly serve. Rejected at
// POST so nothing in the sequence runs, rather than failing halfway with a
// reply that does not explain itself. Returns the reason, or NULL if fine.
static const char* wcCliUnavailable(const char* cmd) {
static const char* wcCliUnavailable(const char* cmd, bool streamed = false) {
// ESP32Board::startOTAUpdate() does `new AsyncWebServer(80)` with no bind
// check and answers "Started" regardless. The portal already holds port 80,
// so from here it can only leak the allocation, inhibit sleep, and lie.
@@ -254,15 +254,10 @@ static const char* wcCliUnavailable(const char* cmd) {
return "clock sync takes its time from the caller, which a web request has "
"no way to supply. Use `time <epoch-seconds>` instead.";
}
// Both write their real output to Serial and hand back a stub the terminal
// would render as success. Bare `log` also streams a whole file from the loop
// task, stalling the mesh and the radio while it does.
if (strcmp(cmd, "log") == 0) {
return "log writes the packet log to the serial console, not here, and "
"blocks the radio while it does. Use `log start` / `log stop`.";
}
if (strcmp(cmd, "get acl") == 0) {
return "get acl writes to the serial console, not here.";
// The bounded batch reply cannot hold these listings. The browser terminal
// uses /api/terminal, which streams them back to the requesting connection.
if (!streamed && (strcmp(cmd, "log") == 0 || strcmp(cmd, "get acl") == 0)) {
return "Use /api/terminal for streamed listings.";
}
return NULL;
}
@@ -273,33 +268,9 @@ static inline bool wcCliEchoesSecret(const char* cmd) {
return strncmp(cmd, "password ", 9) == 0;
}
// CommonCLI splits its surface by CALLER, not by command: a serial caller
// (sender_timestamp 0, physical access) reads secrets in plaintext, while a
// remote one gets "******** (serial only)". Its own comments say so -- "Serial
// only (WiFi creds grant LAN access); remote sees set/unset".
//
// execCommand passes 0, which is what makes `erase`, `stats-*` and `set freq`
// reachable from the terminal at all. Left alone, that also claims
// physical-access trust for an HTTP request: `get prv.key` would hand this
// node's identity to anyone associated with the open setup AP, and `get
// wifi.pwd` would hand over the operator's network. Reading a secret and
// writing one are not the same capability -- the wizard has always been able to
// REPLACE these; nothing in the portal could ever READ them, because
// /api/config masks them (wcIsSecretKey).
//
// So the command surface stays whole and only the READ is masked, restoring the
// distinction CommonCLI intended for a caller who is not at the serial port.
// Which commands those are lives in WebConfigKeys.h (wcIsSecretReadCommand),
// beside the rest of the secret classification and host-tested with it.
// Keep the set/unset signal, which is the useful part and what CommonCLI itself
// reports remotely; only the value goes. A getter answers "> value".
static void wcMaskSecretReply(char* reply) {
const char* val = reply;
if (val[0] == '>' && val[1] == ' ') val += 2;
const bool unset = (val[0] == 0 || strcmp(val, "(not set)") == 0);
strcpy(reply, unset ? "> (not set)" : "> ******** (serial only)");
}
// CLI requests require LAN mode and authentication. Explicit getters have
// local-connection privileges, including secret reads. The configuration API
// still masks secrets, and the open setup AP has no CLI endpoint.
// Reply classification lives in WebConfigBatch.h with the rest of the decisions
// (WebConfigBatch::cliReplyIsFailure / cliReplyGatesReboot / cliWriteSucceeded),
// so the shapes CommonCLI actually emits are enumerated in one host-tested place.
@@ -620,6 +591,22 @@ bool WebConfigServer::formatWiFiSSID(char* reply, size_t reply_len) {
return true;
}
bool WebConfigServer::formatWiFiPassword(char* reply, size_t reply_len) {
if (!reply || !reply_len) return false;
char ssid[33] = {}, password[65] = {};
if (!loadStandaloneWiFi(ssid, sizeof(ssid), password, sizeof(password))) {
if (_active) {
strlcpy(password, _active->_wifi_password, sizeof(password));
}
#ifdef WIFI_PWD
else strlcpy(password, WIFI_PWD, sizeof(password));
#endif
}
snprintf(reply, reply_len, "> %s", password);
memset(password, 0, sizeof(password));
return true;
}
bool WebConfigServer::formatWiFiPowerSave(char* reply, size_t reply_len) {
if (!reply || reply_len == 0) return false;
@@ -1314,7 +1301,6 @@ void WebConfigServer::drainBatch(uint32_t now) {
strcpy(e.reply, "OK");
_admin_pwd_set = true;
}
if (wcIsSecretReadCommand(e.cmd)) wcMaskSecretReply(e.reply);
if (WebConfigBatch::cliReplyGatesReboot(e.cmd)) {
_batch_all_ok = WebConfigBatch::nextAllOk(
_batch_all_ok, WebConfigBatch::cliWriteSucceeded(e.reply));
@@ -2166,7 +2152,7 @@ void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) {
// commands go into the shared deferred slot and tick() drains them.
//
// Unlike a save this is not allowlisted. It is restricted to authenticated LAN
// mode and uses the remote-admin callback, so serial-only secrets stay hidden.
// mode. Commands use local-connection privileges, just like the USB terminal.
// ---------------------------------------------------------------------------
// Commands whose CLI handler never returns would take the node down mid-drain,
@@ -2199,6 +2185,13 @@ void WebConfigServer::serviceTerminal(uint32_t now) {
_terminal->queue.finish();
return;
}
if (_terminal->running) {
if (_cb->streamTerminalBusy()) return;
if (_terminal->availableForWrite() < 2) return;
_terminal->print("> ");
_terminal->queue.finish();
_terminal->running = false;
}
if (!_terminal->queue.pending || _batch_state == BATCH_PENDING) return;
if (!_terminal->attached) {
if (!_cb->beginStreamTerminal(*_terminal)) {
@@ -2218,7 +2211,17 @@ void WebConfigServer::serviceTerminal(uint32_t now) {
_terminal->attached = false;
_terminal->closed = true;
} else {
_cb->runStreamTerminal(command);
const char* unavailable = wcCliUnavailable(command, true);
if (unavailable) {
_terminal->print("Error: ");
_terminal->println(unavailable);
} else {
_cb->runStreamTerminal(command);
}
if (_cb->streamTerminalBusy()) {
_terminal->running = true;
return;
}
_terminal->print("> ");
}
_terminal->queue.finish();
+4 -2
View File
@@ -135,8 +135,8 @@ public:
virtual void getNodeSnapshot(NodeSnapshot& snapshot) = 0;
// Run one CLI command. Called from tick() only (loop task); reply is 160 bytes.
virtual void execCommand(char* cmd, char* reply) = 0;
// Browser terminal commands use remote-admin semantics so serial-only
// secrets and destructive maintenance commands remain unavailable.
// Authenticated LAN commands have local-console access, like a direct
// USB/Ethernet connection. LoRa access is handled separately by each role.
virtual bool supportsCliTerminal() const { return false; }
virtual void execAdminCommand(char* cmd, char* reply) {
execCommand(cmd, reply);
@@ -147,6 +147,7 @@ public:
virtual bool beginStreamTerminal(Stream& output) { return false; }
virtual bool ownsStreamTerminal(const Stream& output) const { return false; }
virtual void runStreamTerminal(char* command) {}
virtual bool streamTerminalBusy() const { return false; }
virtual void endStreamTerminal(Stream& output) {}
virtual void rebootNow() = 0;
// Bracket a config batch so bridge restarts triggered by individual
@@ -198,6 +199,7 @@ public:
static bool setWiFiCliEnabled(const char* value, char* reply,
size_t reply_len);
static bool formatWiFiSSID(char* reply, size_t reply_len);
static bool formatWiFiPassword(char* reply, size_t reply_len);
static bool formatWiFiStatus(
char* reply, size_t reply_len,
const mesh::wifi::CompanionWiFiRuntimeState* companion_runtime = NULL);
+9
View File
@@ -40,6 +40,7 @@ public:
char session[24] = {0};
mesh::TerminalCommandQueue queue;
bool attached = false;
bool running = false;
bool closed = false;
uint32_t last_seen = 0;
@@ -75,6 +76,14 @@ public:
xSemaphoreGive(_lock);
return n;
}
int availableForWrite() override {
if (!ready()) return 0;
xSemaphoreTake(_lock, portMAX_DELAY);
const uint64_t unread = _output.end() - _acknowledged;
const int room = unread < _capacity ? static_cast<int>(_capacity - unread) : 0;
xSemaphoreGive(_lock);
return room;
}
int available() override { return 0; }
int read() override { return -1; }
int peek() override { return -1; }
+7
View File
@@ -30,6 +30,12 @@ static SPIClass ETHERNET_SPI_PORT(NRF_SPIM1, PIN_SPI1_MISO, PIN_SPI1_SCK, PIN_SP
static EthernetServer ethernet_server(ETHERNET_TCP_PORT);
static EthernetClient ethernet_client;
static volatile bool ethernet_running = false;
static bool ethernet_session_reset = false;
static bool ethernet_take_session_reset() {
const bool changed = ethernet_session_reset;
ethernet_session_reset = false;
return changed;
}
// FreeRTOS task: handles hw init, DHCP, and retries in the background
static void ethernet_task(void* param) {
@@ -107,6 +113,7 @@ static void ethernet_check_client() {
if (newClient) {
if (ethernet_client) ethernet_client.stop();
ethernet_client = newClient;
ethernet_session_reset = true;
IPAddress ip = ethernet_client.remoteIP();
Serial.printf("ETH: Client connected from %u.%u.%u.%u\n", ip[0], ip[1], ip[2], ip[3]);
ethernet_client.println(ETHERNET_CLI_BANNER);
+4
View File
@@ -78,6 +78,10 @@ class FirmwareRamTest(unittest.TestCase):
self.assertEqual(ram.requirements("ESP32_PLATFORM", wifi_only, "v4_companion"), plain)
repeater = ram.requirements("ESP32_PLATFORM", defines, "v4_repeater")
self.assertNotIn("browser_terminal_session", repeater["components"])
for role in ("v4_repeater", "v4_room_server"):
local = ram.requirements("ESP32_PLATFORM", {**defines, "ADMIN_PASSWORD": "test"}, role)
self.assertEqual(local["components"]["browser_terminal_session"], 2048)
self.assertEqual(local["components"]["browser_terminal_scrollback"], 4096)
def test_longer_display_previews_reserve_heap_and_contiguous_history(self):
defines = {"DISPLAY_CLASS": "SSD1306Display", "UI_SMALL_MESSAGE_FONT": 0}
+252
View File
@@ -0,0 +1,252 @@
"""Compile real Companion command dispatch with local and LoRa callers."""
from pathlib import Path
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
PREAMBLE = r'''
#include <cassert>
#include <cstdio>
#include <cstring>
#include <cstdint>
#include <helpers/CLICommandUtils.h>
#define ESP32 1
#define WIFI_SSID "test"
#define WITH_MQTT_BRIDGE 1
#define PRV_KEY_SIZE 64
#define MAX_MQTT_SLOTS 2
#define MAX_LORA_TX_POWER 22
#define TXT_TYPE_CLI_COMMAND 1
namespace mesh {
struct Packet {};
void resetLazyPersistenceAfterSuccess(unsigned& when, uint8_t& failures) { when=0; failures=0; }
struct Utils {
static void toHex(char* out,const uint8_t* data,size_t len) {
for(size_t i=0;i<len;++i) sprintf(out+2*i,"%02X",data[i]);
}
};
}
struct ContactInfo { bool isRemoteCLIAllowed() const { return true; } };
struct WiFiSetupPortal {
static bool loadStoredCredentials(char*,size_t,char* password,size_t size) {
snprintf(password,size,"wifi-secret");return true;
}
};
struct Identity {
int writeTo(uint8_t* out,size_t len) { memset(out,0xA5,len);return len; }
};
struct Store {
int erased=0; bool success=true;
bool formatFileSystem(){++erased;return success;}
};
struct Prefs {
float freq=910, bw=125, airtime_factor=1, rx_delay_base=1;
uint8_t sf=7, cr=5, path_hash_mode=0, multi_acks=0, rx_ps_level=0, rx_ps_preamble=16;
uint32_t rx_ps_rx_us=1, rx_ps_sleep_us=1;
int8_t tx_power_dbm=10;
char node_name[32]="test";
Prefs* getRadioPrefs(){return this;}
bool handleCommand(const char*,uint32_t,char*){return false;}
bool isDirty() const {return false;}
void clearDirty(){}
};
struct Board {
bool rebootToUf2Bootloader(){return false;}
bool handleCommand(const char*,uint32_t,char*){return false;}
} board;
struct Radio {bool setTxPower(int){return true;}} radio_driver;
struct AdvertDataParser {static bool isValidName(const char*){return true;}};
struct StrHelper {static void strncpy(char* out,const char* in,size_t len){::strncpy(out,in,len);}};
void recalcRxPowerSavingFromLevel(uint8_t,uint8_t,float,uint8_t,uint32_t*,uint32_t*){}
struct StatsFormatHelper {
template<class... T> static void formatCoreStats(char* out,T&&...){strcpy(out,"core");}
template<class... T> static void formatRadioStats(char* out,T&&...){strcpy(out,"radio");}
template<class... T> static void formatRadioDiag(char* out,T&&...){strcpy(out,"diag");}
template<class... T> static void formatPacketStats(char* out,T&&...){strcpy(out,"packets");}
};
struct WebConfigServer {
static bool formatWiFiPassword(char* out,size_t len){snprintf(out,len,"> wifi-secret");return true;}
};
const char* FIRMWARE_VERSION="test";
const char* FIRMWARE_BUILD_DATE="test";
struct MyMesh {
Identity self_id;
Store store; Store* _store=&store;
Prefs _prefs;
struct {char mqtt_slot_password[2][64]={"mqtt-secret","other"};
char mqtt_slot_token[2][64]={"mqtt-token","other-token"};} _mqtt_prefs;
unsigned dirty_contacts_expiry=1; uint8_t dirty_contacts_failures=1;
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;
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*);
bool handleLocalControlCommand(const char* text,char* reply,size_t) {
if(!strcmp(text,"get mqtt1.password")||!strcmp(text,"get mqtt1.token")) {
strcpy(reply,"> ********");return true;
}
return false;
}
void stopContactsIterator(){}
void resetContacts(){++resets;}
bool savePrefs(){return save_ok;}
int getTotalAirTime(){return 0;}
int getReceiveAirTime(){return 0;}
int getNumSentFlood(){return 0;}
int getNumSentDirect(){return 0;}
int getNumRecvFlood(){return 0;}
int getNumRecvDirect(){return 0;}
bool hasOutbound(){return false;}
void markConnectionActive(const ContactInfo&){}
void queueMessage(const ContactInfo&,int,mesh::Packet*,uint32_t,const void*,int,const char*){}
};
'''
SCENARIOS = r'''
int main() {
MyMesh node;
char reply[160] = {};
const char* local[] = {"get password", "get prv.key", "get wifi.pwd", "get mqtt1.password",
"get mqtt1.token", "stats-core", "stats-radio", "stats-radio-diag", "stats-packets"};
for(const char* command:local) {
memset(reply,0,sizeof(reply));
assert(node.handleCommand(command,0,reply));
assert(strlen(reply)>0);
if(!strcmp(command,"get password")) assert(strstr(reply,"no admin password"));
if(!strcmp(command,"get prv.key")) {
#if ENABLE_PRIVATE_KEY_EXPORT
assert(strlen(reply)==130 && !strncmp(reply,"> A5A5",6));
#else
assert(strstr(reply,"disabled"));
#endif
}
if(!strcmp(command,"get wifi.pwd")) assert(!strcmp(reply,"> wifi-secret"));
if(!strcmp(command,"get mqtt1.password")) assert(!strcmp(reply,"> mqtt-secret"));
if(!strcmp(command,"get mqtt1.token")) assert(!strcmp(reply,"> mqtt-token"));
memset(reply,0,sizeof(reply));
node.handleCommand(command,100,reply);
assert(!strstr(reply,"secret") && !strstr(reply,"token") && !strstr(reply,"A5A5"));
// In particular, an authenticated LoRa packet carrying timestamp zero
// still enters the production receiver with remote restrictions.
memset(reply,0,sizeof(reply));
node.onCLICommandRecv(ContactInfo{},nullptr,0,command,reply);
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);
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"));
assert(!node.handleCommand("erase",100,reply));
node.onCLICommandRecv(ContactInfo{},nullptr,0,"erase",reply);
assert(node.store.erased==0);
assert(node.handleCommand("erase",0,reply));
assert(node.store.erased==1 && node.resets==1 && node.dirty_contacts_expiry==0);
assert(strstr(reply,"OK"));
node.store.success=false;
assert(node.handleCommand("erase",0,reply));
assert(node.resets==1 && strstr(reply,"Err"));
}
'''
class LocalCliAccessTest(unittest.TestCase):
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(")
password = extract_braced(getter, 'if (strcmp(config, "password") == 0)')
web = (ROOT / "src/helpers/esp32/WebConfigServer.cpp").read_text()
limits = extract_braced(web, "static const char* wcCliUnavailable(")
code = r'''
#include <cassert>
#include <cstdio>
#include <cstring>
#include <cstdint>
struct Prefs { char password[16] = "admin-secret"; } prefs;
Prefs* _prefs = &prefs;
void get(const char* config, uint32_t sender_timestamp, char* reply) {
''' + password + "\n}\n" + limits + r'''
int main() {
char reply[160] = {};
get("password",0,reply);
assert(!strcmp(reply,"> admin-secret"));
for(uint32_t stamp:{1u,100u,0xFFFFFFFFu}) {
get("password",stamp,reply);
assert(strstr(reply,"local connection") && !strstr(reply,"admin-secret"));
}
reply[0]=0;get("passwordx",0,reply);assert(!reply[0]);
assert(wcCliUnavailable("get password",false)==nullptr);
assert(wcCliUnavailable("get password",true)==nullptr);
assert(wcCliUnavailable("log",false)!=nullptr);
assert(wcCliUnavailable("log",true)==nullptr);
assert(wcCliUnavailable("get acl",true)==nullptr);
assert(wcCliUnavailable("start ota",true)!=nullptr);
assert(wcCliUnavailable("clock sync",true)!=nullptr);
}
'''
with tempfile.TemporaryDirectory() as temp:
binary = Path(temp) / "test"
result = subprocess.run([
"c++", "-std=c++17", "-include", "initializer_list", "-x", "c++", "-",
"-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_real_companion_dispatch_local_binary_and_radio(self):
source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
implementation = "\n".join(extract_braced(source, signature) for signature in (
"static bool isCompanionRadioPrefsCommand(",
"bool MyMesh::handleDirectCommand(", "bool MyMesh::handleCommand(",
"void MyMesh::onCLICommandRecv("))
for export, webconfig in ((0, False), (1, False), (0, True), (1, True)):
with self.subTest(export=export, webconfig=webconfig), tempfile.TemporaryDirectory() as temp:
binary = Path(temp) / "test"
result = subprocess.run([
"c++", "-std=c++17", "-x", "c++", "-", "-I"+str(ROOT / "src"),
"-DENABLE_PRIVATE_KEY_EXPORT="+str(export),
*(["-DWITH_WEBCONFIG=1"] if webconfig else []),
"-fsanitize=address,undefined", "-fno-pie", "-no-pie", "-o", str(binary),
], input=PREAMBLE+implementation+SCENARIOS, 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_each_host_transport_uses_local_dispatch(self):
for role in ("simple_repeater", "simple_room_server"):
header = (ROOT / "examples" / role / "MyMesh.h").read_text()
web = extract_braced(header, "void execAdminCommand(")
self.assertIn("handleCommand(0, cmd, reply)", web)
local = extract_braced(header, "void handleLocalCommand(")
self.assertIn("_command_output = &output", local)
self.assertIn("handleCommand(0, command, reply)", local)
self.assertIn("_command_output = nullptr", local)
stream = extract_braced(header, "void runStreamTerminal(")
self.assertIn("handleLocalCommand(command, reply, *_web_terminal)", stream)
main = (ROOT / "examples" / role / "main.cpp").read_text()
self.assertIn("handleLocalCommand(ethernet_command, reply, ethernet_client)", main)
self.assertIn("cancelLocalOutput(ethernet_client)", main)
source = (ROOT / "examples" / role / "MyMesh.cpp").read_text()
guard = ("sender != nullptr" if role == "simple_repeater"
else "gpio_client_index >= 0")
self.assertIn("if ("+guard+" && sender_timestamp == 0) sender_timestamp = 1;", source)
companion = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
framed = extract_braced(companion, "else if (mesh::companion::isRunCliFrame(")
self.assertIn("handleCommand(text, 0, reply_buf)", framed)
sensor = (ROOT / "examples/simple_sensor/SensorMesh.cpp").read_text()
self.assertIn("if (gpio_client_index >= 0 && sender_timestamp == 0) sender_timestamp = 1;", sensor)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,124 @@
#include <gtest/gtest.h>
#include <helpers/LocalCliOutput.h>
#include <algorithm>
#include <string>
namespace {
struct FileState { std::string text; size_t reads = 0; bool closed = false, fail = false; };
struct File {
FileState* state = nullptr;
size_t offset = 0;
explicit operator bool() const { return state && !state->closed; }
size_t size() const { return state->text.size(); }
void close() { if (state) state->closed = true; }
int read(uint8_t* out, size_t count) {
++state->reads;
if (state->fail) return -1;
count = std::min(count, state->text.size() - offset);
memcpy(out, state->text.data() + offset, count);
offset += count;
return count;
}
};
struct Output : Stream {
std::string text;
int room = 40;
size_t max_write = 7;
int availableForWrite() override { return room; }
size_t write(const uint8_t* data, size_t count) override {
count = std::min(count, max_write);
text.append(reinterpret_cast<const char*>(data), count);
return count;
}
};
void drain(mesh::LocalCliOutput<File>& job) {
for (size_t n = 0; job.busy() && n < 100000; ++n) job.service();
ASSERT_FALSE(job.busy());
}
TEST(LocalCliOutput, LargeFileIsLosslessWithPartialWritesAndLengthSnapshot) {
FileState file{std::string(90000, 'x')};
const auto expected = file.text;
mesh::LocalCliOutput<File> job;
Output output;
ASSERT_TRUE(job.startFile(output, File{&file}));
file.text += "new live log entries";
drain(job);
EXPECT_EQ(output.text, expected + "\r\n EOF\r\n");
EXPECT_TRUE(file.closed);
EXPECT_EQ(file.reads, (expected.size() + 159) / 160);
}
TEST(LocalCliOutput, SlowClientDoesNotReadUnboundedDataOrStealOutput) {
FileState file{std::string(4096, 'a')};
mesh::LocalCliOutput<File> job;
Output first, second;
first.room = 0;
ASSERT_TRUE(job.startFile(first, File{&file}));
for (int i = 0; i < 1000; ++i) job.service();
EXPECT_EQ(file.reads, 1u);
EXPECT_TRUE(first.text.empty());
EXPECT_TRUE(job.owns(first));
EXPECT_FALSE(job.owns(second));
FileState other{"different"};
EXPECT_FALSE(job.startFile(second, File{&other}));
EXPECT_TRUE(other.closed);
first.room = 40;
drain(job);
EXPECT_EQ(first.text, file.text + "\r\n EOF\r\n");
EXPECT_TRUE(second.text.empty());
}
TEST(LocalCliOutput, CancelStopsOldOutputBeforeAReplacementConnection) {
FileState old{"old connection secret"};
mesh::LocalCliOutput<File> job;
Output output;
output.room = 0;
ASSERT_TRUE(job.startFile(output, File{&old}));
job.service();
job.cancel();
EXPECT_TRUE(old.closed);
FileState replacement{"new connection"};
ASSERT_TRUE(job.startFile(output, File{&replacement}));
output.room = 40;
drain(job);
EXPECT_EQ(output.text, "new connection\r\n EOF\r\n");
}
TEST(LocalCliOutput, AclRowsDrainCompletelyWithoutATableSizedBuffer) {
mesh::LocalCliOutput<File> job;
Output output;
ASSERT_TRUE(job.startRows(output,
[](void*, size_t& row, char* out, size_t capacity) -> size_t {
if (row == 254) return 0;
return snprintf(out, capacity, "03 %064u\r\n", static_cast<unsigned>(row++));
}, nullptr, "ACL:\r\n"));
drain(job);
std::string expected = "ACL:\r\n";
for (unsigned n = 0; n < 254; ++n) {
char line[80];
snprintf(line, sizeof(line), "03 %064u\r\n", n);
expected += line;
}
EXPECT_EQ(output.text, expected);
}
TEST(LocalCliOutput, MissingAndFailedFilesEndWithoutBlockingTheNextCommand) {
mesh::LocalCliOutput<File> job;
Output output;
ASSERT_TRUE(job.startFile(output, File{}));
drain(job);
EXPECT_EQ(output.text, "\r\n EOF\r\n");
output.text.clear();
FileState file{"log"}; file.fail = true;
ASSERT_TRUE(job.startFile(output, File{&file}));
drain(job);
EXPECT_EQ(output.text, "\r\nError: log read failed\r\n");
EXPECT_TRUE(file.closed);
}
} // namespace
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
+14
View File
@@ -37,6 +37,7 @@ class Print { public: virtual ~Print() {};
class Stream: public Print { public:
virtual int available()=0; virtual int read()=0; virtual int peek()=0;
virtual void flush()=0;
virtual int availableForWrite(){return 0;}
};
""",
"esp_heap_caps.h": """#pragma once
@@ -91,6 +92,19 @@ int main(){
assert(result==data && bytes()==4096);
}
assert(allocated.empty());
{
WebTerminalStream t;
assert(t.availableForWrite()==4096);
std::string data(4096,'b');
t.write(reinterpret_cast<const uint8_t*>(data.data()),data.size());
assert(t.availableForWrite()==0);
uint64_t cursor=0;char chunk[1025];bool lost=false;
assert(t.readOutput(cursor,chunk,sizeof(chunk),lost)==1024 && !lost);
assert(t.availableForWrite()==0); // A read response alone is not an ACK.
assert(t.readOutput(cursor,chunk,sizeof(chunk),lost)==1024 && !lost);
assert(t.availableForWrite()==1024); // The next cursor acknowledges it.
}
assert(allocated.empty());
psram=false;internal_free=8192;
{WebTerminalStream t;assert(!t.ready());assert(t.write('x')==0);}
assert(allocated.empty());
@@ -150,8 +150,9 @@ TEST(WebConfigKeys, EverySecretKeyIsAlsoAllowed) {
// recognises the serial console and answers secret getters in plaintext. These
// are the reads that must be masked back down for an HTTP caller.
TEST(WebConfigKeys, MasksEverySecretReadTheCliCanReach) {
TEST(WebConfigKeys, ClassifiesEverySecretReadTheCliCanReach) {
const char* masked[] = {
"get password",
"get prv.key", // this node's identity -- the worst one to leak
"get wifi.pwd", // grants the operator's LAN, not just the node
"get guest.password",
@@ -164,7 +165,7 @@ TEST(WebConfigKeys, MasksEverySecretReadTheCliCanReach) {
for (const char* c : masked) EXPECT_TRUE(wcIsSecretReadCommand(c)) << c;
}
TEST(WebConfigKeys, DoesNotMaskReadsThatCarryNoSecret) {
TEST(WebConfigKeys, DoesNotClassifyPublicReadsAsSecrets) {
const char* plain[] = {
"get wifi.ssid", "get mqtt1.username", "get mqtt1.server", "get tx",
"get public.key", // public half, safe to read
+15
View File
@@ -116,6 +116,21 @@ class WebConfigSecretMaskingTest(unittest.TestCase):
)
self.assertIn('strcmp(key, "wifi.pwd") == 0', secret_classifier)
def test_explicit_local_cli_reads_are_not_config_snapshots(self):
# Both terminal APIs require the authenticated LAN, and neither applies
# the config form's secret masking to an explicit command reply.
batch = function_body(self.server, "void WebConfigServer::drainBatch(",
"void WebConfigServer::attachRoutes(")
self.assertIn("execAdminCommand(e.cmd, e.reply)", batch)
self.assertNotIn("wcMaskSecretReply", batch)
for start, end in [
("void WebConfigServer::handleCliPost(", "void WebConfigServer::handleCliResult("),
("bool WebConfigServer::checkTerminalAccess(", "void WebConfigServer::handleTerminalPost("),
]:
handler = function_body(self.server, start, end)
self.assertIn("_mode != MODE_LAN", handler)
self.assertIn("checkAuth(req)", handler)
if __name__ == "__main__":
unittest.main()
+53 -3
View File
@@ -185,10 +185,12 @@ class WebConfigUiRuntimeTest(unittest.TestCase):
suggestions = json.loads(html.unescape(re.search(
r'data-test-suggestions="([^"]*)"', dom).group(1)))
for command in ("set powersaving off", "get usb.logging", "set wifi.cli ",
"set mqtt1.preset ", "get name", "get radio"):
"set mqtt1.preset ", "get name", "get radio", "erase",
"get prv.key", "get mqtt1.password", "get wifi.pwd",
"stats-core", "stats-radio", "stats-packets", "set freq "):
self.assertIn(command, suggestions)
for command in ("erase", "password ", "setperm ", "get prv.key",
"set bridge.enabled ", "get mqtt1.password", "set mqtt2.preset "):
for command in ("password ", "setperm ", "get acl",
"set bridge.enabled ", "set mqtt2.preset "):
self.assertNotIn(command, suggestions)
def test_stream_terminal_imports_long_cards_pages_lists_and_receives_late_replies(self):
@@ -250,6 +252,54 @@ class WebConfigUiRuntimeTest(unittest.TestCase):
self.assertIn("firmware help: import, list, to, send", output)
self.assertIn('data-test-stream-busy="false"', dom)
def test_streamed_raw_log_keeps_entire_file_and_allows_slow_progress(self):
status, config = self.setup_values()
status.update(mode="lan", role="Repeater", cli=True, terminal_stream=True,
terminal_max_command=159, password_supported=False)
prelude = """
<script>
(function(){
var status=%s,config=%s,sequence=0,output="",offset=0;
var dump="log-start\\n"+"packet-row\\n".repeat(10000)+"log-end\\n EOF\\n";
var now=Date.now;Date.now=function(){return now()+offset};
function response(value){return Promise.resolve({ok:true,status:200,json:function(){return Promise.resolve(value)}})}
window.fetch=function(path,options){
if(path==="/api/status")return response(status);
if(path==="/api/config")return response(config);
if(path==="/api/presets")return response({presets:[]});
if(path==="/api/terminal"){
sequence=JSON.parse(options.body).seq;output=dump;
return response({accepted:true});
}
if(path.indexOf("/api/terminal?")===0){
var from=Number(new URLSearchParams(path.split("?")[1]).get("after"));
var chunk=output.slice(from,from+3072),cursor=from+chunk.length;
offset+=2000; // A complete dump takes over 30 seconds, but keeps progressing.
return response({output:chunk,cursor:cursor,seq:sequence,done:cursor>=dump.length,
closed:false,lost:false,more:false});
}
return response({});
};
window.addEventListener("load",function(){
setTimeout(function(){cliStreamRun(["log"])},75);
setTimeout(function(){output+="later-output\\n"},6000);
setTimeout(function(){
var text=document.getElementById("term-out").textContent;
document.body.setAttribute("data-test-complete-log",String(text.includes(dump)));
document.body.setAttribute("data-test-later-output",String(text.includes("later-output")));
document.body.setAttribute("data-test-log-busy",String(cli.busy));
document.body.setAttribute("data-test-log-timeout",String(text.includes("could not be confirmed")));
},6800);
});
})();
</script>
""" % (json.dumps(status), json.dumps(config))
dom = self.run_page(prelude, virtual_time=7000)
self.assertIn('data-test-complete-log="true"', dom)
self.assertIn('data-test-later-output="true"', dom)
self.assertIn('data-test-log-busy="false"', dom)
self.assertIn('data-test-log-timeout="false"', dom)
def test_console_stays_hidden_when_disabled_or_on_setup_ap(self):
for mode in ("lan", "setup"):
with self.subTest(mode=mode):
+23 -11
View File
@@ -1820,8 +1820,7 @@ var CLI_VERBS=[
["stats-packets","Per-packet-type counters"],
["stats-radio","Radio counters (RSSI, SNR, noise)"],
["stats-radio-diag","Extended radio diagnostics"],
// Bare `log` is absent: it streams the file to the serial console and stalls
// the radio doing it, and hands back only "EOF".
["log","Dump stored packet logs to this connection"],
["log start","Start packet logging to the filesystem"],
["log stop","Stop packet logging"],
["log erase","Delete the stored packet logs"],
@@ -1843,6 +1842,7 @@ var CLI_VERBS=[
["get bluetooth.stealth","Show the independent Bluetooth stealth flag"],
["set bluetooth.stealth ","Set stealth {on|off}, keeping the address policy"],
["password ","Change the admin password {new-password}"],
["get password","Read this node's admin password (local connection)"],
["alert test","Send a test alert on the configured channel"],
["ota check","Check for a newer build (does not flash)"],
["ota update","Download and flash the newer build, then reboot"],
@@ -1864,8 +1864,8 @@ var CLI_KEYS=[
["lat","Advert latitude",0],
["lon","Advert longitude",0],
["public.key","This node's public key",1],
// `get acl` is absent: it prints to the serial console and returns nothing.
["prv.key","Restore an identity {64-hex-char-key}",2],
["acl","List client permissions and public keys",1],
["prv.key","Read or restore an identity {128-hex-char-key}",0],
["role","Node role",1],
["radio","Radio parameters {freq,bw,sf,cr}",0],
["freq","Frequency in MHz",0],
@@ -1961,6 +1961,11 @@ var CLI_TYPES="req,response,txt_msg,ack,advert,grp_txt,grp_data,anon_req,path,tr
// Companion uses its USB/TCP command parser, including its chat session.
// Repeater ACL and rescue-only filesystem commands belong to other parsers.
var CLI_COMPANION_VERBS=[
["erase","Erase stored settings and identity"],
["stats-core","Core health counters"],
["stats-radio","Radio counters"],
["stats-radio-diag","Extended radio diagnostics"],
["stats-packets","Packet counters"],
["help","List commands supported by this firmware"],
["card","Export this node's meshcore:// contact card"],
["import ","Add a contact: import meshcore:// followed by the full card"],
@@ -1995,9 +2000,10 @@ var CLI_COMPANION_VERBS=[
["reboot","Restart the node"]
];
var CLI_COMPANION_KEYS=[
["prv.key","Read this node's private identity key",1],
["name","Node name",0], ["role","Node role",1],
["lat","Advert latitude",0], ["lon","Advert longitude",0],
["radio","Radio parameters {freq,bw,sf,cr}",0], ["freq","Frequency in MHz",1],
["radio","Radio parameters {freq,bw,sf,cr}",0], ["freq","Frequency in MHz",0],
["tx","TX power in dBm",0], ["af","Airtime factor",0],
["dutycycle","Duty cycle percent",0], ["rxdelay","RX delay base",0],
["cad","Listen before transmit",0,"on|off"],
@@ -2012,7 +2018,7 @@ var CLI_COMPANION_KEYS=[
["bluetooth.mac","Bluetooth address policy",0,"default|random|random-every-boot|random-after-connect"],
["bluetooth.stealth","Bluetooth stealth",0,"on|off"],
["path.hash.mode","Path hash mode",0,"0|1|2"], ["multi.acks","Extra ACKs",0],
["wifi.ssid","WiFi network name",0], ["wifi.pwd","WiFi password",2],
["wifi.ssid","WiFi network name",0], ["wifi.pwd","WiFi password",0],
["wifi.status","WiFi connection, address and signal",1],
["wifi.powersave","WiFi power saving",0,"min|max|none"],
["wifi.cli","Browser console",0,"on|off"],
@@ -2053,7 +2059,7 @@ function cliTable(){
for(var n=1;n<=st.nslots;n++){
CLI_SLOT.forEach(function(f){
if(companion&&f[0]==="filter")return;
if(!companion||!(/^(password|token)$/.test(f[0])))t.push(["get mqtt"+n+"."+f[0],"Slot "+n+" "+f[1]]);
t.push(["get mqtt"+n+"."+f[0],"Slot "+n+" "+f[1]]);
t.push(["set mqtt"+n+"."+f[0]+" ","Slot "+n+" "+f[1]]);
});
}
@@ -2107,7 +2113,7 @@ function cliBanner(){
cliEcho("sys","Type help for a summary, or a prefix and Tab to complete.");
}
function cliHelp(){
if(st.streamTerminal){if(!cli.busy)cliRun(["help"]);return}
if(st.streamTerminal&&cliIsCompanion()){if(!cli.busy)cliRun(["help"]);return}
cliGap();
cliEcho("cmd","help");
cliEcho("rep",
@@ -2437,8 +2443,8 @@ function cliRun(cmds){
});
}
// A real Companion terminal: one session owns recipient/login state, and
// cursor reads continue after a command finishes to show ACKs and RF replies.
// Stream replies for every role. Companion sessions also own recipient/login
// state and keep reading after completion to show ACKs and RF replies.
var cliStream={session:"",seq:0,cursor:0,timer:0,polling:false,out:null,job:null,errors:0};
function cliStreamReset(){
clearTimeout(cliStream.timer);
@@ -2450,7 +2456,11 @@ function cliStreamText(text){
if(!text)return;
var out=$("#term-out"),atEnd=out.scrollTop+out.clientHeight>=out.scrollHeight-24;
if(!cliStream.out||!cliStream.out.parentNode)cliStream.out=cliEcho("rep","");
cliStream.out.textContent=(cliStream.out.textContent+text.replace(/\r/g,"")).slice(-65536);
var combined=cliStream.out.textContent+text.replace(/\r/g,"");
// Preserve an explicitly requested raw dump, including files over 64 KiB.
// Idle Companion traffic keeps its bounded scrollback.
var job=cliStream.job,rawLog=job&&job.cmds[job.index].trim().toLowerCase()==="log";
cliStream.out.textContent=rawLog?combined:combined.slice(-65536);
if(atEnd)out.scrollTop=out.scrollHeight;
}
function cliStreamRun(cmds){
@@ -2495,11 +2505,13 @@ function cliStreamPoll(){
cliStream.errors=0;
if(r.lost)cliEcho("err","Some terminal output expired while disconnected.");
cliStreamText(r.output||"");cliStream.cursor=r.cursor;
if(r.output&&cliStream.job)cliStream.job.started=Date.now();
if(r.more){cliStream.timer=setTimeout(cliStreamPoll,0);return}
var job=cliStream.job;
if(r.closed){cliStreamReset();return}
if(job&&r.seq===job.seq&&r.done){
cliAfter([job.cmds[job.index]],{});
cliStream.out=null;
if(++job.index<job.cmds.length){cliStreamSend();return}
cliStream.job=null;cliBusy(false);
}else if(job&&Date.now()-job.started>30000){