Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ dependencies = [
"pynmea2>=1.18.0",
"segno>=1.6.0", # QR code generation
"gpsdclient>=1.3",
"pycayennelpp>=2.4.0",
]

[project.optional-dependencies]
Expand Down
3 changes: 3 additions & 0 deletions src/meshcore_console/core/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ class EventType(StrEnum):
# Radio health
RADIO_ERROR = "radio_error"

# Telemetry
TELEMETRY_RECEIVED = "telemetry_received"

# EventService events (mesh.* naming)
MESH_CONTACT_NEW = "mesh.contact.new"
MESH_CHANNEL_MESSAGE_NEW = "mesh.channel.message.new"
Expand Down
4 changes: 4 additions & 0 deletions src/meshcore_console/core/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ def set_favorite(self, peer_id: str, favorite: bool) -> None:
"""Toggle the favorite flag on a peer."""
...

def request_telemetry(self, peer_name: str) -> dict:
"""Request telemetry data from a remote peer. Returns decoded sensor data."""
...

def get_self_public_key(self) -> str | None:
"""Return this node's public key as a hex string, or None if unavailable."""
...
11 changes: 11 additions & 0 deletions src/meshcore_console/core/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,17 @@ async def send_group_text(self, channel_name: str, message: str) -> object:
"""Send a text message to a group channel."""
...

async def send_telemetry_request(
self,
contact_name: str,
want_base: bool = True,
want_location: bool = True,
want_environment: bool = False,
timeout: float = 10.0,
) -> dict:
"""Request telemetry data from a remote peer."""
...


class EventSubscriberProtocol(Protocol):
"""Protocol for pyMC_core EventSubscriber."""
Expand Down
61 changes: 61 additions & 0 deletions src/meshcore_console/meshcore/cayenne_lpp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""CayenneLPP encoder/decoder for telemetry payloads.

Uses the pycayennelpp library for encoding/decoding, and provides
``decode_cayenne_lpp_payload`` matching the interface pymc_core's
ProtocolResponseHandler expects from ``utils.cayenne_lpp_helpers``.
"""

from __future__ import annotations

from cayennelpp import LppFrame


def encode_gps(channel: int, lat: float, lon: float, alt: float = 0.0) -> bytes:
"""Encode a GPS location as CayenneLPP bytes."""
frame = LppFrame()
frame.add_gps(channel, lat, lon, alt)
return bytes(frame)


def decode_cayenne_lpp_payload(hex_string: str) -> dict:
"""Decode a CayenneLPP hex payload into structured sensor data.

Matches the signature expected by pymc_core's
``utils.cayenne_lpp_helpers.decode_cayenne_lpp_payload``.

Returns:
dict with ``sensor_count`` and ``sensors`` list, or ``error`` string.
"""
try:
data = bytes.fromhex(hex_string)
except ValueError as e:
return {"error": f"Invalid hex: {e}"}

try:
frame = LppFrame().from_bytes(data)
except Exception as e:
return {"error": f"LPP decode failed: {e}"}

sensors: list[dict] = []
for item in frame:
value = item.value
# GPS/Location type returns (lat, lon, alt) tuple
if item.type == "Location":
value = {"latitude": value[0], "longitude": value[1], "altitude": value[2]}
elif isinstance(value, tuple) and len(value) == 1:
value = value[0]

sensors.append(
{
"channel": item.channel,
"type": item.type,
"type_id": item.type,
"value": value,
"raw_value": hex_string,
}
)

if not sensors:
return {"error": "No valid sensors found", "sensor_count": 0, "sensors": []}

return {"sensor_count": len(sensors), "sensors": sensors}
26 changes: 26 additions & 0 deletions src/meshcore_console/meshcore/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ def connect(self) -> None:
self._connected = False
raise
self._connected = True
self._session.set_telemetry_data_fn(self._get_local_telemetry)
self._seed_contact_book()
self._gps_provider.start()
self._append_event(
Expand Down Expand Up @@ -777,6 +778,31 @@ def get_self_public_key(self) -> str | None:
"""Return this node's public key as a hex string, or None if unavailable."""
return self._session.get_public_key()

