Reticulum path-table entries for destinations with larger announce app_data run
1025-1033 bytes, over microStore's 1024-byte USTORE_MAX_VALUE_LEN default. They
hit "[ustore] put: failed due to excessive data length" and never entered the
path table ("Failed to add destination ... to path table!"), so those peers were
unreachable even though the TCP link was up and receiving their announces -- it
looked like "can't connect to the TCP server." Raise the cap to 2048; this also
sizes TypedStore's read buffer (reads into a vector of USTORE_MAX_VALUE_LEN), so
reads of the larger values don't truncate. Verified on device: zero
excessive-length / failed-add errors, path table populates.
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
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
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
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
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
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
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
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
Bump microReticulum -> cb71ace and microLXMF -> 3cdde79.
microReticulum (cb71ace): OS::ltime() now uses the monotonic 64-bit esp_timer
instead of a 32-bit millis() rollover counter. The old static low32/high32
counter was not thread-safe -- concurrent ltime() calls from the transport/UI/
BLE tasks raced on `if (new_low32 < low32) high32++` and spuriously inflated
OS::time() by N*49.7 days. Conversation timestamps showed garbage ("Future",
"49w ago", a year-2046 clock). Verified on a T-Deck: the clock now holds at the
correct epoch instead of climbing ~19.7 years within minutes of runtime.
microLXMF (3cdde79): load_message_metadata() drops a redundant LittleFS open per
message (read_file returns 0 on a missing file, so the file_exists probe was
unnecessary) and parses with a JSON field filter that skips the large "packed"
hex blob. Roughly halves conversation-load time on ESP32/LittleFS (~1330ms ->
~670ms for 10 messages).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
The graft namespaced pyxis's BLE/HDLC sources to <microReticulum/Bytes.h> etc.,
but the standalone native unit-test build only had flat shims in tests/native/,
so test_ble_* and test_hdlc failed to compile ("'microReticulum/Bytes.h' file
not found"). Add forwarding shims under tests/native/microReticulum/ that
include the existing flat shims. Production code is unchanged; this only fixes
the test harness for the relocated layout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
The echo bot hardcoded target_port=4242 in its RNS config, but run_e2e.sh bakes
PYXIS_TEST_TCP_PORT into the firmware's TCP target. With a non-default port the
bot kept dialing 127.0.0.1:4242 and silently failed to join the network
(echobot_announce_dest then timed out with a confusing error). Read the port
from the same env var (default 4242) so the bot and T-Deck share one rnsd.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
Three non-blocking robustness nits Greptile noted in the on-device harness
(none touch firmware):
- tdeck_harness.py: t.close() on every early `return 1` so a failed run frees
the USB serial device (otherwise the next run fails with "port in use").
- lxmf_echo_bot.py: bound the prop-syncer's path-acquisition loop (~5 min) so it
can't spin forever when the PN is unreachable; PROPAGATED rounds just skip.
- run_e2e.sh: guard $PIO_PY is non-empty before exec.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
start_echobot() hardcoded /usr/bin/python3 -- use sys.executable (the harness's
own interpreter, which run_e2e.sh selects and which has rns/lxmf importable via
the bot's repo-path insert), overridable with PYXIS_BOT_PY. Also remove the dead
last_size variable in echobot_log_after().
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
tests/hardware/ drives a flashed T-Deck through LXMF round-trips against a
Mac-side echo bot over the device's TCP-client -> rnsd link, via the firmware's
-DPYXIS_TEST_HOOKS T: command surface.
- run_e2e.sh: orchestrator -- autodetect serial port + Mac IP, verify rnsd on
:4242, build+flash with the TCP target baked in, run the harness, assert.
- tdeck_harness.py: resets the device, establishes path/identity with the bot,
then runs a bz2-on-receive probe + DIRECT/OPPORTUNISTIC (and PROPAGATED when
PYXIS_PROPAGATION_NODE_HEX is set) round-trips, watching for crash signatures.
- lxmf_echo_bot.py: Mac-side LXMF echo bot; on a BZ2PROBE trigger it replies
with a ~1.5KB highly-compressible payload so python LXMF sends a bz2-COMPRESSED
Resource, exercising the receiver's decompress-on-receive path.
Parametrized -- serial port, Mac IP, and propagation-node hash all come from the
environment; nothing deployment-specific is committed.
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
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
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 diagnostic patch injected printf("...%s...", bin_str(key, key_len)) into
FileStore.h but never defined bin_str, so a PYXIS_FILESTORE_DIAG=1 build failed
with an undeclared-identifier error (normal DIAG=0 builds were unaffected).
Inject a standard-C hex-encode helper as a static member alongside the prints
so the diagnostic build is self-contained. Verified: the helper compiles clean
under gnu++11 -Wall -Wextra -Werror and round-trips keys correctly.
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
Greptile cleanup (greploop): universal_filesystem is no longer included by
main.cpp (migrated to microStore) -- drop the dead lib_dep so a clean build
doesn't pull its removed SPIFFS dependency. _stat_rx_packets_complete was
declared but never incremented or logged -- remove the unfinished counter.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
Companion to 21b0e96 which deleted BluedroidPlatform.{cpp,h} but
missed staging the three edited files that complete the cleanup:
- platformio.ini: remove the [env:tdeck-bluedroid] env block.
- .github/workflows/build-check.yml: drop tdeck-bluedroid from
the CI matrix.
- lib/ble_interface/BLEPlatform.cpp: drop the
USE_BLUEDROID-gated factory branches.
Same `git add` short-arg trap as the eridanus mishap earlier today
— specifying non-existent paths aborts the add before reaching the
M-file paths. Mental-model fix: stage M-files in a separate `git add`
from the staged-deletes.
The bluedroid BLE stack hasn't been the runtime path for a while;
NimBLE-Arduino is the canonical backend (lighter heap, more modern
API, what the live ble_interface uses). The bluedroid env+code were
still being maintained as a CI matrix entry, and just started
failing on the current branch — no value to keeping it green.
Changes:
- platformio.ini: remove the `[env:tdeck-bluedroid]` env block
entirely (was 159 lines, near-duplicate of [env:tdeck] modulo
`-DUSE_BLUEDROID`). Also remove the stale comment header that
used to sit above it.
- .github/workflows/build-check.yml: drop `tdeck-bluedroid` from
the build matrix.
- lib/ble_interface/platforms/BluedroidPlatform.{cpp,h} deleted
(2 files, ~81 KB / ~2000 LOC of dead code — all gated behind
`#if defined(USE_BLUEDROID)` which can no longer be defined).
- lib/ble_interface/BLEPlatform.cpp: drop USE_BLUEDROID-gated
factory branches (PlatformType::ESP_IDF case + the BluedroidPlatform.h
include + the "Bluedroid takes priority" detection clause).
Left in place:
- PlatformType::ESP_IDF enum member in BLETypes.h — dormant value,
not worth a coordinated removal sweep.
- USE_BLUEDROID build flag was already absent from [env:tdeck]'s
flags (this env always used NimBLE in production); just no
longer ever defined anywhere.
Build verified clean: `pio run -e tdeck` succeeds with the same
27.4% RAM / 79.8% Flash shape.
The microStore dep was the last unpinned third-party library, leaving
pyxis tracking the moving `master` branch while microReticulum and
microLXMF are SHA-pinned. Pin to the same commit the microLXMF
conformance bridge already validates against (ceea8f5 — "Added SD
filesystem and enhanced Flash filesystem", 2026-04-14). Newer commits
on master introduced a dynamic segment-size refactor (pre-0.1.6) that
hasn't been validated against pyxis's MessageStore / path-store usage
patterns; pinning here freezes the version until that's done.
Verified: fresh `pio run -e tdeck` re-fetches the pinned SHA into
.pio/libdeps/tdeck/microStore (`git log -1` confirms `ceea8f5`),
builds clean — same 27.4% RAM / 79.8% Flash shape.
SDArchiveFileSystem.h: split listDirectory's SPI bus mutex hold into
per-entry acquire/release cycles. The previous all-or-nothing hold
scaled with archive size; a directory with thousands of entries
could keep the bus locked past LoRa/display's 500 ms acquire timeout
(LoRa TX would fail silently, display would tear, RX FIFO could
overflow under inbound flood). The new pattern records each entry's
name to a std::string, releases the bus, invokes the callback (or
appends), then re-acquires for the next entry. Callback runs outside
the critical section. SD cursor lives in the root File so other-CS
bus users (display CS, LoRa CS) between iterations don't clobber it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
patch_msgpack.py + patch_filestore.py: read PIOENV instead of
hardcoding "tdeck" in the libdeps path. The hardcode meant the
msgpack public-modifier patch silently no-op'd under tdeck-bluedroid
(and tdeck-ota), making microLXMF's packRawBytes / raw_data /
indices accesses fail to compile. Mirrors the pattern already used
in sync_file_libdeps.py. Resolves the failing tdeck-bluedroid build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SDArchiveFileSystem.h: guard release_bus() in FileImpl::close() on
the acquire_bus return value, matching the pattern every other
method already uses. Previously close() (which is also called from
~FileImpl) issued an unconditional xSemaphoreGive even when
acquire_bus(500) timed out, skewing the SPI bus mutex counter on
each over-release.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
sh123/esp32_codec2 PR #4 (merged Jan 6 2026) updated the bundled
codec2 to the modern stable line — addresses the v0.9.2-vs-pycodec2-
v1.2.0 interop gap that motivated vendoring back on May 8. With the
version gap closed, the standalone vendor in lib/codec2/ becomes
pure noise: 42 .c source files (~2.9 MB tree) sitting in the repo
when a single lib_deps line plus two build_flags get the same
behaviour.
Changes:
- platformio.ini lib_deps (both env blocks): `codec2` (vendored)
→ `sh123/esp32_codec2@^1.0.7`. Comment notes the version
history so future eyes don't re-vendor.
- platformio.ini build_flags (both env blocks): add
`-D__EMBEDDED__` and `-DMEMORY_CRITICAL` so codec2's codebook
tables land in flash (.const) instead of BSS. Moved here from
lib/codec2/library.json's `build.flags`. Without these, ~127 KB
of codebook data competes with LVGL's framebuffer in BSS.
- lib/codec2/ (vendored tree) deleted — 106 files, ~43,000 lines.
PIO will fetch sh123 into .pio/libdeps/tdeck/esp32_codec2/ on
first build.
Size impact:
- RAM: 27.4% → 27.4% (no change; codebooks were already in flash
via __EMBEDDED__).
- Flash: 77.0% → 79.8% (+~88 KB). sh123 ships the full codec2
source (69 .c files) vs the trimmed vendor (42 files): unused
paths like FreeDV, OFDM, COHPSK, FSK get linked in. Well below
the 100% partition limit; can revisit with `lib_ignore` if
flash pressure increases.
This is a real-functional change vs the rest of this PR which is
purely build-config rewiring. Audio fidelity should be unchanged
(sh123 carries the same codec2 SHA range as the vendor), but worth
revalidating with the codec2 fidelity test against pycodec2 before
merging.
microLXMF flipped public today; microReticulum's pyxis-fixes-on-0.3.0
fork branch has been public throughout. Both are now reachable
without machine-local clones, so the file:// pin + lib/microLXMF
symlink workarounds can go.
Changes:
- platformio.ini lib_deps: replace `file://~/repos/microReticulum`
with `https://github.com/torlando-tech/microReticulum.git#3ee2bd8`
(the same SHA the conformance bridge fetches).
- platformio.ini lib_deps: add `https://github.com/torlando-tech/microLXMF.git
#9876dff` (main HEAD after PR #4) as an explicit lib_dep.
- lib/microLXMF symlink to ~/repos/microLXMF removed — no longer
needed; PIO will fetch into .pio/libdeps/tdeck/microLXMF/ on
first build.
- platformio.ini build_flags: remove `-Ideps/microReticulum/src`
from both [env] sections. The hardcoded -I path was shadowing
PIO's auto-include of .pio/libdeps/tdeck/microReticulum/src/
(build_flags are searched first), letting the submodule's
potentially-stale headers win over the freshly-fetched git pin.
Now headers come from the same SHA as the .cpp source.
Pin-coordination note (in the inline comment): bump both SHAs in
tandem if either upstream changes, AND keep them in sync with
microLXMF/conformance-bridge/CMakeLists.txt's FetchContent_Declare
tag — otherwise the bridge tests against a different microReticulum
than the firmware does.
Build: `pio run -e tdeck` succeeds with the new deps, 27.4% RAM,
77.0% Flash — same shape as before. Verified both deps fetched
from github with `git config --get remote.origin.url` in
.pio/libdeps/tdeck/.
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.
Pre-this the 10s heartbeat reported running/scanning/connected/peers
state but nothing about whether data was actually flowing. With the
counters added to BLEInterface and threaded into the heartbeat
snprintf, the line now also surfaces:
tx_pkt — outbound RNS packets attempted
tx_frag — BLE fragments actually written/notified
tx_b — total bytes written
tx_fail — platform write/notify returned false
rx_frag — BLE fragments handed to the reassembler
rx_b — total bytes received
That was enough to root-cause the Columba-side stalls observed
during the BLE end-to-end testing session: pyxis showed connected=1
but tx_pkt frozen, surfacing that the keepalive loop wasn't firing
for a peer whose handshake had completed but identity recording
raced. Cumulative-since-start, no reset; cheap to keep on always.
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>
Three changes, motivated by debugging "Sideband + pyxis on the same
WiFi don't hear each other's announces":
1. Always call \`mld6_joingroup_netif()\` in addition to \`setsockopt
IPV6_JOIN_GROUP\`. On ESP-IDF lwIP, the setsockopt path returns
success but doesn't reliably push the multicast hash into the
WiFi MAC filter — incoming multicast frames get silently dropped
at L2. Calling the netif's mld6 API directly programs the chip
filter. Joining twice on the netif is refcount-safe.
2. Set IPV6_MULTICAST_LOOP=1 so pyxis receives its own multicast
echoes. ESP-IDF lwIP defaults this off, which makes upstream's
"carrier lost / multicast echo timeout" warning fire even on a
functioning network. With LOOP=1, the initial-echo path actually
works on isolated test setups too. Logged as DEBUG if the
platform doesn't support the option.
3. Add a periodic \`AutoInterface: stats announce_tx=N tx_fail=N
disc_rx=N disc_self=N data_rx=N peers=N\` heartbeat (every 10s).
Without this it's hard to tell whether pyxis isn't sending,
isn't receiving, or is sending+receiving but rejecting the
tokens. Discovery-RX from non-self addresses with bad tokens
now also logs once with the hex prefix so token-mismatch cases
are visible (group_id drift, scope-suffix encoding mismatches).
Added _initial_echo_received update on first self-echo so the
firewall warning at startup_grace fires correctly.
After this, pyxis's own multicast loopback works (disc_self=N
matches announce_tx=N within 10s). Cross-LAN multicast against
rnsd / Sideband still doesn't make it through, which is an ESP32
WiFi multicast TX limitation — pyxis's frames aren't reaching the
AP. Not a fix here; the diagnostics make the boundary visible.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>