Commit Graph
57 Commits
Author SHA1 Message Date
torlando-agent[bot]andClaude Opus 4.8 72e4d036c2 fix(wifi): auto-reconnect after the AP drops the association
connect_wifi() never enabled auto-reconnect, and the loop's "WiFi reconnect
check" only re-attempted on the manual Settings -> Reconnect button. So a dropped
association left the device offline (no TCP, no AutoInterface, "no connection" in
the status bar) until a reboot. Enable WiFi.setAutoReconnect(true) + persistent,
and add a non-blocking backstop in the main loop that re-issues WiFi.begin()
every ~15s while disconnected -- setAutoReconnect alone doesn't cover every
disconnect reason. Verified the SSID/AP are fine (connects at boot); closes the
stay-offline-until-reboot gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
2026-06-20 20:19:45 -04:00
torlando-agent[bot]andClaude Opus 4.8 9922130ead fix(tcp): close stop() teardown UAF window — force-delete task on deadline (greptile)
stop()'s join had a fixed deadline (CONNECT_TIMEOUT_MS + 2s); a slow DNS could
keep the task inside connect() past it, so stop() would free the object while the
task still referenced `this`. Extend the deadline well beyond any connect()+DNS,
and if it still expires, vTaskDelete(_task_handle) the task so it can't touch
`this` after return. (The task's own self-delete path sets _task_done first, so
this branch only runs when it has not self-deleted — no double delete.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
2026-06-19 23:31:48 -04:00
torlando-agent[bot]andClaude Opus 4.8 afd374ff3f fix(tcp): publish CONNECTED before _reconnected so the announce isn't dropped (greptile)
Storing _reconnected before _conn_state=CONNECTED left a seq-cst window where the
main loop could observe _reconnected==true while still CONNECTING. check_reconnected()
would then clear the flag and announce on an offline interface (loop() returns
early), so no announce fired once actually connected. Store CONNECTED first; seq-cst
then guarantees _reconnected is only ever observed true on an online interface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
2026-06-19 23:02:25 -04:00
torlando-agent[bot]andClaude Opus 4.8 7be3138fc5 fix(tcp): drop redundant _online write in the task to remove a race (greptile)
task_loop() set `_online = true` during the CONNECTING window, which races with
loop()'s `_online = false` on the main loop (plain bool, no synchronizes-with).
It's redundant: the main loop sets `_online = true` when it observes CONNECTED.
Removing it eliminates the race with no behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
2026-06-19 22:54:49 -04:00
torlando-agent[bot]andClaude Opus 4.8 4a9e44318c fix(tcp): make _task_running/_task_done atomic (greptile)
Both are written by task_loop() (core 0) and read by stop() (core 1); volatile
gives no cross-core ordering. Use std::atomic<bool> to match the other shared
flags (_conn_state, _reconnected, _last_connect_attempt) and give stop()'s join
a well-defined happens-before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
2026-06-19 22:35:53 -04:00
torlando-agent[bot]andClaude Opus 4.8 a1a5104c5b fix(tcp): seed _last_connect_attempt so the initial connect isn't delayed (greptile)
With _last_connect_attempt == 0, task_loop()'s first
`now - _last_connect_attempt >= RECONNECT_WAIT_MS` check only passes once
millis() >= RECONNECT_WAIT_MS, delaying the very first connect up to 15s after
boot. Seed it to millis() - RECONNECT_WAIT_MS in start() so the first attempt
fires immediately (unsigned wraparound keeps it correct when millis() < the wait).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
2026-06-19 22:30:11 -04:00
torlando-agent[bot]andClaude Opus 4.8 7148859582 fix(tcp): real task join in stop() + atomic _last_connect_attempt (greptile)
- stop() now waits on a _task_done flag the task sets right before exiting,
  instead of a fixed sleep. Closes a use-after-free window where an in-flight
  connect() overrunning CONNECT_TIMEOUT_MS (slow DNS) could touch `this` after
  ~TCPClientInterface() freed it.
- _last_connect_attempt is now std::atomic<uint32_t> — it's read/written by
  task_loop() (core 0) and handle_disconnect() (core 1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
2026-06-19 22:23:03 -04:00
torlando-agent[bot]andClaude Opus 4.8 988e42c52c fix: run TCP interface connect on its own task (instant UI / screen wake)
The blocking WiFiClient.connect() ran on the main loop and stalled it for the
lwIP default (~18.5s) when the host was unreachable -- including the DNS lookup --
freezing the UI, so the screen took ~10s to wake. The 2-arg connect() also
ignored CONNECT_TIMEOUT_MS (that only bounds reads).

Move only the blocking connect() to a dedicated FreeRTOS task. read/write/frame
stay on the main loop exactly as before (unchanged low-latency data path -- the
link/Resource timing is untouched). An atomic _conn_state hands _client ownership
between the task (while CONNECTING) and the main loop (while CONNECTED) so they
never touch the socket concurrently. Bound the connect via the 3-arg connect()
and back off retries to 15s.

tests/hardware: wait_for_tcp_link() matched "started", keying on interface
startup rather than the actual connect. With the async connect that let the
harness drive the announce before the link was up, so the device's announce was
lost and the first direct message (bz2-probe) failed. Match "connected to".

Verified on a T-Deck: screen wake instant (connect off the main loop); e2e smoke
5/5 including bz2-on-receive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
2026-06-19 22:14:45 -04:00
torlando-agent[bot]andClaude Opus 4.8 f0a78f70e1 fix: derive OS::time() offset from the 64-bit rolling uptime, not millis()
The offset was computed as unix_ms - millis() (32-bit) but ltime() adds it to a
64-bit (high32<<32|low32) counter. Once high32 != 0 (a millis() rollover, or an
erratic screen-off/wake tripping ltime()'s roll-over check) OS::time() was wrong
by high32*49.7 days -- surfacing as garbage "Nw ago" timestamps in the
conversation list. Compute the offset from OS::ltime()-getTimeOffset() at both
the GPS and NTP sync sites; add a diagnostic logging OS::time() after each sync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
2026-06-19 15:49:44 -04:00
torlando-agent[bot]andClaude Opus 4.8 70d4aa6be9 feat: graft pyxis onto upstream microReticulum 0.4.1
Repins microReticulum + microLXMF onto the upstream-0.4.1 graft and adapts
pyxis to the new src/microReticulum/ layout and 0.4.x APIs. The far-diverged
0.3.0 fork's Resource/Transport/Identity work is subsumed by upstream's
reimplementation; only the still-needed fixes ride on the pinned branches
(PKCS7/HMAC/X25519 crypto -- proven byte-identical to python RNS 1.3.1 --
Packet link-proof callback, Identity short-sig guard, and the bz2 layer +
decompress-on-receive in Resource::assemble()).

Consumer-side changes:
- platformio.ini: pin microReticulum @2f21fee (pyxis-fixes-on-0.4.1) and
  microLXMF @33760d0 (chore/microreticulum-0.4.1-layout); bump microStore
  ceea8f5 -> c5fb69d (0.4.x requires the new BasicFileStore::init API);
  -std=gnu++11 -> gnu++17 (upstream requires C++17).
- Namespace all microReticulum includes (angle + quote) to <microReticulum/...>
  for the relocated layout; shim-local Utilities/Stream.h|Print.h preserved.
- Interface::send_outgoing now returns bool: update TCP/BLE/SX1262/Auto
  overrides with correct success/failure returns.
- SDArchiveFileSystem::init(bool reformatOnFail=true) to match new microStore.
- Static Transport::get_path_table() -> path_table(); instance getter unchanged.
- Remove duplicate shim Cryptography/BZ2 (microReticulum provides it now; keep
  lib/libbz2 as the ESP32 bzlib provider).
- patch_littlefs_paths.py: normalize microStore's LittleFS adapter paths to a
  leading "/" -- ESP32 Arduino LittleFS rejects "./"-prefixed paths, which
  silently broke the path store (no peer paths learned, all messaging blocked).

Validated on T-Deck Plus: builds (RAM 27.5% / Flash 77.7%), boots stable
(no WDT/panic), and a full on-device LXMF e2e (DIRECT + OPPORTUNISTIC +
bz2-compressed-Resource receive) passes 5/5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
2026-06-19 15:49:44 -04:00
torlando-agent[bot]andClaude Opus 4.8 4dc5c89e25 fix: scope LVGL lock to the snapshot in T:SCREENSHOT (not the serial dump)
LVGL_LOCK() was held for the entire base64 serial dump (~18s for a 320x240
frame), blocking the LVGL render task the whole time and tripping the 5s
LVGLLock recursive-take timeout assert in debug builds (crash on every
screenshot). lv_snapshot_take() copies the pixels into its own buffer, so the
lock is only needed for the snapshot itself -- scope it there, dump the copy
unlocked, and re-take the lock only to free the snapshot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
2026-06-19 10:53:33 -04:00
torlando-agent[bot]andClaude Opus 4.8 ee059e86ea fix: restart AutoInterface on WiFi reconnect (greploop)
The `!auto_interface_impl` guard meant start_auto_interface() only ran on the
first WiFi-connect edge; on every reconnect the block was skipped, so
AutoInterface kept stale multicast sockets and peers never rediscovered until
reboot. start_auto_interface() is idempotent (its else-if(!online()) branch
rebinds the sockets), so drop the guard and call it on every connect edge.
TCPClientInterface self-reconnects in its own loop(), so only AutoInterface
needed this.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
2026-06-19 02:16:09 -04:00
torlando-agent[bot]andClaude Opus 4.7 a88983aa05 chore(greptile): iteration 2 — applied 1, rejected 0
main.cpp: split on_wifi_connected NTP sync into kick-off +
pump_ntp_sync_if_pending poller. Previously the synchronous
getLocalTime retry loop blocked loopTask for up to 10 s on first
WiFi-associate, stalling RNS packet ingestion / LXMF delivery /
SX1262 RX FIFO drain. Now configTzTime kicks off the SNTP task
non-blocking and the periodic loop probes for completion until
NTP_TIMEOUT_MS elapses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:12:36 -04:00
torlando-agent[bot]andClaude Opus 4.7 3391cb2fb3 chore(greptile): iteration 1 — applied 2, rejected 3
- main.cpp: split T:RX `count=` from ring-buffer index so soak-test
  harness sees true received-message count past TEST_RX_RING=32
- sync_file_libdeps.py: drop hardcoded ~/repos/microReticulum, read
  PYXIS_MICRORETICULUM_DIR env var for opt-in local-override workflow

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:02:42 -04:00
torlando-tech a9d79660a4 sec: move PYXIS_TEST_TCP_HOST/PORT to env vars + .env.example
The previous hardcoded LAN IP in platformio.ini identified a specific
deployment (Mac rnsd at a 10.0.0.x address). Move host:port to env
vars read at build time via PIO's ${sysenv.X} interpolation, with
a .env.example template + .env gitignored. Production builds (no
PYXIS_TEST_HOOKS) skip the macros entirely.

Changes:
  - platformio.ini (both env blocks): replace literal IP/port with
    ${sysenv.PYXIS_TEST_TCP_HOST} / ${sysenv.PYXIS_TEST_TCP_PORT}.
    Both quoted so an unset env var expands to an empty string
    literal "" rather than an empty token (the latter would break
    the PORT > 0 numeric compare in main.cpp).
  - src/main.cpp test-hooks block: guard host/port assignment so
    empty values fall back to NVS-stored settings. atoi() handles
    the port string side. Missing env var won't silently brick
    test mode — it just becomes "use whatever's in NVS".
  - .env.example: checked-in template with placeholder values
    (TEST-NET-1 192.0.2.x range, not a real LAN IP).
  - .gitignore: add `.env` so the populated local copy stays out
    of source.

Build: verified clean with env vars UNSET (NVS-fallback path) and
SET (override path); 27.4% RAM, 79.8% Flash in both cases.
2026-05-11 00:06:35 -04:00
torlando-tech 48382b2f9f feat: T:SCREENSHOT + T:SHOW serial commands + host-side capture
New testing/docs surface for grabbing the active LVGL screen as a
PNG over USB-CDC. Useful both for documentation (round-trip capture
of every public screen via T:SHOW <name> + T:SCREENSHOT) and
automated UI regression tests.

On-device:
  - lib/lv_conf.h — enable LV_USE_SNAPSHOT (~5 KB code; uses PSRAM
    via the existing hybrid allocator so internal RAM is unaffected)
  - src/main.cpp — T:SCREENSHOT handler takes an lv_snapshot_take()
    of lv_scr_act() under LVGL_LOCK(), dumps a delimited base64
    stream over CDC. Inlines a tiny base64 encoder (no new dep).
  - src/main.cpp — T:SHOW <name> dispatches to UIManager::show_*()
    for the six publicly-navigable screens (conversation_list,
    compose, announces, status, settings, propagation_nodes).

Wire format:
  T:SCREENSHOT BEGIN W=320 H=240 FMT=rgb565<be|le> BYTES=153600
  <base64 line, 76 chars>
  ...
  T:SCREENSHOT END

Host side:
  - screenshot.py — auto-detects the pyxis port via T:ID probe,
    sends T:SCREENSHOT, reads until END, filters out interleaved
    log lines (heap heartbeats / BLE stats can splice in
    mid-dump), validates byte count matches header, decodes RGB565
    with the documented byte order, expands channels via 5→8 / 6→8
    high-bit replication, saves PNG. Pillow + pyserial.

Catalog:
  - docs/serial_commands.md — full reference for all T:* commands
    accumulated so far (identity/paths/send/receive, propagation,
    voice, BLE, UI). New commands should land here when added.

Throughput: ~205 KB base64 over CDC at 115200 → ~18 s/shot. Fine
for docs and automated tests, not video. Bumping baud or zlib-
compressing on-device is queued in the doc as future work.
2026-05-10 15:23:39 -04:00
torlando-techandClaude Opus 4.7 cbac8ed5ca fix(ble): enable NimBLE scan response so service UUID + name fit; add T:BLE hook
Two related changes:

1. NimBLE advertising overflow

   At boot pyxis was logging "NimBLEAdvertisementData: Data length
   exceeded" twice. The 128-bit Reticulum service UUID is 18 bytes
   once you include the AD type+length headers; the device name
   "TD-XXXXXX" is another 9-11 bytes; flags eat 3 bytes. That's
   already over the 31-byte legacy adv-packet limit, so NimBLE was
   silently truncating the advertisement and dropping the service
   UUID. Android Columba's BleScanner filters by ServiceUuid at the
   Android BLE driver layer (ScanFilter.Builder().setServiceUuid),
   so without the UUID in the primary adv data, pyxis was invisible
   to Columba.

   Fix: call enableScanResponse(true) BEFORE addServiceUUID +
   setName. NimBLE then routes the long device name into the
   secondary 31-byte scan-response payload that active scanners
   request, leaving the primary adv data with just flags + the
   service UUID — under budget and visible to the filter.

   Verified: with the fix, Android system Bluetooth reads pyxis's
   name as "TD-46cbcf" and Columba's BleGattServer logs
   "Central connected: FC:69:15:9C:B2:C9" (pyxis as central). The
   connection holds for ~40s before HCI_CONN_TIMEOUT — separate
   issue not addressed here, just the unblock so the link can be
   established at all.

2. T:BLE on|off harness hook

   Mirrors T:CALL_PROFILE / T:ANNLXST / T:LXSTDEST: persists the
   ble_en NVS key and starts/stops the interface live so the LXMF
   harness can flip BLE on/off the same way it drives any other
   subsystem. Idempotent for "already on" / "already off". Useful
   for upcoming pyxis ↔ Android Columba BLE smoke tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:21:08 -04:00
torlando-techandClaude Opus 4.7 848c7df200 fix(autointerface): retry start from on_wifi_connected
AutoInterface init at boot is gated on \`WiFi.status() == WL_CONNECTED\`,
but WiFi association typically takes 2-5s and finishes well after the
boot block runs. The result: with auto_enabled=true in NVS, boot logs
"AutoInterface enabled but WiFi not connected - skipping" and the
interface never starts even though the settings UI shows it on. Pyxis
ends up looking enabled but actually isn't peering with anyone.

TCP interface has the same race and handles it via start_tcp_interface()
called from both boot and on_wifi_connected(). Mirror that pattern with
a new start_auto_interface() helper:

- Idempotent — safe to call from boot AND post-WiFi.
- Creates the AutoInterface instance the first time and registers it
  with Transport.
- Re-starts an existing-but-stopped instance (e.g. WiFi disconnect →
  reconnect cycle).

Boot path now invokes start_auto_interface() inline if WiFi was
already up, otherwise logs "will retry from on_wifi_connected" — and
the post-WiFi handler in the main loop calls it once WiFi lands.

Verified: pyxis with auto_enabled=true now logs the full AutoInterface
startup sequence (multicast join + sockets bound) ~3-5s into runtime
instead of never. Multicast peering visibility is a separate issue
being chased now (pyxis joins the group successfully but its announces
don't reach rnsd or Sideband, and vice versa — likely an ESP32 lwIP
multicast TX/RX subscription issue, not the gating bug fixed here).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:18:39 -04:00
torlando-techandClaude Opus 4.7 f6b90a330b fix(serial): silence audio/wire/path-store noise during active calls
Real-LXST 14s call at ULBW (Codec2-700C) was timing out T:CALL_QOS /
T:CALL_STATS responses ~10-15s in. Pyxis itself was still processing
audio fine; the host's serial reader was just overrun by debug-level
prints from three sources, all firing per-packet during voice traffic:

1. TCPClientInterface: per-frame "[TCP] Reading X bytes" / "[TCP]
   First bytes: ..." / "[HDLC] Frame #N: ..." / "[TCP] Processing
   frame" / 5s "[TCP] connected= ..." were unconditional Serial.printf.
   Now gated behind `RNS::loglevel() >= LOG_DEBUG` and the snprintf
   work skipped when it'd be discarded.

2. i2s_capture.cpp: "[CAP] rate=" fired every 2s regardless of
   activity. Now only emits when ringDrops > 0 OR runningPeak > 1000
   (something happened worth noting). Counters still update — only
   the print is gated.

3. microStore upstream: "[ustore] get: key not found in index" fires
   on every path-store miss, which RNS hits constantly during a call.
   patch_filestore.py was already a registered pre-build script for
   diagnostic patches; reactivate it (was commented out in
   platformio.ini) and add a silence patch as the always-on default.
   Diagnostic exists()/put() patches gated behind PYXIS_FILESTORE_DIAG=1
   so they're easy to bring back when investigating path-store drift
   without touching the script each time.

After this, the ULBW real-LXST call validator returns PASS with full
final stats (pyxis_tx=34 rx=119 decode_ok=151 decode_fail=0
pyxis_rms=4410). 1600bps/3200bps profiles still hit serial-timeout
patterns under sustained TX — likely CPU saturation in the main
loop, separate from this fix; tracked in #75 followup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:52:34 -04:00
torlando-techandClaude Opus 4.7 f5d9e6a480 feat(lxst): T:LXSTDEST + T:ANNLXST hooks for callee interop tests
Adds two harness hooks for testing pyxis as the LXST callee:

- T:LXSTDEST: returns pyxis's lxst.telephony destination hash. The
  caller-side bot (real LXST.Telephony.Telephone in this case) needs
  this to dial pyxis. Backed by a new test_lxst_dest_hex() accessor on
  UIManager that reads _lxst_destination and returns hex (or empty if
  the destination isn't registered yet).

- T:ANNLXST: forces a fresh announce of the lxst.telephony destination.
  The TCP-reconnect handler in main.cpp:963 only announces LXMF, so on
  a fresh boot the lxst.telephony destination is missing from rnsd's
  cache and link requests addressed to it get dropped with "no known
  path to final destination" (rnsd debug log). The harness pings this
  before each callee test to ensure rnsd has a fresh path.

Also adds INFO logging to announce_lxst() mirroring the LXMF announce
log (Announcing destination: <hash> ... announce sent), so it's
visible in tdeck-side traces when an announce actually went out vs.
silently no-oped.

Validated: bot dials, pyxis transitions IDLE -> INCOMING_RINGING (UI
shows incoming-call screen), harness sends T:CALL_ANSWER, state
becomes ACTIVE. (Audio path crashes shortly after via the same
Ed25519 announce-validation bug — fix landed in microReticulum
f4bad06, but PIO's libdeps cache had been holding a stale copy; a
manual rm -rf .pio/libdeps/tdeck/microReticulum was needed to pick it
up. Tracked in #73.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:29:05 -04:00
torlando-techandClaude Opus 4.7 24bfd68a78 fix(tcp): gate TX wire hex dumps behind LOG_DEBUG
WIRE TX raw + WIRE TX framed were INFO-level, ~150-180 bytes each, and
fired per outgoing packet. During an LXST voice call (~5 batches/sec
plus retries) that's ~2KB/s of pure debug log on USB CDC, on top of
existing call/heap/disp prints. The serial buffer saturated ~15s in
and T:CALL_QOS / T:CALL_STATS commands timed out at the 5s threshold,
even though the device itself was healthy.

Demote both to DEBUG and gate the hex-encoding work behind a runtime
loglevel check so the per-packet snprintf loop doesn't run when the
output would be discarded anyway. Re-enable by raising RNS log level
to DEBUG when actually debugging the wire format.

After this, a 14s real-LXST E2E call returns full stats every poll
with no timeouts and the harness validator runs to PASS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:52:53 -04:00
torlando-techandClaude Opus 4.7 612f44e274 feat(lxst): Codec2-700C (ULBW) default profile + T:CALL_ANSWER hook
Adds two profile constants beyond the existing LXST_PROFILE_LBW (0x30,
Codec2-3200): LXST_PROFILE_VLBW (0x20, Codec2-1600) and
LXST_PROFILE_ULBW (0x10, Codec2-700C). Default is now ULBW — a 700C
frame fits comfortably inside an SF7-9 LoRa packet, which is the
target medium for pyxis voice. The previous 3200bps default was 4.5x
larger and unsuitable for the radio path.

Profile is selectable at runtime via T:CALL_PROFILE [hex]. Replaces
five hardcoded LXST_PROFILE_LBW sites: three audio-init paths in
call_process_signal and two profile-negotiation send_signal calls.

Adds T:CALL_ANSWER for harness pyxis-as-callee testing — sets the
same _call_answer_pending flag the UI button does so call_answer()
runs on the main loop in its proper context. Validates against
real LXST.Telephony.Telephone callers.

Validated: pyxis dialed real LXST upstream Telephone bot, negotiated
ULBW end-to-end, reached STATUS_ESTABLISHED, decoded frames cleanly
both directions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:07:41 -04:00
torlando-techandClaude Opus 4.7 5a3ee97856 deps(codec2): vendor codec2 v1.2.0, replace sh123/esp32_codec2_arduino@1.0.7
The PlatformIO dep sh123/esp32_codec2_arduino@1.0.7 bundles codec2
v0.9.2. Mac-side pycodec2 v3.0.4 links libcodec2 v1.2.0. Years of
codec2 development between those releases.

Replace the upstream lib with a local vendor of drowe67/codec2 v1.2.0
under lib/codec2/. Trim the 191-file source tree down to the
~100 files actually needed for codec2 (drop FreeDV, OFDM, COHPSK,
FSK, FM-FSK, LDPC, Horus, CLI tools — pyxis only uses
codec2_create/destroy/encode/decode + samples_per_frame /
bytes_per_frame). Carry over the v0.9 codebook .c files since the
codebook contents matched (compared against v1.2's src/codebook/*.txt).

Define __EMBEDDED__ so the codebooks land in flash (.const) rather
than RAM. Without it the codebooks add ~127KB to BSS and the LVGL
task fails to start (RAM was 65% full vs 27% with __EMBEDDED__).
Provide trivial codec2_malloc/codec2_free wrappers in
codec2_alloc_esp32.c (codec2 v1.2 expects them when __EMBEDDED__ is
defined; ESP-IDF's malloc/free already pull from internal RAM).

Also explicitly add SD/FS to lib_deps and #include <SD.h> in
main.cpp — the previous esp32_codec2 dep transitively pulled SD
which let SDArchiveFileSystem.h get away with depending on it
implicitly. With chain+ ldf mode and no esp32_codec2 dep, we have
to declare the framework lib explicitly.

DOES NOT fix the ~30x speech-decode RMS asymmetry between pycodec2
self-tests (~5800) and pyxis decoding the same encoded bytes (~170).
Sine waves and 3-formant synthesis pass clean both directions; only
real TTS speech triggers it. Probably a separate codec-state
divergence (the encoder/decoder are independent codec2 instances
in pyxis, both fresh per call) or a wire-format quirk we still need
to track down. v1.2 is the right baseline regardless — same bug
class as several upstream fixes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 12:25:52 -04:00
torlando-techandClaude Opus 4.7 12fd28b67c test(lxst): bidirectional audio content-fidelity validation
Extends the LXST harness from frame-flow only (#62) and decoder QoS
only (#63) to a full content-fidelity test: a known 1kHz sine wave
flows in BOTH directions through the Codec2-3200 round-trip, and
the harness asserts the decoded RMS at each end matches expected
energy within tolerance (after Codec2's lossy speech-codec behavior).

Firmware additions (under PYXIS_TEST_HOOKS):

I2SCapture
  setInjectSine(enabled, freq=1000, amp=0.5)
    Replaces mic input with a phase-continuous synthesized sine.
    Bypasses ES7210 capture and the voice filter chain so the
    encoder sees pure samples — peer's decoded RMS validates that
    pyxis's TX path delivers content.

I2SPlayback
  pcmSampleCount(), pcmSumSquares()
    Decoded-PCM energy accumulators, fed from each successful
    Codec2 decode. uint64 sumsq holds ~2³⁴ frames before overflow,
    far longer than any test call.

LXSTAudio + UIManager (test-only)
  captureSetInjectSine, playbackPcmSampleCount, playbackPcmSumSquares
  test_call_set_inject_sine, test_call_pcm_sample_count,
  test_call_pcm_sum_squares

Serial T: hooks (main.cpp)
  T:CALL_INJECT <on|off> [freq] [amp_pct]
    Drive the capture-side injection from the harness.
  T:CALL_QOS now also returns pcm_n + pcm_ss
    Harness divides + sqrts to RMS for content validation.

Validated with /tmp/lxst_call_harness.py + /tmp/lxst_call_bot.py
(scripts vault-local per the no-PII rule):

  pyxis_rms = 6363, bot_rms = 1930, decode_fail = 0
  PASS: bidirectional audio + content-fidelity validated

The empirical RMS floor is 800 (pycodec2 self-test on 1kHz amp 0.5
yields ~1400; pyxis decoder hits ~6300; bot decoder hits ~1900 —
all far above the ~5-50 silence floor). Codec2 is a speech codec
so pure-tone round-trip is naturally lossy; the test gates on
"audio bytes carry actual content energy", not lossless round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 03:11:09 -04:00
torlando-techandClaude Opus 4.7 8b608ec258 test(lxst): T:CALL/T:CALL_QOS hooks + Codec2 decode counters
Adds the on-device test surface the LXST voice-call harness needs to
drive an end-to-end audio flow + QoS validation against a Mac-side
softphone bot. Both hooked behind PYXIS_TEST_HOOKS so production
firmware is unaffected.

T: serial commands (main.cpp):
  T:CALL <hex>      — initiate call (hex = peer's lxst.telephony dest)
  T:CALL_STATE      — current call FSM state name
  T:CALL_HANGUP     — tear down the active call
  T:CALL_STATS      — audio frame counters: tx, rx, state
  T:CALL_QOS        — Codec2 decoder QoS: decode_ok, decode_fail, state
  T:HASIDENTITY <h> — bool, distinct from T:HASPATH because path_store
                     and known_destinations are populated by separate
                     code paths (announce can land in one before the
                     other; harness has to wait for both).

UIManager additions (under PYXIS_TEST_HOOKS):
  test_call_initiate, test_call_hangup, test_call_state_name,
  test_call_audio_tx_count, test_call_audio_rx_count,
  test_call_decode_ok, test_call_decode_fail.

I2SPlayback / LXSTAudio additions (always on — counters are tiny):
  decodeOkCount(), decodeFailCount(), resetCounters() on I2SPlayback
  surface the Codec2 decode success/fail rate. LXSTAudio re-exports.
  Each writeEncodedPacket call increments exactly one counter so the
  ratio is "wire-level audio fidelity" of the peer's encoder.

Validated:
  Frame-flow soak (12s call): pyxis tx=73 rx=72, bot tx=230 rx=72.
  QoS soak (12s call): pyxis tx=73 rx=76, decode_ok=81 decode_fail=0,
    bot tx=227 rx=73.

Harness scripts that drive these hooks live in the local Obsidian
vault (under 80 Assistant/Memory/pyxis/soak_scripts/) — they encode
LAN-specific state and aren't checked in. See the vault's
automated_soak_testing.md for the full procedure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 02:45:11 -04:00
torlando-techandClaude Opus 4.7 e7efdf68c0 perf(boot): remove synchronous GPS+WiFi waits, defer LVGL task start
Boot was 48.5s end-to-end on this hardware: 92% of that was two
synchronous waits — gps_sync (15s) and wifi_connect (30s). Both
have async paths already (gps.encode is fed in loop(); the periodic
status check sets up TCP when WL_CONNECTED appears post-boot), so
blocking at boot wasn't actually load-bearing. Drop both waits to
brief opportunistic checks (500ms / 1s) and let the main loop pick
up async events.

Three follow-on changes were needed to keep functionality:

- Extract on_wifi_connected() (NTP + OTA + UDP logging setup) from
  setup_wifi so the post-boot WL_CONNECTED transition in loop() can
  run it. Idempotent via _wifi_post_connect_done.

- Add periodic GPS-time-sync retry in loop() — every 30s while
  unsynced, attempt a fast (timeout=0) sync_time_from_gps. The
  cheap path returns immediately when TinyGPSPlus already holds a
  valid date+time.

- Defer LVGL task start until AFTER setup_ui_manager. Previously
  the LVGL task started immediately after lv_init and refreshed
  its empty default screen on top of the boot splash, causing a
  visible flash to black before the first real UI frame painted.
  With the start moved to after the UIManager has built screens
  and configured the active one, the splash stays on-screen until
  the first real frame.

Net: boot drops from ~48s to ~3-5s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:13:28 -04:00
torlando-techandClaude Opus 4.7 3194af0639 feat(pyxis): wire SD card as MessageStore archive tier
Adds Hardware::TDeck::SDArchiveFileSystem — a microStore::FileSystem
adapter that piggybacks on the SD card already mounted by SDAccess::init
and serializes every operation through the shared SPI bus mutex (so it
cooperates with display + LoRa traffic on HSPI).

In setup_lxmf, after MessageStore is constructed, attach the SD as the
archive tier at "/lxmf-archive". When the SD is missing the message
store falls back to delete-on-cull (still bounded, just no historical
scrollback). Without this the LittleFS partition (1.875MB) fills in
~30 min of sustained receive and trips lfs_alloc divide-by-zero.

Also moves the LVGL_LOCK in UIManager::on_message_received to AFTER
the save_message call. The 5s LVGL lock timeout was tripping when
LittleFS compaction stalled the save for several seconds, panicking
the loop watchdog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:51:04 -04:00
torlando-techandClaude Opus 4.7 ba18c32c04 feat(test-hooks): T:SEND/SENDOPP/SENDPROP + T:SETPROP/SYNCPROP harness API
Adds a USB-CDC serial command interface gated behind PYXIS_TEST_HOOKS
that lets a host-side harness drive pyxis end-to-end without UI taps.
Built specifically to run /tmp/tdeck_harness.py and prove LXMF DIRECT,
OPPORTUNISTIC, and PROPAGATED delivery against a Mac-side echo bot
over the Mac's rnsd + lxmd.

Commands (all newline-terminated, replies T:OK or T:ERR):

  T:DEST                          — pyxis's delivery dest hash (hex)
  T:ID                            — pyxis's identity hash (hex)
  T:ANN                           — force an announce
  T:PATHS                         — count + dump in-memory path table
  T:HASPATH <hex>                 — Transport::has_path + in-memory check
  T:RECALL <hex>                  — Identity::recall_app_data hex
  T:SEND <hex> <text>             — outbound DIRECT LXMessage
  T:SENDOPP <hex> <text>          — outbound OPPORTUNISTIC LXMessage
  T:SENDPROP <hex> <text>         — outbound PROPAGATED LXMessage
  T:SETPROP <hex> <stamp_cost>    — set outbound propagation node
  T:SYNCPROP                      — request_messages_from_propagation_node
  T:SYNCSTATE                     — current PR_* sync state
  T:STATE <msg_hash>              — LXMessage state for a tracked send
  T:RX                            — drain inbound RX ring
  T:RXCLR                         — clear RX ring

Build-flag side:

  -DPYXIS_TEST_HOOKS               — gates all of the above
  -DPYXIS_TEST_TCP_HOST="..."      — hard-overrides NVS tcp_host so the
                                     harness's rnsd is the only target
  -DPYXIS_TEST_TCP_PORT=...        — same for tcp_port

Also: replaces `lib_extra_dirs = deps/microReticulum` with an explicit
`file://~/repos/microReticulum` lib_dep. lib_extra_dirs
caused PIO to compile microReticulum twice (once through the extra
dir, once through microLXMF's transitive auto-fetch), producing two
copies of `Transport::_path_store` in BSS. Different translation
units linked against different statics, so put() and exists() landed
in different in-memory indexes. Symptom: `T:HASPATH` returned 0 even
when the previous announce's `[ustore] put: wrote key` log line was
visible. Single source path → single static → consistent reads.

`patch_filestore.py` is committed but commented out in extra_scripts
— used during diagnostic when the dual-static issue was being
triaged. Easy to re-arm if FileStore put/exists drift recurs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:03:42 -04:00
torlando-techandClaude Opus 4.7 b2b5474045 feat(reticulum): enable transport mode + switch to LittleFS adapter
Two adjustments to setup_reticulum() / setup_filesystem():

1. `Reticulum::transport_enabled(true)` — unlocks the path-store
   init block at Transport.cpp:244 that was gated behind transport
   mode. Without it, _new_path_table.put() always returned false at
   TypedStore::isValid() (no filesystem assigned), every announce
   surfaced as "Failed to add destination" spam, and the UI's
   announce list stayed empty. Transport mode also enables relaying
   for other nodes — tolerable on a T-Deck Plus with PSRAM headroom
   and LittleFS-backed path persistence.

2. Switch the microStore adapter from SPIFFSFileSystem to
   LittleFSFileSystem (paired with -DUSTORE_USE_LITTLEFS in
   platformio.ini). LittleFS handles sustained writes; SPIFFS GC
   stalls were causing every path-store put to fail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 18:16:21 -04:00
torlando-tech 97d54fd4f7 Track A.10: WDT 60s + log-only — pyxis runs stable on graft (no resets)
Stops the TASK_WDT(6) reset loop that was firing every ~50-90s of
post-setup runtime on the graft (and on pre-graft pyxis). Decoded
backtrace pinned the cause to ESP-IDF's WiFi ppTask doing
pm_tx_data_done_process and starving the CPU0 idle task — a
WiFi-stack-internal that has nothing to do with user code.

Two attempts, both useful to record:

1. sdkconfig.defaults: set CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=n.
   Doesn't take effect — Arduino-ESP32 ships its framework PRE-BUILT
   with the original sdkconfig (CPU0=y, timeout=5s) baked in.
   sdkconfig.defaults overrides only apply if you rebuild the framework
   from source. Kept the .defaults change for documentation; the
   runtime escape uses esp_task_wdt_init.

2. esp_task_wdt_delete(xTaskGetIdleTaskHandleForCPU(0)) at boot.
   Doesn't stick — Arduino-ESP32's runtime re-subscribes CPU0 idle
   on the next loop iteration since CHECK_IDLE_TASK_CPU0 is still
   set in the prebuilt config.

Final fix: esp_task_wdt_init(60, false). 60s timeout (was 30s) AND
panic=false (warnings logged, no reset). This makes WDT a soft
indicator instead of a hard reset trigger — appropriate while the
underlying WiFi-pm-starvation issue lives in ESP-IDF, not pyxis.

Verified runtime:
  - 3 minutes continuous uptime, zero aborts/panics/resets
  - 5 propagation nodes discovered from the network
    (LXMF protocol layer working end-to-end on the graft)
  - Heap stable around 46KB free, low-water 37KB
  - 165KB UART log captured (vs prior ~30KB before reset)

This means pyxis on top of attermann/microReticulum @ 0.3.0 +
torlando-tech/microReticulum:pyxis-fixes-on-0.3.0 is now
**runtime-stable** — first time the graft has run uninterrupted
long enough to actually use.

Future: revisit panic=true once we can prove the WDT culprit is
moved to ESP-IDF's tracker (or pyxis is updated to a framework that
fixes the WiFi pm starvation). Until then, log-only avoids false
positives.
2026-05-05 13:53:12 -04:00
torlando-tech 02ceeda75b Track A.8: re-vendor shim from ca355e5; UniversalFileSystem→microStore;
relocate shim to lib/; pyxis lib API renames

Compile-tier graft progress on top of 40e561f. Key changes:

- Re-vendor lib/microreticulum-shim/ (was src-shim/) from the *actual*
  ca355e5 commit content rather than the stale "feat/t-deck HEAD"
  /tmp clone the previous commit pulled from. Recovers process_sync()
  on LXMRouter and MEMORY_MONITOR_POLL macro that were missing.
- Move src-shim/ → lib/microreticulum-shim/ + add library.json so
  PlatformIO discovers the .cpp files and links them. Was previously
  only on the include path; the .cpps weren't in the build.
  (This unblocks the 30+ undefined-reference linker errors for LXMF
  and Instrumentation symbols.)
- Drop -Isrc-shim/Utilities (/Cryptography/Instrumentation) from
  build_flags — they were over-broad and put our Stream.h on the
  GLOBAL header path, breaking Arduino's Wire.cpp which has
  `class TwoWire: public Stream`. -Ilib/microreticulum-shim alone
  resolves subdir lookups via <Cryptography/X.h>, <Utilities/Y.h>.

- UniversalFileSystem migrated to microStore::Adapters::SPIFFSFileSystem
  (activated by -DUSTORE_USE_SPIFFS). Vanilla upstream microReticulum
  @ 0.3.0 deleted RNS::FileSystem in favor of microStore. Pyxis's
  lib/universal_filesystem/ is now dead code on this build path.

- pyxis lib API renames for the post-graft world:
    SDLogger.cpp:        RNS::setLogCallback -> RNS::set_log_callback
    AnnounceListScreen:  Transport::get_destination_table ->
                         Transport::get_path_table
                         Transport::DestinationEntry ->
                         RNS::Persistence::DestinationEntry
    UIManager.cpp:       _lxst_destination ctor explicit RNS::Type::NONE
                         (vanilla Destination has no default ctor)
                         Identity::mark_persistent calls disabled w/
                         restoration TODO
    ConversationListScreen: Interface::get_rssi/get_stats calls
                            disabled (the methods are non-virtual
                            on BLEInterface/SX1262Interface post
                            de-virtualization in a0ff631)

Compile is clean against the fixed-cryptography submodule pin; current
failure layer is fork-only Type::Channel constants referenced by the
vendored shim's Buffer/ChannelData files. That's the next session's
problem — see pyxis_microReticulum_graft_spike_findings.md for the
plan options (most likely: remove Channel/Buffer/ChannelData/Ratchet
from the shim, since LXMF doesn't use Channel anyway per the 2026-05-04
investigation).
2026-05-05 01:39:44 -04:00
torlando-tech a0ff631001 Track A.5/6/7: Identity persistence + Transport stats + Interface overrides
Three API migrations to keep the graft moving against vanilla
attermann/microReticulum @ 0.3.0:

(A.5) Identity persistence migrated to OS::set_loop_callback.
  Was:  Identity::set_persist_yield_callback(cb)        // fork-only
        Identity::should_persist_data()                 // fork-only
  Now:  RNS::Utilities::OS::set_loop_callback(cb)       // upstream global
        reticulum->should_persist_data()                // already used
  The fork's split between Identity-specific 5s fast-flush and
  Reticulum-level 60s full-persist is unified upstream into a single
  Reticulum::should_persist_data() entry point. The fast cadence is
  folded into microStore's dirty-tracking. If we observe excessive
  lost-known-destinations after crashes, revisit microStore's flush
  cadence rather than re-adding the fork-only Identity API.

(A.6) Transport stats diagnostics disabled — vanilla upstream doesn't
  expose the *_count() getter family the fork added. Two [TABLES]
  diagnostic blocks in main.cpp now print a placeholder. Restore by
  porting to upstream's get_path_table().size() and friends, or PR the
  getters back to upstream Transport. Tracked in
  pyxis_microReticulum_graft_spike_findings.md.

(A.7) BLE/SX1262 Interface stat methods are no longer virtual overrides.
  Vanilla upstream Interface base class doesn't declare get_stats /
  get_rssi / get_snr. Kept the methods as plain (non-virtual)
  BLEInterface / SX1262Interface members; callers needing stats access
  must hold the concrete type, not the base Interface*. Propose
  upstream PR adding to base API if polymorphic access matters.

Also: setLogCallback -> set_log_callback (renamed in upstream commit
4d6f0b9 "Added dual-class PSRAM/TLSF allocator system").

Pyxis still doesn't build — next failures (4 distinct):
  - OS::register_filesystem signature changed to microStore::FileSystem&.
    Real microStore migration needed for UniversalFileSystem.
  - LXMRouter::process_sync still missing despite vendored src-shim copy.
    Include-order or shadowing — needs investigation.
  - MEMORY_MONITOR_POLL macro not picked up despite -I src-shim/Instrumentation.
  - Identity::should_persist_data appears to still be referenced via
    LXMF or another vendored layer — would surface once the above land.
2026-05-04 20:25:02 -04:00
TorlandoandGitHub 70b8df052d Merge pull request #12 from torlando-tech/feature/splash-screen
Add boot splash screen with Pyxis constellation logo
2026-03-04 18:38:43 -05:00
TorlandoandGitHub 3c81eeb5be Merge pull request #11 from torlando-tech/fix/ble-wdt-stability
Fix Task WDT crashes from LVGL priority starvation
2026-03-04 17:07:27 -05:00
torlando-techandClaude Opus 4.6 a4a1aacdd8 Show boot splash within 1s of power-on instead of after 20s+ init
Move Display::init_hardware_only() and POWER_EN to right after serial
banner, before GPS/WiFi/SD/Reticulum init. Add 150ms delay after
POWER_EN HIGH so ST7789V power rail stabilizes before SPI commands
(without this, SWRESET is sent to an unpowered chip and silently lost).

Splash now visible for entire boot period (~18s) until LVGL takes over.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:12:25 -05:00
torlando-techandClaude Opus 4.6 c80e63dee9 Fix Task WDT crashes: LVGL priority starvation + BLE WDT false positives
Two root causes for frequent device reboots:

1. LVGL task (priority 2) starved loopTask (priority 1) on core 1.
   During heavy screen rendering, loopTask couldn't run for 30+ seconds,
   triggering the Task WDT. Fixed by lowering LVGL to priority 1 so
   FreeRTOS round-robins both tasks fairly.

2. BLE task was registered with the 30s Task WDT, but blocking NimBLE
   GATT operations (connect + service discovery + subscribe + read) can
   legitimately take 30-60s total. Removed BLE task from WDT since
   NimBLE has its own internal ~30s timeouts per GATT operation.

Also added ble_hs_synced() guards to write(), read(), notify(),
writeCharacteristic(), discoverServices(), and enableNotifications()
to prevent use-after-free on stale NimBLE client pointers during
host resets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:11:51 -05:00
torlando-techandClaude Opus 4.6 b4afa6d3f7 Fix SD card SPI init: use FSPI before Display claims HSPI
SD card was unresponsive (MISO stuck 0xFF) because Display's HSPI
peripheral had already claimed the GPIO pins via the matrix, preventing
FSPI from routing MISO. Fix by initializing SD card BEFORE Display,
using the global SPI (FSPI) instance — matching LilyGo's reference code.

- Move SD card init before display init in boot sequence
- Use global SPI (FSPI) instead of Display's SPIClass(HSPI)
- Lower SPI frequency to 800kHz matching LilyGo example
- Drive all CS lines (display, LoRa, SD) high before SD init
- Add MISO=38 to Display's SPI.begin for post-init bus sharing
- Add Display::get_spi() accessor for future shared use

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 09:29:47 -05:00
torlando-techandClaude Opus 4.6 d03f0b308f Add shared SPI bus mutex for SD card, display, and LoRa coexistence
The T-Deck Plus shares HSPI across the display (CS=12), LoRa (CS=9),
and SD card (CS=39). Previously SD logging was disabled because
SD.begin() reconfigured the SPI bus and blanked the display.

This introduces a FreeRTOS mutex created in main.cpp and injected into
Display, SX1262Interface, and a new SDAccess class so all three
peripherals serialize their SPI transactions safely.

- Add SDAccess class wrapping SD.begin() and file ops with mutex
- Add set_spi_mutex() to Display and SX1262Interface
- Wrap Display flush, fill, draw, and power ops in mutex
- Refactor SDLogger to use SDAccess mutex instead of owning SD.begin()
- Wire up mutex creation and injection order in setup()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:19:10 -05:00
TorlandoandGitHub d9411fb4bb Merge pull request #7 from torlando-tech/ble-stability-audit
BLE stability: fix desync crash loops and scan recovery
2026-03-03 23:39:38 -05:00
torlando-techandClaude Opus 4.6 46ce057a1e BLE stability: host-controller resync, stuck GAP conn cancel, scan diagnostics
After a 574 connection failure, the NimBLE controller's scan state can
become corrupted (returning rc=530 / Invalid HCI Params) even after the
host re-syncs. This led to scan failure escalation and device reboots.

Key fixes:
- Add ble_gap_conn_cancel() to enterErrorRecovery() — stuck GAP master
  connection operations were blocking all subsequent scans
- Add ble_hs_sched_reset(BLE_HS_ECONTROLLER) in error recovery to force
  a full host-controller resynchronization after desync
- Proactively cancel stale GAP connections before scan start
- Reduce SCAN_FAIL_RECOVERY_THRESHOLD from 10 to 5 for faster recovery
- Enhanced scan failure logging with GAP state diagnostics
- Move ESP reset reason logging after WiFi init for UDP log visibility
- Suppress connection candidate log spam when at max connections

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:57:55 -05:00
torlando-techandClaude Opus 4.6 2cc9441f0a BLE stability: desync connect cooldown prevents crash-on-connect
Add 30-second cooldown after NimBLE host desync recovery before
allowing new connection attempts. During desync, client->connect()
blocks waiting for a host-task completion event that never arrives,
causing WDT crashes. The cooldown skips connection attempts while
the host is desynced or recently recovered.

Also adds ESP reset reason logging at boot to diagnose crash types
(WDT, panic, brownout, etc.) in soak test logs.

Soak test results: Run 3 (before) had 17 reboots in ~4 hours with
a 12-crash-in-14-minutes loop. Run 4 (after) has 1 early reboot
then 19+ hours of continuous uptime with the same desync frequency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 18:34:40 -05:00
davidcranorandClaude Sonnet 4.6 827ff2eb42 Fix cross-platform build: replace ${PROJECT_DIR} with relative paths
platformio.ini:
- Replace -I${PROJECT_DIR}/lib, -I${PROJECT_DIR}/deps/... with relative
  paths (-Ilib, -Ideps/...) in both tdeck-bluedroid and tdeck environments;
  ${PROJECT_DIR} is mangled on Windows inside build_flags, causing include
  paths to resolve inside the PlatformIO builder directory instead of the
  project root
- Remove hardcoded -I.pio/libdeps/tdeck/TinyGPSPlus/src and
  -I.pio/libdeps/tdeck/NimBLE-Arduino/src; these paths reference generated
  cache, break on fresh clones, and are redundant with lib_ldf_mode = deep+
- Fix OTA upload_command: replace python3 with $PYTHONEXE so it resolves
  to PlatformIO's bundled Python on Windows, macOS, and Linux

src/main.cpp, lib/tdeck_ui/UI/LXMF/UIManager.cpp:
- Change #include "tone/Tone.h" to #include "Tone.h"; PlatformIO
  automatically adds -Ilib/tone for local libraries, making the
  subdirectory prefix unnecessary and broken when -Ilib is not effective

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 15:19:32 -05:00
torlando-techandClaude Opus 4.6 609a3bc62b LXMF propagation sync, manual node entry, and status improvements
Propagation sync (microReticulum submodule):
- Fix msgpack interop: send nil (not 0) for per_transfer_limit so
  Python server doesn't reject all messages as exceeding "0 KB limit"
- Fix Resource response routing: extract request_id from packed data
  when not present in Resource advertisement, route to pending request
  callback instead of generic concluded handler
- Fix Link::request() to manually build packed arrays, avoiding
  Bytes::to_msgpack() BIN-wrapping that breaks protocol interop

UI enhancements:
- PropagationNodesScreen: manual node entry via 32-char hex hash in
  search field, with paste support and radio button selection
- StatusScreen: display stamp cost from propagation node
- UIManager: NVS persistence for selected propagation node, proactive
  path request on node selection, sync state machine with timeout
  handling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:03:32 -05:00
torlando-techandClaude Opus 4.6 6744eb136d LXST voice call stability: fix hangup crash, signal queue, TX pump, mic tuning
- Fix use-after-free crash on hangup: set _call_state=IDLE before deleting
  _lxst_audio, preventing pump_call_tx() (runs without LVGL lock) from
  accessing freed memory
- Replace single-slot _call_signal_pending with 8-element ring buffer queue
  to prevent signal loss when CONNECTING+ESTABLISHED arrive in rapid succession
- Extract TX pump into pump_call_tx() called right after reticulum->loop()
  for low-latency audio TX without LVGL lock dependency (was buried at step 10)
- Tune ES7210 mic gain to 21dB (was 15dB) to improve Codec2 input level
  without ADC clipping that occurred at 24dB
- I2S capture: use APLL for accurate 8kHz clock, direct 8kHz sampling
  (no more 16→8kHz decimation), DMA 16x64 for encode burst headroom
- Reduce Reticulum log verbosity to LOG_INFO (was LOG_TRACE)
- BLE: add ble_hs_sched_reset() tiered recovery before reboot on desync,
  widen supervision timeout to 4.0s for WiFi coexistence
- Add UDP multicast log broadcasting and OTA flash support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 10:57:14 -05:00
torlando-techandClaude Opus 4.6 e263e1e7a6 Add OTA flashing and wireless UDP log broadcasting
ArduinoOTA enables wireless firmware uploads (pio run -e tdeck-ota -t upload).
UDP log callback via RNS::setLogCallback sends all log lines plus Serial.printf
diagnostics to multicast group 239.0.99.99:9999 for untethered monitoring.
Includes safety guards: UDP suspended during WiFi transitions, reentrancy
protection, and WiFi status check before each send.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 17:43:48 -05:00
torlando-techandClaude Opus 4.6 e343caf2d2 Stability: WDT yield, BLE mutex fixes, time-based desync recovery
Reduces crash rate from every 60-85s to 1 reboot per 6+ minutes.
Zero WDT triggers in 10-minute stability test.

BLE mutex fixes (BLEInterface.cpp):
- Release _mutex before blocking GATT ops in onConnected() and
  onServicesDiscovered() — prevents 5-30s main-loop stalls during
  service discovery, notification subscribe, identity exchange
- Non-blocking try_lock() for peerCount(), getConnectedPeerSummaries(),
  get_stats() — returns empty/default if BLE task holds mutex
- Write-without-response in initiateHandshake()

WDT and persistence (main.cpp, sdkconfig.defaults, microReticulum):
- 30s WDT timeout (up from 10s) for SPIFFS flash I/O headroom
- Register Identity::set_persist_yield_callback() to feed WDT every
  5 entries during save_known_destinations() (70+ entries = 30-50s)
- WDT feeds between reticulum and identity persist calls

BLE host desync recovery (NimBLEPlatform):
- Time-based desync tracking instead of aggressive counter-based reboot
- 60s tolerance without connections, 5 minutes with active connections
  (data still flows over existing BLE mesh links)
- Remove immediate recoverBLEStack() from 574 handler and
  enterErrorRecovery() — let startScan() manage reboot decision
- Increase CONNECTION_COOLDOWN from 3s to 10s to reduce 574 risk
- Increase SCAN_FAIL_RECOVERY_THRESHOLD from 5 to 10

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:30:30 -05:00
torlando-techandClaude Opus 4.6 3ca27f53f6 Task watchdog, BLE mutex fixes, NimBLE crash-safe recovery
Subscribe loopTask and BLE task to the ESP32 Task Watchdog (10s timeout)
to detect and recover from silent hangs. Per-step WDT feeds in the main
loop prevent false triggers from cumulative slow operations.

Fix BLE mutex starvation that blocked the main loop for 3-6s:
- Move processDiscoveredPeers() out of performMaintenance() so _mutex
  is not held during blocking NimBLE connect calls
- Use try_lock() in send_outgoing() to skip sends when BLE task has
  the mutex, rather than blocking (Reticulum retransmits)
- Switch BLE data writes to write-without-response (non-blocking)
- Add WDT feeds to all NimBLE blocking wait loops

Replace NimBLE soft-reset recovery with immediate reboot — deinit()
during sync failures caused CORRUPT HEAP panics. With atomic file
persistence, data survives reboots reliably.

Reduce loop task stack from 49KB to 16KB (measured peak ~6KB).
Add NimBLE PHY update null guard to patch_nimble.py.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 10:45:43 -05:00
torlando-techandClaude Opus 4.6 a499a2b30a Persistence reliability: NimBLE crash fix, atomic save, fast persist
NimBLE crash fix:
- Patch ble_hs.c assert(0) in BLE_HS_SYNC_STATE_BRINGUP timer handler
  via pre-build script (patch_nimble.py). The assert fires when a timer
  callback races with host re-sync — harmless, but kills the ESP32 and
  corrupts any file writes in progress.

Persistence fixes (in microReticulum submodule):
- Atomic save: write to temp file then rename, protecting existing data
- Fast persist: 5s after dirty flag instead of waiting 60s interval
- Corrupt file recovery: delete invalid files, recover from temp files
- INFO-level logging for load/save visibility

Other:
- Wrap LXMF announce in try/catch for crash safety
- Call Identity::should_persist_data() from main loop

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 01:44:38 -05:00
torlando-techandClaude Opus 4.6 43a7e1088f BLE P2P stability: fix ODR violation, shutdown safety, connection robustness
Fix systemic One Definition Rule violation where BLEInterface.h included
headers from deps/microReticulum/src/BLE/ while .cpp files compiled
against local lib/ble_interface/ versions, causing struct layout mismatches
(PeerInfo field shifting corrupted conn_handle/mtu) and class layout
mismatches (BLEPeerManager member differences caused LoadProhibited crash).

Key fixes:
- Include local BLE headers instead of deps versions in BLEInterface.h
- Sync PeerInfo keepalive tracking fields and BLETypes constants with deps
- Shutdown re-entrancy guard and proper client cleanup via deinit(true)
- Host sync checks before scan, advertise, and connect operations
- Avoid deadlock by deferring _on_connected from NimBLE host task
- Duplicate identity detection, stale handle cross-check in keepalives
- Bounds validation on conn_handle in setPeerHandle/promoteToIdentityKeyed
- Periodic persist_data() call for display name persistence across reboots

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 00:24:45 -05:00
torlando-techandClaude Opus 4.6 769c9952bd BLE P2P stability: PSRAM zero-init, pool sizing, stuck-state recovery
Root cause: Bytes objects stored in PSRAM-allocated BLEInterface had
corrupted shared_ptr members from uninitialized memory, causing crashes
in processDiscoveredPeers(). Fixed by using heap_caps_calloc instead of
heap_caps_malloc for PSRAM placement-new allocation.

Additional fixes:
- Reduce pool sizes to fit memory budget (reassembler 134KB→17KB,
  fragmenters 8→4, handshakes 32→4, pending data 64→8)
- Store local MAC as BLEAddress struct instead of Bytes to avoid
  heap allocation in PSRAM-resident object
- Move setLocalMac after platform start (NimBLE needs to be running
  for valid random address), add lazy MAC init fallback in loop()
- Add stuck-state detector: resets GAP state machine if hardware
  is idle but state machine thinks it's busy
- Enhance getLocalAddress with 3 fallback methods (NimBLE API,
  ble_hs_id_copy_addr RANDOM, esp_read_mac efuse)
- Fix C++17 structured binding to C++11 compatibility
- Increase BLE task stack 8KB→12KB for string ops in debug logs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:57:05 -05:00