Commit Graph
82 Commits
Author SHA1 Message Date
torlando-techandClaude Opus 4.7 612f44e274 feat(lxst): Codec2-700C (ULBW) default profile + T:CALL_ANSWER hook
Adds two profile constants beyond the existing LXST_PROFILE_LBW (0x30,
Codec2-3200): LXST_PROFILE_VLBW (0x20, Codec2-1600) and
LXST_PROFILE_ULBW (0x10, Codec2-700C). Default is now ULBW — a 700C
frame fits comfortably inside an SF7-9 LoRa packet, which is the
target medium for pyxis voice. The previous 3200bps default was 4.5x
larger and unsuitable for the radio path.

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:07:41 -04:00
torlando-techandClaude Opus 4.7 6dbbe1bee7 fix(audio): correct degenerate HPF formula in voice filter chain
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>
2026-05-08 13:27:34 -04:00
torlando-techandClaude Opus 4.7 5a3ee97856 deps(codec2): vendor codec2 v1.2.0, replace sh123/esp32_codec2_arduino@1.0.7
The PlatformIO dep sh123/esp32_codec2_arduino@1.0.7 bundles codec2
v0.9.2. Mac-side pycodec2 v3.0.4 links libcodec2 v1.2.0. Years of
codec2 development between those releases.

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

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

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

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

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

Firmware additions (under PYXIS_TEST_HOOKS):

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 02:45:11 -04:00
torlando-techandClaude Opus 4.7 346b66a04b chore(build): check in SplashImage.h, fix ChatScreen display name
Two fixes from the same testing session:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Build-flag side:

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:03:42 -04:00
torlando-techandClaude Opus 4.7 3c06760829 refactor(boot-profiler): switch SPIFFS → LittleFS
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>
2026-05-06 18:15:44 -04:00
torlando-techandClaude Opus 4.7 39ece8800c fix(libs): drop registry-fetch microReticulum dep from interface libs
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>
2026-05-06 18:15:34 -04:00
microLXMF c7986c8211 feat(track-b): consume microLXMF as a separate library
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>
2026-05-05 16:19:49 -04:00
torlando-tech f9e959f38e Track A.9: Link members default-init to Type::NONE — pyxis runs against upstream
Fixes the StoreProhibited panics surfaced by hardware boot of fd2d3de:

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

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

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

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

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

Caveats for runtime test (per pyxis_microReticulum_graft_spike_findings):
  - BLE + TCP + Transport mode currently disabled in user settings
    (was needed to get a stable enough OTA window during initial flash —
    can re-enable once we confirm pyxis-fixes-on-0.3.0 doesn't introduce
    a BLE/TCP-specific WDT)
  - Resource-form inbound LXMF dispatch is no-op'd
    (static_resource_concluded_callback)
  - Some UI stat fields disabled (LoRa RSSI, BLE peer count)
2026-05-05 12:51:45 -04:00
torlando-tech fd2d3de359 Track A: pyxis builds against attermann/microReticulum @ 0.3.0 + fixes
🎯 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.
2026-05-05 01:44:50 -04:00
torlando-tech 02ceeda75b Track A.8: re-vendor shim from ca355e5; UniversalFileSystem→microStore;
relocate shim to lib/; pyxis lib API renames

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

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

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

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

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

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

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

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

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

Pyxis still doesn't build — next failures (4 distinct):
  - OS::register_filesystem signature changed to microStore::FileSystem&.
    Real microStore migration needed for UniversalFileSystem.
  - LXMRouter::process_sync still missing despite vendored src-shim copy.
    Include-order or shadowing — needs investigation.
  - MEMORY_MONITOR_POLL macro not picked up despite -I src-shim/Instrumentation.
  - Identity::should_persist_data appears to still be referenced via
    LXMF or another vendored layer — would surface once the above land.
