diff --git a/app/fanout/mqtt_ha.py b/app/fanout/mqtt_ha.py index 635f1f3..5dc9f16 100644 --- a/app/fanout/mqtt_ha.py +++ b/app/fanout/mqtt_ha.py @@ -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: diff --git a/app/radio_sync.py b/app/radio_sync.py index f65e789..652dc65 100644 --- a/app/radio_sync.py +++ b/app/radio_sync.py @@ -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( diff --git a/app/repository/repeater_telemetry.py b/app/repository/repeater_telemetry.py index a3dba86..068a812 100644 --- a/app/repository/repeater_telemetry.py +++ b/app/repository/repeater_telemetry.py @@ -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"]), + } diff --git a/app/routers/repeaters.py b/app/routers/repeaters.py index b149bd4..a2dba2e 100644 --- a/app/routers/repeaters.py +++ b/app/routers/repeaters.py @@ -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, diff --git a/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx b/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx index b7c20ac..ce8363b 100644 --- a/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx +++ b/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx @@ -11,19 +11,36 @@ import { import { cn } from '@/lib/utils'; import { Button } from '../ui/button'; import { Separator } from '../ui/separator'; -import type { TelemetryHistoryEntry, Contact } from '../../types'; +import { LPP_UNIT_MAP } from './repeaterPaneShared'; +import type { TelemetryHistoryEntry, TelemetryLppSensor, Contact } from '../../types'; const MAX_TRACKED = 8; -type Metric = 'battery_volts' | 'noise_floor_dbm' | 'packets' | 'uptime_seconds'; +type BuiltinMetric = 'battery_volts' | 'noise_floor_dbm' | 'packets' | 'uptime_seconds'; -const METRIC_CONFIG: Record = { +interface MetricConfig { + label: string; + unit: string; + color: string; +} + +const BUILTIN_METRIC_CONFIG: Record = { battery_volts: { label: 'Voltage', unit: 'V', color: '#22c55e' }, noise_floor_dbm: { label: 'Noise Floor', unit: 'dBm', color: '#8b5cf6' }, packets: { label: 'Packets', unit: '', color: '#0ea5e9' }, uptime_seconds: { label: 'Uptime', unit: 's', color: '#f59e0b' }, }; +const BUILTIN_METRICS: BuiltinMetric[] = Object.keys(BUILTIN_METRIC_CONFIG) as BuiltinMetric[]; + +// Stable color rotation for dynamic LPP sensors +const LPP_COLORS = ['#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16', '#e11d48']; + +/** Build a flat data key for an LPP sensor: lpp_{type_name}_ch{channel} */ +function lppKey(s: TelemetryLppSensor): string { + return `lpp_${s.type_name}_ch${s.channel}`; +} + const TOOLTIP_STYLE = { contentStyle: { backgroundColor: 'hsl(var(--popover))', @@ -66,18 +83,61 @@ export function TelemetryHistoryPane({ trackedTelemetryRepeaters, onToggleTrackedTelemetry, }: TelemetryHistoryPaneProps) { - const [metric, setMetric] = useState('battery_volts'); + const [metric, setMetric] = useState('battery_volts'); const [toggling, setToggling] = useState(false); const isTracked = trackedTelemetryRepeaters.includes(publicKey); const slotsFull = trackedTelemetryRepeaters.length >= MAX_TRACKED && !isTracked; - const config = METRIC_CONFIG[metric]; + // Discover unique LPP sensors across all history entries + const lppMetrics = useMemo(() => { + const seen = new Map(); + for (const e of entries) { + for (const s of e.data.lpp_sensors ?? []) { + const k = lppKey(s); + if (!seen.has(k)) seen.set(k, { type_name: s.type_name, channel: s.channel }); + } + } + const result: { key: string; config: MetricConfig; type_name: string; channel: number }[] = []; + let colorIdx = 0; + for (const [k, info] of seen) { + const label = + info.type_name.charAt(0).toUpperCase() + + info.type_name.slice(1).replace(/_/g, ' ') + + ` Ch${info.channel}`; + const unit = LPP_UNIT_MAP[info.type_name] ?? ''; + result.push({ + key: k, + config: { label, unit, color: LPP_COLORS[colorIdx % LPP_COLORS.length] }, + type_name: info.type_name, + channel: info.channel, + }); + colorIdx++; + } + return result; + }, [entries]); + + const allMetricKeys = useMemo( + () => [...BUILTIN_METRICS, ...lppMetrics.map((m) => m.key)], + [lppMetrics] + ); + + // If the selected metric disappears (e.g. different repeater), reset to default + const activeMetric = allMetricKeys.includes(metric) ? metric : 'battery_volts'; + + const isBuiltin = BUILTIN_METRICS.includes(activeMetric as BuiltinMetric); + const activeConfig: MetricConfig = isBuiltin + ? BUILTIN_METRIC_CONFIG[activeMetric as BuiltinMetric] + : (lppMetrics.find((m) => m.key === activeMetric)?.config ?? { + label: activeMetric, + unit: '', + color: '#888', + }); const chartData = useMemo(() => { return entries.map((e) => { const d = e.data; - return { + const point: Record = { timestamp: e.timestamp, battery_volts: d.battery_volts, noise_floor_dbm: d.noise_floor_dbm, @@ -85,19 +145,25 @@ export function TelemetryHistoryPane({ packets_sent: d.packets_sent, uptime_seconds: d.uptime_seconds, }; + // Flatten LPP sensors into the point + for (const s of d.lpp_sensors ?? []) { + point[lppKey(s)] = typeof s.value === 'number' ? s.value : undefined; + } + return point; }); }, [entries]); - const dataKeys = metric === 'packets' ? ['packets_received', 'packets_sent'] : [metric]; + const dataKeys = + activeMetric === 'packets' ? ['packets_received', 'packets_sent'] : [activeMetric]; const yDomain = useMemo<[number, number] | undefined>(() => { - if (metric !== 'battery_volts' || chartData.length === 0) return undefined; + if (activeMetric !== 'battery_volts' || chartData.length === 0) return undefined; const values = chartData.map((d) => d.battery_volts).filter((v) => v != null) as number[]; if (values.length === 0) return [3, 5]; const lo = Math.min(...values); const hi = Math.max(...values); return [Math.min(3, Math.floor(lo) - 1), Math.max(5, Math.ceil(hi) + 1)]; - }, [metric, chartData]); + }, [activeMetric, chartData]); const handleToggle = async () => { setToggling(true); @@ -181,20 +247,35 @@ export function TelemetryHistoryPane({ {/* Metric selector */} -
- {(Object.keys(METRIC_CONFIG) as Metric[]).map((m) => ( +
+ {BUILTIN_METRICS.map((m) => ( + ))} + {lppMetrics.map((m) => ( + ))}
@@ -221,7 +302,9 @@ export function TelemetryHistoryPane({ tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }} tickLine={false} axisLine={false} - tickFormatter={(v) => (metric === 'uptime_seconds' ? formatUptime(v) : `${v}`)} + tickFormatter={(v) => + activeMetric === 'uptime_seconds' ? formatUptime(v) : `${v}` + } /> { const numVal = typeof value === 'number' ? value : Number(value); - const display = metric === 'uptime_seconds' ? formatUptime(numVal) : `${value}`; + const display = + activeMetric === 'uptime_seconds' ? formatUptime(numVal) : `${value}`; const suffix = - metric === 'uptime_seconds' ? '' : config.unit ? ` ${config.unit}` : ''; + activeMetric === 'uptime_seconds' + ? '' + : activeConfig.unit + ? ` ${activeConfig.unit}` + : ''; const label = - metric === 'packets' + activeMetric === 'packets' ? name === 'packets_received' ? 'Received' : 'Sent' - : config.label; + : activeConfig.label; return [`${display}${suffix}`, label]; }} /> @@ -251,19 +339,41 @@ export function TelemetryHistoryPane({ key={key} type="linear" dataKey={key} - stroke={metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color} - fill={metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color} + stroke={ + activeMetric === 'packets' + ? i === 0 + ? '#0ea5e9' + : '#f43f5e' + : activeConfig.color + } + fill={ + activeMetric === 'packets' + ? i === 0 + ? '#0ea5e9' + : '#f43f5e' + : activeConfig.color + } fillOpacity={0.15} strokeWidth={1.5} dot={{ r: 4, - fill: metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color, + fill: + activeMetric === 'packets' + ? i === 0 + ? '#0ea5e9' + : '#f43f5e' + : activeConfig.color, strokeWidth: 1.5, stroke: 'hsl(var(--popover))', }} activeDot={{ r: 6, - fill: metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color, + fill: + activeMetric === 'packets' + ? i === 0 + ? '#0ea5e9' + : '#f43f5e' + : activeConfig.color, strokeWidth: 2, stroke: 'hsl(var(--popover))', }} diff --git a/frontend/src/components/settings/SettingsDatabaseSection.tsx b/frontend/src/components/settings/SettingsDatabaseSection.tsx index 1bb2b12..ae40810 100644 --- a/frontend/src/components/settings/SettingsDatabaseSection.tsx +++ b/frontend/src/components/settings/SettingsDatabaseSection.tsx @@ -6,6 +6,7 @@ import { Separator } from '../ui/separator'; import { toast } from '../ui/sonner'; import { api } from '../../api'; import { formatTime } from '../../utils/messageParser'; +import { LPP_UNIT_MAP } from '../repeater/repeaterPaneShared'; import { BulkDeleteContactsModal } from './BulkDeleteContactsModal'; import type { AppSettings, @@ -308,6 +309,22 @@ export function SettingsDatabaseSection({ tx {d.packets_sent != null ? d.packets_sent.toLocaleString() : '?'} + {d.lpp_sensors?.map((s) => { + const unit = LPP_UNIT_MAP[s.type_name] ?? ''; + const val = + typeof s.value === 'number' + ? s.value % 1 === 0 + ? s.value + : s.value.toFixed(1) + : s.value; + const label = s.type_name.charAt(0).toUpperCase() + s.type_name.slice(1); + return ( + + {label} {val} + {unit ? ` ${unit}` : ''} + + ); + })} checked {formatTime(snap.timestamp)}
) : snap === null ? ( diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx index 6149b2a..2dc6d51 100644 --- a/frontend/src/components/settings/SettingsFanoutSection.tsx +++ b/frontend/src/components/settings/SettingsFanoutSection.tsx @@ -1004,6 +1004,11 @@ function MqttHaConfigEditor({
  • sensor.meshcore_*_uptime (seconds)
  • +
  • + sensor.meshcore_*_lpp_temperature_ch*,{' '} + *_lpp_humidity_ch*, etc. — + CayenneLPP sensors (auto-detected from repeater) +
  • diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 24ff8ac..e6a2e55 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -487,9 +487,15 @@ export interface PaneState { fetched_at?: number | null; } +export interface TelemetryLppSensor { + channel: number; + type_name: string; + value: number; +} + export interface TelemetryHistoryEntry { timestamp: number; - data: Record; + data: Record & { lpp_sensors?: TelemetryLppSensor[] }; } export interface TraceResponse { diff --git a/tests/test_mqtt_ha.py b/tests/test_mqtt_ha.py index c96ff0f..13f6af7 100644 --- a/tests/test_mqtt_ha.py +++ b/tests/test_mqtt_ha.py @@ -9,6 +9,8 @@ from app.fanout.mqtt_ha import ( MqttHaModule, _contact_tracker_discovery_config, _device_payload, + _lpp_discovery_configs, + _lpp_sensor_key, _message_event_discovery_config, _node_id, _radio_discovery_configs, @@ -479,3 +481,197 @@ class TestMqttHaValidation: result = _enforce_scope("mqtt_ha", {"messages": "all", "raw_packets": "all"}) assert result["raw_packets"] == "none" assert result["messages"] == "all" + + +# --------------------------------------------------------------------------- +# LPP sensor discovery and telemetry +# --------------------------------------------------------------------------- + + +class TestLppSensorKey: + def test_basic(self): + assert _lpp_sensor_key("temperature", 1) == "lpp_temperature_ch1" + + def test_zero_channel(self): + assert _lpp_sensor_key("humidity", 0) == "lpp_humidity_ch0" + + +class TestLppDiscoveryConfigs: + def test_produces_config_per_sensor(self): + nid = "ccdd11223344" + device = _device_payload(nid, "Rep1", "Repeater") + sensors = [ + {"channel": 1, "type_name": "temperature", "value": 23.5}, + {"channel": 2, "type_name": "humidity", "value": 45.0}, + ] + configs = _lpp_discovery_configs("mc", nid, device, sensors, f"mc/{nid}/telemetry") + + assert len(configs) == 2 + topics = [t for t, _ in configs] + assert f"homeassistant/sensor/meshcore_{nid}/lpp_temperature_ch1/config" in topics + assert f"homeassistant/sensor/meshcore_{nid}/lpp_humidity_ch2/config" in topics + + def test_sensor_config_shape(self): + nid = "ccdd11223344" + device = _device_payload(nid, "Rep1", "Repeater") + sensors = [{"channel": 1, "type_name": "temperature", "value": 23.5}] + configs = _lpp_discovery_configs("mc", nid, device, sensors, f"mc/{nid}/telemetry") + + _, cfg = configs[0] + assert cfg["name"] == "Temperature (Ch 1)" + assert cfg["unique_id"] == f"meshcore_{nid}_lpp_temperature_ch1" + assert cfg["device_class"] == "temperature" + assert cfg["unit_of_measurement"] == "°C" + assert cfg["state_class"] == "measurement" + assert cfg["expire_after"] == 36000 + assert "lpp_temperature_ch1" in cfg["value_template"] + + def test_unknown_sensor_type_no_device_class(self): + nid = "ccdd11223344" + device = _device_payload(nid, "Rep1", "Repeater") + sensors = [{"channel": 0, "type_name": "exotic_sensor", "value": 1.0}] + configs = _lpp_discovery_configs("mc", nid, device, sensors, f"mc/{nid}/telemetry") + + _, cfg = configs[0] + assert "device_class" not in cfg + assert "unit_of_measurement" not in cfg + + +class TestMqttHaTelemetryWithLpp: + @pytest.mark.asyncio + async def test_on_telemetry_flattens_lpp_sensors(self): + key = "ccdd11223344" + mod = MqttHaModule("test", _base_config(tracked_repeaters=[key])) + mod._publisher = MagicMock() + mod._publisher.connected = True + mod._publisher.publish = AsyncMock() + # Pretend discovery already covers these sensors + nid = _node_id(key) + mod._discovery_topics = [ + f"homeassistant/sensor/meshcore_{nid}/lpp_temperature_ch1/config", + f"homeassistant/sensor/meshcore_{nid}/lpp_humidity_ch2/config", + ] + + await mod.on_telemetry( + { + "public_key": key, + "battery_volts": 4.1, + "lpp_sensors": [ + {"channel": 1, "type_name": "temperature", "value": 23.5}, + {"channel": 2, "type_name": "humidity", "value": 45.0}, + ], + } + ) + + mod._publisher.publish.assert_called_once() + payload = mod._publisher.publish.call_args[0][1] + assert payload["battery_volts"] == 4.1 + assert payload["lpp_temperature_ch1"] == 23.5 + assert payload["lpp_humidity_ch2"] == 45.0 + + @pytest.mark.asyncio + async def test_on_telemetry_triggers_rediscovery_for_new_lpp_sensor(self): + key = "ccdd11223344" + mod = MqttHaModule("test", _base_config(tracked_repeaters=[key])) + mod._publisher = MagicMock() + mod._publisher.connected = True + mod._publisher.publish = AsyncMock() + mod._discovery_topics = [] # No sensors discovered yet + mod._publish_discovery = AsyncMock() + + await mod.on_telemetry( + { + "public_key": key, + "battery_volts": 4.1, + "lpp_sensors": [ + {"channel": 1, "type_name": "temperature", "value": 23.5}, + ], + } + ) + + mod._publish_discovery.assert_awaited_once() + + @pytest.mark.asyncio + async def test_on_telemetry_discovery_published_before_state(self): + """Discovery configs must arrive before the state payload so HA knows the entity.""" + key = "ccdd11223344" + mod = MqttHaModule("test", _base_config(tracked_repeaters=[key])) + mod._publisher = MagicMock() + mod._publisher.connected = True + mod._publisher.publish = AsyncMock() + mod._discovery_topics = [] # New sensor triggers rediscovery + + call_order: list[str] = [] + + async def fake_discovery(): + call_order.append("discovery") + + mod._publish_discovery = AsyncMock(side_effect=fake_discovery) + + original_publish = mod._publisher.publish + + async def tracking_publish(topic, payload, **kw): + if "/telemetry" in topic: + call_order.append("state") + return await original_publish(topic, payload, **kw) + + mod._publisher.publish = AsyncMock(side_effect=tracking_publish) + + await mod.on_telemetry( + { + "public_key": key, + "battery_volts": 4.1, + "lpp_sensors": [ + {"channel": 1, "type_name": "temperature", "value": 23.5}, + ], + } + ) + + assert call_order == ["discovery", "state"] + + @pytest.mark.asyncio + async def test_on_telemetry_no_rediscovery_when_already_known(self): + key = "ccdd11223344" + nid = _node_id(key) + mod = MqttHaModule("test", _base_config(tracked_repeaters=[key])) + mod._publisher = MagicMock() + mod._publisher.connected = True + mod._publisher.publish = AsyncMock() + mod._discovery_topics = [ + f"homeassistant/sensor/meshcore_{nid}/lpp_temperature_ch1/config", + ] + mod._publish_discovery = AsyncMock() + + await mod.on_telemetry( + { + "public_key": key, + "battery_volts": 4.1, + "lpp_sensors": [ + {"channel": 1, "type_name": "temperature", "value": 23.5}, + ], + } + ) + + mod._publish_discovery.assert_not_awaited() + + @pytest.mark.asyncio + async def test_on_telemetry_without_lpp_sensors(self): + """Existing behavior: no lpp_sensors key means no LPP fields in payload.""" + key = "ccdd11223344" + mod = MqttHaModule("test", _base_config(tracked_repeaters=[key])) + mod._publisher = MagicMock() + mod._publisher.connected = True + mod._publisher.publish = AsyncMock() + + await mod.on_telemetry( + { + "public_key": key, + "battery_volts": 4.1, + "noise_floor_dbm": -112, + } + ) + + payload = mod._publisher.publish.call_args[0][1] + assert payload["battery_volts"] == 4.1 + # No lpp keys + assert not any(k.startswith("lpp_") for k in payload) diff --git a/tests/test_radio_sync.py b/tests/test_radio_sync.py index 80b7c00..7e1ef07 100644 --- a/tests/test_radio_sync.py +++ b/tests/test_radio_sync.py @@ -1695,3 +1695,170 @@ class TestPeriodicSyncLoopRaces: mock_cleanup.assert_called_once() mock_sync.assert_not_called() mock_time.assert_called_once_with(mock_mc) + + +# --------------------------------------------------------------------------- +# _collect_repeater_telemetry — LPP sensor collection +# --------------------------------------------------------------------------- + + +class TestCollectRepeaterTelemetryLpp: + """Verify that _collect_repeater_telemetry fetches LPP sensors.""" + + @pytest.mark.asyncio + async def test_lpp_sensors_included_in_data(self): + from app.radio_sync import _collect_repeater_telemetry + + mc = MagicMock() + mc.commands.add_contact = AsyncMock() + mc.commands.req_status_sync = AsyncMock( + return_value={"bat": 4100, "noise_floor": -110, "nb_recv": 10, "nb_sent": 5} + ) + mc.commands.req_telemetry_sync = AsyncMock( + return_value=[ + {"channel": 1, "type": "temperature", "value": 23.5}, + {"channel": 2, "type": "humidity", "value": 45.0}, + ] + ) + + contact = MagicMock() + contact.public_key = "aabbccddeeff11223344" + contact.name = "TestRepeater" + contact.to_radio_dict.return_value = {} + + recorded_data = {} + + async def mock_record(public_key, timestamp, data): + recorded_data.update(data) + + mock_fanout = MagicMock() + mock_fanout.broadcast_telemetry = AsyncMock() + + with ( + patch( + "app.radio_sync.RepeaterTelemetryRepository.record", + new_callable=AsyncMock, + side_effect=mock_record, + ), + patch("app.fanout.manager.fanout_manager", mock_fanout), + ): + result = await _collect_repeater_telemetry(mc, contact) + + assert result is True + assert "lpp_sensors" in recorded_data + assert len(recorded_data["lpp_sensors"]) == 2 + assert recorded_data["lpp_sensors"][0]["type_name"] == "temperature" + assert recorded_data["lpp_sensors"][0]["value"] == 23.5 + assert recorded_data["lpp_sensors"][1]["type_name"] == "humidity" + + @pytest.mark.asyncio + async def test_lpp_failure_does_not_fail_collection(self): + from app.radio_sync import _collect_repeater_telemetry + + mc = MagicMock() + mc.commands.add_contact = AsyncMock() + mc.commands.req_status_sync = AsyncMock(return_value={"bat": 4100, "noise_floor": -110}) + mc.commands.req_telemetry_sync = AsyncMock(side_effect=Exception("no sensors")) + + contact = MagicMock() + contact.public_key = "aabbccddeeff11223344" + contact.name = "TestRepeater" + contact.to_radio_dict.return_value = {} + + recorded_data = {} + + async def mock_record(public_key, timestamp, data): + recorded_data.update(data) + + mock_fanout = MagicMock() + mock_fanout.broadcast_telemetry = AsyncMock() + + with ( + patch( + "app.radio_sync.RepeaterTelemetryRepository.record", + new_callable=AsyncMock, + side_effect=mock_record, + ), + patch("app.fanout.manager.fanout_manager", mock_fanout), + ): + result = await _collect_repeater_telemetry(mc, contact) + + assert result is True + assert "lpp_sensors" not in recorded_data + # Status data still present + assert recorded_data["battery_volts"] == 4.1 + + @pytest.mark.asyncio + async def test_lpp_multivalue_sensors_skipped(self): + from app.radio_sync import _collect_repeater_telemetry + + mc = MagicMock() + mc.commands.add_contact = AsyncMock() + mc.commands.req_status_sync = AsyncMock(return_value={"bat": 4000}) + mc.commands.req_telemetry_sync = AsyncMock( + return_value=[ + {"channel": 1, "type": "temperature", "value": 23.5}, + {"channel": 3, "type": "gps", "value": {"lat": 1.0, "lon": 2.0, "alt": 3.0}}, + ] + ) + + contact = MagicMock() + contact.public_key = "aabbccddeeff11223344" + contact.name = "TestRepeater" + contact.to_radio_dict.return_value = {} + + recorded_data = {} + + async def mock_record(public_key, timestamp, data): + recorded_data.update(data) + + mock_fanout = MagicMock() + mock_fanout.broadcast_telemetry = AsyncMock() + + with ( + patch( + "app.radio_sync.RepeaterTelemetryRepository.record", + new_callable=AsyncMock, + side_effect=mock_record, + ), + patch("app.fanout.manager.fanout_manager", mock_fanout), + ): + result = await _collect_repeater_telemetry(mc, contact) + + assert result is True + assert len(recorded_data["lpp_sensors"]) == 1 + assert recorded_data["lpp_sensors"][0]["type_name"] == "temperature" + + @pytest.mark.asyncio + async def test_lpp_none_response_no_sensors_key(self): + from app.radio_sync import _collect_repeater_telemetry + + mc = MagicMock() + mc.commands.add_contact = AsyncMock() + mc.commands.req_status_sync = AsyncMock(return_value={"bat": 4000}) + mc.commands.req_telemetry_sync = AsyncMock(return_value=None) + + contact = MagicMock() + contact.public_key = "aabbccddeeff11223344" + contact.name = "TestRepeater" + contact.to_radio_dict.return_value = {} + + recorded_data = {} + + async def mock_record(public_key, timestamp, data): + recorded_data.update(data) + + mock_fanout = MagicMock() + mock_fanout.broadcast_telemetry = AsyncMock() + + with ( + patch( + "app.radio_sync.RepeaterTelemetryRepository.record", + new_callable=AsyncMock, + side_effect=mock_record, + ), + patch("app.fanout.manager.fanout_manager", mock_fanout), + ): + await _collect_repeater_telemetry(mc, contact) + + assert "lpp_sensors" not in recorded_data