Tests that require a real Meshtastic device. CI cannot exercise these — they run on a developer's bench (or eventually a hardware-loop runner). Each test produces a pass/fail line in a hand-edited
MANUAL-TEST-RESULTS.mdper release candidate.Owned by Phase 2+ (TCP transport + first vertical) and grows per phase.
Sprint 7 ships a cli conformance subcommand that scripts the Phase 5 acceptance set —
six scenarios (cs1 … cs6) that map directly onto the manual entries below. Run it pre-release
against your bench radio:
./gradlew :samples:cli:installDist
samples/cli/build/install/cli/bin/cli conformance \
--transport=tcp:meshtastic.local \
--peer-node='!aabbccdd' \
--candidate=v0.1.0-rc1 \
--output MANUAL-TEST-RESULTS.mdThe command prints one line per scenario, writes a markdown transcript matching the
"Recording results" template, and exits non-zero on any FAIL. Use --scenario cs1,cs3 to
restrict the sweep when iterating on a single failure mode. Scenarios that need a second
device (cs4 traceRoute) SKIP cleanly when --peer-node is omitted, leaving the exit code
unaffected — a reviewer still sees the gap in the transcript.
The csN ids correspond to manual entries below as follows:
| Scenario | Manual analogue | What it asserts |
|---|---|---|
| cs1 | A1 / A4 | Handshake reaches Connected within 30 s and ownNode is non-null. |
| cs2 | C1 | Broadcast text resolves to SendOutcome.Success within 30 s. |
| cs3 | (new — Phase 2) | client.admin.getOwner() returns AdminResult.Success(User). |
| cs4 | (new — Phase 2) | client.routing.traceRoute(peer) returns a RouteDiscovery with at least one hop. |
| cs5 | B1 | client.nodeSnapshot() has ≥ --min-nodes (default 1) within 30 s. |
| cs6 | A4 | Disconnect + reconnect cycle preserves NodeNum. |
Scenarios that cannot be scripted today (BLE pairing prompts in I1, pre-handshake byte
discard in A5, sniffer-required nodeinfo absence in A6) still need manual execution — they
have no cs* entry and remain in the categories below.
Minimum:
- One Meshtastic device on a known firmware version (record in
MANUAL-TEST-RESULTS.md). - USB cable for serial path.
- WiFi/Ethernet path for TCP/HTTP.
- A second device (any Meshtastic radio in range) for unicast/ack/PKI tests. Phase 6+ can defer multi-device tests until a second unit is available.
Recommended:
- A desk-mounted "test stand" with both radios on stable power.
- BLE-capable laptop (Mac/Linux/Windows) for BLE tests; a phone is also fine.
Each test specifies: transport • steps • expected • how to verify • firmware-feature gate (if any).
- Transport: TCP
- Steps:
- Power-cycle device.
- Wait until WiFi LED solid.
- Run
samples/cli/build/install/cli/bin/cli info --transport=tcp:meshtastic.local.
- Expected: within 30 s, CLI prints
Connected.followed byOwn node: 0x…. - Verify: stdout shows the sequence
Connecting → Configuring(STAGE_1_*) → Configuring(STAGE_2_*) → Configuring(SEEDING_SESSION) → Connected.
- Transport: BLE
- Status: DEPRECATED pending rewrite. Scenario requires meshtastic Android app or similar BLE-capable client; the
samples/android-app(formerly in this repo) has been moved to the separate meshtastic/android repository. - Steps:
- Run a Meshtastic-capable BLE client (e.g., the official Android app from
meshtastic/androidrepo); tap "Scan", select your device. - Accept BLE pairing prompt if not previously bonded.
- Run a Meshtastic-capable BLE client (e.g., the official Android app from
- Expected: Connected within 30 s;
ownNodepopulated.
- Transport: Serial JVM
- Steps:
- Plug device into USB.
- Identify port (
ls /dev/tty.*mac,dmesg | taillinux). - Run
samples/cli/build/install/cli/bin/cli info --transport=serial:ttyUSB0.
- Expected: Connected within 30 s.
- Transport: any
- Steps:
- After Connected, call
client.disconnect(). - Wait 5 s, call
client.connect()again.
- After Connected, call
- Expected: clean Disconnected → Connected;
ownNoderepopulates with same NodeNum; no log spam between cycles.
- Transport: TCP
- Steps:
- Connect, observe logs at
LogLevel.Debug.
- Connect, observe logs at
- Expected: any
FromRadioarriving before Stage 1 send is logged asdropped pre-handshake frameand not surfaced to public flows. - Note: can be hard to provoke; useful when chasing a regression.
- Transport: any (TCP/serial/BLE)
- Context: current firmware overloads
Heartbeat(nonce = 1)as a "force-broadcast NodeInfo" sentinel. The SDK skips that nonce (first heartbeat isnonce = 2). This test verifies from the airwaves side that connects do not emit an unintended NodeInfo broadcast. Gates audit finding F-3.1. - Setup: second Meshtastic radio in range, tuned to the same channel/region, running
meshtastic --listen --info(or equivalent packet-capture mode). - Steps:
- Sniffer: start listening, note a baseline of incoming packets for ~60 s.
- DUT:
cli info --transport=tcp:…(or any transport). Repeat 3× with 10 s between connects. - Sniffer: capture for ~60 s after the last connect.
- Expected: no
NODEINFO_APPbroadcast from the DUT'sNodeNumduring or immediately after the connect cycles (beyond the normal ~10 min cadence driven by the device itself). - Regression: if a NodeInfo broadcast appears within a few seconds of each
connect()call, the SDK is sendingHeartbeat(nonce = 1)— re-verifyheartbeatNonceinitialisation inMeshEngine.kt.
- Transport: any
- Steps:
- Subscribe to
client.nodes; collect into a list. - Wait 60 s with the device's mesh active (need a second radio sending NodeInfo).
- Subscribe to
- Expected: first emission is
Snapshot(nodes = …); subsequent emissions areAdded/Updated/Removed; never a secondSnapshoton the same subscription.
- Transport: any
- Steps:
- After 30 s of B1, start a second collector on
client.nodes.
- After 30 s of B1, start a second collector on
- Expected: the second collector receives a
Snapshotfirst (not a missed-deltas replay), reflecting the engine's current map.
- Transport: any (BLE preferred — easier to saturate)
- Steps:
- Subscribe to
client.packetswith a 200 msdelayper item. - Have a second radio fire 200 text messages back-to-back.
- Subscribe to
- Expected:
client.eventsemits one or morePacketsDropped(Packets, n)withnsumming to roughly the delta between sent and received counts.
- Transport: TCP
- Steps:
- Connect, idle for 10 minutes.
- Expected: connection stays Connected; debug logs show
Heartbeat sent (nonce=N)every 30 s.
- Transport: Serial
- Steps: as B4.
- Expected: as B4.
- Transport: BLE
- Steps:
- Connect with default Builder (BLE heartbeat default-on).
- Reconnect with
Builder.disableBleHeartbeat(); idle 10 min.
- Expected: first run shows heartbeat ticks; second run shows none. Both stay connected.
- Transport: TCP (easiest to simulate)
- Context: exercises the engine liveness watchdog (
MeshEngine.LIVENESS_TIMEOUT_MS = 60 s) and the TCP-layer read timeout (TcpTransport.READ_TIMEOUT_MS = 65 s). Together they guarantee a half-open socket (e.g. firewall drops state, Wi-Fi AP yanked, NAT entry evicted) surfaces as aTransportErrorwithin ~60-70 s instead of hanging forever. Gates audit findings §2.2 / §2.3 / §2.4. - Steps:
- Connect over TCP, reach
Connected, verify traffic flows (e.g.cli info). - Without closing the TCP session cleanly, partition the device from the host — either pull the device's Ethernet/Wi-Fi, or drop its packets at the host firewall (
sudo pfctl -E; echo "block drop from any to <device-ip>" | sudo pfctl -f -). - Watch
client.events/client.connection.
- Connect over TCP, reach
- Expected:
- Within ~60 s of the last successful
FromRadio, oneMeshEvent.TransportErroris emitted witherror.messagecontaining "liveness timeout" (or "TCP read timeout" if the handshake stalled beforeReady). ConnectionStatetransitions toReconnecting(cause)and eventuallyDisconnected.- Reconnect via
client.connect()succeeds once the partition is lifted.
- Within ~60 s of the last successful
- Regression: if the session sits in
Connectedfor minutes with no traffic after a partition, either the watchdog is not running intransitionToReadyorlastRxTimeMs/livenessBudgetMsis being reset by something other than a decodedFromRadio.
- Transport: any
- Steps:
client.sendText("hello").- Observe
MessageHandle.state.
- Expected:
Queued → Sent → (Delivered if mesh acks rebroadcast).
- Transport: any
- Steps:
- Identify second radio's NodeId.
client.sendText("hi", to = otherNode).
- Expected:
Queued → Sent → Acked.
- Transport: Serial (easier to predict timing) or any
- Steps:
val h = client.send(packet). Immediately callh.cancel()before observingSent.
- Expected:
state = Failed(Cancelled); transport never sends the frame.
- Steps:
- Build a
MeshPacketwhose encoded size exceeds devicemax_packet_size. - Call
client.send(packet).
- Build a
- Expected:
MeshtasticException.PayloadTooLargethrown before any handle is allocated.
- Transport: TCP (easiest to forcibly drop)
- Steps:
- Send a packet to a slow remote; while in
Sentstate with no ACK yet, kill TCP (tcpkillor pull the device's plug).
- Send a packet to a slow remote; while in
- Expected:
stateresolves toFailed(Disconnected)within the engine's transport-error window; theawait()-ing coroutine returns the corresponding outcome (no leak).
- Steps:
client.admin.getOwner(). - Expected:
AdminResult.Success(User);User.long_namenon-empty.
- Steps:
client.admin.setOwner(currentUser.copy(long_name = "Test ${Random.nextInt()}")).client.admin.getOwner().
- Expected: new long_name observed.
- Steps:
- Force a 5-min clock offset on the host (only if device has no GPS).
- Connect with
Builder.autoSyncTimeOnConnect(true).
- Expected: post-handshake, device's reported time aligns with host within 60 s window.
MeshEvent.ProtocolWarningMAY appear if pre-correction skew was large.
- Steps:
client.admin.reboot(). - Expected:
Reconnecting(cause = Transport(...))follows; reconnect succeeds within ~60 s.
- Steps:
client.admin.editSettings { setConfig(c1); setChannel(ch1); setOwner(u1); }.
- Expected: single device reboot at the end, not three.
- Steps:
- Hard to provoke deterministically. With access to firmware logs, force the device's
expectedSessionKeyto expire (idle ~15 min between connect and admin call). - Issue any admin call.
- Hard to provoke deterministically. With access to firmware logs, force the device's
- Expected: debug logs show
SessionKeyExpired observed; refreshed; retrying; final result isSuccess(...)(notSessionKeyExpired).
- Transport: any
- Steps:
- Connect; let NodeDB populate (≥3 nodes); disconnect.
- Block transport so handshake fails (e.g., disable WiFi); reconnect attempt fails.
- Inspect
client.nodeSnapshot()while inReconnecting— actually expectsNotConnectedexception. - Restore transport; reconnect.
- While
Configuring(STAGE_2_DRAINING), late subscriber tonodesshould receive Snapshot from storage immediately (no need to wait for full Stage 2 drain).
- Expected: late-subscriber Snapshot during reconnect contains pre-disconnect nodes (within last-seen TTL).
- Steps:
- Connect to device A on TCP
meshtastic.local. Populate NodeDB. - Disconnect.
- Plug in different device B (factory-fresh) on the same hostname.
- Connect.
- Connect to device A on TCP
- Expected: during handshake,
MeshEvent.ProtocolWarning("identity rebound to new NodeNum")emits; post-ConnectednodeSnapshot()contains only device B's NodeDB.
- Steps:
- Connect with a
StorageProviderthat begins failing writes after some time (custom test impl).
- Connect with a
- Expected:
MeshEvent.ProtocolWarning("storage write failed; retrying")once; on second consecutive failure,Reconnecting(cause = StorageUnavailable).
- Transport: any, with two channels configured
- Steps:
- Send a text on channel 0 from peer.
- Send a text on channel 1 from peer.
- Subscribe to
client.packets.
- Expected: both arrive (host belongs to both);
MeshPacket.channelreflects the index.
- Steps:
- Connect to a device that has channel 1 with PSK X.
- Receive an encrypted packet on channel 1.
- Have peer change channel 1 PSK to Y; the device receives the new Channel proto.
- New incoming packet on channel 1 with PSK Y.
- Expected: old packet may surface with PSK X decrypt; new packet uses PSK Y. No engine error.
- Steps:
- Configure PKI between local and peer (firmware-side).
- Peer sends PKI DM.
- Expected:
MeshPacket.decoded.payloadis cleartext on receipt; the SDK does NOT see the encrypted form. Confirms "device decrypts PKI" invariant.
- Steps: call
client.disconnect()3× consecutively. - Expected: no exception; state reaches
Disconnectedonce.
- Steps: call
connect()again after first connect succeeds. - Expected:
MeshtasticException.AlreadyConnected. (Documented in error-taxonomy as an intentional non-no-op.)
- Steps:
withTimeout(2_000) { client.connect() }(will time out before Stage 2).
- Expected:
TimeoutCancellationExceptionfromwithTimeout;client.connection.value == Disconnected; transport fully torn down (verifiable via OS sockets/file handles).
- Transport: TCP (easiest to provoke by silencing the device mid-handshake)
- Steps:
- Connect; while
Configuring(STAGE_1_DRAINING), block the device's outbound (e.g.,iptables -A OUTPUT -p tcp --sport 4403 -j DROPon a Linux DUT, or pull the WiFi antenna on a portable device). - Wait ≥ engine's Stage 1 timeout (default 30 s; see
MeshEngine.kt). - Restore connectivity.
- Call
client.connect()again.
- Connect; while
- Expected:
- First
connect()resolves withMeshtasticException.HandshakeTimeout(stage = "Stage1Draining"). client.connection.value == Disconnected(orReconnectingif the engine elects to retry — log line will say which).- The retry succeeds within the usual 30 s window;
eventsemits aProtocolWarningdescribing the prior timeout. - No coroutine leak: thread/coroutine count returns to baseline after
Disconnected.
- First
- Repeat for
stage = "Stage2Draining"and"SeedingSession"— block at the corresponding handshake stage.
- Transport: any
- Pre-req: A SQLite database file written by SDK ≤ v0.0.x (schema v1 — has the
messagestable). Easiest to obtain by checking out the previous tag, running A1 once against any device, then upgrading. - Steps:
- Locate the database file used by the SDK on your platform (Android: app data dir; JVM:
~/.meshtastic-sdk/<identity>.db; iOS: app documents). - Verify table presence pre-migration:
sqlite3 <db> ".schema messages" # should show the v1 table
- Launch the upgraded SDK and connect once.
- Re-inspect:
sqlite3 <db> ".tables" sqlite3 <db> "PRAGMA user_version;" # should be 2
- Locate the database file used by the SDK on your platform (Android: app data dir; JVM:
- Expected:
.tablesno longer listsmessages.PRAGMA user_versionreturns2.- All
nodes/channels/configsrows are preserved. - SDK logs a single migration line at
Info; no errors.
- Reference:
storage-sqldelight/.../migration_1__2.sqm.
- Transport: any (storage is transport-agnostic)
- Pre-req: A device has populated NodeDB (≥ 3 nodes).
- Steps:
- Run the SDK to populated state (NodeDB ≥ 3 nodes, last-seen recent).
- Verify PRAGMAs (iOS / macOS — JVM/Android equivalents differ per driver):
sqlite3 <db> "PRAGMA journal_mode;" # expect: wal sqlite3 <db> "PRAGMA synchronous;" # expect: 1 (NORMAL)
- Hard-kill the host process mid-write. Two ways to provoke:
kill -9 <pid>immediately after asaveNode(...)log line.- On Android: force-stop the app from Settings while a transmission is in flight.
- Confirm
<db>-waland<db>-shmfiles exist on disk (WAL artefacts). - Restart the SDK; let it re-open storage without connecting to a device first.
- Read back via
client.nodeSnapshot()(or query directly:sqlite3 <db> "SELECT count(*) FROM nodes;").
- Expected:
- SQLite recovers cleanly on next open;
<db>-walis checkpointed automatically (no manual recovery needed). - At-most-one in-flight transaction is lost (acceptable per ADR-007 /
performance.mddurability stance). - All previously-committed nodes are present.
- No
database is lockedordisk image is malformederrors in the log on subsequent connect.
- SQLite recovers cleanly on next open;
- Steps: see E2 in section E above (kept as the canonical scenario).
- Note: H3 is a pointer; do not duplicate the test entry.
- Transport: BLE
- Pre-req: A device that has not previously bonded with the host (factory-reset or "Forget device" in OS settings).
- Steps:
- Subscribe to the transport's
stateflow (the easiest path iscli --json events --transport=ble:<needle> --watch—stateenvelopes carry the transport phase) or attach a debug collector in code. - Initiate
client.connect(). - Observe the OS pairing prompt (Android: system dialog; iOS: pairing pop-up; macOS: keychain prompt).
- Do not accept yet. Note the transport state.
- Accept the prompt.
- Subscribe to the transport's
- Expected:
- State sequence:
Disconnected → Connecting → Bonding → Connected. - While in
Bonding, the engine has not started the handshake clock — UI can render a "Confirm pairing on your device" prompt without racingHandshakeTimeout. - If the user dismisses / times out the system prompt: state transitions to
Error(cause = …, recoverable = false);client.connect()resolves with a transport-level failure.
- State sequence:
- Reference:
Transport.kt(TransportState.Bondingdoc).
- Transport: BLE
- Pre-req: Same device + host that completed I1 successfully.
- Steps:
- Disconnect and reconnect within the same OS session (no re-pair).
- Expected:
- State sequence:
Disconnected → Connecting → Connected(noBondingemitted; OS reports the bond cached). - Handshake proceeds immediately on
Connected.
- State sequence:
For each release candidate, copy this template into MANUAL-TEST-RESULTS.md at repo root and fill in:
# Manual Test Results — vX.Y.Z RC<n>
- Tester: <github handle>
- Date: <YYYY-MM-DD>
- Devices: <model>/<firmware> + <model>/<firmware>
- Host: <macOS/Linux/Windows + version>
## Connect & handshake
- A1 (TCP cold connect): PASS (`Connected` in 12 s)
- A2 (BLE cold connect): PASS (`Connected` in 18 s)
- A3 (Serial cold connect): N/A (no USB cable on hand)
- A4 (reconnect cycle): PASS
- A5 (pre-handshake discard): SKIPPED (cannot provoke)
## Steady state
...
Failures MUST file a GitHub issue with the test ID and a copy of the relevant logs. Phase 5 ABI freeze requires a clean A/B/C section pass; D/E/F/G are nice-to-have until 1.0.
Post-1.0, we add a self-hosted GitHub Actions runner with a fixed pair of devices on a USB hub. Tests A1, A3, A4, B4, B5, C1, C2, D1, D2, D4, E1 become an automated nightly job (hw-loop.yml). Until then, this document is the manual replacement.
api-reference.md— what each test exercisesarchitecture/handshake-fsm.md— A1/A4/A5 backgrounderror-taxonomy.md— D6/G2 expectationssamples/cli/README.md— script flags used in test commands