Commit Graph
20 Commits
Author SHA1 Message Date
agessaman 16562621a2 fix(mqtt): gate room-server OTA on an unproven stop; reap late acks
- The room server checked canFlashAfterStop() only when the bridge was
  running at OTA time. After a timed-out restart the bridge reads as
  stopped while its unacknowledged task may still own TLS/client state,
  so an OTA could erase and write flash under it. It now refuses while
  the stop is unproven, after first reaping any late ack. The repeater
  already gated unconditionally.
- MyMesh::loop() now reaps a late stop acknowledgement whenever it lands,
  releasing the withheld queue and buffers, and restarts only when a
  resume is pending and the bridge is enabled. Before, a bridge disabled
  during StopUnproven kept those resources until re-enabled or rebooted.
  pollLateStopAck() is public for this.
- The wrapper's destructor no longer stops an already-stopped client:
  destroySlotClients() had just stopped it, so every shutdown logged five
  spurious "esp_mqtt_client_stop failed: ESP_FAIL" errors.

Hardware (Heltec V4, 1 s test stop deadline): restart -> StopUnproven ->
`set bridge.enabled off`; the late ack was reaped ("releasing withheld
resources"), status read "not running", and `set bridge.enabled on`
started cleanly without a second release.
2026-09-19 14:22:00 -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 abe838bf58 fix(mqtt): typed results for client operations, and stop advancing state on failure (F06)
`connect()`, `reconnect()`, `disconnect()`, `softDisconnect()` and `forceStop()`
all returned void, so every caller in the bridge treated "asked" as "done":

- `setupSlot()` marked a slot activated after `connect()` whatever happened. A
  failed `esp_mqtt_client_start()` therefore consumed one of the scarce
  active-slot positions, handed the slot to a reconnect ladder that is gated on
  activation, and was never retried by the deferred-setup path.
- `reconnect()` explicitly proceeded after `esp_mqtt_set_config()` failed,
  reconnecting on the previous configuration — the renewed token in the buffer,
  the old one on the wire.
- the renewal path recorded the new expiry before the bounce succeeded, so a
  failed bounce left the live session on the old credential with the next
  renewal not due for a whole token lifetime.
- `softDisconnect()` logged its timeout and told its caller nothing.
- `disconnect()` waited for the DISCONNECTED event with no bound, on the very
  task whose stop acknowledgement the shutdown waits for.

Now every one of them returns `esp_err_t`, a failed configuration transaction
aborts rather than starting or reconnecting on a half-updated config, and
`disconnect()`'s wait is bounded (it still stops the client, and reports
ESP_ERR_TIMEOUT when the event never arrived).

Bridge consequences:

- a failed start leaves the slot unactivated, so the existing deferred-setup
  retry revisits it and it holds no active-slot position;
- a failed renewal bounce re-arms the renewal instead of recording it, so the
  next maintenance pass retries;
- a reconnect that fails *locally* rolls back the backoff advance made for it.
  The ladder and the breaker bound broker and network faults; an uninitialised
  client or an uncommitted config transaction is neither, and inflating the
  ladder for it was how a local fault could trip a breaker meant for a broker.
2026-09-09 17:40:45 -07:00
agessaman f64852e223 fix(mqtt): log a failed client start instead of reporting success
connect() logged "MQTT client started." unconditionally, so a failing
esp_mqtt_client_start() looked identical to a successful one. That is the one
state a later reconnect() cannot recover from, which made it the worst possible
line to be wrong.
2026-08-14 09:53:38 -07:00
agessaman 4c90db2199 fix(mqtt): renew JWT credentials without stopping the esp-mqtt client
The scheduled JWT bounce called PsychicMqttClient::disconnect(), which ends
with esp_mqtt_client_stop(). That ends the client task and returns its 6 KiB
stack to the heap at the moment the TLS teardown vacates two 16 KiB mbedTLS
record buffers, so the stack lands in that hole and the next handshake cannot
reuse it. On non-PSRAM boards the largest free block then ratchets down 16 KiB
at a time while total free heap stays flat.

Soak evidence from a Heltec V3 on 8d1a0eb3: 43 of 60 disconnects had no
preceding transport error, i.e. they were this proactive bounce rather than a
broker FIN, and two of the three max_alloc steps landed within 5 s of one.
Losing a whole TLS session later returned exactly 16,384 bytes of contiguity.

softDisconnect() closes the transport without the stop, so the task and its
stack stay put across the handshake. The bounce uses it plus reconnect(), and
falls back to connect() when the client really is stopped, since reconnect()
is a silent no-op in that state.