def request_telemetry(self, peer_name: str) -> dict:
"""Request telemetry data from a remote peer."""
if not self._connected:
self.connect()
result = self._run_async(
self._session.send_telemetry_request(peer_name, timeout=10.0),
timeout=15.0,
)
self._append_event(
{
"type": EventType.TELEMETRY_RECEIVED,
"data": {"peer_name": peer_name, "telemetry": result},
}
)
return result # type: ignore[return-value]

def _get_local_telemetry(self) -> dict:
"""Provide local telemetry data for inbound requests."""
loc = self._gps_provider.get_location()
return {
"allow": self._settings.allow_telemetry,
"lat": loc[0] if loc else None,
"lon": loc[1] if loc else None,
}

def _seed_contact_book(self) -> None:
"""Populate the session's contact book with known peers that have public keys."""
book = self._session.contact_book
Expand Down
4 changes: 4 additions & 0 deletions src/meshcore_console/meshcore/contact_book.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
- .contacts — iterable of objects with .public_key (hex str) and .name
- .get_by_name(name) — return a contact or None
- .add_contact(data) — store a new contact
- .list_contacts() — return all contacts (used by ProtocolResponseHandler)
"""

from __future__ import annotations
Expand All @@ -31,6 +32,9 @@ class ContactBook:
def __init__(self) -> None:
self.contacts: list[Contact] = []

def list_contacts(self) -> list[Contact]:
return self.contacts

def get_by_name(self, name: str) -> Contact | None:
for contact in self.contacts:
if contact.name == name:
Expand Down
17 changes: 17 additions & 0 deletions src/meshcore_console/meshcore/operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,23 @@ async def send_group_text(*, node: MeshNodeProtocol, channel_name: str, message:
return await node.send_group_text(channel_name, message)


async def request_telemetry(
*,
node: MeshNodeProtocol,
contact_name: str,
want_location: bool = True,
timeout: float = 10.0,
) -> dict:
"""Request telemetry data from a remote peer."""
return await node.send_telemetry_request(
contact_name,
want_base=True,
want_location=want_location,
want_environment=False,
timeout=timeout,
)


async def send_advert(
*,
node: MeshNodeProtocol,
Expand Down
68 changes: 68 additions & 0 deletions src/meshcore_console/meshcore/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,21 @@
SX1262RadioProtocol,
)

from .cayenne_lpp import encode_gps
from . import cayenne_lpp as _cayenne_lpp_mod

# pymc_core's ProtocolResponseHandler tries ``from utils.cayenne_lpp_helpers
# import decode_cayenne_lpp_payload`` which isn't shipped. Register our
# module under that name so the import succeeds.
import sys as _sys
import types as _types

if "utils.cayenne_lpp_helpers" not in _sys.modules:
_shim = _types.ModuleType("utils.cayenne_lpp_helpers")
_shim.decode_cayenne_lpp_payload = _cayenne_lpp_mod.decode_cayenne_lpp_payload # type: ignore[attr-defined]
_sys.modules.setdefault("utils", _types.ModuleType("utils"))
_sys.modules["utils.cayenne_lpp_helpers"] = _shim

from .channel_db import ChannelDatabase
from .config import RuntimeRadioConfig, load_hardware_config_from_env
from .contact_book import ContactBook
Expand All @@ -46,6 +61,7 @@ def __init__(self, config: RuntimeRadioConfig, logger: LoggerCallback | None = N
self._node_task: asyncio.Task[None] | None = None
self._event_queue: queue.Queue[MeshEventDict] = queue.Queue()
self._event_notify: Callable[[], None] | None = None
self._telemetry_data_fn: Callable[[], dict[str, Any]] | None = None
self._db = open_db()
self._channel_db = ChannelDatabase(self._db)
self._contact_book = ContactBook()
Expand All @@ -58,6 +74,10 @@ def _log(self, message: str) -> None:
def set_event_notify(self, notify_fn: Callable[[], None]) -> None:
self._event_notify = notify_fn

def set_telemetry_data_fn(self, fn: Callable[[], dict[str, Any]]) -> None:
"""Set the callback that provides local telemetry data for inbound requests."""
self._telemetry_data_fn = fn

def _emit(self, payload: MeshEventDict) -> None:
self._event_queue.put_nowait(payload)
if self._event_notify is not None:
Expand Down Expand Up @@ -120,6 +140,33 @@ async def _send() -> None:

control_handler.set_request_callback(_on_discovery_request)

def _build_telemetry_handler(self) -> Callable[..., bytes | None]:
"""Build a handler closure for inbound telemetry requests (REQ type 0x03)."""
TELEM_PERM_LOCATION = 0x02 # noqa: N806

def _handle_telemetry(_client: Any, _timestamp: int, req_data: bytes) -> bytes | None:
if self._telemetry_data_fn is None:
return None
data = self._telemetry_data_fn()
if not data.get("allow"):
self._log("telemetry request denied (allow_telemetry=False)")
return None
# req_data is an inverse permission mask — bits set = permissions EXCLUDED
mask = req_data[0] if req_data else 0x00
if mask & TELEM_PERM_LOCATION:
self._log("telemetry request excludes location")
return None
lat = data.get("lat")
lon = data.get("lon")
if lat is None or lon is None:
self._log("telemetry request: no GPS fix")
return None
payload = encode_gps(channel=1, lat=lat, lon=lon)
self._log(f"responding to telemetry request with GPS ({lat:.4f}, {lon:.4f})")
return payload

return _handle_telemetry

def _register_req_handler(self) -> None:
"""Register a handler for incoming REQ packets.

