mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-07-26 19:42:46 +02:00
fix(protocol): answer telemetry requests with cayenne lpp sensor data
This commit is contained in:
@@ -14,9 +14,17 @@ from openhop_core.node.handlers.protocol_request import (
|
||||
REQ_TYPE_GET_NEIGHBOURS,
|
||||
REQ_TYPE_GET_OWNER_INFO,
|
||||
REQ_TYPE_GET_STATUS,
|
||||
REQ_TYPE_GET_TELEMETRY_DATA,
|
||||
SERVER_RESPONSE_DELAY_MS,
|
||||
ProtocolRequestHandler,
|
||||
)
|
||||
from openhop_core.protocol.cayenne_lpp import (
|
||||
TELEM_CHANNEL_SELF,
|
||||
encode_relative_humidity,
|
||||
encode_temperature,
|
||||
encode_voltage,
|
||||
)
|
||||
from openhop_core.protocol.constants import TELEM_PERM_ENVIRONMENT
|
||||
|
||||
logger = logging.getLogger("ProtocolRequestHelper")
|
||||
|
||||
@@ -33,6 +41,7 @@ class ProtocolRequestHelper:
|
||||
engine=None,
|
||||
neighbor_tracker=None,
|
||||
config=None,
|
||||
sensor_manager=None,
|
||||
):
|
||||
|
||||
self.identity_manager = identity_manager
|
||||
@@ -42,6 +51,7 @@ class ProtocolRequestHelper:
|
||||
self.engine = engine
|
||||
self.neighbor_tracker = neighbor_tracker
|
||||
self.config = config or {}
|
||||
self.sensor_manager = sensor_manager
|
||||
|
||||
# Dictionary of core handlers keyed by dest_hash
|
||||
self.handlers = {}
|
||||
@@ -62,6 +72,7 @@ class ProtocolRequestHelper:
|
||||
# Build request handlers dict
|
||||
request_handlers = {
|
||||
REQ_TYPE_GET_STATUS: self._handle_get_status,
|
||||
REQ_TYPE_GET_TELEMETRY_DATA: self._handle_get_telemetry,
|
||||
REQ_TYPE_GET_ACCESS_LIST: self._make_handle_get_access_list(identity_acl),
|
||||
REQ_TYPE_GET_NEIGHBOURS: self._handle_get_neighbours,
|
||||
REQ_TYPE_GET_OWNER_INFO: self._handle_get_owner_info,
|
||||
@@ -244,6 +255,100 @@ class ProtocolRequestHelper:
|
||||
|
||||
return stats
|
||||
|
||||
def _handle_get_telemetry(self, client, timestamp: int, req_data: bytes):
|
||||
"""Return CayenneLPP telemetry: base voltage + configured sensors.
|
||||
|
||||
Matches C++ simple_repeater/simple_room_server handleRequest
|
||||
REQ_TYPE_GET_TELEMETRY_DATA. req_data[0] is the inverse permission mask
|
||||
(firmware payload[1]); guests are restricted to base telemetry. The base
|
||||
voltage entry on TELEM_CHANNEL_SELF is always emitted. Environment
|
||||
sensors are appended only when the mask grants environment access.
|
||||
|
||||
The firmware's MCU-temperature append is intentionally not mirrored: only
|
||||
configured sensors are reported.
|
||||
"""
|
||||
# Inverse mask -> permission mask (firmware: perm_mask = ~payload[1]).
|
||||
inverse_mask = req_data[0] if len(req_data) >= 1 else 0
|
||||
perm_mask = (~inverse_mask) & 0xFF
|
||||
|
||||
# Guests get base telemetry only (firmware: PERM_ACL_GUEST -> perm_mask 0).
|
||||
if hasattr(client, "is_guest") and client.is_guest():
|
||||
perm_mask = 0x00
|
||||
|
||||
readings = self._get_sensor_readings()
|
||||
|
||||
# Base voltage entry: configured UPS/battery voltage when available,
|
||||
# else 0.0 V (voltage-only floor, mirroring the companion self-telemetry).
|
||||
lpp = bytearray(encode_voltage(TELEM_CHANNEL_SELF, self._battery_voltage(readings)))
|
||||
|
||||
# Environment sensors: each gets the next channel starting at 2, in the
|
||||
# order they are configured (firmware querySensors channel assignment).
|
||||
if perm_mask & TELEM_PERM_ENVIRONMENT:
|
||||
channel = TELEM_CHANNEL_SELF + 1
|
||||
for reading in readings:
|
||||
entry = self._encode_environment_reading(channel, reading)
|
||||
if entry:
|
||||
lpp.extend(entry)
|
||||
channel += 1
|
||||
|
||||
logger.debug(
|
||||
"GET_TELEMETRY: perm_mask=0x%02X, %d LPP bytes",
|
||||
perm_mask,
|
||||
len(lpp),
|
||||
)
|
||||
return bytes(lpp)
|
||||
|
||||
def _get_sensor_readings(self):
|
||||
"""Return the latest cached sensor readings (empty list if unavailable)."""
|
||||
if not self.sensor_manager:
|
||||
return []
|
||||
try:
|
||||
summary = self.sensor_manager.get_summary()
|
||||
return summary.get("readings", []) or []
|
||||
except Exception as exc:
|
||||
logger.debug("Could not read sensor summary: %s", exc)
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _battery_voltage(readings) -> float:
|
||||
"""First configured sensor's pack/bus voltage, else 0.0 V."""
|
||||
for reading in readings:
|
||||
if not reading.get("ok"):
|
||||
continue
|
||||
data = reading.get("data") or {}
|
||||
voltage = data.get("bus_voltage_v")
|
||||
if voltage is not None:
|
||||
try:
|
||||
return float(voltage)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return 0.0
|
||||
|
||||
@staticmethod
|
||||
def _encode_environment_reading(channel: int, reading) -> bytes:
|
||||
"""Encode a temperature/humidity reading as CayenneLPP, or b"" if none.
|
||||
|
||||
Follows the firmware SHT/BME query order: temperature then relative
|
||||
humidity on the same channel.
|
||||
"""
|
||||
if not reading.get("ok"):
|
||||
return b""
|
||||
data = reading.get("data") or {}
|
||||
out = bytearray()
|
||||
temperature = data.get("temperature_c")
|
||||
if temperature is not None:
|
||||
try:
|
||||
out.extend(encode_temperature(channel, float(temperature)))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
humidity = data.get("humidity_pct")
|
||||
if humidity is not None:
|
||||
try:
|
||||
out.extend(encode_relative_humidity(channel, float(humidity)))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return bytes(out)
|
||||
|
||||
def _make_handle_get_access_list(self, identity_acl):
|
||||
"""Create a closure for GET_ACCESS_LIST bound to a specific identity ACL."""
|
||||
|
||||
|
||||
@@ -563,6 +563,7 @@ class RepeaterDaemon:
|
||||
engine=self.repeater_handler,
|
||||
neighbor_tracker=self.advert_helper,
|
||||
config=self.config,
|
||||
sensor_manager=self.sensor_manager,
|
||||
)
|
||||
# Register repeater identity for protocol requests
|
||||
self.protocol_request_helper.register_identity(
|
||||
|
||||
@@ -669,3 +669,150 @@ async def test_protocol_request_real_crypto_consume_vs_collision_forward():
|
||||
assert await helper.process_request_packet(collision) is False
|
||||
assert not collision.is_marked_do_not_retransmit()
|
||||
injector.assert_not_awaited()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ_TYPE_GET_TELEMETRY_DATA (matches simple_repeater / simple_room_server
|
||||
# handleRequest). Response payload is CayenneLPP: a base voltage entry on
|
||||
# TELEM_CHANNEL_SELF, then configured sensors gated by the (inverse) perm mask.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from openhop_core.node.handlers.protocol_request import ( # noqa: E402
|
||||
REQ_TYPE_GET_TELEMETRY_DATA,
|
||||
)
|
||||
from openhop_core.protocol.cayenne_lpp import ( # noqa: E402
|
||||
TELEM_CHANNEL_SELF,
|
||||
encode_relative_humidity,
|
||||
encode_temperature,
|
||||
encode_voltage,
|
||||
)
|
||||
|
||||
|
||||
class _FakeSensorManager:
|
||||
"""Minimal sensor manager exposing cached readings via get_summary()."""
|
||||
|
||||
def __init__(self, readings):
|
||||
self._readings = readings
|
||||
|
||||
def get_summary(self):
|
||||
return {"readings": self._readings}
|
||||
|
||||
|
||||
def _reading(ok=True, **data):
|
||||
return {"name": "s", "type": "t", "ok": ok, "data": data}
|
||||
|
||||
|
||||
def test_telemetry_base_voltage_floor_when_no_sensors():
|
||||
"""No configured sensors -> base voltage-only floor of 0.0 V."""
|
||||
helper = ProtocolRequestHelper(identity_manager=MagicMock(), packet_injector=AsyncMock())
|
||||
admin = SimpleNamespace(is_guest=lambda: False)
|
||||
|
||||
lpp = helper._handle_get_telemetry(admin, 0, b"\x00")
|
||||
|
||||
assert lpp == encode_voltage(TELEM_CHANNEL_SELF, 0.0)
|
||||
|
||||
|
||||
def test_telemetry_base_voltage_from_ups_sensor():
|
||||
"""The configured UPS bus voltage seeds the base entry."""
|
||||
sm = _FakeSensorManager([_reading(bus_voltage_v=12.6)])
|
||||
helper = ProtocolRequestHelper(
|
||||
identity_manager=MagicMock(), packet_injector=AsyncMock(), sensor_manager=sm
|
||||
)
|
||||
admin = SimpleNamespace(is_guest=lambda: False)
|
||||
|
||||
lpp = helper._handle_get_telemetry(admin, 0, b"\x00")
|
||||
|
||||
# No environment values on the UPS reading -> voltage entry only.
|
||||
assert lpp == encode_voltage(TELEM_CHANNEL_SELF, 12.6)
|
||||
|
||||
|
||||
def test_telemetry_admin_full_mask_includes_environment_sensors():
|
||||
"""Admin with full mask gets configured env sensors after the base entry.
|
||||
|
||||
The UPS reading has no temperature/humidity, so channel 2 is assigned to the
|
||||
first environment sensor (firmware querySensors channel assignment).
|
||||
"""
|
||||
sm = _FakeSensorManager(
|
||||
[
|
||||
_reading(bus_voltage_v=12.6),
|
||||
_reading(temperature_c=21.5, humidity_pct=55.0),
|
||||
]
|
||||
)
|
||||
helper = ProtocolRequestHelper(
|
||||
identity_manager=MagicMock(), packet_injector=AsyncMock(), sensor_manager=sm
|
||||
)
|
||||
admin = SimpleNamespace(is_guest=lambda: False)
|
||||
|
||||
# req_data[0] = 0x00 inverse mask -> perm_mask 0xFF (environment allowed).
|
||||
lpp = helper._handle_get_telemetry(admin, 0, b"\x00")
|
||||
|
||||
expected = (
|
||||
encode_voltage(TELEM_CHANNEL_SELF, 12.6)
|
||||
+ encode_temperature(TELEM_CHANNEL_SELF + 1, 21.5)
|
||||
+ encode_relative_humidity(TELEM_CHANNEL_SELF + 1, 55.0)
|
||||
)
|
||||
assert lpp == expected
|
||||
|
||||
|
||||
def test_telemetry_guest_forced_to_base_only():
|
||||
"""A guest is restricted to base telemetry even when requesting the full mask."""
|
||||
sm = _FakeSensorManager([_reading(temperature_c=21.5, humidity_pct=55.0)])
|
||||
helper = ProtocolRequestHelper(
|
||||
identity_manager=MagicMock(), packet_injector=AsyncMock(), sensor_manager=sm
|
||||
)
|
||||
guest = SimpleNamespace(is_guest=lambda: True)
|
||||
|
||||
lpp = helper._handle_get_telemetry(guest, 0, b"\x00")
|
||||
|
||||
assert lpp == encode_voltage(TELEM_CHANNEL_SELF, 0.0)
|
||||
|
||||
|
||||
def test_telemetry_inverse_mask_gates_environment():
|
||||
"""An inverse mask that clears the environment bit strips env sensors.
|
||||
|
||||
perm_mask = ~inverse_mask; inverse byte 0x04 clears TELEM_PERM_ENVIRONMENT.
|
||||
"""
|
||||
sm = _FakeSensorManager([_reading(temperature_c=21.5)])
|
||||
helper = ProtocolRequestHelper(
|
||||
identity_manager=MagicMock(), packet_injector=AsyncMock(), sensor_manager=sm
|
||||
)
|
||||
admin = SimpleNamespace(is_guest=lambda: False)
|
||||
|
||||
lpp = helper._handle_get_telemetry(admin, 0, b"\x04")
|
||||
|
||||
assert lpp == encode_voltage(TELEM_CHANNEL_SELF, 0.0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telemetry_req_end_to_end_produces_response_and_advances_watermark():
|
||||
"""A real, correctly-encrypted telemetry REQ is consumed, answered through
|
||||
the normal _build_response path, and advances the client replay watermark."""
|
||||
local, sender, _remote = _distinct_identities()
|
||||
local_hash = local.get_public_key()[0]
|
||||
|
||||
acl = _acl_with_client(sender, local_identity=local)
|
||||
client = acl.clients[sender.get_public_key()]
|
||||
assert client.last_timestamp == 0
|
||||
|
||||
sm = _FakeSensorManager([_reading(bus_voltage_v=12.6)])
|
||||
injector = AsyncMock(return_value=True)
|
||||
helper = ProtocolRequestHelper(
|
||||
identity_manager=MagicMock(),
|
||||
packet_injector=injector,
|
||||
acl_dict={local_hash: acl},
|
||||
sensor_manager=sm,
|
||||
)
|
||||
helper.register_identity("rep", local, identity_type="repeater")
|
||||
|
||||
genuine, ts = PacketBuilder.create_protocol_request(
|
||||
_SendDest(local.get_public_key()),
|
||||
sender,
|
||||
REQ_TYPE_GET_TELEMETRY_DATA,
|
||||
b"\x00",
|
||||
)
|
||||
with patch("repeater.handler_helpers.protocol_request.asyncio.sleep", new_callable=AsyncMock):
|
||||
assert await helper.process_request_packet(genuine) is True
|
||||
|
||||
assert genuine.is_marked_do_not_retransmit()
|
||||
injector.assert_awaited() # a RESPONSE frame was transmitted
|
||||
assert client.last_timestamp == ts # replay watermark advanced
|
||||
|
||||
Reference in New Issue
Block a user