From 78b5598f67724e270f5eae39542df8cd935a83d1 Mon Sep 17 00:00:00 2001 From: Gnome Adrift <646322+gnomeadrift@users.noreply.github.com> Date: Sun, 29 Mar 2026 06:14:14 -0700 Subject: [PATCH 1/7] First draft of repeater telemetry feature --- app/migrations.py | 37 +++ app/models.py | 15 ++ app/radio_sync.py | 101 ++++++++ app/repository/__init__.py | 2 + app/repository/repeater_telemetry.py | 62 +++++ app/repository/settings.py | 28 ++- app/routers/repeaters.py | 43 +++- app/routers/settings.py | 11 + app/services/radio_lifecycle.py | 2 + frontend/package.json | 3 +- frontend/src/api.ts | 10 + frontend/src/components/RepeaterDashboard.tsx | 30 ++- .../repeater/RepeaterBatteryHistoryPane.tsx | 197 +++++++++++++++ frontend/src/test/appFavorites.test.tsx | 1 + frontend/src/test/repeaterDashboard.test.tsx | 15 ++ frontend/src/test/settingsModal.test.tsx | 1 + frontend/src/test/setup.ts | 16 ++ frontend/src/types.ts | 12 + tests/test_repeater_telemetry.py | 237 ++++++++++++++++++ 19 files changed, 817 insertions(+), 6 deletions(-) create mode 100644 app/repository/repeater_telemetry.py create mode 100644 frontend/src/components/repeater/RepeaterBatteryHistoryPane.tsx create mode 100644 tests/test_repeater_telemetry.py diff --git a/app/migrations.py b/app/migrations.py index 6711536..63fceef 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -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() diff --git a/app/models.py b/app/models.py index 42c9c30..d937428 100644 --- a/app/models.py +++ b/app/models.py @@ -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] diff --git a/app/radio_sync.py b/app/radio_sync.py index cd57de8..eaa3e8d 100644 --- a/app/radio_sync.py +++ b/app/radio_sync.py @@ -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 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 6b91148..c0c4a01 100644 --- a/app/repository/settings.py +++ b/app/repository/settings.py @@ -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], diff --git a/app/routers/repeaters.py b/app/routers/repeaters.py index 8def3a6..2019f87 100644 --- a/app/routers/repeaters.py +++ b/app/routers/repeaters.py @@ -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: diff --git a/app/routers/settings.py b/app/routers/settings.py index 3625584..ee24928 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -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. 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.json b/frontend/package.json index cf95aa4..5373be8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -42,7 +42,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 fc0a8af..8d3609f 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -35,6 +35,7 @@ import type { RepeaterOwnerInfoResponse, RepeaterRadioSettingsResponse, RepeaterStatusResponse, + RepeaterTelemetryHistoryResponse, StatisticsResponse, TraceResponse, UnreadCounts, @@ -319,6 +320,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) => @@ -386,6 +392,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 758db05..1272129 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, Info, Route, Star, Trash2 } from 'lucide-react'; @@ -23,6 +24,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) @@ -91,6 +93,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); @@ -291,6 +314,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 19fe953..f0243e6 100644 --- a/frontend/src/test/repeaterDashboard.test.tsx +++ b/frontend/src/test/repeaterDashboard.test.tsx @@ -51,6 +51,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 6bff4cb..f454706 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -70,6 +70,7 @@ const baseSettings: AppSettings = { blocked_keys: [], blocked_names: [], discovery_blocked_types: [], + 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 fdfadd8..05a9b38 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -342,6 +342,7 @@ export interface AppSettings { blocked_keys: string[]; blocked_names: string[]; discovery_blocked_types: number[]; + telemetry_tracked_keys: string[]; } export interface AppSettingsUpdate { @@ -479,6 +480,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"] From 0511d6f69bc292467b9fe6d408c1bba2720b0aa3 Mon Sep 17 00:00:00 2001 From: Gnome Adrift <646322+gnomeadrift@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:07:20 -0700 Subject: [PATCH 2/7] Make battery history update when fetching telemetry --- frontend/src/components/RepeaterDashboard.tsx | 1 + .../src/components/repeater/RepeaterBatteryHistoryPane.tsx | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/RepeaterDashboard.tsx b/frontend/src/components/RepeaterDashboard.tsx index 1272129..24d173b 100644 --- a/frontend/src/components/RepeaterDashboard.tsx +++ b/frontend/src/components/RepeaterDashboard.tsx @@ -318,6 +318,7 @@ export function RepeaterDashboard({ publicKey={conversation.id} isTracked={telemetryTracked} onToggleTracking={handleToggleTelemetryTracking} + statusFetchedAt={paneStates.status.fetched_at} /> void; + statusFetchedAt?: number | null; }) { const chartRef = useRef(null); const uplotRef = useRef(null); @@ -47,7 +49,7 @@ export function BatteryHistoryPane({ useEffect(() => { fetchHistory(range); - }, [fetchHistory, range]); + }, [fetchHistory, range, statusFetchedAt]); // Build / rebuild chart useEffect(() => { From 87df4b4aa1833d6539c73927e83cfca94a5cc023 Mon Sep 17 00:00:00 2001 From: Gnome Adrift <646322+gnomeadrift@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:38:05 -0700 Subject: [PATCH 3/7] Fix for telemetry polling --- app/radio_sync.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/radio_sync.py b/app/radio_sync.py index eaa3e8d..38fb893 100644 --- a/app/radio_sync.py +++ b/app/radio_sync.py @@ -877,7 +877,7 @@ async def _repeater_telemetry_loop(): blocking=False, suspend_auto_fetch=True, ) as mc: - contact = await ContactRepository.get_by_public_key(key) + contact = await ContactRepository.get_by_key(key) if contact is None: logger.debug("Telemetry poll: contact %s not found, skipping", key[:12]) continue From c808f0930b4ea4af878e8b267d4e8331873b6fb5 Mon Sep 17 00:00:00 2001 From: Gnome Adrift <646322+gnomeadrift@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:54:39 -0700 Subject: [PATCH 4/7] Remove automatic telemetry querying, remove battery pane, add telemetry history pane --- app/migrations.py | 12 +- app/models.py | 17 +- app/radio_sync.py | 100 --------- app/repository/repeater_telemetry.py | 32 +-- app/repository/settings.py | 28 +-- app/routers/repeaters.py | 39 ++-- app/routers/settings.py | 11 - app/services/radio_lifecycle.py | 2 - frontend/package-lock.json | 4 +- frontend/package.json | 3 +- frontend/src/api.ts | 10 - frontend/src/components/RepeaterDashboard.tsx | 37 +--- .../repeater/RepeaterBatteryHistoryPane.tsx | 199 ------------------ .../repeater/RepeaterTelemetryHistoryPane.tsx | 176 ++++++++++++++++ frontend/src/test/appFavorites.test.tsx | 1 - frontend/src/test/repeaterDashboard.test.tsx | 11 +- frontend/src/test/settingsModal.test.tsx | 5 +- frontend/src/types.ts | 19 +- tests/test_repeater_telemetry.py | 162 +++++--------- 19 files changed, 272 insertions(+), 596 deletions(-) delete mode 100644 frontend/src/components/repeater/RepeaterBatteryHistoryPane.tsx create mode 100644 frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx diff --git a/app/migrations.py b/app/migrations.py index 63fceef..9a0ce70 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -3109,16 +3109,14 @@ async def _migrate_049_foreign_key_cascade(conn: aiosqlite.Connection) -> None: 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.""" + """Create repeater_telemetry_history table for JSON-blob telemetry snapshots.""" 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, + data TEXT NOT NULL, FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE ) """ @@ -3129,10 +3127,4 @@ async def _migrate_050_repeater_telemetry_history(conn: aiosqlite.Connection) -> 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 d937428..c55411d 100644 --- a/app/models.py +++ b/app/models.py @@ -530,6 +530,9 @@ class RepeaterStatusResponse(BaseModel): flood_dups: int = Field(description="Duplicate flood packets") direct_dups: int = Field(description="Duplicate direct packets") full_events: int = Field(description="Full event queue count") + telemetry_history: list["TelemetryHistoryEntry"] = Field( + default_factory=list, description="Recent telemetry history snapshots" + ) class RepeaterNodeInfoResponse(BaseModel): @@ -805,7 +808,7 @@ class AppSettings(BaseModel): default_factory=list, description="List of favorited conversations" ) auto_decrypt_dm_on_advert: bool = Field( - default=True, + default=False, description="Whether to attempt historical DM decryption on new contact advertisement", ) sidebar_sort_order: Literal["recent", "alpha"] = Field( @@ -847,10 +850,6 @@ 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): @@ -929,10 +928,4 @@ class StatisticsResponse(BaseModel): 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] + data: dict diff --git a/app/radio_sync.py b/app/radio_sync.py index 38fb893..7fd44c5 100644 --- a/app/radio_sync.py +++ b/app/radio_sync.py @@ -146,15 +146,6 @@ 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 @@ -843,97 +834,6 @@ 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_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). diff --git a/app/repository/repeater_telemetry.py b/app/repository/repeater_telemetry.py index d438e27..405b3c5 100644 --- a/app/repository/repeater_telemetry.py +++ b/app/repository/repeater_telemetry.py @@ -1,3 +1,4 @@ +import json import logging from app.database import db @@ -10,18 +11,16 @@ class RepeaterTelemetryRepository: async def record( public_key: str, timestamp: int, - battery_volts: float, - uptime_seconds: int | None = None, - noise_floor_dbm: int | None = None, + data: dict, ) -> None: - """Insert a telemetry history row.""" + """Insert a telemetry history row with the full status snapshot as JSON.""" await db.conn.execute( """ INSERT INTO repeater_telemetry_history - (public_key, timestamp, battery_volts, uptime_seconds, noise_floor_dbm) - VALUES (?, ?, ?, ?, ?) + (public_key, timestamp, data) + VALUES (?, ?, ?) """, - (public_key, timestamp, battery_volts, uptime_seconds, noise_floor_dbm), + (public_key, timestamp, json.dumps(data)), ) await db.conn.commit() @@ -30,7 +29,7 @@ class RepeaterTelemetryRepository: """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 + SELECT timestamp, data FROM repeater_telemetry_history WHERE public_key = ? AND timestamp >= ? ORDER BY timestamp ASC @@ -41,22 +40,7 @@ class RepeaterTelemetryRepository: return [ { "timestamp": row["timestamp"], - "battery_volts": row["battery_volts"], - "uptime_seconds": row["uptime_seconds"], - "noise_floor_dbm": row["noise_floor_dbm"], + "data": json.loads(row["data"]), } 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 c0c4a01..6b91148 100644 --- a/app/repository/settings.py +++ b/app/repository/settings.py @@ -29,8 +29,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, discovery_blocked_types, - telemetry_tracked_keys + blocked_keys, blocked_names, discovery_blocked_types FROM app_settings WHERE id = 1 """ ) @@ -90,14 +89,6 @@ 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"): @@ -116,7 +107,6 @@ class AppSettingsRepository: blocked_keys=blocked_keys, blocked_names=blocked_names, discovery_blocked_types=discovery_blocked_types, - telemetry_tracked_keys=telemetry_tracked_keys, ) @staticmethod @@ -133,7 +123,6 @@ 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 = [] @@ -188,10 +177,6 @@ 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) @@ -241,17 +226,6 @@ 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 2019f87..29328a2 100644 --- a/app/routers/repeaters.py +++ b/app/routers/repeaters.py @@ -2,7 +2,7 @@ import logging import time from typing import TYPE_CHECKING -from fastapi import APIRouter, HTTPException, Query +from fastapi import APIRouter, HTTPException from meshcore import EventType from app.dependencies import require_connected @@ -24,7 +24,6 @@ from app.models import ( RepeaterOwnerInfoResponse, RepeaterRadioSettingsResponse, RepeaterStatusResponse, - RepeaterTelemetryHistoryResponse, TelemetryHistoryEntry, ) from app.repository import ContactRepository, RepeaterTelemetryRepository @@ -133,39 +132,29 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse: full_events=status.get("full_evts", 0), ) - # Record to telemetry history for charting (best-effort) + # Record to telemetry history as a JSON blob (best-effort) + now = int(time.time()) + status_dict = response.model_dump(exclude={"telemetry_history"}) 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, + timestamp=now, + data=status_dict, ) except Exception as e: logger.warning("Failed to record telemetry history: %s", e) + # Fetch recent history and embed in response + try: + since = now - 30 * 86400 # last 30 days + rows = await RepeaterTelemetryRepository.get_history(contact.public_key, since) + response.telemetry_history = [TelemetryHistoryEntry(**row) for row in rows] + except Exception as e: + logger.warning("Failed to fetch 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: """Fetch CayenneLPP sensor telemetry from a repeater (single attempt, 10s timeout).""" diff --git a/app/routers/settings.py b/app/routers/settings.py index ee24928..3625584 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -65,10 +65,6 @@ 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") @@ -203,13 +199,6 @@ 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 05d90a7..bdea931 100644 --- a/app/services/radio_lifecycle.py +++ b/app/services/radio_lifecycle.py @@ -33,7 +33,6 @@ 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, ) @@ -234,7 +233,6 @@ 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 69c4495..54fb75e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "remoteterm-meshcore-frontend", - "version": "3.6.2", + "version": "3.6.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "remoteterm-meshcore-frontend", - "version": "3.6.2", + "version": "3.6.3", "dependencies": { "@codemirror/lang-python": "^6.2.1", "@codemirror/theme-one-dark": "^6.1.3", diff --git a/frontend/package.json b/frontend/package.json index 5373be8..cf95aa4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -42,8 +42,7 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tailwindcss-animate": "^1.0.7", - "three": "^0.182.0", - "uplot": "^1.6.32" + "three": "^0.182.0" }, "devDependencies": { "@eslint/js": "^9.17.0", diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 8d3609f..fc0a8af 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -35,7 +35,6 @@ import type { RepeaterOwnerInfoResponse, RepeaterRadioSettingsResponse, RepeaterStatusResponse, - RepeaterTelemetryHistoryResponse, StatisticsResponse, TraceResponse, UnreadCounts, @@ -320,11 +319,6 @@ 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) => @@ -392,10 +386,6 @@ 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 24d173b..d34286d 100644 --- a/frontend/src/components/RepeaterDashboard.tsx +++ b/frontend/src/components/RepeaterDashboard.tsx @@ -1,6 +1,5 @@ -import { useState, useCallback, useEffect } from 'react'; +import { useState } from 'react'; -import { api } from '../api'; import { toast } from './ui/sonner'; import { Button } from './ui/button'; import { Bell, Info, Route, Star, Trash2 } from 'lucide-react'; @@ -24,7 +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 { TelemetryHistoryPane } from './repeater/RepeaterTelemetryHistoryPane'; import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal'; // Re-export for backwards compatibility (used by repeaterFormatters.test.ts) @@ -94,26 +93,6 @@ export function RepeaterDashboard({ 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); @@ -314,12 +293,6 @@ export function RepeaterDashboard({ onRefresh={() => refreshPane('status')} disabled={anyLoading} /> - + + {/* Telemetry history chart — full width, below console */} +
)} diff --git a/frontend/src/components/repeater/RepeaterBatteryHistoryPane.tsx b/frontend/src/components/repeater/RepeaterBatteryHistoryPane.tsx deleted file mode 100644 index f21ae9e..0000000 --- a/frontend/src/components/repeater/RepeaterBatteryHistoryPane.tsx +++ /dev/null @@ -1,199 +0,0 @@ -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 = { - 24: '24h', - 168: '7d', - 720: '30d', -}; - -export function BatteryHistoryPane({ - publicKey, - isTracked, - onToggleTracking, - statusFetchedAt, -}: { - publicKey: string; - isTracked: boolean; - onToggleTracking: () => void; - statusFetchedAt?: number | null; -}) { - 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, statusFetchedAt]); - - // 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/components/repeater/RepeaterTelemetryHistoryPane.tsx b/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx new file mode 100644 index 0000000..ff39f4a --- /dev/null +++ b/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx @@ -0,0 +1,176 @@ +import { useState, useMemo } from 'react'; +import { + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip as RechartsTooltip, + ResponsiveContainer, +} from 'recharts'; +import { cn } from '@/lib/utils'; +import type { TelemetryHistoryEntry } from '../../types'; + +type Metric = 'battery_volts' | 'noise_floor_dbm' | 'packets' | 'uptime_seconds'; + +const METRIC_CONFIG: Record = { + 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 TOOLTIP_STYLE = { + contentStyle: { + backgroundColor: 'hsl(var(--popover))', + border: '1px solid hsl(var(--border))', + borderRadius: '6px', + fontSize: '11px', + color: 'hsl(var(--popover-foreground))', + }, + itemStyle: { color: 'hsl(var(--popover-foreground))' }, + labelStyle: { color: 'hsl(var(--muted-foreground))' }, +} as const; + +function formatTime(ts: number): string { + return new Date(ts * 1000).toLocaleString([], { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +function formatUptime(seconds: number): string { + if (seconds < 3600) return `${Math.round(seconds / 60)}m`; + if (seconds < 86400) return `${(seconds / 3600).toFixed(1)}h`; + return `${(seconds / 86400).toFixed(1)}d`; +} + +export function TelemetryHistoryPane({ + entries, + statusFetchedAt, +}: { + entries: TelemetryHistoryEntry[]; + statusFetchedAt?: number | null; +}) { + const [metric, setMetric] = useState('battery_volts'); + // statusFetchedAt is used to indicate freshness; suppress unused lint + void statusFetchedAt; + + const config = METRIC_CONFIG[metric]; + + const chartData = useMemo(() => { + return entries.map((e) => { + const d = e.data; + return { + timestamp: e.timestamp, + battery_volts: d.battery_volts, + noise_floor_dbm: d.noise_floor_dbm, + packets_received: d.packets_received, + packets_sent: d.packets_sent, + uptime_seconds: d.uptime_seconds, + }; + }); + }, [entries]); + + const dataKeys = metric === 'packets' ? ['packets_received', 'packets_sent'] : [metric]; + + return ( +
+
+

Telemetry History

+ {entries.length} samples +
+
+ {/* Metric selector */} +
+ {(Object.keys(METRIC_CONFIG) as Metric[]).map((m) => ( + + ))} +
+ + {entries.length === 0 ? ( +

+ No history yet. Fetch status above to record data points. +

+ ) : ( + + + + + + metric === 'uptime_seconds' ? formatUptime(v) : `${v}` + } + /> + formatTime(Number(ts))} + // 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 suffix = metric === 'uptime_seconds' ? '' : config.unit ? ` ${config.unit}` : ''; + const label = + metric === 'packets' + ? name === 'packets_received' + ? 'Received' + : 'Sent' + : config.label; + return [`${display}${suffix}`, label]; + }} + /> + {dataKeys.map((key, i) => ( + + ))} + + + )} +
+
+ ); +} diff --git a/frontend/src/test/appFavorites.test.tsx b/frontend/src/test/appFavorites.test.tsx index 50addc2..48781d0 100644 --- a/frontend/src/test/appFavorites.test.tsx +++ b/frontend/src/test/appFavorites.test.tsx @@ -198,7 +198,6 @@ 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 f0243e6..d62fbf9 100644 --- a/frontend/src/test/repeaterDashboard.test.tsx +++ b/frontend/src/test/repeaterDashboard.test.tsx @@ -51,17 +51,9 @@ vi.mock('../hooks/useRepeaterDashboard', () => ({ useRepeaterDashboard: () => mockHook, })); -// Mock api module (BatteryHistoryPane calls api.getSettings on mount) +// Mock api module 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' }), }, })); @@ -433,6 +425,7 @@ describe('RepeaterDashboard', () => { flood_dups: 1, direct_dups: 0, full_events: 0, + telemetry_history: [], }; render(); diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index f454706..305dafc 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -70,7 +70,6 @@ const baseSettings: AppSettings = { blocked_keys: [], blocked_names: [], discovery_blocked_types: [], - telemetry_tracked_keys: [], }; function renderModal(overrides?: { @@ -617,10 +616,10 @@ describe('SettingsModal', () => { openDatabaseSection(); expect( - screen.getByText(/removes packet-analysis availability for those messages/i) + screen.getByText(/remove packet-analysis availability for those historical messages/i) ).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Packets' })); + fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Raw Packets' })); await waitFor(() => { expect(runMaintenanceSpy).toHaveBeenCalledWith({ purgeLinkedRawPackets: true }); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 05a9b38..523c156 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -243,15 +243,6 @@ export interface ChannelDetail { top_senders_24h: ChannelTopSender[]; } -export interface BulkCreateHashtagChannelsResult { - created_channels: Channel[]; - existing_count: number; - invalid_names: string[]; - decrypt_started: boolean; - decrypt_total_packets: number; - message: string; -} - /** A single path that a message took to reach us */ export interface MessagePath { /** Hex-encoded routing path */ @@ -342,7 +333,6 @@ export interface AppSettings { blocked_keys: string[]; blocked_names: string[]; discovery_blocked_types: number[]; - telemetry_tracked_keys: string[]; } export interface AppSettingsUpdate { @@ -417,6 +407,7 @@ export interface RepeaterStatusResponse { flood_dups: number; direct_dups: number; full_events: number; + telemetry_history: TelemetryHistoryEntry[]; } export interface RepeaterNeighborsResponse { @@ -482,13 +473,7 @@ export interface PaneState { export interface TelemetryHistoryEntry { timestamp: number; - battery_volts: number; - uptime_seconds: number | null; - noise_floor_dbm: number | null; -} - -export interface RepeaterTelemetryHistoryResponse { - entries: TelemetryHistoryEntry[]; + data: RepeaterStatusResponse; } export interface TraceResponse { diff --git a/tests/test_repeater_telemetry.py b/tests/test_repeater_telemetry.py index a55900c..760566e 100644 --- a/tests/test_repeater_telemetry.py +++ b/tests/test_repeater_telemetry.py @@ -1,12 +1,12 @@ -"""Tests for repeater telemetry history: repository CRUD, pruning, and API endpoints.""" +"""Tests for repeater telemetry history: repository CRUD and embedded status response.""" +import json import time import pytest from app.models import CONTACT_TYPE_REPEATER from app.repository import ( - AppSettingsRepository, ContactRepository, RepeaterTelemetryRepository, ) @@ -14,6 +14,26 @@ from app.repository import ( KEY_A = "aa" * 32 KEY_B = "bb" * 32 +SAMPLE_STATUS = { + "battery_volts": 4.15, + "tx_queue_len": 0, + "noise_floor_dbm": -100, + "last_rssi_dbm": -80, + "last_snr_db": 5.0, + "packets_received": 100, + "packets_sent": 50, + "airtime_seconds": 300, + "rx_airtime_seconds": 200, + "uptime_seconds": 1000, + "sent_flood": 10, + "sent_direct": 40, + "recv_flood": 60, + "recv_direct": 40, + "flood_dups": 5, + "direct_dups": 2, + "full_events": 0, +} + async def _insert_repeater(public_key: str, name: str = "Repeater"): """Insert a repeater contact into the test database.""" @@ -51,7 +71,7 @@ async def _db(test_db): class TestRepeaterTelemetryRepository: - """Tests for RepeaterTelemetryRepository CRUD operations.""" + """Tests for RepeaterTelemetryRepository CRUD operations with JSON blob storage.""" @pytest.mark.asyncio async def test_record_and_get_history(self, _db): @@ -61,22 +81,18 @@ class TestRepeaterTelemetryRepository: await RepeaterTelemetryRepository.record( public_key=KEY_A, timestamp=now - 3600, - battery_volts=4.15, - uptime_seconds=1000, - noise_floor_dbm=-100, + data={**SAMPLE_STATUS, "battery_volts": 4.15}, ) await RepeaterTelemetryRepository.record( public_key=KEY_A, timestamp=now, - battery_volts=4.10, - uptime_seconds=2000, - noise_floor_dbm=-95, + data={**SAMPLE_STATUS, "battery_volts": 4.10}, ) 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]["data"]["battery_volts"] == 4.15 + assert history[1]["data"]["battery_volts"] == 4.10 assert history[0]["timestamp"] < history[1]["timestamp"] @pytest.mark.asyncio @@ -84,9 +100,9 @@ class TestRepeaterTelemetryRepository: 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) + await RepeaterTelemetryRepository.record(KEY_A, now - 7200, SAMPLE_STATUS) + await RepeaterTelemetryRepository.record(KEY_A, now - 3600, SAMPLE_STATUS) + await RepeaterTelemetryRepository.record(KEY_A, now, SAMPLE_STATUS) history = await RepeaterTelemetryRepository.get_history(KEY_A, now - 3601) assert len(history) == 2 @@ -97,87 +113,40 @@ class TestRepeaterTelemetryRepository: 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) + await RepeaterTelemetryRepository.record( + KEY_A, now, {**SAMPLE_STATUS, "battery_volts": 4.1} + ) + await RepeaterTelemetryRepository.record( + KEY_B, now, {**SAMPLE_STATUS, "battery_volts": 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 + assert history_a[0]["data"]["battery_volts"] == 4.1 @pytest.mark.asyncio - async def test_prune_old(self, _db): + async def test_data_stored_as_json(self, _db): + """Verify the data column stores valid JSON that round-trips correctly.""" 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) + await RepeaterTelemetryRepository.record(KEY_A, now, SAMPLE_STATUS) 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 + assert history[0]["data"] == SAMPLE_STATUS -class TestTelemetryTrackingToggle: - """Tests for telemetry tracking toggle in app settings.""" +class TestTelemetryHistoryInStatusResponse: + """Tests that history is embedded in the status response (no separate endpoint).""" @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): + async def test_history_not_available_as_separate_endpoint(self, _db, client): + """The old GET telemetry-history endpoint should be gone.""" 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 + resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history") + assert resp.status_code in (404, 405) @pytest.mark.asyncio async def test_history_endpoint_non_repeater_rejected(self, _db, client): @@ -200,38 +169,5 @@ class TestTelemetryHistoryEndpoint: } ) 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"] + # Either 404 (method not found) or 400 (not a repeater) — endpoint is gone + assert resp.status_code in (400, 404, 405) From 967dd05fad3c2be0524acc4ebbacfb1d8de723c0 Mon Sep 17 00:00:00 2001 From: Gnome Adrift <646322+gnomeadrift@users.noreply.github.com> Date: Wed, 1 Apr 2026 13:02:02 -0700 Subject: [PATCH 5/7] Prune telemetry entries, remove uplot comments, format code --- app/radio_sync.py | 1 - app/repository/repeater_telemetry.py | 31 ++++++++++++++++++- app/routers/repeaters.py | 2 -- .../repeater/RepeaterTelemetryHistoryPane.tsx | 7 ++--- frontend/src/test/setup.ts | 2 +- frontend/src/types.ts | 2 +- tests/test_repeater_telemetry.py | 1 - 7 files changed, 35 insertions(+), 11 deletions(-) diff --git a/app/radio_sync.py b/app/radio_sync.py index 7fd44c5..cd57de8 100644 --- a/app/radio_sync.py +++ b/app/radio_sync.py @@ -834,7 +834,6 @@ async def stop_periodic_advert(): logger.info("Stopped periodic advertisement") - # 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/repeater_telemetry.py b/app/repository/repeater_telemetry.py index 405b3c5..a3dba86 100644 --- a/app/repository/repeater_telemetry.py +++ b/app/repository/repeater_telemetry.py @@ -1,10 +1,17 @@ import json import logging +import time from app.database import db logger = logging.getLogger(__name__) +# Maximum age for telemetry history entries (30 days) +_MAX_AGE_SECONDS = 30 * 86400 + +# Maximum entries to keep per repeater (sanity cap) +_MAX_ENTRIES_PER_REPEATER = 1000 + class RepeaterTelemetryRepository: @staticmethod @@ -13,7 +20,7 @@ class RepeaterTelemetryRepository: timestamp: int, data: dict, ) -> None: - """Insert a telemetry history row with the full status snapshot as JSON.""" + """Insert a telemetry history row and prune stale entries.""" await db.conn.execute( """ INSERT INTO repeater_telemetry_history @@ -22,6 +29,28 @@ class RepeaterTelemetryRepository: """, (public_key, timestamp, json.dumps(data)), ) + + # Prune entries older than 30 days + cutoff = int(time.time()) - _MAX_AGE_SECONDS + await db.conn.execute( + "DELETE FROM repeater_telemetry_history WHERE public_key = ? AND timestamp < ?", + (public_key, cutoff), + ) + + # Cap at _MAX_ENTRIES_PER_REPEATER (keep newest) + await db.conn.execute( + """ + DELETE FROM repeater_telemetry_history + WHERE public_key = ? AND id NOT IN ( + SELECT id FROM repeater_telemetry_history + WHERE public_key = ? + ORDER BY timestamp DESC + LIMIT ? + ) + """, + (public_key, public_key, _MAX_ENTRIES_PER_REPEATER), + ) + await db.conn.commit() @staticmethod diff --git a/app/routers/repeaters.py b/app/routers/repeaters.py index 29328a2..e9ee9c9 100644 --- a/app/routers/repeaters.py +++ b/app/routers/repeaters.py @@ -1,9 +1,7 @@ import logging import time -from typing import TYPE_CHECKING from fastapi import APIRouter, HTTPException -from meshcore import EventType from app.dependencies import require_connected from app.models import ( diff --git a/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx b/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx index ff39f4a..caf7952 100644 --- a/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx +++ b/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx @@ -123,9 +123,7 @@ 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) => (metric === 'uptime_seconds' ? formatUptime(v) : `${v}`)} /> { const numVal = typeof value === 'number' ? value : Number(value); const display = metric === 'uptime_seconds' ? formatUptime(numVal) : `${value}`; - const suffix = metric === 'uptime_seconds' ? '' : config.unit ? ` ${config.unit}` : ''; + const suffix = + metric === 'uptime_seconds' ? '' : config.unit ? ` ${config.unit}` : ''; const label = metric === 'packets' ? name === 'packets_received' diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index 05656d6..339b76d 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -8,7 +8,7 @@ class ResizeObserver { globalThis.ResizeObserver = ResizeObserver; -// uPlot calls matchMedia at import time for DPI detection +// Several components call matchMedia at import time for responsive detection if (typeof globalThis.matchMedia === 'undefined') { Object.defineProperty(globalThis, 'matchMedia', { value: (query: string) => ({ diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 523c156..26d77c6 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -473,7 +473,7 @@ export interface PaneState { export interface TelemetryHistoryEntry { timestamp: number; - data: RepeaterStatusResponse; + data: Record; } export interface TraceResponse { diff --git a/tests/test_repeater_telemetry.py b/tests/test_repeater_telemetry.py index 760566e..46c3ecc 100644 --- a/tests/test_repeater_telemetry.py +++ b/tests/test_repeater_telemetry.py @@ -1,6 +1,5 @@ """Tests for repeater telemetry history: repository CRUD and embedded status response.""" -import json import time import pytest From 5f969017f7368749a497b4d78251ec24ee7423bc Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Thu, 2 Apr 2026 12:43:42 -0700 Subject: [PATCH 6/7] Add some tests, make it an actual endpoint (whoops said we didn't need that) and tidy things up a bit --- app/routers/repeaters.py | 14 +++++ frontend/src/api.ts | 3 + frontend/src/components/RepeaterDashboard.tsx | 34 +++++++++-- .../repeater/RepeaterTelemetryHistoryPane.tsx | 10 +--- frontend/src/test/repeaterDashboard.test.tsx | 60 ++++++++++++++++++- frontend/src/test/settingsModal.test.tsx | 4 +- frontend/src/types.ts | 9 +++ tests/test_migrations.py | 32 +++++----- tests/test_repeater_telemetry.py | 33 +++++++--- 9 files changed, 157 insertions(+), 42 deletions(-) diff --git a/app/routers/repeaters.py b/app/routers/repeaters.py index e9ee9c9..d822195 100644 --- a/app/routers/repeaters.py +++ b/app/routers/repeaters.py @@ -153,6 +153,20 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse: return response +@router.get( + "/{public_key}/repeater/telemetry-history", + response_model=list[TelemetryHistoryEntry], +) +async def repeater_telemetry_history(public_key: str) -> list[TelemetryHistoryEntry]: + """Return stored telemetry history for a repeater (read-only, no radio access).""" + contact = await _resolve_contact_or_404(public_key) + _require_repeater(contact) + + since = int(time.time()) - 30 * 86400 + rows = await RepeaterTelemetryRepository.get_history(contact.public_key, since) + return [TelemetryHistoryEntry(**row) for row in rows] + + @router.post("/{public_key}/repeater/lpp-telemetry", response_model=RepeaterLppTelemetryResponse) async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse: """Fetch CayenneLPP sensor telemetry from a repeater (single attempt, 10s timeout).""" diff --git a/frontend/src/api.ts b/frontend/src/api.ts index fc0a8af..76fb34f 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -35,6 +35,7 @@ import type { RepeaterOwnerInfoResponse, RepeaterRadioSettingsResponse, RepeaterStatusResponse, + TelemetryHistoryEntry, StatisticsResponse, TraceResponse, UnreadCounts, @@ -414,6 +415,8 @@ export const api = { fetchJson(`/contacts/${publicKey}/repeater/lpp-telemetry`, { method: 'POST', }), + repeaterTelemetryHistory: (publicKey: string) => + fetchJson(`/contacts/${publicKey}/repeater/telemetry-history`), roomLogin: (publicKey: string, password: string) => fetchJson(`/contacts/${publicKey}/room/login`, { method: 'POST', diff --git a/frontend/src/components/RepeaterDashboard.tsx b/frontend/src/components/RepeaterDashboard.tsx index d34286d..bf8100e 100644 --- a/frontend/src/components/RepeaterDashboard.tsx +++ b/frontend/src/components/RepeaterDashboard.tsx @@ -1,5 +1,6 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; +import { api } from '../api'; import { toast } from './ui/sonner'; import { Button } from './ui/button'; import { Bell, Info, Route, Star, Trash2 } from 'lucide-react'; @@ -12,7 +13,13 @@ import { isFavorite } from '../utils/favorites'; import { handleKeyboardActivate } from '../utils/a11y'; import { isValidLocation } from '../utils/pathUtils'; import { ContactStatusInfo } from './ContactStatusInfo'; -import type { Contact, Conversation, Favorite, PathDiscoveryResponse } from '../types'; +import type { + Contact, + Conversation, + Favorite, + PathDiscoveryResponse, + TelemetryHistoryEntry, +} from '../types'; import { cn } from '../lib/utils'; import { TelemetryPane } from './repeater/RepeaterTelemetryPane'; import { NeighborsPane } from './repeater/RepeaterNeighborsPane'; @@ -91,6 +98,24 @@ export function RepeaterDashboard({ const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } = useRememberedServerPassword('repeater', conversation.id); + // Telemetry history: preload from stored data, refresh from live status + const [telemetryHistory, setTelemetryHistory] = useState([]); + useEffect(() => { + if (!loggedIn) return; + api + .repeaterTelemetryHistory(conversation.id) + .then(setTelemetryHistory) + .catch(() => {}); + }, [loggedIn, conversation.id]); + + // When a live status fetch returns embedded telemetry_history, replace local state + useEffect(() => { + const liveHistory = paneData.status?.telemetry_history; + if (liveHistory && liveHistory.length > 0) { + setTelemetryHistory(liveHistory); + } + }, [paneData.status?.telemetry_history]); + const isFav = isFavorite(favorites, 'contact', conversation.id); const handleRepeaterLogin = async (nextPassword: string) => { @@ -357,10 +382,7 @@ export function RepeaterDashboard({ /> {/* Telemetry history chart — full width, below console */} - +
)} diff --git a/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx b/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx index caf7952..89c5508 100644 --- a/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx +++ b/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx @@ -47,16 +47,8 @@ function formatUptime(seconds: number): string { return `${(seconds / 86400).toFixed(1)}d`; } -export function TelemetryHistoryPane({ - entries, - statusFetchedAt, -}: { - entries: TelemetryHistoryEntry[]; - statusFetchedAt?: number | null; -}) { +export function TelemetryHistoryPane({ entries }: { entries: TelemetryHistoryEntry[] }) { const [metric, setMetric] = useState('battery_volts'); - // statusFetchedAt is used to indicate freshness; suppress unused lint - void statusFetchedAt; const config = METRIC_CONFIG[metric]; diff --git a/frontend/src/test/repeaterDashboard.test.tsx b/frontend/src/test/repeaterDashboard.test.tsx index d62fbf9..e93062d 100644 --- a/frontend/src/test/repeaterDashboard.test.tsx +++ b/frontend/src/test/repeaterDashboard.test.tsx @@ -51,9 +51,10 @@ vi.mock('../hooks/useRepeaterDashboard', () => ({ useRepeaterDashboard: () => mockHook, })); -// Mock api module +// Mock api module (TelemetryHistoryPane fetches on mount) vi.mock('../api', () => ({ api: { + repeaterTelemetryHistory: vi.fn().mockResolvedValue([]), setContactRoutingOverride: vi.fn().mockResolvedValue({ status: 'ok' }), }, })); @@ -642,4 +643,61 @@ describe('RepeaterDashboard', () => { overrideSpy.mockRestore(); }); }); + + describe('telemetry history', () => { + it('loads telemetry history on mount when logged in', async () => { + const { api } = await import('../api'); + mockHook.loggedIn = true; + + render(); + + await waitFor(() => { + expect(api.repeaterTelemetryHistory).toHaveBeenCalledWith(REPEATER_KEY); + }); + }); + + it('shows telemetry history pane in logged-in view even before status fetch', () => { + mockHook.loggedIn = true; + + render(); + + expect(screen.getByText('Telemetry History')).toBeInTheDocument(); + expect(screen.getByText('0 samples')).toBeInTheDocument(); + }); + + it('updates history from live status fetch', async () => { + const { api } = await import('../api'); + const historySpy = vi.mocked(api.repeaterTelemetryHistory); + const liveEntry = { timestamp: 1700000000, data: { battery_volts: 4.2 } }; + historySpy.mockResolvedValue([]); + + mockHook.loggedIn = true; + mockHook.paneData.status = { + battery_volts: 4.2, + tx_queue_len: 0, + noise_floor_dbm: -120, + last_rssi_dbm: -85, + last_snr_db: 7.5, + packets_received: 100, + packets_sent: 50, + airtime_seconds: 600, + rx_airtime_seconds: 1200, + uptime_seconds: 86400, + sent_flood: 10, + sent_direct: 40, + recv_flood: 30, + recv_direct: 70, + flood_dups: 1, + direct_dups: 0, + full_events: 0, + telemetry_history: [liveEntry], + }; + + render(); + + await waitFor(() => { + expect(screen.getByText('1 samples')).toBeInTheDocument(); + }); + }); + }); }); diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index 305dafc..6bff4cb 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -616,10 +616,10 @@ describe('SettingsModal', () => { openDatabaseSection(); expect( - screen.getByText(/remove packet-analysis availability for those historical messages/i) + screen.getByText(/removes packet-analysis availability for those messages/i) ).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Raw Packets' })); + fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Packets' })); await waitFor(() => { expect(runMaintenanceSpy).toHaveBeenCalledWith({ purgeLinkedRawPackets: true }); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 26d77c6..b2a9956 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -235,6 +235,15 @@ export interface ChannelTopSender { message_count: number; } +export interface BulkCreateHashtagChannelsResult { + created_channels: Channel[]; + existing_count: number; + invalid_names: string[]; + decrypt_started: boolean; + decrypt_total_packets: number; + message: string; +} + export interface ChannelDetail { channel: Channel; message_counts: ChannelMessageCounts; diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 342acc1..6c3044e 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -1249,8 +1249,8 @@ class TestMigration039: applied = await run_migrations(conn) - assert applied == 11 - assert await get_version(conn) == 49 + assert applied == 12 + assert await get_version(conn) == 50 cursor = await conn.execute( """ @@ -1321,8 +1321,8 @@ class TestMigration039: applied = await run_migrations(conn) - assert applied == 11 - assert await get_version(conn) == 49 + assert applied == 12 + assert await get_version(conn) == 50 cursor = await conn.execute( """ @@ -1388,8 +1388,8 @@ class TestMigration039: applied = await run_migrations(conn) - assert applied == 5 - assert await get_version(conn) == 49 + assert applied == 6 + assert await get_version(conn) == 50 cursor = await conn.execute( """ @@ -1441,8 +1441,8 @@ class TestMigration040: applied = await run_migrations(conn) - assert applied == 10 - assert await get_version(conn) == 49 + assert applied == 11 + assert await get_version(conn) == 50 await conn.execute( """ @@ -1503,8 +1503,8 @@ class TestMigration041: applied = await run_migrations(conn) - assert applied == 9 - assert await get_version(conn) == 49 + assert applied == 10 + assert await get_version(conn) == 50 await conn.execute( """ @@ -1556,8 +1556,8 @@ class TestMigration042: applied = await run_migrations(conn) - assert applied == 8 - assert await get_version(conn) == 49 + assert applied == 9 + assert await get_version(conn) == 50 await conn.execute( """ @@ -1696,8 +1696,8 @@ class TestMigration046: applied = await run_migrations(conn) - assert applied == 4 - assert await get_version(conn) == 49 + assert applied == 5 + assert await get_version(conn) == 50 cursor = await conn.execute( """ @@ -1790,8 +1790,8 @@ class TestMigration047: applied = await run_migrations(conn) - assert applied == 3 - assert await get_version(conn) == 49 + assert applied == 4 + assert await get_version(conn) == 50 cursor = await conn.execute( """ diff --git a/tests/test_repeater_telemetry.py b/tests/test_repeater_telemetry.py index 46c3ecc..4a86579 100644 --- a/tests/test_repeater_telemetry.py +++ b/tests/test_repeater_telemetry.py @@ -137,18 +137,31 @@ class TestRepeaterTelemetryRepository: assert history[0]["data"] == SAMPLE_STATUS -class TestTelemetryHistoryInStatusResponse: - """Tests that history is embedded in the status response (no separate endpoint).""" +class TestTelemetryHistoryEndpoint: + """Tests for the read-only GET telemetry-history endpoint.""" @pytest.mark.asyncio - async def test_history_not_available_as_separate_endpoint(self, _db, client): - """The old GET telemetry-history endpoint should be gone.""" + async def test_returns_history_for_repeater(self, _db, client): await _insert_repeater(KEY_A) + now = int(time.time()) + await RepeaterTelemetryRepository.record(KEY_A, now, SAMPLE_STATUS) + resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history") - assert resp.status_code in (404, 405) + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["data"]["battery_volts"] == 4.15 @pytest.mark.asyncio - async def test_history_endpoint_non_repeater_rejected(self, _db, client): + async def test_returns_empty_list_when_no_history(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 + assert resp.json() == [] + + @pytest.mark.asyncio + async def test_rejects_non_repeater(self, _db, client): await ContactRepository.upsert( { "public_key": KEY_A, @@ -168,5 +181,9 @@ class TestTelemetryHistoryInStatusResponse: } ) resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history") - # Either 404 (method not found) or 400 (not a repeater) — endpoint is gone - assert resp.status_code in (400, 404, 405) + assert resp.status_code == 400 + + @pytest.mark.asyncio + async def test_returns_404_for_unknown_contact(self, _db, client): + resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history") + assert resp.status_code == 404 From 93d31adecd03220d6c422edecefd0cd01473aa49 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Thu, 2 Apr 2026 13:21:21 -0700 Subject: [PATCH 7/7] Don't change historical migrations (cruft from rebasing) and don't overwrite data --- app/models.py | 2 +- frontend/src/components/RepeaterDashboard.tsx | 24 ++++++-- frontend/src/test/repeaterDashboard.test.tsx | 55 +++++++++++++++++++ 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/app/models.py b/app/models.py index c55411d..6251bc1 100644 --- a/app/models.py +++ b/app/models.py @@ -808,7 +808,7 @@ class AppSettings(BaseModel): default_factory=list, description="List of favorited conversations" ) auto_decrypt_dm_on_advert: bool = Field( - default=False, + default=True, description="Whether to attempt historical DM decryption on new contact advertisement", ) sidebar_sort_order: Literal["recent", "alpha"] = Field( diff --git a/frontend/src/components/RepeaterDashboard.tsx b/frontend/src/components/RepeaterDashboard.tsx index bf8100e..96feadf 100644 --- a/frontend/src/components/RepeaterDashboard.tsx +++ b/frontend/src/components/RepeaterDashboard.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { api } from '../api'; import { toast } from './ui/sonner'; @@ -100,20 +100,34 @@ export function RepeaterDashboard({ // Telemetry history: preload from stored data, refresh from live status const [telemetryHistory, setTelemetryHistory] = useState([]); + const telemetryHistorySourceRef = useRef<'none' | 'preload' | 'live'>('none'); + const telemetryHistoryRequestRef = useRef(0); + useEffect(() => { + telemetryHistoryRequestRef.current += 1; + telemetryHistorySourceRef.current = 'none'; + setTelemetryHistory([]); + if (!loggedIn) return; + + const requestId = telemetryHistoryRequestRef.current; api .repeaterTelemetryHistory(conversation.id) - .then(setTelemetryHistory) + .then((history) => { + if (telemetryHistoryRequestRef.current !== requestId) return; + if (telemetryHistorySourceRef.current === 'live') return; + telemetryHistorySourceRef.current = 'preload'; + setTelemetryHistory(history); + }) .catch(() => {}); }, [loggedIn, conversation.id]); // When a live status fetch returns embedded telemetry_history, replace local state useEffect(() => { const liveHistory = paneData.status?.telemetry_history; - if (liveHistory && liveHistory.length > 0) { - setTelemetryHistory(liveHistory); - } + if (!liveHistory) return; + telemetryHistorySourceRef.current = 'live'; + setTelemetryHistory(liveHistory); }, [paneData.status?.telemetry_history]); const isFav = isFavorite(favorites, 'contact', conversation.id); diff --git a/frontend/src/test/repeaterDashboard.test.tsx b/frontend/src/test/repeaterDashboard.test.tsx index e93062d..d51d785 100644 --- a/frontend/src/test/repeaterDashboard.test.tsx +++ b/frontend/src/test/repeaterDashboard.test.tsx @@ -126,6 +126,16 @@ const defaultProps = { onDeleteContact: vi.fn(), }; +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + describe('RepeaterDashboard', () => { beforeEach(() => { vi.clearAllMocks(); @@ -645,6 +655,11 @@ describe('RepeaterDashboard', () => { }); describe('telemetry history', () => { + beforeEach(async () => { + const { api } = await import('../api'); + vi.mocked(api.repeaterTelemetryHistory).mockResolvedValue([]); + }); + it('loads telemetry history on mount when logged in', async () => { const { api } = await import('../api'); mockHook.loggedIn = true; @@ -699,5 +714,45 @@ describe('RepeaterDashboard', () => { expect(screen.getByText('1 samples')).toBeInTheDocument(); }); }); + + it('does not let an older preload overwrite newer live status history', async () => { + const { api } = await import('../api'); + const historySpy = vi.mocked(api.repeaterTelemetryHistory); + const deferred = createDeferred<{ timestamp: number; data: { battery_volts: number } }[]>(); + historySpy.mockReturnValue(deferred.promise); + + mockHook.loggedIn = true; + mockHook.paneData.status = { + battery_volts: 4.2, + tx_queue_len: 0, + noise_floor_dbm: -120, + last_rssi_dbm: -85, + last_snr_db: 7.5, + packets_received: 100, + packets_sent: 50, + airtime_seconds: 600, + rx_airtime_seconds: 1200, + uptime_seconds: 86400, + sent_flood: 10, + sent_direct: 40, + recv_flood: 30, + recv_direct: 70, + flood_dups: 1, + direct_dups: 0, + full_events: 0, + telemetry_history: [{ timestamp: 1700000000, data: { battery_volts: 4.2 } }], + }; + + render(); + + await waitFor(() => { + expect(screen.getByText('1 samples')).toBeInTheDocument(); + }); + + deferred.resolve([{ timestamp: 1690000000, data: { battery_volts: 3.9 } }]); + await deferred.promise; + + expect(screen.getByText('1 samples')).toBeInTheDocument(); + }); }); });