Commit Graph
106 Commits
Author SHA1 Message Date
agessaman e53755a5a9 feat(mqtt): add opt-in advert signature check before publishing
Received packets are uploaded before Mesh::onRecvPacket validates them, so
an advert corrupted over the air that still passes the LoRa CRC is published
with a mangled name or location. `set mqtt.advert.verify on` verifies each
received advert's Ed25519 signature (same message layout as Mesh.cpp,
including the 32-byte app-data clamp) and skips publishing failures.

Defaults off so collectors keep seeing RF corruption on the mesh. Dropped
adverts are counted as badsig= in `get mqtt.stats`. Exposed in the web
config Publishing card and persisted as mqtt.advert_verify in prefs JSON.
2026-10-07 20:07:36 -07:00
agessaman 45d8f15098 Merge branch 'feat/ota-branch-switch' into merge/ota-branch-into-observer-firmware-dev 2026-10-03 15:52:39 -07:00
agessaman 8ec31dda71 fix(ota): skip the bare compat search literal when scanning an image
Every image also holds "ota-compat:" with no value (the search string);
finding it first made every channel switch look untagged.
2026-10-03 15:52:21 -07:00
agessaman baf01a5c16 Merge branch 'feat/ota-branch-switch' into merge/ota-branch-into-observer-firmware-dev
# Conflicts:
#	examples/simple_repeater/MyMesh.cpp
2026-10-03 15:47:01 -07:00
agessaman ed8af1b7ca fix(ota): refuse a channel switch to a build that cannot carry this node
A switch can land on an older build: e.g. prod cannot read dev's /mqtt.json,
and prod's M7 image has no Ethernet. Each image carries an ota-compat tag
(state generation + transports). A switch is flashed without rebooting, the
new image's tag is read (bounded by its length), and boot is pointed back at
the running image unless the target keeps this node's state and transports.
CI now requires the tag in every published binary.
2026-10-03 15:46:46 -07:00
agessaman ee88192acb Merge branch 'feat/ota-branch-switch' into merge/ota-branch-into-observer-firmware-dev
# Conflicts:
#	.github/workflows/build-observer-firmwares.yml
#	examples/simple_repeater/MyMesh.cpp
#	examples/simple_room_server/MyMesh.cpp
#	src/helpers/ESP32Board.cpp
#	src/helpers/ESP32Board.h
#	test/test_config_serializer/test_config_serializer.cpp
2026-10-03 15:24:33 -07:00
agessaman 9c65e198f1 feat(ota): prod/beta channel names for ota branch, with stable/dev aliases
Report the selected channel and the build's own channel, tag each baked-in
base for CI, and list ota branch in the CLI docs and portal autocomplete.
2026-10-03 15:19:06 -07:00
Elektr0Vodka 3910489414 feat(ota): persist ota_channel selector in NodePrefs
(cherry picked from commit db1db5b57ecce408c7441cb6aa70f29608b09603)
2026-10-03 15:17:36 -07:00
Elektr0Vodka f04d163bea feat(ota): channel-base resolver + arg parser with native tests
(cherry picked from commit 52a4ccc39093891b64e3752316fe7c191fd9b9ca)
2026-10-03 15:17:36 -07:00
agessaman 6390763c71 fix(mqtt): count failed starts and ignore bounced sessions in cf
connect()/reconnect() returning an error started no attempt, so no event
arrived and the failure went uncounted -- the low-heap case the counter exists
for. Counted now in a bridge-task-only start_failures, summed into cf: and
mqtt_connect_failures.

A renewal or corrected-clock bounce whose softDisconnect() times out gets its
old session's DISCONNECTED after the new attempt raised the flag. That session
is still marked connected until the handler runs, so the gate now also
requires !connected. An ignored late CONNECTED clears the flag too.

cf: no longer depends on dc being non-zero. Comments corrected for the
PSRAM-fallback stack cost and heap_largest's sampling point; the worst-case
test now models 6 slots.
2026-09-26 12:25:53 -07:00
agessaman 73098a44b6 fix(mqtt): count connect failures from a flag set before the attempt
The Starting-state gate raced the event task: client_state becomes Starting
only after connect()/reconnect() returns, so a fast failure could deliver
DISCONNECTED first and go uncounted, while a deliberate stop of an attempt in
flight was counted as a failure. A per-slot flag is now raised before the call
and cleared by onConnect, a failed start, or stopSlotClient().

Also stops zero-valued connect_failures/slots_breaker from creating an empty
stats object.
2026-09-26 11:53:54 -07:00
agessaman 7004b81c0c feat(mqtt): count connect failures and report tripped breakers
Two gaps remained after the first connection-health commit.

A slot retrying into a heap that cannot satisfy its TLS allocation moved no
counter at all. disconnect_count rose, but it also rises for dropped live
sessions, so it could not separate "actively failing to connect" from "was
connected, dropped once". Adds a per-slot connect_failures, incremented in
onDisconnect when the event arrives while the client is Starting -- meaning
it ended an attempt that never reached onConnect. Late events from a stopped
or quarantined client, and soft-disconnects of a live session, are in other
states and are not miscounted.

A slot parked by the circuit breaker also looked identical to one still
retrying, despite being materially worse: the breaker only probes every 30
minutes, so recovery is far slower and an operator may need to intervene.

