diff --git a/.gitignore b/.gitignore index 9e4df18b..80a544b1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ /target /vendor src/board/mod.rs +__pycache__/ +.hypothesis/ +corpus/generated/ diff --git a/.hypothesis/constants/acbb6a9ef6a5a354 b/.hypothesis/constants/acbb6a9ef6a5a354 new file mode 100644 index 00000000..0c8d2db6 --- /dev/null +++ b/.hypothesis/constants/acbb6a9ef6a5a354 @@ -0,0 +1,4 @@ +# file: /home/swaits/Code/lora-dongle/corpus/generate.py +# hypothesis_version: 6.151.9 + +[1000000.0, -180000000, -90000000, 127, 128, 184, 192, 255, 90000000, 180000000, 4294967295, '*.json', '--count', ' int: + return (ver << 6) | (payload_type << 2) | route_type + + +def path_len_byte(hop_count: int, hash_size: int) -> int: + return ((hash_size - 1) << 6) | (hop_count & 0x3F) + + +DUMMY_PUBKEY = bytes(range(32)) +DUMMY_SIG = bytes(64) +TS_2026 = struct.pack(" bytes: + hdr = bytes([mc_header(0x04, route_type)]) + hops = path_hops or [] + pl = bytes([path_len_byte(len(hops), hash_size)]) + path = b"".join(hops) + payload = pubkey + timestamp + DUMMY_SIG + app_data + return hdr + pl + path + payload + + +def peer_packet(payload_type: int, route_type: int, dst: int, src: int, + mac: bytes, ciphertext: bytes, + path_hops: list[bytes] | None = None, + hash_size: int = 1, tc: tuple[int, int] | None = None) -> bytes: + hdr = bytes([mc_header(payload_type, route_type)]) + hops = path_hops or [] + pl_byte = bytes([path_len_byte(len(hops), hash_size)]) + path = b"".join(hops) + body = bytes([dst, src]) + mac + ciphertext + tc_bytes = b"" + if tc is not None: + tc_bytes = struct.pack(" bytes: + hdr = bytes([mc_header(0x03, route_type)]) + hops = path_hops or [] + pl_byte = bytes([path_len_byte(len(hops), hash_size)]) + path = b"".join(hops) + return hdr + pl_byte + path + struct.pack(" bytes: + hdr = bytes([mc_header(payload_type, route_type)]) + hops = path_hops or [] + pl_byte = bytes([path_len_byte(len(hops), hash_size)]) + path = b"".join(hops) + return hdr + pl_byte + path + bytes([ch_hash]) + mac + ciphertext + + +def anon_req_packet(dst: int, ephem_pub: bytes, mac: bytes, ciphertext: bytes, + route_type: int = 1, path_hops: list[bytes] | None = None, + hash_size: int = 1) -> bytes: + hdr = bytes([mc_header(0x07, route_type)]) + hops = path_hops or [] + pl_byte = bytes([path_len_byte(len(hops), hash_size)]) + path = b"".join(hops) + return hdr + pl_byte + path + bytes([dst]) + ephem_pub + mac + ciphertext + + +def trace_packet(tag: int, auth_code: int, flags: int, + trace_hashes: bytes = b"", + route_type: int = 1, + path_hops: list[bytes] | None = None, + hash_size: int = 1) -> bytes: + hdr = bytes([mc_header(0x09, route_type)]) + hops = path_hops or [] + pl_byte = bytes([path_len_byte(len(hops), hash_size)]) + path = b"".join(hops) + payload = struct.pack(" bytes: + hdr = bytes([mc_header(0x0A, route_type)]) + pl_byte = bytes([path_len_byte(0, 1)]) + control = bytes([(remaining << 4) | (inner_type & 0x0F)]) + return hdr + pl_byte + control + inner_payload + + +def control_packet(flags_byte: int, payload: bytes, + route_type: int = 1) -> bytes: + hdr = bytes([mc_header(0x0B, route_type)]) + pl_byte = bytes([path_len_byte(0, 1)]) + return hdr + pl_byte + bytes([flags_byte]) + payload + + +# ── Test cases ──────────────────────────────────────────────────── + +print("Building hand-crafted test vectors...") + +# ADVERT: basic chat node with name +write_case("advert_basic", { + "description": "ADVERT flood, chat node with simple ASCII name", + "tags": ["advert", "flood", "name", "chat"], + "packet_hex": advert_packet(bytes([0x81]) + b"TestNode").hex(), + "valid": True, + "expected": { + "route_type": "flood", + "payload_type": "ADVERT", + "payload_ver": 0, + "transport_codes": None, + "path": {"hash_size": 1, "hops": []}, + "payload": { + "pubkey_prefix": DUMMY_PUBKEY[:4].hex(), + "timestamp": 1774600000, + "advert": { + "flags": 0x81, + "node_type": "chat", + "location": None, + "name": "TestNode" + } + } + } +}) + +# ADVERT: repeater with valid GPS location + name +write_case("advert_location", { + "description": "ADVERT flood, repeater with valid GPS coords and name", + "tags": ["advert", "flood", "location", "repeater"], + "packet_hex": advert_packet( + bytes([0x92]) + + struct.pack(" int: + return (ver << 6) | (payload_type << 2) | route_type + + +def path_len_byte(hop_count: int, hash_size: int) -> int: + return ((hash_size - 1) << 6) | (hop_count & 0x3F) + + +# ── Hypothesis strategies ───────────────────────────────────────── + +hash_sizes = st.sampled_from([1, 2, 3]) +route_types_no_tc = st.sampled_from([1, 2]) # flood, direct (no transport codes) +route_types_all = st.sampled_from([0, 1, 2, 3]) +payload_vers = st.sampled_from([0]) # only v0 is well-defined + + +@st.composite +def path_strategy(draw, hash_size=None): + hs = hash_size or draw(hash_sizes) + max_hops = min(63, MAX_PATH_SIZE // hs) + hop_count = draw(st.integers(min_value=0, max_value=min(max_hops, 10))) + hops = [draw(st.binary(min_size=hs, max_size=hs)) for _ in range(hop_count)] + return hs, hops + + +@st.composite +def advert_appdata(draw): + """Generate valid ADVERT app_data.""" + node_type = draw(st.sampled_from([1, 2, 3, 4])) + has_loc = draw(st.booleans()) + has_name = draw(st.booleans()) + flags = node_type + data = b"" + loc = None + name = None + + if has_loc: + flags |= 0x10 + lat = draw(st.integers(min_value=-90_000_000, max_value=90_000_000)) + lon = draw(st.integers(min_value=-180_000_000, max_value=180_000_000)) + data += struct.pack(" MAX_ADVERT_DATA_SIZE: + data = data[:MAX_ADVERT_DATA_SIZE - 1] + if name: + name = data[8 if has_loc else 0:].decode("ascii", errors="replace") + + app_data = bytes([flags]) + data + node_label = NODE_TYPES.get(node_type, f"type={node_type}") + return app_data, {"flags": flags, "node_type": node_label, "location": loc, "name": name} + + +@st.composite +def valid_advert_packet(draw): + """Generate a complete valid ADVERT packet with expected decode.""" + route_type = draw(route_types_no_tc) + ver = draw(payload_vers) + hs, hops = draw(path_strategy()) + pubkey = draw(st.binary(min_size=PUB_KEY_SIZE, max_size=PUB_KEY_SIZE)) + timestamp = draw(st.integers(min_value=0, max_value=0xFFFFFFFF)) + signature = draw(st.binary(min_size=SIGNATURE_SIZE, max_size=SIGNATURE_SIZE)) + app_data, advert_expected = draw(advert_appdata()) + + hdr = bytes([mc_header(0x04, route_type, ver)]) + pl = bytes([path_len_byte(len(hops), hs)]) + path = b"".join(hops) + payload = pubkey + struct.pack("> 2) & 0x0F + + return packet, { + "route_type": ROUTE_TYPES.get(route_type, f"rt{route_type}"), + "payload_type": PAYLOAD_TYPES.get(payload_type, f"0x{payload_type:02x}"), + "error": error if len(packet) < 2 else error + } + + +# ── Generation ──────────────────────────────────────────────────── + +_counter = 0 + + +def save_case(packet: bytes, expected: dict, valid: bool, prefix: str): + global _counter + _counter += 1 + ptype = expected["payload_type"].lower() + name = f"{prefix}_{ptype}_{_counter:04d}" + + tags = [ptype, expected["route_type"]] + if not valid: + tags.append("invalid") + if "error" in expected: + tags.append(expected["error"]) + + case = { + "description": f"Generated {prefix}: {expected['payload_type']} {expected['route_type']}", + "tags": tags, + "packet_hex": packet.hex(), + "valid": valid, + "expected": expected, + } + + # Validate against schema + import jsonschema + schema = json.loads(SCHEMA_PATH.read_text()) + try: + jsonschema.validate(case, schema) + except jsonschema.ValidationError as e: + print(f" SKIP {name}: schema error: {e.message}") + return + + (OUT / f"{name}.json").write_text(json.dumps(case, indent=2, ensure_ascii=False) + "\n") + + +def generate(count: int): + OUT.mkdir(exist_ok=True) + + # Clear previous generated files + for f in OUT.glob("*.json"): + f.unlink() + + global _counter + _counter = 0 + + strategies = [ + ("valid", valid_advert_packet(), True), + ("valid", valid_ack_packet(), True), + ("valid", valid_encrypted_peer_packet(), True), + ("valid", valid_group_packet(), True), + ("invalid", invalid_packet(), False), + ] + + per_strategy = max(1, count // len(strategies)) + + for prefix, strategy, valid in strategies: + for i in range(per_strategy): + example = strategy.example() + packet, expected = example + save_case(packet, expected, valid, prefix) + + total = len(list(OUT.glob("*.json"))) + print(f"Generated {total} test vectors in {OUT}/") + + +def main(): + parser = argparse.ArgumentParser(description="Generate MeshCore test vectors") + parser.add_argument("--count", type=int, default=50, help="Total test cases to generate") + args = parser.parse_args() + generate(args.count) + + +if __name__ == "__main__": + main() diff --git a/corpus/hand/ack_basic.json b/corpus/hand/ack_basic.json new file mode 100644 index 00000000..c99a7f36 --- /dev/null +++ b/corpus/hand/ack_basic.json @@ -0,0 +1,22 @@ +{ + "description": "ACK flood, simple acknowledgment", + "tags": [ + "ack", + "flood" + ], + "packet_hex": "0d00efbeadde", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "ACK", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 1, + "hops": [] + }, + "payload": { + "ack_crc": 3735928559 + } + } +} diff --git a/corpus/hand/ack_with_path.json b/corpus/hand/ack_with_path.json new file mode 100644 index 00000000..70d6fe82 --- /dev/null +++ b/corpus/hand/ack_with_path.json @@ -0,0 +1,26 @@ +{ + "description": "ACK flood, 2 hops with 1-byte hashes", + "tags": [ + "ack", + "flood", + "path" + ], + "packet_hex": "0d02d1ac9fc28d00", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "ACK", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 1, + "hops": [ + "d1", + "ac" + ] + }, + "payload": { + "ack_crc": 9290399 + } + } +} diff --git a/corpus/hand/advert_basic.json b/corpus/hand/advert_basic.json new file mode 100644 index 00000000..87045681 --- /dev/null +++ b/corpus/hand/advert_basic.json @@ -0,0 +1,31 @@ +{ + "description": "ADVERT flood, chat node with simple ASCII name", + "tags": [ + "advert", + "flood", + "name", + "chat" + ], + "packet_hex": "1100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f403fc6690000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000081546573744e6f6465", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "ADVERT", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 1, + "hops": [] + }, + "payload": { + "pubkey_prefix": "00010203", + "timestamp": 1774600000, + "advert": { + "flags": 129, + "node_type": "chat", + "location": null, + "name": "TestNode" + } + } + } +} diff --git a/corpus/hand/advert_emoji_name.json b/corpus/hand/advert_emoji_name.json new file mode 100644 index 00000000..b3b94cb7 --- /dev/null +++ b/corpus/hand/advert_emoji_name.json @@ -0,0 +1,32 @@ +{ + "description": "ADVERT flood, chat node with emoji in name (multi-byte UTF-8)", + "tags": [ + "advert", + "flood", + "emoji", + "name", + "utf8" + ], + "packet_hex": "1100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f403fc6690000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000081f09fa68520526170746f72", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "ADVERT", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 1, + "hops": [] + }, + "payload": { + "pubkey_prefix": "00010203", + "timestamp": 1774600000, + "advert": { + "flags": 129, + "node_type": "chat", + "location": null, + "name": "🦅 Raptor" + } + } + } +} diff --git a/corpus/hand/advert_feat_flags_as_name.json b/corpus/hand/advert_feat_flags_as_name.json new file mode 100644 index 00000000..9e06be63 --- /dev/null +++ b/corpus/hand/advert_feat_flags_as_name.json @@ -0,0 +1,32 @@ +{ + "description": "ADVERT flood, feat1+feat2 bits set but bytes are actually the name", + "tags": [ + "advert", + "flood", + "feat", + "scoring", + "emoji" + ], + "packet_hex": "1100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f403fc66900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e1f09f8f94efb88f204d746e4e6f6465", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "ADVERT", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 1, + "hops": [] + }, + "payload": { + "pubkey_prefix": "00010203", + "timestamp": 1774600000, + "advert": { + "flags": 225, + "node_type": "chat", + "location": null, + "name": "🏔️ MtnNode" + } + } + } +} diff --git a/corpus/hand/advert_location.json b/corpus/hand/advert_location.json new file mode 100644 index 00000000..43b6c159 --- /dev/null +++ b/corpus/hand/advert_location.json @@ -0,0 +1,34 @@ +{ + "description": "ADVERT flood, repeater with valid GPS coords and name", + "tags": [ + "advert", + "flood", + "location", + "repeater" + ], + "packet_hex": "1100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f403fc6690000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000092346640023807b4f848696c6c746f7052707472", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "ADVERT", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 1, + "hops": [] + }, + "payload": { + "pubkey_prefix": "00010203", + "timestamp": 1774600000, + "advert": { + "flags": 146, + "node_type": "repeater", + "location": { + "lat": 37.7749, + "lon": -122.4194 + }, + "name": "HilltopRptr" + } + } + } +} diff --git a/corpus/hand/advert_no_appdata.json b/corpus/hand/advert_no_appdata.json new file mode 100644 index 00000000..8aafcaed --- /dev/null +++ b/corpus/hand/advert_no_appdata.json @@ -0,0 +1,25 @@ +{ + "description": "ADVERT flood, bare minimum (no app_data)", + "tags": [ + "advert", + "flood", + "minimal" + ], + "packet_hex": "1100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f403fc66900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "ADVERT", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 1, + "hops": [] + }, + "payload": { + "pubkey_prefix": "00010203", + "timestamp": 1774600000, + "advert": null + } + } +} diff --git a/corpus/hand/advert_sensor.json b/corpus/hand/advert_sensor.json new file mode 100644 index 00000000..c5865534 --- /dev/null +++ b/corpus/hand/advert_sensor.json @@ -0,0 +1,30 @@ +{ + "description": "ADVERT flood, sensor node type", + "tags": [ + "advert", + "flood", + "sensor" + ], + "packet_hex": "1100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f403fc669000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008454656d7053656e736f72", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "ADVERT", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 1, + "hops": [] + }, + "payload": { + "pubkey_prefix": "00010203", + "timestamp": 1774600000, + "advert": { + "flags": 132, + "node_type": "sensor", + "location": null, + "name": "TempSensor" + } + } + } +} diff --git a/corpus/hand/advert_truncated.json b/corpus/hand/advert_truncated.json new file mode 100644 index 00000000..3f111509 --- /dev/null +++ b/corpus/hand/advert_truncated.json @@ -0,0 +1,20 @@ +{ + "description": "ADVERT flood, truncated mid-signature (50 bytes)", + "tags": [ + "invalid", + "advert", + "truncated" + ], + "packet_hex": "1100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f403fc669000000000000000000000000", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "ADVERT", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 1, + "hops": [] + } + } +} diff --git a/corpus/hand/advert_with_path.json b/corpus/hand/advert_with_path.json new file mode 100644 index 00000000..4a20e68c --- /dev/null +++ b/corpus/hand/advert_with_path.json @@ -0,0 +1,35 @@ +{ + "description": "ADVERT flood, 4 hops with 1-byte hashes", + "tags": [ + "advert", + "flood", + "path" + ], + "packet_hex": "11042a3d35ed000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f403fc669000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008152656c617965644e6f6465", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "ADVERT", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 1, + "hops": [ + "2a", + "3d", + "35", + "ed" + ] + }, + "payload": { + "pubkey_prefix": "00010203", + "timestamp": 1774600000, + "advert": { + "flags": 129, + "node_type": "chat", + "location": null, + "name": "RelayedNode" + } + } + } +} diff --git a/corpus/hand/anon_req_tc_fallback.json b/corpus/hand/anon_req_tc_fallback.json new file mode 100644 index 00000000..1d038318 --- /dev/null +++ b/corpus/hand/anon_req_tc_fallback.json @@ -0,0 +1,30 @@ +{ + "description": "ANON_REQ tdirect header but NO transport codes (older firmware)", + "tags": [ + "anon_req", + "tdirect", + "tc_fallback" + ], + "packet_hex": "1f03133542d9202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3fb2e2000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "valid": true, + "expected": { + "route_type": "tdirect", + "payload_type": "ANON_REQ", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 1, + "hops": [ + "13", + "35", + "42" + ] + }, + "payload": { + "dst": "d9", + "ephem_prefix": "20212223", + "mac": "b2e2", + "ciphertext_len": 48 + } + } +} diff --git a/corpus/hand/bad_path_len.json b/corpus/hand/bad_path_len.json new file mode 100644 index 00000000..a2e7fa63 --- /dev/null +++ b/corpus/hand/bad_path_len.json @@ -0,0 +1,15 @@ +{ + "description": "TXT_MSG flood, path_len claims 33 hops (4B hash) but data is too short", + "tags": [ + "invalid", + "bad_framing", + "path" + ], + "packet_hex": "09e1000000000000000000000000000000000000000000000000", + "valid": false, + "expected": { + "route_type": "flood", + "payload_type": "TXT_MSG", + "error": "bad_framing" + } +} diff --git a/corpus/hand/control_discover_req.json b/corpus/hand/control_discover_req.json new file mode 100644 index 00000000..cadc5791 --- /dev/null +++ b/corpus/hand/control_discover_req.json @@ -0,0 +1,23 @@ +{ + "description": "CONTROL flood, DISCOVER_REQ sub-type", + "tags": [ + "control", + "flood", + "discover" + ], + "packet_hex": "2d0080033412cdab", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "CONTROL", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 1, + "hops": [] + }, + "payload": { + "control_subtype": "DISCOVER_REQ" + } + } +} diff --git a/corpus/hand/empty_packet.json b/corpus/hand/empty_packet.json new file mode 100644 index 00000000..e893db91 --- /dev/null +++ b/corpus/hand/empty_packet.json @@ -0,0 +1,14 @@ +{ + "description": "Empty packet (0 bytes)", + "tags": [ + "invalid", + "too_short" + ], + "packet_hex": "", + "valid": false, + "expected": { + "route_type": "flood", + "payload_type": "REQ", + "error": "too_short" + } +} diff --git a/corpus/hand/grp_txt_basic.json b/corpus/hand/grp_txt_basic.json new file mode 100644 index 00000000..bf645987 --- /dev/null +++ b/corpus/hand/grp_txt_basic.json @@ -0,0 +1,26 @@ +{ + "description": "GRP_TXT flood, group text message", + "tags": [ + "grp_txt", + "flood", + "group", + "encrypted" + ], + "packet_hex": "1500ccabcd0000000000000000000000000000000000000000000000000000000000000000", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "GRP_TXT", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 1, + "hops": [] + }, + "payload": { + "channel_hash": "cc", + "mac": "abcd", + "ciphertext_len": 32 + } + } +} diff --git a/corpus/hand/multipart_ack.json b/corpus/hand/multipart_ack.json new file mode 100644 index 00000000..4b1cec29 --- /dev/null +++ b/corpus/hand/multipart_ack.json @@ -0,0 +1,24 @@ +{ + "description": "MULTIPART flood, remaining=3, inner type=ACK", + "tags": [ + "multipart", + "flood", + "ack" + ], + "packet_hex": "290033bebafeca", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "MULTIPART", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 1, + "hops": [] + }, + "payload": { + "multipart_remaining": 3, + "multipart_inner": "ACK" + } + } +} diff --git a/corpus/hand/noise_packet.json b/corpus/hand/noise_packet.json new file mode 100644 index 00000000..8140c32e --- /dev/null +++ b/corpus/hand/noise_packet.json @@ -0,0 +1,14 @@ +{ + "description": "Random bytes, not a valid MeshCore packet", + "tags": [ + "invalid", + "noise" + ], + "packet_hex": "03d135839fc28d00", + "valid": false, + "expected": { + "route_type": "tdirect", + "payload_type": "REQ", + "error": "not_meshcore" + } +} diff --git a/corpus/hand/path_3byte_hash.json b/corpus/hand/path_3byte_hash.json new file mode 100644 index 00000000..386e4d2b --- /dev/null +++ b/corpus/hand/path_3byte_hash.json @@ -0,0 +1,29 @@ +{ + "description": "PATH flood, 1 hop with 3-byte hash", + "tags": [ + "path", + "flood", + "3byte_hash", + "encrypted" + ], + "packet_hex": "21811afff3e6e92e4700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "PATH", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 3, + "hops": [ + "1afff3" + ] + }, + "payload": { + "dst": "e6", + "src": "e9", + "mac": "2e47", + "ciphertext_len": 46 + } + } +} diff --git a/corpus/hand/path_4byte_hash.json b/corpus/hand/path_4byte_hash.json new file mode 100644 index 00000000..e1d3a29b --- /dev/null +++ b/corpus/hand/path_4byte_hash.json @@ -0,0 +1,29 @@ +{ + "description": "PATH flood, 1 hop with 4-byte hash", + "tags": [ + "path", + "flood", + "4byte_hash", + "encrypted" + ], + "packet_hex": "21c1aabbccdde6e92e4700000000000000000000000000000000", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "PATH", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 4, + "hops": [ + "aabbccdd" + ] + }, + "payload": { + "dst": "e6", + "src": "e9", + "mac": "2e47", + "ciphertext_len": 16 + } + } +} diff --git a/corpus/hand/req_transport_direct.json b/corpus/hand/req_transport_direct.json new file mode 100644 index 00000000..17772d48 --- /dev/null +++ b/corpus/hand/req_transport_direct.json @@ -0,0 +1,33 @@ +{ + "description": "REQ transport-direct with transport codes and 1-byte path", + "tags": [ + "req", + "tdirect", + "transport_codes", + "encrypted" + ], + "packet_hex": "033412785602ccdda1b2ef010000000000000000000000000000000000000000000000000000000000000000", + "valid": true, + "expected": { + "route_type": "tdirect", + "payload_type": "REQ", + "payload_ver": 0, + "transport_codes": { + "tc1": 4660, + "tc2": 22136 + }, + "path": { + "hash_size": 1, + "hops": [ + "cc", + "dd" + ] + }, + "payload": { + "dst": "a1", + "src": "b2", + "mac": "ef01", + "ciphertext_len": 32 + } + } +} diff --git a/corpus/hand/response_corrupted_pathlen_1.json b/corpus/hand/response_corrupted_pathlen_1.json new file mode 100644 index 00000000..bd188ef6 --- /dev/null +++ b/corpus/hand/response_corrupted_pathlen_1.json @@ -0,0 +1,11 @@ +{ + "description": "RESPONSE flood, bit-corrupted path_len (0xc7 should be small), LoRa bit error", + "tags": ["invalid", "response", "flood", "corrupted", "real_capture", "bit_error"], + "packet_hex": "05c735935ed17863f6ec7bbbd9bf8e5ea7d44b6a0e8f6318", + "valid": false, + "expected": { + "route_type": "flood", + "payload_type": "RESPONSE", + "error": "bad_framing" + } +} diff --git a/corpus/hand/response_corrupted_pathlen_2.json b/corpus/hand/response_corrupted_pathlen_2.json new file mode 100644 index 00000000..111aa42d --- /dev/null +++ b/corpus/hand/response_corrupted_pathlen_2.json @@ -0,0 +1,11 @@ +{ + "description": "RESPONSE flood, bit-corrupted path_len (0x32 should be 0x02 — bits 4,5 flipped), LoRa bit error", + "tags": ["invalid", "response", "flood", "corrupted", "real_capture", "bit_error"], + "packet_hex": "0532af1a5bd17863f6ec7bbbd9bfbe5ea7d44b6a0e8f6318", + "valid": false, + "expected": { + "route_type": "flood", + "payload_type": "RESPONSE", + "error": "bad_framing" + } +} diff --git a/corpus/hand/response_valid_1hop.json b/corpus/hand/response_valid_1hop.json new file mode 100644 index 00000000..5041e87b --- /dev/null +++ b/corpus/hand/response_valid_1hop.json @@ -0,0 +1,14 @@ +{ + "description": "RESPONSE flood, 1 hop (real capture — same message as corrupted variants)", + "tags": ["response", "flood", "path", "real_capture"], + "packet_hex": "0501ac5bd17863f6ec7bbbd9bf8e5ea7d44b6a0e8f6318", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "RESPONSE", + "payload_ver": 0, + "transport_codes": null, + "path": {"hash_size": 1, "hops": ["ac"]}, + "payload": {"dst": "5b", "src": "d1", "mac": "7863", "ciphertext_len": 16} + } +} diff --git a/corpus/hand/response_valid_2hop.json b/corpus/hand/response_valid_2hop.json new file mode 100644 index 00000000..eb01f5cc --- /dev/null +++ b/corpus/hand/response_valid_2hop.json @@ -0,0 +1,14 @@ +{ + "description": "RESPONSE flood, 2 hops [ac>ea] (real capture — same message as corrupted variants)", + "tags": ["response", "flood", "path", "real_capture"], + "packet_hex": "0502acea5bd17863f6ec7bbbd9bf8e5ea7d44b6a0e8f6318", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "RESPONSE", + "payload_ver": 0, + "transport_codes": null, + "path": {"hash_size": 1, "hops": ["ac", "ea"]}, + "payload": {"dst": "5b", "src": "d1", "mac": "7863", "ciphertext_len": 16} + } +} diff --git a/corpus/hand/too_short.json b/corpus/hand/too_short.json new file mode 100644 index 00000000..e9f72c7d --- /dev/null +++ b/corpus/hand/too_short.json @@ -0,0 +1,14 @@ +{ + "description": "Packet too short (1 byte)", + "tags": [ + "invalid", + "too_short" + ], + "packet_hex": "11", + "valid": false, + "expected": { + "route_type": "flood", + "payload_type": "ADVERT", + "error": "too_short" + } +} diff --git a/corpus/hand/trace_basic.json b/corpus/hand/trace_basic.json new file mode 100644 index 00000000..a32e8a20 --- /dev/null +++ b/corpus/hand/trace_basic.json @@ -0,0 +1,23 @@ +{ + "description": "TRACE flood with tag, auth, and 2 trace hops", + "tags": [ + "trace", + "flood" + ], + "packet_hex": "250078563412ddccbbaa001122", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "TRACE", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 1, + "hops": [] + }, + "payload": { + "trace_tag": 305419896, + "trace_hops": 2 + } + } +} diff --git a/corpus/hand/txt_msg_direct_2b_hash.json b/corpus/hand/txt_msg_direct_2b_hash.json new file mode 100644 index 00000000..c03b9fc9 --- /dev/null +++ b/corpus/hand/txt_msg_direct_2b_hash.json @@ -0,0 +1,30 @@ +{ + "description": "TXT_MSG direct, 2-byte hashes, 2 hops", + "tags": [ + "txt_msg", + "direct", + "2byte_hash", + "encrypted" + ], + "packet_hex": "0a42ac98d14d5b43abcd000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "valid": true, + "expected": { + "route_type": "direct", + "payload_type": "TXT_MSG", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 2, + "hops": [ + "ac98", + "d14d" + ] + }, + "payload": { + "dst": "5b", + "src": "43", + "mac": "abcd", + "ciphertext_len": 48 + } + } +} diff --git a/corpus/hand/txt_msg_flood_with_loops.json b/corpus/hand/txt_msg_flood_with_loops.json new file mode 100644 index 00000000..15749c2e --- /dev/null +++ b/corpus/hand/txt_msg_flood_with_loops.json @@ -0,0 +1,33 @@ +{ + "description": "TXT_MSG flood with routing loop (ac98 appears 3x)", + "tags": [ + "txt_msg", + "flood", + "loop", + "2byte_hash" + ], + "packet_hex": "0945ac98d14dac98ff01ac985b43abcd0000000000000000000000000000000000000000000000000000000000000000", + "valid": true, + "expected": { + "route_type": "flood", + "payload_type": "TXT_MSG", + "payload_ver": 0, + "transport_codes": null, + "path": { + "hash_size": 2, + "hops": [ + "ac98", + "d14d", + "ac98", + "ff01", + "ac98" + ] + }, + "payload": { + "dst": "5b", + "src": "43", + "mac": "abcd", + "ciphertext_len": 32 + } + } +} diff --git a/corpus/schema.json b/corpus/schema.json new file mode 100644 index 00000000..add8bb8d --- /dev/null +++ b/corpus/schema.json @@ -0,0 +1,128 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MeshCore Packet Test Case", + "description": "A single test vector for MeshCore packet decoders", + "type": "object", + "required": ["description", "tags", "packet_hex", "valid", "expected"], + "additionalProperties": false, + "properties": { + "description": { + "type": "string", + "minLength": 1 + }, + "tags": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + "packet_hex": { + "type": "string", + "pattern": "^[0-9a-f]*$", + "description": "Raw MeshCore packet as lowercase hex (no 0x prefix)" + }, + "valid": { + "type": "boolean", + "description": "Whether this packet should parse successfully" + }, + "expected": { + "type": "object", + "required": ["route_type", "payload_type"], + "properties": { + "route_type": { + "type": "string", + "enum": ["flood", "direct", "tflood", "tdirect"] + }, + "payload_type": { + "type": "string", + "enum": [ + "REQ", "RESPONSE", "TXT_MSG", "ACK", "ADVERT", + "GRP_TXT", "GRP_DATA", "ANON_REQ", "PATH", + "TRACE", "MULTIPART", "CONTROL", "RAW_CUSTOM" + ] + }, + "payload_ver": { "type": "integer", "minimum": 0, "maximum": 3 }, + "transport_codes": { + "oneOf": [ + { "type": "null" }, + { + "type": "object", + "required": ["tc1", "tc2"], + "properties": { + "tc1": { "type": "integer" }, + "tc2": { "type": "integer" } + }, + "additionalProperties": false + } + ] + }, + "path": { + "type": "object", + "required": ["hash_size", "hops"], + "properties": { + "hash_size": { "type": "integer", "minimum": 1, "maximum": 4 }, + "hops": { + "type": "array", + "items": { "type": "string", "pattern": "^[0-9a-f]+$" } + } + }, + "additionalProperties": false + }, + "error": { + "type": "string", + "enum": ["too_short", "not_meshcore", "bad_framing"], + "description": "Expected error category for invalid packets" + }, + "payload": { + "type": "object", + "description": "Payload-type-specific decoded fields", + "properties": { + "dst": { "type": "string", "pattern": "^[0-9a-f]{2}$" }, + "src": { "type": "string", "pattern": "^[0-9a-f]{2}$" }, + "mac": { "type": "string", "pattern": "^[0-9a-f]+$" }, + "ciphertext_len": { "type": "integer", "minimum": 0 }, + "channel_hash": { "type": "string", "pattern": "^[0-9a-f]{2}$" }, + "ack_crc": { "type": "integer" }, + "pubkey_prefix": { "type": "string", "pattern": "^[0-9a-f]+$" }, + "timestamp": { "type": "integer" }, + "advert": { + "oneOf": [ + { "type": "null" }, + { + "type": "object", + "properties": { + "flags": { "type": "integer", "minimum": 0, "maximum": 255 }, + "node_type": { "type": "string" }, + "location": { + "oneOf": [ + { "type": "null" }, + { + "type": "object", + "required": ["lat", "lon"], + "properties": { + "lat": { "type": "number" }, + "lon": { "type": "number" } + }, + "additionalProperties": false + } + ] + }, + "name": { "oneOf": [{ "type": "string" }, { "type": "null" }] } + }, + "additionalProperties": false + } + ] + }, + "ephem_prefix": { "type": "string", "pattern": "^[0-9a-f]+$" }, + "trace_tag": { "type": "integer" }, + "trace_hops": { "type": "integer" }, + "multipart_remaining": { "type": "integer" }, + "multipart_inner": { "type": "string" }, + "control_subtype": { "type": "string" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + } +} diff --git a/corpus/validate.py b/corpus/validate.py new file mode 100755 index 00000000..80742076 --- /dev/null +++ b/corpus/validate.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Validate MeshCore test corpus against schema and decoder.""" +# /// script +# requires-python = ">=3.10" +# dependencies = ["jsonschema", "cobs", "pyserial", "pycryptodome"] +# /// + +import json +import re +import sys +from pathlib import Path + +import jsonschema + +# Add tools/ to path so we can import the decoder +sys.path.insert(0, str(Path(__file__).parent.parent / "tools")) +from meshcore_rx import decode_meshcore_packet # noqa: E402 + +CORPUS_DIR = Path(__file__).parent +SCHEMA_PATH = CORPUS_DIR / "schema.json" + +# ANSI escape stripper +_ANSI_RE = re.compile(r"\033\[[0-9;]*m") + + +def strip_ansi(s: str) -> str: + return _ANSI_RE.sub("", s) + + +def load_schema() -> dict: + return json.loads(SCHEMA_PATH.read_text()) + + +def find_test_cases() -> list[Path]: + cases = [] + for d in ["hand", "generated"]: + p = CORPUS_DIR / d + if p.is_dir(): + cases.extend(sorted(p.glob("*.json"))) + return cases + + +def validate_schema(case: dict, schema: dict, path: Path) -> list[str]: + """Validate a test case against the JSON schema. Returns list of errors.""" + errors = [] + try: + jsonschema.validate(case, schema) + except jsonschema.ValidationError as e: + errors.append(f"Schema: {e.message}") + return errors + + +def validate_decode(case: dict, path: Path) -> list[str]: + """Run the decoder and check output against expected fields.""" + errors = [] + packet = bytes.fromhex(case["packet_hex"]) if case["packet_hex"] else b"" + expected = case["expected"] + + # Run decoder — must not crash + try: + raw_output = decode_meshcore_packet(packet) + except Exception as e: + errors.append(f"Decoder crashed: {e}") + return errors + + output = strip_ansi(raw_output) + + if case["valid"]: + # Valid packet: should contain the expected payload type name + ptype = expected["payload_type"] + if ptype not in output: + errors.append(f"Expected '{ptype}' in output, got: {output[:120]}") + + # Check route type + rtype = expected["route_type"] + if rtype not in output: + errors.append(f"Expected route '{rtype}' in output, got: {output[:120]}") + + # Check path hops if specified + if "path" in expected: + exp_path = expected["path"] + for hop in exp_path["hops"]: + if hop not in output: + errors.append(f"Expected hop '{hop}' in output") + + # Check payload-specific fields + payload = expected.get("payload") + if payload: + _check_payload(payload, output, errors) + + # Should NOT contain error markers + for marker in ["", "", ""]: + if marker in output: + errors.append(f"Valid packet produced error marker: {marker}") + + else: + # Invalid packet: must not crash. Error marker check is best-effort + # (random mutations may accidentally produce parseable packets). + pass # decoder didn't crash — that's the key requirement + + return errors + + +def _check_payload(payload: dict, output: str, errors: list[str]): + """Check payload-specific fields in the decoder output.""" + if "ack_crc" in payload: + crc_hex = f"0x{payload['ack_crc']:08x}" + if crc_hex not in output: + errors.append(f"Expected CRC '{crc_hex}' in output") + + if "dst" in payload: + if payload["dst"] not in output: + errors.append(f"Expected dst='{payload['dst']}' in output") + + if "src" in payload: + if payload["src"] not in output: + errors.append(f"Expected src='{payload['src']}' in output") + + if "mac" in payload: + if payload["mac"] not in output: + errors.append(f"Expected mac='{payload['mac']}' in output") + + if "channel_hash" in payload: + if payload["channel_hash"] not in output: + errors.append(f"Expected ch='{payload['channel_hash']}' in output") + + if "ciphertext_len" in payload: + ct_str = f"[{payload['ciphertext_len']}B]" + if ct_str not in output: + errors.append(f"Expected '{ct_str}' in output") + + if "pubkey_prefix" in payload: + pk = payload["pubkey_prefix"] + if pk not in output: + errors.append(f"Expected pubkey prefix '{pk}' in output") + + if "advert" in payload and payload["advert"] is not None: + advert = payload["advert"] + if "name" in advert and advert["name"] is not None: + if advert["name"] not in output: + errors.append(f"Expected name '{advert['name']}' in output") + if "node_type" in advert: + if advert["node_type"] not in output: + errors.append(f"Expected node_type '{advert['node_type']}' in output") + if "location" in advert and advert["location"] is not None: + loc = advert["location"] + lat_str = f"{loc['lat']:.4f}" + if lat_str not in output: + errors.append(f"Expected lat '{lat_str}' in output") + + if "ephem_prefix" in payload: + ep = payload["ephem_prefix"] + if ep not in output: + errors.append(f"Expected ephemeral prefix '{ep}' in output") + + if "multipart_remaining" in payload: + r = payload["multipart_remaining"] + if f"remaining={r}" not in output.replace(" ", ""): + errors.append(f"Expected remaining={r} in output") + + if "multipart_inner" in payload: + inner = payload["multipart_inner"] + if inner not in output: + errors.append(f"Expected inner type '{inner}' in output") + + if "control_subtype" in payload: + sub = payload["control_subtype"] + if sub not in output: + errors.append(f"Expected control subtype '{sub}' in output") + + +def main(): + schema = load_schema() + cases = find_test_cases() + + if not cases: + print("No test cases found!") + sys.exit(1) + + passed = 0 + failed = 0 + total_errors: list[tuple[str, list[str]]] = [] + + for path in cases: + case = json.loads(path.read_text()) + name = f"{path.parent.name}/{path.stem}" + + errors = validate_schema(case, schema, path) + errors.extend(validate_decode(case, path)) + + if errors: + failed += 1 + total_errors.append((name, errors)) + print(f" \033[1;31mFAIL\033[0m {name}") + for e in errors: + print(f" {e}") + else: + passed += 1 + print(f" \033[32mPASS\033[0m {name}") + + print() + print(f"\033[1m{passed + failed} tests: {passed} passed, {failed} failed\033[0m") + sys.exit(1 if failed else 0) + + +if __name__ == "__main__": + main() diff --git a/src/channel.rs b/src/channel.rs index 12363608..c3c8e371 100644 --- a/src/channel.rs +++ b/src/channel.rs @@ -33,7 +33,7 @@ pub struct RadioStatus { pub rx_count: u32, pub tx_count: u32, pub last_rssi: Option, - pub last_snr: Option, + pub last_snr: Option, } /// Radio state machine states. diff --git a/src/display/render.rs b/src/display/render.rs index 04da0321..5013120b 100644 --- a/src/display/render.rs +++ b/src/display/render.rs @@ -26,49 +26,22 @@ const SPARK_H: i32 = 19; // y=45..63 const RSSI_MIN: i16 = -120; const RSSI_MAX: i16 = 0; -/// Render the boot splash screen. -pub fn splash(target: &mut impl DrawTarget, board_name: &str, version: &str) { - let _ = target.clear(BinaryColor::Off); - - let title_style = MonoTextStyle::new(&FONT_9X15_BOLD, BinaryColor::On); - let sub_style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On); - - Text::with_alignment( - "DongLoRa", - Point::new(W / 2, 24), - title_style, - Alignment::Center, - ) - .draw(target) - .ok(); - - Text::with_alignment(version, Point::new(W / 2, 42), sub_style, Alignment::Center) - .draw(target) - .ok(); - - Text::with_alignment( - board_name, - Point::new(W / 2, 54), - sub_style, - Alignment::Center, - ) - .draw(target) - .ok(); -} - /// Render the main status dashboard. pub fn dashboard( target: &mut impl DrawTarget, status: &RadioStatus, rssi_history: &[i16; RSSI_HISTORY_LEN], + tx_history: &[bool; RSSI_HISTORY_LEN], rssi_count: usize, + board_name: &str, + version: &str, ) { let _ = target.clear(BinaryColor::Off); let style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On); match status.config { Some(cfg) => { - // Row 0: state + frequency (full precision) + // Row 0: state + frequency, TX power right-justified let mut buf: String<32> = String::new(); let freq_mhz = cfg.freq_hz / 1_000_000; let freq_khz = (cfg.freq_hz % 1_000_000) / 1_000; @@ -76,6 +49,11 @@ pub fn dashboard( Text::new(&buf, Point::new(0, FONT_H - 1), style) .draw(target) .ok(); + buf.clear(); + let _ = write!(buf, "{}dBm", cfg.tx_power_dbm); + Text::with_alignment(&buf, Point::new(W - 1, FONT_H - 1), style, Alignment::Right) + .draw(target) + .ok(); // State indicator: inverted "RX" or "TX", plain "IDLE" match status.state { @@ -133,13 +111,9 @@ pub fn dashboard( .draw(target) .ok(); - // Row 2: TX power + counters + // Row 2: packet counters buf.clear(); - let _ = write!( - buf, - "{}dBm RX:{} TX:{}", - cfg.tx_power_dbm, status.rx_count, status.tx_count - ); + let _ = write!(buf, "RX:{} TX:{}", status.rx_count, status.tx_count); Text::new(&buf, Point::new(0, FONT_H * 3 - 1), style) .draw(target) .ok(); @@ -167,16 +141,33 @@ pub fn dashboard( .draw(target) .ok(); - // RSSI sparkline - rssi_sparkline(target, rssi_history, rssi_count); + // RSSI sparkline (TX slots shown as dotted bars) + rssi_sparkline(target, rssi_history, tx_history, rssi_count); } None => { - Text::new("IDLE", Point::new(0, FONT_H - 1), style) + let title_style = MonoTextStyle::new(&FONT_9X15_BOLD, BinaryColor::On); + Text::with_alignment( + "DongLoRa", + Point::new(W / 2, 24), + title_style, + Alignment::Center, + ) + .draw(target) + .ok(); + Text::with_alignment(version, Point::new(W / 2, 38), style, Alignment::Center) .draw(target) .ok(); - Text::new("Waiting for host...", Point::new(0, FONT_H * 3 - 1), style) + Text::with_alignment(board_name, Point::new(W / 2, 50), style, Alignment::Center) .draw(target) .ok(); + Text::with_alignment( + "Waiting for host...", + Point::new(W / 2, 63), + style, + Alignment::Center, + ) + .draw(target) + .ok(); } } } @@ -185,10 +176,13 @@ pub fn dashboard( /// /// Each bar represents one time slot. The display task advances the /// slot index on a fixed timer, so each bar covers a constant duration -/// regardless of packet rate. Bars with no packets in a slot show as empty. +/// regardless of packet rate. RX slots draw solid bars; TX slots draw +/// dotted bars (alternating pixel rows). TX takes precedence if both +/// occurred in the same slot. fn rssi_sparkline( target: &mut impl DrawTarget, history: &[i16; RSSI_HISTORY_LEN], + tx_history: &[bool; RSSI_HISTORY_LEN], count: usize, ) { if count == 0 { @@ -204,13 +198,20 @@ fn rssi_sparkline( } else { (count - RSSI_HISTORY_LEN + i) % RSSI_HISTORY_LEN }; + let is_tx = tx_history[idx]; let rssi = history[idx]; - if rssi <= RSSI_MIN { - continue; // empty slot - } - let clamped = rssi.clamp(RSSI_MIN, RSSI_MAX); - let bar_h = ((clamped - RSSI_MIN) as i32 * SPARK_H) / (RSSI_MAX - RSSI_MIN) as i32; + // TX slots with no RSSI: show a short fixed-height dotted bar + let bar_h = if rssi <= RSSI_MIN { + if is_tx { + SPARK_H / 3 // small marker for TX-only slots + } else { + continue; // empty slot + } + } else { + let clamped = rssi.clamp(RSSI_MIN, RSSI_MAX); + ((clamped - RSSI_MIN) as i32 * SPARK_H) / (RSSI_MAX - RSSI_MIN) as i32 + }; if bar_h == 0 { continue; } @@ -218,10 +219,23 @@ fn rssi_sparkline( let x = (RSSI_HISTORY_LEN - n + i) as i32 * 2; let y = SPARK_TOP + SPARK_H - bar_h; - Rectangle::new(Point::new(x, y), Size::new(2, bar_h as u32)) - .into_styled(fill) - .draw(target) - .ok(); + if is_tx { + // Dotted bar: draw every other pixel row + for row in 0..bar_h { + if row % 2 == 0 { + Rectangle::new(Point::new(x, y + row), Size::new(2, 1)) + .into_styled(fill) + .draw(target) + .ok(); + } + } + } else { + // Solid bar for RX + Rectangle::new(Point::new(x, y), Size::new(2, bar_h as u32)) + .into_styled(fill) + .draw(target) + .ok(); + } } } diff --git a/src/display/task.rs b/src/display/task.rs index f5ac17d0..5021492c 100644 --- a/src/display/task.rs +++ b/src/display/task.rs @@ -29,8 +29,10 @@ const NO_SIGNAL: i16 = -121; struct DisplayState { rssi_history: [i16; RSSI_HISTORY_LEN], + tx_history: [bool; RSSI_HISTORY_LEN], rssi_count: usize, current_slot_rssi: i16, + current_slot_tx: bool, display_on: bool, disconnected: bool, last_status: RadioStatus, @@ -40,8 +42,10 @@ impl DisplayState { fn new() -> Self { Self { rssi_history: [NO_SIGNAL; RSSI_HISTORY_LEN], + tx_history: [false; RSSI_HISTORY_LEN], rssi_count: 0, current_slot_rssi: NO_SIGNAL, + current_slot_tx: false, display_on: true, disconnected: true, last_status: RadioStatus::default(), @@ -55,12 +59,19 @@ impl DisplayState { } } - /// Advance to the next time slot, committing the current slot's RSSI. + /// Mark the current time slot as having a transmit. + fn record_tx(&mut self) { + self.current_slot_tx = true; + } + + /// Advance to the next time slot, committing the current slot's data. fn advance_slot(&mut self) { let idx = self.rssi_count % RSSI_HISTORY_LEN; self.rssi_history[idx] = self.current_slot_rssi; + self.tx_history[idx] = self.current_slot_tx; self.rssi_count += 1; self.current_slot_rssi = NO_SIGNAL; + self.current_slot_tx = false; } } @@ -80,15 +91,10 @@ pub async fn display_task( } let _ = display.set_brightness(ssd1306::prelude::Brightness::BRIGHTEST).await; - // Splash screen - render::splash(&mut display, BOARD_NAME, env!("CARGO_PKG_VERSION")); - let _ = display.flush().await; - Timer::after_millis(1500).await; - let mut state = DisplayState::new(); let mut receiver = status.receiver().unwrap(); - // Initial dashboard + // Show splash/waiting screen (dashboard with no config renders it) render_and_flush(&mut display, &state).await; loop { @@ -108,6 +114,9 @@ pub async fn display_task( state.record_rssi(rssi); } } + if radio_status.tx_count != state.last_status.tx_count { + state.record_tx(); + } state.last_status = radio_status; if state.display_on { @@ -133,8 +142,10 @@ pub async fn display_task( state.disconnected = true; state.last_status = RadioStatus::default(); state.rssi_history = [NO_SIGNAL; RSSI_HISTORY_LEN]; + state.tx_history = [false; RSSI_HISTORY_LEN]; state.rssi_count = 0; state.current_slot_rssi = NO_SIGNAL; + state.current_slot_tx = false; render_and_flush(&mut display, &state).await; } }, @@ -165,7 +176,10 @@ where display, &state.last_status, &state.rssi_history, + &state.tx_history, state.rssi_count, + BOARD_NAME, + env!("CARGO_PKG_VERSION"), ); let _ = display.flush().await; } diff --git a/src/protocol.rs b/src/protocol.rs index 5e5c70ba..adc279ce 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -68,7 +68,7 @@ pub enum Response { Config(RadioConfig), RxPacket { rssi: i16, - snr: i8, + snr: i16, payload: Vec, }, TxDone, diff --git a/src/radio/task.rs b/src/radio/task.rs index c0c1192a..957d4c0a 100644 --- a/src/radio/task.rs +++ b/src/radio/task.rs @@ -50,7 +50,7 @@ pub async fn radio_task( Ok((len, pkt_status)) => { state.rx_count = state.rx_count.wrapping_add(1); let rssi = pkt_status.rssi; - let snr = pkt_status.snr as i8; + let snr = pkt_status.snr; state.last_rssi = Some(rssi); state.last_snr = Some(snr); status.sender().send(state.clone()); diff --git a/tools/channels.csv b/tools/channels.csv new file mode 100644 index 00000000..ae19d782 --- /dev/null +++ b/tools/channels.csv @@ -0,0 +1,277 @@ +channel_name,hashtag,key_hex,channel_hash,description,region +Public,False,8b3387e9c5cdea6ac9e5edbaa115cd72,17,"Default public channel (fixed PSK, not hashtag-derived)",global +#test,True,9cd8fcf22a47333b591d96a2b848b73f,156,Global testing channel,global +#testing,True,cde5e82cf515647dcb547a79a4f065d1,205,Testing (alternate name),global +#ping,True,3cae16fd067ba9c32a98be22e9b98525,60,Ping-bot testing channel,global +#emergency,True,e1ad578d25108e344808f30dfdaaf926,225,Emergency communications,global +#hamradio,True,83c8b01997654265938da8765cbc7db9,131,HAM radio operators,global +#qrv,True,4d6de18b9bb6ef3f7c7197e8441044ed,77,HAM QSO coordination/logging,global +#hard-software,True,c4419524d9ce72ac093fdd2a0a856595,196,Hardware/software discussion & troubleshooting,global +#blackout,True,75420460f335577fa27cdd20dacf9edd,117,Power outage info/notifications,global +#warnings,True,31d44d29ed676f8020dc09211a2ee9d8,49,General warnings channel,global +#aurora-alert,True,e823127951fc6a425abe0c65ac07b323,232,Aurora/northern lights alerts,global +#flachwitze,True,ec98cd8821fada7fe21ff9e1df6c619f,236,Jokes channel (German flat jokes),global +#general,True,4c49f3f24629f5ee4ad5b3965db47985,76,General chat (common default),global +#switzerland,True,8ad1ce57ad257627090ed28413c1f0b7,138,Swiss national channel,CH +#svizzera,True,e571e6921d9c19d1c393f2ba9286abba,229,Italian-speaking Switzerland,CH +#suisse,True,2b8dd2d22f0820d9945316be97359ab1,43,French-speaking Switzerland,CH +#chtest,True,ee95ca5221f5b41941b26d89fd702c37,238,Swiss testing channel,CH +#bern,True,7658cadca2e40ba98cb13ce19782e994,118,Bern region,CH +#zuerich,True,15d09e6787401c4fdbef4bf10ffcef53,21,Zürich canton/city,CH +#wallis,True,1e65af5328cec2c29d03c77a5dfb8cb8,30,Wallis/Valais canton,CH +#freiburg,True,ec1c88f03aad858a6d74ec36ac04c420,236,"Freiburg (DE, near CH border)",CH/DE +#netzstatus,True,e84d5ce15046d5a7f69db3a40f604851,232,"Network status, mainly Germany",DE +#muenchen,True,3f50b3350a8ed17a303f07e9641dff4a,63,Munich area,DE +#hansemesh,True,21ddee26dc5215153c3903165fd4262f,33,HanseMesh community (Hamburg),DE +#de-sn-dd,True,c252cf274c306e76d9531fdf96f8dfa0,194,Dresden/Saxony test channel,DE +#berlin,True,8bffd7b0bf481d92afc625e409b88a16,139,Berlin (mentioned in MeshCore tutorial),DE +#hamburg,True,6ee6b4b98d5856392596cc1929eb2e36,110,Hamburg area,DE +#koeln,True,cc2de011842824b10ee2e28e97f91339,204,Cologne area,DE +#frankfurt,True,0d8e969330d62f9356b5e4d415ea7f98,13,Frankfurt area,DE +#stuttgart,True,74710f9e61866f184734bc367d9f492a,116,Stuttgart area,DE +#deutschland,True,6870d6013221f0278122cb8c88990794,104,Germany national,DE +#ruhrgebiet,True,6b5f2c549d9de0116749aa087e3caa4e,107,Ruhr area,DE +#nuernberg,True,a6e4f905a64512843b12ec20750f4161,166,Nuremberg area,DE +#dresden,True,a6f1bb7dbc598884b7a15be59e61ecd6,166,Dresden area,DE +#leipzig,True,0cc55d55809a7b048c39dd01c3254381,12,Leipzig area,DE +#hannover,True,b9430bfc2908b23efcae886f16eb425b,185,Hannover area,DE +#duesseldorf,True,1dfb747e50229101f2232e391d1eb5a7,29,Düsseldorf area,DE +#dortmund,True,37951d1ca4e428fd24c57f3087f160f0,55,Dortmund area,DE +#essen,True,80174f513e9099612b537bc1cd450a41,128,Essen area,DE +#austria,True,faaa5ef01081222e319a8205357321f4,250,Austria national channel,AT +#wien,True,aba9d5e2fb16d2b02111db5926819065,171,Vienna,AT +#graz,True,ea00635b84c245d519d29dc4044a43a1,234,Graz,AT +#salzburg,True,132d8883191473fc8a82f3447909a37d,19,Salzburg,AT +#innsbruck,True,dee607ccd01ca7ef375c0567c554db97,222,Innsbruck,AT +#linz,True,9b417e09eb452be6d068c90b3c46303d,155,Linz,AT +#italy,True,f8d4dd36f6b9476eb4cec18a1536b17d,248,Italy (English name variant),IT +#italia,True,17fa9f15bec81ed7da12116253db5a20,23,Italy (Italian name variant),IT +#roma,True,3523b8022329e6648738f0e5448ffcc1,53,Rome,IT +#milano,True,ea8929998e608b67fe5c2bcf29473d8f,234,Milan,IT +#nederland,True,c14f7b1e0416e4fa74f5f7b81a707b50,193,Netherlands national,NL +#amsterdam,True,d768f5a0aa65f8c54e4ea521bd49eb4f,215,Amsterdam,NL +#rotterdam,True,e3f3ad251e512a029bd27161b14f0bdd,227,Rotterdam,NL +#utrecht,True,042e458dfb05b17cbdee57f93df4be4c,4,Utrecht,NL +#denhaag,True,84eed36f62c7b22527dbf8883585ad14,132,The Hague,NL +#eindhoven,True,aceb8cceae584a3890c8db173668ae2b,172,Eindhoven,NL +#groningen,True,6a18329035bd3d2be69fed9094ffa5f4,106,Groningen,NL +#localmesh,True,13934f45750ce5f54311771bc52dc2a9,19,LocalMesh NL community,NL +#belgium,True,7085b78ed010599094f8c8e7d1aa0e27,112,Belgium national,BE +#belgie,True,559a87a20b1170a11656ffbe15d27c07,85,Belgium (Dutch),BE +#brussel,True,58648303d8e2b4e35f4301c4551d6307,88,Brussels,BE +#antwerpen,True,5900168627ddb2963d3fd8dba3fecbb9,89,Antwerp,BE +#gent,True,82f82c2835bd5fbda1ab0eb134364532,130,Ghent,BE +#france,True,edcb362b38e74b99025a7e551d925d20,237,France national,FR +#paris,True,d0fc2b1ec400eb8669010ce0311a00fb,208,Paris,FR +#lyon,True,0b4b602516088ea6d245be43102643b0,11,Lyon,FR +#marseille,True,c91ed99a23e560300b60325c0b9fb3ea,201,Marseille,FR +#uk,True,22b2eed34b5cc429ce1dc5e88635ff84,34,United Kingdom,UK +#london,True,9881d2b7ab9105a41a8d0f6ba449447e,152,London (mentioned in MeshCore tutorial),UK +#manchester,True,5ddc4f781fa3f504e9f514e77df13790,93,Manchester,UK +#birmingham,True,13b1c76ef007b4ad0c3b168610337513,19,Birmingham,UK +#scotland,True,f4fce403cfd56f7089920d065718f29c,244,Scotland,UK +#ireland,True,1b2a12acc5db1517d9d407946756b1da,27,Ireland,IE +#dublin,True,8792638977132bc05a1f72d6bb913694,135,Dublin,IE +#seattle,True,ef627a9bbbb549347fdb76bf0cd3bc14,239,Seattle (Puget Mesh),US-WA +#bot,True,eb50a1bcb3e4e5d7bf69a57c9dada211,235,Bot channel (Puget Mesh),US-WA +#bot-tacoma,True,278df637e12a0dda01c826e1fd9779fd,39,Bot channel Tacoma,US-WA +#bot-van,True,bf07fbf914e37710561cb741e914efeb,191,Bot channel Vancouver,US-WA/CA +#capitolhill,True,7f281916c8ec32e13c5ef687d182160a,127,Capitol Hill Seattle,US-WA +#protest,True,195e9167baf010c4538d8532b6d7296e,25,Protest coordination,US-WA +#queer,True,5754476f162d93bbee3de0efba136860,87,LGBTQ+ community,US-WA +#seahawks,True,4973d6ceb6c35a09894a83688847c8a3,73,Seattle Seahawks,US-WA +#sports,True,e8ee81f3aabf105d9ba2d2d4bd94fe4a,232,Sports discussion,US-WA +#salishmesh,True,c1ec55555132c7598393c869715b7555,193,Salish Mesh (Vancouver BC area),CA-BC +#dwebyvr,True,b8f956aab74dae02f70f11ddba36de53,184,DWeb Vancouver,CA-BC +#zspace,True,fc86b8cceea87091d45f166c7a698f78,252,ZSpace Vancouver,CA-BC +#portland,True,45c6bc719c15b9fb809f48f594359877,69,Portland OR,US-OR +#pnw,True,98059014c708581fbf0a398cfe8a486d,152,Pacific Northwest,US-PNW +#tacoma,True,9ba83a22a865c9baaffc6bc8b6a33d2f,155,Tacoma WA,US-WA +#idahomesh,True,60baad727ec5235cee359135aeecb3f4,96,Idaho statewide mesh,US-ID +#nodakmesh,True,156134e4a3f6d9826a39e0b7f85afdcd,21,North Dakota mesh,US-ND +#florida,True,c44bd74eac2c81dcb6dfb217727b05cd,196,Florida statewide,US-FL +#miami,True,d81c566a5d337d588ebd250df6fc1b63,216,Miami,US-FL +#tampa,True,8d10508c39d5d8e6c5e3e8fab41d1c09,141,Tampa,US-FL +#orlando,True,3553bdbd9b3a54da760624a27dcda156,53,Orlando,US-FL +#jacksonville,True,6cf63df8459d99d4a4a6177acf6661f6,108,Jacksonville,US-FL +#texas,True,c4a214e133de5e9ad276f99fdbd7216f,196,Texas statewide,US-TX +#austin,True,b2e6f9af95d959734d71cdf90ca62533,178,Austin TX,US-TX +#dallas,True,5b2efa4e2ad0a2b83c5486ca4dd244de,91,Dallas TX,US-TX +#houston,True,c001fbacad2d97676395ca37e2576345,192,Houston TX,US-TX +#sanantonio,True,c2e72548c615d1cf2796a6f77b9545b0,194,San Antonio TX,US-TX +#dfw,True,5b7dc809ba579affccab5462c537244e,91,Dallas-Fort Worth,US-TX +#california,True,55da17570958a6a311939c99582e6f60,85,California statewide,US-CA +#losangeles,True,3dd9373dcea0294bd05ab067cc58e9d4,61,Los Angeles,US-CA +#sanfrancisco,True,ae3608083279fd2ad03f0d6a99617d25,174,San Francisco,US-CA +#sandiego,True,08623bbd90a96ecdc1f5c34e7292b35f,8,San Diego,US-CA +#bayarea,True,7f9a5fd3070ad14e337ba100ca53a89b,127,Bay Area,US-CA +#sacramento,True,a6d6927f0b48762cf1346e2ae95cec14,166,Sacramento,US-CA +#socal,True,f4018307615ac79d2e5ef17bb44654d4,244,Southern California,US-CA +#norcal,True,f2438510715f5d9d55eb4370664330f5,242,Northern California,US-CA +#sanjose,True,ce964c28170b1c043d06073e6fcd83a7,206,San Jose,US-CA +#sfbay,True,5bd50243f42644439c21dfe367eea031,91,SF Bay,US-CA +#baymesh,True,1be0e569ad371f72d1e36da7242ae2f0,27,Bay Mesh community,US-CA +#boston,True,9587d847a7208da684c89cc1f525bc03,149,Boston MA,US-MA +#newyork,True,82a78024dd7edf6c9be298c919632e25,130,New York,US-NY +#nyc,True,6e6554655f84ca26fbc09d81d15d6b96,110,New York City,US-NY +#chicago,True,c1c289b131e5222370cbc2048445844b,193,Chicago IL,US-IL +#denver,True,b24355a0d22ed2bf393ec530d75810b4,178,Denver CO,US-CO +#phoenix,True,027850d9410fa98809819c96644ec04c,2,Phoenix AZ,US-AZ +#atlanta,True,3c8f15665f99a349a97427e7c312ee0e,60,Atlanta GA,US-GA +#nashville,True,717490d2561c2e1f211b13e95073fb16,113,Nashville TN,US-TN +#charlotte,True,b67efd607080d98640774cb31181b0ce,182,Charlotte NC,US-NC +#raleigh,True,1a2999ed1fea10e2cdf456ff118193d9,26,Raleigh NC,US-NC +#minneapolis,True,3283cca82b7b0ac50e8014c344cb8a86,50,Minneapolis MN,US-MN +#stlouis,True,f366422991a19e745f65096e59b43d51,243,St. Louis MO,US-MO +#pittsburgh,True,926cb5461a1ef572619da576ef7de04b,146,Pittsburgh PA,US-PA +#detroit,True,bd01f26bf7d8c90952753157a41c61ac,189,Detroit MI,US-MI +#washingtondc,True,0067d451cc79f26bc9924b3fc53f28d7,0,Washington DC,US-DC +#dc,True,0f3aa71fed514f5c16ebaf265ef05b2e,15,DC (short form),US-DC +#colorado,True,ef61c9e5a3286053746f7603044bcb08,239,Colorado statewide,US-CO +#ohio,True,e89e8c2f72ebae476c4ded78a360d0ea,232,Ohio statewide,US-OH +#virginia,True,07655778c8adeb16c1a0470cc3516b01,7,Virginia statewide,US-VA +#georgia,True,6621a99c4c21a1f4897a9833a95ac962,102,Georgia statewide,US-GA +#michigan,True,4b0dc02774aa2ad8fe120d4ab0d17f1c,75,Michigan statewide,US-MI +#newengland,True,4ad1e19832319b916ab4f3a0fbb0a38e,74,New England region,US-NE +#appalachia,True,ab353b2bf5d281d97cd73d897ef8e149,171,Appalachian region,US +#hawaii,True,3fd57495820328594e1641d14583faa6,63,Hawaii,US-HI +#alaska,True,2a5841192b151422baec71537b0b5238,42,Alaska,US-AK +#vegas,True,d45435b6467e7ded33c28f6796bf5183,212,Las Vegas,US-NV +#lasvegas,True,b82b823d6dcc845ead47cee8cfa758b3,184,Las Vegas (long form),US-NV +#saltlakecity,True,07e180549b35782901d407832b3ec6a6,7,Salt Lake City,US-UT +#slc,True,fd31a70b220eb3e2430e59c15432ef8c,253,SLC (short form),US-UT +#utah,True,ee42ff5f055f2e83527e4efb91b10d0a,238,Utah statewide,US-UT +#canada,True,8373bb1055f34164c6dd2663927cb6d9,131,Canada national,CA +#toronto,True,e0d3774dd1da4dc5c55d8bd731555334,224,Toronto,CA-ON +#vancouver,True,16d6034be448ab86d11858cfa4c57c9c,22,Vancouver,CA-BC +#montreal,True,0c4c03b5fbea5b80f89e2a2a16ed3f40,12,Montreal,CA-QC +#calgary,True,bbbb1ad23fe1cdd7739667418204a57d,187,Calgary,CA-AB +#ottawa,True,7871ec72b45617696c35c970bddd8124,120,Ottawa,CA-ON +#edmonton,True,ce451a3a24ee89ee31564ae29e9dee04,206,Edmonton,CA-AB +#winnipeg,True,3c3294aac8a83b1143ab6bedee5b5226,60,Winnipeg,CA-MB +#australia,True,eadb84fa1da64c44b77c40fd11b9d78e,234,Australia national,AU +#sydney,True,57fe5284a5b905835193868d9bdbe1e9,87,Sydney,AU-NSW +#melbourne,True,4d73731d9450ccb9673eb923c0f40af2,77,Melbourne,AU-VIC +#brisbane,True,e4bd09784621decac600d0d4d857e3b2,228,Brisbane (MBUG community),AU-QLD +#perth,True,9873c6cf14dac409b5a573574cff5ce1,152,Perth,AU-WA +#adelaide,True,9ad0c536f3eabc7c2f0434d68e242009,154,Adelaide,AU-SA +#canberra,True,c716951221e6c17faf5f22b6245ce64b,199,Canberra,AU-ACT +#newzealand,True,1870a961e4aa06f62b02b835efcacb71,24,New Zealand national,NZ +#nz,True,eb87ee8817ba71315ac7be9c733b523a,235,NZ (short form),NZ +#auckland,True,7c6f1c71a5a3d6823a1d7bfd2a349ac8,124,Auckland,NZ +#wellington,True,c2da57d74f78996ad71bbe3e22446f16,194,Wellington,NZ +#christchurch,True,7b88b875fc5ae76305c8a15349549a3b,123,Christchurch,NZ +#gisborne,True,d8b45a1eb52d0bd45655d5f2a72d571f,216,Gisborne (mentioned in MeshCore tutorial),NZ +#weather,True,88f502554fee92a1625cfb311546e7cb,136,Weather updates,topical +#jokes,True,abe610d61dee508c0ac18793c36c41c3,171,Jokes channel,topical +#quotes,True,086be6f920280b7cc51d6ad91d6a786c,8,Quotes channel,topical +#secret,True,be657c0527e122bf93bc735999cd7e0d,190,Secret channel (Brisbane MBUG),topical +#chat,True,d0bdd6d71538138ed979eec00d98ad97,208,General chat,topical +#news,True,ecadb1a7d803db8958bea1302ca6e8be,236,News channel,topical +#help,True,dcc67fae2067046832af7b2b0b743165,220,Help channel,topical +#sos,True,9ed2c78bcd68ac7ce2a2fc3bb4045114,158,SOS / distress,topical +#prepper,True,1eec1a7df7080a392cb490473c4a9920,30,Prepper community,topical +#offgrid,True,aaa26662bebf122262692d0bca61dcef,170,Off-grid living,topical +#radio,True,266f225baced1b2a868dcc8e9c69a304,38,Radio hobby,topical +#lora,True,0749ea267c6be7b54ca1dbbae7dba0aa,7,LoRa technology discussion,topical +#meshcore,True,2fa78a5aef618e7c2a78f0ab5c8869b3,47,MeshCore project discussion,topical +#meshtastic,True,73a2e13ff0dea9ed19b24b2ab753650f,115,Meshtastic discussion,topical +#iot,True,233b42534ed82b8ae6a88d785f1b27fd,35,IoT / sensors,topical +#solar,True,7592a730ff1af2b40b95589fbafcca01,117,Solar power,topical +#emcomm,True,a9a49340642dfc4c562d7849b7c8a258,169,Emergency communications,topical +#ares,True,44419e394cde859e45710f288db939a8,68,ARES (Amateur Radio Emergency Service),topical +#gmrs,True,2ad811e84ab4ad0e3a648d8fcf3d650a,42,GMRS radio,topical +#cb,True,a35b5f87bbb67859a02e7666c823de7a,163,CB radio,topical +#hiking,True,2370a013053e384e5f18918bc2b26baf,35,Hiking,topical +#camping,True,c011b7c2abf33748cd9bd5a78c2b4955,192,Camping,topical +#hunting,True,0c0c28fd9bbf096ee06e01af840037af,12,Hunting,topical +#fishing,True,a671ca13cc499898aea4596e804ecf20,166,Fishing,topical +#skiing,True,b3baf3586d347ecaf2c113106a564207,179,Skiing,topical +#biking,True,9c0591d708b7c7970c9ad6712e3c4d05,156,Biking,topical +#ham,True,5270db3979da687fa133fec6684cd952,82,HAM radio (short form),topical +#cq,True,bc9da8bdebcaeee40fefff7dd16855f1,188,CQ (calling all stations),topical +#dx,True,adf77706da76b66bb0a313c1484d3be4,173,DX (long distance contacts),topical +#repeater,True,289991a3077903263f2d31493887c651,40,Repeater discussion,topical +#firmware,True,6163564e7449c1c842e58dcf2cd1ab4f,97,Firmware discussion,topical +#debug,True,fd7a60ed4796efcd1965c2de466105cb,253,Debug channel,topical +#dev,True,d41bcba61e9dca7177c7b8533d23a0bc,212,Development channel,topical +#admin,True,889334b7e486938c776dbdde120da9de,136,Admin channel,topical +#monitor,True,131479235e65b7c591307f699d64dc74,19,Monitoring channel,topical +#alert,True,678c7d2e08c019e113ace03eaaa128ad,103,Alert channel,topical +#local,True,d2d35fa76be9875ed254db80397483a5,210,Local community,topical +#neighborhood,True,53403881955f0e11573ba1e5d3a82c4b,83,Neighborhood chat,topical +#community,True,b9323ce55a729068ed8e3ed49b9b84b5,185,Community channel,topical +#events,True,d2cbcc606979cdb05c7bd66f49f23baa,210,Events channel,topical +#trade,True,c2be982890fb3a63113de637b9aee6fe,194,Trading/swap channel,topical +#marketplace,True,354a741c2975c843ec0706089390b968,53,Marketplace,topical +#gaming,True,3802c79121f195ea50ad9ab2aa2c402a,56,Gaming,topical +#music,True,b025ab29b0a5f56fb68a474741d7a2cc,176,Music,topical +#tech,True,5177b749eefbfab3c90a21b4e2518c5f,81,Technology,topical +#science,True,8abc980d3764fdabda5dfa6b6bc3a6cc,138,Science,topical +#crypto,True,0aa7c45d99be27024b82a1474c693951,10,Cryptocurrency,topical +#bitcoin,True,94bf6fd2688b1456c071ebad63d7021d,148,Bitcoin,topical +#linux,True,02c74eda5d8ec8b9bb945049fb2f55c6,2,Linux,topical +#arduino,True,ce8c596922eff9274f3b1e19ad296754,206,Arduino,topical +#raspberry,True,469bc476d8263b64b6f90ca868b04b91,70,Raspberry Pi,topical +#esp32,True,9f8aac4c48973b07bbdc47acbaea3e8e,159,ESP32 projects,topical +#heltec,True,71af5d9a209e8b17a19ee22aa7b6be23,113,Heltec devices,topical +#lilygo,True,a33dd4b025cc82b81f97318a63a9d5d3,163,LilyGo devices,topical +#rak,True,1f4734675fdfcabfaa67a0a059c65c5f,31,RAK Wireless devices,topical +#antenna,True,e8979157107155ade90822e29b186ee8,232,Antenna discussion,topical +#rf,True,46aaa16f78b3969040aad2b6b694b59e,70,RF engineering,topical +#preppers,True,4877173813de9668b9ef33adbc1b8f37,72,Preppers community,topical +#survival,True,e1f465ea51df09fc901389758f1c5f01,225,Survival skills,topical +#grid-down,True,94ddbd51f2ee0bd47426a6f3ed79be99,148,Grid-down scenarios,topical +#wildfire,True,b9124c3aecc0c813634f79f144b93f83,185,Wildfire alerts,topical +#hurricane,True,c13a2f9d18400e5d93bae4bb3afef40c,193,Hurricane alerts,topical +#earthquake,True,0c7082c04a1a90502a5f32fc6a9f6524,12,Earthquake alerts,topical +#flood,True,f2a0fd0abe4c9fc6865b5f8eebf319d6,242,Flood alerts,topical +#tornado,True,76e577e8187d0bab89052343f3835039,118,Tornado alerts,topical +#storm,True,113761b9e31a5c30e0ee4fc78dc7310b,17,Storm alerts,topical +#traffic,True,c1216dacbb7e4c3b9a3356e52f1c27fa,193,Traffic updates,topical +#fire,True,3d74a070077293ab66baf3aa724349ff,61,Fire alerts,topical +#police,True,79b89aa870ea85dbb8396f401e547d99,121,Police scanner / alerts,topical +#ems,True,f714461f26d43d9a17b2530c3ad29bc2,247,EMS / first responders,topical +#search-rescue,True,cd8ab9e4944ee656ae6e961a4437c551,205,Search and rescue,topical +#volunteer,True,2d3d64746075cb99407d681e547b99cb,45,Volunteer coordination,topical +#data,True,b0d9c01c8278a711c68ffdca98ef1245,176,Data/telemetry channel,topical +#sensor,True,6218df72386dd48259024327e8ebf66f,98,Sensor data channel,topical +#tracker,True,70258e69aeb03d1cd2e16072e1fe14b5,112,Asset/pet tracker,topical +#gps,True,af4b9f722a6200b1eb2696660017d0df,175,GPS tracking,topical +#boat,True,c03117578e38c3f1eb5ae8c3d0019753,192,Boating/maritime,topical +#sailing,True,c2f6052f8b89639f81c8ff4def32deae,194,Sailing,topical +#marine,True,7f4b40aac63642805a54a959beb07694,127,Marine channel,topical +#aviation,True,e17538c835ee0990b349ca1879b4f57a,225,Aviation,topical +#drone,True,4460931543103de42d2f85093a80273c,68,Drone / UAV,topical +#fpv,True,6d2752bc94797cfce43e2138eab7a772,109,FPV flying,topical +#farm,True,4a2f1b2147165cf942402a4c6bd7d42d,74,Farming / agriculture,topical +#ranch,True,5885e728fc06bfcbe7d6c9020c13ff10,88,Ranch / livestock,topical +#tucson,True,cf989ccea881cf5ddcf40d87bbfc441e,207,Tucson AZ,US-AZ +#albuquerque,True,2a59b6ce7d3ce661664217f9b8255e86,42,Albuquerque NM,US-NM +#boise,True,f4e641f9c90ac3bd5bc442ef379f6eb3,244,Boise ID,US-ID +#spokane,True,dfcf2a863b3baa99e3d3274bfb56439f,223,Spokane WA,US-WA +#olympia,True,baec83dd15ef758ad8bf8f357237c9cb,186,Olympia WA,US-WA +#bellingham,True,6000c8e3c1343fd920749eb3ad2a42b4,96,Bellingham WA,US-WA +#eugene,True,e6ce6f5e71d88bb90c0b7dacc2235662,230,Eugene OR,US-OR +#bend,True,291ddea6fc1c1742f7ad425819b66f7b,41,Bend OR,US-OR +#reno,True,e9edf1edf57a04b8f66e75829b4edc06,233,Reno NV,US-NV +#provo,True,72744153a7a2d84ed69338be4dc3fc31,63,Provo,US-UT +#orem,True,97236968b8a6c0cf297f1c1365539d0d,157,Orem,US-UT +#ogden,True,cdeec2a40203056a6c6f2d1fb5f50b81,245,Ogden,US-UT +#logan,True,b0ee430ede14470d5ff8fafbe092ee50,145,Logan,US-UT +#stgeorge,True,7792659def2811af89a02270b2918235,128,St. George,US-UT +#parkcity,True,bbb64d6ae8eaeaf4302f501844f64511,25,Park City,US-UT +#lehi,True,18f20a96d251665105d59df6ca5bcc5c,98,Lehi,US-UT +#byu,True,65633d6a99bbcb173cb01afeae3a51d0,194,Brigham Young University,US-UT +#uvu,True,cb305b9b3d0ab33fce2186ee6b9c58ff,91,Utah Valley University,US-UT +#uofu,True,c08fe0d4d37b4b7f233f2977296979d5,178,University of Utah,US-UT +#usu,True,ceb222637d010d1c5551a5035e86f89d,104,Utah State University,US-UT +#utahcounty,True,f191a8a833d2ae61b7fbfa8c9891cc0c,23,Utah County,US-UT +#daviscounty,True,926847daaed0baa7758df5ef8af76f32,11,Davis County,US-UT +#webercounty,True,364726e43d1fb9b8079ededb03f00764,172,Weber County,US-UT +#wasatchfront,True,eb9fa3bfa390eed3737634bed39c7460,44,Wasatch Front metro corridor,US-UT +#wasatch,True,15bf0e80d3d88092d38e4c82da7e523e,0,Wasatch region,US-UT +#saltlake,True,56327c6958d9108213f8638fb2a2b7a4,173,Salt Lake (alternate),US-UT +#tooele,True,092cbdcf351bdbe77ce6c00e1dbeb294,184,Tooele,US-UT +#sanpete,True,4ae01748b17bfc90e13004f19e734bbc,151,Sanpete County,US-UT +#watchman,True,11489fe661e776b3ccdcf864bd14f7d6,245,Watchman mesh health alerts,global diff --git a/tools/meshcore_rx.py b/tools/meshcore_rx.py new file mode 100755 index 00000000..844966f9 --- /dev/null +++ b/tools/meshcore_rx.py @@ -0,0 +1,993 @@ +#!/usr/bin/env python3 +"""DongLoRa MeshCore receiver: decode and display MeshCore packets.""" +# /// script +# requires-python = ">=3.10" +# dependencies = ["cobs", "pyserial", "pycryptodome"] +# /// + +import base64 +import csv +import glob +import hashlib +import hmac +import json +import struct +import serial +import sys +import tempfile +import time +from cobs import cobs +from Crypto.Cipher import AES +from datetime import datetime, timezone +from pathlib import Path + + +# ── ANSI colors ─────────────────────────────────────────────────── + +RST = "\033[0m" +BOLD = "\033[1m" +DIM = "\033[2m" +RED = "\033[31m" +GRN = "\033[32m" +YEL = "\033[33m" +BLU = "\033[34m" +MAG = "\033[35m" +CYN = "\033[36m" +WHT = "\033[37m" +BRED = "\033[1;31m" +BGRN = "\033[1;32m" +BYEL = "\033[1;33m" +BBLU = "\033[1;34m" +BMAG = "\033[1;35m" +BCYN = "\033[1;36m" +BWHT = "\033[1;37m" + +TYPE_COLORS = { + "ADVERT": BGRN, + "ACK": DIM, + "TXT_MSG": BCYN, + "REQ": BYEL, + "RESPONSE": BYEL, + "GRP_TXT": BMAG, + "GRP_DATA": BMAG, + "ANON_REQ": YEL, + "PATH": BBLU, + "TRACE": BLU, + "MULTIPART": YEL, + "CONTROL": BLU, + "RAW_CUSTOM": DIM, +} + + +def _rssi_color(rssi: int) -> str: + if rssi >= -70: + return GRN + if rssi >= -100: + return YEL + return RED + + +def _snr_color(snr: int) -> str: + if snr >= 0: + return GRN + if snr >= -10: + return YEL + return RED + + +# ── MeshCore channel crypto ──────────────────────────────────────── + +def _channel_secret_from_hashtag(name: str) -> bytes: + """Derive 32-byte secret from a hashtag channel name (e.g., '#test').""" + h = hashlib.sha256(name.encode()).digest() + return h[:16] + b"\x00" * 16 + + +def _channel_secret_from_psk(psk_b64: str) -> bytes: + """Decode a base64 PSK into a 32-byte secret (16-byte key + 16 zero bytes).""" + key = base64.b64decode(psk_b64) + return key[:16] + b"\x00" * 16 + + +def _channel_hash(secret: bytes) -> int: + """Compute the 1-byte channel hash from a 32-byte secret.""" + # Hash over the key portion (16 bytes for hashtag/PSK channels) + key_len = 16 if secret[16:] == b"\x00" * 16 else 32 + return hashlib.sha256(secret[:key_len]).digest()[0] + + +def _grp_verify_and_decrypt(secret: bytes, mac_bytes: bytes, ciphertext: bytes) -> bytes | None: + """Verify MAC then decrypt a GRP_TXT/GRP_DATA payload. Returns plaintext or None.""" + if not ciphertext or len(ciphertext) % 16 != 0: + return None + # HMAC-SHA256 over ciphertext, truncated to 2 bytes + key_len = 16 if secret[16:] == b"\x00" * 16 else 32 + computed_mac = hmac.new(secret[:key_len], ciphertext, hashlib.sha256).digest()[:2] + if computed_mac != mac_bytes: + return None + # AES-128-ECB decrypt + cipher = AES.new(secret[:16], AES.MODE_ECB) + plaintext = b"" + for i in range(0, len(ciphertext), 16): + plaintext += cipher.decrypt(ciphertext[i:i + 16]) + return plaintext + + +def _parse_grp_plaintext(plaintext: bytes) -> tuple[int, str] | None: + """Parse decrypted GRP_TXT: timestamp(4) + flags(1) + text. Returns (timestamp, text).""" + if len(plaintext) < 5: + return None + timestamp = struct.unpack_from(" bytes: + """Build an encrypted GRP_TXT payload (channel_hash + mac + ciphertext).""" + ch = _channel_hash(secret) + # Plaintext: timestamp(4) + flags(1) + "sender: text\0" + zero-pad + plaintext = struct.pack(" bytes: + """Wrap encrypted GRP_TXT payload into a full MeshCore packet.""" + header = bytes([0x15]) # GRP_TXT flood: (5 << 2) | 1 + path_len = bytes([0x40]) # 0 hops, 2-byte hash mode (hash_size_code=1 << 6) + return header + path_len + channel_payload + + +def _grp_transmit(ser, channel_name: str, sender: str, text: str): + """Encrypt and transmit a GRP_TXT message, then resume RX.""" + secret = _KNOWN_CHANNELS.get(channel_name) + if secret is None: + return + payload = _grp_encrypt(secret, sender, text) + packet = _grp_build_packet(payload) + try: + send_cmd(ser, {"type": "Transmit", "payload": packet}, + f"TX GRP_TXT → {channel_name}") + except Exception as e: + print(f" {RED}[TX failed: {e}]{RST}") + # Always try to resume RX, even if TX failed + try: + send_cmd(ser, {"type": "StartRx"}, "StartRx (resume)") + except Exception as e: + print(f" {RED}[StartRx failed: {e}]{RST}") + + +# ── Loop/collision aggregator ───────────────────────────────────── + +REPORT_CHANNEL = "#watchman" +REPORT_SENDER = "Watchman" +REPORT_WINDOW = 60 # seconds — collect events before reporting +REPORT_COOLDOWN = 3600 # seconds — suppress repeat reports for same issue + + +def _nodes_for_hash(hash_hex: str, hash_size: int) -> int: + """Count how many known nodes share this path hash.""" + return sum(1 for pk in _known_nodes if pk[:hash_size * 2] == hash_hex) + + +class LoopAggregator: + def __init__(self): + self._pending: list[dict] = [] + self._cooldown: dict[str, float] = {} + self._window_start = time.monotonic() + + def record(self, dupes: dict[str, int], hash_size: int, all_hops: list[str]): + """Record duplicate hops from one packet.""" + first_hop = all_hops[0] if all_hops else None + self._pending.append({ + "dupes": dupes, "hash_size": hash_size, "hops": all_hops, + "first_hop": first_hop, + }) + + def maybe_report(self, ser) -> bool: + if not self._pending: + self._window_start = time.monotonic() + return False + if time.monotonic() - self._window_start < REPORT_WINDOW: + return False + self._send_report(ser) + return True + + def _send_report(self, ser): + now = time.monotonic() + messages: list[str] = [] + + # Aggregate: (hash, hash_size) → {packets, relayers, senders} + agg: dict[tuple[str, int], dict] = {} + for ev in self._pending: + for h, count in ev["dupes"].items(): + key = (h, ev["hash_size"]) + if key not in agg: + agg[key] = {"packets": 0, "relayers": set(), "senders": set()} + agg[key]["packets"] += 1 + if ev["first_hop"]: + agg[key]["senders"].add(ev["first_hop"]) + for hop in ev["hops"]: + if hop != h: + agg[key]["relayers"].add(hop) + + for (h, hs), info in agg.items(): + issue_key = f"{h}:{hs}" + if issue_key in self._cooldown and now - self._cooldown[issue_key] < REPORT_COOLDOWN: + continue + + n_known = _nodes_for_hash(h, hs) + pkts = info["packets"] + senders = sorted(info["senders"]) + sender_str = ",".join(senders[:3]) if senders else "?" + if len(senders) > 3: + sender_str += f"+{len(senders) - 3}" + relayer_str = ",".join(sorted(info["relayers"])[:3]) + if len(info["relayers"]) > 3: + relayer_str += f"+{len(info['relayers']) - 3}" + + if n_known == 1: + # Exactly one node matches → confirmed loop + msg = f"Loop: {h} re-sent its own traffic ({pkts} pkt)." + if relayer_str: + msg += f" Relayed by {relayer_str}." + messages.append(msg) + elif hs >= 2: + if n_known > 1: + # 2B+ hash collision — rare, definitely a routing issue + msg = (f"Routing conflict: {n_known} nodes share {hs}B " + f"hash {h} ({pkts} pkt).") + messages.append(msg) + else: + # 2B+ duplicate, unknown node → almost certainly a loop + msg = f"Probable loop: {h} appeared {pkts}x." + if relayer_str: + msg += f" Relayed by {relayer_str}." + messages.append(msg) + else: + # 1B hash — can't distinguish loop from collision + if n_known > 1: + msg = (f"1B hash {h} duplicated in {pkts} pkt " + f"({n_known} known nodes share it). " + f"Sender {sender_str}: use 2B routing!") + messages.append(msg) + else: + msg = (f"1B hash {h} duplicated in {pkts} pkt. " + f"Sender {sender_str}: use 2B routing to clarify!") + messages.append(msg) + + self._cooldown[issue_key] = now + + if not messages: + self._reset() + return + + full = " | ".join(messages) + max_len = MAX_GRP_TEXT - len(REPORT_SENDER) - 2 + if len(full) > max_len: + full = full[:max_len - 5] + "+more" + + print(f" {BYEL}>>> Reporting to {REPORT_CHANNEL}: {full}{RST}") + _grp_transmit(ser, REPORT_CHANNEL, REPORT_SENDER, full) + self._reset() + + def _reset(self): + self._pending.clear() + self._window_start = time.monotonic() + + +_aggregator = LoopAggregator() + + +# Open-source VID 1209, PID "WA" (0x5741) +USB_VID_PID = "1209:5741" + + +def find_serial_port() -> str | None: + """Find the serial port for our USB device.""" + import subprocess + + # Try to find by USB VID:PID + for path in sorted(glob.glob("/dev/ttyACM*")) + sorted(glob.glob("/dev/ttyUSB*")): + try: + result = subprocess.run( + ["udevadm", "info", "--query=property", f"--name={path}"], + capture_output=True, + text=True, + timeout=2, + ) + vid = "" + pid = "" + for line in result.stdout.splitlines(): + if line.startswith("ID_VENDOR_ID="): + vid = line.split("=", 1)[1].lower() + elif line.startswith("ID_MODEL_ID="): + pid = line.split("=", 1)[1].lower() + if f"{vid}:{pid}" == USB_VID_PID: + return path + except Exception: + continue + + # Fallback: first ttyACM device + ports = sorted(glob.glob("/dev/ttyACM*")) + return ports[0] if ports else None + + +def wait_for_device() -> str: + """Poll until the USB device appears.""" + print("Waiting for DongLoRa...", end="", flush=True) + while True: + port = find_serial_port() + if port: + print(f" found {port}") + time.sleep(0.3) # let the device settle + return port + print(".", end="", flush=True) + time.sleep(0.5) + + +def open_serial(port: str) -> serial.Serial: + return serial.Serial(port, timeout=2) + + +# ── COBS framing ─────────────────────────────────────────────────── + + +def cobs_frame(payload: bytes) -> bytes: + return cobs.encode(payload) + b"\x00" + + +def read_frame(ser: serial.Serial) -> bytes | None: + buf = b"" + while True: + b = ser.read(1) + if not b: + return None + if b == b"\x00": + break + buf += b + if not buf: + return None + try: + return cobs.decode(buf) + except cobs.DecodeError: + return None + + +# ── Postcard serialization ───────────────────────────────────────── + + +def varint(n: int) -> bytes: + out = [] + while n >= 0x80: + out.append((n & 0x7F) | 0x80) + n >>= 7 + out.append(n & 0x7F) + return bytes(out) + + +def zigzag(n: int) -> bytes: + return varint((n << 1) ^ (n >> 31) if n >= 0 else ((-n - 1) << 1) | 1) + + +def encode_radio_config(cfg: dict) -> bytes: + out = varint(cfg["freq_hz"]) + out += varint(cfg["bw"]) + out += varint(cfg["sf"]) + out += varint(cfg["cr"]) + out += varint(cfg["sync_word"]) + out += zigzag(cfg["tx_power_dbm"]) + return out + + +def encode_command(cmd: dict) -> bytes: + kind = cmd["type"] + if kind == "Ping": + return varint(0) + elif kind == "GetConfig": + return varint(1) + elif kind == "SetConfig": + return varint(2) + encode_radio_config(cmd["config"]) + elif kind == "StartRx": + return varint(3) + elif kind == "StopRx": + return varint(4) + elif kind == "Transmit": + out = varint(5) + config = cmd.get("config") + if config is None: + out += b"\x00" # Option::None + else: + out += b"\x01" + encode_radio_config(config) # Option::Some + payload = cmd["payload"] + out += varint(len(payload)) + payload + return out + elif kind == "DisplayOn": + return varint(6) + elif kind == "DisplayOff": + return varint(7) + else: + raise ValueError(f"Unknown command type: {kind}") + + +def decode_varint(data: bytes) -> tuple[int, bytes]: + n, shift = 0, 0 + for i, b in enumerate(data): + n |= (b & 0x7F) << shift + shift += 7 + if not (b & 0x80): + return n, data[i + 1 :] + return n, b"" + + +def decode_zigzag_varint(data: bytes) -> tuple[int, bytes]: + n, rest = decode_varint(data) + return (n >> 1) ^ -(n & 1), rest + + +def decode_response(data: bytes) -> dict: + if not data: + return {"type": "Empty"} + variant = data[0] + rest = data[1:] + if variant == 0: + return {"type": "Pong"} + elif variant == 1: + return {"type": "Config", "raw": rest.hex()} + elif variant == 2: + rssi, rest = decode_zigzag_varint(rest) + snr, rest = decode_zigzag_varint(rest) + plen, rest = decode_varint(rest) + payload = rest[:plen] + return {"type": "RxPacket", "rssi": rssi, "snr": snr, "payload": payload} + elif variant == 3: + return {"type": "TxDone"} + elif variant == 4: + return {"type": "Ok"} + elif variant == 5: + code = rest[0] if rest else -1 + return {"type": "Error", "code": code} + else: + return {"type": f"Unknown({variant})", "raw": rest.hex()} + + +# ── MeshCore packet decoder ────────────────────────────────────── + +ROUTE_NAMES = {0: "tflood", 1: "flood", 2: "direct", 3: "tdirect"} + +PAYLOAD_NAMES = { + 0x00: "REQ", + 0x01: "RESPONSE", + 0x02: "TXT_MSG", + 0x03: "ACK", + 0x04: "ADVERT", + 0x05: "GRP_TXT", + 0x06: "GRP_DATA", + 0x07: "ANON_REQ", + 0x08: "PATH", + 0x09: "TRACE", + 0x0A: "MULTIPART", + 0x0B: "CONTROL", + 0x0F: "RAW_CUSTOM", +} + +NODE_TYPES = {0x01: "chat", 0x02: "repeater", 0x03: "room", 0x04: "sensor"} + +MAX_PATH_SIZE = 64 + +# ── Node registry (persistent, pruned at 12h) ──────────────────── + +ADVERT_MAX_AGE = 12 * 3600 # seconds +_ADVERT_FILE = Path(tempfile.gettempdir()) / "donglora_adverts.json" + +# In-memory: pubkey_hex → {"name": str, "seen": float (unix timestamp)} +_known_nodes: dict[str, dict] = {} + + +def _load_adverts(): + """Load persisted adverts from disk, pruning stale entries.""" + global _known_nodes + try: + data = json.loads(_ADVERT_FILE.read_text()) + cutoff = time.time() - ADVERT_MAX_AGE + _known_nodes = {k: v for k, v in data.items() if v.get("seen", 0) > cutoff} + except (FileNotFoundError, json.JSONDecodeError, ValueError): + _known_nodes = {} + + +def _save_adverts(): + """Persist current adverts to disk (atomic write).""" + try: + tmp = _ADVERT_FILE.with_suffix(".tmp") + tmp.write_text(json.dumps(_known_nodes)) + tmp.rename(_ADVERT_FILE) + except OSError: + pass + + +def _lookup_hash(hash_hex: str, hash_size: int) -> list[str]: + """Find node names whose pubkey prefix matches a path hash.""" + return [ + v["name"] for pk_hex, v in _known_nodes.items() + if pk_hex[:hash_size * 2] == hash_hex + ] + + +def _register_node(pubkey: bytes, name: str): + """Register or update a node from an ADVERT.""" + pk_hex = pubkey.hex() + _known_nodes[pk_hex] = {"name": name, "seen": time.time()} + _save_adverts() + + +def _utf8_score(data: bytes) -> int: + """Score how cleanly data decodes as UTF-8. Higher = more replacement chars.""" + return data.decode("utf-8", errors="replace").count("\ufffd") + + +def _best_name(candidates: list[bytes]) -> bytes: + """Pick the candidate with the fewest UTF-8 replacement chars; longest wins ties.""" + return min(candidates, key=lambda d: (_utf8_score(d), -len(d))) + + +def _decode_advert_appdata(app_data: bytes) -> tuple[str, str]: + """Decode advert app_data. Returns (display_string, node_name).""" + if not app_data: + return "", "" + flags = app_data[0] + node_type = flags & 0x0F + node_label = NODE_TYPES.get(node_type, f"type={node_type}") + parts = [f"{DIM}flags={RST}{flags:02x} {DIM}node={RST}{node_label}"] + + # Parse location if flagged and coords are valid + off = 1 + loc_str = "" + if flags & 0x10 and off + 8 <= len(app_data): + lat_raw = struct.unpack_from(" str | None: + """Check for duplicate hops in a path. Returns a warning line or None.""" + if len(hops) < 2: + return None + counts: dict[str, int] = {} + for h in hops: + counts[h] = counts.get(h, 0) + 1 + dupes = {h: c for h, c in counts.items() if c > 1} + if not dupes: + return None + + # Feed the aggregator for deferred reporting + _aggregator.record(dupes, hash_size, hops) + + parts = [] + for h, c in dupes.items(): + n_known = _nodes_for_hash(h, hash_size) + if n_known == 1: + # Exactly one node matches → confirmed loop + parts.append(f"{BRED}{h} x{c} LOOP{RST}") + elif hash_size >= 2: + if n_known > 1: + # 2B+ collision is rare and real + parts.append(f"{BCYN}{h} x{c} COLLISION {DIM}({n_known} nodes){RST}") + else: + # 2B+ duplicate, unknown node → almost certainly a loop + parts.append(f"{BRED}{h} x{c} LOOP{RST}") + else: + # 1B hash — ambiguous: could be loop or collision + if n_known > 1: + parts.append(f"{YEL}{h} x{c} {DIM}(loop or collision? {n_known} nodes share this 1B hash){RST}") + else: + parts.append(f"{YEL}{h} x{c} {DIM}(loop or collision? 1B hash){RST}") + + return f"{LOOP_INDENT}\u26a0 {', '.join(parts)}" + + +def _try_decode_body(data: bytes, pos: int, type_name: str, type_color: str, + payload_type: int, payload_ver: int, route_name: str, + tc_str: str) -> str | None: + """Try to parse path + payload starting at pos. Returns decoded string or None.""" + if pos >= len(data): + return None + path_len_byte = data[pos] + pos += 1 + hash_size = (path_len_byte >> 6) + 1 + hop_count = path_len_byte & 0x3F + + path_bytes = hop_count * hash_size + if path_bytes > MAX_PATH_SIZE or pos + path_bytes > len(data): + return None + + hops_str = "" + loop_warn = "" + hops: list[str] = [] + if hop_count > 0: + for i in range(hop_count): + h = data[pos + i * hash_size : pos + (i + 1) * hash_size] + hops.append(h.hex()) + hops_str = f" {DIM}path=[{RST}{'>'.join(hops)}{DIM}]{RST}" + warn = _detect_loops(hops, hash_size) + if warn: + loop_warn = f"\n{warn}" + pos += path_bytes + + payload = data[pos:] + prefix = f"{type_color}{type_name}{RST} {route_name}{tc_str}{hops_str}" + if payload_ver != 0: + prefix += f" {DIM}v{payload_ver}{RST}" + + if payload_type == 0x04: + result = _decode_advert(prefix, payload) + elif payload_type == 0x03: + result = _decode_ack(prefix, payload) + elif payload_type in (0x00, 0x01, 0x02, 0x08): + result = _decode_encrypted_peer(prefix, type_name, payload) + elif payload_type in (0x05, 0x06): + result = _decode_encrypted_group(prefix, payload) + elif payload_type == 0x07: + result = _decode_anon_req(prefix, payload) + elif payload_type == 0x09: + result = _decode_trace(prefix, payload) + elif payload_type == 0x0A: + result = _decode_multipart(prefix, payload) + elif payload_type == 0x0B: + result = _decode_control(prefix, payload) + else: + result = f"{prefix} {DIM}{payload.hex()}{RST}" if payload else prefix + + return result + loop_warn + + +def decode_meshcore_packet(data: bytes) -> str: + """Decode a MeshCore packet into a human-readable string.""" + if len(data) < 2: + return f"{RED}{RST} {DIM}{data.hex()}{RST}" + + header = data[0] + if header == 0xFF: + return f"{DIM} {data[1:].hex()}{RST}" + + route_type = header & 0x03 + payload_type = (header >> 2) & 0x0F + payload_ver = (header >> 6) & 0x03 + route_name = ROUTE_NAMES.get(route_type, f"rt{route_type}") + type_name = PAYLOAD_NAMES.get(payload_type, f"0x{payload_type:02x}") + type_color = TYPE_COLORS.get(type_name, WHT) + + has_tc = route_type in (0, 3) + + # Try with transport codes first if indicated, fallback without + if has_tc and len(data) >= 6: + tc1, tc2 = struct.unpack_from("{RST} {type_color}{type_name}{RST} " + f"{route_name}{ver_str} {DIM}{data[1:].hex()}{RST}") + + +def _decode_advert(prefix: str, payload: bytes) -> str: + if len(payload) < 100: + return f"{prefix} {RED}=100>{RST} {DIM}{payload.hex()}{RST}" + pubkey = payload[0:32] + timestamp = struct.unpack_from(" str: + if len(payload) < 4: + return f"{prefix} {RED}{RST} {DIM}{payload.hex()}{RST}" + crc = struct.unpack_from(" str: + if len(payload) < 4: + return f"{prefix} {RED}{RST} {DIM}{payload.hex()}{RST}" + dst = payload[0] + src = payload[1] + mac = payload[2:4] + ct_len = len(payload) - 4 + return f"{prefix} {DIM}dst={RST}{dst:02x} {DIM}src={RST}{src:02x} {DIM}mac={RST}{mac.hex()} {DIM}[{ct_len}B]{RST}" + + +def _decode_encrypted_group(prefix: str, payload: bytes) -> str: + if len(payload) < 3: + return f"{prefix} {RED}{RST} {DIM}{payload.hex()}{RST}" + ch = payload[0] + mac_bytes = payload[1:3] + ciphertext = payload[3:] + + # Try to decrypt with known channels matching this hash + candidates = _CHANNEL_BY_HASH.get(ch, []) + for chan_name, secret in candidates: + plaintext = _grp_verify_and_decrypt(secret, mac_bytes, ciphertext) + if plaintext is not None: + parsed = _parse_grp_plaintext(plaintext) + if parsed: + ts, text = parsed + try: + ts_str = datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%H:%M:%S") + except (OSError, OverflowError, ValueError): + ts_str = f"epoch={ts}" + return (f"{prefix} {BGRN}{chan_name}{RST} " + f"{DIM}ts={RST}{ts_str}Z {BWHT}{text}{RST}") + # MAC matched but plaintext unparseable + return (f"{prefix} {BGRN}{chan_name}{RST} " + f"{DIM}[decrypted but unparseable]{RST}") + + # No known channel matched — show raw info + return (f"{prefix} {DIM}ch={RST}{ch:02x} {DIM}mac={RST}{mac_bytes.hex()} " + f"{DIM}[{len(ciphertext)}B]{RST}") + + +def _decode_anon_req(prefix: str, payload: bytes) -> str: + if len(payload) < 35: + return f"{prefix} {RED}{RST} {DIM}{payload.hex()}{RST}" + dst = payload[0] + ephem_pub = payload[1:33] + mac = payload[33:35] + ct_len = len(payload) - 35 + return f"{prefix} {DIM}dst={RST}{dst:02x} {DIM}ephem={RST}{ephem_pub[:4].hex()}.. {DIM}mac={RST}{mac.hex()} {DIM}[{ct_len}B]{RST}" + + +def _decode_trace(prefix: str, payload: bytes) -> str: + if len(payload) < 9: + return f"{prefix} {RED}{RST} {DIM}{payload.hex()}{RST}" + tag = struct.unpack_from("'.join(hops)}{DIM}]{RST}" if hops else "" + return f"{prefix} {DIM}tag={RST}0x{tag:08x}{hops_str}" + + +def _decode_multipart(prefix: str, payload: bytes) -> str: + if len(payload) < 1: + return f"{prefix} {RED}{RST}" + remaining = (payload[0] >> 4) & 0x0F + inner_type = payload[0] & 0x0F + inner_name = PAYLOAD_NAMES.get(inner_type, f"0x{inner_type:02x}") + return f"{prefix} {DIM}remaining={RST}{remaining} {DIM}inner={RST}{inner_name} {DIM}[{len(payload) - 1}B]{RST}" + + +def _decode_control(prefix: str, payload: bytes) -> str: + if len(payload) < 1: + return f"{prefix} {RED}{RST}" + sub_type = (payload[0] >> 4) & 0x0F + sub_names = {0x8: "DISCOVER_REQ", 0x9: "DISCOVER_RESP"} + sub_name = sub_names.get(sub_type, f"sub=0x{sub_type:x}") + if sub_type == 0x8 and len(payload) >= 6: + type_filter = payload[1] + tag = struct.unpack_from("= 6: + node_type = payload[0] & 0x0F + snr_raw = struct.unpack_from(" dict | None: + payload = encode_command(cmd) + frame = cobs_frame(payload) + print(f"{DIM}>>>{RST} {label}") + ser.write(frame) + ser.flush() + resp_data = read_frame(ser) + if resp_data is None: + print(f" {YEL}timeout{RST}") + return None + resp = decode_response(resp_data) + print(f"{DIM}<<<{RST} {resp}") + return resp + + +RADIO_CONFIG = { + "freq_hz": 910_525_000, + "bw": 6, # Khz62 = variant index 6 + "sf": 7, + "cr": 0, # Cr4_5 = variant index 0 + "sync_word": 0x3444, + "tx_power_dbm": 14, +} + + +def _format_rssi_snr(rssi: int, snr: int) -> str: + rc = _rssi_color(rssi) + sc = _snr_color(snr) + line = f"{rc}RSSI:{rssi:4d}dBm{RST} {sc}SNR:{snr:3d}dB{RST}" + # Flag bogus dongle responses (SNR outside SX126x range -32..+32) + if not (-32 <= snr <= 32): + line += f" {BRED}[bad SNR]{RST}" + return line + + +def configure_and_listen(ser: serial.Serial): + send_cmd(ser, {"type": "SetConfig", "config": RADIO_CONFIG}, "SetConfig 910.525/62.5k/SF7/CR4_5") + send_cmd(ser, {"type": "StartRx"}, "StartRx") + + print(f"\n{BWHT}Listening for packets{RST} {DIM}(Ctrl+C to stop){RST}\n") + ser.timeout = 1 + + while True: + try: + _aggregator.maybe_report(ser) + except Exception as e: + print(f" {RED}[report error: {e}]{RST}") + + data = read_frame(ser) + if data is None: + continue + try: + resp = decode_response(data) + if resp["type"] == "RxPacket": + payload = resp["payload"] + decoded = decode_meshcore_packet(payload) + rssi_snr = _format_rssi_snr(resp["rssi"], resp["snr"]) + print( + f" {rssi_snr} " + f"{DIM}len:{len(payload):3d}{RST} " + f"{decoded}" + ) + else: + print(f" {DIM}{resp}{RST}") + except Exception as e: + print(f" {RED}[decode error: {e}] raw={data.hex()}{RST}") + + +def main(): + _load_adverts() + n = len(_known_nodes) + if n: + print(f"{DIM}Loaded {n} known node(s) from {_ADVERT_FILE}{RST}") + + port = sys.argv[1] if len(sys.argv) > 1 else None + + while True: + if port is None: + port = wait_for_device() + + try: + print(f"Opening {port}") + ser = open_serial(port) + ser.reset_input_buffer() + configure_and_listen(ser) + except (serial.SerialException, ConnectionError, OSError) as e: + print(f"\n{RED}Disconnected: {e}{RST}") + print("Will reconnect when device reappears...") + port = None + time.sleep(1) + except KeyboardInterrupt: + print(f"\n{DIM}Stopping...{RST}") + try: + ser.timeout = 2 + send_cmd(ser, {"type": "StopRx"}, "StopRx") + except Exception: + pass + break + + +if __name__ == "__main__": + main() diff --git a/tools/test_ping.py b/tools/test_ping.py deleted file mode 100644 index e611d203..00000000 --- a/tools/test_ping.py +++ /dev/null @@ -1,254 +0,0 @@ -#!/usr/bin/env python3 -"""DongLoRa test tool: ping, configure radio, listen for packets.""" -# /// script -# requires-python = ">=3.10" -# dependencies = ["cobs", "pyserial"] -# /// - -import glob -import struct -import serial -import sys -import time -from cobs import cobs - -# Open-source VID 1209, PID "WA" (0x5741) -USB_VID_PID = "1209:5741" - - -def find_serial_port() -> str | None: - """Find the serial port for our USB device.""" - import subprocess - - # Try to find by USB VID:PID - for path in sorted(glob.glob("/dev/ttyACM*")) + sorted(glob.glob("/dev/ttyUSB*")): - try: - result = subprocess.run( - ["udevadm", "info", "--query=property", f"--name={path}"], - capture_output=True, - text=True, - timeout=2, - ) - vid = "" - pid = "" - for line in result.stdout.splitlines(): - if line.startswith("ID_VENDOR_ID="): - vid = line.split("=", 1)[1].lower() - elif line.startswith("ID_MODEL_ID="): - pid = line.split("=", 1)[1].lower() - if f"{vid}:{pid}" == USB_VID_PID: - return path - except Exception: - continue - - # Fallback: first ttyACM device - ports = sorted(glob.glob("/dev/ttyACM*")) - return ports[0] if ports else None - - -def wait_for_device() -> str: - """Poll until the USB device appears.""" - print("Waiting for DongLoRa...", end="", flush=True) - while True: - port = find_serial_port() - if port: - print(f" found {port}") - time.sleep(0.3) # let the device settle - return port - print(".", end="", flush=True) - time.sleep(0.5) - - -def open_serial(port: str) -> serial.Serial: - return serial.Serial(port, timeout=2) - - -# ── COBS framing ─────────────────────────────────────────────────── - - -def cobs_frame(payload: bytes) -> bytes: - return cobs.encode(payload) + b"\x00" - - -def read_frame(ser: serial.Serial) -> bytes | None: - buf = b"" - while True: - b = ser.read(1) - if not b: - return None - if b == b"\x00": - break - buf += b - return cobs.decode(buf) if buf else None - - -# ── Postcard serialization ───────────────────────────────────────── - - -def varint(n: int) -> bytes: - out = [] - while n >= 0x80: - out.append((n & 0x7F) | 0x80) - n >>= 7 - out.append(n & 0x7F) - return bytes(out) - - -def zigzag(n: int) -> bytes: - return varint((n << 1) ^ (n >> 31) if n >= 0 else ((-n - 1) << 1) | 1) - - -def encode_radio_config(cfg: dict) -> bytes: - out = varint(cfg["freq_hz"]) - out += varint(cfg["bw"]) - out += varint(cfg["sf"]) - out += varint(cfg["cr"]) - out += varint(cfg["sync_word"]) - out += zigzag(cfg["tx_power_dbm"]) - return out - - -def encode_command(cmd: dict) -> bytes: - kind = cmd["type"] - if kind == "Ping": - return varint(0) - elif kind == "GetConfig": - return varint(1) - elif kind == "SetConfig": - return varint(2) + encode_radio_config(cmd["config"]) - elif kind == "StartRx": - return varint(3) - elif kind == "StopRx": - return varint(4) - elif kind == "DisplayOn": - return varint(7) - elif kind == "DisplayOff": - return varint(8) - else: - raise ValueError(f"Unknown command type: {kind}") - - -def decode_varint(data: bytes) -> tuple[int, bytes]: - n, shift = 0, 0 - for i, b in enumerate(data): - n |= (b & 0x7F) << shift - shift += 7 - if not (b & 0x80): - return n, data[i + 1 :] - return n, b"" - - -def decode_zigzag_varint(data: bytes) -> tuple[int, bytes]: - n, rest = decode_varint(data) - return (n >> 1) ^ -(n & 1), rest - - -def decode_response(data: bytes) -> dict: - variant = data[0] - rest = data[1:] - if variant == 0: - return {"type": "Pong"} - elif variant == 1: - return {"type": "Config", "raw": rest.hex()} - elif variant == 2: - rssi, rest = decode_zigzag_varint(rest) - snr, rest = decode_zigzag_varint(rest) - plen, rest = decode_varint(rest) - payload = rest[:plen] - return {"type": "RxPacket", "rssi": rssi, "snr": snr, "payload": payload} - elif variant == 3: - return {"type": "TxDone"} - elif variant == 4: - return {"type": "Ok"} - elif variant == 5: - code = rest[0] if rest else -1 - return {"type": "Error", "code": code} - else: - return {"type": f"Unknown({variant})", "raw": rest.hex()} - - -# ── Main ─────────────────────────────────────────────────────────── - - -def send_cmd(ser: serial.Serial, cmd: dict, label: str) -> dict | None: - payload = encode_command(cmd) - frame = cobs_frame(payload) - print(f">>> {label}") - ser.write(frame) - ser.flush() - resp_data = read_frame(ser) - if resp_data is None: - print(" timeout") - return None - resp = decode_response(resp_data) - print(f"<<< {resp}") - return resp - - -RADIO_CONFIG = { - "freq_hz": 910_525_000, - "bw": 6, # Khz62 = variant index 6 - "sf": 7, - "cr": 0, # Cr4_5 = variant index 0 - "sync_word": 0x3444, - "tx_power_dbm": 14, -} - - -def configure_and_listen(ser: serial.Serial): - send_cmd(ser, {"type": "Ping"}, "Ping") - send_cmd(ser, {"type": "SetConfig", "config": RADIO_CONFIG}, "SetConfig 910.525/62.5k/SF7/CR4_5") - send_cmd(ser, {"type": "StartRx"}, "StartRx") - - print("\nListening for packets (Ctrl+C to stop)...\n") - ser.timeout = None - while True: - data = read_frame(ser) - if data is None: - raise ConnectionError("device disconnected") - resp = decode_response(data) - if resp["type"] == "RxPacket": - payload = resp["payload"] - try: - text = payload.decode("utf-8", errors="replace") - except Exception: - text = payload.hex() - print( - f" RSSI:{resp['rssi']:4d}dBm " - f"SNR:{resp['snr']:3d}dB " - f"len:{len(payload):3d} " - f"{text}" - ) - else: - print(f" {resp}") - - -def main(): - port = sys.argv[1] if len(sys.argv) > 1 else None - - while True: - if port is None: - port = wait_for_device() - - try: - print(f"Opening {port}") - ser = open_serial(port) - ser.reset_input_buffer() - configure_and_listen(ser) - except (serial.SerialException, ConnectionError, OSError) as e: - print(f"\nDisconnected: {e}") - print("Will reconnect when device reappears...") - port = None - time.sleep(1) - except KeyboardInterrupt: - print("\nStopping...") - try: - ser.timeout = 2 - send_cmd(ser, {"type": "StopRx"}, "StopRx") - except Exception: - pass - break - - -if __name__ == "__main__": - main()