diff --git a/repeater/handler_helpers/protocol_request.py b/repeater/handler_helpers/protocol_request.py index bfd8734..03e30ce 100644 --- a/repeater/handler_helpers/protocol_request.py +++ b/repeater/handler_helpers/protocol_request.py @@ -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.""" diff --git a/repeater/main.py b/repeater/main.py index e48e894..27bc814 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -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( diff --git a/tests/test_handler_helpers_path_protocol_text.py b/tests/test_handler_helpers_path_protocol_text.py index 4413349..8da7643 100644 --- a/tests/test_handler_helpers_path_protocol_text.py +++ b/tests/test_handler_helpers_path_protocol_text.py @@ -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