diff --git a/app/migrations.py b/app/migrations.py index 64c7826..1e62576 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -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() diff --git a/app/models.py b/app/models.py index 650ec18..8aad0bb 100644 --- a/app/models.py +++ b/app/models.py @@ -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] diff --git a/app/radio_sync.py b/app/radio_sync.py index 9ad6c1f..9894bbd 100644 --- a/app/radio_sync.py +++ b/app/radio_sync.py @@ -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 diff --git a/app/repository/__init__.py b/app/repository/__init__.py index cb34f9c..313f502 100644 --- a/app/repository/__init__.py +++ b/app/repository/__init__.py @@ -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", ] diff --git a/app/repository/repeater_telemetry.py b/app/repository/repeater_telemetry.py new file mode 100644 index 0000000..d438e27 --- /dev/null +++ b/app/repository/repeater_telemetry.py @@ -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 diff --git a/app/repository/settings.py b/app/repository/settings.py index 5c94f95..91e8720 100644 --- a/app/repository/settings.py +++ b/app/repository/settings.py @@ -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], diff --git a/app/routers/repeaters.py b/app/routers/repeaters.py index fe9744f..8ac3c89 100644 --- a/app/routers/repeaters.py +++ b/app/routers/repeaters.py @@ -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: diff --git a/app/routers/settings.py b/app/routers/settings.py index 913a1a8..10c569f 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -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. diff --git a/app/services/radio_lifecycle.py b/app/services/radio_lifecycle.py index bdea931..05d90a7 100644 --- a/app/services/radio_lifecycle.py +++ b/app/services/radio_lifecycle.py @@ -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: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b760184..9aa9db1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 00e5e84..092d46f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 56a610c..a2dbc4e 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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('/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(`/contacts/${publicKey}/repeater/status`, { method: 'POST', }), + repeaterTelemetryHistory: (publicKey: string, hours: number = 168) => + fetchJson( + `/contacts/${publicKey}/repeater/telemetry-history?hours=${hours}` + ), repeaterNeighbors: (publicKey: string) => fetchJson(`/contacts/${publicKey}/repeater/neighbors`, { method: 'POST', diff --git a/frontend/src/components/RepeaterDashboard.tsx b/frontend/src/components/RepeaterDashboard.tsx index e7d77db..486708c 100644 --- a/frontend/src/components/RepeaterDashboard.tsx +++ b/frontend/src/components/RepeaterDashboard.tsx @@ -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} /> + = { + 24: '24h', + 168: '7d', + 720: '30d', +}; + +export function BatteryHistoryPane({ + publicKey, + isTracked, + onToggleTracking, +}: { + publicKey: string; + isTracked: boolean; + onToggleTracking: () => void; +}) { + const chartRef = useRef(null); + const uplotRef = useRef(null); + const [entries, setEntries] = useState(null); + const [range, setRange] = useState(168); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( +
+
+

Battery History

+
+ +
+
+
+ {/* Time range toggles */} +
+ {([24, 168, 720] as TimeRange[]).map((h) => ( + + ))} +
+ + {loading && ( +

Loading...

+ )} + {error && ( +

{error}

+ )} + {!loading && !error && entries && entries.length === 0 && ( +

+ No history yet. Fetch telemetry above to record a data point + {!isTracked && ', or enable tracking for hourly collection'}. +

+ )} +
0 ? '' : 'hidden')} /> +
+
+ ); +} diff --git a/frontend/src/test/appFavorites.test.tsx b/frontend/src/test/appFavorites.test.tsx index 48781d0..50addc2 100644 --- a/frontend/src/test/appFavorites.test.tsx +++ b/frontend/src/test/appFavorites.test.tsx @@ -198,6 +198,7 @@ const baseSettings = { flood_scope: '', blocked_keys: [], blocked_names: [], + telemetry_tracked_keys: [], }; const publicChannel = { diff --git a/frontend/src/test/repeaterDashboard.test.tsx b/frontend/src/test/repeaterDashboard.test.tsx index 1d3e21d..09a0359 100644 --- a/frontend/src/test/repeaterDashboard.test.tsx +++ b/frontend/src/test/repeaterDashboard.test.tsx @@ -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: { diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index 04f6e0a..c6cc721 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -63,6 +63,7 @@ const baseSettings: AppSettings = { flood_scope: '', blocked_keys: [], blocked_names: [], + telemetry_tracked_keys: [], }; function renderModal(overrides?: { diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index 720f237..05656d6 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -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, + }), + }); +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a894ad5..78e92fc 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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; diff --git a/tests/test_repeater_telemetry.py b/tests/test_repeater_telemetry.py new file mode 100644 index 0000000..a55900c --- /dev/null +++ b/tests/test_repeater_telemetry.py @@ -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"]