fix(protocol): answer telemetry requests with cayenne lpp sensor data

This commit is contained in:
agessaman
2026-07-16 23:37:37 -07:00
parent 2cfeef588d
commit 2139e41fc5
3 changed files with 253 additions and 0 deletions
@@ -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."""