diff --git a/CHANGELOG.md b/CHANGELOG.md
index aa79883d..1c4f649c 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 f2fc6f19..d2e417e5 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 bf0309e8..e24eabc9 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 8e903516..a605dfbc 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 75210e77..0653bbc8 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 614f2cef..5aad7e1b 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 b780e727..003d0308 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 1470399a..90a8bdd8 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 4123274a..9c7a05ef 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 58d87bb4..aa70a016 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 9e0cedc4..3c3ed1ad 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 6f3a6349..179507ee 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 fbe24708..40e22840 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 a27259f1..a5c07bd5 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 552e1f56..35049c52 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 7244706d..ec9bc8b5 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 51626f7b..c6053274 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 0462cd4b..9d03d2c3 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 00000000..f063a0cf
--- /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 00000000..ad20b27f
--- /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 b030353b..65a53eb6 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 4f7130ca..8986c17e 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 e55b1b12..6e7bf714 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 e770a3b5..b2f1c809 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 2042f226..7039d5b3 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 cf259f29..0ea7504e 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 00000000..06d13860
--- /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 00000000..3073a144
--- /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 463a6057..6478e357 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 00000000..ddd8547c
--- /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 00000000..b54a3651
--- /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 fc0bc626..8d6dadf1 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 00000000..9cf545d6
--- /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 00000000..a3812c3e
--- /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 00000000..e3aa6a5e
--- /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 64fa4948..069faf00 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 642eee40..d753d9d7 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 0d07a113..4d37ebd3 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 b5127d35..24c5a04b 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 00000000..b7de0976
--- /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 bb07b369..10db0d9a 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 352ddbb9..52db3110 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 a830775f..6ab085a6 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 00000000..35378bd0
--- /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 25d38501..11b25c2f 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 00000000..d9001406
--- /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 00000000..23d243fe
--- /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 00000000..7e036bba
--- /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 3045caed..32844071 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 00000000..2628e81c
--- /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 00000000..538bdf66
--- /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 75c12ccc..a1f8d8d7 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 d36ef3cb..eea1612a 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 0b38531e..4fa96756 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() {