Handle nodeinfo packets without identifiers (#426) (#427)

This commit is contained in:
l5y
2025-11-11 20:45:32 +01:00
committed by GitHub
parent 2bb8e3fd66
commit 8b090cb238
2 changed files with 130 additions and 37 deletions

View File

@@ -16,6 +16,7 @@
from __future__ import annotations
import contextlib
import glob
import ipaddress
import re
@@ -48,39 +49,97 @@ def _patch_meshtastic_nodeinfo_handler() -> None:
if getattr(original, "_potato_mesh_safe_wrapper", False):
return
def _safe_on_node_info_receive(iface, packet): # type: ignore[override]
candidate_mapping: Mapping | None = None
if isinstance(packet, Mapping):
candidate_mapping = packet
elif hasattr(packet, "__dict__") and isinstance(packet.__dict__, Mapping):
candidate_mapping = packet.__dict__
def _ensure_mapping(value) -> Mapping | None:
"""Return ``value`` as a mapping when conversion is possible."""
node_id = None
if candidate_mapping is not None:
node_id = serialization._canonical_node_id(candidate_mapping.get("id"))
if node_id is None:
user_section = candidate_mapping.get("user")
if isinstance(user_section, Mapping):
node_id = serialization._canonical_node_id(user_section.get("id"))
if node_id is None:
for key in ("fromId", "from_id", "from", "num", "nodeId", "node_id"):
node_id = serialization._canonical_node_id(
candidate_mapping.get(key)
)
if node_id:
break
if isinstance(value, Mapping):
return value
if hasattr(value, "__dict__") and isinstance(value.__dict__, Mapping):
return value.__dict__
with contextlib.suppress(Exception):
converted = serialization._node_to_dict(value)
if isinstance(converted, Mapping):
return converted
return None
def _candidate_node_id(mapping: Mapping | None) -> str | None:
"""Extract a canonical node identifier from ``mapping`` when present."""
if mapping is None:
return None
primary_keys = (
"id",
"userId",
"user_id",
"fromId",
"from_id",
"from",
"nodeId",
"node_id",
"nodeNum",
"node_num",
"num",
)
for key in primary_keys:
node_id = serialization._canonical_node_id(mapping.get(key))
if node_id:
if not isinstance(candidate_mapping, dict):
try:
candidate_mapping = dict(candidate_mapping)
except Exception:
candidate_mapping = {
k: candidate_mapping[k] for k in candidate_mapping
}
if candidate_mapping.get("id") != node_id:
candidate_mapping["id"] = node_id
packet = candidate_mapping
return node_id
user_section = _ensure_mapping(mapping.get("user"))
if user_section is not None:
for key in ("id", "userId", "user_id", "num", "nodeNum", "node_num"):
node_id = serialization._canonical_node_id(user_section.get(key))
if node_id:
return node_id
decoded_section = _ensure_mapping(mapping.get("decoded"))
if decoded_section is not None:
node_id = _candidate_node_id(decoded_section)
if node_id:
return node_id
payload_section = _ensure_mapping(mapping.get("payload"))
if payload_section is not None:
node_id = _candidate_node_id(payload_section)
if node_id:
return node_id
for key in ("packet", "meta", "info"):
node_id = _candidate_node_id(_ensure_mapping(mapping.get(key)))
if node_id:
return node_id
for value in mapping.values():
if isinstance(value, (list, tuple)):
for item in value:
node_id = _candidate_node_id(_ensure_mapping(item))
if node_id:
return node_id
else:
node_id = _candidate_node_id(_ensure_mapping(value))
if node_id:
return node_id
return None
def _safe_on_node_info_receive(iface, packet): # type: ignore[override]
candidate_mapping = _ensure_mapping(packet)
node_id = _candidate_node_id(candidate_mapping)
if node_id and candidate_mapping is not None:
if not isinstance(candidate_mapping, dict):
try:
candidate_mapping = dict(candidate_mapping)
except Exception:
candidate_mapping = {
k: candidate_mapping[k] for k in candidate_mapping
}
if candidate_mapping.get("id") != node_id:
candidate_mapping["id"] = node_id
packet = candidate_mapping
try:
return original(iface, packet)

View File

@@ -198,7 +198,6 @@ def mesh_module(monkeypatch):
def test_snapshot_interval_defaults_to_60_seconds(mesh_module):
mesh = mesh_module
assert mesh.SNAPSHOT_SECS == 60
@@ -1038,12 +1037,47 @@ def test_store_packet_dict_nodeinfo_uses_from_id_when_user_missing(
assert captured
_, payload, _ = captured[0]
assert "!01020304" in payload
node_entry = payload["!01020304"]
assert node_entry["num"] == 0x01020304
assert node_entry["lastHeard"] == 200
assert node_entry["snr"] == pytest.approx(1.5)
assert node_entry["lora_freq"] == 868
assert node_entry["modem_preset"] == "MediumFast"
def test_nodeinfo_wrapper_infers_missing_identifier(mesh_module, monkeypatch):
"""Ensure the Meshtastic nodeinfo hook derives canonical IDs from payloads."""
_ = mesh_module
import meshtastic
from data.mesh_ingestor import interfaces
captured_packets: list[dict] = []
def _original_handler(iface, packet):
captured_packets.append(packet)
return packet["id"]
monkeypatch.setattr(
meshtastic, "_onNodeInfoReceive", _original_handler, raising=False
)
interfaces._patch_meshtastic_nodeinfo_handler()
safe_handler = meshtastic._onNodeInfoReceive
class DummyUser:
def __init__(self) -> None:
self.num = 0x88776655
class DummyDecoded:
def __init__(self) -> None:
self.user = DummyUser()
class DummyPacket:
def __init__(self) -> None:
self.decoded = DummyDecoded()
iface = types.SimpleNamespace(nodes={})
safe_handler(iface, DummyPacket())
assert captured_packets, "Expected wrapper to call the original handler"
packet = captured_packets[0]
assert packet["id"] == "!88776655"
def test_store_packet_dict_ignores_non_text(mesh_module, monkeypatch):