mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-07 22:05:14 +02:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b7e2737ee | |||
| 01158ac69f | |||
| 485df05372 | |||
| e5e9eab935 | |||
| 33b2d3c260 | |||
| eccbd0bac5 | |||
| 4f54ec2c93 | |||
| eed38337c8 | |||
| e1ee7fcd24 | |||
| 2756b1ae8d | |||
| ef1d6a5a1a | |||
| 14f42c59fe | |||
| b9414e84ee | |||
| 95a17ca8ee | |||
| e6cedfbd0b |
@@ -1,3 +1,21 @@
|
||||
## [3.11.3] - 2026-04-12
|
||||
|
||||
* Bugfix: Add icons and screenshots for webmanifest
|
||||
* Bugfix: Use incoming DMs, not just outgoing, for recency ranking for preferential radio contact load
|
||||
|
||||
## [3.11.2] - 2026-04-12
|
||||
|
||||
* Feature: Unread DMs are always at the top of the DM list no matter what
|
||||
* Bugfix: Webmanifest needs withCredentials
|
||||
|
||||
## [3.11.1] - 2026-04-12
|
||||
|
||||
* Feature: Home Assistant MQTT fanout
|
||||
* Feature: Add dummy service worker to enable PWA
|
||||
* Bugfix: DB connection plurality issues
|
||||
* Misc: Migration improvements
|
||||
* Misc: Search keys from beginning
|
||||
|
||||
## [3.11.0] - 2026-04-10
|
||||
|
||||
* Feature: Radio health and contact data accessible on fanout bus
|
||||
|
||||
@@ -178,6 +178,22 @@ class Database:
|
||||
# Persists in the DB file but we set it explicitly on every connection.
|
||||
await self._connection.execute("PRAGMA journal_mode = WAL")
|
||||
|
||||
# synchronous = NORMAL is safe with WAL — only the most recent
|
||||
# transaction can be lost on an OS crash (no corruption risk).
|
||||
# Reduces fsync overhead vs. the default FULL.
|
||||
await self._connection.execute("PRAGMA synchronous = NORMAL")
|
||||
|
||||
# Retry for up to 5s on lock contention instead of failing instantly.
|
||||
# Matters when a second connection (e.g. VACUUM) touches the DB.
|
||||
await self._connection.execute("PRAGMA busy_timeout = 5000")
|
||||
|
||||
# Bump page cache to ~64 MB (negative value = KB). Keeps hot pages
|
||||
# in memory for read-heavy queries (unreads, pagination, search).
|
||||
await self._connection.execute("PRAGMA cache_size = -64000")
|
||||
|
||||
# Keep temp tables and sort spills in memory instead of on disk.
|
||||
await self._connection.execute("PRAGMA temp_store = MEMORY")
|
||||
|
||||
# Incremental auto-vacuum: freed pages are reclaimable via
|
||||
# PRAGMA incremental_vacuum without a full VACUUM. Must be set before
|
||||
# the first table is created (for new databases); for existing databases
|
||||
|
||||
+135
-5
@@ -26,7 +26,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Repeater telemetry sensor definitions ─────────────────────────────────
|
||||
|
||||
_REPEATER_SENSORS: list[dict[str, str | None]] = [
|
||||
_REPEATER_SENSORS: list[dict[str, Any]] = [
|
||||
{
|
||||
"field": "battery_volts",
|
||||
"name": "Battery Voltage",
|
||||
@@ -34,6 +34,7 @@ _REPEATER_SENSORS: list[dict[str, str | None]] = [
|
||||
"device_class": "voltage",
|
||||
"state_class": "measurement",
|
||||
"unit": "V",
|
||||
"precision": 2,
|
||||
},
|
||||
{
|
||||
"field": "noise_floor_dbm",
|
||||
@@ -42,6 +43,7 @@ _REPEATER_SENSORS: list[dict[str, str | None]] = [
|
||||
"device_class": "signal_strength",
|
||||
"state_class": "measurement",
|
||||
"unit": "dBm",
|
||||
"precision": 0,
|
||||
},
|
||||
{
|
||||
"field": "last_rssi_dbm",
|
||||
@@ -50,6 +52,7 @@ _REPEATER_SENSORS: list[dict[str, str | None]] = [
|
||||
"device_class": "signal_strength",
|
||||
"state_class": "measurement",
|
||||
"unit": "dBm",
|
||||
"precision": 0,
|
||||
},
|
||||
{
|
||||
"field": "last_snr_db",
|
||||
@@ -58,6 +61,7 @@ _REPEATER_SENSORS: list[dict[str, str | None]] = [
|
||||
"device_class": None,
|
||||
"state_class": "measurement",
|
||||
"unit": "dB",
|
||||
"precision": 1,
|
||||
},
|
||||
{
|
||||
"field": "packets_received",
|
||||
@@ -66,6 +70,7 @@ _REPEATER_SENSORS: list[dict[str, str | None]] = [
|
||||
"device_class": None,
|
||||
"state_class": "total_increasing",
|
||||
"unit": None,
|
||||
"precision": 0,
|
||||
},
|
||||
{
|
||||
"field": "packets_sent",
|
||||
@@ -74,6 +79,7 @@ _REPEATER_SENSORS: list[dict[str, str | None]] = [
|
||||
"device_class": None,
|
||||
"state_class": "total_increasing",
|
||||
"unit": None,
|
||||
"precision": 0,
|
||||
},
|
||||
{
|
||||
"field": "uptime_seconds",
|
||||
@@ -82,13 +88,78 @@ _REPEATER_SENSORS: list[dict[str, str | None]] = [
|
||||
"device_class": "duration",
|
||||
"state_class": None,
|
||||
"unit": "s",
|
||||
"precision": 0,
|
||||
},
|
||||
]
|
||||
|
||||
# ── LPP sensor metadata ─────────────────────────────────────────────────
|
||||
|
||||
_LPP_HA_META: dict[str, dict[str, Any]] = {
|
||||
"temperature": {"device_class": "temperature", "unit": "°C", "precision": 1},
|
||||
"humidity": {"device_class": "humidity", "unit": "%", "precision": 1},
|
||||
"barometer": {"device_class": "atmospheric_pressure", "unit": "hPa", "precision": 1},
|
||||
"voltage": {"device_class": "voltage", "unit": "V", "precision": 2},
|
||||
"current": {"device_class": "current", "unit": "mA", "precision": 1},
|
||||
"luminosity": {"device_class": "illuminance", "unit": "lux", "precision": 0},
|
||||
"power": {"device_class": "power", "unit": "W", "precision": 1},
|
||||
"energy": {"device_class": "energy", "unit": "kWh", "precision": 2},
|
||||
"distance": {"device_class": "distance", "unit": "mm", "precision": 0},
|
||||
"concentration": {"device_class": None, "unit": "ppm", "precision": 0},
|
||||
"direction": {"device_class": None, "unit": "°", "precision": 0},
|
||||
"altitude": {"device_class": None, "unit": "m", "precision": 1},
|
||||
}
|
||||
|
||||
|
||||
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"]
|
||||
if meta.get("precision") is not None:
|
||||
cfg["suggested_display_precision"] = meta["precision"]
|
||||
|
||||
topic = f"homeassistant/sensor/meshcore_{nid}/{object_id}/config"
|
||||
configs.append((topic, cfg))
|
||||
|
||||
return configs
|
||||
|
||||
|
||||
# ── Local radio sensor definitions ────────────────────────────────────────
|
||||
|
||||
_RADIO_SENSORS: list[dict[str, str | None]] = [
|
||||
_RADIO_SENSORS: list[dict[str, Any]] = [
|
||||
{
|
||||
"field": "noise_floor_dbm",
|
||||
"name": "Noise Floor",
|
||||
@@ -96,14 +167,16 @@ _RADIO_SENSORS: list[dict[str, str | None]] = [
|
||||
"device_class": "signal_strength",
|
||||
"state_class": "measurement",
|
||||
"unit": "dBm",
|
||||
"precision": 0,
|
||||
},
|
||||
{
|
||||
"field": "battery_mv",
|
||||
"field": "battery_volts",
|
||||
"name": "Battery",
|
||||
"object_id": "battery",
|
||||
"device_class": "voltage",
|
||||
"state_class": "measurement",
|
||||
"unit": "mV",
|
||||
"unit": "V",
|
||||
"precision": 2,
|
||||
},
|
||||
{
|
||||
"field": "uptime_secs",
|
||||
@@ -112,6 +185,7 @@ _RADIO_SENSORS: list[dict[str, str | None]] = [
|
||||
"device_class": "duration",
|
||||
"state_class": None,
|
||||
"unit": "s",
|
||||
"precision": 0,
|
||||
},
|
||||
{
|
||||
"field": "last_rssi",
|
||||
@@ -120,6 +194,7 @@ _RADIO_SENSORS: list[dict[str, str | None]] = [
|
||||
"device_class": "signal_strength",
|
||||
"state_class": "measurement",
|
||||
"unit": "dBm",
|
||||
"precision": 0,
|
||||
},
|
||||
{
|
||||
"field": "last_snr",
|
||||
@@ -128,6 +203,7 @@ _RADIO_SENSORS: list[dict[str, str | None]] = [
|
||||
"device_class": None,
|
||||
"state_class": "measurement",
|
||||
"unit": "dB",
|
||||
"precision": 1,
|
||||
},
|
||||
{
|
||||
"field": "tx_air_secs",
|
||||
@@ -136,6 +212,7 @@ _RADIO_SENSORS: list[dict[str, str | None]] = [
|
||||
"device_class": "duration",
|
||||
"state_class": "total_increasing",
|
||||
"unit": "s",
|
||||
"precision": 0,
|
||||
},
|
||||
{
|
||||
"field": "rx_air_secs",
|
||||
@@ -144,6 +221,7 @@ _RADIO_SENSORS: list[dict[str, str | None]] = [
|
||||
"device_class": "duration",
|
||||
"state_class": "total_increasing",
|
||||
"unit": "s",
|
||||
"precision": 0,
|
||||
},
|
||||
{
|
||||
"field": "packets_recv",
|
||||
@@ -152,6 +230,7 @@ _RADIO_SENSORS: list[dict[str, str | None]] = [
|
||||
"device_class": None,
|
||||
"state_class": "total_increasing",
|
||||
"unit": None,
|
||||
"precision": 0,
|
||||
},
|
||||
{
|
||||
"field": "packets_sent",
|
||||
@@ -160,6 +239,7 @@ _RADIO_SENSORS: list[dict[str, str | None]] = [
|
||||
"device_class": None,
|
||||
"state_class": "total_increasing",
|
||||
"unit": None,
|
||||
"precision": 0,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -281,6 +361,8 @@ def _radio_discovery_configs(
|
||||
cfg["state_class"] = sensor["state_class"]
|
||||
if sensor["unit"]:
|
||||
cfg["unit_of_measurement"] = sensor["unit"]
|
||||
if sensor.get("precision") is not None:
|
||||
cfg["suggested_display_precision"] = sensor["precision"]
|
||||
|
||||
topic = f"homeassistant/sensor/meshcore_{nid}/{sensor['object_id']}/config"
|
||||
configs.append((topic, cfg))
|
||||
@@ -314,6 +396,8 @@ def _repeater_discovery_configs(
|
||||
cfg["state_class"] = sensor["state_class"]
|
||||
if sensor["unit"]:
|
||||
cfg["unit_of_measurement"] = sensor["unit"]
|
||||
if sensor.get("precision") is not None:
|
||||
cfg["suggested_display_precision"] = sensor["precision"]
|
||||
# 10 hours — margin over the 8-hour auto-collect cycle
|
||||
cfg["expire_after"] = 36000
|
||||
|
||||
@@ -424,12 +508,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 +574,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:
|
||||
@@ -548,6 +654,13 @@ class MqttHaModule(FanoutModule):
|
||||
field = sensor["field"]
|
||||
if field is not None:
|
||||
payload[field] = data.get(field)
|
||||
|
||||
# Normalize battery from millivolts to volts for consistency with
|
||||
# repeater battery and the discovery config (unit: V, precision: 2).
|
||||
battery_mv = data.get("battery_mv")
|
||||
if battery_mv is not None:
|
||||
payload["battery_volts"] = battery_mv / 1000.0
|
||||
|
||||
await self._publisher.publish(f"{self._prefix}/{nid}/health", payload)
|
||||
|
||||
async def on_contact(self, data: dict) -> None:
|
||||
@@ -590,6 +703,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:
|
||||
|
||||
@@ -148,6 +148,39 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
|
||||
"type": "image/png",
|
||||
"purpose": "maskable",
|
||||
},
|
||||
{
|
||||
"src": f"{base}favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any",
|
||||
},
|
||||
{
|
||||
"src": f"{base}favicon-256x256.png",
|
||||
"sizes": "256x256",
|
||||
"type": "image/png",
|
||||
"purpose": "any",
|
||||
},
|
||||
],
|
||||
"screenshots": [
|
||||
{
|
||||
"src": f"{base}screenshot-wide.png",
|
||||
"sizes": "1367x909",
|
||||
"type": "image/png",
|
||||
"form_factor": "wide",
|
||||
"label": "RemoteTerm desktop view",
|
||||
},
|
||||
{
|
||||
"src": f"{base}screenshot-mobile.png",
|
||||
"sizes": "1170x2532",
|
||||
"type": "image/png",
|
||||
"label": "RemoteTerm mobile view",
|
||||
},
|
||||
{
|
||||
"src": f"{base}screenshot-mobile-2.png",
|
||||
"sizes": "750x1334",
|
||||
"type": "image/png",
|
||||
"label": "RemoteTerm mobile conversation",
|
||||
},
|
||||
],
|
||||
}
|
||||
return JSONResponse(
|
||||
|
||||
+41
-6
@@ -1295,7 +1295,13 @@ async def stop_background_contact_reconciliation() -> None:
|
||||
|
||||
|
||||
async def get_contacts_selected_for_radio_sync() -> list[Contact]:
|
||||
"""Return the contacts that would be loaded onto the radio right now."""
|
||||
"""Return the contacts that would be loaded onto the radio right now.
|
||||
|
||||
Fill order:
|
||||
1. Favorites (up to full capacity)
|
||||
2. Most recently DM-active non-repeaters (sent or received, up to 80% refill target)
|
||||
3. Most recently advertised non-repeaters (up to 80% refill target)
|
||||
"""
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
max_contacts = _effective_radio_capacity(app_settings.max_radio_contacts)
|
||||
refill_target, _full_sync_trigger = _compute_radio_contact_limits(max_contacts)
|
||||
@@ -1315,7 +1321,7 @@ async def get_contacts_selected_for_radio_sync() -> list[Contact]:
|
||||
break
|
||||
|
||||
if len(selected_contacts) < refill_target:
|
||||
for contact in await ContactRepository.get_recently_contacted_non_repeaters(
|
||||
for contact in await ContactRepository.get_recently_dm_active_non_repeaters(
|
||||
limit=max_contacts
|
||||
):
|
||||
key = contact.public_key.lower()
|
||||
@@ -1354,8 +1360,8 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
|
||||
|
||||
Fill order is:
|
||||
1. Favorite contacts
|
||||
2. Most recently interacted-with non-repeaters
|
||||
3. Most recently advert-heard non-repeaters without interaction history
|
||||
2. Most recently DM-active non-repeaters (sent or received)
|
||||
3. Most recently advert-heard non-repeaters
|
||||
|
||||
Favorite contacts are always reloaded first, up to the configured capacity.
|
||||
Additional non-favorite fill stops at the refill target (80% of capacity).
|
||||
@@ -1489,8 +1495,8 @@ async def sync_recent_contacts_to_radio(force: bool = False, mc: MeshCore | None
|
||||
"""
|
||||
Load contacts to the radio for DM ACK support.
|
||||
|
||||
Fill order is favorites, then recently contacted non-repeaters,
|
||||
then recently advert-heard non-repeaters. Favorites are always reloaded
|
||||
Fill order is favorites, then recently DM-active non-repeaters (sent or
|
||||
received), then recently advert-heard non-repeaters. Favorites are always reloaded
|
||||
up to the configured capacity; additional non-favorite fill stops at the
|
||||
80% refill target.
|
||||
Only runs at most once every CONTACT_SYNC_THROTTLE_SECONDS unless forced.
|
||||
@@ -1584,6 +1590,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(
|
||||
|
||||
@@ -294,6 +294,28 @@ class ContactRepository:
|
||||
rows = await cursor.fetchall()
|
||||
return [ContactRepository._row_to_contact(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def get_recently_dm_active_non_repeaters(limit: int = 200) -> list[Contact]:
|
||||
"""Get non-repeater contacts with the most recent DM activity (sent or received)."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT c.*
|
||||
FROM contacts c
|
||||
INNER JOIN (
|
||||
SELECT conversation_key, MAX(received_at) AS last_dm
|
||||
FROM messages
|
||||
WHERE type = 'PRIV'
|
||||
GROUP BY conversation_key
|
||||
) m ON c.public_key = m.conversation_key
|
||||
WHERE c.type != 2 AND length(c.public_key) = 64
|
||||
ORDER BY m.last_dm DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [ContactRepository._row_to_contact(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def get_recently_advertised_non_repeaters(limit: int = 200) -> list[Contact]:
|
||||
"""Get recently advert-heard non-repeater contacts."""
|
||||
|
||||
@@ -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"]),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+6
-1
@@ -13,8 +13,13 @@
|
||||
<link rel="icon" type="image/png" href="./favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="shortcut icon" href="./favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="./apple-touch-icon.png" />
|
||||
<link rel="manifest" href="./site.webmanifest" />
|
||||
<link rel="manifest" href="./site.webmanifest" crossorigin="use-credentials" />
|
||||
<script>
|
||||
// Register minimal service worker for PWA installability.
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('./sw.js').catch(function() {});
|
||||
}
|
||||
|
||||
// Start critical data fetches before React/Vite JS loads.
|
||||
// Must be in <head> BEFORE the module script so the browser queues these
|
||||
// fetches before it discovers and starts downloading the JS bundle.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"version": "3.8.0",
|
||||
"version": "3.11.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"version": "3.8.0",
|
||||
"version": "3.11.0",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"private": true,
|
||||
"version": "3.11.0",
|
||||
"version": "3.11.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 426 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
@@ -0,0 +1,12 @@
|
||||
// Minimal service worker required for PWA installability.
|
||||
// No caching — this app is network-dependent. All fetches pass through.
|
||||
|
||||
self.addEventListener("install", function () {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", function (event) {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", function () {});
|
||||
@@ -265,6 +265,12 @@ export function Sidebar({
|
||||
const sortContactsByOrder = useCallback(
|
||||
(items: Contact[], order: SortOrder) =>
|
||||
[...items].sort((a, b) => {
|
||||
// Unread DM contacts always float to the top
|
||||
const unreadA = unreadCounts[getStateKey('contact', a.public_key)] || 0;
|
||||
const unreadB = unreadCounts[getStateKey('contact', b.public_key)] || 0;
|
||||
if (unreadA > 0 && unreadB === 0) return -1;
|
||||
if (unreadA === 0 && unreadB > 0) return 1;
|
||||
|
||||
if (order === 'recent') {
|
||||
const timeA = getContactRecentTime(a);
|
||||
const timeB = getContactRecentTime(b);
|
||||
@@ -274,7 +280,7 @@ export function Sidebar({
|
||||
}
|
||||
return (a.name || a.public_key).localeCompare(b.name || b.public_key);
|
||||
}),
|
||||
[getContactRecentTime]
|
||||
[getContactRecentTime, unreadCounts]
|
||||
);
|
||||
|
||||
const sortRepeatersByOrder = useCallback(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { RepeaterPane, NotFetched, LppSensorRow } from './repeaterPaneShared';
|
||||
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
|
||||
import type { RepeaterLppTelemetryResponse, PaneState } from '../../types';
|
||||
|
||||
export function LppTelemetryPane({
|
||||
@@ -12,6 +13,7 @@ export function LppTelemetryPane({
|
||||
onRefresh: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { distanceUnit } = useDistanceUnit();
|
||||
return (
|
||||
<RepeaterPane title="LPP Sensors" state={state} onRefresh={onRefresh} disabled={disabled}>
|
||||
{!data ? (
|
||||
@@ -21,7 +23,7 @@ export function LppTelemetryPane({
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{data.sensors.map((sensor, i) => (
|
||||
<LppSensorRow key={i} sensor={sensor} />
|
||||
<LppSensorRow key={i} sensor={sensor} unitPref={distanceUnit} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { RepeaterPane, NotFetched, KvRow } from './repeaterPaneShared';
|
||||
import type { RepeaterOwnerInfoResponse, PaneState } from '../../types';
|
||||
|
||||
function LabeledBlock({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="py-0.5">
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">{label}</span>
|
||||
<p className="text-sm font-medium mt-0.5 break-words">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OwnerInfoPane({
|
||||
data,
|
||||
state,
|
||||
@@ -17,8 +26,8 @@ export function OwnerInfoPane({
|
||||
{!data ? (
|
||||
<NotFetched />
|
||||
) : (
|
||||
<div className="break-all">
|
||||
<KvRow label="Owner Info" value={data.owner_info ?? '—'} />
|
||||
<div className="space-y-1">
|
||||
<LabeledBlock label="Owner Info" value={data.owner_info ?? '—'} />
|
||||
<KvRow label="Guest Password" value={data.guest_password ?? '—'} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -11,19 +11,37 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '../ui/button';
|
||||
import { Separator } from '../ui/separator';
|
||||
import type { TelemetryHistoryEntry, Contact } from '../../types';
|
||||
import { lppDisplayUnit } from './repeaterPaneShared';
|
||||
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
|
||||
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<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' },
|
||||
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 +84,62 @@ export function TelemetryHistoryPane({
|
||||
trackedTelemetryRepeaters,
|
||||
onToggleTrackedTelemetry,
|
||||
}: TelemetryHistoryPaneProps) {
|
||||
const [metric, setMetric] = useState<Metric>('battery_volts');
|
||||
const { distanceUnit } = useDistanceUnit();
|
||||
const [metric, setMetric] = useState<string>('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<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 } = lppDisplayUnit(info.type_name, 0, distanceUnit);
|
||||
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, distanceUnit]);
|
||||
|
||||
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<string, number | undefined> = {
|
||||
timestamp: e.timestamp,
|
||||
battery_volts: d.battery_volts,
|
||||
noise_floor_dbm: d.noise_floor_dbm,
|
||||
@@ -85,19 +147,27 @@ export function TelemetryHistoryPane({
|
||||
packets_sent: d.packets_sent,
|
||||
uptime_seconds: d.uptime_seconds,
|
||||
};
|
||||
// Flatten LPP sensors into the point, converting units as needed
|
||||
for (const s of d.lpp_sensors ?? []) {
|
||||
if (typeof s.value === 'number') {
|
||||
point[lppKey(s)] = lppDisplayUnit(s.type_name, s.value, distanceUnit).value;
|
||||
}
|
||||
}
|
||||
return point;
|
||||
});
|
||||
}, [entries]);
|
||||
}, [entries, distanceUnit]);
|
||||
|
||||
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 +251,35 @@ export function TelemetryHistoryPane({
|
||||
<Separator className="mb-3" />
|
||||
|
||||
{/* Metric selector */}
|
||||
<div className="flex gap-1 mb-2">
|
||||
{(Object.keys(METRIC_CONFIG) as Metric[]).map((m) => (
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{BUILTIN_METRICS.map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setMetric(m)}
|
||||
className={cn(
|
||||
'text-[0.6875rem] px-2 py-0.5 rounded transition-colors',
|
||||
metric === m
|
||||
activeMetric === m
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: '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>
|
||||
))}
|
||||
</div>
|
||||
@@ -221,7 +306,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}`
|
||||
}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
{...TOOLTIP_STYLE}
|
||||
@@ -234,15 +321,20 @@ export function TelemetryHistoryPane({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
formatter={(value: any, name: any) => {
|
||||
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 +343,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))',
|
||||
}}
|
||||
|
||||
@@ -223,11 +223,26 @@ export const LPP_UNIT_MAP: Record<string, string> = {
|
||||
colour: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the display unit and converted value for an LPP sensor,
|
||||
* respecting the user's unit preference for temperature.
|
||||
*/
|
||||
export function lppDisplayUnit(
|
||||
typeName: string,
|
||||
value: number,
|
||||
unitPref: 'metric' | 'imperial' | string
|
||||
): { unit: string; value: number } {
|
||||
if (typeName === 'temperature' && unitPref === 'imperial') {
|
||||
return { unit: '°F', value: (value * 9) / 5 + 32 };
|
||||
}
|
||||
return { unit: LPP_UNIT_MAP[typeName] ?? '', value };
|
||||
}
|
||||
|
||||
export function formatLppLabel(typeName: string): string {
|
||||
return typeName.charAt(0).toUpperCase() + typeName.slice(1).replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
export function LppSensorRow({ sensor }: { sensor: LppSensor }) {
|
||||
export function LppSensorRow({ sensor, unitPref }: { sensor: LppSensor; unitPref?: string }) {
|
||||
const label = formatLppLabel(sensor.type_name);
|
||||
|
||||
if (typeof sensor.value === 'object' && sensor.value !== null) {
|
||||
@@ -248,10 +263,10 @@ export function LppSensorRow({ sensor }: { sensor: LppSensor }) {
|
||||
);
|
||||
}
|
||||
|
||||
const unit = LPP_UNIT_MAP[sensor.type_name] ?? '';
|
||||
const display = lppDisplayUnit(sensor.type_name, sensor.value as number, unitPref ?? 'metric');
|
||||
const formatted =
|
||||
typeof sensor.value === 'number'
|
||||
? `${sensor.value % 1 === 0 ? sensor.value : sensor.value.toFixed(2)}${unit ? ` ${unit}` : ''}`
|
||||
? `${display.value % 1 === 0 ? display.value : display.value.toFixed(2)}${display.unit ? ` ${display.unit}` : ''}`
|
||||
: String(sensor.value);
|
||||
|
||||
return <KvRow label={label} value={formatted} />;
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Separator } from '../ui/separator';
|
||||
import { toast } from '../ui/sonner';
|
||||
import { api } from '../../api';
|
||||
import { formatTime } from '../../utils/messageParser';
|
||||
import { lppDisplayUnit } from '../repeater/repeaterPaneShared';
|
||||
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
|
||||
import { BulkDeleteContactsModal } from './BulkDeleteContactsModal';
|
||||
import type {
|
||||
AppSettings,
|
||||
@@ -44,6 +46,7 @@ export function SettingsDatabaseSection({
|
||||
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
|
||||
className?: string;
|
||||
}) {
|
||||
const { distanceUnit } = useDistanceUnit();
|
||||
const [retentionDays, setRetentionDays] = useState('14');
|
||||
const [cleaning, setCleaning] = useState(false);
|
||||
const [purgingDecryptedRaw, setPurgingDecryptedRaw] = useState(false);
|
||||
@@ -308,6 +311,22 @@ export function SettingsDatabaseSection({
|
||||
<span>
|
||||
tx {d.packets_sent != null ? d.packets_sent.toLocaleString() : '?'}
|
||||
</span>
|
||||
{d.lpp_sensors?.map((s) => {
|
||||
const display = lppDisplayUnit(s.type_name, s.value, distanceUnit);
|
||||
const val =
|
||||
typeof display.value === 'number'
|
||||
? display.value % 1 === 0
|
||||
? display.value
|
||||
: display.value.toFixed(1)
|
||||
: display.value;
|
||||
const label = s.type_name.charAt(0).toUpperCase() + s.type_name.slice(1);
|
||||
return (
|
||||
<span key={`${s.type_name}-${s.channel}`}>
|
||||
{label} {val}
|
||||
{display.unit ? ` ${display.unit}` : ''}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<span className="ml-auto">checked {formatTime(snap.timestamp)}</span>
|
||||
</div>
|
||||
) : snap === null ? (
|
||||
|
||||
@@ -1004,6 +1004,11 @@ function MqttHaConfigEditor({
|
||||
<li>
|
||||
<code className="text-[0.6875rem]">sensor.meshcore_*_uptime</code> (seconds)
|
||||
</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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -513,6 +513,42 @@ describe('Sidebar section summaries', () => {
|
||||
expect(contactRows).toEqual(['DM Recent', 'Advert Only', 'No Recency']);
|
||||
});
|
||||
|
||||
it('floats contacts with unread DMs above read contacts regardless of recency', () => {
|
||||
const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
|
||||
const readRecent = makeContact('11'.repeat(32), 'Read Recent', 1, { last_advert: 500 });
|
||||
const unreadOld = makeContact('22'.repeat(32), 'Unread Old', 1, { last_advert: 100 });
|
||||
|
||||
render(
|
||||
<Sidebar
|
||||
contacts={[readRecent, unreadOld]}
|
||||
channels={[publicChannel]}
|
||||
activeConversation={null}
|
||||
onSelectConversation={vi.fn()}
|
||||
onNewMessage={vi.fn()}
|
||||
lastMessageTimes={{
|
||||
[getStateKey('contact', readRecent.public_key)]: 500,
|
||||
[getStateKey('contact', unreadOld.public_key)]: 200,
|
||||
}}
|
||||
unreadCounts={{
|
||||
[getStateKey('contact', unreadOld.public_key)]: 3,
|
||||
}}
|
||||
mentions={{}}
|
||||
showCracker={false}
|
||||
crackerRunning={false}
|
||||
onToggleCracker={vi.fn()}
|
||||
onMarkAllRead={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const contactRows = screen
|
||||
.getAllByText(/^(Read Recent|Unread Old)$/)
|
||||
.map((node) => node.textContent)
|
||||
.filter((text): text is string => Boolean(text));
|
||||
|
||||
// Unread Old has unread DMs so it floats above Read Recent despite older recency
|
||||
expect(contactRows).toEqual(['Unread Old', 'Read Recent']);
|
||||
});
|
||||
|
||||
it('sorts repeaters by heard recency even when message times disagree', () => {
|
||||
const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
|
||||
const staleMessageRelay = makeContact(
|
||||
|
||||
@@ -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<string, number>;
|
||||
data: Record<string, number> & { lpp_sensors?: TelemetryLppSensor[] };
|
||||
}
|
||||
|
||||
export interface TraceResponse {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "remoteterm-meshcore"
|
||||
version = "3.11.0"
|
||||
version = "3.11.3"
|
||||
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+218
-1
@@ -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,
|
||||
@@ -102,6 +104,21 @@ class TestRadioDiscovery:
|
||||
for _, cfg in configs[1:]:
|
||||
assert cfg["expire_after"] == 120
|
||||
|
||||
def test_sensor_configs_have_display_precision(self):
|
||||
configs = _radio_discovery_configs("mc", "aabbccddeeff", "R")
|
||||
# All sensor configs (skip the binary_sensor at index 0)
|
||||
for _, cfg in configs[1:]:
|
||||
assert "suggested_display_precision" in cfg
|
||||
assert isinstance(cfg["suggested_display_precision"], int)
|
||||
|
||||
def test_battery_sensor_uses_volts(self):
|
||||
configs = _radio_discovery_configs("mc", "aabbccddeeff", "R")
|
||||
battery_cfgs = [(t, c) for t, c in configs if "battery" in t]
|
||||
assert len(battery_cfgs) == 1
|
||||
_, cfg = battery_cfgs[0]
|
||||
assert cfg["unit_of_measurement"] == "V"
|
||||
assert cfg["suggested_display_precision"] == 2
|
||||
|
||||
|
||||
class TestRepeaterDiscovery:
|
||||
def test_produces_sensor_per_field(self):
|
||||
@@ -122,6 +139,11 @@ class TestRepeaterDiscovery:
|
||||
for _, cfg in configs:
|
||||
assert cfg["expire_after"] == 36000
|
||||
|
||||
def test_sensors_have_display_precision(self):
|
||||
configs = _repeater_discovery_configs("mc", "ccdd", "Rep1", None)
|
||||
for _, cfg in configs:
|
||||
assert "suggested_display_precision" in cfg
|
||||
|
||||
|
||||
class TestContactTrackerDiscovery:
|
||||
def test_config_shape(self):
|
||||
@@ -261,7 +283,7 @@ class TestMqttHaHealth:
|
||||
payload = health_calls[-1][0][1]
|
||||
assert payload["connected"] is True
|
||||
assert payload["noise_floor_dbm"] == -110
|
||||
assert payload["battery_mv"] == 4150
|
||||
assert payload["battery_volts"] == 4.15
|
||||
assert payload["uptime_secs"] == 3600
|
||||
assert payload["last_rssi"] == -85
|
||||
assert payload["packets_recv"] == 500
|
||||
@@ -479,3 +501,198 @@ 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 cfg["suggested_display_precision"] == 1
|
||||
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)
|
||||
|
||||
+289
-7
@@ -377,14 +377,22 @@ class TestSyncRecentContactsToRadio:
|
||||
assert result["loaded"] == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fills_remaining_slots_with_recently_contacted_then_advertised(self, test_db):
|
||||
"""Fill order is favorites, then recent contacts, then recent adverts."""
|
||||
await _insert_contact(KEY_A, "Alice", last_contacted=100)
|
||||
await _insert_contact(KEY_B, "Bob", last_contacted=2000)
|
||||
await _insert_contact("cc" * 32, "Carol", last_contacted=1000)
|
||||
async def test_fills_remaining_slots_with_dm_active_then_advertised(self, test_db):
|
||||
"""Fill order is favorites, then DM-active contacts, then recent adverts."""
|
||||
await _insert_contact(KEY_A, "Alice")
|
||||
await _insert_contact(KEY_B, "Bob")
|
||||
await _insert_contact("cc" * 32, "Carol")
|
||||
await _insert_contact("dd" * 32, "Dave", last_advert=3000)
|
||||
await _insert_contact("ee" * 32, "Eve", last_advert=2500)
|
||||
|
||||
# Create DM activity for Alice (oldest), Bob (most recent), Carol (middle)
|
||||
for key, ts in [(KEY_A, 100), (KEY_B, 2000), ("cc" * 32, 1000)]:
|
||||
await test_db.conn.execute(
|
||||
"INSERT INTO messages (type, conversation_key, text, received_at) VALUES ('PRIV', ?, 'hi', ?)",
|
||||
(key, ts),
|
||||
)
|
||||
await test_db.conn.commit()
|
||||
|
||||
await AppSettingsRepository.update(max_radio_contacts=5)
|
||||
await ContactRepository.set_favorite(KEY_A, True)
|
||||
|
||||
@@ -401,6 +409,7 @@ class TestSyncRecentContactsToRadio:
|
||||
loaded_keys = [
|
||||
call.args[0]["public_key"] for call in mock_mc.commands.add_contact.call_args_list
|
||||
]
|
||||
# Alice (favorite), then Bob & Carol (DM-active, most recent first), then Dave (advert)
|
||||
assert loaded_keys == [KEY_A, KEY_B, "cc" * 32, "dd" * 32]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -509,8 +518,15 @@ class TestSyncAndOffloadAll:
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_favorite_not_loaded_twice(self, test_db):
|
||||
"""Duplicate favorite entries still load the contact only once."""
|
||||
await _insert_contact(KEY_A, "Alice", last_contacted=2000)
|
||||
await _insert_contact(KEY_B, "Bob", last_contacted=1000)
|
||||
await _insert_contact(KEY_A, "Alice")
|
||||
await _insert_contact(KEY_B, "Bob")
|
||||
|
||||
# Bob has DM activity so he appears in tier 2
|
||||
await test_db.conn.execute(
|
||||
"INSERT INTO messages (type, conversation_key, text, received_at) VALUES ('PRIV', ?, 'hi', 1000)",
|
||||
(KEY_B,),
|
||||
)
|
||||
await test_db.conn.commit()
|
||||
|
||||
await AppSettingsRepository.update(max_radio_contacts=2)
|
||||
await ContactRepository.set_favorite(KEY_A, True)
|
||||
@@ -1695,3 +1711,269 @@ 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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_contacts_selected_for_radio_sync — DM-active prioritization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestContactSelectionDmActive:
|
||||
"""Verify that tier 2 prioritizes contacts with recent DM activity."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_incoming_dm_contact_selected_over_advert_only(self, test_db):
|
||||
"""A contact who sent us a DM should be prioritized over one who only advertised."""
|
||||
from app.radio_sync import get_contacts_selected_for_radio_sync
|
||||
|
||||
# Create two non-repeater contacts
|
||||
dm_sender_key = "aa" * 32
|
||||
advert_only_key = "bb" * 32
|
||||
|
||||
await test_db.conn.execute(
|
||||
"INSERT INTO contacts (public_key, name, type, last_seen, last_advert) VALUES (?, ?, 1, 100, 100)",
|
||||
(dm_sender_key, "DM Sender"),
|
||||
)
|
||||
await test_db.conn.execute(
|
||||
"INSERT INTO contacts (public_key, name, type, last_seen, last_advert) VALUES (?, ?, 1, 200, 200)",
|
||||
(advert_only_key, "Advert Only"),
|
||||
)
|
||||
|
||||
# DM Sender sent us a message (incoming DM)
|
||||
await test_db.conn.execute(
|
||||
"INSERT INTO messages (type, conversation_key, text, received_at) VALUES ('PRIV', ?, 'hello', 300)",
|
||||
(dm_sender_key,),
|
||||
)
|
||||
await test_db.conn.commit()
|
||||
|
||||
with patch(
|
||||
"app.radio_sync.AppSettingsRepository.get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=MagicMock(max_radio_contacts=200, tracked_telemetry_repeaters=[]),
|
||||
):
|
||||
selected = await get_contacts_selected_for_radio_sync()
|
||||
|
||||
keys = [c.public_key for c in selected]
|
||||
assert dm_sender_key in keys
|
||||
assert advert_only_key in keys
|
||||
# DM Sender should come before Advert Only (tier 2 before tier 3)
|
||||
assert keys.index(dm_sender_key) < keys.index(advert_only_key)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outgoing_dm_contact_also_selected(self, test_db):
|
||||
"""A contact we sent a DM to should also appear via DM-active tier."""
|
||||
from app.radio_sync import get_contacts_selected_for_radio_sync
|
||||
|
||||
contact_key = "cc" * 32
|
||||
await test_db.conn.execute(
|
||||
"INSERT INTO contacts (public_key, name, type) VALUES (?, ?, 1)",
|
||||
(contact_key, "Outgoing Target"),
|
||||
)
|
||||
await test_db.conn.execute(
|
||||
"INSERT INTO messages (type, conversation_key, text, received_at, outgoing) VALUES ('PRIV', ?, 'hey', 300, 1)",
|
||||
(contact_key,),
|
||||
)
|
||||
await test_db.conn.commit()
|
||||
|
||||
with patch(
|
||||
"app.radio_sync.AppSettingsRepository.get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=MagicMock(max_radio_contacts=200, tracked_telemetry_repeaters=[]),
|
||||
):
|
||||
selected = await get_contacts_selected_for_radio_sync()
|
||||
|
||||
keys = [c.public_key for c in selected]
|
||||
assert contact_key in keys
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repeaters_excluded_from_dm_active_tier(self, test_db):
|
||||
"""Repeater contacts should not appear in tier 2 even with DM activity."""
|
||||
from app.radio_sync import get_contacts_selected_for_radio_sync
|
||||
|
||||
repeater_key = "dd" * 32
|
||||
await test_db.conn.execute(
|
||||
"INSERT INTO contacts (public_key, name, type) VALUES (?, ?, 2)",
|
||||
(repeater_key, "Repeater"),
|
||||
)
|
||||
await test_db.conn.execute(
|
||||
"INSERT INTO messages (type, conversation_key, text, received_at) VALUES ('PRIV', ?, 'cmd', 300)",
|
||||
(repeater_key,),
|
||||
)
|
||||
await test_db.conn.commit()
|
||||
|
||||
with patch(
|
||||
"app.radio_sync.AppSettingsRepository.get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=MagicMock(max_radio_contacts=200, tracked_telemetry_repeaters=[]),
|
||||
):
|
||||
selected = await get_contacts_selected_for_radio_sync()
|
||||
|
||||
keys = [c.public_key for c in selected]
|
||||
assert repeater_key not in keys
|
||||
|
||||
Reference in New Issue
Block a user