diff --git a/app/migrations.py b/app/migrations.py index 6711536..9a0ce70 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,25 @@ 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 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, + data TEXT NOT NULL, + 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) + """ + ) + await conn.commit() diff --git a/app/models.py b/app/models.py index 42c9c30..6251bc1 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): @@ -921,3 +924,8 @@ class StatisticsResponse(BaseModel): known_channels_active: ContactActivityCounts path_hash_width_24h: PathHashWidthStats noise_floor_24h: NoiseFloorHistoryStats + + +class TelemetryHistoryEntry(BaseModel): + timestamp: int + data: dict 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..a3dba86 --- /dev/null +++ b/app/repository/repeater_telemetry.py @@ -0,0 +1,75 @@ +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 + async def record( + public_key: str, + timestamp: int, + data: dict, + ) -> None: + """Insert a telemetry history row and prune stale entries.""" + await db.conn.execute( + """ + INSERT INTO repeater_telemetry_history + (public_key, timestamp, data) + VALUES (?, ?, ?) + """, + (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 + 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, data + 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"], + "data": json.loads(row["data"]), + } + for row in rows + ] diff --git a/app/routers/repeaters.py b/app/routers/repeaters.py index 8def3a6..d822195 100644 --- a/app/routers/repeaters.py +++ b/app/routers/repeaters.py @@ -1,4 +1,5 @@ import logging +import time from fastapi import APIRouter, HTTPException @@ -21,8 +22,9 @@ from app.models import ( RepeaterOwnerInfoResponse, RepeaterRadioSettingsResponse, RepeaterStatusResponse, + 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 +110,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 +130,42 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse: full_events=status.get("full_evts", 0), ) + # 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=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=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: 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/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 758db05..96feadf 100644 --- a/frontend/src/components/RepeaterDashboard.tsx +++ b/frontend/src/components/RepeaterDashboard.tsx @@ -1,5 +1,6 @@ -import { useState } from 'react'; +import { useEffect, useRef, 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'; @@ -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'; @@ -23,6 +30,7 @@ import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane'; import { OwnerInfoPane } from './repeater/RepeaterOwnerInfoPane'; import { ActionsPane } from './repeater/RepeaterActionsPane'; import { ConsolePane } from './repeater/RepeaterConsolePane'; +import { TelemetryHistoryPane } from './repeater/RepeaterTelemetryHistoryPane'; import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal'; // Re-export for backwards compatibility (used by repeaterFormatters.test.ts) @@ -90,7 +98,40 @@ 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([]); + 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((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) return; + telemetryHistorySourceRef.current = 'live'; + setTelemetryHistory(liveHistory); + }, [paneData.status?.telemetry_history]); + const isFav = isFavorite(favorites, 'contact', conversation.id); + const handleRepeaterLogin = async (nextPassword: string) => { await login(nextPassword); persistAfterLogin(nextPassword); @@ -353,6 +394,9 @@ export function RepeaterDashboard({ loading={consoleLoading} onSend={sendConsoleCommand} /> + + {/* Telemetry history chart — full width, below console */} + )} diff --git a/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx b/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx new file mode 100644 index 0000000..89c5508 --- /dev/null +++ b/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx @@ -0,0 +1,167 @@ +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 }: { entries: TelemetryHistoryEntry[] }) { + const [metric, setMetric] = useState('battery_volts'); + + 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/repeaterDashboard.test.tsx b/frontend/src/test/repeaterDashboard.test.tsx index 19fe953..d51d785 100644 --- a/frontend/src/test/repeaterDashboard.test.tsx +++ b/frontend/src/test/repeaterDashboard.test.tsx @@ -51,6 +51,14 @@ vi.mock('../hooks/useRepeaterDashboard', () => ({ useRepeaterDashboard: () => mockHook, })); +// Mock api module (TelemetryHistoryPane fetches on mount) +vi.mock('../api', () => ({ + api: { + repeaterTelemetryHistory: vi.fn().mockResolvedValue([]), + setContactRoutingOverride: vi.fn().mockResolvedValue({ status: 'ok' }), + }, +})); + // Mock sonner toast vi.mock('../components/ui/sonner', () => ({ toast: { @@ -118,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(); @@ -418,6 +436,7 @@ describe('RepeaterDashboard', () => { flood_dups: 1, direct_dups: 0, full_events: 0, + telemetry_history: [], }; render(); @@ -634,4 +653,106 @@ describe('RepeaterDashboard', () => { overrideSpy.mockRestore(); }); }); + + 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; + + 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(); + }); + }); + + 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(); + }); + }); }); diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index 720f237..339b76d 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -7,3 +7,19 @@ class ResizeObserver { } globalThis.ResizeObserver = ResizeObserver; + +// Several components call matchMedia at import time for responsive 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..b2a9956 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -235,14 +235,6 @@ export interface ChannelTopSender { message_count: number; } -export interface ChannelDetail { - channel: Channel; - message_counts: ChannelMessageCounts; - first_message_at: number | null; - unique_sender_count: number; - top_senders_24h: ChannelTopSender[]; -} - export interface BulkCreateHashtagChannelsResult { created_channels: Channel[]; existing_count: number; @@ -252,6 +244,14 @@ export interface BulkCreateHashtagChannelsResult { message: string; } +export interface ChannelDetail { + channel: Channel; + message_counts: ChannelMessageCounts; + first_message_at: number | null; + unique_sender_count: number; + top_senders_24h: ChannelTopSender[]; +} + /** A single path that a message took to reach us */ export interface MessagePath { /** Hex-encoded routing path */ @@ -416,6 +416,7 @@ export interface RepeaterStatusResponse { flood_dups: number; direct_dups: number; full_events: number; + telemetry_history: TelemetryHistoryEntry[]; } export interface RepeaterNeighborsResponse { @@ -479,6 +480,11 @@ export interface PaneState { fetched_at?: number | null; } +export interface TelemetryHistoryEntry { + timestamp: number; + data: Record; +} + export interface TraceResponse { remote_snr: number | null; local_snr: number | null; 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 new file mode 100644 index 0000000..4a86579 --- /dev/null +++ b/tests/test_repeater_telemetry.py @@ -0,0 +1,189 @@ +"""Tests for repeater telemetry history: repository CRUD and embedded status response.""" + +import time + +import pytest + +from app.models import CONTACT_TYPE_REPEATER +from app.repository import ( + ContactRepository, + RepeaterTelemetryRepository, +) + +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.""" + 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 with JSON blob storage.""" + + @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, + data={**SAMPLE_STATUS, "battery_volts": 4.15}, + ) + await RepeaterTelemetryRepository.record( + public_key=KEY_A, + timestamp=now, + data={**SAMPLE_STATUS, "battery_volts": 4.10}, + ) + + history = await RepeaterTelemetryRepository.get_history(KEY_A, now - 7200) + assert len(history) == 2 + 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 + 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, 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 + + @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, {**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]["data"]["battery_volts"] == 4.1 + + @pytest.mark.asyncio + 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()) + + await RepeaterTelemetryRepository.record(KEY_A, now, SAMPLE_STATUS) + history = await RepeaterTelemetryRepository.get_history(KEY_A, 0) + assert len(history) == 1 + assert history[0]["data"] == SAMPLE_STATUS + + +class TestTelemetryHistoryEndpoint: + """Tests for the read-only GET telemetry-history endpoint.""" + + @pytest.mark.asyncio + 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 == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["data"]["battery_volts"] == 4.15 + + @pytest.mark.asyncio + 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, + "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_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