Commit Graph
56 Commits
Author SHA1 Message Date
torlando-agent[bot] 3351cab398 test: strengthen call admission contention coverage 2026-08-01 14:14:56 +00:00
torlando-agent[bot] 63e0fd0380 test: cover generation-scoped call admission 2026-08-01 14:14:56 +00:00
torlando-agent[bot] 9d5cc32b82 fix: avoid duplicate delivery-state persistence 2026-07-27 04:24:58 +00:00
torlando-agent[bot] 0506452f77 docs: record message-store stack contract 2026-07-26 15:43:54 +00:00
torlando-agent[bot] e885f27c77 fix: prefer opportunistic delivery for short UI messages 2026-07-26 15:30:45 +00:00
torlando-agent[bot] dcc80e14d7 fix: report active firmware and LittleFS storage 2026-07-25 02:01:05 +00:00
torlando-agent[bot] 0ea2be3fda fix: confirm successful OTA boot before restart 2026-07-24 19:21:56 +00:00
torlando-agent[bot] 12d25b2acc fix: preserve retryable messages on storage failure 2026-07-24 19:02:06 +00:00
torlando-agent[bot] 4e2e74296b fix: fail closed when message persistence fails 2026-07-24 18:22:35 +00:00
torlando-agent[bot] 0f3b39bc19 fix: serialize LXST audio teardown on loopTask 2026-07-23 02:11:39 +00:00
torlando-agent[bot] efd3b0bb8f fix: address Greptile safety findings 2026-07-22 19:28:32 -04:00
torlando-agent[bot] 31631b0afd fix: make full-duplex voice fit live device memory 2026-07-22 13:28:23 -04:00
torlando-agent[bot] 9e151877cc fix(voice): stabilize LXST call lifecycle and add E2E harness 2026-07-22 10:25:45 -04:00
torlando-agent[bot]andClaude Opus 4.8 bd7b357b96 fix(audio): ES7210 mic gain saturation + heap-starvation reboots + raw-mic diagnostic harness
Confirmed fixes:
- LVGL hybrid-allocator threshold 1024->256 (lib/lv_mem_hybrid.h): moves ~45KB of
  small LVGL objects to PSRAM, fixing internal-heap starvation that made the
  call/loopback pipeline intermittently fail to allocate and reboot mid-call
  (internal free 71->116KB, largest contiguous block 61->106KB).
- ES7210 mic PGA gain 21dB->12dB (lxst_audio.cpp): 21dB saturated the ADC -- an
  rms-7003 noise floor in silence + 0x8000 negative-rail spikes + a spectral-peak
  shift that masqueraded as a "+17% pitch warp". At 12dB idle silence is clean (rms 48).

Diagnostic harness (PYXIS_TEST_HOOKS, test-only): T:RAWMIC[stage], T:REG, and
T:RECORD/T:DUMPREC add a raw-mic UDP tap plus a reliable PSRAM recorder with a
checksummed serial transfer, and T:LOOPBACK wiring, for objective mic-capture
analysis (offboard Whisper STT scoring).

