mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-03 23:43:21 +02:00
* 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 <ben@benallfree.com> * data: run black --------- Co-authored-by: Ben Allfree <ben@benallfree.com>
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user