Commit Graph
3701 Commits
Author SHA1 Message Date
agessaman 53bae5684e fix(mqtt): measure the setup-retry interval from the failure
last_reconnect_attempt starts at zero and teardownSlot() re-zeroes it, so the
retry gate in maintainSlotConnections() reduced to "uptime >=
SLOT_SETUP_RETRY_INTERVAL". Past 60 s of uptime a failed setup was therefore
retried on the next maintenance pass rather than 60 s later -- in the same task
iteration for a live reconfigure, since reconfigure processing runs before
maintenance in the loop.

setupSlot() now stamps last_reconnect_attempt on each failure that represents a
real attempt (client allocation, and both JWT token paths), so all three callers
get the interval measured from the failure. The bounds check and the !enabled
early return are not attempts and stay unstamped. The reconnect ladder reads
this field only for slots with initial_connect_done set, which a failed setup
never sets, so reconnect timing is unaffected.

The retry path's own pre-call stamp is kept as a backstop for any future
false-returning path that does not stamp itself.
2026-08-04 14:04:08 -07:00
agessaman 6be84eb4eb fix(mqtt): let a retried setup consume the reconnect allowance
reconnect_attempted_this_cycle is computed once before the maintenance loop and
only maintainSlotConnection() was setting it. The deferred-setup retry armed the
15 s cross-slot guard via _last_slot_reconnect_ms but left the local flag false,
so a disconnected slot later in the same pass still saw "no reconnect yet" and
started a second TLS handshake. Two concurrent ~40 KB sessions is exactly the
contention the guard prevents, and it landed in the one situation where internal
heap is already known to be short -- a failed allocation is why the retry runs.

Set on success only: setupSlot() returns true only after client->connect(), so
success means a handshake was launched. A failed retry launches nothing and
continues to spend only setup_retry_this_cycle, which rate-limits the allocation
attempts without consuming the handshake allowance.
2026-08-04 14:03:21 -07:00
agessaman e242cc6fd3 fix(mqtt): address review of the demand-driven slot work
[P1] A failed setup no longer strands the slot. setupSlot() returns bool and the
startup loops count only successful activations, so a slot that fails on a client
allocation neither consumes an active-slot position (starving a later healthy
broker on capped hardware) nor sits dead forever: maintainSlotConnections()
previously skipped clientless slots and the reconnect ladder is gated on
initial_connect_done, so nothing retried it. It now retries an enabled but
unactivated slot on a 60 s timer, one per cycle, gated on the same
_slots_setup_done ordering so the NTP-deferred setup sequence is preserved.

[P2] JWT setup no longer proceeds without a usable token. Both the preset and
custom-audience paths returned after ignoring createSlotAuthToken()'s result,
then called connect() and latched initial_connect_done -- so the token-allocation
failure introduced by the previous commit produced an unauthenticated attempt
exactly when memory was exhausted. They now return false and let the retry path
handle it.

[P2] ensureSlotAuthToken() no longer clears an existing token. It cleared
unconditionally, so every renewal wiped the current token before JWTHelper ran;
a renewal that then failed left an empty password where the inline buffer used to
preserve working credentials (JWTHelper writes only on success). Only freshly
allocated buffers are initialised now.

[P2] Raw publications reuse the shared document. buildRawJSON() reached
MQTTPayloadBuilder::buildRawMessage(), which constructed its own default
JsonDocument and therefore malloc'd and freed an internal-heap variant pool per
message -- on the highest-rate topic. The document is threaded through both
builders and the bridge passes _json_scratch_doc.

[P3] The writeTo() guard validates the source fields, not just the destination.
A corrupt payload_len of MAX_PACKET_PAYLOAD + 1 still leaves getRawLength()
inside MAX_TRANS_UNIT, so writeTo() read past packet->payload. Sizing and
validation moved to a pure MQTTWireScratch header with host tests covering the
accept/reject edges, matching the MQTTPacketFilter/MQTTConnectionPolicy pattern.
Two findings fell out: MAX_PATH_SIZE one-byte hops is not encodable (the hop
count is 6 bits, so 64 & 63 == 0; 32 two-byte hops is the widest real path), and
a zero-payload packet serializes but does not survive readFrom() -- pinned as a
test because it constrains any future wire-only queue.

[P3] Corrected the pool-size comment: these targets are 32-bit, so
ARDUINOJSON_SLOT_ID_SIZE is 2 and a pool block is 128 slots / 1024 bytes, not
4096. The 4096 figure came from a pre-existing comment near
NEIGHBORS_DOC_POOL_BUDGET, which is left alone -- its byte measurements are
empirical and still stand, only the block-size attribution is wrong.

Activation is now centralized in activatedSlotCount()/canActivateSlot(), used by
both startup loops, the retry path, and applySlotPreset(). That closes the
pre-existing divergence where a live preset change called setupSlot() without
consulting _max_active_slots, letting a non-PSRAM board reach three concurrent
TLS sessions against a cap of two. BEHAVIOUR CHANGE: a reconfigure that would
exceed the cap now logs and leaves the slot inactive instead of connecting.
Reconfiguring an already-active slot still works, because teardownSlot() releases
its position first.