2026-05-04 20:25:02 -04:00
torlando-tech dff8bf273c Track A.2: vendor libbz2 + 37 fork-only files into pyxis
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.
2026-05-04 18:08:59 -04:00
torlando-tech 49ede41036 Fix CI: explicit cstdlib include + libbz2-dev install
- 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.
2026-05-04 14:56:50 -04:00
torlando-techandClaude Opus 4.6 e2df70161b Protect _clients lookup in discoverServices with _conn_mutex
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>
2026-03-05 12:30:52 -05:00
torlando-techandClaude Opus 4.6 602d8f7083 Improve write() cache-miss warning to indicate discovery dependency
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:28:35 -05:00
torlando-techandClaude Opus 4.6 74def922a7 Guard enableNotifications against missing connection entry
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:05:50 -05:00
torlando-techandClaude Opus 4.6 8013597a5f Avoid redundant mutex retry in discoverServices error path
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:59:05 -05:00
torlando-techandClaude Opus 4.6 d9883c9e36 Report failure on mutex timeout in discoverServices
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>
2026-03-05 11:46:43 -05:00
torlando-techandClaude Opus 4.6 8c0dd227f4 Add missing mutex timeout warning in updateConnectionMTU
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:44:30 -05:00
torlando-techandClaude Opus 4.6 20a072d258 Fix TOCTOU in disconnect, stale cache in discovery, silent onConnect failures
- 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>
2026-03-05 11:01:55 -05:00
torlando-techandClaude Opus 4.6 9bd075c91e Cache char pointers in discoverServices, defer disconnect, add mutex timeout logs
- 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>
2026-03-05 01:50:46 -05:00
torlando-techandClaude Opus 4.6 334f024179 Close TOCTOU gap and protect onConnect map insertions
- Move beginWriteOperation() before xSemaphoreGive(_conn_mutex) in
  write(), writeCharacteristic(), read(), and enableNotifications()
  so the active-op counter is incremented while the mutex is still
  held. This closes the window where processPendingDisconnects()
  could observe hasActiveWriteOperations()==false and delete the
  client before the GATT caller has registered its operation.
- Add _conn_mutex around _connections/_clients insertions in both
  server and client onConnect() callbacks, preventing concurrent
  map insertions from corrupting the red-black tree.
- Protect updateConnectionMTU() with _conn_mutex.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 00:16:58 -05:00
torlando-techandClaude Opus 4.6 fffd8ec79e Add beginWriteOperation() guards to all blocking GATT methods
writeCharacteristic(), read(), and enableNotifications() resolve
characteristic pointers under _conn_mutex then call blocking GATT
ops after releasing it — same pattern as write(). Without the
active-operation guard, processPendingDisconnects() could delete
the client (and its child characteristics) during the GATT call.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:54:01 -05:00
torlando-techandClaude Opus 4.6 71bc4ae82b Address Greptile review: fix use-after-free and unprotected accessors
- Defer NimBLEDevice::deleteClient() in processPendingDisconnects()
  until after releasing _conn_mutex and waiting for any active write
  operations to complete. Prevents use-after-free when write() holds
  a child NimBLERemoteCharacteristic* pointer across the mutex boundary.
- Add _conn_mutex protection to getConnectionCount(), isConnectedTo(),
  and isDeviceConnected() which read _connections without synchronization.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:05:47 -05:00
torlando-techandClaude Opus 4.6 9174d27183 Fix cross-thread race condition on BLE connection maps
send_outgoing() on loopTask (core 1) calls write() which reads
_connections, _clients, and _cached_rx_chars maps, while
processPendingDisconnects() on the BLE task (core 0) erases from
them — with no synchronization. This causes std::map red-black tree
corruption, manifesting as LoadProhibited crashes in map rotate/insert
operations (EXCVADDR=0x00000008).