Also corrects a comment claiming the mbedTLS context survives a transport
close: only the esp-mqtt client object does.

(cherry picked from commit 10cf5cf48fb009e751e25b37fcc1f3d1256ddbbc)
2026-08-14 09:47:40 -07:00
agessaman 53c39dc282 fix(mqtt): publish QoS0 synchronously to bypass ~1 msg/s outbox drain
The esp-mqtt task drains only one QUEUED outbox item per loop iteration, and
each iteration blocks up to MQTT_POLL_READ_TIMEOUT_MS (1s) on esp_transport_poll_read.
With little inbound traffic that caps throughput at ~1 message/second per
connection, so even a light packet rate (~1.2/s) outruns the drain: the outbox
pins at its cap and ~20-30% of QoS0 packets are dropped as backpressure. The
poll timeout is a compile-time constant baked into the precompiled esp-mqtt lib,
so the async drain rate cannot be raised on the Arduino/IDF 4.4 toolchain.

Route QoS0 packet publishes through esp_mqtt_client_publish() (async=false) so
they write straight to the socket, bypassing the outbox drain entirely — QoS0 no
longer touches the outbox. QoS1 status keeps the async/outbox + retransmit path.
The esp-mqtt task releases its API lock before the poll, so a synchronous publish
from the (Core-0, prio-1) MQTT task acquires the lock and writes immediately; a
stalled socket blocks only that task (mesh RX on Core 1 and the WiFi/TCP stack
are unaffected), bounded by a new setNetworkTimeout() lowered to 2500ms so a
first stall fails fast and flips the slot to disconnected.

The outbox cap from the previous commit stays as a dormant safety net. Retools
the MQTT_DEBUG diagnostic from outbox size/drops (now always ~0) to per-slot
publish ok/err counts, the live signal for delivery health, with 1-based slot
numbering to match the status line.
2026-07-11 09:36:35 -07:00
agessaman b7c145929f fix(mqtt): bound esp-mqtt outbox for QoS0 publishes
QoS0 packet/raw publishes are forced into the esp-mqtt outbox (store=true,
async) so packet topics keep flowing, but the outbox has no size bound of its
own — esp-mqtt frees entries only on send-ack or ~30s expiry. On a stalled or
slow uplink (socket still "connected") QoS0 frames accumulate on internal heap
without limit, driving the heap exhaustion/fragmentation seen in the field.

Cap the outbox at the application level: PsychicMqttClient::setOutboxLimit()
records a per-client byte cap, and publish() drops a QoS0 message (returns -2)
when esp_mqtt_client_get_outbox_size() is already at/over the cap, before
enqueuing. The bridge's existing processPacketQueue retry/drop path handles the
-2 as backpressure. Caps: 16 KiB PSRAM / 8 KiB non-PSRAM (outbox lives on
internal heap, so non-PSRAM is the fragmentation-sensitive case).

Portable across IDF 4.4 and 5 via esp_mqtt_client_get_outbox_size(); esp-mqtt's
own outbox.limit config is not used (its enqueue path does not reliably enforce
it for QoS0, and the app-level guard fires before enqueue regardless).

Adds getOutboxSize()/getOutboxLimit()/getOutboxDrops() and surfaces per-slot
outbox size/cap/drops via a throttled logMemoryStatus() in the MQTT task loop
(MQTT_DEBUG-gated) to confirm the bound on-target.
2026-07-11 09:35:40 -07:00
agessaman 4cff79695b fix(mqtt): raise QoS1 retransmit timeout to stop duplicate /status storms
esp-mqtt's default message_retransmit_timeout is 1000 ms: any unacked QoS 1
PUBLISH is resent (byte-identical, DUP=1) every second until the PUBACK
arrives or the outbox entry expires (30 s). Status messages are the only
QoS 1 publishes; on a congested or recovering uplink where broker acks take
several seconds, each 5-minute /status was delivered ~6 times, ~1 s apart,
as exact copies (same timestamp and stats). Downstream observers flagged
excessive_packet_copies and at least one broker treats it as abuse.

Expose message_retransmit_timeout via PsychicMqttClient and set it to 15 s
in optimizeMqttClientConfig: one retry still fits inside the 30 s outbox
expiry, preserving at-least-once delivery while capping duplicates at one.

