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>
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>
Local patch via patch_filestore.py for upstream attermann/microStore
issue: \`finalize_compaction()\` and \`clear()\` both call
\`_filesystem.remove()\` on segment files without first closing
\`active_file\`. On filesystems that don't auto-close FDs on unlink
(LittleFS / FAT) the descriptor leaks. Over enough compaction cycles
on pyxis the path-store eventually can't open new files.
Both \`open_segment()\` and \`rotate_segment_if_needed()\` already
close \`active_file\` before reopening — the unlink paths just
forgot. Added \`if (active_file) active_file.close();\` at the top
of each.
Validated: 2-round LXMF soak post-patch, 8 pass / 2 fail, identical
to pre-patch baseline (the 2 fails are the known propagation timing
flake unrelated to FD handling). Patch applies cleanly and re-applies
on every build via the pre-build hook.
Will be reverted once the upstream lands the same fix; tracked in
the vault TODO at "80 Assistant/Memory/pyxis/upstream_patches.md".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
PIO copies a \`file://\` lib_dep into \`.pio/libdeps/<env>/<lib>\` once
on first install and never re-syncs even when the source changes.
That silently masks fixes — the build succeeds and the device flashes,
but the firmware contains the OLD source tree from whenever PIO last
fetched. Bit us hard on a microReticulum security fix that lived in
\`~/repos/microReticulum/\` for an entire test session
before anyone realized it had never reached the device. Every "this
should be fixed now" run was reading stale code; only \`rm -rf
.pio/libdeps/tdeck/microReticulum\` actually picked up the patch.
Add \`sync_file_libdeps.py\` as a pre-build script that mirrors each
registered file:// source tree into its libdeps cache by mtime. Skip
if PIO hasn't done the first install yet (let it fetch normally).
Skip git/build/__pycache__ and .pyc/.o/.a artifacts. Output one
"SYNC: <lib>: refreshed N/M files" line per affected dep so the
refresh is visible in the build log.
Validated: touching ~/repos/microReticulum/src/Identity.cpp
triggers \`SYNC: microReticulum: refreshed 1/186 files\` on next \`pio
run\`. Steady-state build (no source changes) is silent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Earlier commit gated this print behind \`peak > 1000 || ringDrops >
0\`. Backfired in callee mode: t-deck speaker plays incoming TTS,
mic picks it up via acoustic feedback, peak rises above 1000, the
print fires every 2s anyway, and serial saturates ~12s into the call
— pyxis stops responding to T:CALL_QOS / T:CALL_STATS even though
audio is still flowing fine.
Now only print on ring drops (an actual problem). Counters keep
updating internally for callers that need them.
After this, --callee mode validates clean: pyxis_tx=34 rx=115
decode_ok=120 decode_fail=0 pyxis_rms=4345 with TTS bot. Both
directions of pyxis ↔ real LXST.Telephone interop now PASS
end-to-end at ULBW (Codec2-700C, 0x10).
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>
WIRE TX raw + WIRE TX framed were INFO-level, ~150-180 bytes each, and
fired per outgoing packet. During an LXST voice call (~5 batches/sec
plus retries) that's ~2KB/s of pure debug log on USB CDC, on top of
existing call/heap/disp prints. The serial buffer saturated ~15s in
and T:CALL_QOS / T:CALL_STATS commands timed out at the 5s threshold,
even though the device itself was healthy.
Demote both to DEBUG and gate the hex-encoding work behind a runtime
loglevel check so the per-packet snprintf loop doesn't run when the
output would be discarded anyway. Re-enable by raising RNS log level
to DEBUG when actually debugging the wire format.
After this, a 14s real-LXST E2E call returns full stats every poll
with no timeouts and the harness validator runs to PASS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
VoiceFilterChain::applyHighPass collapsed to a constant-gain
multiplier instead of a real high-pass response. The inner loop
read `samples[prevIdx]` as "previous input" — but that slot held
y[n-1] (output) since the previous iteration had overwritten it.
Substituting that into the formula
y[n] = α(y[n-1] + x[n] - x[n-1])
with x[n-1] := y[n-1] gave
y[n] = α(y[n-1] + x[n] - y[n-1]) = α · x[n]
i.e. just a fixed gain ≈ 0.81 at 300Hz cutoff / 8kHz. DC offsets
sailed through; the chain only kept signal levels reasonable
because the AGC stage downstream pulled the residual toward target.
The same bug exists in upstream LXST-kt's
native_audio_filters.cpp (filed as LXST-kt#13).
Fix: walk per-channel with explicit `xPrev` / `yPrev` variables so
input history isn't clobbered by the output write. Per-chunk save
of `lastInputs[ch]` now stores the actual last input, not the last
output, so the first sample of each new chunk uses the correct
x[n-1].
Test `dc_offset_attenuated_by_hpf` updated to assert tail RMS
< 0.01 (was < 0.5 — accommodating the broken behavior). Pass.
End-to-end acoustic test (Mac speaker → T-Deck mic): pyxis_rms
went up from 4378 → 5835, bot_rms from 1194 → 1517 — DC offset
removal lets clean signal through better.
Pyxis-side fix only. LXST-kt should pick up the same fix
upstream — see project_lxst_hpf_filter_bug.md in the vault.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The PlatformIO dep sh123/esp32_codec2_arduino@1.0.7 bundles codec2
v0.9.2. Mac-side pycodec2 v3.0.4 links libcodec2 v1.2.0. Years of
codec2 development between those releases.
Replace the upstream lib with a local vendor of drowe67/codec2 v1.2.0
under lib/codec2/. Trim the 191-file source tree down to the
~100 files actually needed for codec2 (drop FreeDV, OFDM, COHPSK,
FSK, FM-FSK, LDPC, Horus, CLI tools — pyxis only uses
codec2_create/destroy/encode/decode + samples_per_frame /
bytes_per_frame). Carry over the v0.9 codebook .c files since the
codebook contents matched (compared against v1.2's src/codebook/*.txt).
Define __EMBEDDED__ so the codebooks land in flash (.const) rather
than RAM. Without it the codebooks add ~127KB to BSS and the LVGL
task fails to start (RAM was 65% full vs 27% with __EMBEDDED__).
Provide trivial codec2_malloc/codec2_free wrappers in
codec2_alloc_esp32.c (codec2 v1.2 expects them when __EMBEDDED__ is
defined; ESP-IDF's malloc/free already pull from internal RAM).
Also explicitly add SD/FS to lib_deps and #include <SD.h> in
main.cpp — the previous esp32_codec2 dep transitively pulled SD
which let SDArchiveFileSystem.h get away with depending on it
implicitly. With chain+ ldf mode and no esp32_codec2 dep, we have
to declare the framework lib explicitly.
DOES NOT fix the ~30x speech-decode RMS asymmetry between pycodec2
self-tests (~5800) and pyxis decoding the same encoded bytes (~170).
Sine waves and 3-formant synthesis pass clean both directions; only
real TTS speech triggers it. Probably a separate codec-state
divergence (the encoder/decoder are independent codec2 instances
in pyxis, both fresh per call) or a wire-format quirk we still need
to track down. v1.2 is the right baseline regardless — same bug
class as several upstream fixes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codec2-3200 is a SPEECH codec — pure-tone round-trip retains only
~12% of input RMS. The 1kHz-sine variant of the LXST QoS test
worked but tested the codec on its worst-case input.
Switch I2SCapture::setInjectSine to generate a synthesized voice-
like signal instead of a pure sine: F1 at the freq arg (default
730Hz, "ah" formant), F2 at 1.5·F1, F3 at 3.3·F1, all summed with
weights 0.55 / 0.30 / 0.15, modulated by a 120Hz amplitude envelope
that emulates glottal pulses. Phase-continuous so the encoder never
sees a discontinuity.
Codec2 retains far more energy on this content (~5400 RMS vs the
~1900 the sine produced) — same gate, much wider quality margin.
Validated end-to-end:
pyxis_rms=4378 bot_rms=5438 decode_fail=0+0 PASS
The LXST harness drives this via the same T:CALL_INJECT command
(API unchanged); only the in-firmware generator changed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
Boot was 48.5s end-to-end on this hardware: 92% of that was two
synchronous waits — gps_sync (15s) and wifi_connect (30s). Both
have async paths already (gps.encode is fed in loop(); the periodic
status check sets up TCP when WL_CONNECTED appears post-boot), so
blocking at boot wasn't actually load-bearing. Drop both waits to
brief opportunistic checks (500ms / 1s) and let the main loop pick
up async events.
Three follow-on changes were needed to keep functionality:
- Extract on_wifi_connected() (NTP + OTA + UDP logging setup) from
setup_wifi so the post-boot WL_CONNECTED transition in loop() can
run it. Idempotent via _wifi_post_connect_done.
- Add periodic GPS-time-sync retry in loop() — every 30s while
unsynced, attempt a fast (timeout=0) sync_time_from_gps. The
cheap path returns immediately when TinyGPSPlus already holds a
valid date+time.
- Defer LVGL task start until AFTER setup_ui_manager. Previously
the LVGL task started immediately after lv_init and refreshed
its empty default screen on top of the boot splash, causing a
visible flash to black before the first real UI frame painted.
With the start moved to after the UIManager has built screens
and configured the active one, the splash stays on-screen until
the first real frame.
Net: boot drops from ~48s to ~3-5s.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
Two adjustments to setup_reticulum() / setup_filesystem():
1. `Reticulum::transport_enabled(true)` — unlocks the path-store
init block at Transport.cpp:244 that was gated behind transport
mode. Without it, _new_path_table.put() always returned false at
TypedStore::isValid() (no filesystem assigned), every announce
surfaced as "Failed to add destination" spam, and the UI's
announce list stayed empty. Transport mode also enables relaying
for other nodes — tolerable on a T-Deck Plus with PSRAM headroom
and LittleFS-backed path persistence.
2. Switch the microStore adapter from SPIFFSFileSystem to
LittleFSFileSystem (paired with -DUSTORE_USE_LITTLEFS in
platformio.ini). LittleFS handles sustained writes; SPIFFS GC
stalls were causing every path-store put to fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five build-config changes that unlock proper microReticulum behavior
on the T-Deck Plus:
1. PSRAM allocator (`-DRNS_DEFAULT_ALLOCATOR=RNS_PSRAM_ALLOCATOR`,
`-DRNS_CONTAINER_ALLOCATOR=RNS_PSRAM_POOL_ALLOCATOR`,
`-DRNS_PSRAM_POOL_BUFFER_SIZE=2048000`). Previously default-heap
on ESP32, which routed every microReticulum allocation (path
table, destinations, etc.) through internal SRAM. Under live
announce flood the internal heap dropped from 92KB → 40KB free,
max_block fragmented from 77KB → 19KB. With PSRAM allocator the
internal heap stays rock-steady at 137KB and microReticulum lives
in 2MB of dedicated TLSF pool in PSRAM.
2. LittleFS instead of SPIFFS (`-DUSTORE_USE_LITTLEFS`). microStore's
FileStore puts (path table, etc.) hit the filesystem several
times per second on a busy network. SPIFFS GC stalls for 100s of
ms during block erase, causing flush_buffer() to fail and every
put to bail silently. LittleFS handles sustained writes cleanly.
3. Filesystem-backed path persistence (`-DRNS_USE_FS`,
`-DRNS_PERSIST_PATHS`). Without these flags, Transport::start()
skips _path_store.init() entirely and every announce-driven put
fails at TypedStore::isValid() — surfacing as "Failed to add
destination to path table" spam (~3.4/sec) and an empty UI
announce list.
4. Switch lib_ldf_mode to chain+ (was deep+) — deep+ scans every
#include statement and auto-fetches matching libs from the
registry, which was pulling a parallel microReticulum copy and
bypassing our deps/microReticulum/ overlay. chain+ follows only
explicit dependencies declared in library.json files.
5. patch_msgpack.py PIO pre-script — promotes hideakitai/MsgPack's
`Packer::packRawBytes` and `Unpacker::indices` from private to
public so microLXMF can splice arbitrary msgpack values into
LXMessage's fields-map wire format. Mirrors the equivalent patch
in microLXMF/conformance-bridge/CMakeLists.txt:106-125.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follows the project-wide SPIFFS → LittleFS migration. SPIFFS chokes
under sustained writes (GC stalls block writes for 100s of ms) and
its FileStore-backed path table was failing every put. LittleFS
tolerates the workload and is the new default. Boot profiler's tiny
log files don't care which backend they sit on.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
auto_interface and ble_interface declared `"microReticulum": "*"` as
a PIO library dependency. With deep+ LDF mode, that triggered PIO to
auto-fetch a parallel microReticulum copy into
.pio/libdeps/tdeck/microReticulum/ alongside our intended
deps/microReticulum/ overlay. The linker would silently pick the
fetched copy, dropping any local fork's .cpp changes.
These libs only need microReticulum HEADERS (already provided by the
project-level `-Ideps/microReticulum/src` build_flag), not a separate
linkable library — the actual microReticulum .a is built once via
`lib_extra_dirs = deps/microReticulum`. Dropping the dependency
declaration prevents the duplicate fetch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the vendored 11-file LXMF subtree under
lib/microreticulum-shim/LXMF/ with a symlink at lib/microLXMF
pointing at ~/repos/microLXMF. PlatformIO's lib_ldf_mode = deep+
follows the symlink, finds microLXMF/library.json, and treats it as
a regular library — no platformio.ini changes required.
Build verified: pio run -e tdeck succeeds, RAM 21.4%, Flash 71.3%
(comparable to pre-extract baseline).
The lib/microreticulum-shim/ "fork-only-glue" library remains in
place — it still owns BZ2 + libbz2 + Display + FileSystem +
Instrumentation + Utilities pieces that haven't been extracted.
For collaborators without ~/repos/microLXMF checked out, the
symlink will dangle. Once microLXMF has a tagged release we'll
swap the symlink for a `lib_deps` git URL pin.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stops the TASK_WDT(6) reset loop that was firing every ~50-90s of
post-setup runtime on the graft (and on pre-graft pyxis). Decoded
backtrace pinned the cause to ESP-IDF's WiFi ppTask doing
pm_tx_data_done_process and starving the CPU0 idle task — a
WiFi-stack-internal that has nothing to do with user code.
Two attempts, both useful to record:
1. sdkconfig.defaults: set CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=n.
Doesn't take effect — Arduino-ESP32 ships its framework PRE-BUILT
with the original sdkconfig (CPU0=y, timeout=5s) baked in.
sdkconfig.defaults overrides only apply if you rebuild the framework
from source. Kept the .defaults change for documentation; the
runtime escape uses esp_task_wdt_init.
2. esp_task_wdt_delete(xTaskGetIdleTaskHandleForCPU(0)) at boot.
Doesn't stick — Arduino-ESP32's runtime re-subscribes CPU0 idle
on the next loop iteration since CHECK_IDLE_TASK_CPU0 is still
set in the prebuilt config.
Final fix: esp_task_wdt_init(60, false). 60s timeout (was 30s) AND
panic=false (warnings logged, no reset). This makes WDT a soft
indicator instead of a hard reset trigger — appropriate while the
underlying WiFi-pm-starvation issue lives in ESP-IDF, not pyxis.
Verified runtime:
- 3 minutes continuous uptime, zero aborts/panics/resets
- 5 propagation nodes discovered from the network
(LXMF protocol layer working end-to-end on the graft)
- Heap stable around 46KB free, low-water 37KB
- 165KB UART log captured (vs prior ~30KB before reset)
This means pyxis on top of attermann/microReticulum @ 0.3.0 +
torlando-tech/microReticulum:pyxis-fixes-on-0.3.0 is now
**runtime-stable** — first time the graft has run uninterrupted
long enough to actually use.
Future: revisit panic=true once we can prove the WDT culprit is
moved to ESP-IDF's tracker (or pyxis is updated to a framework that
fixes the WiFi pm starvation). Until then, log-only avoids false
positives.
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)
🎯 First successful build against vanilla upstream (+ our PKCS7/HMAC/X25519
fixes branch). Flash 71.3% / RAM 21.4% on tdeck env.
Final shim trim + API patches to get the build through:
- Drop fork-only files from lib/microreticulum-shim/ that aren't
referenced anywhere outside the shim itself:
Buffer.cpp/h, ChannelData.h — fork's Channel work, unused by LXMF
Cryptography/Ratchet.cpp/h — RNS 1.x compat, included `<X25519.h>`
directly; not used by anything pyxis
touches today
SegmentAccumulator.cpp/h — fork's Resource segmentation, depends
on Resource methods vanilla doesn't
expose; not used outside shim
- LXMessage default-init Destination members with {RNS::Type::NONE} so the
default LXMessage() ctor isn't implicitly deleted (vanilla Destination
has no default ctor).
- LXMF/LXMRouter::static_resource_concluded_callback stubbed to a no-op
+ ERROR log: vanilla Resource doesn't expose link(). Resource-form
inbound LXMF delivery is currently disabled (PROPAGATION ⇄ LXMessage
glue is broken until Resource API is reconciled). Tracked in spike doc.
- Link::pending_requests_count() -> .pending_requests().size().
What works on this build:
- Compile + link (firmware.elf produced, 2.24MB flash)
- Crypto path goes through our pyxis-fixes-on-0.3.0 branch which has
spec-correct PKCS7, HMAC, and X25519 clamping
- microStore-based SPIFFS persistence via the SPIFFSFileSystem adapter
- All BLE/SX1262/auto-interface adapters
- LXMF outbound + inbound for non-Resource (DIRECT-via-Link, OPPORTUNISTIC)
What's broken on this build (known, deliberate, tracked):
- Resource-form inbound LXMF delivery (propagation node sync doesn't
receive messages — outbound path still works for sending)
- Transport memory diagnostics ([TABLES] dumps replaced with placeholder)
- LoRa RSSI display + BLE peer-count display in the LXMF UI
- Identity::mark_persistent (the 5s fast-flush no longer exists; fall
back to microStore's dirty-tracking)
Next: runtime smoke test on T-Deck (10.0.0.177 OTA) to validate that the
above "works" surfaces actually work end-to-end against the python
reference impl. Conformance bridge already passes 52/85 against this
exact submodule pin.
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).
Was: attermann/microReticulum @ tag 0.3.0 (vanilla)
Now: torlando-tech/microReticulum @ pyxis-fixes-on-0.3.0 @ 97e72a3
= vanilla 0.3.0 + 3 focused upstream-PR-ready commits
The branch carries our spec-conformance fixes that vanilla upstream
0.3.0 is missing:
bb177e4 Fix PKCS7 padding to be spec-conformant (RFC 5652 §6.3)
918f743 Fix HMAC convenience digest() helper hashing the message twice
97e72a3 Apply RFC 7748 §5 scalar clamping in X25519PrivateKey constructor
Each is one self-contained commit with a unit test that catches the
bug — designed so they can be cherry-picked or PR'd to attermann
upstream individually after we've finished e2e testing pyxis on top.
The conformance bridge baseline against vanilla 0.3.0 was 50/85;
expectation post-this-pin is to recover to 52/85 (the X25519 clamping
unblocks both x25519_generate and identity_from_private_key).
Three API migrations to keep the graft moving against vanilla
attermann/microReticulum @ 0.3.0:
(A.5) Identity persistence migrated to OS::set_loop_callback.
Was: Identity::set_persist_yield_callback(cb) // fork-only
Identity::should_persist_data() // fork-only
Now: RNS::Utilities::OS::set_loop_callback(cb) // upstream global
reticulum->should_persist_data() // already used
The fork's split between Identity-specific 5s fast-flush and
Reticulum-level 60s full-persist is unified upstream into a single
Reticulum::should_persist_data() entry point. The fast cadence is
folded into microStore's dirty-tracking. If we observe excessive
lost-known-destinations after crashes, revisit microStore's flush
cadence rather than re-adding the fork-only Identity API.
(A.6) Transport stats diagnostics disabled — vanilla upstream doesn't
expose the *_count() getter family the fork added. Two [TABLES]
diagnostic blocks in main.cpp now print a placeholder. Restore by
porting to upstream's get_path_table().size() and friends, or PR the
getters back to upstream Transport. Tracked in
pyxis_microReticulum_graft_spike_findings.md.
(A.7) BLE/SX1262 Interface stat methods are no longer virtual overrides.
Vanilla upstream Interface base class doesn't declare get_stats /
get_rssi / get_snr. Kept the methods as plain (non-virtual)
BLEInterface / SX1262Interface members; callers needing stats access
must hold the concrete type, not the base Interface*. Propose
upstream PR adding to base API if polymorphic access matters.
Also: setLogCallback -> set_log_callback (renamed in upstream commit
4d6f0b9 "Added dual-class PSRAM/TLSF allocator system").
Pyxis still doesn't build — next failures (4 distinct):
- OS::register_filesystem signature changed to microStore::FileSystem&.
Real microStore migration needed for UniversalFileSystem.
- LXMRouter::process_sync still missing despite vendored src-shim copy.
Include-order or shadowing — needs investigation.
- MEMORY_MONITOR_POLL macro not picked up despite -I src-shim/Instrumentation.
- Identity::should_persist_data appears to still be referenced via
LXMF or another vendored layer — would surface once the above land.
Configuration moves to make pyxis buildable against vanilla
attermann/microReticulum @ 0.3.0 instead of the fork:
- lib/libbz2/ — pulled from torlando-tech/microReticulum @ ca355e5
(the previous submodule pin). Used by LXMF compression. Will
eventually move into the standalone microLXMF library (Track B).
- src-shim/ — the 37 fork-only files NOT already vendored into
pyxis/lib/ as part of the original split. Includes:
- LXMF/* (11 files) — to extract as a standalone library (Track B)
- Cryptography/{BZ2,Ratchet}.{cpp,h} — used by LXMF + RNS 1.x compat
- Instrumentation/{BootProfiler,MemoryMonitor}.{cpp,h} — pyxis-specific
- Utilities/{Print,Stream}.{cpp,h} — pyxis logging supplements
- root: Buffer, BytesPool, ChannelData, Display+Graphics, FileStream,
FileSystem, MessageBase, ObjectPool, PSRAMAllocator, SegmentAccumulator
- platformio.ini lib_deps: drop the symlink:// to deps/microReticulum/lib/libbz2
(gone in upstream), add microStore as a github URL dep (vanilla
upstream needs it for Bytes.h's microStore/Codec.h include).
- platformio.ini build_flags: add -Isrc-shim plus its subdirectories
so the vendored ../X.h-style includes (rewritten to <X.h>) resolve
through pyxis's own paths first.
src-shim/* was bulk-rewritten so `#include "../X.h"` (relative to
the fork's src/) becomes `#include <X.h>` resolved via -I deps/microReticulum/src.
Pyxis still doesn't build at this point — the next layer of breakage
is API-level (not file-level): pyxis main.cpp calls fork-only
Identity/LXMRouter/Transport methods that vanilla upstream doesn't
have. See the followup commit's notes / branch description for the
full list.
Was: torlando-tech/microReticulum @ feat/t-deck @ ca355e5 (a torlando
fork with unrelated git history per pyxis_microReticulum_alignment_analysis).
Now: attermann/microReticulum @ tag 0.3.0 @ f8d91d1 (canonical upstream).
This commit is configuration-only — just the submodule pointer + URL.
Pyxis will not build yet because vanilla upstream doesn't ship the
fork's added subtrees (src/LXMF, src/BLE, src/Hardware/TDeck, src/UI,
src/Instrumentation, PSRAMAllocator/BytesPool/ObjectPool/FileStream/
FileSystem, Cryptography/{BZ2,Ratchet}, Utilities/Print,
SegmentAccumulator). Subsequent commits in this branch vendor those
into pyxis itself or carve them out into standalone libraries per
the alignment plan.
Greptile review feedback on PR #21:
ACCEPT:
- test_patch_nimble.py:151 (P1) — replace dead `if False else True`
ternary with a real assertion that "already applied" is absent on
the first run.
- test_patch_nimble.py:247 (P1) — invoke the shim subprocess via
`sys.executable` instead of hardcoded `/usr/bin/python3` so CI's
setup-python interpreter is used consistently.
- workflows/test.yml:50 (P2) — include hash of
deps/microReticulum/platformio.ini in PlatformIO cache key so the
cache invalidates when dependencies change.
MODIFY (narrowed):
- test_ring_buffers.cpp:209 (P2) — keep both `write(data, 0)` and
`write(data, -1)` assertions, but add a comment clarifying that
EncodedRingBuffer::write() takes signed `int length` (not size_t),
so -1 hits the `length <= 0` branch — same as 0. Greptile's
premise (size_t wrap to SIZE_MAX) does not apply to this codebase.
The two assertions lock the contract in case the param is ever
migrated to size_t.
REJECT (silently — no public reply per agent policy):
- test_audio_filters.cpp:237 (P1) — VoiceFilterChain::process()
takes `numSamples = frames * channels` per the documented
contract in audio_filters.h:33-40, and the implementation does
`numFrames = numSamples / channels_` (audio_filters.cpp:63). The
multichannel test correctly passes `(int)samples.size() = 8000`
(4000 frames * 2 channels). No out-of-bounds read occurs.
- lib/lxst_audio/{packet,encoded}_ring_buffer.cpp use malloc/free without
including <cstdlib>. macOS leaks it via header transitivity but Linux
clang is stricter — real portability bug surfaced by the new pytest CI.
- microReticulum native17 tests link against system libbz2 via the fork's
pre:link_bz2.py script. Ubuntu runners need libbz2-dev installed.
Standalone C++ tests of pyxis-unique code (BLE fragmenter/reassembler,
peer manager, GATT op queue, LXST ring buffers, audio filters, HDLC
framing) plus Python tests of the patch_nimble.py build script.
Each C++ test is compiled directly by clang++/g++ with shims in
tests/native/ (Bytes.h, Log.h, Utilities/OS.h) so pyxis sources can build
without microReticulum's full Arduino/MsgPack dep tree. A pytest wrapper
per test compiles, runs, and parses the summary line — the whole suite
is one command: `pytest tests/build_scripts tests/native -v`.
Total: 13 pytest tests, ~72 underlying C++ assertions, 3.4s.
Surfaced an HPF-formula bug in lxst_audio (mirrored upstream in
LXST-kt/native_audio_filters.cpp) — filed as LXST-kt#13 and tracked
in the corresponding test with a TODO link.
CI workflow runs the pyxis pytest suite plus the clean-passing
microReticulum native17 unit tests (94/114 of the existing fork
test/* suites) on push and PR.
The unprotected _clients.find() could race with
processPendingDisconnects() erasing from the map concurrently.
Mutex is released before the blocking getService() GATT call.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Previously, a mutex timeout left characteristic caches empty but
still signalled success to callers, making all GATT ops silently
fail for the connection.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Re-check hasActiveWriteOperations() after acquiring mutex in
processPendingDisconnects() to close race where write() registers
an op between the pre-mutex check and mutex acquisition
- Move cached char pointer writes inside connection-exists guard in
discoverServices() to prevent dangling pointers on handle reuse
- Add WARNING logs to both onConnect callbacks on mutex timeout
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Move getService()/getCharacteristic() out of mutex-held paths in
writeCharacteristic(), read(), enableNotifications() by caching all
three char pointers (RX, TX, Identity) during discoverServices()
- Replace 5-second spin-wait in processPendingDisconnects() with
non-blocking deferral: break if GATT ops in flight, retry next loop
- Add WARNING logs to all read-path helpers on mutex timeout
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>