From 1629725d7d4913f4763c0e79b7144e1c5fcffba7 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Mon, 31 Aug 2026 14:29:42 -0700 Subject: [PATCH] Add safe ESP32 transports and Indicator UI Reserve board-specific GPIOs, make ESP-NOW channel/format handling configurable, and coordinate WiFi, BLE, NTP, MQTT, and host actions safely. Add the Indicator message page, exclusive transport selector, dynamic 480/320 rendering, larger transport typography, pairing cleanup, and corresponding tests and documentation. --- build.sh | 21 + docs/WiFi.md | 61 +- docs/cli_command_availability.md | 1 + docs/cli_commands.md | 51 +- docs/companion_protocol.md | 27 +- docs/companion_radio_full.md | 94 +- docs/hardware_validation_checklist.md | 219 +++- docs/host_cli_service.md | 194 ++- examples/companion_radio/CompanionWiFi.h | 18 + examples/companion_radio/MyMesh.cpp | 68 + examples/companion_radio/MyMesh.h | 13 + examples/companion_radio/main.cpp | 290 ++++- examples/companion_radio/ui-new/UITask.cpp | 565 ++++++-- examples/companion_radio/ui-new/UITask.h | 2 + examples/companion_radio/ui-orig/UITask.cpp | 19 +- examples/companion_radio/ui-tiny/UITask.cpp | 22 +- examples/host_cli_service/README.md | 198 ++- examples/host_cli_service/host_cli_service.py | 769 ++++++++++- .../meshcore-chrony-step.service | 30 + .../meshcore-clock-control.service | 20 + .../meshcore-clock-control.socket | 14 + .../meshcore-host-actions.service | 36 + .../meshcore-host-actions.socket | 14 + .../meshcore-host-reboot.service | 30 + .../meshcore-host-reboot.timer | 9 + .../meshcore-networkmanager-restart.service | 31 + .../meshcore_clock_control.py | 320 +++++ .../host_cli_service/meshcore_host_actions.py | 704 ++++++++++ .../host_cli_service/test_host_cli_service.py | 1131 ++++++++++++++++- .../test_meshcore_host_actions.py | 735 +++++++++++ examples/simple_repeater/MyMesh.cpp | 8 +- examples/simple_room_server/MyMesh.cpp | 3 + examples/simple_room_server/MyMesh.h | 2 +- src/helpers/BaseChatMesh.cpp | 7 +- src/helpers/CompanionWiFiNtpPolicy.h | 57 + src/helpers/ESP32Board.cpp | 7 + src/helpers/ESP32Board.h | 24 +- src/helpers/MQTTConnectionPolicy.h | 10 +- src/helpers/MeshClockSync.cpp | 4 + src/helpers/MeshClockSync.h | 1 + src/helpers/bridges/MQTTBridge.cpp | 142 ++- src/helpers/bridges/MQTTBridge.h | 18 +- src/helpers/esp32/WebConfigServer.cpp | 6 +- src/helpers/ui/BluetoothPairingUiPolicy.h | 23 + src/helpers/ui/ColorTheme.h | 48 + src/helpers/ui/CompanionHomeLayout.h | 133 ++ src/helpers/ui/CompanionMessageHistory.h | 188 +++ .../ui/CompanionTransportSelectorLayout.h | 44 + src/helpers/ui/DisplayDriver.h | 13 + src/helpers/ui/IndicatorRenderProfile.h | 34 + src/helpers/ui/LGFXDisplay.cpp | 224 +++- src/helpers/ui/LGFXDisplay.h | 12 +- src/helpers/ui/NV3001BDisplay.cpp | 19 +- src/helpers/ui/ObserverDashboard.h | 22 +- src/helpers/ui/ST7789LCDDisplay.cpp | 19 +- src/helpers/ui/TouchInput.h | 37 +- test/README.md | 5 + test/test_bluetooth_pairing_ui.py | 88 ++ test/test_color_theme.py | 157 +++ .../test_companion_message_history.cpp | 126 ++ test/test_companion_terminal_profile.py | 9 + test/test_companion_transport_selector.py | 217 ++++ test/test_companion_wifi_ntp.py | 159 +++ .../test_companion_wifi_ntp_policy.cpp | 58 + .../test_display_driver.cpp | 179 ++- test/test_indicator_display_profile.py | 68 + test/test_indicator_exclusive_transport.py | 169 +++ test/test_indicator_messages_profile.py | 45 + test/test_indicator_render_profile.py | 122 ++ .../test_mqtt_connection_policy.cpp | 17 - test/test_tls_download_clock_gates.py | 72 +- test/test_touch_input/test_touch_input.cpp | 53 + tools/lora_ota/test_lora_ota.py | 28 + variants/sensecap_indicator-espnow/README.md | 66 +- .../sensecap_indicator-espnow/platformio.ini | 13 +- 75 files changed, 7886 insertions(+), 576 deletions(-) create mode 100644 examples/host_cli_service/meshcore-chrony-step.service create mode 100644 examples/host_cli_service/meshcore-clock-control.service create mode 100644 examples/host_cli_service/meshcore-clock-control.socket create mode 100644 examples/host_cli_service/meshcore-host-actions.service create mode 100644 examples/host_cli_service/meshcore-host-actions.socket create mode 100644 examples/host_cli_service/meshcore-host-reboot.service create mode 100644 examples/host_cli_service/meshcore-host-reboot.timer create mode 100644 examples/host_cli_service/meshcore-networkmanager-restart.service create mode 100755 examples/host_cli_service/meshcore_clock_control.py create mode 100644 examples/host_cli_service/meshcore_host_actions.py create mode 100644 examples/host_cli_service/test_meshcore_host_actions.py create mode 100644 src/helpers/CompanionWiFiNtpPolicy.h create mode 100644 src/helpers/ui/BluetoothPairingUiPolicy.h create mode 100644 src/helpers/ui/ColorTheme.h create mode 100644 src/helpers/ui/CompanionHomeLayout.h create mode 100644 src/helpers/ui/CompanionMessageHistory.h create mode 100644 src/helpers/ui/CompanionTransportSelectorLayout.h create mode 100644 src/helpers/ui/IndicatorRenderProfile.h create mode 100644 test/test_bluetooth_pairing_ui.py create mode 100644 test/test_color_theme.py create mode 100644 test/test_companion_message_history/test_companion_message_history.cpp create mode 100644 test/test_companion_transport_selector.py create mode 100644 test/test_companion_wifi_ntp.py create mode 100644 test/test_companion_wifi_ntp_policy/test_companion_wifi_ntp_policy.cpp create mode 100644 test/test_indicator_exclusive_transport.py create mode 100644 test/test_indicator_messages_profile.py create mode 100644 test/test_indicator_render_profile.py diff --git a/build.sh b/build.sh index a8145db66..253e59cb3 100755 --- a/build.sh +++ b/build.sh @@ -3422,6 +3422,27 @@ apply_companion_radio_full_profile() { ;; esac + # Both Indicator Full layouts select exactly one secondary Companion + # transport per boot. On the ESP-NOW layout, BLE mode leaves the primary + # ESP-NOW WiFi radio running while omitting only infrastructure WiFi; WiFi + # mode can release Bluetooth memory without disturbing ESP-NOW. + case "${env_name,,}" in + sensecapindicator-espnow_companion_radio_full|\ + sensecapindicator-lora_companion_radio_full) + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DCOMPANION_EXCLUSIVE_WIFI_BLE=1 -DINDICATOR_TRANSPORT_RENDER_PROFILE=1" + ;; + esac + + # Allocate the native canvas while internal RAM is least fragmented. After + # preferences load, ESP-NOW+BLE shrinks it to 320x320 before BLE starts; + # every other combination retains native 480x480 rendering. + case "${env_name,,}" in + sensecapindicator-espnow_companion_radio_full|\ + sensecapindicator-lora_companion_radio_full) + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UUI_ZOOM -DUI_ZOOM=1.0f -UUI_COORD_SCALE -DUI_COORD_SCALE=3" + ;; + esac + # ESP-NOW is the primary mesh radio on these two layouts, so conventional # Companion WiFi must share its protocol mask and fixed channel instead of # resetting the driver to B/G/N on an arbitrary access-point channel. The diff --git a/docs/WiFi.md b/docs/WiFi.md index a49c47c5d..0f9ca97eb 100644 --- a/docs/WiFi.md +++ b/docs/WiFi.md @@ -196,11 +196,15 @@ A `*_companion_radio_wifi` build replaces the BLE or USB companion link with the MeshCore companion protocol over TCP port 5000. The phone or computer must be able to reach the device on the same LAN. -An ESP32 `*_companion_radio_full` target keeps all three Companion links at -once: USB, BLE, and TCP port 5000. It also provides a source-only LoRa mOTA -service on ports 5001 and 5002. See the -[full Companion guide](./companion_radio_full.md) for its build, terminal mode, -and complete update-source workflow. +Most ESP32 `*_companion_radio_full` targets keep all three Companion links at +once: USB, BLE, and TCP port 5000. The two SenseCAP Indicator Full targets are +the exception: USB remains available, while a saved next-boot selector starts +exactly one secondary wireless Companion transport, either BLE or +infrastructure WiFi/TCP. On the ESP-NOW Indicator, this selector never stops the +primary ESP-NOW mesh radio. Full ESP32 images also provide a source-only LoRa +mOTA service on ports 5001 and 5002 whenever their WiFi transport is active. +See the [full Companion guide](./companion_radio_full.md) for its build, +terminal mode, and complete update-source workflow. The LilyGo T-Beam 1W Full Companion maps one press of the physical `BOOT` button (GPIO0) to a persistent WiFi on/off toggle. Turning WiFi off closes WebConfig, @@ -232,6 +236,18 @@ standalone credential store accepts an empty password, ordinary passphrases up to 63 characters, or an exact 64-character hexadecimal WPA/WPA2 PSK. A non-hexadecimal 64-character value and every longer value are rejected. +After the saved station first connects following boot, every ESP32 WiFi +Companion requests a fresh UTC time from NTP. A verified reply updates both the +MeshCore clock and any detected hardware RTC; the next explicit refresh is due +24 hours after that success. A failed request times out without blocking the +radio loop and retries after five minutes. While WiFi is disconnected or only +the setup AP is available, the request waits for a station connection. Builds +with a configured MQTT bridge use its boot-and-daily NTP owner instead, so two +firmware services never reconfigure the ESP32 SNTP client at the same time. A +later WiFi reconnect does not add another successful-sync request; it preserves +the existing 24-hour deadline. Until a fresh internet reply succeeds, LoRa +clock consensus remains available as the fallback. + On Full Companion targets whose primary mesh radio is ESP-NOW, ESP-NOW, the setup AP, and the infrastructure-WiFi station must share one 2.4 GHz channel. The persisted channel defaults to 1 and can be inspected or changed from the @@ -303,16 +319,20 @@ WiFi card in Companion WebConfig exposes `none`, `min`, and `max`. Fresh Cascade-profile builds select `min`; target-default builds select `none`. A saved setting takes precedence after an upgrade. -WiFi-only Companions can use all three modes. Full Companion also runs BLE, so -ESP32 WiFi/Bluetooth coexistence requires at least minimum modem sleep. It -reports `min` when an old or target-default `none` value is found and rejects a -new `none` selection while Bluetooth is compiled in. When ESP-NOW is also the -Full Companion's primary mesh radio, `max` is unavailable: the access point does -not buffer ESP-NOW broadcasts for a sleeping station, so maximum modem sleep can -lose them. Firmware caps a previously saved `max` value to and reports it as -`min`, and rejects a new `max` selection. Thus a WiFi/BLE/primary-ESP-NOW Full -Companion uses `min`. Device power saving can still be turned on or off without -changing the selected WiFi modem policy. +WiFi-only Companions can use all three modes. A Full Companion that runs WiFi +and BLE simultaneously requires at least minimum modem sleep. It reports `min` +when an old or target-default `none` value is found and rejects a new `none` +selection. When ESP-NOW is also the primary mesh radio, `max` is unavailable: +the access point does not buffer ESP-NOW broadcasts for a sleeping station, so +maximum modem sleep can lose them. Firmware caps a previously saved `max` value +to and reports it as `min`, and rejects a new `max` selection. + +The SenseCAP Indicator Full targets select BLE or infrastructure WiFi instead +of running both. On a WiFi-selected boot, the LoRa layout accepts +`none|min|max`, while the ESP-NOW layout accepts `none|min` and still rejects +`max`. On a BLE-selected boot infrastructure WiFi is off; the LoRa layout +accepts `min|max` for the saved WiFi setting, while ESP-NOW + BLE requires +`min`. Device power saving remains independent of this modem policy. WiFi companions do not have the repeater/room-server admin CLI password model, so their LAN WebConfig page is intentionally unauthenticated. Use them only on @@ -547,13 +567,14 @@ reliability on busy nodes. A saved operator setting takes precedence on an upgrade. ESP32 WiFi Companions expose the same values in their WebConfig WiFi card and USB text terminal. Full Companion also accepts the text commands from TCP port 5002. The normal binary Companion protocol can read or write the -setting over USB, BLE, or TCP port 5000 without entering terminal mode. Full -Companion rejects `none` because its simultaneous BLE transport requires WiFi -modem sleep. An ESP32 Full Companion whose primary mesh radio is ESP-NOW also -rejects `max`; maximum +setting over USB, BLE, or TCP port 5000 without entering terminal mode. A Full +Companion that runs BLE and infrastructure WiFi simultaneously rejects `none` +because coexistence requires WiFi modem sleep. An ESP32 Full Companion whose +primary mesh radio is ESP-NOW also rejects `max`; maximum modem sleep can cause the station to miss ESP-NOW broadcasts because the access point does not buffer them. A previously saved `max` value is capped to and -reported as `min`. Such a primary-ESP-NOW Full Companion therefore uses `min`. +reported as `min`. The SenseCAP Indicator exception uses the active-mode rules +above rather than assuming BLE and infrastructure WiFi are active together. ESP-NOW and infrastructure WiFi still must use the same fixed channel. diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md index dbf9f74df..c540df13d 100644 --- a/docs/cli_command_availability.md +++ b/docs/cli_command_availability.md @@ -61,6 +61,7 @@ over the normal binary USB, BLE, or TCP connection: | Command | Availability | |---|---| | [`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 | | [`memory`](#memory) | ESP32 Full Companion text terminal and command `0x42` | | [`get/set radio.rxgain`](cli_commands.md#view-or-change-this-nodes-rx-boosted-gain-mode-sx12xx-and-lr1110-v1141) | Companion with a supported radio | | [`get/set radio.fem.rxgain`](cli_commands.md#view-or-change-the-lora-fem-receive-path-gain-state-on-supported-boards) | Companion on a board with controllable LoRa FEM LNA | diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 83aebac57..caccfa1c7 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -130,10 +130,19 @@ proof. This avoids any dependency on repeater/Pi clock agreement. Every accepted from LoRa or the Ethernet CLI. The included Raspberry Pi endpoint supports exact commands for `help`, -`cpu-temp`, `hostname`, `uptime`, `load`, `memory`, and `disk-free`. It also -provides `reboot` as an opt-in action example and `run [arguments]` for -locally allowlisted executables with typed arguments. Reboot is disabled by -default, arbitrary executables are never accepted, and no action uses a shell. +`cpu-temp`, `hostname`, `uptime`, `load`, `memory`, `disk-free`, and +`clock status`. It also provides strictly validated `clock sync` and +`clock set ` recovery actions, opt-in `network restart` and +`reboot` actions, `action status `, and +`run [arguments]` for locally allowlisted executables with typed +arguments. Clock changes and host recovery actions are disabled by default, +arbitrary executables are never accepted, and no action uses a shell. Clock set +accepts only canonical unsigned decimal epochs from 2020 through 2099; a root-owned +Unix-socket service revalidates the request and authenticates the local service +account with `SO_PEERCRED` before changing system time. Network restart and +reboot use a separate root-owned Unix-socket broker with fixed systemd units; +installing clock control does not grant those actions. The endpoint never uses +`sudo` for privileged host actions. See [LoRa CLI host service](host_cli_service.md) for the complete `meshcoretomqtt` setup and security model. @@ -265,17 +274,22 @@ 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 -all three modes. Full Companion rejects -`none` because its simultaneous BLE transport requires modem sleep. Companion -device `powersaving` and LoRa `radio.rxps` remain independent. On an ESP32 Full -Companion whose primary mesh radio is ESP-NOW, `max` is also unavailable: a -station using maximum modem sleep can miss ESP-NOW broadcasts, which the access -point does not buffer for it. A previously saved `max` value is capped to and -reported as `min`, and a new `max` selection is rejected. These -WiFi/BLE/primary-ESP-NOW builds therefore report `min`. The primary mesh radio -also holds the ESP-IDF WiFi wake reference continuously so unsolicited ESP-NOW -frames remain receivable; selecting `min` does not put that primary receiver to -sleep. +all three modes. A Full Companion that runs BLE and infrastructure WiFi +simultaneously rejects `none` because coexistence requires modem sleep. +Companion device `powersaving` and LoRa `radio.rxps` remain independent. On an +ESP32 Full Companion whose primary mesh radio is ESP-NOW, `max` is also +unavailable: a station using maximum modem sleep can miss ESP-NOW broadcasts, +which the access point does not buffer for it. A previously saved conflicting +value is capped to and reported as `min`, and a new conflicting selection is +rejected. The primary mesh radio also holds the ESP-IDF WiFi wake reference +continuously so unsolicited ESP-NOW frames remain receivable; selecting `min` +does not put that primary receiver to sleep. + +SenseCAP Indicator Full is the exclusive-secondary exception. On a +WiFi-selected boot, LoRa accepts `none|min|max` and ESP-NOW accepts `none|min`. +On a BLE-selected boot infrastructure WiFi is not started; LoRa accepts +`min|max` for the saved WiFi setting, while ESP-NOW + BLE requires `min`. USB +and the primary LoRa or ESP-NOW radio remain available in every mode. #### View or change the primary ESP-NOW/WiFi channel @@ -776,6 +790,11 @@ in `both` mode. ### Get the Version **Usage:** `ver` +On Full Companion, the Binary device-info frame retains its legacy fixed-width +version field. Use `version` in the text terminal, or send `version` through +Binary protocol command `0x42` (`CMD_RUN_CLI_COMMAND`), to read the complete +untruncated firmware string together with protocol and build date. + --- ### Show the hardware name @@ -1477,7 +1496,7 @@ get clock.sync.status **Note:** Infrastructure firmware enters sleep between radio transmissions. It refuses to enable power saving from the local serial console or while an active USB serial data connection is detected; USB power alone does not block power saving. -Companion firmware defaults this setting to `on`. Full Companion accepts the command from its local USB terminal and exposes the same setting in WebConfig. On ESP32, it lowers the CPU clock to 80 MHz, enables idle yielding, and enables the configured GPS duty cycle. USB, BLE, and WiFi remain available. `powersaving off` restores the board's normal CPU clock and disables the GPS duty cycle. This device setting is separate from LoRa RXPS (`radio.rxps`) and WiFi modem power save (`wifi.powersave`). Infrastructure WebConfig uses the `set powersaving` form; enabling it can put the node to sleep and make WiFi temporarily unavailable. +Companion firmware defaults this setting to `on`. Full Companion accepts the command from its local USB terminal and exposes the same setting in WebConfig. On ESP32, it lowers the CPU clock to 80 MHz, enables idle yielding, and enables the configured GPS duty cycle. USB and each active wireless transport remain available; SenseCAP Indicator Full keeps only its selected BLE or infrastructure-WiFi secondary transport active. `powersaving off` restores the board's normal CPU clock and disables the GPS duty cycle. This device setting is separate from LoRa RXPS (`radio.rxps`) and WiFi modem power save (`wifi.powersave`). Infrastructure WebConfig uses the `set powersaving` form; enabling it can put the node to sleep and make WiFi temporarily unavailable. --- diff --git a/docs/companion_protocol.md b/docs/companion_protocol.md index a605dfbc5..643994543 100644 --- a/docs/companion_protocol.md +++ b/docs/companion_protocol.md @@ -251,6 +251,12 @@ 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. +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 +20-byte legacy version field in `RESP_CODE_DEVICE_INFO`, so existing clients +keep the same frame layout. + Firmware from this fork predating the upstream `0x42` allocation used `0x42`-`0x49` for these eight settings. This firmware accepts those values as deprecated inbound aliases so existing clients continue to work. A one-byte @@ -296,11 +302,22 @@ WiFi power-save modes are: | `1` | `none` - no modem sleep | | `2` | `max` - maximum modem sleep | -Full Companion rejects WiFi mode `none` because simultaneous BLE transport -requires modem sleep. A Full Companion using ESP-NOW as its primary mesh radio -also rejects `max`, because maximum modem sleep can miss broadcasts that the -access point cannot buffer. If an older image saved `max`, the effective mode -is capped to and reported as `min`. Device `powersaving` remains independent. +A Full Companion that runs BLE and infrastructure WiFi simultaneously rejects +WiFi mode `none` because coexistence requires modem sleep. A Full Companion +using ESP-NOW as its primary mesh radio also rejects `max`, because maximum +modem sleep can miss broadcasts that the access point cannot buffer. If an +older image saved a conflicting value, the effective mode is capped to and +reported as `min`. Device `powersaving` remains independent. + +The SenseCAP Indicator Full profiles run exactly one secondary wireless +Companion transport per boot. Their active-mode constraints are: + +| Indicator mode | Accepted `wifi.powersave` values | +|---|---| +| LoRa + infrastructure WiFi | `none`, `min`, `max` | +| LoRa + BLE | `min`, `max`; infrastructure WiFi is not started | +| ESP-NOW + infrastructure WiFi | `none`, `min`; `max` conflicts with primary ESP-NOW | +| ESP-NOW + BLE | `min`; infrastructure WiFi is not started and primary ESP-NOW remains active | The Bluetooth name can be configured over USB, BLE, or TCP. Use `set bluetooth.name default` to restore `MeshCore-`; an empty CLI diff --git a/docs/companion_radio_full.md b/docs/companion_radio_full.md index 2ff7bb073..038e618b7 100644 --- a/docs/companion_radio_full.md +++ b/docs/companion_radio_full.md @@ -1,7 +1,11 @@ # Full Companion -`companion_radio_full` combines every Companion transport available on its -platform and acts as a host-backed LoRa mOTA source for updating other nodes. +`companion_radio_full` combines every qualified Companion transport for its +platform into one artifact and acts as a host-backed LoRa mOTA source for +updating other nodes. Most targets can run those transports simultaneously; +the SenseCAP Indicator exception selects one secondary wireless transport per +boot as described below. + The full Companion is deliberately not a LoRa OTA destination: it has no firmware staging store, refuses `ota install`, and never advertises its own firmware as an mOTA image. @@ -18,6 +22,31 @@ firmware as an mOTA image. | Ethernet Companion | On targets with an Ethernet module | On RAK4631 with RAK13800 | | LoRa self-update | No | No | +### SenseCAP Indicator transport and rendering exception + +Both SenseCAP Indicator Full layouts keep USB available and start exactly one +secondary wireless Companion transport per boot: BLE or infrastructure WiFi. +The saved `companion.transport` selection takes effect after reboot. BLE mode +does not start infrastructure WiFi, WebConfig, MQTT, TCP Companion, or network +OTA services. WiFi mode never initializes BLE and releases its controller and +host memory. LoRa remains the primary radio on the LoRa layout. ESP-NOW remains +the primary mesh radio in both ESP-NOW modes, including BLE mode; only the +infrastructure-WiFi services are omitted. + +The requested 4-bit internal render profiles are: + +| Primary radio | Secondary Companion transport | Canvas | +|---|---|---:| +| LoRa | Infrastructure WiFi | Native 480x480 | +| LoRa | BLE | Native 480x480 | +| ESP-NOW | Infrastructure WiFi | Native 480x480 | +| ESP-NOW | BLE | 320x320, scaled 1.5x to the 480x480 panel | + +All four modes retain the same 160x160 logical UI. If a contiguous native +DMA-capable block is unavailable, startup keeps the device usable with the +320x320 emergency fallback and reports the canvas actually retained. Such a +fallback is not a successful native-480 validation result. + ## Build and install The target is synthesized by `build.sh` only for an exact board recipe that has @@ -50,6 +79,15 @@ for that same fixed channel. Turning Companion WiFi off stops its TCP/AP services but deliberately leaves the ESP-NOW mesh radio running on the selected channel. +Every ESP32 Full Companion with a station connection synchronizes its UTC clock +from NTP after boot and explicitly refreshes it every 24 hours. The common WiFi +path owns this for non-MQTT and runtime-unconfigured nodes; a configured MQTT +bridge owns the same boot-and-daily schedule. Successful sync updates an +attached hardware RTC as well as the ESP32 clock and suppresses LoRa clock +fallback for that boot. Reconnecting WiFi preserves the existing daily +deadline. A timeout leaves the existing clock and fallback intact and retries +without blocking radio work. + Inspect or change the shared channel from the Full Companion text terminal: ```text @@ -195,14 +233,16 @@ all longer values are rejected. The normal binary Companion connection can also read or write this setting over USB, BLE, or TCP port 5000 without entering terminal mode. The mode values are `0` for `min`, `1` for `none`, and `2` for `max`; see the -[Companion protocol](./companion_protocol.md#commands). Full Companion rejects -`none` because simultaneous WiFi and BLE require modem sleep. The two -primary-ESP-NOW Full targets also reject `max`; unlike infrastructure traffic, -peer ESP-NOW -broadcasts cannot be buffered by the access point while the station sleeps. If -an older image saved `max`, firmware applies and reports `min` instead. Fresh -Cascade builds select `min`, and an existing valid saved selection takes -precedence. +[Companion protocol](./companion_protocol.md#commands). A Full Companion that +runs WiFi and BLE simultaneously rejects `none` because coexistence requires +modem sleep. A primary-ESP-NOW Full target also rejects `max`; unlike +infrastructure traffic, peer ESP-NOW broadcasts cannot be buffered by the +access point while the station sleeps. If an older image saved a conflicting +value, firmware applies and reports `min` instead. The SenseCAP Indicator uses +the active-mode constraints in the exception above: LoRa + WiFi accepts all +three values, ESP-NOW + WiFi accepts `none|min`, LoRa + BLE accepts `min|max` +for the inactive WiFi setting, and ESP-NOW + BLE requires `min`. Fresh Cascade +builds select `min`, and an existing valid saved selection takes precedence. On radios with RX duty-cycle support, WebConfig and the text terminal also expose the persisted RXPS setting: @@ -226,10 +266,12 @@ migration, an explicit `powersaving off` selection remains persistent. On ESP32, enabling it lowers the CPU clock to 80 MHz, enables idle yielding, and enables the configured GPS duty cycle. Disabling it restores the normal CPU -clock and keeps GPS awake. Full Companion transports remain available in both -states; WiFi modem sleep stays enabled when BLE is present because coexistence -requires it. Changing device power saving does not overwrite the saved WiFi -power-save mode. While a native-USB host is enumerated, the platform sleep +clock and keeps GPS awake. Active Full Companion transports remain available in +both states; on the SenseCAP Indicator this means USB plus the selected BLE or +infrastructure-WiFi secondary transport. WiFi modem sleep stays enabled when +BLE and the ESP32 WiFi radio are active together because coexistence requires +it. Changing device power saving does not overwrite the saved WiFi power-save +mode. While a native-USB host is enumerated, the platform sleep attempt is held off so USB CDC remains responsive; detaching the host releases that guard. CPU, radio-modem, and GPS power-saving settings remain active, and USB power from a charger alone does not create a Companion session. The @@ -250,20 +292,22 @@ TempRadio, the OTA CLI, the TCP terminal, USB folder seeding, and memory diagnostics. The legacy `COMPANION_RADIO_FULL` flag remains an input for older target recipes, but application behavior no longer uses that umbrella as an unrelated compile guard. In particular, ESP32 WiFi/WebConfig terminal controls -are compiled from their actual WiFi/WebConfig capability, and BLE is always -started before WiFi on any combined ESP32 WiFi+BLE Companion to avoid heap -fragmentation. Compile-time prerequisite checks reject inconsistent feature -flags. After linking, the capability sidecar verifies USB, BLE, the OTA CLI, +are compiled from their actual WiFi/WebConfig capability. On targets that run +ESP32 WiFi and BLE simultaneously, BLE is started first to avoid heap +fragmentation. The SenseCAP Indicator starts only its selected secondary +transport. Compile-time prerequisite checks reject inconsistent feature flags. +After linking, the capability sidecar verifies USB, BLE, the OTA CLI, TempRadio, and each platform's host-folder transport; it also verifies the TCP terminal, WebConfig, and WiFi seeder on ESP32, plus dedicated logging on nRF52. On 4 MB ESP32 boards, the full target uses a single 3 MB application partition -so WiFi, BLE, WebConfig, and source-only mOTA fit together. The T-Beam 1W Full -Companion uses that same LilyGo factory-compatible boot layout on its 16 MB -flash because this source-only role does not install updates into a second app -slot. Flash the generated `-merged.bin` when first installing this partition -layout. Other boards with 8 MB or more retain dual application partitions. +so WiFi, BLE, WebConfig, and source-only mOTA fit in one artifact. The T-Beam 1W +Full Companion uses that same LilyGo factory-compatible boot layout on its 16 +MB flash because this source-only role does not install updates into a second +app slot. Flash the generated `-merged.bin` when first installing this +partition layout. Other boards with 8 MB or more retain dual application +partitions. Heltec V2 and TLora V2 use 100 contacts, 8 group channels, and a 16-frame offline queue in this combined profile because of internal DRAM limits. Meshadventurer SX1262 and SX1268 retain 160 contacts, use 30 group channels, and use a @@ -392,7 +436,9 @@ the logs, and leaves that TTY in the normal ASCII terminal, matching a fresh Full installation. Send `+++MESHCORE-TERM-STOP`, or let a Companion app send a valid framed probe, to switch it to Binary Companion afterward. A saved logging-on preference boots directly into this input-capable logging terminal. -BLE and Wi-Fi Companion remain available while USB is logging. +BLE and Wi-Fi Companion remain available while USB is logging on simultaneous +targets. On the SenseCAP Indicator, only the selected BLE or Wi-Fi Companion +transport remains available alongside the logging terminal. ESP32 Full Companion uses the repository's Arduino-ESP32 2.x platform base where the board supports it. RC32 and ESP32-C6 retain their board-required diff --git a/docs/hardware_validation_checklist.md b/docs/hardware_validation_checklist.md index 67d10fa19..81fd96fb3 100644 --- a/docs/hardware_validation_checklist.md +++ b/docs/hardware_validation_checklist.md @@ -9,12 +9,22 @@ exit code when the tool has a documented false-success mode. | Hardware | Stable identity | Current state | Next blocking check | | --- | --- | --- | --- | -| Seeed XIAO nRF52840 | `B35E71C1C3726CE7` | Exact Full Companion application and latency-disable bootloader candidate installed. An exact bonded B-A-B link-profile test passed at 6.225, 3.343, and 6.231 kB/s for 15 ms, untouched 30 ms control, and 15 ms repeat; CRC, activation, target disconnect, and stable USB return passed three times | Power-removal cold test, then exercise BLE-controlled LoRa OTA | -| Seeed Tracker T1000-E | `34A9141999729D5D` | Later connection-policy-reapply OTAFIX candidate and temporary Full Companion are running; the exact bonded warm baseline passed at 3.34 kB/s. Two lab-only pre-START 15 ms/latency-0 DFUs with `0/0` no-preference event-length hints passed at 6.23 and 6.52 kB/s; a 10 ms event-length control timed out before START | Prove installed bootloader bytes by readback, exercise a physical cold 20-to-244-byte readiness transition, restore the protected Repeater identity, cold boot, then force LR1110 reset | -| RAK3401 | `0B81C9C68D8D01B4`; FICR `8D8D01B4 0B81C9C6` | Direct V4 LoRa OTA from `bf092b24` to exact `3caf9dcf` passed. OTAFIX test version `0x02040403` is installed; bidirectional application UF2, exact SWD application/bootloader readback, and unchanged UICR all pass | Add RF packet counters, then repeat bandwidth and routed-hop matrix | +| Seeed XIAO nRF52840 | `B35E71C1C3726CE7` | A stalled Companion transport survived the stop token and exact USB reset, then recovered after a 36-second exact application DFU. Current Full Companion USB, bonded BLE, clock sync, terminal/Binary switching, serial-mOTA folder attach, and reversible dual-CDC logging all pass | Prove current-build cold persistence; separately reproduce the stall before using a power cut to test whether power alone clears it; then transfer an actual image through BLE-controlled LoRa OTA | +| Seeed Tracker T1000-E | `34A9141999729D5D` | A temporary Full Companion and one of two same-visible-version OTAFIX candidates are running; the exact candidate bytes are not yet proven. The bonded warm baseline passed at 3.34 kB/s. Two lab-only pre-START 15 ms/latency-0 DFUs with `0/0` no-preference event-length hints passed at 6.23 and 6.52 kB/s; a 10 ms event-length control timed out before START | Prove installed bootloader bytes by readback, exercise a physical cold 20-to-244-byte readiness transition, restore the protected Repeater identity, cold boot, then force LR1110 reset | +| RAK3401 | `0B81C9C68D8D01B4`; FICR `8D8D01B4 0B81C9C6` | OTAFIX test version `0x02040403` passes bidirectional application UF2, exact SWD application/bootloader readback, unchanged UICR, and a 74-block direct V4 LoRa apply from exact `3caf9dcf` to current HEAD `9fd580c8`. Post-apply USB and LoRa identity/hash checks pass and the temporary 0 dBm bench setting is restored to 22 dBm | Add exact RF packet counters, then repeat the supported-bandwidth and controlled/passive/mixed routed-hop matrix | | Heltec MeshTower V2 with SD | `9352162A72082314` | Exact SD LoRa-OTA Repeater `e26d48e4` is running after identity-gated BLE recovery. Card format/cooldown/forced-format/raw-erase/remount pass; live bootloader contract reports ABI 3, FULL+INPLACE, and SD apply | Signed interrupted-download resume, corruption/signature rejection, then full/delta apply | | Heltec V4 | USB MAC `44:1B:F6:6A:E8:44`; BLE `44:1B:F6:6A:E8:45` | USB/BLE/Wi-Fi and all three TCP services pass on hardware. The final fresh-NTP-gated Full Companion image builds with 4.31 MB app space free | Flash the exact final NTP build, prove NTP-before-TLS success/failure ordering, then OTA seeding | -| SenseCAP Indicator LoRa | CH340 plus USB MAC `D8:3B:DA:75:23:AC`; BLE `D8:3B:DA:75:23:AD` | Exact 2,145,640-byte Full Companion font-recovery application, SHA-256 `88d8d8ae138b297fc7841005ab40c303e1efe9d27a2bb4a89a30735748a073c4`, is installed; identity-gated flash, USB ASCII/Binary switching, runtime logging, configured Wi-Fi, BLE, all three TCP services, fresh-NTP-gated HTTPS recovery, strict Range resume, STAGEV2 install, and exact RP USB readback pass | Physical display-wrap check, LoRa/TempRadio, and OTA seeding | +| SenseCAP Indicator LoRa | CH340 plus USB MAC `D8:3B:DA:75:23:AC`; BLE `D8:3B:DA:75:23:AD` | The current dark-layout application is SHA-256 `81bebdc07b6a8349c1c975cb5a0e30c5f6019d35bcac641e5f3d4df951f7406b`; identity-gated flash, USB ASCII/Binary switching, runtime logging, and separate configured-Wi-Fi, BLE, and TCP checks pass. Fresh-NTP HTTPS recovery, strict Range resume, STAGEV2 install, and RP USB readback passed on earlier exact artifacts; the final coordinator source still needs its blocked-UDP/123 hardware gate | Physical display-wrap and four-mode render/heap checks, exact-final blocked-NTP recovery gate, LoRa/TempRadio, and non-empty OTA seeding | + +The current source checkpoint passes 1,132/1,132 native cases, 68/68 host +broker/endpoint cases, all 318 LoRa-OTA chain/route/bundle cases, and 41/41 +MOTA format/signature/delta cases under the documented pipx `detools` +interpreter. Indicator geometry, dark-theme, and font-recovery contracts pass +4/4, 6/6, and 25/25. A serialized real Indicator ESP32-S3 Wi-Fi/USB profile +also compiles at 95,944 bytes RAM and 1,589,433 bytes flash. The direct +system-Python MOTA invocation predictably lacks `detools`; that environment +boundary is not a firmware failure and must not be worked around by installing +the package globally. The RAK failure log is `/home/mikec/hwtest/runs/rak3401-syncidle-clean-tSp7PLgA/result.txt` on @@ -93,6 +103,27 @@ After SWD reset-halt/resume, USB again reported the expected application, identity, name, radio, 22 dBm, no TempRadio lease, and `blrc:00` (the retained success diagnostic is intentionally cleared by the next normal boot). +The current `0x02040403` bootloader candidate then passed a second direct V4 +LoRa qualification using a materially larger application change. The unsigned +in-place package was 75,614 bytes with 75,108 payload bytes and 74 transfer +blocks. Its mandatory fixed three-minute rehearsal passed, including natural +TempRadio expiry and exact normal-channel recovery; one lost read-only +`ota status` reply was reconciled by its bounded exact retry. The transfer +reached 74/74 and READY in 122 seconds, the explicit unsigned install was +accepted, and reboot produced exact HEAD `9fd580c8` with body +`744BC4443891ED10`. The complete safety-wrapped run took 589.487 seconds, +including rehearsal, recovery, and final verification. The first postboot +`ver` reply was lost and its exact read-only retry passed. Independent USB +checks then confirmed target `2FA509C1`, hardware `RAK_3401`, unchanged +identity/name/radio/RXPS/watchdog state, bootloader CRC `6683C066`, ABI 3, +apply result `blrc:B8`, and no active TempRadio/download/seeder/key state. +After the separate host-recovery exercise, the near-field TX setting was +restored from 0 dBm to its exact original 22 dBm and read back over an +authenticated LoRa session. The sanitized authoritative log is +`/home/mikec/hwtest/runs/rak3401-02040403-head-vzWOXVxT/qualification-redacted.log` +with SHA-256 +`3e53dcaa5922c7c18a7890963b8fc092311d2db01caa5603640f43f1482802f6`. + A serialized VM build of the six connected-board qualification profiles then completed 6/6 in 136.122 seconds: XIAO nRF52 BLE Companion, T1000-E BLE Companion, RAK3401 LoRa-OTA Repeater, SenseCAP Indicator LoRa USB/Wi-Fi @@ -105,25 +136,25 @@ RTC handoff, and daily refresh policy were added, the complete native matrix passed 1,102/1,102 cases in 93.953 seconds. The scheduler is host-tested at 24-hours-minus-one-millisecond, the exact 24-hour boundary, normal and wrapped `millis()` values, a short retry that overrides the daily cadence, the zero -deadline sentinel, and a Wi-Fi reconnect. A reconnect schedules one fresh -sample and clears an arbitrarily old short-retry deadline; while continuously -connected, successful samples are requested only once per day. All current +deadline sentinel, and a Wi-Fi event latch. A reconnect after a successful +sample preserves the existing deadline, so successful samples are requested +after boot and then only once per day. All current Python and shell contract suites passed, and the expanded LoRa-OTA automation suite passed 317/317 cases. Serialized real builds also passed after that change. The final event-safe -implementation uses an atomic `GOT_IP` latch so even a disconnect/reconnect -shorter than the 10-second MQTT status sample cannot skip the required fresh -NTP request. The final Heltec V4 MQTT Observer application is 1,853,304 bytes +implementation uses an atomic `GOT_IP` latch so even a short initial connection +before the 10-second MQTT status sample cannot skip the required boot NTP +request. The final Heltec V4 MQTT Observer application is 1,853,304 bytes with SHA-256 `1938ae88fcdbe601cf8698df44b548656079562d899801886b808af913e5f9ac`. -The consolidated Full V4 image used 2,247,757/6,553,600 flash bytes and left -4,305,368 bytes of app-image space. Its 2,248,232-byte application image has +The consolidated Full V4 image used 2,250,801/6,553,600 flash bytes and left +4,302,799 bytes of app-image space. Its 2,251,272-byte application image has SHA-256 -`2547bb245349a500b7c9a112ac25bcec91ce70733c648ee0ff9ac7de3176da1a`; -the 2,313,768-byte merged image has SHA-256 -`1f89ff127d50f06c3e3ce11c642611e079dae74772f169da9f2e4643da55a797`. -The focused daily-NTP policy suite passed all 38 cases after the latch was +`05131eac29164be46d1bcceeb4f03455f5ea8d61c8d4f529b77d8adfe25fdb6a`; +the 2,316,808-byte merged image has SHA-256 +`c4c1fc639b6d0087c795a81aca79c07eba56dc4e071eb374dc70da0bec81815e`. +The focused daily-NTP policy suites passed all 42 cases after the latch was added, and the Full capability manifest verified all ten expected features. The reduced RAK3401 LoRa-OTA Repeater also compiled with the verified WisBlock I2C aliases. At that source state its link used 471,344/815,104 flash bytes; the @@ -185,6 +216,25 @@ continuation must resolve the stable serial/by-id identity rather than VID/PID. The T1000-E remained in the exact bonded Full Companion application; no stale DFU, scanner, `meshcli`, or HCI-capture process was running. +The post-reboot seven-TTY inventory was also resolved without assuming that +one board owned adjacent device names. `ttyUSB0` is the SenseCAP Indicator's +ESP32-S3 CH340 UART and `ttyACM5` is its RP2040; their sibling physical USB +paths and the Indicator's live TCP board reply agree. The separately attached +`ttyACM4` is the MeshTower V2 native `HT-n5262` endpoint and returned the +qualified MeshTower build over its own console. The remaining ACM endpoints +independently identify the RAK3401, XIAO nRF52840, Heltec V4, and T1000-E. + +ModemManager had restarted after the Pi reboot and could probe otherwise idle +radio consoles. Six serial-bearing boards now use stable-serial-only +`ID_MM_DEVICE_IGNORE` rules. Because the Indicator CH340 has no stable serial, +its separate rule requires the exact current physical port chain *and* exact +`1a86:7523` identity; there is no broad CH340, product, or VID/PID-only rule. +A controlled ModemManager restart reported zero modems, displaced no TTY owner, +caused no USB disconnect/re-enumeration, and left all seven endpoints ignored. +The same mctomqtt process retained sole ownership of the RAK, while a subsequent +Indicator TCP query still returned the expected board and build. Reversible +root-only backups were retained on the Pi before each rule update. + ## Run-wide gates - [ ] Record commit, version string, role/profile, artifact filename, size, and @@ -315,6 +365,57 @@ DFU, scanner, `meshcli`, or HCI-capture process was running. the relevant pipx environment when a probe needs one of its dependencies; the Pi system Python did not contain `pyserial` during the RAK run. +### LoRa-controlled host recovery gate + +- [x] The privileged recovery boundary is a root-owned, group-restricted + AF_UNIX broker with a fixed command grammar and fixed `systemctl` argv. + The unprivileged MQTT endpoint cannot gain privileges under + `NoNewPrivileges`, cannot select an executable or unit, and commits an + action only after its signed challenge/reply has been published and + confirmed. The installed broker passed its cold activation, peer + credential, socket owner/mode, injection, duplicate, timeout, durable + reservation, and ambiguous-outcome checks. +- [x] A signed `host network restart` was accepted exactly once in 4.088 + seconds. The broker retained one `network-restart` record at sequence 1, + and the fixed unit journal had exactly one start/finish pair. Independent + management probing measured 7.184 seconds from last successful probe to + first recovered success and 6.314 seconds from first failure to recovery. + Pi-local loopback MQTT delivered 107/107 samples; mosquitto, mctomqtt, + and the endpoint retained their exact PIDs, and mctomqtt remained the sole + owner of the RAK USB port. One read-only LoRa status reply was lost at the + temporary 0 dBm bench setting, then authenticated status returned + `scheduled`; no mutation was replayed. One monitor parsed two + `systemctl --value` columns in reverse order, so its boolean labels are + discarded; its PID/MQTT trace and the independent postchecks remain + valid. +- [x] A signed `host reboot` was accepted exactly once in 3.312 seconds and + armed the fixed 10-second timer. A changed boot ID and one observed + outage prove one reboot; management recovery took 35.763 seconds from + the last pre-outage success and 31.276 seconds from the first failed + probe. Postboot signed LoRa uptime replied in 3.651 seconds. All seven + exact USB identities returned, only the RAK port was owned, the deployed + configuration hashes were unchanged, and NetworkManager, Tailscale, + mosquitto, mctomqtt, the endpoint, and both privileged sockets were + active. Broker action state is intentionally under `/run` and persistent + journaling was unavailable, so exactly-once evidence is the accepted + reply, one boot-ID transition, one outage, an inactive timer, and no + second outage; do not claim a durable postboot operation record. +- [x] Reboot auto-started the still-enabled ModemManager service, but all seven + exact devices retained `ID_MM_DEVICE_IGNORE=1`, zero modems appeared, + and no TTY was probed or claimed. The service was returned to its + pretest inactive state without disabling or masking it. The RAK then + passed exact identity/body/radio gates; an explicit radio-busy rejection + left TX at 0 dBm, one bounded idempotent retry after idle returned OK, + and authenticated readback proved exact restoration to 22 dBm. V4 + identity, power-saving, RXPS, TempRadio, and seeder state also matched + their pretest values. + +The sanitized host-recovery evidence bundle is under +`out/host-actions-live-20260831/`. Its `SHA256SUMS` manifest has SHA-256 +`5acff71ed98f483bb769e8c99c577bf358d0491355ac72855ee7093cd658bf21`, +and `sha256sum -c` verifies every deployed-hash, pre/post-state, radio, OTA, +network, and reboot record in that manifest. + ### Mercerwood Pi USB power-cycle gate - [ ] Confirm the Pi is reachable through Wi-Fi/Tailscale and that `eth0` is not @@ -504,6 +605,41 @@ DFU, scanner, `meshcli`, or HCI-capture process was running. - [ ] Warm app-to-bootloader handoff is tested with the application watchdog active; cold recovery is tested separately. - [ ] BLE Companion/recovery behavior and radio-reset recovery are exercised. +- [x] The post-Pi-recovery application still enumerated as exact + `2886:8044` / `B35E71C1C3726CE7`, but one identity-gated, bounded USB + Binary Companion attempt received no protocol response. `meshcore-cli` + 1.6.0 printed both of its no-response error lines and nevertheless exited + zero; the supervisor treated the attempt as a failure and independently + proved that no process retained the serial port. The exact terminal-stop + token and an identity/path-gated USB bus reset also failed, which excludes + an ordinary startup Terminal-mode hold and host USB endpoint state. The + external tool defect remains in upstream `meshcore-cli` 1.6.3: its serial + creation failure returns normally, so callers must validate command output + instead of trusting exit zero. The LoRa-OTA controller has an exact + three-diagnostic regression and continues to fail closed. +- [x] An exact hash-recorded Full Companion application DFU erased/replaced only + the application and completed in 36 seconds. The transferred ZIP was + 440,083 bytes with SHA-256 + `9946b8417c389784ac92c20e4ce5cf0b6a0123a58136627c0689815637f96992`. + The same stable USB serial returned as interface `00`; USB and the existing + bonded BLE identity both reported `Seeed Xiao-nrf52`, the 31-Aug-2026 + build, and Companion role. Preserved settings included a roughly six-month + stale clock, which Binary Companion `clock sync` advanced to one second of + the Pi without changing the role. +- [x] The recovered application passed explicit Terminal start/stop, exact board + query, saved `usb.logging off`, source-only OTA status, and a real serial + `motatool serve` attach/detach with an empty folder (`COUNT -> 0`). Binary + Companion returned after both terminal and mOTA ownership. Enabling USB + logging and rebooting produced exact interfaces `00` and `02`; interface + `02` emitted 123 bytes of entirely printable log data while Binary + Companion stayed usable on `00`. Disabling and rebooting removed `02`, + restored the single-interface descriptor, and read back logging off. +- [ ] A physical power-removal cold test is still needed to prove persistence of + the current application. It cannot retrospectively determine whether a + power cycle alone would have cleared the original stall. Answer that only + after reproducing the stall under a bounded stress loop, capturing state, + and power-cycling before any DFU. The application DFU proves recovery but + intentionally does not claim the underlying stall trigger is fixed. ## Seeed Tracker T1000-E @@ -512,6 +648,11 @@ DFU, scanner, `meshcli`, or HCI-capture process was running. product ID `239a:8029`. Application is `239a:8029`; the exercised bootloader is `2886:0057`, with the same stable serial and exact `T1000-E`/`T1KE_DFU` model/name gates. +- [x] A post-Pi-recovery, identity-gated, read-only USB Companion query returned + the exact T1000-E model, non-repeater Full Companion role, 22 dBm maximum, + and `910.525/62.5/SF7/CR5`. Its clock readback was within the query's + few-second host interval. No clock, radio, role, key, or settings write + was issued. - [ ] Board, version, repeater role, bootloader status, and USA radio tuple are correct after erase/install and after reboot. The live baseline and a 2,184 ms software reboot passed: `3caf9dcf`, `Seeed Tracker T1000-E`, 22 dBm, @@ -746,6 +887,16 @@ DFU, scanner, `meshcli`, or HCI-capture process was running. 60-second reboot probe, and 514 seconds for the complete safety-wrapped run. Log: `/home/mikec/hwtest/runs/rak3401-0dbm-final-9KWjey61/run.log`. +- [x] The current OTAFIX test version `0x02040403` independently passes a + 74-block direct LoRa update from exact `3caf9dcf` body + `27A223DDFC3A8F47` to HEAD `9fd580c8` body `744BC4443891ED10`. + READY took 122 seconds and the complete three-minute-rehearsal, + transfer, install, reboot, recovery, and restore wrapper took 589.487 + seconds. Lost read-only preflight and postboot replies were reconciled by + bounded exact retries. USB confirmed the target/hardware/body and + bootloader success result, and authenticated LoRa readback confirmed the + final 22 dBm restoration. Sanitized log: + `/home/mikec/hwtest/runs/rak3401-02040403-head-vzWOXVxT/qualification-redacted.log`. - [ ] Add exact RF packet sent/confirmed counters to the device/host telemetry. The qualified run records two payload blocks, all seeder reads, command retries, and final success, but those are not equivalent to LoRa packet @@ -805,6 +956,13 @@ DFU, scanner, `meshcli`, or HCI-capture process was running. `910.525/62.5/SF7/CR5`. `ota self` reports SD apply ABI 3 with codec mask `0x5`; `ota bootloader` reports board `239A0071`, target `1150F50E`, name `TOWER_V2_OTA`, and capability byte `09`. +- [x] A post-Pi-recovery identity-gated serial audit reconfirmed the exact + Tower V2 model, Repeater role, build, `910.525/62.5/SF7/CR5`, target + `0A9DBBF0`, body `E9282D47`, SD ABI 3/codecs `0x5`, and installed + bootloader identity/capability `239A0071`/`09`. The card reported + 959.6 MiB free and no active download. This profile exposes the local + text console but deliberately has no runtime `usb.logging` setting; the + unknown-setting reply is a capability result, not a transport failure. - [x] Card detection and destructive maintenance pass. Normal format took 3,927 ms; an immediate repeat was rejected by the five-minute cooldown; forced format took 3,933 ms; and raw erase plus format took 3,947 ms. @@ -863,6 +1021,13 @@ DFU, scanner, `meshcli`, or HCI-capture process was running. `df08b770313ca8776436456525681cf286cd9b5b40e05e7cf55f21ef097f338a`; its ELF, map, manifest, and partition signature are archived beside it on the Mercerwood Pi. +- [x] The current dark-layout Full Companion application was flashed app-only + with exact CH340/MAC/partition gates, preserving identity, Wi-Fi, and + settings. The 2,147,800-byte application has SHA-256 + `81bebdc07b6a8349c1c975cb5a0e30c5f6019d35bcac641e5f3d4df951f7406b`; + esptool verified its flash hash in 134.2 seconds. USB Binary/terminal, + saved logging-off state, configured Wi-Fi, BLE, TCP ports 5000/5001/5002, + and an empty-folder Wi-Fi mOTA `COUNT -> 0` exchange all pass afterward. - [x] Keep one identity-gated CH340 descriptor open with DTR/RTS deasserted through startup. On the valid-current-font path, the Indicator streams a 1,302,608-byte font from its RP2040 at 1 Mbps before registering the USB @@ -953,7 +1118,27 @@ DFU, scanner, `meshcli`, or HCI-capture process was running. Repeat the negative UDP/123-blocked hardware gate on this exact final source before treating the coordinator refinement as hardware-qualified. - [ ] Full Wi-Fi SSID, setup address, and related status text wrap without - clipping on the physical display. + clipping on the physical display. The new dark palette and exclusive + lower pairing block are installed; a fresh bond removal held the random + PIN request on-screen for 30 seconds and then an immediate second attempt + paired, trusted, and returned the exact Indicator identity over BLE. A + human physical-readability confirmation is still required; the automated + geometry tests prove the PIN block is centered at four-fifths height and + cannot overlap IP, inbox, or status text. +- [ ] Boot the exact current LoRa and ESP-NOW Full artifacts in both saved + secondary-transport modes and capture the `Indicator render` line. Require + LoRa + WiFi, LoRa + BLE, and ESP-NOW + infrastructure WiFi to retain + 480x480, and ESP-NOW + BLE to select 320x320 scaled to the panel. A logged + emergency 320x320 fallback keeps the unit recoverable but does not pass a + native-480 gate. +- [ ] For all four boots, record free heap and largest internal block before + interfaces and after the selected interface is usable. In WiFi mode prove + BLE never starts; in BLE mode prove infrastructure WiFi, WebConfig, MQTT, + and TCP never start. Keep the primary LoRa or ESP-NOW radio exchanging + traffic throughout. For both BLE modes pair/encrypt, negotiate MTU, + subscribe, exchange repeated Companion requests, disconnect/reconnect, + and repeat after bond removal; exercise network/TCP traffic in both WiFi + modes and record the lowest heap/largest-block values. - [x] With no saved SSID, setup Wi-Fi powers down after the absolute 30-minute window even if queried or used. - [x] A software reboot starts a fresh unconfigured setup window. diff --git a/docs/host_cli_service.md b/docs/host_cli_service.md index d4639a3d1..6af02fe2d 100644 --- a/docs/host_cli_service.md +++ b/docs/host_cli_service.md @@ -12,12 +12,18 @@ cmd host uptime cmd host load cmd host memory cmd host disk-free +cmd host clock status +cmd host clock sync +cmd host clock set +cmd host network restart cmd host reboot +cmd host action status cmd host run [arguments] ``` -The first six actions after `help` are read-only. `reboot` is an opt-in action -example and is disabled unless the endpoint is started with `--allow-reboot`. +The first seven actions after `help` are read-only. Clock changes, network +restart, and reboot are opt-in actions and are disabled unless explicitly +enabled. Text such as `reboot now`, `cpu-temp; reboot`, and embedded newlines is not a command: the endpoint accepts only an exact allowlist match. @@ -37,6 +43,12 @@ LoRa -> repeater -> USB -> meshcoretomqtt -> MQTT -> host endpoint LoRa <- repeater <- USB <- meshcoretomqtt <- MQTT <- host endpoint ``` +Clock-recovery deployments must run the loopback broker, `meshcoretomqtt`, and +endpoint on the same Pi. A wrong Pi clock can prevent TLS validation against a +remote broker, while split host clocks can reject the signed live claim before +the correction arrives. Remote brokers remain suitable for ordinary commands +when the connection and both clocks are already healthy. + Launch `meshcoretomqtt` with its existing arguments plus `--debug`; its current debug-topic parser needs that flag to publish the repeater request record. Do not open the serial TTY from the host endpoint too. @@ -117,23 +129,161 @@ Copy and edit then give the service account only the operating-system permissions needed by those trusted programs. -## Opt-in reboot example +## Opt-in network and reboot recovery -To enable only the exact `host reboot` action, give the service account narrow -permission in `/etc/sudoers.d/meshcore-host-cli`: +These actions use their own socket-activated root broker, independently of the +clock-control broker. Installing or enabling clock control does not grant host +recovery actions. Install the following as root-owned files: -```text -mctomqtt ALL=(root) NOPASSWD: /usr/bin/systemctl reboot +```sh +sudo install -o root -g root -m 0755 meshcore_host_actions.py \ + /usr/local/sbin/meshcore-host-actions +sudo install -o root -g root -m 0644 meshcore-host-actions.socket \ + /etc/systemd/system/meshcore-host-actions.socket +sudo install -o root -g root -m 0644 meshcore-host-actions.service \ + /etc/systemd/system/meshcore-host-actions.service +sudo install -o root -g root -m 0644 meshcore-networkmanager-restart.service \ + /etc/systemd/system/meshcore-networkmanager-restart.service +sudo install -o root -g root -m 0644 meshcore-host-reboot.timer \ + /etc/systemd/system/meshcore-host-reboot.timer +sudo install -o root -g root -m 0644 meshcore-host-reboot.service \ + /etc/systemd/system/meshcore-host-reboot.service ``` -Set that file to mode `440`, test it locally, and add `--allow-reboot` to the -endpoint command. Its default response is `OK - host reboot scheduled in 5s`. -The delay can be set from 3 through 60 seconds with `--reboot-delay`. +The root-owned broker policy enables nothing by default. Create a systemd +drop-in and select `network-restart`, `reboot`, or both as an exact +comma-separated list: -The implementation always invokes the fixed argument vector -`/usr/bin/sudo -n /usr/bin/systemctl reboot`; no LoRa text is placed in a shell, -path, or process argument. Do not grant the service account a wildcard sudo -rule. +```sh +sudo systemctl edit meshcore-host-actions.service +``` + +```ini +[Service] +Environment=MESHCORE_HOST_ACTIONS=network-restart,reboot +``` + +Reload systemd and enable only the broker socket: + +```sh +sudo systemctl daemon-reload +sudo systemctl enable --now meshcore-host-actions.socket +``` + +After changing the policy on an already active installation, restart +`meshcore-host-actions.service` so the broker reads the new value. + +Finally add `--allow-network-restart`, `--allow-reboot`, or both to the +unprivileged endpoint. An action must pass both gates: the endpoint flag and +the root-owned broker policy. The endpoint never invokes `sudo`; remove legacy +`wifi-restart` program aliases and host-action sudoers entries because sudo +aliases are not a fallback for this hardened service. Use the exact +`host network restart` action instead. + +Systemd creates `/run/meshcore-host-actions.sock` as `root:mctomqtt` mode +`0660`. The root broker authenticates both the endpoint UID and primary GID +with `SO_PEERCRED`, while the endpoint authenticates the root-created listener. +The protocol is one bounded ASCII line. Neither the caller nor request text can +choose an executable, unit, path, or argument. The broker can start only the +fixed static NetworkManager restart service or the fixed reboot timer; the +broker and action units have empty capability sets and systemd sandboxing. +The configured `mctomqtt` UID and its effective primary GID are the delegated +local trust boundary; supplementary group membership alone is rejected. + +Side effects follow a fail-closed two-phase sequence. The endpoint reserves a +canonical 128-bit operation ID with `PREPARE`, publishes its reply with MQTT +QoS 1, and requires `wait_for_publish` plus `is_published` confirmation before +sending one `COMMIT`. It does not block the Paho network callback while waiting. +No confirmation means no commit. Reboot uses a fixed approximately 10-second +timer that starts only after commit. The MQTT confirmation proves acceptance +by the local broker, not delivery over USB and LoRa; there is no correlated +serial delivery acknowledgement, so the physical reply is best effort. + +`network restart` deliberately drops Wi-Fi and Tailscale management while +NetworkManager restarts. Use a loopback MQTT broker and keep the broker, +`meshcoretomqtt`, host endpoint, and USB device services independent of +NetworkManager: they must not have `Requires=`, `BindsTo=`, or ordering +dependencies on it. This preserves the local reply/commit path while remote +management temporarily disappears. + +The operation ID is derived from the authenticated repeater key, request ID, +and nonce. The reply displays it, and its state can be queried later: + +```text +cmd host action status 0123456789ABCDEF0123456789ABCDEF +``` + +The root broker retains at most 64 records for the current boot. It persists +`prepared` before replying and `in-progress` before scheduling, schedules a +committed operation at most once, and never automatically retries an ambiguous +outcome. A broker restart converts recovered `in-progress` to `ambiguous`. +Only an old uncommitted `prepared` record may be evicted; otherwise a full +store rejects new work. Reusing an operation ID for a different action is +rejected. `scheduled` means PID 1 accepted the fixed unit job; it does not claim +that the subsequent network restart or reboot completed successfully. + +A distinct reboot is rejected while another reboot is committed or ambiguous, +so a second request cannot falsely promise a fresh 10-second timer while the +first timer is already running. A new reboot reservation may supersede only an +older, uncommitted reboot reservation; the superseded operation can no longer +be committed. + +## Opt-in clock recovery + +`clock status` reports the Pi epoch and NTP synchronization state without root +access. To enable the exact `clock sync` and `clock set ` actions, +install +[`meshcore_clock_control.py`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/examples/host_cli_service/meshcore_clock_control.py) +as `/usr/local/sbin/meshcore-clock-control`, owned by root and mode `0755`, +then install the accompanying +[`meshcore-clock-control.socket`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/examples/host_cli_service/meshcore-clock-control.socket) +and +[`meshcore-clock-control.service`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/examples/host_cli_service/meshcore-clock-control.service) +and static +[`meshcore-chrony-step.service`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/examples/host_cli_service/meshcore-chrony-step.service) +files in `/etc/systemd/system` as root-owned mode-`0644` files: + +```sh +sudo install -o root -g root -m 0755 meshcore_clock_control.py \ + /usr/local/sbin/meshcore-clock-control +sudo install -o root -g root -m 0644 meshcore-clock-control.socket \ + /etc/systemd/system/meshcore-clock-control.socket +sudo install -o root -g root -m 0644 meshcore-clock-control.service \ + /etc/systemd/system/meshcore-clock-control.service +sudo install -o root -g root -m 0644 meshcore-chrony-step.service \ + /etc/systemd/system/meshcore-chrony-step.service +sudo systemctl daemon-reload +sudo systemctl enable --now meshcore-clock-control.socket +``` + +The socket is fixed at `/run/meshcore-clock-control.sock`; systemd creates it +as `root:mctomqtt` with mode `0660`. Run the endpoint as user and primary group +`mctomqtt`, then add `--allow-clock-control`. The endpoint validates the socket +metadata and authenticates the connected server as root with Linux +`SO_PEERCRED`. The root service independently requires the peer's UID and +primary GID to match `mctomqtt:mctomqtt` before it reads or executes a request. +No clock-control sudo rule is used; remove old clock-helper sudoers lines when +upgrading. + +Both layers require one canonical unsigned decimal argument with no sign, +leading zero, whitespace, or trailing text. The accepted epoch range is 2020 +through 2099. The private protocol accepts only the complete bounded ASCII +lines `sync` and `set `. Every child process uses a fixed absolute argv, +`shell=False`, and a timeout no longer than 1.5 seconds. + +`clock set` changes `CLOCK_REALTIME` without disabling NTP, then requests an NTP +step. If the NTP request fails, the response honestly reports that the clock +changed and which synchronization step remains incomplete. If `clock sync` +enables NTP but the explicit step/restart fails, that partial outcome is also +reported. When chrony is available, the root broker starts only the fixed +static `meshcore-chrony-step.service`. That hardened one-shot runs +`/usr/bin/chronyc -a makestep` as `_chrony:_chrony`, with no capabilities, +`AF_UNIX` only, and a one-second start timeout; it can therefore reach chrony's +private runtime socket without broadening the root broker. The `_chrony` +account comes from the Debian/Raspberry Pi chrony package. Without chrony, the +broker restarts systemd-timesyncd. Clock drift cannot authorize a captured command: +the signed request still needs the repeater's live one-time claim, whose +deadline is monotonic rather than wall-clock based. ## Trust and injection controls @@ -156,10 +306,11 @@ A captured proof does not match a new challenge, and restarting the endpoint forgets pending challenges. Repeater/Pi clock drift therefore cannot turn an old `reboot` or `run` record into a valid action. The reply and claim commands still use short-lived JWTs; `meshcoretomqtt` checks their signer allowlist, -target, expiration, signature, and separate replay nonce. If the endpoint and -`meshcoretomqtt` are on different computers, those two host clocks must be -compatible for JWT validation. They naturally share a clock when both run on -the same Pi. +target, expiration, signature, and separate replay nonce. Wall-clock expiry and +its in-memory nonce cache are not sufficient across a backward clock jump plus +restart; the firmware's live one-time claim is what protects these host +actions. Keep recovery-mode `allowed_companions` limited to the dedicated +service key, with the endpoint and `meshcoretomqtt` on the same Pi. Newlines and other reply control characters are converted to spaces and are also independently rejected by the firmware parser. @@ -175,8 +326,11 @@ sign serial requests. legacy three-byte companion correlation prefix. - The complete LoRa reply is at most 162 UTF-8 bytes. - Only one host request can be pending per repeater. -- The live claim must complete within 4 seconds; after it is accepted, the - program and reply have 6 seconds. +- Only one privileged action or action-status query can be active; overlapping + work is rejected before reservation. +- The live claim must complete within 4 seconds. Allowlisted programs have a + configured 1-5 second child-process timeout. Privileged actions and status + queries share one 5-second broker/reply-confirmation deadline. - `host.reply` is accepted only through physical USB, not LoRa or Ethernet. Use `cmd get host` to report the bridge state and limits. diff --git a/examples/companion_radio/CompanionWiFi.h b/examples/companion_radio/CompanionWiFi.h index 41c92f631..01a2fe245 100644 --- a/examples/companion_radio/CompanionWiFi.h +++ b/examples/companion_radio/CompanionWiFi.h @@ -2,8 +2,26 @@ #include +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) \ + && !(defined(ESP32) && defined(WIFI_SSID) && defined(BLE_PIN_CODE)) +#error "COMPANION_EXCLUSIVE_WIFI_BLE requires ESP32, WIFI_SSID, and BLE_PIN_CODE" +#endif + #if defined(ESP32) && defined(WIFI_SSID) +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) +enum class CompanionTransportMode : uint8_t { + Bluetooth, + WiFi, +}; + +// The selected transport is persisted immediately but is deliberately applied +// only on the next boot. This keeps Bluetooth memory release irreversible only +// within the boot that selected exclusive WiFi. +CompanionTransportMode getCompanionTransportMode(); +bool selectCompanionTransportMode(CompanionTransportMode mode); +#endif + enum class CompanionWiFiPowerSaveResult : uint8_t { Applied, SavedForNextConnection, diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index d95429010..2ae0afb55 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1708,6 +1708,9 @@ void MyMesh::begin(bool has_display, bool radio_available) { configureRadioFromPrefs(); #if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID) +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) + if (_prefs.wifi_enabled != 0) { +#endif applyMQTTDefaults(&_mqtt_prefs); _mqtt_configured = CompanionMqttSetupPortal::loadStoredConfig(_mqtt_prefs); if (!_mqtt_configured) applyMQTTDefaults(&_mqtt_prefs); @@ -1734,9 +1737,15 @@ void MyMesh::begin(bool has_display, bool radio_available) { _mqtt_bridge->setBuildDate(FIRMWARE_BUILD_DATE); _mqtt_bridge->setStatsSources(this, _radio, &board, _ms); } +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) + } +#endif #endif #ifdef WITH_WEBCONFIG +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) + if (_prefs.wifi_enabled != 0) { +#endif void* web_mqtt_prefs = nullptr; #ifdef WITH_MQTT_BRIDGE web_mqtt_prefs = &_mqtt_prefs; @@ -1745,6 +1754,9 @@ void MyMesh::begin(bool has_display, bool radio_available) { self_id.pub_key, FIRMWARE_VERSION, FIRMWARE_BUILD_DATE, "companion", board.getManufacturerName()); +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) + } +#endif #endif } @@ -2055,6 +2067,13 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, return true; } + if (strcmp(command, "version") == 0) { + snprintf(reply, reply_size, "Companion %s (protocol %u, build %s)", + FIRMWARE_VERSION, (unsigned)FIRMWARE_VER_CODE, + FIRMWARE_BUILD_DATE); + return true; + } + #if COMPANION_FEATURE_MEMORY_DIAGNOSTICS if (strcmp(command, "memory") == 0) { const mesh::CompanionMemoryDiagnostics diagnostics = { @@ -2273,6 +2292,40 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, #endif #if defined(ESP32) && defined(WIFI_SSID) +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) + if (strcmp(command, "get companion.transport") == 0) { + snprintf(reply, reply_size, "%s", + getCompanionTransportMode() == CompanionTransportMode::WiFi + ? "wifi" : "ble"); + return true; + } + + if (strncmp(command, "set companion.transport", 23) == 0 + && (command[23] == 0 || command[23] == ' ' + || command[23] == '\t')) { + const char* value = command + 23; + while (*value == ' ' || *value == '\t') value++; + CompanionTransportMode selected; + if (strcmp(value, "wifi") == 0) { + selected = CompanionTransportMode::WiFi; + } else if (strcmp(value, "ble") == 0) { + selected = CompanionTransportMode::Bluetooth; + } else { + snprintf(reply, reply_size, + "Error: use set companion.transport "); + return true; + } + if (!selectCompanionTransportMode(selected)) { + snprintf(reply, reply_size, + "Error: failed to save companion transport"); + } else { + snprintf(reply, reply_size, + "OK - companion transport %s saved; reboot required", value); + } + return true; + } +#endif + #ifdef WITH_WEBCONFIG if (strncmp(command, "get ", 4) == 0) { const mesh::cli::StandaloneWiFiKey wifi_key = @@ -2695,6 +2748,13 @@ void MyMesh::getNodeSnapshot(WebConfigServer::NodeSnapshot& s) { } bool MyMesh::startWebConfig(bool force_ap, char* reply) { +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) + if (!isCompanionWiFiEnabled()) { + strcpy(reply, + "Err: WebUI unavailable while Bluetooth transport is active; select WiFi and reboot"); + return false; + } +#endif if (!_webconfig) { strcpy(reply, "Err: WebUI unavailable (not enough memory)"); return false; @@ -6043,6 +6103,7 @@ void MyMesh::handleTerminalCommand(char* command) { } else if (strcmp(command, "help") == 0) { terminalOutput().print("Commands:\r\n"); terminalOutput().print(" board\r\n"); + terminalOutput().print(" version\r\n"); #if COMPANION_FEATURE_MEMORY_DIAGNOSTICS terminalOutput().print(" memory\r\n"); #endif @@ -6058,6 +6119,10 @@ void MyMesh::handleTerminalCommand(char* command) { terminalOutput().print(" set usb.logging [reboot]\r\n"); #endif #if defined(ESP32) && defined(WIFI_SSID) +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) + terminalOutput().print(" get companion.transport\r\n"); + terminalOutput().print(" set companion.transport \r\n"); +#endif terminalOutput().print(" get wifi.powersave\r\n"); terminalOutput().print(" set wifi.powersave \r\n"); #ifdef WITH_WEBCONFIG @@ -6484,6 +6549,9 @@ void MyMesh::checkSerialInterface() { } void MyMesh::loop() { +#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID) + if (_mqtt_bridge) _mqtt_bridge->servicePendingClockCorrection(); +#endif if (_scheduled_reboot_at != 0 && millisHasNowPassed(_scheduled_reboot_at)) { _scheduled_reboot_at = 0; diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 7290ed565..aa3281260 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -138,6 +138,11 @@ public: CompanionNodePrefs *getNodePrefs(); uint32_t getBLEPin(); int getOfflineQueueCapacity() const; + void noteInternetClockSet() { +#ifdef COMPANION_MESH_CLOCK_SYNC + _clock_sync.onInternetClockSet(); +#endif + } void setMotaSourceControl(mesh::companion::MotaSourceControl* control) { _mota_source_control = control; } @@ -146,6 +151,14 @@ public: void serviceMQTT(const char* wifi_ssid, const char* wifi_password); void stopMQTT(); bool isMQTTConfigured() const { return _mqtt_configured; } + bool isMQTTRunning() const { + return _mqtt_started && _mqtt_bridge != nullptr + && _mqtt_bridge->isRunning(); + } + bool hasFreshMQTTNtpThisBoot() const { + return _mqtt_bridge != nullptr + && _mqtt_bridge->hasFreshNtpThisBoot(); + } #endif #ifdef WITH_WEBCONFIG diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 54eff05ac..6f8491286 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -67,10 +67,16 @@ MultiSerialInterface interface_manager; #include #include #include + #include #include + #include + #include + #include #include #include #include + #include + #include #include SerialWifiInterface wifi_interface; #ifndef WIFI_PWD @@ -817,8 +823,215 @@ void halt() { static bool companion_wifi_power_save_loaded = false; static uint8_t companion_wifi_power_save = mesh::wifi::kDefaultPowerSave; +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) + static CompanionTransportMode companion_transport_boot_mode = + CompanionTransportMode::WiFi; + static bool companion_transport_boot_mode_loaded = false; + + CompanionTransportMode getCompanionTransportMode() { + return the_mesh.getNodePrefs()->wifi_enabled != 0 + ? CompanionTransportMode::WiFi + : CompanionTransportMode::Bluetooth; + } + + bool selectCompanionTransportMode(CompanionTransportMode mode) { + if (mode != CompanionTransportMode::Bluetooth + && mode != CompanionTransportMode::WiFi) { + return false; + } + + CompanionNodePrefs* prefs = the_mesh.getNodePrefs(); + const uint8_t previous = prefs->wifi_enabled; + const uint8_t selected = mode == CompanionTransportMode::WiFi ? 1 : 0; + if (previous == selected) return true; + + prefs->wifi_enabled = selected; + if (the_mesh.savePrefs()) return true; + prefs->wifi_enabled = previous; + return false; + } + + static bool companionTransportWiFiActiveAtBoot() { + return companion_transport_boot_mode == CompanionTransportMode::WiFi; + } + + static void loadCompanionTransportModeForBoot() { + companion_transport_boot_mode = getCompanionTransportMode(); + companion_transport_boot_mode_loaded = true; + companion_wifi_requested = companionTransportWiFiActiveAtBoot(); + mesh::usbLoggingPort().printf( + "Companion transport: exclusive %s selected for this boot\r\n", + companion_wifi_requested ? "WiFi" : "Bluetooth"); + } + + static void releaseCompanionBluetoothMemoryForWiFi() { + const esp_err_t result = esp_bt_mem_release(ESP_BT_MODE_BTDM); + if (result == ESP_OK) { + mesh::usbLoggingPort().println( + "Companion transport: released Bluetooth memory for exclusive WiFi mode"); + } else { + mesh::usbLoggingPort().printf( + "Companion transport: Bluetooth memory release failed: %s\r\n", + esp_err_to_name(result)); + } + } +#endif + + static constexpr uint32_t COMPANION_WIFI_NTP_TIMEOUT_MS = 15000UL; + static mesh::wifi::CompanionWiFiNtpPolicy companion_wifi_ntp_policy; + static std::atomic companion_wifi_ntp_operation_generation{0}; + static std::atomic companion_wifi_ntp_proof_generation{0}; + static std::atomic companion_wifi_ntp_epoch{0}; + static std::atomic companion_wifi_ntp_proven_at{0}; + static bool companion_wifi_ntp_pending = false; + static uint32_t companion_wifi_ntp_started = 0; + static uint32_t companion_wifi_ntp_previous_epoch = 0; + + static void clearCompanionWiFiNtpCallback() { + companion_wifi_ntp_operation_generation.store( + 0, std::memory_order_release); + esp_sntp_set_time_sync_notification_cb(nullptr); + // The policy below owns the cadence. Leave no SDK-default (three-hour in + // the current ESP32 toolchain) background poll running between the boot + // request and the explicit 24-hour refresh. + esp_sntp_stop(); + } + + static mesh::sntp_coord::OperationLease companion_wifi_ntp_operation( + mesh::sntp_coord::processWideCoordinator(), + clearCompanionWiFiNtpCallback); + + static void noteCompanionWiFiNtpTime(struct timeval* value) { + const uint32_t generation = + companion_wifi_ntp_operation_generation.load( + std::memory_order_acquire); + if (!mesh::sntp_coord::processWideCoordinator().owns(generation)) return; + if (value == nullptr || !mesh::tls_clock::timeIsValid(value->tv_sec) + || static_cast(value->tv_sec) > UINT32_MAX) { + return; + } + companion_wifi_ntp_epoch.store( + static_cast(value->tv_sec), std::memory_order_release); + companion_wifi_ntp_proven_at.store(millis(), std::memory_order_release); + companion_wifi_ntp_proof_generation.store( + generation, std::memory_order_release); + } + + static bool companionWiFiMqttOwnsNtp() { +#ifdef WITH_MQTT_BRIDGE + const bool running = the_mesh.isMQTTRunning(); + if (running && the_mesh.hasFreshMQTTNtpThisBoot()) { + the_mesh.noteInternetClockSet(); + } + return running; +#else + return false; +#endif + } + + static void cancelCompanionWiFiNtp(bool retry_on_reconnect) { + if (!companion_wifi_ntp_pending) return; + companion_wifi_ntp_pending = false; + companion_wifi_ntp_operation.release(); + if (retry_on_reconnect) companion_wifi_ntp_policy.requestNow(); + } + + static void serviceCompanionWiFiNtp() { + // Configured MQTT builds already perform a fresh startup NTP sync and a + // daily refresh. The common Companion owner covers every non-MQTT or + // runtime-unconfigured WiFi node without racing MQTT's SNTP proof. + if (companionWiFiMqttOwnsNtp()) { + cancelCompanionWiFiNtp(false); + companion_wifi_ntp_policy.requestNow(); + return; + } + + if (WiFi.status() != WL_CONNECTED) { + cancelCompanionWiFiNtp(true); + return; + } + + const uint32_t now = millis(); + if (companion_wifi_ntp_pending) { + const uint32_t proof_epoch = companion_wifi_ntp_epoch.load( + std::memory_order_acquire); + const uint32_t proof_generation = + companion_wifi_ntp_proof_generation.load( + std::memory_order_acquire); + const uint32_t proven_at = companion_wifi_ntp_proven_at.load( + std::memory_order_acquire); + if (proof_epoch != 0 && companion_wifi_ntp_operation.owns() + && proof_generation == companion_wifi_ntp_operation.generation() + && proven_at - companion_wifi_ntp_started <= + COMPANION_WIFI_NTP_TIMEOUT_MS) { + // The callback's epoch is freshness proof, not the value to install: + // the main loop may consume it one or more seconds later. Copy the + // current ESP system clock so an external RTC is not immediately + // written behind the time that SNTP established. + const time_t raw_system_time = time(nullptr); + if (mesh::tls_clock::timeIsValid(raw_system_time) + && static_cast(raw_system_time) <= UINT32_MAX) { + const uint32_t epoch = static_cast(raw_system_time); + const bool moved_backward = + epoch < companion_wifi_ntp_previous_epoch; + rtc_clock.setCurrentTime(epoch); + if (moved_backward) rtc_clock.resetUniqueTime(epoch); + the_mesh.noteInternetClockSet(); + + const int64_t adjustment = static_cast(epoch) + - static_cast(companion_wifi_ntp_previous_epoch); + mesh::usbLoggingPort().printf( + "Companion WiFi NTP: clock synchronized (%lu, adjustment %+lld seconds)\r\n", + (unsigned long)epoch, (long long)adjustment); + companion_wifi_ntp_pending = false; + companion_wifi_ntp_operation.release(); + companion_wifi_ntp_policy.noteSuccess(now); + return; + } + } + + if (now - companion_wifi_ntp_started >= + COMPANION_WIFI_NTP_TIMEOUT_MS) { + mesh::usbLoggingPort().println( + "Companion WiFi NTP: timed out; retrying in 5 minutes"); + companion_wifi_ntp_pending = false; + companion_wifi_ntp_operation.release(); + companion_wifi_ntp_policy.noteFailure(now); + } + return; + } + + if (!companion_wifi_ntp_policy.attemptDue(now)) return; + if (!companion_wifi_ntp_operation.tryAcquire()) { + companion_wifi_ntp_policy.noteBusy(now); + return; + } + + companion_wifi_ntp_epoch.store(0, std::memory_order_release); + companion_wifi_ntp_proven_at.store(0, std::memory_order_release); + companion_wifi_ntp_proof_generation.store(0, std::memory_order_release); + companion_wifi_ntp_operation_generation.store( + companion_wifi_ntp_operation.generation(), + std::memory_order_release); + companion_wifi_ntp_started = now; + companion_wifi_ntp_previous_epoch = rtc_clock.getCurrentTime(); + companion_wifi_ntp_pending = true; + esp_sntp_set_time_sync_notification_cb(noteCompanionWiFiNtpTime); + esp_sntp_set_sync_status(SNTP_SYNC_STATUS_RESET); + configTime(0, 0, "time.cloudflare.com", "time.google.com", + "pool.ntp.org"); + mesh::usbLoggingPort().println( + "Companion WiFi NTP: requesting fresh UTC time"); + } + static bool companionWiFiBluetoothActive() { -#if defined(BLE_PIN_CODE) +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) + const CompanionTransportMode active_mode = + companion_transport_boot_mode_loaded + ? companion_transport_boot_mode + : getCompanionTransportMode(); + return active_mode == CompanionTransportMode::Bluetooth; +#elif defined(BLE_PIN_CODE) return true; #else return false; @@ -932,6 +1145,22 @@ void halt() { } bool toggleCompanionWiFi() { +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) + const CompanionTransportMode current = getCompanionTransportMode(); + const CompanionTransportMode selected = + current == CompanionTransportMode::WiFi + ? CompanionTransportMode::Bluetooth + : CompanionTransportMode::WiFi; + if (!selectCompanionTransportMode(selected)) { + WIFI_DEBUG_PRINTLN( + "Companion transport selection could not be saved"); + return current == CompanionTransportMode::WiFi; + } + WIFI_DEBUG_PRINTLN( + "Companion transport %s saved; reboot required", + selected == CompanionTransportMode::WiFi ? "WiFi" : "Bluetooth"); + return selected == CompanionTransportMode::WiFi; +#else companion_wifi_requested = !companion_wifi_requested; the_mesh.getNodePrefs()->wifi_enabled = companion_wifi_requested ? 1 : 0; the_mesh.savePrefs(); @@ -941,6 +1170,7 @@ void halt() { WIFI_DEBUG_PRINTLN("BOOT/GPIO 0 click requested WiFi %s", companion_wifi_requested ? "on" : "off"); return companion_wifi_requested; +#endif } #if defined(WITH_WEBCONFIG) && defined(DISPLAY_CLASS) @@ -1330,6 +1560,7 @@ void halt() { } static bool finishStoppingCompanionWiFi() { + cancelCompanionWiFiNtp(false); stopCompanionWiFiServices(); #ifdef WITH_WEBCONFIG if (the_mesh.isWebConfigActiveOrStopping()) { @@ -1570,7 +1801,36 @@ void setup() { // platforms have no separate port, so this is a harmless no-op there. mesh::beginUsbLoggingPort(); -// Load WiFi state before bringing up either wireless stack. +// Lock the saved transport selection before bringing up either wireless stack. +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) + loadCompanionTransportModeForBoot(); + if (companionTransportWiFiActiveAtBoot()) { + // This boot will never initialize Bluetooth. Reclaim both the controller + // and host allocations before WiFi starts; the release is irreversible + // until reboot, which is why transport changes are next-boot-only. + releaseCompanionBluetoothMemoryForWiFi(); + } +#if defined(INDICATOR_TRANSPORT_RENDER_PROFILE) && defined(DISPLAY_CLASS) + if (disp != nullptr) { + const mesh::ui::IndicatorRenderProfile render_profile = + mesh::ui::selectIndicatorRenderProfile( +#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW + true, +#else + false, +#endif + companionTransportWiFiActiveAtBoot()); + const bool selected = disp->setRenderScale( + render_profile.coordinate_scale, render_profile.output_zoom); + mesh::usbLoggingPort().printf( + "Indicator render: %dx%d internal canvas%s\r\n", + disp->renderWidth(), disp->renderHeight(), + selected ? "" : " (requested profile unavailable; restored fallback)"); + } +#endif +#endif + +// Load WiFi credentials without starting the radio. #ifdef WIFI_SSID loadCompanionWiFiCredentials(); #endif @@ -1579,15 +1839,21 @@ void setup() { logFullCompanionMemory("before interfaces"); #endif -// NimBLE must reserve its internal controller/host memory before WiFi starts -// its AP and WebConfig services. Starting WiFi first can leave plenty of total -// heap but no internal block large enough for nimble_port_init(). +// Generic WiFi+BLE builds reserve NimBLE first. The Indicator exclusive +// profile instead starts Bluetooth only when it was selected for this boot. #if defined(BLE_PIN_CODE) && defined(ESP32) && defined(WIFI_SSID) +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) + if (!companionTransportWiFiActiveAtBoot()) startCompanionBluetooth(); +#else startCompanionBluetooth(); #endif +#endif // add wifi interface #ifdef WIFI_SSID +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) + if (companionTransportWiFiActiveAtBoot()) { +#endif WiFi.onEvent([](WiFiEvent_t event, WiFiEventInfo_t info){ if (event == ARDUINO_EVENT_WIFI_STA_DISCONNECTED) { #if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW @@ -1619,7 +1885,9 @@ void setup() { }); interface_manager.addInterface(InterfaceType::WiFi, &wifi_interface); +#if !defined(COMPANION_EXCLUSIVE_WIFI_BLE) companion_wifi_requested = the_mesh.getNodePrefs()->wifi_enabled != 0; +#endif if (companion_wifi_requested) { startCompanionWiFi(); } else { @@ -1627,6 +1895,9 @@ void setup() { board.setInhibitSleep(false); WIFI_DEBUG_PRINTLN("WiFi remains off from the saved BOOT/GPIO 0 setting"); } +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) + } +#endif #endif #if defined(ESP32_PLATFORM) && COMPANION_FEATURE_MEMORY_DIAGNOSTICS @@ -1937,10 +2208,19 @@ void loop() { } #ifdef WITH_MQTT_BRIDGE the_mesh.serviceMQTT(configured_wifi_ssid, configured_wifi_password); + serviceCompanionWiFiNtp(); +#else + serviceCompanionWiFiNtp(); #endif } #endif #if defined(BLE_PIN_CODE) +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) + if (!companionTransportWiFiActiveAtBoot()) { + serviceDeferredCompanionBluetooth(); + } +#else serviceDeferredCompanionBluetooth(); #endif +#endif } diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 398f9b7a2..237eb9186 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1,5 +1,9 @@ #include "UITask.h" #include +#include +#include +#include +#include #include "../MyMesh.h" #include "../CompanionWiFi.h" #include "target.h" @@ -7,6 +11,9 @@ #ifdef WIFI_SSID #include #endif +#if defined(ESP32) + #include +#endif #ifndef UI_TZ_OFFSET #define UI_TZ_OFFSET 0 @@ -34,6 +41,25 @@ #define LONG_PRESS_MILLIS 1200 +static uint64_t companionMessageNowMillis() { +#if defined(ESP32) + // Unlike Arduino millis(), this does not wrap after roughly 49.7 days. + return static_cast(esp_timer_get_time()) / 1000ULL; +#else + return static_cast(millis()); +#endif +} + +static uint64_t companionMessageElapsedMillis(uint64_t heard_millis) { +#if defined(ESP32) + return companionMessageNowMillis() - heard_millis; +#else + // Preserve Arduino's rollover-safe 32-bit subtraction on other platforms. + return static_cast(millis() + - static_cast(heard_millis)); +#endif +} + #ifndef UI_RECENT_LIST_SIZE #define UI_RECENT_LIST_SIZE 4 #endif @@ -44,6 +70,38 @@ #define PRESS_LABEL "long press" #endif +#ifdef COMPANION_EXCLUSIVE_WIFI_BLE +static void drawCompanionTransportChoice(DisplayDriver& display, + int x, int y, int width, int height, + const char* label, bool active, + bool selected) { + if (active) { + display.setColor(UIColor::corp_blue); + display.fillRect(x, y, width, height); + display.setColor(UIColor::window_bkg); + } else { + display.setColor(UIColor::secondary_txt); + display.drawRect(x, y, width, height); + display.setColor(UIColor::primary_txt); + } + + const bool large_transport_text = display.height() >= 96; + display.setTextSize(large_transport_text ? 2 : 1); + const bool show_status_label = height >= 44; + const int label_y = show_status_label + ? y + (large_transport_text ? 9 : 12) + : y + (height - 8) / 2; + display.drawTextCentered(x + width / 2, label_y, label); + if (show_status_label && (active || selected)) { + display.drawTextCentered( + x + width / 2, + y + height - (large_transport_text ? 23 : 17), + active ? "ACTIVE" + : (large_transport_text ? "NEXT" : "NEXT BOOT")); + } +} +#endif + #include "icons.h" class SplashScreen : public UIScreen { @@ -102,9 +160,16 @@ public: class HomeScreen : public UIScreen { enum HomePage { FIRST, +#if UI_MESSAGES_HOME_PAGE == 1 + MESSAGES, +#endif RECENT, RADIO, +#ifdef COMPANION_EXCLUSIVE_WIFI_BLE + TRANSPORT, +#else BLUETOOTH, +#endif ADVERT, #if ENV_INCLUDE_GPS == 1 GPS, @@ -258,6 +323,14 @@ public: void showFirstPage() { _page = HomePage::FIRST; } + bool isTransportSelectorPage() const { +#ifdef COMPANION_EXCLUSIVE_WIFI_BLE + return _page == HomePage::TRANSPORT; +#else + return false; +#endif + } + void poll() override { if (_shutdown_init && !_task->isButtonPressed()) { // must wait for USR button to be released _task->shutdown(); @@ -285,7 +358,10 @@ public: if (UIColor::title_bkg == UIColor::window_bkg) { display.setColor(UIColor::title_txt); } else { - display.setColor(UIColor::title_bkg); + // A dark title panel is only subtly different from a dark page. Use the + // high-contrast accent for these tiny dots; monochrome palettes take the + // equal-background branch above and retain their light text colour. + display.setColor(UIColor::corp_blue); } int y = 14; int x = display.width() / 2 - 5 * (HomePage::Count-1); @@ -302,52 +378,161 @@ public: display.setTextSize(2); sprintf(tmp, "INBOX: %d", _task->getPreviewCount()); display.drawTextCentered(display.width() / 2, 22, tmp); +#ifdef UI_DEDICATED_PAIRING_BLOCK + const mesh::ui::CompanionHomeLayout layout = + mesh::ui::makeLargeCompanionHomeLayout(display.width(), + display.height()); + + // These are distinct repaint regions. In particular, removing or + // replacing a pairing PIN cannot leave old glyphs behind, and long + // instruction/network strings cannot enter the pairing block. + display.setColor(UIColor::window_bkg); + mesh::ui::clearDisplayRegion(display, layout.info); + mesh::ui::clearDisplayRegion(display, layout.pairing); + display.setTextSize(1); display.setColor(UIColor::secondary_txt); - display.drawTextCentered(display.width() / 2, 43, - "tap center: inbox"); - - #ifdef UI_SHOW_CLOCK - display.setTextSize(3); - uint32_t now = _rtc->getCurrentTime(); - int8_t tz = UI_TZ_OFFSET; // for now draw time from Santo Domingo ... - now += (int32_t)tz * 3600; - DateTime dt (now); - sprintf(tmp, "%02d:%02d", dt.hour(), dt.minute()); - display.drawTextCentered(display.width() / 2, 60, tmp); - display.setTextSize(1); - sprintf(tmp, "%02d/%02d/%d", dt.day(), dt.month(), dt.year()); - display.drawTextCentered(display.width() / 2, 80, tmp); - #endif + mesh::ui::drawTextCenteredEllipsized( + display, layout.info, layout.instruction_y, "tap center: inbox"); + #ifdef WIFI_SSID if (isCompanionWiFiEnabled()) { IPAddress ip = WiFi.localIP(); - snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]); + snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", + ip[0], ip[1], ip[2], ip[3]); } else { strcpy(tmp, "WiFi: OFF"); } - display.setTextSize(1); - display.drawTextCentered(display.width() / 2, 54, tmp); + mesh::ui::drawTextCenteredEllipsized( + display, layout.info, layout.network_y, tmp); #endif - if (_task->hasBluetoothConnection()) { - display.setColor(UIColor::warning_txt); - display.setTextSize(1); - #ifdef UI_SHOW_CLOCK - display.drawTextCentered(display.width() / 2, 110, "< Connected >"); - #else - display.drawTextCentered(display.width() / 2, 43, "< Connected >"); - #endif - } else if (the_mesh.getBLEPin() != 0) { // BT pin - display.setColor(UIColor::warning_txt); - sprintf(tmp, "Pin:%d", the_mesh.getBLEPin()); - #ifdef UI_SHOW_CLOCK - display.setTextSize(1); - display.drawTextCentered(display.width() / 2, 110, tmp); - #else - display.setTextSize(2); - display.drawTextCentered(display.width() / 2, 43, tmp); - #endif + + const bool bluetooth_enabled = _task->isBluetoothEnabled(); + const bool bluetooth_connected = _task->hasBluetoothConnection(); + const uint32_t bluetooth_pin = the_mesh.getBLEPin(); + const char* pairing_label = nullptr; + const char* pairing_value = nullptr; + int pairing_value_size = 2; + char pairing_pin[16]; + if (bluetooth_connected) { + pairing_label = "BLUETOOTH"; + pairing_value = "CONNECTED"; + } else if (mesh::ui::shouldDisplayBluetoothPairingPin( + bluetooth_enabled, bluetooth_connected, bluetooth_pin)) { + pairing_label = "BLUETOOTH PIN"; + snprintf(pairing_pin, sizeof(pairing_pin), "%06u", + (unsigned int)bluetooth_pin); + pairing_value = pairing_pin; + pairing_value_size = 3; } + + if (pairing_value != nullptr) { + display.setColor(UIColor::title_bkg); + mesh::ui::clearDisplayRegion(display, layout.pairing); + display.setColor(UIColor::title_txt); + display.setTextSize(1); + mesh::ui::drawTextCenteredEllipsized( + display, layout.pairing, layout.pairing_label_y, pairing_label); + display.setTextSize(pairing_value_size); + mesh::ui::drawTextCenteredEllipsized( + display, layout.pairing, layout.pairing_value_y, pairing_value); + } +#else + const bool bluetooth_enabled = _task->isBluetoothEnabled(); + const bool bluetooth_connected = _task->hasBluetoothConnection(); + const uint32_t bluetooth_pin = the_mesh.getBLEPin(); + const bool show_bluetooth_pin = + mesh::ui::shouldDisplayBluetoothPairingPin( + bluetooth_enabled, bluetooth_connected, bluetooth_pin); + const bool compact_pairing = + mesh::ui::usesCompactCompanionPairingLayout( + display.width(), display.height()) + && (bluetooth_connected || show_bluetooth_pin); + + if (compact_pairing) { + const mesh::ui::CompactCompanionPairingLayout layout = + mesh::ui::makeCompactCompanionPairingLayout( + display.width(), display.height()); + char pairing_pin[16]; + const char* pairing_label = bluetooth_connected + ? "BLUETOOTH" : "BLUETOOTH PIN"; + const char* pairing_value = "CONNECTED"; + if (!bluetooth_connected) { + snprintf(pairing_pin, sizeof(pairing_pin), "%06u", + (unsigned int)bluetooth_pin); + pairing_value = pairing_pin; + } + + // This repaint deliberately removes the ordinary instruction and + // Wi-Fi/IP rows. Both occupy this same lower area on a 128x64 screen. + display.setColor(UIColor::title_bkg); + mesh::ui::clearDisplayRegion(display, layout.pairing); + display.setColor(UIColor::title_txt); + display.setTextSize(1); + mesh::ui::drawTextCenteredEllipsized( + display, layout.pairing, layout.pairing_label_y, pairing_label); + display.setTextSize(2); + mesh::ui::drawTextCenteredEllipsized( + display, layout.pairing, layout.pairing_value_y, pairing_value); + } else { + display.setTextSize(1); + display.setColor(UIColor::secondary_txt); + display.drawTextCentered(display.width() / 2, 43, + "tap center: inbox"); + + #ifdef UI_SHOW_CLOCK + display.setTextSize(3); + uint32_t now = _rtc->getCurrentTime(); + int8_t tz = UI_TZ_OFFSET; // for now draw time from Santo Domingo ... + now += (int32_t)tz * 3600; + DateTime dt (now); + sprintf(tmp, "%02d:%02d", dt.hour(), dt.minute()); + display.drawTextCentered(display.width() / 2, 60, tmp); + display.setTextSize(1); + sprintf(tmp, "%02d/%02d/%d", dt.day(), dt.month(), dt.year()); + display.drawTextCentered(display.width() / 2, 80, tmp); + #endif + + #ifdef WIFI_SSID + if (isCompanionWiFiEnabled()) { + IPAddress ip = WiFi.localIP(); + snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", + ip[0], ip[1], ip[2], ip[3]); + } else { + strcpy(tmp, "WiFi: OFF"); + } + display.setTextSize(1); + display.drawTextCentered(display.width() / 2, 54, tmp); + #endif + + if (bluetooth_connected) { + display.setColor(UIColor::warning_txt); + display.setTextSize(1); + #ifdef UI_SHOW_CLOCK + display.drawTextCentered(display.width() / 2, 110, + "< Connected >"); + #else + display.drawTextCentered(display.width() / 2, 43, + "< Connected >"); + #endif + } else if (show_bluetooth_pin) { // BT pin + display.setColor(UIColor::warning_txt); + snprintf(tmp, sizeof(tmp), "Pin:%06u", + (unsigned int)bluetooth_pin); + #ifdef UI_SHOW_CLOCK + display.setTextSize(1); + display.drawTextCentered(display.width() / 2, 110, tmp); + #else + display.setTextSize(2); + display.drawTextCentered(display.width() / 2, 43, tmp); + #endif + } + } +#endif +#if UI_MESSAGES_HOME_PAGE == 1 + } else if (_page == HomePage::MESSAGES) { + _task->renderMessageSummary(display); +#endif } else if (_page == HomePage::RECENT) { the_mesh.getRecentlyHeard(recent, UI_RECENT_LIST_SIZE); display.setColor(UIColor::primary_txt); @@ -397,6 +582,46 @@ public: snprintf(tmp, sizeof(tmp), "Noise floor: %.1f", noise_floor); } display.print(tmp); +#ifdef COMPANION_EXCLUSIVE_WIFI_BLE + } else if (_page == HomePage::TRANSPORT) { + const bool wifi_active = isCompanionWiFiEnabled(); + const bool wifi_selected = getCompanionTransportMode() + == CompanionTransportMode::WiFi; + const mesh::ui::CompanionTransportSelectorLayout layout = + mesh::ui::makeCompanionTransportSelectorLayout( + display.width(), display.height()); + + // Size 2 is already a substantial increase. Keep this page independent + // of the additional native-480 text boost so ACTIVE still fits inside + // either half-screen choice; the compact status strip above was drawn + // before this point and is unaffected. + display.setCompactText(true); + if (layout.show_title) { + display.setColor(UIColor::primary_txt); + display.setTextSize(2); + display.drawTextCentered( + display.width() / 2, layout.title_y, "TRANSPORT"); + } + drawCompanionTransportChoice( + display, layout.wifi.x, layout.wifi.y, + layout.wifi.width, layout.wifi.height, + "Wi-Fi", wifi_active, wifi_selected); + drawCompanionTransportChoice( + display, layout.bluetooth.x, layout.bluetooth.y, + layout.bluetooth.width, layout.bluetooth.height, + "BLE", !wifi_active, !wifi_selected); + + display.setColor(UIColor::secondary_txt); + display.setTextSize(display.height() >= 96 ? 2 : 1); +#ifdef HAS_TOUCH + display.drawTextCentered( + display.width() / 2, layout.prompt_y, "tap a box"); +#else + display.drawTextCentered( + display.width() / 2, layout.prompt_y, PRESS_LABEL); +#endif + display.setCompactText(false); +#else } else if (_page == HomePage::BLUETOOTH) { display.setColor(UIColor::corp_blue); display.drawXbm((display.width() - 32) / 2, 18, @@ -405,6 +630,7 @@ public: display.setColor(UIColor::secondary_txt); display.setTextSize(1); display.drawTextCentered(display.width() / 2, 64 - 11, "toggle: " PRESS_LABEL); +#endif } else if (_page == HomePage::ADVERT) { display.setColor(UIColor::corp_blue); display.drawXbm((display.width() - 32) / 2, 18, advert_icon, 32, 32); @@ -546,7 +772,11 @@ public: } #endif } - return _page == HomePage::RADIO ? UI_RADIO_REFRESH_MILLIS : 5000; + if (_page == HomePage::RADIO) return UI_RADIO_REFRESH_MILLIS; +#if UI_MESSAGES_HOME_PAGE == 1 + if (_page == HomePage::MESSAGES) return 1000; +#endif + return 5000; } bool handleInput(char c) override { @@ -561,6 +791,33 @@ public: } return true; } +#ifdef COMPANION_EXCLUSIVE_WIFI_BLE + if (_page == HomePage::TRANSPORT + && (c == KEY_ENTER || c == KEY_UP || c == KEY_DOWN)) { + const CompanionTransportMode selected = getCompanionTransportMode(); + const CompanionTransportMode active = isCompanionWiFiEnabled() + ? CompanionTransportMode::WiFi + : CompanionTransportMode::Bluetooth; + CompanionTransportMode requested = selected; + if (c == KEY_UP) { + requested = CompanionTransportMode::WiFi; + } else if (c == KEY_DOWN) { + requested = CompanionTransportMode::Bluetooth; + } else { + requested = active == CompanionTransportMode::WiFi + ? CompanionTransportMode::Bluetooth + : CompanionTransportMode::WiFi; + } + + if (requested != selected + && !selectCompanionTransportMode(requested)) { + _task->showAlert("Transport save failed", 1500); + return true; + } + if (requested != active) _task->shutdown(true); + return true; + } +#else if (c == KEY_ENTER && _page == HomePage::BLUETOOTH) { if (_task->isBluetoothEnabled()) { // toggle Bluetooth on/off _task->disableBluetooth(); @@ -569,10 +826,17 @@ public: } return true; } +#endif if (c == KEY_ENTER && _page == HomePage::FIRST) { _task->showMessages(); return true; } +#if UI_MESSAGES_HOME_PAGE == 1 + if (c == KEY_ENTER && _page == HomePage::MESSAGES) { + _task->showMessages(); + return true; + } +#endif if (c == KEY_ENTER && _page == HomePage::ADVERT) { _task->notify(UIEventType::ack); if (the_mesh.advert()) { @@ -608,27 +872,23 @@ public: #ifndef UI_MSG_PREVIEW_SIZE #define UI_MSG_PREVIEW_SIZE 78 #endif +#ifndef UI_COMPACT_MESSAGE_STATUS + #define UI_COMPACT_MESSAGE_STATUS 0 +#endif class MsgPreviewScreen : public UIScreen { UITask* _task; - mesh::RTCClock* _rtc; static constexpr int CHANNEL_FILTER_ALL = -2; static constexpr int CHANNEL_FILTER_DIRECT = -1; + static constexpr size_t MAX_RECENT_MESSAGES = 32; + using MessageHistory = mesh::ui::CompanionMessageHistory< + MAX_RECENT_MESSAGES, UI_MSG_PREVIEW_SIZE>; + using MsgEntry = MessageHistory::Entry; - struct MsgEntry { - uint32_t timestamp; - int channel_idx; - char channel_name[32]; - char origin[62]; - char msg[UI_MSG_PREVIEW_SIZE]; - }; - #define MAX_UNREAD_MSGS 32 - int num_unread; + MessageHistory history; int view_offset; int channel_filter; - int head = MAX_UNREAD_MSGS - 1; // index of latest unread message - MsgEntry unread[MAX_UNREAD_MSGS]; bool matchesFilter(const MsgEntry& entry) const { return channel_filter == CHANNEL_FILTER_ALL @@ -637,19 +897,19 @@ class MsgPreviewScreen : public UIScreen { int filteredCount() const { int count = 0; - for (int age = 0; age < num_unread; ++age) { - int index = (head + MAX_UNREAD_MSGS - age) % MAX_UNREAD_MSGS; - if (matchesFilter(unread[index])) ++count; + for (size_t age = 0; age < history.count(); ++age) { + const MsgEntry* entry = history.newest(age); + if (entry != nullptr && matchesFilter(*entry)) ++count; } return count; } const MsgEntry* filteredEntry(int offset) const { int match = 0; - for (int age = 0; age < num_unread; ++age) { - int index = (head + MAX_UNREAD_MSGS - age) % MAX_UNREAD_MSGS; - if (!matchesFilter(unread[index])) continue; - if (match++ == offset) return &unread[index]; + for (size_t age = 0; age < history.count(); ++age) { + const MsgEntry* entry = history.newest(age); + if (entry == nullptr || !matchesFilter(*entry)) continue; + if (match++ == offset) return entry; } return nullptr; } @@ -702,12 +962,12 @@ class MsgPreviewScreen : public UIScreen { snprintf(label, size, "Ch %d %s", channel_filter, details.name); return; } - for (int age = 0; age < num_unread; ++age) { - int index = (head + MAX_UNREAD_MSGS - age) % MAX_UNREAD_MSGS; - if (unread[index].channel_idx == channel_filter - && unread[index].channel_name[0] != 0) { + for (size_t age = 0; age < history.count(); ++age) { + const MsgEntry* entry = history.newest(age); + if (entry != nullptr && entry->channel_idx == channel_filter + && entry->channel_name[0] != 0) { snprintf(label, size, "Ch %d %s", channel_filter, - unread[index].channel_name); + entry->channel_name); return; } } @@ -715,7 +975,10 @@ class MsgPreviewScreen : public UIScreen { } void renderChannelFilter(DisplayDriver& display) const { - const int bar_height = 24; + const mesh::ui::CompanionMessageChromeLayout layout = + mesh::ui::makeCompanionMessageChromeLayout( + UI_COMPACT_MESSAGE_STATUS == 1); + const int bar_height = layout.filter_height; const int bar_y = display.height() - bar_height; display.setColor(UIColor::window_bkg); display.fillRect(0, bar_y, display.width(), bar_height); @@ -724,56 +987,111 @@ class MsgPreviewScreen : public UIScreen { char channel[48]; channelFilterLabel(channel, sizeof(channel)); + display.setCompactText(layout.compact_text); display.setTextSize(1); - const int text_y = bar_y + 8; + const int text_y = bar_y + layout.filter_text_offset; display.setCursor(8, text_y); display.print("<"); display.drawTextCentered(display.width() / 2, text_y, channel); display.setCursor(display.width() - display.getTextWidth(">") - 8, text_y); display.print(">"); + display.setCompactText(false); } public: - MsgPreviewScreen(UITask* task, mesh::RTCClock* rtc) - : _task(task), _rtc(rtc), num_unread(0), view_offset(0), - channel_filter(CHANNEL_FILTER_ALL) {} + explicit MsgPreviewScreen(UITask* task) + : _task(task), view_offset(0), channel_filter(CHANNEL_FILTER_ALL) {} - bool hasMessages() const { return num_unread > 0; } - int messageCount() const { return num_unread; } - - void clear() { - num_unread = 0; - view_offset = 0; - channel_filter = CHANNEL_FILTER_ALL; - } + bool hasMessages() const { return !history.empty(); } + int messageCount() const { return (int)history.count(); } void addPreview(uint8_t path_len, const char* from_name, const char* msg, int channel_idx, const char* channel_name) { - head = (head + 1) % MAX_UNREAD_MSGS; - if (num_unread < MAX_UNREAD_MSGS) num_unread++; + if (channel_idx < CHANNEL_FILTER_DIRECT + || channel_idx >= MAX_GROUP_CHANNELS) { + channel_idx = CHANNEL_FILTER_DIRECT; + channel_name = nullptr; + } view_offset = 0; channel_filter = channel_idx; - auto p = &unread[head]; - p->timestamp = _rtc->getCurrentTime(); - p->channel_idx = channel_idx; - StrHelper::strncpy(p->channel_name, - channel_name == nullptr ? "" : channel_name, - sizeof(p->channel_name)); + char origin[62]; if (path_len == 0xFF) { - snprintf(p->origin, sizeof(p->origin), "%s [direct]:", from_name); + snprintf(origin, sizeof(origin), "%s [direct]:", from_name); } else { - snprintf(p->origin, sizeof(p->origin), "%s [%uh]:", from_name, + snprintf(origin, sizeof(origin), "%s [%uh]:", from_name, (unsigned int)path_len); } - StrHelper::strncpy(p->msg, msg, sizeof(p->msg)); + history.add(companionMessageNowMillis(), channel_idx, channel_name, + origin, msg); + } + + void renderSummary(DisplayDriver& display) const { + const mesh::ui::CompanionMessageListLayout layout = + mesh::ui::makeCompanionMessageListLayout(display.height()); + int rendered = 0; + + display.setTextSize(1); + for (size_t age = 0; + age < history.count() && rendered < layout.visible_rows; + ++age) { + if (history.hasNewerEntryForThread(age)) continue; + const MsgEntry* entry = history.newest(age); + if (entry == nullptr) continue; + + const int row_y = layout.top + rendered * layout.row_height; + char label[48]; + MessageHistory::threadLabel(*entry, label, sizeof(label)); + char filtered_label[sizeof(label)]; + display.translateUTF8ToBlocks(filtered_label, label, + sizeof(filtered_label)); + + char age_text[16]; + mesh::ui::formatCompanionMessageAge( + age_text, sizeof(age_text), + companionMessageElapsedMillis(entry->heard_millis)); + const int age_width = display.getTextWidth(age_text); + int label_width = display.width() - age_width - 5; + if (label_width < 0) label_width = 0; + + display.setColor(UIColor::primary_txt); + display.drawTextEllipsized(0, row_y + layout.title_offset, + label_width, filtered_label); + display.setColor(UIColor::secondary_txt); + display.setCursor(display.width() - age_width - 1, + row_y + layout.title_offset); + display.print(age_text); + + char filtered_message[sizeof(entry->message)]; + display.translateUTF8ToBlocks(filtered_message, entry->message, + sizeof(filtered_message)); + mesh::ui::makeCompanionMessagePreviewSingleLine(filtered_message); + display.drawTextEllipsized(0, row_y + layout.preview_offset, + display.width() - 1, filtered_message); + + display.setColor(UIColor::title_bkg); + display.drawRect(0, row_y + layout.divider_offset, + display.width(), 1); + ++rendered; + } + + if (rendered == 0) { + display.setColor(UIColor::secondary_txt); + display.drawTextCentered(display.width() / 2, + layout.top + layout.row_height, + "No messages heard"); + } } int render(DisplayDriver& display) override { + const mesh::ui::CompanionMessageChromeLayout layout = + mesh::ui::makeCompanionMessageChromeLayout( + UI_COMPACT_MESSAGE_STATUS == 1); char tmp[24]; int filtered_count = filteredCount(); if (view_offset >= filtered_count) view_offset = 0; + display.setCompactText(layout.compact_text); display.setCursor(0, 0); display.setTextSize(1); display.setColor(UIColor::corp_blue); @@ -784,7 +1102,8 @@ public: const MsgEntry* p = filteredEntry(view_offset); if (p == nullptr) { - display.drawRect(0, 11, display.width(), 1); + display.drawRect(0, layout.header_divider_y, display.width(), 1); + display.setCompactText(false); display.setColor(UIColor::secondary_txt); display.drawTextCentered(display.width() / 2, 40, "No buffered messages"); @@ -792,29 +1111,24 @@ public: return 5000; } - int secs = _rtc->getCurrentTime() - p->timestamp; - if (secs < 60) { - sprintf(tmp, "%ds", secs); - } else if (secs < 60*60) { - sprintf(tmp, "%dm", secs / 60); - } else { - sprintf(tmp, "%dh", secs / (60*60)); - } + mesh::ui::formatCompanionMessageAge( + tmp, sizeof(tmp), companionMessageElapsedMillis(p->heard_millis)); display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0); display.print(tmp); - display.drawRect(0, 11, display.width(), 1); // horiz line + display.drawRect(0, layout.header_divider_y, display.width(), 1); + display.setCompactText(false); - display.setCursor(0, 14); + display.setCursor(0, layout.origin_y); display.setColor(UIColor::secondary_txt); char filtered_origin[sizeof(p->origin)]; display.translateUTF8ToBlocks(filtered_origin, p->origin, sizeof(filtered_origin)); display.print(filtered_origin); - display.setCursor(0, 25); + display.setCursor(0, layout.message_y); display.setColor(UIColor::primary_txt); - char filtered_msg[sizeof(p->msg)]; - display.translateUTF8ToBlocks(filtered_msg, p->msg, sizeof(filtered_msg)); + char filtered_msg[sizeof(p->message)]; + display.translateUTF8ToBlocks(filtered_msg, p->message, sizeof(filtered_msg)); display.printWordWrap(filtered_msg, display.width()); renderChannelFilter(display); @@ -890,7 +1204,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNode splash = new SplashScreen(this); home = new HomeScreen(this, &rtc_clock, sensors, node_prefs); - msg_preview = new MsgPreviewScreen(this, &rtc_clock); + msg_preview = new MsgPreviewScreen(this); setCurrScreen(splash); } @@ -917,6 +1231,10 @@ int UITask::getPreviewCount() const { return static_cast(msg_preview)->messageCount(); } +void UITask::renderMessageSummary(DisplayDriver& display) const { + static_cast(msg_preview)->renderSummary(display); +} + void UITask::notify(UIEventType t) { #if defined(PIN_BUZZER) switch(t){ @@ -954,7 +1272,6 @@ void UITask::msgRead(int msgcount) { const bool holding_usb_preview = curr == msg_preview && _msg_preview_until != 0 && static_cast(millis() - _msg_preview_until) < 0; if (!holding_usb_preview) { - static_cast(msg_preview)->clear(); gotoHomeScreen(); } } @@ -1021,12 +1338,15 @@ void UITask::setCurrScreen(UIScreen* c) { } bool UITask::isPairingScreenActive() const { - return _pairing_screen_until != 0 - && !hasBluetoothConnection() - && static_cast(millis() - _pairing_screen_until) < 0; + return mesh::ui::isBluetoothPairingPromptActive( + isBluetoothEnabled(), hasBluetoothConnection(), + static_cast(_pairing_screen_until), + static_cast(millis())); } void UITask::showPairingPin() { + if (!isBluetoothEnabled()) return; + const unsigned long now = millis(); _pairing_screen_until = now + BLE_PAIRING_DISPLAY_MILLIS; if (curr == msg_preview && _msgcount > 0) { @@ -1107,7 +1427,7 @@ void UITask::loop() { if (_pairing_screen_until != 0) { const bool timed_out = static_cast(millis() - _pairing_screen_until) >= 0; - if (hasBluetoothConnection() || timed_out) { + if (!isPairingScreenActive()) { finishPairingScreen(timed_out); } } @@ -1198,9 +1518,29 @@ void UITask::loop() { int touch_x = -1; int touch_y = -1; const bool touched = _display->getTouch(&touch_x, &touch_y); + mesh::ui::TouchSplitSelector transport_touch_selector = {}; + const mesh::ui::TouchSplitSelector* split_transport_selector = nullptr; +#ifdef COMPANION_EXCLUSIVE_WIFI_BLE + if (curr == home + && static_cast(home)->isTransportSelectorPage()) { + const mesh::ui::CompanionTransportSelectorLayout layout = + mesh::ui::makeCompanionTransportSelectorLayout( + _display->width(), _display->height()); + transport_touch_selector = { + layout.wifi.x, + layout.wifi.width, + layout.bluetooth.x, + layout.bluetooth.width, + layout.wifi.y, + layout.wifi.height, + }; + split_transport_selector = &transport_touch_selector; + } +#endif const mesh::ui::TouchAction action = touch_input.update( touched, touch_x, touch_y, _display->width(), _display->height(), - curr == msg_preview); + curr == msg_preview, split_transport_selector); + const bool on_transport_selector = split_transport_selector != nullptr; if (c == 0) { switch (action) { case mesh::ui::TouchAction::Previous: @@ -1212,12 +1552,18 @@ void UITask::loop() { case mesh::ui::TouchAction::Select: c = checkDisplayOn(KEY_ENTER); break; - case mesh::ui::TouchAction::VerticalPrevious: + case mesh::ui::TouchAction::SelectLeft: c = checkDisplayOn(KEY_UP); break; - case mesh::ui::TouchAction::VerticalNext: + case mesh::ui::TouchAction::SelectRight: c = checkDisplayOn(KEY_DOWN); break; + case mesh::ui::TouchAction::VerticalPrevious: + if (!on_transport_selector) c = checkDisplayOn(KEY_UP); + break; + case mesh::ui::TouchAction::VerticalNext: + if (!on_transport_selector) c = checkDisplayOn(KEY_DOWN); + break; case mesh::ui::TouchAction::None: break; } @@ -1260,7 +1606,6 @@ void UITask::loop() { if (_msgcount == 0 && _msg_preview_until != 0 && static_cast(millis() - _msg_preview_until) >= 0) { _msg_preview_until = 0; - static_cast(msg_preview)->clear(); if (curr == msg_preview) gotoHomeScreen(); } diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 73b623c85..333d17fee 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -95,6 +95,7 @@ public: _msg_preview_until = 0; _pairing_screen_until = 0; _deferred_msg_preview = false; + _msgcount = 0; ui_started_at = 0; curr = NULL; } @@ -106,6 +107,7 @@ public: void showAlert(const char* text, int duration_millis); int getMsgCount() const { return _msgcount; } int getPreviewCount() const; + void renderMessageSummary(DisplayDriver& display) const; bool hasDisplay() const { return _display != NULL; } bool supportsDisplayRotation() const override { return _display != NULL && _display->supportsRotation(); diff --git a/examples/companion_radio/ui-orig/UITask.cpp b/examples/companion_radio/ui-orig/UITask.cpp index 4df0564a9..07b5651e0 100644 --- a/examples/companion_radio/ui-orig/UITask.cpp +++ b/examples/companion_radio/ui-orig/UITask.cpp @@ -1,6 +1,7 @@ #include "UITask.h" #include #include +#include #include "../MyMesh.h" #define AUTO_OFF_MILLIS 15000 // 15 seconds @@ -287,11 +288,14 @@ void UITask::renderCurrScreen() { _display->print(tmp); // BT pin - if (!hasBluetoothConnection() && the_mesh.getBLEPin() != 0) { + const bool bluetooth_connected = hasBluetoothConnection(); + const uint32_t bluetooth_pin = the_mesh.getBLEPin(); + if (mesh::ui::shouldDisplayBluetoothPairingPin( + isBluetoothEnabled(), bluetooth_connected, bluetooth_pin)) { _display->setColor(UIColor::warning_txt); _display->setTextSize(2); _display->setCursor(0, 43); - sprintf(tmp, "Pin:%d", the_mesh.getBLEPin()); + sprintf(tmp, "Pin:%u", (unsigned int)bluetooth_pin); _display->print(tmp); _display->setColor(UIColor::primary_txt); } else { @@ -459,12 +463,15 @@ void UITask::shutdown(bool restart){ } bool UITask::isPairingScreenActive() const { - return _pairing_screen_until != 0 - && !hasBluetoothConnection() - && static_cast(millis() - _pairing_screen_until) < 0; + return mesh::ui::isBluetoothPairingPromptActive( + isBluetoothEnabled(), hasBluetoothConnection(), + static_cast(_pairing_screen_until), + static_cast(millis())); } void UITask::showPairingPin() { + if (!isBluetoothEnabled()) return; + const unsigned long now = millis(); _pairing_screen_until = now + BLE_PAIRING_DISPLAY_MILLIS; _need_refresh = true; @@ -494,7 +501,7 @@ void UITask::loop() { if (_pairing_screen_until != 0) { const bool timed_out = static_cast(millis() - _pairing_screen_until) >= 0; - if (hasBluetoothConnection() || timed_out) { + if (!isPairingScreenActive()) { finishPairingScreen(timed_out); } } diff --git a/examples/companion_radio/ui-tiny/UITask.cpp b/examples/companion_radio/ui-tiny/UITask.cpp index 378f06299..dbb70fa5d 100644 --- a/examples/companion_radio/ui-tiny/UITask.cpp +++ b/examples/companion_radio/ui-tiny/UITask.cpp @@ -1,5 +1,6 @@ #include "UITask.h" #include +#include #include "../MyMesh.h" #include "target.h" #include "u8g2_icons.h" @@ -184,15 +185,19 @@ public: display.setTextSize(1); display.drawTextCentered(display.width() / 2, 54, tmp); #endif - if (_task->hasBluetoothConnection()) { + const bool bluetooth_connected = _task->hasBluetoothConnection(); + const uint32_t bluetooth_pin = the_mesh.getBLEPin(); + if (bluetooth_connected) { display.setColor(UIColor::warning_txt); display.setTextSize(1); display.drawTextCentered(display.width() / 2, display.height()-8, "< Connected >"); - } else if (the_mesh.getBLEPin() != 0) { // BT pin + } else if (mesh::ui::shouldDisplayBluetoothPairingPin( + _task->isBluetoothEnabled(), bluetooth_connected, + bluetooth_pin)) { // BT pin display.setColor(UIColor::warning_txt); display.setTextSize(2); - sprintf(tmp, "Pin:%d", the_mesh.getBLEPin()); + sprintf(tmp, "Pin:%u", (unsigned int)bluetooth_pin); display.drawTextCentered(display.width() / 2, display.height()-8, tmp); } } else if (_page == HomePage::RECENT) { @@ -563,12 +568,15 @@ void UITask::setCurrScreen(UIScreen* c) { } bool UITask::isPairingScreenActive() const { - return _pairing_screen_until != 0 - && !hasBluetoothConnection() - && static_cast(millis() - _pairing_screen_until) < 0; + return mesh::ui::isBluetoothPairingPromptActive( + isBluetoothEnabled(), hasBluetoothConnection(), + static_cast(_pairing_screen_until), + static_cast(millis())); } void UITask::showPairingPin() { + if (!isBluetoothEnabled()) return; + const unsigned long now = millis(); _pairing_screen_until = now + BLE_PAIRING_DISPLAY_MILLIS; static_cast(home)->showFirstPage(); @@ -633,7 +641,7 @@ void UITask::loop() { if (_pairing_screen_until != 0) { const bool timed_out = static_cast(millis() - _pairing_screen_until) >= 0; - if (hasBluetoothConnection() || timed_out) { + if (!isPairingScreenActive()) { finishPairingScreen(timed_out); } } diff --git a/examples/host_cli_service/README.md b/examples/host_cli_service/README.md index 943a2841e..f3f15c05d 100644 --- a/examples/host_cli_service/README.md +++ b/examples/host_cli_service/README.md @@ -12,14 +12,20 @@ host uptime host load host memory host disk-free +host clock status +host clock sync +host clock set +host network restart host reboot +host action status host run [arguments] ``` The status commands read bounded operating-system APIs or virtual files. The service never invokes a shell and does not accept arbitrary commands, file paths, or executable names. Locally configured program aliases can accept -strictly validated arguments. Host reboot is disabled by default. +strictly validated arguments. Network restart, host reboot, and clock changes +are disabled by default; clock status remains read-only. Normal repeater firmware includes the host bridge. The specialized Wio-E5 RS232 bridge image omits it to stay within that board's fixed 240 KiB @@ -40,6 +46,12 @@ Two processes must not read the same serial TTY. The MQTT endpoint avoids that race and can run on the same Pi as the broker and `meshcoretomqtt`, or on a different host. +For **clock recovery**, keep the broker, `meshcoretomqtt`, and this endpoint on +the same Pi and use the loopback broker address. A wrong Pi clock can break TLS +to a remote broker, and split hosts can reject the signed claim before the +clock is repaired. Other read-only host commands may use a remote broker when +the clocks and TLS connection are already healthy. + The current `meshcoretomqtt` release publishes repeater `DEBUG` records only when it is launched with `--debug`. Add `--debug` to its existing systemd `ExecStart` command and restart it. Keep any existing `--config` arguments. @@ -97,19 +109,166 @@ For TLS, add `--tls`, and optionally `--ca-cert FILE`. If the debug or serial command topic is customized in `meshcoretomqtt`, pass the matching `--request-topic` or `--command-topic` value. -To enable the exact `host reboot` request, give only `systemctl reboot` to the -service account in `/etc/sudoers.d/meshcore-host-cli`: +## Opt-in host recovery actions -```text -mctomqtt ALL=(root) NOPASSWD: /usr/bin/systemctl reboot +Network recovery and reboot use a separate socket-activated root broker. They +do not use `sudo`, and installing the clock-control broker does not grant these +actions. Install the broker and its fixed, static action units as root-owned +files: + +```sh +sudo install -o root -g root -m 0755 meshcore_host_actions.py \ + /usr/local/sbin/meshcore-host-actions +sudo install -o root -g root -m 0644 meshcore-host-actions.socket \ + /etc/systemd/system/meshcore-host-actions.socket +sudo install -o root -g root -m 0644 meshcore-host-actions.service \ + /etc/systemd/system/meshcore-host-actions.service +sudo install -o root -g root -m 0644 meshcore-networkmanager-restart.service \ + /etc/systemd/system/meshcore-networkmanager-restart.service +sudo install -o root -g root -m 0644 meshcore-host-reboot.timer \ + /etc/systemd/system/meshcore-host-reboot.timer +sudo install -o root -g root -m 0644 meshcore-host-reboot.service \ + /etc/systemd/system/meshcore-host-reboot.service ``` -Set that file to mode `440`, then add `--allow-reboot` to the endpoint command. -The endpoint publishes `OK - host reboot scheduled in 5s`, waits for the MQTT -publish, and then runs the fixed argv -`/usr/bin/sudo -n /usr/bin/systemctl reboot` after five seconds. Change the -delay from 3 through 60 seconds with `--reboot-delay`; no LoRa text is ever -inserted into the process invocation. Do not add a broad sudo wildcard. +The broker's root-owned policy is empty by default. Opt in to either action or +both with a systemd drop-in; only these two exact values are valid: + +```sh +sudo systemctl edit meshcore-host-actions.service +``` + +```ini +[Service] +Environment=MESHCORE_HOST_ACTIONS=network-restart,reboot +``` + +Then activate the socket: + +```sh +sudo systemctl daemon-reload +sudo systemctl enable --now meshcore-host-actions.socket +``` + +After changing the policy on an already active installation, restart +`meshcore-host-actions.service` so the root broker reads the new value. + +Add `--allow-network-restart`, `--allow-reboot`, or both to the unprivileged +endpoint. Both the endpoint flag and the root-owned broker policy must allow an +action. The socket is fixed at `/run/meshcore-host-actions.sock`, owned by +`root:mctomqtt`, and mode `0660`. The broker requires the endpoint's exact UID +and primary GID through `SO_PEERCRED`; the endpoint requires a root-created +listener. It accepts one bounded ASCII line and can start only the fixed +`meshcore-networkmanager-restart.service` or +`meshcore-host-reboot.timer`. All action units have empty capability sets and +systemd sandboxing. No request text becomes an executable, unit, path, or +argument. + +The configured `mctomqtt` UID with `mctomqtt` as its effective primary group +is the delegated local trust boundary. Merely having `mctomqtt` as a +supplementary group is rejected. + +For a new operation the endpoint sends `PREPARE` first, publishes the LoRa +reply through MQTT QoS 1, waits for local MQTT publish confirmation, and only +then sends one `COMMIT`. The reboot commit starts a fixed approximately +10-second timer. If publish confirmation is absent, the action is not +committed. The MQTT acknowledgement confirms acceptance by the local broker; +there is no correlated serial/LoRa delivery acknowledgement, so physical reply +delivery remains best effort. + +`network restart` intentionally interrupts Wi-Fi and Tailscale management while +NetworkManager restarts. Keep the MQTT broker on loopback, and ensure the +broker, `meshcoretomqtt`, host endpoint, and USB device services do not use +`Requires=`, `BindsTo=`, or ordering dependencies on NetworkManager. That local +USB/MQTT path must remain up long enough to accept the reply and commit, even +though remote management temporarily disappears. + +Each reply names a canonical 32-hex operation ID derived from the authenticated +repeater identity plus request ID and nonce. Check a result with: + +```text +cmd host action status 0123456789ABCDEF0123456789ABCDEF +``` + +The root broker keeps at most 64 per-boot operation records. A committed action +is scheduled at most once. `scheduled` means PID 1 accepted the fixed unit job, +not that the later network restart or reboot completed. A service restart +converts a recovered +`in-progress` record to `ambiguous`; an ambiguous or timed-out operation is +never automatically retried. While a reboot is committed or ambiguous, a +different reboot is rejected instead of starting or promising a fresh timer. +A new reboot reservation may atomically supersede an older, uncommitted reboot +reservation; the older operation can no longer be committed. Only old, +uncommitted `prepared` records may be evicted. If no safe record can be +evicted, new actions fail closed as `state full`. + +Remove the legacy `wifi-restart` program alias and every legacy host-action +entry from `sudoers`. Use the exact `host network restart` action instead. This +endpoint never invokes `sudo`, and sudo command aliases do not provide a +fallback under its hardened service configuration. + +## Clock status and recovery + +`host clock status` reports the Pi epoch and NTP synchronization state without +requiring root. To opt in to clock changes, install the independently +validating socket service and its systemd units. The executable must remain +root-owned, and the endpoint must run with `mctomqtt` as both its user and +primary group: + +```sh +sudo install -o root -g root -m 0755 meshcore_clock_control.py \ + /usr/local/sbin/meshcore-clock-control +sudo install -o root -g root -m 0644 meshcore-clock-control.socket \ + /etc/systemd/system/meshcore-clock-control.socket +sudo install -o root -g root -m 0644 meshcore-clock-control.service \ + /etc/systemd/system/meshcore-clock-control.service +sudo install -o root -g root -m 0644 meshcore-chrony-step.service \ + /etc/systemd/system/meshcore-chrony-step.service +sudo systemctl daemon-reload +sudo systemctl enable --now meshcore-clock-control.socket +``` + +The socket unit creates exactly +`/run/meshcore-clock-control.sock` as `root:mctomqtt` with mode `0660`. The +root service starts on the first request and accepts only a process whose +Linux `SO_PEERCRED` UID and primary GID both resolve to the configured +`mctomqtt:mctomqtt` identity. The endpoint independently checks the socket's +type, owner, group, and mode, then requires the connected server's +`SO_PEERCRED` UID to be root. It does not use `sudo` for clock control. Remove +any older clock-helper entries from `/etc/sudoers.d/meshcore-host-cli`. + +Add `--allow-clock-control` to the endpoint. The available requests are: + +```text +cmd host clock status +cmd host clock sync +cmd host clock set 1788147000 +``` + +The endpoint and root service both require a canonical unsigned decimal epoch, +with no sign, leading zero, whitespace, or trailing argument. Values are +limited to 2020 through 2099. The private protocol permits only one bounded +ASCII line: `sync` or `set `. Extra lines, control characters, partial +lines, oversized messages, unrecognized replies, and a reply for the wrong +epoch all fail closed. Every child operation uses a fixed absolute argv, +`shell=False`, and a timeout of at most 1.5 seconds. + +`clock set` never disables NTP: it sets the epoch, then requests an NTP step. +If setting the clock succeeds but NTP recovery fails, the service reports a +partial result rather than incorrectly reporting total failure. Likewise, if +`clock sync` enables NTP but its explicit step/restart fails, the reply says +that NTP was enabled and names the remaining failure. When chrony is installed, +the root broker starts only the fixed, static +`meshcore-chrony-step.service`. That hardened one-shot runs only +`/usr/bin/chronyc -a makestep` as `_chrony:_chrony`, has no capabilities, +permits only `AF_UNIX`, and has its own one-second start timeout. The `_chrony` +account is supplied by the Debian/Raspberry Pi chrony package. Without chrony, +the broker restarts systemd-timesyncd. + +Clock commands still require the signed LoRa request and live one-time claim. +Their replay protection uses random nonces and monotonic claim deadlines, not a +tight comparison between the repeater clock and Pi clock. The final JWT is +created and checked by processes on the same Pi. ## Allowlisted programs with arguments @@ -193,10 +352,12 @@ record cannot match the endpoint's new random challenge, and a service restart forgets all outstanding challenges. These replay properties do not use wall clock time. The final response still travels in a short-lived JWT whose signature, signer allowlist, target, expiration, and independent nonce are -checked by `meshcoretomqtt`. When both processes run on the same Pi, that JWT -check naturally uses the same host clock; if they run on different computers, -those two computers need compatible clocks. Repeater-to-Pi clock drift remains -irrelevant. +checked by `meshcoretomqtt`. Its wall-clock expiry and in-memory nonce cache are +not by themselves safe across a backward clock jump plus restart. The +firmware's live one-time claim protects these host actions from that rollback. +For recovery deployments, keep `allowed_companions` limited to this dedicated +service key, and run the endpoint and `meshcoretomqtt` on the same Pi so the +claim JWT uses one clock. Repeater-to-Pi clock drift remains irrelevant. Request text is Base64URL encoded before it enters the line-oriented USB stream. Reply controls such as carriage return and newline are converted to spaces, and @@ -214,8 +375,11 @@ debug topic and publish to this repeater's `serial/commands` topic. companion uses its legacy three-byte correlation prefix. - LoRa reply: 162 UTF-8 bytes total, including that correlation prefix. - One request can be pending per repeater. -- Live-claim deadline: 4 seconds, followed by up to 6 seconds for execution and - the reply. +- Only one privileged action or action-status query can be active; overlapping + work is rejected before it is reserved. +- Live-claim deadline: 4 seconds. Allowlisted programs have a configured 1-5 + second child-process timeout. Privileged actions and status queries share + one 5-second broker/reply-confirmation deadline. - `host.reply` is accepted only from the physical USB serial command path, not from LoRa or the Ethernet CLI. diff --git a/examples/host_cli_service/host_cli_service.py b/examples/host_cli_service/host_cli_service.py index 1ddf760d1..fd6cf9b38 100755 --- a/examples/host_cli_service/host_cli_service.py +++ b/examples/host_cli_service/host_cli_service.py @@ -14,17 +14,20 @@ import argparse import base64 from collections import OrderedDict from dataclasses import dataclass +import hashlib import hmac import json import logging import os from pathlib import Path +import queue import re import secrets import shlex import shutil import socket import stat +import struct import subprocess import sys import threading @@ -39,6 +42,34 @@ MAX_REQUEST_BYTES = 155 MAX_REQUEST_ENCODED_BYTES = (MAX_REQUEST_BYTES * 4 + 2) // 3 MAX_REPLY_BYTES = 162 CHALLENGE_TIMEOUT_SECONDS = 4.0 +MIN_CLOCK_EPOCH = 1577836800 # 2020-01-01T00:00:00Z +MAX_CLOCK_EPOCH = 4102444799 # 2099-12-31T23:59:59Z +CLOCK_CONTROL_SOCKET = Path("/run/meshcore-clock-control.sock") +CLOCK_CONTROL_TIMEOUT_SECONDS = 5.0 +CLOCK_CONTROL_MAX_RESPONSE_BYTES = 192 +CLOCK_STATUS_CHILD_TIMEOUT_SECONDS = 1.5 +HOST_ACTIONS_SOCKET = Path("/run/meshcore-host-actions.sock") +HOST_ACTION_TOTAL_TIMEOUT_SECONDS = 5.0 +HOST_ACTION_PUBLISH_TIMEOUT_SECONDS = 2.0 +HOST_ACTION_MAX_RESPONSE_BYTES = 128 +HOST_ACTION_QUEUE_DEPTH = 1 +HOST_ACTION_REBOOT_DELAY_SECONDS = 10 +HOST_ACTION_OPERATION_RE = re.compile(r"[0-9A-F]{32}") +HOST_ACTION_NAMES = frozenset(("network-restart", "reboot")) +HOST_ACTION_ERROR_RESPONSES = frozenset( + ( + "ERR action disabled", + "ERR operation not prepared", + "ERR reboot pending", + "ERR state full", + "ERR state unavailable", + "ERR operation conflict", + "ERR invalid request", + "ERR request timed out", + "ERR unauthorized peer", + "ERR server error", + ) +) REQUEST_RE = re.compile( r"^DEBUG (HOSTCLI/1 REQUEST " r"([0-9A-Fa-f]{8}) " @@ -89,7 +120,15 @@ class ServiceKey: @dataclass(frozen=True) class HostResult: text: str - reboot_requested: bool = False + host_action: str | None = None + operation_id: str | None = None + + +@dataclass(frozen=True) +class VerifiedHostAction: + request: HostRequest + action: str + operation_id: str @dataclass(frozen=True) @@ -393,6 +432,372 @@ def read_disk_free(path: Path) -> str: return f"Disk {usage.free / gib:.1f}/{usage.total / gib:.1f} GiB free" +def read_clock_status() -> str: + epoch = int(time.time()) + synchronized = "unknown" + try: + completed = subprocess.run( + [ + "/usr/bin/timedatectl", "show", + "--property=NTPSynchronized", "--value", + ], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + encoding="utf-8", + errors="replace", + timeout=CLOCK_STATUS_CHILD_TIMEOUT_SECONDS, + check=False, + shell=False, + cwd="/", + env={"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C.UTF-8"}, + ) + value = completed.stdout.strip().lower() + if completed.returncode == 0 and value in ("yes", "no"): + synchronized = value + except (OSError, subprocess.SubprocessError): + pass + # systemd's NTPSynchronized marker can remain "no" when chrony owns the + # clock. Prefer chrony's own bounded tracking state in that case instead of + # falsely reporting an unsynchronized recovery host. + if synchronized != "yes": + chronyc = Path("/usr/bin/chronyc") + if chronyc.is_file() and os.access(chronyc, os.X_OK): + try: + tracking = subprocess.run( + [str(chronyc), "-n", "tracking"], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + encoding="utf-8", + errors="replace", + timeout=CLOCK_STATUS_CHILD_TIMEOUT_SECONDS, + check=False, + shell=False, + cwd="/", + env={ + "PATH": "/usr/sbin:/usr/bin:/sbin:/bin", + "LANG": "C.UTF-8", + }, + ) + leap = re.search( + r"^\s*Leap status\s*:\s*(.*?)\s*$", + tracking.stdout, + flags=re.IGNORECASE | re.MULTILINE, + ) + if tracking.returncode == 0 and leap is not None: + state = leap.group(1).casefold() + if state == "normal": + synchronized = "yes (chrony)" + elif state in ("not synchronised", "not synchronized"): + synchronized = "no (chrony)" + except (OSError, subprocess.SubprocessError): + pass + return f"Clock epoch {epoch}; NTP synchronized {synchronized}" + + +def validate_clock_control_socket( + path: Path = CLOCK_CONTROL_SOCKET, + *, + expected_gid: int | None = None, +) -> None: + """Require the systemd socket's documented privilege boundary.""" + try: + metadata = path.lstat() + except OSError as exc: + raise ValueError(f"host clock control socket is missing: {path}") from exc + if not stat.S_ISSOCK(metadata.st_mode): + raise ValueError(f"host clock control path must be a socket: {path}") + if metadata.st_uid != 0: + raise ValueError(f"host clock control socket must be root-owned: {path}") + if stat.S_IMODE(metadata.st_mode) != 0o660: + raise ValueError(f"host clock control socket must have mode 0660: {path}") + if expected_gid is not None and metadata.st_gid != expected_gid: + raise ValueError( + "host clock control socket group must match the endpoint's " + f"primary group: {path}" + ) + + +def _remaining_clock_control_time(deadline: float) -> float: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("clock control deadline expired") + return remaining + + +def _read_clock_control_response( + connection: socket.socket, + deadline: float, +) -> str: + response = bytearray() + while True: + connection.settimeout(_remaining_clock_control_time(deadline)) + chunk = connection.recv(CLOCK_CONTROL_MAX_RESPONSE_BYTES + 1 - len(response)) + if not chunk: + break + response.extend(chunk) + if len(response) > CLOCK_CONTROL_MAX_RESPONSE_BYTES: + raise ValueError("clock control response is too long") + if not response or not response.endswith(b"\n") or response.count(b"\n") != 1: + raise ValueError("clock control response framing is invalid") + try: + line = response[:-1].decode("ascii", errors="strict") + except UnicodeDecodeError as exc: + raise ValueError("clock control response is not ASCII") from exc + if not line or any(ord(character) < 0x20 for character in line): + raise ValueError("clock control response contains control characters") + return line + + +def _exchange_clock_control(request: str) -> str: + """Send one exact request to the fixed, root-authenticated local socket.""" + request_bytes = (request + "\n").encode("ascii") + deadline = time.monotonic() + CLOCK_CONTROL_TIMEOUT_SECONDS + validate_clock_control_socket( + CLOCK_CONTROL_SOCKET, + expected_gid=os.getegid(), + ) + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection: + connection.settimeout(_remaining_clock_control_time(deadline)) + connection.connect(str(CLOCK_CONTROL_SOCKET)) + credentials = connection.getsockopt( + socket.SOL_SOCKET, + socket.SO_PEERCRED, + struct.calcsize("3i"), + ) + _server_pid, server_uid, _server_gid = struct.unpack("3i", credentials) + if server_uid != 0: + raise PermissionError("clock control peer is not root") + connection.settimeout(_remaining_clock_control_time(deadline)) + connection.sendall(request_bytes) + connection.shutdown(socket.SHUT_WR) + return _read_clock_control_response(connection, deadline) + + +def _clock_control_result( + wire_response: str, + action: str, + epoch: int | None, +) -> HostResult: + common_failures = { + "invalid request", + "request timed out", + "unauthorized peer", + "server error", + } + if wire_response.startswith("ERR "): + detail = wire_response[4:] + action_failures = ( + {"NTP enable request failed"} + if action == "sync" + else {"clock set failed"} + ) + if detail not in common_failures | action_failures: + return HostResult("Err - invalid clock control response") + return HostResult(f"Err - {detail}") + if action == "sync": + if wire_response == "OK NTP sync requested": + return HostResult("OK - NTP sync requested") + partial = re.fullmatch( + r"PARTIAL NTP enabled; (chrony step request failed|" + r"systemd-timesyncd restart failed)", + wire_response, + ) + if partial is not None: + return HostResult(f"Warning - NTP enabled; {partial.group(1)}") + return HostResult("Err - invalid clock control response") + + assert epoch is not None + if wire_response == f"OK clock set to {epoch}; NTP sync requested": + return HostResult(f"OK - clock set to {epoch}; NTP sync requested") + ntp_enable_failure = ( + f"PARTIAL clock set to {epoch}; NTP enable request failed" + ) + if wire_response == ntp_enable_failure: + return HostResult( + f"Warning - clock set to {epoch}; NTP enable request failed" + ) + for failure in ( + "chrony step request failed", + "systemd-timesyncd restart failed", + ): + if wire_response == ( + f"PARTIAL clock set to {epoch}; NTP enabled; {failure}" + ): + return HostResult( + f"Warning - clock set to {epoch}; NTP enabled; {failure}" + ) + return HostResult("Err - invalid clock control response") + + +def run_clock_control(action: str, epoch: int | None = None) -> HostResult: + if action not in ("set", "sync"): + return HostResult("Err - unsupported clock action") + if action == "set": + if epoch is None or not MIN_CLOCK_EPOCH <= epoch <= MAX_CLOCK_EPOCH: + return HostResult("Err - clock epoch must be from 2020 through 2099") + request = f"set {epoch}" + else: + if epoch is not None: + return HostResult("Err - invalid clock sync request") + request = "sync" + try: + response = _exchange_clock_control(request) + except (TimeoutError, socket.timeout): + return HostResult("Err - clock control timed out") + except PermissionError: + return HostResult("Err - clock control peer authentication failed") + except (OSError, ValueError): + return HostResult("Err - clock control unavailable") + return _clock_control_result(response, action, epoch) + + +def validate_host_actions_socket( + path: Path = HOST_ACTIONS_SOCKET, + *, + expected_gid: int, +) -> None: + try: + metadata = path.lstat() + except OSError as exc: + raise ValueError(f"host actions socket is missing: {path}") from exc + if not stat.S_ISSOCK(metadata.st_mode): + raise ValueError(f"host actions path must be a socket: {path}") + if metadata.st_uid != 0: + raise ValueError(f"host actions socket must be root-owned: {path}") + if stat.S_IMODE(metadata.st_mode) != 0o660: + raise ValueError(f"host actions socket must have mode 0660: {path}") + if metadata.st_gid != expected_gid: + raise ValueError( + "host actions socket group must match the endpoint's primary group" + ) + + +def _remaining_host_action_time(deadline: float) -> float: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("host action deadline expired") + return remaining + + +def _read_host_action_response( + connection: socket.socket, + deadline: float, +) -> str: + response = bytearray() + while True: + connection.settimeout(_remaining_host_action_time(deadline)) + chunk = connection.recv(HOST_ACTION_MAX_RESPONSE_BYTES + 1 - len(response)) + if not chunk: + break + response.extend(chunk) + if len(response) > HOST_ACTION_MAX_RESPONSE_BYTES: + raise ValueError("host action response is too long") + if not response.endswith(b"\n") or response.count(b"\n") != 1: + raise ValueError("host action response framing is invalid") + try: + line = response[:-1].decode("ascii", errors="strict") + except UnicodeDecodeError as exc: + raise ValueError("host action response is not ASCII") from exc + if any(ord(character) < 0x20 or ord(character) == 0x7F for character in line): + raise ValueError("host action response contains control characters") + return line + + +def _validate_host_action_response( + response: str, + verb: str, + action: str | None, + operation_id: str, +) -> str: + if response in HOST_ACTION_ERROR_RESPONSES: + return response + if verb == "status" and response == f"UNKNOWN {operation_id}": + return response + states = ( + ("PREPARED", "IN-PROGRESS", "SCHEDULED", "AMBIGUOUS") + if verb in ("prepare", "status") + else ("IN-PROGRESS", "SCHEDULED", "AMBIGUOUS") + ) + actions = HOST_ACTION_NAMES if verb == "status" else (action,) + if any( + response == f"{state_name} {candidate_action} {operation_id}" + for state_name in states + for candidate_action in actions + ): + return response + raise ValueError("invalid host action response") + + +def exchange_host_action( + verb: str, + operation_id: str, + action: str | None = None, + *, + deadline: float | None = None, +) -> str: + if HOST_ACTION_OPERATION_RE.fullmatch(operation_id) is None: + raise ValueError("invalid host action operation ID") + if verb == "status": + if action is not None: + raise ValueError("status must not include an action") + request = f"status {operation_id}\n" + elif verb in ("prepare", "commit") and action in HOST_ACTION_NAMES: + request = f"{verb} {action} {operation_id}\n" + else: + raise ValueError("invalid host action request") + deadline = ( + time.monotonic() + HOST_ACTION_TOTAL_TIMEOUT_SECONDS + if deadline is None else deadline + ) + validate_host_actions_socket( + HOST_ACTIONS_SOCKET, + expected_gid=os.getegid(), + ) + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection: + connection.settimeout(_remaining_host_action_time(deadline)) + connection.connect(str(HOST_ACTIONS_SOCKET)) + encoded_credentials = connection.getsockopt( + socket.SOL_SOCKET, + socket.SO_PEERCRED, + struct.calcsize("3i"), + ) + _pid, peer_uid, _peer_gid = struct.unpack("3i", encoded_credentials) + # With socket activation this authenticates the root-created listening + # socket (normally PID 1), while the root-owned path protects connect(). + if peer_uid != 0: + raise PermissionError("host action peer is not root") + connection.settimeout(_remaining_host_action_time(deadline)) + connection.sendall(request.encode("ascii")) + connection.shutdown(socket.SHUT_WR) + response = _read_host_action_response(connection, deadline) + return _validate_host_action_response( + response, verb, action, operation_id + ) + + +def host_action_operation_id( + repeater_public_key: str, + request: HostRequest, +) -> str: + normalized_key = normalize_key( + repeater_public_key, 32, "repeater public key" + ) + if re.fullmatch(r"[0-9A-Fa-f]{8}", request.request_id) is None: + raise ValueError("invalid host request ID") + if re.fullmatch(r"[0-9A-Fa-f]{16}", request.request_nonce) is None: + raise ValueError("invalid host request nonce") + digest = hashlib.sha256() + digest.update(b"MeshCore HOSTCLI/1 host action operation\x00") + digest.update(bytes.fromhex(normalized_key)) + digest.update(bytes.fromhex(request.request_id)) + digest.update(bytes.fromhex(request.request_nonce)) + return digest.digest()[:16].hex().upper() + + def _safe_config_text(value: Any, label: str, max_bytes: int = 128) -> str: if not isinstance(value, str) or not value: raise ValueError(f"{label} must be a nonempty string") @@ -635,7 +1040,8 @@ def handle_request( temperature_path: Path, *, allow_reboot: bool = False, - reboot_delay: int = 5, + allow_network_restart: bool = False, + allow_clock_control: bool = False, uptime_path: Path = Path("/proc/uptime"), load_path: Path = Path("/proc/loadavg"), memory_path: Path = Path("/proc/meminfo"), @@ -648,7 +1054,9 @@ def handle_request( if request_text == "help": program_aliases = ",".join(sorted(programs or {})) or "off" return HostResult( - "Commands: cpu-temp,hostname,uptime,load,memory,disk-free; " + "Commands: cpu-temp,hostname,uptime,load,memory,disk-free,clock; " + f"clock-control={'on' if allow_clock_control else 'off'}; " + f"network={'on' if allow_network_restart else 'off'}; " f"reboot={'on' if allow_reboot else 'off'}; run={program_aliases}" ) if request_text == "cpu-temp": @@ -663,50 +1071,44 @@ def handle_request( return HostResult(read_memory(memory_path)) if request_text == "disk-free": return HostResult(read_disk_free(disk_path)) + if request_text == "clock status": + return HostResult(read_clock_status()) + if request_text == "clock sync": + if not allow_clock_control: + return HostResult("Err - host clock control is disabled") + return run_clock_control("sync") + if request_text.startswith("clock set"): + match = re.fullmatch(r"clock set (0|[1-9][0-9]*)", request_text) + if match is None: + return HostResult("Err - use: clock set ") + epoch = int(match.group(1)) + if not MIN_CLOCK_EPOCH <= epoch <= MAX_CLOCK_EPOCH: + return HostResult("Err - clock epoch must be from 2020 through 2099") + if not allow_clock_control: + return HostResult("Err - host clock control is disabled") + return run_clock_control("set", epoch) if request_text == "reboot": if not allow_reboot: return HostResult("Err - host reboot is disabled") + return HostResult("", host_action="reboot") + if request_text == "network restart": + if not allow_network_restart: + return HostResult("Err - host network restart is disabled") + return HostResult("", host_action="network-restart") + if request_text.startswith("action status"): + match = re.fullmatch(r"action status ([0-9A-F]{32})", request_text) + if match is None: + return HostResult("Err - use: action status ") + if not (allow_reboot or allow_network_restart): + return HostResult("Err - host recovery actions are disabled") return HostResult( - f"OK - host reboot scheduled in {reboot_delay}s", - reboot_requested=True, + "", host_action="status", operation_id=match.group(1) ) if request_text == "run" or request_text.startswith("run "): return run_configured_program(request_text, programs or {}) return HostResult("Err - unsupported host request") -def schedule_host_reboot(delay_seconds: int) -> None: - # Fixed absolute paths and argv are intentional. Never substitute request - # text into this action and never enable shell=True. - command = [ - "/usr/bin/sudo", "-n", "/usr/bin/systemctl", "reboot" - ] - - def reboot() -> None: - try: - completed = subprocess.run( - command, - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - text=True, - timeout=15, - check=False, - ) - if completed.returncode != 0: - LOGGER.error( - "Host reboot failed with code %d: %s", - completed.returncode, - completed.stderr.strip(), - ) - except (OSError, subprocess.SubprocessError) as exc: - LOGGER.error("Host reboot failed: %s", exc) - - timer = threading.Timer(delay_seconds, reboot) - timer.daemon = True - timer.start() - - def bounded_line_text(text: str, max_bytes: int = MAX_REPLY_BYTES) -> str: line_safe = "".join( character if character.isprintable() else " " @@ -780,8 +1182,9 @@ class HostCliEndpoint: command_topic: str, temperature_path: Path, allow_reboot: bool = False, - reboot_delay: int = 5, - reboot_scheduler: Callable[[int], None] = schedule_host_reboot, + allow_network_restart: bool = False, + allow_clock_control: bool = False, + host_action_exchange: Callable[..., str] | None = None, programs: dict[str, ProgramDefinition] | None = None, dedupe_seconds: float = 60.0, challenge_timeout_seconds: float = CHALLENGE_TIMEOUT_SECONDS, @@ -796,8 +1199,12 @@ class HostCliEndpoint: self.command_topic = command_topic self.temperature_path = temperature_path self.allow_reboot = allow_reboot - self.reboot_delay = reboot_delay - self.reboot_scheduler = reboot_scheduler + self.allow_network_restart = allow_network_restart + self.allow_clock_control = allow_clock_control + self.host_action_exchange = ( + exchange_host_action + if host_action_exchange is None else host_action_exchange + ) self.programs = programs or {} self.dedupe_seconds = dedupe_seconds self.challenge_timeout_seconds = challenge_timeout_seconds @@ -805,6 +1212,227 @@ class HostCliEndpoint: self.monotonic = monotonic self.seen: OrderedDict[tuple[str, str], float] = OrderedDict() self.pending: PendingHostAction | None = None + self._action_queue: queue.Queue[VerifiedHostAction] = queue.Queue( + maxsize=HOST_ACTION_QUEUE_DEPTH + ) + self._action_worker_lock = threading.Lock() + self._action_worker_started = False + self._action_slot = threading.BoundedSemaphore(value=1) + + def _start_action_worker(self) -> None: + with self._action_worker_lock: + if self._action_worker_started: + return + worker = threading.Thread( + target=self._action_worker_loop, + name="meshcore-host-actions", + daemon=True, + ) + worker.start() + self._action_worker_started = True + + def _enqueue_host_action(self, work: VerifiedHostAction) -> bool: + if not self._action_slot.acquire(blocking=False): + self._publish_serial_reply( + work.request, + "Err - another host action is active; action was not reserved", + ) + return False + try: + self._action_queue.put_nowait(work) + except queue.Full: + self._action_slot.release() + self._publish_serial_reply( + work.request, + "Err - host action queue is full; action was not reserved", + ) + return False + self._start_action_worker() + return True + + def _action_worker_loop(self) -> None: + while True: + work = self._action_queue.get() + try: + self._process_host_action(work) + except Exception: + # The worker must remain available for later status checks. A + # failed or ambiguous operation is deliberately never retried. + LOGGER.exception( + "Host action worker failed for operation %s; not retrying", + work.operation_id, + ) + finally: + self._action_queue.task_done() + self._action_slot.release() + + def _broker_request( + self, + verb: str, + operation_id: str, + action: str | None, + deadline: float, + ) -> str: + response = self.host_action_exchange( + verb, + operation_id, + action, + deadline=deadline, + ) + return _validate_host_action_response( + response, verb, action, operation_id + ) + + def _publish_action_status( + self, + work: VerifiedHostAction, + deadline: float, + ) -> None: + try: + response = self._broker_request( + "status", work.operation_id, None, deadline + ) + except (OSError, PermissionError, TimeoutError, ValueError, socket.timeout): + reply = ( + "Err - host action status unavailable for " + + work.operation_id + ) + else: + if response == f"UNKNOWN {work.operation_id}": + reply = f"Host action {work.operation_id} is unknown" + elif response in HOST_ACTION_ERROR_RESPONSES: + reply = f"Err - host action status failed for {work.operation_id}" + else: + state_name, action, _operation_id = response.split(" ") + reply = ( + f"Host action {work.operation_id}: {action} " + f"{state_name.lower()}" + ) + self._publish_serial_reply(work.request, reply) + + @staticmethod + def _prepare_failure_reply( + action: str, + operation_id: str, + response: str, + ) -> str: + if response == "ERR action disabled": + return f"Err - {action} is disabled by root policy; {operation_id}" + if response == "ERR operation conflict": + return f"Err - operation {operation_id} conflicts; not retried" + if response == "ERR reboot pending": + return f"Err - another reboot is pending; {operation_id} not reserved" + if response == "ERR state full": + return f"Err - host action state is full; {operation_id} not reserved" + return f"Err - host action unavailable; {operation_id} not retried" + + def _process_host_action(self, work: VerifiedHostAction) -> None: + deadline = time.monotonic() + HOST_ACTION_TOTAL_TIMEOUT_SECONDS + if work.action == "status": + self._publish_action_status(work, deadline) + return + + try: + prepared = self._broker_request( + "prepare", work.operation_id, work.action, deadline + ) + except (TimeoutError, socket.timeout): + self._publish_serial_reply( + work.request, + f"Warning - {work.action} state unknown; " + f"{work.operation_id} not retried", + ) + return + except (OSError, PermissionError, ValueError): + self._publish_serial_reply( + work.request, + f"Err - {work.action} broker unavailable; " + f"{work.operation_id} not retried", + ) + return + + if prepared in HOST_ACTION_ERROR_RESPONSES: + self._publish_serial_reply( + work.request, + self._prepare_failure_reply( + work.action, work.operation_id, prepared + ), + ) + return + state_name = prepared.split(" ", 1)[0] + if state_name == "SCHEDULED": + self._publish_serial_reply( + work.request, + f"OK - {work.action} {work.operation_id} already scheduled", + ) + return + if state_name in ("IN-PROGRESS", "AMBIGUOUS"): + self._publish_serial_reply( + work.request, + f"Warning - {work.action} {work.operation_id} outcome unknown; " + "not retried", + ) + return + if state_name != "PREPARED": + raise ValueError("unexpected host action prepare result") + + reply = ( + f"OK - reboot {work.operation_id} accepted; fixed " + f"{HOST_ACTION_REBOOT_DELAY_SECONDS}s timer follows reply" + if work.action == "reboot" + else f"OK - network restart {work.operation_id} accepted after reply" + ) + try: + publish_result = self._publish_serial_reply(work.request, reply) + wait_for_publish = getattr(publish_result, "wait_for_publish", None) + is_published = getattr(publish_result, "is_published", None) + if not callable(wait_for_publish) or not callable(is_published): + LOGGER.error( + "MQTT client lacks publish confirmation for %s; not committing", + work.operation_id, + ) + return + wait_timeout = min( + HOST_ACTION_PUBLISH_TIMEOUT_SECONDS, + _remaining_host_action_time(deadline), + ) + wait_for_publish(timeout=wait_timeout) + if not is_published(): + LOGGER.error( + "Reply was not MQTT-confirmed for %s; not committing", + work.operation_id, + ) + return + except Exception: + LOGGER.exception( + "Reply publish failed for %s; not committing", + work.operation_id, + ) + return + + try: + _remaining_host_action_time(deadline) + committed = self._broker_request( + "commit", work.operation_id, work.action, deadline + ) + except (OSError, PermissionError, TimeoutError, ValueError, socket.timeout): + LOGGER.exception( + "Commit outcome is unknown for %s; not retrying", + work.operation_id, + ) + return + if committed != f"SCHEDULED {work.action} {work.operation_id}": + LOGGER.error( + "Host action %s commit returned %r; not retrying", + work.operation_id, + committed, + ) + return + LOGGER.info( + "Scheduled verified host action %s (%s)", + work.operation_id, + work.action, + ) def _already_seen(self, request: HostRequest) -> bool: now = self.monotonic() @@ -897,15 +1525,27 @@ class HostCliEndpoint: request.text, self.temperature_path, allow_reboot=self.allow_reboot, - reboot_delay=self.reboot_delay, + allow_network_restart=self.allow_network_restart, + allow_clock_control=self.allow_clock_control, programs=self.programs, ) - result = self._publish_serial_reply(request, response.text) - if response.reboot_requested: - wait_for_publish = getattr(result, "wait_for_publish", None) - if callable(wait_for_publish): - wait_for_publish(timeout=2.0) - self.reboot_scheduler(self.reboot_delay) + if response.host_action is not None: + operation_id = ( + response.operation_id + if response.host_action == "status" + else host_action_operation_id(self.repeater_public_key, request) + ) + if operation_id is None: + raise ValueError("host action is missing its operation ID") + self._enqueue_host_action( + VerifiedHostAction( + request=request, + action=response.host_action, + operation_id=operation_id, + ) + ) + else: + self._publish_serial_reply(request, response.text) LOGGER.info( "Replied to verified host request %s (%r)", request.request_id, @@ -974,16 +1614,16 @@ def run_endpoint(args: argparse.Namespace) -> None: if args.tls: client.tls_set(ca_certs=str(args.ca_cert) if args.ca_cert else None) - if args.allow_reboot: - required_binaries = (Path("/usr/bin/sudo"), Path("/usr/bin/systemctl")) - missing_binaries = [ - str(path) for path in required_binaries - if not path.is_file() or not os.access(path, os.X_OK) - ] - if missing_binaries: - raise ValueError( - "host reboot needs executable " + ", ".join(missing_binaries) - ) + if args.allow_clock_control: + validate_clock_control_socket( + CLOCK_CONTROL_SOCKET, + expected_gid=os.getegid(), + ) + if args.allow_reboot or args.allow_network_restart: + validate_host_actions_socket( + HOST_ACTIONS_SOCKET, + expected_gid=os.getegid(), + ) programs = ( load_programs_file(args.programs_file) @@ -997,7 +1637,8 @@ def run_endpoint(args: argparse.Namespace) -> None: command_topic=command_topic, temperature_path=args.temperature_path, allow_reboot=args.allow_reboot, - reboot_delay=args.reboot_delay, + allow_network_restart=args.allow_network_restart, + allow_clock_control=args.allow_clock_control, programs=programs, ) @@ -1058,8 +1699,12 @@ def build_parser() -> argparse.ArgumentParser: help="allow the exact 'reboot' request for the attached host", ) parser.add_argument( - "--reboot-delay", type=int, default=5, - help="seconds to wait after publishing the reply before rebooting (3-60)", + "--allow-network-restart", action="store_true", + help="allow the exact 'network restart' request for the attached host", + ) + parser.add_argument( + "--allow-clock-control", action="store_true", + help="allow clock set/sync through the root-authenticated Unix socket", ) parser.add_argument( "--programs-file", type=Path, @@ -1094,8 +1739,6 @@ def main(argv: list[str] | None = None) -> int: missing = [name for name, value in required.items() if value is None] if missing: parser.error("required for service mode: " + ", ".join(missing)) - if not 3 <= args.reboot_delay <= 60: - parser.error("--reboot-delay must be from 3 through 60 seconds") run_endpoint(args) return 0 except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as exc: diff --git a/examples/host_cli_service/meshcore-chrony-step.service b/examples/host_cli_service/meshcore-chrony-step.service new file mode 100644 index 000000000..284ed4da1 --- /dev/null +++ b/examples/host_cli_service/meshcore-chrony-step.service @@ -0,0 +1,30 @@ +[Unit] +Description=MeshCore bounded chrony clock step + +[Service] +Type=oneshot +User=_chrony +Group=_chrony +ExecStart=/usr/bin/chronyc -a makestep +TimeoutStartSec=1s +NoNewPrivileges=yes +CapabilityBoundingSet= +AmbientCapabilities= +PrivateDevices=yes +PrivateTmp=yes +ProtectClock=yes +ProtectControlGroups=yes +ProtectHome=yes +ProtectHostname=yes +ProtectKernelLogs=yes +ProtectKernelModules=yes +ProtectKernelTunables=yes +ProtectSystem=strict +RestrictAddressFamilies=AF_UNIX +RestrictNamespaces=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes +SystemCallArchitectures=native +UMask=0077 diff --git a/examples/host_cli_service/meshcore-clock-control.service b/examples/host_cli_service/meshcore-clock-control.service new file mode 100644 index 000000000..0b4c55b1e --- /dev/null +++ b/examples/host_cli_service/meshcore-clock-control.service @@ -0,0 +1,20 @@ +[Unit] +Description=MeshCore root clock-control service +Requires=meshcore-clock-control.socket +After=meshcore-clock-control.socket + +[Service] +Type=simple +User=root +Group=root +ExecStart=/usr/local/sbin/meshcore-clock-control --service-user mctomqtt --service-group mctomqtt +NoNewPrivileges=yes +PrivateDevices=yes +PrivateTmp=yes +ProtectHome=yes +ProtectSystem=strict +ProtectClock=no +RestrictAddressFamilies=AF_UNIX +CapabilityBoundingSet=CAP_SYS_TIME +AmbientCapabilities=CAP_SYS_TIME +UMask=0077 diff --git a/examples/host_cli_service/meshcore-clock-control.socket b/examples/host_cli_service/meshcore-clock-control.socket new file mode 100644 index 000000000..105290002 --- /dev/null +++ b/examples/host_cli_service/meshcore-clock-control.socket @@ -0,0 +1,14 @@ +[Unit] +Description=MeshCore root clock-control socket + +[Socket] +ListenStream=/run/meshcore-clock-control.sock +FileDescriptorName=clock-control +SocketUser=root +SocketGroup=mctomqtt +SocketMode=0660 +DirectoryMode=0755 +RemoveOnStop=yes + +[Install] +WantedBy=sockets.target diff --git a/examples/host_cli_service/meshcore-host-actions.service b/examples/host_cli_service/meshcore-host-actions.service new file mode 100644 index 000000000..8d4d10857 --- /dev/null +++ b/examples/host_cli_service/meshcore-host-actions.service @@ -0,0 +1,36 @@ +[Unit] +Description=MeshCore privileged host recovery action broker +Requires=meshcore-host-actions.socket +After=meshcore-host-actions.socket + +[Service] +Type=simple +User=root +Group=root +Environment=MESHCORE_HOST_ACTIONS= +ExecStart=/usr/local/sbin/meshcore-host-actions --service-user mctomqtt --service-group mctomqtt +RuntimeDirectory=meshcore-host-actions +RuntimeDirectoryMode=0700 +RuntimeDirectoryPreserve=yes +ReadWritePaths=/run/meshcore-host-actions +NoNewPrivileges=yes +CapabilityBoundingSet= +AmbientCapabilities= +PrivateDevices=yes +PrivateTmp=yes +ProtectClock=yes +ProtectControlGroups=yes +ProtectHome=yes +ProtectHostname=yes +ProtectKernelLogs=yes +ProtectKernelModules=yes +ProtectKernelTunables=yes +ProtectSystem=strict +RestrictAddressFamilies=AF_UNIX +RestrictNamespaces=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes +SystemCallArchitectures=native +UMask=0077 diff --git a/examples/host_cli_service/meshcore-host-actions.socket b/examples/host_cli_service/meshcore-host-actions.socket new file mode 100644 index 000000000..dbcad1320 --- /dev/null +++ b/examples/host_cli_service/meshcore-host-actions.socket @@ -0,0 +1,14 @@ +[Unit] +Description=MeshCore privileged host recovery action socket + +[Socket] +ListenStream=/run/meshcore-host-actions.sock +FileDescriptorName=host-actions +SocketUser=root +SocketGroup=mctomqtt +SocketMode=0660 +DirectoryMode=0755 +RemoveOnStop=yes + +[Install] +WantedBy=sockets.target diff --git a/examples/host_cli_service/meshcore-host-reboot.service b/examples/host_cli_service/meshcore-host-reboot.service new file mode 100644 index 000000000..4fede4c86 --- /dev/null +++ b/examples/host_cli_service/meshcore-host-reboot.service @@ -0,0 +1,30 @@ +[Unit] +Description=MeshCore fixed host reboot action + +[Service] +Type=oneshot +User=root +Group=root +ExecStart=/usr/bin/systemctl reboot +TimeoutStartSec=5s +NoNewPrivileges=yes +CapabilityBoundingSet= +AmbientCapabilities= +PrivateDevices=yes +PrivateTmp=yes +ProtectClock=yes +ProtectControlGroups=yes +ProtectHome=yes +ProtectHostname=yes +ProtectKernelLogs=yes +ProtectKernelModules=yes +ProtectKernelTunables=yes +ProtectSystem=strict +RestrictAddressFamilies=AF_UNIX +RestrictNamespaces=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes +SystemCallArchitectures=native +UMask=0077 diff --git a/examples/host_cli_service/meshcore-host-reboot.timer b/examples/host_cli_service/meshcore-host-reboot.timer new file mode 100644 index 000000000..fe0f7b075 --- /dev/null +++ b/examples/host_cli_service/meshcore-host-reboot.timer @@ -0,0 +1,9 @@ +[Unit] +Description=MeshCore fixed delayed host reboot timer + +[Timer] +OnActiveSec=10s +AccuracySec=1s +Persistent=no +RemainAfterElapse=no +Unit=meshcore-host-reboot.service diff --git a/examples/host_cli_service/meshcore-networkmanager-restart.service b/examples/host_cli_service/meshcore-networkmanager-restart.service new file mode 100644 index 000000000..bd4d18230 --- /dev/null +++ b/examples/host_cli_service/meshcore-networkmanager-restart.service @@ -0,0 +1,31 @@ +[Unit] +Description=MeshCore fixed NetworkManager recovery action +After=NetworkManager.service + +[Service] +Type=oneshot +User=root +Group=root +ExecStart=/usr/bin/systemctl --no-block restart NetworkManager.service +TimeoutStartSec=8s +NoNewPrivileges=yes +CapabilityBoundingSet= +AmbientCapabilities= +PrivateDevices=yes +PrivateTmp=yes +ProtectClock=yes +ProtectControlGroups=yes +ProtectHome=yes +ProtectHostname=yes +ProtectKernelLogs=yes +ProtectKernelModules=yes +ProtectKernelTunables=yes +ProtectSystem=strict +RestrictAddressFamilies=AF_UNIX +RestrictNamespaces=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes +SystemCallArchitectures=native +UMask=0077 diff --git a/examples/host_cli_service/meshcore_clock_control.py b/examples/host_cli_service/meshcore_clock_control.py new file mode 100755 index 000000000..d12e50e0b --- /dev/null +++ b/examples/host_cli_service/meshcore_clock_control.py @@ -0,0 +1,320 @@ +#!/usr/bin/python3 +"""Root clock-control service for the MeshCore host endpoint. + +The service is activated by ``meshcore-clock-control.socket``. It accepts one +bounded ASCII line per connection and authorizes the client using Linux +``SO_PEERCRED`` before parsing or executing the request. Only ``sync`` and +``set `` exist in the protocol. +""" + +from __future__ import annotations + +import argparse +import grp +import os +from pathlib import Path +import pwd +import re +import socket +import stat +import struct +import subprocess +import sys +import time +from typing import Mapping + + +MIN_CLOCK_EPOCH = 1577836800 # 2020-01-01T00:00:00Z +MAX_CLOCK_EPOCH = 4102444799 # 2099-12-31T23:59:59Z +CLOCK_CONTROL_SOCKET = Path("/run/meshcore-clock-control.sock") +SOCKET_MODE = 0o660 +SYSTEMD_LISTEN_FDS_START = 3 +MAX_REQUEST_BYTES = 32 +MAX_RESPONSE_BYTES = 192 +CLIENT_IO_TIMEOUT_SECONDS = 1.0 +CHILD_TIMEOUT_SECONDS = 1.5 +MINIMAL_ENV = { + "PATH": "/usr/sbin:/usr/bin:/sbin:/bin", + "LANG": "C.UTF-8", +} +NTP_ENABLE_ERROR = "NTP enable request failed" +CHRONY_STEP_ERROR = "chrony step request failed" +TIMESYNCD_RESTART_ERROR = "systemd-timesyncd restart failed" +CHRONY_STEP_SERVICE = "meshcore-chrony-step.service" + + +def run_fixed(command: list[str]) -> bool: + """Run one compile-time-selected argv with a short hard timeout.""" + try: + completed = subprocess.run( + command, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + timeout=CHILD_TIMEOUT_SECONDS, + check=False, + shell=False, + cwd="/", + env=MINIMAL_ENV, + ) + except (OSError, subprocess.TimeoutExpired): + return False + return completed.returncode == 0 + + +def request_ntp_sync() -> str | None: + if not run_fixed(["/usr/bin/timedatectl", "set-ntp", "true"]): + return NTP_ENABLE_ERROR + chronyc = Path("/usr/bin/chronyc") + if chronyc.is_file() and os.access(chronyc, os.X_OK): + if not run_fixed( + ["/usr/bin/systemctl", "start", CHRONY_STEP_SERVICE] + ): + return CHRONY_STEP_ERROR + return None + if not run_fixed( + ["/usr/bin/systemctl", "restart", "systemd-timesyncd.service"] + ): + return TIMESYNCD_RESTART_ERROR + return None + + +def set_clock(epoch: int) -> tuple[bool, str | None]: + # CLOCK_REALTIME can be changed while NTP remains enabled. Never turn NTP + # off: a crash or timeout must not strand the recovery host without NTP. + try: + time.clock_settime(time.CLOCK_REALTIME, float(epoch)) + except (OSError, OverflowError, ValueError): + return False, None + return True, request_ntp_sync() + + +def parse_request(request: bytes) -> tuple[str, int | None]: + if not request or len(request) > MAX_REQUEST_BYTES: + raise ValueError("invalid request length") + if not request.endswith(b"\n") or request.count(b"\n") != 1: + raise ValueError("invalid request framing") + try: + line = request[:-1].decode("ascii", errors="strict") + except UnicodeDecodeError as exc: + raise ValueError("request is not ASCII") from exc + if line == "sync": + return "sync", None + match = re.fullmatch(r"set (0|[1-9][0-9]*)", line) + if match is None: + raise ValueError("invalid request") + epoch = int(match.group(1)) + if not MIN_CLOCK_EPOCH <= epoch <= MAX_CLOCK_EPOCH: + raise ValueError("epoch outside accepted range") + return "set", epoch + + +def process_request(request: bytes) -> str: + action, epoch = parse_request(request) + if action == "sync": + error = request_ntp_sync() + if error is None: + return "OK NTP sync requested" + if error == NTP_ENABLE_ERROR: + return f"ERR {error}" + return f"PARTIAL NTP enabled; {error}" + + assert epoch is not None + changed, error = set_clock(epoch) + if not changed: + return "ERR clock set failed" + if error is None: + return f"OK clock set to {epoch}; NTP sync requested" + if error == NTP_ENABLE_ERROR: + return f"PARTIAL clock set to {epoch}; {error}" + return f"PARTIAL clock set to {epoch}; NTP enabled; {error}" + + +def peer_credentials(connection: socket.socket) -> tuple[int, int, int]: + encoded = connection.getsockopt( + socket.SOL_SOCKET, + socket.SO_PEERCRED, + struct.calcsize("3i"), + ) + return struct.unpack("3i", encoded) + + +def receive_request(connection: socket.socket) -> bytes: + request = bytearray() + deadline = time.monotonic() + CLIENT_IO_TIMEOUT_SECONDS + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise socket.timeout("clock control request deadline expired") + connection.settimeout(remaining) + chunk = connection.recv(MAX_REQUEST_BYTES + 1 - len(request)) + if not chunk: + break + request.extend(chunk) + if len(request) > MAX_REQUEST_BYTES: + raise ValueError("request is too long") + return bytes(request) + + +def send_response(connection: socket.socket, response: str) -> None: + encoded = (response + "\n").encode("ascii", errors="strict") + if len(encoded) > MAX_RESPONSE_BYTES: + encoded = b"ERR server error\n" + connection.settimeout(CLIENT_IO_TIMEOUT_SECONDS) + connection.sendall(encoded) + + +def handle_connection( + connection: socket.socket, + *, + allowed_uid: int, + allowed_gid: int, +) -> bool: + try: + _pid, peer_uid, peer_gid = peer_credentials(connection) + except OSError: + return False + if peer_uid != allowed_uid or peer_gid != allowed_gid: + try: + send_response(connection, "ERR unauthorized peer") + except (OSError, socket.timeout): + pass + return False + try: + request = receive_request(connection) + except socket.timeout: + response = "ERR request timed out" + except (OSError, ValueError): + response = "ERR invalid request" + else: + try: + response = process_request(request) + except ValueError: + response = "ERR invalid request" + except Exception: + # Never disclose exception text across the privilege boundary. + response = "ERR server error" + try: + send_response(connection, response) + except (OSError, socket.timeout): + return False + return not response.startswith("ERR ") + + +def validate_socket_metadata(metadata: os.stat_result, expected_gid: int) -> None: + if not stat.S_ISSOCK(metadata.st_mode): + raise ValueError("clock control path is not a socket") + if metadata.st_uid != 0: + raise ValueError("clock control socket is not root-owned") + if metadata.st_gid != expected_gid: + raise ValueError("clock control socket has the wrong group") + if stat.S_IMODE(metadata.st_mode) != SOCKET_MODE: + raise ValueError("clock control socket must have mode 0660") + + +def validate_listening_socket( + listener: socket.socket, + *, + path: Path, + expected_gid: int, +) -> None: + if listener.family != socket.AF_UNIX: + raise ValueError("activation socket must use AF_UNIX") + if listener.getsockopt(socket.SOL_SOCKET, socket.SO_TYPE) != socket.SOCK_STREAM: + raise ValueError("activation socket must use SOCK_STREAM") + if listener.getsockopt(socket.SOL_SOCKET, socket.SO_ACCEPTCONN) != 1: + raise ValueError("activation socket is not listening") + socket_name = listener.getsockname() + if not isinstance(socket_name, str) or Path(socket_name) != path: + raise ValueError("activation socket has the wrong path") + try: + metadata = path.lstat() + except OSError as exc: + raise ValueError("clock control socket path is missing") from exc + validate_socket_metadata(metadata, expected_gid) + + +def activation_socket( + environment: Mapping[str, str] | None = None, +) -> socket.socket: + variables = os.environ if environment is None else environment + if variables.get("LISTEN_PID") != str(os.getpid()): + raise ValueError("service must be started by systemd socket activation") + if variables.get("LISTEN_FDS") != "1": + raise ValueError("service requires exactly one activation socket") + descriptor_names = variables.get("LISTEN_FDNAMES") + if descriptor_names not in (None, "clock-control"): + raise ValueError("activation socket has an unexpected descriptor name") + try: + return socket.socket(fileno=SYSTEMD_LISTEN_FDS_START) + except OSError as exc: + raise ValueError("activation socket descriptor is unavailable") from exc + + +def resolve_service_identity(user: str, group: str) -> tuple[int, int]: + try: + user_record = pwd.getpwnam(user) + group_record = grp.getgrnam(group) + except KeyError as exc: + raise ValueError("configured service user or group does not exist") from exc + if user_record.pw_gid != group_record.gr_gid: + raise ValueError("configured group must be the service user's primary group") + return user_record.pw_uid, group_record.gr_gid + + +def serve( + listener: socket.socket, + *, + allowed_uid: int, + allowed_gid: int, +) -> None: + while True: + connection, _address = listener.accept() + with connection: + handle_connection( + connection, + allowed_uid=allowed_uid, + allowed_gid=allowed_gid, + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Root-only MeshCore clock-control socket service" + ) + parser.add_argument("--service-user", required=True) + parser.add_argument("--service-group", required=True) + return parser + + +def main(argv: list[str] | None = None) -> int: + arguments = build_parser().parse_args(argv) + try: + if os.geteuid() != 0: + raise ValueError("clock control service requires root") + allowed_uid, allowed_gid = resolve_service_identity( + arguments.service_user, + arguments.service_group, + ) + listener = activation_socket() + validate_listening_socket( + listener, + path=CLOCK_CONTROL_SOCKET, + expected_gid=allowed_gid, + ) + serve( + listener, + allowed_uid=allowed_uid, + allowed_gid=allowed_gid, + ) + except (OSError, ValueError) as exc: + print(f"meshcore-clock-control: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/host_cli_service/meshcore_host_actions.py b/examples/host_cli_service/meshcore_host_actions.py new file mode 100644 index 000000000..449c13766 --- /dev/null +++ b/examples/host_cli_service/meshcore_host_actions.py @@ -0,0 +1,704 @@ +#!/usr/bin/python3 +"""Root broker for exact MeshCore host recovery actions. + +This service is activated by ``meshcore-host-actions.socket``. The unprivileged +MQTT endpoint may reserve an operation, publish its LoRa reply, and only then +commit the operation. Linux ``SO_PEERCRED`` and a root-owned systemd policy +gate the two fixed actions. No request text becomes an executable, unit, or +argument. +""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass, replace +import grp +import json +import os +from pathlib import Path +import pwd +import re +import secrets +import socket +import stat +import struct +import subprocess +import sys +import time +from typing import Callable, Mapping + + +HOST_ACTIONS_SOCKET = Path("/run/meshcore-host-actions.sock") +STATE_DIRECTORY = Path("/run/meshcore-host-actions") +STATE_PATH = STATE_DIRECTORY / "state.json" +SOCKET_MODE = 0o660 +STATE_DIRECTORY_MODE = 0o700 +STATE_FILE_MODE = 0o600 +SYSTEMD_LISTEN_FDS_START = 3 +MAX_REQUEST_BYTES = 96 +MAX_RESPONSE_BYTES = 128 +MAX_STATE_BYTES = 32768 +MAX_OPERATIONS = 64 +MAX_SEQUENCE = (1 << 63) - 1 +CLIENT_IO_TIMEOUT_SECONDS = 1.0 +SCHEDULE_TIMEOUT_SECONDS = 1.5 +VALID_ACTIONS = frozenset(("network-restart", "reboot")) +VALID_STATES = frozenset(("prepared", "in-progress", "scheduled", "ambiguous")) +ACTION_UNITS = { + "network-restart": "meshcore-networkmanager-restart.service", + "reboot": "meshcore-host-reboot.timer", +} +MINIMAL_ENV = { + "PATH": "/usr/sbin:/usr/bin:/sbin:/bin", + "LANG": "C.UTF-8", +} +OPERATION_ID_RE = re.compile(r"[0-9A-F]{32}") + + +class StateError(RuntimeError): + """Persistent state is unavailable or invalid.""" + + +class StateFullError(StateError): + """No operation can be safely forgotten during this boot.""" + + +class RebootPendingError(RuntimeError): + """A distinct reboot is already committed or has an unknown outcome.""" + + +def _strict_json_object(pairs: list[tuple[str, object]]) -> dict[str, object]: + document: dict[str, object] = {} + for key, value in pairs: + if key in document: + raise StateError("host action state contains a duplicate key") + document[key] = value + return document + + +@dataclass(frozen=True) +class OperationRecord: + operation_id: str + action: str + state: str + sequence: int + + +def parse_allowed_actions(value: str) -> frozenset[str]: + if value == "": + return frozenset() + values = value.split(",") + if ( + any(not item or item not in VALID_ACTIONS for item in values) + or len(values) != len(set(values)) + ): + raise ValueError( + "allowed actions must be a comma-separated subset of " + "network-restart,reboot" + ) + return frozenset(values) + + +def validate_operation_id(operation_id: str) -> None: + if OPERATION_ID_RE.fullmatch(operation_id) is None: + raise ValueError("operation ID must be 32 uppercase hexadecimal digits") + + +def _document_from_records( + records: dict[str, OperationRecord], + next_sequence: int, +) -> dict[str, object]: + return { + "version": 1, + "next_sequence": next_sequence, + "operations": [ + { + "id": record.operation_id, + "action": record.action, + "state": record.state, + "sequence": record.sequence, + } + for record in sorted(records.values(), key=lambda item: item.sequence) + ], + } + + +def parse_state_document( + document: object, +) -> tuple[dict[str, OperationRecord], int]: + if not isinstance(document, dict) or set(document) != { + "version", "next_sequence", "operations" + }: + raise StateError("host action state has an invalid top-level schema") + if ( + not isinstance(document["version"], int) + or isinstance(document["version"], bool) + or document["version"] != 1 + ): + raise StateError("host action state has an unsupported version") + next_sequence = document["next_sequence"] + operations = document["operations"] + if ( + not isinstance(next_sequence, int) + or isinstance(next_sequence, bool) + or next_sequence < 1 + or next_sequence > MAX_SEQUENCE + or not isinstance(operations, list) + or len(operations) > MAX_OPERATIONS + ): + raise StateError("host action state bounds are invalid") + records: dict[str, OperationRecord] = {} + sequences: set[int] = set() + for raw_record in operations: + if not isinstance(raw_record, dict) or set(raw_record) != { + "id", "action", "state", "sequence" + }: + raise StateError("host action record has an invalid schema") + operation_id = raw_record["id"] + action = raw_record["action"] + state_name = raw_record["state"] + sequence = raw_record["sequence"] + if ( + not isinstance(operation_id, str) + or OPERATION_ID_RE.fullmatch(operation_id) is None + or not isinstance(action, str) + or action not in VALID_ACTIONS + or not isinstance(state_name, str) + or state_name not in VALID_STATES + or not isinstance(sequence, int) + or isinstance(sequence, bool) + or sequence < 1 + or sequence > MAX_SEQUENCE + or operation_id in records + or sequence in sequences + ): + raise StateError("host action record is invalid") + records[operation_id] = OperationRecord( + operation_id, action, state_name, sequence + ) + sequences.add(sequence) + if sequences and next_sequence <= max(sequences): + raise StateError("host action next sequence is invalid") + return records, next_sequence + + +def validate_state_directory(path: Path = STATE_DIRECTORY) -> None: + try: + metadata = path.lstat() + except OSError as exc: + raise StateError(f"host action state directory is missing: {path}") from exc + if not stat.S_ISDIR(metadata.st_mode) or metadata.st_uid != 0: + raise StateError("host action state directory must be root-owned directory") + if stat.S_IMODE(metadata.st_mode) != STATE_DIRECTORY_MODE: + raise StateError("host action state directory must have mode 0700") + + +def _validate_state_file_metadata(metadata: os.stat_result) -> None: + if not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != 0: + raise StateError("host action state must be a root-owned regular file") + if stat.S_IMODE(metadata.st_mode) != STATE_FILE_MODE: + raise StateError("host action state must have mode 0600") + if metadata.st_nlink != 1 or metadata.st_size > MAX_STATE_BYTES: + raise StateError("host action state metadata is invalid") + + +class StateFile: + def __init__( + self, + path: Path = STATE_PATH, + directory: Path = STATE_DIRECTORY, + ) -> None: + self.path = path + self.directory = directory + + def load(self) -> tuple[dict[str, OperationRecord], int]: + validate_state_directory(self.directory) + try: + descriptor = os.open( + self.path, + os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC, + ) + except FileNotFoundError: + return {}, 1 + except OSError as exc: + raise StateError("host action state could not be opened") from exc + try: + metadata = os.fstat(descriptor) + _validate_state_file_metadata(metadata) + with os.fdopen(descriptor, "rb", closefd=False) as stream: + encoded = stream.read(MAX_STATE_BYTES + 1) + if len(encoded) > MAX_STATE_BYTES: + raise StateError("host action state is too large") + finally: + os.close(descriptor) + try: + document = json.loads( + encoded.decode("ascii", errors="strict"), + object_pairs_hook=_strict_json_object, + ) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise StateError("host action state is not valid ASCII JSON") from exc + return parse_state_document(document) + + def save( + self, + records: dict[str, OperationRecord], + next_sequence: int, + ) -> None: + validate_state_directory(self.directory) + document = _document_from_records(records, next_sequence) + encoded = ( + json.dumps(document, sort_keys=True, separators=(",", ":")) + "\n" + ).encode("ascii") + if len(encoded) > MAX_STATE_BYTES: + raise StateError("host action state exceeds its size limit") + temporary = self.directory / ( + ".state." + str(os.getpid()) + "." + secrets.token_hex(8) + ) + descriptor = -1 + try: + descriptor = os.open( + temporary, + os.O_WRONLY + | os.O_CREAT + | os.O_EXCL + | os.O_NOFOLLOW + | os.O_CLOEXEC, + STATE_FILE_MODE, + ) + with os.fdopen(descriptor, "wb", closefd=False) as stream: + stream.write(encoded) + stream.flush() + os.fsync(stream.fileno()) + os.close(descriptor) + descriptor = -1 + os.replace(temporary, self.path) + directory_descriptor = os.open( + self.directory, + os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC, + ) + try: + os.fsync(directory_descriptor) + finally: + os.close(directory_descriptor) + except OSError as exc: + raise StateError("host action state could not be saved") from exc + finally: + if descriptor >= 0: + os.close(descriptor) + try: + temporary.unlink() + except OSError: + pass + + +Persist = Callable[[dict[str, OperationRecord], int], None] +Scheduler = Callable[[str], bool] + + +class OperationState: + def __init__( + self, + records: dict[str, OperationRecord] | None = None, + next_sequence: int = 1, + persist: Persist | None = None, + ) -> None: + self.records = dict(records or {}) + self.next_sequence = next_sequence + self.persist = persist or (lambda _records, _next: None) + recovered = { + operation_id: ( + replace(record, state="ambiguous") + if record.state == "in-progress" else record + ) + for operation_id, record in self.records.items() + } + if recovered != self.records: + self.persist(recovered, self.next_sequence) + self.records = recovered + + def _persist_candidate( + self, + records: dict[str, OperationRecord], + next_sequence: int | None = None, + ) -> None: + candidate_next = self.next_sequence if next_sequence is None else next_sequence + self.persist(records, candidate_next) + self.records = records + self.next_sequence = candidate_next + + def _existing(self, operation_id: str, action: str) -> OperationRecord | None: + record = self.records.get(operation_id) + if record is not None and record.action != action: + raise ValueError("operation conflict") + return record + + def prepare( + self, + action: str, + operation_id: str, + allowed_actions: frozenset[str], + ) -> str: + record = self._existing(operation_id, action) + if action not in allowed_actions: + raise PermissionError("action disabled") + if record is not None: + return record.state + records = dict(self.records) + if action == "reboot": + other_reboots = [ + item + for item in records.values() + if item.action == "reboot" + ] + if any(item.state != "prepared" for item in other_reboots): + raise RebootPendingError("another reboot is already pending") + # An abandoned PREPARE has no side effect and is safe to supersede. + # Removing all older reboot reservations atomically ensures only one + # distinct operation can remain commit-capable at a time. + for item in other_reboots: + del records[item.operation_id] + if len(records) >= MAX_OPERATIONS: + prepared = [ + item for item in records.values() if item.state == "prepared" + ] + if not prepared: + raise StateFullError("host action state is full") + oldest = min(prepared, key=lambda item: item.sequence) + del records[oldest.operation_id] + if self.next_sequence >= MAX_SEQUENCE: + raise StateFullError("host action sequence is exhausted") + record = OperationRecord( + operation_id, action, "prepared", self.next_sequence + ) + records[operation_id] = record + self._persist_candidate(records, self.next_sequence + 1) + return record.state + + def commit( + self, + action: str, + operation_id: str, + allowed_actions: frozenset[str], + scheduler: Scheduler, + ) -> str: + record = self._existing(operation_id, action) + if record is None: + raise LookupError("operation not prepared") + if action not in allowed_actions: + raise PermissionError("action disabled") + if record.state != "prepared": + return record.state + + in_progress = replace(record, state="in-progress") + records = dict(self.records) + records[operation_id] = in_progress + self._persist_candidate(records) + + scheduled = False + try: + scheduled = scheduler(action) + except Exception: + scheduled = False + final_state = "scheduled" if scheduled else "ambiguous" + final = replace(in_progress, state=final_state) + records = dict(self.records) + records[operation_id] = final + try: + self._persist_candidate(records) + except StateError: + # The durable record remains in-progress. Keep the live state + # ambiguous and never invoke the scheduler again. + self.records[operation_id] = replace(in_progress, state="ambiguous") + return "ambiguous" + return final_state + + def status(self, operation_id: str) -> OperationRecord | None: + return self.records.get(operation_id) + + +def schedule_action(action: str) -> bool: + unit = ACTION_UNITS.get(action) + if unit is None: + return False + try: + completed = subprocess.run( + ["/usr/bin/systemctl", "--no-block", "start", unit], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + timeout=SCHEDULE_TIMEOUT_SECONDS, + check=False, + shell=False, + cwd="/", + env=MINIMAL_ENV, + ) + except (OSError, subprocess.TimeoutExpired): + return False + return completed.returncode == 0 + + +def parse_request(request: bytes) -> tuple[str, str | None, str]: + if not request or len(request) > MAX_REQUEST_BYTES: + raise ValueError("invalid request length") + if not request.endswith(b"\n") or request.count(b"\n") != 1: + raise ValueError("invalid request framing") + try: + line = request[:-1].decode("ascii", errors="strict") + except UnicodeDecodeError as exc: + raise ValueError("request is not ASCII") from exc + status_match = re.fullmatch(r"status ([0-9A-F]{32})", line) + if status_match is not None: + return "status", None, status_match.group(1) + action_match = re.fullmatch( + r"(prepare|commit) (network-restart|reboot) ([0-9A-F]{32})", + line, + ) + if action_match is None: + raise ValueError("invalid request") + return action_match.group(1), action_match.group(2), action_match.group(3) + + +def _state_response(record: OperationRecord) -> str: + label = record.state.upper() + return f"{label} {record.action} {record.operation_id}" + + +def process_request( + request: bytes, + state: OperationState, + allowed_actions: frozenset[str], + scheduler: Scheduler = schedule_action, +) -> str: + verb, action, operation_id = parse_request(request) + if verb == "status": + record = state.status(operation_id) + return ( + f"UNKNOWN {operation_id}" + if record is None else _state_response(record) + ) + assert action is not None + try: + if verb == "prepare": + state_name = state.prepare(action, operation_id, allowed_actions) + else: + state_name = state.commit( + action, operation_id, allowed_actions, scheduler + ) + except PermissionError: + return "ERR action disabled" + except LookupError: + return "ERR operation not prepared" + except RebootPendingError: + return "ERR reboot pending" + except StateFullError: + return "ERR state full" + except StateError: + return "ERR state unavailable" + except ValueError: + return "ERR operation conflict" + return f"{state_name.upper()} {action} {operation_id}" + + +def peer_credentials(connection: socket.socket) -> tuple[int, int, int]: + encoded = connection.getsockopt( + socket.SOL_SOCKET, + socket.SO_PEERCRED, + struct.calcsize("3i"), + ) + return struct.unpack("3i", encoded) + + +def receive_request(connection: socket.socket) -> bytes: + request = bytearray() + deadline = time.monotonic() + CLIENT_IO_TIMEOUT_SECONDS + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise socket.timeout("host action request deadline expired") + connection.settimeout(remaining) + chunk = connection.recv(MAX_REQUEST_BYTES + 1 - len(request)) + if not chunk: + break + request.extend(chunk) + if len(request) > MAX_REQUEST_BYTES: + raise ValueError("request is too long") + return bytes(request) + + +def send_response(connection: socket.socket, response: str) -> None: + encoded = (response + "\n").encode("ascii", errors="strict") + if len(encoded) > MAX_RESPONSE_BYTES: + encoded = b"ERR server error\n" + connection.settimeout(CLIENT_IO_TIMEOUT_SECONDS) + connection.sendall(encoded) + + +def handle_connection( + connection: socket.socket, + *, + allowed_uid: int, + allowed_gid: int, + allowed_actions: frozenset[str], + state: OperationState, +) -> bool: + try: + _pid, peer_uid, peer_gid = peer_credentials(connection) + except OSError: + return False + if peer_uid != allowed_uid or peer_gid != allowed_gid: + try: + send_response(connection, "ERR unauthorized peer") + except (OSError, socket.timeout): + pass + return False + try: + request = receive_request(connection) + except socket.timeout: + response = "ERR request timed out" + except (OSError, ValueError): + response = "ERR invalid request" + else: + try: + response = process_request(request, state, allowed_actions) + except ValueError: + response = "ERR invalid request" + except Exception: + response = "ERR server error" + try: + send_response(connection, response) + except (OSError, socket.timeout): + return False + return not response.startswith("ERR ") + + +def validate_socket_metadata(metadata: os.stat_result, expected_gid: int) -> None: + if not stat.S_ISSOCK(metadata.st_mode): + raise ValueError("host actions path is not a socket") + if metadata.st_uid != 0: + raise ValueError("host actions socket is not root-owned") + if metadata.st_gid != expected_gid: + raise ValueError("host actions socket has the wrong group") + if stat.S_IMODE(metadata.st_mode) != SOCKET_MODE: + raise ValueError("host actions socket must have mode 0660") + + +def validate_listening_socket( + listener: socket.socket, + *, + path: Path, + expected_gid: int, +) -> None: + if listener.family != socket.AF_UNIX: + raise ValueError("activation socket must use AF_UNIX") + if listener.getsockopt(socket.SOL_SOCKET, socket.SO_TYPE) != socket.SOCK_STREAM: + raise ValueError("activation socket must use SOCK_STREAM") + if listener.getsockopt(socket.SOL_SOCKET, socket.SO_ACCEPTCONN) != 1: + raise ValueError("activation socket is not listening") + socket_name = listener.getsockname() + if not isinstance(socket_name, str) or Path(socket_name) != path: + raise ValueError("activation socket has the wrong path") + try: + metadata = path.lstat() + except OSError as exc: + raise ValueError("host actions socket path is missing") from exc + validate_socket_metadata(metadata, expected_gid) + + +def activation_socket( + environment: Mapping[str, str] | None = None, +) -> socket.socket: + variables = os.environ if environment is None else environment + if variables.get("LISTEN_PID") != str(os.getpid()): + raise ValueError("service must be started by systemd socket activation") + if variables.get("LISTEN_FDS") != "1": + raise ValueError("service requires exactly one activation socket") + if variables.get("LISTEN_FDNAMES") not in (None, "host-actions"): + raise ValueError("activation socket has an unexpected descriptor name") + try: + return socket.socket(fileno=SYSTEMD_LISTEN_FDS_START) + except OSError as exc: + raise ValueError("activation socket descriptor is unavailable") from exc + + +def resolve_service_identity(user: str, group: str) -> tuple[int, int]: + try: + user_record = pwd.getpwnam(user) + group_record = grp.getgrnam(group) + except KeyError as exc: + raise ValueError("configured service user or group does not exist") from exc + if user_record.pw_gid != group_record.gr_gid: + raise ValueError("configured group must be the service user's primary group") + return user_record.pw_uid, group_record.gr_gid + + +def serve( + listener: socket.socket, + *, + allowed_uid: int, + allowed_gid: int, + allowed_actions: frozenset[str], + state: OperationState, +) -> None: + while True: + connection, _address = listener.accept() + with connection: + handle_connection( + connection, + allowed_uid=allowed_uid, + allowed_gid=allowed_gid, + allowed_actions=allowed_actions, + state=state, + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Root-only MeshCore host recovery action broker" + ) + parser.add_argument("--service-user", required=True) + parser.add_argument("--service-group", required=True) + return parser + + +def main(argv: list[str] | None = None) -> int: + arguments = build_parser().parse_args(argv) + try: + if os.geteuid() != 0: + raise ValueError("host action broker requires root") + allowed_uid, allowed_gid = resolve_service_identity( + arguments.service_user, + arguments.service_group, + ) + # This environment value comes only from the root-owned systemd unit + # (or a root-started diagnostic process), never from the socket peer. + allowed_actions = parse_allowed_actions( + os.environ.get("MESHCORE_HOST_ACTIONS", "") + ) + listener = activation_socket() + validate_listening_socket( + listener, + path=HOST_ACTIONS_SOCKET, + expected_gid=allowed_gid, + ) + state_file = StateFile() + records, next_sequence = state_file.load() + state = OperationState(records, next_sequence, state_file.save) + serve( + listener, + allowed_uid=allowed_uid, + allowed_gid=allowed_gid, + allowed_actions=allowed_actions, + state=state, + ) + except (OSError, RuntimeError, ValueError) as exc: + print(f"meshcore-host-actions: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/host_cli_service/test_host_cli_service.py b/examples/host_cli_service/test_host_cli_service.py index bd63fd2f2..223380048 100644 --- a/examples/host_cli_service/test_host_cli_service.py +++ b/examples/host_cli_service/test_host_cli_service.py @@ -2,12 +2,19 @@ from __future__ import annotations import base64 import json +import os from pathlib import Path +import socket +import stat +import struct +import subprocess import tempfile +import threading import unittest from unittest import mock import host_cli_service as service +import meshcore_clock_control as clock_helper PUBLIC_KEY = "11" * 32 @@ -154,7 +161,7 @@ class RequestHandlerTests(unittest.TestCase): "not-a-terminal-control"), ) - def test_exact_fixed_commands_and_opt_in_reboot(self) -> None: + def test_exact_fixed_commands_and_opt_in_recovery_actions(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) uptime = root / "uptime" @@ -192,17 +199,305 @@ class RequestHandlerTests(unittest.TestCase): disabled = service.handle_request("reboot", Path("/unused")) self.assertEqual("Err - host reboot is disabled", disabled.text) - self.assertFalse(disabled.reboot_requested) enabled = service.handle_request( - "reboot", Path("/unused"), allow_reboot=True, reboot_delay=7 + "reboot", Path("/unused"), allow_reboot=True + ) + self.assertEqual(service.HostResult("", host_action="reboot"), enabled) + self.assertEqual( + "Err - host network restart is disabled", + service.handle_request("network restart", Path("/unused")).text, + ) + self.assertEqual( + service.HostResult("", host_action="network-restart"), + service.handle_request( + "network restart", + Path("/unused"), + allow_network_restart=True, + ), + ) + operation_id = "A1" * 16 + self.assertEqual( + service.HostResult( + "", host_action="status", operation_id=operation_id + ), + service.handle_request( + f"action status {operation_id}", + Path("/unused"), + allow_reboot=True, + ), ) - self.assertEqual("OK - host reboot scheduled in 7s", enabled.text) - self.assertTrue(enabled.reboot_requested) self.assertEqual( service.HostResult("Err - unsupported host request"), service.handle_request("reboot now", Path("/unused"), allow_reboot=True), ) + for injected in ( + "network restart now", + "network restart\nreboot", + f"action status {operation_id};reboot", + f"action status {operation_id.lower()}", + ): + with self.subTest(injected=injected): + self.assertIsNone( + service.handle_request( + injected, + Path("/unused"), + allow_reboot=True, + allow_network_restart=True, + ).host_action + ) + + def test_clock_status_is_read_only_and_strictly_bounded(self) -> None: + status_result = mock.Mock(returncode=0, stdout="yes\n") + with mock.patch.object(service.time, "time", return_value=1788147000), \ + mock.patch.object( + service.subprocess, "run", return_value=status_result + ) as run: + status = service.handle_request("clock status", Path("/unused")) + self.assertEqual( + "Clock epoch 1788147000; NTP synchronized yes", status.text + ) + self.assertEqual( + [ + "/usr/bin/timedatectl", "show", + "--property=NTPSynchronized", "--value", + ], + run.call_args.args[0], + ) + self.assertFalse(run.call_args.kwargs["shell"]) + self.assertEqual( + service.CLOCK_STATUS_CHILD_TIMEOUT_SECONDS, + run.call_args.kwargs["timeout"], + ) + + disabled = service.handle_request( + "clock set 1788147000", Path("/unused") + ) + self.assertEqual("Err - host clock control is disabled", disabled.text) + disabled_sync = service.handle_request("clock sync", Path("/unused")) + self.assertEqual( + "Err - host clock control is disabled", disabled_sync.text + ) + + rejected = ( + "clock set", + "clock set ", + "clock set +1788147000", + "clock set -1", + "clock set 01788147000", + "clock set 1788147000 extra", + "clock set 1788147000\nreboot", + "clock set 999999999999999999999999999999999999", + ) + with mock.patch.object(service, "_exchange_clock_control") as exchange: + for request in rejected: + with self.subTest(request=request): + result = service.handle_request( + request, Path("/unused"), allow_clock_control=True + ) + self.assertTrue(result.text.startswith("Err -")) + exchange.assert_not_called() + + def test_clock_control_uses_only_the_fixed_unix_socket_protocol(self) -> None: + with mock.patch.object( + service, + "_exchange_clock_control", + return_value=( + "OK clock set to 1788147000; NTP sync requested" + ), + ) as exchange, mock.patch.object(service.subprocess, "run") as run: + result = service.handle_request( + "clock set 1788147000", + Path("/unused"), + allow_clock_control=True, + ) + self.assertEqual( + "OK - clock set to 1788147000; NTP sync requested", result.text + ) + exchange.assert_called_once_with("set 1788147000") + run.assert_not_called() + + with mock.patch.object( + service, + "_exchange_clock_control", + return_value="OK NTP sync requested", + ) as exchange, mock.patch.object(service.subprocess, "run") as run: + result = service.handle_request( + "clock sync", Path("/unused"), allow_clock_control=True + ) + self.assertEqual("OK - NTP sync requested", result.text) + exchange.assert_called_once_with("sync") + run.assert_not_called() + + def test_clock_status_uses_chrony_when_systemd_marker_is_wrong(self) -> None: + systemd = mock.Mock(returncode=0, stdout="no\n") + chrony = mock.Mock( + returncode=0, + stdout=( + "Stratum : 4\n" + "System time : 0.000000000 seconds fast of NTP time\n" + "Leap status : Normal\n" + ), + ) + with mock.patch.object(service.time, "time", return_value=1788147000), \ + mock.patch.object( + service.subprocess, "run", side_effect=[systemd, chrony] + ) as run, mock.patch.object(Path, "is_file", return_value=True), \ + mock.patch.object(service.os, "access", return_value=True): + result = service.handle_request("clock status", Path("/unused")) + self.assertEqual( + "Clock epoch 1788147000; NTP synchronized yes (chrony)", + result.text, + ) + self.assertEqual( + ["/usr/bin/chronyc", "-n", "tracking"], + run.call_args.args[0], + ) + self.assertEqual(1.5, run.call_args.kwargs["timeout"]) + + def test_clock_epoch_boundaries_in_both_layers(self) -> None: + for epoch in (service.MIN_CLOCK_EPOCH, service.MAX_CLOCK_EPOCH): + with self.subTest(epoch=epoch), mock.patch.object( + service, + "_exchange_clock_control", + return_value=f"OK clock set to {epoch}; NTP sync requested", + ) as exchange: + result = service.handle_request( + f"clock set {epoch}", + Path("/unused"), + allow_clock_control=True, + ) + self.assertEqual( + f"OK - clock set to {epoch}; NTP sync requested", result.text + ) + exchange.assert_called_once_with(f"set {epoch}") + + with mock.patch.object(service, "_exchange_clock_control") as exchange: + for epoch in ( + service.MIN_CLOCK_EPOCH - 1, + service.MAX_CLOCK_EPOCH + 1, + ): + with self.subTest(epoch=epoch): + result = service.handle_request( + f"clock set {epoch}", + Path("/unused"), + allow_clock_control=True, + ) + self.assertIn("2020 through 2099", result.text) + exchange.assert_not_called() + + for epoch in ( + clock_helper.MIN_CLOCK_EPOCH, + clock_helper.MAX_CLOCK_EPOCH, + ): + self.assertEqual( + ("set", epoch), + clock_helper.parse_request(f"set {epoch}\n".encode()), + ) + for epoch in ( + clock_helper.MIN_CLOCK_EPOCH - 1, + clock_helper.MAX_CLOCK_EPOCH + 1, + ): + with self.assertRaises(ValueError): + clock_helper.parse_request(f"set {epoch}\n".encode()) + + def test_clock_helper_timeouts_are_bounded_and_fail_closed(self) -> None: + self.assertLess( + 2 * clock_helper.CHILD_TIMEOUT_SECONDS, + service.CLOCK_CONTROL_TIMEOUT_SECONDS, + ) + self.assertLess(service.CLOCK_CONTROL_TIMEOUT_SECONDS, 6) + timeout = subprocess.TimeoutExpired(["timedatectl"], 1.5) + with mock.patch.object( + clock_helper.subprocess, "run", side_effect=timeout + ) as run: + self.assertEqual( + "NTP enable request failed", clock_helper.request_ntp_sync() + ) + self.assertEqual(1, run.call_count) + self.assertEqual( + clock_helper.CHILD_TIMEOUT_SECONDS, + run.call_args.kwargs["timeout"], + ) + + enabled = mock.Mock(returncode=0) + with mock.patch.object( + clock_helper.subprocess, + "run", + side_effect=[enabled, timeout], + ) as run, mock.patch.object( + clock_helper.Path, "is_file", return_value=False + ): + self.assertEqual( + "systemd-timesyncd restart failed", + clock_helper.request_ntp_sync(), + ) + self.assertEqual(2, run.call_count) + self.assertEqual( + [ + "/usr/bin/systemctl", + "restart", + "systemd-timesyncd.service", + ], + run.call_args.args[0], + ) + + with mock.patch.object( + clock_helper.subprocess, "run", side_effect=FileNotFoundError + ) as run: + self.assertEqual( + "NTP enable request failed", clock_helper.request_ntp_sync() + ) + self.assertEqual(1, run.call_count) + + def test_clock_set_never_disables_ntp_and_reports_partial_success( + self, + ) -> None: + with mock.patch.object(clock_helper.time, "clock_settime") as settime, \ + mock.patch.object( + clock_helper, + "request_ntp_sync", + return_value="chrony step request failed", + ) as sync, \ + mock.patch.object(clock_helper, "run_fixed") as run_fixed: + self.assertEqual( + (True, "chrony step request failed"), + clock_helper.set_clock(1788147000), + ) + settime.assert_called_once_with( + clock_helper.time.CLOCK_REALTIME, 1788147000.0 + ) + sync.assert_called_once_with() + run_fixed.assert_not_called() + + with mock.patch.object( + clock_helper, + "set_clock", + return_value=(True, clock_helper.CHRONY_STEP_ERROR), + ): + self.assertEqual( + "PARTIAL clock set to 1788147000; NTP enabled; " + "chrony step request failed", + clock_helper.process_request(b"set 1788147000\n"), + ) + self.assertEqual( + "Warning - clock set to 1788147000; NTP enabled; " + "chrony step request failed", + service._clock_control_result( + "PARTIAL clock set to 1788147000; NTP enabled; " + "chrony step request failed", + "set", + 1788147000, + ).text, + ) + + with mock.patch.object( + clock_helper.time, "clock_settime", side_effect=OSError + ), mock.patch.object(clock_helper, "request_ntp_sync") as sync: + self.assertEqual( + (False, None), clock_helper.set_clock(1788147000) + ) + sync.assert_not_called() def test_reply_is_utf8_bounded(self) -> None: text = "x" * 161 + "\N{EURO SIGN}" @@ -332,6 +627,411 @@ class RequestHandlerTests(unittest.TestCase): service.load_programs_file(config) +class ClockControlServiceTests(unittest.TestCase): + def test_daemon_protocol_rejects_injection_before_mutation(self) -> None: + rejected = ( + b"", + b"sync", + b"sync\r\n", + b" sync\n", + b"sync \n", + b"sync\nset 1788147000\n", + b"set\n", + b"set +1788147000\n", + b"set -1788147000\n", + b"set 01788147000\n", + b"set 1788147000 extra\n", + b"set 1788147000\x00\n", + b"set 1788147000\nreboot", + b"set 999999999999999999999999\n", + b"\xff\n", + b"x" * (clock_helper.MAX_REQUEST_BYTES + 1), + ) + with mock.patch.object(clock_helper, "set_clock") as set_clock, \ + mock.patch.object(clock_helper, "request_ntp_sync") as sync: + for request in rejected: + with self.subTest(request=request): + with self.assertRaises(ValueError): + clock_helper.process_request(request) + set_clock.assert_not_called() + sync.assert_not_called() + + self.assertEqual( + ("sync", None), clock_helper.parse_request(b"sync\n") + ) + self.assertEqual( + ("set", 1788147000), + clock_helper.parse_request(b"set 1788147000\n"), + ) + + def test_daemon_reports_success_failure_and_partial_outcomes(self) -> None: + with mock.patch.object( + clock_helper, "request_ntp_sync", return_value=None + ): + self.assertEqual( + "OK NTP sync requested", + clock_helper.process_request(b"sync\n"), + ) + with mock.patch.object( + clock_helper, + "request_ntp_sync", + return_value=clock_helper.NTP_ENABLE_ERROR, + ): + self.assertEqual( + "ERR NTP enable request failed", + clock_helper.process_request(b"sync\n"), + ) + with mock.patch.object( + clock_helper, + "request_ntp_sync", + return_value=clock_helper.CHRONY_STEP_ERROR, + ): + self.assertEqual( + "PARTIAL NTP enabled; chrony step request failed", + clock_helper.process_request(b"sync\n"), + ) + + outcomes = ( + ((False, None), "ERR clock set failed"), + ( + (True, None), + "OK clock set to 1788147000; NTP sync requested", + ), + ( + (True, clock_helper.NTP_ENABLE_ERROR), + "PARTIAL clock set to 1788147000; " + "NTP enable request failed", + ), + ( + (True, clock_helper.TIMESYNCD_RESTART_ERROR), + "PARTIAL clock set to 1788147000; NTP enabled; " + "systemd-timesyncd restart failed", + ), + ) + for outcome, expected in outcomes: + with self.subTest(outcome=outcome), mock.patch.object( + clock_helper, "set_clock", return_value=outcome + ): + self.assertEqual( + expected, + clock_helper.process_request(b"set 1788147000\n"), + ) + + def test_peer_credentials_reject_wrong_uid_or_primary_gid(self) -> None: + for credentials in ((123, 999, 456), (123, 123, 999)): + connection = mock.Mock() + with self.subTest(credentials=credentials), mock.patch.object( + clock_helper, "peer_credentials", return_value=credentials + ), mock.patch.object( + clock_helper, "process_request" + ) as process: + self.assertFalse( + clock_helper.handle_connection( + connection, allowed_uid=123, allowed_gid=456 + ) + ) + process.assert_not_called() + connection.sendall.assert_called_once_with( + b"ERR unauthorized peer\n" + ) + + def test_authorized_peer_gets_one_bounded_request(self) -> None: + connection = mock.Mock() + with mock.patch.object( + clock_helper, "peer_credentials", return_value=(99, 123, 456) + ), mock.patch.object( + clock_helper, "receive_request", return_value=b"sync\n" + ) as receive, mock.patch.object( + clock_helper, "process_request", return_value="OK NTP sync requested" + ) as process: + self.assertTrue( + clock_helper.handle_connection( + connection, allowed_uid=123, allowed_gid=456 + ) + ) + receive.assert_called_once_with(connection) + process.assert_called_once_with(b"sync\n") + connection.sendall.assert_called_once_with(b"OK NTP sync requested\n") + + def test_client_and_daemon_require_root_group_mode_0660_socket(self) -> None: + safe = mock.Mock( + st_mode=stat.S_IFSOCK | 0o660, + st_uid=0, + st_gid=456, + ) + with mock.patch.object(Path, "lstat", return_value=safe): + service.validate_clock_control_socket( + Path("/run/test.sock"), expected_gid=456 + ) + clock_helper.validate_socket_metadata(safe, 456) + + listener = mock.Mock(family=socket.AF_UNIX) + listener.getsockopt.side_effect = (socket.SOCK_STREAM, 1) + listener.getsockname.return_value = "/run/test.sock" + with mock.patch.object(Path, "lstat", return_value=safe): + clock_helper.validate_listening_socket( + listener, + path=Path("/run/test.sock"), + expected_gid=456, + ) + + wrong_type = mock.Mock(family=socket.AF_UNIX) + wrong_type.getsockopt.return_value = socket.SOCK_SEQPACKET + with self.assertRaisesRegex(ValueError, "SOCK_STREAM"): + clock_helper.validate_listening_socket( + wrong_type, + path=Path("/run/test.sock"), + expected_gid=456, + ) + + unsafe = ( + mock.Mock(st_mode=stat.S_IFREG | 0o660, st_uid=0, st_gid=456), + mock.Mock(st_mode=stat.S_IFSOCK | 0o660, st_uid=1, st_gid=456), + mock.Mock(st_mode=stat.S_IFSOCK | 0o666, st_uid=0, st_gid=456), + mock.Mock(st_mode=stat.S_IFSOCK | 0o660, st_uid=0, st_gid=999), + ) + for metadata in unsafe: + with self.subTest(metadata=metadata), mock.patch.object( + Path, "lstat", return_value=metadata + ): + with self.assertRaises(ValueError): + service.validate_clock_control_socket( + Path("/run/test.sock"), expected_gid=456 + ) + with self.assertRaises(ValueError): + clock_helper.validate_socket_metadata(metadata, 456) + + def test_endpoint_sends_exact_line_and_authenticates_root_server(self) -> None: + class Connection: + def __init__(self, server_uid: int = 0) -> None: + self.server_uid = server_uid + self.response = [b"OK NTP sync requested\n", b""] + self.sent = b"" + self.connected = "" + self.shutdown_how: int | None = None + + def __enter__(self) -> "Connection": + return self + + def __exit__(self, *_args: object) -> None: + return None + + def settimeout(self, _timeout: float) -> None: + pass + + def connect(self, path: str) -> None: + self.connected = path + + def getsockopt(self, *_args: object) -> bytes: + return struct.pack("3i", 99, self.server_uid, 0) + + def sendall(self, value: bytes) -> None: + self.sent += value + + def shutdown(self, how: int) -> None: + self.shutdown_how = how + + def recv(self, _size: int) -> bytes: + return self.response.pop(0) + + connection = Connection() + with mock.patch.object( + service, "validate_clock_control_socket" + ) as validate, mock.patch.object( + service.socket, "socket", return_value=connection + ) as constructor: + self.assertEqual( + "OK NTP sync requested", + service._exchange_clock_control("sync"), + ) + constructor.assert_called_once_with(socket.AF_UNIX, socket.SOCK_STREAM) + validate.assert_called_once_with( + service.CLOCK_CONTROL_SOCKET, + expected_gid=os.getegid(), + ) + self.assertEqual(str(service.CLOCK_CONTROL_SOCKET), connection.connected) + self.assertEqual(b"sync\n", connection.sent) + self.assertEqual(socket.SHUT_WR, connection.shutdown_how) + + with mock.patch.object( + service, "validate_clock_control_socket" + ), mock.patch.object( + service.socket, "socket", return_value=Connection(server_uid=1000) + ): + with self.assertRaises(PermissionError): + service._exchange_clock_control("sync") + + def test_endpoint_fails_closed_on_timeout_errors_and_bad_responses(self) -> None: + failures = ( + (TimeoutError(), "Err - clock control timed out"), + (socket.timeout(), "Err - clock control timed out"), + (PermissionError(), "Err - clock control peer authentication failed"), + (FileNotFoundError(), "Err - clock control unavailable"), + (ValueError(), "Err - clock control unavailable"), + ) + for failure, expected in failures: + with self.subTest(failure=failure), mock.patch.object( + service, "_exchange_clock_control", side_effect=failure + ): + self.assertEqual( + expected, + service.run_clock_control("sync").text, + ) + + invalid = ( + "OK clock set to 1788147001; NTP sync requested", + "OK NTP sync requested; reboot", + "PARTIAL clock set to 1788147001; NTP enable request failed", + "PARTIAL clock set to 1788147000; NTP enabled; " + "NTP enable request failed", + "PARTIAL clock set to 1788147000; chrony step request failed", + "PARTIAL clock set to 1788147000; " + "systemd-timesyncd restart failed", + "ERR attacker supplied text", + ) + for response in invalid: + with self.subTest(response=response): + self.assertEqual( + "Err - invalid clock control response", + service._clock_control_result( + response, "set", 1788147000 + ).text, + ) + + def test_request_timeout_returns_error_without_mutation(self) -> None: + connection = mock.Mock() + with mock.patch.object( + clock_helper, "peer_credentials", return_value=(99, 123, 456) + ), mock.patch.object( + clock_helper, "receive_request", side_effect=socket.timeout + ), mock.patch.object(clock_helper, "process_request") as process: + self.assertFalse( + clock_helper.handle_connection( + connection, allowed_uid=123, allowed_gid=456 + ) + ) + process.assert_not_called() + connection.sendall.assert_called_once_with(b"ERR request timed out\n") + + trickle = mock.Mock() + trickle.recv.return_value = b"s" + with mock.patch.object( + clock_helper.time, + "monotonic", + side_effect=(0.0, 0.2, 1.1), + ): + with self.assertRaises(socket.timeout): + clock_helper.receive_request(trickle) + trickle.recv.assert_called_once() + + def test_child_commands_are_exact_bounded_argv_without_a_shell(self) -> None: + success = mock.Mock(returncode=0) + with mock.patch.object( + clock_helper.subprocess, "run", return_value=success + ) as run, mock.patch.object( + clock_helper.Path, "is_file", return_value=True + ), mock.patch.object(clock_helper.os, "access", return_value=True): + self.assertIsNone(clock_helper.request_ntp_sync()) + self.assertEqual(2, run.call_count) + self.assertEqual( + ["/usr/bin/timedatectl", "set-ntp", "true"], + run.call_args_list[0].args[0], + ) + self.assertEqual( + [ + "/usr/bin/systemctl", + "start", + clock_helper.CHRONY_STEP_SERVICE, + ], + run.call_args_list[1].args[0], + ) + self.assertNotIn( + "/usr/bin/chronyc", + (argument for call in run.call_args_list for argument in call.args[0]), + ) + for call in run.call_args_list: + self.assertFalse(call.kwargs["shell"]) + self.assertEqual( + clock_helper.CHILD_TIMEOUT_SECONDS, call.kwargs["timeout"] + ) + self.assertEqual("/", call.kwargs["cwd"]) + + def test_systemd_examples_pin_socket_owner_group_and_mode(self) -> None: + directory = Path(__file__).resolve().parent + socket_unit = (directory / "meshcore-clock-control.socket").read_text( + encoding="utf-8" + ) + service_unit = (directory / "meshcore-clock-control.service").read_text( + encoding="utf-8" + ) + chrony_unit = (directory / "meshcore-chrony-step.service").read_text( + encoding="utf-8" + ) + self.assertIn("ListenStream=/run/meshcore-clock-control.sock", socket_unit) + self.assertIn("SocketUser=root", socket_unit) + self.assertIn("SocketGroup=mctomqtt", socket_unit) + self.assertIn("SocketMode=0660", socket_unit) + self.assertIn("User=root", service_unit) + self.assertIn("--service-user mctomqtt", service_unit) + self.assertIn("--service-group mctomqtt", service_unit) + self.assertIn("CapabilityBoundingSet=CAP_SYS_TIME", service_unit) + self.assertNotIn("CAP_DAC_OVERRIDE", service_unit) + + self.assertIn("Type=oneshot", chrony_unit) + self.assertIn("User=_chrony", chrony_unit) + self.assertIn("Group=_chrony", chrony_unit) + self.assertIn("ExecStart=/usr/bin/chronyc -a makestep", chrony_unit) + self.assertIn("TimeoutStartSec=1s", chrony_unit) + self.assertIn("NoNewPrivileges=yes", chrony_unit) + self.assertIn("\nCapabilityBoundingSet=\n", chrony_unit) + self.assertIn("\nAmbientCapabilities=\n", chrony_unit) + self.assertIn("RestrictAddressFamilies=AF_UNIX", chrony_unit) + self.assertIn("ProtectClock=yes", chrony_unit) + self.assertIn("ProtectSystem=strict", chrony_unit) + self.assertNotIn("CAP_", chrony_unit) + self.assertNotIn("[Install]", chrony_unit) + + def test_activation_requires_one_named_systemd_descriptor_and_root(self) -> None: + listener = mock.Mock() + environment = { + "LISTEN_PID": "4242", + "LISTEN_FDS": "1", + "LISTEN_FDNAMES": "clock-control", + } + with mock.patch.object( + clock_helper.os, "getpid", return_value=4242 + ), mock.patch.object( + clock_helper.socket, "socket", return_value=listener + ) as constructor: + self.assertIs(listener, clock_helper.activation_socket(environment)) + constructor.assert_called_once_with( + fileno=clock_helper.SYSTEMD_LISTEN_FDS_START + ) + + invalid = ( + {**environment, "LISTEN_PID": "7"}, + {**environment, "LISTEN_FDS": "2"}, + {**environment, "LISTEN_FDNAMES": "other"}, + ) + with mock.patch.object(clock_helper.os, "getpid", return_value=4242): + for variables in invalid: + with self.subTest(variables=variables), self.assertRaises( + ValueError + ): + clock_helper.activation_socket(variables) + + with mock.patch.object(clock_helper.os, "geteuid", return_value=1000), \ + mock.patch.object(clock_helper, "activation_socket") as activate, \ + mock.patch("builtins.print"): + self.assertEqual( + 1, + clock_helper.main( + ["--service-user", "mctomqtt", "--service-group", "mctomqtt"] + ), + ) + activate.assert_not_called() + + class TokenTests(unittest.TestCase): def test_signed_remote_serial_token_has_required_claims(self) -> None: key = service.ServiceKey(PUBLIC_KEY, PRIVATE_KEY) @@ -366,15 +1066,27 @@ class TokenTests(unittest.TestCase): class EndpointTests(unittest.TestCase): - def test_request_needs_matching_live_claim_before_reboot(self) -> None: + def test_reboot_pending_broker_error_is_specific_and_validated(self) -> None: + operation_id = "A1" * 16 + self.assertEqual( + "ERR reboot pending", + service._validate_host_action_response( + "ERR reboot pending", "prepare", "reboot", operation_id + ), + ) + self.assertEqual( + f"Err - another reboot is pending; {operation_id} not reserved", + service.HostCliEndpoint._prepare_failure_reply( + "reboot", operation_id, "ERR reboot pending" + ), + ) + + def test_request_needs_matching_live_claim_before_action_is_queued(self) -> None: events: list[object] = [] class PublishResult: rc = 0 - def wait_for_publish(self, timeout: float) -> None: - events.append(("wait", timeout)) - class Client: def publish(self, topic: str, token: str, qos: int) -> PublishResult: events.append(("publish", topic, token, qos)) @@ -387,11 +1099,10 @@ class EndpointTests(unittest.TestCase): command_topic="serial/commands", temperature_path=Path("/unused"), allow_reboot=True, - reboot_delay=7, - reboot_scheduler=lambda delay: events.append(("reboot", delay)), challenge_generator=lambda: "A1B2C3D4E5F60718", monotonic=lambda: 100.0, ) + endpoint._enqueue_host_action = mock.Mock(return_value=True) request = service.HostRequest( "12345678", "FEDCBA9876543210", "reboot" ) @@ -407,18 +1118,13 @@ class EndpointTests(unittest.TestCase): events.append(("token", claims["command"])) return "signed" - reboot_result = service.HostResult( - "OK - host reboot scheduled in 7s", reboot_requested=True - ) with mock.patch.object( service, "parse_and_verify_request", return_value=request ), mock.patch.object( service, "create_auth_token", side_effect=make_token - ), mock.patch.object( - service, "handle_request", return_value=reboot_result - ) as handler: + ), mock.patch.object(service, "exchange_host_action") as broker: self.assertTrue(endpoint.handle_mqtt_message(b"request")) - handler.assert_not_called() + broker.assert_not_called() with mock.patch.object( service, "parse_and_verify_request", return_value=None @@ -436,17 +1142,386 @@ class EndpointTests(unittest.TestCase): events[0], ) self.assertEqual("publish", events[1][0]) - self.assertEqual( - ( - "token", - "host.reply 12345678 FEDCBA9876543210 " - "OK - host reboot scheduled in 7s", - ), - events[2], + self.assertEqual(2, len(events)) + expected_operation_id = service.host_action_operation_id( + "44" * 32, request ) - self.assertEqual("publish", events[3][0]) - self.assertEqual(("wait", 2.0), events[4]) - self.assertEqual(("reboot", 7), events[5]) + endpoint._enqueue_host_action.assert_called_once_with( + service.VerifiedHostAction( + request=request, + action="reboot", + operation_id=expected_operation_id, + ) + ) + broker.assert_not_called() + + def test_real_action_worker_keeps_mqtt_callback_nonblocking(self) -> None: + started = threading.Event() + release = threading.Event() + finished = threading.Event() + + class PublishResult: + rc = 0 + + def wait_for_publish(self, timeout: float) -> None: + self.timeout = timeout + + def is_published(self) -> bool: + return True + + class Client: + def publish( + self, _topic: str, _token: str, qos: int + ) -> PublishResult: + self.qos = qos + return PublishResult() + + def exchange( + verb: str, + operation_id: str, + action: str | None, + *, + deadline: float, + ) -> str: + del deadline + if verb == "prepare": + started.set() + self.assertTrue(release.wait(timeout=2.0)) + return f"PREPARED {action} {operation_id}" + finished.set() + return f"SCHEDULED {action} {operation_id}" + + endpoint = service.HostCliEndpoint( + client=Client(), + repeater_public_key="44" * 32, + service_key=service.ServiceKey(PUBLIC_KEY, PRIVATE_KEY), + command_topic="serial/commands", + temperature_path=Path("/unused"), + allow_reboot=True, + host_action_exchange=exchange, + challenge_generator=lambda: "A1B2C3D4E5F60718", + monotonic=lambda: 100.0, + ) + request = service.HostRequest( + "12345678", "FEDCBA9876543210", "reboot" + ) + claim = service.HostClaim( + request.request_id, + request.request_nonce, + "A1B2C3D4E5F60718", + ) + with mock.patch.object( + service, "create_auth_token", return_value="signed" + ): + endpoint._handle_request(request) + self.assertTrue(endpoint._handle_claim(claim)) + self.assertTrue(started.wait(timeout=1.0)) + self.assertFalse(release.is_set()) + release.set() + self.assertTrue(finished.wait(timeout=2.0)) + endpoint._action_queue.join() + + def test_action_worker_orders_prepare_publish_confirmation_then_commit( + self, + ) -> None: + events: list[object] = [] + operation_id = "A1" * 16 + + class PublishResult: + rc = 0 + + def wait_for_publish(self, timeout: float) -> None: + events.append(("wait", timeout)) + + def is_published(self) -> bool: + events.append("is-published") + return True + + class Client: + def publish(self, topic: str, token: str, qos: int) -> PublishResult: + events.append(("publish", topic, token, qos)) + return PublishResult() + + def exchange( + verb: str, + sent_operation_id: str, + action: str | None, + *, + deadline: float, + ) -> str: + self.assertGreater(deadline, service.time.monotonic()) + events.append((verb, sent_operation_id, action)) + state_name = "PREPARED" if verb == "prepare" else "SCHEDULED" + return f"{state_name} {action} {sent_operation_id}" + + endpoint = service.HostCliEndpoint( + client=Client(), + repeater_public_key="44" * 32, + service_key=service.ServiceKey(PUBLIC_KEY, PRIVATE_KEY), + command_topic="serial/commands", + temperature_path=Path("/unused"), + allow_reboot=True, + host_action_exchange=exchange, + ) + request = service.HostRequest( + "12345678", "FEDCBA9876543210", "reboot" + ) + with mock.patch.object( + service, "create_auth_token", return_value="signed" + ): + endpoint._process_host_action( + service.VerifiedHostAction(request, "reboot", operation_id) + ) + + self.assertEqual( + [ + ("prepare", operation_id, "reboot"), + ("publish", "serial/commands", "signed", 1), + ("wait", service.HOST_ACTION_PUBLISH_TIMEOUT_SECONDS), + "is-published", + ("commit", operation_id, "reboot"), + ], + events, + ) + + def test_action_worker_fails_closed_without_mqtt_confirmation(self) -> None: + operation_id = "A2" * 16 + request = service.HostRequest( + "12345678", "FEDCBA9876543210", "network restart" + ) + + class Client: + def __init__(self, publish_result: object) -> None: + self.publish_result = publish_result + + def publish(self, _topic: str, _token: str, qos: int) -> object: + self.qos = qos + return self.publish_result + + class Result: + rc = 0 + + def __init__(self, outcome: str) -> None: + self.outcome = outcome + + def wait_for_publish(self, timeout: float) -> None: + self.timeout = timeout + if self.outcome == "raise": + raise RuntimeError("disconnected") + + def is_published(self) -> bool: + return self.outcome == "published" + + for label, publish_result in ( + ("missing-methods", mock.Mock(rc=0, spec=["rc"])), + ("wait-error", Result("raise")), + ("unpublished", Result("unpublished")), + ): + calls: list[str] = [] + + def exchange( + verb: str, + sent_operation_id: str, + action: str | None, + *, + deadline: float, + ) -> str: + del deadline + calls.append(verb) + return f"PREPARED {action} {sent_operation_id}" + + endpoint = service.HostCliEndpoint( + client=Client(publish_result), + repeater_public_key="44" * 32, + service_key=service.ServiceKey(PUBLIC_KEY, PRIVATE_KEY), + command_topic="serial/commands", + temperature_path=Path("/unused"), + allow_network_restart=True, + host_action_exchange=exchange, + ) + with self.subTest(label=label), mock.patch.object( + service, "create_auth_token", return_value="signed" + ): + endpoint._process_host_action( + service.VerifiedHostAction( + request, "network-restart", operation_id + ) + ) + self.assertEqual(["prepare"], calls) + + def test_action_worker_never_retries_an_ambiguous_commit(self) -> None: + operation_id = "A3" * 16 + calls: list[str] = [] + + class PublishResult: + rc = 0 + + def wait_for_publish(self, timeout: float) -> None: + self.timeout = timeout + + def is_published(self) -> bool: + return True + + client = mock.Mock() + client.publish.return_value = PublishResult() + + def exchange( + verb: str, + sent_operation_id: str, + action: str | None, + *, + deadline: float, + ) -> str: + del deadline + calls.append(verb) + if verb == "commit": + raise socket.timeout("reply lost") + return f"PREPARED {action} {sent_operation_id}" + + endpoint = service.HostCliEndpoint( + client=client, + repeater_public_key="44" * 32, + service_key=service.ServiceKey(PUBLIC_KEY, PRIVATE_KEY), + command_topic="serial/commands", + temperature_path=Path("/unused"), + allow_reboot=True, + host_action_exchange=exchange, + ) + with mock.patch.object( + service, "create_auth_token", return_value="signed" + ): + endpoint._process_host_action( + service.VerifiedHostAction( + service.HostRequest( + "12345678", "FEDCBA9876543210", "reboot" + ), + "reboot", + operation_id, + ) + ) + self.assertEqual(["prepare", "commit"], calls) + + def test_action_worker_does_not_commit_after_total_deadline(self) -> None: + operation_id = "A6" * 16 + calls: list[str] = [] + + class PublishResult: + rc = 0 + + def wait_for_publish(self, timeout: float) -> None: + self.timeout = timeout + + def is_published(self) -> bool: + return True + + client = mock.Mock() + client.publish.return_value = PublishResult() + + def exchange( + verb: str, + sent_operation_id: str, + action: str | None, + *, + deadline: float, + ) -> str: + del deadline + calls.append(verb) + return f"PREPARED {action} {sent_operation_id}" + + endpoint = service.HostCliEndpoint( + client=client, + repeater_public_key="44" * 32, + service_key=service.ServiceKey(PUBLIC_KEY, PRIVATE_KEY), + command_topic="serial/commands", + temperature_path=Path("/unused"), + allow_reboot=True, + host_action_exchange=exchange, + ) + with mock.patch.object( + service, "create_auth_token", return_value="signed" + ), mock.patch.object( + service.time, "monotonic", side_effect=(0.0, 1.0, 6.0) + ), self.assertLogs(service.LOGGER, level="ERROR"): + endpoint._process_host_action( + service.VerifiedHostAction( + service.HostRequest( + "12345678", "FEDCBA9876543210", "reboot" + ), + "reboot", + operation_id, + ) + ) + self.assertEqual(["prepare"], calls) + + def test_action_status_reports_exact_broker_state_without_commit(self) -> None: + operation_id = "A4" * 16 + calls: list[str] = [] + client = mock.Mock() + client.publish.return_value = mock.Mock(rc=0) + + def exchange( + verb: str, + sent_operation_id: str, + action: str | None, + *, + deadline: float, + ) -> str: + del deadline + calls.append(verb) + self.assertIsNone(action) + return f"AMBIGUOUS reboot {sent_operation_id}" + + endpoint = service.HostCliEndpoint( + client=client, + repeater_public_key="44" * 32, + service_key=service.ServiceKey(PUBLIC_KEY, PRIVATE_KEY), + command_topic="serial/commands", + temperature_path=Path("/unused"), + allow_reboot=True, + host_action_exchange=exchange, + ) + request = service.HostRequest( + "12345678", + "FEDCBA9876543210", + f"action status {operation_id}", + ) + with mock.patch.object( + service, "create_auth_token", return_value="signed" + ) as token: + endpoint._process_host_action( + service.VerifiedHostAction(request, "status", operation_id) + ) + self.assertEqual(["status"], calls) + self.assertIn( + f"Host action {operation_id}: reboot ambiguous", + token.call_args.args[1]["command"], + ) + + def test_overlapping_action_is_rejected_before_broker_reservation(self) -> None: + endpoint = service.HostCliEndpoint( + client=mock.Mock(), + repeater_public_key="44" * 32, + service_key=service.ServiceKey(PUBLIC_KEY, PRIVATE_KEY), + command_topic="serial/commands", + temperature_path=Path("/unused"), + allow_reboot=True, + ) + self.assertTrue(endpoint._action_slot.acquire(blocking=False)) + work = service.VerifiedHostAction( + service.HostRequest( + "12345678", "FEDCBA9876543210", "reboot" + ), + "reboot", + "A5" * 16, + ) + endpoint._publish_serial_reply = mock.Mock() + self.assertFalse(endpoint._enqueue_host_action(work)) + endpoint._publish_serial_reply.assert_called_once_with( + work.request, + "Err - another host action is active; action was not reserved", + ) + self.assertFalse(endpoint._action_worker_started) + endpoint._action_slot.release() def test_mismatched_expired_and_replayed_claims_do_not_execute(self) -> None: now = [10.0] diff --git a/examples/host_cli_service/test_meshcore_host_actions.py b/examples/host_cli_service/test_meshcore_host_actions.py new file mode 100644 index 000000000..c7c25dfab --- /dev/null +++ b/examples/host_cli_service/test_meshcore_host_actions.py @@ -0,0 +1,735 @@ +from __future__ import annotations + +import os +from pathlib import Path +import shutil +import socket +import stat +import struct +import subprocess +import tempfile +import unittest +from unittest import mock + +import host_cli_service as endpoint +import meshcore_host_actions as broker + + +OPERATION_ID = "A1" * 16 + + +def operation( + operation_id: str, + action: str, + state_name: str, + sequence: int, +) -> broker.OperationRecord: + return broker.OperationRecord( + operation_id, action, state_name, sequence + ) + + +class ProtocolTests(unittest.TestCase): + def test_accepts_only_the_bounded_canonical_ascii_protocol(self) -> None: + self.assertEqual( + ("prepare", "reboot", OPERATION_ID), + broker.parse_request(f"prepare reboot {OPERATION_ID}\n".encode()), + ) + self.assertEqual( + ("commit", "network-restart", OPERATION_ID), + broker.parse_request( + f"commit network-restart {OPERATION_ID}\n".encode() + ), + ) + self.assertEqual( + ("status", None, OPERATION_ID), + broker.parse_request(f"status {OPERATION_ID}\n".encode()), + ) + + invalid = ( + b"", + f"prepare reboot {OPERATION_ID}".encode(), + f"prepare reboot {OPERATION_ID}\nstatus {OPERATION_ID}\n".encode(), + f"prepare reboot {OPERATION_ID.lower()}\n".encode(), + f"prepare reboot {OPERATION_ID};reboot\n".encode(), + f"prepare reboot {OPERATION_ID}\x00\n".encode(), + f"prepare reboot {OPERATION_ID} \n".encode(), + f"prepare shutdown {OPERATION_ID}\n".encode(), + f"run reboot {OPERATION_ID}\n".encode(), + b"\xff\n", + b"x" * (broker.MAX_REQUEST_BYTES + 1), + ) + for request in invalid: + with self.subTest(request=request), self.assertRaises(ValueError): + broker.parse_request(request) + + def test_injection_never_reaches_scheduler(self) -> None: + state = broker.OperationState() + scheduler = mock.Mock(return_value=True) + invalid = ( + f"commit reboot {OPERATION_ID}\nreboot\n".encode(), + f"commit reboot {OPERATION_ID};x\n".encode(), + f"commit network-restart {OPERATION_ID.lower()}\n".encode(), + ) + for request in invalid: + with self.subTest(request=request), self.assertRaises(ValueError): + broker.process_request( + request, + state, + broker.VALID_ACTIONS, + scheduler, + ) + scheduler.assert_not_called() + + def test_endpoint_sends_exact_line_and_authenticates_root_listener(self) -> None: + class Connection: + def __init__(self, uid: int = 0) -> None: + self.uid = uid + self.responses = [ + f"PREPARED reboot {OPERATION_ID}\n".encode(), b"" + ] + + def __enter__(self) -> "Connection": + return self + + def __exit__(self, *_args: object) -> None: + pass + + def settimeout(self, timeout: float) -> None: + self.timeout = timeout + + def connect(self, path: str) -> None: + self.connected = path + + def getsockopt(self, *_args: object) -> bytes: + return struct.pack("3i", 1, self.uid, 0) + + def sendall(self, value: bytes) -> None: + self.sent = value + + def shutdown(self, how: int) -> None: + self.shutdown_how = how + + def recv(self, _length: int) -> bytes: + return self.responses.pop(0) + + connection = Connection() + with mock.patch.object(endpoint, "validate_host_actions_socket") as validate, \ + mock.patch.object(endpoint.socket, "socket", return_value=connection): + self.assertEqual( + f"PREPARED reboot {OPERATION_ID}", + endpoint.exchange_host_action( + "prepare", OPERATION_ID, "reboot" + ), + ) + validate.assert_called_once_with( + endpoint.HOST_ACTIONS_SOCKET, + expected_gid=os.getegid(), + ) + self.assertEqual(str(endpoint.HOST_ACTIONS_SOCKET), connection.connected) + self.assertEqual( + f"prepare reboot {OPERATION_ID}\n".encode(), connection.sent + ) + self.assertEqual(socket.SHUT_WR, connection.shutdown_how) + + with mock.patch.object(endpoint, "validate_host_actions_socket"), \ + mock.patch.object( + endpoint.socket, "socket", return_value=Connection(uid=1000) + ), self.assertRaises(PermissionError): + endpoint.exchange_host_action("prepare", OPERATION_ID, "reboot") + + def test_endpoint_rejects_mismatched_or_impossible_responses(self) -> None: + invalid = ( + f"PREPARED reboot {'B2' * 16}", + f"PREPARED network-restart {OPERATION_ID}", + f"PREPARED reboot {OPERATION_ID}\nreboot", + f"UNKNOWN {OPERATION_ID}", + f"PREPARED reboot {OPERATION_ID}", + ) + verbs = ("prepare", "prepare", "prepare", "prepare", "commit") + for response, verb in zip(invalid, verbs): + with self.subTest(response=response, verb=verb), self.assertRaises( + ValueError + ): + endpoint._validate_host_action_response( + response, verb, "reboot", OPERATION_ID + ) + + def test_socket_metadata_requires_root_service_group_and_0660(self) -> None: + safe = os.stat_result( + (stat.S_IFSOCK | 0o660, 1, 1, 1, 0, 456, 0, 0, 0, 0) + ) + broker.validate_socket_metadata(safe, 456) + with mock.patch.object(Path, "lstat", return_value=safe): + endpoint.validate_host_actions_socket( + Path("/fixed.sock"), expected_gid=456 + ) + + variants = ( + os.stat_result((stat.S_IFREG | 0o660, 1, 1, 1, 0, 456, 0, 0, 0, 0)), + os.stat_result((stat.S_IFSOCK | 0o660, 1, 1, 1, 1000, 456, 0, 0, 0, 0)), + os.stat_result((stat.S_IFSOCK | 0o660, 1, 1, 1, 0, 999, 0, 0, 0, 0)), + os.stat_result((stat.S_IFSOCK | 0o666, 1, 1, 1, 0, 456, 0, 0, 0, 0)), + ) + for metadata in variants: + with self.subTest(mode=metadata.st_mode, uid=metadata.st_uid), \ + self.assertRaises(ValueError): + broker.validate_socket_metadata(metadata, 456) + with self.subTest(endpoint=True), mock.patch.object( + Path, "lstat", return_value=metadata + ), self.assertRaises(ValueError): + endpoint.validate_host_actions_socket( + Path("/fixed.sock"), expected_gid=456 + ) + + def test_operation_id_is_stable_and_does_not_include_action_text(self) -> None: + first = endpoint.HostRequest( + "12345678", "FEDCBA9876543210", "reboot" + ) + same_identity = endpoint.HostRequest( + "12345678", "FEDCBA9876543210", "network restart" + ) + self.assertEqual( + "40CF367EF6D9C3305FB71021DA3E7D92", + endpoint.host_action_operation_id("44" * 32, first), + ) + self.assertEqual( + endpoint.host_action_operation_id("44" * 32, first), + endpoint.host_action_operation_id("44" * 32, same_identity), + ) + + +class OperationStateTests(unittest.TestCase): + def test_prepare_commit_and_duplicates_schedule_exactly_once(self) -> None: + snapshots: list[dict[str, broker.OperationRecord]] = [] + state = broker.OperationState( + persist=lambda records, _next: snapshots.append(dict(records)) + ) + scheduler = mock.Mock(return_value=True) + self.assertEqual( + "prepared", + state.prepare("reboot", OPERATION_ID, broker.VALID_ACTIONS), + ) + self.assertEqual( + "prepared", + state.prepare("reboot", OPERATION_ID, broker.VALID_ACTIONS), + ) + self.assertEqual( + "scheduled", + state.commit( + "reboot", OPERATION_ID, broker.VALID_ACTIONS, scheduler + ), + ) + self.assertEqual( + "scheduled", + state.commit( + "reboot", OPERATION_ID, broker.VALID_ACTIONS, scheduler + ), + ) + scheduler.assert_called_once_with("reboot") + self.assertEqual( + ["prepared", "in-progress", "scheduled"], + [snapshot[OPERATION_ID].state for snapshot in snapshots], + ) + + def test_same_id_different_action_conflicts_and_policy_is_rechecked(self) -> None: + state = broker.OperationState() + state.prepare("reboot", OPERATION_ID, broker.VALID_ACTIONS) + with self.assertRaisesRegex(ValueError, "conflict"): + state.prepare("network-restart", OPERATION_ID, broker.VALID_ACTIONS) + with self.assertRaises(PermissionError): + state.prepare("reboot", OPERATION_ID, frozenset()) + + def test_new_reboot_supersedes_only_uncommitted_reboot_reservations( + self, + ) -> None: + first = "A1" * 16 + second = "B2" * 16 + network = "C3" * 16 + snapshots: list[dict[str, broker.OperationRecord]] = [] + state = broker.OperationState( + persist=lambda records, _next: snapshots.append(dict(records)) + ) + state.prepare("network-restart", network, broker.VALID_ACTIONS) + state.prepare("reboot", first, broker.VALID_ACTIONS) + + self.assertEqual( + "prepared", state.prepare("reboot", second, broker.VALID_ACTIONS) + ) + self.assertNotIn(first, state.records) + self.assertIn(second, state.records) + self.assertIn(network, state.records) + self.assertNotIn(first, snapshots[-1]) + + scheduler = mock.Mock(return_value=True) + with self.assertRaisesRegex(LookupError, "not prepared"): + state.commit("reboot", first, broker.VALID_ACTIONS, scheduler) + scheduler.assert_not_called() + + def test_distinct_reboot_is_rejected_after_commit_starts(self) -> None: + second = "B2" * 16 + for state_name in ("in-progress", "scheduled", "ambiguous"): + with self.subTest(state=state_name): + state = broker.OperationState( + { + OPERATION_ID: operation( + OPERATION_ID, "reboot", state_name, 1 + ) + }, + next_sequence=2, + ) + with self.assertRaisesRegex( + broker.RebootPendingError, "already pending" + ): + state.prepare("reboot", second, broker.VALID_ACTIONS) + effective_state = ( + "ambiguous" if state_name == "in-progress" else state_name + ) + self.assertEqual( + effective_state, state.status(OPERATION_ID).state + ) + self.assertIsNone(state.status(second)) + + # Exact duplicate requests still report their durable state. + self.assertEqual( + effective_state, + state.prepare( + "reboot", OPERATION_ID, broker.VALID_ACTIONS + ), + ) + + def test_reboot_pending_has_fixed_protocol_error(self) -> None: + state = broker.OperationState( + { + OPERATION_ID: operation( + OPERATION_ID, "reboot", "scheduled", 1 + ) + }, + next_sequence=2, + ) + second = "B2" * 16 + self.assertEqual( + "ERR reboot pending", + broker.process_request( + f"prepare reboot {second}\n".encode(), + state, + broker.VALID_ACTIONS, + ), + ) + + def test_recovered_in_progress_is_ambiguous_and_never_retried(self) -> None: + saved: list[dict[str, broker.OperationRecord]] = [] + records = { + OPERATION_ID: operation( + OPERATION_ID, "network-restart", "in-progress", 1 + ) + } + state = broker.OperationState( + records, + next_sequence=2, + persist=lambda candidate, _next: saved.append(dict(candidate)), + ) + self.assertEqual("ambiguous", state.status(OPERATION_ID).state) + self.assertEqual("ambiguous", saved[-1][OPERATION_ID].state) + scheduler = mock.Mock(return_value=True) + self.assertEqual( + "ambiguous", + state.commit( + "network-restart", + OPERATION_ID, + broker.VALID_ACTIONS, + scheduler, + ), + ) + scheduler.assert_not_called() + + def test_scheduler_or_final_persist_failure_is_ambiguous_without_retry( + self, + ) -> None: + for failure in ("scheduler", "persist"): + calls = [0] + + def persist( + _records: dict[str, broker.OperationRecord], _next: int + ) -> None: + calls[0] += 1 + if failure == "persist" and calls[0] == 3: + raise broker.StateError("disk failure") + + state = broker.OperationState(persist=persist) + state.prepare("reboot", OPERATION_ID, broker.VALID_ACTIONS) + scheduler = mock.Mock( + side_effect=(RuntimeError("failure") if failure == "scheduler" else None), + return_value=True, + ) + with self.subTest(failure=failure): + self.assertEqual( + "ambiguous", + state.commit( + "reboot", + OPERATION_ID, + broker.VALID_ACTIONS, + scheduler, + ), + ) + self.assertEqual( + "ambiguous", state.status(OPERATION_ID).state + ) + state.commit( + "reboot", OPERATION_ID, broker.VALID_ACTIONS, scheduler + ) + scheduler.assert_called_once() + + def test_commit_never_schedules_before_in_progress_is_durable(self) -> None: + state = broker.OperationState( + { + OPERATION_ID: operation( + OPERATION_ID, "reboot", "prepared", 1 + ) + }, + next_sequence=2, + persist=mock.Mock(side_effect=broker.StateError("disk failure")), + ) + scheduler = mock.Mock(return_value=True) + with self.assertRaises(broker.StateError): + state.commit( + "reboot", OPERATION_ID, broker.VALID_ACTIONS, scheduler + ) + scheduler.assert_not_called() + self.assertEqual("prepared", state.status(OPERATION_ID).state) + + def test_bounded_store_evicts_only_prepared_records(self) -> None: + records = { + f"{index:032X}": operation( + f"{index:032X}", "network-restart", "scheduled", index + 1 + ) + for index in range(broker.MAX_OPERATIONS) + } + state = broker.OperationState(records, broker.MAX_OPERATIONS + 1) + with self.assertRaises(broker.StateFullError): + state.prepare( + "network-restart", "F" * 32, broker.VALID_ACTIONS + ) + + oldest = next(iter(records)) + records[oldest] = operation( + oldest, "network-restart", "prepared", 1 + ) + state = broker.OperationState(records, broker.MAX_OPERATIONS + 1) + self.assertEqual( + "prepared", + state.prepare( + "network-restart", "F" * 32, broker.VALID_ACTIONS + ), + ) + self.assertNotIn(oldest, state.records) + + def test_state_schema_rejects_boolean_and_unbounded_values(self) -> None: + valid = { + "version": 1, + "next_sequence": 2, + "operations": [ + { + "id": OPERATION_ID, + "action": "reboot", + "state": "prepared", + "sequence": 1, + } + ], + } + broker.parse_state_document(valid) + for path, value in ( + (("version",), True), + (("next_sequence",), True), + (("operations", 0, "sequence"), True), + (("operations", 0, "state"), "complete"), + ): + document = { + "version": valid["version"], + "next_sequence": valid["next_sequence"], + "operations": [dict(valid["operations"][0])], + } + target: object = document + for component in path[:-1]: + target = target[component] # type: ignore[index] + target[path[-1]] = value # type: ignore[index] + with self.subTest(path=path), self.assertRaises(broker.StateError): + broker.parse_state_document(document) + + valid["next_sequence"] = broker.MAX_SEQUENCE + 1 + with self.assertRaises(broker.StateError): + broker.parse_state_document(valid) + + def test_status_is_read_only_and_reports_exact_persisted_state(self) -> None: + state = broker.OperationState() + self.assertEqual( + f"UNKNOWN {OPERATION_ID}", + broker.process_request( + f"status {OPERATION_ID}\n".encode(), + state, + frozenset(), + ), + ) + self.assertEqual( + f"PREPARED reboot {OPERATION_ID}", + broker.process_request( + f"prepare reboot {OPERATION_ID}\n".encode(), + state, + broker.VALID_ACTIONS, + ), + ) + scheduler = mock.Mock() + self.assertEqual( + f"PREPARED reboot {OPERATION_ID}", + broker.process_request( + f"status {OPERATION_ID}\n".encode(), + state, + frozenset(), + scheduler, + ), + ) + scheduler.assert_not_called() + + +class BrokerExecutionTests(unittest.TestCase): + def test_peer_uid_and_primary_gid_are_both_required_before_read(self) -> None: + connection = mock.Mock() + connection.sendall = mock.Mock() + for credentials in ((1, 999, 456), (1, 123, 999)): + with self.subTest(credentials=credentials), mock.patch.object( + broker, "peer_credentials", return_value=credentials + ), mock.patch.object(broker, "receive_request") as receive: + self.assertFalse( + broker.handle_connection( + connection, + allowed_uid=123, + allowed_gid=456, + allowed_actions=broker.VALID_ACTIONS, + state=broker.OperationState(), + ) + ) + receive.assert_not_called() + + def test_authorized_timeout_returns_fixed_error_without_scheduling(self) -> None: + connection = mock.Mock() + scheduler = mock.Mock() + with mock.patch.object( + broker, "peer_credentials", return_value=(1, 123, 456) + ), mock.patch.object( + broker, "receive_request", side_effect=socket.timeout + ), mock.patch.object(broker, "process_request") as process: + self.assertFalse( + broker.handle_connection( + connection, + allowed_uid=123, + allowed_gid=456, + allowed_actions=broker.VALID_ACTIONS, + state=broker.OperationState(), + ) + ) + process.assert_not_called() + connection.sendall.assert_called_once_with(b"ERR request timed out\n") + scheduler.assert_not_called() + + def test_scheduler_uses_only_fixed_systemctl_argv_and_bounds(self) -> None: + completed = mock.Mock(returncode=0) + with mock.patch.object( + broker.subprocess, "run", return_value=completed + ) as run: + self.assertTrue(broker.schedule_action("network-restart")) + self.assertEqual( + [ + "/usr/bin/systemctl", + "--no-block", + "start", + "meshcore-networkmanager-restart.service", + ], + run.call_args.args[0], + ) + self.assertFalse(run.call_args.kwargs["shell"]) + self.assertEqual("/", run.call_args.kwargs["cwd"]) + self.assertEqual( + broker.SCHEDULE_TIMEOUT_SECONDS, + run.call_args.kwargs["timeout"], + ) + self.assertEqual(broker.MINIMAL_ENV, run.call_args.kwargs["env"]) + + with mock.patch.object( + broker.subprocess, + "run", + side_effect=subprocess.TimeoutExpired("systemctl", 1.5), + ): + self.assertFalse(broker.schedule_action("reboot")) + self.assertFalse(broker.schedule_action("shutdown")) + + def test_default_root_policy_is_empty_and_parser_is_exact(self) -> None: + self.assertEqual(frozenset(), broker.parse_allowed_actions("")) + self.assertEqual( + broker.VALID_ACTIONS, + broker.parse_allowed_actions("network-restart,reboot"), + ) + for value in ( + "reboot,network-restart,reboot", + "reboot,", + " reboot", + "shutdown", + "*", + ): + with self.subTest(value=value), self.assertRaises(ValueError): + broker.parse_allowed_actions(value) + + def test_activation_requires_one_named_descriptor_and_root(self) -> None: + listener = mock.Mock() + environment = { + "LISTEN_PID": "4242", + "LISTEN_FDS": "1", + "LISTEN_FDNAMES": "host-actions", + } + with mock.patch.object(broker.os, "getpid", return_value=4242), \ + mock.patch.object(broker.socket, "socket", return_value=listener) as create: + self.assertIs(listener, broker.activation_socket(environment)) + create.assert_called_once_with(fileno=broker.SYSTEMD_LISTEN_FDS_START) + + for invalid in ( + {**environment, "LISTEN_PID": "1"}, + {**environment, "LISTEN_FDS": "2"}, + {**environment, "LISTEN_FDNAMES": "clock-control"}, + ): + with self.subTest(invalid=invalid), mock.patch.object( + broker.os, "getpid", return_value=4242 + ), self.assertRaises(ValueError): + broker.activation_socket(invalid) + + with mock.patch.object(broker.os, "geteuid", return_value=1000), \ + mock.patch.object(broker, "activation_socket") as activate, \ + mock.patch("builtins.print"): + self.assertEqual( + 1, + broker.main( + ["--service-user", "mctomqtt", "--service-group", "mctomqtt"] + ), + ) + activate.assert_not_called() + + def test_state_paths_require_root_only_directory_and_regular_file(self) -> None: + directory_safe = os.stat_result( + (stat.S_IFDIR | 0o700, 1, 1, 1, 0, 0, 0, 0, 0, 0) + ) + file_safe = os.stat_result( + (stat.S_IFREG | 0o600, 1, 1, 1, 0, 0, 10, 0, 0, 0) + ) + with mock.patch.object(Path, "lstat", return_value=directory_safe): + broker.validate_state_directory(Path("/fixed")) + broker._validate_state_file_metadata(file_safe) + + bad_directories = ( + os.stat_result((stat.S_IFDIR | 0o755, 1, 1, 1, 0, 0, 0, 0, 0, 0)), + os.stat_result((stat.S_IFDIR | 0o700, 1, 1, 1, 1000, 0, 0, 0, 0, 0)), + os.stat_result((stat.S_IFREG | 0o700, 1, 1, 1, 0, 0, 0, 0, 0, 0)), + ) + for metadata in bad_directories: + with self.subTest(directory=metadata), mock.patch.object( + Path, "lstat", return_value=metadata + ), self.assertRaises(broker.StateError): + broker.validate_state_directory(Path("/fixed")) + + bad_files = ( + os.stat_result((stat.S_IFREG | 0o644, 1, 1, 1, 0, 0, 10, 0, 0, 0)), + os.stat_result((stat.S_IFREG | 0o600, 1, 1, 1, 1000, 0, 10, 0, 0, 0)), + os.stat_result((stat.S_IFREG | 0o600, 1, 1, 2, 0, 0, 10, 0, 0, 0)), + os.stat_result( + ( + stat.S_IFREG | 0o600, + 1, 1, 1, 0, 0, + broker.MAX_STATE_BYTES + 1, + 0, 0, 0, + ) + ), + ) + for metadata in bad_files: + with self.subTest(file=metadata), self.assertRaises(broker.StateError): + broker._validate_state_file_metadata(metadata) + + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + target = directory / "target" + target.write_text("{}", encoding="ascii") + link = directory / "state.json" + link.symlink_to(target) + state_file = broker.StateFile(link, directory) + with mock.patch.object(broker, "validate_state_directory"), \ + self.assertRaises(broker.StateError): + state_file.load() + + self.assertRaises( + broker.StateError, + broker._strict_json_object, + [("version", 1), ("version", 1)], + ) + + +class SystemdUnitTests(unittest.TestCase): + def test_units_pin_socket_policy_delay_argv_and_empty_capabilities(self) -> None: + directory = Path(__file__).parent + socket_unit = (directory / "meshcore-host-actions.socket").read_text() + broker_unit = (directory / "meshcore-host-actions.service").read_text() + network_unit = ( + directory / "meshcore-networkmanager-restart.service" + ).read_text() + reboot_timer = (directory / "meshcore-host-reboot.timer").read_text() + reboot_unit = (directory / "meshcore-host-reboot.service").read_text() + + self.assertIn( + "ListenStream=/run/meshcore-host-actions.sock", socket_unit + ) + self.assertIn("SocketUser=root", socket_unit) + self.assertIn("SocketGroup=mctomqtt", socket_unit) + self.assertIn("SocketMode=0660", socket_unit) + self.assertIn("Environment=MESHCORE_HOST_ACTIONS=\n", broker_unit) + self.assertIn("User=root", broker_unit) + self.assertIn("CapabilityBoundingSet=\n", broker_unit) + self.assertIn("AmbientCapabilities=\n", broker_unit) + self.assertIn("RestrictAddressFamilies=AF_UNIX", broker_unit) + self.assertIn("RuntimeDirectoryPreserve=yes", broker_unit) + self.assertIn( + "ExecStart=/usr/bin/systemctl --no-block restart NetworkManager.service", + network_unit, + ) + self.assertIn("OnActiveSec=10s", reboot_timer) + self.assertIn("Persistent=no", reboot_timer) + self.assertIn("RemainAfterElapse=no", reboot_timer) + self.assertIn( + "ExecStart=/usr/bin/systemctl reboot", reboot_unit + ) + for action_unit in (network_unit, reboot_unit): + self.assertIn("CapabilityBoundingSet=\n", action_unit) + self.assertIn("AmbientCapabilities=\n", action_unit) + self.assertIn("RestrictAddressFamilies=AF_UNIX", action_unit) + for static_unit in (network_unit, reboot_timer, reboot_unit): + self.assertNotIn("[Install]", static_unit) + + @unittest.skipUnless(shutil.which("systemd-analyze"), "systemd unavailable") + def test_systemd_examples_have_no_parser_errors(self) -> None: + directory = Path(__file__).parent + units = [ + directory / "meshcore-host-actions.socket", + directory / "meshcore-host-actions.service", + directory / "meshcore-networkmanager-restart.service", + directory / "meshcore-host-reboot.timer", + directory / "meshcore-host-reboot.service", + ] + completed = subprocess.run( + ["systemd-analyze", "verify", *(str(path) for path in units)], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=10, + check=False, + ) + output = completed.stdout + for marker in ("Unknown key", "Failed to parse", "Invalid argument"): + self.assertNotIn(marker, output) + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 093fe3c4f..0c78209c3 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -9825,7 +9825,8 @@ bool MyMesh::applyClockEstimate(uint32_t estimate, uint8_t source, uint8_t sampl #ifdef WITH_MQTT_BRIDGE // Close the normal cross-core race where NTP can finish after checkClockSync() // selected mesh fallback but before that estimate reaches the RTC. - if (source == CLOCK_SYNC_SOURCE_MESH && mqtt_bridge != NULL && mqtt_bridge->hasNtpTime()) { + if (source == CLOCK_SYNC_SOURCE_MESH && mqtt_bridge != NULL + && mqtt_bridge->hasFreshNtpThisBoot()) { suppressMeshClockSyncForBoot(CLOCK_SYNC_MESH_SUPPRESS_INTERNET); return true; } @@ -9879,7 +9880,7 @@ void MyMesh::checkClockSync() { // MQTT/WiFi builds already set the RTC as soon as an NTP server answers. // Once that authoritative source succeeds, LoRa remains only a next-boot // fallback and must not replace internet time later in this boot. - if (mqtt_bridge != NULL && mqtt_bridge->hasNtpTime()) { + if (mqtt_bridge != NULL && mqtt_bridge->hasFreshNtpThisBoot()) { suppressMeshClockSyncForBoot(CLOCK_SYNC_MESH_SUPPRESS_INTERNET); } #endif @@ -11583,6 +11584,9 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * } void MyMesh::loop() { +#ifdef WITH_MQTT_BRIDGE + if (mqtt_bridge) mqtt_bridge->servicePendingClockCorrection(); +#endif // Check radio FIRST to ensure we don't miss incoming packets // MQTT processing runs in a separate FreeRTOS task on Core 0, so we don't call bridge.loop() here mesh::Mesh::loop(); diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 747fd17f2..bbd03f669 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -2317,6 +2317,9 @@ bool MyMesh::saveFilter(ClientInfo* client) { } void MyMesh::loop() { +#ifdef WITH_MQTT_BRIDGE + if (bridge) bridge->servicePendingClockCorrection(); +#endif // Check radio FIRST to ensure we don't miss incoming packets // MQTT processing can take time, so we prioritize radio reception mesh::Mesh::loop(); diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 3e4cdaf50..6146ac68a 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -411,7 +411,7 @@ public: void onManualClockSet() override { _clock_sync.onManualClockSet(); } bool hasAuthoritativeClock() const override { #ifdef WITH_MQTT_BRIDGE - return bridge != nullptr && bridge->hasNtpTime(); + return bridge != nullptr && bridge->hasFreshNtpThisBoot(); #else return false; #endif diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index 8c17956c4..606979caa 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -100,8 +100,11 @@ void BaseChatMesh::bootstrapRTCfromContacts() { latest = contacts[i].lastmod; } } - if (latest != 0) { - getRTCClock()->setCurrentTime(latest + 1); + if (latest != 0 && latest != UINT32_MAX) { + const uint32_t contact_floor = latest + 1; + if (contact_floor > getRTCClock()->getCurrentTime()) { + getRTCClock()->setCurrentTime(contact_floor); + } } } diff --git a/src/helpers/CompanionWiFiNtpPolicy.h b/src/helpers/CompanionWiFiNtpPolicy.h new file mode 100644 index 000000000..009198202 --- /dev/null +++ b/src/helpers/CompanionWiFiNtpPolicy.h @@ -0,0 +1,57 @@ +#pragma once + +#include + +namespace mesh { +namespace wifi { + +static constexpr uint32_t kCompanionNtpRefreshMillis = + 24UL * 60UL * 60UL * 1000UL; +static constexpr uint32_t kCompanionNtpFailureRetryMillis = + 5UL * 60UL * 1000UL; +static constexpr uint32_t kCompanionNtpBusyRetryMillis = 5000UL; + +// Schedules an immediate boot attempt, a 24-hour refresh after success, and +// bounded retries after transient failures. All intervals are shorter than +// half of the uint32_t range, so signed deadline comparisons remain safe over +// millis() rollover. +class CompanionWiFiNtpPolicy { +public: + CompanionWiFiNtpPolicy() : _scheduled(false), _deadline(0) {} + + bool attemptDue(uint32_t now) const { + return !_scheduled || static_cast(now - _deadline) >= 0; + } + + void requestNow() { + _scheduled = false; + _deadline = 0; + } + + void noteBusy(uint32_t now) { + scheduleAfter(now, kCompanionNtpBusyRetryMillis); + } + + void noteFailure(uint32_t now) { + scheduleAfter(now, kCompanionNtpFailureRetryMillis); + } + + void noteSuccess(uint32_t now) { + scheduleAfter(now, kCompanionNtpRefreshMillis); + } + + uint32_t deadline() const { return _deadline; } + bool scheduled() const { return _scheduled; } + +private: + bool _scheduled; + uint32_t _deadline; + + void scheduleAfter(uint32_t now, uint32_t interval) { + _deadline = now + interval; + _scheduled = true; + } +}; + +} // namespace wifi +} // namespace mesh diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index 28cc11288..f488ad172 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -5,6 +5,13 @@ #include "UsbLogging.h" #include "UserGpioPinPolicy.h" +namespace mesh { +namespace esp32_clock { +RTC_NOINIT_ATTR uint32_t rtc_backup_time; +RTC_NOINIT_ATTR uint32_t rtc_backup_magic; +} // namespace esp32_clock +} // namespace mesh + namespace { bool isEsp32SystemPin(uint8_t pin) { diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index 7f297b2a1..252b782b7 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -263,8 +263,12 @@ public: } }; -static RTC_NOINIT_ATTR uint32_t _rtc_backup_time; -static RTC_NOINIT_ATTR uint32_t _rtc_backup_magic; +namespace mesh { +namespace esp32_clock { +extern uint32_t rtc_backup_time; +extern uint32_t rtc_backup_magic; +} // namespace esp32_clock +} // namespace mesh #define RTC_BACKUP_MAGIC 0xAA55CC33 #define RTC_TIME_MIN 1772323200 // 1 Mar 2026 @@ -279,8 +283,9 @@ public: // All other resets (power-on, crash, WDT, brownout) lose system time. // Restore from RTC backup if valid, otherwise use hardcoded seed. struct timeval tv; - if (_rtc_backup_magic == RTC_BACKUP_MAGIC && _rtc_backup_time > RTC_TIME_MIN) { - tv.tv_sec = _rtc_backup_time; + if (mesh::esp32_clock::rtc_backup_magic == RTC_BACKUP_MAGIC + && mesh::esp32_clock::rtc_backup_time > RTC_TIME_MIN) { + tv.tv_sec = mesh::esp32_clock::rtc_backup_time; } else { tv.tv_sec = RTC_TIME_MIN; } @@ -297,15 +302,16 @@ public: tv.tv_sec = time; tv.tv_usec = 0; settimeofday(&tv, NULL); - _rtc_backup_time = time; - _rtc_backup_magic = RTC_BACKUP_MAGIC; + mesh::esp32_clock::rtc_backup_time = time; + mesh::esp32_clock::rtc_backup_magic = RTC_BACKUP_MAGIC; } void tick() override { time_t now; time(&now); - if (now > RTC_TIME_MIN && (uint32_t)now != _rtc_backup_time) { - _rtc_backup_time = (uint32_t)now; - _rtc_backup_magic = RTC_BACKUP_MAGIC; + if (now > RTC_TIME_MIN + && (uint32_t)now != mesh::esp32_clock::rtc_backup_time) { + mesh::esp32_clock::rtc_backup_time = (uint32_t)now; + mesh::esp32_clock::rtc_backup_magic = RTC_BACKUP_MAGIC; } } }; diff --git a/src/helpers/MQTTConnectionPolicy.h b/src/helpers/MQTTConnectionPolicy.h index 1170d02f6..b77c47b8b 100644 --- a/src/helpers/MQTTConnectionPolicy.h +++ b/src/helpers/MQTTConnectionPolicy.h @@ -52,17 +52,11 @@ static inline uint32_t ntpRetryAt(uint32_t now) { return retry_at == 0 ? 1 : retry_at; } -static inline uint32_t ntpReconnectRefreshAt(uint32_t now) { - // Reconnection invalidates any old short deadline. Schedule the refresh for - // the current task loop while preserving zero as the unscheduled sentinel. - return now == 0 ? 1 : now; -} - // WiFi events can run on a different task from MQTT maintenance. Keep the // callback to a single atomic edge latch: the MQTT task consumes the edge only // after WiFi is connected and makes every clock/scheduler decision itself. -// Coalescing multiple GOT_IP events is intentional; one fresh sample after the -// latest reconnect is sufficient. +// Coalescing multiple GOT_IP events is intentional. Once the boot sync has +// succeeded, reconnects leave the explicit 24-hour deadline unchanged. class NtpReconnectLatch { public: NtpReconnectLatch() : _pending(false) {} diff --git a/src/helpers/MeshClockSync.cpp b/src/helpers/MeshClockSync.cpp index e2a9cd842..9b04a1f90 100644 --- a/src/helpers/MeshClockSync.cpp +++ b/src/helpers/MeshClockSync.cpp @@ -339,6 +339,10 @@ void MeshClockSync::onManualClockSet() { suppressForBoot(SUPPRESS_CLI); } +void MeshClockSync::onInternetClockSet() { + suppressForBoot(SUPPRESS_INTERNET); +} + void MeshClockSync::checkGpsOverride() { LocationProvider* location = _sensors->getLocationProvider(); if (location != nullptr && location->consumeTimeSyncApplied()) { diff --git a/src/helpers/MeshClockSync.h b/src/helpers/MeshClockSync.h index 265093072..0fa5966a3 100644 --- a/src/helpers/MeshClockSync.h +++ b/src/helpers/MeshClockSync.h @@ -35,6 +35,7 @@ public: void loop(); void onManualClockSet(); + void onInternetClockSet(); // Edge mode observes valid packets even when this node does not forward // them. Normal/path mode observes only packets accepted for forwarding. diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index 8628740ee..cca873bb4 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -75,6 +75,15 @@ static uint32_t mqttNtpRetryAt(uint32_t now) { return MQTTConnectionPolicy::ntpRetryAt(now); } +#ifdef ESP_PLATFORM +static void stopMqttSntpOperation() { + // MQTT uses the firmware's explicit boot/reconnect/daily scheduler. Keep + // each configTime() request one-shot instead of leaving the SDK's separate + // background scheduler active after the owning lease is released. + esp_sntp_stop(); +} +#endif + static void formatRadioInfo(float freq, float bw, int sf, int cr, char* dest, size_t dest_size) { char freq_text[20]; @@ -681,7 +690,17 @@ MQTTBridge::MQTTBridge(const MQTTNodeInfo& node_info, MQTTPrefs *obs, _manage_wifi(manage_wifi), _queue_count(0), _last_status_publish(0), _last_status_retry(0), _status_interval(300000), - _ntp_client(_ntp_udp, effectiveNtpPrimary(obs), 0, 60000), _last_ntp_sync(0), _ntp_refresh_retry_at(0), _ntp_refresh_started_at(0), _ntp_refresh_pending(false), _ntp_synced(false), _ntp_sync_pending(false), _ntp_reconnect_latch(), _slots_setup_done(false), _max_active_slots(RUNTIME_MQTT_SLOTS), + _ntp_client(_ntp_udp, effectiveNtpPrimary(obs), 0, 60000), + _last_ntp_sync(0), _ntp_refresh_retry_at(0), + _ntp_refresh_started_at(0), _ntp_refresh_previous_epoch(0), +#ifdef ESP_PLATFORM + _ntp_refresh_operation(mesh::sntp_coord::processWideCoordinator(), + stopMqttSntpOperation), +#endif + _ntp_refresh_pending(false), _ntp_synced(false), + _fresh_ntp_this_boot(false), _backward_clock_reset_epoch(0), + _ntp_sync_pending(false), _ntp_reconnect_latch(), + _slots_setup_done(false), _max_active_slots(RUNTIME_MQTT_SLOTS), _ntp_force_requested(false), _ntp_force_done(false), _ntp_force_result(false), _ntp_diag_requested(false), _ntp_diag_done(false), _ntp_diag_count(0), _ntp_estimate_requested(false), _ntp_estimate_done(false), @@ -864,9 +883,8 @@ void MQTTBridge::begin() { return; } + cancelNtpRefresh(); _ntp_refresh_retry_at = 0; - _ntp_refresh_started_at = 0; - _ntp_refresh_pending = false; // Do not clear _ntp_reconnect_latch here. The WiFi event handler remains // registered while MQTT is stopped, so a GOT_IP edge received between end() // and begin() must survive until the restarted task can consume it. @@ -1201,6 +1219,10 @@ void MQTTBridge::end() { // Timezone is inline class storage (_timezone_storage) - nothing to delete. // The shared JSON document's pools were freed by releaseRuntimeBuffers() above. _initialized = false; + // A clean task stop already cancels this on its owning core. The second + // cancellation is intentionally idempotent and also releases the lease + // after the lifecycle's bounded forced-stop fallback. + cancelNtpRefresh(); _slots_setup_done = false; // Reset so deferred setup runs again on next begin() _ntp_estimate_requested = false; _ntp_estimate_done = false; @@ -1406,6 +1428,7 @@ void MQTTBridge::mqttTaskLoop() { // trampoline (vTaskDelete(nullptr)). if (_stop_requested) { MQTT_DEBUG_PRINTLN("MQTT task: cooperative stop - tearing down clients on Core 0"); + cancelNtpRefresh(); for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { teardownSlot(i); } @@ -1461,15 +1484,6 @@ void MQTTBridge::mqttTaskLoop() { if (_ntp_reconnect_latch.consumeIfConnected(wifi_connected)) { if (!_ntp_synced.load(std::memory_order_acquire)) { _ntp_sync_pending = true; - } else { - // A successful reconnect gets one fresh clock sample regardless of how - // long the link was down. This also discards a short retry deadline that - // may be ambiguous after a very long offline interval. The normal - // continuously-connected cadence remains once per day. - _ntp_refresh_pending = false; - _ntp_refresh_started_at = 0; - _ntp_refresh_retry_at = - MQTTConnectionPolicy::ntpReconnectRefreshAt(now); } } @@ -2929,7 +2943,9 @@ bool MQTTBridge::handleWiFiConnection(unsigned long now) { wifi_ps_type_t ps_mode; uint8_t ps_pref = mesh::wifi::effectivePowerSave( _obs->wifi_power_save, -#if defined(BLE_PIN_CODE) && defined(WIFI_SSID) +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) + false, +#elif defined(BLE_PIN_CODE) && defined(WIFI_SSID) true, #else false, @@ -3056,15 +3072,9 @@ void MQTTBridge::loop() { #else unsigned long now = millis(); const bool wifi_just_connected = handleWiFiConnection(now); - if (wifi_just_connected) { - if (!_ntp_synced.load(std::memory_order_acquire)) { - syncTimeWithNTP(); - } else { - _ntp_refresh_pending = false; - _ntp_refresh_started_at = 0; - _ntp_refresh_retry_at = - MQTTConnectionPolicy::ntpReconnectRefreshAt((uint32_t)now); - } + if (wifi_just_connected + && !_ntp_synced.load(std::memory_order_acquire)) { + syncTimeWithNTP(); } if (_ntp_sync_pending && WiFi.status() == WL_CONNECTED) { _ntp_sync_pending = false; @@ -4096,6 +4106,22 @@ void MQTTBridge::storeRawRadioData(const uint8_t* raw_data, int len, float snr, // NTP time sync // --------------------------------------------------------------------------- +void MQTTBridge::cancelNtpRefresh() { + _ntp_refresh_pending = false; + _ntp_refresh_started_at = 0; +#ifdef ESP_PLATFORM + _ntp_refresh_operation.release(); +#endif +} + +bool MQTTBridge::servicePendingClockCorrection() { + const uint32_t epoch = _backward_clock_reset_epoch.exchange( + 0, std::memory_order_acq_rel); + if (epoch == 0 || _rtc == nullptr) return false; + _rtc->resetUniqueTime(epoch); + return true; +} + void MQTTBridge::refreshNTP() { const uint32_t now = millis(); if (_ntp_refresh_pending) return; @@ -4110,9 +4136,7 @@ void MQTTBridge::refreshNTP() { } #ifdef ESP_PLATFORM - mesh::sntp_coord::OperationLease sntp_operation( - mesh::sntp_coord::processWideCoordinator()); - if (!sntp_operation.tryAcquire()) { + if (!_ntp_refresh_operation.tryAcquire()) { _ntp_refresh_retry_at = mqttNtpRetryAt(now); MQTT_DEBUG_PRINTLN( "NTP refresh deferred; another firmware service owns SNTP"); @@ -4127,6 +4151,8 @@ void MQTTBridge::refreshNTP() { #ifdef ESP_PLATFORM sntp_set_sync_status(SNTP_SYNC_STATUS_RESET); #endif + _ntp_refresh_previous_epoch = + _rtc ? (unsigned long)_rtc->getCurrentTime() : 0; configTime(0, 0, effectiveNtpPrimary(_obs)); _ntp_refresh_retry_at = 0; _ntp_refresh_started_at = millis(); @@ -4139,7 +4165,7 @@ void MQTTBridge::pollNtpRefresh(uint32_t now) { const uint32_t elapsed = now - _ntp_refresh_started_at; if (WiFi.status() != WL_CONNECTED) { - _ntp_refresh_pending = false; + cancelNtpRefresh(); _ntp_refresh_retry_at = mqttNtpRetryAt(now); MQTT_DEBUG_PRINTLN("NTP refresh aborted; WiFi disconnected"); return; @@ -4147,7 +4173,8 @@ void MQTTBridge::pollNtpRefresh(uint32_t now) { #ifdef ESP_PLATFORM const bool completed = - sntp_get_sync_status() == SNTP_SYNC_STATUS_COMPLETED; + _ntp_refresh_operation.owns() + && sntp_get_sync_status() == SNTP_SYNC_STATUS_COMPLETED; #else // Other Arduino WiFi stacks do not expose ESP-IDF's completion status. Keep // their historical asynchronous configTime() behavior, but wait before @@ -4162,10 +4189,15 @@ void MQTTBridge::pollNtpRefresh(uint32_t now) { static_cast(raw_system_time), MQTT_NTP_MIN_VALID_EPOCH, system_time)) { if (_rtc) _rtc->setCurrentTime(system_time); + if (system_time < _ntp_refresh_previous_epoch) { + _backward_clock_reset_epoch.store( + system_time, std::memory_order_release); + } _ntp_synced.store(true, std::memory_order_release); + _fresh_ntp_this_boot.store(true, std::memory_order_release); _last_ntp_sync = now; _ntp_refresh_retry_at = 0; - _ntp_refresh_pending = false; + cancelNtpRefresh(); MQTT_DEBUG_PRINTLN( "NTP refresh completed; MeshCore RTC updated: %lu", system_time); return; @@ -4174,7 +4206,7 @@ void MQTTBridge::pollNtpRefresh(uint32_t now) { // A completion carrying an implausible time cannot become authoritative. // The ESP status is edge-like and may reset after it is read, so retry the // whole operation instead of waiting on a completion that was consumed. - _ntp_refresh_pending = false; + cancelNtpRefresh(); _ntp_refresh_retry_at = mqttNtpRetryAt(now); MQTT_DEBUG_PRINTLN( "NTP refresh returned implausible time %lld; retry scheduled", @@ -4183,7 +4215,7 @@ void MQTTBridge::pollNtpRefresh(uint32_t now) { } if (elapsed >= MQTT_NTP_REFRESH_TIMEOUT_MS) { - _ntp_refresh_pending = false; + cancelNtpRefresh(); _ntp_refresh_retry_at = mqttNtpRetryAt(now); MQTT_DEBUG_PRINTLN("NTP refresh timed out; retry scheduled"); } @@ -4207,6 +4239,13 @@ bool MQTTBridge::syncTimeWithNTP(bool force, bool primary_only) { } sync_in_progress = true; + // A forced or initial blocking sync supersedes a periodic asynchronous + // request. Cancel it on the MQTT task before using NTPClient or attempting + // the SNTP fallback, so the process-wide lease cannot self-block. + cancelNtpRefresh(); + const uint32_t rtc_before_sync = + _rtc ? _rtc->getCurrentTime() : 0; + MQTT_DEBUG_PRINTLN("Syncing time with NTP..."); const char* servers[kMaxNtpServers]; @@ -4224,7 +4263,6 @@ bool MQTTBridge::syncTimeWithNTP(bool force, bool primary_only) { unsigned long epochTime = 0; const unsigned long kMinValidEpoch = MQTT_NTP_MIN_VALID_EPOCH; const char* ntp_server_used = nullptr; - bool sntp_fallback_configured = false; _ntp_client.begin(); const int kMaxNtpRetriesPerServer = 2; @@ -4268,7 +4306,8 @@ bool MQTTBridge::syncTimeWithNTP(bool force, bool primary_only) { const char* server = servers[s]; MQTT_DEBUG_PRINTLN("SNTP fallback trying %s...", server); mesh::sntp_coord::OperationLease sntp_operation( - mesh::sntp_coord::processWideCoordinator()); + mesh::sntp_coord::processWideCoordinator(), + stopMqttSntpOperation); if (!sntp_operation.tryAcquire()) { MQTT_DEBUG_PRINTLN( "SNTP fallback deferred; another firmware service owns SNTP"); @@ -4290,7 +4329,6 @@ bool MQTTBridge::syncTimeWithNTP(bool force, bool primary_only) { epochTime = checked_system_time; ntp_ok = true; ntp_server_used = server; - sntp_fallback_configured = true; MQTT_DEBUG_PRINTLN("SNTP fallback succeeded on %s: %lu", server, epochTime); } else { MQTT_DEBUG_PRINTLN( @@ -4331,37 +4369,27 @@ bool MQTTBridge::syncTimeWithNTP(bool force, bool primary_only) { accepted.tv_usec = 0; settimeofday(&accepted, nullptr); - if (ntp_server_used && !sntp_fallback_configured) { - // A direct NTPClient response has already made the clock authoritative. - // Re-arm background SNTP without letting this asynchronous convenience - // race a callback-owning firmware service. Failure here does not discard - // the fresh direct response; the task loop retries the re-arm shortly. - mesh::sntp_coord::OperationLease sntp_operation( - mesh::sntp_coord::processWideCoordinator()); - if (sntp_operation.tryAcquire()) { - configTime(0, 0, ntp_server_used); - _ntp_refresh_retry_at = 0; - } else { - _ntp_refresh_retry_at = mqttNtpRetryAt(millis()); - MQTT_DEBUG_PRINTLN( - "Background SNTP re-arm deferred; another firmware service owns SNTP"); - } - } else if (sntp_fallback_configured) { - // The successful fallback's configTime() remains the active async client. - _ntp_refresh_retry_at = 0; - } - - if (_rtc) { - _rtc->setCurrentTime(epochTime); + if (_rtc) _rtc->setCurrentTime(epochTime); + if (epochTime < rtc_before_sync) { + _backward_clock_reset_epoch.store( + epochTime, std::memory_order_release); } bool was_ntp_synced = _ntp_synced.load(std::memory_order_acquire); _ntp_synced.store(true, std::memory_order_release); + const bool fresh_ntp = ntp_server_used != nullptr; + if (fresh_ntp) { + _fresh_ntp_this_boot.store(true, std::memory_order_release); + } // A blocking/direct sync already copied its accepted epoch into _rtc, so // it supersedes any older asynchronous periodic request. - _ntp_refresh_pending = false; - _ntp_refresh_started_at = 0; + cancelNtpRefresh(); _last_ntp_sync = millis(); + // A real reply begins a clean 24-hour interval. A retained-clock fallback + // stays usable for MQTT/JWT startup but keeps a short retry pending until + // this boot has actual internet authority. + _ntp_refresh_retry_at = + fresh_ntp ? 0 : mqttNtpRetryAt(_last_ntp_sync); sync_in_progress = false; MQTT_DEBUG_PRINTLN("Time synced: %lu (via %s)", epochTime, diff --git a/src/helpers/bridges/MQTTBridge.h b/src/helpers/bridges/MQTTBridge.h index 4a2be847d..2401691e2 100644 --- a/src/helpers/bridges/MQTTBridge.h +++ b/src/helpers/bridges/MQTTBridge.h @@ -22,6 +22,7 @@ class MeshSNMPAgent; // Forward declaration #endif #ifdef ESP_PLATFORM +#include "helpers/esp32/SntpOperationCoordinator.h" #include #include #include @@ -224,8 +225,14 @@ private: unsigned long _last_ntp_sync; unsigned long _ntp_refresh_retry_at; unsigned long _ntp_refresh_started_at; + unsigned long _ntp_refresh_previous_epoch; +#ifdef ESP_PLATFORM + mesh::sntp_coord::OperationLease _ntp_refresh_operation; +#endif bool _ntp_refresh_pending; std::atomic _ntp_synced; + std::atomic _fresh_ntp_this_boot; + std::atomic _backward_clock_reset_epoch; bool _ntp_sync_pending; // Owned by the MQTT task; the WiFi callback only records a GOT_IP edge MQTTConnectionPolicy::NtpReconnectLatch _ntp_reconnect_latch; bool _slots_setup_done; // Deferred: slots set up after NTP sync @@ -512,8 +519,9 @@ private: void queuePacket(mesh::Packet* packet, bool is_tx); void dequeuePacket(); bool isAnySlotConnected(); - void refreshNTP(); // Start a lightweight daily/reconnect NTP refresh (non-blocking) + void refreshNTP(); // Start a lightweight scheduled NTP refresh (non-blocking) void pollNtpRefresh(uint32_t now); // Copy a completed async refresh into MeshCore's RTC + void cancelNtpRefresh(); void runNtpDiagProbe(); // Probe every server for connectivity; never sets the clock. Core 0 only. void runNtpEstimateProbe(); // Query the first usable server without changing any clock. Core 0 only. // Populates dst_out/std_out with TimeChangeRules for the given IANA or @@ -729,6 +737,14 @@ public: bool hasNtpTime() const { return _ntp_synced.load(std::memory_order_acquire); } + /** True only after a real NTP/SNTP reply was accepted this boot. */ + bool hasFreshNtpThisBoot() const { + return _fresh_ntp_this_boot.load(std::memory_order_acquire); + } + /** Apply a queued backward-clock uniqueness repair from the role's main + * loop. MQTT performs network work on another core on ESP32, while + * RTCClock::getCurrentTimeUnique() is main-loop owned. */ + bool servicePendingClockCorrection(); static void formatMqttStatusReply(char* buf, size_t bufsize, const MQTTPrefs* obs); /** On-demand publish-health + heap snapshot for `get mqtt.stats` (per-slot ok/err, * outbox size, free/max heap, queue depth). */ diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index b5d1a5055..ae6b49812 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -161,7 +161,11 @@ public: }; static bool bluetoothWiFiCoexistenceRequired() { -#if defined(BLE_PIN_CODE) && defined(WIFI_SSID) +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) + // This server is never constructed on the exclusive profile's BLE boot. + // Any running WebConfig instance therefore has no active Bluetooth stack. + return false; +#elif defined(BLE_PIN_CODE) && defined(WIFI_SSID) return true; #else return false; diff --git a/src/helpers/ui/BluetoothPairingUiPolicy.h b/src/helpers/ui/BluetoothPairingUiPolicy.h new file mode 100644 index 000000000..8bc222fff --- /dev/null +++ b/src/helpers/ui/BluetoothPairingUiPolicy.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +namespace mesh { +namespace ui { + +constexpr bool shouldDisplayBluetoothPairingPin(bool bluetooth_enabled, + bool bluetooth_connected, + uint32_t pairing_pin) { + return bluetooth_enabled && !bluetooth_connected && pairing_pin != 0; +} + +constexpr bool isBluetoothPairingPromptActive(bool bluetooth_enabled, + bool bluetooth_connected, + uint32_t display_until, + uint32_t now) { + return bluetooth_enabled && !bluetooth_connected && display_until != 0 + && static_cast(now - display_until) < 0; +} + +} // namespace ui +} // namespace mesh diff --git a/src/helpers/ui/ColorTheme.h b/src/helpers/ui/ColorTheme.h new file mode 100644 index 000000000..36c560bbb --- /dev/null +++ b/src/helpers/ui/ColorTheme.h @@ -0,0 +1,48 @@ +#pragma once + +#include "DisplayDriver.h" + +// Shared default palette for displays that can render real colour. Monochrome +// OLED and e-paper drivers deliberately keep their own black/white palettes: +// e-paper in particular should retain its white idle background. +namespace mesh { +namespace ui { +namespace color_theme { + +constexpr ColorVal rgb565(uint8_t red, uint8_t green, uint8_t blue) { + return (ColorVal)(((red & 0xF8) << 8) + | ((green & 0xFC) << 3) | (blue >> 3)); +} + +// Expand a rendered RGB565 colour for indexed canvases. Keeping the indexed +// palette derived from the semantic RGB565 slots prevents the Indicator's +// low-memory 4-bit canvas from silently drifting to a different theme. +constexpr uint32_t rgb888(ColorVal color) { + return ((uint32_t)(((color >> 11) & 0x1F) * 255 / 31) << 16) + | ((uint32_t)(((color >> 5) & 0x3F) * 255 / 63) << 8) + | (uint32_t)((color & 0x1F) * 255 / 31); +} + +constexpr ColorVal WINDOW_BACKGROUND = rgb565(10, 12, 16); +constexpr ColorVal TITLE_BACKGROUND = rgb565(18, 38, 74); +constexpr ColorVal TEXT = rgb565(232, 238, 246); +constexpr ColorVal SECONDARY_TEXT = rgb565(122, 134, 150); +constexpr ColorVal WARNING_TEXT = rgb565(248, 176, 72); +// Keep this distinct from TITLE_BACKGROUND. Indexed-colour drivers identify +// semantic slots by their RGB565 value before selecting a palette entry. +constexpr ColorVal POPUP_BACKGROUND = rgb565(20, 48, 70); +constexpr ColorVal ACCENT = rgb565(64, 176, 240); + +enum IndexedColor : uint8_t { + INDEX_BACKGROUND = 0, + INDEX_TEXT = 1, + INDEX_TITLE_BACKGROUND = 2, + INDEX_SECONDARY_TEXT = 3, + INDEX_WARNING_TEXT = 4, + INDEX_POPUP_BACKGROUND = 5, + INDEX_ACCENT = 6, +}; + +} // namespace color_theme +} // namespace ui +} // namespace mesh diff --git a/src/helpers/ui/CompanionHomeLayout.h b/src/helpers/ui/CompanionHomeLayout.h new file mode 100644 index 000000000..d8b0e8235 --- /dev/null +++ b/src/helpers/ui/CompanionHomeLayout.h @@ -0,0 +1,133 @@ +#pragma once + +#include + +namespace mesh { +namespace ui { + +struct DisplayRegion { + int x; + int y; + int width; + int height; + + int right() const { return x + width; } + int bottom() const { return y + height; } +}; + +struct CompanionHomeLayout { + DisplayRegion info; + DisplayRegion pairing; + int instruction_y; + int network_y; + int pairing_label_y; + int pairing_value_y; +}; + +struct CompactCompanionPairingLayout { + DisplayRegion pairing; + int pairing_label_y; + int pairing_value_y; +}; + +// The common Companion OLED/TFT viewport is 128x64. Its normal instruction +// and network rows occupy the same lower area needed by the Bluetooth status, +// so an active PIN/connection replaces those rows with this compact block. +// The INBOX title at y=22 with a size-2 fallback font ends at y=38; the block +// begins exactly there and keeps a size-1 label plus size-2 value on-screen. +inline bool usesCompactCompanionPairingLayout(int width, int height) { + return width == 128 && height == 64; +} + +inline CompactCompanionPairingLayout makeCompactCompanionPairingLayout( + int width, int height) { + const int pairing_height = height >= 26 ? 26 : height; + const int pairing_y = height - pairing_height; + const int pairing_margin = width >= 16 ? 4 : 0; + return { + {pairing_margin, pairing_y, width - pairing_margin * 2, pairing_height}, + pairing_y, + pairing_y + 10, + }; +} + +// The large Companion home page keeps ordinary instructions/network status in +// the upper half and reserves a visually separate pairing block around four +// fifths of the display height. The Indicator renders a 160x160 logical UI, +// for which this produces a 48-pixel-tall block centered at y=128. +inline CompanionHomeLayout makeLargeCompanionHomeLayout(int width, + int height) { + // Ordinary one-line status needs almost the full logical width when the + // Indicator's recovered mono font is active. The PIN block gets the larger + // inset so it remains visibly distinct from the page background. + const int info_margin = width >= 80 ? 2 : 0; + const int pairing_margin = width >= 80 ? 8 : 2; + // Native 480 rendering enlarges non-compact Indicator text by 20 percent. + // Four extra logical pixels keep its size-3 PIN inside this block while the + // 320 fallback retains the same geometry and gains a little more breathing + // room. + const int pairing_height = height >= 128 ? 48 : 28; + const int pairing_center = (height * 4) / 5; + int pairing_y = pairing_center - pairing_height / 2; + if (pairing_y < 0) pairing_y = 0; + if (pairing_y + pairing_height > height) { + pairing_y = height - pairing_height; + } + + const int info_y = height >= 128 ? 40 : 18; + int info_bottom = pairing_y - 12; + if (info_bottom < info_y) info_bottom = info_y; + + CompanionHomeLayout layout = { + {info_margin, info_y, width - info_margin * 2, + info_bottom - info_y}, + {pairing_margin, pairing_y, width - pairing_margin * 2, + pairing_height}, + info_y + 2, + info_y + 18, + pairing_y + 4, + pairing_y + 17, + }; + return layout; +} + +inline bool displayRegionsOverlap(const DisplayRegion& left, + const DisplayRegion& right) { + return left.x < right.right() && right.x < left.right() + && left.y < right.bottom() && right.y < left.bottom(); +} + +inline bool displayRegionContainsLine(const DisplayRegion& region, int y, + int line_height) { + return y >= region.y && line_height >= 0 + && y + line_height <= region.bottom(); +} + +// Explicitly repaint a reserved region. Full frames already clear the +// canvas, but keeping this local clear makes the layout safe if a display +// driver later gains partial home-page redraws or a PIN/status disappears. +inline void clearDisplayRegion(DisplayDriver& display, + const DisplayRegion& region) { + if (region.width > 0 && region.height > 0) { + display.fillRect(region.x, region.y, region.width, region.height); + } +} + +// Center short text inside the supplied region and ellipsize long text from +// the region's left edge. In both cases the rendered row is horizontally +// bounded by the region and cannot run into an adjacent UI block. +inline void drawTextCenteredEllipsized(DisplayDriver& display, + const DisplayRegion& region, int y, + const char* text) { + if (text == nullptr || text[0] == 0 || region.width <= 0) return; + const int text_width = display.getTextWidth(text); + if (text_width <= region.width) { + display.setCursor(region.x + (region.width - text_width) / 2, y); + display.print(text); + } else { + display.drawTextEllipsized(region.x, y, region.width, text); + } +} + +} // namespace ui +} // namespace mesh diff --git a/src/helpers/ui/CompanionMessageHistory.h b/src/helpers/ui/CompanionMessageHistory.h new file mode 100644 index 000000000..295f63e51 --- /dev/null +++ b/src/helpers/ui/CompanionMessageHistory.h @@ -0,0 +1,188 @@ +#pragma once + +#include +#include +#include +#include + +namespace mesh { +namespace ui { + +struct CompanionMessageListLayout { + int top; + int row_height; + int title_offset; + int preview_offset; + int divider_offset; + int visible_rows; +}; + +struct CompanionMessageChromeLayout { + bool compact_text; + int header_divider_y; + int origin_y; + int message_y; + int filter_height; + int filter_text_offset; +}; + +inline CompanionMessageChromeLayout makeCompanionMessageChromeLayout( + bool compact_text) { + if (compact_text) { + // At the Indicator's 3x physical mapping these are a 27px header and a + // 42px footer around a 24px-tall fractionally scaled status font. + return {true, 8, 11, 22, 14, 3}; + } + return {false, 11, 14, 25, 24, 8}; +} + +inline CompanionMessageListLayout makeCompanionMessageListLayout( + int display_height) { + const int top = 20; + const int row_height = 26; + const int usable_height = display_height > top ? display_height - top : 0; + return {top, row_height, 0, 11, 23, usable_height / row_height}; +} + +inline void formatCompanionMessageAge(char* output, size_t output_size, + uint64_t elapsed_millis) { + if (output == nullptr || output_size == 0) return; + + const uint64_t seconds = elapsed_millis / 1000ULL; + if (seconds == 0) { + snprintf(output, output_size, "now"); + } else if (seconds < 60ULL) { + snprintf(output, output_size, "%lus ago", (unsigned long)seconds); + } else if (seconds < 60ULL * 60ULL) { + snprintf(output, output_size, "%lum ago", + (unsigned long)(seconds / 60ULL)); + } else if (seconds < 24ULL * 60ULL * 60ULL) { + snprintf(output, output_size, "%luh ago", + (unsigned long)(seconds / (60ULL * 60ULL))); + } else { + snprintf(output, output_size, "%lud ago", + (unsigned long)(seconds / (24ULL * 60ULL * 60ULL))); + } +} + +inline void makeCompanionMessagePreviewSingleLine(char* text) { + if (text == nullptr) return; + for (; *text != 0; ++text) { + if (*text == '\r' || *text == '\n' || *text == '\t') *text = ' '; + } +} + +template +class CompanionMessageHistory { +public: + struct Entry { + uint64_t heard_millis; + int channel_idx; + char channel_name[32]; + char origin[62]; + char message[MessageCapacity]; + }; + +private: + size_t _count; + size_t _head; + Entry _entries[Capacity]; + + static void copyField(char* destination, size_t capacity, + const char* source) { + if (capacity == 0) return; + if (source == nullptr) source = ""; + strncpy(destination, source, capacity - 1); + destination[capacity - 1] = 0; + } + + static size_t directNameLength(const char* origin) { + if (origin == nullptr) return 0; + const char* route = strstr(origin, " ["); + return route == nullptr ? strlen(origin) : (size_t)(route - origin); + } + + static bool sameDirectOrigin(const char* left, const char* right) { + const size_t left_length = directNameLength(left); + const size_t right_length = directNameLength(right); + return left_length == right_length + && memcmp(left, right, left_length) == 0; + } + +public: + CompanionMessageHistory() : _count(0), _head(Capacity - 1) { + static_assert(Capacity > 0, "message history capacity must be positive"); + static_assert(MessageCapacity > 1, + "message storage must include a terminator"); + } + + size_t count() const { return _count; } + bool empty() const { return _count == 0; } + + void clear() { + _count = 0; + _head = Capacity - 1; + } + + void add(uint64_t heard_millis, int channel_idx, + const char* channel_name, const char* origin, + const char* message) { + _head = (_head + 1) % Capacity; + if (_count < Capacity) ++_count; + + Entry& entry = _entries[_head]; + entry.heard_millis = heard_millis; + entry.channel_idx = channel_idx; + copyField(entry.channel_name, sizeof(entry.channel_name), channel_name); + copyField(entry.origin, sizeof(entry.origin), origin); + copyField(entry.message, sizeof(entry.message), message); + } + + const Entry* newest(size_t age) const { + if (age >= _count) return nullptr; + const size_t index = (_head + Capacity - age) % Capacity; + return &_entries[index]; + } + + static bool sameThread(const Entry& left, const Entry& right) { + if (left.channel_idx >= 0 || right.channel_idx >= 0) { + return left.channel_idx >= 0 && right.channel_idx >= 0 + && left.channel_idx == right.channel_idx; + } + return sameDirectOrigin(left.origin, right.origin); + } + + bool hasNewerEntryForThread(size_t age) const { + const Entry* candidate = newest(age); + if (candidate == nullptr) return false; + for (size_t newer_age = 0; newer_age < age; ++newer_age) { + const Entry* newer = newest(newer_age); + if (newer != nullptr && sameThread(*candidate, *newer)) return true; + } + return false; + } + + static void threadLabel(const Entry& entry, char* output, + size_t output_size) { + if (output == nullptr || output_size == 0) return; + if (entry.channel_idx >= 0) { + if (entry.channel_name[0] != 0) { + copyField(output, output_size, entry.channel_name); + } else { + snprintf(output, output_size, "Channel %d", entry.channel_idx); + } + return; + } + + const size_t prefix_length = directNameLength(entry.origin); + if (prefix_length == 0) { + snprintf(output, output_size, "Direct"); + return; + } + snprintf(output, output_size, "Direct: %.*s", (int)prefix_length, + entry.origin); + } +}; + +} // namespace ui +} // namespace mesh diff --git a/src/helpers/ui/CompanionTransportSelectorLayout.h b/src/helpers/ui/CompanionTransportSelectorLayout.h new file mode 100644 index 000000000..23d4fa0fc --- /dev/null +++ b/src/helpers/ui/CompanionTransportSelectorLayout.h @@ -0,0 +1,44 @@ +#pragma once + +namespace mesh { +namespace ui { + +struct CompanionTransportChoiceRegion { + int x; + int y; + int width; + int height; +}; + +struct CompanionTransportSelectorLayout { + CompanionTransportChoiceRegion wifi; + CompanionTransportChoiceRegion bluetooth; + int title_y; + int prompt_y; + bool show_title; +}; + +inline CompanionTransportSelectorLayout makeCompanionTransportSelectorLayout( + int width, int height) { + const bool tall_display = height >= 96; + const int margin = 2; + const int gap = 4; + const int box_y = tall_display ? 40 : 20; + const int prompt_y = height - (tall_display ? 22 : 11); + int box_height = prompt_y - box_y - 8; + if (box_height > 72) box_height = 72; + if (box_height < 20) box_height = 20; + const int box_width = (width - margin * 2 - gap) / 2; + const int bluetooth_x = margin + box_width + gap; + + return { + {margin, box_y, box_width, box_height}, + {bluetooth_x, box_y, box_width, box_height}, + tall_display ? 17 : 0, + prompt_y, + tall_display, + }; +} + +} // namespace ui +} // namespace mesh diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index 8d6dadf14..9d2527771 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -47,6 +47,19 @@ public: virtual void clear() = 0; virtual void startFrame(ColorVal bkg = UIColor::window_bkg) = 0; virtual void setTextSize(int sz) = 0; + // Optional fractional small-text mode for dense status chrome. Drivers that + // only support integer bitmap scales retain their normal size. + virtual void setCompactText(bool compact) { (void)compact; } + // Optional runtime canvas scaling. Large RGB panels can retain one logical + // UI geometry while selecting a different internal render resolution based + // on the active transport's RAM cost. + virtual bool setRenderScale(uint8_t coordinate_scale, float output_zoom) { + (void)coordinate_scale; + (void)output_zoom; + return false; + } + virtual int renderWidth() const { return width(); } + virtual int renderHeight() const { return height(); } virtual void setColor(ColorVal c) = 0; virtual void setCursor(int x, int y) = 0; virtual void print(const char* str) = 0; diff --git a/src/helpers/ui/IndicatorRenderProfile.h b/src/helpers/ui/IndicatorRenderProfile.h new file mode 100644 index 000000000..5e08fb4fa --- /dev/null +++ b/src/helpers/ui/IndicatorRenderProfile.h @@ -0,0 +1,34 @@ +#pragma once + +#include + +namespace mesh { +namespace ui { + +struct IndicatorRenderProfile { + uint8_t coordinate_scale; + float output_zoom; + uint16_t canvas_size; +}; + +// ESP-NOW and BLE are the Indicator's tightest internal-RAM combination. +// Every other exclusive Full-Companion combination can use a native 480px +// internal canvas while preserving the established 160px UI coordinates. +inline IndicatorRenderProfile selectIndicatorRenderProfile( + bool primary_espnow, bool companion_wifi_active) { + if (primary_espnow && !companion_wifi_active) { + return {2, 1.5f, 320}; + } + return {3, 1.0f, 480}; +} + +// Native rendering has enough pixel density to make ordinary UI text more +// legible without changing the established 160x160 layout. Keep compact +// message chrome deliberately small, and keep the memory-saving 320 profile +// visually identical to existing Indicator images. +inline float selectIndicatorTextScale(uint16_t canvas_size, bool compact) { + return canvas_size == 480 && !compact ? 1.2f : 1.0f; +} + +} // namespace ui +} // namespace mesh diff --git a/src/helpers/ui/LGFXDisplay.cpp b/src/helpers/ui/LGFXDisplay.cpp index 152b469d8..2ed33717a 100644 --- a/src/helpers/ui/LGFXDisplay.cpp +++ b/src/helpers/ui/LGFXDisplay.cpp @@ -1,4 +1,6 @@ #include "LGFXDisplay.h" +#include "ColorTheme.h" +#include "IndicatorRenderProfile.h" #ifndef DISPLAY_ROTATION #define DISPLAY_ROTATION 1 @@ -86,8 +88,14 @@ bool validateVlw(const uint8_t* data, size_t size) { LGFXDisplay* activeEmojiDisplay = nullptr; static const uint32_t UI_PALETTE[16] = { - 0x000000, 0xFFFFFF, 0x0000FF, 0x929292, - 0xFFAA00, 0x00FFFF, 0x0000D6, 0xE53935, + mesh::ui::color_theme::rgb888(mesh::ui::color_theme::WINDOW_BACKGROUND), + mesh::ui::color_theme::rgb888(mesh::ui::color_theme::TEXT), + mesh::ui::color_theme::rgb888(mesh::ui::color_theme::TITLE_BACKGROUND), + mesh::ui::color_theme::rgb888(mesh::ui::color_theme::SECONDARY_TEXT), + mesh::ui::color_theme::rgb888(mesh::ui::color_theme::WARNING_TEXT), + mesh::ui::color_theme::rgb888(mesh::ui::color_theme::POPUP_BACKGROUND), + mesh::ui::color_theme::rgb888(mesh::ui::color_theme::ACCENT), + 0xE53935, 0x43A047, 0x1E88E5, 0x8E24AA, 0xFDD835, 0x6D4C41, 0x00ACC1, 0xF06292, 0xFF00FF, }; @@ -104,15 +112,15 @@ bool isSequence(const uint8_t* text, size_t remaining, } // namespace // Color scheme -ColorVal UIColor::window_bkg = 0xFFFF; -ColorVal UIColor::title_bkg = 0x001F; -ColorVal UIColor::title_txt = 0xFFFF; -ColorVal UIColor::primary_txt = 0x0000; -ColorVal UIColor::secondary_txt = (18 << 11) | (36 << 5) | 18; // mid-gray -ColorVal UIColor::warning_txt = 0xFD20; -ColorVal UIColor::popup_bkg = 0x07FF; // CYAN -ColorVal UIColor::popup_txt = 0x0000; -ColorVal UIColor::corp_blue = 0x001A; +ColorVal UIColor::window_bkg = mesh::ui::color_theme::WINDOW_BACKGROUND; +ColorVal UIColor::title_bkg = mesh::ui::color_theme::TITLE_BACKGROUND; +ColorVal UIColor::title_txt = mesh::ui::color_theme::TEXT; +ColorVal UIColor::primary_txt = mesh::ui::color_theme::TEXT; +ColorVal UIColor::secondary_txt = mesh::ui::color_theme::SECONDARY_TEXT; +ColorVal UIColor::warning_txt = mesh::ui::color_theme::WARNING_TEXT; +ColorVal UIColor::popup_bkg = mesh::ui::color_theme::POPUP_BACKGROUND; +ColorVal UIColor::popup_txt = mesh::ui::color_theme::TEXT; +ColorVal UIColor::corp_blue = mesh::ui::color_theme::ACCENT; bool LGFXDisplay::begin() { if (!display->init()) return false; @@ -123,19 +131,76 @@ bool LGFXDisplay::begin() { display->setTextColor(TFT_WHITE); buffer.setColorDepth(UI_BUFFER_COLOR_DEPTH); - // The RGB scanout framebuffer must live in PSRAM. Keep this much smaller - // logical UI sprite in internal DMA-capable RAM so page redraws do not - // compete with scanout and permanently shift the panel after an underflow. + // The RGB scanout framebuffer must live in PSRAM. Keep the UI sprite in + // internal DMA-capable RAM so page redraws do not compete with scanout and + // permanently shift the panel after an underflow. buffer.setPsram(false); - if (buffer.createSprite(width() * UI_COORD_SCALE, - height() * UI_COORD_SCALE) == nullptr) { + if (createRenderBuffer()) return true; + +#if defined(INDICATOR_TRANSPORT_RENDER_PROFILE) + // Full Indicator images allocate the largest possible canvas before the + // radio and companion stacks can fragment internal RAM. If that early + // native allocation is unavailable, keep the device usable at the proven + // 320px profile; setup() may retry the selected profile after preferences + // have been loaded. + buffer.deleteSprite(); + _coordinateScale = 2; + _outputZoom = 1.5f; + if (createRenderBuffer()) return true; +#endif + + return false; +} + +bool LGFXDisplay::createRenderBuffer() { + if (buffer.createSprite(width() * _coordinateScale, + height() * _coordinateScale) == nullptr) { return false; } - configurePalette(); - + if (!configurePalette()) { + buffer.deleteSprite(); + return false; + } + applyTextSize(); + _emojiOverlayCount = 0; + _presentedEmojiOverlayCount = 0; + _hasTransparentEmojiPixels = false; + _hasLastFrame = false; return true; } +bool LGFXDisplay::setRenderScale(uint8_t coordinate_scale, + float output_zoom) { + if (coordinate_scale == 0 || output_zoom <= 0.0f) return false; + + const float target_width = width() * coordinate_scale * output_zoom; + const float target_height = height() * coordinate_scale * output_zoom; + if (target_width < display->width() - 0.5f + || target_width > display->width() + 0.5f + || target_height < display->height() - 0.5f + || target_height > display->height() + 0.5f) { + return false; + } + if (_coordinateScale == coordinate_scale && _outputZoom == output_zoom) { + return true; + } + + const uint8_t previous_scale = _coordinateScale; + const float previous_zoom = _outputZoom; + buffer.deleteSprite(); + _coordinateScale = coordinate_scale; + _outputZoom = output_zoom; + if (createRenderBuffer()) return true; + + // A requested larger DMA-capable block may be unavailable after another + // subsystem fragmented internal RAM. Restore the known-good canvas rather + // than leaving the display unusable for this boot. + _coordinateScale = previous_scale; + _outputZoom = previous_zoom; + createRenderBuffer(); + return false; +} + void LGFXDisplay::turnOn() { if (!_isOn) { // Keep the panel initialized while blanked so touch remains available as @@ -175,24 +240,50 @@ void LGFXDisplay::startFrame(ColorVal bkg) { setColor(UIColor::primary_txt); } -void LGFXDisplay::setTextSize(int sz) { - int scaled = (sz * UI_COORD_SCALE + _fontNativeScale / 2) +void LGFXDisplay::applyTextSize() { + float profile_scale = 1.0f; +#if defined(INDICATOR_TRANSPORT_RENDER_PROFILE) + profile_scale = mesh::ui::selectIndicatorTextScale( + static_cast(renderWidth()), _compactText); +#endif + if (_compactText || profile_scale != 1.0f + || _coordinateScale % _fontNativeScale != 0) { + // LovyanGFX accepts fractional smooth-font scaling. Preserve the loaded + // Unicode/emoji font at the exact coordinate/native-scale ratio, then + // apply the native-480 legibility increase for non-compact text. + float scaled = static_cast(_logicalTextSize * _coordinateScale) + / static_cast(_fontNativeScale) * profile_scale; + buffer.setTextSize(scaled); + return; + } + + int sz = _logicalTextSize; + int scaled = (sz * _coordinateScale + _fontNativeScale / 2) / _fontNativeScale; if (scaled < 1) scaled = 1; buffer.setTextSize(scaled); } +void LGFXDisplay::setTextSize(int sz) { + _logicalTextSize = sz < 1 ? 1 : sz; + applyTextSize(); +} + +void LGFXDisplay::setCompactText(bool compact) { + _compactText = compact; + applyTextSize(); +} + void LGFXDisplay::setColor(ColorVal c) { _color = renderColor(c); // Every frame starts from a freshly cleared canvas, so transparent glyph - // backgrounds are both sufficient and unambiguous. In particular, - // primary_txt and popup_txt are both black and cannot be distinguished by - // their RGB565 value alone. + // backgrounds are both sufficient and unambiguous. Semantic text slots use + // the same light RGB565 value and intentionally share one indexed entry. buffer.setTextColor(_color); } void LGFXDisplay::setCursor(int x, int y) { - buffer.setCursor(x * UI_COORD_SCALE, y * UI_COORD_SCALE); + buffer.setCursor(x * _coordinateScale, y * _coordinateScale); } void LGFXDisplay::print(const char* str) { @@ -202,30 +293,30 @@ void LGFXDisplay::print(const char* str) { } void LGFXDisplay::fillRect(int x, int y, int w, int h) { - buffer.fillRect(x * UI_COORD_SCALE, y * UI_COORD_SCALE, - w * UI_COORD_SCALE, h * UI_COORD_SCALE, _color); + buffer.fillRect(x * _coordinateScale, y * _coordinateScale, + w * _coordinateScale, h * _coordinateScale, _color); } void LGFXDisplay::drawRect(int x, int y, int w, int h) { if (w <= 0 || h <= 0) return; - if (UI_COORD_SCALE == 1) { + if (_coordinateScale == 1) { buffer.drawRect(x, y, w, h, _color); return; } - int left = x * UI_COORD_SCALE; - int top = y * UI_COORD_SCALE; - int scaled_width = w * UI_COORD_SCALE; - int scaled_height = h * UI_COORD_SCALE; - buffer.fillRect(left, top, scaled_width, UI_COORD_SCALE, _color); - buffer.fillRect(left, top + scaled_height - UI_COORD_SCALE, - scaled_width, UI_COORD_SCALE, _color); - buffer.fillRect(left, top, UI_COORD_SCALE, scaled_height, _color); - buffer.fillRect(left + scaled_width - UI_COORD_SCALE, top, - UI_COORD_SCALE, scaled_height, _color); + int left = x * _coordinateScale; + int top = y * _coordinateScale; + int scaled_width = w * _coordinateScale; + int scaled_height = h * _coordinateScale; + buffer.fillRect(left, top, scaled_width, _coordinateScale, _color); + buffer.fillRect(left, top + scaled_height - _coordinateScale, + scaled_width, _coordinateScale, _color); + buffer.fillRect(left, top, _coordinateScale, scaled_height, _color); + buffer.fillRect(left + scaled_width - _coordinateScale, top, + _coordinateScale, scaled_height, _color); } void LGFXDisplay::drawXbm(int x, int y, const uint8_t* bits, int w, int h) { - if (UI_COORD_SCALE == 1) { + if (_coordinateScale == 1) { buffer.drawBitmap(x, y, bits, w, h, _color); return; } @@ -245,10 +336,10 @@ void LGFXDisplay::drawXbm(int x, int y, const uint8_t* bits, int w, int h) { ++column; } if (first != column) { - buffer.fillRect((x + first) * UI_COORD_SCALE, - (y + row) * UI_COORD_SCALE, - (column - first) * UI_COORD_SCALE, - UI_COORD_SCALE, _color); + buffer.fillRect((x + first) * _coordinateScale, + (y + row) * _coordinateScale, + (column - first) * _coordinateScale, + _coordinateScale, _color); } } } @@ -256,8 +347,8 @@ void LGFXDisplay::drawXbm(int x, int y, const uint8_t* bits, int w, int h) { uint16_t LGFXDisplay::getTextWidth(const char* str) { String mapped = mapText(str); - return (buffer.textWidth(mapped.c_str()) + UI_COORD_SCALE - 1) - / UI_COORD_SCALE; + return (buffer.textWidth(mapped.c_str()) + _coordinateScale - 1) + / _coordinateScale; } void LGFXDisplay::translateUTF8ToBlocks(char* dest, const char* src, @@ -305,14 +396,16 @@ void LGFXDisplay::endFrame() { _hasLastFrame = true; display->startWrite(); - if (UI_ZOOM != 1) { + if (_outputZoom != 1.0f) { if (_hasTransparentEmojiPixels) { buffer.pushRotateZoom(display, display->width() / 2, - display->height() / 2, 0, UI_ZOOM, UI_ZOOM, + display->height() / 2, 0, + _outputZoom, _outputZoom, TRANSPARENT_EMOJI_COLOR); } else { buffer.pushRotateZoom(display, display->width() / 2, - display->height() / 2, 0, UI_ZOOM, UI_ZOOM); + display->height() / 2, 0, + _outputZoom, _outputZoom); } } else { if (_hasTransparentEmojiPixels) { @@ -328,11 +421,11 @@ void LGFXDisplay::endFrame() { const EmojiOverlay& overlay = _emojiOverlays[i]; const uint8_t* pixels = _emojiAtlas.glyph(overlay.codepoint); if (pixels == nullptr) continue; - float size = overlay.size * UI_ZOOM; + float size = overlay.size * _outputZoom; float zoom_x = size / _emojiAtlas.width(); float zoom_y = size / _emojiAtlas.height(); - float x = overlay.x * UI_ZOOM + (size - 1) * 0.5f; - float y = overlay.y * UI_ZOOM + (size - 1) * 0.5f; + float x = overlay.x * _outputZoom + (size - 1) * 0.5f; + float y = overlay.y * _outputZoom + (size - 1) * 0.5f; display->pushImageRotateZoom( x, y, source_x, source_y, 0, zoom_x, zoom_y, _emojiAtlas.width(), _emojiAtlas.height(), @@ -350,9 +443,9 @@ void LGFXDisplay::endFrame() { bool LGFXDisplay::getTouch(int* x, int* y) { lgfx::v1::touch_point_t point = {}; if (display->getTouch(&point) == 0) return false; - if (UI_ZOOM * UI_COORD_SCALE != 1) { - *x = point.x / (UI_ZOOM * UI_COORD_SCALE); - *y = point.y / (UI_ZOOM * UI_COORD_SCALE); + if (_outputZoom * _coordinateScale != 1.0f) { + *x = point.x / (_outputZoom * _coordinateScale); + *y = point.y / (_outputZoom * _coordinateScale); } else { *x = point.x; *y = point.y; @@ -390,24 +483,27 @@ bool LGFXDisplay::installRuntimeFont(uint8_t* data, size_t size) { uint32_t LGFXDisplay::renderColor(ColorVal color) const { #if UI_BUFFER_COLOR_DEPTH < 8 - if (color == UIColor::primary_txt) return 0; - if (color == UIColor::window_bkg || color == UIColor::title_txt) return 1; - if (color == UIColor::title_bkg) return 2; - if (color == UIColor::secondary_txt) return 3; - if (color == UIColor::warning_txt) return 4; - if (color == UIColor::popup_bkg) return 5; - if (color == UIColor::popup_txt) return 0; - if (color == UIColor::corp_blue) return 6; + using namespace mesh::ui::color_theme; + if (color == UIColor::window_bkg) return INDEX_BACKGROUND; + if (color == UIColor::primary_txt || color == UIColor::title_txt + || color == UIColor::popup_txt) return INDEX_TEXT; + if (color == UIColor::title_bkg) return INDEX_TITLE_BACKGROUND; + if (color == UIColor::secondary_txt) return INDEX_SECONDARY_TEXT; + if (color == UIColor::warning_txt) return INDEX_WARNING_TEXT; + if (color == UIColor::popup_bkg) return INDEX_POPUP_BACKGROUND; + if (color == UIColor::corp_blue) return INDEX_ACCENT; return color & 0x0F; #else return color; #endif } -void LGFXDisplay::configurePalette() { +bool LGFXDisplay::configurePalette() { #if UI_BUFFER_COLOR_DEPTH < 8 - buffer.createPalette(UI_PALETTE, - sizeof(UI_PALETTE) / sizeof(UI_PALETTE[0])); + return buffer.createPalette(UI_PALETTE, + sizeof(UI_PALETTE) / sizeof(UI_PALETTE[0])); +#else + return true; #endif } diff --git a/src/helpers/ui/LGFXDisplay.h b/src/helpers/ui/LGFXDisplay.h index a12918410..ec1a481e6 100644 --- a/src/helpers/ui/LGFXDisplay.h +++ b/src/helpers/ui/LGFXDisplay.h @@ -44,10 +44,14 @@ protected: bool _hasTransparentEmojiPixels = false; uint32_t _lastFrameHash = 0; bool _hasLastFrame = false; + int _logicalTextSize = 1; + bool _compactText = false; + uint8_t _coordinateScale = UI_COORD_SCALE; + float _outputZoom = UI_ZOOM; String mapText(const char* str) const; uint32_t renderColor(ColorVal color) const; - void configurePalette(); + bool configurePalette(); uint32_t frameHash() const; bool wasEmojiPresented(const EmojiOverlay& overlay) const; void drawEmojiMask(int32_t x, int32_t y, int32_t size, @@ -58,6 +62,8 @@ protected: uint32_t codepoint, int32_t font_height); static int32_t drawEmoji(lgfx::v1::LGFXBase* gfx, int32_t x, int32_t y, uint32_t codepoint, int32_t font_height); + void applyTextSize(); + bool createRenderBuffer(); public: LGFXDisplay(int w, int h, LGFX_Device &disp) @@ -70,6 +76,10 @@ public: void clear() override; void startFrame(ColorVal bkg = UIColor::window_bkg) override; void setTextSize(int sz) override; + void setCompactText(bool compact) override; + bool setRenderScale(uint8_t coordinate_scale, float output_zoom) override; + int renderWidth() const override { return width() * _coordinateScale; } + int renderHeight() const override { return height() * _coordinateScale; } void setColor(ColorVal c) override; void setCursor(int x, int y) override; void print(const char* str) override; diff --git a/src/helpers/ui/NV3001BDisplay.cpp b/src/helpers/ui/NV3001BDisplay.cpp index 1ff7765cb..0969381ba 100644 --- a/src/helpers/ui/NV3001BDisplay.cpp +++ b/src/helpers/ui/NV3001BDisplay.cpp @@ -1,4 +1,5 @@ #include "NV3001BDisplay.h" +#include "ColorTheme.h" #include #include @@ -97,15 +98,15 @@ #endif // Color scheme -ColorVal UIColor::window_bkg = 0xFFFF; -ColorVal UIColor::title_bkg = 0x001F; -ColorVal UIColor::title_txt = 0xFFFF; -ColorVal UIColor::primary_txt = 0x0000; -ColorVal UIColor::secondary_txt = (18 << 11) | (36 << 5) | 18; // mid-gray -ColorVal UIColor::warning_txt = 0xFD20; -ColorVal UIColor::popup_bkg = 0x07FF; // CYAN -ColorVal UIColor::popup_txt = 0x0000; -ColorVal UIColor::corp_blue = 0x001A; +ColorVal UIColor::window_bkg = mesh::ui::color_theme::WINDOW_BACKGROUND; +ColorVal UIColor::title_bkg = mesh::ui::color_theme::TITLE_BACKGROUND; +ColorVal UIColor::title_txt = mesh::ui::color_theme::TEXT; +ColorVal UIColor::primary_txt = mesh::ui::color_theme::TEXT; +ColorVal UIColor::secondary_txt = mesh::ui::color_theme::SECONDARY_TEXT; +ColorVal UIColor::warning_txt = mesh::ui::color_theme::WARNING_TEXT; +ColorVal UIColor::popup_bkg = mesh::ui::color_theme::POPUP_BACKGROUND; +ColorVal UIColor::popup_txt = mesh::ui::color_theme::TEXT; +ColorVal UIColor::corp_blue = mesh::ui::color_theme::ACCENT; static int scaleX(int x) { return (int)(x * DISPLAY_SCALE_X); diff --git a/src/helpers/ui/ObserverDashboard.h b/src/helpers/ui/ObserverDashboard.h index e3aa6a5eb..1e8ce7c84 100644 --- a/src/helpers/ui/ObserverDashboard.h +++ b/src/helpers/ui/ObserverDashboard.h @@ -7,6 +7,7 @@ #include "../RadioActivityWindow.h" #include "DisplayDriver.h" #include "DisplayFrameSignature.h" +#include "ColorTheme.h" // Observer analytics dashboard for the Heltec V4 R8 TFT targets. // @@ -18,24 +19,25 @@ namespace ObserverDashboard { // ---------------------------------------------------------------- palette --- -// Kept local to the dashboard. applyDarkPalette() retunes the shared UIColor -// slots at runtime, which is why no display driver needs editing for the theme. +// applyDarkPalette() retunes the shared UIColor slots at runtime. Its base +// semantic colours come from the same default used by the colour drivers, so +// observer and ordinary companion/repeater screens cannot drift apart. constexpr ColorVal rgb565(uint8_t r, uint8_t g, uint8_t b) { - return (ColorVal)(((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)); + return mesh::ui::color_theme::rgb565(r, g, b); } -constexpr ColorVal BG = rgb565(10, 12, 16); -constexpr ColorVal HEADER_BG = rgb565(18, 38, 74); +constexpr ColorVal BG = mesh::ui::color_theme::WINDOW_BACKGROUND; +constexpr ColorVal HEADER_BG = mesh::ui::color_theme::TITLE_BACKGROUND; constexpr ColorVal HEADER_SUB = rgb565(130, 170, 214); -constexpr ColorVal TEXT = rgb565(232, 238, 246); -constexpr ColorVal MUTED = rgb565(122, 134, 150); -constexpr ColorVal ACCENT = rgb565(64, 176, 240); +constexpr ColorVal TEXT = mesh::ui::color_theme::TEXT; +constexpr ColorVal MUTED = mesh::ui::color_theme::SECONDARY_TEXT; +constexpr ColorVal ACCENT = mesh::ui::color_theme::ACCENT; constexpr ColorVal BAR = rgb565(38, 116, 168); constexpr ColorVal BAR_NOW = rgb565(96, 208, 255); constexpr ColorVal GRID = rgb565(38, 46, 60); constexpr ColorVal GOOD = rgb565(72, 208, 136); -constexpr ColorVal WARN = rgb565(248, 176, 72); +constexpr ColorVal WARN = mesh::ui::color_theme::WARNING_TEXT; // Retunes the shared colour slots so every screen this UITask draws - boot, // setup portal, reboot, power-off and the dashboard - is coherently dark. @@ -46,7 +48,7 @@ inline void applyDarkPalette() { UIColor::primary_txt = TEXT; UIColor::secondary_txt = MUTED; UIColor::warning_txt = WARN; - UIColor::popup_bkg = HEADER_BG; + UIColor::popup_bkg = mesh::ui::color_theme::POPUP_BACKGROUND; UIColor::popup_txt = TEXT; UIColor::corp_blue = ACCENT; } diff --git a/src/helpers/ui/ST7789LCDDisplay.cpp b/src/helpers/ui/ST7789LCDDisplay.cpp index 4d37ebd3a..717bd8965 100644 --- a/src/helpers/ui/ST7789LCDDisplay.cpp +++ b/src/helpers/ui/ST7789LCDDisplay.cpp @@ -1,4 +1,5 @@ #include "ST7789LCDDisplay.h" +#include "ColorTheme.h" #ifdef ST7789_PORTRAIT_PROFILE #include "DisplayViewport.h" @@ -51,15 +52,15 @@ bool ST7789LCDDisplay::i2c_probe(TwoWire& wire, uint8_t addr) { } // Color scheme -ColorVal UIColor::window_bkg = ST77XX_WHITE; -ColorVal UIColor::title_bkg = ST77XX_BLUE; -ColorVal UIColor::title_txt = ST77XX_WHITE; -ColorVal UIColor::primary_txt = ST77XX_BLACK; -ColorVal UIColor::secondary_txt = (18 << 11) | (36 << 5) | 18; // mid-gray -ColorVal UIColor::warning_txt = ST77XX_ORANGE; -ColorVal UIColor::popup_bkg = ST77XX_CYAN; -ColorVal UIColor::popup_txt = ST77XX_BLACK; -ColorVal UIColor::corp_blue = 0x001A; +ColorVal UIColor::window_bkg = mesh::ui::color_theme::WINDOW_BACKGROUND; +ColorVal UIColor::title_bkg = mesh::ui::color_theme::TITLE_BACKGROUND; +ColorVal UIColor::title_txt = mesh::ui::color_theme::TEXT; +ColorVal UIColor::primary_txt = mesh::ui::color_theme::TEXT; +ColorVal UIColor::secondary_txt = mesh::ui::color_theme::SECONDARY_TEXT; +ColorVal UIColor::warning_txt = mesh::ui::color_theme::WARNING_TEXT; +ColorVal UIColor::popup_bkg = mesh::ui::color_theme::POPUP_BACKGROUND; +ColorVal UIColor::popup_txt = mesh::ui::color_theme::TEXT; +ColorVal UIColor::corp_blue = mesh::ui::color_theme::ACCENT; bool ST7789LCDDisplay::begin() { if (!_isOn) { diff --git a/src/helpers/ui/TouchInput.h b/src/helpers/ui/TouchInput.h index 207733abf..ff3bca6a9 100644 --- a/src/helpers/ui/TouchInput.h +++ b/src/helpers/ui/TouchInput.h @@ -10,11 +10,22 @@ enum class TouchAction : uint8_t { Previous, Next, Select, + SelectLeft, + SelectRight, VerticalPrevious, VerticalNext, }; -// Converts one-finger touch samples into the three actions understood by the +struct TouchSplitSelector { + int left_x; + int left_width; + int right_x; + int right_width; + int top_y; + int height; +}; + +// Converts one-finger touch samples into actions understood by the // button-oriented companion UI. An action is emitted only after release so a // drag cannot activate the page under the user's finger. class TouchInput { @@ -54,7 +65,8 @@ public: : center_zone_percent) {} TouchAction update(bool touched, int x, int y, int width, int height, - bool bottom_selector = false) { + bool bottom_selector = false, + const TouchSplitSelector* split_selector = nullptr) { if (touched) { _release_samples = 0; if (!_active) { @@ -111,6 +123,27 @@ public: return TouchAction::None; } + // A two-choice page can turn stationary taps into explicit left/right + // selections. Swipe detection above retains priority, so page navigation + // gestures cannot accidentally activate either choice. + if (split_selector != nullptr) { + if (_start_y < split_selector->top_y + || _start_y >= split_selector->top_y + split_selector->height) { + return TouchAction::None; + } + if (_start_x >= split_selector->left_x + && _start_x < split_selector->left_x + + split_selector->left_width) { + return TouchAction::SelectLeft; + } + if (_start_x >= split_selector->right_x + && _start_x < split_selector->right_x + + split_selector->right_width) { + return TouchAction::SelectRight; + } + return TouchAction::None; + } + // Taps use three broad zones so every companion action remains available: // left = previous, center = select, right = next. // Use the initial position for a tap. If a short swipe falls just below diff --git a/test/README.md b/test/README.md index abc5e2aa9..5b66fad4f 100644 --- a/test/README.md +++ b/test/README.md @@ -14,6 +14,10 @@ pio test -e native # all suites except KISS modem pio test -e native_kiss_modem # KISS modem suite pio test -e native -f test_webconfig_keys # a single suite python3 test/test_indicator_display_profile.py # Indicator RAM/scale contract +python3 test/test_indicator_render_profile.py # four-mode Indicator canvas matrix/fallback contract +python3 test/test_indicator_exclusive_transport.py # Indicator secondary-transport ownership contract +python3 test/test_companion_transport_selector.py # Indicator transport-selector touch/UI contract +python3 test/test_color_theme.py # shared color-display dark-palette contract python3 test/test_indicator_font_recovery.py # Indicator TLS/SD font recovery contract python3 test/test_companion_terminal_profile.py # Companion CLI capability gates python3 test/test_client_login_profile_contract.py # ACL login ordering/role contract @@ -49,6 +53,7 @@ does not reflect the GoogleTest count -- run the built binary directly | `test_mqtt_prefs_atomic_store` | `src/helpers/MQTTPrefsAtomicStore.h` | transactional MQTT writes and legacy `/node_prefs` handoff; exact short-write detection; begin/finish/rename failure cleanup; original-file preservation | | `test_mqtt_prefs_json_import` | `src/helpers/MQTTPrefsJsonImport.h`, `src/helpers/MQTTPrefsSerializer.h` | one-time observer `/mqtt.json` v1 import grammar, strict schema/repair rules, binary-first precedence, future/artifact preservation, display-tail encoding, and atomic commit failure routing | | `test_display_viewport` | `src/helpers/ui/DisplayViewport.h`, `src/helpers/ui/DisplayFrameSignature.h` | portrait logical-to-physical mapping, span coverage, fitted-width conversion, text scaling, and visible-frame signatures | +| `test_display_driver` | `src/helpers/ui/DisplayDriver.h`, `DisplayTextLayout.h`, `CompanionHomeLayout.h` | UTF-8-safe ellipsis/wrapping; maximum SSID and long setup-address bounds; Indicator home info/pairing separation and 4/5-height PIN placement; compact 128x64 pairing replacement; stale-region clearing | | `test_indicator_font_stage_v2_protocol` | `src/helpers/IndicatorFontStageV2Protocol.h` | fail-closed STAGEV2 negotiation and legacy fallback; exact cumulative ACK parsing; 512-byte block boundaries, short final blocks, and real font-asset transfer geometry | | `test_radio_activity_window` | `src/helpers/RadioActivityWindow.h` | rolling minute buckets, derived rates, expiry, warm-up, silence, saturation, and `millis()` rollover | | `test_observer_dashboard` | `src/helpers/ui/ObserverDashboard.h` | landscape and portrait dashboard layout bounds, compact formatting, graph scaling, row signatures, and partial repaint policy | diff --git a/test/test_bluetooth_pairing_ui.py b/test/test_bluetooth_pairing_ui.py new file mode 100644 index 000000000..35a47fbb7 --- /dev/null +++ b/test/test_bluetooth_pairing_ui.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 + +from pathlib import Path +import subprocess +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +POLICY = ROOT / "src" / "helpers" / "ui" / "BluetoothPairingUiPolicy.h" +UI_VARIANTS = ( + ROOT / "examples" / "companion_radio" / "ui-new" / "UITask.cpp", + ROOT / "examples" / "companion_radio" / "ui-orig" / "UITask.cpp", + ROOT / "examples" / "companion_radio" / "ui-tiny" / "UITask.cpp", +) + + +class BluetoothPairingUiTest(unittest.TestCase): + def test_policy_requires_enabled_disconnected_bluetooth(self): + translation_unit = r''' +#include + +static_assert(mesh::ui::shouldDisplayBluetoothPairingPin(true, false, 123456), + "enabled, disconnected BLE should show a nonzero PIN"); +static_assert(!mesh::ui::shouldDisplayBluetoothPairingPin(false, false, 123456), + "disabled BLE must hide its PIN"); +static_assert(!mesh::ui::shouldDisplayBluetoothPairingPin(true, true, 123456), + "connected BLE no longer needs its PIN"); +static_assert(!mesh::ui::shouldDisplayBluetoothPairingPin(true, false, 0), + "zero is the no-PIN sentinel"); + +static_assert(mesh::ui::isBluetoothPairingPromptActive( + true, false, 200, 100), + "an enabled pending prompt should remain active"); +static_assert(!mesh::ui::isBluetoothPairingPromptActive( + false, false, 200, 100), + "disabling BLE must dismiss a pending prompt"); +static_assert(!mesh::ui::isBluetoothPairingPromptActive( + true, true, 200, 100), + "connecting BLE must dismiss a pending prompt"); +static_assert(!mesh::ui::isBluetoothPairingPromptActive( + true, false, 200, 200), + "an expired prompt is inactive"); +static_assert(mesh::ui::isBluetoothPairingPromptActive( + true, false, 0x10u, 0xfffffff0u), + "the deadline comparison must survive millis rollover"); +''' + result = subprocess.run( + [ + "c++", + "-std=c++11", + f"-I{ROOT / 'src'}", + "-x", + "c++", + "-fsyntax-only", + "-", + ], + input=translation_unit, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_every_companion_display_uses_shared_visibility_policy(self): + self.assertTrue(POLICY.is_file()) + for ui_path in UI_VARIANTS: + with self.subTest(ui=ui_path.parent.name): + source = ui_path.read_text(encoding="utf-8") + self.assertIn( + "#include ", source + ) + self.assertIn("shouldDisplayBluetoothPairingPin", source) + self.assertIn("isBluetoothPairingPromptActive", source) + self.assertIn("if (!isBluetoothEnabled()) return;", source) + self.assertIn( + "if (!isPairingScreenActive()) {\n" + " finishPairingScreen(timed_out);", + source, + ) + + full_ui = UI_VARIANTS[0].read_text(encoding="utf-8") + self.assertNotIn("else if (bluetooth_pin != 0)", full_ui) + self.assertIn("else if (show_bluetooth_pin) { // BT pin", full_ui) + self.assertIn("_pairing_screen_until = 0;", full_ui) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_color_theme.py b/test/test_color_theme.py new file mode 100644 index 000000000..d9b085174 --- /dev/null +++ b/test/test_color_theme.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Host checks for the default colour-display theme contract.""" + +from pathlib import Path +import re +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +def rgb565(red: int, green: int, blue: int) -> int: + return ((red & 0xF8) << 8) | ((green & 0xFC) << 3) | (blue >> 3) + + +def relative_luminance(color: tuple[int, int, int]) -> float: + def linear(channel: int) -> float: + value = channel / 255.0 + return value / 12.92 if value <= 0.04045 else ((value + 0.055) / 1.055) ** 2.4 + + red, green, blue = (linear(channel) for channel in color) + return 0.2126 * red + 0.7152 * green + 0.0722 * blue + + +def contrast(left: tuple[int, int, int], right: tuple[int, int, int]) -> float: + lighter, darker = sorted((relative_luminance(left), relative_luminance(right)), reverse=True) + return (lighter + 0.05) / (darker + 0.05) + + +class ColorThemeTest(unittest.TestCase): + def test_dark_palette_has_contrasting_semantic_slots(self) -> None: + source = (ROOT / "src/helpers/ui/ColorTheme.h").read_text() + expected = { + "WINDOW_BACKGROUND": rgb565(10, 12, 16), + "TITLE_BACKGROUND": rgb565(18, 38, 74), + "TEXT": rgb565(232, 238, 246), + "SECONDARY_TEXT": rgb565(122, 134, 150), + "WARNING_TEXT": rgb565(248, 176, 72), + "POPUP_BACKGROUND": rgb565(20, 48, 70), + "ACCENT": rgb565(64, 176, 240), + } + for name, value in expected.items(): + match = re.search( + rf"constexpr ColorVal {name} = rgb565\((\d+), (\d+), (\d+)\);", + source, + ) + self.assertIsNotNone(match, name) + self.assertEqual(value, rgb565(*(int(part) for part in match.groups()))) + self.assertNotEqual(expected["WINDOW_BACKGROUND"], expected["TEXT"]) + self.assertNotEqual(expected["TITLE_BACKGROUND"], expected["POPUP_BACKGROUND"]) + background = (10, 12, 16) + self.assertGreaterEqual(contrast(background, (232, 238, 246)), 7.0) + self.assertGreaterEqual(contrast(background, (122, 134, 150)), 4.5) + self.assertGreaterEqual(contrast(background, (248, 176, 72)), 4.5) + self.assertGreaterEqual(contrast(background, (64, 176, 240)), 7.0) + + def test_every_light_color_driver_uses_shared_dark_default(self) -> None: + for relative in ( + "src/helpers/ui/LGFXDisplay.cpp", + "src/helpers/ui/NV3001BDisplay.cpp", + "src/helpers/ui/ST7789LCDDisplay.cpp", + ): + source = (ROOT / relative).read_text() + self.assertIn('#include "ColorTheme.h"', source, relative) + self.assertIn( + "UIColor::window_bkg = mesh::ui::color_theme::WINDOW_BACKGROUND;", + source, + relative, + ) + self.assertIn( + "UIColor::primary_txt = mesh::ui::color_theme::TEXT;", + source, + relative, + ) + + def test_indexed_indicator_palette_maps_background_to_dark_entry(self) -> None: + source = (ROOT / "src/helpers/ui/LGFXDisplay.cpp").read_text() + self.assertIn( + "if (color == UIColor::window_bkg) return INDEX_BACKGROUND;", source + ) + palette_start = source.index("static const uint32_t UI_PALETTE[16]") + palette_end = source.index("};", palette_start) + palette = source[palette_start:palette_end] + semantic_order = ( + "WINDOW_BACKGROUND", + "TEXT", + "TITLE_BACKGROUND", + "SECONDARY_TEXT", + "WARNING_TEXT", + "POPUP_BACKGROUND", + "ACCENT", + ) + positions = [ + palette.index(f"rgb888(mesh::ui::color_theme::{name})") + for name in semantic_order + ] + self.assertEqual(positions, sorted(positions)) + + def test_pagination_uses_readable_accent_on_color_displays(self) -> None: + source = (ROOT / "examples/companion_radio/ui-new/UITask.cpp").read_text() + branch = re.search( + r"if \(UIColor::title_bkg == UIColor::window_bkg\) \{" + r".*?setColor\(UIColor::title_txt\);.*?\} else \{" + r".*?setColor\(UIColor::corp_blue\);", + source, + re.DOTALL, + ) + self.assertIsNotNone(branch) + + def test_monochrome_and_eink_palettes_are_not_inverted(self) -> None: + contracts = { + "src/helpers/ui/SSD1306Display.cpp": ( + "UIColor::window_bkg = SSD1306_BLACK;", + "UIColor::primary_txt = SSD1306_WHITE;", + ), + "src/helpers/ui/SH1106Display.cpp": ( + "UIColor::window_bkg = SH110X_BLACK;", + "UIColor::primary_txt = SH110X_WHITE;", + ), + "src/helpers/ui/GxEPDDisplay.cpp": ( + "UIColor::window_bkg = GxEPD_WHITE;", + "UIColor::primary_txt = GxEPD_BLACK;", + ), + "src/helpers/ui/E213Display.cpp": ( + "UIColor::window_bkg = WHITE;", + "UIColor::primary_txt = BLACK;", + ), + "src/helpers/ui/E290Display.cpp": ( + "UIColor::window_bkg = WHITE;", + "UIColor::primary_txt = BLACK;", + ), + } + for relative, required in contracts.items(): + source = (ROOT / relative).read_text() + for line in required: + self.assertIn(line, source, relative) + + def test_color_hardware_profiles_select_a_dark_default_driver(self) -> None: + contracts = { + "variants/sensecap_indicator-espnow/platformio.ini": "LGFXDisplay.cpp", + "variants/heltec_rc32/platformio.ini": "NV3001BDisplay.cpp", + "variants/heltec_v4/platformio.ini": "ST7789LCDDisplay.cpp", + "variants/heltec_v4_r8/platformio.ini": "ST7789LCDDisplay.cpp", + "variants/lilygo_tdeck/platformio.ini": "ST7789LCDDisplay.cpp", + # These colour families were dark already and remain so. + "variants/heltec_tracker/platformio.ini": "ST7735Display.cpp", + "variants/heltec_tracker_v2/platformio.ini": "ST7735Display.cpp", + "variants/heltec_t096/platformio.ini": "ST7735Display.cpp", + "variants/heltec_t1/platformio.ini": "ST7735Display.cpp", + } + for relative, driver in contracts.items(): + source = (ROOT / relative).read_text() + self.assertIn(driver, source, relative) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_companion_message_history/test_companion_message_history.cpp b/test/test_companion_message_history/test_companion_message_history.cpp new file mode 100644 index 000000000..2d025e504 --- /dev/null +++ b/test/test_companion_message_history/test_companion_message_history.cpp @@ -0,0 +1,126 @@ +#include + +#include + +#include +#include + +using History = mesh::ui::CompanionMessageHistory<3, 16>; +using LargerHistory = mesh::ui::CompanionMessageHistory<8, 32>; + +TEST(CompanionMessageHistory, RetainsNewestEntriesAndOverwritesOldest) { + History history; + history.add(100, 0, "Public", "Ch 0 Public [0h]:", "one"); + history.add(200, 1, "#testing", "Ch 1 #testing [0h]:", "two"); + history.add(300, 2, "#bot", "Ch 2 #bot [0h]:", "three"); + history.add(400, 3, "#alerts", "Ch 3 #alerts [0h]:", "four"); + + ASSERT_EQ(3U, history.count()); + EXPECT_STREQ("four", history.newest(0)->message); + EXPECT_STREQ("three", history.newest(1)->message); + EXPECT_STREQ("two", history.newest(2)->message); + EXPECT_EQ(nullptr, history.newest(3)); +} + +TEST(CompanionMessageHistory, CopiesEphemeralInputAndTerminatesFields) { + History history; + char channel[] = "temporary"; + char origin[] = "sender [direct]:"; + char message[] = "0123456789abcdef-overflow"; + history.add(10, -1, channel, origin, message); + + channel[0] = 'X'; + origin[0] = 'X'; + message[0] = 'X'; + const History::Entry* entry = history.newest(0); + ASSERT_NE(nullptr, entry); + EXPECT_STREQ("temporary", entry->channel_name); + EXPECT_STREQ("sender [direct]:", entry->origin); + EXPECT_STREQ("0123456789abcde", entry->message); +} + +TEST(CompanionMessageHistory, SelectsOnlyLatestEntryPerChannelOrDirectPeer) { + LargerHistory history; + history.add(100, 0, "Public", "Ch 0 Public [0h]:", "old public"); + history.add(200, -1, "", "Alice [direct]:", "old direct"); + history.add(300, 1, "#testing", "Ch 1 #testing [0h]:", "testing"); + history.add(400, 0, "Public", "Ch 0 Public [1h]:", "new public"); + history.add(500, -1, "", "Alice [2h]:", "new direct"); + + EXPECT_FALSE(history.hasNewerEntryForThread(0)); + EXPECT_FALSE(history.hasNewerEntryForThread(1)); + EXPECT_FALSE(history.hasNewerEntryForThread(2)); + EXPECT_TRUE(history.hasNewerEntryForThread(3)); + EXPECT_TRUE(history.hasNewerEntryForThread(4)); +} + +TEST(CompanionMessageHistory, FormatsChannelAndDirectLabels) { + History history; + history.add(100, 7, "#bot-pdx", "Ch 7 #bot-pdx [0h]:", "ack"); + history.add(200, -1, "", "Alice [direct]:", "hello"); + + char label[32]; + History::threadLabel(*history.newest(1), label, sizeof(label)); + EXPECT_STREQ("#bot-pdx", label); + History::threadLabel(*history.newest(0), label, sizeof(label)); + EXPECT_STREQ("Direct: Alice", label); +} + +TEST(CompanionMessageHistory, LaysOutFiveIndicatorRows) { + const mesh::ui::CompanionMessageListLayout layout = + mesh::ui::makeCompanionMessageListLayout(160); + EXPECT_EQ(20, layout.top); + EXPECT_EQ(26, layout.row_height); + EXPECT_EQ(5, layout.visible_rows); + EXPECT_LT(layout.top + (layout.visible_rows - 1) * layout.row_height + + layout.divider_offset, + 160); +} + +TEST(CompanionMessageHistory, CompactsOnlyMessageScreenChrome) { + const mesh::ui::CompanionMessageChromeLayout normal = + mesh::ui::makeCompanionMessageChromeLayout(false); + EXPECT_FALSE(normal.compact_text); + EXPECT_EQ(11, normal.header_divider_y); + EXPECT_EQ(24, normal.filter_height); + + const mesh::ui::CompanionMessageChromeLayout compact = + mesh::ui::makeCompanionMessageChromeLayout(true); + EXPECT_TRUE(compact.compact_text); + EXPECT_EQ(8, compact.header_divider_y); + EXPECT_EQ(11, compact.origin_y); + EXPECT_EQ(22, compact.message_y); + EXPECT_EQ(14, compact.filter_height); + EXPECT_EQ(3, compact.filter_text_offset); +} + +TEST(CompanionMessageHistory, FormatsRelativeAgeAndMillisRollover) { + char age[16]; + mesh::ui::formatCompanionMessageAge(age, sizeof(age), 0); + EXPECT_STREQ("now", age); + mesh::ui::formatCompanionMessageAge(age, sizeof(age), 12'000); + EXPECT_STREQ("12s ago", age); + mesh::ui::formatCompanionMessageAge(age, sizeof(age), 60'000); + EXPECT_STREQ("1m ago", age); + mesh::ui::formatCompanionMessageAge(age, sizeof(age), 3'600'000); + EXPECT_STREQ("1h ago", age); + mesh::ui::formatCompanionMessageAge( + age, sizeof(age), 50ULL * 24ULL * 60ULL * 60ULL * 1000ULL); + EXPECT_STREQ("50d ago", age); + + const uint32_t heard = UINT32_MAX - 999U; + const uint32_t now = 1000U; + mesh::ui::formatCompanionMessageAge(age, sizeof(age), now - heard); + EXPECT_STREQ("2s ago", age); +} + +TEST(CompanionMessageHistory, MakesPreviewSingleLine) { + char preview[] = "first\nsecond\rthird\tfourth"; + mesh::ui::makeCompanionMessagePreviewSingleLine(preview); + EXPECT_STREQ("first second third fourth", preview); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_companion_terminal_profile.py b/test/test_companion_terminal_profile.py index 4a1683680..18ba681f2 100644 --- a/test/test_companion_terminal_profile.py +++ b/test/test_companion_terminal_profile.py @@ -14,6 +14,14 @@ local = source[local_start:local_end] assert local.count('strcmp(command, "board") == 0') == 1 assert 'board.getManufacturerName()' in local +assert local.count('strcmp(command, "version") == 0') == 1 +assert re.search( + r'if \(strcmp\(command, "version"\) == 0\).*?' + r'"Companion %s \(protocol %u, build %s\)".*?' + r'FIRMWARE_VERSION.*?FIRMWARE_VER_CODE.*?FIRMWARE_BUILD_DATE', + local, + re.DOTALL, +) assert re.search( r"#if COMPANION_FEATURE_MEMORY_DIAGNOSTICS\s+" r'if \(strcmp\(command, "memory"\) == 0\).*?' @@ -40,6 +48,7 @@ assert terminal.index("handleLocalControlCommand(") < terminal.index( 'strncmp(command, "set ", 4) == 0' ) assert 'terminalOutput().print(" board\\r\\n")' in terminal +assert 'terminalOutput().print(" version\\r\\n")' in terminal assert 'terminalOutput().print(" set pin <0-999999>\\r\\n")' in terminal assert re.search( r"#if COMPANION_FEATURE_MEMORY_DIAGNOSTICS\s+" diff --git a/test/test_companion_transport_selector.py b/test/test_companion_transport_selector.py new file mode 100644 index 000000000..67282c377 --- /dev/null +++ b/test/test_companion_transport_selector.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 + +from pathlib import Path +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +UI = ROOT / "examples" / "companion_radio" / "ui-new" / "UITask.cpp" + + +class CompanionTransportSelectorTest(unittest.TestCase): + def test_indicator_layout_has_room_for_large_transport_text(self): + source = r''' +#include + +int main() { + const auto layout = mesh::ui::makeCompanionTransportSelectorLayout(160, 160); + if (!layout.show_title) return 1; + if (layout.wifi.x != 2 || layout.wifi.width != 76) return 2; + if (layout.bluetooth.x != 82 || layout.bluetooth.width != 76) return 3; + if (layout.wifi.y != 40 || layout.wifi.height != 72) return 4; + if (layout.title_y != 17 || layout.prompt_y != 138) return 5; + + const auto small = mesh::ui::makeCompanionTransportSelectorLayout(128, 64); + if (small.show_title) return 6; + if (small.wifi.x != 2 || small.wifi.width != 60) return 7; + if (small.bluetooth.x != 66 || small.bluetooth.width != 60) return 8; + if (small.wifi.y != 20 || small.wifi.height != 25) return 9; + if (small.prompt_y != 53) return 10; + return 0; +} +''' + with tempfile.TemporaryDirectory() as temp_dir: + executable = Path(temp_dir) / "transport_layout" + compile_result = subprocess.run( + [ + "c++", + "-std=c++11", + f"-I{ROOT / 'src'}", + "-x", + "c++", + "-", + "-o", + str(executable), + ], + input=source, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(compile_result.returncode, 0, compile_result.stderr) + run_result = subprocess.run( + [str(executable)], capture_output=True, text=True, check=False + ) + self.assertEqual(run_result.returncode, 0, run_result.stderr) + + logical_glyph_width = 6 * 2 + logical_glyph_height = 8 * 2 + for text in ("Wi-Fi", "BLE", "ACTIVE", "NEXT"): + self.assertLessEqual(len(text) * logical_glyph_width, 76) + for text in ("TRANSPORT", "tap a box"): + self.assertLessEqual(len(text) * logical_glyph_width, 160) + self.assertLessEqual(17 + logical_glyph_height, 40) + self.assertLessEqual(89 + logical_glyph_height, 112) + self.assertLessEqual(138 + logical_glyph_height, 160) + + def test_touch_split_selector_distinguishes_taps_from_swipes(self): + source = r''' +#include +#include + +using mesh::ui::TouchAction; +using mesh::ui::TouchInput; +using mesh::ui::TouchSplitSelector; + +static TouchAction release(TouchInput& input, + const TouchSplitSelector* selector) { + input.update(false, -1, -1, 160, 160, false, selector); + return input.update(false, -1, -1, 160, 160, false, selector); +} + +int main() { + TouchInput input(true, true, 70); + const auto layout = mesh::ui::makeCompanionTransportSelectorLayout(160, 160); + const TouchSplitSelector selector{ + layout.wifi.x, layout.wifi.width, + layout.bluetooth.x, layout.bluetooth.width, + layout.wifi.y, layout.wifi.height}; + + input.update(true, 30, 70, 160, 160, false, &selector); + input.update(true, 31, 70, 160, 160, false, &selector); + if (release(input, &selector) != TouchAction::SelectLeft) return 1; + + input.update(true, 130, 70, 160, 160, false, &selector); + input.update(true, 129, 70, 160, 160, false, &selector); + if (release(input, &selector) != TouchAction::SelectRight) return 2; + + input.update(true, 80, 70, 160, 160, false, &selector); + input.update(true, 80, 70, 160, 160, false, &selector); + if (release(input, &selector) != TouchAction::None) return 3; + + input.update(true, 30, 20, 160, 160, false, &selector); + input.update(true, 30, 20, 160, 160, false, &selector); + if (release(input, &selector) != TouchAction::None) return 4; + + input.update(true, 130, 70, 160, 160, false, &selector); + input.update(true, 30, 72, 160, 160, false, &selector); + if (release(input, &selector) != TouchAction::Previous) return 5; + + input.update(true, 30, 70, 160, 160, false, &selector); + input.update(true, 130, 68, 160, 160, false, &selector); + if (release(input, &selector) != TouchAction::Next) return 6; + + return 0; +} +''' + with tempfile.TemporaryDirectory() as temp_dir: + executable = Path(temp_dir) / "touch_split_selector" + compile_result = subprocess.run( + [ + "c++", + "-std=c++11", + f"-I{ROOT / 'src'}", + "-x", + "c++", + "-", + "-o", + str(executable), + ], + input=source, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(compile_result.returncode, 0, compile_result.stderr) + run_result = subprocess.run( + [str(executable)], capture_output=True, text=True, check=False + ) + self.assertEqual(run_result.returncode, 0, run_result.stderr) + + def test_exclusive_page_renders_and_persists_two_choices(self): + source = UI.read_text(encoding="utf-8") + self.assertIn("#ifdef COMPANION_EXCLUSIVE_WIFI_BLE", source) + self.assertIn("TRANSPORT,", source) + self.assertIn('"Wi-Fi", wifi_active, wifi_selected', source) + self.assertIn('"BLE", !wifi_active, !wifi_selected', source) + self.assertIn("display.fillRect(x, y, width, height);", source) + self.assertIn('"ACTIVE"', source) + self.assertIn('large_transport_text ? 2 : 1', source) + self.assertIn("const bool show_status_label = height >= 44;", source) + self.assertIn('active ? "ACTIVE"', source) + self.assertIn('large_transport_text ? "NEXT" : "NEXT BOOT"', source) + self.assertIn('display.setTextSize(2);', source) + self.assertIn("makeCompanionTransportSelectorLayout", source) + + handler_start = source.index( + "if (_page == HomePage::TRANSPORT\n" + " && (c == KEY_ENTER || c == KEY_UP || c == KEY_DOWN))" + ) + handler_end = source.index("#else", handler_start) + handler = source[handler_start:handler_end] + active = handler.index("const CompanionTransportMode active") + save = handler.index("selectCompanionTransportMode(requested)") + error = handler.index('showAlert("Transport save failed"') + reboot = handler.index("if (requested != active) _task->shutdown(true);") + self.assertLess(active, save) + self.assertLess(save, error) + self.assertLess(error, reboot) + + render_start = source.index( + "} else if (_page == HomePage::TRANSPORT) {" + ) + render_end = source.index( + "} else if (_page == HomePage::BLUETOOTH) {", render_start + ) + render = source[render_start:render_end] + self.assertIn("const bool wifi_active = isCompanionWiFiEnabled();", render) + self.assertIn("const bool wifi_selected = getCompanionTransportMode()", render) + compact_on = render.index("display.setCompactText(true);") + choices = render.index("drawCompanionTransportChoice(") + compact_off = render.index("display.setCompactText(false);") + self.assertLess(compact_on, choices) + self.assertLess(choices, compact_off) + + def test_only_split_page_maps_box_taps_to_transport_keys(self): + source = UI.read_text(encoding="utf-8") + self.assertIn( + "curr == home\n" + " && static_cast(home)" + "->isTransportSelectorPage()", + source, + ) + self.assertIn("curr == msg_preview, split_transport_selector", source) + self.assertIn( + "case mesh::ui::TouchAction::SelectLeft:\n" + " c = checkDisplayOn(KEY_UP);", + source, + ) + self.assertIn( + "case mesh::ui::TouchAction::SelectRight:\n" + " c = checkDisplayOn(KEY_DOWN);", + source, + ) + self.assertIn( + "if (!on_transport_selector) c = checkDisplayOn(KEY_UP);", + source, + ) + self.assertIn( + "if (!on_transport_selector) c = checkDisplayOn(KEY_DOWN);", + source, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_companion_wifi_ntp.py b/test/test_companion_wifi_ntp.py new file mode 100644 index 000000000..ada1b6a82 --- /dev/null +++ b/test/test_companion_wifi_ntp.py @@ -0,0 +1,159 @@ +import pathlib +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +MAIN = ROOT / "examples/companion_radio/main.cpp" +POLICY = ROOT / "src/helpers/CompanionWiFiNtpPolicy.h" +MESH_CLOCK = ROOT / "src/helpers/MeshClockSync.cpp" +MQTT = ROOT / "src/helpers/bridges/MQTTBridge.cpp" +MQTT_HEADER = ROOT / "src/helpers/bridges/MQTTBridge.h" +ESP32_BOARD = ROOT / "src/helpers/ESP32Board.cpp" +ESP32_BOARD_HEADER = ROOT / "src/helpers/ESP32Board.h" +BASE_CHAT = ROOT / "src/helpers/BaseChatMesh.cpp" +ROOM_SERVER = ROOT / "examples/simple_room_server/MyMesh.h" +ROOM_SERVER_IMPL = ROOT / "examples/simple_room_server/MyMesh.cpp" +REPEATER = ROOT / "examples/simple_repeater/MyMesh.cpp" +COMPANION_MESH = ROOT / "examples/companion_radio/MyMesh.cpp" + + +def source(path: pathlib.Path) -> str: + return path.read_text(encoding="utf-8") + + +class CompanionWiFiNtpIntegrationTests(unittest.TestCase): + def test_policy_is_boot_immediate_and_daily_after_success(self): + policy = source(POLICY) + self.assertIn("24UL * 60UL * 60UL * 1000UL", policy) + self.assertIn("CompanionWiFiNtpPolicy() : _scheduled(false)", policy) + self.assertIn("noteSuccess", policy) + self.assertIn("kCompanionNtpRefreshMillis", policy) + self.assertIn("static_cast(now - _deadline) >= 0", policy) + + def test_common_wifi_path_uses_fresh_owned_sntp_completion(self): + main = source(MAIN) + self.assertIn("#include ", main) + self.assertIn("processWideCoordinator()", main) + self.assertIn("companion_wifi_ntp_operation_generation", main) + self.assertIn("companion_wifi_ntp_proof_generation", main) + self.assertIn("companion_wifi_ntp_proven_at", main) + self.assertIn( + "proven_at - companion_wifi_ntp_started <=", main + ) + self.assertIn("sntp_set_sync_status(SNTP_SYNC_STATUS_RESET)", main) + self.assertIn("configTime(0, 0, \"time.cloudflare.com\"", main) + self.assertLess( + main.index("sntp_set_sync_status(SNTP_SYNC_STATUS_RESET)"), + main.index("configTime(0, 0, \"time.cloudflare.com\""), + ) + + def test_success_updates_board_rtc_and_blocks_mesh_fallback(self): + main = source(MAIN) + proof = main.index("const uint32_t proof_epoch") + accepted = main.index("const time_t raw_system_time = time(nullptr)", proof) + write_rtc = main.index("rtc_clock.setCurrentTime(epoch);", accepted) + self.assertLess(proof, accepted) + self.assertLess(accepted, write_rtc) + self.assertIn("rtc_clock.setCurrentTime(epoch);", main) + self.assertIn( + "if (moved_backward) rtc_clock.resetUniqueTime(epoch);", main + ) + self.assertIn("the_mesh.noteInternetClockSet();", main) + self.assertIn("serviceCompanionWiFiNtp();", main) + self.assertIn("void MeshClockSync::onInternetClockSet()", source(MESH_CLOCK)) + self.assertIn("suppressForBoot(SUPPRESS_INTERNET)", source(MESH_CLOCK)) + + def test_configured_mqtt_remains_the_single_ntp_owner(self): + main = source(MAIN) + self.assertIn("companionWiFiMqttOwnsNtp", main) + self.assertIn("const bool running = the_mesh.isMQTTRunning();", main) + self.assertIn("the_mesh.hasFreshMQTTNtpThisBoot()", main) + owner_check = main.index("if (companionWiFiMqttOwnsNtp())") + generic_start = main.index("companion_wifi_ntp_operation.tryAcquire()") + self.assertLess(owner_check, generic_start) + + def test_common_and_mqtt_requests_are_one_shot_with_explicit_daily_cadence(self): + main = source(MAIN) + mqtt = source(MQTT) + header = source(MQTT_HEADER) + cleanup = main[main.index("static void clearCompanionWiFiNtpCallback") :] + mqtt_cleanup = mqtt[ + mqtt.index("static void stopMqttSntpOperation()") : + mqtt.index("static void formatRadioInfo") + ] + self.assertIn("esp_sntp_stop();", cleanup) + self.assertNotIn("esp_sntp_set_sync_interval(", mqtt) + self.assertIn("static void stopMqttSntpOperation()", mqtt) + self.assertIn("esp_sntp_stop();", mqtt_cleanup) + self.assertIn("OperationLease _ntp_refresh_operation", header) + self.assertIn("_ntp_refresh_operation.tryAcquire()", mqtt) + self.assertIn("_ntp_refresh_operation.owns()", mqtt) + self.assertIn("_ntp_refresh_operation.release()", mqtt) + self.assertNotIn("ntpReconnectRefreshAt", mqtt) + + def test_backward_mqtt_correction_is_serviced_on_role_main_loops(self): + mqtt = source(MQTT) + header = source(MQTT_HEADER) + self.assertIn("std::atomic _backward_clock_reset_epoch", header) + self.assertIn("bool servicePendingClockCorrection();", header) + self.assertIn("_backward_clock_reset_epoch.store(", mqtt) + self.assertIn("_rtc->resetUniqueTime(epoch);", mqtt) + for role in (COMPANION_MESH, ROOM_SERVER_IMPL, REPEATER): + self.assertIn("servicePendingClockCorrection()", source(role)) + + def test_mqtt_boot_paths_and_fresh_success_begin_daily_cadence(self): + mqtt = source(MQTT) + initialize = mqtt[ + mqtt.index("void MQTTBridge::initializeWiFiInTask()") : + mqtt.index("void MQTTBridge::mqttTaskLoop()") + ] + task_loop = mqtt[ + mqtt.index("void MQTTBridge::mqttTaskLoop()") : + mqtt.index("bool MQTTBridge::isSlotReady") + ] + sync = mqtt[ + mqtt.index("bool MQTTBridge::syncTimeWithNTP") : + mqtt.index("bool MQTTBridge::requestForcedNtpSync") + ] + self.assertIn("!_ntp_synced.load", initialize) + self.assertIn("_ntp_sync_pending = true", initialize) + self.assertIn("_ntp_reconnect_latch.consumeIfConnected", task_loop) + self.assertIn("_ntp_sync_pending = true", task_loop) + self.assertIn("syncTimeWithNTP();", task_loop) + self.assertIn("_last_ntp_sync = millis();", sync) + self.assertIn( + "fresh_ntp ? 0 : mqttNtpRetryAt(_last_ntp_sync)", sync + ) + + def test_only_fresh_internet_replies_suppress_mesh_fallback(self): + mqtt = source(MQTT) + header = source(MQTT_HEADER) + self.assertIn("std::atomic _fresh_ntp_this_boot", header) + self.assertIn("bool hasFreshNtpThisBoot() const", header) + self.assertIn("const bool fresh_ntp = ntp_server_used != nullptr;", mqtt) + self.assertIn("if (fresh_ntp) {", mqtt) + self.assertIn("_fresh_ntp_this_boot.store(true", mqtt) + self.assertNotIn("bridge->hasNtpTime()", source(ROOM_SERVER)) + self.assertNotIn("mqtt_bridge->hasNtpTime()", source(REPEATER)) + self.assertIn("bridge->hasFreshNtpThisBoot()", source(ROOM_SERVER)) + self.assertIn("mqtt_bridge->hasFreshNtpThisBoot()", source(REPEATER)) + + def test_esp32_retained_clock_has_one_program_wide_backup(self): + header = source(ESP32_BOARD_HEADER) + implementation = source(ESP32_BOARD) + self.assertNotIn("static RTC_NOINIT_ATTR uint32_t", header) + self.assertIn("extern uint32_t rtc_backup_time;", header) + self.assertIn("RTC_NOINIT_ATTR uint32_t rtc_backup_time;", implementation) + + def test_contact_bootstrap_is_only_a_forward_clock_floor(self): + implementation = source(BASE_CHAT) + bootstrap = implementation[ + implementation.index("void BaseChatMesh::bootstrapRTCfromContacts()") : + implementation.index("ContactInfo* BaseChatMesh::allocateContactSlot") + ] + self.assertIn("latest != 0 && latest != UINT32_MAX", bootstrap) + self.assertIn("contact_floor > getRTCClock()->getCurrentTime()", bootstrap) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_companion_wifi_ntp_policy/test_companion_wifi_ntp_policy.cpp b/test/test_companion_wifi_ntp_policy/test_companion_wifi_ntp_policy.cpp new file mode 100644 index 000000000..d1bd758c4 --- /dev/null +++ b/test/test_companion_wifi_ntp_policy/test_companion_wifi_ntp_policy.cpp @@ -0,0 +1,58 @@ +#include + +#include + +using mesh::wifi::CompanionWiFiNtpPolicy; + +TEST(CompanionWiFiNtpPolicy, AttemptsImmediatelyAfterBoot) { + CompanionWiFiNtpPolicy policy; + EXPECT_TRUE(policy.attemptDue(0)); + EXPECT_TRUE(policy.attemptDue(123456)); +} + +TEST(CompanionWiFiNtpPolicy, RefreshesExactlyTwentyFourHoursAfterSuccess) { + CompanionWiFiNtpPolicy policy; + const uint32_t now = 1234; + policy.noteSuccess(now); + EXPECT_FALSE(policy.attemptDue( + now + mesh::wifi::kCompanionNtpRefreshMillis - 1)); + EXPECT_TRUE(policy.attemptDue( + now + mesh::wifi::kCompanionNtpRefreshMillis)); +} + +TEST(CompanionWiFiNtpPolicy, UsesShortBoundedFailureAndBusyRetries) { + CompanionWiFiNtpPolicy policy; + const uint32_t now = 500; + policy.noteBusy(now); + EXPECT_FALSE(policy.attemptDue( + now + mesh::wifi::kCompanionNtpBusyRetryMillis - 1)); + EXPECT_TRUE(policy.attemptDue( + now + mesh::wifi::kCompanionNtpBusyRetryMillis)); + + policy.noteFailure(now); + EXPECT_FALSE(policy.attemptDue( + now + mesh::wifi::kCompanionNtpFailureRetryMillis - 1)); + EXPECT_TRUE(policy.attemptDue( + now + mesh::wifi::kCompanionNtpFailureRetryMillis)); +} + +TEST(CompanionWiFiNtpPolicy, DeadlinesSurviveMillisRollover) { + CompanionWiFiNtpPolicy policy; + const uint32_t now = UINT32_MAX - 1000U; + policy.noteBusy(now); + EXPECT_FALSE(policy.attemptDue(now + 4999U)); + EXPECT_TRUE(policy.attemptDue(now + 5000U)); +} + +TEST(CompanionWiFiNtpPolicy, InterruptedAttemptCanResumeWhenConnectivityReturns) { + CompanionWiFiNtpPolicy policy; + policy.noteFailure(100); + EXPECT_FALSE(policy.attemptDue(101)); + policy.requestNow(); + EXPECT_TRUE(policy.attemptDue(101)); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_display_driver/test_display_driver.cpp b/test/test_display_driver/test_display_driver.cpp index f8f3889c3..15d297df2 100644 --- a/test/test_display_driver/test_display_driver.cpp +++ b/test/test_display_driver/test_display_driver.cpp @@ -1,6 +1,7 @@ #include #include +#include #include #include @@ -40,19 +41,30 @@ public: std::string text; }; + struct FilledRect { + int x; + int y; + int width; + int height; + }; + std::string printed; std::vector rows; + std::vector fills; int cursor_x = 0; int cursor_y = 0; + int text_size = 1; + int glyph_width = 1; - TestDisplay() : DisplayDriver(100, 100) {} + TestDisplay(int width = 100, int height = 100, int glyph_width = 1) + : DisplayDriver(width, height), glyph_width(glyph_width) {} bool isOn() override { return true; } void turnOn() override {} void turnOff() override {} void clear() override {} void startFrame(ColorVal) override {} - void setTextSize(int) override {} + void setTextSize(int size) override { text_size = size > 0 ? size : 1; } void setColor(ColorVal) override {} void setCursor(int x, int y) override { cursor_x = x; @@ -62,7 +74,9 @@ public: printed = str; rows.push_back({cursor_x, cursor_y, str}); } - void fillRect(int, int, int, int) override {} + void fillRect(int x, int y, int width, int height) override { + fills.push_back({x, y, width, height}); + } void drawRect(int, int, int, int) override {} void drawXbm(int, int, const uint8_t*, int, int) override {} uint16_t getTextWidth(const char* str) override { @@ -70,7 +84,7 @@ public: for (size_t i = 0; str[i] != 0; ++i) { if (((uint8_t)str[i] & 0xC0) != 0x80) ++width; } - return width; + return width * glyph_width * text_size; } void endFrame() override {} }; @@ -131,6 +145,163 @@ TEST(DisplayDriver, KeepsMaximumLengthSSIDWithoutEllipsis) { + display.rows[2].text); } +TEST(DisplayDriver, LongSetupAddressStaysInsideItsRows) { + TestDisplay display(160, 160); + const std::string address = + "http://very-long-indicator-hostname-for-a-setup-page.example.invalid/" + "path/that/remains/longer/than/one/logical/display/row/and/continues/" + "through/the/second/bounded/row/without/reaching/the/pairing/status"; + EXPECT_EQ(2, mesh::ui::drawTextWrapped( + display, 6, 99, 148, 13, 2, address.c_str())); + ASSERT_EQ(2U, display.rows.size()); + for (const auto& row : display.rows) { + EXPECT_GE(row.x, 6); + EXPECT_LE(row.x + display.getTextWidth(row.text.c_str()), 154); + EXPECT_GE(row.y, 99); + EXPECT_LT(row.y, 125); + } +} + +TEST(DisplayDriver, IndicatorHomeSeparatesInfoAndPairingGeometry) { + const mesh::ui::CompanionHomeLayout layout = + mesh::ui::makeLargeCompanionHomeLayout(160, 160); + + EXPECT_EQ(128, layout.pairing.y + layout.pairing.height / 2); + EXPECT_EQ(48, layout.pairing.height); + EXPECT_FALSE(mesh::ui::displayRegionsOverlap(layout.info, + layout.pairing)); + EXPECT_LE(layout.info.bottom() + 12, layout.pairing.y); + EXPECT_TRUE(mesh::ui::displayRegionContainsLine( + layout.info, layout.instruction_y, 12)); + EXPECT_TRUE(mesh::ui::displayRegionContainsLine( + layout.info, layout.network_y, 12)); + EXPECT_TRUE(mesh::ui::displayRegionContainsLine( + layout.pairing, layout.pairing_label_y, 12)); + EXPECT_TRUE(mesh::ui::displayRegionContainsLine( + layout.pairing, layout.pairing_value_y, 24)); +} + +TEST(DisplayDriver, IndicatorPairingValuesFitAtRenderedTextSizes) { + // Model the fallback font's fixed six-logical-pixel cell. It is the wider + // of the two paths for CONNECTED; the profile test reads the recovered + // VLW's real advances and independently checks that path. + TestDisplay display(160, 160, 6); + const mesh::ui::CompanionHomeLayout layout = + mesh::ui::makeLargeCompanionHomeLayout(display.width(), + display.height()); + + display.setTextSize(3); + EXPECT_EQ(108, display.getTextWidth("123456")); + EXPECT_LE(display.getTextWidth("123456"), layout.pairing.width); + mesh::ui::drawTextCenteredEllipsized( + display, layout.pairing, layout.pairing_value_y, "123456"); + ASSERT_EQ(1U, display.rows.size()); + EXPECT_EQ("123456", display.rows.back().text); + EXPECT_GE(display.rows.back().x, layout.pairing.x); + EXPECT_LE(display.rows.back().x + display.getTextWidth("123456"), + layout.pairing.right()); + + display.setTextSize(2); + EXPECT_EQ(108, display.getTextWidth("CONNECTED")); + EXPECT_LE(display.getTextWidth("CONNECTED"), layout.pairing.width); + mesh::ui::drawTextCenteredEllipsized( + display, layout.pairing, layout.pairing_value_y, "CONNECTED"); + ASSERT_EQ(2U, display.rows.size()); + EXPECT_EQ("CONNECTED", display.rows.back().text); + EXPECT_GE(display.rows.back().x, layout.pairing.x); + EXPECT_LE(display.rows.back().x + display.getTextWidth("CONNECTED"), + layout.pairing.right()); +} + +TEST(DisplayDriver, CompactPairingOwnsBounded128x64LowerRegion) { + EXPECT_TRUE(mesh::ui::usesCompactCompanionPairingLayout(128, 64)); + EXPECT_FALSE(mesh::ui::usesCompactCompanionPairingLayout(160, 160)); + EXPECT_FALSE(mesh::ui::usesCompactCompanionPairingLayout(128, 65)); + + const mesh::ui::CompactCompanionPairingLayout layout = + mesh::ui::makeCompactCompanionPairingLayout(128, 64); + const mesh::ui::DisplayRegion inbox = {0, 22, 128, 16}; + + EXPECT_EQ(4, layout.pairing.x); + EXPECT_EQ(38, layout.pairing.y); + EXPECT_EQ(120, layout.pairing.width); + EXPECT_EQ(26, layout.pairing.height); + EXPECT_FALSE(mesh::ui::displayRegionsOverlap(inbox, layout.pairing)); + EXPECT_TRUE(mesh::ui::displayRegionContainsLine( + layout.pairing, layout.pairing_label_y, 8)); + EXPECT_TRUE(mesh::ui::displayRegionContainsLine( + layout.pairing, layout.pairing_value_y, 16)); + + // The old instruction and Wi-Fi rows are entirely replaced while pairing + // is active, so neither can remain behind or overprint the new value. + EXPECT_TRUE(mesh::ui::displayRegionContainsLine(layout.pairing, 43, 8)); + EXPECT_TRUE(mesh::ui::displayRegionContainsLine(layout.pairing, 54, 8)); +} + +TEST(DisplayDriver, CompactPairingValuesFitAtSizeTwo) { + TestDisplay display(128, 64, 6); + const mesh::ui::CompactCompanionPairingLayout layout = + mesh::ui::makeCompactCompanionPairingLayout( + display.width(), display.height()); + + display.setTextSize(2); + for (const char* value : {"123456", "CONNECTED"}) { + EXPECT_LE(display.getTextWidth(value), layout.pairing.width); + mesh::ui::drawTextCenteredEllipsized( + display, layout.pairing, layout.pairing_value_y, value); + EXPECT_EQ(value, display.rows.back().text); + EXPECT_GE(display.rows.back().x, layout.pairing.x); + EXPECT_LE(display.rows.back().x + display.getTextWidth(value), + layout.pairing.right()); + } +} + +TEST(DisplayDriver, LongHomeStatusCannotEnterPairingBlock) { + TestDisplay display(160, 160); + const mesh::ui::CompanionHomeLayout layout = + mesh::ui::makeLargeCompanionHomeLayout(display.width(), + display.height()); + const std::string instruction(240, 'I'); + const std::string network_status = + "Connected to a maximum-length-wireless-network-name at an " + "unexpectedly-long-address.example.invalid"; + + mesh::ui::drawTextCenteredEllipsized( + display, layout.info, layout.instruction_y, instruction.c_str()); + mesh::ui::drawTextCenteredEllipsized( + display, layout.info, layout.network_y, network_status.c_str()); + + ASSERT_EQ(2U, display.rows.size()); + for (const auto& row : display.rows) { + EXPECT_GE(row.x, layout.info.x); + EXPECT_LE(row.x + display.getTextWidth(row.text.c_str()), + layout.info.right()); + EXPECT_LT(row.y, layout.pairing.y); + } +} + +TEST(DisplayDriver, PairingRegionIsClearedBeforeStatusReplacement) { + TestDisplay display(160, 160); + const mesh::ui::CompanionHomeLayout layout = + mesh::ui::makeLargeCompanionHomeLayout(display.width(), + display.height()); + + mesh::ui::clearDisplayRegion(display, layout.pairing); + mesh::ui::drawTextCenteredEllipsized( + display, layout.pairing, layout.pairing_value_y, "123456"); + mesh::ui::clearDisplayRegion(display, layout.pairing); + mesh::ui::drawTextCenteredEllipsized( + display, layout.pairing, layout.pairing_value_y, "CONNECTED"); + + ASSERT_EQ(2U, display.fills.size()); + for (const auto& fill : display.fills) { + EXPECT_EQ(layout.pairing.x, fill.x); + EXPECT_EQ(layout.pairing.y, fill.y); + EXPECT_EQ(layout.pairing.width, fill.width); + EXPECT_EQ(layout.pairing.height, fill.height); + } +} + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); diff --git a/test/test_indicator_display_profile.py b/test/test_indicator_display_profile.py index a706fcec9..9ff48986e 100644 --- a/test/test_indicator_display_profile.py +++ b/test/test_indicator_display_profile.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import re +import struct from pathlib import Path import unittest @@ -8,6 +9,7 @@ import unittest ROOT = Path(__file__).resolve().parents[1] PROFILE = ROOT / "variants" / "sensecap_indicator-espnow" / "platformio.ini" DISPLAY = ROOT / "src" / "helpers" / "ui" / "LGFXDisplay.cpp" +FONT = ROOT / "variants" / "sensecap_indicator-espnow" / "sd" / "ui-font.vlw" def base_profile() -> str: @@ -61,6 +63,72 @@ class IndicatorDisplayProfileTest(unittest.TestCase): self.assertGreaterEqual(old_canvas_bytes - canvas_bytes, 64_000) self.assertIn("buffer.setPsram(false);", DISPLAY.read_text()) + def test_uses_dedicated_pairing_block(self): + self.assertIn("-D UI_DEDICATED_PAIRING_BLOCK=1", base_profile()) + + logical_height = 160 + block_height = 48 + block_center = logical_height * 4 // 5 + block_top = block_center - block_height // 2 + info_top = 40 + info_bottom = block_top - 12 + + self.assertEqual(block_center, 128) + self.assertEqual(block_top, 104) + self.assertLess(info_top, info_bottom) + self.assertLessEqual(info_bottom + 12, block_top) + + def test_recovered_font_pairing_values_fit_reserved_block(self): + data = FONT.read_bytes() + glyph_count = struct.unpack_from(">I", data, 0)[0] + advances = {} + heights = {} + for index in range(glyph_count): + offset = 24 + index * 28 + codepoint, height, _width, advance = struct.unpack_from( + ">IIII", data, offset + ) + advances[codepoint] = advance + heights[codepoint] = height + + footer = data[-40:] + self.assertEqual(b"MCEMAP2\0", footer[:8]) + atlas_offset = struct.unpack_from("wifi_enabled != 0" in selector +assert "const uint8_t previous = prefs->wifi_enabled;" in selector +assert "if (the_mesh.savePrefs()) return true;" in selector +assert "prefs->wifi_enabled = previous;" in selector +assert "companion_wifi_requested = companionTransportWiFiActiveAtBoot();" in selector +setter = selector[ + selector.index("bool selectCompanionTransportMode(") : + selector.index("static bool companionTransportWiFiActiveAtBoot()") +] +assert "companion_wifi_requested" not in setter + + +# A BLE-selected boot must not spend memory on dormant infrastructure-WiFi +# services before main.cpp applies the boot selection. +mesh_begin_start = mesh.index("void MyMesh::begin(") +mesh_begin_end = mesh.index("\nvoid MyMesh::configureRadioFromPrefs()", mesh_begin_start) +mesh_begin = mesh[mesh_begin_start:mesh_begin_end] +assert re.search( + r"#if defined\(WITH_MQTT_BRIDGE\).*?" + r"#if defined\(COMPANION_EXCLUSIVE_WIFI_BLE\)\s+" + r"if \(_prefs\.wifi_enabled != 0\) \{.*?" + r"_mqtt_bridge = new MQTTBridge", + mesh_begin, + re.DOTALL, +) +assert re.search( + r"#ifdef WITH_WEBCONFIG\s+" + r"#if defined\(COMPANION_EXCLUSIVE_WIFI_BLE\)\s+" + r"if \(_prefs\.wifi_enabled != 0\) \{.*?" + r"_webconfig = new WebConfigServer", + mesh_begin, + re.DOTALL, +) + + +# Full Bluetooth controller + host memory is released only for a WiFi-selected +# boot and before the WiFi stack is started. BLE-selected boots initialize BLE +# while the WiFi event handler/interface/start block remains gated out. +setup_start = main.index("void setup()") +setup_end = main.index("\nvoid loop()", setup_start) +setup = main[setup_start:setup_end] +assert "esp_bt_mem_release(ESP_BT_MODE_BTDM)" in main +assert setup.index("loadCompanionTransportModeForBoot();") < setup.index( + "releaseCompanionBluetoothMemoryForWiFi();" +) +assert setup.index("releaseCompanionBluetoothMemoryForWiFi();") < setup.index( + "startCompanionWiFi();" +) +assert re.search( + r"#if defined\(COMPANION_EXCLUSIVE_WIFI_BLE\)\s+" + r"if \(!companionTransportWiFiActiveAtBoot\(\)\) startCompanionBluetooth\(\);", + setup, +) +assert re.search( + r"#ifdef WIFI_SSID\s+" + r"#if defined\(COMPANION_EXCLUSIVE_WIFI_BLE\)\s+" + r"if \(companionTransportWiFiActiveAtBoot\(\)\) \{.*?" + r"WiFi\.onEvent\(.*?startCompanionWiFi\(\);.*?" + r"#if defined\(COMPANION_EXCLUSIVE_WIFI_BLE\)\s+\}", + setup, + re.DOTALL, +) +assert re.search( + r"#if defined\(COMPANION_EXCLUSIVE_WIFI_BLE\)\s+" + r"if \(!companionTransportWiFiActiveAtBoot\(\)\) \{\s+" + r"serviceDeferredCompanionBluetooth\(\);", + main, +) + + +# WiFi power-save conflicts describe the active boot transport, not merely the +# fact that BLE support was compiled into the binary. The legacy simultaneous +# behavior remains in the non-exclusive branch. +power_helper_start = main.index("static bool companionWiFiBluetoothActive()") +power_helper_end = main.index("const char* companionWiFiPowerSaveName", power_helper_start) +power_helper = main[power_helper_start:power_helper_end] +assert "companion_transport_boot_mode_loaded" in power_helper +assert "active_mode == CompanionTransportMode::Bluetooth" in power_helper +assert "#elif defined(BLE_PIN_CODE)" in power_helper +assert re.search( + r"static bool bluetoothWiFiCoexistenceRequired\(\) \{\s+" + r"#if defined\(COMPANION_EXCLUSIVE_WIFI_BLE\)\s+.*?return false;\s+" + r"#elif defined\(BLE_PIN_CODE\) && defined\(WIFI_SSID\)", + webconfig, + re.DOTALL, +) +assert re.search( + r"mesh::wifi::effectivePowerSave\(\s+_obs->wifi_power_save,\s+" + r"#if defined\(COMPANION_EXCLUSIVE_WIFI_BLE\)\s+false,\s+" + r"#elif defined\(BLE_PIN_CODE\) && defined\(WIFI_SSID\)", + mqtt, +) + + +# Local/framed/rescue dispatch and terminal help share the reboot-only selector. +local_start = mesh.index("bool MyMesh::handleLocalControlCommand(") +local_end = mesh.index("\n#if COMPANION_FEATURE_TEMP_RADIO\nvoid MyMesh::serviceTempRadio()", local_start) +local = mesh[local_start:local_end] +assert 'strcmp(command, "get companion.transport") == 0' in local +assert 'strncmp(command, "set companion.transport", 23) == 0' in local +assert '"OK - companion transport %s saved; reboot required"' in local +assert '"Error: failed to save companion transport"' in local +assert 'terminalOutput().print(" get companion.transport\\r\\n")' in mesh +assert 'terminalOutput().print(" set companion.transport \\r\\n")' in mesh +assert "WebUI unavailable while Bluetooth transport is active" in mesh + + +# Both Indicator Full overlays get this policy. ESP-NOW BLE mode keeps its +# primary WiFi/ESP-NOW radio while omitting only infrastructure WiFi services. +profile_start = build.index("\napply_companion_radio_full_profile() {") +profile_end = build.index("\napply_radio_overrides()", profile_start) +profile = build[profile_start:profile_end] +flag = "-DCOMPANION_EXCLUSIVE_WIFI_BLE=1" +assert profile.count(flag) == 1 +exclusive_case = re.search( + r'case "\$\{env_name,,\}" in\s+' + r"sensecapindicator-espnow_companion_radio_full\|\\\s+" + r"sensecapindicator-lora_companion_radio_full\)\s+" + r'export PLATFORMIO_BUILD_FLAGS="\$\{PLATFORMIO_BUILD_FLAGS\} ' + r"-DCOMPANION_EXCLUSIVE_WIFI_BLE=1 " + r"-DINDICATOR_TRANSPORT_RENDER_PROFILE=1\"\s+;;\s+esac", + profile, +) +assert exclusive_case +assert "espnow" in exclusive_case.group(0).lower() + + +assert "get companion.transport" in readme +assert "set companion.transport wifi" in readme +assert "set companion.transport ble" in readme +assert "reboot is required" in readme + +print("test_indicator_exclusive_transport: PASS") diff --git a/test/test_indicator_messages_profile.py b/test/test_indicator_messages_profile.py new file mode 100644 index 000000000..df3fc7378 --- /dev/null +++ b/test/test_indicator_messages_profile.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 + +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +PROFILE = ROOT / "variants" / "sensecap_indicator-espnow" / "platformio.ini" +UI = ROOT / "examples" / "companion_radio" / "ui-new" / "UITask.cpp" + + +class IndicatorMessagesProfileTest(unittest.TestCase): + def test_indicator_enables_seventh_messages_home_page(self): + profile = PROFILE.read_text() + ui = UI.read_text() + + self.assertIn("-D UI_MESSAGES_HOME_PAGE=1", profile) + self.assertIn("-D UI_COMPACT_MESSAGE_STATUS=1", profile) + self.assertIn("#if UI_MESSAGES_HOME_PAGE == 1\n MESSAGES,", ui) + self.assertIn("_page == HomePage::MESSAGES", ui) + + def test_full_message_chrome_uses_compact_visuals_only(self): + ui = UI.read_text(encoding="utf-8") + touch = (ROOT / "src/helpers/ui/TouchInput.h").read_text( + encoding="utf-8" + ) + self.assertIn("makeCompanionMessageChromeLayout", ui) + self.assertIn("display.setCompactText(layout.compact_text)", ui) + self.assertIn("display.setCompactText(false)", ui) + self.assertIn("_start_y >= (height * 3) / 4", touch) + self.assertIn("_task->renderMessageSummary(display);", ui) + + def test_history_is_not_cleared_when_companion_drains_messages(self): + ui = UI.read_text() + + self.assertNotIn("MsgPreviewScreen*>(msg_preview)->clear()", ui) + self.assertIn( + "companionMessageElapsedMillis(entry->heard_millis)", ui + ) + self.assertIn("esp_timer_get_time()", ui) + self.assertIn("history.hasNewerEntryForThread(age)", ui) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_indicator_render_profile.py b/test/test_indicator_render_profile.py new file mode 100644 index 000000000..bf5b36138 --- /dev/null +++ b/test/test_indicator_render_profile.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 + +from pathlib import Path +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +BUILD = ROOT / "build.sh" +MAIN = ROOT / "examples" / "companion_radio" / "main.cpp" + + +class IndicatorRenderProfileTest(unittest.TestCase): + def test_transport_matrix(self): + source = r''' +#include + +int main() { + using mesh::ui::selectIndicatorRenderProfile; + const auto lora_wifi = selectIndicatorRenderProfile(false, true); + const auto lora_ble = selectIndicatorRenderProfile(false, false); + const auto espnow_wifi = selectIndicatorRenderProfile(true, true); + const auto espnow_ble = selectIndicatorRenderProfile(true, false); + if (lora_wifi.canvas_size != 480 || lora_wifi.coordinate_scale != 3 + || lora_wifi.output_zoom != 1.0f) return 1; + if (lora_ble.canvas_size != 480 || lora_ble.coordinate_scale != 3 + || lora_ble.output_zoom != 1.0f) return 2; + if (espnow_wifi.canvas_size != 480 || espnow_wifi.coordinate_scale != 3 + || espnow_wifi.output_zoom != 1.0f) return 3; + if (espnow_ble.canvas_size != 320 || espnow_ble.coordinate_scale != 2 + || espnow_ble.output_zoom != 1.5f) return 4; + if (mesh::ui::selectIndicatorTextScale(480, false) != 1.2f) return 5; + if (mesh::ui::selectIndicatorTextScale(480, true) != 1.0f) return 6; + if (mesh::ui::selectIndicatorTextScale(320, false) != 1.0f) return 7; + return 0; +} +''' + with tempfile.TemporaryDirectory() as temp_dir: + executable = Path(temp_dir) / "indicator_render_profile" + compiled = subprocess.run( + [ + "c++", + "-std=c++11", + f"-I{ROOT / 'src'}", + "-x", + "c++", + "-", + "-o", + str(executable), + ], + input=source, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(compiled.returncode, 0, compiled.stderr) + ran = subprocess.run( + [str(executable)], capture_output=True, text=True, check=False + ) + self.assertEqual(ran.returncode, 0, ran.stderr) + + def test_full_profiles_allocate_native_before_runtime_selection(self): + build = BUILD.read_text(encoding="utf-8") + self.assertIn( + "sensecapindicator-espnow_companion_radio_full|\\\n" + " sensecapindicator-lora_companion_radio_full)", + build, + ) + self.assertIn("-UUI_ZOOM -DUI_ZOOM=1.0f", build) + self.assertIn("-UUI_COORD_SCALE -DUI_COORD_SCALE=3", build) + self.assertIn("-DINDICATOR_TRANSPORT_RENDER_PROFILE=1", build) + + def test_runtime_selection_precedes_bluetooth_start(self): + main = MAIN.read_text(encoding="utf-8") + setup = main[main.index("void setup()") : main.index("\nvoid loop()")] + select = setup.index("selectIndicatorRenderProfile(") + resize = setup.index("disp->setRenderScale(", select) + bluetooth = setup.index("startCompanionBluetooth();", resize) + self.assertLess(select, resize) + self.assertLess(resize, bluetooth) + self.assertIn("true,", setup[select:resize]) + self.assertIn("companionTransportWiFiActiveAtBoot()", setup[select:resize]) + + def test_canvas_stays_internal_and_can_rollback(self): + display = (ROOT / "src/helpers/ui/LGFXDisplay.cpp").read_text( + encoding="utf-8" + ) + self.assertIn("buffer.setPsram(false);", display) + self.assertIn("if (!configurePalette()) {\n buffer.deleteSprite();", display) + self.assertIn("buffer.deleteSprite();", display) + self.assertIn("_coordinateScale = previous_scale;", display) + self.assertIn("_outputZoom = previous_zoom;", display) + self.assertIn("createRenderBuffer();\n return false;", display) + + def test_failed_native_startup_retries_safe_canvas(self): + display = (ROOT / "src/helpers/ui/LGFXDisplay.cpp").read_text( + encoding="utf-8" + ) + begin = display[display.index("bool LGFXDisplay::begin()") :] + begin = begin[: begin.index("\nbool LGFXDisplay::createRenderBuffer()")] + first_attempt = begin.index("if (createRenderBuffer()) return true;") + guard = begin.index("INDICATOR_TRANSPORT_RENDER_PROFILE", first_attempt) + fallback_scale = begin.index("_coordinateScale = 2;", guard) + fallback_zoom = begin.index("_outputZoom = 1.5f;", fallback_scale) + retry = begin.index("if (createRenderBuffer()) return true;", fallback_zoom) + self.assertLess(first_attempt, guard) + self.assertLess(guard, fallback_scale) + self.assertLess(fallback_scale, fallback_zoom) + self.assertLess(fallback_zoom, retry) + + def test_native_font_increase_preserves_compact_chrome(self): + display = (ROOT / "src/helpers/ui/LGFXDisplay.cpp").read_text( + encoding="utf-8" + ) + self.assertIn("selectIndicatorTextScale(", display) + self.assertIn("static_cast(renderWidth()), _compactText", display) + self.assertIn("* profile_scale;", display) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_mqtt_connection_policy/test_mqtt_connection_policy.cpp b/test/test_mqtt_connection_policy/test_mqtt_connection_policy.cpp index 1728d048f..5401ab611 100644 --- a/test/test_mqtt_connection_policy/test_mqtt_connection_policy.cpp +++ b/test/test_mqtt_connection_policy/test_mqtt_connection_policy.cpp @@ -47,23 +47,6 @@ TEST(MQTTConnectionPolicy, NtpRetryDeadlineHandlesZeroSentinelAtRollover) { EXPECT_TRUE(Policy::ntpRefreshDue(1U, 0U, retry_at)); } -TEST(MQTTConnectionPolicy, NtpReconnectRefreshDiscardsOldDeadlineSafely) { - EXPECT_EQ(1U, Policy::ntpReconnectRefreshAt(0U)); - EXPECT_FALSE(Policy::ntpRefreshDue( - 0U, Policy::kNtpRefreshIntervalMs - 1U, - Policy::ntpReconnectRefreshAt(0U))); - EXPECT_TRUE(Policy::ntpRefreshDue( - 1U, Policy::kNtpRefreshIntervalMs - 1U, - Policy::ntpReconnectRefreshAt(0U))); - - const uint32_t reconnected_at = 123456U; - EXPECT_EQ(reconnected_at, - Policy::ntpReconnectRefreshAt(reconnected_at)); - EXPECT_TRUE(Policy::ntpRefreshDue( - reconnected_at, reconnected_at - 1000U, - Policy::ntpReconnectRefreshAt(reconnected_at))); -} - TEST(MQTTConnectionPolicy, NtpReconnectLatchPreservesEdgesUntilConnected) { Policy::NtpReconnectLatch latch; diff --git a/test/test_tls_download_clock_gates.py b/test/test_tls_download_clock_gates.py index 6d32d894a..d73a9f850 100644 --- a/test/test_tls_download_clock_gates.py +++ b/test/test_tls_download_clock_gates.py @@ -176,6 +176,7 @@ class TlsDownloadClockGateTest(unittest.TestCase): ) def test_every_mqtt_sntp_mutation_is_coordinator_leased(self): + header = source(MQTT_BRIDGE) implementation = source(MQTT_BRIDGE_IMPL) refresh = section( implementation, @@ -189,7 +190,7 @@ class TlsDownloadClockGateTest(unittest.TestCase): ) self.assertIn("SntpOperationCoordinator.h", implementation) - self.assertEqual(len(re.findall(r"(?m)^\s*configTime\(", implementation)), 3) + self.assertEqual(len(re.findall(r"(?m)^\s*configTime\(", implementation)), 2) self.assertEqual( len(re.findall(r"(?m)^\s*sntp_set_sync_status\(", implementation)), 2, @@ -198,12 +199,39 @@ class TlsDownloadClockGateTest(unittest.TestCase): len(re.findall(r"sntp_get_sync_status\(", implementation)), 2, ) + self.assertNotIn("esp_sntp_set_sync_interval(", implementation) + self.assertIn("OperationLease _ntp_refresh_operation", header) + cleanup = section( + implementation, + "static void stopMqttSntpOperation()", + "static void formatRadioInfo", + ) + self.assertIn("esp_sntp_stop();", cleanup) + self.assertIn( + "_ntp_refresh_operation(mesh::sntp_coord::processWideCoordinator(),", + implementation, + ) + self.assertIn("stopMqttSntpOperation)", implementation) - refresh_lease = refresh.index("OperationLease sntp_operation") - refresh_acquire = refresh.index("if (!sntp_operation.tryAcquire())") + refresh_acquire = refresh.index( + "if (!_ntp_refresh_operation.tryAcquire())" + ) refresh_configure = refresh.index("configTime(") - self.assertLess(refresh_lease, refresh_acquire) self.assertLess(refresh_acquire, refresh_configure) + poll = section( + implementation, + "void MQTTBridge::pollNtpRefresh", + "bool MQTTBridge::syncTimeWithNTP", + ) + owns = poll.index("_ntp_refresh_operation.owns()") + completed = poll.index("SNTP_SYNC_STATUS_COMPLETED", owns) + self.assertLess(owns, completed) + cancel = section( + implementation, + "void MQTTBridge::cancelNtpRefresh()", + "bool MQTTBridge::servicePendingClockCorrection()", + ) + self.assertIn("_ntp_refresh_operation.release()", cancel) fallback_start = sync.index( "// Fallback: use ESP32 built-in SNTP (configTime)" @@ -221,18 +249,9 @@ class TlsDownloadClockGateTest(unittest.TestCase): self.assertLess(fallback_acquire, reset) self.assertLess(reset, configure) self.assertLess(configure, completed) - - final_rearm = section( - sync, - "if (ntp_server_used && !sntp_fallback_configured)", - "if (_rtc)", + self.assertEqual( + len(re.findall(r"(?m)^\s*configTime\(", sync)), 1 ) - final_lease = final_rearm.index("OperationLease sntp_operation") - final_acquire = final_rearm.index("if (sntp_operation.tryAcquire())") - final_configure = final_rearm.index("configTime(0, 0, ntp_server_used)") - self.assertLess(final_lease, final_acquire) - self.assertLess(final_acquire, final_configure) - self.assertIn("else if (sntp_fallback_configured)", final_rearm) def test_mqtt_async_sntp_contention_uses_a_wrap_safe_bounded_retry(self): header = source(MQTT_BRIDGE) @@ -254,15 +273,10 @@ class TlsDownloadClockGateTest(unittest.TestCase): self.assertIn( "elapsedMs(now, last_sync) >= kNtpRefreshIntervalMs", policy ) - self.assertIn("ntpReconnectRefreshAt", policy) - self.assertEqual( - implementation.count( - "MQTTConnectionPolicy::ntpReconnectRefreshAt(" - ), - 2, - ) + self.assertNotIn("ntpReconnectRefreshAt", policy) + self.assertNotIn("ntpReconnectRefreshAt", implementation) self.assertGreaterEqual(implementation.count("mqttNtpRefreshDue("), 4) - busy = refresh.index("if (!sntp_operation.tryAcquire())") + busy = refresh.index("if (!_ntp_refresh_operation.tryAcquire())") retry = refresh.index("mqttNtpRetryAt(now)", busy) leave = refresh.index("return;", retry) configure = refresh.index( @@ -278,14 +292,9 @@ class TlsDownloadClockGateTest(unittest.TestCase): "bool MQTTBridge::syncTimeWithNTP", "bool MQTTBridge::requestForcedNtpSync", ) - final_rearm = section( - sync, - "if (ntp_server_used && !sntp_fallback_configured)", - "if (_rtc)", - ) self.assertIn( - "_ntp_refresh_retry_at = mqttNtpRetryAt(millis())", - final_rearm, + "fresh_ntp ? 0 : mqttNtpRetryAt(_last_ntp_sync)", + sync, ) def test_mqtt_periodic_refresh_updates_rtc_only_after_sntp_completion(self): @@ -305,6 +314,7 @@ class TlsDownloadClockGateTest(unittest.TestCase): self.assertIn("unsigned long _ntp_refresh_started_at", header) self.assertIn("bool _ntp_refresh_pending", header) self.assertIn("std::atomic _ntp_synced", header) + self.assertIn("OperationLease _ntp_refresh_operation", header) self.assertIn( "MQTTConnectionPolicy::NtpReconnectLatch " "_ntp_reconnect_latch", @@ -338,6 +348,7 @@ class TlsDownloadClockGateTest(unittest.TestCase): self.assertLess(valid, rtc) self.assertLess(rtc, success) self.assertEqual(poll.count("_last_ntp_sync ="), 1) + self.assertIn("_ntp_refresh_operation.owns()", poll) timeout = poll.index("elapsed >= MQTT_NTP_REFRESH_TIMEOUT_MS") retry = poll.index("mqttNtpRetryAt(now)", timeout) @@ -364,6 +375,7 @@ class TlsDownloadClockGateTest(unittest.TestCase): "_ntp_reconnect_latch.consumeIfConnected(wifi_connected)" ) self.assertLess(connected, consume) + self.assertNotIn("ntpReconnectRefreshAt", task_loop) begin = section( implementation, "void MQTTBridge::begin()", diff --git a/test/test_touch_input/test_touch_input.cpp b/test/test_touch_input/test_touch_input.cpp index bd9681562..0fc285a23 100644 --- a/test/test_touch_input/test_touch_input.cpp +++ b/test/test_touch_input/test_touch_input.cpp @@ -4,6 +4,7 @@ using mesh::ui::TouchAction; using mesh::ui::TouchInput; +using mesh::ui::TouchSplitSelector; namespace { @@ -119,6 +120,58 @@ TEST(TouchInput, BottomSelectorHasLargeArrowTapTargets) { TouchAction::None); } +TEST(TouchInput, SplitSelectorMapsTapHalvesToExplicitChoices) { + TouchInput input(true, true, 70); + const TouchSplitSelector selector{8, 68, 84, 68, 38, 72}; + + input.update(true, 30, 70, 160, 160, false, &selector); + input.update(true, 31, 70, 160, 160, false, &selector); + EXPECT_EQ(input.update(false, -1, -1, 160, 160, false, &selector), + TouchAction::None); + EXPECT_EQ(input.update(false, -1, -1, 160, 160, false, &selector), + TouchAction::SelectLeft); + + input.update(true, 130, 70, 160, 160, false, &selector); + input.update(true, 129, 70, 160, 160, false, &selector); + EXPECT_EQ(input.update(false, -1, -1, 160, 160, false, &selector), + TouchAction::None); + EXPECT_EQ(input.update(false, -1, -1, 160, 160, false, &selector), + TouchAction::SelectRight); + + input.update(true, 80, 70, 160, 160, false, &selector); + input.update(true, 80, 70, 160, 160, false, &selector); + EXPECT_EQ(input.update(false, -1, -1, 160, 160, false, &selector), + TouchAction::None); + EXPECT_EQ(input.update(false, -1, -1, 160, 160, false, &selector), + TouchAction::None); + + input.update(true, 30, 20, 160, 160, false, &selector); + input.update(true, 30, 20, 160, 160, false, &selector); + EXPECT_EQ(input.update(false, -1, -1, 160, 160, false, &selector), + TouchAction::None); + EXPECT_EQ(input.update(false, -1, -1, 160, 160, false, &selector), + TouchAction::None); +} + +TEST(TouchInput, SplitSelectorKeepsSwipesAsNavigation) { + TouchInput input(true, true, 70); + const TouchSplitSelector selector{8, 68, 84, 68, 38, 72}; + + input.update(true, 130, 70, 160, 160, false, &selector); + input.update(true, 30, 72, 160, 160, false, &selector); + EXPECT_EQ(input.update(false, -1, -1, 160, 160, false, &selector), + TouchAction::None); + EXPECT_EQ(input.update(false, -1, -1, 160, 160, false, &selector), + TouchAction::Previous); + + input.update(true, 30, 70, 160, 160, false, &selector); + input.update(true, 130, 68, 160, 160, false, &selector); + EXPECT_EQ(input.update(false, -1, -1, 160, 160, false, &selector), + TouchAction::None); + EXPECT_EQ(input.update(false, -1, -1, 160, 160, false, &selector), + TouchAction::Next); +} + TEST(TouchInput, CanReverseSwipesWithoutReversingTapZones) { TouchInput input(true); diff --git a/tools/lora_ota/test_lora_ota.py b/tools/lora_ota/test_lora_ota.py index 29d6ab441..2236c72bd 100644 --- a/tools/lora_ota/test_lora_ota.py +++ b/tools/lora_ota/test_lora_ota.py @@ -4540,6 +4540,34 @@ class TempRadioPreflightTests(unittest.TestCase): class ReliabilityTests(unittest.TestCase): + def test_controller_rejects_meshcli_zero_exit_no_response(self) -> None: + """Do not trust meshcore-cli's exit status without protocol output.""" + diagnostic = ( + "ERROR:meshcore:No response from meshcore node, disconnecting\n" + "ERROR:meshcore:Are you sure your node is a serial companion ?\n" + "ERROR:meshcore:To connect to a repeater, use -r option.\n" + ) + controller = object.__new__(ota.Controller) + controller._execute = mock.Mock( + return_value=subprocess.CompletedProcess( + [], 0, stdout="", stderr=diagnostic + ) + ) + + with self.assertRaisesRegex( + ota.OtaError, "returned no JSON" + ) as unmarked: + controller._run(["infos", "ver", "clock"], "XIAO probe") + self.assertIn("No response from meshcore node", str(unmarked.exception)) + + with self.assertRaisesRegex( + ota.TransmissionError, "did not reach the command marker" + ) as marked: + controller._run_marked( + ["infos", "ver", "clock"], "XIAO probe", "NEVER_REACHED" + ) + self.assertIn("No response from meshcore node", str(marked.exception)) + def test_target_version_falls_back_to_ver(self) -> None: class Controller: def __init__(self) -> None: diff --git a/variants/sensecap_indicator-espnow/README.md b/variants/sensecap_indicator-espnow/README.md index 93033e9ec..a2ab648c2 100644 --- a/variants/sensecap_indicator-espnow/README.md +++ b/variants/sensecap_indicator-espnow/README.md @@ -1,11 +1,12 @@ # SenseCAP Indicator controls -The Indicator preserves the existing 160x160 UI coordinate system in a -320x320, 4-bit internal canvas and scales it 1.5x onto the 480x480 panel. The -canvas avoids PSRAM contention with RGB scanout while reserving enough internal -RAM for Bluetooth and WiFi to operate together. The screen blanks after five -minutes on USB power; the hardware button or a touch wakes it without selecting -anything. +The Indicator preserves the existing 160x160 UI coordinate system. Ordinary +images use a 320x320, 4-bit internal canvas scaled 1.5x onto the 480x480 panel. +Full Companion images first request a native 480x480 canvas, then normally +shrink to 320x320 only for the tight ESP-NOW + BLE combination before Bluetooth +starts. The canvas stays out of PSRAM to avoid contention with RGB scanout. The +screen blanks after five minutes on USB power; the hardware button or a touch +wakes it without selecting anything. ## Touch navigation @@ -20,6 +21,15 @@ anything. The message preview is a non-destructive local inbox, newest first: +- A seventh home page, immediately after the status page, shows the newest + retained message for each heard channel. Each two-line row contains the + channel name, rollover-safe relative age, and a one-line ellipsized message + preview. Direct messages are grouped by contact and labelled `Direct`. +- The summary retains up to 32 received messages in RAM even after a connected + Companion drains its unread queue. The oldest history is overwritten and a + reboot starts with an empty list; messages are not written to flash. +- Tap the center of the message-summary page to open the existing full message + view. Horizontal swipes continue to move among the seven home pages. - The first home page shows the retained local `INBOX` count, which remains useful when a USB host has already drained the protocol's unread count. Tap the center to open the inbox and channel selector even when the count is 0. @@ -27,15 +37,18 @@ The message preview is a non-destructive local inbox, newest first: - Each preview retains the complete 160-byte maximum chat payload. Long UTF-8 messages wrap through the available middle of the screen instead of being cut at the small-display 78-byte limit. -- When a USB host drains the radio queue, the displayed preview is retained - for five minutes rather than disappearing immediately. +- When a USB host drains the radio queue, the detailed message screen remains + visible for five minutes before returning home; its recent-message history + remains available from the summary page and inbox. - Swipe left for an older preview and right for a newer preview. Navigation stops at the oldest and newest entries instead of wrapping. - The bottom bar shows the active inbox filter. Its large `<` and `>` end buttons select the previous or next filter; swipe up or down does the same. Filters include `All channels`, `Direct`, and configured channels such as `Ch 1 #testing`. A newly received message automatically selects its source - channel. + channel. On the 480x480 Indicator, the full inbox's top status strip and + bottom selector use a compact 24-physical-pixel font; their visual heights + are 27 and 42 pixels while the forgiving arrow touch regions remain 120x120. - Tap the center to return home without deleting the previews. - On the first home page, tap the wide center target to reopen the previews, including when `INBOX` is zero. @@ -79,6 +92,41 @@ accepts `get wifi.status`, `get wifi.ssid`, `get wifi.cli`, and `start webconfig ap`; enter it with `+++MESHCORE-TERM-START` and return to the framed Companion protocol with `+++MESHCORE-TERM-STOP`. +### Full Companion transport selection + +Both Full Indicator layouts run exactly one secondary wireless Companion +transport per boot. Select infrastructure WiFi or BLE from the Indicator UI, +or use the USB/TCP text terminal: + +```text +get companion.transport +set companion.transport wifi +set companion.transport ble +``` + +The getter replies `wifi` or `ble`. A setter saves the next-boot choice and +replies that a reboot is required; it does not restart either radio in the +current session. WiFi mode never initializes BLE and releases the ESP32 +Bluetooth controller and host memory. BLE mode does not start infrastructure +WiFi, WebConfig, MQTT, OTA networking, or TCP Companion services. USB remains +available in both modes for recovery. + +LoRa remains the primary radio in both LoRa modes. On the ESP-NOW layout, +selecting BLE leaves the primary ESP-NOW WiFi radio and its fixed channel +running; it disables only infrastructure WiFi. Rendering follows the memory +cost of each combination: + +- LoRa + WiFi: targets a native 480x480 canvas. +- LoRa + BLE: targets a native 480x480 canvas. +- ESP-NOW + infrastructure WiFi: targets a native 480x480 canvas. +- ESP-NOW + BLE: 320x320 canvas scaled 1.5x to the panel. + +If the early native allocation or a later profile retry cannot obtain a +contiguous DMA-capable block, any mode requesting 480x480 keeps or restores the +320x320 emergency canvas instead of disabling the display. The startup log +reports the canvas actually retained; an emergency fallback does not change the +saved Companion transport or stop the primary LoRa/ESP-NOW radio. + If a Companion client does not supply device time over USB or WiFi, this build starts evaluating LoRa time after two minutes. It requires three independent, agreeing sources from signed adverts or authenticated Public-channel messages; diff --git a/variants/sensecap_indicator-espnow/platformio.ini b/variants/sensecap_indicator-espnow/platformio.ini index ace0bfad0..bc8ce100f 100644 --- a/variants/sensecap_indicator-espnow/platformio.ini +++ b/variants/sensecap_indicator-espnow/platformio.ini @@ -22,11 +22,20 @@ build_flags = -D DISPLAY_ROTATION=1 ; Render the established 160x160 UI into a 320x320 internal canvas, then ; scale it 1.5x onto the 480x480 panel. The 4-bit canvas stays out of PSRAM - ; to protect RGB scanout, but now consumes 51,200 instead of 115,200 bytes - ; of scarce internal RAM so BLE and WiFi can run together. + ; to protect RGB scanout, but consumes 51,200 instead of 115,200 bytes of + ; scarce internal RAM. Full Indicator overlays start native and retain this + ; compact profile only for ESP-NOW + BLE. -D UI_ZOOM=1.5f -D UI_COORD_SCALE=2 -D UI_BUFFER_COLOR_DEPTH=4 + ; Keep instructions/network status above a dedicated high-contrast BLE PIN + ; block instead of painting the PIN over the home-page instruction. + -D UI_DEDICATED_PAIRING_BLOCK=1 + ; Add a seventh home page with the latest retained message per channel. + -D UI_MESSAGES_HOME_PAGE=1 + ; Use fractional smooth-font scaling for the full inbox's Message/age header + ; and channel selector, reclaiming space without shrinking touch targets. + -D UI_COMPACT_MESSAGE_STATUS=1 -D AUTO_OFF_MILLIS=300000UL -D USB_MESSAGE_PREVIEW_MILLIS=300000UL ; Retain every byte of a maximum-length 160-byte chat message. The generic