272/272 native tests pass (5 new); both observer envs and an nRF52 repeater build
clean. Flash 1593249 B non-PSRAM, 1555625 B PSRAM.
2026-08-04 10:13:04 -07:00
agessaman b93b6b1560 perf(mqtt): keep slot JWT tokens in PSRAM where available
ensureSlotAuthToken()/releaseSlotAuthToken() now use the bridge's existing
psram_malloc/psram_free rather than malloc/free, so a JWT slot's 768-byte token
comes out of PSRAM instead of internal DRAM. On PSRAM boards this is the larger
half of the auth_token work: the previous commit reclaims 768 B per slot that
never creates a token, while this reclaims 768 B per slot that does -- up to
3840 B of internal DRAM on a fully configured five-slot board, which is where
the mbedTLS working set is competing for space.

No effect on non-PSRAM boards: psram_malloc falls back to internal DRAM, so the
buffer stays exactly where the previous commit left it. Same for a
BOARD_HAS_PSRAM board whose PSRAM fails to initialise.

Safe to move because every access is a CPU copy on the bridge task, never DMA,
an ISR, or a cache-disabled window: JWTHelper memcpy's the token into this
buffer, and esp-mqtt copies it out of _mqtt_cfg into its own internal-DRAM
storage when connect() applies the config. This is unlike the PSRAM-backed MQTT
task stack that was tried and reverted for resetting Heltec V4 boards, where
the fault was PSRAM execution context rather than a plain buffer read.

Split from the previous commit so it can be reverted alone if hardware soak
shows any PSRAM-related instability on the JWT path.

flash  non-PSRAM 1592801 -> 1592865 B (+64)
           PSRAM 1555213 -> 1555237 B (+24)
267/267 native tests pass; both observer envs and an nRF52 repeater build clean.
2026-08-04 09:47:06 -07:00
agessaman 9141ad854f perf(mqtt): allocate slot JWT token buffers on demand
MQTTSlot carried an inline char auth_token[768] -- 768 of its 1192 bytes --
for every runtime slot, whether the slot was JWT, username/password, disabled,
or above the active-slot cap. Now a char* allocated by ensureSlotAuthToken()
from createSlotAuthToken(), the sole writer, which runs only once a slot is
confirmed to have a JWT audience.

Because _slots[] is a fixed member array, this shrinks the bridge object
unconditionally at construction and re-adds only what is used:

  MQTTSlot     1192 ->  428 B  (-764)
  MQTTBridge  non-PSRAM 12032 -> 9740 B  (-2292)
                  PSRAM 10080 -> 5496 B  (-4584)

The object is allocated as one contiguous block at boot, so a smaller boot-time
chunk is easier to satisfy and leaves a larger contiguous remainder; JWT slots
then take 768 B each as separate blocks. Net steady-state saving is 768 B per
slot that never creates a token -- 768 B on a fully configured board, up to
3840 B on a PSRAM board with one JWT slot. 22 of the 29 presets are
MQTT_AUTH_JWT, so this is mostly reclaiming unconfigured slots rather than
non-JWT ones.

Lifetime rules, which are the whole risk here:
- setCredentials() stores this pointer in _mqtt_cfg rather than copying, and
  esp-mqtt re-reads it whenever a later connect() re-applies a dirtied config.
  So the buffer is freed only alongside the client, in destroySlotClients(),
  after the delete. MQTTSlot::broker_uri already carries an "avoids dangling
  pointer" comment from this same hazard with setServer().