Exploratory / still unresolved: captured speech remains garbled (oscillating static,
likely an ES7210 sigma-delta capture instability -- under investigation). Includes a
16kHz/main-PLL clock path + MICBIAS exploration; the "warp" was the gain artifact, not
a clock issue, so the 16kHz path can later be simplified back to 8kHz.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
2026-06-24 17:21:25 -04:00
torlando-agent[bot]andClaude Opus 4.8 b2236d5901 fix(settings): correct announce/sync prefs defaults + stale unit comments
Addresses Greptile review on #37. SettingsScreen has its OWN settings load
(separate from main.cpp's app_settings), which still defaulted announce to 60s and
sync to 3600s and overrode the intended defaults -- so the UI still showed the old
values. Set them to 3600s (1h) and 14400s (4h) to match. Also updated two stale
block comments in update_ui_from_settings / update_settings_from_ui that still said
"minutes" after the sync field was promoted to hours.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
2026-06-22 23:37:07 -04:00
torlando-agent[bot]andClaude Opus 4.8 36d93f965f fix: announce/sync interval units+defaults, live Settings clock, OTA repair
Settings/clock (user-reported):
- Announce interval: default 1h (was 60s); the field is now MINUTES (label
  "Announce Interval (min):", populate /60, read *60).
- Prop sync interval: default 4h (was 1h); the field is now HOURS (label
  "Prop Sync Interval (hrs):", populate /3600, read *3600); log prints hours.
- Settings clock now ticks live: SettingsScreen::tick() (throttled ~1s) is hooked
  into UIManager::update() like the announce-list tick, so the Time/GPS/system
  readouts refresh while the screen is open instead of being a static snapshot.

OTA repair (was broken two ways; diagnosed via an ultracode workflow):
- patch_nimble.py hardcoded the `tdeck` libdeps path, so building any OTHER env
  (tdeck-ota gets its own libdeps tree) never received the NimBLE patch that
  DEFINES nimble_host_reset_reason -> undefined-reference link error. Made it
  env-aware (env.get("PIOENV",...)), matching the four sibling pre-scripts. Also
  restores 3 NimBLE stability patches that were silently missing from OTA builds.
- ArduinoOTA onStart synchronously called TCPClientInterface::stop() (blocks up to
  30s), overrunning espota's ~10s connect-back window -> "No response from device"
  (phase 2), and its un-fed watchdog tripped reboots. Made onStart non-blocking.
- [env:tdeck-ota] upload_command invoked a non-existent tools/espota.py -> switched
  to upload_protocol=espota.
- Do NOT WiFi.setSleep(false): the ESP32 requires WiFi modem-sleep when WiFi+BT
  coexist (aborts at boot otherwise) -- documented inline.

Verified on device: tdeck-ota links; OTA over WiFi completes 100% (Result: OK) and
the device reboots clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
2026-06-22 23:28:48 -04:00
torlando-agent[bot]andClaude Opus 4.8 27ff17b2ad fix(gps): Arizona no-DST carve-out + raise unsynced-display threshold to 2024
Addresses Greptile review on #36:
- Arizona is inside the Mountain longitude band but doesn't observe DST (and it
  straddles the Mountain/Pacific boundary), so MST7MDT wrongly showed MDT in
  summer. It now gets an explicit MST7 (no-DST) carve-out by lat/lon box before
  the longitude bands. (The DST-observing Navajo Nation is not separately handled.)
- The Settings "Time:" display used a >= 2016 threshold, which let the ESP32's
  ~2016-01-01 first-boot default render as a real date instead of "not set".
  Raised to >= 2024 so an unsynced clock is visibly unsynced.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
2026-06-22 17:08:22 -04:00
torlando-agent[bot]andClaude Opus 4.8 c208e9aaec feat(settings): show current time in GPS status + fix HDOP readout
- Add a live "Time:" line to Settings -> GPS showing the system clock (e.g.
  "Time: 2026-06-22 16:32:12", or "not set" when unsynced), so the GPS time-sync
  can be verified on-device at a glance -- a far-future year or "not set" flags a
  rollover/unsynced clock without needing serial.
- Fix the HDOP readout: it divided by 100 a second time (hdop.hdop() already
  returns the true value), so it always showed ~0.0 (Ideal). Now shows the real
  geometry -- which matters since HDOP is the metric the new sync-gate keys off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
2026-06-22 16:44:20 -04:00
torlando-agent[bot]andClaude Opus 4.8 834f047200 address greptile: gather all before sort + re-add containers to focus group
- AnnounceListScreen::tick(): drop the pre-sort 64-item gather cap so the sort
  always sees the true newest destinations (the path table is bounded by
  USTORE_DEFAULT_MAX_RECS=400 and items allocate in PSRAM, so the gather is
  bounded); only the render stays capped at MAX_DISPLAY.
