test: on-device LXMF e2e harness with bz2-on-receive probe

tests/hardware/ drives a flashed T-Deck through LXMF round-trips against a
Mac-side echo bot over the device's TCP-client -> rnsd link, via the firmware's
-DPYXIS_TEST_HOOKS T: command surface.

- run_e2e.sh: orchestrator -- autodetect serial port + Mac IP, verify rnsd on
  :4242, build+flash with the TCP target baked in, run the harness, assert.
- tdeck_harness.py: resets the device, establishes path/identity with the bot,
  then runs a bz2-on-receive probe + DIRECT/OPPORTUNISTIC (and PROPAGATED when
  PYXIS_PROPAGATION_NODE_HEX is set) round-trips, watching for crash signatures.
- lxmf_echo_bot.py: Mac-side LXMF echo bot; on a BZ2PROBE trigger it replies
  with a ~1.5KB highly-compressible payload so python LXMF sends a bz2-COMPRESSED
  Resource, exercising the receiver's decompress-on-receive path.

Parametrized -- serial port, Mac IP, and propagation-node hash all come from the
environment; nothing deployment-specific is committed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
This commit is contained in:
torlando-agent[bot]
2026-06-19 15:49:44 -04:00
co-authored by Claude Opus 4.8
parent 70d4aa6be9
commit 092fbedc44
3 changed files with 1044 additions and 0 deletions
+331
View File
@@ -0,0 +1,331 @@
#!/usr/bin/env python3
"""
Mac-side LXMF echo bot for diagnosing pyxis T-Deck delivery issues.
Joins the same RNS network as the T-Deck via AutoInterface (link-local
IPv6 multicast). Announces an identity called "Mac Echo Bot" and echoes
any received DIRECT message back to the sender. Every announce send,
announce receive, message receive, and message send is logged with a
timestamp.
Usage:
pip install rns lxmf
python3 /tmp/lxmf_echobot.py
Expected output when working:
[12:00:00.123] Reticulum up. Identity hash=<hex>. Delivery dest hash=<hex>.
[12:00:00.234] Sending announce
[12:00:01.456] Received announce from <hex8>: Some Peer
[12:00:05.789] Received DIRECT message from <hex8>: 'hello'
[12:00:05.890] Echoing back: 'echo: hello'
[12:00:06.012] Echo SENT (state=delivered)
"""
import os, sys, time, threading, datetime
# Pin to a local checkout of RNS + LXMF if one exists, so the bot
# uses the same library pyxis interops with rather than whatever
# pip points at. Override with RNS_REPO / LXMF_REPO env vars.
for env_var, default_subdir in (("RNS_REPO", "repos/Reticulum"),
("LXMF_REPO", "repos/LXMF")):
p = os.environ.get(env_var) \
or os.path.expanduser(os.path.join("~", default_subdir))
if os.path.isdir(p):
sys.path.insert(0, p)
import RNS
import LXMF
def ts():
return datetime.datetime.now().strftime("%H:%M:%S.%f")[:-3]
def log(msg):
print(f"[{ts()}] {msg}", flush=True)
CONFIG_DIR = "/tmp/echobot-rnsconfig"
STORAGE_DIR = "/tmp/echobot-storage"
DISPLAY_NAME = "Mac Echo Bot"
# Optional: when the harness launches the bot, it sets ECHOBOT_PEER_HEX
# to pyxis's delivery destination hash. We use that to proactively
# request a path / cached-announce so we have pyxis's identity in
# `Identity.recall` cache before the harness drives messages — otherwise
# we depend on rnsd's announce rebroadcast and miss it if rnsd's
# per-announce rebroadcast limit was already reached.
PEER_HEX = os.environ.get("ECHOBOT_PEER_HEX", "").strip()
# lxmd propagation node the bot routes PROPAGATED messages through.
# Deployment-specific, so it comes from PYXIS_PROPAGATION_NODE_HEX (no
# hardcoded default — keeps environment-specific hashes out of source).
# When unset, the bot skips all propagation setup and the harness skips
# its PROPAGATED rounds; DIRECT + OPPORTUNISTIC + the bz2 probe still run.
PROPAGATION_NODE_HEX = os.environ.get("PYXIS_PROPAGATION_NODE_HEX", "").strip()
PROPAGATION_STAMP_COST = 16 # lxmd default is 16; 13 is the floor.
# How often to pull queued messages from the PN. The bot's not running
# any UI, so it relies on this loop to actually receive PROPAGATED
# messages.
PROP_SYNC_INTERVAL_SEC = 8
os.makedirs(CONFIG_DIR, exist_ok=True)
os.makedirs(STORAGE_DIR, exist_ok=True)
# Minimal RNS config: TCPClient to the local rnsd at 127.0.0.1:4242.
# Pyxis on the T-Deck connects as a TCP CLIENT to the same rnsd's
# TCPServerInterface (built into rnsd's default config) so this bot
# and pyxis end up on the same Reticulum network with the rnsd acting
# as a hub.
#
# We use an isolated config dir / non-default control ports so this
# bot does NOT accidentally join the user's primary rnsd shared
# instance (which would mix the bot's identity into their personal
# Reticulum state). The bot is its own RNS process.
config_path = os.path.join(CONFIG_DIR, "config")
with open(config_path, "w") as f:
f.write("""\
[reticulum]
enable_transport = Yes
share_instance = No
shared_instance_port = 47428
instance_control_port = 47429
[logging]
loglevel = 4
[interfaces]
[[TCP to local rnsd]]
type = TCPClientInterface
enabled = yes
target_host = 127.0.0.1
target_port = 4242
""")
reticulum = RNS.Reticulum(configdir=CONFIG_DIR, loglevel=4)
# Persistent identity stored in the config dir so reruns reuse the
# same delivery destination hash (peers don't have to re-learn the
# path on every restart).
ident_path = os.path.join(CONFIG_DIR, "identity")
if os.path.exists(ident_path):
identity = RNS.Identity.from_file(ident_path)
log(f"Loaded identity from {ident_path}")
else:
identity = RNS.Identity()
identity.to_file(ident_path)
log(f"Created new identity, saved to {ident_path}")
router = LXMF.LXMRouter(identity=identity, storagepath=STORAGE_DIR)
delivery_destination = router.register_delivery_identity(
identity, display_name=DISPLAY_NAME
)
delivery_destination.set_default_app_data(
lambda: router.get_announce_app_data(delivery_destination.hash)
)
log(f"Reticulum up. Identity hash={identity.hash.hex()}.")
log(f"Delivery dest hash={delivery_destination.hash.hex()}.")
log(f"Display name: {DISPLAY_NAME}")
# Configure propagation node so the bot can SEND propagated and SYNC
# its inbox. We have to wait for the PN to be path-known before
# `set_outbound_propagation_node` is useful; the path arrives via
# the rnsd<->lxmd shared instance. Set it right away anyway —
# request_messages_from_propagation_node tolerates "no path yet"
# and retries the path request internally.
prop_node_hash = bytes.fromhex(PROPAGATION_NODE_HEX) if PROPAGATION_NODE_HEX else None
if prop_node_hash is not None:
router.set_outbound_propagation_node(prop_node_hash)
router.outbound_propagation_node = prop_node_hash
log(f"Configured outbound propagation node: {PROPAGATION_NODE_HEX}")
else:
log("No PYXIS_PROPAGATION_NODE_HEX set — propagation disabled (DIRECT/OPP/bz2 only)")
def on_announce(destination_hash, announced_identity, app_data):
name = LXMF.display_name_from_app_data(app_data) or "(no name)"
log(f"Announce RX: dest={destination_hash.hex()} name={name!r}")
announce_handler = type(
"Handler", (), {
"aspect_filter": "lxmf.delivery",
# Without `receive_path_responses = True`, RNS only fires this
# handler for LIVE announces (received via broadcast/forward).
# Path responses (the cached announce sent back by a next-hop
# in reply to RNS.Transport.request_path) are skipped. The
# harness's eager-path-acquirer relies on path responses to
# learn pyxis's identity quickly, so we opt in here.
"receive_path_responses": True,
"received_announce": staticmethod(on_announce),
}
)
RNS.Transport.register_announce_handler(announce_handler)
def on_delivery(message):
src_hex = message.source_hash.hex() if message.source_hash else "?"
method = {
LXMF.LXMessage.OPPORTUNISTIC: "OPPORTUNISTIC",
LXMF.LXMessage.DIRECT: "DIRECT",
LXMF.LXMessage.PROPAGATED: "PROPAGATED",
}.get(message.method, str(message.method))
try:
content = message.content.decode("utf-8")
except Exception:
content = repr(message.content)
title = ""
try:
if message.title:
title = message.title.decode("utf-8")
except Exception:
title = repr(message.title)
log(
f"Message RX: from={src_hex} method={method} "
f"title={title!r} content={content!r}"
)
# Echo back. Mirror the sender's method so PROPAGATED messages
# echo via the PN (round-trip: sender uploads → PN persists → bot
# syncs down → bot replies via PN → sender syncs down) and DIRECT
# messages echo over a fresh link.
try:
# Recall the sender's identity so we can construct a destination.
sender_identity = RNS.Identity.recall(message.source_hash)
if sender_identity is None:
log(f" Cannot echo: sender identity not yet known for {src_hex}")
return
dest = RNS.Destination(
sender_identity, RNS.Destination.OUT, RNS.Destination.SINGLE,
"lxmf", "delivery"
)
echo_method = (
LXMF.LXMessage.PROPAGATED
if message.method == LXMF.LXMessage.PROPAGATED
else LXMF.LXMessage.DIRECT
)
# bz2-on-receive probe: when the harness sends "BZ2PROBE", reply with
# a large, highly-compressible payload. It exceeds microLXMF's Resource
# threshold AND compresses ~99%, so python LXMF sends it as a bz2-
# compressed Resource (auto_compress, since microLXMF announces
# compression-capable). That forces pyxis's Resource::assemble() to
# bz2_decompress on receive — the path that, unpatched upstream, marks
# the resource CORRUPT and tears down the link. If pyxis surfaces the
# full payload via T:RX, decompress-on-receive works on hardware.
if content.startswith("BZ2PROBE"):
echo_body = "BZ2OK:" + ("ABCDABCDABCDABCD" * 96) # ~1542 bytes, repeating
else:
echo_body = f"echo: {content}"
echo_kwargs = dict(
destination=dest,
source=delivery_destination,
content=echo_body.encode("utf-8"),
title=b"echo",
desired_method=echo_method,
)
if echo_method == LXMF.LXMessage.PROPAGATED:
# python LXMF requires `include_ticket=False` here so the
# outbound propagation flow doesn't trip on missing ticket
# state. The default is fine but spelled out for clarity.
echo_kwargs["include_ticket"] = False
echo = LXMF.LXMessage(**echo_kwargs)
def on_delivered(msg):
log(f" Echo DELIVERED to {src_hex}")
def on_failed(msg):
log(f" Echo FAILED to {src_hex} state={msg.state}")
def on_sent(msg):
log(f" Echo SENT (PRF received) to {src_hex}")
echo.register_delivery_callback(on_delivered)
echo.register_failed_callback(on_failed)
# `sent` only fires on PROPAGATED in upstream LXMF; we still set it
# for parity with the `delivery` callback nomenclature.
try:
echo.register_sent_callback(on_sent)
except Exception:
pass
router.handle_outbound(echo)
log(f" Echo queued for delivery to {src_hex}")
except Exception as e:
log(f" Echo construction failed: {e}")
router.register_delivery_callback(on_delivery)
# Announce loop — every 30s so the T-Deck hears us multiple times.
def announcer():
while True:
delivery_destination.announce()
log("Announce TX")
time.sleep(30)
# Propagation sync loop — periodically pull queued messages from the PN.
# Without this the bot would never receive PROPAGATED messages (no UI
# tap to drive a manual sync; the LXMRouter only auto-syncs when its
# `delivery_destination` has someone tapping `sync_inbound`).
#
# lxmd's default propagation announce_interval is 5 MINUTES (the value
# in the config is multiplied by 60), so waiting passively for an
# announce can stall the first sync several minutes. We proactively
# `RNS.Transport.request_path()` until we get a path.
def prop_syncer():
while not RNS.Transport.has_path(prop_node_hash):
log(" Requesting path to PN...")
RNS.Transport.request_path(prop_node_hash)
for _ in range(10):
if RNS.Transport.has_path(prop_node_hash):
break
time.sleep(0.5)
log(f"Path to PN known; starting periodic sync every {PROP_SYNC_INTERVAL_SEC}s")
while True:
try:
router.request_messages_from_propagation_node(identity)
except Exception as e:
log(f" prop sync error: {e}")
time.sleep(PROP_SYNC_INTERVAL_SEC)
threading.Thread(target=announcer, daemon=True).start()
if prop_node_hash is not None:
threading.Thread(target=prop_syncer, daemon=True).start()
# Eager peer-path acquisition. When the harness tells us pyxis's hash
# via env, request a path immediately. RNS forwards the cached announce
# from the next-hop, which fires our announce-handler and populates
# Identity.recall_app_data. Without this, if the bot connects AFTER
# rnsd hit its rebroadcast limit for the peer's announce, we never
# learn pyxis's identity and can't echo PROPAGATED messages.
if PEER_HEX:
def peer_path_acquirer():
try:
peer_hash = bytes.fromhex(PEER_HEX)
except Exception as e:
log(f" Bad ECHOBOT_PEER_HEX={PEER_HEX!r}: {e}")
return
for _ in range(10):
if RNS.Transport.has_path(peer_hash):
log(f"Path to peer {PEER_HEX} known via cached announce")
return
log(f" Requesting path to peer {PEER_HEX}...")
RNS.Transport.request_path(peer_hash)
for _ in range(20):
if RNS.Transport.has_path(peer_hash):
log(f"Path to peer {PEER_HEX} known via cached announce")
return
time.sleep(0.5)
log(f" WARN: never got path to peer {PEER_HEX}")
threading.Thread(target=peer_path_acquirer, daemon=True).start()
log(f"Echo bot ready. Press Ctrl-C to stop. Storage: {STORAGE_DIR}")
try:
while True:
time.sleep(60)
except KeyboardInterrupt:
log("Shutting down.")
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
#
# Full end-to-end on-device test for pyxis: build + flash a T-Deck, bring up a
# Mac-side LXMF echo bot over the local rnsd, and drive LXMF round-trips
# (DIRECT / OPPORTUNISTIC / optional PROPAGATED) plus a bz2-on-receive probe
# via the firmware's `T:` serial command surface (-DPYXIS_TEST_HOOKS).
#
# All environment-specific values are read from the environment (nothing
# deployment-specific is committed):
# PYXIS_TEST_TCP_HOST Mac IP the T-Deck dials for rnsd (default: en0 IPv4)
# PYXIS_TEST_TCP_PORT rnsd TCPServerInterface port (default: 4242)
# PYXIS_SERIAL_PORT T-Deck USB serial (default: first /dev/cu.usbmodem*)
# PYXIS_ENV platformio env (default: tdeck)
# PYXIS_PROPAGATION_NODE_HEX lxmd PN hash (optional; enables PROPAGATED rounds)
#
# Usage:
# tests/hardware/run_e2e.sh # build, flash, one pass + bz2 probe
# tests/hardware/run_e2e.sh --soak-hours 1 # ... then soak round-trips for 1h
# PYXIS_SKIP_FLASH=1 tests/hardware/run_e2e.sh # reuse already-flashed fw
#
# Exits 0 only if every round (incl. the bz2 probe) passed and no crash fired.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO="$(cd "$HERE/../.." && pwd)"
SOAK_HOURS="0"
[ "${1:-}" = "--soak-hours" ] && SOAK_HOURS="${2:-0}"
PYXIS_ENV="${PYXIS_ENV:-tdeck}"
PYXIS_TEST_TCP_PORT="${PYXIS_TEST_TCP_PORT:-4242}"
PYXIS_TEST_TCP_HOST="${PYXIS_TEST_TCP_HOST:-$(ipconfig getifaddr en0 2>/dev/null || true)}"
PYXIS_SERIAL_PORT="${PYXIS_SERIAL_PORT:-$(ls /dev/cu.usbmodem* 2>/dev/null | head -1 || true)}"
PIO="$(command -v pio || echo /opt/homebrew/bin/pio)"
# The harness needs pyserial; PlatformIO's bundled python has it. Prefer an
# explicit PYXIS_HARNESS_PY, else the penv python, else any python with serial.
PIO_PY="${PYXIS_HARNESS_PY:-}"
[ -z "$PIO_PY" ] && PIO_PY="$(ls "$HOME"/.platformio/penv/bin/python3 2>/dev/null | head -1 || true)"
[ -z "$PIO_PY" ] && for c in /opt/homebrew/Cellar/platformio/*/libexec/bin/python3; do [ -x "$c" ] && PIO_PY="$c" && break; done
echo "== pyxis on-device e2e =="
echo " repo: $REPO"
echo " env: $PYXIS_ENV"
echo " serial: ${PYXIS_SERIAL_PORT:-<none found>}"
echo " rnsd target: ${PYXIS_TEST_TCP_HOST:-<unset>}:$PYXIS_TEST_TCP_PORT"
echo " prop node: ${PYXIS_PROPAGATION_NODE_HEX:-<unset — PROPAGATED skipped>}"
echo " soak hours: $SOAK_HOURS"
echo " harness py: ${PIO_PY:-<not found>}"
[ -z "${PYXIS_SERIAL_PORT:-}" ] && { echo "ERROR: no T-Deck serial port found (set PYXIS_SERIAL_PORT)"; exit 2; }
[ -z "${PYXIS_TEST_TCP_HOST:-}" ] && { echo "ERROR: could not determine Mac IP (set PYXIS_TEST_TCP_HOST)"; exit 2; }
# rnsd must be listening so both the T-Deck (LAN) and the bot (127.0.0.1) can
# attach to the same Reticulum hub.
if ! lsof -nP -iTCP:"$PYXIS_TEST_TCP_PORT" -sTCP:LISTEN >/dev/null 2>&1; then
echo "ERROR: nothing listening on TCP:$PYXIS_TEST_TCP_PORT — start rnsd with a"
echo " TCPServerInterface on that port first (e.g. \`rnsd\`)."
exit 2
fi
export PYXIS_TEST_TCP_HOST PYXIS_TEST_TCP_PORT PYXIS_SERIAL_PORT PYXIS_PROPAGATION_NODE_HEX
if [ "${PYXIS_SKIP_FLASH:-0}" != "1" ]; then
echo "== build + flash ($PYXIS_ENV) with TCP target baked in =="
"$PIO" run -e "$PYXIS_ENV" -t upload --upload-port "$PYXIS_SERIAL_PORT"
fi
echo "== running harness (resets device, drives T: commands) =="
exec "$PIO_PY" "$HERE/tdeck_harness.py" --soak-hours "$SOAK_HOURS"
+645
View File
@@ -0,0 +1,645 @@
#!/usr/bin/env python3
"""
T-Deck ↔ Mac echo-bot integration harness.
This script drives a pyxis-built T-Deck firmware (with -DPYXIS_TEST_HOOKS)
and a Mac-side LXMF echo bot together, exercising LXMF DIRECT delivery
in both directions over the local rnsd's TCPServerInterface
(host:port configured at firmware-build-time via PYXIS_TEST_TCP_HOST /
PYXIS_TEST_TCP_PORT in platformio.ini).
What it does:
1. Open the T-Deck serial port (/dev/cu.usbmodem1101) and reset the device.
Stream every line into a captured log; parse `T:OK` / `T:ERR` /
`T:RXMSG` / `T:PATH` lines for the harness, pass everything else
through to the log.
2. Wait for pyxis boot: `BOOT END: ui_manager` line, then a TCP
connect-success log to confirm the rnsd link is up.
3. Start the echo bot AFTER pyxis is fully up (subprocess) so its
announce arrives while pyxis is listening. Bot uses the same rnsd
the T-Deck talks to (shared instance via shared_instance_port).
4. Wait for pyxis to learn the bot's path (`T:HASPATH <bot_dest>`
returns `T:OK 1`), with bot manually re-announcing if needed.
5. Test loop, repeating until either a failure or the soak deadline:
- Send short message from pyxis to bot via `T:SEND`.
- Wait for bot to log RX + ECHO SENT.
- Wait for pyxis to log RX (poll `T:RX`).
- Verify content matches `echo: <orig>`.
- Send long (~1KB) message and repeat.
- Sleep `--cadence` seconds between rounds.
6. Soak: run for at least `--soak-hours` hours (default 1.1). Crash =
fail. Mid-test failures are logged but don't abort.
Usage:
# Run with a python that has pyserial available. PlatformIO's
# bundled python works:
"$(pio system info | awk -F: '/Python Executable/{print $2}' | xargs)" \\
tests/soak/tdeck_soak_harness.py [--soak-hours 1.1] [--cadence 30]
Outputs:
/tmp/tdeck-harness.log — combined timestamped event log
/tmp/tdeck-harness-tdeck.log — verbatim serial output from T-Deck
/tmp/echobot.log — echo bot's own log (subprocess stdout)
Exits 0 on full success, 1 on failure.
"""
import os, sys, time, threading, subprocess, argparse, queue, signal
import datetime
# pyserial: try the active python first, fall back to PlatformIO's
# bundled site-packages if the user runs us through system python.
try:
import serial # noqa: F401
except ImportError:
pio_site = os.environ.get("PIO_SITE_PACKAGES")
if pio_site and os.path.isdir(pio_site):
sys.path.insert(0, pio_site)
import serial # second try; let it raise if still missing
# Default serial port for a USB-attached T-Deck Plus on macOS. Override
# with PYXIS_SERIAL_PORT (eg "/dev/ttyUSB0" on Linux).
PORT = os.environ.get("PYXIS_SERIAL_PORT", "/dev/cu.usbmodem1101")
BAUD = 115200
HARNESS_LOG = "/tmp/tdeck-harness.log"
TDECK_LOG = "/tmp/tdeck-harness-tdeck.log"
ECHOBOT_LOG = "/tmp/echobot.log"
ECHOBOT_PY = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"lxmf_echo_bot.py")
def ts():
return datetime.datetime.now().strftime("%H:%M:%S.%f")[:-3]
_log_lock = threading.Lock()
_log_fh = None
def log(category, msg):
line = f"[{ts()}] [{category}] {msg}\n"
with _log_lock:
sys.stdout.write(line)
sys.stdout.flush()
if _log_fh is not None:
_log_fh.write(line)
_log_fh.flush()
class TDeck:
"""Serial driver: reset, command, parse response."""
def __init__(self, port=PORT, baud=BAUD):
self.ser = serial.Serial(port, baud, timeout=0.1)
self._line_q = queue.Queue() # raw text lines
self._tdeck_log = open(TDECK_LOG, "wb")
self._stop = threading.Event()
self._reader = threading.Thread(target=self._read_loop, daemon=True)
self._reader.start()
def reset(self):
"""Pulse DTR/RTS like esptool to reboot the ESP32 cleanly."""
log("HARNESS", "Resetting T-Deck via DTR/RTS pulse")
self.ser.dtr = False
self.ser.rts = True
time.sleep(0.1)
self.ser.dtr = True
self.ser.rts = False
time.sleep(0.1)
self.ser.dtr = False
self.ser.rts = False
def _read_loop(self):
buf = b""
while not self._stop.is_set():
try:
d = self.ser.read(4096)
except Exception as e:
log("HARNESS", f"Serial read error: {e}")
return
if not d:
continue
self._tdeck_log.write(d)
self._tdeck_log.flush()
buf += d
while b"\n" in buf:
line, buf = buf.split(b"\n", 1)
try:
text = line.rstrip(b"\r").decode("utf-8", errors="replace")
except Exception:
text = repr(line)
self._line_q.put(text)
def drain_lines(self):
"""Pop all currently-buffered lines (non-blocking)."""
out = []
while True:
try:
out.append(self._line_q.get_nowait())
except queue.Empty:
break
return out
def wait_for_line(self, predicate, timeout):
"""Block until a line matches predicate(text) or timeout."""
deadline = time.time() + timeout
while time.time() < deadline:
try:
line = self._line_q.get(timeout=min(1.0, deadline - time.time()))
except queue.Empty:
continue
if predicate(line):
return line
return None
def send_command(self, cmd, response_timeout=10.0):
"""Send a `T:` command, wait for the next `T:OK` or `T:ERR` line.
Returns the response line (without the `T:OK` / `T:ERR` prefix
portion left intact for inspection), or None on timeout.
"""
log("HARNESS-TX", cmd)
# Drain any stale T: responses queued up from earlier
deadline = time.time() + response_timeout
# Send
self.ser.write((cmd + "\n").encode("utf-8"))
self.ser.flush()
# Read until we see a T:OK or T:ERR
while time.time() < deadline:
try:
line = self._line_q.get(timeout=min(1.0, deadline - time.time()))
except queue.Empty:
continue
if line.startswith("T:OK") or line.startswith("T:ERR"):
log("HARNESS-RX", line)
return line
log("HARNESS", f"send_command({cmd!r}) timed out")
return None
def close(self):
self._stop.set()
self.ser.close()
self._tdeck_log.close()
def wait_for_pyxis_boot(t, timeout=90):
log("HARNESS", "Waiting for pyxis boot to complete...")
line = t.wait_for_line(
lambda L: "BOOT" in L and "ui_manager" in L and "END" in L,
timeout,
)
if not line:
return False
log("HARNESS", f"Boot complete: {line}")
return True
def wait_for_tcp_link(t, timeout=60):
log("HARNESS", "Waiting for TCP interface to connect to rnsd...")
line = t.wait_for_line(
lambda L: "TCPClientInterface" in L and (
"connected" in L.lower() or "Connected" in L or "started" in L.lower()
),
timeout,
)
if line:
log("HARNESS", f"TCP link up: {line}")
else:
log("HARNESS", "(TCP-link line not seen — continuing anyway, "
"pyxis may have already attached.)")
return line is not None
def start_echobot(peer_hex=""):
log("HARNESS", f"Launching echo bot (peer_hex={peer_hex})...")
fh = open(ECHOBOT_LOG, "w")
env = dict(os.environ)
if peer_hex:
# The bot uses this to proactively request_path() so it picks up
# pyxis's cached announce from rnsd, even if rnsd's announce
# rebroadcast limit was already reached when the bot connected.
env["ECHOBOT_PEER_HEX"] = peer_hex
proc = subprocess.Popen(
["/usr/bin/python3", ECHOBOT_PY],
stdout=fh, stderr=subprocess.STDOUT,
env=env,
)
return proc, fh
def echobot_announce_dest(echobot_log_path, timeout=20):
"""Read the echobot's own log to extract its delivery destination hash."""
deadline = time.time() + timeout
last = ""
while time.time() < deadline:
try:
with open(echobot_log_path) as f:
last = f.read()
except FileNotFoundError:
time.sleep(0.5)
continue
for line in last.splitlines():
# `Delivery dest hash=<hex>.`
if "Delivery dest hash=" in line:
hex_part = line.split("Delivery dest hash=", 1)[1].rstrip(".").strip()
# may have trailing punctuation
hex_part = hex_part.split()[0].rstrip(".")
return hex_part
time.sleep(0.5)
return None
def wait_for_path(t, dest_hex, timeout=120):
log("HARNESS", f"Polling pyxis for path to bot {dest_hex}...")
deadline = time.time() + timeout
last_paths_dump = 0
while time.time() < deadline:
resp = t.send_command(f"T:HASPATH {dest_hex}", response_timeout=5)
if resp and resp.startswith("T:OK 1"):
log("HARNESS", "Pyxis has path to bot.")
return True
# Every 20s, dump T:PATHS to see what pyxis DOES know about
if time.time() - last_paths_dump > 20:
last_paths_dump = time.time()
t.ser.write(b"T:PATHS\n")
t.ser.flush()
ok = t.wait_for_line(lambda L: L.startswith("T:OK count="), timeout=5)
if ok:
log("HARNESS", f"T:PATHS {ok}")
# Drain the T:PATH lines that follow
try:
count = int(ok.split("=", 1)[1])
except Exception:
count = 0
for _ in range(count):
p = t.wait_for_line(lambda L: L.startswith("T:PATH "), timeout=2)
if p:
log("HARNESS", f" {p}")
time.sleep(2.0)
return False
def echobot_log_after(marker, predicate, timeout=30):
"""Wait until the echobot log has a line matching predicate appearing
after `marker` (a substring known to be present already, used to
avoid matching old log entries from a previous round)."""
deadline = time.time() + timeout
last_size = 0
while time.time() < deadline:
try:
with open(ECHOBOT_LOG) as f:
txt = f.read()
except FileNotFoundError:
time.sleep(0.5)
continue
if marker:
mi = txt.find(marker)
if mi < 0:
time.sleep(0.5)
continue
txt = txt[mi + len(marker):]
for line in txt.splitlines():
if predicate(line):
return line
time.sleep(0.5)
return None
def poll_tdeck_rx(t, expected_substring, timeout=60):
"""Poll T:RX until a received message contains expected_substring."""
deadline = time.time() + timeout
while time.time() < deadline:
# T:RX returns multiple lines; we have to read more than one
log("HARNESS-TX", "T:RX")
t.ser.write(b"T:RX\n")
t.ser.flush()
# Collect: T:OK count=N then N T:RXMSG lines
ok_line = t.wait_for_line(
lambda L: L.startswith("T:OK count=") or L.startswith("T:ERR"),
timeout=5,
)
if not ok_line:
time.sleep(2.0)
continue
try:
count = int(ok_line.split("=", 1)[1])
except Exception:
count = 0
rx_msgs = []
for _ in range(count):
line = t.wait_for_line(lambda L: L.startswith("T:RXMSG"), timeout=2)
if line:
rx_msgs.append(line)
for line in rx_msgs:
if expected_substring in line:
log("HARNESS", f"Pyxis RX confirmed: {line}")
return line
time.sleep(2.0)
return None
def main():
global _log_fh
parser = argparse.ArgumentParser()
parser.add_argument("--soak-hours", type=float, default=0.0,
help="0 = one pass of each method + the bz2 probe (default); "
">0 loops the round-trip tests for a soak")
parser.add_argument("--cadence", type=float, default=30,
help="seconds between message rounds")
parser.add_argument("--no-reset", action="store_true",
help="don't pulse DTR (use if pyxis is already booted)")
args = parser.parse_args()
_log_fh = open(HARNESS_LOG, "w")
log("HARNESS", f"Harness starting; soak target = {args.soak_hours} hours")
# 1. Open T-Deck serial, reset, wait for boot
t = TDeck()
if not args.no_reset:
t.reset()
if not wait_for_pyxis_boot(t, timeout=90):
log("HARNESS", "FAILED: pyxis boot did not complete in 90s")
return 1
wait_for_tcp_link(t, timeout=30)
time.sleep(2.0)
pyxis_dest_resp = t.send_command("T:DEST", response_timeout=5)
if not pyxis_dest_resp or not pyxis_dest_resp.startswith("T:OK"):
log("HARNESS", f"FAILED: T:DEST returned {pyxis_dest_resp}")
return 1
pyxis_dest = pyxis_dest_resp.split(" ", 1)[1].strip()
log("HARNESS", f"Pyxis delivery dest = {pyxis_dest}")
# 2. Start echo bot AFTER pyxis is up. Pass pyxis's destination
# hash so the bot can proactively request_path() and pick up
# the cached announce from rnsd.
bot, bot_fh = start_echobot(peer_hex=pyxis_dest)
bot_dest = echobot_announce_dest(ECHOBOT_LOG, timeout=20)
if not bot_dest:
log("HARNESS", "FAILED: could not extract bot destination hash")
bot.terminate()
return 1
log("HARNESS", f"Echo bot delivery dest = {bot_dest}")
# 3. Wait for pyxis to learn the bot's path
if not wait_for_path(t, bot_dest, timeout=120):
log("HARNESS", "FAILED: pyxis never learned bot's path")
bot.terminate()
return 1
# 3b. Drive pyxis to announce so the bot learns pyxis's identity
# for echo construction. Two ways the bot can end up with the
# identity: (a) live announce delivered through rnsd while the bot
# is connected — fires our announce_handler and we log "Announce
# RX" — or (b) a path-response from rnsd's cache when the bot
# calls RNS.Transport.request_path (which the bot does eagerly
# for ECHOBOT_PEER_HEX). Path (b) populates Identity.recall but
# doesn't always fire the announce_handler (path-response
# delivery is gated on `receive_path_responses` and hits a thread
# boundary). So treat "bot has pyxis path" OR "bot logged Announce
# RX" as either-or success. Drive a few T:ANN cycles to give path
# (a) a chance and not just rely on the eager request_path.
log("HARNESS", "Driving pyxis to announce, "
"waiting for bot to learn pyxis's identity...")
bot_saw_pyxis = False
for attempt in range(6):
t.send_command("T:ANN", response_timeout=5)
# Either: bot's announce_handler fired (live announce) OR
# bot's eager request_path landed (path-response → cached
# announce → Identity.remember). The latter logs "Path to
# peer <hex> known via cached announce" via the
# peer_path_acquirer thread.
deadline = time.time() + 10
while time.time() < deadline:
try:
with open(ECHOBOT_LOG) as f:
txt = f.read()
except FileNotFoundError:
txt = ""
if (("Announce RX:" in txt and pyxis_dest in txt)
or f"Path to peer {pyxis_dest} known" in txt):
bot_saw_pyxis = True
break
time.sleep(0.5)
if bot_saw_pyxis:
log("HARNESS", "Bot has pyxis identity (via announce or path-response)")
break
log("HARNESS", f" attempt {attempt+1}: bot not yet — re-announce")
if not bot_saw_pyxis:
log("HARNESS", "FAILED: bot never learned pyxis's identity — "
"echoes will fail")
bot.terminate()
return 1
fails = 0
successes = 0
# 4. bz2-on-receive probe — the graft's riskiest new code. Send a short
# "BZ2PROBE" trigger (fits the USB-CDC single-line limit); the echo bot
# replies with a ~1.5KB highly-compressible payload. python LXMF sends
# that as a bz2-COMPRESSED Resource (auto_compress — microLXMF announces
# compression-capable). pyxis must bz2_decompress it in
# Resource::assemble() and surface the full body via T:RX. Unpatched
# upstream marks a compressed inbound resource CORRUPT and tears down
# the link, so a clean decompressed receive here proves the graft's
# decompress-on-receive port works on real hardware.
log("HARNESS", "=== bz2-on-receive probe ===")
with open(ECHOBOT_LOG) as f:
probe_marker = f.read()[-256:]
probe_resp = t.send_command(f"T:SEND {bot_dest} BZ2PROBE", response_timeout=10)
if probe_resp and probe_resp.startswith("T:OK"):
bot_rx = echobot_log_after(
probe_marker,
lambda L: "Message RX:" in L and "BZ2PROBE" in L,
timeout=60,
)
if not bot_rx:
log("HARNESS", "FAIL [bz2-probe]: bot never received BZ2PROBE trigger")
fails += 1
else:
rx = poll_tdeck_rx(t, "BZ2OK:", timeout=90)
if rx and len(rx) > 800:
log("HARNESS", f"PASS [bz2-probe]: pyxis received + decompressed "
f"compressed Resource ({len(rx)}-char T:RXMSG line)")
successes += 1
else:
log("HARNESS", f"FAIL [bz2-probe]: no decompressed payload surfaced "
f"(got {rx!r}) — check for link teardown / CORRUPT")
fails += 1
t.send_command("T:RXCLR", response_timeout=5)
else:
log("HARNESS", f"FAIL [bz2-probe]: T:SEND BZ2PROBE returned {probe_resp!r}")
fails += 1
# 5. Round-trip tests. DIRECT + OPPORTUNISTIC always run; PROPAGATED runs
# only when PYXIS_PROPAGATION_NODE_HEX is set (deployment-specific hash
# kept out of source). Runs one pass minimum, then loops for --soak-hours.
deadline = time.time() + args.soak_hours * 3600
round_n = 0
payloads = [
("direct-short", "T:SEND", "hi t-deck"),
("direct-medium", "T:SEND", "this is a 100-char-ish payload to verify pyxis can frame a 1-packet LXMF message cleanly y"),
("opportunistic-short", "T:SENDOPP", "opp t-deck"),
("opportunistic-medium", "T:SENDOPP", "opp 100-char payload should fit a single LXMF packet without any link establishment xx"),
]
propagation_node_hex = os.environ.get("PYXIS_PROPAGATION_NODE_HEX", "").strip()
if propagation_node_hex:
log("HARNESS", f"Configuring propagation node on pyxis: {propagation_node_hex}")
set_resp = t.send_command(f"T:SETPROP {propagation_node_hex} 16", response_timeout=5)
if not set_resp or not set_resp.startswith("T:OK"):
log("HARNESS", f"WARN: T:SETPROP failed: {set_resp!r}")
if not wait_for_path(t, propagation_node_hex, timeout=60):
log("HARNESS", "WARN: pyxis never learned PN path; propagation rounds will fail")
else:
log("HARNESS", "Waiting for PN identity to land in recall cache...")
identity_deadline = time.time() + 60
while time.time() < identity_deadline:
r = t.send_command(f"T:RECALL {propagation_node_hex}", response_timeout=5)
if r and "size=" in r and int(r.split("size=", 1)[1].split()[0]) > 0:
log("HARNESS", f"PN identity known: {r}")
break
time.sleep(2.0)
payloads.append(("propagation-short", "T:SENDPROP", "prop t-deck"))
else:
log("HARNESS", "No PYXIS_PROPAGATION_NODE_HEX set — skipping PROPAGATED rounds")
first_pass = True
while first_pass or time.time() < deadline:
first_pass = False
round_n += 1
for label, send_cmd, content in payloads:
log("HARNESS", f"=== Round {round_n} / {label} ===")
# Mark current echobot log so we only look at lines after this point
with open(ECHOBOT_LOG) as f:
marker_text = f.read()
marker = marker_text[-256:] if len(marker_text) > 256 else marker_text
# 4a. T-Deck → Bot
send_resp = t.send_command(f"{send_cmd} {bot_dest} {content}", response_timeout=10)
if not send_resp or not send_resp.startswith("T:OK"):
log("HARNESS", f"FAIL [{label}]: T:SEND returned {send_resp}")
fails += 1
continue
# Parse the message hash so we can poll state
try:
msg_hash = send_resp.split("hash=", 1)[1].split()[0]
except Exception:
msg_hash = None
log("HARNESS", f"WARN [{label}]: could not parse msg_hash from {send_resp}")
# Wait for bot to log RX
rx_line = echobot_log_after(
marker,
lambda L: "Message RX:" in L and "from=" + pyxis_dest in L,
timeout=60,
)
if not rx_line:
log("HARNESS", f"FAIL [{label}]: bot never received from pyxis")
fails += 1
continue
log("HARNESS", f"OK [{label}]: bot RX: {rx_line.strip()}")
# Wait for echo back
echo_line = echobot_log_after(
marker,
lambda L: "Echo queued for delivery" in L
or "Echo SENT" in L or "Echo DELIVERED" in L,
timeout=20,
)
if not echo_line:
log("HARNESS", f"WARN [{label}]: bot did not log echo send "
f"(may still arrive)")
# For PROPAGATED rounds the bot's echo doesn't go directly
# to pyxis — it gets uploaded to the PN first, then pyxis
# has to sync down. Wait for the bot's "Echo DELIVERED" (or
# "Echo SENT") log line before kicking a sync, then retry
# sync a few times because the first sync after bot upload
# can race the PN's index update.
if send_cmd == "T:SENDPROP":
upload_line = echobot_log_after(
marker,
lambda L: ("Echo DELIVERED" in L
or "Echo SENT" in L
or "Echo queued" in L),
timeout=20,
)
# Echo "DELIVERED" for PROPAGATED in python LXMF means
# uploaded to PN. Even after that the PN index update
# can lag a few seconds, so we give it a small grace
# period.
if upload_line and ("DELIVERED" in upload_line
or "SENT" in upload_line):
log("HARNESS", " Bot uploaded echo to PN; "
"waiting 3s for PN to index it")
time.sleep(3)
else:
log("HARNESS", " WARN: didn't see echo upload; "
"syncing anyway and hoping for the best")
# Try up to 4 sync rounds. Each call to T:SYNCPROP
# restarts the FSM if it's idle/complete/failed; we
# poll T:SYNCSTATE until PR_COMPLETE (=6) or FAILED (=7).
for sync_attempt in range(4):
log("HARNESS",
f" T:SYNCPROP attempt {sync_attempt + 1}/4")
t.send_command("T:SYNCPROP", response_timeout=5)
sync_deadline = time.time() + 30
while time.time() < sync_deadline:
sresp = t.send_command("T:SYNCSTATE",
response_timeout=5)
if sresp and ("state=6" in sresp
or "state=7" in sresp):
break
time.sleep(2)
# Quick peek for the echo before kicking another sync
quick_match = poll_tdeck_rx(t, "echo:", timeout=5)
if quick_match:
break
# Wait for pyxis to RX the echo
rx_timeout = 60 if send_cmd != "T:SENDPROP" else 30
rx_match = poll_tdeck_rx(t, "echo:", timeout=rx_timeout)
if not rx_match:
log("HARNESS", f"FAIL [{label}]: pyxis never received echo")
fails += 1
continue
log("HARNESS", f"OK [{label}]: pyxis RX echo: {rx_match}")
# Clear the pyxis RX ring so the next round starts clean
t.send_command("T:RXCLR", response_timeout=5)
successes += 1
log("HARNESS", f"=== Round {round_n} / {label}: PASS "
f"(total: {successes} pass, {fails} fail) ===")
# Stability check: peek for any panic/abort/assert in the T-Deck log
# (drain new lines but don't block on them).
for line in t.drain_lines():
if any(k in line for k in (
"Guru Meditation", "PANIC", "abort", "assertion", "rst:0x"
)):
log("HARNESS", f"CRASH detected: {line}")
fails += 1
deadline = 0
break
time.sleep(args.cadence)
log("HARNESS", f"Soak complete. successes={successes} fails={fails}")
bot.terminate()
bot_fh.close()
t.close()
return 0 if fails == 0 and successes > 0 else 1
if __name__ == "__main__":
sys.exit(main())