Surfaces both:
- `mqttN.diag` gains `cf:<n>` beside the existing `dc:<n>`.
- the status payload gains `mqtt_connect_failures` (summed across slots) and
  `mqtt_slots_breaker` (count of tripped slots), each emitted only when
  non-zero so a healthy board's payload does not grow.

The counter is what distinguishes a retrying slot from a quiet one: while a
slot is down its publish counters cannot move by definition, so this is the
only value that shows the device is still trying.

Both suggested by adversarial review.
2026-09-26 11:10:13 -07:00
agessaman 383339eccc fix(mqtt): size the status buffer for real field maxima
The worst-case test added with the connection-health fields did not model
production maxima -- it used a 28-byte model and a 24-byte firmware string,
while MQTTBridge permits _origin[32], _device_id[65], _board_model[64],
_firmware_version[64] and derives a 63-byte client version. isValidName()
rejects [ ] / \ : , ? * but permits '"', so a 31-character node name of
quotes is legal and every quote escapes to two bytes.

Measured at those real maxima:

  without connection-health fields   762 bytes
  with connection-health fields      851 bytes
  buffer                             768 bytes

So the 768-byte buffer already had only six bytes of headroom before this
work, and the four new fields overflow it. Overflow is silent:
serializeComplete() returns 0 and clears the buffer, and the bridge
publishes only a positive length, so a device would simply stop publishing
status with no error anywhere -- the same blindness the connection-health
fields exist to remove.

Raises STATUS_JSON_BUFFER_SIZE to 1024 and corrects the test to use the
actual maxima. Status serializes into the shared 2048-byte publish scratch
buffer, so this costs no extra RAM; only the PSRAM-allocation-failed stack
fallback grows by 256 bytes. A static_assert keeps the status ceiling within
the shared buffer.

Found by adversarial review of the preceding commit.
2026-09-26 11:09:21 -07:00
agessaman f7ed2f176d feat(mqtt): report slot connection health so an outage is visible
A slot that cannot establish TLS reported nothing. The per-slot counters
track publish successes and failures, and a slot that never connects never
attempts a publish -- so its ok count freezes and its error count stays at
zero for the entire outage. Observed on hardware: a slot sat dead for over
five hours while `sN=` showed 0 errors and the published status payload
carried no indication at all.

Two surfaces gain the missing signal:

- `get mqtt.stats` gains `down=<n>` (omitted when zero, like `filt=`) and
  marks each disconnected slot with a trailing `!`. The marker goes after
  the counts so existing "s(\d)=(\d+)/(\d+)" parsers keep matching.
- The published status payload gains `mqtt_slots_up`, `mqtt_slots_total`,
  `heap_largest`, and `mqtt_outage_secs`. These ride on every healthy slot's
  status message, because the slot that is down cannot report itself.

`mqtt_slots_up < mqtt_slots_total` is the condition to alert on.
`mqtt_outage_secs` is detail: it is present only when the outage has a known
start, so its absence does not imply health -- a slot that has never
connected since boot has no outage start time.

`heap_largest` is included because free heap alone does not predict a failed
connection. A slot needs one contiguous 16,384-byte block for its TLS record
buffers; measured immediately before an attempt this value predicted the
outcome in every observed case, while total free heap was 85 KB throughout a
multi-hour outage. A low value with every slot up is normal rather than a
fault -- established sessions already hold their buffers.

Publish-error semantics are unchanged: connect failures are not folded into
them.

Adds host coverage for the new fields, for the outage key's presence rule,
and for worst-case payload size. That last one matters because
serializeComplete() returns 0 and clears the buffer on overflow and the
bridge publishes only a positive length, so exceeding the 768-byte status
buffer would silently stop status publication -- the same blindness this
change exists to remove.
2026-09-26 11:05:40 -07:00
agessamanandACETyr a91009e3dd fix(mqtt): omit SNR/RSSI instead of publishing placeholder values
The reconstruction-less fallback path in buildPacketJSON (queued packet
with no captured raw frame that cannot be re-serialized) published
fabricated SNR 12.5 / RSSI -65 that consumers could not distinguish
from real radio readings. It now passes NAN / MQTT_RSSI_UNKNOWN, and
buildPacketMessage omits the SNR/RSSI keys entirely when no measurement
exists, mirroring the existing score omission.

Ports the fix from ACETyr's PR #13 (0a037040) on mqtt-bridge-implementation.

Co-Authored-By: ACETyr <35745290+ACETyr@users.noreply.github.com>
2026-09-26 10:46:11 -07:00
agessaman 3d2d0b5da6 feat(webconfig): let the portal discover board-specific CLI commands
The FEM commands moved out of CommonCLI into Board::handleCommand(), and
the T-Beam 1W fan control was added there too, but the portal never caught
up: `radio.fem.txgain` and the fan commands were missing from the terminal
table entirely, while `radio.fem.rxgain` was offered on every board even
though a board with no hook now answers "??:" rather than "unsupported".

Whether a node answers these is a property of the board, not of the build,
so the page cannot know from the firmware version. Ask the board instead:
probeBoardCommands() runs each candidate getter once on the loop task at
startup and keeps the ones that answer, which needs no per-variant list
because Board::handleCommand() already reports whether it handled a
command. /api/status names the survivors and the page hides everything
else, so adding a command to a variant means one entry in WC_BOARD_CMDS
rather than an edit per board.

The two FEM keys also become Radio-panel toggles, gated the same way;
their `set` reaches the board hook through the existing config batch, so
only the allowlist and /api/config needed to grow.

webconfig_cli_audit.py now scans variants/*/*Board.cpp for the boards that
actually build the portal, checks set-only keys in the reverse direction,
and verifies every gate is a command some board answers; the mock gained
--board-cmds so both shapes of board are testable. `stop ota` joins
NOT_OFFERED: `start ota` cannot run from the portal, so it has nothing to
stop.
2026-09-19 20:51:25 -07:00
agessaman 6e7f3cdb52 fix(network): bound link transitions and restore each medium's DNS
Two defects found by an adversarial review of the merge.

A link transition no longer stops a slot whose connect attempt is in flight.
Stopping one means esp_mqtt_client_stop(), which waits on the SDK's API mutex
and its task's stopped event with no bound; the client task notices only when
it returns from whatever transport call it is in. During teardown the bridge's
StopUnproven timeout contains that, but during a transition nothing does, so a
routine WiFi flap could freeze the sole MQTT worker for far longer than the 2 s
this path advertises, with the bridge's own stop handshake queued behind it.
The attempt is left to resolve instead: unlike a reconfigure, a transition
changes neither endpoint nor credentials, so an attempt that completes is
credited to the broker it actually reached and drops with the old route.

Failing back to Ethernet left the node using the WiFi network's DNS server.
lwIP keeps one global server list and IDF 4.4 has no per-interface retention,
so the medium that leased last owns DNS for every socket. Each link now
remembers the resolver its own DHCP lease installed and puts it back when it is
selected again; a medium that never held a lease leaves the current resolver
alone. Where the two networks are on different subnets this was a silent
outage: the link read as connected while every broker and NTP hostname failed
to resolve, until Ethernet's own DHCP renewal happened to fix it.
2026-09-19 15:31:51 -07:00
agessaman 25f26c3058 Merge fix/mqtt-review-2026-09-09 into feat/abstract-networking
#54 lands first, so the network abstraction is reworked on top of the
per-slot client ownership model instead of alongside it.

Two resolutions are semantic, not textual:

- Link-transition teardown goes through the ownership API. It called
  softDisconnect() on every started client, which bypassed client_state and
  ignored the typed result. A slot that is still Starting now gets a real
  stop: softDisconnect() returns immediately on a client that is not yet
  connected, so its attempt would otherwise complete against the old route and
  deliver a CONNECTED event indistinguishable from the new one's (F04). On a
  medium switch the old route can still be briefly usable, so that is not
  hypothetical. A connected client keeps the cheap bounded path, and a
  quarantined one is left alone.

- NetworkLink::applyPowerPrefs() adopts WifiPowerSavePolicy. Its local
  `2 ? MAX : NONE` mapping would have reintroduced F11 and read the new stored
  value 3 (explicit `min`) as none. It also applies the setting when the link
  starts already associated, which is the case the bridge used to cover.

The NTP probe from #54 keeps its validation and its one-attempt-per-server
bound, but resolves and gates on the selected link rather than on WiFi, so it
works on an Ethernet-preferred node.
2026-09-19 15:15:07 -07:00
agessaman 946d6c4f1e fix(wifi): keep the legacy power-save default off; store explicit min as 3
Stored 0 was the shipped default from 2026-01-02 to 2026-03-28, and every
association path has run it with power save off since. Reading it as
MIN_MODEM, as F11 did, would have put every node set up in that window
into modem sleep on its next association. 0 now reads as `none`, and
`set wifi.powersave min` stores a new value 3. /mqtt.json accepts 0..3;
older firmware repairs 3 to `none` on load. Binary snapshots are no longer
written, so 3 never reaches a legacy layout.
2026-09-19 11:27:01 -07:00
agessaman 9316f50d07 Merge branch 'observer-firmware-dev' into fix/mqtt-review-2026-09-09 2026-09-19 11:05:42 -07:00
agessaman 7aed59075b fix(network): bound link-transition teardown and gate Ethernet restarts
- Link down/switch edges now softDisconnect() every started slot instead
  of disconnect(). The full stop could wait forever for a DISCONNECTED
  event on the Core 0 MQTT task, and it destroyed and recreated every
  slot's esp-mqtt task on each Wi-Fi drop, where the bridge previously
  stopped none (measured: 62 s deauth, five slots, zero stops).
  softDisconnect() is bounded and keeps the task; reconnectSlotClient()
  then calls reconnect() on the new route. Rename the transition action
  to disconnect_started_slots to match.
- Ethernet no-IP recovery and init retries rebuild the CH390 netif, so
  they now run inside the route-switch mutation gate. An OTA/WebConfig
  lock taken after maintain() samples the lock count can no longer have
  the interface torn down underneath it.
2026-09-19 10:23:12 -07:00
agessaman 4c683b13b3 fix(network): pre-merge review fixes
- Guard the boot-time link bootstrap to observer ESP32 builds. It ran
  unconditionally in MyMesh::begin(), breaking every non-observer
  repeater/room server build (ESP32 and nRF52).
- Rename NetworkInterface -> NetworkLink (class, accessor, files).
  Arduino-ESP32 3.x ships its own NetworkInterface class and header,
  which broke the ESP32-C6 builds. Drop WiFi.setAutoConnect(), a no-op
  on 2.x and removed in 3.x.
- Refresh stored Wi-Fi credentials every bridge tick so the STA
  reconnect loop picks up `set wifi.ssid` / `set wifi.pwd` without a
  reboot, as the bridge did before the link moved out of it. Skip
  reconnects while the SSID is empty.
- Restore the "WiFi connected: <ip>" / "WiFi disconnected: reason N"
  debug lines the bridge used to print.
- Alert on Ethernet only once it has held a lease this boot or when no
  Wi-Fi is configured; Wi-Fi-only installs of an Ethernet-preferred
  image keep Wi-Fi alerts instead of reporting "Ethernet down".
- Record wifi.setup_complete only for Ethernet LAN onboarding, so
  Wi-Fi builds keep the SSID-based first-boot portal rule.
- Use seq_cst for the route-switch lock/mutation flag handshake.
- Docs: SNMP RSSI sentinel is -127; describe link-return vs medium-switch
  reconnect behavior accurately; note runtime credential pickup.
- Test: unknown keys inside a known /mqtt.json group are ignored, which
  keeps wifi.setup_complete downgrade-safe.
2026-09-19 10:14:16 -07:00
agessaman ad65f64156 Merge branch 'observer-firmware-dev' into feat/abstract-networking 2026-09-19 09:23:32 -07:00
agessaman d42c4249a5 Merge upstream/dev into observer-firmware-dev 2026-09-17 21:19:23 -07:00
agessaman b4daf8ca43 fix(mqtt): close the review's P1 and P2 findings on the F01/F04/F06 work
Review of the branch found one merge-blocking lifecycle hole and three
correctness gaps where the implementation stopped short of contracts the design
had already written down. All four are real; each was confirmed against the
source (two of them against hardware) before anything changed.

**P1 — a quarantined client could still produce a clean bridge stop.** The
cooperative teardown set `_teardown_complete` unconditionally, so a slot whose
`esp_mqtt_client_stop()` had not completed — deliberately skipped by
`destroySlotClients()` and marked Quarantined — still let the trampoline publish
the acknowledgement. The owner then freed the queue and buffers and allowed a
restart while that SDK task might still be running: exactly the ownership
ambiguity StopUnproven exists to remove. The ack is now withheld unless EVERY
client is proven stopped, so one unproven client leaves the whole bridge
unproven. The rule lives in MQTTClientState.h (`mqttStopMayBeAcknowledged`) with
host tests, alongside the state predicates moved out of the bridge.

**P2 — the F04 protection did not cover a client that was still connecting.**
`softDisconnect()` returns ESP_OK immediately when the client is not connected,
so for a slot mid-DNS/TLS/CONNECT it cancelled nothing: the attempt ran on and
its CONNECTED event arrived after the new configuration was applied, and with
callbacks registered once per client and esp-mqtt events carrying no generation,
nothing could tell it from the new attempt's. A reconfigure that lands on a
`Starting` client now stops it, joining its SDK task, before applying the new
configuration. A Connected client still takes the cheap softDisconnect path,
which is where the fragmentation argument applies. One helper
(`closeLiveClientForReconfigure`) so the two call sites cannot drift.

**P2 — a failed renewal bounce still advanced the effective expiry.** Minting
updates `token_expires_at` immediately and the renewal decision read it, so a
bounce that failed looked complete: the next pass saw a fresh future expiry and
never retried, and clearing `last_token_renewal` re-armed nothing. Slots now
carry `applied_token_expires_at` — the expiry of the credential the CONNECTION
is using — which only advances when a connect or reconnect has carried it. A
failed bounce leaves it on the old credential, so the renewal stays due.

**P2 — config-committed and start-accepted were conflated.** `connect()`
returned one result for both, so a start that failed after the configuration had
committed left `applied_config` describing the previous configuration, and the
next recreate-or-reuse decision could reuse a client whose trust policy was not
the one it believed. `applyConfig()` is now its own wrapper operation;
`applied_config` records the commit, activation records the start. The reconnect
ladder resets there too rather than in `teardownSlot()` — the old endpoint's
history still applies until a replacement configuration actually commits.

Two of my own bugs surfaced on hardware while testing this, both fixed here:

- `recreateSlotClient()` called the full `teardownSlot()`, which cleared
  `broker_uri`, the just-minted token and both expiries out from under a
  configuration that had already been decided, so a recreate handed the SDK an
  empty URI and an empty token. It now stops the client and swaps the object,
  touching nothing else, and the apply step refuses to configure a URI that
  changed under it rather than passing it on.
- `stopSlotClient()` quarantined on any non-OK result, but `ESP_FAIL` from
  `esp_mqtt_client_stop()` means "client is in invalid state", i.e. not started:
  there was no task to join, the safest state there is. It was observed
  quarantining healthy clients on hardware. The case that genuinely cannot be
  proven is a stop that never RETURNS, which cannot surface here at all — it
  hangs the task, which is what the bridge-level timeout contains.

Hardware (Heltec V4, 5 live slots): a reconfigure landing on a connecting client
logs `reconfigure during connect - stopping to cancel the attempt`; a broker
holding the CONNACK sees the client close the socket and the disabled slot never
connects; `wss`→`mqtt`→`wss` recreate cycles reconnect each way; a blackholed
endpoint recovers. 499/499 native tests, four envs clean.
2026-09-09 20:03:13 -07:00
agessaman 0a78712ef2 feat(mqtt): effective-config value type and the recreate decision (F02/F03)
The decision half of the client-configuration fix, as a pure header so every
transition is a host test rather than a hardware run.

`MqttEffectiveConfig` is the complete owned description of what a slot's client
should be configured with — URI (its own copy, because `slot.broker_uri` is
rewritten in place), transport, trust policy, auth mode, credential pointers,
buffer size, keepalive. Two things are deliberate:

- the transport is derived from the URI scheme, and the trust policy is
  normalised against it: an unencrypted transport verifies nothing, whatever
  certificate material was requested, so a plaintext endpoint can never look
  like it retained a verified policy;
- absent credentials are represented as nullptr and written as "", never
  omitted. IDF's `esp_mqtt_set_if_config()` treats NULL as "leave unchanged",
  so a cleared wrapper pointer cannot erase an SDK-held credential, while an
  empty string overwrites it and leaves the CONNECT's username flag clear
  (verified on an ESP32-S3 against a local broker on IDF 4.4).

`mqttConfigRecreateDecision()` says whether the existing client can be
reconfigured in place. Reuse covers every credential and auth-mode change and
any endpoint move within one scheme — the cases that matter for reconnect and
token renewal, where a client create/destroy cycle is the fork's documented
internal-heap fragmentation driver. Recreation is reserved for the three fields
that cannot be overwritten safely: a transport (scheme) change, a trust-policy
or CA-certificate change, and growth beyond the allocated buffer capacity,
which IDF 4.4 fixes at client init.

Client certificates are excluded on purpose: `setClientCertificate()` has no
caller in the firmware, so modelling mutual TLS here would be untested
configuration surface. Noted in the header.

Nothing consumes this yet; the bridge is migrated onto it after the result
propagation and generation-guard steps, which it depends on.
2026-09-09 17:35:49 -07:00
agessaman 2d4490bcaa fix(mqtt): release nothing after a stop the MQTT task never acknowledged (F01)
The timeout fallback did the two things that cannot be done safely to a task
that may still be inside mbedTLS or holding esp-mqtt's API mutex: it deleted
that task from the loop task, then force-stopped and deleted the clients it
owned, then freed the queue and buffers it can reach. `esp_mqtt_client_stop()`
waits on the API mutex and the task's stopped event with no bound, so killing
its caller strands the next one; a second stop sees the run flag already clear
and returns failure without joining. Nothing in that path established
quiescence, and `canFlashAfterStop()` could only withhold OTA afterwards.

Changes, all of them about who owns what:

- `MQTTLifecycle` gains a `StopUnproven` state (appended, so existing values
  keep their numbering). `StopTimedOut` now leads there and releases NOTHING;
  it still fires `ota_release` so the OTA barrier aborts. `acceptsNewWork`,
  `mayRestart` and `isStopInProgress` are false there, `mayTouchOwnedState` is
  true, and `end()`'s wait loop still terminates.
- `begin()` refuses to start while a stop is unproven. A new start was never
  proof that the previous clients stopped, and it used to clear the dirty latch
  that withheld OTA. Because a start is now impossible from `StopUnproven`,
  clearing that latch on an accepted start is sound.
- A late acknowledgement is honoured. The task publishes it only after tearing
  its clients down, so it proves the same thing whenever it arrives; the
  deadline is an availability policy, not a statement about what the ack means.
  `pollLateStopAck()` releases the withheld resources and makes the bridge
  restartable — a slow stop (a blackholed WSS broker can hold the SDK well past
  the budget) no longer costs a reboot.
- The handshake flags become `std::atomic<bool>` with release/acquire, and the
  ack moves into the task trampoline immediately before `vTaskDelete(nullptr)`,
  gated on a `_teardown_complete` flag the loop sets. Published from inside the
  loop it proved teardown returned, not that the task had stopped executing —
  and `volatile` ordered none of it. This is the soak campaign's blocker #20,
  the one item on its list that can corrupt memory.
- The force-stop paths are gone with their only caller: `destroySlotClients()`
  and `teardownSlot()` no longer take a `force` flag.
- `get mqtt.status` reports "previous stop unproven, reboot to recover" instead
  of a bare "not running", latched so it survives end() clearing the singleton.

Two lifecycle tests asserted the old contract and are re-encoded, not deleted:
a timed-out stop is no longer `Stopped`-and-released, and "repeated failed
stops leave the bridge restartable" becomes "repeated *slow* stops recover on
their late ack" plus a new case pinning that a never-acknowledged stop stays
unusable for the boot. That availability trade is the point of the change.
2026-09-09 17:33:46 -07:00
agessaman 4c7b6450d7 fix(mqtt): validate NTP replies before trusting them
NTPClient::forceUpdate() treated any non-empty datagram arriving on its fixed
local port 1337 as a time response: it ignored the read length, the version,
the mode, the stratum, the leap indicator and any request/response correlation,
then handed the bytes at offset 40 to the bridge. The bridge only checked that
the derived epoch was at least 2026-01-01 before calling settimeofday() and
writing the RTC. A host probe against the installed library source accepted a
one-byte non-NTP datagram and produced epoch 2085978496. Anyone able to land a
UDP datagram during a query window could set a bogus clock, which then feeds JWT
issuance, certificate validity and packet timestamps.

Replace that path with probeNtpServer(): one exchange per server on a fresh
ephemeral socket, closed again on every exit. A reply is accepted only if it is
a full 48 bytes, from the address and port queried, NTPv3/v4 mode 4, from a
synchronised server (no leap alarm, stratum 1-15), echoing the random transmit
timestamp of the request, with an epoch inside a plausible range. Rejected
datagrams leave the clock alone and do not end the wait, so an early bogus
packet cannot pre-empt the real answer. Era-1 (post-2036) timestamps convert
forward instead of wrapping into 1900.

The diagnostic now runs the same validated probe, so `get mqtt.ntp.diag`
answers the question it is asked — would this server be trusted? — instead of
reporting a datagram nothing checked. It also no longer leaves its UDP socket
open after the probe, and each server's per-probe result carries the reason it
failed ("DNS failed", "unsolicited reply", "server unsynced", ...). The
diagnostic still blocks its caller; making it asynchronous is a separate change.

The acceptance rules live in NtpValidation.h (pure, host-tested), including the
review's one-byte-datagram reproduction.
2026-09-09 14:45:22 -07:00
agessaman 5ce284f0d9 fix(mqtt): label SDK errors from named constants; report broker refusals
Four of the hand-written error labels named the wrong failure against the
installed SDK: Wi-Fi reason 201 is NO_AP_FOUND (reported as "security
mismatch"), 202 is AUTH_FAIL (reported as "auth mode rejected"), 39 is TIMEOUT
(reported as "SSID not found") and 34 is MISSING_ACKS (reported as an
"AP state mismatch"). esp-tls 0x8008 is TCP_CLOSED_FIN, not a timeout, and
0x8010 is CERT_PARTLY_OK, not a generic mbedTLS error. 0x800B, labelled
"cert verify failed", is not an esp-tls error at all. So the one line an
operator reads to find out why a node is offline could name the wrong cause.

Move the tables into MQTTErrorLabels.h (pure, host-tested) with every value
static_asserted against the SDK enum/#define actually being compiled, so a
framework bump fails the build instead of relabelling errors in the field.
Codes the SDK does not define — including 61/88/168, which had labels no
header supports — now return no label and the caller prints the raw number.

Also keep the CONNACK return code from a broker refusal and show it in
`get mqttN.diag` ("refused: bad user/password (4)"). A refusal is not a
transport failure, so the TLS/socket fields are empty and a slot with wrong
credentials previously showed no useful detail at all.
2026-09-09 14:44:37 -07:00
agessaman dfdcc25b50 fix(wifi): one power-save mapping for CLI, startup and reconnect
`set wifi.powersave min` stored 0 and applied WIFI_PS_MIN_MODEM, but the
bridge's post-reconnect mapping read the same 0 as WIFI_PS_NONE. A node
explicitly configured for minimum modem sleep therefore ran with power save off
after its first reconnect, while `get wifi.powersave` still reported min. The
stored default is already 1 (`none`), so nothing here changes the product
default — it stops an operator's explicit choice from being reinterpreted.

Move the value/name/mode table into WifiPowerSavePolicy (pure, host-tested) and
use it from the CLI setter and getter, the web config snapshot and the bridge.
The bridge now also applies the mode when it finds the STA already associated at
start — that path has no connect transition to carry the setting, so a bridge
restart used to leave whatever mode was set before.
2026-09-09 14:44:14 -07:00
agessaman d8ffae3230 fix(dispatcher): pick radio-watchdog activity by age, not largest timestamp
The watchdog took max(last_recv, last_irq, last_tx) of three raw millis()
timestamps. Around the 32-bit wrap a transmit stamped in the upper half of the
range is numerically larger than every fresh post-wrap receive, so it kept
winning for up to ~24 days: the watchdog measured silence from that stale
transmit, concluded the radio had gone deaf, and reset a radio that was
receiving normally — once per watchdog interval, until the old value stopped
winning.

Extract the decision into RadioWatchdog (pure, host-testable). It compares each
timestamp only against its own previous value, so any change is fresh activity,
and stamps it against a monotonic 64-bit clock extended from millis(). Evidence
older than one 32-bit cycle now stays old instead of aliasing back to "recent",
which the previous unsigned-subtraction age could not do either.

Covered in test/test_radio_watchdog: pre-wrap TX with continuous post-wrap RX,
genuine silence across the wrap, silence past a full millis() cycle, no activity
since boot, disabled watchdog, TX-only traffic, and a stalled mesh loop.
2026-09-09 14:42:39 -07:00
Michael Graff d33fb4e9a7 Fix config parser aborting on an empty object
'custom:{}' (a DynamicConfigSerializer with nothing set) hits EXPECT_KEY
with a '}' and returns TOK_ERROR, so loadSerial stops there and silently
drops every property after it. Nothing follows 'custom' in NodePrefs
today, so it goes unnoticed until you add one.

Also include stdlib.h, which Arduino.h was providing on-device but not
in the native test build.
2026-09-05 23:15:13 -05:00
agessaman 9b739a9b43 fix(network): harden Ethernet-preferred lifecycle 2026-09-04 08:42:46 -07:00
agessaman 50543c9b47 fix(network): harden automatic link transitions 2026-09-03 20:47:11 -07:00
agessaman 17409e56a0 feat(network): implement hostname configuration for automatic interfaces 2026-09-03 17:16:30 -07:00
agessaman cdde4bf673 feat(mqtt): add link diagnostics and improve network logging 2026-09-02 21:37:30 -07:00
agessaman 50e2b2acba feat: add automatic network failover 2026-09-01 21:18:22 -07:00
agessaman 3975bc0b01 feat: abstract MQTT network transport 2026-09-01 16:31:28 -07:00
Adam Gessaman 99a7e99a2e Merge upstream/dev into observer-firmware-dev 2026-08-28 19:45:59 -07:00
agessaman 018d68063f fix(display): harden CHSC6X polling and long-gap activity accounting
Addresses a Codex review of the preceding three commits.

Touch could disable itself for the whole session. begin() latched _present
from a single address probe, but this controller NACKs whenever it has nothing
to report, so an idle probe at boot was indistinguishable from absent
hardware. The probe is now diagnostics only; checkTap() polls regardless, and
a NACK costs one quiet bus cycle.

Touch no longer mutates shared bus state. Wrapping the read in
setTimeOut()/restore was an unsynchronised global write, and the MQTT task
drives the same Wire through AutoDiscoverRTCClock in its NTP fallback, so it
could inherit the short timeout. The address probe alone removes the
ESP_ERR_TIMEOUT stalls that motivated it, and it also ran before the timeout
was installed, so the transaction most exposed to a wedged bus was unprotected
anyway.

A single failed read could fake a release. The debounce window was 40 ms
against a 50 ms poll, so a state change was confirmed by the very next sample
and one NACK mid-touch produced a release followed by a second tap. It is now
80 ms - two consecutive consistent reads.

RadioActivityWindow froze after a gap longer than ~24.8 days. tick() treated
any delta past the signed halfway mark as an out-of-order timestamp, so a node
left unserviced that long kept a month-old packet in the 20-minute window and
reported it as recently received. A backwards step is now only believed when
it is small, which is what an out-of-order reading between two call sites
actually looks like.

Not changed: a downgrade that then saves prefs drops the display group, since
/mqtt.json is rewritten from the older serializer's known schema. That is
inherent to every appended field in this format, and bumping the JSON version
would be worse - older firmware would reject the file rather than ignore one
group. Documented instead.
2026-08-28 14:55:44 -07:00
agessaman f3c81b559b feat(display): add runtime display.flip for panel orientation
`set display.flip 0|1` (also off/on) turns the panel 180 degrees from its
compiled DISPLAY_ROTATION, persisted in MQTTPrefs alongside display.timeout
and applied live without a reboot.

Adding 2 to the compiled rotation rather than setting an absolute value keeps
portrait portrait and landscape landscape, so the DisplayViewport geometry
never changes with it and the setting cannot produce a nonsensical mix.
DisplayDriver gains a defaulted no-op setFlipped(), so no other display
driver is affected.

The compiled rotation was verified identical across `pio run` and `build.sh`
on two machines (movi a11, 2 at the setRotation call site), yet the panel read
upside down for one tester and upright for another - which is what a board
mounted either way up looks like. No single compiled constant satisfies both,
so orientation becomes a setting rather than another rebuild.

Runtime-only, like display_timeout_secs: LegacyV1MQTTPrefs and the frozen
binary payload sizes are untouched, and the JSON group is an append that older
firmware skips.
2026-08-28 14:37:03 -07:00
agessaman fcd92e985f feat(display): add R8 observer TFT dashboard, touch toggle and display.timeout
Replace the sparse Heltec V4 R8 observer home screen with a padded dark
analytics dashboard, add manual display control, and make blanking a runtime
setting.

Dashboard (DISPLAY_ACTIVITY_DASHBOARD, the four R8 TFT observer envs):

- RadioActivityWindow: 20 one-minute buckets of valid RX packets, no heap.
  The caller's 32-bit millis() is extended to a monotonic 64-bit clock, so
  nothing downstream has a rollover case; an always-on node passes 2^32 ms
  after ~49.7 days, which would otherwise re-enter warm-up and divide 20
  minutes of traffic by seconds. Rates use 19 whole minutes plus the elapsed
  part of the current one rather than a fixed 1200 s.
- ObserverDashboard: header, radio strip, headline totals, a 20-bar
  packets-per-minute graph and RF/status footers, with separate portrait and
  landscape layouts. A text row is a fixed 16 px, which is 3.2 logical units
  in portrait but 4.27 in landscape, so one shared grid would overlap.
  Text is trimmed by character budget, not measured width: getTextWidth()
  reports an over-long string at the portrait driver's fallback scale, so
  DisplayDriver::drawTextEllipsized() under-trims and the row renders at half
  height.
- Six per-row signatures computed from what is actually drawn, so only the
  rows whose pixels changed repaint. No startFrame(), no whole-screen clear.
  Link state moved out of the full-frame signature, so a DHCP renewal or WiFi
  flap repaints one footer row instead of the panel.
- Dark theme by retuning the UIColor statics at runtime, which needs no
  display-driver edit and carries boot, setup, reboot and power-off with it.

Touch and button (DISPLAY_TOUCH_TOGGLE):

- CHSC6X at I2C 0x2E, polled; TP_INT is unusable (optional R13, and GPIO 43
  is U0TXD). The point-count byte is tested against a valid count, never
  against non-zero: an idle read returns 0xFF, which reads as a finger held
  down forever and latches the tap detector after one event.
- turnOff() no longer parks PIN_TFT_RST low on this board. GPIO 21 is a
  shared LCD_RST/TP_RST net, so doing that held the touch controller in
  reset for as long as the display was off. Verified against Heltec's
  expansion-board and mainboard schematics and the V4-R8 datasheet pinout,
  which also correct the pin comment in HeltecV4R8Board.cpp.
- The USER button click now toggles the display too; it previously did
  nothing whenever the display was already on.

display.timeout:

- `set display.timeout <secs>` / `get display.timeout`, 0 = stay on, 60 s
  default, 3600 max. Read live, so a change applies without a reboot and
  restarts the countdown rather than firing on the old deadline.
- Stored in MQTTPrefs (/mqtt.json), keeping NodePrefs aligned with upstream.
  Runtime-only: LegacyV1MQTTPrefs and the four frozen binary payload sizes
  are unchanged. No JSON format-version bump - the loader skips keys no
  def() claims, so older firmware reads newer files and this firmware reads
  older ones with the default applied. Both directions are covered by tests.
- Joins the observer atomic-setter contract, so a failed save rolls the live
  value back instead of only claiming to.

New periodic work uses a wrap-safe deadline check; `millis() >= deadline`
fires every loop for a whole interval before each rollover.

Adds test_radio_activity_window, test_observer_dashboard (driving the real
renderer against a recording DisplayDriver in both orientation profiles) and
test_touch_tap_detector. 440 native cases pass.
2026-08-28 13:39:16 -07:00
agessaman fc361ca94b feat(display): add R8 portrait observer layouts 2026-08-28 09:21:38 -07:00
Scott Powell 8ccc9928a8 * DynamicConfigSerializer fixes, and unit tests 2026-08-24 19:42:55 +10:00
agessaman 0a3b50bcf9 test(alert): add WiFi fault policy host tests 2026-08-20 19:46:58 -07:00
agessaman 3666cb6da9 fix(mqtt): preserve WiFi outage state for alerts 2026-08-20 19:46:44 -07:00
agessaman a0aec91c17 fix(mqtt): render the mbedtls diagnostic magnitude correctly
ESP-IDF stores the mbedTLS stack error as a positive magnitude (it captures
-ret), so negating it before printing produced "mbedtls:-0xFFFF8100" instead
of "mbedtls:-0x7F00" for the record-buffer allocation failure. %04X is a
minimum width, so nothing masked it.

Normalisation moves into MQTTReplyFormat.h as mbedtlsErrorMagnitude() rather
than staying inline in the bridge: inline is why this survived, since the
existing test passes the already-correct magnitude straight into replyAppendf
and never exercised the caller. It accepts either sign so a later SDK storing
the real negative code still renders, and widens to int64_t before negating
because negating INT32_MIN is undefined behaviour.

MQTTReplyFormat.h also gains the stdint.h it was always missing: it compiled
only because MQTTBridge.cpp pulls stdint in via other headers, and the host
test includes the header standalone.
2026-08-18 13:54:55 -07:00
agessaman 4bdbe33a45 fix(mqtt): Align JWT reuse with renewal buffer 2026-08-17 10:42:09 -07:00
agessaman 28877f3017 fix(mqtt): report an undeletable rejected /mqtt.json temp as unresolved
Cleanup before the commit phase ignored whether the temp was actually
removed. On a fresh install a complete, byte-verified temp that failed
schema verification (or whose read-back failed in finish()) could survive
a failed remove() with no primary to outrank it, and boot recovery then
promoted the very value the CLI had just reported as rolled back.

Both cleanups now return a disposition: success means the temp is gone or
an existing primary is authoritative. A false disposition maps to the new
CleanupIndeterminate, which latches the same indeterminate reply as a
commit that could not be rolled back. A short write is excluded, since it
leaves structurally incomplete JSON that recovery classifies as invalid.

Recovery also stops spending transaction state on an opaque backup: an
uncertain temp beside a FutureUsable or uncertain backup now holds both
names and runs defaults instead of promoting the candidate into the
authoritative name, where the "any primary owns the name" rule would keep
it even after a later boot proved it corrupt.
2026-08-15 21:41:20 -07:00
agessaman dad6d39c45 fix(mqtt): keep an unclassifiable /mqtt.json candidate across boots
Recovery preserved a FutureClaimed or Indeterminate temp, but published the
backup into the primary name to run that boot. That spent the one piece of
state saying the candidate had already passed the backup rename: the next
boot saw an ordinary usable primary beside a stray temp, and deleted the
temp precisely when more heap or newer firmware finally made it readable.
The OOM path needed no future firmware to hit it — power cut after the
backup rename, one boot short of classification scratch, and a verified
new image was gone.

Answer an uncertain temp with UseBackupHeld instead: rename nothing, read
the last committed image straight out of /mqtt.json.bak, and hold writes.
The filenames then still describe the interrupted transaction, so a later
boot promotes the candidate through the ordinary temp rule, or falls back
to the backup once the candidate proves definitively corrupt.

Tests: two-boot sequences for Indeterminate and FutureClaimed candidates
that later classify as Usable or FutureUsable, the invalid-candidate
fallback, and the no-usable-backup case where the candidate still takes
the authoritative name.
2026-08-15 15:22:22 -07:00