Expand All @@ -131,13 +178,16 @@ def _register_req_handler(self) -> None:
from pymc_core.node.handlers.protocol_request import ProtocolRequestHandler
from pymc_core.protocol.constants import PAYLOAD_TYPE_REQ

REQ_TYPE_GET_TELEMETRY_DATA = 0x03 # noqa: N806

assert self._node is not None
assert self._identity is not None

req_handler = ProtocolRequestHandler(
local_identity=self._identity,
contacts=self._contact_book,
log_fn=self._log,
request_handlers={REQ_TYPE_GET_TELEMETRY_DATA: self._build_telemetry_handler()},
)

dispatcher = self._node.dispatcher
Expand Down Expand Up @@ -384,6 +434,24 @@ async def send_advert(
route_type=route_type,
)

async def send_telemetry_request(
self,
contact_name: str,
*,
want_location: bool = True,
timeout: float = 10.0,
) -> dict:
"""Request telemetry from a remote peer. Returns decoded sensor data."""
if self._node is None:
raise RuntimeError("Session is not started.")
return await self._node.send_telemetry_request(
contact_name,
want_base=True,
want_location=want_location,
want_environment=False,
timeout=timeout,
)

async def listen_events(self) -> AsyncIterator[MeshEventDict]:
if self._node is None:
raise RuntimeError("Session is not started.")
Expand Down
42 changes: 42 additions & 0 deletions src/meshcore_console/mock/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,48 @@ def set_favorite(self, peer_id: str, favorite: bool) -> None:
peer.is_favorite = favorite
return

def request_telemetry(self, peer_name: str) -> dict:
"""Return synthetic telemetry data matching pymc_core's format."""
import time

loc = self._gps_provider.get_location()
lat = (loc[0] + 0.012) if loc else 37.7749
lon = (loc[1] - 0.008) if loc else -122.4194
result = {
"success": True,
"contact": peer_name,
"telemetry_data": {
"type": "telemetry",
"reflected_timestamp": int(time.time()),
"sensor_count": 2,
"sensors": [
{
"channel": 1,
"type": "Voltage",
"type_id": "Voltage",
"value": 3.87,
"raw_value": "01740183",
},
{
"channel": 2,
"type": "Location",
"type_id": "Location",
"value": {"latitude": lat, "longitude": lon, "altitude": 42.5},
"raw_value": "",
},
],
},
"rtt_ms": 1247.0,
"reason": "Telemetry response received",
}
self._append_event(
{
"type": "telemetry_received",
"data": {"peer_name": peer_name, "telemetry": result},
}
)
return result

def get_self_public_key(self) -> str | None:
"""Return a mock public key for testing."""
return "6b547fd13630e0f7a6b167df23b9876543210abcdef0123456789abcdef0a619"
Expand Down
Loading