First draft of repeater telemetry feature

This commit is contained in:
Gnome Adrift
2026-03-29 06:14:14 -07:00
committed by Jack Kingsman
parent 5e1bdb2cc1
commit 78b5598f67
19 changed files with 817 additions and 6 deletions
+37
View File
@@ -382,6 +382,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 49)
applied += 1
# Migration 50: Repeater telemetry history table + tracking opt-in column
if version < 50:
logger.info("Applying migration 50: repeater telemetry history")
await _migrate_050_repeater_telemetry_history(conn)
await set_version(conn, 50)
applied += 1
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -3099,3 +3106,33 @@ async def _migrate_049_foreign_key_cascade(conn: aiosqlite.Connection) -> None:
)
await conn.commit()
logger.debug("Rebuilt contact_name_history with ON DELETE CASCADE")
async def _migrate_050_repeater_telemetry_history(conn: aiosqlite.Connection) -> None:
"""Create repeater_telemetry_history table and add tracking opt-in column to app_settings."""
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS repeater_telemetry_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
timestamp INTEGER NOT NULL,
battery_volts REAL NOT NULL,
uptime_seconds INTEGER,
noise_floor_dbm INTEGER,
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
)
"""
)
await conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_repeater_telemetry_pk_ts
ON repeater_telemetry_history (public_key, timestamp)
"""
)
try:
await conn.execute(
"ALTER TABLE app_settings ADD COLUMN telemetry_tracked_keys TEXT DEFAULT '[]'"
)
except Exception:
pass # Column may already exist
await conn.commit()
+15
View File
@@ -847,6 +847,10 @@ class AppSettings(BaseModel):
"advertisements should not create new contacts; existing contacts are still updated"
),
)
telemetry_tracked_keys: list[str] = Field(
default_factory=list,
description="Repeater public keys opted in to hourly telemetry tracking",
)
class FanoutConfig(BaseModel):
@@ -921,3 +925,14 @@ class StatisticsResponse(BaseModel):
known_channels_active: ContactActivityCounts
path_hash_width_24h: PathHashWidthStats
noise_floor_24h: NoiseFloorHistoryStats
class TelemetryHistoryEntry(BaseModel):
timestamp: int
battery_volts: float
uptime_seconds: int | None = None
noise_floor_dbm: int | None = None
class RepeaterTelemetryHistoryResponse(BaseModel):
entries: list[TelemetryHistoryEntry]
+101
View File
@@ -146,6 +146,15 @@ MESSAGE_POLL_AUDIT_INTERVAL = 3600
# Periodic advertisement task handle
_advert_task: asyncio.Task | None = None
# Repeater telemetry polling task handle
_telemetry_task: asyncio.Task | None = None
# Telemetry polling interval (1 hour)
TELEMETRY_POLL_INTERVAL = 3600
# Max age for telemetry history (30 days)
TELEMETRY_MAX_AGE_SECONDS = 30 * 86400
# Default check interval when periodic advertising is disabled (seconds)
# We still need to periodically check if it's been enabled
ADVERT_CHECK_INTERVAL = 60
@@ -834,6 +843,98 @@ async def stop_periodic_advert():
logger.info("Stopped periodic advertisement")
async def _repeater_telemetry_loop():
"""Background task that periodically polls telemetry from opted-in repeaters."""
from app.repository import RepeaterTelemetryRepository
while True:
try:
await asyncio.sleep(TELEMETRY_POLL_INTERVAL)
if not radio_manager.is_connected or is_polling_paused():
continue
app_settings = await AppSettingsRepository.get()
tracked_keys = app_settings.telemetry_tracked_keys
if not tracked_keys:
continue
# Prune old entries
try:
pruned = await RepeaterTelemetryRepository.prune_old(TELEMETRY_MAX_AGE_SECONDS)
if pruned > 0:
logger.info("Pruned %d old telemetry history rows", pruned)
except Exception as e:
logger.warning("Failed to prune telemetry history: %s", e)
for key in tracked_keys:
if not radio_manager.is_connected:
break
try:
async with radio_manager.radio_operation(
"telemetry_poll",
blocking=False,
suspend_auto_fetch=True,
) as mc:
contact = await ContactRepository.get_by_public_key(key)
if contact is None:
logger.debug("Telemetry poll: contact %s not found, skipping", key[:12])
continue
await mc.commands.add_contact(contact.to_radio_dict())
status = await mc.commands.req_status_sync(key, timeout=10, min_timeout=5)
if status is not None:
await RepeaterTelemetryRepository.record(
public_key=key,
timestamp=int(time.time()),
battery_volts=status.get("bat", 0) / 1000.0,
uptime_seconds=status.get("uptime"),
noise_floor_dbm=status.get("noise_floor"),
)
logger.debug("Recorded telemetry for %s", key[:12])
else:
logger.debug("No telemetry response from %s", key[:12])
except RadioOperationBusyError:
logger.debug("Skipping telemetry poll for %s: radio busy", key[:12])
except Exception as e:
logger.warning("Error polling telemetry for %s: %s", key[:12], e)
await asyncio.sleep(2)
except asyncio.CancelledError:
logger.info("Repeater telemetry polling task cancelled")
break
except Exception as e:
logger.warning("Error in repeater telemetry loop: %s", e, exc_info=True)
def start_repeater_telemetry_polling():
"""Start the periodic repeater telemetry polling background task."""
global _telemetry_task
if _telemetry_task is None or _telemetry_task.done():
_telemetry_task = asyncio.create_task(_repeater_telemetry_loop())
logger.info(
"Started repeater telemetry polling task (interval: %ds)",
TELEMETRY_POLL_INTERVAL,
)
async def stop_repeater_telemetry_polling():
"""Stop the periodic repeater telemetry polling background task."""
global _telemetry_task
if _telemetry_task and not _telemetry_task.done():
_telemetry_task.cancel()
try:
await _telemetry_task
except asyncio.CancelledError:
pass
_telemetry_task = None
logger.info("Stopped repeater telemetry polling")
# Prevents reboot-loop: once we've rebooted to fix clock skew this session,
# don't do it again (the hardware RTC case can't be fixed by reboot).
_clock_reboot_attempted: bool = False
+2
View File
@@ -8,6 +8,7 @@ from app.repository.contacts import (
from app.repository.fanout import FanoutConfigRepository
from app.repository.messages import MessageRepository
from app.repository.raw_packets import RawPacketRepository
from app.repository.repeater_telemetry import RepeaterTelemetryRepository
from app.repository.settings import AppSettingsRepository, StatisticsRepository
__all__ = [
@@ -20,5 +21,6 @@ __all__ = [
"FanoutConfigRepository",
"MessageRepository",
"RawPacketRepository",
"RepeaterTelemetryRepository",
"StatisticsRepository",
]
+62
View File
@@ -0,0 +1,62 @@
import logging
from app.database import db
logger = logging.getLogger(__name__)
class RepeaterTelemetryRepository:
@staticmethod
async def record(
public_key: str,
timestamp: int,
battery_volts: float,
uptime_seconds: int | None = None,
noise_floor_dbm: int | None = None,
) -> None:
"""Insert a telemetry history row."""
await db.conn.execute(
"""
INSERT INTO repeater_telemetry_history
(public_key, timestamp, battery_volts, uptime_seconds, noise_floor_dbm)
VALUES (?, ?, ?, ?, ?)
""",
(public_key, timestamp, battery_volts, uptime_seconds, noise_floor_dbm),
)
await db.conn.commit()
@staticmethod
async def get_history(public_key: str, since_timestamp: int) -> list[dict]:
"""Return telemetry rows for a repeater since a given timestamp, ordered ASC."""
cursor = await db.conn.execute(
"""
SELECT timestamp, battery_volts, uptime_seconds, noise_floor_dbm
FROM repeater_telemetry_history
WHERE public_key = ? AND timestamp >= ?
ORDER BY timestamp ASC
""",
(public_key, since_timestamp),
)
rows = await cursor.fetchall()
return [
{
"timestamp": row["timestamp"],
"battery_volts": row["battery_volts"],
"uptime_seconds": row["uptime_seconds"],
"noise_floor_dbm": row["noise_floor_dbm"],
}
for row in rows
]
@staticmethod
async def prune_old(max_age_seconds: int) -> int:
"""Delete rows older than max_age_seconds. Returns count of deleted rows."""
import time
cutoff = int(time.time()) - max_age_seconds
cursor = await db.conn.execute(
"DELETE FROM repeater_telemetry_history WHERE timestamp < ?",
(cutoff,),
)
await db.conn.commit()
return cursor.rowcount
+27 -1
View File
@@ -29,7 +29,8 @@ class AppSettingsRepository:
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
sidebar_sort_order, last_message_times, preferences_migrated,
advert_interval, last_advert_time, flood_scope,
blocked_keys, blocked_names, discovery_blocked_types
blocked_keys, blocked_names, discovery_blocked_types,
telemetry_tracked_keys
FROM app_settings WHERE id = 1
"""
)
@@ -89,6 +90,14 @@ class AppSettingsRepository:
except (json.JSONDecodeError, TypeError):
discovery_blocked_types = []
# Parse telemetry_tracked_keys JSON
telemetry_tracked_keys: list[str] = []
if row["telemetry_tracked_keys"]:
try:
telemetry_tracked_keys = json.loads(row["telemetry_tracked_keys"])
except (json.JSONDecodeError, TypeError):
telemetry_tracked_keys = []
# Validate sidebar_sort_order (fallback to "recent" if invalid)
sort_order = row["sidebar_sort_order"]
if sort_order not in ("recent", "alpha"):
@@ -107,6 +116,7 @@ class AppSettingsRepository:
blocked_keys=blocked_keys,
blocked_names=blocked_names,
discovery_blocked_types=discovery_blocked_types,
telemetry_tracked_keys=telemetry_tracked_keys,
)
@staticmethod
@@ -123,6 +133,7 @@ class AppSettingsRepository:
blocked_keys: list[str] | None = None,
blocked_names: list[str] | None = None,
discovery_blocked_types: list[int] | None = None,
telemetry_tracked_keys: list[str] | None = None,
) -> AppSettings:
"""Update app settings. Only provided fields are updated."""
updates = []
@@ -177,6 +188,10 @@ class AppSettingsRepository:
updates.append("discovery_blocked_types = ?")
params.append(json.dumps(discovery_blocked_types))
if telemetry_tracked_keys is not None:
updates.append("telemetry_tracked_keys = ?")
params.append(json.dumps(telemetry_tracked_keys))
if updates:
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
await db.conn.execute(query, params)
@@ -226,6 +241,17 @@ class AppSettingsRepository:
new_names = settings.blocked_names + [name]
return await AppSettingsRepository.update(blocked_names=new_names)
@staticmethod
async def toggle_telemetry_tracked_key(key: str) -> AppSettings:
"""Toggle a public key in the telemetry tracking list. Keys are normalized to lowercase."""
normalized = key.lower()
settings = await AppSettingsRepository.get()
if normalized in settings.telemetry_tracked_keys:
new_keys = [k for k in settings.telemetry_tracked_keys if k != normalized]
else:
new_keys = settings.telemetry_tracked_keys + [normalized]
return await AppSettingsRepository.update(telemetry_tracked_keys=new_keys)
@staticmethod
async def migrate_preferences_from_frontend(
favorites: list[dict],
+40 -3
View File
@@ -1,6 +1,9 @@
import logging
import time
from typing import TYPE_CHECKING
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Query
from meshcore import EventType
from app.dependencies import require_connected
from app.models import (
@@ -21,8 +24,10 @@ from app.models import (
RepeaterOwnerInfoResponse,
RepeaterRadioSettingsResponse,
RepeaterStatusResponse,
RepeaterTelemetryHistoryResponse,
TelemetryHistoryEntry,
)
from app.repository import ContactRepository
from app.repository import ContactRepository, RepeaterTelemetryRepository
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
from app.routers.server_control import (
batch_cli_fetch,
@@ -108,7 +113,7 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
if status is None:
raise HTTPException(status_code=504, detail="No status response from repeater")
return RepeaterStatusResponse(
response = RepeaterStatusResponse(
battery_volts=status.get("bat", 0) / 1000.0,
tx_queue_len=status.get("tx_queue_len", 0),
noise_floor_dbm=status.get("noise_floor", 0),
@@ -128,6 +133,38 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
full_events=status.get("full_evts", 0),
)
# Record to telemetry history for charting (best-effort)
try:
await RepeaterTelemetryRepository.record(
public_key=contact.public_key,
timestamp=int(time.time()),
battery_volts=response.battery_volts,
uptime_seconds=response.uptime_seconds,
noise_floor_dbm=response.noise_floor_dbm,
)
except Exception as e:
logger.warning("Failed to record telemetry history: %s", e)
return response
@router.get(
"/{public_key}/repeater/telemetry-history",
response_model=RepeaterTelemetryHistoryResponse,
)
async def repeater_telemetry_history(
public_key: str,
hours: int = Query(default=168, ge=1, le=720),
) -> RepeaterTelemetryHistoryResponse:
"""Get historical telemetry data for a repeater."""
contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact)
since = int(time.time()) - hours * 3600
rows = await RepeaterTelemetryRepository.get_history(contact.public_key, since)
entries = [TelemetryHistoryEntry(**row) for row in rows]
return RepeaterTelemetryHistoryResponse(entries=entries)
@router.post("/{public_key}/repeater/lpp-telemetry", response_model=RepeaterLppTelemetryResponse)
async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
+11
View File
@@ -65,6 +65,10 @@ class BlockNameRequest(BaseModel):
name: str = Field(description="Display name to toggle block status")
class TelemetryTrackKeyRequest(BaseModel):
key: str = Field(description="Public key to toggle telemetry tracking")
class FavoriteRequest(BaseModel):
type: Literal["channel", "contact"] = Field(description="'channel' or 'contact'")
id: str = Field(description="Channel key or contact public key")
@@ -199,6 +203,13 @@ async def toggle_blocked_name(request: BlockNameRequest) -> AppSettings:
return await AppSettingsRepository.toggle_blocked_name(request.name)
@router.post("/telemetry-tracked-keys/toggle", response_model=AppSettings)
async def toggle_telemetry_tracked_key(request: TelemetryTrackKeyRequest) -> AppSettings:
"""Toggle a repeater's telemetry tracking status."""
logger.info("Toggling telemetry tracking: %s", request.key[:12])
return await AppSettingsRepository.toggle_telemetry_tracked_key(request.key)
@router.post("/migrate", response_model=MigratePreferencesResponse)
async def migrate_preferences(request: MigratePreferencesRequest) -> MigratePreferencesResponse:
"""Migrate all preferences from frontend localStorage to database.
+2
View File
@@ -33,6 +33,7 @@ async def run_post_connect_setup(radio_manager) -> None:
start_message_polling,
start_periodic_advert,
start_periodic_sync,
start_repeater_telemetry_polling,
sync_and_offload_all,
sync_radio_time,
)
@@ -233,6 +234,7 @@ async def run_post_connect_setup(radio_manager) -> None:
start_periodic_sync()
start_periodic_advert()
start_message_polling()
start_repeater_telemetry_polling()
radio_manager._setup_complete = True
finally: