This repository has been archived on 2026-08-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
donglora/examples/meshcore/telemetry_monitor.py
T

1144 lines
39 KiB
Python

#!/usr/bin/env python3
"""MeshCore repeater telemetry monitor.
Periodically logs into configured repeaters and fetches telemetry data
(battery voltage, temperature, etc.). Uses route escalation to minimise
flooding: last-known route → zero-hop direct → flood, then learns the
return path for next time.
Configuration lives in ~/.donglora/telemetry.json:
{
"repeaters": [
{
"name": "Hilltop-1",
"password": "guest123"
}
],
"poll_interval_secs": 3600
}
The "pubkey" field is optional. If missing (or empty), the monitor
listens for MeshCore ADVERTs, matches by name, fills in the pubkey
automatically, and saves the config back to disk.
"""
import hashlib
import hmac
import json
import os
import random
import struct
import sys
import time
from pathlib import Path
import serial
from cobs import cobs
from Crypto.Cipher import AES
from nacl.bindings import crypto_scalarmult
from nacl.signing import SigningKey, VerifyKey
import donglora as dl
# ── Constants ─────────────────────────────────────────────────────
DATA_DIR = Path.home() / ".donglora"
CONFIG_FILE = DATA_DIR / "telemetry.json"
KEY_FILE = DATA_DIR / "telemetry_key.bin"
STATE_FILE = DATA_DIR / "telemetry_state.json"
DEFAULT_POLL_INTERVAL = 3600 # 1 hour
# MeshCore protocol constants
REQ_TYPE_GET_TELEMETRY_DATA = 0x03
TELEM_PERM_BASE = 0x01
TELEM_PERM_LOCATION = 0x02
TELEM_PERM_ENVIRONMENT = 0x04
# Timeouts and retries
LOGIN_TIMEOUT = 15.0 # seconds to wait for login response
TELEM_TIMEOUT = 15.0 # seconds to wait for telemetry response
TX_SETTLE = 1.0 # delay between transmissions
FLOOD_RETRIES = 3 # retry flood login/telemetry this many times
RADIO_CONFIG = {
"freq_hz": 910_525_000,
"bw": 6, # 62.5 kHz
"sf": 7,
"cr": 5, # CR 4/5
"sync_word": 0x3444,
"tx_power_dbm": -128, # TX_POWER_MAX
}
# ── ANSI helpers ──────────────────────────────────────────────────
RST = "\033[0m"
DIM = "\033[2m"
RED = "\033[31m"
GRN = "\033[32m"
YEL = "\033[33m"
CYN = "\033[36m"
BCYN = "\033[1;36m"
BGRN = "\033[1;32m"
BWHT = "\033[1;37m"
# ── Identity ──────────────────────────────────────────────────────
_signing_key: SigningKey | None = None
_verify_key: VerifyKey | None = None
_pubkey_bytes: bytes = b""
def _init_identity():
global _signing_key, _verify_key, _pubkey_bytes
DATA_DIR.mkdir(parents=True, exist_ok=True)
if KEY_FILE.is_file():
seed = KEY_FILE.read_bytes()
if len(seed) == 32:
_signing_key = SigningKey(seed)
print(f" {DIM}Loaded keypair from {KEY_FILE}{RST}")
else:
_signing_key = SigningKey.generate()
KEY_FILE.write_bytes(bytes(_signing_key))
else:
_signing_key = SigningKey.generate()
KEY_FILE.write_bytes(bytes(_signing_key))
print(f" {BWHT}Generated new keypair → {KEY_FILE}{RST}")
_verify_key = _signing_key.verify_key
_pubkey_bytes = bytes(_verify_key)
print(f" {DIM}Pubkey: {_pubkey_bytes.hex()}{RST}")
print(f" {DIM}Node hash: 0x{_pubkey_bytes[0]:02x}{RST}")
def _my_hash() -> int:
return _pubkey_bytes[0]
# ── Crypto ────────────────────────────────────────────────────────
def _ecdh_shared_secret(peer_ed25519_pub: bytes) -> bytes:
my_x25519 = bytes(_signing_key.to_curve25519_private_key())
peer_verify = VerifyKey(peer_ed25519_pub)
peer_x25519 = bytes(peer_verify.to_curve25519_public_key())
return crypto_scalarmult(my_x25519, peer_x25519)
def _encrypt(shared_secret: bytes, plaintext: bytes) -> bytes:
"""Encrypt-then-MAC. Returns mac(2) + ciphertext."""
pad_len = (16 - len(plaintext) % 16) % 16
plaintext += b"\x00" * pad_len
cipher = AES.new(shared_secret[:16], AES.MODE_ECB)
ciphertext = b""
for i in range(0, len(plaintext), 16):
ciphertext += cipher.encrypt(plaintext[i : i + 16])
mac = hmac.new(shared_secret[:32], ciphertext, hashlib.sha256).digest()[:2]
return mac + ciphertext
def _verify_and_decrypt(shared_secret: bytes, mac_bytes: bytes, ciphertext: bytes) -> bytes | None:
computed = hmac.new(shared_secret[:32], ciphertext, hashlib.sha256).digest()[:2]
if computed != mac_bytes:
return None
cipher = AES.new(shared_secret[:16], AES.MODE_ECB)
plaintext = b""
for i in range(0, len(ciphertext), 16):
plaintext += cipher.decrypt(ciphertext[i : i + 16])
return plaintext
# ── Route table ───────────────────────────────────────────────────
# pubkey_hex → (path_hops: list[bytes], hash_size: int)
_route_table: dict[str, tuple[list[bytes], int]] = {}
MIN_HASH_SIZE = 2 # always use 2-byte routing minimum
def _learn_route(pubkey_hex: str, path_hops: list[bytes], hash_size: int):
"""Learn a return route from an incoming packet's path (reversed)."""
if hash_size < MIN_HASH_SIZE:
return # reject 1-byte routes
existing = _route_table.get(pubkey_hex)
if existing is not None:
_, existing_hs = existing
if hash_size < existing_hs:
return
_route_table[pubkey_hex] = (list(reversed(path_hops)), hash_size)
def _get_route(pubkey_hex: str) -> tuple[list[bytes], int] | None:
return _route_table.get(pubkey_hex)
# ── Persistent state ─────────────────────────────────────────────
_state: dict = {"routes": {}}
def _load_state():
global _state
if STATE_FILE.is_file():
try:
with open(STATE_FILE) as f:
loaded = json.load(f)
_state.update(loaded)
# Restore routes from state
for pk_hex, route_data in _state.get("routes", {}).items():
hops = [bytes.fromhex(h) for h in route_data["hops"]]
_route_table[pk_hex] = (hops, route_data["hash_size"])
print(f" {DIM}Loaded state ({len(_route_table)} routes){RST}")
except Exception as e:
print(f" {YEL}Failed to load state: {e}{RST}")
def _save_state():
try:
# Serialise routes
routes = {}
for pk_hex, (hops, hs) in _route_table.items():
routes[pk_hex] = {"hops": [h.hex() for h in hops], "hash_size": hs}
_state["routes"] = routes
DATA_DIR.mkdir(parents=True, exist_ok=True)
tmp = STATE_FILE.with_suffix(".tmp")
with open(tmp, "w") as f:
json.dump(_state, f, indent=2)
tmp.rename(STATE_FILE)
except Exception as e:
print(f" {RED}Failed to save state: {e}{RST}")
# ── Serial / USB ──────────────────────────────────────────────────
def open_connection(port: str | None = None):
"""Open a DongLoRa connection (mux auto-detected, falls back to USB)."""
return dl.connect(port=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
# ── Command encoding / response decoding ─────────────────────────
def encode_radio_config(cfg: dict) -> bytes:
return struct.pack(
"<IBBBHBHB",
cfg["freq_hz"], cfg["bw"], cfg["sf"], cfg["cr"],
cfg["sync_word"], cfg["tx_power_dbm"] & 0xFF,
cfg.get("preamble_len", 0), cfg.get("cad", 1),
)
def encode_command(cmd: dict) -> bytes:
kind = cmd["type"]
tags = {
"Ping": 0, "GetConfig": 1, "SetConfig": 2, "StartRx": 3,
"StopRx": 4, "Transmit": 5, "DisplayOn": 6, "DisplayOff": 7,
}
out = bytes([tags[kind]])
if kind == "SetConfig":
out += encode_radio_config(cmd["config"])
elif kind == "Transmit":
config = cmd.get("config")
if config is None:
out += b"\x00"
else:
out += b"\x01" + encode_radio_config(config)
payload = cmd["payload"]
out += struct.pack("<H", len(payload)) + payload
return out
def decode_response(data: bytes) -> dict:
if not data:
return {"type": "Empty"}
tag = data[0]
rest = data[1:]
if tag == 0:
return {"type": "Pong"}
elif tag == 1:
return {"type": "Config", "raw": rest.hex()}
elif tag == 2:
rssi = struct.unpack_from("<h", rest, 0)[0]
snr = struct.unpack_from("<h", rest, 2)[0]
plen = struct.unpack_from("<H", rest, 4)[0]
payload = rest[6 : 6 + plen]
return {"type": "RxPacket", "rssi": rssi, "snr": snr, "payload": payload}
elif tag == 3:
return {"type": "TxDone"}
elif tag == 4:
return {"type": "Ok"}
elif tag == 5:
code = rest[0] if rest else -1
return {"type": "Error", "code": code}
else:
return {"type": f"Unknown({tag})", "raw": rest.hex()}
def send_cmd(ser: serial.Serial, cmd: dict, label: str = "") -> dict | None:
payload = encode_command(cmd)
frame = cobs_frame(payload)
if label:
print(f" {DIM}>>> {label}{RST}")
ser.write(frame)
ser.flush()
for _ in range(50):
resp_data = read_frame(ser)
if resp_data is None:
return None
resp = decode_response(resp_data)
if resp["type"] == "RxPacket":
continue
return resp
return None
def transmit(ser: serial.Serial, packet: bytes, label: str = "TX"):
resp = send_cmd(ser, {"type": "Transmit", "payload": packet}, label=label)
if resp is None:
print(f" {RED}TX failed: no response{RST}")
elif resp["type"] == "Error":
print(f" {RED}TX error: {resp}{RST}")
# ── MeshCore packet parsing ──────────────────────────────────────
def parse_header_and_path(packet: bytes):
"""Parse MeshCore header + path.
Returns (payload_type, route_type, pos, payload, path_hops, hash_size) or None.
"""
if len(packet) < 3:
return None
header = packet[0]
route_type = header & 0x03
payload_type = (header >> 2) & 0x0F
payload_ver = (header >> 6) & 0x03
if payload_ver != 0:
return None
has_tc = route_type in (0, 3)
for skip_tc in ([True, False] if has_tc else [False]):
pos = 5 if skip_tc else 1
if pos >= len(packet):
continue
path_len_byte = packet[pos]
pos += 1
hash_size_code = path_len_byte >> 6
if hash_size_code == 3:
continue
hash_size = hash_size_code + 1
hop_count = path_len_byte & 0x3F
path_bytes = hop_count * hash_size
if path_bytes > 64 or pos + path_bytes > len(packet):
continue
hops = []
for i in range(hop_count):
hops.append(packet[pos + i * hash_size : pos + (i + 1) * hash_size])
pos += path_bytes
payload = packet[pos:]
if payload and len(payload) <= 184:
return payload_type, route_type, pos, payload, hops, hash_size
return None
# ── Packet construction ──────────────────────────────────────────
_RT_NAMES = {0: "tflood", 1: "flood", 2: "direct", 3: "tdirect"}
def _build_routed_packet(
payload_type: int,
payload: bytes,
route: tuple[list[bytes], int] | None,
) -> tuple[bytes, str]:
"""Build a packet with the given route. Returns (packet, route_description)."""
if route is not None:
hops, hash_size = route
hash_size_code = hash_size - 1
path_len_byte = (hash_size_code << 6) | (len(hops) & 0x3F)
path_data = b"".join(hops)
header = bytes([(payload_type << 2) | 2]) # DIRECT
return header + bytes([path_len_byte]) + path_data + payload, "direct"
else:
header = bytes([(payload_type << 2) | 1]) # FLOOD
path_len = bytes([0x40]) # 0 hops, 2-byte hash mode
return header + path_len + payload, "flood"
def _build_direct_zero_hop_packet(payload_type: int, payload: bytes) -> bytes:
"""Build a zero-hop direct packet (reaches only immediate neighbours)."""
header = bytes([(payload_type << 2) | 2]) # DIRECT
path_len = bytes([0x40]) # 0 hops, 2-byte hash mode
return header + path_len + payload
# ── ANON_REQ (login) ─────────────────────────────────────────────
def build_login_packet(
repeater_pubkey: bytes,
password: str,
route: tuple[list[bytes], int] | None,
) -> tuple[bytes, str]:
"""Build an ANON_REQ login packet for a repeater.
Plaintext: timestamp(4) + password (UTF-8, no null terminator needed —
encryption pads to 16-byte boundary, server reads until null/end).
Returns (packet, route_description).
"""
shared_secret = _ecdh_shared_secret(repeater_pubkey)
# Repeater login plaintext: timestamp(4) + password bytes
plaintext = struct.pack("<I", int(time.time()))
plaintext += password[:15].encode("utf-8")
mac_ct = _encrypt(shared_secret, plaintext)
# ANON_REQ payload: dest_hash(1) + sender_pubkey(32) + mac(2) + ciphertext
anon_payload = bytes([repeater_pubkey[0]]) + _pubkey_bytes + mac_ct
return _build_routed_packet(0x07, anon_payload, route)
# ── Telemetry REQUEST ─────────────────────────────────────────────
def build_telemetry_request(
repeater_pubkey: bytes,
route: tuple[list[bytes], int] | None,
) -> tuple[bytes, str]:
"""Build a REQ packet with protocol_code=GET_TELEMETRY_DATA.
Plaintext: timestamp(4) + protocol_code(1) + inverse_perm_mask(1)
We want base + environment (battery + temp), skip location.
Returns (packet, route_description).
"""
shared_secret = _ecdh_shared_secret(repeater_pubkey)
# inverse perm mask: skip location only
inv_mask = TELEM_PERM_LOCATION
plaintext = struct.pack("<I", int(time.time()))
plaintext += bytes([REQ_TYPE_GET_TELEMETRY_DATA, inv_mask])
mac_ct = _encrypt(shared_secret, plaintext)
# REQUEST payload: dest_hash(1) + src_hash(1) + mac(2) + ciphertext
req_payload = bytes([repeater_pubkey[0], _my_hash()]) + mac_ct
return _build_routed_packet(0x00, req_payload, route)
# ── CayenneLPP decoder ───────────────────────────────────────────
# Type ID → (name, data_size_bytes, scale_divisor)
# Per ElectronicCats/CayenneLPP library used by MeshCore firmware
CAYENNE_TYPES: dict[int, tuple[str, int, float]] = {
0x00: ("digital_in", 1, 1),
0x01: ("digital_out", 1, 1),
0x02: ("analog_in", 2, 100), # 0.01V
0x03: ("analog_out", 2, 100), # 0.01V (older firmware uses this for battery)
0x65: ("illuminance", 2, 1),
0x66: ("presence", 1, 1),
0x67: ("temperature", 2, 10), # 0.1°C, signed
0x68: ("humidity", 1, 2), # 0.5%
0x71: ("accelerometer", 6, 1000), # 0.001G per axis
0x73: ("barometer", 2, 10), # 0.1 hPa
0x74: ("voltage", 2, 100), # 0.01V — MeshCore addVoltage()
0x75: ("current", 2, 1000), # 0.001A — MeshCore addCurrent()
0x76: ("percentage", 1, 1),
0x80: ("power", 2, 1), # 1W — MeshCore addPower()
0x82: ("distance", 4, 1000), # 0.001m
0x86: ("gps", 9, 1),
0x88: ("gps", 9, 1),
}
def decode_cayenne_lpp(data: bytes) -> list[dict]:
"""Decode CayenneLPP payload. Returns list of sensor readings."""
sensors = []
pos = 0
while pos < len(data):
if pos + 2 > len(data):
break
channel = data[pos]
type_id = data[pos + 1]
pos += 2
type_info = CAYENNE_TYPES.get(type_id)
if type_info is None:
# Unknown type — can't determine size, stop parsing
break
name, size, divisor = type_info
if pos + size > len(data):
break
raw = data[pos : pos + size]
pos += size
# Decode value (big-endian per CayenneLPP spec)
signed = name in ("temperature", "analog_in", "analog_out", "voltage", "current")
val_raw = int.from_bytes(raw, "big", signed=signed) if size > 1 else raw[0]
value = val_raw / divisor
UNITS = {
"temperature": "°C", "humidity": "%", "barometer": "hPa",
"voltage": "V", "analog_in": "V", "analog_out": "V",
"current": "A", "power": "W", "distance": "m",
"illuminance": "lux", "percentage": "%",
}
sensors.append({
"channel": channel, "type": name,
"value": value, "unit": UNITS.get(name, ""),
})
return sensors
# ── Response waiting ──────────────────────────────────────────────
def wait_for_response(
ser: serial.Serial,
repeater_pubkey: bytes,
expected_types: set[int],
timeout: float,
) -> tuple[dict | None, list[bytes], int]:
"""Wait for a MeshCore response addressed to us from the repeater.
Returns (parsed_info, path_hops, hash_size) or (None, [], 0).
parsed_info depends on payload type.
"""
pk_hex = repeater_pubkey.hex()
shared_secret = _ecdh_shared_secret(repeater_pubkey)
dest_hash = repeater_pubkey[0]
deadline = time.monotonic() + timeout
old_timeout = ser.timeout
ser.timeout = 1
try:
while time.monotonic() < deadline:
data = read_frame(ser)
if data is None:
continue
resp = decode_response(data)
if resp["type"] != "RxPacket":
continue
packet = resp["payload"]
parsed = parse_header_and_path(packet)
if parsed is None:
continue
payload_type, route_type, pos, raw_payload, path_hops, hash_size = parsed
if payload_type not in expected_types:
continue
# ── PATH (0x08) — responses routed back via PATH ──
if payload_type == 0x08:
if len(raw_payload) < 20:
continue
dst = raw_payload[0]
src = raw_payload[1]
if dst != _my_hash():
continue
if src != dest_hash:
continue
mac_bytes = raw_payload[2:4]
ciphertext = raw_payload[4:]
plaintext = _verify_and_decrypt(shared_secret, mac_bytes, ciphertext)
if plaintext is None:
continue
# PATH inner: path_len(1) + path_data + extra_type(1) + extra_data
inner_path_len_byte = plaintext[0]
inner_hash_size = (inner_path_len_byte >> 6) + 1
inner_hop_count = inner_path_len_byte & 0x3F
inner_path_bytes = inner_hop_count * inner_hash_size
ipos = 1 + inner_path_bytes
if ipos < len(plaintext):
extra_type = plaintext[ipos]
extra_data = plaintext[ipos + 1:]
else:
extra_type = 0xFF
extra_data = b""
# Learn route from inner path
if inner_hop_count > 0:
inner_hops = []
for i in range(inner_hop_count):
inner_hops.append(plaintext[1 + i * inner_hash_size : 1 + (i + 1) * inner_hash_size])
_learn_route(pk_hex, inner_hops, inner_hash_size)
_save_state()
if extra_type != 0x01 or len(extra_data) < 4:
continue
timestamp = struct.unpack_from("<I", extra_data, 0)[0]
# Detect login responses by their structure:
# Login: ts(4) + rc(1=0x00|0x80) + legacy(1) + admin(1) + perms(1) + random(4) + fw_ver(1)
is_login_response = (
len(extra_data) >= 13
and extra_data[4] in (0x00, 0x80)
and extra_data[12] in (0x01, 0x02, 0x03)
)
if is_login_response:
if 0x01 in expected_types:
# Waiting for telemetry — skip this stale login response
continue # skip stale login response
response_code = extra_data[4]
is_admin = extra_data[6] if len(extra_data) > 6 else 0
return {
"type": "login_response",
"response_code": response_code,
"is_admin": bool(is_admin),
"timestamp": timestamp,
}, path_hops, hash_size
# Telemetry response: ts(4) + CayenneLPP (no response_code)
if 0x01 in expected_types:
lpp_data = extra_data[4:]
while lpp_data and lpp_data[-1] == 0:
lpp_data = lpp_data[:-1]
sensors = decode_cayenne_lpp(lpp_data) if lpp_data else []
return {
"type": "telemetry",
"reflected_timestamp": timestamp,
"sensors": sensors,
"raw_hex": extra_data.hex(),
}, path_hops, hash_size
continue
# ── RESPONSE (0x01) — telemetry data ──
if payload_type == 0x01:
if len(raw_payload) < 20:
continue
dst = raw_payload[0]
src = raw_payload[1]
if dst != _my_hash():
continue
if src != dest_hash:
continue
mac_bytes = raw_payload[2:4]
ciphertext = raw_payload[4:]
plaintext = _verify_and_decrypt(shared_secret, mac_bytes, ciphertext)
if plaintext is None:
continue
# Telemetry response: timestamp(4) + CayenneLPP (no response_code)
if len(plaintext) < 4:
continue
reflected_ts = struct.unpack_from("<I", plaintext, 0)[0]
lpp_data = plaintext[4:]
# Strip trailing zero padding
while lpp_data and lpp_data[-1] == 0:
lpp_data = lpp_data[:-1]
sensors = decode_cayenne_lpp(lpp_data) if lpp_data else []
return {
"type": "telemetry",
"reflected_timestamp": reflected_ts,
"sensors": sensors,
"raw_hex": plaintext.hex(),
}, path_hops, hash_size
finally:
ser.timeout = old_timeout
return None, [], 0
# ── Route escalation ─────────────────────────────────────────────
def _route_strategies(pk_hex: str) -> list[tuple[tuple[list[bytes], int] | None, str]]:
"""Return route strategies in escalation order.
a) last known route b) zero-hop direct c) flood
"""
strategies = []
# a) Last known route
known = _get_route(pk_hex)
if known is not None:
strategies.append((known, "last-known"))
# b) Zero-hop direct (immediate neighbours only — no flooding)
strategies.append(("zero-hop", "zero-hop"))
# c) Flood (only as last resort)
strategies.append((None, "flood"))
return strategies
# ── Poll one repeater ─────────────────────────────────────────────
def poll_repeater(ser: serial.Serial, repeater: dict) -> dict | None:
"""Login to repeater, fetch telemetry. Returns telemetry dict or None."""
name = repeater["name"]
pubkey = bytes.fromhex(repeater["pubkey"])
password = repeater["password"]
pk_hex = repeater["pubkey"]
print(f"\n {BCYN}── {name} ──{RST}")
strategies = _route_strategies(pk_hex)
# Step 1: Login with route escalation + flood retries
login_ok = False
for route_val, route_desc in strategies:
# Flood gets multiple attempts since it should always eventually work
attempts = FLOOD_RETRIES if route_val is None else 1
for attempt in range(attempts):
if route_val == "zero-hop":
shared_secret = _ecdh_shared_secret(pubkey)
plaintext = struct.pack("<I", int(time.time()))
plaintext += password[:15].encode("utf-8")
mac_ct = _encrypt(shared_secret, plaintext)
anon_payload = bytes([pubkey[0]]) + _pubkey_bytes + mac_ct
packet = _build_direct_zero_hop_packet(0x07, anon_payload)
actual_desc = "zero-hop"
else:
packet, actual_desc = build_login_packet(pubkey, password, route_val)
actual_desc = route_desc
retry_label = f" #{attempt + 1}" if attempts > 1 and attempt > 0 else ""
print(f" {DIM}Login via {actual_desc}{retry_label}...{RST}", end="", flush=True)
transmit(ser, packet, label=f"LOGIN({actual_desc})→{name}")
time.sleep(TX_SETTLE)
result, path_hops, hash_size = wait_for_response(
ser, pubkey, {0x08}, LOGIN_TIMEOUT
)
if result is not None and result.get("response_code") in (0x00, 0x80):
login_ok = True
if path_hops:
_learn_route(pk_hex, path_hops, hash_size)
_save_state()
print(f" {GRN}OK{RST} {DIM}(learned {len(path_hops)}-hop route){RST}")
else:
print(f" {GRN}OK{RST}")
break
elif result is not None:
print(f" {RED}rejected (code=0x{result.get('response_code', -1):02x}){RST}")
login_ok = False
break # Wrong password — don't retry
else:
print(f" {YEL}timeout{RST}")
if login_ok or (result is not None and result.get("response_code") not in (0x00, 0x80)):
break
if not login_ok:
print(f" {RED}Login failed for {name}{RST}")
return None
# Step 2: Request telemetry with escalation (direct → flood with retries)
telem_strategies = []
known = _get_route(pk_hex)
if known is not None:
telem_strategies.append((known, "direct"))
telem_strategies.append((None, "flood"))
result = None
for route, route_desc in telem_strategies:
attempts = FLOOD_RETRIES if route is None else 1
for attempt in range(attempts):
packet, _ = build_telemetry_request(pubkey, route)
retry_label = f" #{attempt + 1}" if attempts > 1 and attempt > 0 else ""
print(f" {DIM}Requesting telemetry via {route_desc}{retry_label}...{RST}", end="", flush=True)
transmit(ser, packet, label=f"TELEM_REQ({route_desc})→{name}")
time.sleep(TX_SETTLE)
result, path_hops, hash_size = wait_for_response(
ser, pubkey, {0x01, 0x08}, TELEM_TIMEOUT
)
if result is not None:
if path_hops:
_learn_route(pk_hex, path_hops, hash_size)
_save_state()
print(f" {GRN}OK{RST}")
break
else:
print(f" {YEL}timeout{RST}")
if result is not None:
break
# Clear stale route before escalating to next strategy
_route_table.pop(pk_hex, None)
if result is None:
print(f" {RED}Telemetry failed for {name}{RST}")
return None
# Display raw hex for debugging
raw_hex = result.get("raw_hex", "")
if raw_hex:
print(f" {DIM}raw: {raw_hex}{RST}")
# Display parsed sensors — deduplicate by (channel, type), keep first
# (external sensors are added before MCU fallback in firmware)
sensors = result.get("sensors", [])
seen = set()
deduped = []
for s in sensors:
key = (s["channel"], s["type"])
if key not in seen:
seen.add(key)
deduped.append(s)
if deduped:
for s in deduped:
stype = s["type"]
val = s["value"]
unit = s.get("unit", "")
if stype == "temperature":
f_val = val * 9 / 5 + 32
print(f" {BWHT}temperature{RST}: {val:.1f}°C / {f_val:.1f}°F")
elif stype in ("voltage", "analog_in", "analog_out") and unit == "V":
print(f" {BWHT}voltage{RST}: {val:.2f}V")
elif stype in ("digital_in", "digital_out"):
state = "On" if val else "Off"
print(f" {DIM}digital {stype.split('_')[1]}{RST}: {state}")
else:
print(f" {BWHT}{stype}{RST} ch{s['channel']}: {val:.2f}{unit}")
return result
# ── ADVERT decoding ──────────────────────────────────────────────
def decode_advert(raw_payload: bytes) -> tuple[bytes, str, int] | None:
"""Decode and verify an ADVERT. Returns (pubkey, name, node_type) or None."""
if len(raw_payload) < 100:
return None
pubkey = raw_payload[0:32]
timestamp = raw_payload[32:36]
signature = raw_payload[36:100]
app_data = raw_payload[100:]
try:
verify_key = VerifyKey(pubkey)
sign_msg = pubkey + timestamp + app_data
verify_key.verify(sign_msg, signature)
except Exception:
return None
name = ""
node_type = 0
if app_data:
flags = app_data[0]
node_type = flags & 0x0F
pos = 1
if flags & 0x10: # has location
pos += 8
if flags & 0x20: # feat1
pos += 2
if flags & 0x40: # feat2
pos += 2
if flags & 0x80 and pos < len(app_data): # has name
name = app_data[pos:].decode("utf-8", errors="replace")
return pubkey, name if name else pubkey.hex()[:8], node_type
def discover_pubkeys(ser: serial.Serial, wanted: list[dict]) -> int:
"""Listen for ADVERTs and match against repeaters missing pubkeys.
Returns number of pubkeys discovered.
"""
# Build a lookup: lowercase name → list of config entries wanting that name
by_name: dict[str, list[dict]] = {}
for r in wanted:
by_name.setdefault(r["name"].lower(), []).append(r)
found = 0
total = len(wanted)
print(f"\n {BCYN}Waiting for ADVERTs from {total} repeater(s):{RST}")
for r in wanted:
print(f" {CYN}{r['name']}{RST}")
old_timeout = ser.timeout
ser.timeout = 1
try:
while found < total:
data = read_frame(ser)
if data is None:
continue
resp = decode_response(data)
if resp["type"] != "RxPacket":
continue
packet = resp["payload"]
parsed = parse_header_and_path(packet)
if parsed is None:
continue
payload_type, route_type, pos, raw_payload, path_hops, hash_size = parsed
if payload_type != 0x04: # not ADVERT
continue
result = decode_advert(raw_payload)
if result is None:
continue
pubkey, adv_name, node_type = result
pk_hex = pubkey.hex()
# Match against wanted names (case-insensitive)
matches = by_name.get(adv_name.lower(), [])
if not matches:
print(f" {DIM}ADVERT {adv_name} (not in config){RST}")
continue
for r in matches:
if r.get("pubkey"):
continue # already discovered
r["pubkey"] = pk_hex
found += 1
# Learn route from the ADVERT path too
if path_hops:
_learn_route(pk_hex, path_hops, hash_size)
print(
f" {BGRN}Discovered {adv_name}{RST} "
f"{DIM}pubkey={pk_hex[:16]}... "
f"hash=0x{pubkey[0]:02x}{RST}"
)
if found >= total:
break
finally:
ser.timeout = old_timeout
return found
# ── Config ────────────────────────────────────────────────────────
def load_config() -> dict:
if not CONFIG_FILE.is_file():
print(f" {RED}Config not found: {CONFIG_FILE}{RST}")
print(f" {DIM}Create it with:{RST}")
print(f"""
{{
"repeaters": [
{{
"name": "MyRepeater",
"password": "guest123"
}}
],
"poll_interval_secs": 3600
}}""")
sys.exit(1)
with open(CONFIG_FILE) as f:
config = json.load(f)
repeaters = config.get("repeaters", [])
if not repeaters:
print(f" {RED}No repeaters configured in {CONFIG_FILE}{RST}")
sys.exit(1)
for r in repeaters:
if "name" not in r or "password" not in r:
print(f" {RED}Repeater missing name or password: {r}{RST}")
sys.exit(1)
pk = r.get("pubkey", "")
if pk and len(pk) != 64:
print(f" {RED}Invalid pubkey length for {r['name']}: expected 64 hex chars{RST}")
sys.exit(1)
return config
def save_config(config: dict):
"""Write config back to disk (atomic)."""
try:
DATA_DIR.mkdir(parents=True, exist_ok=True)
tmp = CONFIG_FILE.with_suffix(".tmp")
with open(tmp, "w") as f:
json.dump(config, f, indent=4, ensure_ascii=False)
tmp.rename(CONFIG_FILE)
except Exception as e:
print(f" {RED}Failed to save config: {e}{RST}")
def needs_discovery(config: dict) -> list[dict]:
"""Return repeaters that are missing their pubkey."""
return [r for r in config["repeaters"] if not r.get("pubkey")]
# ── Main loop ─────────────────────────────────────────────────────
def _print_repeater_status(repeaters: list[dict]):
for r in repeaters:
pk = r.get("pubkey", "")
if pk:
route = _get_route(pk)
route_str = f"{len(route[0])}-hop" if route else "none"
print(f" {CYN}{r['name']}{RST} {DIM}(hash=0x{bytes.fromhex(pk)[0]:02x} route={route_str}){RST}")
else:
print(f" {CYN}{r['name']}{RST} {YEL}(pubkey unknown — will discover via ADVERT){RST}")
def main():
import argparse
parser = argparse.ArgumentParser(description="MeshCore repeater telemetry monitor")
parser.add_argument("--port", "-p", default=None, help="Serial port (auto-detect if omitted)")
args = parser.parse_args()
_init_identity()
_load_state()
config = load_config()
repeaters = config["repeaters"]
poll_interval = config.get("poll_interval_secs", DEFAULT_POLL_INTERVAL)
print(f"\n {BWHT}Telemetry Monitor{RST}")
print(f" {DIM}Repeaters: {len(repeaters)} | Poll interval: {poll_interval}s{RST}")
_print_repeater_status(repeaters)
port = args.port
while True:
try:
print(f" {DIM}Connecting...{RST}")
ser = open_connection(port)
print(f" {GRN}Connected{RST}")
send_cmd(ser, {"type": "Ping"}, "Ping")
send_cmd(ser, {"type": "SetConfig", "config": RADIO_CONFIG}, "SetConfig")
send_cmd(ser, {"type": "StartRx"}, "StartRx")
# Discover any repeaters missing their pubkey
missing = needs_discovery(config)
if missing:
found = discover_pubkeys(ser, missing)
if found:
save_config(config)
_save_state()
print(f" {BGRN}Saved {found} pubkey(s) to {CONFIG_FILE}{RST}")
still_missing = needs_discovery(config)
if still_missing:
names = ", ".join(r["name"] for r in still_missing)
print(f" {YEL}Still waiting for: {names}{RST}")
print(f" {DIM}Will retry discovery each poll cycle{RST}")
while True:
ts = time.strftime("%Y-%m-%d %H:%M:%S %Z")
print(f"\n {BWHT}═══ Poll cycle {ts} ═══{RST}")
# Re-check for missing pubkeys each cycle
missing = needs_discovery(config)
if missing:
print(f" {DIM}Listening for {len(missing)} missing ADVERT(s)...{RST}")
found = discover_pubkeys(ser, missing)
if found:
save_config(config)
_save_state()
for repeater in repeaters:
if not repeater.get("pubkey"):
print(f"\n {BCYN}── {repeater['name']} ──{RST}")
print(f" {YEL}Skipping — pubkey not yet discovered{RST}")
continue
try:
poll_repeater(ser, repeater)
except (ConnectionError, OSError):
raise # mux/serial disconnect — let outer loop reconnect
except Exception as e:
print(f" {RED}Error polling {repeater['name']}: {e}{RST}")
_save_state()
next_poll = time.strftime(
"%H:%M:%S", time.localtime(time.time() + poll_interval)
)
print(f"\n {DIM}Next poll at {next_poll} (sleeping {poll_interval}s){RST}")
# Sleep in 1-second increments so Ctrl-C is responsive
deadline = time.monotonic() + poll_interval
while time.monotonic() < deadline:
time.sleep(1)
except (serial.SerialException, ConnectionError, OSError) as e:
print(f"\n {RED}Disconnected: {e}{RST}")
print(f" {DIM}Reconnecting...{RST}")
time.sleep(1)
except KeyboardInterrupt:
print()
_save_state()
try:
ser.timeout = 2
send_cmd(ser, {"type": "StopRx"}, "StopRx")
except Exception:
pass
break
if __name__ == "__main__":
main()