First draft of repeater telemetry feature

This commit is contained in:
Gnome Adrift
2026-03-29 06:14:14 -07:00
parent df0ed8452b
commit 7f7e8cacd1
20 changed files with 824 additions and 9 deletions
+37
View File
@@ -360,6 +360,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 46)
applied += 1
# Migration 47: Repeater telemetry history table + tracking opt-in column
if version < 47:
logger.info("Applying migration 47: repeater telemetry history")
await _migrate_047_repeater_telemetry_history(conn)
await set_version(conn, 47)
applied += 1
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -2868,3 +2875,33 @@ async def _migrate_046_cleanup_orphaned_contact_child_rows(conn: aiosqlite.Conne
)
await conn.commit()
async def _migrate_047_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
@@ -764,6 +764,10 @@ class AppSettings(BaseModel):
default_factory=list,
description="Display names whose messages are hidden from the UI",
)
telemetry_tracked_keys: list[str] = Field(
default_factory=list,
description="Repeater public keys opted in to hourly telemetry tracking",
)
class FanoutConfig(BaseModel):
@@ -815,3 +819,14 @@ class StatisticsResponse(BaseModel):
contacts_heard: ContactActivityCounts
repeaters_heard: ContactActivityCounts
path_hash_width_24h: PathHashWidthStats
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
@@ -131,6 +131,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
@@ -810,6 +819,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
+26 -1
View File
@@ -28,7 +28,7 @@ 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
blocked_keys, blocked_names, telemetry_tracked_keys
FROM app_settings WHERE id = 1
"""
)
@@ -80,6 +80,14 @@ class AppSettingsRepository:
except (json.JSONDecodeError, TypeError):
blocked_names = []
# 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"):
@@ -97,6 +105,7 @@ class AppSettingsRepository:
flood_scope=row["flood_scope"] or "",
blocked_keys=blocked_keys,
blocked_names=blocked_names,
telemetry_tracked_keys=telemetry_tracked_keys,
)
@staticmethod
@@ -112,6 +121,7 @@ class AppSettingsRepository:
flood_scope: str | None = None,
blocked_keys: list[str] | None = None,
blocked_names: list[str] | None = None,
telemetry_tracked_keys: list[str] | None = None,
) -> AppSettings:
"""Update app settings. Only provided fields are updated."""
updates = []
@@ -162,6 +172,10 @@ class AppSettingsRepository:
updates.append("blocked_names = ?")
params.append(json.dumps(blocked_names))
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)
@@ -211,6 +225,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],
+38 -3
View File
@@ -1,8 +1,9 @@
import asyncio
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
@@ -24,8 +25,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 (
_monotonic,
@@ -167,7 +170,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),
@@ -187,6 +190,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
@@ -58,6 +58,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")
@@ -186,6 +190,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:
+10 -3
View File
@@ -1,12 +1,12 @@
{
"name": "remoteterm-meshcore-frontend",
"version": "2.7.9",
"version": "3.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "remoteterm-meshcore-frontend",
"version": "2.7.9",
"version": "3.5.0",
"dependencies": {
"@codemirror/lang-python": "^6.2.1",
"@codemirror/theme-one-dark": "^6.1.3",
@@ -32,7 +32,8 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"three": "^0.182.0"
"three": "^0.182.0",
"uplot": "^1.6.32"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
@@ -6385,6 +6386,12 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/uplot": {
"version": "1.6.32",
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/uplot/-/uplot-1.6.32.tgz",
"integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==",
"license": "MIT"
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+2 -1
View File
@@ -40,7 +40,8 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"three": "^0.182.0"
"three": "^0.182.0",
"uplot": "^1.6.32"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
+10
View File
@@ -31,6 +31,7 @@ import type {
RepeaterOwnerInfoResponse,
RepeaterRadioSettingsResponse,
RepeaterStatusResponse,
RepeaterTelemetryHistoryResponse,
StatisticsResponse,
TraceResponse,
UnreadCounts,
@@ -296,6 +297,11 @@ export const api = {
method: 'POST',
body: JSON.stringify({ name }),
}),
toggleTelemetryTracking: (key: string) =>
fetchJson<AppSettings>('/settings/telemetry-tracked-keys/toggle', {
method: 'POST',
body: JSON.stringify({ key }),
}),
// Favorites
toggleFavorite: (type: Favorite['type'], id: string) =>
@@ -355,6 +361,10 @@ export const api = {
fetchJson<RepeaterStatusResponse>(`/contacts/${publicKey}/repeater/status`, {
method: 'POST',
}),
repeaterTelemetryHistory: (publicKey: string, hours: number = 168) =>
fetchJson<RepeaterTelemetryHistoryResponse>(
`/contacts/${publicKey}/repeater/telemetry-history?hours=${hours}`
),
repeaterNeighbors: (publicKey: string) =>
fetchJson<RepeaterNeighborsResponse>(`/contacts/${publicKey}/repeater/neighbors`, {
method: 'POST',
+29 -1
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useState, useCallback, useEffect } from 'react';
import { api } from '../api';
import { toast } from './ui/sonner';
import { Button } from './ui/button';
import { Bell, Route, Star, Trash2 } from 'lucide-react';
@@ -22,6 +23,7 @@ import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
import { OwnerInfoPane } from './repeater/RepeaterOwnerInfoPane';
import { ActionsPane } from './repeater/RepeaterActionsPane';
import { ConsolePane } from './repeater/RepeaterConsolePane';
import { BatteryHistoryPane } from './repeater/RepeaterBatteryHistoryPane';
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
// Re-export for backwards compatibility (used by repeaterFormatters.test.ts)
@@ -87,6 +89,27 @@ export function RepeaterDashboard({
useRememberedServerPassword('repeater', conversation.id);
const isFav = isFavorite(favorites, 'contact', conversation.id);
// Telemetry tracking state
const [telemetryTracked, setTelemetryTracked] = useState(false);
useEffect(() => {
api.getSettings().then((s) => {
setTelemetryTracked(s.telemetry_tracked_keys.includes(conversation.id.toLowerCase()));
}).catch(() => {});
}, [conversation.id]);
const handleToggleTelemetryTracking = useCallback(async () => {
const wasTracked = telemetryTracked;
setTelemetryTracked(!wasTracked);
try {
const updated = await api.toggleTelemetryTracking(conversation.id);
setTelemetryTracked(updated.telemetry_tracked_keys.includes(conversation.id.toLowerCase()));
} catch {
setTelemetryTracked(wasTracked);
toast.error('Failed to toggle telemetry tracking');
}
}, [conversation.id, telemetryTracked]);
const handleRepeaterLogin = async (nextPassword: string) => {
await login(nextPassword);
persistAfterLogin(nextPassword);
@@ -264,6 +287,11 @@ export function RepeaterDashboard({
onRefresh={() => refreshPane('status')}
disabled={anyLoading}
/>
<BatteryHistoryPane
publicKey={conversation.id}
isTracked={telemetryTracked}
onToggleTracking={handleToggleTelemetryTracking}
/>
<RadioSettingsPane
data={paneData.radioSettings}
state={paneStates.radioSettings}
@@ -0,0 +1,197 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import uPlot from 'uplot';
import 'uplot/dist/uPlot.min.css';
import { api } from '../../api';
import { cn } from '@/lib/utils';
import type { TelemetryHistoryEntry } from '../../types';
type TimeRange = 24 | 168 | 720;
const RANGE_LABELS: Record<TimeRange, string> = {
24: '24h',
168: '7d',
720: '30d',
};
export function BatteryHistoryPane({
publicKey,
isTracked,
onToggleTracking,
}: {
publicKey: string;
isTracked: boolean;
onToggleTracking: () => void;
}) {
const chartRef = useRef<HTMLDivElement>(null);
const uplotRef = useRef<uPlot | null>(null);
const [entries, setEntries] = useState<TelemetryHistoryEntry[] | null>(null);
const [range, setRange] = useState<TimeRange>(168);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchHistory = useCallback(
async (hours: TimeRange) => {
setLoading(true);
setError(null);
try {
const resp = await api.repeaterTelemetryHistory(publicKey, hours);
setEntries(resp.entries);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load history');
} finally {
setLoading(false);
}
},
[publicKey]
);
useEffect(() => {
fetchHistory(range);
}, [fetchHistory, range]);
// Build / rebuild chart
useEffect(() => {
if (!chartRef.current || !entries || entries.length === 0) {
if (uplotRef.current) {
uplotRef.current.destroy();
uplotRef.current = null;
}
return;
}
const timestamps = entries.map((e) => e.timestamp);
const volts = entries.map((e) => e.battery_volts);
const data: uPlot.AlignedData = [timestamps, volts];
// Get CSS variable colors for dark-theme compat
const style = getComputedStyle(document.documentElement);
const textColor = style.getPropertyValue('--foreground').trim() || '#a1a1aa';
const gridColor = style.getPropertyValue('--border').trim() || '#27272a';
const accentColor = '#22c55e'; // green-500
// Resolve oklch/hsl CSS colors to a usable format
const resolvedText = `hsl(${textColor})`;
const resolvedGrid = `hsl(${gridColor})`;
const opts: uPlot.Options = {
width: chartRef.current.clientWidth,
height: 180,
cursor: { show: true },
legend: { show: false },
padding: [8, 8, 0, 0],
axes: [
{
stroke: resolvedText,
grid: { stroke: resolvedGrid, width: 1 },
ticks: { stroke: resolvedGrid, width: 1 },
font: '10px sans-serif',
space: 60,
},
{
stroke: resolvedText,
grid: { stroke: resolvedGrid, width: 1 },
ticks: { stroke: resolvedGrid, width: 1 },
font: '10px sans-serif',
label: 'Volts',
labelFont: '10px sans-serif',
size: 50,
},
],
series: [
{},
{
label: 'Battery',
stroke: accentColor,
width: 2,
points: { show: entries.length < 50, size: 4 },
},
],
};
if (uplotRef.current) {
uplotRef.current.destroy();
}
uplotRef.current = new uPlot(opts, data, chartRef.current);
return () => {
if (uplotRef.current) {
uplotRef.current.destroy();
uplotRef.current = null;
}
};
}, [entries]);
// Resize handler
useEffect(() => {
if (!chartRef.current || !uplotRef.current) return;
const observer = new ResizeObserver(() => {
if (uplotRef.current && chartRef.current) {
uplotRef.current.setSize({
width: chartRef.current.clientWidth,
height: 180,
});
}
});
observer.observe(chartRef.current);
return () => observer.disconnect();
}, [entries]);
return (
<div className="border border-border rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
<h3 className="text-sm font-medium">Battery History</h3>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onToggleTracking}
className={cn(
'text-[11px] px-2 py-0.5 rounded-full border transition-colors',
isTracked
? 'bg-success/15 border-success/30 text-success'
: 'bg-muted border-border text-muted-foreground hover:text-foreground'
)}
>
{isTracked ? 'Tracking' : 'Track'}
</button>
</div>
</div>
<div className="p-3">
{/* Time range toggles */}
<div className="flex gap-1 mb-2">
{([24, 168, 720] as TimeRange[]).map((h) => (
<button
key={h}
type="button"
onClick={() => setRange(h)}
className={cn(
'text-[11px] px-2 py-0.5 rounded transition-colors',
range === h
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
>
{RANGE_LABELS[h]}
</button>
))}
</div>
{loading && (
<p className="text-sm text-muted-foreground italic">Loading...</p>
)}
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
{!loading && !error && entries && entries.length === 0 && (
<p className="text-sm text-muted-foreground italic">
No history yet. Fetch telemetry above to record a data point
{!isTracked && ', or enable tracking for hourly collection'}.
</p>
)}
<div ref={chartRef} className={cn(entries && entries.length > 0 ? '' : 'hidden')} />
</div>
</div>
);
}
+1
View File
@@ -198,6 +198,7 @@ const baseSettings = {
flood_scope: '',
blocked_keys: [],
blocked_names: [],
telemetry_tracked_keys: [],
};
const publicChannel = {
@@ -50,6 +50,21 @@ vi.mock('../hooks/useRepeaterDashboard', () => ({
useRepeaterDashboard: () => mockHook,
}));
// Mock api module (BatteryHistoryPane calls api.getSettings on mount)
vi.mock('../api', () => ({
api: {
getSettings: vi.fn().mockResolvedValue({
telemetry_tracked_keys: [],
blocked_keys: [],
blocked_names: [],
favorites: [],
}),
repeaterTelemetryHistory: vi.fn().mockResolvedValue({ entries: [] }),
toggleTelemetryTracking: vi.fn().mockResolvedValue({ telemetry_tracked_keys: [] }),
setContactRoutingOverride: vi.fn().mockResolvedValue({ status: 'ok' }),
},
}));
// Mock sonner toast
vi.mock('../components/ui/sonner', () => ({
toast: {
+1
View File
@@ -63,6 +63,7 @@ const baseSettings: AppSettings = {
flood_scope: '',
blocked_keys: [],
blocked_names: [],
telemetry_tracked_keys: [],
};
function renderModal(overrides?: {
+16
View File
@@ -7,3 +7,19 @@ class ResizeObserver {
}
globalThis.ResizeObserver = ResizeObserver;
// uPlot calls matchMedia at import time for DPI detection
if (typeof globalThis.matchMedia === 'undefined') {
Object.defineProperty(globalThis, 'matchMedia', {
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
}
+12
View File
@@ -327,6 +327,7 @@ export interface AppSettings {
flood_scope: string;
blocked_keys: string[];
blocked_names: string[];
telemetry_tracked_keys: string[];
}
export interface AppSettingsUpdate {
@@ -463,6 +464,17 @@ export interface PaneState {
fetched_at?: number | null;
}
export interface TelemetryHistoryEntry {
timestamp: number;
battery_volts: number;
uptime_seconds: number | null;
noise_floor_dbm: number | null;
}
export interface RepeaterTelemetryHistoryResponse {
entries: TelemetryHistoryEntry[];
}
export interface TraceResponse {
remote_snr: number | null;
local_snr: number | null;
+237
View File
@@ -0,0 +1,237 @@
"""Tests for repeater telemetry history: repository CRUD, pruning, and API endpoints."""
import time
import pytest
from app.models import CONTACT_TYPE_REPEATER
from app.repository import (
AppSettingsRepository,
ContactRepository,
RepeaterTelemetryRepository,
)
KEY_A = "aa" * 32
KEY_B = "bb" * 32
async def _insert_repeater(public_key: str, name: str = "Repeater"):
"""Insert a repeater contact into the test database."""
await ContactRepository.upsert(
{
"public_key": public_key,
"name": name,
"type": CONTACT_TYPE_REPEATER,
"flags": 0,
"direct_path": None,
"direct_path_len": -1,
"direct_path_hash_mode": -1,
"last_advert": None,
"lat": None,
"lon": None,
"last_seen": None,
"on_radio": False,
"last_contacted": None,
"first_seen": None,
}
)
@pytest.fixture
async def _db(test_db):
"""Set up test DB and patch the repeater_telemetry module's db reference."""
from app.repository import repeater_telemetry
original = repeater_telemetry.db
repeater_telemetry.db = test_db
try:
yield test_db
finally:
repeater_telemetry.db = original
class TestRepeaterTelemetryRepository:
"""Tests for RepeaterTelemetryRepository CRUD operations."""
@pytest.mark.asyncio
async def test_record_and_get_history(self, _db):
await _insert_repeater(KEY_A)
now = int(time.time())
await RepeaterTelemetryRepository.record(
public_key=KEY_A,
timestamp=now - 3600,
battery_volts=4.15,
uptime_seconds=1000,
noise_floor_dbm=-100,
)
await RepeaterTelemetryRepository.record(
public_key=KEY_A,
timestamp=now,
battery_volts=4.10,
uptime_seconds=2000,
noise_floor_dbm=-95,
)
history = await RepeaterTelemetryRepository.get_history(KEY_A, now - 7200)
assert len(history) == 2
assert history[0]["battery_volts"] == 4.15
assert history[1]["battery_volts"] == 4.10
assert history[0]["timestamp"] < history[1]["timestamp"]
@pytest.mark.asyncio
async def test_get_history_filters_by_time(self, _db):
await _insert_repeater(KEY_A)
now = int(time.time())
await RepeaterTelemetryRepository.record(KEY_A, now - 7200, 4.0)
await RepeaterTelemetryRepository.record(KEY_A, now - 3600, 4.1)
await RepeaterTelemetryRepository.record(KEY_A, now, 4.2)
history = await RepeaterTelemetryRepository.get_history(KEY_A, now - 3601)
assert len(history) == 2
@pytest.mark.asyncio
async def test_get_history_isolates_by_key(self, _db):
await _insert_repeater(KEY_A)
await _insert_repeater(KEY_B)
now = int(time.time())
await RepeaterTelemetryRepository.record(KEY_A, now, 4.1)
await RepeaterTelemetryRepository.record(KEY_B, now, 3.9)
history_a = await RepeaterTelemetryRepository.get_history(KEY_A, 0)
history_b = await RepeaterTelemetryRepository.get_history(KEY_B, 0)
assert len(history_a) == 1
assert len(history_b) == 1
assert history_a[0]["battery_volts"] == 4.1
@pytest.mark.asyncio
async def test_prune_old(self, _db):
await _insert_repeater(KEY_A)
now = int(time.time())
# Insert one old and one recent
await RepeaterTelemetryRepository.record(KEY_A, now - 100000, 3.5)
await RepeaterTelemetryRepository.record(KEY_A, now, 4.0)
pruned = await RepeaterTelemetryRepository.prune_old(50000)
assert pruned == 1
remaining = await RepeaterTelemetryRepository.get_history(KEY_A, 0)
assert len(remaining) == 1
assert remaining[0]["battery_volts"] == 4.0
@pytest.mark.asyncio
async def test_record_nullable_fields(self, _db):
await _insert_repeater(KEY_A)
now = int(time.time())
await RepeaterTelemetryRepository.record(KEY_A, now, 4.0)
history = await RepeaterTelemetryRepository.get_history(KEY_A, 0)
assert len(history) == 1
assert history[0]["uptime_seconds"] is None
assert history[0]["noise_floor_dbm"] is None
class TestTelemetryTrackingToggle:
"""Tests for telemetry tracking toggle in app settings."""
@pytest.mark.asyncio
async def test_toggle_adds_and_removes_key(self, _db):
settings = await AppSettingsRepository.get()
assert settings.telemetry_tracked_keys == []
settings = await AppSettingsRepository.toggle_telemetry_tracked_key(KEY_A)
assert KEY_A.lower() in settings.telemetry_tracked_keys
settings = await AppSettingsRepository.toggle_telemetry_tracked_key(KEY_A)
assert KEY_A.lower() not in settings.telemetry_tracked_keys
@pytest.mark.asyncio
async def test_toggle_normalizes_to_lowercase(self, _db):
upper_key = "AA" * 32
settings = await AppSettingsRepository.toggle_telemetry_tracked_key(upper_key)
assert KEY_A in settings.telemetry_tracked_keys
@pytest.mark.asyncio
async def test_toggle_persists_across_reads(self, _db):
await AppSettingsRepository.toggle_telemetry_tracked_key(KEY_A)
settings = await AppSettingsRepository.get()
assert KEY_A in settings.telemetry_tracked_keys
class TestTelemetryHistoryEndpoint:
"""Tests for the telemetry history API endpoint."""
@pytest.mark.asyncio
async def test_history_endpoint(self, _db, client):
await _insert_repeater(KEY_A)
now = int(time.time())
await RepeaterTelemetryRepository.record(KEY_A, now, 4.1, 1000, -90)
resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history?hours=24")
assert resp.status_code == 200
data = resp.json()
assert len(data["entries"]) == 1
assert data["entries"][0]["battery_volts"] == 4.1
assert data["entries"][0]["uptime_seconds"] == 1000
assert data["entries"][0]["noise_floor_dbm"] == -90
@pytest.mark.asyncio
async def test_history_endpoint_non_repeater_rejected(self, _db, client):
await ContactRepository.upsert(
{
"public_key": KEY_A,
"name": "Node",
"type": 0,
"flags": 0,
"direct_path": None,
"direct_path_len": -1,
"direct_path_hash_mode": -1,
"last_advert": None,
"lat": None,
"lon": None,
"last_seen": None,
"on_radio": False,
"last_contacted": None,
"first_seen": None,
}
)
resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history")
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_history_endpoint_404_unknown_key(self, _db, client):
resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history")
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_history_endpoint_default_hours(self, _db, client):
await _insert_repeater(KEY_A)
resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history")
assert resp.status_code == 200
class TestToggleEndpoint:
"""Tests for the telemetry tracking toggle API endpoint."""
@pytest.mark.asyncio
async def test_toggle_endpoint(self, _db, client):
resp = await client.post(
"/api/settings/telemetry-tracked-keys/toggle",
json={"key": KEY_A},
)
assert resp.status_code == 200
data = resp.json()
assert KEY_A in data["telemetry_tracked_keys"]
# Toggle off
resp = await client.post(
"/api/settings/telemetry-tracked-keys/toggle",
json={"key": KEY_A},
)
assert resp.status_code == 200
data = resp.json()
assert KEY_A not in data["telemetry_tracked_keys"]