From dcb374fbf9401d8de052ecddb292efb45d4b7ada Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Sat, 4 Apr 2026 10:41:06 +0200 Subject: [PATCH] enh: surface meshcore role types (#680) (#685) * enh: surface meshcore role types (#680) Map MeshCore ADV_TYPE_* integers to user.role strings so COMPANION, REPEATER, ROOM_SERVER, and SENSOR roles are surfaced to the dashboard. Role is omitted when ADV_TYPE_NONE (0) or unknown. Co-authored-by: Ben Allfree * data: run black --------- Co-authored-by: Ben Allfree --- data/mesh_ingestor/CONTRACTS.md | 3 +- data/mesh_ingestor/providers/meshcore.py | 45 ++++++++++++++++-- tests/test_provider_unit.py | 60 ++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 6 deletions(-) diff --git a/data/mesh_ingestor/CONTRACTS.md b/data/mesh_ingestor/CONTRACTS.md index aab3863..f362dce 100644 --- a/data/mesh_ingestor/CONTRACTS.md +++ b/data/mesh_ingestor/CONTRACTS.md @@ -37,7 +37,8 @@ Node entry fields are “Meshtastic-ish” (camelCase) and may include: - `snr` (float) - `hopsAway` (int) - `isFavorite` (bool) -- `user` (mapping; e.g. `shortName`, `longName`, `macaddr`, `hwModel`, `role`, `publicKey`, `isUnmessagable`) +- `user` (mapping; e.g. `shortName`, `longName`, `macaddr`, `hwModel`, `publicKey`, `isUnmessagable`) + - `role` (optional string) — omit when unknown; known values include Meshtastic role names (e.g. `CLIENT`, `ROUTER`) and MeshCore role names (`COMPANION`, `REPEATER`, `ROOM_SERVER`, `SENSOR`) - `deviceMetrics` (mapping; e.g. `batteryLevel`, `voltage`, `channelUtilization`, `airUtilTx`, `uptimeSeconds`) - `position` (mapping; `latitude`, `longitude`, `altitude`, `time`, `locationSource`, `precisionBits`, optional nested `raw`) - Optional radio metadata: `lora_freq`, `modem_preset` diff --git a/data/mesh_ingestor/providers/meshcore.py b/data/mesh_ingestor/providers/meshcore.py index bab7ba3..25175ac 100644 --- a/data/mesh_ingestor/providers/meshcore.py +++ b/data/mesh_ingestor/providers/meshcore.py @@ -73,6 +73,14 @@ _CONNECT_TIMEOUT_SECS: float = 30.0 _DEFAULT_BAUDRATE: int = 115200 """Default baud rate for MeshCore serial connections.""" +# MeshCore ``ADV_TYPE_*`` (``AdvertDataHelpers.h``) → ``user.role`` for POST /api/nodes. +_MESHCORE_ADV_TYPE_ROLE: dict[int, str] = { + 1: "COMPANION", # ADV_TYPE_CHAT + 2: "REPEATER", # ADV_TYPE_REPEATER + 3: "ROOM_SERVER", # ADV_TYPE_ROOM_SERVER + 4: "SENSOR", # ADV_TYPE_SENSOR +} + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -131,14 +139,36 @@ def _meshcore_short_name(public_key_hex: str | None) -> str: public_key_hex: Full public key as a hex string from the MeshCore API. Returns: - Four lowercase hex characters, or an empty string when the key is - missing or shorter than four hex digits. + Four lowercase hex characters (e.g. ``"aabb"``), or an empty string + when the key is missing or shorter than four hex characters. """ if not public_key_hex or len(public_key_hex) < 4: return "" return public_key_hex[:4].lower() +def _meshcore_adv_type_to_role(adv_type: object) -> str | None: + """Map MeshCore ``ADV_TYPE_*`` (contact ``type`` / self ``adv_type``) to ingest role. + + Values match MeshCore firmware ``AdvertDataHelpers.h`` (``ADV_TYPE_CHAT``, + ``ADV_TYPE_REPEATER``, …). Role strings match the MeshCore palette keys + used by the web dashboard (``COMPANION``, ``REPEATER``, …). + + Parameters: + adv_type: Raw type byte from meshcore_py (typically ``int`` 0–4). + Non-integer values (e.g. ``float``, ``None``) are rejected and + return ``None``. Future firmware type codes not yet in the mapping + also return ``None`` until the table is updated. + + Returns: + Uppercase role string, or ``None`` when the value is unknown or should + not override the web default (``ADV_TYPE_NONE`` / unrecognised). + """ + if not isinstance(adv_type, int): + return None + return _MESHCORE_ADV_TYPE_ROLE.get(adv_type) + + def _pubkey_prefix_to_node_id(contacts: dict, pubkey_prefix: str) -> str | None: """Look up a canonical node ID by six-byte public-key prefix. @@ -162,20 +192,22 @@ def _contact_to_node_dict(contact: dict) -> dict: Parameters: contact: Contact dict from the MeshCore library. Expected keys - include ``public_key``, ``adv_name``, ``last_advert``, - ``adv_lat``, and ``adv_lon``. + include ``public_key``, ``type`` (``ADV_TYPE_*``), ``adv_name``, + ``last_advert``, ``adv_lat``, and ``adv_lon``. Returns: Node dict compatible with the ``POST /api/nodes`` payload format. """ pub_key = contact.get("public_key", "") name = (contact.get("adv_name") or "").strip() + role = _meshcore_adv_type_to_role(contact.get("type")) node: dict = { "lastHeard": contact.get("last_advert"), "user": { "longName": name, "shortName": _meshcore_short_name(pub_key), "publicKey": pub_key, + **({"role": role} if role is not None else {}), }, } lat = contact.get("adv_lat") @@ -190,19 +222,22 @@ def _self_info_to_node_dict(self_info: dict) -> dict: Parameters: self_info: Payload dict from the ``SELF_INFO`` event. Expected keys - include ``name``, ``public_key``, ``adv_lat``, and ``adv_lon``. + include ``name``, ``public_key``, ``adv_type`` (``ADV_TYPE_*``), + ``adv_lat``, and ``adv_lon``. Returns: Node dict compatible with the ``POST /api/nodes`` payload format. """ name = (self_info.get("name") or "").strip() pub_key = self_info.get("public_key", "") + role = _meshcore_adv_type_to_role(self_info.get("adv_type")) node: dict = { "lastHeard": int(time.time()), "user": { "longName": name, "shortName": _meshcore_short_name(pub_key), "publicKey": pub_key, + **({"role": role} if role is not None else {}), }, } lat = self_info.get("adv_lat") diff --git a/tests/test_provider_unit.py b/tests/test_provider_unit.py index 2a31077..35fe12f 100644 --- a/tests/test_provider_unit.py +++ b/tests/test_provider_unit.py @@ -38,6 +38,7 @@ from data.mesh_ingestor.providers.meshcore import ( # noqa: E402 - path setup _derive_message_id, _make_connection, _make_event_handlers, + _meshcore_adv_type_to_role, _meshcore_node_id, _meshcore_short_name, _process_contact_update, @@ -551,6 +552,11 @@ def test_meshcore_short_name_empty_when_too_short(): assert _meshcore_short_name(None) == "" # type: ignore[arg-type] +def test_meshcore_short_name_exactly_four_chars(): + """_meshcore_short_name with exactly four hex chars returns those four chars.""" + assert _meshcore_short_name("abcd") == "abcd" + + # --------------------------------------------------------------------------- # _pubkey_prefix_to_node_id # --------------------------------------------------------------------------- @@ -575,6 +581,30 @@ def test_pubkey_prefix_returns_none_for_empty_contacts(): assert _pubkey_prefix_to_node_id({}, "aabbccddee11") is None +# --------------------------------------------------------------------------- +# _meshcore_adv_type_to_role +# --------------------------------------------------------------------------- + + +def test_meshcore_adv_type_to_role_maps_adv_types(): + """Known ADV_TYPE_* integers map to dashboard role strings.""" + assert _meshcore_adv_type_to_role(1) == "COMPANION" + assert _meshcore_adv_type_to_role(2) == "REPEATER" + assert _meshcore_adv_type_to_role(3) == "ROOM_SERVER" + assert _meshcore_adv_type_to_role(4) == "SENSOR" + + +def test_meshcore_adv_type_to_role_none_for_unmapped(): + """ADV_TYPE_NONE, unknown codes, and non-integers yield None.""" + assert _meshcore_adv_type_to_role(0) is None + assert _meshcore_adv_type_to_role(99) is None + assert _meshcore_adv_type_to_role(None) is None + assert _meshcore_adv_type_to_role("1") is None + assert ( + _meshcore_adv_type_to_role(2.0) is None + ) # float rejected; JSON numeric coercion guard + + # --------------------------------------------------------------------------- # _contact_to_node_dict # --------------------------------------------------------------------------- @@ -592,6 +622,23 @@ def test_contact_to_node_dict_basic_fields(): assert node["user"]["longName"] == "Alice" assert node["user"]["shortName"] == "aabb" assert node["user"]["publicKey"] == contact["public_key"] + assert "role" not in node["user"] + + +def test_contact_to_node_dict_sets_role_from_type(): + """Contact ``type`` must populate ``user.role`` when ADV_TYPE is mapped.""" + base = {"public_key": "aabbccdd" + "00" * 28, "adv_name": "Rpt"} + assert _contact_to_node_dict({**base, "type": 2})["user"]["role"] == "REPEATER" + + +def test_contact_to_node_dict_omits_role_for_adv_type_none(): + """ADV_TYPE_NONE (0) must not set ``user.role``.""" + contact = { + "public_key": "aabbccdd" + "00" * 28, + "adv_name": "X", + "type": 0, + } + assert "role" not in _contact_to_node_dict(contact)["user"] def test_contact_to_node_dict_includes_position_when_nonzero(): @@ -633,6 +680,19 @@ def test_self_info_to_node_dict_basic_fields(): assert node["user"]["shortName"] == "bbbb" assert node["user"]["publicKey"] == "bb" * 32 assert isinstance(node["lastHeard"], int) + assert "role" not in node["user"] + + +def test_self_info_to_node_dict_sets_role_from_adv_type(): + """SELF_INFO ``adv_type`` must populate ``user.role`` when mapped.""" + self_info = {"name": "Srv", "public_key": "cc" * 32, "adv_type": 3} + assert _self_info_to_node_dict(self_info)["user"]["role"] == "ROOM_SERVER" + + +def test_self_info_to_node_dict_omits_role_for_adv_type_none(): + """adv_type 0 must not set ``user.role``.""" + self_info = {"name": "N", "public_key": "dd" * 32, "adv_type": 0} + assert "role" not in _self_info_to_node_dict(self_info)["user"] def test_self_info_to_node_dict_includes_position():