The dominant garble was NOT the mic: T:RAWMIC/recorder ran full-duplex (start_loopback), so
the T-Deck played the decoded loopback round-trip out its OWN speaker, inches from the mic ->
acoustic feedback that built into "oscillating static that gets slowly louder". Gate the
playback ring-write off while the PSRAM recorder is active (i2s_playback.cpp) so raw-mic
captures are feedback-free -> speech becomes intelligible (Whisper recovers ~85% of words).
Also in this change:
- Recorder stores BOTH TDM channels (main.cpp) for offboard de-interleave; confirmed CH0 is
the mic, CH1 dead -> de-interleave was always correct.
- MCLK raised to an exact APLL-locked 12.288MHz (es7210.cpp MCLK_DIV_FRE=768 + the
{12288000,16000} coeff; i2s_capture.cpp use_apll=true/fixed_mclk) -- cleaner than the
fractional-N 4.096MHz, though the feedback (not jitter) was the real cause.
Residual (under investigation): signal-correlated distortion ("static reacts to speech") +
50Hz mains hum (USB-ground pickup, removed by the existing 300Hz HPF in the call path).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
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
- test_patch_nimble.py: the refactor made the script import its sibling
_build_helpers; the standalone-exec test ran it with a temp PROJECT_DIR, so add
the script's real directory to the shim's sys.path (CI pytest failure fix).
- sync_file_libdeps.py: remove the now-dead ENV_NAME binding.
- _build_helpers.py: reword the docstring (the "never literal env" line conflicted
with the helper's own "tdeck" fallback) and note inline that "tdeck" is a
never-hit default (PlatformIO always injects PIOENV).
- main.cpp: DRY the OTA hostname into OTA_HOSTNAME used by both setHostname() and
the log line.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
OTA hardening follow-up to the patch_nimble.py hardcoded-"tdeck" bug that broke
tdeck-ota linking (undefined reference to nimble_host_reset_reason):
- Add _build_helpers.env_libdeps_dir(env, *parts): the single per-environment
libdeps path resolver (.pio/libdeps/<PIOENV>/...). Converted all five pre-scripts
(patch_nimble/msgpack/filestore/littlefs_paths, sync_file_libdeps) to use it, so
the env component can no longer be hand-rolled/hardcoded wrong per script.
- "OTA: Ready" log -> "OTA: wireless flash service started (pyxis-tdeck:3232)":
ArduinoOTA.begin() is void and can't confirm a ready state, so log the target
instead of claiming readiness we can't verify.
Verified: both tdeck and tdeck-ota build, and the NimBLE patch lands in EACH env's
own libdeps tree (nimble_host_reset_reason count 2 in both).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
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
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
The device intermittently stamped messages ~19 years in the future. Root cause is
a GPS week-number rollover (1024 weeks ~= 19.6y): an un-resolved/cold-start fix
emits a rollover date (e.g. 2046), and sync_time_from_gps accepted any date with
year >= 2024 (no upper bound) and wrote it to the system clock, which microLXMF
then stamps onto every outgoing message.
- Quality+stability gate: set the clock only from a real 3D fix (>= 4 satellites,
HDOP <= 5) whose calendar date has been stable for >= 3s. Rollover/cold-start
bogus dates are weak and don't hold steady, so they're rejected. No hardcoded
future cutoff (which would itself age out). Date stability is tracked off the
main loop's continuous gps.encode() feed.
- Re-sync fixed: the periodic retry called sync_time_from_gps(0), which returned
immediately without syncing, and only ran until the first sync. It's now
genuinely non-blocking (sets the clock iff the current fix passes the gate) and
keeps retrying, so the clock self-corrects when a fix appears (e.g. basement ->
window) with no reboot.
- DST + double-mktime (ported from abandoned branch 8d4a047): TZ=UTC0 is set
before the single mktime() (the old double call corrupted the struct and skewed
the UTC clock by the DST offset), and continental-US timezones use POSIX strings
with DST rules so summer shows EDT/CDT/MDT/PDT instead of standard time.
Field-verified: from a window fix it synced to the correct 2026-06-22, no rollover.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
- Backstop ARMS its timer on the first disconnected tick instead of firing
immediately, so it can't issue a redundant begin() ~1s into boot and reset the
in-progress association (or setAutoReconnect's own retry). Re-armed on each
(re)connect so every drop gets a fresh ~15s grace.
- WiFi.persistent(false): begin() no longer writes creds to NVS on every call, so
the periodic backstop during a long outage doesn't wear flash. Creds are
already persisted by the app's Preferences store; setAutoReconnect is in-RAM.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
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
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
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
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
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
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
- 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
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
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
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
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
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
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>
- 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>
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.
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.
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>
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>
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>
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>