- teardownSlot() still clears the token to an empty string but keeps the
  buffer: the client survives teardown and does not clear cfg->password (only
  setupSlot()'s reconfigure branch does). Freeing there would dangle.
- Never freed per reconnect, preserving the churn-avoidance the inline buffer
  was there for.

Allocation goes through MQTTRuntimeBufferLifecycle (already host-tested) using
plain malloc, so the buffer stays in internal DRAM exactly where it was when
inline. Failure propagates through createSlotAuthToken()'s existing bool.

The two call sites that create a token and then test it (preset JWT in
setupSlot, and the custom-slot audience path) now null-check first; the five
other readers are all inside if (createSlotAuthToken(...)) success branches and
need no change. destroySlotClients() releases the token unconditionally rather
than after its client null-check, so a token can never outlive its slot.

flash  non-PSRAM 1592697 -> 1592801 B (+104)
           PSRAM 1555109 -> 1555213 B (+104)
267/267 native tests pass; both observer envs build clean.
2026-08-04 09:45:30 -07:00
agessaman c833da10e9 perf(mqtt): allocate slot clients on demand
initSlotClients() created a PsychicMqttClient for every one of
RUNTIME_MQTT_SLOTS at begin(), without consulting slot.enabled or the active
cap -- even though presets are applied and _max_active_slots is computed
earlier in the same function. RUNTIME_MQTT_SLOTS is deliberately cap+1
(6/5 with PSRAM, 3/2 without), so at least one client was always unusable,
and a lightly-configured board wasted several.

Replaced with ensureSlotClient(index), called from setupSlot() -- i.e. only
for a slot that is enabled, inside the cap, and ready to connect. A client
that never reaches setupSlot() is completely inert: the reconnect ladder is
gated on initial_connect_done, which only setupSlot() sets. Retained for the
bridge lifetime as before, so reconfigure/reconnect still reuse one mbedTLS
context; that context is built by connect(), not by the constructor, so
deferring costs nothing but the 1284-byte object.

Internal DRAM saved, by configured slot count:
  non-PSRAM (3 runtime / 2 cap)   1 configured  2568 B
                                 2-3           1284 B
  PSRAM     (6 runtime / 5 cap)   1 configured  6420 B
                                 2             5136 B
                                 5-6           1284 B
A BOARD_HAS_PSRAM board whose PSRAM fails to init gets cap 2 against 6
runtime slots, so it saves at least 5136 B -- on the board that just lost its
PSRAM.

Two gates used "client != nullptr" as a proxy for "configured". Under eager
allocation that conjunct was always true and therefore harmless; with lazy
allocation it would have made shouldQueuePacketType() drop every packet
received before the post-NTP-sync slot setup, which is precisely the window
the queue exists to cover. Both now key on slot.enabled, matching the
documented intent above eligiblePacketSlots() that a configured-but-
disconnected broker is still a target.

formatSlotDiagReply() gains the !isSlotReady() -> "wait" branch that
get mqtt.status and getSlotStatusSnapshot() already have; that state
previously fell through to "disc" and read as a network fault when it is
really a missing token/IATA/credential. "no client" now means only what its
name says: the slot is ready but the client could not be allocated. That
state is newly reachable, so the allocation uses new (std::nothrow) -- this
framework enables C++ exceptions, and a throwing new on exhaustion would
panic the node instead of degrading one slot.

flash  non-PSRAM 1592573 -> 1592697 B (+124)
           PSRAM 1555001 -> 1555109 B (+108)
267/267 native tests pass; both observer envs build clean.
2026-08-04 09:16:47 -07:00
agessaman c28548aecf perf(mqtt): tranche 1 memory reductions in the bridge
Five independent, behavior-preserving reductions. Measured on
Heltec_v3_repeater_observer_mqtt (non-PSRAM) and
ThinkNode_M7_repeater_observer_mqtt (PSRAM), 267/267 native tests green.

Drop the unused static-task bookkeeping. StaticTask_t _mqtt_task_tcb (344 B)
and StackType_t* _mqtt_task_stack were never used: there is no
xTaskCreateStatic call, the pointer was assigned nullptr immediately before
xTaskCreatePinnedToCore, and the two psram_free() calls on it were dead.

Size the wire-format scratch buffers from the protocol maximum. raw_hex[1024]
at three sites becomes 2*MAX_TRANS_UNIT+1, and raw_buf/reconstructed[512]
become MAX_TRANS_UNIT. Both writeTo() sites now check getRawLength() first:
writeTo() does not bounds-check and returns uint8_t, so the old 512-byte
buffers were the only thing absorbing a malformed payload_len, and the
post-hoc "raw_len > sizeof(buf)" test ran after the overrun.

Pass the already-known serialized length into publishToSlot() instead of
re-running strlen() per destination slot (up to 2 KB per packet per slot, and
NEIGHBORS_JSON_BUFFER_SIZE per neighbor snapshot). Same for the direct
publish in publishStatusToSlot().

Share one JSON buffer and one document across packet, raw, and status. All
publish paths serialize on the bridge task, so the separate status buffer and
document were never concurrent. Status keeps STATUS_JSON_BUFFER_SIZE as its
serialization ceiling, so which oversized status documents get dropped is
unchanged.

Route the document's pools through a PSRAM-preferring allocator. Under
ArduinoJson 7 StaticJsonDocument<N> is a deprecated empty subclass of
JsonDocument whose template argument only feeds capacity(); the object is
64 B and each pool block (4096 B here) came from plain malloc(), i.e. the
internal DRAM the mbedTLS working set needs. Mirrors NeighborsDocAllocator.
The old comment claiming an inline pool has been corrected.

Measured:
  sizeof(MQTTBridge)  non-PSRAM 13208 -> 12032 B  (-1176, internal heap)
                          PSRAM 10492 -> 10080 B  (-412, plus one fewer
                                                   768 B PSRAM allocation)
  stack, non-PSRAM    buildPacketJSON[FromRaw] 1264 -> 736 B
                      buildRawJSON             1136 -> 608 B
                      publishPacket             688 -> ~432 B
                      packetToHex               560 -> ~304 B
                      deepest publish chain    ~2.6 -> ~1.8 KB of 8 KB
  flash               1592513 -> 1592573 B (+60)
  static RAM          74656 B unchanged -- MQTTBridge is heap-allocated, so
                      these savings are internal heap, not the linker figure

Deferred from the review: the QueuedPacket wire-only redesign (reward is
1.56 KB on non-PSRAM only, and Packet::readFrom() rejects payload_len == 0),
demand-driven slot clients/JWT tokens, and pool retention across publishes --
JsonDocument::to<T>() always calls clear(), which destroys pools, so
"retain pools by clearing the root object" needs a string-pool lifetime
analysis first.
2026-08-03 22:15:12 -07:00
agessaman 0bfc281a91 docs(handoff): record the 2026-08-03 upstream merge
13 commits, 6 files, zero conflicts. Records the byte-identical smoke-build
baseline, the LR1110 RX-timeout fix and the global nRF52 CC310 crypto switch,
and the fact that the ESP32-only smoke pair could not see either.
2026-08-03 11:28:18 -07:00
agessaman 45379ad742 fix(build): restore nRF52 builds broken by observer-only dependencies
Three fork-introduced dependencies had leaked into non-ESP32 builds, breaking
every nRF52 and RP2040 target since 7e4f75c9 (2026-04-10):

- The `memory` CLI command called ESP.getFreeHeap()/heap_caps_* unguarded from
  shared CommonCLI.cpp. ESP32 output is unchanged; other platforms now report
  newlib arena stats, omitting min-ever-free and largest-free-block rather than
  substituting numbers that mean something different.
- The vendored PsychicMqttClient (ESP-IDF esp-mqtt) was pulled in by the LDF on
  nRF52; nrf52_base now lib_ignores it, matching how RP2040 variants ignore BLE.
- JWTHelper.cpp and MQTTMessageBuilder.cpp are excluded in arduino_base, but 22
  variants re-glob helpers/*.cpp after inheriting it, which undoes the exclusion.
  Guarding the file contents on WITH_MQTT_BRIDGE is robust against any variant's
  filter, and matches helpers/esp32/WebConfigServer.cpp.

Verified: RAK4631 repeater + room server, Heltec T114, T1000-E, Wio WM1110,
Xiao nRF52 and ThinkNode M1 all build. ESP32 observer builds are byte-identical
to before (RAM and flash), and the native suite stays at 267/267.

RP2040 still fails separately: four boards declare the pre-force_ap
startOTAUpdate signature. Not addressed here.
2026-08-03 11:27:36 -07:00
agessaman 126a2564ed Merge remote-tracking branch 'upstream/dev' into observer-firmware-dev 2026-08-03 11:02:13 -07:00
agessaman 716f54d4d8 docs(handoff): scope the neighbors pool bug to the dev channel
The starvation needs ArduinoJson v7's fixed 4096-byte pool blocks plus dev's
custom budget-capped allocator. Production is still on v6 (MQTTMessageBuilder
uses createNestedArray, removed in 7.0), where DynamicJsonDocument(10240) is a
real compact slot pool needing ~3.8 KB for 50 entries, so prod is unaffected
and needs no hand-port.

Also notes that prod pins no ArduinoJson version at all.
2026-08-03 10:58:31 -07:00
agessaman 28e586f027 merge: non-PSRAM neighbors support into observer-firmware-dev
Enables neighbors publication on the ESP32-S3 non-PSRAM observer envs via a
per-variant MQTT_NEIGHBORS_WITHOUT_PSRAM opt-in, and fixes a pre-existing JSON
pool-budget bug that silently dropped the publish on PSRAM boards with roughly
40+ neighbours.

Bench-verified 2026-08-03 at the 2-wss-slot non-PSRAM maximum.
2026-08-03 10:55:41 -07:00
agessaman 46ee60da90 docs(handoff): record non-PSRAM neighbors hardware verification
Bench run 2026-08-03 on a non-PSRAM ESP32-S3 observer at the 2-wss-slot
non-PSRAM maximum: periodic publish succeeded, min-ever free internal heap
53776 B, payload 1252 B of the 4096 B buffer.

Also records the pre-existing PSRAM pool-budget bug found while verifying,
and the two scenarios still untested on hardware: the >20-neighbour
truncation path, and a publish coinciding with a slot reconnect.
2026-08-03 10:54:43 -07:00
agessaman a3a0a94dc4 feat(mqtt-neighbors): support non-PSRAM observer boards
Neighbors publication was gated on BOARD_HAS_PSRAM. Removing that gate alone
was not enough: the feature built but was inert without PSRAM, because three
allocation sites asked for MALLOC_CAP_SPIRAM (which returns null with no
PSRAM), the bridge's persistent buffer was allocated inside a BOARD_HAS_PSRAM
block, and neighborDiscoverReady() rejected every pass at runtime on
psramFound(). The entry table also did not fit: finishNeighborDiscover put
pubkey_hex[50][65] plus entries[50] on the stack, a 4752-byte frame against
the mesh loop task's 8 KB.

- Gate on MAX_NEIGHBOURS plus PSRAM or an explicit per-variant
  MQTT_NEIGHBORS_WITHOUT_PSRAM opt-in.
- Move the entry table and its hex strings into one heap block sized to the
  pass; the frame drops from 4752 to 304 bytes.
- Prefer PSRAM and fall back to internal DRAM in the mesh-side allocations
  and the ArduinoJson pool; hoist the bridge's persistent buffer out of the
  BOARD_HAS_PSRAM block (psram_malloc already falls back).
- Keep the runtime psramFound() check only where the buffers are sized for
  PSRAM, so a board whose PSRAM failed to init still refuses.
- Size for internal DRAM without PSRAM: 4 KB text buffer and 20 entries per
  publish, keeping the pool to a single block and the peak near 13 KB rather
  than ~35 KB. Oversized tables truncate and report total_neighbors as before.

Enabled on the ESP32-S3 observer envs (Heltec V3/WSL3, RAK3112, Heltec
Tracker v1.1/v2). Left off for the classic ESP32 T-LoRa V2.1-1.6, which is
already limited to one active TLS slot.

Costs ~7.4 KB static DRAM on repeaters and ~9.6 KB on room servers. The
prefs layout is unchanged, so this is neutral for existing devices.
2026-08-03 10:29:54 -07:00
agessaman 34037f202d fix(mqtt-neighbors): budget the JSON pool separately from the publish buffer
ArduinoJson v7 hands out document-pool blocks in fixed 4096-byte chunks, so
the pool is not bounded by the size of the text it serialises to. Budgeting
it at NEIGHBORS_JSON_BUFFER_SIZE starved it once the table grew: a 50-entry
table needs 12541 B of pool against the 10240 B cap, and a starved allocator
sets doc.overflowed(), which makes buildNeighborsMessage return 0 and drop
the entire publish rather than truncating the tail.

Repeaters with roughly 40 or more neighbours therefore published no neighbors
message at all, silently, while smaller tables published normally.

Give the pool its own NEIGHBORS_DOC_POOL_BUDGET and add an explicit
NEIGHBORS_MAX_PUBLISH_ENTRIES cap alongside the existing text-size check.
2026-08-03 10:29:32 -07:00
Huw Duddy 9d902e634a Merge pull request #3076 from MDamon/fix/lr1110-rx-timeout
LR1110: fix startReceive() passing an IRQ bit as the RX timeout 🤖🤖
2026-08-03 23:02:52 +10:00
Huw Duddy 5d940a1dc9 Clean up comments in startReceive method
Removed unnecessary comments regarding RX timeout and IRQ mask.
2026-08-03 22:57:34 +10:00
Liam Cottle d6ee3a17f6 Merge pull request #3098 from jirogit/fix/unit-c6l-flash-mode-dio
fix: M5Stack Unit C6L boot failure caused by flash_mode=qio
2026-08-03 19:45:38 +12:00
ripplebiz 626a82fd30 Merge pull request #2824 from NickDunklee/rak-advert-hw-encryption
feat: use nrf52 hardware crypto where we can
2026-08-02 15:35:33 +10:00
me ae1b610a94 fix: M5Stack Unit C6L merged.bin fails to boot (flash_mode qio->dio)
Board manifest for esp32-c6-devkitm-1 defaults build.flash_mode to qio,
which this module's flash chip does not support -- causes a boot
crash-loop (repeated USB-Serial-JTAG reconnects) on real hardware.
Override with board_build.flash_mode = dio in the common M5Stack_Unit_C6L
section so it applies to all envs (ble/usb/repeater/room_server).
2026-08-01 22:20:20 -07:00
agessaman 37444be7b5 feat(thinknode-m7): add repeater and room server observer variants
Adds ThinkNode_M7_repeater_observer_mqtt and
ThinkNode_M7_room_server_observer_mqtt, following the Station G3 observer
overlay: adafruit-full cert bundle, MQTT bridge + SNMP, pinned observer
lib_deps, and the quieter default logging profile.

WiFi-only for now. The M7 has an onboard CH390 Ethernet controller (used by
ThinkNode_M7_companion_radio_ethernet, and an lwIP netif so the MQTT data path
would work over it), but the bridge's link management is bound to the WiFi
station API, so Ethernet cannot carry MQTT yet.

The board has PSRAM, so MAX_NEIGHBOURS=50 enables WITH_MQTT_NEIGHBORS —
verified present in both images. Builds use 46.5% of the 3.19 MB app slot at
13.7% RAM, and build.sh emits the full artifact set (.bin, -merged.bin,
.partsig). The partition table is inherited from [ThinkNode_M7] unchanged, so
no merged first flash is needed; its signature is byte-identical to the T-Beam
Supreme observer builds (both default_8MB.csv).

Also fixes two pre-existing build failures on this board:

- [ThinkNode_M7] re-added a blanket helpers/*.cpp after arduino_base excludes
  the MQTT-only sources, so every M7 env failed on a missing Timezone.h. The
  glob was otherwise fully redundant with the base, so it is dropped rather
  than re-excluding the two files.
- ThinkNode_M7_companion_radio_ethernet sets DISPLAY_CLASS=NullDisplayDriver
  but never compiled NullDisplayDriver.cpp, which defines UIColor::window_bkg,
  so it failed at link. The ble and wifi envs both already list it.

All nine M7 envs now build. Observer env discovery picks up both new envs, and
the ArduinoJson pin check covers both new sections.
2026-07-31 09:34:42 -07:00
agessaman 5828a3c563 fix(mqtt-neighbors): report unusable heard ages as null
A neighbour heard before the clock is set carries the firmware's unset-clock
default (1715770351, 15 May 2024). Subtracting that from an NTP-synced clock
published ages of ~806 days for neighbours that had just answered a live scope
query, and a backwards clock step reported 0, i.e. "heard just now".

- finishNeighborDiscover() reports the age as unknown ("heard_secs_ago": null)
  when the stored stamp and the current clock come from different epochs, or
  when the clock has stepped backwards
- handleNeighborDiscoverResponse() re-stamps heard_timestamp on a zero-hop scope
  reply, in both the snapshot and the live table, so entries heal once per
  discovery cycle instead of waiting for the neighbour's next advert
- publish ordering places usable ages ahead of unknown ones so a poisoned stamp
  cannot displace fresh entries when the JSON buffer truncates
- document the null case, and the always-present total_neighbors /
  queried_neighbors / truncated fields the payload sample omitted
- add UPSTREAM_BUGS.md, tracking the monotonic-uptime fix to propose upstream
  plus the unclamped subtraction in the companion and CLI readouts

Containment only: upstream still stamps neighbours from the wall clock at
packet-reception time, which on a cold boot always precedes NTP.
2026-07-31 08:54:39 -07:00
agessaman d8fd3defc4 fix(xiao_s3_wio): restore environment sensor support
The [Xiao_S3_WIO] base block lost its sensor wiring when upstream
3dd6dc02 ("xiao_s3: use environment sensor manager and add sensor
role") was resolved to our side in a later merge. Only the .ini half
was dropped -- target.h/target.cpp kept the EnvironmentSensorManager,
so builds still compiled helpers/sensors with an empty SENSOR_TABLE:
no ENV_INCLUDE_* macros meant the table held only its sentinel, so
begin() scanned the I2C bus and initialized nothing and querySensors()
emitted nothing above channel 1.

Reported as an INA226 missing from channel 2 on a MQTT Observer build,
working on stock firmware. Affected every Xiao_S3_WIO env, including
the dedicated Xiao_S3_WIO_sensor role.

Restores sensor_base build_flags/lib_deps, -UENV_INCLUDE_GPS and the
PIN_BOARD_SDA/SCL defines, making the base block identical to upstream
again. Also picks up the commented rs232 pin relocation from the same
upstream commit, since 5,6 is now the I2C bus.

Flash on Xiao_S3_WIO_repeater_observer_mqtt: 42.7% -> 44.3%.
2026-07-30 21:13:50 -07:00
agessaman d502bbaf5b chore(scripts): remove compiled Python cache file for webconfig_mock_server 2026-07-30 15:36:10 -07:00
agessaman fe3b5f8a0f feat(station-g3): add repeater and room server observer variants
Adds Station_G3_ESP32_repeater_observer_mqtt and
Station_G3_ESP32_room_server_observer_mqtt, mirroring the known-good
Station G2 observer envs. The two boards are the same family — ESP32-S3,
qio_opi, PSRAM, 16 MB flash, SH1106 display — so the overlay ports across
unchanged: default_16MB.csv partitions, adafruit-full cert bundle,
MQTT bridge + SNMP, and the same lib_deps pinning.

Verified the G3 partition signature is byte-identical to the G2 observer's,
so the OTA partition-compatibility gate treats them the same. Both envs
build clean (1.67 MB of the 6.25 MB app slot, 28% RAM) and produce the full
CI artifact set via build.sh: .bin, -merged.bin and .partsig. Observer env
discovery now finds 32 envs (was 30) and the ArduinoJson pin check covers
both new sections.

Docs: added the G3 build commands and the two partition-table rows to
MQTT_IMPLEMENTATION.md.
2026-07-30 15:21:20 -07:00
agessaman 4b390dfcec merge: pick up .gitignore pycache rule from observer-firmware-dev 2026-07-30 15:07:43 -07:00
agessaman 69ec2866c5 chore(.gitignore): ignore Python cache files in scripts directory 2026-07-30 15:01:31 -07:00
agessaman 612c52132c merge: upstream/dev into observer-firmware-dev (2026-07-30, db232808)
Absorbs 106 upstream commits. Seven files conflicted; the substantive one
was upstream's new JSON ConfigSerializer (PR #2982), which replaces the
binary /com_prefs layout with /prefs.json and makes NodePrefs a
ConfigSerializer subclass.

Prefs migration
- Adopt upstream's ConfigSerializer. writeCommonPrefsImage() and its
  documented offsets (0-294) are deleted, along with the now-unreachable
  saveCommonPrefsImageAtomically()/CommonPrefsFileStore atomic rename path.
- Load order is /prefs.json, then /com_prefs, then /node_prefs. Upstream
  dropped the /node_prefs fallback; it is restored here so devices that
  never advanced past that filename keep their config.
- Legacy files are never removed, so migration cannot destroy its own
  source and a deferred or failed save simply retries next boot.
- /com_prefs is treated as a format migration only, not an "upgrade", so
  it does not trip the bridge.source tx->rx flip on existing nodes.
- The MQTTPrefsAtomicStore legacy gate is retained: the observer tail
  recovered from an old-format file still commits to /mqtt_prefs before
  /prefs.json is written.
- MQTTPrefs and /mqtt_prefs are untouched; savePrefs keeps its save_mqtt
  parameter and now returns upstream's bool.

Fixes to upstream code
- RadioPrefs::structure() bound both "rxgain" and "fem_rxgain" to
  rx_boosted_gain, so radio_fem_rxgain was never persisted. Bound to the
  correct field.
- discovery_mod_timestamp was dropped from structure(); it gates
  'since'-filtered DISCOVER replies and is set on every config change, so
  losing it would silently stop discovery responses after a reboot. Added
  as "disc_mod".

Merge artifacts repaired
- Restored bblanchon/ArduinoJson to the native test env; a clean but wrong
  auto-merge at the lib_deps block boundary dropped it and broke all 19
  host test suites.
- Migrated the fork's WebConfig UITask screens off the removed
  DisplayDriver::Color enum to upstream's UIColor element types.
- Removed duplicate getCADEnabled() definitions in companion MyMesh.cpp
  and simple_sensor SensorMesh.cpp that both sides had added.
- Dropped memset(&_prefs, 0, ...) in the four example meshes; NodePrefs now
  has a vtable. guard gains an initializer that memset used to provide.

Other resolutions
- simple_room_server keeps both the fork's discover.* commands and
  upstream's new room.post.
- docs/payloads.md taken from upstream, undoing content earlier merges had
  reverted (Control data section, split login tables).

Verified: 273/273 host tests pass across native and native_kiss_modem;
Heltec v3 repeater, repeater_observer_mqtt, room_server_observer_mqtt,
sensor, and companion_radio_ble all build clean.
2026-07-30 14:56:23 -07:00
agessaman dd7d13497b docs: trim MQTT guide redundancy and split out internals
Port the observer doc cleanup from observer-firmware (4beb5472) onto dev,
keeping dev-specific content intact.

- move internal mechanics into MQTT_INTERNALS.md: deferred construction,
  runtime slot memory, backoff/circuit breaker, message building, command
  namespacing, and the legacy prefs key mapping
- drop the duplicated First-Time Setup, Command Architecture, and SNMP
  Monitoring sections, folding their unique steps into Quick Start
- condense the partition/NVS prose and merge the five custom-broker
  examples into one Custom Brokers section
- promote the preset list to a top-level Broker Presets section with an
  Extra setup column, and document the presets the table was missing:
  meshcore-fi, corecomms, mesh-chaun14, wcmesh, and meshtexas
- fix the coloradomesh port (443, not 1883)
- point flasher and changelog links at observer.gessaman.com
- restore the nbr: status field, which the code still emits, and correct
  the neighbors topic to QoS 0 retained per allow_retain

Dev-specific content is preserved as-is: per-slot packet filters, the Web
Configuration Portal, local testing without hardware, MeshRank's raw
exclusion, and neighbors default_scope.
2026-07-30 12:06:18 -07:00
Mike Damon 78723d2565 LR1110: fix startReceive() passing an IRQ bit as the RX timeout
CustomLR1110::startReceive() passed RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED
(1<<4 = 16) as RadioLib's first argument, which is the RX *timeout*, not an
IRQ mask. At the LR11x0's 30.52us tick that armed the receiver for ~488us, so
it dropped out of RX before any packet could arrive and the node received
nothing at all -- while transmitting normally.

Symptoms on a SenseCAP T1000-E: tx_air_secs rising, rx_air_secs stuck at 0,
recv_errors 0, and the noise floor pinned at the -120 clamp because
getCurrentRSSI() never sampled a live receiver.

Pass RADIOLIB_LR11X0_RX_TIMEOUT_INF (continuous RX), which is what
LR11x0::startReceive() itself uses, keeping the PREAMBLE_DETECTED flag in the
reported IRQ flags as intended.

Introduced in ea5d7c8b ("LR1110: add PREAMBLE_DETECTED to reported irq flags").
Verified on two T1000-E units: with only the repeater fixed it began receiving
(last_rssi -29, SNR 17.0) while the unfixed companion stayed deaf; fixing both
brought up the link in each direction.
2026-07-30 12:14:39 -04:00
ripplebiz db232808aa Merge pull request #2688 from Che177/feature/room-server-system-posts
room_server: add room.post command for server-originated posts
2026-07-30 13:08:18 +10:00
ripplebiz 57ddada3ba Merge pull request #3004 from axhoff/agent/preserve-utf8-advert-names
Preserve UTF-8 boundaries in advert names
2026-07-30 12:59:59 +10:00
ripplebiz b797ca198e Merge pull request #3060 from entr0p1/fix/mesh-debug-active
Clean up debugs left on in platform.ini files
2026-07-30 12:35:22 +10:00
agessaman 616fe96d1e feat(mqtt): publish all message types except raw to MeshRank
MeshRank slots previously took packets only. The broker tolerates status and
neighbors (and its maintainer intends to look at using the neighbors data), so
those now publish under meshrank/uplink/{token}/{device}/{type}, using the same
type suffixes as the MeshCore layout. Raw stays excluded: it is the
highest-volume topic and the broker does not consume it, so `set mqtt.raw on`
has no effect on a MeshRank slot.

All MeshRank gating funnels through the topic router, so relaxing that single
guard was sufficient - publishStatusToSlot, publishStatus, publishRaw,
publishNeighbors, and eligiblePacketSlots already skip slots that cannot form a
topic. A per-slot token is still required. Because eligiblePacketSlots resolves
topic support before serialising, raw JSON is never built for a MeshRank slot
rather than built and discarded.

observer-firmware sends raw to MeshRank; this supersedes that decision, so raw
must stay excluded when the branches merge. MeshRankTakesEveryTypeExceptRaw
fails if a merge re-enables it.
2026-07-29 15:51:28 -07:00
agessaman 8b16af3830 feat(mqtt): add default_scope to neighbor discovery JSON handling
Enhance the neighbor discovery JSON structure by introducing a
default_scope field, which indicates the region name this node
floods to by default. This change improves clarity in the
neighbor discovery process and aligns with the unscoped flood
behavior when no default region is set. Updates include
modifications to the MyMesh class and related message building
functions to accommodate the new field.
2026-07-29 15:00:32 -07:00
ripplebiz 6e39cff8a1 Merge pull request #3063 from oltaco/unify-irq-timeout
Unify IRQ timeout logic across all applicable radio types
2026-07-29 23:52:48 +10:00
ripplebiz 279ac78122 Merge pull request #3049 from liamcottle/feature/ethernet
Refactor Companion Interfaces + Add ThinkNode M7 Ethernet Support
2026-07-29 23:45:51 +10:00
taco 79eb7f5d8d LLCC68: add IRQ timeout logic 2026-07-29 23:34:01 +10:00
taco a3732e1c65 SX1268: add IRQ timeout logic 2026-07-29 23:34:01 +10:00
taco a5c323cc19 STM32WLx: add IRQ timeout logic 2026-07-29 23:33:51 +10:00
taco a7426de0bf LR1110: fix unnecessary IRQ clear 2026-07-29 23:18:15 +10:00
ripplebiz 9f73fbf870 Merge pull request #3062 from oltaco/fix-irq-clear
Fix unnecessary IRQ clearing
2026-07-29 22:37:30 +10:00
taco 20f55a4f42 fix unnecessary IRQ clear 2026-07-29 21:39:56 +10:00
entr0p1 d7f378b489 Clean up debugs left on in platform.ini files 2026-07-29 18:27:15 +10:00
Che177 5a04e060a5 room_server: use standard mesh debug logging 2026-07-28 21:57:42 -07:00
Adam Gessaman 971a3b1464 Merge pull request #38 from agessaman/feat/packet-filters
Enhance MQTT slot filters with named types and downgrade support
2026-07-28 09:10:46 -07:00
agessaman ecbb5005e9 feat(mqtt): implement downgrade contract for MQTT preferences
Enhance the MQTT preferences handling by establishing a downgrade
contract that ensures compatibility between different firmware versions.
This contract allows nodes to read settings from newer builds while
safeguarding against data loss during downgrades. The implementation
includes updates to the classification logic, ensuring that longer
payloads from newer versions are handled correctly without rejecting
files, thus preserving critical WiFi credentials and broker settings.
2026-07-28 09:03:19 -07:00
agessaman 52bd27190f feat(mqtt): enhance packet filter functionality with named types
Add support for named packet types in per-slot filters, allowing users
to specify packet types using descriptive names alongside numeric values.
This improves usability and clarity in configuring MQTT slot filters.
Updates include modifications to the parsing logic, WebConfig interface,
and related documentation to reflect the new naming conventions.
2026-07-27 22:29:11 -07:00
Liam Cottle adc631f315 Merge pull request #3048 from entr0p1/fix/startota-failure-recovery
nRF52840 "start ota" Failure Recovery
2026-07-28 15:36:48 +12:00
Che177 3a9f19c14b room_server: add room.post system posts 2026-07-27 19:50:13 -07:00