- tick() now re-adds the freshly rendered item containers to the focus group and
  focuses the first. The deferred gather meant show() (which adds widgets to the
  group) ran before the containers existed and lv_obj_clean dropped the old ones,
  so trackball navigation couldn't reach the list items.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
2026-06-21 03:11:14 -04:00
torlando-agent[bot]andClaude Opus 4.8 142ff464e8 fix: populate announces end-to-end (ratchet name, blob leak, path-table, safe refresh)
Pins microReticulum to the fork's pyxis-fixes-on-0.4.1 @ b582986 (three 0.4.1-graft
regression fixes) and adds the pyxis-side pieces.

microReticulum fixes (via the pin):
- Identity::recall now accounts for the announce ratchet (matches validate_announce)
  — it read app_data 32 bytes early for ratchet-bearing announces (Sideband/Columba),
  garbling peer display names.
- DestinationEntry caps persisted random_blobs to PERSIST_RANDOM_BLOBS (16); the set
  was never trimmed, growing path entries ~12B/re-announce past microStore's 1024B
  ceiling so put() rejected them (empty path table) — and under a raised ceiling
  bloated the FS until compaction crashed.
- Transport mirrors learned paths into _path_table so path_table() (the UI announce
  list + T:PATHS) reflects them; the microStore migration stopped populating it.

pyxis pieces:
- -DUSTORE_DEFAULT_MAX_RECS=400 caps the live path-store record count (defense-in-depth).
- AnnounceListScreen::refresh() defers to a main-loop tick(): the gather (path-table
  iteration + per-entry recall/app_data) runs off the LVGL lock, serialized with the
  main-loop path-table writes, instead of racing them on the LVGL task and blocking
  past the 5s lock timeout — which hung/crashed on open once the table was non-empty.

