mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-21 09:02:57 +02:00
Add LPP/tracked repeater telemetry and HA fanout
This commit is contained in:
+102
-1
@@ -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 ────────────────────────────────────────
|
# ── Local radio sensor definitions ────────────────────────────────────────
|
||||||
|
|
||||||
@@ -424,12 +486,21 @@ class MqttHaModule(FanoutModule):
|
|||||||
radio_name = self._radio_name or "MeshCore Radio"
|
radio_name = self._radio_name or "MeshCore Radio"
|
||||||
configs.extend(_radio_discovery_configs(self._prefix, self._radio_key, radio_name))
|
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:
|
for pub_key in self._tracked_repeaters:
|
||||||
rname = await self._resolve_contact_name(pub_key)
|
rname = await self._resolve_contact_name(pub_key)
|
||||||
configs.extend(
|
configs.extend(
|
||||||
_repeater_discovery_configs(self._prefix, pub_key, rname, self._radio_key)
|
_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
|
# Tracked contacts — resolve names from DB best-effort
|
||||||
for pub_key in self._tracked_contacts:
|
for pub_key in self._tracked_contacts:
|
||||||
@@ -481,6 +552,19 @@ class MqttHaModule(FanoutModule):
|
|||||||
pass
|
pass
|
||||||
return pub_key[:12]
|
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:
|
def _seed_radio_identity_from_runtime(self) -> None:
|
||||||
"""Best-effort bootstrap from the currently connected radio session."""
|
"""Best-effort bootstrap from the currently connected radio session."""
|
||||||
try:
|
try:
|
||||||
@@ -590,6 +674,23 @@ class MqttHaModule(FanoutModule):
|
|||||||
field = s["field"]
|
field = s["field"]
|
||||||
if field is not None:
|
if field is not None:
|
||||||
payload[field] = data.get(field)
|
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)
|
await self._publisher.publish(f"{self._prefix}/{nid}/telemetry", payload)
|
||||||
|
|
||||||
async def on_message(self, data: dict) -> None:
|
async def on_message(self, data: dict) -> None:
|
||||||
|
|||||||
@@ -1584,6 +1584,35 @@ async def _collect_repeater_telemetry(mc: MeshCore, contact: Contact) -> bool:
|
|||||||
"full_events": status.get("full_evts", 0),
|
"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:
|
try:
|
||||||
timestamp = int(time.time())
|
timestamp = int(time.time())
|
||||||
await RepeaterTelemetryRepository.record(
|
await RepeaterTelemetryRepository.record(
|
||||||
|
|||||||
@@ -73,3 +73,24 @@ class RepeaterTelemetryRepository:
|
|||||||
}
|
}
|
||||||
for row in rows
|
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"]),
|
||||||
|
}
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
|
|||||||
contact = await _resolve_contact_or_404(public_key)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
_require_repeater(contact)
|
_require_repeater(contact)
|
||||||
|
|
||||||
|
lpp_raw = None
|
||||||
async with radio_manager.radio_operation(
|
async with radio_manager.radio_operation(
|
||||||
"repeater_status", pause_polling=True, suspend_auto_fetch=True
|
"repeater_status", pause_polling=True, suspend_auto_fetch=True
|
||||||
) as mc:
|
) 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)
|
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:
|
if status is None:
|
||||||
raise HTTPException(status_code=504, detail="No status response from repeater")
|
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)
|
# Record to telemetry history as a JSON blob (best-effort)
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
status_dict = response.model_dump(exclude={"telemetry_history"})
|
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:
|
try:
|
||||||
await RepeaterTelemetryRepository.record(
|
await RepeaterTelemetryRepository.record(
|
||||||
public_key=contact.public_key,
|
public_key=contact.public_key,
|
||||||
|
|||||||
@@ -11,19 +11,36 @@ import {
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { Separator } from '../ui/separator';
|
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;
|
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<Metric, { label: string; unit: string; color: string }> = {
|
interface MetricConfig {
|
||||||
|
label: string;
|
||||||
|
unit: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUILTIN_METRIC_CONFIG: Record<BuiltinMetric, MetricConfig> = {
|
||||||
battery_volts: { label: 'Voltage', unit: 'V', color: '#22c55e' },
|
battery_volts: { label: 'Voltage', unit: 'V', color: '#22c55e' },
|
||||||
noise_floor_dbm: { label: 'Noise Floor', unit: 'dBm', color: '#8b5cf6' },
|
noise_floor_dbm: { label: 'Noise Floor', unit: 'dBm', color: '#8b5cf6' },
|
||||||
packets: { label: 'Packets', unit: '', color: '#0ea5e9' },
|
packets: { label: 'Packets', unit: '', color: '#0ea5e9' },
|
||||||
uptime_seconds: { label: 'Uptime', unit: 's', color: '#f59e0b' },
|
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 = {
|
const TOOLTIP_STYLE = {
|
||||||
contentStyle: {
|
contentStyle: {
|
||||||
backgroundColor: 'hsl(var(--popover))',
|
backgroundColor: 'hsl(var(--popover))',
|
||||||
@@ -66,18 +83,61 @@ export function TelemetryHistoryPane({
|
|||||||
trackedTelemetryRepeaters,
|
trackedTelemetryRepeaters,
|
||||||
onToggleTrackedTelemetry,
|
onToggleTrackedTelemetry,
|
||||||
}: TelemetryHistoryPaneProps) {
|
}: TelemetryHistoryPaneProps) {
|
||||||
const [metric, setMetric] = useState<Metric>('battery_volts');
|
const [metric, setMetric] = useState<string>('battery_volts');
|
||||||
const [toggling, setToggling] = useState(false);
|
const [toggling, setToggling] = useState(false);
|
||||||
|
|
||||||
const isTracked = trackedTelemetryRepeaters.includes(publicKey);
|
const isTracked = trackedTelemetryRepeaters.includes(publicKey);
|
||||||
const slotsFull = trackedTelemetryRepeaters.length >= MAX_TRACKED && !isTracked;
|
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<string, { type_name: string; channel: number }>();
|
||||||
|
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(() => {
|
const chartData = useMemo(() => {
|
||||||
return entries.map((e) => {
|
return entries.map((e) => {
|
||||||
const d = e.data;
|
const d = e.data;
|
||||||
return {
|
const point: Record<string, number | undefined> = {
|
||||||
timestamp: e.timestamp,
|
timestamp: e.timestamp,
|
||||||
battery_volts: d.battery_volts,
|
battery_volts: d.battery_volts,
|
||||||
noise_floor_dbm: d.noise_floor_dbm,
|
noise_floor_dbm: d.noise_floor_dbm,
|
||||||
@@ -85,19 +145,25 @@ export function TelemetryHistoryPane({
|
|||||||
packets_sent: d.packets_sent,
|
packets_sent: d.packets_sent,
|
||||||
uptime_seconds: d.uptime_seconds,
|
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]);
|
}, [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>(() => {
|
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[];
|
const values = chartData.map((d) => d.battery_volts).filter((v) => v != null) as number[];
|
||||||
if (values.length === 0) return [3, 5];
|
if (values.length === 0) return [3, 5];
|
||||||
const lo = Math.min(...values);
|
const lo = Math.min(...values);
|
||||||
const hi = Math.max(...values);
|
const hi = Math.max(...values);
|
||||||
return [Math.min(3, Math.floor(lo) - 1), Math.max(5, Math.ceil(hi) + 1)];
|
return [Math.min(3, Math.floor(lo) - 1), Math.max(5, Math.ceil(hi) + 1)];
|
||||||
}, [metric, chartData]);
|
}, [activeMetric, chartData]);
|
||||||
|
|
||||||
const handleToggle = async () => {
|
const handleToggle = async () => {
|
||||||
setToggling(true);
|
setToggling(true);
|
||||||
@@ -181,20 +247,35 @@ export function TelemetryHistoryPane({
|
|||||||
<Separator className="mb-3" />
|
<Separator className="mb-3" />
|
||||||
|
|
||||||
{/* Metric selector */}
|
{/* Metric selector */}
|
||||||
<div className="flex gap-1 mb-2">
|
<div className="flex flex-wrap gap-1 mb-2">
|
||||||
{(Object.keys(METRIC_CONFIG) as Metric[]).map((m) => (
|
{BUILTIN_METRICS.map((m) => (
|
||||||
<button
|
<button
|
||||||
key={m}
|
key={m}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMetric(m)}
|
onClick={() => setMetric(m)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-[0.6875rem] px-2 py-0.5 rounded transition-colors',
|
'text-[0.6875rem] px-2 py-0.5 rounded transition-colors',
|
||||||
metric === m
|
activeMetric === m
|
||||||
? 'bg-primary text-primary-foreground'
|
? 'bg-primary text-primary-foreground'
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{METRIC_CONFIG[m].label}
|
{BUILTIN_METRIC_CONFIG[m].label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{lppMetrics.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMetric(m.key)}
|
||||||
|
className={cn(
|
||||||
|
'text-[0.6875rem] px-2 py-0.5 rounded transition-colors',
|
||||||
|
activeMetric === m.key
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{m.config.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -221,7 +302,9 @@ export function TelemetryHistoryPane({
|
|||||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickFormatter={(v) => (metric === 'uptime_seconds' ? formatUptime(v) : `${v}`)}
|
tickFormatter={(v) =>
|
||||||
|
activeMetric === 'uptime_seconds' ? formatUptime(v) : `${v}`
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<RechartsTooltip
|
<RechartsTooltip
|
||||||
{...TOOLTIP_STYLE}
|
{...TOOLTIP_STYLE}
|
||||||
@@ -234,15 +317,20 @@ export function TelemetryHistoryPane({
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
formatter={(value: any, name: any) => {
|
formatter={(value: any, name: any) => {
|
||||||
const numVal = typeof value === 'number' ? value : Number(value);
|
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 =
|
const suffix =
|
||||||
metric === 'uptime_seconds' ? '' : config.unit ? ` ${config.unit}` : '';
|
activeMetric === 'uptime_seconds'
|
||||||
|
? ''
|
||||||
|
: activeConfig.unit
|
||||||
|
? ` ${activeConfig.unit}`
|
||||||
|
: '';
|
||||||
const label =
|
const label =
|
||||||
metric === 'packets'
|
activeMetric === 'packets'
|
||||||
? name === 'packets_received'
|
? name === 'packets_received'
|
||||||
? 'Received'
|
? 'Received'
|
||||||
: 'Sent'
|
: 'Sent'
|
||||||
: config.label;
|
: activeConfig.label;
|
||||||
return [`${display}${suffix}`, label];
|
return [`${display}${suffix}`, label];
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -251,19 +339,41 @@ export function TelemetryHistoryPane({
|
|||||||
key={key}
|
key={key}
|
||||||
type="linear"
|
type="linear"
|
||||||
dataKey={key}
|
dataKey={key}
|
||||||
stroke={metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color}
|
stroke={
|
||||||
fill={metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color}
|
activeMetric === 'packets'
|
||||||
|
? i === 0
|
||||||
|
? '#0ea5e9'
|
||||||
|
: '#f43f5e'
|
||||||
|
: activeConfig.color
|
||||||
|
}
|
||||||
|
fill={
|
||||||
|
activeMetric === 'packets'
|
||||||
|
? i === 0
|
||||||
|
? '#0ea5e9'
|
||||||
|
: '#f43f5e'
|
||||||
|
: activeConfig.color
|
||||||
|
}
|
||||||
fillOpacity={0.15}
|
fillOpacity={0.15}
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
dot={{
|
dot={{
|
||||||
r: 4,
|
r: 4,
|
||||||
fill: metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color,
|
fill:
|
||||||
|
activeMetric === 'packets'
|
||||||
|
? i === 0
|
||||||
|
? '#0ea5e9'
|
||||||
|
: '#f43f5e'
|
||||||
|
: activeConfig.color,
|
||||||
strokeWidth: 1.5,
|
strokeWidth: 1.5,
|
||||||
stroke: 'hsl(var(--popover))',
|
stroke: 'hsl(var(--popover))',
|
||||||
}}
|
}}
|
||||||
activeDot={{
|
activeDot={{
|
||||||
r: 6,
|
r: 6,
|
||||||
fill: metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color,
|
fill:
|
||||||
|
activeMetric === 'packets'
|
||||||
|
? i === 0
|
||||||
|
? '#0ea5e9'
|
||||||
|
: '#f43f5e'
|
||||||
|
: activeConfig.color,
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
stroke: 'hsl(var(--popover))',
|
stroke: 'hsl(var(--popover))',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Separator } from '../ui/separator';
|
|||||||
import { toast } from '../ui/sonner';
|
import { toast } from '../ui/sonner';
|
||||||
import { api } from '../../api';
|
import { api } from '../../api';
|
||||||
import { formatTime } from '../../utils/messageParser';
|
import { formatTime } from '../../utils/messageParser';
|
||||||
|
import { LPP_UNIT_MAP } from '../repeater/repeaterPaneShared';
|
||||||
import { BulkDeleteContactsModal } from './BulkDeleteContactsModal';
|
import { BulkDeleteContactsModal } from './BulkDeleteContactsModal';
|
||||||
import type {
|
import type {
|
||||||
AppSettings,
|
AppSettings,
|
||||||
@@ -308,6 +309,22 @@ export function SettingsDatabaseSection({
|
|||||||
<span>
|
<span>
|
||||||
tx {d.packets_sent != null ? d.packets_sent.toLocaleString() : '?'}
|
tx {d.packets_sent != null ? d.packets_sent.toLocaleString() : '?'}
|
||||||
</span>
|
</span>
|
||||||
|
{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 (
|
||||||
|
<span key={`${s.type_name}-${s.channel}`}>
|
||||||
|
{label} {val}
|
||||||
|
{unit ? ` ${unit}` : ''}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
<span className="ml-auto">checked {formatTime(snap.timestamp)}</span>
|
<span className="ml-auto">checked {formatTime(snap.timestamp)}</span>
|
||||||
</div>
|
</div>
|
||||||
) : snap === null ? (
|
) : snap === null ? (
|
||||||
|
|||||||
@@ -1004,6 +1004,11 @@ function MqttHaConfigEditor({
|
|||||||
<li>
|
<li>
|
||||||
<code className="text-[0.6875rem]">sensor.meshcore_*_uptime</code> (seconds)
|
<code className="text-[0.6875rem]">sensor.meshcore_*_uptime</code> (seconds)
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<code className="text-[0.6875rem]">sensor.meshcore_*_lpp_temperature_ch*</code>,{' '}
|
||||||
|
<code className="text-[0.6875rem]">*_lpp_humidity_ch*</code>, etc. —
|
||||||
|
CayenneLPP sensors (auto-detected from repeater)
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -487,9 +487,15 @@ export interface PaneState {
|
|||||||
fetched_at?: number | null;
|
fetched_at?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TelemetryLppSensor {
|
||||||
|
channel: number;
|
||||||
|
type_name: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TelemetryHistoryEntry {
|
export interface TelemetryHistoryEntry {
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
data: Record<string, number>;
|
data: Record<string, number> & { lpp_sensors?: TelemetryLppSensor[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TraceResponse {
|
export interface TraceResponse {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from app.fanout.mqtt_ha import (
|
|||||||
MqttHaModule,
|
MqttHaModule,
|
||||||
_contact_tracker_discovery_config,
|
_contact_tracker_discovery_config,
|
||||||
_device_payload,
|
_device_payload,
|
||||||
|
_lpp_discovery_configs,
|
||||||
|
_lpp_sensor_key,
|
||||||
_message_event_discovery_config,
|
_message_event_discovery_config,
|
||||||
_node_id,
|
_node_id,
|
||||||
_radio_discovery_configs,
|
_radio_discovery_configs,
|
||||||
@@ -479,3 +481,197 @@ class TestMqttHaValidation:
|
|||||||
result = _enforce_scope("mqtt_ha", {"messages": "all", "raw_packets": "all"})
|
result = _enforce_scope("mqtt_ha", {"messages": "all", "raw_packets": "all"})
|
||||||
assert result["raw_packets"] == "none"
|
assert result["raw_packets"] == "none"
|
||||||
assert result["messages"] == "all"
|
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)
|
||||||
|
|||||||
@@ -1695,3 +1695,170 @@ class TestPeriodicSyncLoopRaces:
|
|||||||
mock_cleanup.assert_called_once()
|
mock_cleanup.assert_called_once()
|
||||||
mock_sync.assert_not_called()
|
mock_sync.assert_not_called()
|
||||||
mock_time.assert_called_once_with(mock_mc)
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user