From 493e1888940f532bf1962516fad9b76594c1b7c3 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 28 Aug 2026 18:39:26 -0700 Subject: [PATCH] feat: integrate R8 observer and companion updates --- CHANGELOG.md | 7 + docs/cli_command_availability.md | 17 + docs/cli_commands.md | 55 ++ docs/companion_protocol.md | 27 +- examples/companion_radio/MyMesh.cpp | 33 +- examples/simple_repeater/MyMesh.cpp | 18 + examples/simple_repeater/MyMesh.h | 15 + examples/simple_repeater/UITask.cpp | 347 ++++++++++- examples/simple_repeater/UITask.h | 57 ++ examples/simple_repeater/main.cpp | 6 + examples/simple_room_server/MyMesh.cpp | 23 +- examples/simple_room_server/MyMesh.h | 14 + examples/simple_room_server/UITask.cpp | 362 ++++++++++- examples/simple_room_server/UITask.h | 72 ++- examples/simple_room_server/main.cpp | 10 + src/helpers/CommonCLI.cpp | 122 +++- src/helpers/CommonCLI.h | 7 +- src/helpers/CommonCLI_Observer.cpp | 42 ++ src/helpers/CompanionHardwareCommandCompat.h | 61 ++ src/helpers/CompanionMqttPrefsNvs.h | 31 + src/helpers/CompanionMqttSetupPortal.cpp | 54 +- src/helpers/ConfigSerializer.cpp | 181 +++++- src/helpers/ConfigSerializer.h | 89 ++- src/helpers/MQTTDefaults.h | 5 + src/helpers/MQTTObserverValidation.h | 31 + src/helpers/MQTTPrefsCodec.h | 43 +- src/helpers/MQTTPrefsJsonImport.h | 75 +++ src/helpers/MQTTPrefsSerializer.h | 447 ++++++++++++++ src/helpers/MQTTPrefsStorage.h | 34 +- src/helpers/RadioActivityWindow.h | 217 +++++++ src/helpers/ui/CHSC6XTouch.h | 126 ++++ src/helpers/ui/DisplayDriver.h | 4 + src/helpers/ui/DisplayFrameSignature.h | 23 + src/helpers/ui/DisplayViewport.h | 41 ++ src/helpers/ui/ObserverDashboard.h | 527 ++++++++++++++++ src/helpers/ui/SSD1306Display.cpp | 25 +- src/helpers/ui/SSD1306Display.h | 9 + src/helpers/ui/ST7789LCDDisplay.cpp | 175 +++++- src/helpers/ui/ST7789LCDDisplay.h | 12 + src/helpers/ui/TouchTapDetector.h | 65 ++ test/README.md | 9 +- .../test_companion_frame_queue.cpp | 30 + .../test_config_serializer.cpp | 28 + .../test_display_viewport.cpp | 83 +++ .../test_mqtt_prefs_codec.cpp | 104 +++- .../test_mqtt_prefs_json_import.cpp | 371 +++++++++++ test/test_observer_dashboard/MockDisplay.h | 122 ++++ .../test_observer_dashboard.cpp | 581 ++++++++++++++++++ .../test_observer_validation.cpp | 42 ++ .../test_radio_activity_window.cpp | 393 ++++++++++++ .../test_touch_tap_detector.cpp | 139 +++++ variants/heltec_v4_r8/HeltecV4R8Board.cpp | 6 + variants/heltec_v4_r8/platformio.ini | 203 +++++- variants/heltec_v4_r8/target.cpp | 9 +- 54 files changed, 5471 insertions(+), 158 deletions(-) create mode 100644 src/helpers/CompanionHardwareCommandCompat.h create mode 100644 src/helpers/CompanionMqttPrefsNvs.h create mode 100644 src/helpers/MQTTPrefsJsonImport.h create mode 100644 src/helpers/MQTTPrefsSerializer.h create mode 100644 src/helpers/RadioActivityWindow.h create mode 100644 src/helpers/ui/CHSC6XTouch.h create mode 100644 src/helpers/ui/DisplayFrameSignature.h create mode 100644 src/helpers/ui/DisplayViewport.h create mode 100644 src/helpers/ui/ObserverDashboard.h create mode 100644 src/helpers/ui/TouchTapDetector.h create mode 100644 test/test_display_viewport/test_display_viewport.cpp create mode 100644 test/test_mqtt_prefs_json_import/test_mqtt_prefs_json_import.cpp create mode 100644 test/test_observer_dashboard/MockDisplay.h create mode 100644 test/test_observer_dashboard/test_observer_dashboard.cpp create mode 100644 test/test_radio_activity_window/test_radio_activity_window.cpp create mode 100644 test/test_touch_tap_detector/test_touch_tap_detector.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index aa79883d9..1c4f649c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,13 @@ upstream MeshCore `dev` branch, which generally pulls in a new MeshCore software ### August 2026 +- **New** - Added Heltec V4 R8 OLED and TFT MQTT observer targets, including portrait TFT variants 2026-08-28 +- **New** - Added a dark R8 TFT observer dashboard with a rolling 20-minute RF activity graph and partial row redraws 2026-08-28 +- **New** - Added live, persisted `display.timeout` and relative 180-degree `display.flip` observer controls 2026-08-28 +- **Fix** - Corrected the R8 TFT backlight polarity and made blank/wake preserve the reset line shared with its touch controller 2026-08-28 +- **Fix** - Preserved rollback-readable Companion MQTT preferences and added a strict, one-time `/mqtt.json` observer migration without deleting the source file 2026-08-28 +- **Improvement** - Made Companion command `0x42` framed CLI the canonical hardware-settings path while retaining both older binary command ranges as receive-only aliases 2026-08-28 +- **Fix** - Strengthened observer OTA shutdown gating and enabled the WiFi/WebConfig overlay consistently for ESP32 Full Companion builds 2026-08-28 - **Improvement** - Added a WebConfig CLI terminal, tightened command failure/reboot handling, stopped secret reads, and enforced the setup password 2026-08-08 * `d7109c18`, `c831e599`, `8abe26ba` - **New** * `mqtt` - Added IdahoMesh, GoMesh, okimesh, and atvirastinklas presets; expanded the built-in preset table to 31 entries 2026-08-08 * `da37b6eb`, `3b11540e`, `73faa30c`, `dbee39b5` - **New** - Enabled online OTA for Station G3 observer builds 2026-08-08 * `5000391c` diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md index f2fc6f19c..d2e417e50 100644 --- a/docs/cli_command_availability.md +++ b/docs/cli_command_availability.md @@ -39,6 +39,22 @@ Runtime state can still make an included command fail. Examples include no GPS fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without `.mota` apply support. +## Companion framed CLI + +Companion builds are not represented by the role/profile matrices below. Their +local terminal and command `0x42` (`CMD_RUN_CLI_COMMAND`) expose these settings +over the normal binary USB, BLE, or TCP connection: + +| Command | Availability | +|---|---| +| [`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 | +| [`get/set wifi.powersave`](cli_commands.md#browser-configuration-portal-esp32-repeater-and-room-server) | ESP32 WiFi Companion; active transport constraints still apply | +| [`get/set bluetooth.name`](cli_commands.md#view-or-change-the-independent-bluetooth-name-companion) | Companion firmware | + +Deprecated binary aliases remain receive-only for older clients; new clients +should use command `0x42`. See [Companion radio binary protocol](companion_protocol.md). + ## nRF52 | Area | Command | Scope | Standard | Logging | LoRa OTA | @@ -218,6 +234,7 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without | Operational | [`advert`](cli_commands.md#send-a-flood-advert) | Advert-capable role | Yes | Yes | Yes | Yes | Yes | | Operational | [`advert.zerohop`](cli_commands.md#send-a-zero-hop-advert) | Advert-capable role | Yes | Yes | Yes | Yes | Yes | | Operational | [`erase`](cli_commands.md#erasefactory-reset) | Local serial | Serial | Serial | Serial | Serial | Serial | +| Display | [`get/set display.timeout`; `get/set display.flip`](cli_commands.md#set-mqtt-observer-display-timeout-and-flip) | MQTT observer with display; flip needs a supported driver | No | No | No | Feature | No | | Information | [`ver`](cli_commands.md#get-the-version) | All text CLI roles | Yes | Yes | Yes | Yes | Yes | | Information | [`board`](cli_commands.md#show-the-hardware-name) | All text CLI roles | Yes | Yes | Yes | Yes | Yes | | Diagnostics | [`get storage.layout`](cli_commands.md#show-the-storage-layout) | Detected flash size and live partition table | Yes | Yes | Yes | Yes | Yes | diff --git a/docs/cli_commands.md b/docs/cli_commands.md index bf0309e83..e24eabc93 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -620,6 +620,42 @@ board's compiled default. Unsupported display drivers return an error. --- +## Set MQTT observer display timeout and flip + +MQTT observer builds with a display support a persisted inactivity timeout: + +```text +get display.timeout +set display.timeout 0 +set display.timeout 60 +``` + +The value is seconds. `0` keeps the display on; `1` through `3600` blanks it +after that much inactivity. The default is `60`. A change applies immediately +and restarts the countdown. On the Heltec V4 R8 Expansion Kit V2 observer, a +panel tap or USER-button click can also blank or wake the display. + +Supported observer displays, including the R8 OLED and ST7789 panels, can also +be turned 180 degrees relative to their compiled orientation: + +```text +get display.flip +set display.flip off +set display.flip on +``` + +`0` and `1` are accepted aliases for `off` and `on`. This is intentionally +different from Full Companion `display.rotation`: observer `display.flip` is a +relative 180-degree mounting choice and cannot switch between portrait and +landscape. The value is harmless on an observer display driver that does not +support flipping. + +Both settings survive reboot and firmware updates that preserve the filesystem; +erasing flash restores the `60`/`off` defaults. The boot log reports the saved +flip state on display-enabled observer builds. + +--- + ## Logging Builds compiled with `MESH_PACKET_LOGGING` emit one `RAW:` line for every @@ -1024,6 +1060,25 @@ Station G2/G3 targets default to `off`. --- +#### View or change the independent Bluetooth name (Companion) + +**Usage:** + +- `get bluetooth.name` +- `set bluetooth.name ` + +`get ble.name` and `set ble.name ...` are accepted aliases. This command is +specific to Companion firmware. The default is `MeshCore-`; +`default` (or `clear`) removes a custom override. Names are limited to 31 valid +UTF-8 bytes without control characters and take effect after reboot. + +A binary Companion client should carry the same text in command `0x42` +(`CMD_RUN_CLI_COMMAND`), which works over USB, BLE, or TCP without entering USB +terminal mode. See [Companion radio binary protocol](companion_protocol.md) for +the frame and reply format. + +--- + #### View or change this node's latitude **Usage:** - `get lat` diff --git a/docs/companion_protocol.md b/docs/companion_protocol.md index 8e9035165..a605dfbc5 100644 --- a/docs/companion_protocol.md +++ b/docs/companion_protocol.md @@ -237,6 +237,7 @@ catalog; bytes `0x2C`-`0x31` are parked and `0x35` is unused. | `0x42` | `CMD_RUN_CLI_COMMAND` | Run a local CLI command (protocol v14+). | | `0x4A` | `CMD_EXEC_LOCAL_OTA_CONTROL` | Run one bounded local TempRadio or OTA command on a Full Companion. | | `0x4B` | `CMD_BLE_MOTA_SOURCE` | Query, start, or stop an nRF52 Full Companion's Bluetooth-backed LoRa mOTA source. | +| `0x78`-`0x7F` | Deprecated hardware-setting aliases | Receive-only compatibility for clients shipped before command `0x42` became the canonical settings path. | The sections below detail the most common frames. Refer to the source named above for command bodies that are not expanded here. @@ -246,7 +247,9 @@ NUL. The device returns `RESP_CODE_CLI_REPLY` (`0x1D`) followed by the reply text. This is separate from sending a remote on-air CLI command with `CMD_SEND_TXT_MSG` and `TXT_TYPE_CLI_COMMAND`. The body must contain at least one byte and must not contain an embedded NUL. An unknown command is returned -as the normal CLI reply text `Unknown command`, not as an error frame. +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. Firmware from this fork predating the upstream `0x42` allocation used `0x42`-`0x49` for these eight settings. This firmware accepts those values as @@ -257,6 +260,21 @@ these settings, rather than allocating additional command bytes. For example, send `0x42` followed by `get radio.rxgain` or `set radio.rxgain on`. The reply is `RESP_CODE_CLI_REPLY` followed by the normal CLI reply text. +Two deprecated binary alias blocks remain receive-only for compatibility: + +| Setting | Original alias | Later fork alias | GET body/reply | SET body/reply | +|---|---:|---:|---|---| +| FEM receive gain | `0x42` / `0x43` | `0x78` / `0x79` | No body; `OK, state` | One byte `0`/`1`; `OK` | +| Radio receive gain | `0x44` / `0x45` | `0x7A` / `0x7B` | No body; `OK, state` | One byte `0`/`1`; `OK` | +| WiFi power save | `0x46` / `0x47` | `0x7C` / `0x7D` | No body; `OK, mode` | One mode byte `0`-`2`; `OK` | +| Bluetooth name | `0x48` / `0x49` | `0x7E` / `0x7F` | No body; `OK, custom, name` | Zero to 31 UTF-8 bytes; `OK` | + +Each pair lists GET then SET. Here `OK` is `RESP_CODE_OK`; the remaining reply +bytes have the same meanings as the CLI settings below. A bare `0x42` is the +old FEM GET, while `0x42` plus at least one text byte is +`CMD_RUN_CLI_COMMAND`. New clients must use the framed CLI form; these aliases +exist only so deployed clients do not break after a firmware update. + The equivalent framed CLI commands are: | Setting | Commands | @@ -284,9 +302,10 @@ 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. -The Bluetooth name can be configured over USB or TCP before rebooting into BLE. -An empty/default selection restores `MeshCore-`; a custom name is -limited to 31 valid UTF-8 bytes and takes effect after reboot. +The Bluetooth name can be configured over USB, BLE, or TCP. Use +`set bluetooth.name default` to restore `MeshCore-`; an empty CLI +value is rejected. (`clear` is also accepted as an alias for `default`.) A +custom name is limited to 31 valid UTF-8 bytes and takes effect after reboot. ### Bluetooth LoRa mOTA source diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 75210e774..0653bbc83 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2,6 +2,7 @@ #include // needed for PlatformIO #include +#include #include #include #include "helpers/radiolib/RXPowerSaving.h" @@ -121,20 +122,6 @@ static const uint32_t COMMAND_RADIO_APPLY_TIMEOUT_MS = 5000UL; #define CMD_SET_DEFAULT_FLOOD_SCOPE 63 #define CMD_GET_DEFAULT_FLOOD_SCOPE 64 #define CMD_SEND_RAW_PACKET 65 -#define CMD_RUN_CLI_COMMAND 66 // v14+ - -// Compatibility aliases used by this fork before upstream assigned 0x42 to -// CMD_RUN_CLI_COMMAND. A one-byte 0x42 frame remains distinguishable from the -// official command, whose frame always includes CLI text. New clients should -// use CMD_RUN_CLI_COMMAND rather than allocating more one-byte command IDs. -#define LEGACY_CMD_GET_RADIO_FEM_RXGAIN 0x42 -#define LEGACY_CMD_SET_RADIO_FEM_RXGAIN 0x43 -#define LEGACY_CMD_GET_RADIO_RXGAIN 0x44 -#define LEGACY_CMD_SET_RADIO_RXGAIN 0x45 -#define LEGACY_CMD_GET_WIFI_POWER_SAVE 0x46 -#define LEGACY_CMD_SET_WIFI_POWER_SAVE 0x47 -#define LEGACY_CMD_GET_BLUETOOTH_NAME 0x48 -#define LEGACY_CMD_SET_BLUETOOTH_NAME 0x49 #if defined(RADIO_FEM_RXGAIN) && (RADIO_FEM_RXGAIN == 0) static constexpr uint8_t DEFAULT_FEM_RX_GAIN = 0; @@ -3194,7 +3181,7 @@ void MyMesh::handleCmdFrame(size_t len) { memcpy(&out_frame[i], _prefs.node_name, tlen); i += tlen; _serial->writeFrame(out_frame, i); - } else if (cmd_frame[0] == CMD_RUN_CLI_COMMAND && len >= 2) { // V14+ + } else if (mesh::companion::isRunCliFrame(cmd_frame[0], len)) { // V14+ int i = 1; char *text = (char *)&cmd_frame[i]; int tlen = len - i; @@ -4102,7 +4089,7 @@ void MyMesh::handleCmdFrame(size_t len) { } else { writeErrFrame(ERR_CODE_ILLEGAL_ARG); } - } else if (cmd_frame[0] == LEGACY_CMD_GET_RADIO_FEM_RXGAIN) { + } else if (mesh::companion::isFemRxGainGet(cmd_frame[0])) { if (len != 1) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); } else if (!board.canControlLoRaFemLna()) { @@ -4113,7 +4100,7 @@ void MyMesh::handleCmdFrame(size_t len) { memcpy(&out_frame[1], &value, 1); _serial->writeFrame(out_frame, 2); } - } else if (cmd_frame[0] == LEGACY_CMD_SET_RADIO_FEM_RXGAIN) { + } else if (mesh::companion::isFemRxGainSet(cmd_frame[0])) { uint8_t value = len >= 2 ? cmd_frame[1] : 0; if (len != 2) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); @@ -4128,7 +4115,7 @@ void MyMesh::handleCmdFrame(size_t len) { } else { writeErrFrame(ERR_CODE_ILLEGAL_ARG); } - } else if (cmd_frame[0] == LEGACY_CMD_GET_RADIO_RXGAIN) { + } else if (mesh::companion::isRadioRxGainGet(cmd_frame[0])) { if (len != 1) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); } else if (!radio_driver.supportsRxBoostedGainMode()) { @@ -4138,7 +4125,7 @@ void MyMesh::handleCmdFrame(size_t len) { out_frame[1] = _prefs.rx_boosted_gain ? 1 : 0; _serial->writeFrame(out_frame, 2); } - } else if (cmd_frame[0] == LEGACY_CMD_SET_RADIO_RXGAIN) { + } else if (mesh::companion::isRadioRxGainSet(cmd_frame[0])) { uint8_t value = len >= 2 ? cmd_frame[1] : 0; if (len != 2 || value > 1) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); @@ -4149,7 +4136,7 @@ void MyMesh::handleCmdFrame(size_t len) { } else { writeOKFrame(); } - } else if (cmd_frame[0] == LEGACY_CMD_GET_WIFI_POWER_SAVE) { + } else if (mesh::companion::isWiFiPowerSaveGet(cmd_frame[0])) { if (len != 1) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); } else { @@ -4161,7 +4148,7 @@ void MyMesh::handleCmdFrame(size_t len) { writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); #endif } - } else if (cmd_frame[0] == LEGACY_CMD_SET_WIFI_POWER_SAVE) { + } else if (mesh::companion::isWiFiPowerSaveSet(cmd_frame[0])) { if (len != 2) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); } else { @@ -4182,7 +4169,7 @@ void MyMesh::handleCmdFrame(size_t len) { writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); #endif } - } else if (cmd_frame[0] == LEGACY_CMD_GET_BLUETOOTH_NAME) { + } else if (mesh::companion::isBluetoothNameGet(cmd_frame[0])) { if (len != 1) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); } else { @@ -4195,7 +4182,7 @@ void MyMesh::handleCmdFrame(size_t len) { BLE_NAME_PREFIX, _prefs.node_name); _serial->writeFrame(out_frame, 2 + strlen(effective_name)); } - } else if (cmd_frame[0] == LEGACY_CMD_SET_BLUETOOTH_NAME) { + } else if (mesh::companion::isBluetoothNameSet(cmd_frame[0])) { const size_t name_len = len - 1; if (name_len > mesh::companion::BLUETOOTH_NAME_MAX_BYTES || memchr(&cmd_frame[1], 0, name_len) != NULL) { diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 614f2cefb..5aad7e1b4 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -126,6 +126,13 @@ static_assert(mesh::HostCliBridge::REMOTE_REPLY_MAX // channel so an update is never stalled indefinitely. #define OTA_TX_DRAIN_TIMEOUT_MS 5000 +// Bench mitigation for a residual ThinkNode M7 race after a clean MQTT stop: +// the bridge task acknowledges after client teardown but before its own +// FreeRTOS stack/TCB has necessarily been reclaimed. OTA allocates a large task +// immediately afterward; 25 ms still failed intermittently and 100 ms passed +// five consecutive one-slot update cycles in observer-firmware-dev. +#define OTA_MQTT_STOP_SETTLE_MS 100 + #define LAZY_CONTACTS_WRITE_DELAY 5000 #define LEGACY_FLOOD_CHANNEL_BLOCK_FILE "/flood_ch_block" @@ -1068,6 +1075,14 @@ void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { } void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { +#ifdef DISPLAY_ACTIVITY_DASHBOARD + // Count valid, parsed RF packets before role-level filtering. The dashboard + // intentionally reflects radio activity, including packets this role later + // decides not to process or forward. + _activity.recordPacket(millis(), (uint16_t)len, + _radio->getEstAirtimeFor(len), + (int8_t)(pkt->getSNR() * 4.0f), pkt->getRSSI()); +#endif #ifdef WITH_MQTT_BRIDGE // MQTT bridge: always feed RX packets - bridge decides based on mqtt.rx setting if (mqtt_bridge) mqtt_bridge->onPacketReceived(pkt); @@ -11694,6 +11709,9 @@ void __attribute__((noinline)) MyMesh::servicePostMeshLoop() { // duty-limited channel) is lost when the flash spins the loop and reboots. drainOutbound(OTA_TX_DRAIN_TIMEOUT_MS); setBridgeState(false); + // TODO: Replace this timed settle with a task-exit/join barrier once MQTT + // teardown can prove that the idle task has reclaimed the worker resources. + delay(OTA_MQTT_STOP_SETTLE_MS); char ota_reply[160]; // OTA teardown barrier (Phase 5): only flash after a CLEAN MQTT shutdown. // A timed-out/forced stop leaves mbedTLS/heap ownership uncertain - writing diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index b780e727a..003d03086 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -77,6 +77,10 @@ #include "helpers/SNMPAgent.h" #endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD +#include +#endif + #include #include #include @@ -306,6 +310,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks mesh::Packet* pending_battery_alert_packet; bool battery_alert_sent; bool _logging; +#ifdef DISPLAY_ACTIVITY_DASHBOARD + RadioActivityWindow _activity; +#endif NodePrefs _prefs; ClientACL acl; CommonCLI _cli; @@ -894,6 +901,14 @@ public: return &_prefs; } +#ifdef DISPLAY_ACTIVITY_DASHBOARD + RadioActivityWindow* getActivityWindow() { return &_activity; } +#endif + +#ifdef WITH_MQTT_BRIDGE + MQTTPrefs* getObserverPrefs() { return _cli.getObserverPrefs(); } +#endif + void savePrefs( PrefsSaveRouting::Scope scope = PrefsSaveRouting::Scope::Common) override { _cli.savePrefs(_fs, scope); diff --git a/examples/simple_repeater/UITask.cpp b/examples/simple_repeater/UITask.cpp index 1470399ac..90a8bdd82 100644 --- a/examples/simple_repeater/UITask.cpp +++ b/examples/simple_repeater/UITask.cpp @@ -3,16 +3,64 @@ #include #include +#ifdef DISPLAY_REDRAW_ON_CHANGE +#include +#endif + #ifndef USER_BTN_PRESSED #define USER_BTN_PRESSED LOW #endif #ifdef ESP_PLATFORM #include -#include +#include // defines WITH_WEBCONFIG on ESP32 #endif -#define AUTO_OFF_MILLIS 20000 // 20 seconds +#ifndef AUTO_OFF_MILLIS +#define AUTO_OFF_MILLIS 20000 // 20 seconds; 0 keeps the screen on +#endif + +#ifdef DISPLAY_TOUCH_TOGGLE +#define TOUCH_POLL_MILLIS 50 +#endif + +// Wrap-safe deadline test. `millis() >= deadline` fires early for the whole +// interval before a rollover, because the deadline has already wrapped to a +// small value while millis() is still near UINT32_MAX; the signed difference +// stays correct across it. +static inline bool millisReached(unsigned long now, unsigned long deadline) { + return (int32_t)((uint32_t)now - (uint32_t)deadline) >= 0; +} + +// Applies `display.flip` when it changes, forcing a complete repaint because +// the panel's existing contents are now the wrong way up. +void UITask::applyDisplayFlip() { +#ifdef WITH_MQTT_BRIDGE + if (_observer_prefs == NULL || _observer_prefs->display_flip == _flip_seen) return; + _flip_seen = _observer_prefs->display_flip; + _display->setFlipped(_flip_seen != 0); + // Logged unconditionally: this is persisted config, so it survives a reflash + // and is otherwise invisible when someone is chasing a wrong orientation. + Serial.printf("Display: flip %s\n", _flip_seen ? "on (rotated 180)" : "off"); +#ifdef DISPLAY_REDRAW_ON_CHANGE + _frame_valid = false; +#endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD + _rows_valid = false; +#endif + _next_refresh = 0; +#endif +} + +// `display.timeout` when the observer prefs are available, otherwise the +// compiled-in default. Read on every use so a `set display.timeout` takes +// effect immediately. +unsigned long UITask::displayTimeoutMillis() const { +#ifdef WITH_MQTT_BRIDGE + if (_observer_prefs) return (unsigned long)_observer_prefs->display_timeout_secs * 1000UL; +#endif + return AUTO_OFF_MILLIS; +} #define BOOT_SCREEN_MILLIS 4000 // 4 seconds #define POWEROFF_DELAY 3000 @@ -36,10 +84,21 @@ static const uint8_t meshcore_logo [] PROGMEM = { void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version) { _prevBtnState = HIGH; - _auto_off = millis() + AUTO_OFF_MILLIS; + _timeout_seen = displayTimeoutMillis(); + _auto_off = millis() + displayTimeoutMillis(); _started_at = millis(); _node_prefs = node_prefs; +#ifdef DISPLAY_ACTIVITY_DASHBOARD + ObserverDashboard::applyDarkPalette(); // retunes UIColor for this target only +#endif _display->turnOn(); + applyDisplayFlip(); +#ifdef DISPLAY_TOUCH_TOGGLE + _touch.begin(); +#endif +#ifdef DISPLAY_REDRAW_ON_CHANGE + _frame_valid = false; +#endif #if defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) user_btn.begin(); @@ -60,7 +119,10 @@ void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* fi void UITask::renderCurrScreen() { char tmp[80]; - if (millis() < _started_at + BOOT_SCREEN_MILLIS) { // boot screen +#ifdef DISPLAY_ACTIVITY_DASHBOARD + _rows_valid = false; +#endif + if ((uint32_t)(millis() - _started_at) < BOOT_SCREEN_MILLIS) { // boot screen // meshcore logo _display->setColor(UIColor::corp_blue); int logoWidth = 128; @@ -96,9 +158,11 @@ void UITask::renderCurrScreen() { uint16_t poffWidth = _display->getTextWidth(poweroff_string); _display->setCursor((_display->width() - poffWidth) / 2, 48); _display->drawTextCentered(_display->width()/2, 48, poweroff_string); - } else { + } else { // home screen #ifdef WITH_WEBCONFIG if (WebConfigServer::isRebootPending()) { + // save confirmed on-device: show ground truth even if the browser + // lost its connection before the confirmation reached it _display->setTextSize(1); _display->setColor(UIColor::corp_blue); _display->setCursor(0, 14); @@ -110,16 +174,23 @@ void UITask::renderCurrScreen() { } char wc_ssid[33], wc_ip[16]; if (WebConfigServer::getSetupInfo(wc_ssid, sizeof(wc_ssid), wc_ip, sizeof(wc_ip))) { + // setup portal active: show join instructions instead of the home screen _display->setTextSize(1); _display->setColor(UIColor::corp_blue); _display->setCursor(0, 0); +#ifdef DISPLAY_ACTIVITY_DASHBOARD + _display->print("Observer WiFi Setup"); +#else _display->print("WebUI WiFi Setup"); +#endif + _display->setColor(UIColor::primary_txt); _display->setCursor(0, 14); _display->print("Join WiFi:"); _display->setColor(UIColor::warning_txt); _display->setCursor(6, 24); _display->print(wc_ssid); + _display->setColor(UIColor::primary_txt); _display->setCursor(0, 40); _display->print("Then browse to:"); @@ -129,6 +200,11 @@ void UITask::renderCurrScreen() { return; } #endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD + renderDashboard(); + return; +#endif + // node name _display->setCursor(0, 0); _display->setTextSize(1); _display->setColor(UIColor::primary_txt); @@ -167,57 +243,272 @@ void UITask::renderCurrScreen() { } } +#ifdef DISPLAY_REDRAW_ON_CHANGE +uint32_t UITask::getFrameSignature() { + uint32_t signature = DisplayFrameSignature::INITIAL; + char tmp[80]; + + if ((uint32_t)(millis() - _started_at) < BOOT_SCREEN_MILLIS) { + signature = DisplayFrameSignature::append(signature, "boot"); + return DisplayFrameSignature::append(signature, _version_info); + } + + if (_powering_off_at > 0) { + return DisplayFrameSignature::append(signature, "powering-off"); + } + +#ifdef WITH_WEBCONFIG + if (WebConfigServer::isRebootPending()) { + return DisplayFrameSignature::append(signature, "rebooting"); + } + + char wc_ssid[33], wc_ip[16]; + if (WebConfigServer::getSetupInfo(wc_ssid, sizeof(wc_ssid), wc_ip, sizeof(wc_ip))) { + signature = DisplayFrameSignature::append(signature, "setup"); + signature = DisplayFrameSignature::append(signature, wc_ssid); + return DisplayFrameSignature::append(signature, wc_ip); + } +#endif + + signature = DisplayFrameSignature::append(signature, "home"); + signature = DisplayFrameSignature::append(signature, _node_prefs->node_name); + snprintf(tmp, sizeof(tmp), "FREQ: %06.3f SF%d", _node_prefs->freq, _node_prefs->sf); + signature = DisplayFrameSignature::append(signature, tmp); + snprintf(tmp, sizeof(tmp), "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr); + signature = DisplayFrameSignature::append(signature, tmp); + +#if defined(WITH_MQTT_BRIDGE) && !defined(DISPLAY_ACTIVITY_DASHBOARD) + if (WiFi.status() == WL_CONNECTED) { + IPAddress ip = WiFi.localIP(); + snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]); + signature = DisplayFrameSignature::append(signature, tmp); + } else { + signature = DisplayFrameSignature::append(signature, "wifi-disconnected"); + } +#endif + +#ifndef DISPLAY_ACTIVITY_DASHBOARD +#ifdef WITH_MQTT_BRIDGE + if (WiFi.status() != WL_CONNECTED) { + snprintf(tmp, sizeof(tmp), "BAT: %.2fV", _board->getBattMilliVolts() / 1000.0f); + signature = DisplayFrameSignature::append(signature, tmp); + } +#else + snprintf(tmp, sizeof(tmp), "BAT: %.2fV", _board->getBattMilliVolts() / 1000.0f); + signature = DisplayFrameSignature::append(signature, tmp); +#endif + signature = DisplayFrameSignature::append( + signature, _node_prefs->powersaving_enabled ? "powersaving-on" : "powersaving-off"); +#endif + + return signature; +} +#endif + +#ifdef DISPLAY_ACTIVITY_DASHBOARD +#define ACTIVITY_REFRESH_MILLIS 5000 + +bool UITask::buildDashboardContext(ObserverDashboard::Context* ctx) { + if (_node_prefs == NULL) return false; + ctx->node_name = _node_prefs->node_name; + ctx->role_label = "REPEATER"; + ctx->freq = _node_prefs->freq; + ctx->sf = _node_prefs->sf; + ctx->bw = _node_prefs->bw; +#ifdef WITH_MQTT_BRIDGE + ctx->link_up = (WiFi.status() == WL_CONNECTED); +#else + ctx->link_up = false; +#endif + return true; +} + +void UITask::renderDashboard() { + ObserverDashboard::Context ctx; + if (!buildDashboardContext(&ctx)) return; + + RadioActivitySnapshot snap; + if (_activity) { + _activity->snapshot(millis(), &snap); + } else { + memset(&snap, 0, sizeof(snap)); + } + + const ObserverDashboard::Layout& layout = ObserverDashboard::activeLayout(); + ObserverDashboard::drawFull(*_display, layout, ctx, snap); + ObserverDashboard::allRowSignatures(layout, ctx, snap, _row_signatures); + _rows_valid = true; + _next_activity = millis() + ACTIVITY_REFRESH_MILLIS; +} + +// Repaints just the analytics rows whose contents moved. No startFrame(), so +// the header, the radio strip and the rest of the panel are never cleared. +void UITask::updateActivityRows() { + if (!_rows_valid || _activity == NULL) return; // not showing the dashboard + + ObserverDashboard::Context ctx; + if (!buildDashboardContext(&ctx)) return; + + RadioActivitySnapshot snap; + _activity->snapshot(millis(), &snap); + ObserverDashboard::drawChangedRows(*_display, ObserverDashboard::activeLayout(), ctx, snap, + _row_signatures); +} +#endif + +#ifdef DISPLAY_TOUCH_TOGGLE +void UITask::toggleDisplay(const char* source) { + if (_display->isOn()) { + _display->turnOff(); + } else { + _display->turnOn(); + } +#ifdef DISPLAY_TOUCH_DEBUG + Serial.printf("Display: %s -> %s\n", source, _display->isOn() ? "on" : "off"); +#else + (void)source; +#endif +#ifdef DISPLAY_REDRAW_ON_CHANGE + _frame_valid = false; // wake draws one complete current frame +#endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD + _rows_valid = false; +#endif + _next_refresh = 0; // redraw at once rather than showing the stale frame + _auto_off = millis() + displayTimeoutMillis(); +} +#endif + void UITask::loop() { #if defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) int ev = user_btn.check(); - if (ev == BUTTON_EVENT_CLICK) { + // Multiclick stays enabled on the R8 observer so triple-click can toggle + // WebConfig. Collapse a completed double-click into one display action + // instead of silently consuming both presses. + if (ev == BUTTON_EVENT_CLICK || ev == BUTTON_EVENT_DOUBLE_CLICK) { +#ifdef DISPLAY_TOUCH_TOGGLE + toggleDisplay("button"); // same action as tapping the panel +#else if (_display->isOn()) { // TODO: any action ? } else { _display->turnOn(); +#ifdef DISPLAY_REDRAW_ON_CHANGE + _frame_valid = false; +#endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD + _rows_valid = false; +#endif } - _auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer + _auto_off = millis() + displayTimeoutMillis(); // extend auto-off timer +#endif } else if (ev == BUTTON_EVENT_LONG_PRESS) { _display->turnOn(); Serial.println("Powering Off"); _powering_off_at = millis() + POWEROFF_DELAY; +#ifdef DISPLAY_REDRAW_ON_CHANGE + _frame_valid = false; +#endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD + _rows_valid = false; +#endif + _next_refresh = 0; #ifdef WITH_WEBCONFIG } else if (ev == BUTTON_EVENT_TRIPLE_CLICK) { - // Single click wakes the display and long press powers off. Triple-click - // was unused in the repeater UI, so use it for the persistent WebUI toggle. + // Preserve the fork's persistent WebUI toggle. R8 observer repeaters keep + // multiclick enabled specifically for this action. WebConfigServer::requestToggleFromButton(); _display->turnOn(); - _auto_off = millis() + AUTO_OFF_MILLIS; + _auto_off = millis() + displayTimeoutMillis(); #endif } #endif -#ifdef WITH_WEBCONFIG - if (WebConfigServer::getSetupInfo(nullptr, 0, nullptr, 0)) { - if (!_display->isOn()) _display->turnOn(); - _auto_off = millis() + AUTO_OFF_MILLIS; - } -#endif - #ifdef WITH_WEBCONFIG // While the setup portal is up there's no user button to wake the screen // reliably - keep it on so the join instructions stay visible. if (WebConfigServer::getSetupInfo(NULL, 0, NULL, 0)) { - if (!_display->isOn()) _display->turnOn(); - _auto_off = millis() + AUTO_OFF_MILLIS; + if (!_display->isOn()) { + _display->turnOn(); +#ifdef DISPLAY_REDRAW_ON_CHANGE + _frame_valid = false; +#endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD + _rows_valid = false; +#endif + } + _auto_off = millis() + displayTimeoutMillis(); } #endif - if (_display->isOn()) { - if (millis() >= _next_refresh) { - _display->startFrame(); - renderCurrScreen(); - _display->endFrame(); - - _next_refresh = millis() + 1000; // refresh every second +#ifdef DISPLAY_TOUCH_TOGGLE + { + unsigned long now = millis(); + if (millisReached(now, _next_touch)) { + _next_touch = now + TOUCH_POLL_MILLIS; + if (_powering_off_at == 0 && _touch.checkTap(now)) toggleDisplay("touch"); } - if (millis() > _auto_off) { + } +#endif + + // Observe timeout changes even while the panel is blanked. In particular, + // `set display.timeout 0` means the display must stay on, so wake it now + // rather than waiting for a button/touch event that may never arrive. + unsigned long timeout = displayTimeoutMillis(); + if (timeout != _timeout_seen) { + _timeout_seen = timeout; + _auto_off = millis() + timeout; + if (timeout == 0 && !_display->isOn()) { + _display->turnOn(); +#ifdef DISPLAY_REDRAW_ON_CHANGE + _frame_valid = false; +#endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD + _rows_valid = false; +#endif + _next_refresh = 0; + } + } + + if (_display->isOn()) { + // Apply a live orientation change before drawing, including the first frame + // after a preference was changed while the panel was blanked. + applyDisplayFlip(); + if (millisReached(millis(), _next_refresh)) { + bool redraw = true; +#ifdef DISPLAY_REDRAW_ON_CHANGE + uint32_t frame_signature = getFrameSignature(); + redraw = !_frame_valid || frame_signature != _last_frame_signature; +#endif + if (redraw) { + _display->startFrame(); + renderCurrScreen(); + _display->endFrame(); +#ifdef DISPLAY_REDRAW_ON_CHANGE + _last_frame_signature = frame_signature; + _frame_valid = true; +#endif + } +#ifdef DISPLAY_ACTIVITY_DASHBOARD + else if (millisReached(millis(), _next_activity)) { + updateActivityRows(); + _next_activity = millis() + ACTIVITY_REFRESH_MILLIS; + } +#endif + + _next_refresh = millis() + 1000; // check for visible changes every second + } + // `_auto_off` is only armed on activity, so a timeout changed at runtime has + // to restart the countdown here - otherwise 0 -> 60 blanks instantly off a + // boot-time deadline, and 60 -> 3600 still blanks at the old 60 s mark. + if (_powering_off_at == 0 && timeout > 0 && millisReached(millis(), _auto_off)) { _display->turnOff(); +#ifdef DISPLAY_REDRAW_ON_CHANGE + _frame_valid = false; +#endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD + _rows_valid = false; +#endif } } @@ -225,7 +516,7 @@ void UITask::loop() { #ifdef LED_PIN digitalWrite(LED_PIN, LED_STATE_ON); // switch on the led until poweroff #endif - if (millis() > _powering_off_at) { + if (millisReached(millis(), _powering_off_at)) { _board->powerOff(); // should not return } } diff --git a/examples/simple_repeater/UITask.h b/examples/simple_repeater/UITask.h index 4123274a8..9c7a05ef9 100644 --- a/examples/simple_repeater/UITask.h +++ b/examples/simple_repeater/UITask.h @@ -3,6 +3,19 @@ #include #include +#ifdef DISPLAY_ACTIVITY_DASHBOARD + #ifndef DISPLAY_REDRAW_ON_CHANGE + #error "DISPLAY_ACTIVITY_DASHBOARD needs DISPLAY_REDRAW_ON_CHANGE: without it every frame clears the whole screen" + #endif + #include + #include + +#endif + +#ifdef DISPLAY_TOUCH_TOGGLE + #include +#endif + class UITask { mesh::MainBoard* _board; DisplayDriver* _display; @@ -13,10 +26,54 @@ class UITask { unsigned long _powering_off_at = 0; unsigned long _started_at = 0; +#ifdef DISPLAY_REDRAW_ON_CHANGE + uint32_t _last_frame_signature = 0; + bool _frame_valid = false; + + uint32_t getFrameSignature(); +#endif + +#ifdef DISPLAY_ACTIVITY_DASHBOARD + RadioActivityWindow* _activity = NULL; + unsigned long _next_activity = 0; + uint32_t _row_signatures[ObserverDashboard::ROW_COUNT] = {0}; + bool _rows_valid = false; // true only while the dashboard is the drawn screen + + bool buildDashboardContext(ObserverDashboard::Context* ctx); + void renderDashboard(); + void updateActivityRows(); +#endif + +#ifdef DISPLAY_TOUCH_TOGGLE + CHSC6XTouch _touch; + unsigned long _next_touch = 0; + + void toggleDisplay(const char* source); +#endif + +#ifdef WITH_MQTT_BRIDGE + MQTTPrefs* _observer_prefs = NULL; +#endif + unsigned long _timeout_seen = 0; // to notice a live `display.timeout` change + uint8_t _flip_seen = 0xFF; // 0xFF forces the first apply + void applyDisplayFlip(); + + unsigned long displayTimeoutMillis() const; + void renderCurrScreen(); public: UITask(mesh::MainBoard& board, DisplayDriver& display) : _board(&board), _display(&display) { _next_read = _next_refresh = 0; } void begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version); +#ifdef WITH_MQTT_BRIDGE + // Supplies `display.timeout`, which is read live so a config change applies + // without a reboot. Call before begin(). + void setObserverPrefs(MQTTPrefs* prefs) { _observer_prefs = prefs; } +#endif + +#ifdef DISPLAY_ACTIVITY_DASHBOARD + void setActivityWindow(RadioActivityWindow* activity) { _activity = activity; } +#endif + void loop(); }; diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 58d87bb4a..aa70a0169 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -192,6 +192,12 @@ void setup() { #ifdef DISPLAY_CLASS if (display_ready) { +#ifdef WITH_MQTT_BRIDGE + ui_task.setObserverPrefs(the_mesh.getObserverPrefs()); +#endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD + ui_task.setActivityWindow(the_mesh.getActivityWindow()); +#endif ui_task.begin(the_mesh.getNodePrefs(), FIRMWARE_BUILD_DATE, FIRMWARE_VERSION); } #endif diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 9e0cedc44..3c3ed1ada 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -49,6 +49,10 @@ static uint32_t nextRadioApplyRetryDelay(uint8_t& failure_count) { // reboots. Do not let a busy or duty-limited channel stall the update forever. #define OTA_TX_DRAIN_TIMEOUT_MS 5000 +// MQTT stop acknowledgment precedes final FreeRTOS task reclamation. Leave the +// same proven settle used by the repeater before OTA allocates its worker. +#define OTA_MQTT_STOP_SETTLE_MS 100 + #define LAZY_CONTACTS_WRITE_DELAY 5000 struct ServerStats { @@ -284,6 +288,14 @@ void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { } void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { +#ifdef DISPLAY_ACTIVITY_DASHBOARD + // Count valid, parsed RF packets before role-level filtering. The dashboard + // intentionally reflects radio activity, including packets this role later + // decides not to process. + _activity.recordPacket(millis(), (uint16_t)len, + _radio->getEstAirtimeFor(len), + (int8_t)(pkt->getSNR() * 4.0f), pkt->getRSSI()); +#endif #ifdef WITH_MQTT_BRIDGE // MQTT bridge: always feed RX packets - bridge decides based on mqtt.rx setting if (_prefs.bridge_enabled && bridge) bridge->onPacketReceived(pkt); @@ -2431,7 +2443,11 @@ void MyMesh::loop() { const bool bridge_was_running = bridge && bridge->isRunning(); drainOutbound(OTA_TX_DRAIN_TIMEOUT_MS); - bool may_flash = true; + // A previously timed-out stop leaves the lifecycle dirty even though the + // bridge is no longer running. Honor that latch on every OTA attempt; a + // later request must not bypass the teardown barrier merely because there + // is no active task left to stop this time. + bool may_flash = !bridge || bridge->canFlashAfterStop(); if (bridge_was_running) { setBridgeState(false); // OTA must not write after a forced/timed-out MQTT shutdown: its TLS/heap @@ -2439,7 +2455,12 @@ void MyMesh::loop() { may_flash = bridge && bridge->canFlashAfterStop(); if (!may_flash) { Serial.println("OTA: aborted, MQTT stop did not complete cleanly"); + } else { + // TODO: Replace this mitigation with a real MQTT task-exit/join barrier. + delay(OTA_MQTT_STOP_SETTLE_MS); } + } else if (!may_flash) { + Serial.println("OTA: aborted, prior MQTT stop did not complete cleanly"); } char ota_reply[160]; diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 6f3a63490..179507eee 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -16,6 +16,9 @@ #include #include #include +#ifdef DISPLAY_ACTIVITY_DASHBOARD +#include +#endif #include #include #include @@ -159,6 +162,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks, uint64_t uptime_millis; unsigned long next_local_advert, next_flood_advert; bool _logging; +#ifdef DISPLAY_ACTIVITY_DASHBOARD + RadioActivityWindow _activity; +#endif bool region_load_active; NodePrefs _prefs; TransportKeyStore key_store; @@ -389,6 +395,14 @@ public: return &_prefs; } +#ifdef DISPLAY_ACTIVITY_DASHBOARD + RadioActivityWindow* getActivityWindow() { return &_activity; } +#endif + +#ifdef WITH_MQTT_BRIDGE + MQTTPrefs* getObserverPrefs() { return _cli.getObserverPrefs(); } +#endif + void savePrefs( PrefsSaveRouting::Scope scope = PrefsSaveRouting::Scope::Common) override { _cli.savePrefs(_fs, scope); diff --git a/examples/simple_room_server/UITask.cpp b/examples/simple_room_server/UITask.cpp index fbe24708b..40e22840c 100644 --- a/examples/simple_room_server/UITask.cpp +++ b/examples/simple_room_server/UITask.cpp @@ -1,19 +1,74 @@ #include "UITask.h" +#ifdef DISPLAY_TOUCH_TOGGLE +#include "target.h" +#endif #include #include +#ifdef DISPLAY_REDRAW_ON_CHANGE +#include +#endif + #ifndef USER_BTN_PRESSED #define USER_BTN_PRESSED LOW #endif #ifdef ESP_PLATFORM #include -#include +#include // defines WITH_WEBCONFIG on ESP32 #endif -#define AUTO_OFF_MILLIS 20000 // 20 seconds +#ifndef AUTO_OFF_MILLIS +#define AUTO_OFF_MILLIS 20000 // 20 seconds; 0 keeps the screen on +#endif + +#ifdef DISPLAY_TOUCH_TOGGLE +#define TOUCH_POLL_MILLIS 50 +#endif + +// Wrap-safe deadline test. `millis() >= deadline` fires early for the whole +// interval before a rollover, because the deadline has already wrapped to a +// small value while millis() is still near UINT32_MAX; the signed difference +// stays correct across it. +static inline bool millisReached(unsigned long now, unsigned long deadline) { + return (int32_t)((uint32_t)now - (uint32_t)deadline) >= 0; +} + +// Applies `display.flip` when it changes, forcing a complete repaint because +// the panel's existing contents are now the wrong way up. +void UITask::applyDisplayFlip() { +#ifdef WITH_MQTT_BRIDGE + if (_observer_prefs == NULL || _observer_prefs->display_flip == _flip_seen) return; + _flip_seen = _observer_prefs->display_flip; + _display->setFlipped(_flip_seen != 0); + // Logged unconditionally: this is persisted config, so it survives a reflash + // and is otherwise invisible when someone is chasing a wrong orientation. + Serial.printf("Display: flip %s\n", _flip_seen ? "on (rotated 180)" : "off"); +#ifdef DISPLAY_REDRAW_ON_CHANGE + _frame_valid = false; +#endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD + _rows_valid = false; +#endif + _next_refresh = 0; +#endif +} + +// `display.timeout` when the observer prefs are available, otherwise the +// compiled-in default. Read on every use so a `set display.timeout` takes +// effect immediately. +unsigned long UITask::displayTimeoutMillis() const { +#ifdef WITH_MQTT_BRIDGE + if (_observer_prefs) return (unsigned long)_observer_prefs->display_timeout_secs * 1000UL; +#endif + return AUTO_OFF_MILLIS; +} #define BOOT_SCREEN_MILLIS 4000 // 4 seconds +#ifdef DISPLAY_TOUCH_TOGGLE +#define POWEROFF_DELAY 3000 +#endif + // 'meshcore', 128x13px static const uint8_t meshcore_logo [] PROGMEM = { 0x3c, 0x01, 0xe3, 0xff, 0xc7, 0xff, 0x8f, 0x03, 0x87, 0xfe, 0x1f, 0xfe, 0x1f, 0xfe, 0x1f, 0xfe, @@ -33,9 +88,24 @@ static const uint8_t meshcore_logo [] PROGMEM = { void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version) { _prevBtnState = HIGH; - _auto_off = millis() + AUTO_OFF_MILLIS; + _timeout_seen = displayTimeoutMillis(); + _auto_off = millis() + displayTimeoutMillis(); + _started_at = millis(); _node_prefs = node_prefs; +#ifdef DISPLAY_ACTIVITY_DASHBOARD + ObserverDashboard::applyDarkPalette(); // retunes UIColor for this target only +#endif _display->turnOn(); + applyDisplayFlip(); +#ifdef DISPLAY_TOUCH_TOGGLE + _touch.begin(); +#if defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) + user_btn.begin(); +#endif +#endif +#ifdef DISPLAY_REDRAW_ON_CHANGE + _frame_valid = false; +#endif // strip off dash and commit hash by changing dash to null terminator // e.g: v1.2.3-abcdef -> v1.2.3 @@ -52,7 +122,10 @@ void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* fi void UITask::renderCurrScreen() { char tmp[80]; - if (millis() < BOOT_SCREEN_MILLIS) { // boot screen +#ifdef DISPLAY_ACTIVITY_DASHBOARD + _rows_valid = false; +#endif + if ((uint32_t)(millis() - _started_at) < BOOT_SCREEN_MILLIS) { // boot screen // meshcore logo _display->setColor(UIColor::corp_blue); int logoWidth = 128; @@ -77,9 +150,19 @@ void UITask::renderCurrScreen() { uint16_t typeWidth = _display->getTextWidth(node_type); _display->setCursor((_display->width() - typeWidth) / 2, 48); _display->print(node_type); +#ifdef DISPLAY_TOUCH_TOGGLE + } else if (_powering_off_at > 0) { + _display->setColor(UIColor::corp_blue); + _display->drawXbm(0, 3, meshcore_logo, 128, 13); + _display->setTextSize(1); + _display->setColor(UIColor::primary_txt); + _display->drawTextCentered(_display->width() / 2, 48, "Turning OFF"); +#endif } else { // home screen #ifdef WITH_WEBCONFIG if (WebConfigServer::isRebootPending()) { + // save confirmed on-device: show ground truth even if the browser + // lost its connection before the confirmation reached it _display->setTextSize(1); _display->setColor(UIColor::corp_blue); _display->setCursor(0, 14); @@ -91,16 +174,23 @@ void UITask::renderCurrScreen() { } char wc_ssid[33], wc_ip[16]; if (WebConfigServer::getSetupInfo(wc_ssid, sizeof(wc_ssid), wc_ip, sizeof(wc_ip))) { + // setup portal active: show join instructions instead of the home screen _display->setTextSize(1); _display->setColor(UIColor::corp_blue); _display->setCursor(0, 0); +#ifdef DISPLAY_ACTIVITY_DASHBOARD + _display->print("Observer WiFi Setup"); +#else _display->print("WebUI WiFi Setup"); +#endif + _display->setColor(UIColor::primary_txt); _display->setCursor(0, 14); _display->print("Join WiFi:"); _display->setColor(UIColor::warning_txt); _display->setCursor(6, 24); _display->print(wc_ssid); + _display->setColor(UIColor::primary_txt); _display->setCursor(0, 40); _display->print("Then browse to:"); @@ -109,6 +199,10 @@ void UITask::renderCurrScreen() { _display->print(wc_ip); return; } +#endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD + renderDashboard(); + return; #endif // node name _display->setCursor(0, 0); @@ -139,18 +233,168 @@ void UITask::renderCurrScreen() { } } +#ifdef DISPLAY_REDRAW_ON_CHANGE +uint32_t UITask::getFrameSignature() { + uint32_t signature = DisplayFrameSignature::INITIAL; + char tmp[80]; + + if ((uint32_t)(millis() - _started_at) < BOOT_SCREEN_MILLIS) { + signature = DisplayFrameSignature::append(signature, "boot"); + return DisplayFrameSignature::append(signature, _version_info); + } + +#ifdef DISPLAY_TOUCH_TOGGLE + if (_powering_off_at > 0) { + return DisplayFrameSignature::append(signature, "powering-off"); + } +#endif + +#ifdef WITH_WEBCONFIG + if (WebConfigServer::isRebootPending()) { + return DisplayFrameSignature::append(signature, "rebooting"); + } + + char wc_ssid[33], wc_ip[16]; + if (WebConfigServer::getSetupInfo(wc_ssid, sizeof(wc_ssid), wc_ip, sizeof(wc_ip))) { + signature = DisplayFrameSignature::append(signature, "setup"); + signature = DisplayFrameSignature::append(signature, wc_ssid); + return DisplayFrameSignature::append(signature, wc_ip); + } +#endif + + signature = DisplayFrameSignature::append(signature, "home"); + signature = DisplayFrameSignature::append(signature, _node_prefs->node_name); + snprintf(tmp, sizeof(tmp), "FREQ: %06.3f SF%d", _node_prefs->freq, _node_prefs->sf); + signature = DisplayFrameSignature::append(signature, tmp); + snprintf(tmp, sizeof(tmp), "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr); + signature = DisplayFrameSignature::append(signature, tmp); + +#if defined(WITH_MQTT_BRIDGE) && !defined(DISPLAY_ACTIVITY_DASHBOARD) + if (WiFi.status() == WL_CONNECTED) { + IPAddress ip = WiFi.localIP(); + snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]); + signature = DisplayFrameSignature::append(signature, tmp); + } else { + signature = DisplayFrameSignature::append(signature, "wifi-disconnected"); + } +#endif + + return signature; +} +#endif + +#ifdef DISPLAY_ACTIVITY_DASHBOARD +#define ACTIVITY_REFRESH_MILLIS 5000 + +bool UITask::buildDashboardContext(ObserverDashboard::Context* ctx) { + if (_node_prefs == NULL) return false; + ctx->node_name = _node_prefs->node_name; + ctx->role_label = "ROOM SERVER"; + ctx->freq = _node_prefs->freq; + ctx->sf = _node_prefs->sf; + ctx->bw = _node_prefs->bw; +#ifdef WITH_MQTT_BRIDGE + ctx->link_up = (WiFi.status() == WL_CONNECTED); +#else + ctx->link_up = false; +#endif + return true; +} + +void UITask::renderDashboard() { + ObserverDashboard::Context ctx; + if (!buildDashboardContext(&ctx)) return; + + RadioActivitySnapshot snap; + if (_activity) { + _activity->snapshot(millis(), &snap); + } else { + memset(&snap, 0, sizeof(snap)); + } + + const ObserverDashboard::Layout& layout = ObserverDashboard::activeLayout(); + ObserverDashboard::drawFull(*_display, layout, ctx, snap); + ObserverDashboard::allRowSignatures(layout, ctx, snap, _row_signatures); + _rows_valid = true; + _next_activity = millis() + ACTIVITY_REFRESH_MILLIS; +} + +// Repaints just the analytics rows whose contents moved. No startFrame(), so +// the header, the radio strip and the rest of the panel are never cleared. +void UITask::updateActivityRows() { + if (!_rows_valid || _activity == NULL) return; // not showing the dashboard + + ObserverDashboard::Context ctx; + if (!buildDashboardContext(&ctx)) return; + + RadioActivitySnapshot snap; + _activity->snapshot(millis(), &snap); + ObserverDashboard::drawChangedRows(*_display, ObserverDashboard::activeLayout(), ctx, snap, + _row_signatures); +} +#endif + +#ifdef DISPLAY_TOUCH_TOGGLE +void UITask::toggleDisplay(const char* source) { + if (_display->isOn()) { + _display->turnOff(); + } else { + _display->turnOn(); + } +#ifdef DISPLAY_TOUCH_DEBUG + Serial.printf("Display: %s -> %s\n", source, _display->isOn() ? "on" : "off"); +#else + (void)source; +#endif +#ifdef DISPLAY_REDRAW_ON_CHANGE + _frame_valid = false; // wake draws one complete current frame +#endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD + _rows_valid = false; +#endif + _next_refresh = 0; // redraw at once rather than showing the stale frame + _auto_off = millis() + displayTimeoutMillis(); +} +#endif + void UITask::loop() { -#ifdef PIN_USER_BTN - if (millis() >= _next_read) { +#if defined(PIN_USER_BTN) && defined(DISPLAY_TOUCH_TOGGLE) + int ev = user_btn.check(); + if (ev == BUTTON_EVENT_CLICK) { + toggleDisplay("button"); + } else if (ev == BUTTON_EVENT_LONG_PRESS) { + _display->turnOn(); + Serial.println("Powering Off"); + _powering_off_at = millis() + POWEROFF_DELAY; +#ifdef DISPLAY_REDRAW_ON_CHANGE + _frame_valid = false; +#endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD + _rows_valid = false; +#endif + _next_refresh = 0; + } +#elif defined(PIN_USER_BTN) + if (millisReached(millis(), _next_read)) { int btnState = digitalRead(PIN_USER_BTN); if (btnState != _prevBtnState) { if (btnState == USER_BTN_PRESSED) { // pressed? +#ifdef DISPLAY_TOUCH_TOGGLE + toggleDisplay("button"); // same action as tapping the panel +#else if (_display->isOn()) { // TODO: any action ? } else { _display->turnOn(); +#ifdef DISPLAY_REDRAW_ON_CHANGE + _frame_valid = false; +#endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD + _rows_valid = false; +#endif } - _auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer + _auto_off = millis() + displayTimeoutMillis(); // extend auto-off timer +#endif } _prevBtnState = btnState; } @@ -159,22 +403,100 @@ void UITask::loop() { #endif #ifdef WITH_WEBCONFIG - if (WebConfigServer::getSetupInfo(nullptr, 0, nullptr, 0)) { - if (!_display->isOn()) _display->turnOn(); - _auto_off = millis() + AUTO_OFF_MILLIS; + // While the setup portal is up there's no user button to wake the screen + // reliably - keep it on so the join instructions stay visible. + if (WebConfigServer::getSetupInfo(NULL, 0, NULL, 0)) { + if (!_display->isOn()) { + _display->turnOn(); +#ifdef DISPLAY_REDRAW_ON_CHANGE + _frame_valid = false; +#endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD + _rows_valid = false; +#endif + } + _auto_off = millis() + displayTimeoutMillis(); } #endif - if (_display->isOn()) { - if (millis() >= _next_refresh) { - _display->startFrame(); - renderCurrScreen(); - _display->endFrame(); - - _next_refresh = millis() + 1000; // refresh every second - } - if (millis() > _auto_off) { - _display->turnOff(); +#ifdef DISPLAY_TOUCH_TOGGLE + { + unsigned long now = millis(); + if (millisReached(now, _next_touch)) { + _next_touch = now + TOUCH_POLL_MILLIS; + if (_powering_off_at == 0 && _touch.checkTap(now)) toggleDisplay("touch"); } } +#endif + + // Observe timeout changes even while the panel is blanked. In particular, + // `set display.timeout 0` means the display must stay on, so wake it now + // rather than waiting for a button/touch event that may never arrive. + unsigned long timeout = displayTimeoutMillis(); + if (timeout != _timeout_seen) { + _timeout_seen = timeout; + _auto_off = millis() + timeout; + if (timeout == 0 && !_display->isOn()) { + _display->turnOn(); +#ifdef DISPLAY_REDRAW_ON_CHANGE + _frame_valid = false; +#endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD + _rows_valid = false; +#endif + _next_refresh = 0; + } + } + + if (_display->isOn()) { + // Apply a live orientation change before drawing, including the first frame + // after a preference was changed while the panel was blanked. + applyDisplayFlip(); + if (millisReached(millis(), _next_refresh)) { + bool redraw = true; +#ifdef DISPLAY_REDRAW_ON_CHANGE + uint32_t frame_signature = getFrameSignature(); + redraw = !_frame_valid || frame_signature != _last_frame_signature; +#endif + if (redraw) { + _display->startFrame(); + renderCurrScreen(); + _display->endFrame(); +#ifdef DISPLAY_REDRAW_ON_CHANGE + _last_frame_signature = frame_signature; + _frame_valid = true; +#endif + } +#ifdef DISPLAY_ACTIVITY_DASHBOARD + else if (millisReached(millis(), _next_activity)) { + updateActivityRows(); + _next_activity = millis() + ACTIVITY_REFRESH_MILLIS; + } +#endif + + _next_refresh = millis() + 1000; // check for visible changes every second + } + // `_auto_off` is only armed on activity, so a timeout changed at runtime has + // to restart the countdown here - otherwise 0 -> 60 blanks instantly off a + // boot-time deadline, and 60 -> 3600 still blanks at the old 60 s mark. +#ifdef DISPLAY_TOUCH_TOGGLE + if (_powering_off_at == 0 && timeout > 0 && millisReached(millis(), _auto_off)) { +#else + if (timeout > 0 && millisReached(millis(), _auto_off)) { +#endif + _display->turnOff(); +#ifdef DISPLAY_REDRAW_ON_CHANGE + _frame_valid = false; +#endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD + _rows_valid = false; +#endif + } + } + +#ifdef DISPLAY_TOUCH_TOGGLE + if (_powering_off_at > 0 && millisReached(millis(), _powering_off_at)) { + _board->powerOff(); // current board path performs the full radio/GPS shutdown + } +#endif } diff --git a/examples/simple_room_server/UITask.h b/examples/simple_room_server/UITask.h index a27259f11..a5c07bd50 100644 --- a/examples/simple_room_server/UITask.h +++ b/examples/simple_room_server/UITask.h @@ -3,17 +3,87 @@ #include #include +#ifdef DISPLAY_ACTIVITY_DASHBOARD + #ifndef DISPLAY_REDRAW_ON_CHANGE + #error "DISPLAY_ACTIVITY_DASHBOARD needs DISPLAY_REDRAW_ON_CHANGE: without it every frame clears the whole screen" + #endif + #include + #include + +#endif + +#ifdef DISPLAY_TOUCH_TOGGLE + #include +#endif + class UITask { +#ifdef DISPLAY_TOUCH_TOGGLE + mesh::MainBoard* _board; +#endif DisplayDriver* _display; unsigned long _next_read, _next_refresh, _auto_off; int _prevBtnState; NodePrefs* _node_prefs; char _version_info[32]; + unsigned long _started_at = 0; +#ifdef DISPLAY_TOUCH_TOGGLE + unsigned long _powering_off_at = 0; +#endif + +#ifdef DISPLAY_REDRAW_ON_CHANGE + uint32_t _last_frame_signature = 0; + bool _frame_valid = false; + + uint32_t getFrameSignature(); +#endif + +#ifdef DISPLAY_ACTIVITY_DASHBOARD + RadioActivityWindow* _activity = NULL; + unsigned long _next_activity = 0; + uint32_t _row_signatures[ObserverDashboard::ROW_COUNT] = {0}; + bool _rows_valid = false; // true only while the dashboard is the drawn screen + + bool buildDashboardContext(ObserverDashboard::Context* ctx); + void renderDashboard(); + void updateActivityRows(); +#endif + +#ifdef DISPLAY_TOUCH_TOGGLE + CHSC6XTouch _touch; + unsigned long _next_touch = 0; + + void toggleDisplay(const char* source); +#endif + +#ifdef WITH_MQTT_BRIDGE + MQTTPrefs* _observer_prefs = NULL; +#endif + unsigned long _timeout_seen = 0; // to notice a live `display.timeout` change + uint8_t _flip_seen = 0xFF; // 0xFF forces the first apply + void applyDisplayFlip(); + + unsigned long displayTimeoutMillis() const; void renderCurrScreen(); public: +#ifdef DISPLAY_TOUCH_TOGGLE + UITask(mesh::MainBoard& board, DisplayDriver& display) : _board(&board), _display(&display) { + _next_read = _next_refresh = 0; + } +#else UITask(DisplayDriver& display) : _display(&display) { _next_read = _next_refresh = 0; } +#endif void begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version); +#ifdef WITH_MQTT_BRIDGE + // Supplies `display.timeout`, which is read live so a config change applies + // without a reboot. Call before begin(). + void setObserverPrefs(MQTTPrefs* prefs) { _observer_prefs = prefs; } +#endif + +#ifdef DISPLAY_ACTIVITY_DASHBOARD + void setActivityWindow(RadioActivityWindow* activity) { _activity = activity; } +#endif + void loop(); -}; \ No newline at end of file +}; diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index 552e1f567..35049c529 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -18,7 +18,11 @@ #ifdef DISPLAY_CLASS #include "UITask.h" +#ifdef DISPLAY_TOUCH_TOGGLE + static UITask ui_task(board, display); +#else static UITask ui_task(display); +#endif static bool display_ready = false; #endif @@ -122,6 +126,12 @@ void setup() { #ifdef DISPLAY_CLASS if (display_ready) { +#ifdef WITH_MQTT_BRIDGE + ui_task.setObserverPrefs(the_mesh.getObserverPrefs()); +#endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD + ui_task.setActivityWindow(the_mesh.getActivityWindow()); +#endif ui_task.begin(the_mesh.getNodePrefs(), FIRMWARE_BUILD_DATE, FIRMWARE_VERSION); } #endif diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 7244706d8..ec9bc8b57 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #if defined(NRF52_PLATFORM) @@ -72,6 +73,7 @@ static void resetToUf2Bootloader() { #include "MQTTDefaults.h" #include "MQTTPrefsAtomicStore.h" #include "MQTTPrefsCodec.h" +#include "MQTTPrefsJsonImport.h" #include "MQTTPrefsRecovery.h" #endif @@ -1613,7 +1615,10 @@ void CommonCLI::savePrefs(FILESYSTEM* fs, PrefsSaveRouting::Scope scope) { // Observer builds use a verified temp/backup transaction for common prefs. // Radio and bridge changes must never leave a truncated boot-time image. if (plan.common) saveCommonPrefsImageAtomically(fs); - if (plan.observer) saveMQTTPrefs(fs); + if (plan.observer) { + _observer_save_result_known = true; + _observer_save_succeeded = saveMQTTPrefs(fs); + } return; #else // Observer-only saves are a no-op on roles with no observer preference image. @@ -1781,6 +1786,37 @@ static File openMqttPrefsRead(FILESYSTEM* fs, const char* path = "/mqtt_prefs") #endif } +static bool mqttJsonImportPresetValid(const char* preset) { + return findMQTTPreset(preset) != nullptr; +} + +static MQTTPrefsJsonImport::Result loadMqttJsonImport( + FILESYSTEM* fs, const MQTTPrefs& defaults, MQTTPrefs* candidate) { + File file = openMqttPrefsRead(fs, "/mqtt.json"); + if (!file) return MQTTPrefsJsonImport::Result::InvalidSyntax; + if (file.size() > MQTTPrefsJsonImport::kMaximumFileSize) { + file.close(); + return MQTTPrefsJsonImport::Result::TooLarge; + } + const MQTTPrefsJsonImport::Result result = + MQTTPrefsJsonImport::decode( + file, defaults, candidate, mqttJsonImportPresetValid); + file.close(); + return result; +} + +static const char* mqttJsonImportResultName(MQTTPrefsJsonImport::Result result) { + switch (result) { + case MQTTPrefsJsonImport::Result::Loaded: return "loaded"; + case MQTTPrefsJsonImport::Result::LoadedWithRepairs: return "loaded with repairs"; + case MQTTPrefsJsonImport::Result::InvalidSyntax: return "invalid ConfigSerializer syntax"; + case MQTTPrefsJsonImport::Result::InvalidSchema: return "invalid v1 schema"; + case MQTTPrefsJsonImport::Result::UnsupportedVersion: return "unsupported version"; + case MQTTPrefsJsonImport::Result::TooLarge: return "file too large"; + } + return "unknown error"; +} + static MQTTPrefsRecovery::FileState mqttPrefsFileState(FILESYSTEM* fs, const char* path) { if (!fs->exists(path)) return MQTTPrefsRecovery::FileState::Missing; File file = openMqttPrefsRead(fs, path); @@ -2127,6 +2163,72 @@ void CommonCLI::loadMQTTPrefs( // A failed recovery leaves the artifacts untouched and blocks this boot from // replacing them with defaults through a later CLI save. _mqtt_prefs_hold = recoverMqttPrefsFiles(fs); + + // `/mqtt.json` was the authoritative observer-firmware-dev format. Import it + // exactly once, but only when binary recovery found no primary or recovery + // artifact at all. A valid, default-valued, corrupt, unreadable, or future + // binary image always wins because neither format carries a generation that + // could prove the JSON is newer. + const MQTTPrefsJsonImport::Decision json_import = + MQTTPrefsJsonImport::select( + _mqtt_prefs_hold, + fs->exists("/mqtt_prefs"), fs->exists("/mqtt_prefs.tmp"), + fs->exists("/mqtt_prefs.bak"), fs->exists("/mqtt.json"), + fs->exists("/mqtt.json.tmp"), fs->exists("/mqtt.json.bak")); + if (json_import == MQTTPrefsJsonImport::Decision::HoldJsonArtifacts) { + _mqtt_prefs_hold = true; + _legacy_tail.valid = false; + MESH_DEBUG_PRINTLN( + "MQTT: unresolved /mqtt.json transaction files; defaults active and all sources preserved"); + return; + } + if (json_import == MQTTPrefsJsonImport::Decision::ImportPrimary) { + MQTTPrefs* candidate = new (std::nothrow) MQTTPrefs; + if (candidate == nullptr) { + _mqtt_prefs_hold = true; + _legacy_tail.valid = false; + MESH_DEBUG_PRINTLN( + "MQTT: no memory to import /mqtt.json; defaults active and source preserved"); + return; + } + setMQTTPrefsDefaults(candidate); + const MQTTPrefsJsonImport::Result result = + loadMqttJsonImport(fs, _mqtt_prefs, candidate); + if (!MQTTPrefsJsonImport::loaded(result)) { + delete candidate; + _mqtt_prefs_hold = true; + _legacy_tail.valid = false; + MESH_DEBUG_PRINTLN( + "MQTT: /mqtt.json import refused (%s); defaults active and source preserved", + mqttJsonImportResultName(result)); + return; + } + + _mqtt_prefs = *candidate; + delete candidate; + legacy_upgrade->requireMqttRewrite(); + if (saveMQTTPrefs(fs)) { + legacy_upgrade->recordMqttSave(true); + // JSON contains the full observer group. Returning prevents a stale + // /com_prefs observer tail from being overlaid after the committed import. + _legacy_tail.valid = false; + MESH_DEBUG_PRINTLN( + "MQTT: imported /mqtt.json v1 into atomic /mqtt_prefs (%s); JSON preserved", + mqttJsonImportResultName(result)); + return; + } + + // Do not expose a partially imported runtime state after a failed publish. + // The JSON remains untouched, and binary recovery owns any completed temp. + setMQTTPrefsDefaults(&_mqtt_prefs); + _mqtt_prefs_hold = true; + _legacy_tail.valid = false; + legacy_upgrade->recordMqttSave(false); + MESH_DEBUG_PRINTLN( + "MQTT: /mqtt.json decoded but binary commit failed; defaults active and sources preserved"); + return; + } + bool has_observer_fields = false; bool mqtt_rewrite_pending = false; bool migrated_legacy_mqtt = false; @@ -2290,6 +2392,15 @@ void CommonCLI::loadMQTTPrefs( MESH_DEBUG_PRINTLN("MQTT: invalid neighbors interval reset to %u hours", (unsigned)MQTT_NEIGHBORS_DEFAULT_INTERVAL_HOURS); } + if (MQTTPrefsCodec::repairDisplayPrefs(&_mqtt_prefs)) { + // Old payloads retain valid defaults because the object was defaulted before + // their shorter prefix was copied. This branch repairs only a full/newer + // payload carrying invalid display values. + if (!_mqtt_prefs_hold) { + mqtt_rewrite_pending = true; + } + MESH_DEBUG_PRINTLN("MQTT: invalid display preferences reset to defaults"); + } _legacy_tail.valid = false; if (mqtt_rewrite_pending) { @@ -2355,8 +2466,15 @@ void CommonCLI::savePrefs(PrefsSaveRouting::Scope scope) { _prefs->clearDirty(); } -void CommonCLI::saveObserverPrefs() { +bool CommonCLI::saveObserverPrefs() { +#ifdef WITH_MQTT_BRIDGE + _observer_save_result_known = false; + _observer_save_succeeded = false; _callbacks->savePrefs(PrefsSaveRouting::Scope::Observer); + return _observer_save_result_known && _observer_save_succeeded; +#else + return false; +#endif } uint8_t CommonCLI::buildAdvertData(uint8_t node_type, uint8_t* app_data) { diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 51626f7b6..c60532740 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -725,13 +725,18 @@ class CommonCLI { // /mqtt_prefs is newer, corrupt, or temporarily unreadable. The in-memory prefs // run on defaults and saveMQTTPrefs() must not overwrite the source file. bool _mqtt_prefs_hold = false; + // CommonCLICallbacks::savePrefs() has a stable void ABI. Observer setters use + // these synchronous result latches to learn whether the callback's nested + // CommonCLI::savePrefs(fs, Observer) transaction actually committed. + bool _observer_save_result_known = false; + bool _observer_save_succeeded = false; #endif bool _com_prefs_needs_upgrade = false; // old-format legacy prefs detected; rewrite once after load mesh::RTCClock* getRTCClock() { return _rtc; } void savePrefs( PrefsSaveRouting::Scope scope = PrefsSaveRouting::Scope::Common); - void saveObserverPrefs(); + bool saveObserverPrefs(); void loadPrefsInt(FILESYSTEM* _fs, const char* filename); #ifdef WITH_MQTT_BRIDGE bool recoverCommonPrefsFiles(FILESYSTEM* fs); diff --git a/src/helpers/CommonCLI_Observer.cpp b/src/helpers/CommonCLI_Observer.cpp index 0462cd4bc..9d03d2c3f 100644 --- a/src/helpers/CommonCLI_Observer.cpp +++ b/src/helpers/CommonCLI_Observer.cpp @@ -218,6 +218,44 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf } } } + } else if (strncmp(config, "display.timeout ", 16) == 0) { + uint16_t seconds = 0; + if (!observerParseBoundedUint16(config + 16, DISPLAY_TIMEOUT_MAX_SECS, + &seconds)) { + sprintf(reply, "Error: display.timeout must be 0-%u seconds", + (unsigned)DISPLAY_TIMEOUT_MAX_SECS); + } else { + const uint16_t previous = _mqtt_prefs.display_timeout_secs; + _mqtt_prefs.display_timeout_secs = seconds; + if (!saveObserverPrefs()) { + _mqtt_prefs.display_timeout_secs = previous; + strcpy(reply, "Error: display.timeout save failed"); + } else if (seconds == 0) { + strcpy(reply, "OK - display stays on"); + } else { + sprintf(reply, "OK - display off after %u s", (unsigned)seconds); + } + } + } else if (strcmp(config, "display.timeout") == 0) { + strcpy(reply, "Error: missing display.timeout seconds"); + } else if (strncmp(config, "display.flip ", 13) == 0) { + uint8_t flipped = 0; + if (!observerParseDisplayFlip(config + 13, &flipped)) { + strcpy(reply, "Error: display.flip must be 0/1 (or off/on)"); + } else { + const uint8_t previous = _mqtt_prefs.display_flip; + _mqtt_prefs.display_flip = flipped; + if (!saveObserverPrefs()) { + _mqtt_prefs.display_flip = previous; + strcpy(reply, "Error: display.flip save failed"); + } else { + strcpy(reply, flipped + ? "OK - display.flip on (applies on supported display)" + : "OK - display.flip off (applies on supported display)"); + } + } + } else if (strcmp(config, "display.flip") == 0) { + strcpy(reply, "Error: missing display.flip state"); #ifdef WITH_MQTT_BRIDGE } else if (strcmp(config, "mqtt.origin") == 0) { _mqtt_prefs.mqtt_origin[0] = '\0'; @@ -837,6 +875,10 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf strcpy(reply, _mqtt_prefs.snmp_enabled ? "> on" : "> off"); } else if (memcmp(config, "radio.watchdog", 14) == 0) { sprintf(reply, "> %d", (uint32_t)_mqtt_prefs.radio_watchdog_minutes); + } else if (strcmp(config, "display.timeout") == 0) { + sprintf(reply, "> %u", (unsigned)_mqtt_prefs.display_timeout_secs); + } else if (strcmp(config, "display.flip") == 0) { + strcpy(reply, _mqtt_prefs.display_flip ? "> on" : "> off"); #ifdef WITH_MQTT_BRIDGE } else if (memcmp(config, "mqtt.origin", 11) == 0) { char effective_origin[32]; diff --git a/src/helpers/CompanionHardwareCommandCompat.h b/src/helpers/CompanionHardwareCommandCompat.h new file mode 100644 index 000000000..f063a0cf8 --- /dev/null +++ b/src/helpers/CompanionHardwareCommandCompat.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include + +namespace mesh { +namespace companion { + +static constexpr uint8_t kRunCliCommand = 0x42; + +// Original aliases predate upstream's command-66 allocation. The later block +// was shipped briefly before this fork standardized new clients on framed CLI. +static constexpr uint8_t kOriginalGetFemRxGain = 0x42; +static constexpr uint8_t kOriginalSetFemRxGain = 0x43; +static constexpr uint8_t kOriginalGetRadioRxGain = 0x44; +static constexpr uint8_t kOriginalSetRadioRxGain = 0x45; +static constexpr uint8_t kOriginalGetWiFiPowerSave = 0x46; +static constexpr uint8_t kOriginalSetWiFiPowerSave = 0x47; +static constexpr uint8_t kOriginalGetBluetoothName = 0x48; +static constexpr uint8_t kOriginalSetBluetoothName = 0x49; + +static constexpr uint8_t kDeprecatedGetFemRxGain = 0x78; +static constexpr uint8_t kDeprecatedSetFemRxGain = 0x79; +static constexpr uint8_t kDeprecatedGetRadioRxGain = 0x7A; +static constexpr uint8_t kDeprecatedSetRadioRxGain = 0x7B; +static constexpr uint8_t kDeprecatedGetWiFiPowerSave = 0x7C; +static constexpr uint8_t kDeprecatedSetWiFiPowerSave = 0x7D; +static constexpr uint8_t kDeprecatedGetBluetoothName = 0x7E; +static constexpr uint8_t kDeprecatedSetBluetoothName = 0x7F; + +inline bool isRunCliFrame(uint8_t command, size_t frame_len) { + return command == kRunCliCommand && frame_len >= 2; +} + +inline bool isFemRxGainGet(uint8_t command) { + return command == kOriginalGetFemRxGain || command == kDeprecatedGetFemRxGain; +} +inline bool isFemRxGainSet(uint8_t command) { + return command == kOriginalSetFemRxGain || command == kDeprecatedSetFemRxGain; +} +inline bool isRadioRxGainGet(uint8_t command) { + return command == kOriginalGetRadioRxGain || command == kDeprecatedGetRadioRxGain; +} +inline bool isRadioRxGainSet(uint8_t command) { + return command == kOriginalSetRadioRxGain || command == kDeprecatedSetRadioRxGain; +} +inline bool isWiFiPowerSaveGet(uint8_t command) { + return command == kOriginalGetWiFiPowerSave || command == kDeprecatedGetWiFiPowerSave; +} +inline bool isWiFiPowerSaveSet(uint8_t command) { + return command == kOriginalSetWiFiPowerSave || command == kDeprecatedSetWiFiPowerSave; +} +inline bool isBluetoothNameGet(uint8_t command) { + return command == kOriginalGetBluetoothName || command == kDeprecatedGetBluetoothName; +} +inline bool isBluetoothNameSet(uint8_t command) { + return command == kOriginalSetBluetoothName || command == kDeprecatedSetBluetoothName; +} + +} // namespace companion +} // namespace mesh diff --git a/src/helpers/CompanionMqttPrefsNvs.h b/src/helpers/CompanionMqttPrefsNvs.h new file mode 100644 index 000000000..ad20b27fc --- /dev/null +++ b/src/helpers/CompanionMqttPrefsNvs.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include + +#include "MQTTPrefsStorage.h" + +#ifdef WITH_MQTT_BRIDGE + +// The ESP32 Companion setup portal stores a raw MQTTPrefs blob in NVS rather +// than the observer's versioned /mqtt_prefs file. Display preferences are not +// used by Companion firmware, so keep writing the last pre-display boundary: +// older firmware can then read a record saved by a newer image after rollback. +namespace CompanionMqttPrefsNvs { + +static constexpr size_t kWriteSize = MQTT_PREFS_V1_PRE_DISPLAY_PAYLOAD_SIZE; +// A short-lived development image wrote the compiler-padded runtime struct. +// Freeze that exact deployed shape: using sizeof(MQTTPrefs) here would stop +// recognizing its records the next time an append-only field grows the struct. +static constexpr size_t kRecoverySize = 2880; +static_assert(sizeof(MQTTPrefs) >= kRecoverySize, + "MQTTPrefs no longer contains the recoverable NVS prefix"); + +inline bool accepts(uint16_t version, size_t length) { + return version == MQTT_PREFS_VERSION + && (length == kWriteSize || length == kRecoverySize); +} + +} // namespace CompanionMqttPrefsNvs + +#endif diff --git a/src/helpers/CompanionMqttSetupPortal.cpp b/src/helpers/CompanionMqttSetupPortal.cpp index b030353b3..65a53eb62 100644 --- a/src/helpers/CompanionMqttSetupPortal.cpp +++ b/src/helpers/CompanionMqttSetupPortal.cpp @@ -1,4 +1,5 @@ #include "CompanionMqttSetupPortal.h" +#include "CompanionMqttPrefsNvs.h" #if defined(ESP32_PLATFORM) && defined(WIFI_SSID) && defined(WITH_MQTT_BRIDGE) @@ -582,52 +583,67 @@ bool CompanionMqttSetupPortal::loadStoredConfig(MQTTPrefs& prefs) { if (!nvs.begin(NVS_NAMESPACE, true)) return false; const uint16_t version = nvs.getUShort(NVS_VERSION_KEY, 0); const size_t length = nvs.getBytesLength(NVS_PREFS_KEY); - const bool shape_ok = version == MQTT_PREFS_VERSION && length == sizeof(MQTTPrefs); - const size_t read = shape_ok ? nvs.getBytes(NVS_PREFS_KEY, &prefs, sizeof(prefs)) : 0; + // Companion stores this struct directly rather than using the observer's + // versioned file header. Accept the previously shipped pre-display size and + // default its append-only display tail so adding observer UI preferences + // cannot discard an existing Companion MQTT setup after an update. + const bool shape_ok = CompanionMqttPrefsNvs::accepts(version, length); + MQTTPrefs loaded; + applyMQTTDefaults(&loaded); + const size_t read = shape_ok ? nvs.getBytes(NVS_PREFS_KEY, &loaded, length) : 0; nvs.end(); - if (read != sizeof(prefs)) return false; + if (!shape_ok || read != length) return false; - if (!storedStringsAreValid(prefs) - || prefs.mqtt_status_enabled > 1 - || prefs.mqtt_packets_enabled > 1 - || prefs.mqtt_raw_enabled > 1 - || prefs.mqtt_rx_enabled > 1 - || prefs.mqtt_tx_enabled > 2 - || prefs.wifi_power_save > 2) { + if (!storedStringsAreValid(loaded) + || loaded.mqtt_status_enabled > 1 + || loaded.mqtt_packets_enabled > 1 + || loaded.mqtt_raw_enabled > 1 + || loaded.mqtt_rx_enabled > 1 + || loaded.mqtt_tx_enabled > 2 + || loaded.wifi_power_save > 2 + || loaded.display_timeout_secs > DISPLAY_TIMEOUT_MAX_SECS + || loaded.display_flip > 1) { return false; } - const char* preset = prefs.mqtt_slot_preset[0]; + const char* preset = loaded.mqtt_slot_preset[0]; const bool custom = strcmp(preset, MQTT_PRESET_CUSTOM) == 0; const MQTTPresetDef* preset_def = custom ? nullptr : findMQTTPreset(preset); if (!preset[0] || (!custom && !preset_def)) return false; if (custom) { - const char* host = prefs.mqtt_slot_host[0]; + const char* host = loaded.mqtt_slot_host[0]; if (!host[0] - || (prefs.mqtt_slot_port[0] == 0 && strstr(host, "://") == nullptr) - || (!prefs.mqtt_slot_topic[0][0] && !isThreeLetterCode(prefs.mqtt_iata))) { + || (loaded.mqtt_slot_port[0] == 0 && strstr(host, "://") == nullptr) + || (!loaded.mqtt_slot_topic[0][0] && !isThreeLetterCode(loaded.mqtt_iata))) { return false; } } else { if (preset_def->topic_style == MQTT_TOPIC_MESHCORE - && !isThreeLetterCode(prefs.mqtt_iata)) return false; + && !isThreeLetterCode(loaded.mqtt_iata)) return false; if (preset_def->topic_style == MQTT_TOPIC_MESHRANK - && !prefs.mqtt_slot_token[0][0]) return false; + && !loaded.mqtt_slot_token[0][0]) return false; if (mqttPresetNeedsSlotCredentials(preset_def) - && (!prefs.mqtt_slot_username[0][0] || !prefs.mqtt_slot_password[0][0])) { + && (!loaded.mqtt_slot_username[0][0] || !loaded.mqtt_slot_password[0][0])) { return false; } } + prefs = loaded; return true; } bool CompanionMqttSetupPortal::saveStoredConfig(const MQTTPrefs& prefs) { Preferences nvs; if (!nvs.begin(NVS_NAMESPACE, false)) return false; - const size_t written = nvs.putBytes(NVS_PREFS_KEY, &prefs, sizeof(prefs)); + // Companion does not consume the appended observer display tail. Keeping + // this record at the pre-display boundary makes a firmware rollback retain + // the MQTT setup. loadStoredConfig() still accepts the briefly written 2880 + // byte runtime shape so development images remain recoverable. + const size_t written = nvs.putBytes( + NVS_PREFS_KEY, &prefs, CompanionMqttPrefsNvs::kWriteSize); const size_t version_written = nvs.putUShort(NVS_VERSION_KEY, MQTT_PREFS_VERSION); nvs.end(); - return written == sizeof(prefs) && version_written == sizeof(uint16_t); + return written == CompanionMqttPrefsNvs::kWriteSize + && version_written == sizeof(uint16_t); } #endif diff --git a/src/helpers/ConfigSerializer.cpp b/src/helpers/ConfigSerializer.cpp index 4f7130ca5..8986c17ed 100644 --- a/src/helpers/ConfigSerializer.cpp +++ b/src/helpers/ConfigSerializer.cpp @@ -22,9 +22,12 @@ bool ConfigSerializer::saveSerial(Stream& s) { static bool is_whitespace(char c) { return c == ' ' || c == '\t' || c == '\r' || c == '\n'; } -static bool is_key_char(char c) { +static bool is_key_start_char(char c) { return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '_'; } +static bool is_key_char(char c) { + return is_key_start_char(c) || (c >= '0' && c <= '9'); +} static bool is_value_char(char c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || c == '-' || c == '.'; } @@ -58,13 +61,12 @@ static double parse_fixed_decimal(const char* text) { } #define EXPECT_OPEN_BRACE 0 -#define EXPECT_KEY 1 -#define EXPECT_VAL_OR_OBJ 2 -#define EXPECT_STRING_VAL 3 -#define EXPECT_STRING_ESCAPE 4 -#define EXPECT_COMMA_OR_CLOSE 5 -#define EXPECT_COMMA_OR_KEY 6 -#define EXPECT_COMMA_OR_KEY_OR_CLOSE 7 +#define EXPECT_KEY_OR_CLOSE 1 +#define EXPECT_KEY 2 +#define EXPECT_VAL_OR_OBJ 3 +#define EXPECT_STRING_VAL 4 +#define EXPECT_STRING_ESCAPE 5 +#define EXPECT_COMMA_OR_CLOSE 6 int ConfigSerializer::Context::readNext() { char c; @@ -79,33 +81,50 @@ int ConfigSerializer::Context::readNext() { c = (char)n; } + if (rd_root_complete) { + return is_whitespace(c) ? TOK_WHITESPACE : TOK_ERROR; + } + switch (rd_mode) { case EXPECT_OPEN_BRACE: - if (c == '{') { rd_mode = EXPECT_KEY; return TOK_START_OBJ; } + if (c == '{') { rd_mode = EXPECT_KEY_OR_CLOSE; return TOK_START_OBJ; } if (is_whitespace(c)) return TOK_WHITESPACE; return TOK_ERROR; - case EXPECT_COMMA_OR_KEY_OR_CLOSE: - if (c == '}') { rd_mode = EXPECT_COMMA_OR_KEY_OR_CLOSE; return TOK_END_OBJ; } - case EXPECT_COMMA_OR_KEY: - if (c == ',') { rd_mode = EXPECT_KEY; return TOK_WHITESPACE; } - case EXPECT_KEY: + case EXPECT_KEY_OR_CLOSE: // Empty objects are valid and are emitted by serializers such as the // dynamic `custom` preferences object before it has any entries. if (rd_len == 0 && c == '}') { - rd_mode = EXPECT_COMMA_OR_KEY_OR_CLOSE; + rd_mode = EXPECT_COMMA_OR_CLOSE; return TOK_END_OBJ; } + // A non-empty object follows the same key grammar, but only this state + // (entered directly after '{') permits an immediate closing brace. + // fall through + case EXPECT_KEY: if (rd_len > 0 && c == ':') { rd_buf[rd_len] = 0; rd_len = 0; rd_mode = EXPECT_VAL_OR_OBJ; return TOK_KEY; } if (rd_len == 0 && is_whitespace(c)) return TOK_WHITESPACE; - if (rd_len < CONFIG_MAX_KEYLEN-1 && is_key_char(c)) { rd_buf[rd_len++] = c; return TOK_WHITESPACE; } + if (rd_len < CONFIG_MAX_KEYLEN-1 && + ((rd_len == 0 && is_key_start_char(c)) || + (rd_len > 0 && is_key_char(c)))) { + rd_buf[rd_len++] = c; + return TOK_WHITESPACE; + } return TOK_ERROR; case EXPECT_VAL_OR_OBJ: if (rd_len == 0 && is_whitespace(c)) return TOK_WHITESPACE; - if (rd_len == 0 && c == '"') { rd_mode = EXPECT_STRING_VAL; return TOK_WHITESPACE; } - if (rd_len == 0 && c == '{') { rd_mode = EXPECT_KEY; return TOK_START_OBJ; } - if (is_value_char(c) && rd_len < CONFIG_MAX_TOKEN_LEN-1) { rd_buf[rd_len++] = c; return TOK_WHITESPACE; } + if (rd_len == 0 && c == '"') { + rd_token_quoted = true; + rd_mode = EXPECT_STRING_VAL; + return TOK_WHITESPACE; + } + if (rd_len == 0 && c == '{') { rd_mode = EXPECT_KEY_OR_CLOSE; return TOK_START_OBJ; } + if (is_value_char(c) && rd_len < CONFIG_MAX_TOKEN_LEN-1) { + if (rd_len == 0) rd_token_quoted = false; + rd_buf[rd_len++] = c; + return TOK_WHITESPACE; + } if (rd_len > 0 && (c == ',' || c == '}' || is_whitespace(c))) { pending = c; rd_buf[rd_len] = 0; rd_len = 0; rd_mode = EXPECT_COMMA_OR_CLOSE; return TOK_VALUE; } return TOK_ERROR; @@ -118,12 +137,13 @@ int ConfigSerializer::Context::readNext() { case EXPECT_STRING_VAL: if (c == '"') { rd_buf[rd_len] = 0; rd_len = 0; rd_mode = EXPECT_COMMA_OR_CLOSE; return TOK_VALUE; } if (c == '\\') { rd_mode = EXPECT_STRING_ESCAPE; return TOK_WHITESPACE; } + if (c == '\0') return TOK_ERROR; // never silently truncate a persisted string if (rd_len < CONFIG_MAX_TOKEN_LEN-1) { rd_buf[rd_len++] = c; return TOK_WHITESPACE; } return TOK_ERROR; case EXPECT_COMMA_OR_CLOSE: if (c == ',') { rd_mode = EXPECT_KEY; return TOK_WHITESPACE; } - if (c == '}') { rd_mode = EXPECT_COMMA_OR_KEY_OR_CLOSE; return TOK_END_OBJ; } + if (c == '}') { rd_mode = EXPECT_COMMA_OR_CLOSE; return TOK_END_OBJ; } if (is_whitespace(c)) return TOK_WHITESPACE; return TOK_ERROR; } @@ -141,9 +161,19 @@ bool ConfigSerializer::loadSerial(Stream& s) { if (next_tok == TOK_KEY) { context.setKey(sp, context.getToken()); } else if (next_tok == TOK_VALUE) { + context.setValueEvent(sp, false); _depth = 1; // re-run the structure() hierarchy again (looking for specific key, at specific depth) structure(); } else if (next_tok == TOK_START_OBJ) { + // The root has no key. For every nested object, rerun the schema at the + // parent depth so known scalar fields can reject an object value and + // known object fields can confirm their expected shape. + if (sp > 0) { + context.setValueEvent(sp, true); + _depth = 1; + structure(); + if (!context.success) break; + } if (sp < CONFIG_MAX_DEPTH - 1) { sp++; } else { @@ -154,6 +184,7 @@ bool ConfigSerializer::loadSerial(Stream& s) { } else if (next_tok == TOK_END_OBJ) { if (sp > 0) { sp--; + if (sp == 0) context.markRootComplete(); } else { //Serial.printf("Error: too many closing '}'"); // TODO: debug logging context.success = false; @@ -161,8 +192,8 @@ bool ConfigSerializer::loadSerial(Stream& s) { } } } - if (sp != 0 || next_tok == TOK_ERROR) { - context.success = false; // unmatched { }, or other parse error + if (sp != 0 || !context.rootComplete() || next_tok == TOK_ERROR) { + context.success = false; // missing/unmatched root object, or other parse error } _context = NULL; return context.success; @@ -357,9 +388,115 @@ void ConfigSerializer::def(const char* key, ConfigSerializer& sub_obj) { if (_context->file()->print("}") != 1) _context->success = false; // failure detect } else { if (_context->keyMatch(_depth, key)) { + if (_context->objectStart() && _context->valueDepth() == _depth) { + return; // the object itself; descendant values recurse below + } + if (_context->valueDepth() <= _depth) { + _context->success = false; // known object supplied as a scalar + return; + } sub_obj._context = _context; // inherit the Context sub_obj._depth = _depth + 1; sub_obj.structure(); // recurse into sub object } } } + +bool ConfigSerializer::defStrict(const char* key, char* value, size_t max_len, + bool& seen) { + if (_context->op() == OP::WRITE) { + def(key, value, max_len); + return true; + } + if (!_context->keyMatch(_depth, key)) return false; + if (seen || max_len == 0 || _context->objectStart() || + _context->valueDepth() != _depth || !_context->tokenQuoted()) { + _context->success = false; + return false; + } + seen = true; + const char* token = _context->getToken(); + const size_t len = strlen(token); + if (len >= max_len) { + _context->success = false; + return false; + } + memcpy(value, token, len + 1); + return true; +} + +bool ConfigSerializer::defStrict(const char* key, int32_t& value, bool& seen) { + if (_context->op() == OP::WRITE) { + def(key, value); + return true; + } + if (!_context->keyMatch(_depth, key)) return false; + if (seen || _context->objectStart() || _context->valueDepth() != _depth || + _context->tokenQuoted()) { + _context->success = false; + return false; + } + seen = true; + + const char* token = _context->getToken(); + bool negative = false; + if (*token == '-') { + negative = true; + ++token; + } + if (*token < '0' || *token > '9') { + _context->success = false; + return false; + } + + // Accumulate the magnitude as unsigned so INT32_MIN remains representable. + const uint32_t limit = negative ? 2147483648UL : 2147483647UL; + uint32_t magnitude = 0; + do { + const uint32_t digit = static_cast(*token++ - '0'); + if (magnitude > (limit - digit) / 10U) { + _context->success = false; + return false; + } + magnitude = magnitude * 10U + digit; + } while (*token >= '0' && *token <= '9'); + + if (*token != '\0') { + _context->success = false; + return false; + } + if (negative && magnitude == 2147483648UL) { + value = INT32_MIN; + } else { + value = negative ? -static_cast(magnitude) + : static_cast(magnitude); + } + return true; +} + +bool ConfigSerializer::defStrict(const char* key, ConfigSerializer& sub_obj, + bool& seen) { + if (_context->op() == OP::WRITE) { + def(key, sub_obj); + return true; + } + if (!_context->keyMatch(_depth, key)) return false; + + if (_context->objectStart() && _context->valueDepth() == _depth) { + if (seen) { + _context->success = false; + return false; + } + seen = true; + return true; + } + if (!seen || _context->valueDepth() <= _depth) { + _context->success = false; + return false; + } + + sub_obj._context = _context; + sub_obj._depth = _depth + 1; + sub_obj.structure(); + return true; +} diff --git a/src/helpers/ConfigSerializer.h b/src/helpers/ConfigSerializer.h index e55b1b120..6e7bf7142 100644 --- a/src/helpers/ConfigSerializer.h +++ b/src/helpers/ConfigSerializer.h @@ -27,17 +27,39 @@ protected: OP _op; uint8_t rd_len; uint8_t rd_mode; + uint8_t rd_value_depth; char pending; + bool rd_token_quoted; + bool rd_object_start; + bool rd_root_complete; char rd_buf[CONFIG_MAX_TOKEN_LEN]; char _keys[CONFIG_MAX_DEPTH][CONFIG_MAX_KEYLEN]; public: bool success = true; - Context(Stream* f, OP op) : _f(f), _op(op) { rd_buf[rd_len = 0] = 0; rd_mode = 0; pending = 0; } + Context(Stream* f, OP op) : _f(f), _op(op) { + rd_buf[rd_len = 0] = 0; + rd_mode = 0; + rd_value_depth = 0; + pending = 0; + rd_token_quoted = false; + rd_object_start = false; + rd_root_complete = false; + memset(_keys, 0, sizeof(_keys)); + } OP op() const { return _op; } Stream* file() const { return _f; } int readNext(); const char* getToken() const { return rd_buf; } + bool tokenQuoted() const { return rd_token_quoted; } + uint8_t valueDepth() const { return rd_value_depth; } + bool objectStart() const { return rd_object_start; } + bool rootComplete() const { return rd_root_complete; } + void markRootComplete() { rd_root_complete = true; } + void setValueEvent(uint8_t depth, bool object_start) { + rd_value_depth = depth; + rd_object_start = object_start; + } bool keyMatch(int8_t depth, const char* key) { return strcmp(key, _keys[depth]) == 0; } void setKey(uint8_t depth, const char* key) { strcpy(_keys[depth], key); } const char* getKey(uint8_t depth) { return _keys[depth]; } @@ -63,6 +85,71 @@ protected: void def(const char* key, bool& value); void def(const char* key, ConfigSerializer& sub_obj); + // Strict read helpers for externally editable schemas. Unlike the legacy + // overloads, these reject duplicate keys, truncated strings, malformed + // integers, integer overflow, and scalar/object type mismatches. + bool defStrict(const char* key, char* value, size_t max_len, bool& seen); + bool defStrict(const char* key, int32_t& value, bool& seen); + bool defStrict(const char* key, ConfigSerializer& sub_obj, bool& seen); + + // Keep literal schema keys inside the tokenizer's visible-key limit. Dynamic + // serializers can continue using the pointer overloads above. + template static void checkKey(const char (&)[N]) { + static_assert(N <= CONFIG_MAX_KEYLEN, + "ConfigSerializer key exceeds the visible-key limit"); + } + template void def(const char (&key)[N], char* value, size_t max_len) { + checkKey(key); def(static_cast(key), value, max_len); + } + template void def(const char (&key)[N], void* value, size_t len) { + checkKey(key); def(static_cast(key), value, len); + } + template void def(const char (&key)[N], int32_t& value) { + checkKey(key); def(static_cast(key), value); + } + template void def(const char (&key)[N], int16_t& value) { + checkKey(key); def(static_cast(key), value); + } + template void def(const char (&key)[N], int8_t& value) { + checkKey(key); def(static_cast(key), value); + } + template void def(const char (&key)[N], uint32_t& value) { + checkKey(key); def(static_cast(key), value); + } + template void def(const char (&key)[N], uint16_t& value) { + checkKey(key); def(static_cast(key), value); + } + template void def(const char (&key)[N], uint8_t& value) { + checkKey(key); def(static_cast(key), value); + } + template void def(const char (&key)[N], float& value) { + checkKey(key); def(static_cast(key), value); + } + template void def(const char (&key)[N], double& value) { + checkKey(key); def(static_cast(key), value); + } + template void def(const char (&key)[N], bool& value) { + checkKey(key); def(static_cast(key), value); + } + template void def(const char (&key)[N], ConfigSerializer& sub_obj) { + checkKey(key); def(static_cast(key), sub_obj); + } + template + bool defStrict(const char (&key)[N], char* value, size_t max_len, bool& seen) { + checkKey(key); + return defStrict(static_cast(key), value, max_len, seen); + } + template + bool defStrict(const char (&key)[N], int32_t& value, bool& seen) { + checkKey(key); + return defStrict(static_cast(key), value, seen); + } + template + bool defStrict(const char (&key)[N], ConfigSerializer& sub_obj, bool& seen) { + checkKey(key); + return defStrict(static_cast(key), sub_obj, seen); + } + virtual void structure() = 0; void markDirty() { _dirty = true; } diff --git a/src/helpers/MQTTDefaults.h b/src/helpers/MQTTDefaults.h index e770a3b51..b2f1c809e 100644 --- a/src/helpers/MQTTDefaults.h +++ b/src/helpers/MQTTDefaults.h @@ -111,6 +111,11 @@ static inline void applyMQTTDefaults(MQTTPrefs* prefs) { // (not 0) so an in-lineage upgrade from a pre-neighbors payload is sane. prefs->mqtt_neighbors_enabled = 0; prefs->mqtt_neighbors_interval = MQTT_NEIGHBORS_DEFAULT_INTERVAL_MS; + + // Runtime observer display defaults. These remain implicit in compact + // pre-display payloads and are serialized only after either value changes. + prefs->display_timeout_secs = DISPLAY_TIMEOUT_DEFAULT_SECS; + prefs->display_flip = 0; } #endif // WITH_MQTT_BRIDGE diff --git a/src/helpers/MQTTObserverValidation.h b/src/helpers/MQTTObserverValidation.h index 2042f2267..7039d5b36 100644 --- a/src/helpers/MQTTObserverValidation.h +++ b/src/helpers/MQTTObserverValidation.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include // Pure, dependency-free validators for the observer's CLI/web configuration @@ -58,3 +59,33 @@ static inline bool mqttNtpHostnameValid(const char* host) { static inline bool mqttValueFits(const char* s, size_t bufsize) { return s != NULL && bufsize > 0 && strlen(s) < bufsize; } + +// Strict decimal parser for bounded observer settings. Unlike atoi(), this +// rejects missing values, signs, whitespace, trailing text, and overflow. +static inline bool observerParseBoundedUint16(const char* text, uint16_t maximum, + uint16_t* result) { + if (text == NULL || result == NULL || *text == '\0') return false; + uint32_t value = 0; + for (const char* p = text; *p != '\0'; ++p) { + if (*p < '0' || *p > '9') return false; + value = value * 10u + static_cast(*p - '0'); + if (value > maximum) return false; + } + *result = static_cast(value); + return true; +} + +// `display.flip` accepts only complete, lower-case tokens so a typo cannot +// silently change persisted panel orientation. +static inline bool observerParseDisplayFlip(const char* text, uint8_t* result) { + if (text == NULL || result == NULL) return false; + if (strcmp(text, "0") == 0 || strcmp(text, "off") == 0) { + *result = 0; + return true; + } + if (strcmp(text, "1") == 0 || strcmp(text, "on") == 0) { + *result = 1; + return true; + } + return false; +} diff --git a/src/helpers/MQTTPrefsCodec.h b/src/helpers/MQTTPrefsCodec.h index cf259f29a..0ea7504e0 100644 --- a/src/helpers/MQTTPrefsCodec.h +++ b/src/helpers/MQTTPrefsCodec.h @@ -39,25 +39,43 @@ struct DecodePlan { static const size_t kV1PreObserverPayloadSize = MQTT_PREFS_V1_PRE_OBSERVER_PAYLOAD_SIZE; static const size_t kV1PreNeighborsPayloadSize = MQTT_PREFS_V1_PRE_NEIGHBORS_PAYLOAD_SIZE; static const size_t kV1PreFilterPayloadSize = MQTT_PREFS_V1_PRE_FILTER_PAYLOAD_SIZE; +static const size_t kV1PreDisplayPayloadSize = MQTT_PREFS_V1_PRE_DISPLAY_PAYLOAD_SIZE; static const size_t kV1BaselinePayloadSize = MQTT_PREFS_V1_FULL_PAYLOAD_SIZE; static const size_t kEncodedSize = sizeof(MQTTPrefsHeader) + kV1BaselinePayloadSize; // Shortest payload length that still round-trips this configuration. // -// The packet-filter tail is the only optional part of the current layout, and -// its default (all types) is exactly what a pre-filter decoder supplies for a -// missing tail. So a device whose filters are all default keeps writing the -// 2864-byte payload that pre-filter firmware can still read. That matters -// because /mqtt_prefs also carries the WiFi credentials: an unrecognised -// longer payload sends older firmware to defaults with no network, and it -// refuses to overwrite the file, so the node cannot be recovered over the air. -// Touching any filter opts that node into the longer payload -- a deliberate, -// operator-initiated trade rather than a side effect of upgrading. +// The packet-filter and display tails are optional. Their defaults are exactly +// what a decoder supplies when the corresponding tail is missing, so merely +// upgrading does not lengthen the file. That matters because /mqtt_prefs also +// carries WiFi credentials: old firmware that cannot read a longer payload can +// otherwise lose its network configuration after a downgrade. Changing a +// filter or display control deliberately opts the node into the required tail. inline size_t payloadLenFor(const MQTTPrefs& prefs) { + if (prefs.display_timeout_secs != DISPLAY_TIMEOUT_DEFAULT_SECS || + prefs.display_flip != 0) { + return kV1BaselinePayloadSize; + } return MQTTPacketFilter::allMasksDefault(prefs.mqtt_slot_packet_filter, MQTT_PREFS_SLOT_COUNT) ? kV1PreFilterPayloadSize - : kV1BaselinePayloadSize; + : kV1PreDisplayPayloadSize; +} + +// Clamp the display tail after loading. Returns true when a persisted value was +// repaired and should be rewritten (unless the source file is being held). +inline bool repairDisplayPrefs(MQTTPrefs* prefs) { + if (prefs == nullptr) return false; + bool repaired = false; + if (prefs->display_timeout_secs > DISPLAY_TIMEOUT_MAX_SECS) { + prefs->display_timeout_secs = DISPLAY_TIMEOUT_DEFAULT_SECS; + repaired = true; + } + if (prefs->display_flip > 1) { + prefs->display_flip = 0; + repaired = true; + } + return repaired; } inline MQTTPrefsHeader makeHeader(size_t payload_len) { @@ -138,6 +156,11 @@ inline DecodePlan classify(const uint8_t* prefix, size_t prefix_read, size_t fil if (header.payload_len == kV1BaselinePayloadSize) { return {Source::Current, false, false, true, kV1BaselinePayloadSize}; } + if (header.payload_len == kV1PreDisplayPayloadSize) { + // Written before runtime display controls. Defaults supply timeout and + // flip while preserving the packet-filter tail. + return {Source::Current, false, false, true, kV1PreDisplayPayloadSize}; + } if (header.payload_len == kV1PreFilterPayloadSize) { // Written before the per-slot packet-filter tail. Defaults supply an // all-types mask for every slot. diff --git a/src/helpers/MQTTPrefsJsonImport.h b/src/helpers/MQTTPrefsJsonImport.h new file mode 100644 index 000000000..06d138600 --- /dev/null +++ b/src/helpers/MQTTPrefsJsonImport.h @@ -0,0 +1,75 @@ +#pragma once + +#include +#include + +#include "MQTTPrefsSerializer.h" + +#ifdef WITH_MQTT_BRIDGE + +// Conservative one-way import policy for observer-firmware-dev's historical +// `/mqtt.json` v1. Despite its name, that file uses ConfigSerializer notation +// (unquoted keys), so decoding must use the source firmware's grammar rather +// than a general-purpose RFC JSON parser. +namespace MQTTPrefsJsonImport { + +static const size_t kMaximumFileSize = 16384; + +enum class Decision : uint8_t { + BinaryAuthoritative, + NoJsonSource, + ImportPrimary, + HoldJsonArtifacts, +}; + +// No generation is shared by the two formats. Any binary primary or recovery +// artifact therefore owns the configuration name, even if it later proves +// corrupt, future, or default-valued. Likewise, unresolved JSON transaction +// artifacts are preserved rather than guessed at. +inline Decision select(bool binary_hold, bool binary_primary, + bool binary_temp, bool binary_backup, + bool json_primary, bool json_temp, bool json_backup) { + if (binary_hold || binary_primary || binary_temp || binary_backup) { + return Decision::BinaryAuthoritative; + } + if (json_temp || json_backup) return Decision::HoldJsonArtifacts; + return json_primary ? Decision::ImportPrimary : Decision::NoJsonSource; +} + +enum class Result : uint8_t { + Loaded, + LoadedWithRepairs, + InvalidSyntax, + InvalidSchema, + UnsupportedVersion, + TooLarge, +}; + +inline bool loaded(Result result) { + return result == Result::Loaded || result == Result::LoadedWithRepairs; +} + +// `candidate` must start with this build's defaults and must not alias the live +// runtime object. Strict parsing may touch it before a late error; the caller +// commits it only after this function reports Loaded/LoadedWithRepairs. +inline Result decode(Stream& source, const MQTTPrefs& repair_defaults, + MQTTPrefs* candidate, + MQTTPrefsPresetValidator preset_valid) { + if (candidate == nullptr) return Result::InvalidSchema; + MQTTPrefsSerializer serializer(candidate, &repair_defaults, preset_valid); + const bool parsed = serializer.loadSerial(source); + + // Version is format-mandated as the first root property. If a future writer + // uses new grammar later in the file, preserve it as opaque even though this + // parser stops at that unknown construct. + if (serializer.hasFutureVersion()) return Result::UnsupportedVersion; + if (!parsed) return Result::InvalidSyntax; + + bool repaired = false; + if (!serializer.apply(&repaired)) return Result::InvalidSchema; + return repaired ? Result::LoadedWithRepairs : Result::Loaded; +} + +} // namespace MQTTPrefsJsonImport + +#endif // WITH_MQTT_BRIDGE diff --git a/src/helpers/MQTTPrefsSerializer.h b/src/helpers/MQTTPrefsSerializer.h new file mode 100644 index 000000000..3073a144b --- /dev/null +++ b/src/helpers/MQTTPrefsSerializer.h @@ -0,0 +1,447 @@ +#pragma once + +#include +#include +#include + +#ifdef WITH_MQTT_BRIDGE + +static const int32_t MQTT_PREFS_JSON_FORMAT_VERSION = 1; +typedef bool (*MQTTPrefsPresetValidator)(const char* preset); + +// Format invariant inherited from observer-firmware-dev: `version` is the +// first root property. Recognizing it before any later unknown grammar lets the +// importer preserve a future file as opaque rather than misclassifying it as a +// corrupt v1 source. + +// ConfigSerializer adapter for the observer preference POD. Keeping the +// serializer separate avoids making the runtime object layout part of the JSON +// format and adds no permanent per-slot serializer state to CommonCLI. +class MQTTPrefsSerializer : public ConfigSerializer { + + class WifiPrefs : public ConfigSerializer { + MQTTPrefs* _prefs; + int32_t _power_save, _default_power_save; + bool _seen_ssid = false, _seen_password = false, _seen_power_save = false; + protected: + void structure() override { + defStrict("ssid", _prefs->wifi_ssid, sizeof(_prefs->wifi_ssid), _seen_ssid); + defStrict("password", _prefs->wifi_password, sizeof(_prefs->wifi_password), _seen_password); + defStrict("power_save", _power_save, _seen_power_save); + } + public: + WifiPrefs(MQTTPrefs* prefs, const MQTTPrefs* defaults) + : _prefs(prefs), _power_save(prefs->wifi_power_save), + _default_power_save(defaults->wifi_power_save) { + if (_default_power_save < 0 || _default_power_save > 2) { + _default_power_save = 1; + } + } + bool apply(bool* repaired) { + if (_power_save < 0 || _power_save > 2) { + _power_save = _default_power_save; + *repaired = true; + } + _prefs->wifi_power_save = static_cast(_power_save); + return true; + } + }; + + class TimePrefs : public ConfigSerializer { + MQTTPrefs* _prefs; + int32_t _utc_offset, _default_utc_offset; + char _default_ntp_server[sizeof(MQTTPrefs::mqtt_ntp_server)]; + bool _seen_timezone = false, _seen_utc_offset = false, _seen_ntp_server = false; + protected: + void structure() override { + defStrict("timezone", _prefs->timezone_string, sizeof(_prefs->timezone_string), _seen_timezone); + defStrict("utc_offset", _utc_offset, _seen_utc_offset); + defStrict("ntp_server", _prefs->mqtt_ntp_server, sizeof(_prefs->mqtt_ntp_server), _seen_ntp_server); + } + public: + TimePrefs(MQTTPrefs* prefs, const MQTTPrefs* defaults) + : _prefs(prefs), _utc_offset(prefs->timezone_offset), + _default_utc_offset(defaults->timezone_offset) { + if (_default_utc_offset < -12 || _default_utc_offset > 14) { + _default_utc_offset = 0; + } + memcpy(_default_ntp_server, defaults->mqtt_ntp_server, sizeof(_default_ntp_server)); + if (_default_ntp_server[0] != '\0' && + !mqttNtpHostnameValid(_default_ntp_server)) { + _default_ntp_server[0] = '\0'; + } + } + bool apply(bool* repaired) { + if (_utc_offset < -12 || _utc_offset > 14) { + _utc_offset = _default_utc_offset; + *repaired = true; + } + if (_prefs->mqtt_ntp_server[0] != '\0' && + !mqttNtpHostnameValid(_prefs->mqtt_ntp_server)) { + memcpy(_prefs->mqtt_ntp_server, _default_ntp_server, sizeof(_default_ntp_server)); + *repaired = true; + } + _prefs->timezone_offset = static_cast(_utc_offset); + return true; + } + }; + + class StatusPrefs : public ConfigSerializer { + MQTTPrefs* _prefs; + int32_t _enabled, _interval_ms; + bool _seen_enabled = false, _seen_interval = false; + protected: + void structure() override { + defStrict("enabled", _enabled, _seen_enabled); + defStrict("interval_ms", _interval_ms, _seen_interval); + } + public: + explicit StatusPrefs(MQTTPrefs* prefs) + : _prefs(prefs), _enabled(prefs->mqtt_status_enabled), + _interval_ms(static_cast(prefs->mqtt_status_interval)) {} + void apply(bool* repaired) { + if (_enabled < 0 || _enabled > 1) { _enabled = 1; *repaired = true; } + if (_interval_ms < 60000 || _interval_ms > 3600000) { + _interval_ms = 300000; + *repaired = true; + } + _prefs->mqtt_status_enabled = static_cast(_enabled); + _prefs->mqtt_status_interval = static_cast(_interval_ms); + } + }; + + class NeighborPrefs : public ConfigSerializer { + MQTTPrefs* _prefs; + int32_t _enabled, _interval_ms; + bool _seen_enabled = false, _seen_interval = false; + protected: + void structure() override { + defStrict("enabled", _enabled, _seen_enabled); + defStrict("interval_ms", _interval_ms, _seen_interval); + } + public: + explicit NeighborPrefs(MQTTPrefs* prefs) + : _prefs(prefs), _enabled(prefs->mqtt_neighbors_enabled), + _interval_ms(static_cast(prefs->mqtt_neighbors_interval)) {} + void apply(bool* repaired) { + if (_enabled < 0 || _enabled > 1) { _enabled = 0; *repaired = true; } + if (_interval_ms < static_cast(MQTT_NEIGHBORS_MIN_INTERVAL_MS) || + _interval_ms > static_cast(MQTT_NEIGHBORS_MAX_INTERVAL_MS)) { + _interval_ms = static_cast(MQTT_NEIGHBORS_DEFAULT_INTERVAL_MS); + *repaired = true; + } + _prefs->mqtt_neighbors_enabled = static_cast(_enabled); + _prefs->mqtt_neighbors_interval = static_cast(_interval_ms); + } + }; + + class OwnerPrefs : public ConfigSerializer { + MQTTPrefs* _prefs; + bool _seen_public_key = false, _seen_email = false; + protected: + void structure() override { + defStrict("public_key", _prefs->mqtt_owner_public_key, + sizeof(_prefs->mqtt_owner_public_key), _seen_public_key); + defStrict("email", _prefs->mqtt_email, sizeof(_prefs->mqtt_email), _seen_email); + } + public: + explicit OwnerPrefs(MQTTPrefs* prefs) : _prefs(prefs) {} + void apply(bool* repaired) { + if (_prefs->mqtt_owner_public_key[0] != '\0' && + !mqttOwnerKeyValid(_prefs->mqtt_owner_public_key)) { + _prefs->mqtt_owner_public_key[0] = '\0'; + *repaired = true; + } + } + }; + + class SlotPrefs : public ConfigSerializer { + MQTTPrefs* _prefs; + int _index; + int32_t _port, _filter; + MQTTPrefsPresetValidator _preset_valid; + bool _seen_preset = false, _seen_host = false, _seen_port = false; + bool _seen_username = false, _seen_password = false, _seen_token = false; + bool _seen_topic = false, _seen_audience = false, _seen_filter = false; + protected: + void structure() override { + defStrict("preset", _prefs->mqtt_slot_preset[_index], + sizeof(_prefs->mqtt_slot_preset[_index]), _seen_preset); + defStrict("host", _prefs->mqtt_slot_host[_index], + sizeof(_prefs->mqtt_slot_host[_index]), _seen_host); + defStrict("port", _port, _seen_port); + defStrict("username", _prefs->mqtt_slot_username[_index], + sizeof(_prefs->mqtt_slot_username[_index]), _seen_username); + defStrict("password", _prefs->mqtt_slot_password[_index], + sizeof(_prefs->mqtt_slot_password[_index]), _seen_password); + defStrict("token", _prefs->mqtt_slot_token[_index], + sizeof(_prefs->mqtt_slot_token[_index]), _seen_token); + defStrict("topic", _prefs->mqtt_slot_topic[_index], + sizeof(_prefs->mqtt_slot_topic[_index]), _seen_topic); + defStrict("audience", _prefs->mqtt_slot_audience[_index], + sizeof(_prefs->mqtt_slot_audience[_index]), _seen_audience); + defStrict("packet_filter", _filter, _seen_filter); + } + public: + SlotPrefs(MQTTPrefs* prefs, int index, MQTTPrefsPresetValidator preset_valid) + : _prefs(prefs), _index(index), _port(prefs->mqtt_slot_port[index]), + _filter(prefs->mqtt_slot_packet_filter[index]), + _preset_valid(preset_valid) {} + void apply(bool* repaired) { + if (_port < 0 || _port > 65535) { _port = 0; *repaired = true; } + if (_filter < 0 || _filter > 65535) { _filter = 0xffff; *repaired = true; } + const char* preset = _prefs->mqtt_slot_preset[_index]; + if (strcmp(preset, "none") != 0 && strcmp(preset, "custom") != 0 && + (_preset_valid == nullptr || !_preset_valid(preset))) { + strcpy(_prefs->mqtt_slot_preset[_index], "none"); + *repaired = true; + } + _prefs->mqtt_slot_port[_index] = static_cast(_port); + _prefs->mqtt_slot_packet_filter[_index] = static_cast(_filter); + } + }; + + class MqttPrefs : public ConfigSerializer { + MQTTPrefs* _prefs; + int32_t _packets_enabled, _raw_enabled, _tx_enabled, _rx_enabled; + char _default_iata[sizeof(MQTTPrefs::mqtt_iata)]; + bool _seen_origin = false, _seen_iata = false, _seen_packets = false; + bool _seen_raw = false, _seen_tx = false, _seen_rx = false; + bool _seen_status = false, _seen_neighbors = false, _seen_owner = false; + bool _seen_slot1 = false, _seen_slot2 = false, _seen_slot3 = false; + bool _seen_slot4 = false, _seen_slot5 = false, _seen_slot6 = false; + StatusPrefs _status; + NeighborPrefs _neighbors; + OwnerPrefs _owner; + static_assert(MQTT_PREFS_SLOT_COUNT == 6, + "MQTTPrefsSerializer slot members and keys must be updated"); + SlotPrefs _slot1, _slot2, _slot3, _slot4, _slot5, _slot6; + protected: + void structure() override { + defStrict("origin", _prefs->mqtt_origin, sizeof(_prefs->mqtt_origin), _seen_origin); + defStrict("iata", _prefs->mqtt_iata, sizeof(_prefs->mqtt_iata), _seen_iata); + defStrict("packets_enabled", _packets_enabled, _seen_packets); + defStrict("raw_enabled", _raw_enabled, _seen_raw); + defStrict("tx_enabled", _tx_enabled, _seen_tx); + defStrict("rx_enabled", _rx_enabled, _seen_rx); + defStrict("status", _status, _seen_status); + defStrict("neighbors", _neighbors, _seen_neighbors); + defStrict("owner", _owner, _seen_owner); + defStrict("slot1", _slot1, _seen_slot1); + defStrict("slot2", _slot2, _seen_slot2); + defStrict("slot3", _slot3, _seen_slot3); + defStrict("slot4", _slot4, _seen_slot4); + defStrict("slot5", _slot5, _seen_slot5); + defStrict("slot6", _slot6, _seen_slot6); + } + public: + MqttPrefs(MQTTPrefs* prefs, const MQTTPrefs* defaults, + MQTTPrefsPresetValidator preset_valid) + : _prefs(prefs), _packets_enabled(prefs->mqtt_packets_enabled), + _raw_enabled(prefs->mqtt_raw_enabled), _tx_enabled(prefs->mqtt_tx_enabled), + _rx_enabled(prefs->mqtt_rx_enabled), _status(prefs), _neighbors(prefs), + _owner(prefs), _slot1(prefs, 0, preset_valid), + _slot2(prefs, 1, preset_valid), _slot3(prefs, 2, preset_valid), + _slot4(prefs, 3, preset_valid), _slot5(prefs, 4, preset_valid), + _slot6(prefs, 5, preset_valid) { + memcpy(_default_iata, defaults->mqtt_iata, sizeof(_default_iata)); + if (_default_iata[0] != '\0' && !mqttIataValid(_default_iata)) { + _default_iata[0] = '\0'; + } + for (char* p = _default_iata; *p; ++p) { + if (*p >= 'a' && *p <= 'z') *p = static_cast(*p - ('a' - 'A')); + } + } + void apply(bool* repaired) { + if (_packets_enabled < 0 || _packets_enabled > 1) { _packets_enabled = 1; *repaired = true; } + if (_raw_enabled < 0 || _raw_enabled > 1) { _raw_enabled = 0; *repaired = true; } + if (_tx_enabled < 0 || _tx_enabled > 2) { _tx_enabled = 2; *repaired = true; } + if (_rx_enabled < 0 || _rx_enabled > 1) { _rx_enabled = 1; *repaired = true; } + _prefs->mqtt_packets_enabled = static_cast(_packets_enabled); + _prefs->mqtt_raw_enabled = static_cast(_raw_enabled); + _prefs->mqtt_tx_enabled = static_cast(_tx_enabled); + _prefs->mqtt_rx_enabled = static_cast(_rx_enabled); + if (_prefs->mqtt_iata[0] != '\0') { + if (!mqttIataValid(_prefs->mqtt_iata)) { + memcpy(_prefs->mqtt_iata, _default_iata, sizeof(_default_iata)); + *repaired = true; + } else { + for (char* p = _prefs->mqtt_iata; *p; ++p) { + if (*p >= 'a' && *p <= 'z') { + *p = static_cast(*p - ('a' - 'A')); + *repaired = true; + } + } + } + } + _status.apply(repaired); + _neighbors.apply(repaired); + _owner.apply(repaired); + _slot1.apply(repaired); _slot2.apply(repaired); _slot3.apply(repaired); + _slot4.apply(repaired); _slot5.apply(repaired); _slot6.apply(repaired); + } + }; + + class SnmpPrefs : public ConfigSerializer { + MQTTPrefs* _prefs; + int32_t _enabled; + bool _seen_enabled = false, _seen_community = false; + protected: + void structure() override { + defStrict("enabled", _enabled, _seen_enabled); + defStrict("community", _prefs->snmp_community, + sizeof(_prefs->snmp_community), _seen_community); + } + public: + explicit SnmpPrefs(MQTTPrefs* prefs) : _prefs(prefs), _enabled(prefs->snmp_enabled) {} + void apply(bool* repaired) { + if (_enabled < 0 || _enabled > 1) { _enabled = 0; *repaired = true; } + _prefs->snmp_enabled = static_cast(_enabled); + } + }; + + class RadioPrefs : public ConfigSerializer { + MQTTPrefs* _prefs; + int32_t _watchdog_min; + bool _seen_watchdog = false; + protected: + void structure() override { defStrict("watchdog_min", _watchdog_min, _seen_watchdog); } + public: + explicit RadioPrefs(MQTTPrefs* prefs) + : _prefs(prefs), _watchdog_min(prefs->radio_watchdog_minutes) {} + void apply(bool* repaired) { + if (_watchdog_min < 0 || _watchdog_min > 120) { _watchdog_min = 5; *repaired = true; } + _prefs->radio_watchdog_minutes = static_cast(_watchdog_min); + } + }; + + class AlertPrefs : public ConfigSerializer { + MQTTPrefs* _prefs; + int32_t _enabled, _wifi_minutes, _mqtt_minutes, _rate_limit_min; + bool _seen_enabled = false, _seen_psk = false, _seen_wifi = false; + bool _seen_mqtt = false, _seen_rate = false, _seen_hashtag = false, _seen_region = false; + protected: + void structure() override { + defStrict("enabled", _enabled, _seen_enabled); + defStrict("psk_hex", _prefs->alert_psk_hex, sizeof(_prefs->alert_psk_hex), _seen_psk); + defStrict("wifi_minutes", _wifi_minutes, _seen_wifi); + defStrict("mqtt_minutes", _mqtt_minutes, _seen_mqtt); + defStrict("rate_limit_min", _rate_limit_min, _seen_rate); + defStrict("hashtag", _prefs->alert_hashtag, sizeof(_prefs->alert_hashtag), _seen_hashtag); + defStrict("region", _prefs->alert_region, sizeof(_prefs->alert_region), _seen_region); + } + public: + explicit AlertPrefs(MQTTPrefs* prefs) + : _prefs(prefs), _enabled(prefs->alert_enabled), + _wifi_minutes(prefs->alert_wifi_minutes), _mqtt_minutes(prefs->alert_mqtt_minutes), + _rate_limit_min(prefs->alert_min_interval_min) {} + void apply(bool* repaired) { + if (_enabled < 0 || _enabled > 1) { _enabled = 0; *repaired = true; } + if (_wifi_minutes < 0 || _wifi_minutes > 1440) { _wifi_minutes = 30; *repaired = true; } + if (_mqtt_minutes < 0 || _mqtt_minutes > 10080) { _mqtt_minutes = 240; *repaired = true; } + if (_rate_limit_min < 60 || _rate_limit_min > 10080) { _rate_limit_min = 60; *repaired = true; } + if (_prefs->alert_psk_hex[0] != '\0') { + const size_t len = strlen(_prefs->alert_psk_hex); + bool valid_hex = len == 32; + for (size_t i = 0; valid_hex && i < len; ++i) { + const char c = _prefs->alert_psk_hex[i]; + valid_hex = (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || + (c >= 'a' && c <= 'f'); + } + if (!valid_hex) { + _prefs->alert_psk_hex[0] = '\0'; + _prefs->alert_hashtag[0] = '\0'; + *repaired = true; + } + } + _prefs->alert_enabled = static_cast(_enabled); + _prefs->alert_wifi_minutes = static_cast(_wifi_minutes); + _prefs->alert_mqtt_minutes = static_cast(_mqtt_minutes); + _prefs->alert_min_interval_min = static_cast(_rate_limit_min); + } + }; + + class DisplayPrefs : public ConfigSerializer { + MQTTPrefs* _prefs; + int32_t _timeout_s, _flip; + bool _seen_timeout = false, _seen_flip = false; + protected: + void structure() override { + defStrict("timeout_s", _timeout_s, _seen_timeout); + defStrict("flip", _flip, _seen_flip); + } + public: + explicit DisplayPrefs(MQTTPrefs* prefs) + : _prefs(prefs), _timeout_s(prefs->display_timeout_secs), _flip(prefs->display_flip) {} + void apply(bool* repaired) { + if (_timeout_s < 0 || _timeout_s > DISPLAY_TIMEOUT_MAX_SECS) { + _timeout_s = DISPLAY_TIMEOUT_DEFAULT_SECS; + *repaired = true; + } + if (_flip < 0 || _flip > 1) { _flip = 0; *repaired = true; } + _prefs->display_timeout_secs = static_cast(_timeout_s); + _prefs->display_flip = static_cast(_flip); + } + }; + + MQTTPrefs* _prefs; + int32_t _version = MQTT_PREFS_JSON_FORMAT_VERSION; + bool _seen_version = false; + bool _seen_wifi = false, _seen_time = false, _seen_mqtt = false; + bool _seen_snmp = false, _seen_radio = false, _seen_alert = false; + bool _seen_display = false; + WifiPrefs _wifi; + TimePrefs _time; + MqttPrefs _mqtt; + SnmpPrefs _snmp; + RadioPrefs _radio; + AlertPrefs _alert; + DisplayPrefs _display; + +protected: + void structure() override { + defStrict("version", _version, _seen_version); // must stay first; see above + defStrict("wifi", _wifi, _seen_wifi); + defStrict("time", _time, _seen_time); + defStrict("mqtt", _mqtt, _seen_mqtt); + defStrict("snmp", _snmp, _seen_snmp); + defStrict("radio", _radio, _seen_radio); + defStrict("alert", _alert, _seen_alert); + defStrict("display", _display, _seen_display); + } + +public: + explicit MQTTPrefsSerializer( + MQTTPrefs* prefs, const MQTTPrefs* repair_defaults = nullptr, + MQTTPrefsPresetValidator preset_valid = nullptr) + : _prefs(prefs), _wifi(prefs, repair_defaults ? repair_defaults : prefs), + _time(prefs, repair_defaults ? repair_defaults : prefs), + _mqtt(prefs, repair_defaults ? repair_defaults : prefs, preset_valid), _snmp(prefs), + _radio(prefs), _alert(prefs), _display(prefs) {} + + bool hasSupportedVersion() const { + return _seen_version && _version == MQTT_PREFS_JSON_FORMAT_VERSION; + } + bool hasFutureVersion() const { + return _seen_version && _version > MQTT_PREFS_JSON_FORMAT_VERSION; + } + + bool normalize(bool* repaired) { + if (repaired == nullptr) return false; + *repaired = false; + _wifi.apply(repaired); + _time.apply(repaired); + _mqtt.apply(repaired); + _snmp.apply(repaired); + _radio.apply(repaired); + _alert.apply(repaired); + _display.apply(repaired); + return true; + } + + bool apply(bool* repaired) { + return hasSupportedVersion() && normalize(repaired); + } +}; + +#endif // WITH_MQTT_BRIDGE diff --git a/src/helpers/MQTTPrefsStorage.h b/src/helpers/MQTTPrefsStorage.h index 463a6057c..6478e3574 100644 --- a/src/helpers/MQTTPrefsStorage.h +++ b/src/helpers/MQTTPrefsStorage.h @@ -122,6 +122,13 @@ struct MQTTPrefs { // for both packets and raw MQTT topics. Appended so older v1 payloads load // with the default all-types masks intact. uint16_t mqtt_slot_packet_filter[MQTT_PREFS_SLOT_COUNT]; + + // Runtime display controls. These are an append-only v1 tail: older payloads + // stop before them and retain the defaults below. The serialized payload ends + // after display_flip at byte 2879; the compiler's final alignment byte is not + // part of the on-flash format. + uint16_t display_timeout_secs; + uint8_t display_flip; }; // Neighbor discovery is scheduled with the wrap-safe millis() helpers, whose @@ -134,7 +141,10 @@ static const uint32_t MQTT_NEIGHBORS_MIN_INTERVAL_MS = MQTT_NEIGHBORS_MIN_INTERV static const uint32_t MQTT_NEIGHBORS_MAX_INTERVAL_MS = MQTT_NEIGHBORS_MAX_INTERVAL_HOURS * 3600000UL; static const uint32_t MQTT_NEIGHBORS_DEFAULT_INTERVAL_MS = MQTT_NEIGHBORS_DEFAULT_INTERVAL_HOURS * 3600000UL; -// Version-1 has four payload layouts this firmware can decode. Never infer a +static const uint16_t DISPLAY_TIMEOUT_DEFAULT_SECS = 60; +static const uint16_t DISPLAY_TIMEOUT_MAX_SECS = 3600; + +// Version-1 has five payload layouts this firmware can decode. Never infer a // compatible payload from an arbitrary SHORTER size: raw prefs have no // checksum, so a short length has to match a boundary that was really shipped. // @@ -146,16 +156,19 @@ static const uint32_t MQTT_NEIGHBORS_DEFAULT_INTERVAL_MS = MQTT_NEIGHBORS_DEFAUL // - PRE_OBSERVER (2736): stops before the observer tail (snmp_*/alert_*). // - PRE_NEIGHBORS (2860): full observer tail, no neighbors fields yet. // - PRE_FILTER (2864): neighbors tail, no per-slot packet filters. -// - FULL (2876): current baseline, with six uint16_t filter masks. +// - PRE_DISPLAY (2876): six filter masks, no runtime display controls. +// - FULL (2879): timeout plus flip, excluding final struct padding. // // FULL is the maximum written, not the default: MQTTPrefsCodec::payloadLenFor() -// keeps emitting PRE_FILTER while every slot holds the all-types default, so a -// node that never touches a filter stays readable by pre-filter firmware. See -// the rollback note there -- /mqtt_prefs also carries the WiFi credentials. +// keeps emitting PRE_FILTER while every slot and display control has its +// default. A non-default filter writes PRE_DISPLAY; a non-default display +// control writes FULL. See the rollback note there -- /mqtt_prefs also carries +// the WiFi credentials. static const size_t MQTT_PREFS_V1_PRE_OBSERVER_PAYLOAD_SIZE = 2736; static const size_t MQTT_PREFS_V1_PRE_NEIGHBORS_PAYLOAD_SIZE = 2860; static const size_t MQTT_PREFS_V1_PRE_FILTER_PAYLOAD_SIZE = 2864; -static const size_t MQTT_PREFS_V1_FULL_PAYLOAD_SIZE = 2876; +static const size_t MQTT_PREFS_V1_PRE_DISPLAY_PAYLOAD_SIZE = 2876; +static const size_t MQTT_PREFS_V1_FULL_PAYLOAD_SIZE = 2879; // /mqtt_prefs starts with a self-describing 8-byte header. Headerless files // are deployed legacy layouts and continue to be distinguished by size. @@ -274,8 +287,8 @@ static const size_t LEGACY6_AUDIENCE_RX_SIZE = 2840; static_assert(sizeof(MQTTPrefsHeader) == 8, "versioned /mqtt_prefs header must stay 8 bytes"); static_assert(offsetof(MQTTPrefs, snmp_enabled) == MQTT_PREFS_V1_PRE_OBSERVER_PAYLOAD_SIZE, "v1 pre-observer /mqtt_prefs boundary changed"); -static_assert(sizeof(MQTTPrefs) == MQTT_PREFS_V1_FULL_PAYLOAD_SIZE, - "v1 /mqtt_prefs payload layout changed"); +static_assert(sizeof(MQTTPrefs) == 2880, + "runtime MQTTPrefs layout or final alignment changed"); // Lock the neighbors tail to the flex neighbors build's layout so a /mqtt_prefs // written by either firmware is byte-for-byte interchangeable. The enable flag // lands in the old struct's zeroed trailing padding (offset 2857), and the @@ -287,6 +300,11 @@ static_assert(offsetof(MQTTPrefs, mqtt_neighbors_interval) == MQTT_PREFS_V1_PRE_ "neighbors interval offset must equal the pre-neighbors payload size"); static_assert(offsetof(MQTTPrefs, mqtt_slot_packet_filter) == MQTT_PREFS_V1_PRE_FILTER_PAYLOAD_SIZE, "packet filters must begin at the pre-filter payload boundary"); +static_assert(offsetof(MQTTPrefs, display_timeout_secs) == MQTT_PREFS_V1_PRE_DISPLAY_PAYLOAD_SIZE, + "display timeout must begin at the pre-display payload boundary"); +static_assert(offsetof(MQTTPrefs, display_flip) + sizeof(MQTTPrefs::display_flip) == + MQTT_PREFS_V1_FULL_PAYLOAD_SIZE, + "serialized display tail must end immediately after display_flip"); static_assert(sizeof(OldMQTTPrefs) == 472, "frozen pre-slot /mqtt_prefs layout changed"); static_assert(sizeof(PreWifiPowerOldMQTTPrefs) == 472, "frozen pre-WiFi-power /mqtt_prefs layout changed"); static_assert(offsetof(OldMQTTPrefs, wifi_power_save) == 144, diff --git a/src/helpers/RadioActivityWindow.h b/src/helpers/RadioActivityWindow.h new file mode 100644 index 000000000..ddd8547cb --- /dev/null +++ b/src/helpers/RadioActivityWindow.h @@ -0,0 +1,217 @@ +#pragma once + +#include +#include + +// Fixed-memory rolling window of RF receive activity, bucketed by minute. +// +// Pure logic: no Arduino, radio, display or role headers. Callers supply a +// millisecond counter and the per-packet values. +// +// The caller's 32-bit millis() is extended to a monotonic 64-bit clock on entry +// (see tick()), so nothing downstream has a rollover case. Unsigned-subtraction +// tricks are not enough here: they only survive a single wrap crossing, while an +// always-on observer accumulates uptime past 2^32 ms (~49.7 days), at which +// point a 32-bit tracker age would collapse back to a small value and the +// window would re-enter warm-up and divide 20 minutes of traffic by seconds. + +#define RADIO_ACTIVITY_BUCKETS 20 +#define RADIO_ACTIVITY_BUCKET_MS 60000UL + +// Beyond this, "time since last packet" stops being reported rather than shown +// as a stale or (after a 49-day rollover) nonsensical age. +#define RADIO_ACTIVITY_MAX_AGE_MS (100UL * RADIO_ACTIVITY_BUCKET_MS) + +// How far a timestamp may run behind the previous one and still count as an +// out-of-order reading rather than a very long forward gap. +#define RADIO_ACTIVITY_BACKSTEP_TOLERANCE_MS 5000UL + +struct RadioActivitySnapshot { + uint32_t packets; + uint32_t wire_bytes; + uint32_t airtime_ms; + int32_t snr_q4_sum; + int32_t rssi_sum; + + // Span the totals actually cover: 19 whole minutes plus the elapsed part of + // the current one, so it tops out just under 20 minutes and never claims + // coverage the ring does not have. + uint32_t window_ms; + uint32_t tracking_ms; // how long the tracker has been running + + uint32_t last_packet_age_ms; + bool has_last_packet; // false until the first packet, and once stale + + uint16_t peak_per_min; + uint16_t buckets[RADIO_ACTIVITY_BUCKETS]; // [0] oldest .. [N-1] current minute + + bool isEmpty() const { return packets == 0; } + + bool isWarmingUp() const { + return tracking_ms < (uint32_t)RADIO_ACTIVITY_BUCKETS * RADIO_ACTIVITY_BUCKET_MS; + } + uint32_t warmupMinutes() const { return tracking_ms / RADIO_ACTIVITY_BUCKET_MS; } + + // Derived values, in integer fixed point so host tests are exact and the + // formatting path stays off the FPU. All are zero when the window is empty. + uint32_t packetsPerMinuteX10() const { + if (window_ms == 0) return 0; + return (uint32_t)(((uint64_t)packets * RADIO_ACTIVITY_BUCKET_MS * 10) / window_ms); + } + uint32_t bytesPerSecondX10() const { + if (window_ms == 0) return 0; + return (uint32_t)(((uint64_t)wire_bytes * 1000 * 10) / window_ms); + } + uint32_t avgBytesPerPacket() const { + if (packets == 0) return 0; + return (wire_bytes + packets / 2) / packets; + } + // Receive airtime as tenths of a percent of the window. + uint32_t airtimePercentX10() const { + if (window_ms == 0) return 0; + return (uint32_t)(((uint64_t)airtime_ms * 1000) / window_ms); + } + // Average SNR in tenths of a dB (sums are quarter-dB units). + int32_t avgSnrX10() const { + if (packets == 0) return 0; + return (snr_q4_sum * 10) / ((int32_t)packets * 4); + } + int32_t avgRssi() const { + if (packets == 0) return 0; + return rssi_sum / (int32_t)packets; + } +}; + +class RadioActivityWindow { +public: + RadioActivityWindow() { reset(0); } + + void reset(uint32_t now_ms) { + memset(_buckets, 0, sizeof(_buckets)); + _head = 0; + _now_ms = 0; + _last_input_ms = now_ms; + _bucket_start_ms = 0; + _tracking_since_ms = 0; + _last_packet_ms = 0; + _ever_received = false; + } + + void recordPacket(uint32_t now_ms, uint16_t wire_bytes, uint32_t airtime_ms, int8_t snr_q4, + int16_t rssi_dbm) { + advance(now_ms); + + Bucket& b = _buckets[_head]; + // Saturated: drop the event whole so the bucket's averages stay consistent + // with its packet count. Unreachable at any real LoRa packet rate. + if (b.packets == 0xFFFF) return; + + b.packets++; + b.wire_bytes += wire_bytes; + b.airtime_ms += airtime_ms; + b.snr_q4_sum += snr_q4; + b.rssi_sum += rssi_dbm; + + _last_packet_ms = _now_ms; + _ever_received = true; + } + + void snapshot(uint32_t now_ms, RadioActivitySnapshot* out) { + advance(now_ms); + memset(out, 0, sizeof(*out)); + + for (int i = 0; i < RADIO_ACTIVITY_BUCKETS; i++) { + const Bucket& b = _buckets[(_head + 1 + i) % RADIO_ACTIVITY_BUCKETS]; + out->buckets[i] = b.packets; + out->packets += b.packets; + out->wire_bytes += b.wire_bytes; + out->airtime_ms += b.airtime_ms; + out->snr_q4_sum += b.snr_q4_sum; + out->rssi_sum += b.rssi_sum; + if (b.packets > out->peak_per_min) out->peak_per_min = b.packets; + } + + uint64_t elapsed_in_current = _now_ms - _bucket_start_ms; // < BUCKET_MS after advance() + uint64_t max_span = + (uint64_t)(RADIO_ACTIVITY_BUCKETS - 1) * RADIO_ACTIVITY_BUCKET_MS + elapsed_in_current; + uint64_t tracking = _now_ms - _tracking_since_ms; + const uint64_t full_span = (uint64_t)RADIO_ACTIVITY_BUCKETS * RADIO_ACTIVITY_BUCKET_MS; + + // Clamped, so the 32-bit snapshot fields stay in range on a long-lived node. + // Past full_span the exact tracker age is not needed: the window is warm. + out->tracking_ms = (uint32_t)(tracking < full_span ? tracking : full_span); + out->window_ms = (uint32_t)(tracking < max_span ? tracking : max_span); + + if (_ever_received) { + uint64_t age = _now_ms - _last_packet_ms; + if (age <= RADIO_ACTIVITY_MAX_AGE_MS) { + out->last_packet_age_ms = (uint32_t)age; + out->has_last_packet = true; + } + } + } + +private: + struct Bucket { + uint32_t wire_bytes; + uint32_t airtime_ms; + int32_t snr_q4_sum; + int32_t rssi_sum; + uint16_t packets; + uint16_t _reserved; + }; + + Bucket _buckets[RADIO_ACTIVITY_BUCKETS]; + uint64_t _now_ms; // monotonic clock, extended from the caller's + uint64_t _bucket_start_ms; // start of the current (newest) minute + uint64_t _tracking_since_ms; + uint64_t _last_packet_ms; + uint32_t _last_input_ms; // last 32-bit value the caller handed in + uint8_t _head; // ring index of the current minute + bool _ever_received; + + // Accumulates the unsigned delta since the previous call, which is correct + // across one millis() wrap. + // + // A delta past the halfway mark is ambiguous: it is either a slightly stale + // reading or a genuine gap of more than ~24.8 days. recordPacket() and + // snapshot() sample millis() microseconds apart, so a real stale reading is + // tiny - anything larger is treated as the long gap it is, which matters + // because rejecting it outright would freeze the ring and leave a + // month-old packet looking recently received. + void tick(uint32_t now_ms) { + uint32_t delta = now_ms - _last_input_ms; + if (delta > 0x80000000u && + (uint32_t)(_last_input_ms - now_ms) <= RADIO_ACTIVITY_BACKSTEP_TOLERANCE_MS) { + return; // out-of-order reading: no time has passed + } + _now_ms += delta; + _last_input_ms = now_ms; + } + + // Retires expired buckets lazily, advancing the boundary by whole BUCKET_MS + // steps so the minute phase is preserved across gaps. + void advance(uint32_t now_ms) { + tick(now_ms); + + uint64_t elapsed = _now_ms - _bucket_start_ms; + if (elapsed < RADIO_ACTIVITY_BUCKET_MS) return; + + uint64_t steps = elapsed / RADIO_ACTIVITY_BUCKET_MS; + _bucket_start_ms += steps * RADIO_ACTIVITY_BUCKET_MS; + + if (steps >= RADIO_ACTIVITY_BUCKETS) { + memset(_buckets, 0, sizeof(_buckets)); + _head = 0; + _tracking_since_ms = _bucket_start_ms; + return; + } + + for (uint64_t i = 0; i < steps; i++) { + _head = (uint8_t)((_head + 1) % RADIO_ACTIVITY_BUCKETS); + memset(&_buckets[_head], 0, sizeof(Bucket)); + } + } +}; + +static_assert(sizeof(RadioActivityWindow) <= 1024, "RadioActivityWindow must stay under 1 KiB"); diff --git a/src/helpers/ui/CHSC6XTouch.h b/src/helpers/ui/CHSC6XTouch.h new file mode 100644 index 000000000..b54a3651d --- /dev/null +++ b/src/helpers/ui/CHSC6XTouch.h @@ -0,0 +1,126 @@ +#pragma once + +#include +#include + +#include "TouchTapDetector.h" + +// Minimal polled driver for the CHSC6X capacitive touch controller on the +// Heltec V4 R8 Expansion Kit V2 panel. +// +// Only "is a finger down" is needed to toggle the display, so no coordinates +// and no calibration are read. TP_INT is not used: on this board it is an +// optional link (R13) on GPIO 43, which is also U0TXD - see HeltecV4R8Board.cpp +// for the verified pin map. + +#ifndef CHSC6X_I2C_ADDR +#define CHSC6X_I2C_ADDR 0x2E +#endif + +#define CHSC6X_READ_LEN 5 + +class CHSC6XTouch { +public: + // Probes the bus for diagnostics only. The result must NOT gate polling: this + // controller NACKs its address whenever it has nothing to report, so a single + // idle probe at boot is indistinguishable from absent hardware. checkTap() + // keeps polling either way, and a NACK there costs one quiet, fast bus cycle. + bool begin(TwoWire& wire = Wire) { + _wire = &wire; + _wire->beginTransmission((uint8_t)CHSC6X_I2C_ADDR); + _present = (_wire->endTransmission() == 0); + _detector.reset(millis()); + + #if defined(DISPLAY_TOUCH_DEBUG) && defined(PIN_TOUCH_INT) + pinMode(PIN_TOUCH_INT, INPUT_PULLUP); + #endif + + if (_present) { + Serial.printf("Touch: CHSC6X found at 0x%02X\n", CHSC6X_I2C_ADDR); + } else { + // Report what is actually on the bus, so an unexpected controller or + // address can be identified from a normal boot log. + Serial.printf("Touch: idle or absent at 0x%02X; I2C bus currently answers:", + CHSC6X_I2C_ADDR); + for (uint8_t addr = 8; addr < 0x78; addr++) { + _wire->beginTransmission(addr); + if (_wire->endTransmission() == 0) Serial.printf(" 0x%02X", addr); + } + Serial.println(); + } + return _present; + } + + bool isPresent() const { return _present; } + + // True exactly once per new touch. + bool checkTap(uint32_t now_ms) { + bool pressed = readPressed(); + if (pressed && !_present) { + _present = true; // answered late; the boot probe caught it mid-idle + Serial.println("Touch: CHSC6X responding"); + } + return _detector.update(now_ms, pressed); + } + +private: + TwoWire* _wire = NULL; + bool _present = false; + TouchTapDetector _detector; + + bool readPressed() { + // Probe the address first. This controller NACKs while it has nothing to + // report, and going straight to requestFrom() turns that into a logged bus + // error every poll plus, once the bus wedges, a ~1 s timeout stall in the + // UI loop. endTransmission() reports the same NACK quietly and cheaply. + _wire->beginTransmission((uint8_t)CHSC6X_I2C_ADDR); + if (_wire->endTransmission() != 0) return false; + + uint8_t got = _wire->requestFrom((uint8_t)CHSC6X_I2C_ADDR, (uint8_t)CHSC6X_READ_LEN); + uint8_t buf[CHSC6X_READ_LEN]; + for (uint8_t i = 0; i < CHSC6X_READ_LEN; i++) { + buf[i] = i < got ? (uint8_t)_wire->read() : 0xFF; + } + while (_wire->available()) _wire->read(); // drain a short read + + if (got != CHSC6X_READ_LEN) { + logRaw(got, NULL); + return false; + } + logRaw(got, buf); + + // Measured on the Expansion Kit V2 panel: byte 0 reads 0x00 while idle and + // 0x1F while a finger is down - not the 0x01 point count the reference + // CHSC6X drivers document, so testing for a count of 1 never fires. A + // partly-failed read leaves 0xFF, which must not register as a press. + return buf[0] != 0x00 && buf[0] != 0xFF; + } + +#ifdef DISPLAY_TOUCH_DEBUG + int16_t _logged = -1; + uint8_t _log_budget = 5; // always show the first few frames, then on change + + // Mostly logs on change, so a normal boot stays quiet - but the opening + // frames are unconditional so an idle read that never changes is still + // visible in the log. + void logRaw(uint8_t got, const uint8_t* buf) { + int16_t key = buf ? (int16_t)buf[0] : (int16_t)(-2 - (int16_t)got); + if (key == _logged && _log_budget == 0) return; + if (_log_budget > 0) _log_budget--; + _logged = key; + + if (!buf) { + Serial.printf("Touch: short read (%u of %u bytes)\n", got, CHSC6X_READ_LEN); + return; + } + Serial.printf("Touch: raw %02X %02X %02X %02X %02X", buf[0], buf[1], buf[2], buf[3], buf[4]); + #ifdef PIN_TOUCH_INT + // Pulled up, so an unfitted R13 sits steady HIGH and a wired INT pulses LOW. + Serial.printf(" INT=%d", digitalRead(PIN_TOUCH_INT)); + #endif + Serial.println(); + } +#else + void logRaw(uint8_t, const uint8_t*) {} +#endif +}; diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index fc0bc6264..8d6dadf14 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -36,6 +36,10 @@ public: (void)degrees; return false; } + // Rotate 180 degrees relative to the display's compiled orientation. This is + // intentionally separate from absolute rotation: fixed portrait/landscape + // layouts can be flipped without changing their geometry. + virtual void setFlipped(bool flipped) { (void)flipped; } virtual bool isEink() { return false; } // default to non-eink, override in eink drivers virtual void forceFullRefresh() {} // next refresh will be full for eink virtual void turnOn() = 0; diff --git a/src/helpers/ui/DisplayFrameSignature.h b/src/helpers/ui/DisplayFrameSignature.h new file mode 100644 index 000000000..9cf545d6f --- /dev/null +++ b/src/helpers/ui/DisplayFrameSignature.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +namespace DisplayFrameSignature { + +static constexpr uint32_t INITIAL = 2166136261u; + +inline uint32_t append(uint32_t signature, const char *text) { + if (text) { + while (*text) { + signature ^= static_cast(*text++); + signature *= 16777619u; + } + } + + // Separate adjacent fields so { "ab", "c" } differs from { "a", "bc" }. + signature ^= 0xFFu; + signature *= 16777619u; + return signature; +} + +} // namespace DisplayFrameSignature diff --git a/src/helpers/ui/DisplayViewport.h b/src/helpers/ui/DisplayViewport.h new file mode 100644 index 000000000..a3812c3ea --- /dev/null +++ b/src/helpers/ui/DisplayViewport.h @@ -0,0 +1,41 @@ +#pragma once + +#include + +namespace DisplayViewport { + +struct Geometry { + int16_t logical_width; + int16_t logical_height; + int16_t physical_width; + int16_t physical_height; + + int16_t mapX(int16_t x) const { + return static_cast((static_cast(x) * physical_width) / logical_width); + } + + int16_t mapY(int16_t y) const { + return static_cast((static_cast(y) * physical_height) / logical_height); + } + + uint16_t spanX(int16_t x, int16_t width) const { return static_cast(mapX(x + width) - mapX(x)); } + + uint16_t spanY(int16_t y, int16_t height) const { + return static_cast(mapY(y + height) - mapY(y)); + } + + uint16_t logicalWidthForPhysical(uint16_t width) const { + return static_cast((static_cast(width) * logical_width + physical_width - 1) / + physical_width); + } +}; + +inline uint8_t selectTextScale(uint16_t width_at_scale_one, uint8_t preferred_scale, uint8_t minimum_scale, + uint16_t available_width) { + if (static_cast(width_at_scale_one) * preferred_scale <= available_width) { + return preferred_scale; + } + return minimum_scale; +} + +} // namespace DisplayViewport diff --git a/src/helpers/ui/ObserverDashboard.h b/src/helpers/ui/ObserverDashboard.h new file mode 100644 index 000000000..e3aa6a5eb --- /dev/null +++ b/src/helpers/ui/ObserverDashboard.h @@ -0,0 +1,527 @@ +#pragma once + +#include +#include +#include + +#include "../RadioActivityWindow.h" +#include "DisplayDriver.h" +#include "DisplayFrameSignature.h" + +// Observer analytics dashboard for the Heltec V4 R8 TFT targets. +// +// Fork-owned and self-contained: the role UITasks only pick a layout, hand over +// a Context plus a snapshot, and ask for a full frame or a single changed row. +// Everything here is host-buildable against DisplayDriver, so the layout, the +// formatting and the redraw policy are all covered by test_observer_dashboard. + +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. + +constexpr ColorVal rgb565(uint8_t r, uint8_t g, uint8_t b) { + return (ColorVal)(((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)); +} + +constexpr ColorVal BG = rgb565(10, 12, 16); +constexpr ColorVal HEADER_BG = rgb565(18, 38, 74); +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 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); + +// Retunes the shared colour slots so every screen this UITask draws - boot, +// setup portal, reboot, power-off and the dashboard - is coherently dark. +inline void applyDarkPalette() { + UIColor::window_bkg = BG; + UIColor::title_bkg = HEADER_BG; + UIColor::title_txt = TEXT; + UIColor::primary_txt = TEXT; + UIColor::secondary_txt = MUTED; + UIColor::warning_txt = WARN; + UIColor::popup_bkg = HEADER_BG; + UIColor::popup_txt = TEXT; + UIColor::corp_blue = ACCENT; +} + +// ----------------------------------------------------------------- layout --- +// Logical 128x64 coordinates, as the shared DisplayDriver API expects. The two +// orientations need separate row pitches: a text row is a fixed 16 physical +// pixels, which is 3.2 logical units in portrait (y scale 5) but 4.27 in +// landscape (y scale 3.75), so one shared grid would overlap in landscape. + +struct Layout { + int16_t margin_x; + int16_t right_x; // right edge of the content column (exclusive) + int16_t header_h; + int16_t header_text_y; + int16_t header_sub_y; // second header row for the role label; -1 = same row + int16_t radio_y; + int16_t window_y; + int16_t headline_y; + int16_t headline_h; + int16_t rate_y; + int16_t graph_y; + int16_t graph_h; // includes the one-unit baseline at the bottom + int16_t rf_y; + int16_t status_y; + int16_t text_h; // logical height of one size-1 text row + int16_t max_chars; // size-1 characters that fit between the margins + int16_t max_chars_big; // size-2 characters that fit +}; + +// 240x320 panel: x scale 1.875, y scale 5, 12x16 glyphs (24x32 at size 2). +constexpr Layout portraitLayout() { + return Layout{ + /*margin_x*/ 4, /*right_x*/ 124, + /*header_h*/ 9, /*header_text_y*/ 1, /*header_sub_y*/ 5, + /*radio_y*/ 11, + /*window_y*/ 16, + /*headline_y*/ 21, /*headline_h*/ 7, + /*rate_y*/ 29, + /*graph_y*/ 34, /*graph_h*/ 16, + /*rf_y*/ 52, + /*status_y*/ 57, + /*text_h*/ 4, + /*max_chars*/ 18, /*max_chars_big*/ 9}; +} + +// 320x240 panel: x scale 2.5, y scale 3.75, 12x16 glyphs (30x40 at size 2). +constexpr Layout landscapeLayout() { + return Layout{ + /*margin_x*/ 4, /*right_x*/ 124, + /*header_h*/ 7, /*header_text_y*/ 1, /*header_sub_y*/ -1, + /*radio_y*/ 9, + /*window_y*/ 14, + /*headline_y*/ 19, /*headline_h*/ 12, + /*rate_y*/ 31, + /*graph_y*/ 36, /*graph_h*/ 12, + /*rf_y*/ 50, + /*status_y*/ 56, + /*text_h*/ 5, + /*max_chars*/ 25, /*max_chars_big*/ 10}; +} + +inline const Layout& activeLayout() { +#ifdef ST7789_PORTRAIT_PROFILE + static const Layout layout = portraitLayout(); +#else + static const Layout layout = landscapeLayout(); +#endif + return layout; +} + +// ------------------------------------------------------------- formatting --- +// Every value is formatted from integers. Arguments are widened explicitly so +// the same code is correct on a 32-bit target and on a 64-bit host. + +inline void formatCompactCount(char* out, size_t n, uint32_t v) { + if (v < 10000) { + snprintf(out, n, "%lu", (unsigned long)v); + } else if (v < 100000) { + snprintf(out, n, "%lu.%luk", (unsigned long)(v / 1000), (unsigned long)((v % 1000) / 100)); + } else if (v < 1000000) { + snprintf(out, n, "%luk", (unsigned long)(v / 1000)); + } else if (v < 100000000) { + snprintf(out, n, "%lu.%luM", (unsigned long)(v / 1000000), + (unsigned long)((v % 1000000) / 100000)); + } else { + snprintf(out, n, "%luM", (unsigned long)(v / 1000000)); + } +} + +inline void formatCompactBytes(char* out, size_t n, uint32_t v) { + if (v < 1024) { + snprintf(out, n, "%lu B", (unsigned long)v); + return; + } + if (v < 1048576UL) { + uint32_t tenths = (uint32_t)(((uint64_t)v * 10) / 1024); + if (tenths < 1000) { + snprintf(out, n, "%lu.%lu KB", (unsigned long)(tenths / 10), (unsigned long)(tenths % 10)); + } else { + snprintf(out, n, "%lu KB", (unsigned long)(tenths / 10)); + } + return; + } + uint32_t tenths = (uint32_t)(((uint64_t)v * 10) / 1048576UL); + if (tenths < 1000) { + snprintf(out, n, "%lu.%lu MB", (unsigned long)(tenths / 10), (unsigned long)(tenths % 10)); + } else { + snprintf(out, n, "%lu MB", (unsigned long)(tenths / 10)); + } +} + +// One decimal below 100, whole numbers above, so the field cannot grow wide. +inline void formatTenths(char* out, size_t n, uint32_t tenths) { + if (tenths < 1000) { + snprintf(out, n, "%lu.%lu", (unsigned long)(tenths / 10), (unsigned long)(tenths % 10)); + } else { + formatCompactCount(out, n, tenths / 10); + } +} + +inline void formatSignedTenths(char* out, size_t n, int32_t tenths) { + const char* sign = tenths < 0 ? "-" : "+"; + uint32_t mag = (uint32_t)(tenths < 0 ? -tenths : tenths); + snprintf(out, n, "%s%lu.%lu", sign, (unsigned long)(mag / 10), (unsigned long)(mag % 10)); +} + +// Quantised to the 5 s activity cadence so the string is stable within a tick +// and cannot make the status row repaint more often than the panel updates. +inline uint32_t quantizeAgeSecs(uint32_t age_ms) { return (age_ms / 5000) * 5; } + +inline void formatAge(char* out, size_t n, uint32_t age_ms, bool valid) { + if (!valid) { + snprintf(out, n, "--"); + return; + } + uint32_t secs = quantizeAgeSecs(age_ms); + if (secs < 5) { + snprintf(out, n, "now"); + } else if (secs < 60) { + snprintf(out, n, "%lus", (unsigned long)secs); + } else if (secs < 3600) { + snprintf(out, n, "%lum", (unsigned long)(secs / 60)); + } else if (secs < 86400) { + snprintf(out, n, "%luh", (unsigned long)(secs / 3600)); + } else { + snprintf(out, n, "%lud", (unsigned long)(secs / 86400)); + } +} + +// "910.525 SF7 BW62.5" / "869.618 SF8 BW250" - a trailing ".0" on the +// bandwidth would push the widest case past the content column. +inline void formatRadioStrip(char* out, size_t n, float freq, uint8_t sf, float bw) { + int32_t tenths = (int32_t)(bw * 10.0f + 0.5f); + char bw_str[24]; + if (tenths % 10 == 0) { + snprintf(bw_str, sizeof(bw_str), "%ld", (long)(tenths / 10)); + } else { + snprintf(bw_str, sizeof(bw_str), "%ld.%ld", (long)(tenths / 10), (long)(tenths % 10)); + } + snprintf(out, n, "%.3f SF%u BW%s", (double)freq, (unsigned)sf, bw_str); +} + +// Trims to a character budget rather than a measured width. The font is fixed +// width, so the budget is exact - and DisplayDriver::drawTextEllipsized() must +// not be used here: it trims against getTextWidth(), which reports an +// over-long string at the portrait driver's *fallback* scale and so stops +// trimming while the string is still too wide to draw at full size. +inline void fitToChars(char* out, size_t n, const char* src, int max_chars) { + if (max_chars < 0) max_chars = 0; + if ((size_t)max_chars > n - 1) max_chars = (int)(n - 1); + + size_t len = src ? strlen(src) : 0; + if (len <= (size_t)max_chars) { + memcpy(out, src ? src : "", len); + out[len] = 0; + return; + } + if (max_chars <= 3) { + memcpy(out, src, (size_t)max_chars); + out[max_chars] = 0; + return; + } + memcpy(out, src, (size_t)max_chars - 3); + memcpy(out + max_chars - 3, "...", 4); +} + +// ------------------------------------------------------------- row content -- + +enum Row { ROW_WINDOW = 0, ROW_HEADLINE, ROW_RATE, ROW_GRAPH, ROW_RF, ROW_STATUS, ROW_COUNT }; + +struct Context { + const char* node_name; + const char* role_label; // "REPEATER" / "ROOM SERVER" + float freq; + uint8_t sf; + float bw; + bool link_up; +}; + +struct RowText { + char left[24]; + char right[24]; + ColorVal left_color; + ColorVal right_color; +}; + +inline void composeRow(Row row, const Context& ctx, const RadioActivitySnapshot& s, RowText* out) { + out->left[0] = out->right[0] = 0; + out->left_color = TEXT; + out->right_color = MUTED; + + // All compact helpers fit inside 15 visible characters. Keeping this + // intermediate bound tighter than RowText also lets the compiler prove that + // adding row labels cannot truncate the destination. + char scratch[16]; + switch (row) { + case ROW_WINDOW: + if (s.isWarmingUp()) { + snprintf(out->left, sizeof(out->left), "LIVE %lum", (unsigned long)s.warmupMinutes()); + } else { + snprintf(out->left, sizeof(out->left), "LAST 20m"); + } + out->left_color = MUTED; + if (s.peak_per_min > 0) { + formatCompactCount(scratch, sizeof(scratch), s.peak_per_min); + snprintf(out->right, sizeof(out->right), "max %s/m", scratch); + } + break; + + case ROW_HEADLINE: + if (s.isEmpty()) { + snprintf(out->left, sizeof(out->left), "No RF yet"); + out->left_color = MUTED; + } else { + formatCompactCount(scratch, sizeof(scratch), s.packets); + snprintf(out->left, sizeof(out->left), "%s pkt", scratch); + out->left_color = BAR_NOW; + } + break; + + case ROW_RATE: + formatCompactBytes(out->left, sizeof(out->left), s.wire_bytes); + formatTenths(scratch, sizeof(scratch), s.packetsPerMinuteX10()); + snprintf(out->right, sizeof(out->right), "%s/min", scratch); + if (s.isEmpty()) { + out->left_color = MUTED; + } + break; + + case ROW_RF: + if (s.isEmpty()) { + snprintf(out->left, sizeof(out->left), "SNR --"); + out->left_color = MUTED; + } else { + int32_t snr = s.avgSnrX10(); + formatSignedTenths(scratch, sizeof(scratch), snr); + snprintf(out->left, sizeof(out->left), "SNR %s", scratch); + out->left_color = snr >= 0 ? GOOD : (snr >= -70 ? TEXT : WARN); + } + { + uint32_t air = s.airtimePercentX10(); + formatTenths(scratch, sizeof(scratch), air); + snprintf(out->right, sizeof(out->right), "AIR %s%%", scratch); + out->right_color = air >= 100 ? WARN : MUTED; + } + break; + + case ROW_STATUS: + formatAge(scratch, sizeof(scratch), s.last_packet_age_ms, s.has_last_packet); + snprintf(out->left, sizeof(out->left), "RX %s", scratch); + out->left_color = s.has_last_packet ? TEXT : MUTED; + snprintf(out->right, sizeof(out->right), ctx.link_up ? "WiFi OK" : "WiFi --"); + out->right_color = ctx.link_up ? GOOD : WARN; + break; + + default: + break; + } +} + +inline int16_t rowY(const Layout& l, Row row) { + switch (row) { + case ROW_WINDOW: return l.window_y; + case ROW_HEADLINE: return l.headline_y; + case ROW_RATE: return l.rate_y; + case ROW_GRAPH: return l.graph_y; + case ROW_RF: return l.rf_y; + default: return l.status_y; + } +} + +inline int16_t rowH(const Layout& l, Row row) { + if (row == ROW_HEADLINE) return l.headline_h; + if (row == ROW_GRAPH) return l.graph_h; + return l.text_h; +} + +// --------------------------------------------------------------- the graph -- + +// Bar heights in logical units, oldest first. Any minute with traffic rounds up +// to at least one unit; empty minutes stay empty. +inline void barHeights(const Layout& l, const RadioActivitySnapshot& s, + uint8_t out[RADIO_ACTIVITY_BUCKETS]) { + uint16_t scale = s.peak_per_min > 0 ? s.peak_per_min : 1; + int16_t max_h = l.graph_h - 1; // the last unit is the baseline + for (int i = 0; i < RADIO_ACTIVITY_BUCKETS; i++) { + uint32_t v = s.buckets[i]; + out[i] = v == 0 ? 0 : (uint8_t)((v * max_h + scale - 1) / scale); + } +} + +inline int16_t barSlot(const Layout& l) { + return (int16_t)((l.right_x - l.margin_x) / RADIO_ACTIVITY_BUCKETS); +} + +// ------------------------------------------------------------- signatures --- +// One signature per row, computed from exactly what is drawn, so a repaint +// happens only where the pixels actually differ. + +inline uint32_t rowSignature(const Layout& l, Row row, const Context& ctx, + const RadioActivitySnapshot& s) { + uint32_t sig = DisplayFrameSignature::INITIAL; + if (row == ROW_GRAPH) { + uint8_t h[RADIO_ACTIVITY_BUCKETS]; + barHeights(l, s, h); + char buf[4 * RADIO_ACTIVITY_BUCKETS]; + size_t p = 0; + for (int i = 0; i < RADIO_ACTIVITY_BUCKETS && p + 4 < sizeof(buf); i++) { + p += (size_t)snprintf(buf + p, sizeof(buf) - p, "%u,", (unsigned)h[i]); + } + return DisplayFrameSignature::append(sig, buf); + } + + RowText t; + composeRow(row, ctx, s, &t); + sig = DisplayFrameSignature::append(sig, t.left); + return DisplayFrameSignature::append(sig, t.right); +} + +inline void allRowSignatures(const Layout& l, const Context& ctx, const RadioActivitySnapshot& s, + uint32_t out[ROW_COUNT]) { + for (int r = 0; r < ROW_COUNT; r++) out[r] = rowSignature(l, (Row)r, ctx, s); +} + +// ------------------------------------------------------------------ render -- + +// Right-hand text is measured and placed first, then the left text is +// ellipsized into whatever column is left, so the two can never collide. +// +// The right edge gets a one-unit gutter: getTextWidth() converts physical +// glyph widths back to logical units and rounds, so anchoring flush at right_x +// can land a pixel past it. +inline void drawPair(DisplayDriver& d, const Layout& l, int16_t y, const RowText& t, + int max_chars) { + int right_len = (int)strlen(t.right); + if (right_len > 0) { + int16_t rw = (int16_t)d.getTextWidth(t.right); + d.setColor(t.right_color); + d.setCursor((int16_t)(l.right_x - rw - 1), y); + d.print(t.right); + } + + int budget = max_chars - (right_len > 0 ? right_len + 1 : 0); + if (t.left[0] && budget > 0) { + char fitted[32]; + fitToChars(fitted, sizeof(fitted), t.left, budget); + d.setColor(t.left_color); + d.setCursor(l.margin_x, y); + d.print(fitted); + } +} + +inline void clearRow(DisplayDriver& d, const Layout& l, Row row) { + d.setColor(BG); + d.fillRect(l.margin_x, rowY(l, row), l.right_x - l.margin_x, rowH(l, row)); +} + +inline void drawGraph(DisplayDriver& d, const Layout& l, const RadioActivitySnapshot& s) { + int16_t base_y = (int16_t)(l.graph_y + l.graph_h - 1); + d.setColor(GRID); + d.fillRect(l.margin_x, base_y, l.right_x - l.margin_x, 1); + + uint8_t h[RADIO_ACTIVITY_BUCKETS]; + barHeights(l, s, h); + int16_t slot = barSlot(l); + for (int i = 0; i < RADIO_ACTIVITY_BUCKETS; i++) { + if (h[i] == 0) continue; + d.setColor(i == RADIO_ACTIVITY_BUCKETS - 1 ? BAR_NOW : BAR); + d.fillRect((int16_t)(l.margin_x + i * slot), (int16_t)(base_y - h[i]), (int16_t)(slot - 1), + h[i]); + } +} + +inline void drawRow(DisplayDriver& d, const Layout& l, Row row, const Context& ctx, + const RadioActivitySnapshot& s, bool clear_first) { + if (clear_first) clearRow(d, l, row); + + if (row == ROW_GRAPH) { + drawGraph(d, l, s); + return; + } + + RowText t; + composeRow(row, ctx, s, &t); + d.setTextSize(row == ROW_HEADLINE ? 2 : 1); + drawPair(d, l, rowY(l, row), t, row == ROW_HEADLINE ? l.max_chars_big : l.max_chars); + if (row == ROW_HEADLINE) d.setTextSize(1); +} + +inline void drawHeader(DisplayDriver& d, const Layout& l, const Context& ctx) { + d.setColor(HEADER_BG); + d.fillRect(0, 0, 128, l.header_h); + d.setTextSize(1); + + RowText t{}; + t.left_color = TEXT; + t.right_color = HEADER_SUB; + snprintf(t.left, sizeof(t.left), "%s", ctx.node_name ? ctx.node_name : ""); + snprintf(t.right, sizeof(t.right), "%s", ctx.role_label ? ctx.role_label : ""); + + if (l.header_sub_y < 0) { + drawPair(d, l, l.header_text_y, t, l.max_chars); // both fit on one line + return; + } + + // Narrow panel: the node name keeps the whole width and the role drops to a + // second line, rather than the name being ellipsized down to a few letters. + RowText name{}; + name.left_color = t.left_color; + memcpy(name.left, t.left, sizeof(name.left)); + drawPair(d, l, l.header_text_y, name, l.max_chars); + + RowText role{}; + role.left_color = t.right_color; + memcpy(role.left, t.right, sizeof(role.left)); + drawPair(d, l, l.header_sub_y, role, l.max_chars); +} + +inline void drawRadioStrip(DisplayDriver& d, const Layout& l, const Context& ctx) { + char tmp[32]; + formatRadioStrip(tmp, sizeof(tmp), ctx.freq, ctx.sf, ctx.bw); + char fitted[32]; + fitToChars(fitted, sizeof(fitted), tmp, l.max_chars); + d.setTextSize(1); + d.setColor(MUTED); + d.setCursor(l.margin_x, l.radio_y); + d.print(fitted); +} + +// Complete repaint. The caller has already run startFrame(), which clears to +// UIColor::window_bkg - the dark background applyDarkPalette() installed. +inline void drawFull(DisplayDriver& d, const Layout& l, const Context& ctx, + const RadioActivitySnapshot& s) { + drawHeader(d, l, ctx); + drawRadioStrip(d, l, ctx); + for (int r = 0; r < ROW_COUNT; r++) drawRow(d, l, (Row)r, ctx, s, false); +} + +// Repaints only the rows whose signature moved. Never touches the header, the +// radio strip, or anything outside the analytics rows, so no startFrame() and +// no whole-screen clear is involved. +inline bool drawChangedRows(DisplayDriver& d, const Layout& l, const Context& ctx, + const RadioActivitySnapshot& s, uint32_t signatures[ROW_COUNT]) { + uint32_t fresh[ROW_COUNT]; + allRowSignatures(l, ctx, s, fresh); + + bool drew = false; + for (int r = 0; r < ROW_COUNT; r++) { + if (fresh[r] == signatures[r]) continue; + drawRow(d, l, (Row)r, ctx, s, true); + signatures[r] = fresh[r]; + drew = true; + } + return drew; +} + +} // namespace ObserverDashboard diff --git a/src/helpers/ui/SSD1306Display.cpp b/src/helpers/ui/SSD1306Display.cpp index 64fa4948d..069faf00c 100644 --- a/src/helpers/ui/SSD1306Display.cpp +++ b/src/helpers/ui/SSD1306Display.cpp @@ -25,10 +25,20 @@ bool SSD1306Display::begin() { const bool started = display.begin(SSD1306_SWITCHCAPVCC, DISPLAY_ADDRESS, true, false) && i2c_probe(Wire, DISPLAY_ADDRESS); - if (started) setRotationDegrees(0); + if (started) { + _panel_ready = true; + applyRotation(); + } return started; } +void SSD1306Display::applyRotation() { + display.setRotation((_base_rotation + (_flipped ? 2 : 0)) & 3); + setDimensions(display.width(), display.height()); + display.clearDisplay(); + display.display(); +} + bool SSD1306Display::setRotationDegrees(uint16_t degrees) { uint8_t rotation; switch (degrees) { @@ -45,13 +55,17 @@ bool SSD1306Display::setRotationDegrees(uint16_t degrees) { default: return false; } - display.setRotation(rotation); - setDimensions(display.width(), display.height()); - display.clearDisplay(); - display.display(); + _base_rotation = rotation; + if (_panel_ready) applyRotation(); return true; } +void SSD1306Display::setFlipped(bool flipped) { + if (_flipped == flipped) return; + _flipped = flipped; + if (_panel_ready) applyRotation(); +} + void SSD1306Display::turnOn() { if (!_isOn) { if (_peripher_power) _peripher_power->claim(); @@ -69,6 +83,7 @@ void SSD1306Display::turnOff() { digitalWrite(PIN_OLED_RESET, LOW); #endif _peripher_power->release(); + _panel_ready = false; } _isOn = false; } diff --git a/src/helpers/ui/SSD1306Display.h b/src/helpers/ui/SSD1306Display.h index 642eee401..d753d9d7b 100644 --- a/src/helpers/ui/SSD1306Display.h +++ b/src/helpers/ui/SSD1306Display.h @@ -18,10 +18,18 @@ class SSD1306Display : public DisplayDriver { Adafruit_SSD1306 display; bool _isOn; + bool _panel_ready = false; + bool _flipped = false; +#ifdef DISPLAY_ROTATION + uint8_t _base_rotation = DISPLAY_ROTATION & 3; +#else + uint8_t _base_rotation = 0; +#endif uint8_t _color; RefCountedDigitalPin* _peripher_power; bool i2c_probe(TwoWire& wire, uint8_t addr); + void applyRotation(); public: SSD1306Display(RefCountedDigitalPin* peripher_power=NULL) : DisplayDriver(128, 64), display(128, 64, &Wire, PIN_OLED_RESET), @@ -34,6 +42,7 @@ public: bool isOn() override { return _isOn; } bool supportsRotation() const override { return true; } bool setRotationDegrees(uint16_t degrees) override; + void setFlipped(bool flipped) override; void turnOn() override; void turnOff() override; void clear() override; diff --git a/src/helpers/ui/ST7789LCDDisplay.cpp b/src/helpers/ui/ST7789LCDDisplay.cpp index 0d07a1137..4d37ebd3a 100644 --- a/src/helpers/ui/ST7789LCDDisplay.cpp +++ b/src/helpers/ui/ST7789LCDDisplay.cpp @@ -1,5 +1,9 @@ #include "ST7789LCDDisplay.h" +#ifdef ST7789_PORTRAIT_PROFILE + #include "DisplayViewport.h" +#endif + #ifndef PIN_TFT_MISO #define PIN_TFT_MISO -1 #endif @@ -19,6 +23,29 @@ #define DISPLAY_WIDTH 240 #define DISPLAY_HEIGHT 320 +#ifdef ST7789_PORTRAIT_PROFILE + #ifndef ST7789_PORTRAIT_TEXT_SCALE + #define ST7789_PORTRAIT_TEXT_SCALE 2 + #endif + +static DisplayViewport::Geometry portraitViewport(int16_t physical_width, int16_t physical_height) { + return {128, 64, physical_width, physical_height}; +} +#endif + +// The compiled orientation, optionally turned 180 degrees by `display.flip`. +// Adding 2 keeps portrait portrait and landscape landscape, so the viewport +// geometry below never has to change with it. +uint8_t ST7789LCDDisplay::effectiveRotation() const { + return (uint8_t)((DISPLAY_ROTATION + (_flipped ? 2 : 0)) & 3); +} + +void ST7789LCDDisplay::setFlipped(bool flipped) { + if (_flipped == flipped) return; + _flipped = flipped; + if (_panel_ready) display.setRotation(effectiveRotation()); +} + bool ST7789LCDDisplay::i2c_probe(TwoWire& wire, uint8_t addr) { return true; } @@ -36,6 +63,22 @@ ColorVal UIColor::corp_blue = 0x001A; bool ST7789LCDDisplay::begin() { if (!_isOn) { + #ifdef HELTEC_V4_R8_TFT + // turnOff() leaves this panel configured and powered - its reset line is + // shared with the touch controller, so it is never parked low - which makes + // waking just a backlight switch. Re-running the init below would re-enter + // SPI setup and pulse GPIO 21, resetting the touch controller on every wake + // and stalling the UI loop for ~500 ms of reset delays. + if (_panel_ready) { + if (_peripher_power) _peripher_power->claim(); + if (PIN_TFT_LEDA_CTL != -1) { + digitalWrite(PIN_TFT_LEDA_CTL, PIN_TFT_LEDA_CTL_ACTIVE); + } + _isOn = true; + return true; + } + #endif + if (_peripher_power) { _peripher_power->claim(); #ifdef HELTEC_V4_R8_TFT @@ -58,13 +101,19 @@ bool ST7789LCDDisplay::begin() { #endif display.init(DISPLAY_WIDTH, DISPLAY_HEIGHT); - display.setRotation(DISPLAY_ROTATION); + display.setRotation(effectiveRotation()); display.setSPISpeed(40e6); display.fillScreen(ST77XX_BLACK); display.setTextColor(ST77XX_WHITE); - display.setTextSize(2 * DISPLAY_SCALE_X); + #ifdef ST7789_PORTRAIT_PROFILE + _logical_text_size = 1; + display.setTextSize(ST7789_PORTRAIT_TEXT_SCALE); + display.setTextWrap(false); + #else + display.setTextSize(2 * DISPLAY_SCALE_X); + #endif display.cp437(true); // Use full 256 char 'Code Page 437' font #ifdef HELTEC_V4_R8_TFT @@ -73,6 +122,7 @@ bool ST7789LCDDisplay::begin() { } #endif + _panel_ready = true; _isOn = true; } @@ -92,13 +142,18 @@ void ST7789LCDDisplay::turnOff() { digitalWrite(PIN_TFT_LEDA_CTL, HIGH); #endif } + #ifndef HELTEC_V4_R8_TFT if (PIN_TFT_RST != -1) { digitalWrite(PIN_TFT_RST, LOW); } - #ifndef HELTEC_V4_R8_TFT if (PIN_TFT_LEDA_CTL != -1) { digitalWrite(PIN_TFT_LEDA_CTL, LOW); } + #else + // On the V4 R8 Expansion Kit this reset line is shared with the touch + // panel's TP_RST, so parking it low would hold the touch controller in + // reset for as long as the display is off. Killing the backlight is what + // "off" means for this LCD anyway. #endif _isOn = false; @@ -113,12 +168,22 @@ void ST7789LCDDisplay::clear() { void ST7789LCDDisplay::startFrame(ColorVal bkg) { display.fillScreen(bkg); display.setTextColor(_color = UIColor::primary_txt); +#ifdef ST7789_PORTRAIT_PROFILE + _logical_text_size = 1; + display.setTextSize(ST7789_PORTRAIT_TEXT_SCALE); +#else display.setTextSize(1 * DISPLAY_SCALE_X); // This one affects size of Please wait... message +#endif display.cp437(true); // Use full 256 char 'Code Page 437' font } void ST7789LCDDisplay::setTextSize(int sz) { +#ifdef ST7789_PORTRAIT_PROFILE + _logical_text_size = sz > 0 ? static_cast(sz) : 1; + display.setTextSize(_logical_text_size * ST7789_PORTRAIT_TEXT_SCALE); +#else display.setTextSize(sz * DISPLAY_SCALE_X); +#endif } void ST7789LCDDisplay::setColor(ColorVal c) { @@ -126,24 +191,70 @@ void ST7789LCDDisplay::setColor(ColorVal c) { } void ST7789LCDDisplay::setCursor(int x, int y) { +#ifdef ST7789_PORTRAIT_PROFILE + DisplayViewport::Geometry viewport = portraitViewport(display.width(), display.height()); + display.setCursor(viewport.mapX(x), viewport.mapY(y)); +#else display.setCursor(x * DISPLAY_SCALE_X, y * DISPLAY_SCALE_Y); +#endif } void ST7789LCDDisplay::print(const char* str) { +#ifdef ST7789_PORTRAIT_PROFILE + int16_t cursor_x = display.getCursorX(); + if (cursor_x < 0) { + cursor_x = 0; + display.setCursor(cursor_x, display.getCursorY()); + } + if (cursor_x >= display.width()) return; + + printFitted(str, static_cast(display.width() - cursor_x)); +#else display.print(str); +#endif } void ST7789LCDDisplay::fillRect(int x, int y, int w, int h) { +#ifdef ST7789_PORTRAIT_PROFILE + DisplayViewport::Geometry viewport = portraitViewport(display.width(), display.height()); + display.fillRect(viewport.mapX(x), viewport.mapY(y), viewport.spanX(x, w), viewport.spanY(y, h), _color); +#else display.fillRect(x * DISPLAY_SCALE_X, y * DISPLAY_SCALE_Y, w * DISPLAY_SCALE_X, h * DISPLAY_SCALE_Y, _color); +#endif } void ST7789LCDDisplay::drawRect(int x, int y, int w, int h) { +#ifdef ST7789_PORTRAIT_PROFILE + DisplayViewport::Geometry viewport = portraitViewport(display.width(), display.height()); + display.drawRect(viewport.mapX(x), viewport.mapY(y), viewport.spanX(x, w), viewport.spanY(y, h), _color); +#else display.drawRect(x * DISPLAY_SCALE_X, y * DISPLAY_SCALE_Y, w * DISPLAY_SCALE_X, h * DISPLAY_SCALE_Y, _color); +#endif } void ST7789LCDDisplay::drawXbm(int x, int y, const uint8_t* bits, int w, int h) { uint8_t byteWidth = (w + 7) / 8; +#ifdef ST7789_PORTRAIT_PROFILE + DisplayViewport::Geometry viewport = portraitViewport(display.width(), display.height()); + int16_t physical_y = viewport.mapY(y); + + for (int j = 0; j < h; j++) { + // Scale both bitmap axes from the logical X ratio so logo pixels stay square. + int16_t y0 = physical_y + viewport.mapX(j); + int16_t y1 = physical_y + viewport.mapX(j + 1); + for (int i = 0; i < w; i++) { + uint8_t byte = bits[j * byteWidth + i / 8]; + bool pixelOn = byte & (0x80 >> (i & 7)); + + if (pixelOn) { + int16_t x0 = viewport.mapX(x + i); + int16_t x1 = viewport.mapX(x + i + 1); + display.fillRect(x0, y0, x1 - x0, y1 - y0, _color); + } + } + } +#else for (int j = 0; j < h; j++) { for (int i = 0; i < w; i++) { uint8_t byte = bits[j * byteWidth + i / 8]; @@ -158,16 +269,74 @@ void ST7789LCDDisplay::drawXbm(int x, int y, const uint8_t* bits, int w, int h) } } } +#endif } uint16_t ST7789LCDDisplay::getTextWidth(const char* str) { +#ifdef ST7789_PORTRAIT_PROFILE + uint8_t physical_scale = selectTextScale(str, display.width()); + uint16_t physical_width = measureTextWidth(str, physical_scale); + if (physical_width > display.width()) physical_width = display.width(); + + DisplayViewport::Geometry viewport = portraitViewport(display.width(), display.height()); + return viewport.logicalWidthForPhysical(physical_width); +#else int16_t x1, y1; uint16_t w, h; display.getTextBounds(str, 0, 0, &x1, &y1, &w, &h); return w / DISPLAY_SCALE_X; +#endif } +#ifdef ST7789_PORTRAIT_PROFILE +uint16_t ST7789LCDDisplay::measureTextWidth(const char* str, uint8_t physical_scale) { + int16_t x1, y1; + uint16_t w, h; + display.setTextSize(physical_scale); + display.getTextBounds(str, 0, 0, &x1, &y1, &w, &h); + return w; +} + +uint8_t ST7789LCDDisplay::selectTextScale(const char* str, uint16_t available_width) { + uint16_t width_at_scale_one = measureTextWidth(str, 1); + uint8_t preferred_scale = _logical_text_size * ST7789_PORTRAIT_TEXT_SCALE; + return DisplayViewport::selectTextScale(width_at_scale_one, preferred_scale, _logical_text_size, + available_width); +} + +void ST7789LCDDisplay::printFitted(const char* str, uint16_t available_width) { + if (!str || available_width == 0) return; + + uint8_t physical_scale = selectTextScale(str, available_width); + if (measureTextWidth(str, physical_scale) <= available_width) { + display.print(str); + return; + } + + static const char* ellipsis = "..."; + uint16_t ellipsis_width = measureTextWidth(ellipsis, physical_scale); + if (ellipsis_width > available_width) return; + + char fitted[256]; + size_t len = strlen(str); + if (len > sizeof(fitted) - 4) len = sizeof(fitted) - 4; + memcpy(fitted, str, len); + fitted[len] = 0; + + while (len > 0 && measureTextWidth(fitted, physical_scale) + ellipsis_width > available_width) { + --len; + while (len > 0 && (static_cast(fitted[len]) & 0xC0) == 0x80) + --len; + fitted[len] = 0; + } + + memcpy(fitted + len, ellipsis, 4); + display.setTextSize(physical_scale); + display.print(fitted); +} +#endif + void ST7789LCDDisplay::endFrame() { // display.display(); } diff --git a/src/helpers/ui/ST7789LCDDisplay.h b/src/helpers/ui/ST7789LCDDisplay.h index b5127d350..24c5a04b0 100644 --- a/src/helpers/ui/ST7789LCDDisplay.h +++ b/src/helpers/ui/ST7789LCDDisplay.h @@ -13,9 +13,20 @@ class ST7789LCDDisplay : public DisplayDriver { #endif Adafruit_ST7789 display; bool _isOn; + bool _panel_ready = false; // panel has been configured at least once + bool _flipped = false; uint16_t _color; RefCountedDigitalPin* _peripher_power; +#ifdef ST7789_PORTRAIT_PROFILE + uint8_t _logical_text_size = 1; + + uint16_t measureTextWidth(const char* str, uint8_t physical_scale); + uint8_t selectTextScale(const char* str, uint16_t available_width); + void printFitted(const char* str, uint16_t available_width); +#endif + + uint8_t effectiveRotation() const; bool i2c_probe(TwoWire& wire, uint8_t addr); public: #ifdef USE_PIN_TFT @@ -48,6 +59,7 @@ public: void turnOff() override; void clear() override; void startFrame(ColorVal bkg = UIColor::window_bkg) override; + void setFlipped(bool flipped) override; void setTextSize(int sz) override; void setColor(ColorVal c) override; void setCursor(int x, int y) override; diff --git a/src/helpers/ui/TouchTapDetector.h b/src/helpers/ui/TouchTapDetector.h new file mode 100644 index 000000000..b7de09766 --- /dev/null +++ b/src/helpers/ui/TouchTapDetector.h @@ -0,0 +1,65 @@ +#pragma once + +#include + +// Debounced rising-edge detector for a polled touch panel. +// +// Pure logic: no Arduino, no I2C. The caller polls the panel and hands over a +// raw "finger down" reading; this decides when that counts as a new tap. All +// elapsed-time comparisons are unsigned subtractions, so millis() rollover is +// a non-event. + +// Must exceed the caller's poll interval, or a state change is confirmed by the +// very next sample and the debounce does nothing. At the 50 ms touch poll this +// requires two consecutive consistent reads, so one NACK or short read during a +// continuous touch cannot fake a release (and therefore a second tap). +#ifndef TOUCH_TAP_DEBOUNCE_MS +#define TOUCH_TAP_DEBOUNCE_MS 80 +#endif + +// Ignores a second tap arriving this soon after an accepted one, so a bouncy +// panel or a slightly long press cannot toggle the display twice. +#ifndef TOUCH_TAP_MIN_GAP_MS +#define TOUCH_TAP_MIN_GAP_MS 400 +#endif + +class TouchTapDetector { +public: + TouchTapDetector() { reset(0); } + + void reset(uint32_t now_ms = 0) { + _raw = false; + _stable = false; + _changed_at = now_ms; + _last_tap = now_ms; + _tapped_before = false; + } + + // Returns true exactly once per accepted finger-down. + bool update(uint32_t now_ms, bool pressed) { + if (pressed != _raw) { // reading moved; restart the settling window + _raw = pressed; + _changed_at = now_ms; + return false; + } + if (now_ms - _changed_at < TOUCH_TAP_DEBOUNCE_MS) return false; // not settled + if (_raw == _stable) return false; // nothing new + + _stable = _raw; + if (!_stable) return false; // this is the release, not a tap + + if (_tapped_before && (now_ms - _last_tap) < TOUCH_TAP_MIN_GAP_MS) return false; + _last_tap = now_ms; + _tapped_before = true; + return true; + } + + bool isTouched() const { return _stable; } + +private: + uint32_t _changed_at; + uint32_t _last_tap; + bool _raw; + bool _stable; + bool _tapped_before; +}; diff --git a/test/README.md b/test/README.md index bb07b3693..10db0d9a8 100644 --- a/test/README.md +++ b/test/README.md @@ -37,8 +37,13 @@ does not reflect the GoogleTest count -- run the built binary directly | `test_mqtt_packet_queue_policy` | `src/helpers/MQTTPacketQueuePolicy.h` | queue-full eviction; stale-disconnect flush; adaptive drain limits; bounded QoS0 retries; exact timing boundaries and 32-bit `millis()` rollover | | `test_mqtt_packet_filter` | `src/helpers/MQTTPacketFilter.h` | per-slot 0-15 allowlist parsing/formatting, numeric and named spellings; exact bounds; membership; candidate/eligible split and retry-completion policy; pre-queue union gate; default-mask detection | | `test_mqtt_runtime_buffer_lifecycle` | `src/helpers/MQTTRuntimeBufferLifecycle.h` | idempotent allocation/release; partial-allocation degradation; retry of only missing buffers | -| `test_mqtt_prefs_codec` | `src/helpers/MQTTPrefsStorage.h`, `src/helpers/MQTTPrefsCodec.h` | binary pre-slot/3-slot/6-slot migration fixtures; v1 header integrity; downgrade preservation; shortest-payload write policy (default filters stay downgrade-readable) | +| `test_mqtt_prefs_codec` | `src/helpers/MQTTPrefsStorage.h`, `src/helpers/MQTTPrefsCodec.h`, `src/helpers/CompanionMqttPrefsNvs.h` | binary pre-slot/3-slot/6-slot and display-tail migration fixtures; v1 header integrity; fixed Companion rollback/recovery shapes; downgrade preservation; shortest-payload write policy | | `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_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 | +| `test_touch_tap_detector` | `src/helpers/ui/TouchTapDetector.h` | debounced one-shot taps, bounce and long-press handling, minimum gaps, reset, and `millis()` rollover | | `test_prefs_save_routing` | `src/helpers/PrefsSaveRouting.h` | runtime common/observer setters write only their owning preference image; mixed-owner setters and migrations can deliberately write both | | `test_mqtt_payload_builder` | `src/helpers/MQTTPayloadBuilder.cpp` | status/packet/raw JSON contracts; optional fields; escaping; RX metrics and path; score handling; exact buffer bounds; maximum representative payloads | | `test_telemetry_history` | `src/helpers/TelemetryHistory.h`, `ExternalVoltageHistory.h` | 30-minute rings; seven-day temperature/battery and four-day multi-channel I2C voltage retention; exact 1 C temperature/status and 0.02 V packed voltage encodings; zero-only channel omission; Base64 pages and raw chunks; GPS differentials, resize preservation, heap budgets, and paging bounds | @@ -49,7 +54,7 @@ does not reflect the GoogleTest count -- run the built binary directly | `test_gps_time_validation` | `src/helpers/sensors/GpsTimeValidation.h` | complete NMEA UTC validation; leap years and calendar bounds; rejection of receiver-default and signed-32-bit-out-of-range dates | | `test_identity_generation` | `src/helpers/IdentityGeneration.h` | reserved-prefix rejection; bounded retries; final provisioned attempt; fail-closed exhaustion | | `test_remote_cli_reply_cache` | `src/helpers/RemoteCliReplyCache.h`, `src/helpers/RemoteCliRequest.h`, `src/helpers/RemoteCliTimeout.h` | authenticated logical-request matching; bounded recent-reply history; backward-compatible retry identity; 300% response timeout; empty-response completion; on-air truncation and clearing | -| `test_companion_frame_queue` | `src/helpers/CompanionFrameQueue.h` | response/required/best-effort classification; reserved capacity; stable priority; safe eviction; message-waiting coalescing | +| `test_companion_frame_queue` | `src/helpers/CompanionFrameQueue.h`, `src/helpers/CompanionHardwareCommandCompat.h` | response/required/best-effort classification; reserved capacity; stable priority; safe eviction; message-waiting coalescing; command `0x42` framed-CLI disambiguation and deprecated hardware-alias mapping | | `test_companion_status_response` | `src/helpers/CompanionStatusResponse.h` | request-tag correlation and minimum status-response length, including rejection of the three-entry ACL payload that previously masqueraded as status | | `test_serial_mode_switch` | `src/helpers/ArduinoSerialInterface.cpp`, `src/helpers/MultiSerialInterface.h` | terminal/seeder control-sequence recognition, single-TTY logging transitions, and USB/TCP ownership; queued/atomic USB output under backpressure and short writes; partial-frame busy state; requester-affine replies, locked contact streams, and Bluetooth-only pairing routing | | `test_ble_tx_stall_watchdog` | `src/helpers/BleTxStallWatchdog.h` | exact BLE fragment progress; blocked-reply timeout; rollover-safe elapsed time; disconnect recovery retry and completion | diff --git a/test/test_companion_frame_queue/test_companion_frame_queue.cpp b/test/test_companion_frame_queue/test_companion_frame_queue.cpp index 352ddbb9b..52db3110a 100644 --- a/test/test_companion_frame_queue/test_companion_frame_queue.cpp +++ b/test/test_companion_frame_queue/test_companion_frame_queue.cpp @@ -1,6 +1,7 @@ #include #include +#include namespace { @@ -125,6 +126,35 @@ TEST(CompanionFrameQueue, ResponseDoesNotEvictAnotherResponse) { EXPECT_EQ(0x06, queue[2].buf[0]); } +TEST(CompanionHardwareCommandCompat, Command66TextWinsOverBareLegacyGet) { + EXPECT_FALSE(mesh::companion::isRunCliFrame(0x42, 1)); + EXPECT_TRUE(mesh::companion::isFemRxGainGet(0x42)); + EXPECT_TRUE(mesh::companion::isRunCliFrame(0x42, 2)); + EXPECT_FALSE(mesh::companion::isRunCliFrame(0x41, 2)); +} + +TEST(CompanionHardwareCommandCompat, BothShippedAliasBlocksRemainInbound) { + EXPECT_TRUE(mesh::companion::isFemRxGainGet(0x42)); + EXPECT_TRUE(mesh::companion::isFemRxGainGet(0x78)); + EXPECT_TRUE(mesh::companion::isFemRxGainSet(0x43)); + EXPECT_TRUE(mesh::companion::isFemRxGainSet(0x79)); + EXPECT_TRUE(mesh::companion::isRadioRxGainGet(0x44)); + EXPECT_TRUE(mesh::companion::isRadioRxGainGet(0x7A)); + EXPECT_TRUE(mesh::companion::isRadioRxGainSet(0x45)); + EXPECT_TRUE(mesh::companion::isRadioRxGainSet(0x7B)); + EXPECT_TRUE(mesh::companion::isWiFiPowerSaveGet(0x46)); + EXPECT_TRUE(mesh::companion::isWiFiPowerSaveGet(0x7C)); + EXPECT_TRUE(mesh::companion::isWiFiPowerSaveSet(0x47)); + EXPECT_TRUE(mesh::companion::isWiFiPowerSaveSet(0x7D)); + EXPECT_TRUE(mesh::companion::isBluetoothNameGet(0x48)); + EXPECT_TRUE(mesh::companion::isBluetoothNameGet(0x7E)); + EXPECT_TRUE(mesh::companion::isBluetoothNameSet(0x49)); + EXPECT_TRUE(mesh::companion::isBluetoothNameSet(0x7F)); + + EXPECT_FALSE(mesh::companion::isRadioRxGainGet(0x78)); + EXPECT_FALSE(mesh::companion::isWiFiPowerSaveSet(0x7F)); +} + } // namespace int main(int argc, char** argv) { diff --git a/test/test_config_serializer/test_config_serializer.cpp b/test/test_config_serializer/test_config_serializer.cpp index a830775f2..6ab085a64 100644 --- a/test/test_config_serializer/test_config_serializer.cpp +++ b/test/test_config_serializer/test_config_serializer.cpp @@ -189,6 +189,34 @@ TEST(ConfigSerializer, LoadSerial_MissingCommas) { EXPECT_FALSE(success); } +TEST(ConfigSerializer, LoadSerial_MissingCommaAfterObject) { + MockInputStream s("{radio:{} bridge:{}}"); + TestStruct data; + + EXPECT_FALSE(data.loadSerial(s)); +} + +TEST(ConfigSerializer, LoadSerial_RequiresOneCompleteRootObject) { + for (const char* text : { + "", + " \r\n\t ", + "{age:" TEST_INT_S ",}", + "{age:" TEST_INT_S "},", + "{age:" TEST_INT_S "}garbage", + "{age:" TEST_INT_S "}{flags:1}"}) { + MockInputStream s(text); + TestStruct data; + EXPECT_FALSE(data.loadSerial(s)) << text; + } +} + +TEST(ConfigSerializer, LoadSerial_AcceptsEmptyObject) { + MockInputStream s("{} \r\n\t"); + TestStruct data; + + EXPECT_TRUE(data.loadSerial(s)); +} + TEST(ConfigSerializer, LoadSerial_IgnoreUnknowns) { MockInputStream s("{age:" TEST_INT_S ",xxx:" TEST_INT_S ",name:\"Scott\"}"); TestStruct data; diff --git a/test/test_display_viewport/test_display_viewport.cpp b/test/test_display_viewport/test_display_viewport.cpp new file mode 100644 index 000000000..35378bd09 --- /dev/null +++ b/test/test_display_viewport/test_display_viewport.cpp @@ -0,0 +1,83 @@ +#include "helpers/ui/DisplayFrameSignature.h" +#include "helpers/ui/DisplayViewport.h" + +#include + +namespace { + +const DisplayViewport::Geometry kPortrait{ 128, 64, 240, 320 }; + +} // namespace + +TEST(DisplayViewport, MapsLogicalBoundsToPortraitPanel) { + EXPECT_EQ(0, kPortrait.mapX(0)); + EXPECT_EQ(240, kPortrait.mapX(128)); + EXPECT_EQ(0, kPortrait.mapY(0)); + EXPECT_EQ(320, kPortrait.mapY(64)); +} + +TEST(DisplayViewport, MapsObserverRowsAcrossPortraitHeight) { + EXPECT_EQ(0, kPortrait.mapY(0)); + EXPECT_EQ(70, kPortrait.mapY(14)); + EXPECT_EQ(100, kPortrait.mapY(20)); + EXPECT_EQ(120, kPortrait.mapY(24)); + EXPECT_EQ(150, kPortrait.mapY(30)); + EXPECT_EQ(200, kPortrait.mapY(40)); + EXPECT_EQ(240, kPortrait.mapY(48)); + EXPECT_EQ(250, kPortrait.mapY(50)); +} + +TEST(DisplayViewport, FractionalHorizontalSpansHaveNoGapsOrOverlaps) { + int16_t previous_end = 0; + int total_width = 0; + + for (int x = 0; x < 128; ++x) { + int16_t start = kPortrait.mapX(x); + int16_t end = kPortrait.mapX(x + 1); + EXPECT_EQ(previous_end, start); + EXPECT_TRUE(end - start == 1 || end - start == 2); + total_width += end - start; + previous_end = end; + } + + EXPECT_EQ(240, previous_end); + EXPECT_EQ(240, total_width); +} + +TEST(DisplayViewport, ConvertsFittedPhysicalWidthBackToLogicalWidth) { + EXPECT_EQ(0, kPortrait.logicalWidthForPhysical(0)); + EXPECT_EQ(64, kPortrait.logicalWidthForPhysical(120)); + EXPECT_EQ(122, kPortrait.logicalWidthForPhysical(228)); + EXPECT_EQ(128, kPortrait.logicalWidthForPhysical(240)); +} + +TEST(DisplayViewport, SelectsPreferredTextScaleOnlyWhenItFits) { + EXPECT_EQ(2, DisplayViewport::selectTextScale(114, 2, 1, 240)); + EXPECT_EQ(1, DisplayViewport::selectTextScale(150, 2, 1, 240)); + EXPECT_EQ(1, DisplayViewport::selectTextScale(192, 2, 1, 229)); + EXPECT_EQ(1, DisplayViewport::selectTextScale(300, 2, 1, 240)); +} + +TEST(DisplayFrameSignature, ChangesWithVisibleContent) { + uint32_t initial = DisplayFrameSignature::INITIAL; + uint32_t home = DisplayFrameSignature::append(initial, "home"); + + EXPECT_EQ(home, DisplayFrameSignature::append(initial, "home")); + EXPECT_NE(home, DisplayFrameSignature::append(initial, "setup")); + EXPECT_NE(DisplayFrameSignature::append(home, "10.0.0.1"), DisplayFrameSignature::append(home, "10.0.0.2")); +} + +TEST(DisplayFrameSignature, KeepsAdjacentFieldsDistinct) { + uint32_t first = DisplayFrameSignature::append(DisplayFrameSignature::INITIAL, "ab"); + first = DisplayFrameSignature::append(first, "c"); + + uint32_t second = DisplayFrameSignature::append(DisplayFrameSignature::INITIAL, "a"); + second = DisplayFrameSignature::append(second, "bc"); + + EXPECT_NE(first, second); +} + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_mqtt_prefs_codec/test_mqtt_prefs_codec.cpp b/test/test_mqtt_prefs_codec/test_mqtt_prefs_codec.cpp index 25d385014..11b25c2fa 100644 --- a/test/test_mqtt_prefs_codec/test_mqtt_prefs_codec.cpp +++ b/test/test_mqtt_prefs_codec/test_mqtt_prefs_codec.cpp @@ -4,6 +4,7 @@ #include #define WITH_MQTT_BRIDGE 1 +#include "helpers/CompanionMqttPrefsNvs.h" #include "helpers/MQTTPrefsCodec.h" #include "helpers/MQTTPacketFilter.h" @@ -28,6 +29,8 @@ MQTTPrefs defaults() { prefs.alert_wifi_minutes = 30; prefs.alert_mqtt_minutes = 240; prefs.alert_min_interval_min = 60; + prefs.display_timeout_secs = DISPLAY_TIMEOUT_DEFAULT_SECS; + prefs.display_flip = 0; return prefs; } @@ -237,6 +240,8 @@ TEST(MQTTPrefsCodec, CurrentVersionedPayloadRoundTripsExactly) { for (int i = 0; i < MQTT_PREFS_SLOT_COUNT; ++i) { source.mqtt_slot_packet_filter[i] = static_cast(1u << i); } + source.display_timeout_secs = 300; + source.display_flip = 1; std::vector bytes(Codec::kEncodedSize); ASSERT_EQ(Codec::kEncodedSize, Codec::encode(source, bytes.data(), bytes.size())); @@ -251,8 +256,8 @@ TEST(MQTTPrefsCodec, CurrentVersionedPayloadRoundTripsExactly) { // /mqtt_prefs also carries the WiFi credentials, so a payload older firmware // rejects strands a downgraded node with no network and no way to save. The -// filter tail is only written once it actually holds something. -TEST(MQTTPrefsCodec, DefaultFiltersKeepTheDowngradeReadablePayloadLength) { +// optional tails are written only once they actually hold something. +TEST(MQTTPrefsCodec, DefaultFiltersAndDisplayKeepTheDowngradeReadablePayloadLength) { MQTTPrefs source = defaults(); strncpy(source.wifi_ssid, "home-net", sizeof(source.wifi_ssid) - 1); strncpy(source.mqtt_slot_preset[0], "analyzer-us", sizeof(source.mqtt_slot_preset[0]) - 1); @@ -292,12 +297,15 @@ TEST(MQTTPrefsCodec, AnyNonDefaultFilterOptsIntoTheLongerPayload) { static_cast(1u << 4), static_cast(MQTTPacketFilter::kAllPacketTypes & ~1u)}) { source.mqtt_slot_packet_filter[slot] = mask; - ASSERT_EQ(Codec::kV1BaselinePayloadSize, Codec::payloadLenFor(source)) << slot; + ASSERT_EQ(Codec::kV1PreDisplayPayloadSize, Codec::payloadLenFor(source)) << slot; std::vector bytes(Codec::kEncodedSize); - ASSERT_EQ(Codec::kEncodedSize, Codec::encode(source, bytes.data(), bytes.size())); + const size_t written = Codec::encode(source, bytes.data(), bytes.size()); + ASSERT_EQ(sizeof(MQTTPrefsHeader) + Codec::kV1PreDisplayPayloadSize, + written); + bytes.resize(written); const Codec::DecodePlan plan = classify(bytes); - ASSERT_EQ(Codec::kV1BaselinePayloadSize, plan.payload_len); + ASSERT_EQ(Codec::kV1PreDisplayPayloadSize, plan.payload_len); MQTTPrefs loaded = defaults(); memcpy(&loaded, bytes.data() + sizeof(MQTTPrefsHeader), plan.payload_len); @@ -322,10 +330,71 @@ TEST(MQTTPrefsCodec, EncodeRefusesAnOutputBufferShorterThanItsChosenPayload) { // A buffer that fits the short payload is not enough for the long one. MQTTPrefs longest = defaults(); longest.mqtt_slot_packet_filter[0] = 0; + longest.display_flip = 1; EXPECT_EQ(0u, Codec::encode(longest, bytes.data(), short_size)); EXPECT_EQ(Codec::kEncodedSize, Codec::encode(longest, bytes.data(), bytes.size())); } +TEST(MQTTPrefsCodec, EitherNonDefaultDisplayControlOptsIntoTheFullTail) { + for (int scenario = 0; scenario < 2; ++scenario) { + MQTTPrefs source = defaults(); + if (scenario == 0) { + source.display_timeout_secs = 0; + } else { + source.display_flip = 1; + } + + ASSERT_EQ(Codec::kV1BaselinePayloadSize, Codec::payloadLenFor(source)); + std::vector bytes(Codec::kEncodedSize); + ASSERT_EQ(Codec::kEncodedSize, + Codec::encode(source, bytes.data(), bytes.size())); + + const Codec::DecodePlan plan = classify(bytes); + ASSERT_EQ(Codec::Source::Current, plan.source); + ASSERT_EQ(Codec::kV1BaselinePayloadSize, plan.payload_len); + MQTTPrefs loaded = defaults(); + memcpy(&loaded, bytes.data() + sizeof(MQTTPrefsHeader), plan.payload_len); + EXPECT_EQ(source.display_timeout_secs, loaded.display_timeout_secs); + EXPECT_EQ(source.display_flip, loaded.display_flip); + } +} + +TEST(MQTTPrefsCodec, PreDisplayPayloadPreservesFiltersAndDefaultsDisplayTail) { + MQTTPrefs source = defaults(); + source.mqtt_slot_packet_filter[2] = 0; + source.display_timeout_secs = 900; + source.display_flip = 1; + + std::vector bytes( + sizeof(MQTTPrefsHeader) + Codec::kV1PreDisplayPayloadSize, 0); + writeHeader(&bytes, MQTT_PREFS_VERSION, + static_cast(Codec::kV1PreDisplayPayloadSize)); + memcpy(bytes.data() + sizeof(MQTTPrefsHeader), &source, + Codec::kV1PreDisplayPayloadSize); + + const Codec::DecodePlan plan = classify(bytes); + ASSERT_EQ(Codec::Source::Current, plan.source); + ASSERT_EQ(Codec::kV1PreDisplayPayloadSize, plan.payload_len); + MQTTPrefs loaded = defaults(); + memcpy(&loaded, bytes.data() + sizeof(MQTTPrefsHeader), plan.payload_len); + EXPECT_EQ(0, loaded.mqtt_slot_packet_filter[2]); + EXPECT_EQ(DISPLAY_TIMEOUT_DEFAULT_SECS, loaded.display_timeout_secs); + EXPECT_EQ(0, loaded.display_flip); +} + +TEST(MQTTPrefsCodec, RepairsInvalidDisplayTailValues) { + MQTTPrefs prefs = defaults(); + EXPECT_FALSE(Codec::repairDisplayPrefs(&prefs)); + EXPECT_FALSE(Codec::repairDisplayPrefs(nullptr)); + + prefs.display_timeout_secs = DISPLAY_TIMEOUT_MAX_SECS + 1; + prefs.display_flip = 7; + EXPECT_TRUE(Codec::repairDisplayPrefs(&prefs)); + EXPECT_EQ(DISPLAY_TIMEOUT_DEFAULT_SECS, prefs.display_timeout_secs); + EXPECT_EQ(0, prefs.display_flip); + EXPECT_FALSE(Codec::repairDisplayPrefs(&prefs)); +} + TEST(MQTTPrefsCodec, PreFilterV1PayloadDefaultsEverySlotToAllTypes) { MQTTPrefs source = defaults(); strncpy(source.mqtt_origin, "pre-filter-node", sizeof(source.mqtt_origin) - 1); @@ -491,7 +560,9 @@ TEST(MQTTPrefsCodec, LegacyPlausibilityRejectsHighEntropyBytesAtEveryWhitelisted TEST(MQTTPrefsCodec, UnsupportedHeaderlessSizesArePreserved) { // 3024 was produced briefly before versioning, but repository history says // that raw observer-tail form was not shipped. Preserve it rather than guess. - for (const size_t size : {size_t(471), size_t(473), size_t(1465), size_t(2905), size_t(3024)}) { + for (const size_t size : {size_t(471), size_t(473), size_t(1465), + size_t(2879), size_t(2880), size_t(2905), + size_t(3024)}) { std::vector bytes(size, 0); const Codec::DecodePlan plan = classify(bytes); EXPECT_EQ(Codec::Source::Corrupt, plan.source) << size; @@ -525,6 +596,8 @@ TEST(MQTTPrefsCodec, LongerSameVersionPayloadLoadsTheBaselineAndIgnoresTheTail) for (int i = 0; i < MQTT_PREFS_SLOT_COUNT; ++i) { source.mqtt_slot_packet_filter[i] = static_cast(1u << i); } + source.display_timeout_secs = 120; + source.display_flip = 1; // Baseline image plus a 40-byte tail of fields this build has never heard of. const size_t kTail = 40; @@ -551,6 +624,8 @@ TEST(MQTTPrefsCodec, LongerSameVersionPayloadLoadsTheBaselineAndIgnoresTheTail) EXPECT_STREQ("meshrank", loaded.mqtt_slot_preset[0]); EXPECT_EQ(1u, loaded.mqtt_neighbors_enabled); EXPECT_EQ(1u, loaded.mqtt_slot_packet_filter[0]); + EXPECT_EQ(120, loaded.display_timeout_secs); + EXPECT_EQ(1, loaded.display_flip); } // The rule has to hold for any appended size, including a single byte and a @@ -579,6 +654,23 @@ TEST(MQTTPrefsCodec, LongerDeclaredLengthWithoutTheBytesIsStillCorrupt) { EXPECT_TRUE(plan.preserve_file); } +TEST(CompanionMqttPrefsNvs, WritesRollbackReadableShapeAndAcceptsRecoveryShape) { + EXPECT_EQ(MQTT_PREFS_V1_PRE_DISPLAY_PAYLOAD_SIZE, + CompanionMqttPrefsNvs::kWriteSize); + EXPECT_LT(CompanionMqttPrefsNvs::kWriteSize, sizeof(MQTTPrefs)); + + EXPECT_TRUE(CompanionMqttPrefsNvs::accepts( + MQTT_PREFS_VERSION, CompanionMqttPrefsNvs::kWriteSize)); + EXPECT_TRUE(CompanionMqttPrefsNvs::accepts( + MQTT_PREFS_VERSION, CompanionMqttPrefsNvs::kRecoverySize)); + EXPECT_EQ(2880u, CompanionMqttPrefsNvs::kRecoverySize); + EXPECT_LE(CompanionMqttPrefsNvs::kRecoverySize, sizeof(MQTTPrefs)); + EXPECT_FALSE(CompanionMqttPrefsNvs::accepts( + MQTT_PREFS_VERSION + 1, CompanionMqttPrefsNvs::kWriteSize)); + EXPECT_FALSE(CompanionMqttPrefsNvs::accepts( + MQTT_PREFS_VERSION, CompanionMqttPrefsNvs::kWriteSize - 1)); +} + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); diff --git a/test/test_mqtt_prefs_json_import/test_mqtt_prefs_json_import.cpp b/test/test_mqtt_prefs_json_import/test_mqtt_prefs_json_import.cpp new file mode 100644 index 000000000..d90014067 --- /dev/null +++ b/test/test_mqtt_prefs_json_import/test_mqtt_prefs_json_import.cpp @@ -0,0 +1,371 @@ +#include + +#include +#include +#include + +#define WITH_MQTT_BRIDGE 1 +#define PROGMEM +#include "helpers/MQTTPrefsAtomicStore.h" +#include "helpers/MQTTPrefsCodec.h" +#include "helpers/MQTTPrefsJsonImport.h" + +namespace Import = MQTTPrefsJsonImport; +namespace Atomic = MQTTPrefsAtomicStore; + +namespace { + +class InputStream : public Stream { +public: + explicit InputStream(const std::string& text) : _text(text) {} + int available() override { return static_cast(_text.size() - _pos); } + int read() override { + return _pos < _text.size() + ? static_cast(_text[_pos++]) : -1; + } + int peek() override { + return _pos < _text.size() + ? static_cast(_text[_pos]) : -1; + } +private: + std::string _text; + size_t _pos = 0; +}; + +bool validPreset(const char* preset) { + return strcmp(preset, "analyzer-us") == 0 || + strcmp(preset, "analyzer-eu") == 0; +} + +MQTTPrefs makeDefaults(uint8_t wifi_power_save = 1) { + MQTTPrefs prefs = {}; + prefs.mqtt_status_enabled = 1; + prefs.mqtt_packets_enabled = 1; + prefs.mqtt_tx_enabled = 2; + prefs.mqtt_rx_enabled = 1; + prefs.mqtt_status_interval = 300000; + prefs.wifi_power_save = wifi_power_save; + prefs.timezone_offset = -7; + prefs.radio_watchdog_minutes = 5; + prefs.alert_wifi_minutes = 30; + prefs.alert_mqtt_minutes = 240; + prefs.alert_min_interval_min = 60; + prefs.mqtt_neighbors_interval = MQTT_NEIGHBORS_DEFAULT_INTERVAL_MS; + prefs.display_timeout_secs = DISPLAY_TIMEOUT_DEFAULT_SECS; + strcpy(prefs.snmp_community, "public"); + for (int i = 0; i < MQTT_PREFS_SLOT_COUNT; ++i) { + strcpy(prefs.mqtt_slot_preset[i], "none"); + prefs.mqtt_slot_packet_filter[i] = 0xffff; + } + return prefs; +} + +Import::Result decodeText(const std::string& text, + const MQTTPrefs& defaults, + MQTTPrefs* candidate) { + InputStream stream(text); + return Import::decode(stream, defaults, candidate, validPreset); +} + +class CommitStore { +public: + explicit CommitStore(bool commit_ok) : _commit_ok(commit_ok) { + primary = {'o', 'l', 'd'}; + } + bool begin() { + staging.clear(); + open = true; + return true; + } + size_t write(const uint8_t* data, size_t size) { + if (!open) return 0; + staging.insert(staging.end(), data, data + size); + return size; + } + bool finish() { + open = false; + finished = true; + return true; + } + bool commit() { + if (!_commit_ok) return false; + primary = staging; + finished = false; + return true; + } + void abort() { + open = false; + if (!finished) staging.clear(); + finished = false; + } + + std::vector primary; + std::vector staging; + bool open = false; + bool finished = false; +private: + bool _commit_ok; +}; + +Atomic::Result commitPrefs(CommitStore* store, const MQTTPrefs& prefs) { + std::vector encoded(MQTTPrefsCodec::kEncodedSize); + const size_t encoded_size = + MQTTPrefsCodec::encode(prefs, encoded.data(), encoded.size()); + EXPECT_GT(encoded_size, sizeof(MQTTPrefsHeader)); + return Atomic::write( + *store, encoded.data(), sizeof(MQTTPrefsHeader), + encoded.data() + sizeof(MQTTPrefsHeader), + encoded_size - sizeof(MQTTPrefsHeader)); +} + +} // namespace + +TEST(MQTTPrefsJsonImport, BinaryAndRecoveryArtifactsAlwaysTakePrecedence) { + using D = Import::Decision; + EXPECT_EQ(D::BinaryAuthoritative, + Import::select(false, true, false, false, true, false, false)); + EXPECT_EQ(D::BinaryAuthoritative, + Import::select(true, false, false, false, true, false, false)); + EXPECT_EQ(D::BinaryAuthoritative, + Import::select(false, false, true, false, true, false, false)); + EXPECT_EQ(D::BinaryAuthoritative, + Import::select(false, false, false, true, true, false, false)); + EXPECT_EQ(D::BinaryAuthoritative, + Import::select(false, true, false, false, false, true, true)); +} + +TEST(MQTTPrefsJsonImport, ImportsOnlyCleanPrimaryAndHoldsJsonArtifacts) { + using D = Import::Decision; + EXPECT_EQ(D::ImportPrimary, + Import::select(false, false, false, false, true, false, false)); + EXPECT_EQ(D::NoJsonSource, + Import::select(false, false, false, false, false, false, false)); + EXPECT_EQ(D::HoldJsonArtifacts, + Import::select(false, false, false, false, true, true, false)); + EXPECT_EQ(D::HoldJsonArtifacts, + Import::select(false, false, false, false, false, false, true)); +} + +TEST(MQTTPrefsJsonImport, DecodesEveryObserverGroupAndDisplayTail) { + const MQTTPrefs defaults = makeDefaults(); + MQTTPrefs candidate = defaults; + const char* text = + "{version:1," + "wifi:{ssid:\"mesh-net\",password:\"p\\\\\\\"ass\\nword\",power_save:2}," + "time:{timezone:\"MST7MDT\",utc_offset:-6,ntp_server:\"time.example\"}," + "mqtt:{origin:\"observer-one\",iata:\"sea\",packets_enabled:1," + "raw_enabled:1,tx_enabled:1,rx_enabled:0," + "status:{enabled:1,interval_ms:60000}," + "neighbors:{enabled:1,interval_ms:43200000}," + "owner:{public_key:\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"," + "email:\"owner@example.com\"}," + "slot1:{preset:\"analyzer-us\",host:\"broker.example\",port:1883," + "username:\"user\",password:\"secret\",token:\"token\"," + "topic:\"mesh/{iata}\",audience:\"aud\",packet_filter:32769}," + "slot6:{preset:\"custom\",host:\"six.example\",port:65535," + "username:\"u6\",password:\"p6\",token:\"t6\",topic:\"six\"," + "audience:\"a6\",packet_filter:0}}," + "snmp:{enabled:1,community:\"private\"},radio:{watchdog_min:120}," + "alert:{enabled:1,psk_hex:\"0123456789abcdef0123456789abcdef\"," + "wifi_minutes:45,mqtt_minutes:300,rate_limit_min:90," + "hashtag:\"#ops\",region:\"PNW\"}," + "display:{timeout_s:45,flip:1}}"; + + EXPECT_EQ(Import::Result::LoadedWithRepairs, + decodeText(text, defaults, &candidate)); + EXPECT_STREQ("mesh-net", candidate.wifi_ssid); + EXPECT_STREQ("p\\\"ass\nword", candidate.wifi_password); + EXPECT_EQ(2, candidate.wifi_power_save); + EXPECT_EQ(-6, candidate.timezone_offset); + EXPECT_STREQ("time.example", candidate.mqtt_ntp_server); + EXPECT_STREQ("observer-one", candidate.mqtt_origin); + EXPECT_STREQ("SEA", candidate.mqtt_iata); + EXPECT_EQ(1, candidate.mqtt_raw_enabled); + EXPECT_EQ(0, candidate.mqtt_rx_enabled); + EXPECT_EQ(60000u, candidate.mqtt_status_interval); + EXPECT_EQ(MQTT_NEIGHBORS_MIN_INTERVAL_MS, + candidate.mqtt_neighbors_interval); + EXPECT_STREQ("analyzer-us", candidate.mqtt_slot_preset[0]); + EXPECT_EQ(1883, candidate.mqtt_slot_port[0]); + EXPECT_EQ(32769, candidate.mqtt_slot_packet_filter[0]); + EXPECT_STREQ("custom", candidate.mqtt_slot_preset[5]); + EXPECT_EQ(65535, candidate.mqtt_slot_port[5]); + EXPECT_EQ(0, candidate.mqtt_slot_packet_filter[5]); + EXPECT_EQ(1, candidate.snmp_enabled); + EXPECT_STREQ("private", candidate.snmp_community); + EXPECT_EQ(120, candidate.radio_watchdog_minutes); + EXPECT_EQ(1, candidate.alert_enabled); + EXPECT_STREQ("#ops", candidate.alert_hashtag); + EXPECT_EQ(45, candidate.display_timeout_secs); + EXPECT_EQ(1, candidate.display_flip); +} + +TEST(MQTTPrefsJsonImport, MissingFieldsRetainCurrentBuildDefaults) { + MQTTPrefs defaults = makeDefaults(0); + strcpy(defaults.mqtt_iata, "PDX"); + defaults.display_timeout_secs = 60; + MQTTPrefs candidate = defaults; + EXPECT_EQ(Import::Result::Loaded, + decodeText("{version:1,mqtt:{origin:\"changed\"},display:{}}", + defaults, &candidate)); + EXPECT_STREQ("changed", candidate.mqtt_origin); + EXPECT_STREQ("PDX", candidate.mqtt_iata); + EXPECT_EQ(0, candidate.wifi_power_save); + EXPECT_EQ(300000u, candidate.mqtt_status_interval); + EXPECT_EQ(60, candidate.display_timeout_secs); +} + +TEST(MQTTPrefsJsonImport, SemanticRepairsUseSchemaAndCurrentWifiDefaults) { + MQTTPrefs defaults = makeDefaults(0); + MQTTPrefs candidate = defaults; + const char* text = + "{version:1,wifi:{power_save:9},time:{utc_offset:99,ntp_server:\"bad/host\"}," + "mqtt:{iata:\"bad!\",packets_enabled:-1,raw_enabled:2,tx_enabled:9," + "rx_enabled:2,status:{enabled:3,interval_ms:10}," + "neighbors:{enabled:2,interval_ms:100}," + "slot1:{preset:\"bogus\",port:-1,packet_filter:70000}}," + "snmp:{enabled:2},radio:{watchdog_min:121}," + "alert:{enabled:3,psk_hex:\"bad\",wifi_minutes:-1,mqtt_minutes:10081," + "rate_limit_min:1,hashtag:\"#stale\"}," + "display:{timeout_s:99999,flip:7}}"; + ASSERT_EQ(Import::Result::LoadedWithRepairs, + decodeText(text, defaults, &candidate)); + EXPECT_EQ(0, candidate.wifi_power_save); + EXPECT_EQ(-7, candidate.timezone_offset); + EXPECT_STREQ("", candidate.mqtt_ntp_server); + EXPECT_STREQ("", candidate.mqtt_iata); + EXPECT_EQ(1, candidate.mqtt_packets_enabled); + EXPECT_EQ(0, candidate.mqtt_raw_enabled); + EXPECT_EQ(2, candidate.mqtt_tx_enabled); + EXPECT_EQ(1, candidate.mqtt_rx_enabled); + EXPECT_EQ(300000u, candidate.mqtt_status_interval); + EXPECT_EQ(MQTT_NEIGHBORS_DEFAULT_INTERVAL_MS, + candidate.mqtt_neighbors_interval); + EXPECT_STREQ("none", candidate.mqtt_slot_preset[0]); + EXPECT_EQ(0, candidate.mqtt_slot_port[0]); + EXPECT_EQ(0xffff, candidate.mqtt_slot_packet_filter[0]); + EXPECT_EQ(0, candidate.snmp_enabled); + EXPECT_EQ(5, candidate.radio_watchdog_minutes); + EXPECT_EQ(0, candidate.alert_enabled); + EXPECT_STREQ("", candidate.alert_psk_hex); + EXPECT_STREQ("", candidate.alert_hashtag); + EXPECT_EQ(DISPLAY_TIMEOUT_DEFAULT_SECS, candidate.display_timeout_secs); + EXPECT_EQ(0, candidate.display_flip); +} + +TEST(MQTTPrefsJsonImport, RejectsKnownTypeDuplicateLengthAndOverflowErrors) { + const MQTTPrefs defaults = makeDefaults(); + for (const char* text : { + "{version:\"1\"}", + "{version:true}", + "{version:1.0}", + "{version:1,version:1}", + "{version:1,wifi:{ssid:\"one\"},wifi:{password:\"two\"}}", + "{version:1,wifi:{ssid:meshnet}}", + "{version:1,wifi:{ssid:\"12345678901234567890123456789012\"}}", + "{version:1,mqtt:{slot1:{port:999999999999}}}", + "{version:{x:1}}", + "{version:1,mqtt:1}", + "{version:1,mqtt:{slot1:1}}", + "{version:1,mqtt:{status:{enabled:1},status:{interval_ms:60000}}}"}) { + MQTTPrefs candidate = defaults; + EXPECT_FALSE(Import::loaded(decodeText(text, defaults, &candidate))) << text; + } + + std::string embedded_nul = "{version:1,wifi:{ssid:\"before"; + embedded_nul.push_back('\0'); + embedded_nul += "after\"}}"; + MQTTPrefs candidate = defaults; + EXPECT_EQ(Import::Result::InvalidSyntax, + decodeText(embedded_nul, defaults, &candidate)); +} + +TEST(MQTTPrefsJsonImport, RejectsTrailingMaterialAndMissingObjectSeparators) { + const MQTTPrefs defaults = makeDefaults(); + for (const char* text : { + "{version:1}garbage", + "{version:1},", + "{version:1,}", + "{version:1,wifi:{ssid:\"x\",}}", + "{version:1,wifi:{} mqtt:{}}"}) { + MQTTPrefs candidate = defaults; + EXPECT_EQ(Import::Result::InvalidSyntax, + decodeText(text, defaults, &candidate)) << text; + } + + MQTTPrefs candidate = defaults; + EXPECT_EQ(Import::Result::Loaded, + decodeText("{version:1,wifi:{}} \r\n\t", defaults, &candidate)); +} + +TEST(MQTTPrefsJsonImport, AcceptsSafelyParseableAppendOnlyUnknownFields) { + const MQTTPrefs defaults = makeDefaults(); + MQTTPrefs candidate = defaults; + EXPECT_EQ( + Import::Result::Loaded, + decodeText( + "{version:1,display:{timeout_s:45},future:{thing:1,nested:{x:2}}}", + defaults, &candidate)); + EXPECT_EQ(45, candidate.display_timeout_secs); +} + +TEST(MQTTPrefsJsonImport, FutureVersionIsOpaqueEvenWithFutureGrammar) { + const MQTTPrefs defaults = makeDefaults(); + MQTTPrefs candidate = defaults; + EXPECT_EQ(Import::Result::UnsupportedVersion, + decodeText("{version:2,future:[1]}", defaults, &candidate)); + candidate = defaults; + EXPECT_EQ(Import::Result::InvalidSchema, + decodeText("{version:0}", defaults, &candidate)); + candidate = defaults; + EXPECT_EQ(Import::Result::InvalidSchema, + decodeText("{wifi:{ssid:\"x\"}}", defaults, &candidate)); +} + +TEST(MQTTPrefsJsonImport, ParseFailureNeverTouchesLivePreferences) { + const MQTTPrefs defaults = makeDefaults(); + MQTTPrefs live = defaults; + strcpy(live.wifi_ssid, "live-network"); + MQTTPrefs scratch = defaults; + EXPECT_EQ(Import::Result::InvalidSyntax, + decodeText("{version:1,wifi:{ssid:\"uncommitted\"}", + defaults, &scratch)); + EXPECT_STREQ("live-network", live.wifi_ssid); +} + +TEST(MQTTPrefsJsonImport, ParsedDisplayPrefsRouteThroughFullAtomicBinaryCommit) { + const MQTTPrefs defaults = makeDefaults(); + MQTTPrefs candidate = defaults; + ASSERT_EQ(Import::Result::Loaded, + decodeText("{version:1,display:{timeout_s:45,flip:1}}", + defaults, &candidate)); + EXPECT_EQ(MQTT_PREFS_V1_FULL_PAYLOAD_SIZE, + MQTTPrefsCodec::payloadLenFor(candidate)); + + CommitStore store(true); + ASSERT_EQ(Atomic::Result::Committed, commitPrefs(&store, candidate)); + ASSERT_EQ(sizeof(MQTTPrefsHeader) + MQTT_PREFS_V1_FULL_PAYLOAD_SIZE, + store.primary.size()); + MQTTPrefsHeader header = {}; + memcpy(&header, store.primary.data(), sizeof(header)); + EXPECT_EQ(MQTT_PREFS_V1_FULL_PAYLOAD_SIZE, header.payload_len); +} + +TEST(MQTTPrefsJsonImport, FailedAtomicPublishPreservesPreviousBinaryImage) { + const MQTTPrefs defaults = makeDefaults(); + MQTTPrefs candidate = defaults; + ASSERT_EQ(Import::Result::Loaded, + decodeText("{version:1,wifi:{ssid:\"new\"}}", + defaults, &candidate)); + + CommitStore store(false); + const std::vector old_primary = store.primary; + EXPECT_EQ(Atomic::Result::CommitFailed, commitPrefs(&store, candidate)); + EXPECT_EQ(old_primary, store.primary); + EXPECT_FALSE(store.staging.empty()); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_observer_dashboard/MockDisplay.h b/test/test_observer_dashboard/MockDisplay.h new file mode 100644 index 000000000..23d243fec --- /dev/null +++ b/test/test_observer_dashboard/MockDisplay.h @@ -0,0 +1,122 @@ +#pragma once + +#include "helpers/ui/DisplayDriver.h" +#include "helpers/ui/DisplayViewport.h" + +#include +#include + +// A DisplayDriver that records physical-pixel draw calls instead of pushing +// them at a panel, reproducing the two real ST7789LCDDisplay coordinate and +// text-metric profiles: +// +// portrait (ST7789_PORTRAIT_PROFILE) 240x320, DisplayViewport mapping, +// physical text scale = logical * 2 +// landscape (default) 320x240, x * 2.5 / y * 3.75, +// physical text scale = (int)(logical * 2.5) +// +// Text extents are recorded at the size the layout *asked* for, so a row that +// only fits because the driver would silently shrink or clip it still shows up +// as an out-of-bounds op. +class MockDisplay : public DisplayDriver { +public: + enum Mode { PORTRAIT, LANDSCAPE }; + + struct Op { + enum Kind { FILL, RECT, TEXT } kind; + int x, y, w, h; // physical pixels + ColorVal color; + std::string text; + int logical_size; + bool scale_fallback; // portrait driver would have shrunk this string + }; + + explicit MockDisplay(Mode mode) + : DisplayDriver(128, 64), _mode(mode), _on(true), _color(0), _size(1), _cx(0), _cy(0) {} + + std::vector ops; + + int panelWidth() const { return _mode == PORTRAIT ? 240 : 320; } + int panelHeight() const { return _mode == PORTRAIT ? 320 : 240; } + + void reset() { ops.clear(); } + + // --- DisplayDriver --- + bool isOn() override { return _on; } + void turnOn() override { _on = true; } + void turnOff() override { _on = false; } + void clear() override { ops.clear(); } + void startFrame(ColorVal bkg = UIColor::window_bkg) override { + ops.clear(); + ops.push_back(Op{Op::FILL, 0, 0, panelWidth(), panelHeight(), bkg, "", 1, false}); + _size = 1; + } + void setTextSize(int sz) override { _size = sz > 0 ? sz : 1; } + void setColor(ColorVal c) override { _color = c; } + void setCursor(int x, int y) override { _cx = x; _cy = y; } + + void print(const char* str) override { + if (!str || !*str) return; + int n = (int)strlen(str); + int scale = physicalScale(_size); + int px = mapX(_cx), py = mapY(_cy); + bool fallback = false; + if (_mode == PORTRAIT) { + int available = panelWidth() - px; + if (n * 6 * scale > available) { + fallback = true; // the real driver drops to the minimum scale here + } + } + ops.push_back(Op{Op::TEXT, px, py, n * 6 * scale, 8 * scale, _color, std::string(str), _size, + fallback}); + _cx += (int)((n * 6 * scale) / xScale()); + } + + void fillRect(int x, int y, int w, int h) override { + ops.push_back(Op{Op::FILL, mapX(x), mapY(y), spanX(x, w), spanY(y, h), _color, "", _size, + false}); + } + void drawRect(int x, int y, int w, int h) override { + ops.push_back(Op{Op::RECT, mapX(x), mapY(y), spanX(x, w), spanY(y, h), _color, "", _size, + false}); + } + void drawXbm(int, int, const uint8_t*, int, int) override {} + void endFrame() override {} + + uint16_t getTextWidth(const char* str) override { + if (!str) return 0; + int n = (int)strlen(str); + int scale = physicalScale(_size); + if (_mode == PORTRAIT) { + // Mirrors ST7789LCDDisplay::getTextWidth(): measure at the scale the + // driver would pick, clamp to the panel, convert back to logical. + if (n * 6 * scale > panelWidth()) scale = _size; + int w = n * 6 * scale; + if (w > panelWidth()) w = panelWidth(); + DisplayViewport::Geometry g{128, 64, 240, 320}; + return g.logicalWidthForPhysical((uint16_t)w); + } + return (uint16_t)((n * 6 * scale) / 2.5f); + } + +private: + Mode _mode; + bool _on; + ColorVal _color; + int _size, _cx, _cy; + + float xScale() const { return _mode == PORTRAIT ? (240.0f / 128.0f) : 2.5f; } + + int physicalScale(int logical) const { + return _mode == PORTRAIT ? logical * 2 : (int)(uint8_t)(logical * 2.5f); + } + + int mapX(int x) const { + return _mode == PORTRAIT ? (int)((int32_t)x * 240 / 128) : (int)(x * 2.5f); + } + int mapY(int y) const { + return _mode == PORTRAIT ? (int)((int32_t)y * 320 / 64) : (int)(y * 3.75f); + } + int spanX(int x, int w) const { return mapX(x + w) - mapX(x); } + int spanY(int y, int h) const { return mapY(y + h) - mapY(y); } +}; diff --git a/test/test_observer_dashboard/test_observer_dashboard.cpp b/test/test_observer_dashboard/test_observer_dashboard.cpp new file mode 100644 index 000000000..7e036bba6 --- /dev/null +++ b/test/test_observer_dashboard/test_observer_dashboard.cpp @@ -0,0 +1,581 @@ +#include "MockDisplay.h" +#include "helpers/ui/ObserverDashboard.h" + +#include + +// UIColor's slots live in whichever display driver a firmware target links; the +// host build supplies its own. +ColorVal UIColor::window_bkg = 0; +ColorVal UIColor::title_bkg = 0; +ColorVal UIColor::title_txt = 0; +ColorVal UIColor::primary_txt = 0; +ColorVal UIColor::secondary_txt = 0; +ColorVal UIColor::warning_txt = 0; +ColorVal UIColor::popup_bkg = 0; +ColorVal UIColor::popup_txt = 0; +ColorVal UIColor::corp_blue = 0; + +using namespace ObserverDashboard; + +namespace { + +const int N = RADIO_ACTIVITY_BUCKETS; + +struct Profile { + MockDisplay::Mode mode; + Layout layout; + const char* name; + int panel_w, panel_h; + int margin_left, margin_right; // physical +}; + +Profile portrait() { return {MockDisplay::PORTRAIT, portraitLayout(), "portrait", 240, 320, 7, 232}; } +Profile landscape() { return {MockDisplay::LANDSCAPE, landscapeLayout(), "landscape", 320, 240, 10, 310}; } + +Context makeContext(const char* name = "Ridgeline North") { + Context c; + c.node_name = name; + c.role_label = "REPEATER"; + c.freq = 910.525f; + c.sf = 7; + c.bw = 62.5f; + c.link_up = true; + return c; +} + +// A busy but plausible 20 minutes: 1843 packets, a peak minute of 214. +RadioActivitySnapshot makeBusy() { + RadioActivityWindow w; + w.reset(0); + const uint16_t per_minute[RADIO_ACTIVITY_BUCKETS] = {12, 40, 8, 0, 97, 133, 71, 3, 214, 65, + 19, 88, 44, 27, 0, 150, 92, 61, 7, 35}; + for (int m = 0; m < N; m++) { + for (int i = 0; i < per_minute[m]; i++) { + w.recordPacket((uint32_t)m * RADIO_ACTIVITY_BUCKET_MS + 1000 + i, 48, 120, 26, -103); + } + } + RadioActivitySnapshot s; + w.snapshot((uint32_t)(N - 1) * RADIO_ACTIVITY_BUCKET_MS + 30000, &s); + return s; +} + +RadioActivitySnapshot makeEmpty() { + RadioActivityWindow w; + w.reset(0); + RadioActivitySnapshot s; + w.snapshot(7 * RADIO_ACTIVITY_BUCKET_MS, &s); + return s; +} + +bool insideRect(const MockDisplay::Op& op, int x, int y, int w, int h) { + return op.x >= x && op.y >= y && op.x + op.w <= x + w && op.y + op.h <= y + h; +} + +} // namespace + +// ------------------------------------------------------------- formatting --- + +TEST(ObserverDashboardFormat, CompactCountsStayShortAtEveryMagnitude) { + char b[24]; + struct { uint32_t v; const char* want; } cases[] = { + {0, "0"}, {7, "7"}, {9999, "9999"}, {10000, "10.0k"}, + {12345, "12.3k"}, {99999, "99.9k"}, {100000, "100k"}, {999999, "999k"}, + {1000000, "1.0M"},{12345678, "12.3M"},{100000000, "100M"}}; + for (auto& c : cases) { + formatCompactCount(b, sizeof(b), c.v); + EXPECT_STREQ(c.want, b) << "value " << c.v; + EXPECT_LE(strlen(b), 5u) << "value " << c.v; + } +} + +TEST(ObserverDashboardFormat, CompactBytesPickSensibleUnits) { + char b[24]; + struct { uint32_t v; const char* want; } cases[] = { + {0, "0 B"}, {1023, "1023 B"}, {1024, "1.0 KB"}, + {10240, "10.0 KB"}, {145408, "142 KB"}, {1048576, "1.0 MB"}, + {15728640, "15.0 MB"}}; + for (auto& c : cases) { + formatCompactBytes(b, sizeof(b), c.v); + EXPECT_STREQ(c.want, b) << "value " << c.v; + } +} + +TEST(ObserverDashboardFormat, TenthsAndSignedTenths) { + char b[24]; + formatTenths(b, sizeof(b), 0); EXPECT_STREQ("0.0", b); + formatTenths(b, sizeof(b), 34); EXPECT_STREQ("3.4", b); + formatTenths(b, sizeof(b), 999); EXPECT_STREQ("99.9", b); + formatTenths(b, sizeof(b), 1000); EXPECT_STREQ("100", b); + + formatSignedTenths(b, sizeof(b), 72); EXPECT_STREQ("+7.2", b); + formatSignedTenths(b, sizeof(b), 0); EXPECT_STREQ("+0.0", b); + formatSignedTenths(b, sizeof(b), -115); EXPECT_STREQ("-11.5", b); +} + +TEST(ObserverDashboardFormat, AgeIsQuantisedToTheFiveSecondCadence) { + EXPECT_EQ(0u, quantizeAgeSecs(0)); + EXPECT_EQ(0u, quantizeAgeSecs(4999)); + EXPECT_EQ(5u, quantizeAgeSecs(5000)); + EXPECT_EQ(5u, quantizeAgeSecs(9999)); + + char b[24]; + formatAge(b, sizeof(b), 0, false); EXPECT_STREQ("--", b); + formatAge(b, sizeof(b), 0, true); EXPECT_STREQ("now", b); + formatAge(b, sizeof(b), 4999, true); EXPECT_STREQ("now", b); + formatAge(b, sizeof(b), 12000, true); EXPECT_STREQ("10s", b); + formatAge(b, sizeof(b), 59999, true); EXPECT_STREQ("55s", b); + formatAge(b, sizeof(b), 60000, true); EXPECT_STREQ("1m", b); + formatAge(b, sizeof(b), 3599999, true); EXPECT_STREQ("59m", b); + formatAge(b, sizeof(b), 3600000, true); EXPECT_STREQ("1h", b); +} + +TEST(ObserverDashboardFormat, EmptyWindowNeverProducesNanOrInfinity) { + RadioActivitySnapshot s = makeEmpty(); + Context ctx = makeContext(); + + for (int r = 0; r < ROW_COUNT; r++) { + if (r == ROW_GRAPH) continue; + RowText t; + composeRow((Row)r, ctx, s, &t); + for (const char* p : {t.left, t.right}) { + EXPECT_EQ(nullptr, strstr(p, "nan")) << p; + EXPECT_EQ(nullptr, strstr(p, "inf")) << p; + } + } + + // Unmeasurable values read as "--"; measured zeroes read as real zeroes. + RowText headline, rate, rf, status; + composeRow(ROW_HEADLINE, ctx, s, &headline); + composeRow(ROW_RATE, ctx, s, &rate); + composeRow(ROW_RF, ctx, s, &rf); + composeRow(ROW_STATUS, ctx, s, &status); + EXPECT_STREQ("No RF yet", headline.left); + EXPECT_STREQ("0 B", rate.left); + EXPECT_STREQ("0.0/min", rate.right); + EXPECT_STREQ("SNR --", rf.left); + EXPECT_STREQ("AIR 0.0%", rf.right); + EXPECT_STREQ("RX --", status.left); +} + +TEST(ObserverDashboardFormat, EveryRowFitsTheCharacterBudget) { + for (const Profile& p : {portrait(), landscape()}) { + for (const RadioActivitySnapshot& s : {makeBusy(), makeEmpty()}) { + for (int r = 0; r < ROW_COUNT; r++) { + if (r == ROW_GRAPH) continue; + RowText t; + composeRow((Row)r, makeContext(), s, &t); + int budget = (r == ROW_HEADLINE) ? p.layout.max_chars_big : p.layout.max_chars; + int used = (int)strlen(t.left) + (int)strlen(t.right); + if (t.left[0] && t.right[0]) used += 1; // separating space + EXPECT_LE(used, budget) << p.name << " row " << r << ": '" << t.left << "' / '" << t.right << "'"; + } + } + } +} + +TEST(ObserverDashboardFormat, RadioStripDropsADeadBandwidthDecimal) { + char b[32]; + formatRadioStrip(b, sizeof(b), 910.525f, 7, 62.5f); + EXPECT_STREQ("910.525 SF7 BW62.5", b); + formatRadioStrip(b, sizeof(b), 869.618f, 8, 250.0f); + EXPECT_STREQ("869.618 SF8 BW250", b); + formatRadioStrip(b, sizeof(b), 433.125f, 12, 125.0f); + EXPECT_STREQ("433.125 SF12 BW125", b); +} + +TEST(ObserverDashboardFormat, RadioStripFitsEveryOrientationsBudget) { + // The widest realistic combination must not be ellipsized away. + const struct { float freq; uint8_t sf; float bw; } cases[] = { + {910.525f, 7, 62.5f}, {869.618f, 8, 250.0f}, {433.125f, 12, 125.0f}, + {915.000f, 11, 500.0f}, {868.000f, 9, 41.7f}}; + for (const Profile& p : {portrait(), landscape()}) { + for (const auto& c : cases) { + char b[32]; + formatRadioStrip(b, sizeof(b), c.freq, c.sf, c.bw); + EXPECT_LE((int)strlen(b), p.layout.max_chars) << p.name << " '" << b << "'"; + } + } +} + +TEST(ObserverDashboardLayout, HeaderTextStaysInsideTheHeaderBar) { + for (const Profile& p : {portrait(), landscape()}) { + MockDisplay d(p.mode); + drawHeader(d, p.layout, makeContext()); + + ASSERT_FALSE(d.ops.empty()); + const auto& bar = d.ops.front(); + ASSERT_EQ(MockDisplay::Op::FILL, bar.kind) << p.name; + for (size_t i = 1; i < d.ops.size(); i++) { + EXPECT_TRUE(insideRect(d.ops[i], bar.x, bar.y, bar.w, bar.h)) + << p.name << " '" << d.ops[i].text << "'"; + } + } +} + +TEST(ObserverDashboardLayout, PortraitHeaderShowsTheWholeNodeName) { + // A 16-character name must survive intact: on a 240 px panel the role label + // moves to a second header line rather than eating the name. + MockDisplay d(MockDisplay::PORTRAIT); + Context ctx = makeContext("Ridgeline North"); + drawHeader(d, portraitLayout(), ctx); + + bool saw_name = false, saw_role = false; + for (const auto& op : d.ops) { + if (op.text == "Ridgeline North") saw_name = true; + if (op.text == "REPEATER") saw_role = true; + } + EXPECT_TRUE(saw_name) << "node name was ellipsized"; + EXPECT_TRUE(saw_role); +} + +TEST(ObserverDashboardFormat, TextIsAsciiOnly) { + // The driver's UTF-8 fallback collapses every non-ASCII byte to a full block, + // so any stray multi-byte character would render as a solid glyph. + for (const RadioActivitySnapshot& s : {makeBusy(), makeEmpty()}) { + for (int r = 0; r < ROW_COUNT; r++) { + if (r == ROW_GRAPH) continue; + RowText t; + composeRow((Row)r, makeContext(), s, &t); + for (const char* p : {t.left, t.right}) { + for (const char* c = p; *c; c++) { + EXPECT_GE((unsigned char)*c, 32u) << "row " << r; + EXPECT_LE((unsigned char)*c, 126u) << "row " << r; + } + } + } + } +} + +// ----------------------------------------------------------------- layout --- + +TEST(ObserverDashboardLayout, EveryDrawnPixelStaysOnThePanel) { + for (const Profile& p : {portrait(), landscape()}) { + MockDisplay d(p.mode); + drawFull(d, p.layout, makeContext(), makeBusy()); + ASSERT_FALSE(d.ops.empty()); + for (const auto& op : d.ops) { + EXPECT_GE(op.x, 0) << p.name; + EXPECT_GE(op.y, 0) << p.name; + EXPECT_LE(op.x + op.w, p.panel_w) << p.name << " '" << op.text << "'"; + EXPECT_LE(op.y + op.h, p.panel_h) << p.name << " '" << op.text << "'"; + } + } +} + +TEST(ObserverDashboardLayout, AllTextIsPaddedInsideTheMargins) { + for (const Profile& p : {portrait(), landscape()}) { + for (const RadioActivitySnapshot& s : {makeBusy(), makeEmpty()}) { + MockDisplay d(p.mode); + drawFull(d, p.layout, makeContext(), s); + for (const auto& op : d.ops) { + if (op.kind != MockDisplay::Op::TEXT) continue; + EXPECT_GE(op.x, p.margin_left) << p.name << " '" << op.text << "'"; + EXPECT_LE(op.x + op.w, p.margin_right) << p.name << " '" << op.text << "'"; + } + } + } +} + +TEST(ObserverDashboardLayout, NoTextSilentlyShrinksToTheFallbackScale) { + // The portrait driver halves the glyph size rather than clipping. A row that + // only fits because of that would break the grid, so it must never happen. + for (const RadioActivitySnapshot& s : {makeBusy(), makeEmpty()}) { + MockDisplay d(MockDisplay::PORTRAIT); + drawFull(d, portraitLayout(), makeContext(), s); + for (const auto& op : d.ops) { + EXPECT_FALSE(op.scale_fallback) << "'" << op.text << "'"; + } + } +} + +TEST(ObserverDashboardLayout, ContentClearsTheTopAndBottomEdges) { + for (const Profile& p : {portrait(), landscape()}) { + MockDisplay d(p.mode); + drawFull(d, p.layout, makeContext(), makeBusy()); + int lowest = 0; + for (const auto& op : d.ops) lowest = std::max(lowest, op.y + op.h); + EXPECT_GE(p.panel_h - lowest, 8) << p.name << ": bottom margin too small"; + } +} + +TEST(ObserverDashboardLayout, RowRectanglesDoNotOverlap) { + for (const Profile& p : {portrait(), landscape()}) { + MockDisplay d(p.mode); + for (int a = 0; a < ROW_COUNT; a++) { + for (int b = a + 1; b < ROW_COUNT; b++) { + int ay = p.layout.margin_x, unused = ay; + (void)unused; + int a_top = rowY(p.layout, (Row)a), a_bot = a_top + rowH(p.layout, (Row)a); + int b_top = rowY(p.layout, (Row)b), b_bot = b_top + rowH(p.layout, (Row)b); + bool overlap = a_top < b_bot && b_top < a_bot; + EXPECT_FALSE(overlap) << p.name << ": rows " << a << " and " << b; + } + } + // ...and the header and radio strip sit above the first row. + EXPECT_LT(p.layout.header_h, p.layout.radio_y) << p.name; + EXPECT_LT(p.layout.radio_y + p.layout.text_h, rowY(p.layout, ROW_WINDOW) + 1) << p.name; + } +} + +TEST(ObserverDashboardLayout, EachRowRepaintCoversEverythingThatRowDraws) { + // The no-flash invariant: a partial repaint clears one row rectangle and then + // redraws inside it. Anything drawn outside that rectangle would leave stale + // pixels behind or scribble on a neighbouring row. + for (const Profile& p : {portrait(), landscape()}) { + for (const RadioActivitySnapshot& s : {makeBusy(), makeEmpty()}) { + for (int r = 0; r < ROW_COUNT; r++) { + MockDisplay d(p.mode); + drawRow(d, p.layout, (Row)r, makeContext(), s, true); + ASSERT_FALSE(d.ops.empty()) << p.name << " row " << r; + + const auto& clear = d.ops.front(); + ASSERT_EQ(MockDisplay::Op::FILL, clear.kind) << p.name << " row " << r; + EXPECT_EQ(BG, clear.color) << p.name << " row " << r; + + for (size_t i = 1; i < d.ops.size(); i++) { + EXPECT_TRUE(insideRect(d.ops[i], clear.x, clear.y, clear.w, clear.h)) + << p.name << " row " << r << " op " << i << " '" << d.ops[i].text << "'"; + } + } + } + } +} + +// ------------------------------------------------------------------ graph --- + +TEST(ObserverDashboardGraph, DrawsExactlyTwentyNonOverlappingBars) { + for (const Profile& p : {portrait(), landscape()}) { + RadioActivitySnapshot s = makeBusy(); + for (int i = 0; i < N; i++) s.buckets[i] = (uint16_t)(i + 1); + s.peak_per_min = N; + + MockDisplay d(p.mode); + drawGraph(d, p.layout, s); + + // First op is the baseline, then one bar per non-empty bucket. + ASSERT_GE(d.ops.size(), 1u); + std::vector bars(d.ops.begin() + 1, d.ops.end()); + ASSERT_EQ((size_t)N, bars.size()) << p.name; + + for (size_t i = 1; i < bars.size(); i++) { + EXPECT_GE(bars[i].x, bars[i - 1].x + bars[i - 1].w) << p.name << " bar " << i << " overlaps"; + EXPECT_GE(bars[i].h, bars[i - 1].h) << p.name << " bar " << i << " not monotonic"; + } + EXPECT_EQ(BAR_NOW, bars.back().color) << p.name << ": current minute must stand out"; + EXPECT_EQ(BAR, bars.front().color) << p.name; + } +} + +TEST(ObserverDashboardGraph, BarsStayInsideTheGraphRectangle) { + for (const Profile& p : {portrait(), landscape()}) { + RadioActivitySnapshot s = makeBusy(); + MockDisplay probe(p.mode); + probe.setColor(0); + probe.fillRect(p.layout.margin_x, p.layout.graph_y, p.layout.right_x - p.layout.margin_x, + p.layout.graph_h); + MockDisplay::Op rect = probe.ops.front(); + + MockDisplay d(p.mode); + drawGraph(d, p.layout, s); + for (const auto& op : d.ops) { + EXPECT_TRUE(insideRect(op, rect.x, rect.y, rect.w, rect.h)) << p.name; + } + } +} + +TEST(ObserverDashboardGraph, AllZeroWindowDrawsOnlyTheBaseline) { + for (const Profile& p : {portrait(), landscape()}) { + MockDisplay d(p.mode); + drawGraph(d, p.layout, makeEmpty()); + ASSERT_EQ(1u, d.ops.size()) << p.name << ": empty minutes must not draw one-pixel activity"; + EXPECT_EQ(GRID, d.ops.front().color) << p.name; + } +} + +TEST(ObserverDashboardGraph, SingleSpikeFillsTheGraphAndLeavesTheRestEmpty) { + for (const Profile& p : {portrait(), landscape()}) { + RadioActivitySnapshot s = makeEmpty(); + s.buckets[5] = 400; + s.peak_per_min = 400; + s.packets = 400; + + uint8_t h[RADIO_ACTIVITY_BUCKETS]; + barHeights(p.layout, s, h); + EXPECT_EQ(p.layout.graph_h - 1, h[5]) << p.name; + for (int i = 0; i < N; i++) { + if (i != 5) EXPECT_EQ(0, h[i]) << p.name << " bucket " << i; + } + + MockDisplay d(p.mode); + drawGraph(d, p.layout, s); + EXPECT_EQ(2u, d.ops.size()) << p.name; // baseline + one bar + } +} + +TEST(ObserverDashboardGraph, AnyTrafficRoundsUpToAVisibleBar) { + for (const Profile& p : {portrait(), landscape()}) { + RadioActivitySnapshot s = makeEmpty(); + s.buckets[0] = 1; + s.buckets[19] = 5000; + s.peak_per_min = 5000; + + uint8_t h[RADIO_ACTIVITY_BUCKETS]; + barHeights(p.layout, s, h); + EXPECT_EQ(1, h[0]) << p.name << ": a single packet must still be visible"; + EXPECT_EQ(p.layout.graph_h - 1, h[19]) << p.name; + } +} + +// ------------------------------------------------------------- signatures --- + +TEST(ObserverDashboardSignature, IdenticallyFormattedDataDoesNotRepaint) { + Layout l = portraitLayout(); + Context ctx = makeContext(); + + RadioActivitySnapshot a = makeEmpty(); + a.packets = 100000; + RadioActivitySnapshot b = a; + b.packets = 100999; // both render as "100k pkt" + + EXPECT_EQ(rowSignature(l, ROW_HEADLINE, ctx, a), rowSignature(l, ROW_HEADLINE, ctx, b)); + + b.packets = 101500; // renders as "101k pkt" + EXPECT_NE(rowSignature(l, ROW_HEADLINE, ctx, a), rowSignature(l, ROW_HEADLINE, ctx, b)); +} + +TEST(ObserverDashboardSignature, AGraphChangeTouchesOnlyTheGraphRow) { + Layout l = portraitLayout(); + Context ctx = makeContext(); + + RadioActivitySnapshot a = makeBusy(); + RadioActivitySnapshot b = a; + b.buckets[N - 1] = (uint16_t)(a.buckets[N - 1] + 40); // the current minute grows + + uint32_t sa[ROW_COUNT], sb[ROW_COUNT]; + allRowSignatures(l, ctx, a, sa); + allRowSignatures(l, ctx, b, sb); + + for (int r = 0; r < ROW_COUNT; r++) { + if (r == ROW_GRAPH) { + EXPECT_NE(sa[r], sb[r]) << "graph row must notice the new bar height"; + } else { + EXPECT_EQ(sa[r], sb[r]) << "row " << r << " must not repaint"; + } + } +} + +TEST(ObserverDashboardSignature, DataChangesBelowTheGraphResolutionDoNotRepaint) { + // Signatures are computed from bar heights, not from the packet counts behind + // them, so a busy minute ticking up by one costs nothing on screen. + Layout l = portraitLayout(); + Context ctx = makeContext(); + + RadioActivitySnapshot a = makeBusy(); + RadioActivitySnapshot b = a; + b.buckets[N - 1] = (uint16_t)(a.buckets[N - 1] + 1); + + uint8_t ha[RADIO_ACTIVITY_BUCKETS], hb[RADIO_ACTIVITY_BUCKETS]; + barHeights(l, a, ha); + barHeights(l, b, hb); + ASSERT_EQ(ha[N - 1], hb[N - 1]) << "test needs a change smaller than one bar unit"; + + EXPECT_EQ(rowSignature(l, ROW_GRAPH, ctx, a), rowSignature(l, ROW_GRAPH, ctx, b)); +} + +TEST(ObserverDashboardSignature, LinkStateOnlyTouchesTheStatusRow) { + Layout l = portraitLayout(); + RadioActivitySnapshot s = makeBusy(); + + Context up = makeContext(); + Context down = makeContext(); + down.link_up = false; + + uint32_t sa[ROW_COUNT], sb[ROW_COUNT]; + allRowSignatures(l, up, s, sa); + allRowSignatures(l, down, s, sb); + + for (int r = 0; r < ROW_COUNT; r++) { + if (r == ROW_STATUS) { + EXPECT_NE(sa[r], sb[r]); + } else { + EXPECT_EQ(sa[r], sb[r]) << "row " << r; + } + } +} + +TEST(ObserverDashboardSignature, PartialRepaintDrawsOnlyTheChangedRow) { + Profile p = portrait(); + Context ctx = makeContext(); + RadioActivitySnapshot a = makeBusy(); + + uint32_t sigs[ROW_COUNT]; + allRowSignatures(p.layout, ctx, a, sigs); + + RadioActivitySnapshot b = a; + b.buckets[N - 1] = (uint16_t)(a.buckets[N - 1] + 40); + + MockDisplay d(p.mode); + EXPECT_TRUE(drawChangedRows(d, p.layout, ctx, b, sigs)); + + // One clear plus the graph contents, all inside the graph rectangle. + ASSERT_FALSE(d.ops.empty()); + const auto& clear = d.ops.front(); + EXPECT_EQ(BG, clear.color); + for (const auto& op : d.ops) { + EXPECT_TRUE(insideRect(op, clear.x, clear.y, clear.w, clear.h)); + } + + // Nothing left to do on a second pass with the same data. + MockDisplay d2(p.mode); + EXPECT_FALSE(drawChangedRows(d2, p.layout, ctx, b, sigs)); + EXPECT_TRUE(d2.ops.empty()); +} + +TEST(ObserverDashboardSignature, LongNodeNameIsTrimmedInsideTheHeader) { + for (const Profile& p : {portrait(), landscape()}) { + MockDisplay d(p.mode); + Context ctx = makeContext("A Very Long Repeater Node Name That Cannot Possibly Fit"); + drawHeader(d, p.layout, ctx); + + bool saw_role = false, saw_ellipsis = false; + for (const auto& op : d.ops) { + if (op.kind != MockDisplay::Op::TEXT) continue; + EXPECT_GE(op.x, p.margin_left) << p.name; + EXPECT_LE(op.x + op.w, p.margin_right) << p.name << " '" << op.text << "'"; + EXPECT_FALSE(op.scale_fallback) << p.name << " '" << op.text << "'"; + if (op.text == "REPEATER") saw_role = true; + if (op.text.size() >= 3 && op.text.compare(op.text.size() - 3, 3, "...") == 0) + saw_ellipsis = true; + } + EXPECT_TRUE(saw_role) << p.name << ": the role label must survive a long node name"; + EXPECT_TRUE(saw_ellipsis) << p.name << ": the name must be visibly truncated"; + } +} + +TEST(ObserverDashboardSignature, FitToCharsRespectsItsBudget) { + char b[32]; + fitToChars(b, sizeof(b), "short", 18); EXPECT_STREQ("short", b); + fitToChars(b, sizeof(b), "exactly-18-chars!", 17); EXPECT_STREQ("exactly-18-chars!", b); + fitToChars(b, sizeof(b), "Ridgeline North Ridge", 18); + EXPECT_STREQ("Ridgeline North...", b); + EXPECT_EQ(18u, strlen(b)); + fitToChars(b, sizeof(b), "abcdef", 3); EXPECT_STREQ("abc", b); + fitToChars(b, sizeof(b), "abcdef", 0); EXPECT_STREQ("", b); + fitToChars(b, sizeof(b), "", 18); EXPECT_STREQ("", b); +} + +TEST(ObserverDashboardSignature, DarkPaletteRetunesTheSharedColourSlots) { + applyDarkPalette(); + EXPECT_EQ(BG, UIColor::window_bkg); + EXPECT_EQ(TEXT, UIColor::primary_txt); + EXPECT_EQ(HEADER_BG, UIColor::title_bkg); + EXPECT_EQ(ACCENT, UIColor::corp_blue); + // The setup portal's highlight must stay legible on the dark background. + EXPECT_NE(UIColor::window_bkg, UIColor::warning_txt); + EXPECT_NE(UIColor::window_bkg, UIColor::primary_txt); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_observer_validation/test_observer_validation.cpp b/test/test_observer_validation/test_observer_validation.cpp index 3045caed6..32844071d 100644 --- a/test/test_observer_validation/test_observer_validation.cpp +++ b/test/test_observer_validation/test_observer_validation.cpp @@ -129,6 +129,48 @@ TEST(ValueFits, RejectsNullOrZeroBuffer) { EXPECT_FALSE(mqttValueFits("abc", 0)); } +// ---- display CLI values -------------------------------------------------- + +TEST(DisplayTimeout, AcceptsOnlyCompleteBoundedDecimalValues) { + uint16_t value = 9999; + EXPECT_TRUE(observerParseBoundedUint16("0", 3600, &value)); + EXPECT_EQ(0, value); + EXPECT_TRUE(observerParseBoundedUint16("60", 3600, &value)); + EXPECT_EQ(60, value); + EXPECT_TRUE(observerParseBoundedUint16("3600", 3600, &value)); + EXPECT_EQ(3600, value); + + for (const char* invalid : {"", "3601", "-1", "+1", " 60", "60 ", + "60s", "99999999999999999999"}) { + value = 123; + EXPECT_FALSE(observerParseBoundedUint16(invalid, 3600, &value)) << invalid; + EXPECT_EQ(123, value) << invalid; + } + EXPECT_FALSE(observerParseBoundedUint16(nullptr, 3600, &value)); + EXPECT_FALSE(observerParseBoundedUint16("60", 3600, nullptr)); +} + +TEST(DisplayFlip, AcceptsOnlyExactDocumentedTokens) { + uint8_t value = 7; + for (const char* off : {"0", "off"}) { + value = 7; + EXPECT_TRUE(observerParseDisplayFlip(off, &value)); + EXPECT_EQ(0, value); + } + for (const char* on : {"1", "on"}) { + value = 7; + EXPECT_TRUE(observerParseDisplayFlip(on, &value)); + EXPECT_EQ(1, value); + } + for (const char* invalid : {"", "2", "ON", "Off", "on ", "1x", "true"}) { + value = 7; + EXPECT_FALSE(observerParseDisplayFlip(invalid, &value)) << invalid; + EXPECT_EQ(7, value) << invalid; + } + EXPECT_FALSE(observerParseDisplayFlip(nullptr, &value)); + EXPECT_FALSE(observerParseDisplayFlip("on", nullptr)); +} + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); diff --git a/test/test_radio_activity_window/test_radio_activity_window.cpp b/test/test_radio_activity_window/test_radio_activity_window.cpp new file mode 100644 index 000000000..2628e81c3 --- /dev/null +++ b/test/test_radio_activity_window/test_radio_activity_window.cpp @@ -0,0 +1,393 @@ +#include "helpers/RadioActivityWindow.h" + +#include + +namespace { + +const uint32_t MINUTE = RADIO_ACTIVITY_BUCKET_MS; +const int N = RADIO_ACTIVITY_BUCKETS; + +// Representative packet: 32 wire bytes, 100 ms airtime, +7.0 dB SNR, -95 dBm. +void recordTypical(RadioActivityWindow& w, uint32_t at_ms, uint16_t bytes = 32) { + w.recordPacket(at_ms, bytes, 100, 28, -95); +} + +RadioActivitySnapshot snapshotAt(RadioActivityWindow& w, uint32_t at_ms) { + RadioActivitySnapshot s; + w.snapshot(at_ms, &s); + return s; +} + +} // namespace + +TEST(RadioActivityWindow, EmptySnapshotHasNoTotalsAndNoDivisionByZero) { + RadioActivityWindow w; + w.reset(0); + + RadioActivitySnapshot s = snapshotAt(w, 0); + + EXPECT_TRUE(s.isEmpty()); + EXPECT_EQ(0u, s.packets); + EXPECT_EQ(0u, s.wire_bytes); + EXPECT_EQ(0u, s.window_ms); + EXPECT_FALSE(s.has_last_packet); + EXPECT_EQ(0u, s.peak_per_min); + + // Every derived value must be defined with a zero denominator. + EXPECT_EQ(0u, s.packetsPerMinuteX10()); + EXPECT_EQ(0u, s.bytesPerSecondX10()); + EXPECT_EQ(0u, s.avgBytesPerPacket()); + EXPECT_EQ(0u, s.airtimePercentX10()); + EXPECT_EQ(0, s.avgSnrX10()); + EXPECT_EQ(0, s.avgRssi()); + + for (int i = 0; i < N; i++) EXPECT_EQ(0u, s.buckets[i]); +} + +TEST(RadioActivityWindow, SingleEventProducesExactTotalsAndRates) { + RadioActivityWindow w; + w.reset(0); + recordTypical(w, 1000); + + RadioActivitySnapshot s = snapshotAt(w, 2000); + + EXPECT_EQ(1u, s.packets); + EXPECT_EQ(32u, s.wire_bytes); + EXPECT_EQ(100u, s.airtime_ms); + EXPECT_EQ(2000u, s.window_ms); + EXPECT_EQ(2000u, s.tracking_ms); + + EXPECT_EQ(300u, s.packetsPerMinuteX10()); // 30.0 packets/min + EXPECT_EQ(160u, s.bytesPerSecondX10()); // 16.0 B/s + EXPECT_EQ(32u, s.avgBytesPerPacket()); + EXPECT_EQ(50u, s.airtimePercentX10()); // 5.0 % + EXPECT_EQ(70, s.avgSnrX10()); // +7.0 dB + EXPECT_EQ(-95, s.avgRssi()); + + EXPECT_TRUE(s.has_last_packet); + EXPECT_EQ(1000u, s.last_packet_age_ms); + + // The current minute is the rightmost bucket. + EXPECT_EQ(1u, s.buckets[N - 1]); + for (int i = 0; i < N - 1; i++) EXPECT_EQ(0u, s.buckets[i]); +} + +TEST(RadioActivityWindow, MultipleEventsInOneMinuteAccumulate) { + RadioActivityWindow w; + w.reset(0); + recordTypical(w, 1000, 10); + recordTypical(w, 2000, 20); + recordTypical(w, 3000, 30); + + RadioActivitySnapshot s = snapshotAt(w, 4000); + + EXPECT_EQ(3u, s.packets); + EXPECT_EQ(60u, s.wire_bytes); + EXPECT_EQ(300u, s.airtime_ms); + EXPECT_EQ(20u, s.avgBytesPerPacket()); + EXPECT_EQ(3u, s.buckets[N - 1]); + EXPECT_EQ(3u, s.peak_per_min); + EXPECT_EQ(1000u, s.last_packet_age_ms); +} + +TEST(RadioActivityWindow, EventsRotateIntoTheNextBucketAtTheMinuteBoundary) { + RadioActivityWindow w; + w.reset(0); + recordTypical(w, 30000); // minute 0 + recordTypical(w, MINUTE); // exactly on the boundary: minute 1 + recordTypical(w, MINUTE + 5000); // minute 1 + + RadioActivitySnapshot s = snapshotAt(w, MINUTE + 10000); + + EXPECT_EQ(3u, s.packets); + EXPECT_EQ(2u, s.buckets[N - 1]); // current minute + EXPECT_EQ(1u, s.buckets[N - 2]); // previous minute + EXPECT_EQ(2u, s.peak_per_min); +} + +TEST(RadioActivityWindow, BucketsAreOrderedOldestToNewest) { + RadioActivityWindow w; + w.reset(0); + + // Minute m gets (m + 1) packets. + for (int m = 0; m < N; m++) { + for (int i = 0; i <= m; i++) recordTypical(w, m * MINUTE + 1000 + i); + } + + RadioActivitySnapshot s = snapshotAt(w, (N - 1) * MINUTE + 30000); + + for (int i = 0; i < N; i++) { + EXPECT_EQ((uint16_t)(i + 1), s.buckets[i]) << "bucket " << i; + } + EXPECT_EQ((uint16_t)N, s.peak_per_min); + EXPECT_EQ((uint32_t)(N * (N + 1) / 2), s.packets); +} + +TEST(RadioActivityWindow, OldestBucketExpiresOnceItLeavesTheWindow) { + RadioActivityWindow w; + w.reset(0); + + for (int m = 0; m < N; m++) recordTypical(w, m * MINUTE + 1000); + + // Still inside the window: all 20 minutes are represented. + RadioActivitySnapshot before = snapshotAt(w, (N - 1) * MINUTE + 59999); + EXPECT_EQ((uint32_t)N, before.packets); + EXPECT_EQ(1u, before.buckets[0]); + + // One tick past the boundary: the oldest minute is gone, and the new current + // minute is empty. + RadioActivitySnapshot after = snapshotAt(w, N * MINUTE); + EXPECT_EQ((uint32_t)(N - 1), after.packets); + EXPECT_EQ(1u, after.buckets[0]); // what was minute 1 + EXPECT_EQ(0u, after.buckets[N - 1]); // the fresh current minute +} + +TEST(RadioActivityWindow, MoreThanTwentyMinutesOfSilenceClearsTheRing) { + RadioActivityWindow w; + w.reset(0); + recordTypical(w, 1000); + + uint32_t now = 21 * MINUTE; + RadioActivitySnapshot s = snapshotAt(w, now); + + EXPECT_TRUE(s.isEmpty()); + for (int i = 0; i < N; i++) EXPECT_EQ(0u, s.buckets[i]); + + // Tracking restarts at the current minute, so the window reports itself as + // warming up again rather than claiming 20 minutes of empty coverage. + EXPECT_EQ(0u, s.tracking_ms); + EXPECT_EQ(0u, s.window_ms); + EXPECT_TRUE(s.isWarmingUp()); + + // The last-packet age survives the ring clear: it is still the most useful + // thing to show when nothing is arriving. + EXPECT_TRUE(s.has_last_packet); + EXPECT_EQ(now - 1000, s.last_packet_age_ms); +} + +TEST(RadioActivityWindow, LastPacketAgeIsDroppedOnceItGoesStale) { + RadioActivityWindow w; + w.reset(0); + recordTypical(w, 1000); + + RadioActivitySnapshot fresh = snapshotAt(w, 1000 + RADIO_ACTIVITY_MAX_AGE_MS); + EXPECT_TRUE(fresh.has_last_packet); + + RadioActivitySnapshot stale = snapshotAt(w, 1000 + RADIO_ACTIVITY_MAX_AGE_MS + 1); + EXPECT_FALSE(stale.has_last_packet); +} + +TEST(RadioActivityWindow, WarmupUsesObservedDurationNotAFixedTwentyMinutes) { + RadioActivityWindow w; + w.reset(0); + recordTypical(w, 30000); + + // Five minutes in, rates are computed against five minutes, not twenty. + RadioActivitySnapshot warm = snapshotAt(w, 5 * MINUTE); + EXPECT_TRUE(warm.isWarmingUp()); + EXPECT_EQ(5u, warm.warmupMinutes()); + EXPECT_EQ(5 * MINUTE, warm.window_ms); + // 1 packet over 5 minutes is 0.2/min. Against a fixed 1200 s denominator the + // same data would round away to 0.0/min. + EXPECT_EQ(2u, warm.packetsPerMinuteX10()); +} + +TEST(RadioActivityWindow, SteadyStateWindowNeverClaimsMoreCoverageThanTheRingHas) { + RadioActivityWindow w; + w.reset(0); + for (int m = 0; m < 25; m++) recordTypical(w, m * MINUTE + 1000); + + // 19 whole minutes plus the elapsed part of the current one - never 20:00. + RadioActivitySnapshot at_start = snapshotAt(w, 25 * MINUTE); + EXPECT_FALSE(at_start.isWarmingUp()); + EXPECT_EQ(19 * MINUTE, at_start.window_ms); + + RadioActivitySnapshot mid = snapshotAt(w, 25 * MINUTE + 30000); + EXPECT_EQ(19 * MINUTE + 30000, mid.window_ms); + + RadioActivitySnapshot late = snapshotAt(w, 25 * MINUTE + 59999); + EXPECT_EQ(19 * MINUTE + 59999, late.window_ms); + EXPECT_LT(late.window_ms, (uint32_t)N * MINUTE); +} + +TEST(RadioActivityWindow, PeakIsTheBusiestVisibleMinute) { + RadioActivityWindow w; + w.reset(0); + recordTypical(w, 1000); + for (int i = 0; i < 7; i++) recordTypical(w, MINUTE + 1000 + i); + recordTypical(w, 2 * MINUTE + 1000); + + EXPECT_EQ(7u, snapshotAt(w, 2 * MINUTE + 30000).peak_per_min); + + // Once the busy minute ages out of the ring, so does the peak. + EXPECT_EQ(1u, snapshotAt(w, 21 * MINUTE).peak_per_min); +} + +TEST(RadioActivityWindow, SurvivesMillisRollover) { + const uint32_t base = 0xFFFFF000u; // ~4 s before the 32-bit wrap + RadioActivityWindow w; + w.reset(base); + + recordTypical(w, base + 1000); + + // 65 s later, which is 60904 in wrapped millis(). + uint32_t after_wrap = (uint32_t)(base + 65000); + ASSERT_LT(after_wrap, base) << "test setup must actually cross the wrap"; + recordTypical(w, after_wrap); + + RadioActivitySnapshot s = snapshotAt(w, after_wrap + 1000); + + EXPECT_EQ(2u, s.packets); + EXPECT_EQ(1u, s.buckets[N - 1]); // the post-wrap minute + EXPECT_EQ(1u, s.buckets[N - 2]); // the pre-wrap minute + EXPECT_EQ(66000u, s.window_ms); + EXPECT_EQ(1000u, s.last_packet_age_ms); +} + +TEST(RadioActivityWindow, RolloverDoesNotCorruptTheMinuteBoundary) { + // A boundary derived from now_ms / BUCKET_MS would misplace a minute here, + // because 2^32 is not a whole number of 60000 ms buckets. + const uint32_t base = 0xFFFFFFFFu - 30000u; + RadioActivityWindow w; + w.reset(base); + + for (int m = 0; m < 5; m++) recordTypical(w, (uint32_t)(base + m * MINUTE + 1000)); + + RadioActivitySnapshot s = snapshotAt(w, (uint32_t)(base + 4 * MINUTE + 30000)); + + EXPECT_EQ(5u, s.packets); + for (int i = 0; i < 5; i++) { + EXPECT_EQ(1u, s.buckets[N - 1 - i]) << "minute -" << i; + } + EXPECT_EQ(1u, s.peak_per_min); +} + +TEST(RadioActivityWindow, SurvivesAFullMillisCycleOfContinuousUptime) { + // The always-on dashboard services the tracker every few seconds forever. Past + // 2^32 ms (~49.7 days) a 32-bit tracker age wraps back to a small value, which + // would drop the window into warm-up and divide 20 minutes of traffic by + // seconds - inflating every rate on screen. + RadioActivityWindow w; + w.reset(0); + + const uint32_t STEP = 30000; // two packets per minute bucket + uint32_t now = 0; + for (uint64_t elapsed = 0; elapsed < 0x100000000ull + 10 * MINUTE; elapsed += STEP) { + recordTypical(w, now); + RadioActivitySnapshot tick; + w.snapshot(now, &tick); + now += STEP; + } + + RadioActivitySnapshot s = snapshotAt(w, now); + + EXPECT_FALSE(s.isWarmingUp()) << "must not fall back into warm-up after the wrap"; + EXPECT_GE(s.window_ms, 19 * MINUTE); + EXPECT_LE(s.window_ms, (uint32_t)N * MINUTE); + + // 19 whole minutes at two packets each, plus however much of the current + // minute has elapsed. + EXPECT_GE(s.packets, 38u); + EXPECT_LE(s.packets, 41u); + // Two packets a minute, and it must still read as two. + EXPECT_GE(s.packetsPerMinuteX10(), 15u); + EXPECT_LE(s.packetsPerMinuteX10(), 25u); + + EXPECT_TRUE(s.has_last_packet); + EXPECT_EQ(STEP, s.last_packet_age_ms); +} + +TEST(RadioActivityWindow, StaleLastPacketDoesNotComeBackAfterTheWrap) { + RadioActivityWindow w; + w.reset(0); + recordTypical(w, 1000); + + // Serviced continuously, but silent, for more than one full 32-bit cycle. + uint32_t now = 0; + const uint32_t STEP = 60000; + for (uint64_t elapsed = 0; elapsed < 0x100000000ull + 10 * MINUTE; elapsed += STEP) { + RadioActivitySnapshot tick; + w.snapshot(now, &tick); + if (elapsed > RADIO_ACTIVITY_MAX_AGE_MS) { + ASSERT_FALSE(tick.has_last_packet) << "a stale age must never look fresh again"; + } + now += STEP; + } + + RadioActivitySnapshot s = snapshotAt(w, now); + EXPECT_TRUE(s.isEmpty()); + EXPECT_FALSE(s.has_last_packet); +} + +TEST(RadioActivityWindow, SaturatedMinuteDropsFurtherEventsWhole) { + RadioActivityWindow w; + w.reset(0); + + for (uint32_t i = 0; i < 65535; i++) w.recordPacket(1000, 10, 1, 4, -100); + + RadioActivitySnapshot full = snapshotAt(w, 2000); + EXPECT_EQ(65535u, full.packets); + EXPECT_EQ(655350u, full.wire_bytes); + + // Past saturation nothing is counted, so bytes-per-packet stays truthful. + w.recordPacket(1500, 10, 1, 4, -100); + RadioActivitySnapshot after = snapshotAt(w, 2000); + EXPECT_EQ(65535u, after.packets); + EXPECT_EQ(655350u, after.wire_bytes); + EXPECT_EQ(10u, after.avgBytesPerPacket()); +} + +TEST(RadioActivityWindow, AnOlderTimestampDoesNotExpireTheWindow) { + // recordPacket() and snapshot() read millis() at slightly different moments; + // a reading that arrives out of order must cost nothing. + RadioActivityWindow w; + w.reset(0); + recordTypical(w, 5000); + + RadioActivitySnapshot ahead = snapshotAt(w, 10000); + ASSERT_EQ(1u, ahead.packets); + + recordTypical(w, 9000); // stale reading, 1 s behind the last snapshot + RadioActivitySnapshot s = snapshotAt(w, 10000); + + EXPECT_EQ(2u, s.packets) << "the ring must not have been cleared"; + EXPECT_EQ(2u, s.buckets[N - 1]); + EXPECT_EQ(10000u, s.window_ms); +} + +TEST(RadioActivityWindow, AGapLongerThanHalfTheMillisRangeStillExpiresTheRing) { + // Display off and no traffic for ~25 days: the elapsed time passes the signed + // halfway mark, which must not be mistaken for an out-of-order reading, or a + // month-old packet would still be sitting in the "last 20 minutes". + RadioActivityWindow w; + w.reset(0); + recordTypical(w, 1000); + ASSERT_EQ(1u, snapshotAt(w, 2000).packets); + + const uint32_t twenty_five_days = 25UL * 24 * 3600 * 1000; + ASSERT_GT(twenty_five_days, 0x80000000u) << "gap must cross the halfway mark"; + + RadioActivitySnapshot s = snapshotAt(w, twenty_five_days); + EXPECT_TRUE(s.isEmpty()); + EXPECT_FALSE(s.has_last_packet); +} + +TEST(RadioActivityWindow, AveragesHandleNegativeSnrAndMixedSigns) { + RadioActivityWindow w; + w.reset(0); + w.recordPacket(1000, 40, 50, 28, -80); // +7.0 dB + w.recordPacket(1100, 40, 50, -28, -120); // -7.0 dB + + RadioActivitySnapshot s = snapshotAt(w, 2000); + EXPECT_EQ(0, s.avgSnrX10()); + EXPECT_EQ(-100, s.avgRssi()); +} + +TEST(RadioActivityWindow, StaysWithinItsMemoryBudget) { + EXPECT_LE(sizeof(RadioActivityWindow), 1024u); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_touch_tap_detector/test_touch_tap_detector.cpp b/test/test_touch_tap_detector/test_touch_tap_detector.cpp new file mode 100644 index 000000000..538bdf666 --- /dev/null +++ b/test/test_touch_tap_detector/test_touch_tap_detector.cpp @@ -0,0 +1,139 @@ +#include "helpers/ui/TouchTapDetector.h" + +#include + +namespace { + +// Drives the detector at the firmware's 50 ms poll cadence. +const uint32_t POLL = 50; + +// Holds `pressed` for `ms`, returning how many taps were accepted. +int hold(TouchTapDetector& d, uint32_t& now, bool pressed, uint32_t ms) { + int taps = 0; + for (uint32_t t = 0; t < ms; t += POLL) { + if (d.update(now, pressed)) taps++; + now += POLL; + } + return taps; +} + +} // namespace + +TEST(TouchTapDetector, IdlePanelNeverTaps) { + TouchTapDetector d; + uint32_t now = 1000; + d.reset(now); + EXPECT_EQ(0, hold(d, now, false, 5000)); + EXPECT_FALSE(d.isTouched()); +} + +TEST(TouchTapDetector, OneTouchProducesExactlyOneTap) { + TouchTapDetector d; + uint32_t now = 1000; + d.reset(now); + + EXPECT_EQ(1, hold(d, now, true, 300)); + EXPECT_TRUE(d.isTouched()); + EXPECT_EQ(0, hold(d, now, false, 300)); + EXPECT_FALSE(d.isTouched()); +} + +TEST(TouchTapDetector, HoldingAFingerDownDoesNotRepeat) { + TouchTapDetector d; + uint32_t now = 1000; + d.reset(now); + + EXPECT_EQ(1, hold(d, now, true, 200)); + EXPECT_EQ(0, hold(d, now, true, 10000)) << "a long press must not toggle repeatedly"; +} + +TEST(TouchTapDetector, ContactShorterThanTheDebounceWindowIsIgnored) { + TouchTapDetector d; + uint32_t now = 1000; + d.reset(now); + + // A single 30 ms blip, below TOUCH_TAP_DEBOUNCE_MS. + EXPECT_FALSE(d.update(now, true)); + now += 30; + EXPECT_FALSE(d.update(now, false)); + now += 30; + EXPECT_EQ(0, hold(d, now, false, 500)); +} + +TEST(TouchTapDetector, BounceOnContactStillCountsAsOneTap) { + TouchTapDetector d; + uint32_t now = 1000; + d.reset(now); + + int taps = 0; + for (int i = 0; i < 6; i++) { // chattering edge + if (d.update(now, i % 2 == 0)) taps++; + now += 10; + } + taps += hold(d, now, true, 200); // then settles down + EXPECT_EQ(1, taps); +} + +TEST(TouchTapDetector, SecondTapTooSoonIsSuppressed) { + TouchTapDetector d; + uint32_t now = 1000; + d.reset(now); + + EXPECT_EQ(1, hold(d, now, true, 200)); + EXPECT_EQ(0, hold(d, now, false, 150)); // release long enough to be confirmed + EXPECT_EQ(0, hold(d, now, true, 200)) << "inside TOUCH_TAP_MIN_GAP_MS"; +} + +TEST(TouchTapDetector, DeliberateSecondTapIsAccepted) { + TouchTapDetector d; + uint32_t now = 1000; + d.reset(now); + + EXPECT_EQ(1, hold(d, now, true, 200)); + EXPECT_EQ(0, hold(d, now, false, 600)); // past the min gap + EXPECT_EQ(1, hold(d, now, true, 200)); +} + +TEST(TouchTapDetector, ASingleBadSampleDuringATouchDoesNotFakeARelease) { + // A NACK or short read reports "not pressed" for that poll. One of those must + // not confirm a release, or recovery reads as a second tap and the display + // toggles twice on one touch. + TouchTapDetector d; + uint32_t now = 1000; + d.reset(now); + EXPECT_EQ(1, hold(d, now, true, 200)); + + EXPECT_FALSE(d.update(now, false)); + now += POLL; + EXPECT_TRUE(d.isTouched()) << "one bad sample must not confirm a release"; + + EXPECT_EQ(0, hold(d, now, true, 2000)) << "and must not produce a second tap"; +} + +TEST(TouchTapDetector, SurvivesMillisRollover) { + TouchTapDetector d; + const uint32_t start = 0xFFFFFF9Bu; // ~100 ms short of the 32-bit wrap + uint32_t now = start; + d.reset(now); + + EXPECT_EQ(1, hold(d, now, true, 250)); // the touch itself crosses the wrap + ASSERT_LT(now, start) << "test setup must actually wrap"; + EXPECT_EQ(0, hold(d, now, false, 600)); + EXPECT_EQ(1, hold(d, now, true, 250)); +} + +TEST(TouchTapDetector, ResetClearsPendingState) { + TouchTapDetector d; + uint32_t now = 1000; + d.reset(now); + EXPECT_EQ(1, hold(d, now, true, 200)); + + d.reset(now); + EXPECT_FALSE(d.isTouched()); + EXPECT_EQ(1, hold(d, now, true, 200)) << "a fresh probe starts clean"; +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/variants/heltec_v4_r8/HeltecV4R8Board.cpp b/variants/heltec_v4_r8/HeltecV4R8Board.cpp index 75c12ccc8..a1f8d8d72 100644 --- a/variants/heltec_v4_r8/HeltecV4R8Board.cpp +++ b/variants/heltec_v4_r8/HeltecV4R8Board.cpp @@ -1,5 +1,11 @@ #include "HeltecV4R8Board.h" +// Expansion Kit V2 control-pin notes (kept here beside the shared-reset +// sequencing): display/touch I2C is SDA 17 / SCL 18; GPIO 21 is the shared +// LCD_RESET / TP_RESET line; GPIO 44 drives the active-high backlight. The +// optional R13 touch interrupt lands on GPIO 43, which is also ESP32-S3 U0TXD, +// so production builds deliberately poll touch and do not define PIN_TOUCH_INT. + void HeltecV4R8Board::begin() { ESP32Board::begin(); diff --git a/variants/heltec_v4_r8/platformio.ini b/variants/heltec_v4_r8/platformio.ini index d36ef3cb5..eea1612a8 100644 --- a/variants/heltec_v4_r8/platformio.ini +++ b/variants/heltec_v4_r8/platformio.ini @@ -68,7 +68,7 @@ build_flags = -D PIN_TFT_RST=21 -D PIN_TFT_VDD_CTL=-1 -D PIN_TFT_LEDA_CTL=44 - -D PIN_TFT_LEDA_CTL_ACTIVE=LOW + -D PIN_TFT_LEDA_CTL_ACTIVE=HIGH -D PIN_TFT_CS=47 -D PIN_TFT_DC=48 -D PIN_TFT_SCL=16 @@ -100,6 +100,48 @@ lib_deps = ${esp32_ota.lib_deps} bakercp/CRC32 @ ^2.0.0 +[env:heltec_v4_r8_repeater_observer_mqtt] +extends = heltec_v4_r8_oled +extra_scripts = + ${esp32_base.extra_scripts} + pre:scripts/generate_cert_bundle.py +board_ssl_cert_source = adafruit-full +board_build.embed_files = src/certs/x509_crt_bundle.bin +build_flags = + ${heltec_v4_r8_oled.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"MQTT Observer"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_BRIDGE=1 + -D MQTT_MAX_PACKET_SIZE=1024 + -D MQTT_DEBUG=1 +; Keep the production observer profile quiet enough to avoid runtime contention. +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y + -D ESP32_CPU_FREQ=160 + -D MQTT_WIFI_TX_POWER=WIFI_POWER_11dBm + -D WITH_SNMP=1 +build_src_filter = ${heltec_v4_r8_oled.build_src_filter} + + + + + + + + + + + +<../examples/simple_repeater> +lib_deps = + ${heltec_v4_r8_oled.lib_deps} + ${esp32_ota.lib_deps} + elims/PsychicMqttClient@^0.2.4 + bblanchon/ArduinoJson @ 7.4.3 + arduino-libraries/NTPClient + JChristensen/Timezone + paulstoffregen/Time@1.6.1 + 0neblock/SNMP_Agent + [env:heltec_v4_r8_room_server] extends = heltec_v4_r8_oled build_flags = @@ -117,6 +159,49 @@ lib_deps = ${heltec_v4_r8_oled.lib_deps} ${esp32_ota.lib_deps} +[env:heltec_v4_r8_room_server_observer_mqtt] +extends = heltec_v4_r8_oled +extra_scripts = + ${esp32_base.extra_scripts} + pre:scripts/generate_cert_bundle.py +board_ssl_cert_source = adafruit-full +board_build.embed_files = src/certs/x509_crt_bundle.bin +build_flags = + ${heltec_v4_r8_oled.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"Heltec R8 Room Observer"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' + -D WITH_MQTT_BRIDGE=1 + -D MAX_NEIGHBOURS=50 + -D MQTT_MAX_PACKET_SIZE=1024 + -D MQTT_DEBUG=1 +; Keep the production observer profile quiet enough to avoid runtime contention. +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y + -D ESP32_CPU_FREQ=160 + -D MQTT_WIFI_TX_POWER=WIFI_POWER_11dBm + -D WITH_SNMP=1 +build_src_filter = ${heltec_v4_r8_oled.build_src_filter} + + + + + + + + + + + +<../examples/simple_room_server> +lib_deps = + ${heltec_v4_r8_oled.lib_deps} + ${esp32_ota.lib_deps} + elims/PsychicMqttClient@^0.2.4 + bblanchon/ArduinoJson @ 7.4.3 + arduino-libraries/NTPClient + JChristensen/Timezone + paulstoffregen/Time@1.6.1 + 0neblock/SNMP_Agent + [env:heltec_v4_r8_terminal_chat] extends = heltec_v4_r8_oled build_flags = @@ -234,6 +319,63 @@ lib_deps = ${esp32_ota.lib_deps} bakercp/CRC32 @ ^2.0.0 +[env:heltec_v4_r8_tft_repeater_observer_mqtt] +extends = heltec_v4_r8_tft +extra_scripts = + ${esp32_base.extra_scripts} + pre:scripts/generate_cert_bundle.py +board_ssl_cert_source = adafruit-full +board_build.embed_files = src/certs/x509_crt_bundle.bin +build_flags = + ${heltec_v4_r8_tft.build_flags} + -D DISPLAY_CLASS=ST7789LCDDisplay + -D ADVERT_NAME='"MQTT Observer"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_MQTT_BRIDGE=1 + -D MQTT_MAX_PACKET_SIZE=1024 + -D MQTT_DEBUG=1 +; Keep the production observer profile quiet enough to avoid runtime contention. +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y + -D ESP32_CPU_FREQ=160 + -D MQTT_WIFI_TX_POWER=WIFI_POWER_11dBm + -D WITH_SNMP=1 + -D DISPLAY_REDRAW_ON_CHANGE=1 + -D DISPLAY_ACTIVITY_DASHBOARD=1 + -D USER_BTN_LONG_PRESS_MILLIS=3000 + -D USER_BTN_MULTICLICK=1 +; display.timeout controls runtime blanking; touch and the existing button +; handling can toggle the panel manually. Multiclick remains enabled. + -D DISPLAY_TOUCH_TOGGLE=1 +build_src_filter = ${heltec_v4_r8_tft.build_src_filter} + + + + + + + + + + + +<../examples/simple_repeater> +lib_deps = + ${heltec_v4_r8_tft.lib_deps} + ${esp32_ota.lib_deps} + elims/PsychicMqttClient@^0.2.4 + bblanchon/ArduinoJson @ 7.4.3 + arduino-libraries/NTPClient + JChristensen/Timezone + paulstoffregen/Time@1.6.1 + 0neblock/SNMP_Agent + +[env:heltec_v4_r8_tft_portrait_repeater_observer_mqtt] +extends = env:heltec_v4_r8_tft_repeater_observer_mqtt +build_flags = + ${env:heltec_v4_r8_tft_repeater_observer_mqtt.build_flags} + -D ST7789_PORTRAIT_PROFILE=1 +; Upright on the Expansion Kit V2 panel; display.flip can rotate it at runtime. + -D DISPLAY_ROTATION=0 + [env:heltec_v4_r8_tft_room_server] extends = heltec_v4_r8_tft build_flags = @@ -251,6 +393,65 @@ lib_deps = ${heltec_v4_r8_tft.lib_deps} ${esp32_ota.lib_deps} +[env:heltec_v4_r8_tft_room_server_observer_mqtt] +extends = heltec_v4_r8_tft +extra_scripts = + ${esp32_base.extra_scripts} + pre:scripts/generate_cert_bundle.py +board_ssl_cert_source = adafruit-full +board_build.embed_files = src/certs/x509_crt_bundle.bin +build_flags = + ${heltec_v4_r8_tft.build_flags} + -D DISPLAY_CLASS=ST7789LCDDisplay + -D ADVERT_NAME='"Heltec R8 Room Observer"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' + -D WITH_MQTT_BRIDGE=1 + -D MAX_NEIGHBOURS=50 + -D MQTT_MAX_PACKET_SIZE=1024 + -D MQTT_DEBUG=1 +; Keep the production observer profile quiet enough to avoid runtime contention. +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 + -D CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y + -D ESP32_CPU_FREQ=160 + -D MQTT_WIFI_TX_POWER=WIFI_POWER_11dBm + -D WITH_SNMP=1 + -D DISPLAY_REDRAW_ON_CHANGE=1 + -D DISPLAY_ACTIVITY_DASHBOARD=1 + -D USER_BTN_LONG_PRESS_MILLIS=3000 + -D USER_BTN_MULTICLICK=0 +; display.timeout controls runtime blanking; touch and the existing button +; handling can toggle the panel manually. Room has no multiclick action, so a +; click is delivered immediately instead of waiting for a second click. + -D DISPLAY_TOUCH_TOGGLE=1 +build_src_filter = ${heltec_v4_r8_tft.build_src_filter} + + + + + + + + + + + +<../examples/simple_room_server> +lib_deps = + ${heltec_v4_r8_tft.lib_deps} + ${esp32_ota.lib_deps} + elims/PsychicMqttClient@^0.2.4 + bblanchon/ArduinoJson @ 7.4.3 + arduino-libraries/NTPClient + JChristensen/Timezone + paulstoffregen/Time@1.6.1 + 0neblock/SNMP_Agent + +[env:heltec_v4_r8_tft_portrait_room_server_observer_mqtt] +extends = env:heltec_v4_r8_tft_room_server_observer_mqtt +build_flags = + ${env:heltec_v4_r8_tft_room_server_observer_mqtt.build_flags} + -D ST7789_PORTRAIT_PROFILE=1 +; Upright on the Expansion Kit V2 panel; display.flip can rotate it at runtime. + -D DISPLAY_ROTATION=0 + [env:heltec_v4_r8_tft_terminal_chat] extends = heltec_v4_r8_tft build_flags = diff --git a/variants/heltec_v4_r8/target.cpp b/variants/heltec_v4_r8/target.cpp index 0b38531e3..4fa967560 100644 --- a/variants/heltec_v4_r8/target.cpp +++ b/variants/heltec_v4_r8/target.cpp @@ -25,7 +25,14 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); #ifdef DISPLAY_CLASS DISPLAY_CLASS display(&board.periph_power); - MomentaryButton user_btn(PIN_USER_BTN, 1000, true); + #ifndef USER_BTN_LONG_PRESS_MILLIS + #define USER_BTN_LONG_PRESS_MILLIS 1000 + #endif + #ifndef USER_BTN_MULTICLICK + #define USER_BTN_MULTICLICK 1 + #endif + MomentaryButton user_btn(PIN_USER_BTN, USER_BTN_LONG_PRESS_MILLIS, true, false, + USER_BTN_MULTICLICK); #endif bool radio_init() {