Verified on device: display names correct, T:PATHS 0 -> 11, no rejections, no
crashes, announce list opens cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
2026-06-21 02:58:45 -04:00
torlando-agent[bot]andClaude Opus 4.8 e1760291f5 fix(chat): use lv_obj_del_async to close the full-message view (greptile)
on_full_message_close() runs from the Close button's own callback, and the button
is a descendant of the modal — so lv_obj_del(modal) freed the button mid-dispatch
(use-after-free). lv_obj_del_async defers the delete until the event completes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
2026-06-20 17:22:47 -04:00
torlando-agent[bot]andClaude Opus 4.8 22e985087a feat(chat): long-press a message to view its full text (and copy)
Long-press a bubble to open a scrollable full-message view with Copy/Close.
Bubbles render truncated for scroll performance, so the handler recovers the FULL
stored content (row -> hash -> item) for the view -- which also fixes Copy, which
had regressed to copying the truncated label text after the render cap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
2026-06-20 14:42:02 -04:00
torlando-agent[bot]andClaude Opus 4.8 28c40de3a1 fix(chat): cap rendered bubble text so large messages don't crawl on scroll
A message with multi-KB content (e.g. a large bz2-delivered payload) rendered
untruncated, so LVGL laid it out as a 50+ line wrapped bubble and re-drew the
whole thing while scrolling past it -- crawling the UI. Cap the *displayed* text
to MAX_DISPLAY_CHARS; the full content stays stored. (Decompression is unrelated:
it happens once in Resource::assemble() at receive, content is saved already
decompressed, and load_message_metadata never re-decompresses.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
2026-06-20 01:24:14 -04:00
torlando-agent[bot]andClaude Opus 4.8 13b3d7d73a fix(chat): stream older messages on scroll-up too (no synchronous batch)
on_scroll() loaded a full MESSAGES_PER_PAGE batch synchronously under the LVGL
lock, which froze scrolling. Make it trigger the same incremental
tick_background_fill() streaming as the open path instead. _bg_fill_active is now
std::atomic since on_scroll() (LVGL task) sets it while tick_background_fill()
(main loop) reads it; the target is written before the flag for visibility.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
2026-06-20 01:07:51 -04:00
torlando-agent[bot]andClaude Opus 4.8 f8bbfba3e3 feat(chat): render newest messages first, stream the rest in on the main loop
Open a conversation by rendering only the 3 newest messages synchronously (fast),
then stream the rest of the first page in BG_FILL_BATCH (2) at a time from
UIManager::update() via tick_background_fill(). Each step holds the LVGL lock
only briefly, so a large conversation no longer freezes the UI or trips
LVGLLock's 5s timeout (which previously asserted/crashed). Runs on the main loop
rather than a task because MessageStore shares one _json_doc between save and
load and is not safe for concurrent access.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
2026-06-20 00:27:54 -04:00
torlando-agent[bot]andClaude Opus 4.8 07c2f42284 fix(chat): cap chat-open render to 10 messages to avoid LVGL-lock timeout crash
ChatScreen::refresh() loads + renders MESSAGES_PER_PAGE messages while the LVGL
mutex is held (the open path runs on the LVGL task's lv_task_handler). On a 32+
message conversation with a memory-pressured heap, 20 LittleFS reads + parses
exceeded LVGLLock's 5s timeout and asserted (crash). Cap to 10 so the under-lock
work stays well under budget; older messages load on scroll.

This is a mitigation. The real fix is to do the message I/O off the LVGL lock
(load lock-free on the main loop, render under the lock) so a large conversation
neither freezes the UI nor risks the timeout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
2026-06-20 00:15:13 -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 ed3ec541fd fix: defer ConversationListScreen display-name writes out of the LVGL lock
refresh() called set_display_name() (a microStore/LittleFS write) while the
LVGL lock was held by UIManager::update(). On a cold-boot announce burst,
refresh() writes a name per newly-seen peer, serially stalling the LVGL render
task for the combined I/O time. Mirror the on_message_received fix: accumulate
the write-throughs in _pending_name_writes during refresh(), and flush them at
the top of UIManager::update() before it takes the LVGL lock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
2026-06-19 13:14:05 -04:00
torlando-techandClaude Opus 4.7 9699a15a2d fix(ui): coalesce ConversationListScreen refresh on inbound flood
on_message_received() called \`_conversation_list_screen->refresh()\`
unconditionally per message. Under propagation-sync flood (50+ queued
messages delivered back-to-back) that's 50 full LVGL list redraws,
each holding LVGL_LOCK across:
  - lv_obj_clean(_list) + reload conversations from MessageStore
  - per-conversation container construction
  - per-peer Identity::recall_app_data calls for display names
  - SPI display flush

The refreshes serialize behind LVGL_LOCK, the SPI bus stays saturated
flushing dirty regions, and pyxis's USB CDC TX buffer overflows
because the main loop is too busy with display work to drain the
serial-output FIFO. Harness commands time out as a side effect.

Replace the per-message refresh with a coalescing flag drained from
update():
  - on_message_received only sets _pending_conversation_refresh
  - update() refreshes at most once per 750ms
  - update() also skips the refresh entirely when the user isn't on
    the conversation list (show_conversation_list refreshes when they
    navigate back, so nothing's lost — a chat-screen user gets quiet
    background ingestion)

Validated under a 2-round LXMF soak: direct + opportunistic short and
medium messages all PASS round-trip with the coalescing in effect.
Propagation flake is unchanged (known timing issue between pyxis
upload and bot's 8s sync poll, not a UI regression).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:59:40 -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 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 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 346b66a04b chore(build): check in SplashImage.h, fix ChatScreen display name
Two fixes from the same testing session:

1. Make SplashImage.h a checked-in artifact rather than a per-build
   gitignored regen. The pre-build script generate_splash.py needs
   cairosvg + Pillow in the PlatformIO python env, but its import
   block silently skips on ImportError — every contributor missing
   those deps would silently get a black-screen splash with no clear
   signal. Checked-in artifact means a fresh checkout works
   regardless of local python state; the script's
   should_regenerate() only refreshes when pyxis-icon.svg is newer
   than the header, so the committed copy stays in sync for
   contributors who DO have the deps.

2. ChatScreen now uses the same three-tier display name resolution
   as ConversationListScreen (live announce → MessageStore-cached →
   truncated hash) and writes through to the persistent cache when
   the live cache hits. Previously it always fell to the truncated
   hash if Identity::recall_app_data was empty, even when the
   MessageStore had a cached name from a prior session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:56:29 -04:00
torlando-techandClaude Opus 4.7 2eeb81e42f fix(ui): persist & restore display names; stop showing "Future" pre-clock-sync
Two related bugs in the conversation list:

1. Without GPS/NTP, Utilities::OS::time() returns uptime seconds — way
   smaller than any real unix-epoch message timestamp. The
   format_timestamp "diff < 0 → Future" branch then fired on every
   row. Add a sane-epoch threshold (2024-01-01) below which we render
   "?" instead, since "Future" is misleading when it just means
   "we don't know what time it is."

2. Identity::recall_app_data is in-memory only and lost on reboot. The
   conversation list always re-fell back to truncated hashes on cold
   start. Wire the three-tier resolution flow: live announce →
   MessageStore-persisted name → hash. When the live cache hits, write
   through to the persisted side via MessageStore::set_display_name so
   future cold boots get the name back immediately.

Pulls in microLXMF 5531a59 (MessageStore display-name cache impl).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:47:36 -04:00
torlando-techandClaude Opus 4.7 ed0eebe1f0 fix(ui): add satellites-in-view tier to GPS top-bar indicator
TinyGPSPlus's `satellites` only updates from $GPGGA (sats USED in
the position fix). A module seeing the sky but not yet acquired
shows fix=0 — previously rendered as red "0", which read as
"GPS broken." It's actually "GPS healthy, waiting for lock."

Bind a TinyGPSCustom to $GPGSV field 3 (satellites in view) and
fall through to it when fix-sats is 0:

  --     muted     no GPS handle / no NMEA
  ?      yellow    NMEA flowing, no fix, no GSV count yet
  ?N     yellow    N satellites visible but not yet locked
  N      colored   N satellites locked in fix

So a cold start now shows a meaningful number ticking up as the
module finds birds, then flips to a green N once it gets a fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:41:08 -04:00
torlando-techandClaude Opus 4.7 6501c6e68c fix(ui): re-enable LoRa RSSI + add GPS-alive-no-fix tier on top bar
The top status bar's LoRa indicator was hard-disabled (`if (false &&
_lora_interface)`) — every render printed "--" no matter what the
radio was doing. Re-enable it: SX1262Interface::get_rssi() is
non-virtual on the impl class, so we drop down to the InterfaceImpl*
via Interface::get() and static_cast to SX1262Interface.

Also adds a third tier to the GPS readout so "module connected but no
fix yet" is distinguishable from "no GPS hardware":
  --   muted    no GPS handle, or no NMEA bytes parsed
  ?    yellow   NMEA flowing but no $GPGGA sat-count yet
  N    colored  satellite count valid

Before: a TinyGPSPlus that was happily streaming $GPGSV but hadn't
yet parsed a $GPGGA showed "--" indistinguishably from a missing
module. The "?" tier surfaces "alive, waiting for sky."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:05:41 -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-tech f9e959f38e Track A.9: Link members default-init to Type::NONE — pyxis runs against upstream
Fixes the StoreProhibited panics surfaced by hardware boot of fd2d3de:

  StoreProhibited @ Bytes::assign (offset 0x1c on a null _data shared_ptr)
  ← Ed25519PublicKey ctor
  ← Ed25519PrivateKey::public_key()
  ← Link::Link(Destination&) at Link.cpp:79

Vanilla upstream microReticulum @ 0.3.0 Link's all-defaults ctor goes
through load_private_key() which dereferences _prv (a shared_ptr that's
still null on a freshly-default-constructed Link). Fork's Link probably
guarded against this; upstream doesn't. The explicit
Type::NoneConstructor branch (Link.h:153) leaves _object null and is
safe for default-init of pool/pre-allocated members.

Two sites needed the same fix:
  - lib/microreticulum-shim/LXMF/LXMRouter.h DirectLinkSlot::link
    (the fork's pre-allocated DIRECT-delivery link pool)
  - lib/tdeck_ui/UI/LXMF/UIManager.h UIManager::_call_link
    (the LXST call link state)

Both: `RNS::Link link;` -> `RNS::Link link{RNS::Type::NONE};`.

🎉 First successful runtime of pyxis on top of upstream + fixes:
  - Heap 131KB free post-boot (vs ~45KB on pre-graft, then panic)
  - Min low-water 119KB — never got tight
  - Display flushing, no panic, no reboot loop
  - Reset reason: PANIC (4) on prior runs is replaced by clean run
  - microStore SPIFFSFileSystem mount works
  - Identity loads from NVS with same hash as pre-graft (b5f09f9a833f4ef8)
  - LoRa init, AutoInterface init, Transport::start() all succeed
  - MessageStore loads 4 conversations from /lxmf
  - LXMRouter constructs cleanly
  - PropagationNodeManager initializes

Caveats for runtime test (per pyxis_microReticulum_graft_spike_findings):
  - BLE + TCP + Transport mode currently disabled in user settings
    (was needed to get a stable enough OTA window during initial flash —
    can re-enable once we confirm pyxis-fixes-on-0.3.0 doesn't introduce
    a BLE/TCP-specific WDT)
  - Resource-form inbound LXMF dispatch is no-op'd
    (static_resource_concluded_callback)
  - Some UI stat fields disabled (LoRa RSSI, BLE peer count)
2026-05-05 12:51:45 -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
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
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 8d23c03e3b Fix conversation list showing hashes instead of display names after restart
After boot, the conversation list called recall_app_data() once during
initial load. If announces hadn't arrived yet (or known destinations
hadn't been loaded with app_data), conversations showed raw hashes
permanently until the user navigated away and back.

Add a lazy name resolution check to update_status() (called every 3s):
if any conversations have unresolved names, try recall_app_data() again
and refresh the list when a display name becomes available.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 00:24:30 -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 ddd19a04db Fix LXST TX audio wire format to match Columba's expected batch size
Columba's native OboePlaybackEngine ring buffer expects exactly
frameSamples (1600 for Codec2 3200 mode) decoded samples per
writeEncodedPacket call = 10 sub-frames of 160 samples each.

Changes:
- Batch exactly 10 sub-frames per fixarray element (82 bytes each:
  codec_type + mode_header + 10*8 raw bytes)
- Up to 2 batches per msgpack packet, matching Columba C2C format
- Proper fixarray wrapping for multi-batch, bare bin8 for single
- Add codec_type byte (0x02) prefix per batch element
- Respond to PREFERRED_PROFILE negotiation with LBW (Codec2 3200)
- Add capture diagnostics (raw PCM peaks, I2S dump, rate logging)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 10:57:14 -05:00
torlando-techandClaude Opus 4.6 4e1f379d94 Persist only contacts to flash, mark on send/receive
Only destinations that have exchanged messages are written to SPIFFS.
UIManager marks destinations as persistent on send_message() and
on_message_received(). Reduces persist time from 40-50s to <1s.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 13:17:28 -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 d58ac9573f Switch to Codec2 3200 (LBW profile) for better voice quality
- Change codec from 1600bps to 3200bps (2x bitrate, 20ms frames)
- Signal LBW profile (0x30) instead of VLBW (0x20) to Columba
- TX batch size 10 frames (10×160=1600 samples matches Columba LBW)

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