Add LPP/tracked repeater telemetry and HA fanout

This commit is contained in:
Jack Kingsman
2026-04-12 17:23:25 -07:00
parent 95a17ca8ee
commit b9414e84ee
10 changed files with 704 additions and 24 deletions
+102 -1
View File
@@ -85,6 +85,68 @@ _REPEATER_SENSORS: list[dict[str, str | None]] = [
},
]
# ── LPP sensor metadata ─────────────────────────────────────────────────
_LPP_HA_META: dict[str, dict[str, str | None]] = {
"temperature": {"device_class": "temperature", "unit": "°C"},
"humidity": {"device_class": "humidity", "unit": "%"},
"barometer": {"device_class": "atmospheric_pressure", "unit": "hPa"},
"voltage": {"device_class": "voltage", "unit": "V"},
"current": {"device_class": "current", "unit": "mA"},
"luminosity": {"device_class": "illuminance", "unit": "lux"},
"power": {"device_class": "power", "unit": "W"},
"energy": {"device_class": "energy", "unit": "kWh"},
"distance": {"device_class": "distance", "unit": "mm"},
"concentration": {"device_class": None, "unit": "ppm"},
"direction": {"device_class": None, "unit": "°"},
"altitude": {"device_class": None, "unit": "m"},
}
def _lpp_sensor_key(type_name: str, channel: int) -> str:
"""Build the flat telemetry-payload key for an LPP sensor."""
return f"lpp_{type_name}_ch{channel}"
def _lpp_discovery_configs(
prefix: str,
pub_key: str,
device: dict,
lpp_sensors: list[dict],
state_topic: str,
) -> list[tuple[str, dict]]:
"""Build HA discovery configs for a repeater's LPP sensors."""
configs: list[tuple[str, dict]] = []
for sensor in lpp_sensors:
type_name = sensor.get("type_name", "unknown")
channel = sensor.get("channel", 0)
field = _lpp_sensor_key(type_name, channel)
meta = _LPP_HA_META.get(type_name, {})
nid = _node_id(pub_key)
object_id = field
display = type_name.replace("_", " ").title()
name = f"{display} (Ch {channel})"
cfg: dict[str, Any] = {
"name": name,
"unique_id": f"meshcore_{nid}_{object_id}",
"device": device,
"state_topic": state_topic,
"value_template": "{{ value_json." + field + " }}",
"state_class": "measurement",
"expire_after": 36000,
}
if meta.get("device_class"):
cfg["device_class"] = meta["device_class"]
if meta.get("unit"):
cfg["unit_of_measurement"] = meta["unit"]
topic = f"homeassistant/sensor/meshcore_{nid}/{object_id}/config"
configs.append((topic, cfg))
return configs
# ── Local radio sensor definitions ────────────────────────────────────────
@@ -424,12 +486,21 @@ class MqttHaModule(FanoutModule):
radio_name = self._radio_name or "MeshCore Radio"
configs.extend(_radio_discovery_configs(self._prefix, self._radio_key, radio_name))
# Tracked repeaters — resolve names from DB best-effort
# Tracked repeaters — resolve names and LPP sensors from DB best-effort
for pub_key in self._tracked_repeaters:
rname = await self._resolve_contact_name(pub_key)
configs.extend(
_repeater_discovery_configs(self._prefix, pub_key, rname, self._radio_key)
)
# Dynamic LPP sensor entities from last known telemetry snapshot
lpp_sensors = await self._resolve_lpp_sensors(pub_key)
if lpp_sensors:
nid = _node_id(pub_key)
device = _device_payload(pub_key, rname, "Repeater", via_device_key=self._radio_key)
state_topic = f"{self._prefix}/{nid}/telemetry"
configs.extend(
_lpp_discovery_configs(self._prefix, pub_key, device, lpp_sensors, state_topic)
)
# Tracked contacts — resolve names from DB best-effort
for pub_key in self._tracked_contacts:
@@ -481,6 +552,19 @@ class MqttHaModule(FanoutModule):
pass
return pub_key[:12]
@staticmethod
async def _resolve_lpp_sensors(pub_key: str) -> list[dict]:
"""Return the LPP sensor list from the most recent telemetry snapshot, or []."""
try:
from app.repository.repeater_telemetry import RepeaterTelemetryRepository
latest = await RepeaterTelemetryRepository.get_latest(pub_key)
if latest:
return latest.get("data", {}).get("lpp_sensors", [])
except Exception:
pass
return []
def _seed_radio_identity_from_runtime(self) -> None:
"""Best-effort bootstrap from the currently connected radio session."""
try:
@@ -590,6 +674,23 @@ class MqttHaModule(FanoutModule):
field = s["field"]
if field is not None:
payload[field] = data.get(field)
# Flatten LPP sensors into the same payload so HA value_templates work
lpp_sensors: list[dict] = data.get("lpp_sensors", [])
rediscover = False
for sensor in lpp_sensors:
key = _lpp_sensor_key(sensor.get("type_name", "unknown"), sensor.get("channel", 0))
payload[key] = sensor.get("value")
# Check if discovery for this sensor has been published yet
expected_topic = f"homeassistant/sensor/meshcore_{nid}/{key}/config"
if expected_topic not in self._discovery_topics:
rediscover = True
# If new LPP sensor types appeared, re-publish discovery *before*
# the state payload so HA already knows the entity when the value arrives.
if rediscover:
await self._publish_discovery()
await self._publisher.publish(f"{self._prefix}/{nid}/telemetry", payload)
async def on_message(self, data: dict) -> None:
+29
View File
@@ -1584,6 +1584,35 @@ async def _collect_repeater_telemetry(mc: MeshCore, contact: Contact) -> bool:
"full_events": status.get("full_evts", 0),
}
# Best-effort LPP sensor fetch — failure here does not fail the overall
# collection; status telemetry is still recorded without sensor data.
try:
lpp_raw = await mc.commands.req_telemetry_sync(
contact.public_key, timeout=10, min_timeout=5
)
if lpp_raw:
lpp_sensors = []
for entry in lpp_raw:
value = entry.get("value", 0)
# Skip multi-value sensors (GPS, accelerometer, etc.)
if isinstance(value, dict):
continue
lpp_sensors.append(
{
"channel": entry.get("channel", 0),
"type_name": str(entry.get("type", "unknown")),
"value": value,
}
)
if lpp_sensors:
data["lpp_sensors"] = lpp_sensors
except Exception as e:
logger.debug(
"Telemetry collect: LPP sensor fetch failed for %s (non-fatal): %s",
contact.public_key[:12],
e,
)
try:
timestamp = int(time.time())
await RepeaterTelemetryRepository.record(
+21
View File
@@ -73,3 +73,24 @@ class RepeaterTelemetryRepository:
}
for row in rows
]
@staticmethod
async def get_latest(public_key: str) -> dict | None:
"""Return the most recent telemetry row for a repeater, or None."""
cursor = await db.conn.execute(
"""
SELECT timestamp, data
FROM repeater_telemetry_history
WHERE public_key = ?
ORDER BY timestamp DESC
LIMIT 1
""",
(public_key,),
)
row = await cursor.fetchone()
if row is None:
return None
return {
"timestamp": row["timestamp"],
"data": json.loads(row["data"]),
}
+28
View File
@@ -94,6 +94,7 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact)
lpp_raw = None
async with radio_manager.radio_operation(
"repeater_status", pause_polling=True, suspend_auto_fetch=True
) as mc:
@@ -102,6 +103,15 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
status = await mc.commands.req_status_sync(contact.public_key, timeout=10, min_timeout=5)
# Best-effort LPP sensor fetch while we still hold the lock
if status is not None:
try:
lpp_raw = await mc.commands.req_telemetry_sync(
contact.public_key, timeout=10, min_timeout=5
)
except Exception as e:
logger.debug("LPP sensor fetch failed for %s (non-fatal): %s", public_key[:12], e)
if status is None:
raise HTTPException(status_code=504, detail="No status response from repeater")
@@ -128,6 +138,24 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
# Record to telemetry history as a JSON blob (best-effort)
now = int(time.time())
status_dict = response.model_dump(exclude={"telemetry_history"})
# Attach scalar LPP sensors to the stored snapshot (same logic as auto-collect)
if lpp_raw:
lpp_sensors = []
for entry in lpp_raw:
value = entry.get("value", 0)
if isinstance(value, dict):
continue
lpp_sensors.append(
{
"channel": entry.get("channel", 0),
"type_name": str(entry.get("type", "unknown")),
"value": value,
}
)
if lpp_sensors:
status_dict["lpp_sensors"] = lpp_sensors
try:
await RepeaterTelemetryRepository.record(
public_key=contact.public_key,