mirror of
https://github.com/meshtastic/web.git
synced 2026-07-31 06:56:28 -04:00
* fix(connections): connect to the just-added connection via the live store addConnectionAndConnect() adds a connection and then connects to it in the same tick, but connect() resolved the id against the memoized `connections` closure, which is stale until the hook re-renders. The just-added id was therefore reported as an unknown connection id and Save silently never connected any HTTP/Serial/Bluetooth device. Read savedConnections from useDeviceStore.getState() so the lookup always sees the live store. * test(e2e): real-device Playwright messaging suite Drives the actual web app in Chromium against real meshtasticd firmware over the HTTP phone API and verifies text messaging in both directions across a two-node mesh. Nodes mesh over the firmware's built-in UDP multicast (224.0.0.69) with no MQTT/relay; distinct node numbers, real encryption. - Default backend: two Docker meshtasticd sim nodes (daily-debian). The same specs run against physical hardware via E2E_DEVICE_MODE=hardware. - An off-browser Python meshtastic peer (e2e/peer/peer.py) drives/asserts the non-browser node over the TCP phone API, mirroring firmware mcp-server tests. - Coverage: connect over HTTPS, mesh->web receive, web->mesh send. Direct messages are fixme'd (see below). CI workflow runs it on Linux. Bugs surfaced by the suite: - Fixed (prior commit): connect-on-save never connected (stale-closure id lookup in useConnections). - Not fixed: apps/web/src/core/subscriptions.ts throws 'ReferenceError: nodeDB is not defined' on every device-metrics telemetry packet (the #1050 migration removed that store); caught per-packet, so messaging still works. - Not fixed: direct messages are blocked by a PKI 'Keys Mismatch' (the SDK's stored peer public key != the key presented during NodeInfo exchange), seen even with fresh sim nodes. * test(e2e): address Copilot review feedback - waitForTcp(): destroy the probe socket on the error path so repeated connection failures don't accumulate sockets/FDs across the retry loop. - Don't remove the mesh containers in Playwright globalTeardown in CI — it raced the workflow's failure log capture. Teardown is now gated on E2E_DOCKER_DOWN only; CI dumps device logs on failure and tears the mesh down in a final always() workflow step. * fix(sdk): fold device-metrics telemetry into nodes apps/web/src/core/subscriptions.ts called nodeDB.addDeviceMetrics() on every device-metrics telemetry packet, but the #1050 migration removed that store — so it threw 'ReferenceError: nodeDB is not defined' on each telemetry packet (caught per-packet by the SDK's HandleFromRadio, so messaging still worked but the error spammed the console). Route device metrics into the SDK NodesClient via onTelemetryPacket instead — mirroring the existing position handler; the Node domain already carries a deviceMetrics field — and drop the dead app-side handler. Adds a NodesClient test covering the fold. * docs(e2e): accurate DM root cause + bug status The direct-message fixme is a simulator limitation, not a web-app bug: the keyless meshtasticd sim nodes NAK a DM with NO_CHANNEL (routing error 6) — no Curve25519 keypair is provisioned/shared, and current firmware can't deliver a direct message without a per-node key / decryptable channel. The app surfaces this correctly (key-refresh dialog). Re-enable against hardware or once the sim provisions keys. Also: mark the nodeDB telemetry bug fixed and note the CI teardown change. * docs(e2e): precise DM root cause (firmware/sim PKI) Followed up on the suggestion to provision keys in config.security: the keys ARE settable and persist (verified via admin), but on the native meshtasticd sim they don't sync to the node's owner / NodeInfo key — owner.public_key stays empty and the node keeps its MAC-derived num — so the two nodes never exchange keys. Combined with the firmware refusing non-PKI DMs ('Unknown public key for destination ... refusing to send legacy DM'), the DM is NAK'd with NO_CHANNEL. A firmware/sim limitation; DMs work on real hardware. Spec stays fixme. * docs(e2e): definitive DM root cause (SimRadio PKC payload limit) Per the steer to research the firmware: PKI keygen is gated on a set LoRa region (NodeDB.cpp:3051) and the sim boots region-UNSET — setting lora.region via admin DOES make the nodes generate and exchange keys (verified both ways). But a PKI-encrypted DM still can't traverse the SimRadio: the PKC overhead exceeds its payload limit ('Payload size larger than compressed message allows! Send empty payload'), so the packet is truncated and the receiver NAKs NO_CHANNEL ('No suitable channel found for decoding, hash 0x0'). The firmware skips PKC under --sim (Router.cpp:730) for exactly this reason, but --sim also disables the config-file loading the web app needs, so they're mutually exclusive. DMs work on real hardware; spec stays fixme with this detail. --------- Co-authored-by: Dan Ditomaso <dan.ditomaso@gmail.com>
141 lines
4.8 KiB
Python
141 lines
4.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Standalone Meshtastic test peer for the web E2E suite.
|
|
|
|
Connects to a `meshtasticd` node over the TCP phone API and either sends a text
|
|
message, blocks until a specific text is received, or prints the node's own
|
|
number. It mirrors the `wait_for(predicate, timeout)` + `send_text` conventions
|
|
from `firmware/mcp-server/tests/mesh/_receive.py`, but uses `TCPInterface` so it
|
|
can talk to a daemon (that `ReceiveCollector` is serial-only).
|
|
|
|
Machine-readable stdout markers (everything else goes to stderr):
|
|
- `node-num` -> `NODE_NUM=<n>`
|
|
- `recv` -> `READY` once subscribed, then `RECEIVED=<from>` on match
|
|
|
|
Exit codes: 0 success, 1 timeout / not received, 2 usage / connection error.
|
|
|
|
Usage:
|
|
peer.py --host localhost --port 4404 send "hello" [--to <nodenum>] [--want-ack]
|
|
peer.py --host localhost --port 4403 recv "hello" [--from-node <n>] [--timeout 60]
|
|
peer.py --host localhost --port 4404 node-num
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import sys
|
|
import threading
|
|
import time
|
|
|
|
import meshtastic
|
|
import meshtastic.tcp_interface
|
|
from pubsub import pub
|
|
|
|
|
|
def log(msg: str) -> None:
|
|
print(msg, file=sys.stderr, flush=True)
|
|
|
|
|
|
def emit(msg: str) -> None:
|
|
print(msg, flush=True)
|
|
|
|
|
|
def connect(host: str, port: int, timeout: float):
|
|
log(f"[peer] connecting to {host}:{port} ...")
|
|
iface = meshtastic.tcp_interface.TCPInterface(hostname=host, portNumber=port, connectNow=True)
|
|
deadline = time.monotonic() + timeout
|
|
while getattr(iface, "myInfo", None) is None and time.monotonic() < deadline:
|
|
time.sleep(0.1)
|
|
if getattr(iface, "myInfo", None) is None:
|
|
raise TimeoutError(f"no myInfo from {host}:{port} within {timeout}s")
|
|
log(f"[peer] connected as node {iface.myInfo.my_node_num}")
|
|
return iface
|
|
|
|
|
|
def cmd_node_num(args) -> int:
|
|
iface = connect(args.host, args.port, args.connect_timeout)
|
|
try:
|
|
emit(f"NODE_NUM={iface.myInfo.my_node_num}")
|
|
return 0
|
|
finally:
|
|
iface.close()
|
|
|
|
|
|
def cmd_send(args) -> int:
|
|
iface = connect(args.host, args.port, args.connect_timeout)
|
|
try:
|
|
dest = args.to if args.to is not None else meshtastic.BROADCAST_ADDR
|
|
pkt = iface.sendText(args.text, destinationId=dest, wantAck=args.want_ack)
|
|
pid = getattr(pkt, "id", None)
|
|
log(f"[peer] sent {args.text!r} -> {dest} (id={pid}) from {iface.myInfo.my_node_num}")
|
|
# Let the TX flush over UDP multicast before we disconnect.
|
|
time.sleep(args.linger)
|
|
emit(f"SENT={pid}")
|
|
return 0
|
|
finally:
|
|
iface.close()
|
|
|
|
|
|
def cmd_recv(args) -> int:
|
|
iface = connect(args.host, args.port, args.connect_timeout)
|
|
found = threading.Event()
|
|
hit: dict = {}
|
|
|
|
def on_text(packet, interface=None): # noqa: ANN001
|
|
decoded = (packet or {}).get("decoded", {}) or {}
|
|
if decoded.get("text") != args.text:
|
|
return
|
|
frm = packet.get("from")
|
|
if args.from_node is not None and frm != args.from_node:
|
|
return
|
|
hit["from"] = frm
|
|
found.set()
|
|
|
|
pub.subscribe(on_text, "meshtastic.receive.text")
|
|
log(f"[peer] listening for {args.text!r} on node {iface.myInfo.my_node_num} (timeout {args.timeout}s)")
|
|
emit("READY") # the test waits for this before driving the browser send
|
|
try:
|
|
if found.wait(timeout=args.timeout):
|
|
emit(f"RECEIVED={hit.get('from')}")
|
|
log(f"[peer] received {args.text!r} from {hit.get('from')}")
|
|
return 0
|
|
log(f"[peer] TIMEOUT waiting for {args.text!r}")
|
|
return 1
|
|
finally:
|
|
pub.unsubscribe(on_text, "meshtastic.receive.text")
|
|
iface.close()
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description="Meshtastic E2E test peer (TCP)")
|
|
ap.add_argument("--host", default="localhost")
|
|
ap.add_argument("--port", type=int, default=4403)
|
|
ap.add_argument("--connect-timeout", type=float, default=45.0)
|
|
sub = ap.add_subparsers(dest="mode", required=True)
|
|
|
|
s = sub.add_parser("send", help="send a text message")
|
|
s.add_argument("text")
|
|
s.add_argument("--to", type=int, default=None, help="destination node num (default: broadcast)")
|
|
s.add_argument("--want-ack", action="store_true")
|
|
s.add_argument("--linger", type=float, default=2.0)
|
|
s.set_defaults(func=cmd_send)
|
|
|
|
r = sub.add_parser("recv", help="wait for a specific text")
|
|
r.add_argument("text")
|
|
r.add_argument("--from-node", type=int, default=None)
|
|
r.add_argument("--timeout", type=float, default=60.0)
|
|
r.set_defaults(func=cmd_recv)
|
|
|
|
n = sub.add_parser("node-num", help="print this node's number")
|
|
n.set_defaults(func=cmd_node_num)
|
|
|
|
args = ap.parse_args()
|
|
try:
|
|
return args.func(args)
|
|
except Exception as exc: # noqa: BLE001
|
|
log(f"[peer] ERROR: {exc}")
|
|
return 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|