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.
This commit is contained in:
mikecarper committed 2026-08-31 14:29:42 -07:00
1 parent 2a82472853
commit 5af7b73ff9
75 files changed
+7886 -576

No files matched your search

+21
View File
@@ -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
+41 -20
View File
@@ -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.
+1
View File
@@ -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 |
+35 -16
View File
@@ -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 <alias> [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 <unix_epoch>` recovery actions, opt-in `network restart` and
`reboot` actions, `action status <operation_id>`, and
`run <alias> [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.
---
+22 -5
View File
@@ -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-<advert name>`; an empty CLI
+70 -24
View File
@@ -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
+202 -17
View File
@@ -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.
+174 -20
View File
@@ -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 <unix_epoch>
cmd host network restart
cmd host reboot
cmd host action status <operation_id>
cmd host run <alias> [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 <unix_epoch>` 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 <epoch>`. 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.
+18
View File
@@ -2,8 +2,26 @@
#include <stdint.h>
#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,
+68
View File
@@ -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 <wifi|ble>");
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 <on|off> [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 <wifi|ble>\r\n");
#endif
terminalOutput().print(" get wifi.powersave\r\n");
terminalOutput().print(" set wifi.powersave <none|min|max>\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;
+13
View File
@@ -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
+285 -5
View File
@@ -67,10 +67,16 @@ MultiSerialInterface interface_manager;
#include <helpers/CLICommandUtils.h>
#include <helpers/WiFiReconnectPolicy.h>
#include <helpers/WiFiPowerSave.h>
#include <helpers/CompanionWiFiNtpPolicy.h>
#include <helpers/ui/DisplayTextLayout.h>
#include <helpers/ui/IndicatorRenderProfile.h>
#include <helpers/esp32/SntpOperationCoordinator.h>
#include <helpers/esp32/TlsClockValidity.h>
#include <helpers/esp32/WiFiRadioPolicy.h>
#include <helpers/esp32/WiFiStationPolicy.h>
#include <Preferences.h>
#include <atomic>
#include <esp_sntp.h>
#include <esp_wifi.h>
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<uint32_t> companion_wifi_ntp_operation_generation{0};
static std::atomic<uint32_t> companion_wifi_ntp_proof_generation{0};
static std::atomic<uint32_t> companion_wifi_ntp_epoch{0};
static std::atomic<uint32_t> 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<uint64_t>(value->tv_sec) > UINT32_MAX) {
return;
}
companion_wifi_ntp_epoch.store(
static_cast<uint32_t>(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<uint64_t>(raw_system_time) <= UINT32_MAX) {
const uint32_t epoch = static_cast<uint32_t>(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<int64_t>(epoch)
- static_cast<int64_t>(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
}
+455 -110
View File
@@ -1,5 +1,9 @@
#include "UITask.h"
#include <helpers/TxtDataHelpers.h>
#include <helpers/ui/BluetoothPairingUiPolicy.h>
#include <helpers/ui/CompanionHomeLayout.h>
#include <helpers/ui/CompanionMessageHistory.h>
#include <helpers/ui/CompanionTransportSelectorLayout.h>
#include "../MyMesh.h"
#include "../CompanionWiFi.h"
#include "target.h"
@@ -7,6 +11,9 @@
#ifdef WIFI_SSID
#include <WiFi.h>
#endif
#if defined(ESP32)
#include <esp_timer.h>
#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<uint64_t>(esp_timer_get_time()) / 1000ULL;
#else
return static_cast<uint64_t>(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<uint32_t>(millis()
- static_cast<uint32_t>(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<const MsgPreviewScreen*>(msg_preview)->messageCount();
}
void UITask::renderMessageSummary(DisplayDriver& display) const {
static_cast<const MsgPreviewScreen*>(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<int32_t>(millis() - _msg_preview_until) < 0;
if (!holding_usb_preview) {
static_cast<MsgPreviewScreen*>(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<int32_t>(millis() - _pairing_screen_until) < 0;
return mesh::ui::isBluetoothPairingPromptActive(
isBluetoothEnabled(), hasBluetoothConnection(),
static_cast<uint32_t>(_pairing_screen_until),
static_cast<uint32_t>(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<int32_t>(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<HomeScreen*>(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<int32_t>(millis() - _msg_preview_until) >= 0) {
_msg_preview_until = 0;
static_cast<MsgPreviewScreen*>(msg_preview)->clear();
if (curr == msg_preview) gotoHomeScreen();
}
+2
View File
@@ -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();
+13 -6
View File
@@ -1,6 +1,7 @@
#include "UITask.h"
#include <Arduino.h>
#include <helpers/TxtDataHelpers.h>
#include <helpers/ui/BluetoothPairingUiPolicy.h>
#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<int32_t>(millis() - _pairing_screen_until) < 0;
return mesh::ui::isBluetoothPairingPromptActive(
isBluetoothEnabled(), hasBluetoothConnection(),
static_cast<uint32_t>(_pairing_screen_until),
static_cast<uint32_t>(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<int32_t>(millis() - _pairing_screen_until) >= 0;
if (hasBluetoothConnection() || timed_out) {
if (!isPairingScreenActive()) {
finishPairingScreen(timed_out);
}
}
+15 -7
View File
@@ -1,5 +1,6 @@
#include "UITask.h"
#include <helpers/TxtDataHelpers.h>
#include <helpers/ui/BluetoothPairingUiPolicy.h>
#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<int32_t>(millis() - _pairing_screen_until) < 0;
return mesh::ui::isBluetoothPairingPromptActive(
isBluetoothEnabled(), hasBluetoothConnection(),
static_cast<uint32_t>(_pairing_screen_until),
static_cast<uint32_t>(millis()));
}
void UITask::showPairingPin() {
if (!isBluetoothEnabled()) return;
const unsigned long now = millis();
_pairing_screen_until = now + BLE_PAIRING_DISPLAY_MILLIS;
static_cast<HomeScreen*>(home)->showFirstPage();
@@ -633,7 +641,7 @@ void UITask::loop() {
if (_pairing_screen_until != 0) {
const bool timed_out = static_cast<int32_t>(millis() - _pairing_screen_until) >= 0;
if (hasBluetoothConnection() || timed_out) {
if (!isPairingScreenActive()) {
finishPairingScreen(timed_out);
}
}
+181 -17
View File
@@ -12,14 +12,20 @@ host uptime
host load
host memory
host disk-free
host clock status
host clock sync
host clock set <unix_epoch>
host network restart
host reboot
host action status <operation_id>
host run <alias> [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 <epoch>`. 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.
+706 -63
View File
@@ -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 <unix_epoch>")
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 <operation_id>")
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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+320
View File
@@ -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 <canonical_epoch>`` 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())
@@ -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())
File diff suppressed because it is too large. Load diff
@@ -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()
+6 -2
View File
@@ -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();
+3
View File
@@ -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();
+1 -1
View File
@@ -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
+5 -2
View File
@@ -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);
}
}
}
+57
View File
@@ -0,0 +1,57 @@
#pragma once
#include <stdint.h>
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<int32_t>(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
+7
View File
@@ -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) {
+15 -9
View File
@@ -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;
}
}
};
+2 -8
View File
@@ -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) {}
+4
View File
@@ -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()) {
+1
View File
@@ -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.
+85 -57
View File
@@ -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<int64_t>(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,
+17 -1
View File
@@ -22,6 +22,7 @@ class MeshSNMPAgent; // Forward declaration
#endif
#ifdef ESP_PLATFORM
#include "helpers/esp32/SntpOperationCoordinator.h"
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <freertos/queue.h>
@@ -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<bool> _ntp_synced;
std::atomic<bool> _fresh_ntp_this_boot;
std::atomic<uint32_t> _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). */
+5 -1
View File
@@ -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;
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include <stdint.h>
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<int32_t>(now - display_until) < 0;
}
} // namespace ui
} // namespace mesh
+48
View File
@@ -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
+133
View File
@@ -0,0 +1,133 @@
#pragma once
#include <helpers/ui/DisplayDriver.h>
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
+188
View File
@@ -0,0 +1,188 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
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 <size_t Capacity, size_t MessageCapacity>
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
@@ -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
+13
View File
@@ -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;
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include <stdint.h>
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
+160 -64
View File
@@ -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<uint16_t>(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<float>(_logicalTextSize * _coordinateScale)
/ static_cast<float>(_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
}
+11 -1
View File
@@ -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;
+10 -9
View File
@@ -1,4 +1,5 @@
#include "NV3001BDisplay.h"
#include "ColorTheme.h"
#include <Arduino.h>
#include <string.h>
@@ -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);
+12 -10
View File
@@ -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;
}
+10 -9
View File
@@ -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) {
+35 -2
View File
@@ -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
+5
View File
@@ -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 |
+88
View File
@@ -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 <helpers/ui/BluetoothPairingUiPolicy.h>
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 <helpers/ui/BluetoothPairingUiPolicy.h>", 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()
+157
View File
@@ -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()
@@ -0,0 +1,126 @@
#include <gtest/gtest.h>
#include <helpers/ui/CompanionMessageHistory.h>
#include <cstdint>
#include <cstring>
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();
}
+9
View File
@@ -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+"
+217
View File
@@ -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 <helpers/ui/CompanionTransportSelectorLayout.h>
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 <helpers/ui/TouchInput.h>
#include <helpers/ui/CompanionTransportSelectorLayout.h>
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<HomeScreen*>(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()
+159
View File
@@ -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<int32_t>(now - _deadline) >= 0", policy)
def test_common_wifi_path_uses_fresh_owned_sntp_completion(self):
main = source(MAIN)
self.assertIn("#include <helpers/CompanionWiFiNtpPolicy.h>", 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<uint32_t> _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<bool> _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()
@@ -0,0 +1,58 @@
#include <gtest/gtest.h>
#include <helpers/CompanionWiFiNtpPolicy.h>
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();
}
@@ -1,6 +1,7 @@
#include <gtest/gtest.h>
#include <helpers/ui/DisplayDriver.h>
#include <helpers/ui/CompanionHomeLayout.h>
#include <helpers/ui/DisplayTextLayout.h>
#include <string>
@@ -40,19 +41,30 @@ public:
std::string text;
};
struct FilledRect {
int x;
int y;
int width;
int height;
};
std::string printed;
std::vector<DrawnText> rows;
std::vector<FilledRect> 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();
+68
View File
@@ -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("<I", footer, 20)[0]
native_scale = data[atlas_offset + 9]
coordinate_scale = 2
def dimensions(text, requested_size, coordinate_scale=2,
profile_scale=1.0):
render_scale = (
requested_size * coordinate_scale / native_scale
* profile_scale
)
width = sum(advances[ord(char)] for char in text)
height = max(heights[ord(char)] for char in text)
return (
int((width * render_scale + coordinate_scale - 1)
// coordinate_scale),
int((height * render_scale + coordinate_scale - 1)
// coordinate_scale),
)
self.assertEqual((108, 24), dimensions("123456", 3))
self.assertEqual((108, 16), dimensions("CONNECTED", 2))
for width, height in (
dimensions("123456", 3), dimensions("CONNECTED", 2)
):
self.assertLessEqual(width, 144)
self.assertLessEqual(height, 24)
# This continuous-scale model rounds outward and is deliberately a
# conservative fit bound; LovyanGFX applies fixed-point rounding while
# laying out individual glyphs.
native_pin = dimensions("123456", 3, 3, 1.2)
native_connected = dimensions("CONNECTED", 2, 3, 1.2)
for width, height in (native_pin, native_connected):
self.assertLessEqual(width, 144)
self.assertLessEqual(height, 30)
if __name__ == "__main__":
unittest.main()
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env python3
from pathlib import Path
import re
root = Path(__file__).resolve().parents[1]
header = (root / "examples/companion_radio/CompanionWiFi.h").read_text()
main = (root / "examples/companion_radio/main.cpp").read_text()
mesh = (root / "examples/companion_radio/MyMesh.cpp").read_text()
build = (root / "build.sh").read_text()
readme = (root / "variants/sensecap_indicator-espnow/README.md").read_text()
webconfig = (root / "src/helpers/esp32/WebConfigServer.cpp").read_text()
mqtt = (root / "src/helpers/bridges/MQTTBridge.cpp").read_text()
# The opt-in cannot silently produce a one-sided or non-ESP32 transport build.
assert re.search(
r"#if defined\(COMPANION_EXCLUSIVE_WIFI_BLE\).*?"
r"defined\(ESP32\).*?defined\(WIFI_SSID\).*?defined\(BLE_PIN_CODE\).*?"
r'#error "COMPANION_EXCLUSIVE_WIFI_BLE requires ESP32, WIFI_SSID, and BLE_PIN_CODE"',
header,
re.DOTALL,
)
assert "enum class CompanionTransportMode : uint8_t" in header
assert "CompanionTransportMode getCompanionTransportMode();" in header
assert "bool selectCompanionTransportMode(CompanionTransportMode mode);" in header
# The existing persisted byte is the sole source of truth. A failed write must
# restore RAM, and selection must not mutate the active-boot WiFi latch.
selector_start = main.index("CompanionTransportMode getCompanionTransportMode()")
selector_end = main.index(
"static constexpr uint32_t COMPANION_WIFI_NTP_TIMEOUT_MS", selector_start
)
selector = main[selector_start:selector_end]
assert "the_mesh.getNodePrefs()->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 <wifi|ble>\\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")
+45
View File
@@ -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()
+122
View File
@@ -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 <helpers/ui/IndicatorRenderProfile.h>
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<uint16_t>(renderWidth()), _compactText", display)
self.assertIn("* profile_scale;", display)
if __name__ == "__main__":
unittest.main()
@@ -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;
+42 -30
View File
@@ -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<bool> _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()",
@@ -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);
+28
View File
@@ -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:
+57 -9
View File
@@ -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;
@@ -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