fix: MeshCore spec compliance audit and packet quality warnings
Audited against meshcore-spec and fixed all compliance gaps:
- Fix trace hash size encoding: use 1<<(flags&3) not (flags&3)+1
- Reject reserved path hash_size_code 3 per spec Section 3
- Minimum packet size 3 bytes (was 2)
- Add MAX_PACKET_PAYLOAD (184) and min payload (1) validation
- Tighten payload minimums: ANON_REQ≥51, encrypted≥20, group≥19,
multipart≥2
- Add node_type 0 ("none") to NODE_TYPES
- Fix node_type fallback display (was "node=type=14", now "node=0x0e")
Add packet quality warnings for likely-corrupt frames:
- decode_meshcore_packet returns (str, list[str]) with quality warnings
- ADVERT: flag bad node_type, bad UTF-8 in name, future/bad timestamps
- Combine content warnings with low-SNR signal at display layer
- Warnings stack: one flag = maybe, multiple = high confidence corrupt
Update test corpus and generators for tightened validation rules.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
10 files changed
+198
-82
No files matched your search
@@ -458,6 +458,24 @@ write_case("trace_basic", {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# TRACE: flags=2 means hash_size=4 (power of 2 encoding: 1 << 2 = 4)
|
||||||
|
write_case("trace_4byte_hash", {
|
||||||
|
"description": "TRACE flood with 4-byte trace hashes (flags=0x02, hash_size=4)",
|
||||||
|
"tags": ["trace", "flood", "4byte_hash"],
|
||||||
|
"packet_hex": trace_packet(
|
||||||
|
0xAABBCCDD, 0x11223344, 0x02, b"\xde\xad\xbe\xef\xca\xfe\xba\xbe"
|
||||||
|
).hex(),
|
||||||
|
"valid": True,
|
||||||
|
"expected": {
|
||||||
|
"route_type": "flood",
|
||||||
|
"payload_type": "TRACE",
|
||||||
|
"payload_ver": 0,
|
||||||
|
"transport_codes": None,
|
||||||
|
"path": {"hash_size": 1, "hops": []},
|
||||||
|
"payload": {"trace_tag": 0xAABBCCDD, "trace_hops": 2}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
# MULTIPART: ACK inner type
|
# MULTIPART: ACK inner type
|
||||||
write_case("multipart_ack", {
|
write_case("multipart_ack", {
|
||||||
"description": "MULTIPART flood, remaining=3, inner type=ACK",
|
"description": "MULTIPART flood, remaining=3, inner type=ACK",
|
||||||
@@ -513,29 +531,26 @@ write_case("path_3byte_hash", {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
# PATH: 4-byte hash, 1 hop
|
# PATH: 4-byte hash (hash_size_code 3 = RESERVED, must be rejected)
|
||||||
write_case("path_4byte_hash", {
|
write_case("path_4byte_hash", {
|
||||||
"description": "PATH flood, 1 hop with 4-byte hash",
|
"description": "PATH flood, 4-byte hash (reserved hash_size_code 3, invalid)",
|
||||||
"tags": ["path", "flood", "4byte_hash", "encrypted"],
|
"tags": ["invalid", "path", "flood", "4byte_hash", "reserved_hash_size"],
|
||||||
"packet_hex": peer_packet(
|
"packet_hex": peer_packet(
|
||||||
0x08, 1, 0xe6, 0xe9, b"\x2e\x47", bytes(16),
|
0x08, 1, 0xe6, 0xe9, b"\x2e\x47", bytes(16),
|
||||||
path_hops=[b"\xaa\xbb\xcc\xdd"], hash_size=4
|
path_hops=[b"\xaa\xbb\xcc\xdd"], hash_size=4
|
||||||
).hex(),
|
).hex(),
|
||||||
"valid": True,
|
"valid": False,
|
||||||
"expected": {
|
"expected": {
|
||||||
"route_type": "flood",
|
"route_type": "flood",
|
||||||
"payload_type": "PATH",
|
"payload_type": "PATH",
|
||||||
"payload_ver": 0,
|
"error": "bad_framing"
|
||||||
"transport_codes": None,
|
|
||||||
"path": {"hash_size": 4, "hops": ["aabbccdd"]},
|
|
||||||
"payload": {"dst": "e6", "src": "e9", "mac": "2e47", "ciphertext_len": 16}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
# ── Invalid packets ───────────────────────────────────────────────
|
# ── Invalid packets ───────────────────────────────────────────────
|
||||||
|
|
||||||
# Too short: 1 byte
|
# Too short: 1 byte
|
||||||
write_case("too_short", {
|
write_case("too_short_1byte", {
|
||||||
"description": "Packet too short (1 byte)",
|
"description": "Packet too short (1 byte)",
|
||||||
"tags": ["invalid", "too_short"],
|
"tags": ["invalid", "too_short"],
|
||||||
"packet_hex": "11",
|
"packet_hex": "11",
|
||||||
@@ -547,11 +562,24 @@ write_case("too_short", {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
# Bad path_len: claims 33 hops but only 24 bytes remain
|
# Too short: 2 bytes (need at least 3: header + path_len + 1 byte payload)
|
||||||
bad_pkt = bytes([mc_header(0x02, 1), 0xe1]) + bytes(24)
|
write_case("too_short_2bytes", {
|
||||||
write_case("bad_path_len", {
|
"description": "Packet too short (2 bytes, need >= 3)",
|
||||||
"description": "TXT_MSG flood, path_len claims 33 hops (4B hash) but data is too short",
|
"tags": ["invalid", "too_short"],
|
||||||
"tags": ["invalid", "bad_framing", "path"],
|
"packet_hex": "1100",
|
||||||
|
"valid": False,
|
||||||
|
"expected": {
|
||||||
|
"route_type": "flood",
|
||||||
|
"payload_type": "ADVERT",
|
||||||
|
"error": "too_short"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Bad path_len: reserved hash_size_code 3 (bits 6-7 = 0b11)
|
||||||
|
bad_pkt = bytes([mc_header(0x02, 1), 0xC1]) + bytes(24)
|
||||||
|
write_case("bad_path_len_reserved_hash_size", {
|
||||||
|
"description": "TXT_MSG flood, path_len has reserved hash_size_code 3",
|
||||||
|
"tags": ["invalid", "bad_framing", "path", "reserved_hash_size"],
|
||||||
"packet_hex": bad_pkt.hex(),
|
"packet_hex": bad_pkt.hex(),
|
||||||
"valid": False,
|
"valid": False,
|
||||||
"expected": {
|
"expected": {
|
||||||
@@ -561,6 +589,20 @@ write_case("bad_path_len", {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Bad path_len: claims 33 hops (2B hash) but only 24 bytes remain
|
||||||
|
bad_pkt2 = bytes([mc_header(0x02, 1), 0x61]) + bytes(24)
|
||||||
|
write_case("bad_path_len", {
|
||||||
|
"description": "TXT_MSG flood, path_len claims 33 hops (2B hash) but data is too short",
|
||||||
|
"tags": ["invalid", "bad_framing", "path"],
|
||||||
|
"packet_hex": bad_pkt2.hex(),
|
||||||
|
"valid": False,
|
||||||
|
"expected": {
|
||||||
|
"route_type": "flood",
|
||||||
|
"payload_type": "TXT_MSG",
|
||||||
|
"error": "bad_framing"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
# Noise: random bytes that don't parse as meshcore
|
# Noise: random bytes that don't parse as meshcore
|
||||||
write_case("noise_packet", {
|
write_case("noise_packet", {
|
||||||
"description": "Random bytes, not a valid MeshCore packet",
|
"description": "Random bytes, not a valid MeshCore packet",
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ def advert_appdata(draw):
|
|||||||
name = data[8 if has_loc else 0:].decode("ascii", errors="replace")
|
name = data[8 if has_loc else 0:].decode("ascii", errors="replace")
|
||||||
|
|
||||||
app_data = bytes([flags]) + data
|
app_data = bytes([flags]) + data
|
||||||
node_label = NODE_TYPES.get(node_type, f"type={node_type}")
|
node_label = NODE_TYPES.get(node_type, f"0x{node_type:02x}")
|
||||||
return app_data, {"flags": flags, "node_type": node_label, "location": loc, "name": name}
|
return app_data, {"flags": flags, "node_type": node_label, "location": loc, "name": name}
|
||||||
|
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@ def valid_encrypted_peer_packet(draw):
|
|||||||
dst = draw(st.integers(min_value=0, max_value=255))
|
dst = draw(st.integers(min_value=0, max_value=255))
|
||||||
src = draw(st.integers(min_value=0, max_value=255))
|
src = draw(st.integers(min_value=0, max_value=255))
|
||||||
mac = draw(st.binary(min_size=2, max_size=2))
|
mac = draw(st.binary(min_size=2, max_size=2))
|
||||||
ct_len = draw(st.integers(min_value=0, max_value=64))
|
ct_len = draw(st.integers(min_value=16, max_value=64).filter(lambda n: n % 16 == 0))
|
||||||
ct = draw(st.binary(min_size=ct_len, max_size=ct_len))
|
ct = draw(st.binary(min_size=ct_len, max_size=ct_len))
|
||||||
|
|
||||||
hdr = bytes([mc_header(ptype, route_type)])
|
hdr = bytes([mc_header(ptype, route_type)])
|
||||||
@@ -188,7 +188,7 @@ def valid_group_packet(draw):
|
|||||||
hs, hops = draw(path_strategy())
|
hs, hops = draw(path_strategy())
|
||||||
ch = draw(st.integers(min_value=0, max_value=255))
|
ch = draw(st.integers(min_value=0, max_value=255))
|
||||||
mac = draw(st.binary(min_size=2, max_size=2))
|
mac = draw(st.binary(min_size=2, max_size=2))
|
||||||
ct_len = draw(st.integers(min_value=0, max_value=64))
|
ct_len = draw(st.integers(min_value=16, max_value=64).filter(lambda n: n % 16 == 0))
|
||||||
ct = draw(st.binary(min_size=ct_len, max_size=ct_len))
|
ct = draw(st.binary(min_size=ct_len, max_size=ct_len))
|
||||||
|
|
||||||
hdr = bytes([mc_header(ptype, route_type)])
|
hdr = bytes([mc_header(ptype, route_type)])
|
||||||
@@ -220,7 +220,7 @@ def invalid_packet(draw):
|
|||||||
pkt, _ = draw(valid_ack_packet())
|
pkt, _ = draw(valid_ack_packet())
|
||||||
cut = draw(st.integers(min_value=0, max_value=max(0, len(pkt) - 2)))
|
cut = draw(st.integers(min_value=0, max_value=max(0, len(pkt) - 2)))
|
||||||
packet = pkt[:cut]
|
packet = pkt[:cut]
|
||||||
if len(packet) < 2:
|
if len(packet) < 3:
|
||||||
error = "too_short"
|
error = "too_short"
|
||||||
else:
|
else:
|
||||||
error = "bad_framing"
|
error = "bad_framing"
|
||||||
@@ -230,12 +230,18 @@ def invalid_packet(draw):
|
|||||||
ptype = draw(st.sampled_from(list(PAYLOAD_TYPES.keys())))
|
ptype = draw(st.sampled_from(list(PAYLOAD_TYPES.keys())))
|
||||||
hdr = bytes([mc_header(ptype, route_type)])
|
hdr = bytes([mc_header(ptype, route_type)])
|
||||||
# path_len claiming huge path
|
# path_len claiming huge path
|
||||||
bad_pl = draw(st.integers(min_value=0xC0, max_value=0xFF))
|
# Use hash_size_code 3 (reserved, 0xC0-0xFF) or valid codes with huge counts
|
||||||
|
bad_pl = draw(st.sampled_from([
|
||||||
|
*range(0xC0, 0x100), # reserved hash_size_code 3
|
||||||
|
0x3F, # 63 hops × 1 byte = 63 (valid encoding, will overflow data)
|
||||||
|
0x60, # 32 hops × 2 bytes = 64 (valid encoding, will overflow data)
|
||||||
|
0x95, # 21 hops × 3 bytes = 63 (valid encoding, will overflow data)
|
||||||
|
]))
|
||||||
tail = draw(st.binary(min_size=1, max_size=20))
|
tail = draw(st.binary(min_size=1, max_size=20))
|
||||||
packet = hdr + bytes([bad_pl]) + tail
|
packet = hdr + bytes([bad_pl]) + tail
|
||||||
error = "bad_framing"
|
error = "bad_framing"
|
||||||
else: # random
|
else: # random
|
||||||
packet = draw(st.binary(min_size=2, max_size=50))
|
packet = draw(st.binary(min_size=3, max_size=50))
|
||||||
error = "not_meshcore"
|
error = "not_meshcore"
|
||||||
|
|
||||||
header = packet[0] if packet else 0
|
header = packet[0] if packet else 0
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"description": "TXT_MSG flood, path_len claims 33 hops (4B hash) but data is too short",
|
"description": "TXT_MSG flood, path_len claims 33 hops (2B hash) but data is too short",
|
||||||
"tags": [
|
"tags": [
|
||||||
"invalid",
|
"invalid",
|
||||||
"bad_framing",
|
"bad_framing",
|
||||||
"path"
|
"path"
|
||||||
],
|
],
|
||||||
"packet_hex": "09e1000000000000000000000000000000000000000000000000",
|
"packet_hex": "0961000000000000000000000000000000000000000000000000",
|
||||||
"valid": false,
|
"valid": false,
|
||||||
"expected": {
|
"expected": {
|
||||||
"route_type": "flood",
|
"route_type": "flood",
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"description": "TXT_MSG flood, path_len has reserved hash_size_code 3",
|
||||||
|
"tags": [
|
||||||
|
"invalid",
|
||||||
|
"bad_framing",
|
||||||
|
"path",
|
||||||
|
"reserved_hash_size"
|
||||||
|
],
|
||||||
|
"packet_hex": "09c1000000000000000000000000000000000000000000000000",
|
||||||
|
"valid": false,
|
||||||
|
"expected": {
|
||||||
|
"route_type": "flood",
|
||||||
|
"payload_type": "TXT_MSG",
|
||||||
|
"error": "bad_framing"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,29 +1,17 @@
|
|||||||
{
|
{
|
||||||
"description": "PATH flood, 1 hop with 4-byte hash",
|
"description": "PATH flood, 4-byte hash (reserved hash_size_code 3, invalid)",
|
||||||
"tags": [
|
"tags": [
|
||||||
|
"invalid",
|
||||||
"path",
|
"path",
|
||||||
"flood",
|
"flood",
|
||||||
"4byte_hash",
|
"4byte_hash",
|
||||||
"encrypted"
|
"reserved_hash_size"
|
||||||
],
|
],
|
||||||
"packet_hex": "21c1aabbccdde6e92e4700000000000000000000000000000000",
|
"packet_hex": "21c1aabbccdde6e92e4700000000000000000000000000000000",
|
||||||
"valid": true,
|
"valid": false,
|
||||||
"expected": {
|
"expected": {
|
||||||
"route_type": "flood",
|
"route_type": "flood",
|
||||||
"payload_type": "PATH",
|
"payload_type": "PATH",
|
||||||
"payload_ver": 0,
|
"error": "bad_framing"
|
||||||
"transport_codes": null,
|
|
||||||
"path": {
|
|
||||||
"hash_size": 4,
|
|
||||||
"hops": [
|
|
||||||
"aabbccdd"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"payload": {
|
|
||||||
"dst": "e6",
|
|
||||||
"src": "e9",
|
|
||||||
"mac": "2e47",
|
|
||||||
"ciphertext_len": 16
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File renamed without changes.
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"description": "Packet too short (2 bytes, need >= 3)",
|
||||||
|
"tags": [
|
||||||
|
"invalid",
|
||||||
|
"too_short"
|
||||||
|
],
|
||||||
|
"packet_hex": "1100",
|
||||||
|
"valid": false,
|
||||||
|
"expected": {
|
||||||
|
"route_type": "flood",
|
||||||
|
"payload_type": "ADVERT",
|
||||||
|
"error": "too_short"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"description": "TRACE flood with 4-byte trace hashes (flags=0x02, hash_size=4)",
|
||||||
|
"tags": [
|
||||||
|
"trace",
|
||||||
|
"flood",
|
||||||
|
"4byte_hash"
|
||||||
|
],
|
||||||
|
"packet_hex": "2500ddccbbaa4433221102deadbeefcafebabe",
|
||||||
|
"valid": true,
|
||||||
|
"expected": {
|
||||||
|
"route_type": "flood",
|
||||||
|
"payload_type": "TRACE",
|
||||||
|
"payload_ver": 0,
|
||||||
|
"transport_codes": null,
|
||||||
|
"path": {
|
||||||
|
"hash_size": 1,
|
||||||
|
"hops": []
|
||||||
|
},
|
||||||
|
"payload": {
|
||||||
|
"trace_tag": 2864434397,
|
||||||
|
"trace_hops": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,7 +58,7 @@ def validate_decode(case: dict, path: Path) -> list[str]:
|
|||||||
|
|
||||||
# Run decoder — must not crash
|
# Run decoder — must not crash
|
||||||
try:
|
try:
|
||||||
raw_output = decode_meshcore_packet(packet)
|
raw_output, _warnings = decode_meshcore_packet(packet)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors.append(f"Decoder crashed: {e}")
|
errors.append(f"Decoder crashed: {e}")
|
||||||
return errors
|
return errors
|
||||||
|
|||||||
@@ -514,7 +514,7 @@ PAYLOAD_NAMES = {
|
|||||||
0x0F: "RAW_CUSTOM",
|
0x0F: "RAW_CUSTOM",
|
||||||
}
|
}
|
||||||
|
|
||||||
NODE_TYPES = {0x01: "chat", 0x02: "repeater", 0x03: "room", 0x04: "sensor"}
|
NODE_TYPES = {0x00: "none", 0x01: "chat", 0x02: "repeater", 0x03: "room", 0x04: "sensor"}
|
||||||
|
|
||||||
MAX_PATH_SIZE = 64
|
MAX_PATH_SIZE = 64
|
||||||
|
|
||||||
@@ -574,14 +574,18 @@ def _best_name(candidates: list[bytes]) -> bytes:
|
|||||||
return min(candidates, key=lambda d: (_utf8_score(d), -len(d)))
|
return min(candidates, key=lambda d: (_utf8_score(d), -len(d)))
|
||||||
|
|
||||||
|
|
||||||
def _decode_advert_appdata(app_data: bytes) -> tuple[str, str]:
|
def _decode_advert_appdata(app_data: bytes) -> tuple[str, str, list[str]]:
|
||||||
"""Decode advert app_data. Returns (display_string, node_name)."""
|
"""Decode advert app_data. Returns (display_string, node_name, quality_warnings)."""
|
||||||
if not app_data:
|
if not app_data:
|
||||||
return "", ""
|
return "", "", []
|
||||||
flags = app_data[0]
|
flags = app_data[0]
|
||||||
node_type = flags & 0x0F
|
node_type = flags & 0x0F
|
||||||
node_label = NODE_TYPES.get(node_type, f"type={node_type}")
|
node_label = NODE_TYPES.get(node_type, f"0x{node_type:02x}")
|
||||||
parts = [f"{DIM}flags={RST}{flags:02x} {DIM}node={RST}{node_label}"]
|
parts = [f"{DIM}flags={RST}{flags:02x} {DIM}node={RST}{node_label}"]
|
||||||
|
warnings: list[str] = []
|
||||||
|
|
||||||
|
if node_type > 4:
|
||||||
|
warnings.append("bad node_type")
|
||||||
|
|
||||||
# Parse location if flagged and coords are valid
|
# Parse location if flagged and coords are valid
|
||||||
off = 1
|
off = 1
|
||||||
@@ -614,12 +618,14 @@ def _decode_advert_appdata(app_data: bytes) -> tuple[str, str]:
|
|||||||
if candidates:
|
if candidates:
|
||||||
best = _best_name(candidates)
|
best = _best_name(candidates)
|
||||||
name = best.decode("utf-8", errors="replace")
|
name = best.decode("utf-8", errors="replace")
|
||||||
|
if "\ufffd" in name:
|
||||||
|
warnings.append("bad UTF-8")
|
||||||
|
|
||||||
if loc_str:
|
if loc_str:
|
||||||
parts.append(loc_str)
|
parts.append(loc_str)
|
||||||
if name:
|
if name:
|
||||||
parts.append(f"{BWHT}{name}{RST}")
|
parts.append(f"{BWHT}{name}{RST}")
|
||||||
return " ".join(parts), name
|
return " ".join(parts), name, warnings
|
||||||
|
|
||||||
|
|
||||||
LOOP_INDENT = " " * 38 # align under decoded packet text (after RSSI/SNR/len)
|
LOOP_INDENT = " " * 38 # align under decoded packet text (after RSSI/SNR/len)
|
||||||
@@ -673,18 +679,21 @@ def _try_decode_body(
|
|||||||
payload_ver: int,
|
payload_ver: int,
|
||||||
route_name: str,
|
route_name: str,
|
||||||
tc_str: str,
|
tc_str: str,
|
||||||
) -> str | None:
|
) -> tuple[str | None, list[str]]:
|
||||||
"""Try to parse path + payload starting at pos. Returns decoded string or None."""
|
"""Try to parse path + payload starting at pos. Returns (decoded_string, warnings) or (None, [])."""
|
||||||
if pos >= len(data):
|
if pos >= len(data):
|
||||||
return None
|
return None, []
|
||||||
path_len_byte = data[pos]
|
path_len_byte = data[pos]
|
||||||
pos += 1
|
pos += 1
|
||||||
hash_size = (path_len_byte >> 6) + 1
|
hash_size_code = path_len_byte >> 6
|
||||||
|
if hash_size_code == 3:
|
||||||
|
return None, [] # reserved hash size code per spec Section 3
|
||||||
|
hash_size = hash_size_code + 1
|
||||||
hop_count = path_len_byte & 0x3F
|
hop_count = path_len_byte & 0x3F
|
||||||
|
|
||||||
path_bytes = hop_count * hash_size
|
path_bytes = hop_count * hash_size
|
||||||
if path_bytes > MAX_PATH_SIZE or pos + path_bytes > len(data):
|
if path_bytes > MAX_PATH_SIZE or pos + path_bytes > len(data):
|
||||||
return None
|
return None, []
|
||||||
|
|
||||||
hops_str = ""
|
hops_str = ""
|
||||||
loop_warn = ""
|
loop_warn = ""
|
||||||
@@ -700,10 +709,13 @@ def _try_decode_body(
|
|||||||
pos += path_bytes
|
pos += path_bytes
|
||||||
|
|
||||||
payload = data[pos:]
|
payload = data[pos:]
|
||||||
|
if not payload or len(payload) > 184:
|
||||||
|
return None, []
|
||||||
prefix = f"{type_color}{type_name}{RST} {route_name}{tc_str}{hops_str}"
|
prefix = f"{type_color}{type_name}{RST} {route_name}{tc_str}{hops_str}"
|
||||||
|
|
||||||
|
warnings: list[str] = []
|
||||||
if payload_type == 0x04:
|
if payload_type == 0x04:
|
||||||
result = _decode_advert(prefix, payload)
|
result, warnings = _decode_advert(prefix, payload)
|
||||||
elif payload_type == 0x03:
|
elif payload_type == 0x03:
|
||||||
result = _decode_ack(prefix, payload)
|
result = _decode_ack(prefix, payload)
|
||||||
elif payload_type in (0x00, 0x01, 0x02, 0x08):
|
elif payload_type in (0x00, 0x01, 0x02, 0x08):
|
||||||
@@ -721,17 +733,17 @@ def _try_decode_body(
|
|||||||
else:
|
else:
|
||||||
result = f"{prefix} {DIM}{payload.hex()}{RST}" if payload else prefix
|
result = f"{prefix} {DIM}{payload.hex()}{RST}" if payload else prefix
|
||||||
|
|
||||||
return result + loop_warn
|
return result + loop_warn, warnings
|
||||||
|
|
||||||
|
|
||||||
def decode_meshcore_packet(data: bytes) -> str:
|
def decode_meshcore_packet(data: bytes) -> tuple[str, list[str]]:
|
||||||
"""Decode a MeshCore packet into a human-readable string."""
|
"""Decode a MeshCore packet. Returns (display_string, quality_warnings)."""
|
||||||
if len(data) < 2:
|
if len(data) < 3:
|
||||||
return f"{RED}<too short>{RST} {DIM}{data.hex()}{RST}"
|
return f"{RED}<too short>{RST} {DIM}{data.hex()}{RST}", []
|
||||||
|
|
||||||
header = data[0]
|
header = data[0]
|
||||||
if header == 0xFF:
|
if header == 0xFF:
|
||||||
return f"{DIM}<no-retransmit marker> {data[1:].hex()}{RST}"
|
return f"{DIM}<no-retransmit marker> {data[1:].hex()}{RST}", []
|
||||||
|
|
||||||
route_type = header & 0x03
|
route_type = header & 0x03
|
||||||
payload_type = (header >> 2) & 0x0F
|
payload_type = (header >> 2) & 0x0F
|
||||||
@@ -740,11 +752,10 @@ def decode_meshcore_packet(data: bytes) -> str:
|
|||||||
# MeshCore only implements PAYLOAD_VER_1 (0x00) — versions 1-3 are reserved
|
# MeshCore only implements PAYLOAD_VER_1 (0x00) — versions 1-3 are reserved
|
||||||
# and rejected by real nodes. Packets with ver > 0 are noise or non-MeshCore.
|
# and rejected by real nodes. Packets with ver > 0 are noise or non-MeshCore.
|
||||||
if payload_ver != 0:
|
if payload_ver != 0:
|
||||||
route_name = ROUTE_NAMES.get(route_type, f"rt{route_type}")
|
|
||||||
type_name = PAYLOAD_NAMES.get(payload_type, f"0x{payload_type:02x}")
|
|
||||||
return (
|
return (
|
||||||
f"{RED}<not meshcore>{RST} {DIM}hdr=0x{header:02x} "
|
f"{RED}<not meshcore>{RST} {DIM}hdr=0x{header:02x} "
|
||||||
f"(ver={payload_ver} — only v0 exists) {data[1:].hex()}{RST}"
|
f"(ver={payload_ver} — only v0 exists) {data[1:].hex()}{RST}",
|
||||||
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
route_name = ROUTE_NAMES.get(route_type, f"rt{route_type}")
|
route_name = ROUTE_NAMES.get(route_type, f"rt{route_type}")
|
||||||
@@ -757,7 +768,7 @@ def decode_meshcore_packet(data: bytes) -> str:
|
|||||||
if has_tc and len(data) >= 6:
|
if has_tc and len(data) >= 6:
|
||||||
tc1, tc2 = struct.unpack_from("<HH", data, 1)
|
tc1, tc2 = struct.unpack_from("<HH", data, 1)
|
||||||
tc_str = f" {DIM}tc={tc1:04x}:{tc2:04x}{RST}"
|
tc_str = f" {DIM}tc={tc1:04x}:{tc2:04x}{RST}"
|
||||||
result = _try_decode_body(
|
result, warnings = _try_decode_body(
|
||||||
data,
|
data,
|
||||||
5,
|
5,
|
||||||
type_name,
|
type_name,
|
||||||
@@ -768,42 +779,49 @@ def decode_meshcore_packet(data: bytes) -> str:
|
|||||||
tc_str,
|
tc_str,
|
||||||
)
|
)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
return result
|
return result, warnings
|
||||||
# TC parse failed — retry without (older firmware compatibility)
|
# TC parse failed — retry without (older firmware compatibility)
|
||||||
result = _try_decode_body(
|
result, warnings = _try_decode_body(
|
||||||
data, 1, type_name, type_color, payload_type, payload_ver, route_name, ""
|
data, 1, type_name, type_color, payload_type, payload_ver, route_name, ""
|
||||||
)
|
)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
return result
|
return result, warnings
|
||||||
else:
|
else:
|
||||||
result = _try_decode_body(
|
result, warnings = _try_decode_body(
|
||||||
data, 1, type_name, type_color, payload_type, payload_ver, route_name, ""
|
data, 1, type_name, type_color, payload_type, payload_ver, route_name, ""
|
||||||
)
|
)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
return result
|
return result, warnings
|
||||||
|
|
||||||
# Both attempts failed — show what we know from the header
|
# Both attempts failed — show what we know from the header
|
||||||
return (
|
return (
|
||||||
f"{RED}<bad framing>{RST} {type_color}{type_name}{RST} "
|
f"{RED}<bad framing>{RST} {type_color}{type_name}{RST} "
|
||||||
f"{route_name} {DIM}{data[1:].hex()}{RST}"
|
f"{route_name} {DIM}{data[1:].hex()}{RST}",
|
||||||
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _decode_advert(prefix: str, payload: bytes) -> str:
|
def _decode_advert(prefix: str, payload: bytes) -> tuple[str, list[str]]:
|
||||||
if len(payload) < 100:
|
if len(payload) < 100:
|
||||||
return f"{prefix} {RED}<bad advert: {len(payload)}B, need >=100>{RST} {DIM}{payload.hex()}{RST}"
|
return f"{prefix} {RED}<bad advert: {len(payload)}B, need >=100>{RST} {DIM}{payload.hex()}{RST}", []
|
||||||
pubkey = payload[0:32]
|
pubkey = payload[0:32]
|
||||||
timestamp = struct.unpack_from("<I", payload, 32)[0]
|
timestamp = struct.unpack_from("<I", payload, 32)[0]
|
||||||
app_data = payload[100:]
|
app_data = payload[100:]
|
||||||
|
warnings: list[str] = []
|
||||||
try:
|
try:
|
||||||
ts = datetime.fromtimestamp(timestamp, tz=timezone.utc).strftime(
|
ts = datetime.fromtimestamp(timestamp, tz=timezone.utc).strftime(
|
||||||
"%Y-%m-%d %H:%M:%S"
|
"%Y-%m-%d %H:%M:%S"
|
||||||
)
|
)
|
||||||
|
if timestamp > time.time() + 3600:
|
||||||
|
warnings.append("future ts")
|
||||||
except (OSError, OverflowError, ValueError):
|
except (OSError, OverflowError, ValueError):
|
||||||
ts = f"epoch={timestamp}"
|
ts = f"epoch={timestamp}"
|
||||||
app_str, name = _decode_advert_appdata(app_data)
|
warnings.append("bad ts")
|
||||||
_register_node(pubkey, name)
|
app_str, name, app_warnings = _decode_advert_appdata(app_data)
|
||||||
return f"{prefix} {DIM}pub={RST}{pubkey[:4].hex()}.. {DIM}ts={RST}{ts}Z {app_str}"
|
warnings.extend(app_warnings)
|
||||||
|
if not warnings:
|
||||||
|
_register_node(pubkey, name)
|
||||||
|
return f"{prefix} {DIM}pub={RST}{pubkey[:4].hex()}.. {DIM}ts={RST}{ts}Z {app_str}", warnings
|
||||||
|
|
||||||
|
|
||||||
def _decode_ack(prefix: str, payload: bytes) -> str:
|
def _decode_ack(prefix: str, payload: bytes) -> str:
|
||||||
@@ -816,7 +834,7 @@ def _decode_ack(prefix: str, payload: bytes) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _decode_encrypted_peer(prefix: str, type_name: str, payload: bytes) -> str:
|
def _decode_encrypted_peer(prefix: str, type_name: str, payload: bytes) -> str:
|
||||||
if len(payload) < 4:
|
if len(payload) < 20:
|
||||||
return f"{prefix} {RED}<bad {type_name}: {len(payload)}B>{RST} {DIM}{payload.hex()}{RST}"
|
return f"{prefix} {RED}<bad {type_name}: {len(payload)}B>{RST} {DIM}{payload.hex()}{RST}"
|
||||||
dst = payload[0]
|
dst = payload[0]
|
||||||
src = payload[1]
|
src = payload[1]
|
||||||
@@ -826,7 +844,7 @@ def _decode_encrypted_peer(prefix: str, type_name: str, payload: bytes) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _decode_encrypted_group(prefix: str, payload: bytes) -> str:
|
def _decode_encrypted_group(prefix: str, payload: bytes) -> str:
|
||||||
if len(payload) < 3:
|
if len(payload) < 19:
|
||||||
return (
|
return (
|
||||||
f"{prefix} {RED}<bad group: {len(payload)}B>{RST} {DIM}{payload.hex()}{RST}"
|
f"{prefix} {RED}<bad group: {len(payload)}B>{RST} {DIM}{payload.hex()}{RST}"
|
||||||
)
|
)
|
||||||
@@ -865,7 +883,7 @@ def _decode_encrypted_group(prefix: str, payload: bytes) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _decode_anon_req(prefix: str, payload: bytes) -> str:
|
def _decode_anon_req(prefix: str, payload: bytes) -> str:
|
||||||
if len(payload) < 35:
|
if len(payload) < 51:
|
||||||
return f"{prefix} {RED}<bad anon_req: {len(payload)}B>{RST} {DIM}{payload.hex()}{RST}"
|
return f"{prefix} {RED}<bad anon_req: {len(payload)}B>{RST} {DIM}{payload.hex()}{RST}"
|
||||||
dst = payload[0]
|
dst = payload[0]
|
||||||
ephem_pub = payload[1:33]
|
ephem_pub = payload[1:33]
|
||||||
@@ -881,7 +899,7 @@ def _decode_trace(prefix: str, payload: bytes) -> str:
|
|||||||
)
|
)
|
||||||
tag = struct.unpack_from("<I", payload, 0)[0]
|
tag = struct.unpack_from("<I", payload, 0)[0]
|
||||||
flags = payload[8]
|
flags = payload[8]
|
||||||
trace_hash_size = (flags & 0x03) + 1
|
trace_hash_size = 1 << (flags & 0x03)
|
||||||
trace_hashes = payload[9:]
|
trace_hashes = payload[9:]
|
||||||
n_hashes = len(trace_hashes) // trace_hash_size if trace_hash_size else 0
|
n_hashes = len(trace_hashes) // trace_hash_size if trace_hash_size else 0
|
||||||
hops = []
|
hops = []
|
||||||
@@ -893,8 +911,8 @@ def _decode_trace(prefix: str, payload: bytes) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _decode_multipart(prefix: str, payload: bytes) -> str:
|
def _decode_multipart(prefix: str, payload: bytes) -> str:
|
||||||
if len(payload) < 1:
|
if len(payload) < 2:
|
||||||
return f"{prefix} {RED}<empty multipart>{RST}"
|
return f"{prefix} {RED}<bad multipart: {len(payload)}B>{RST}"
|
||||||
remaining = (payload[0] >> 4) & 0x0F
|
remaining = (payload[0] >> 4) & 0x0F
|
||||||
inner_type = payload[0] & 0x0F
|
inner_type = payload[0] & 0x0F
|
||||||
inner_name = PAYLOAD_NAMES.get(inner_type, f"0x{inner_type:02x}")
|
inner_name = PAYLOAD_NAMES.get(inner_type, f"0x{inner_type:02x}")
|
||||||
@@ -1019,9 +1037,17 @@ def configure_and_listen(ser: serial.Serial):
|
|||||||
last_activity = time.monotonic()
|
last_activity = time.monotonic()
|
||||||
|
|
||||||
payload = resp["payload"]
|
payload = resp["payload"]
|
||||||
decoded = decode_meshcore_packet(payload)
|
decoded, warnings = decode_meshcore_packet(payload)
|
||||||
rssi_snr = _format_rssi_snr(resp["rssi"], resp["snr"])
|
snr = resp["snr"]
|
||||||
print(f" {rssi_snr} {DIM}len:{len(payload):3d}{RST} {decoded}")
|
# SNR below LoRa demodulation floor is one quality signal
|
||||||
|
sf = RADIO_CONFIG["sf"]
|
||||||
|
if -32 <= snr <= 32 and snr < -2.5 * (sf - 4):
|
||||||
|
warnings.append("low SNR")
|
||||||
|
warn_tag = ""
|
||||||
|
if warnings:
|
||||||
|
warn_tag = f" {BYEL}[{', '.join(warnings)}]{RST}"
|
||||||
|
rssi_snr = _format_rssi_snr(resp["rssi"], snr)
|
||||||
|
print(f" {rssi_snr} {DIM}len:{len(payload):3d}{RST} {decoded}{warn_tag}")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user