/packets paths are QoS 0 and were never affected.
2026-07-10 07:53:23 -07:00
agessaman 706d1c7ea2 fix(mqtt): enhance error handling for connection refusals
Improved error handling in the MQTT client to log specific reasons
for connection refusals, including detailed return codes. This change
ensures that users are informed of authentication issues and server
availability problems, enhancing the debugging experience.
2026-06-25 08:13:55 -07:00
agessaman 32449e62cc fix(mqtt): remove errant clearLastWill method from PsychicMqttClient 2026-04-25 18:15:02 -07:00
agessaman 673361b63a fix(mqtt): restore 'origin' field position in packet message structure
Reintroduced the 'origin' field in the buildPacketMessage function to its original position within the JSON object. This adjustment ensures consistency in the message format and aligns with previous structural changes made to enhance clarity.
2026-04-25 17:14:05 -07:00
agessaman 70722a5873 fix(mqtt): restore legacy outbox behavior for QoS0 async publishes
Update the PsychicMqttClient to ensure that QoS0 messages are enqueued with durable outbox storage. This change addresses issues with false-failure semantics in certain connected paths, improving message flow reliability. Additionally, modify platformio.ini to include SSL certificate generation and adjust build flags for reduced verbosity and enhanced functionality.
2026-04-23 22:48:21 -07:00
agessaman bb67b04ef8 fix(mqtt): mark configuration as dirty on setter calls and optimize config application during connect and reconnect
Update the PsychicMqttClient to set a _config_dirty flag whenever a configuration setter is called. This ensures that the MQTT configuration is only applied when changes are made, optimizing the connect and reconnect processes. Added logging to indicate whether the configuration was updated or unchanged.
2026-04-23 21:50:44 -07:00
agessaman 7d0c5bce50 enhance(mqtt): improve QoS handling and retry logic in MQTTBridge
Refactor the MQTTBridge to implement enhanced QoS handling for publish operations. Introduce retry mechanisms for QoS0 packets, allowing for transient failures to be retried with a delay. Update publish methods to return success status, improving error handling and logging. Additionally, adjust the packet queue processing to accommodate new retry logic and ensure better message delivery control.
2026-04-23 21:06:49 -07:00
agessaman e9ff1ae055 Refactor PsychicMqttClient to improve memory management and callback handling. Replace dynamic memory allocations with fixed-size arrays for callbacks, enhancing performance and reducing fragmentation. Introduce inline storage for topics and optimize buffer allocation during connection setup. Update version to 0.2.2 to reflect changes. 2026-04-21 20:43:09 -07:00
agessaman 1b5884bd35 Add reconnect method to PsychicMqttClient for improved MQTT client management
This commit introduces a new `reconnect` method in the `PsychicMqttClient` class, allowing for re-establishing a connection to the MQTT broker without needing to disconnect first. The method checks if the client is initialized, updates the configuration if necessary, and attempts to reconnect, enhancing the overall connection management. Additionally, the MQTT slot management has been updated to support up to 6 configurable slots, improving flexibility in connection handling.
2026-03-25 21:39:51 -07:00
agessaman 498566e6c9 MQTT bridge: fix memory use, improve status reporting
- Main broker: only allocate _mqtt_client when custom broker configured
  (analyzer-only saves one PsychicMqttClient). Reconnect main broker after
  forced disconnect with 30s throttle; set last_attempt on disconnect so
  throttle applies and avoids reconnect storms on flaky WiFi.
- Analyzer clients: call disconnect() when WiFi transitions to disconnected
  so ESP-IDF frees MQTT buffers (it does not free on WiFi drop). Reduces
  fragmentation and Max drop after disconnect/reconnect cycles.
- get wifi.status: report WiFi uptime (Xd Xh Xm Xs) when WITH_MQTT_BRIDGE.
  Track connect time in bridge; backfill when already connected at first check.
- get mqtt.status: show msgs on/off, broker (connected/disconnected/n/a),
  analyzer US/EU (connected/disconnected/off), and queue count.

Note: PsychicMqttClient change (register event only on first client creation)
belongs in the library repo if committed separately.
2026-02-02 19:09:31 -08:00
Rastislav Vysoky 03458269ac moved nrf sdk headers to ./lib/ and added Repeater envs for t114 & t-echo 2025-03-07 00:16:22 +01:00
Scott Powell 86f11d08aa * LocalIdentity:: writeTo( array ) and readFrom (array ) 2025-01-16 01:59:11 +11:00
Scott Powell 6c7efdd0f6 Initial commit 2025-01-13 14:07:48 +11:00