chore: change display timeout in meshcore_rx example to 90s

This commit is contained in:
swaits committed 2026-03-30 11:33:24 -06:00
1 parent 4c6cab0bfe
commit 02dfa4844a
1 file changed
+122 -59
+122 -59
View File
@@ -77,6 +77,7 @@ def _snr_color(snr: int) -> str:
# ── MeshCore channel crypto ──────────────────────────────────────── # ── MeshCore channel crypto ────────────────────────────────────────
def _channel_secret_from_hashtag(name: str) -> bytes: def _channel_secret_from_hashtag(name: str) -> bytes:
"""Derive 32-byte secret from a hashtag channel name (e.g., '#test').""" """Derive 32-byte secret from a hashtag channel name (e.g., '#test')."""
h = hashlib.sha256(name.encode()).digest() h = hashlib.sha256(name.encode()).digest()
@@ -96,7 +97,9 @@ def _channel_hash(secret: bytes) -> int:
return hashlib.sha256(secret[:key_len]).digest()[0] return hashlib.sha256(secret[:key_len]).digest()[0]
def _grp_verify_and_decrypt(secret: bytes, mac_bytes: bytes, ciphertext: bytes) -> bytes | None: 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.""" """Verify MAC then decrypt a GRP_TXT/GRP_DATA payload. Returns plaintext or None."""
if not ciphertext or len(ciphertext) % 16 != 0: if not ciphertext or len(ciphertext) % 16 != 0:
return None return None
@@ -109,7 +112,7 @@ def _grp_verify_and_decrypt(secret: bytes, mac_bytes: bytes, ciphertext: bytes)
cipher = AES.new(secret[:16], AES.MODE_ECB) cipher = AES.new(secret[:16], AES.MODE_ECB)
plaintext = b"" plaintext = b""
for i in range(0, len(ciphertext), 16): for i in range(0, len(ciphertext), 16):
plaintext += cipher.decrypt(ciphertext[i:i + 16]) plaintext += cipher.decrypt(ciphertext[i : i + 16])
return plaintext return plaintext
@@ -179,7 +182,7 @@ def _grp_encrypt(secret: bytes, sender: str, text: str) -> bytes:
cipher = AES.new(secret[:16], AES.MODE_ECB) cipher = AES.new(secret[:16], AES.MODE_ECB)
ciphertext = b"" ciphertext = b""
for i in range(0, len(plaintext), 16): for i in range(0, len(plaintext), 16):
ciphertext += cipher.encrypt(plaintext[i:i + 16]) ciphertext += cipher.encrypt(plaintext[i : i + 16])
# HMAC-SHA256 over ciphertext, truncated to 2 bytes # HMAC-SHA256 over ciphertext, truncated to 2 bytes
key_len = 16 if secret[16:] == b"\x00" * 16 else 32 key_len = 16 if secret[16:] == b"\x00" * 16 else 32
mac = hmac.new(secret[:key_len], ciphertext, hashlib.sha256).digest()[:2] mac = hmac.new(secret[:key_len], ciphertext, hashlib.sha256).digest()[:2]
@@ -201,8 +204,9 @@ def _grp_transmit(ser, channel_name: str, sender: str, text: str):
payload = _grp_encrypt(secret, sender, text) payload = _grp_encrypt(secret, sender, text)
packet = _grp_build_packet(payload) packet = _grp_build_packet(payload)
try: try:
send_cmd(ser, {"type": "Transmit", "payload": packet}, send_cmd(
f"TX GRP_TXT → {channel_name}") ser, {"type": "Transmit", "payload": packet}, f"TX GRP_TXT → {channel_name}"
)
except Exception as e: except Exception as e:
print(f" {RED}[TX failed: {e}]{RST}") print(f" {RED}[TX failed: {e}]{RST}")
# Always try to resume RX, even if TX failed # Always try to resume RX, even if TX failed
@@ -216,13 +220,13 @@ def _grp_transmit(ser, channel_name: str, sender: str, text: str):
REPORT_CHANNEL = "#watchman" REPORT_CHANNEL = "#watchman"
REPORT_SENDER = "Watchman" REPORT_SENDER = "Watchman"
REPORT_WINDOW = 60 # seconds — collect events before reporting REPORT_WINDOW = 60 # seconds — collect events before reporting
REPORT_COOLDOWN = 3600 # seconds — suppress repeat reports for same issue REPORT_COOLDOWN = 3600 # seconds — suppress repeat reports for same issue
def _nodes_for_hash(hash_hex: str, hash_size: int) -> int: def _nodes_for_hash(hash_hex: str, hash_size: int) -> int:
"""Count how many known nodes share this path hash.""" """Count how many known nodes share this path hash."""
return sum(1 for pk in _known_nodes if pk[:hash_size * 2] == hash_hex) return sum(1 for pk in _known_nodes if pk[: hash_size * 2] == hash_hex)
class LoopAggregator: class LoopAggregator:
@@ -234,10 +238,14 @@ class LoopAggregator:
def record(self, dupes: dict[str, int], hash_size: int, all_hops: list[str]): def record(self, dupes: dict[str, int], hash_size: int, all_hops: list[str]):
"""Record duplicate hops from one packet.""" """Record duplicate hops from one packet."""
first_hop = all_hops[0] if all_hops else None first_hop = all_hops[0] if all_hops else None
self._pending.append({ self._pending.append(
"dupes": dupes, "hash_size": hash_size, "hops": all_hops, {
"first_hop": first_hop, "dupes": dupes,
}) "hash_size": hash_size,
"hops": all_hops,
"first_hop": first_hop,
}
)
def maybe_report(self, ser) -> bool: def maybe_report(self, ser) -> bool:
if not self._pending: if not self._pending:
@@ -268,7 +276,10 @@ class LoopAggregator:
for (h, hs), info in agg.items(): for (h, hs), info in agg.items():
issue_key = f"{h}:{hs}" issue_key = f"{h}:{hs}"
if issue_key in self._cooldown and now - self._cooldown[issue_key] < REPORT_COOLDOWN: if (
issue_key in self._cooldown
and now - self._cooldown[issue_key] < REPORT_COOLDOWN
):
continue continue
n_known = _nodes_for_hash(h, hs) n_known = _nodes_for_hash(h, hs)
@@ -290,8 +301,10 @@ class LoopAggregator:
elif hs >= 2: elif hs >= 2:
if n_known > 1: if n_known > 1:
# 2B+ hash collision — rare, definitely a routing issue # 2B+ hash collision — rare, definitely a routing issue
msg = (f"Routing conflict: {n_known} nodes share {hs}B " msg = (
f"hash {h} ({pkts} pkt).") f"Routing conflict: {n_known} nodes share {hs}B "
f"hash {h} ({pkts} pkt)."
)
messages.append(msg) messages.append(msg)
else: else:
# 2B+ duplicate, unknown node → almost certainly a loop # 2B+ duplicate, unknown node → almost certainly a loop
@@ -302,13 +315,17 @@ class LoopAggregator:
else: else:
# 1B hash — can't distinguish loop from collision # 1B hash — can't distinguish loop from collision
if n_known > 1: if n_known > 1:
msg = (f"1B hash {h} duplicated in {pkts} pkt " msg = (
f"({n_known} known nodes share it). " f"1B hash {h} duplicated in {pkts} pkt "
f"Sender {sender_str}: use 2B routing!") f"({n_known} known nodes share it). "
f"Sender {sender_str}: use 2B routing!"
)
messages.append(msg) messages.append(msg)
else: else:
msg = (f"1B hash {h} duplicated in {pkts} pkt. " msg = (
f"Sender {sender_str}: use 2B routing to clarify!") f"1B hash {h} duplicated in {pkts} pkt. "
f"Sender {sender_str}: use 2B routing to clarify!"
)
messages.append(msg) messages.append(msg)
self._cooldown[issue_key] = now self._cooldown[issue_key] = now
@@ -320,7 +337,7 @@ class LoopAggregator:
full = " | ".join(messages) full = " | ".join(messages)
max_len = MAX_GRP_TEXT - len(REPORT_SENDER) - 2 max_len = MAX_GRP_TEXT - len(REPORT_SENDER) - 2
if len(full) > max_len: if len(full) > max_len:
full = full[:max_len - 5] + "+more" full = full[: max_len - 5] + "+more"
print(f" {BYEL}>>> Reporting to {REPORT_CHANNEL}: {full}{RST}") print(f" {BYEL}>>> Reporting to {REPORT_CHANNEL}: {full}{RST}")
_grp_transmit(ser, REPORT_CHANNEL, REPORT_SENDER, full) _grp_transmit(ser, REPORT_CHANNEL, REPORT_SENDER, full)
@@ -414,7 +431,8 @@ def read_frame(ser: serial.Serial) -> bytes | None:
def encode_radio_config(cfg: dict) -> bytes: def encode_radio_config(cfg: dict) -> bytes:
"""Encode RadioConfig to 10 fixed-size LE bytes.""" """Encode RadioConfig to 10 fixed-size LE bytes."""
return struct.pack("<IBBBHB", return struct.pack(
"<IBBBHB",
cfg["freq_hz"], cfg["freq_hz"],
cfg["bw"], cfg["bw"],
cfg["sf"], cfg["sf"],
@@ -428,8 +446,14 @@ def encode_command(cmd: dict) -> bytes:
"""Encode a command dict to fixed-size LE bytes.""" """Encode a command dict to fixed-size LE bytes."""
kind = cmd["type"] kind = cmd["type"]
tags = { tags = {
"Ping": 0, "GetConfig": 1, "SetConfig": 2, "StartRx": 3, "Ping": 0,
"StopRx": 4, "Transmit": 5, "DisplayOn": 6, "DisplayOff": 7, "GetConfig": 1,
"SetConfig": 2,
"StartRx": 3,
"StopRx": 4,
"Transmit": 5,
"DisplayOn": 6,
"DisplayOff": 7,
} }
out = bytes([tags[kind]]) out = bytes([tags[kind]])
if kind == "SetConfig": if kind == "SetConfig":
@@ -459,7 +483,7 @@ def decode_response(data: bytes) -> dict:
rssi = struct.unpack_from("<h", rest, 0)[0] rssi = struct.unpack_from("<h", rest, 0)[0]
snr = struct.unpack_from("<h", rest, 2)[0] snr = struct.unpack_from("<h", rest, 2)[0]
plen = struct.unpack_from("<H", rest, 4)[0] plen = struct.unpack_from("<H", rest, 4)[0]
payload = rest[6:6 + plen] payload = rest[6 : 6 + plen]
return {"type": "RxPacket", "rssi": rssi, "snr": snr, "payload": payload} return {"type": "RxPacket", "rssi": rssi, "snr": snr, "payload": payload}
elif tag == 3: elif tag == 3:
return {"type": "TxDone"} return {"type": "TxDone"}
@@ -529,8 +553,9 @@ def _save_adverts():
def _lookup_hash(hash_hex: str, hash_size: int) -> list[str]: def _lookup_hash(hash_hex: str, hash_size: int) -> list[str]:
"""Find node names whose pubkey prefix matches a path hash.""" """Find node names whose pubkey prefix matches a path hash."""
return [ return [
v["name"] for pk_hex, v in _known_nodes.items() v["name"]
if pk_hex[:hash_size * 2] == hash_hex for pk_hex, v in _known_nodes.items()
if pk_hex[: hash_size * 2] == hash_hex
] ]
@@ -632,16 +657,25 @@ def _detect_loops(hops: list[str], hash_size: int) -> str | None:
else: else:
# 1B hash — ambiguous: could be loop or collision # 1B hash — ambiguous: could be loop or collision
if n_known > 1: if n_known > 1:
parts.append(f"{YEL}{h} x{c} {DIM}(loop or collision? {n_known} nodes share this 1B hash){RST}") parts.append(
f"{YEL}{h} x{c} {DIM}(loop or collision? {n_known} nodes share this 1B hash){RST}"
)
else: else:
parts.append(f"{YEL}{h} x{c} {DIM}(loop or collision? 1B hash){RST}") parts.append(f"{YEL}{h} x{c} {DIM}(loop or collision? 1B hash){RST}")
return f"{LOOP_INDENT}\u26a0 {', '.join(parts)}" return f"{LOOP_INDENT}\u26a0 {', '.join(parts)}"
def _try_decode_body(data: bytes, pos: int, type_name: str, type_color: str, def _try_decode_body(
payload_type: int, payload_ver: int, route_name: str, data: bytes,
tc_str: str) -> str | None: 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.""" """Try to parse path + payload starting at pos. Returns decoded string or None."""
if pos >= len(data): if pos >= len(data):
return None return None
@@ -710,8 +744,10 @@ def decode_meshcore_packet(data: bytes) -> str:
if payload_ver != 0: if payload_ver != 0:
route_name = ROUTE_NAMES.get(route_type, f"rt{route_type}") route_name = ROUTE_NAMES.get(route_type, f"rt{route_type}")
type_name = PAYLOAD_NAMES.get(payload_type, f"0x{payload_type:02x}") type_name = PAYLOAD_NAMES.get(payload_type, f"0x{payload_type:02x}")
return (f"{RED}<not meshcore>{RST} {DIM}hdr=0x{header:02x} " return (
f"(ver={payload_ver} — only v0 exists) {data[1:].hex()}{RST}") f"{RED}<not meshcore>{RST} {DIM}hdr=0x{header:02x} "
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}")
type_name = PAYLOAD_NAMES.get(payload_type, f"0x{payload_type:02x}") type_name = PAYLOAD_NAMES.get(payload_type, f"0x{payload_type:02x}")
@@ -723,24 +759,36 @@ 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(data, 5, type_name, type_color, result = _try_decode_body(
payload_type, payload_ver, route_name, tc_str) data,
5,
type_name,
type_color,
payload_type,
payload_ver,
route_name,
tc_str,
)
if result is not None: if result is not None:
return result return result
# TC parse failed — retry without (older firmware compatibility) # TC parse failed — retry without (older firmware compatibility)
result = _try_decode_body(data, 1, type_name, type_color, result = _try_decode_body(
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
else: else:
result = _try_decode_body(data, 1, type_name, type_color, result = _try_decode_body(
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
# Both attempts failed — show what we know from the header # Both attempts failed — show what we know from the header
return (f"{RED}<bad framing>{RST} {type_color}{type_name}{RST} " return (
f"{route_name} {DIM}{data[1:].hex()}{RST}") f"{RED}<bad framing>{RST} {type_color}{type_name}{RST} "
f"{route_name} {DIM}{data[1:].hex()}{RST}"
)
def _decode_advert(prefix: str, payload: bytes) -> str: def _decode_advert(prefix: str, payload: bytes) -> str:
@@ -750,7 +798,9 @@ def _decode_advert(prefix: str, payload: bytes) -> str:
timestamp = struct.unpack_from("<I", payload, 32)[0] timestamp = struct.unpack_from("<I", payload, 32)[0]
app_data = payload[100:] app_data = payload[100:]
try: try:
ts = datetime.fromtimestamp(timestamp, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S") ts = datetime.fromtimestamp(timestamp, tz=timezone.utc).strftime(
"%Y-%m-%d %H:%M:%S"
)
except (OSError, OverflowError, ValueError): except (OSError, OverflowError, ValueError):
ts = f"epoch={timestamp}" ts = f"epoch={timestamp}"
app_str, name = _decode_advert_appdata(app_data) app_str, name = _decode_advert_appdata(app_data)
@@ -760,7 +810,9 @@ def _decode_advert(prefix: str, payload: bytes) -> str:
def _decode_ack(prefix: str, payload: bytes) -> str: def _decode_ack(prefix: str, payload: bytes) -> str:
if len(payload) < 4: if len(payload) < 4:
return f"{prefix} {RED}<bad ack: {len(payload)}B>{RST} {DIM}{payload.hex()}{RST}" return (
f"{prefix} {RED}<bad ack: {len(payload)}B>{RST} {DIM}{payload.hex()}{RST}"
)
crc = struct.unpack_from("<I", payload, 0)[0] crc = struct.unpack_from("<I", payload, 0)[0]
return f"{prefix} {DIM}crc={RST}0x{crc:08x}" return f"{prefix} {DIM}crc={RST}0x{crc:08x}"
@@ -777,7 +829,9 @@ 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) < 3:
return f"{prefix} {RED}<bad group: {len(payload)}B>{RST} {DIM}{payload.hex()}{RST}" return (
f"{prefix} {RED}<bad group: {len(payload)}B>{RST} {DIM}{payload.hex()}{RST}"
)
ch = payload[0] ch = payload[0]
mac_bytes = payload[1:3] mac_bytes = payload[1:3]
ciphertext = payload[3:] ciphertext = payload[3:]
@@ -791,18 +845,25 @@ def _decode_encrypted_group(prefix: str, payload: bytes) -> str:
if parsed: if parsed:
ts, text = parsed ts, text = parsed
try: try:
ts_str = datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%H:%M:%S") ts_str = datetime.fromtimestamp(ts, tz=timezone.utc).strftime(
"%H:%M:%S"
)
except (OSError, OverflowError, ValueError): except (OSError, OverflowError, ValueError):
ts_str = f"epoch={ts}" ts_str = f"epoch={ts}"
return (f"{prefix} {BGRN}{chan_name}{RST} " return (
f"{DIM}ts={RST}{ts_str}Z {BWHT}{text}{RST}") f"{prefix} {BGRN}{chan_name}{RST} "
f"{DIM}ts={RST}{ts_str}Z {BWHT}{text}{RST}"
)
# MAC matched but plaintext unparseable # MAC matched but plaintext unparseable
return (f"{prefix} {BGRN}{chan_name}{RST} " return (
f"{DIM}[decrypted but unparseable]{RST}") f"{prefix} {BGRN}{chan_name}{RST} {DIM}[decrypted but unparseable]{RST}"
)
# No known channel matched — show raw info # No known channel matched — show raw info
return (f"{prefix} {DIM}ch={RST}{ch:02x} {DIM}mac={RST}{mac_bytes.hex()} " return (
f"{DIM}[{len(ciphertext)}B]{RST}") 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: def _decode_anon_req(prefix: str, payload: bytes) -> str:
@@ -817,7 +878,9 @@ def _decode_anon_req(prefix: str, payload: bytes) -> str:
def _decode_trace(prefix: str, payload: bytes) -> str: def _decode_trace(prefix: str, payload: bytes) -> str:
if len(payload) < 9: if len(payload) < 9:
return f"{prefix} {RED}<bad trace: {len(payload)}B>{RST} {DIM}{payload.hex()}{RST}" return (
f"{prefix} {RED}<bad trace: {len(payload)}B>{RST} {DIM}{payload.hex()}{RST}"
)
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 = (flags & 0x03) + 1
@@ -896,12 +959,16 @@ def _format_rssi_snr(rssi: int, snr: int) -> str:
return line return line
DISPLAY_IDLE_TIMEOUT = 60 # seconds — turn display off when no activity DISPLAY_IDLE_TIMEOUT = 90 # seconds — turn display off when no activity
def configure_and_listen(ser: serial.Serial): def configure_and_listen(ser: serial.Serial):
send_cmd(ser, {"type": "Ping"}, "Ping") 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": "SetConfig", "config": RADIO_CONFIG},
"SetConfig 910.525/62.5k/SF7/CR4/5",
)
send_cmd(ser, {"type": "StartRx"}, "StartRx") send_cmd(ser, {"type": "StartRx"}, "StartRx")
print(f"\n{BWHT}Listening for packets{RST} {DIM}(Ctrl+C to stop){RST}\n") print(f"\n{BWHT}Listening for packets{RST} {DIM}(Ctrl+C to stop){RST}\n")
@@ -937,11 +1004,7 @@ def configure_and_listen(ser: serial.Serial):
payload = resp["payload"] payload = resp["payload"]
decoded = decode_meshcore_packet(payload) decoded = decode_meshcore_packet(payload)
rssi_snr = _format_rssi_snr(resp["rssi"], resp["snr"]) rssi_snr = _format_rssi_snr(resp["rssi"], resp["snr"])
print( print(f" {rssi_snr} {DIM}len:{len(payload):3d}{RST} {decoded}")
f" {rssi_snr} "
f"{DIM}len:{len(payload):3d}{RST} "
f"{decoded}"
)
else: else:
print(f" {DIM}{resp}{RST}") print(f" {DIM}{resp}{RST}")
except Exception as e: except Exception as e: