mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-07 22:05:14 +02:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e695d629b9 | |||
| 300677aca3 | |||
| b89f7ce76b | |||
| 82bd25a09f | |||
| b8f0228f68 | |||
| 25089930f1 |
@@ -321,6 +321,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| GET | `/api/debug` | Support snapshot: recent logs, live radio probe, contact/channel drift audit, and running version/git info |
|
||||
| GET | `/api/radio/config` | Radio configuration, including `path_hash_mode`, `path_hash_mode_supported`, advert-location on/off, and `multi_acks_enabled` |
|
||||
| PATCH | `/api/radio/config` | Update name, location, advert-location on/off, `multi_acks_enabled`, radio params, and `path_hash_mode` when supported |
|
||||
| GET | `/api/radio/private-key` | Export in-memory private key as hex (requires `MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT=true`) |
|
||||
| PUT | `/api/radio/private-key` | Import private key to radio |
|
||||
| POST | `/api/radio/advertise` | Send advertisement (`mode`: `flood` or `zero_hop`, default `flood`) |
|
||||
| POST | `/api/radio/discover` | Run a short mesh discovery sweep for nearby repeaters/sensors |
|
||||
@@ -504,6 +505,7 @@ mc.subscribe(EventType.ACK, handler)
|
||||
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Switch the always-on radio audit task from hourly checks to aggressive 10-second polling; the audit checks both missed message drift and channel-slot cache drift |
|
||||
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | `false` | Disable channel-slot reuse and force `set_channel(...)` before every channel send, even on serial/BLE |
|
||||
| `MESHCORE_LOAD_WITH_AUTOEVICT` | `false` | Enable autoevict contact loading: sets `AUTO_ADD_OVERWRITE_OLDEST` on the radio so adds never fail with TABLE_FULL, skips the removal phase during reconcile, and allows blind loading when `get_contacts` fails. Loaded contacts are not radio-favorited and may be evicted by new adverts when the table is full. |
|
||||
| `MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT` | `false` | Enable `GET /api/radio/private-key` to return the in-memory private key as hex. Disabled by default; only enable on a trusted network where you need to retrieve the key (e.g. for backup or migration). |
|
||||
|
||||
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `advert_interval`, `last_advert_time`, `last_message_times`, `flood_scope`, `blocked_keys`, `blocked_names`, `discovery_blocked_types`, `tracked_telemetry_repeaters`, `auto_resend_channel`, and `telemetry_interval_hours`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`. If the radio's channel slots appear unstable or another client is mutating them underneath this app, operators can force the old always-reconfigure send path with `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true`.
|
||||
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
## [3.12.2] - 2026-04-21
|
||||
|
||||
* Feature: Auto-disambiguate colliding LPP sensor names
|
||||
* Feature: Radio config import/export
|
||||
* Bugfix: Don't push stale firmware version/model on community MQTT
|
||||
* Misc: Expose env vars in debug blob
|
||||
* Misc: Longer linger for web push error
|
||||
* Misc: Docs, test, & CI/CD improvements
|
||||
|
||||
## [3.12.1] - 2026-04-19
|
||||
|
||||
* Feature: Auto-evict/circular-buffer contact load mode (solves potential T-Beam issues)
|
||||
|
||||
@@ -9,6 +9,7 @@ These are intended for diagnosing or working around radios that behave oddly.
|
||||
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | false | Run aggressive 10-second `get_msg()` fallback polling to check for messages |
|
||||
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | false | Disable channel-slot reuse and force `set_channel(...)` before every channel send |
|
||||
| `MESHCORE_LOAD_WITH_AUTOEVICT` | false | Enable autoevict mode for contact loading (see [Contact Loading Issues](#contact-loading-issues) below) |
|
||||
| `MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT` | false | Enable `GET /api/radio/private-key` to return the in-memory private key as hex. Only enable on a trusted network when you need to retrieve the key (e.g. for backup or migration). |
|
||||
| `__CLOWNTOWN_DO_CLOCK_WRAPAROUND` | false | Highly experimental: if the radio clock is ahead of system time, try forcing the clock to `0xFFFFFFFF`, wait for uint32 wraparound, and then retry normal time sync before falling back to reboot |
|
||||
|
||||
By default the app relies on radio events plus MeshCore auto-fetch for incoming messages, and also runs a low-frequency hourly audit poll. That audit checks both:
|
||||
|
||||
@@ -477,7 +477,21 @@ class CommunityMqttPublisher(BaseMqttPublisher):
|
||||
if radio_manager.meshcore and radio_manager.meshcore.self_info:
|
||||
device_name = radio_manager.meshcore.self_info.get("name", "")
|
||||
|
||||
device_info = await self._fetch_device_info()
|
||||
# Prefer the always-fresh radio_manager fields (populated on every reconnect by
|
||||
# radio_lifecycle) over the per-module _cached_device_info, which was only
|
||||
# cleared on module restart and therefore served stale firmware versions after
|
||||
# a radio firmware update. Fall back to _fetch_device_info() for older firmware
|
||||
# where device_info_loaded is False.
|
||||
if radio_manager.device_info_loaded:
|
||||
raw_ver = radio_manager.firmware_version or "unknown"
|
||||
fw_build = radio_manager.firmware_build or ""
|
||||
fw_str = f"{raw_ver} (Build: {fw_build})" if fw_build else f"{raw_ver}"
|
||||
device_info = {
|
||||
"model": radio_manager.device_model or "unknown",
|
||||
"firmware_version": fw_str,
|
||||
}
|
||||
else:
|
||||
device_info = await self._fetch_device_info()
|
||||
stats = await self._fetch_stats() if refresh_stats else self._cached_stats
|
||||
|
||||
status_topic = _build_status_topic(settings, pubkey_hex)
|
||||
|
||||
+24
-8
@@ -115,6 +115,22 @@ def _lpp_sensor_key(type_name: str, channel: int) -> str:
|
||||
return f"lpp_{type_name}_ch{channel}"
|
||||
|
||||
|
||||
def _assign_lpp_keys(lpp_sensors: list[dict]) -> list[tuple[dict, str, int]]:
|
||||
"""Pair each LPP sensor dict with a disambiguated flat key and occurrence.
|
||||
|
||||
First occurrence keeps the base key (``lpp_temperature_ch1``), occurrence=1;
|
||||
subsequent duplicates of the same (type_name, channel) get ``_2``, ``_3``, etc.
|
||||
"""
|
||||
counts: dict[str, int] = {}
|
||||
result: list[tuple[dict, str, int]] = []
|
||||
for sensor in lpp_sensors:
|
||||
base = _lpp_sensor_key(sensor.get("type_name", "unknown"), sensor.get("channel", 0))
|
||||
n = counts.get(base, 0) + 1
|
||||
counts[base] = n
|
||||
result.append((sensor, base if n == 1 else f"{base}_{n}", n))
|
||||
return result
|
||||
|
||||
|
||||
def _repeater_telemetry_payload(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Build the flat HA state payload for a repeater telemetry snapshot."""
|
||||
payload: dict[str, Any] = {}
|
||||
@@ -123,8 +139,7 @@ def _repeater_telemetry_payload(data: dict[str, Any]) -> dict[str, Any]:
|
||||
if field is not None:
|
||||
payload[field] = data.get(field)
|
||||
|
||||
for sensor in data.get("lpp_sensors", []) or []:
|
||||
key = _lpp_sensor_key(sensor.get("type_name", "unknown"), sensor.get("channel", 0))
|
||||
for sensor, key, _ in _assign_lpp_keys(data.get("lpp_sensors", []) or []):
|
||||
payload[key] = sensor.get("value")
|
||||
|
||||
return payload
|
||||
@@ -139,16 +154,19 @@ def _lpp_discovery_configs(
|
||||
) -> list[tuple[str, dict]]:
|
||||
"""Build HA discovery configs for a repeater's LPP sensors."""
|
||||
configs: list[tuple[str, dict]] = []
|
||||
for sensor in lpp_sensors:
|
||||
for sensor, field, occurrence in _assign_lpp_keys(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})"
|
||||
name = (
|
||||
f"{display} (Ch {channel})"
|
||||
if occurrence == 1
|
||||
else f"{display} (Ch {channel}) #{occurrence}"
|
||||
)
|
||||
|
||||
cfg: dict[str, Any] = {
|
||||
"name": name,
|
||||
@@ -731,9 +749,7 @@ class MqttHaModule(FanoutModule):
|
||||
payload = _repeater_telemetry_payload(data)
|
||||
lpp_sensors: list[dict] = data.get("lpp_sensors", [])
|
||||
rediscover = False
|
||||
for sensor in lpp_sensors:
|
||||
# Check if discovery for this sensor has been published yet
|
||||
key = _lpp_sensor_key(sensor.get("type_name", "unknown"), sensor.get("channel", 0))
|
||||
for _, key, _ in _assign_lpp_keys(lpp_sensors):
|
||||
expected_topic = f"homeassistant/sensor/meshcore_{nid}/{key}/config"
|
||||
if expected_topic not in self._discovery_topics:
|
||||
rediscover = True
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"private": true,
|
||||
"version": "3.12.1",
|
||||
"version": "3.12.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { RepeaterPane, NotFetched, LppSensorRow } from './repeaterPaneShared';
|
||||
import { useMemo } from 'react';
|
||||
import { RepeaterPane, NotFetched, LppSensorRow, formatLppLabel } from './repeaterPaneShared';
|
||||
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
|
||||
import type { RepeaterLppTelemetryResponse, PaneState } from '../../types';
|
||||
|
||||
@@ -14,6 +15,19 @@ export function LppTelemetryPane({
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { distanceUnit } = useDistanceUnit();
|
||||
|
||||
// Build disambiguated labels matching the telemetry history chart names
|
||||
const labels = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const counts = new Map<string, number>();
|
||||
return data.sensors.map((s) => {
|
||||
const base = `${s.type_name}_${s.channel}`;
|
||||
const n = (counts.get(base) ?? 0) + 1;
|
||||
counts.set(base, n);
|
||||
return formatLppLabel(s.type_name) + ` Ch${s.channel}` + (n > 1 ? ` (${n})` : '');
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<RepeaterPane title="LPP Sensors" state={state} onRefresh={onRefresh} disabled={disabled}>
|
||||
{!data ? (
|
||||
@@ -23,7 +37,7 @@ export function LppTelemetryPane({
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{data.sensors.map((sensor, i) => (
|
||||
<LppSensorRow key={i} sensor={sensor} unitPref={distanceUnit} />
|
||||
<LppSensorRow key={i} sensor={sensor} unitPref={distanceUnit} label={labels[i]} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -37,9 +37,18 @@ const BUILTIN_METRICS: BuiltinMetric[] = Object.keys(BUILTIN_METRIC_CONFIG) as B
|
||||
// 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}`;
|
||||
/** Assign disambiguated flat keys to an array of LPP sensors.
|
||||
* First occurrence keeps the base key; duplicates of the same (type, channel) get _2, _3, etc. */
|
||||
function assignLppKeys(
|
||||
sensors: TelemetryLppSensor[]
|
||||
): { sensor: TelemetryLppSensor; key: string; occurrence: number }[] {
|
||||
const counts = new Map<string, number>();
|
||||
return sensors.map((s) => {
|
||||
const base = `lpp_${s.type_name}_ch${s.channel}`;
|
||||
const n = (counts.get(base) ?? 0) + 1;
|
||||
counts.set(base, n);
|
||||
return { sensor: s, key: n === 1 ? base : `${base}_${n}`, occurrence: n };
|
||||
});
|
||||
}
|
||||
|
||||
const TOOLTIP_STYLE = {
|
||||
@@ -93,11 +102,10 @@ export function TelemetryHistoryPane({
|
||||
|
||||
// Discover unique LPP sensors across all history entries
|
||||
const lppMetrics = useMemo(() => {
|
||||
const seen = new Map<string, { type_name: string; channel: number }>();
|
||||
const seen = new Map<string, { type_name: string; channel: number; occurrence: 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 });
|
||||
for (const { sensor: s, key: k, occurrence } of assignLppKeys(e.data.lpp_sensors ?? [])) {
|
||||
if (!seen.has(k)) seen.set(k, { type_name: s.type_name, channel: s.channel, occurrence });
|
||||
}
|
||||
}
|
||||
const result: { key: string; config: MetricConfig; type_name: string; channel: number }[] = [];
|
||||
@@ -106,7 +114,8 @@ export function TelemetryHistoryPane({
|
||||
const label =
|
||||
info.type_name.charAt(0).toUpperCase() +
|
||||
info.type_name.slice(1).replace(/_/g, ' ') +
|
||||
` Ch${info.channel}`;
|
||||
` Ch${info.channel}` +
|
||||
(info.occurrence > 1 ? ` (${info.occurrence})` : '');
|
||||
const { unit } = lppDisplayUnit(info.type_name, 0, distanceUnit);
|
||||
result.push({
|
||||
key: k,
|
||||
@@ -148,9 +157,9 @@ export function TelemetryHistoryPane({
|
||||
uptime_seconds: d.uptime_seconds,
|
||||
};
|
||||
// Flatten LPP sensors into the point, converting units as needed
|
||||
for (const s of d.lpp_sensors ?? []) {
|
||||
for (const { sensor: s, key } of assignLppKeys(d.lpp_sensors ?? [])) {
|
||||
if (typeof s.value === 'number') {
|
||||
point[lppKey(s)] = lppDisplayUnit(s.type_name, s.value, distanceUnit).value;
|
||||
point[key] = lppDisplayUnit(s.type_name, s.value, distanceUnit).value;
|
||||
}
|
||||
}
|
||||
return point;
|
||||
|
||||
@@ -242,8 +242,16 @@ export function formatLppLabel(typeName: string): string {
|
||||
return typeName.charAt(0).toUpperCase() + typeName.slice(1).replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
export function LppSensorRow({ sensor, unitPref }: { sensor: LppSensor; unitPref?: string }) {
|
||||
const label = formatLppLabel(sensor.type_name);
|
||||
export function LppSensorRow({
|
||||
sensor,
|
||||
unitPref,
|
||||
label: labelOverride,
|
||||
}: {
|
||||
sensor: LppSensor;
|
||||
unitPref?: string;
|
||||
label?: string;
|
||||
}) {
|
||||
const label = labelOverride ?? formatLppLabel(sensor.type_name);
|
||||
|
||||
if (typeof sensor.value === 'object' && sensor.value !== null) {
|
||||
// Multi-value sensor (GPS, accelerometer, etc.)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "remoteterm-meshcore"
|
||||
version = "3.12.1"
|
||||
version = "3.12.2"
|
||||
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -812,16 +812,14 @@ class TestLwtAndStatusPublish:
|
||||
mock_radio = MagicMock()
|
||||
mock_radio.meshcore = MagicMock()
|
||||
mock_radio.meshcore.self_info = {"name": "TestNode"}
|
||||
mock_radio.device_info_loaded = True
|
||||
mock_radio.device_model = "T-Deck"
|
||||
mock_radio.firmware_version = "v2.2.2"
|
||||
mock_radio.firmware_build = "2025-01-15"
|
||||
|
||||
with (
|
||||
patch("app.keystore.get_public_key", return_value=public_key),
|
||||
patch("app.radio.radio_manager", mock_radio),
|
||||
patch.object(
|
||||
pub,
|
||||
"_fetch_device_info",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"model": "T-Deck", "firmware_version": "v2.2.2 (Build: 2025-01-15)"},
|
||||
),
|
||||
patch.object(
|
||||
pub, "_fetch_stats", new_callable=AsyncMock, return_value={"battery_mv": 4200}
|
||||
),
|
||||
@@ -852,6 +850,82 @@ class TestLwtAndStatusPublish:
|
||||
assert payload["client_version"] == "RemoteTerm/2.4.0-abcdef"
|
||||
assert payload["stats"] == {"battery_mv": 4200}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_status_uses_fallback_fetch_when_device_info_not_loaded(self):
|
||||
"""When device_info_loaded is False, _fetch_device_info() should be called as fallback."""
|
||||
pub = CommunityMqttPublisher()
|
||||
private_key, public_key = _make_test_keys()
|
||||
settings = SimpleNamespace(community_mqtt_enabled=True, community_mqtt_iata="LAX")
|
||||
|
||||
mock_radio = MagicMock()
|
||||
mock_radio.meshcore = MagicMock()
|
||||
mock_radio.meshcore.self_info = {"name": "OldNode"}
|
||||
mock_radio.device_info_loaded = False
|
||||
|
||||
with (
|
||||
patch("app.keystore.get_public_key", return_value=public_key),
|
||||
patch("app.radio.radio_manager", mock_radio),
|
||||
patch.object(
|
||||
pub,
|
||||
"_fetch_device_info",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"model": "LegacyBoard", "firmware_version": "v2"},
|
||||
) as mock_fetch,
|
||||
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=None),
|
||||
patch("app.fanout.community_mqtt._build_radio_info", return_value="0,0,0,0"),
|
||||
patch("app.fanout.community_mqtt._get_client_version", return_value="RemoteTerm/0-x"),
|
||||
patch.object(pub, "publish", new_callable=AsyncMock) as mock_publish,
|
||||
):
|
||||
await pub._publish_status(settings)
|
||||
|
||||
mock_fetch.assert_awaited_once()
|
||||
payload = mock_publish.call_args[0][1]
|
||||
assert payload["model"] == "LegacyBoard"
|
||||
assert payload["firmware_version"] == "v2"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_status_reflects_updated_firmware_version_after_reconnect(self):
|
||||
"""After firmware update + radio reconnect, the published firmware_version must be fresh.
|
||||
|
||||
This is a regression test for the stale-cache bug: previously _cached_device_info
|
||||
was never cleared between reconnects, so a radio firmware update was invisible to
|
||||
the Community MQTT status payload until the fanout module itself restarted.
|
||||
"""
|
||||
pub = CommunityMqttPublisher()
|
||||
private_key, public_key = _make_test_keys()
|
||||
settings = SimpleNamespace(community_mqtt_enabled=True, community_mqtt_iata="LAX")
|
||||
|
||||
mock_radio = MagicMock()
|
||||
mock_radio.meshcore = MagicMock()
|
||||
mock_radio.meshcore.self_info = {"name": "MyNode"}
|
||||
mock_radio.device_info_loaded = True
|
||||
mock_radio.device_model = "T-Deck"
|
||||
mock_radio.firmware_version = "1.14.1"
|
||||
mock_radio.firmware_build = ""
|
||||
|
||||
async def _publish_once(radio_mock):
|
||||
with (
|
||||
patch("app.keystore.get_public_key", return_value=public_key),
|
||||
patch("app.radio.radio_manager", radio_mock),
|
||||
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=None),
|
||||
patch("app.fanout.community_mqtt._build_radio_info", return_value="0,0,0,0"),
|
||||
patch("app.fanout.community_mqtt._get_client_version", return_value="RT/0-x"),
|
||||
patch.object(pub, "publish", new_callable=AsyncMock) as mock_pub,
|
||||
):
|
||||
await pub._publish_status(settings)
|
||||
return mock_pub.call_args[0][1]
|
||||
|
||||
first_payload = await _publish_once(mock_radio)
|
||||
assert first_payload["firmware_version"] == "1.14.1"
|
||||
|
||||
# Simulate firmware update: radio reboots, radio_lifecycle refreshes the manager fields
|
||||
mock_radio.firmware_version = "1.15.0"
|
||||
|
||||
second_payload = await _publish_once(mock_radio)
|
||||
assert second_payload["firmware_version"] == "1.15.0", (
|
||||
"Expected updated firmware version after reconnect; stale cache bug would return v1.14.1"
|
||||
)
|
||||
|
||||
def test_lwt_and_online_share_same_topic(self):
|
||||
"""LWT and on-connect status should use the same topic path."""
|
||||
pub = CommunityMqttPublisher()
|
||||
@@ -896,6 +970,7 @@ class TestLwtAndStatusPublish:
|
||||
|
||||
mock_radio = MagicMock()
|
||||
mock_radio.meshcore = None
|
||||
mock_radio.device_info_loaded = False
|
||||
|
||||
with (
|
||||
patch("app.keystore.get_public_key", return_value=public_key),
|
||||
@@ -1252,18 +1327,16 @@ class TestPublishStatus:
|
||||
mock_radio = MagicMock()
|
||||
mock_radio.meshcore = MagicMock()
|
||||
mock_radio.meshcore.self_info = {"name": "TestNode"}
|
||||
mock_radio.device_info_loaded = True
|
||||
mock_radio.device_model = "T-Deck"
|
||||
mock_radio.firmware_version = "v2.2.2"
|
||||
mock_radio.firmware_build = "2025-01-15"
|
||||
|
||||
stats = {"battery_mv": 4200, "uptime_secs": 3600, "noise_floor": -120}
|
||||
|
||||
with (
|
||||
patch("app.keystore.get_public_key", return_value=public_key),
|
||||
patch("app.radio.radio_manager", mock_radio),
|
||||
patch.object(
|
||||
pub,
|
||||
"_fetch_device_info",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"model": "T-Deck", "firmware_version": "v2.2.2 (Build: 2025-01-15)"},
|
||||
),
|
||||
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=stats),
|
||||
patch("app.fanout.community_mqtt._build_radio_info", return_value="915.0,250.0,10,8"),
|
||||
patch(
|
||||
@@ -1294,6 +1367,7 @@ class TestPublishStatus:
|
||||
|
||||
mock_radio = MagicMock()
|
||||
mock_radio.meshcore = None
|
||||
mock_radio.device_info_loaded = False
|
||||
|
||||
with (
|
||||
patch("app.keystore.get_public_key", return_value=public_key),
|
||||
@@ -1326,6 +1400,7 @@ class TestPublishStatus:
|
||||
|
||||
mock_radio = MagicMock()
|
||||
mock_radio.meshcore = None
|
||||
mock_radio.device_info_loaded = False
|
||||
|
||||
before = time.monotonic()
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import pytest
|
||||
|
||||
from app.fanout.mqtt_ha import (
|
||||
MqttHaModule,
|
||||
_assign_lpp_keys,
|
||||
_contact_tracker_discovery_config,
|
||||
_device_payload,
|
||||
_lpp_discovery_configs,
|
||||
@@ -552,6 +553,45 @@ class TestLppSensorKey:
|
||||
assert _lpp_sensor_key("humidity", 0) == "lpp_humidity_ch0"
|
||||
|
||||
|
||||
class TestAssignLppKeys:
|
||||
def test_no_duplicates(self):
|
||||
sensors = [
|
||||
{"type_name": "temperature", "channel": 1, "value": 20},
|
||||
{"type_name": "humidity", "channel": 2, "value": 45},
|
||||
]
|
||||
result = _assign_lpp_keys(sensors)
|
||||
assert [(k, n) for _, k, n in result] == [
|
||||
("lpp_temperature_ch1", 1),
|
||||
("lpp_humidity_ch2", 1),
|
||||
]
|
||||
|
||||
def test_duplicate_type_and_channel(self):
|
||||
sensors = [
|
||||
{"type_name": "temperature", "channel": 1, "value": 20},
|
||||
{"type_name": "humidity", "channel": 2, "value": 45},
|
||||
{"type_name": "temperature", "channel": 1, "value": 53},
|
||||
]
|
||||
result = _assign_lpp_keys(sensors)
|
||||
assert [(k, n) for _, k, n in result] == [
|
||||
("lpp_temperature_ch1", 1),
|
||||
("lpp_humidity_ch2", 1),
|
||||
("lpp_temperature_ch1_2", 2),
|
||||
]
|
||||
|
||||
def test_triple_duplicate(self):
|
||||
sensors = [
|
||||
{"type_name": "voltage", "channel": 0, "value": 3.3},
|
||||
{"type_name": "voltage", "channel": 0, "value": 5.0},
|
||||
{"type_name": "voltage", "channel": 0, "value": 12.0},
|
||||
]
|
||||
result = _assign_lpp_keys(sensors)
|
||||
keys = [k for _, k, _ in result]
|
||||
assert keys == ["lpp_voltage_ch0", "lpp_voltage_ch0_2", "lpp_voltage_ch0_3"]
|
||||
|
||||
def test_empty_list(self):
|
||||
assert _assign_lpp_keys([]) == []
|
||||
|
||||
|
||||
class TestLppDiscoveryConfigs:
|
||||
def test_produces_config_per_sensor(self):
|
||||
nid = "ccdd11223344"
|
||||
@@ -583,6 +623,27 @@ class TestLppDiscoveryConfigs:
|
||||
assert cfg["suggested_display_precision"] == 1
|
||||
assert "lpp_temperature_ch1" in cfg["value_template"]
|
||||
|
||||
def test_duplicate_type_channel_gets_indexed_keys(self):
|
||||
nid = "ccdd11223344"
|
||||
device = _device_payload(nid, "Rep1", "Repeater")
|
||||
sensors = [
|
||||
{"channel": 1, "type_name": "temperature", "value": 20.0},
|
||||
{"channel": 2, "type_name": "humidity", "value": 45.0},
|
||||
{"channel": 1, "type_name": "temperature", "value": 53.0},
|
||||
]
|
||||
configs = _lpp_discovery_configs("mc", nid, device, sensors, f"mc/{nid}/telemetry")
|
||||
|
||||
assert len(configs) == 3
|
||||
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
|
||||
assert f"homeassistant/sensor/meshcore_{nid}/lpp_temperature_ch1_2/config" in topics
|
||||
|
||||
# First temperature keeps base name, second gets #2 suffix
|
||||
names = {cfg["unique_id"]: cfg["name"] for _, cfg in configs}
|
||||
assert names[f"meshcore_{nid}_lpp_temperature_ch1"] == "Temperature (Ch 1)"
|
||||
assert names[f"meshcore_{nid}_lpp_temperature_ch1_2"] == "Temperature (Ch 1) #2"
|
||||
|
||||
def test_unknown_sensor_type_no_device_class(self):
|
||||
nid = "ccdd11223344"
|
||||
device = _device_payload(nid, "Rep1", "Repeater")
|
||||
@@ -712,6 +773,35 @@ class TestMqttHaTelemetryWithLpp:
|
||||
|
||||
mod._publish_discovery.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_telemetry_duplicate_lpp_sensors_not_overwritten(self):
|
||||
"""Two sensors with same (type_name, channel) get distinct keys."""
|
||||
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",
|
||||
f"homeassistant/sensor/meshcore_{nid}/lpp_temperature_ch1_2/config",
|
||||
]
|
||||
|
||||
await mod.on_telemetry(
|
||||
{
|
||||
"public_key": key,
|
||||
"battery_volts": 4.1,
|
||||
"lpp_sensors": [
|
||||
{"channel": 1, "type_name": "temperature", "value": 20.0},
|
||||
{"channel": 1, "type_name": "temperature", "value": 53.0},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
payload = mod._publisher.publish.call_args[0][1]
|
||||
assert payload["lpp_temperature_ch1"] == 20.0
|
||||
assert payload["lpp_temperature_ch1_2"] == 53.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_telemetry_without_lpp_sensors(self):
|
||||
"""Existing behavior: no lpp_sensors key means no LPP fields in payload."""
|
||||
|
||||
Reference in New Issue
Block a user