Protect all map accesses in write(), writeCharacteristic(), read(),
enableNotifications(), getConnection(), getConnections(), and
processPendingDisconnects() with _conn_mutex. The mutex is released
before any blocking GATT operations (writeValue, readValue, subscribe)
to avoid holding it during 10-30s NimBLE timeouts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:54:29 -05:00
TorlandoandGitHub 70b8df052d Merge pull request #12 from torlando-tech/feature/splash-screen
Add boot splash screen with Pyxis constellation logo
2026-03-04 18:38:43 -05:00
torlando-techandClaude Opus 4.6 ff00c1d783 Clean up splash preprocessor structure and add include warning
Consolidate #ifndef/#ifdef into single #ifdef/#else/#endif block.
Add warning comment to generated header about static linkage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 18:19:49 -05:00
torlando-techandClaude Opus 4.6 bae59ff424 Fix unused BG_COLOR warning and make show_splash() private
Move BG_COLOR inline into #ifndef block to avoid unused variable
when HAS_SPLASH_IMAGE is defined. Make show_splash() private since
it's only called internally from init_hardware_only().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 17:48:38 -05:00
TorlandoandGitHub 3c81eeb5be Merge pull request #11 from torlando-tech/fix/ble-wdt-stability
Fix Task WDT crashes from LVGL priority starvation
2026-03-04 17:07:27 -05:00
torlando-techandClaude Opus 4.6 ed8c08109f Add ble_hs_synced() guard to notifyAll()
Matches the guard already on notify() to prevent use-after-free
of _tx_char during a NimBLE host reset.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 16:20:06 -05:00
torlando-techandClaude Opus 4.6 5b2a1ab53e Skip redundant fill_screen when full-screen splash image is available
Saves one full 320x240 SPI screen write before the splash renders.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 16:09:49 -05:00
torlando-techandClaude Opus 4.6 4ba97057c5 Remove no-op esp_task_wdt_reset() calls from NimBLEPlatform
BLE task is no longer subscribed to WDT, so these 23 calls were
silently returning ESP_ERR_NOT_FOUND. Removes dead code and the
now-unused esp_task_wdt.h include.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:32:44 -05:00
torlando-techandClaude Opus 4.6 2a1b98f8f1 Fix _initialized never set in Display::init()
Prevents double PSRAM allocation and LVGL driver re-registration
if init() were called more than once.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:39:49 -05:00
TorlandoGitHubgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2f61b80567 Update lib/tdeck_ui/Hardware/TDeck/Display.cpp
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-04 14:20:44 -05:00
torlando-techandClaude Opus 4.6 a4a1aacdd8 Show boot splash within 1s of power-on instead of after 20s+ init
Move Display::init_hardware_only() and POWER_EN to right after serial
banner, before GPS/WiFi/SD/Reticulum init. Add 150ms delay after
POWER_EN HIGH so ST7789V power rail stabilizes before SPI commands
(without this, SWRESET is sent to an unpowered chip and silently lost).

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:11:51 -05:00
torlando-techandClaude Opus 4.6 0608af6d38 Unify all SPI peripherals on global FSPI to fix pin conflicts
Display and LoRa were creating separate SPIClass(HSPI) instances which
claimed GPIO pins via the matrix, preventing SD card (on FSPI) from
accessing MISO after Display init. Now all three peripherals use the
global SPI (FSPI) instance, eliminating GPIO routing conflicts.

- Display: use &SPI instead of new SPIClass(HSPI)
- SX1262Interface: use &SPI instead of new SPIClass(HSPI)
- SDAccess: enable format_if_empty for unformatted cards

Verified on device: SD (128GB SDHC), display, and LoRa all coexist.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:19:10 -05:00
TorlandoandGitHub d9411fb4bb Merge pull request #7 from torlando-tech/ble-stability-audit
BLE stability: fix desync crash loops and scan recovery
2026-03-03 23:39:38 -05:00
torlando-techandClaude Opus 4.6 4f22776971 Add tone as explicit dependency of tdeck_ui
UIManager.cpp includes Tone.h, so tdeck_ui should declare this
dependency rather than relying on implicit global discovery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:05:42 -05:00
torlando-techandClaude Opus 4.6 46ce057a1e BLE stability: host-controller resync, stuck GAP conn cancel, scan diagnostics
After a 574 connection failure, the NimBLE controller's scan state can
become corrupted (returning rc=530 / Invalid HCI Params) even after the
host re-syncs. This led to scan failure escalation and device reboots.

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:57:55 -05:00