mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-01 02:53:00 +02:00
Remove automatic telemetry querying, remove battery pane, add telemetry history pane
This commit is contained in:
committed by
Jack Kingsman
parent
87df4b4aa1
commit
c808f0930b
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<AppSettings>('/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<RepeaterStatusResponse>(`/contacts/${publicKey}/repeater/status`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
repeaterTelemetryHistory: (publicKey: string, hours: number = 168) =>
|
||||
fetchJson<RepeaterTelemetryHistoryResponse>(
|
||||
`/contacts/${publicKey}/repeater/telemetry-history?hours=${hours}`
|
||||
),
|
||||
repeaterNeighbors: (publicKey: string) =>
|
||||
fetchJson<RepeaterNeighborsResponse>(`/contacts/${publicKey}/repeater/neighbors`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<BatteryHistoryPane
|
||||
publicKey={conversation.id}
|
||||
isTracked={telemetryTracked}
|
||||
onToggleTracking={handleToggleTelemetryTracking}
|
||||
statusFetchedAt={paneStates.status.fetched_at}
|
||||
/>
|
||||
<RadioSettingsPane
|
||||
data={paneData.radioSettings}
|
||||
state={paneStates.radioSettings}
|
||||
@@ -382,6 +355,12 @@ export function RepeaterDashboard({
|
||||
loading={consoleLoading}
|
||||
onSend={sendConsoleCommand}
|
||||
/>
|
||||
|
||||
{/* Telemetry history chart — full width, below console */}
|
||||
<TelemetryHistoryPane
|
||||
entries={paneData.status?.telemetry_history ?? []}
|
||||
statusFetchedAt={paneStates.status.fetched_at}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<TimeRange, string> = {
|
||||
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<HTMLDivElement>(null);
|
||||
const uplotRef = useRef<uPlot | null>(null);
|
||||
const [entries, setEntries] = useState<TelemetryHistoryEntry[] | null>(null);
|
||||
const [range, setRange] = useState<TimeRange>(168);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchHistory = useCallback(
|
||||
async (hours: TimeRange) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const resp = await api.repeaterTelemetryHistory(publicKey, hours);
|
||||
setEntries(resp.entries);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load history');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[publicKey]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHistory(range);
|
||||
}, [fetchHistory, range, 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 (
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
|
||||
<h3 className="text-sm font-medium">Battery History</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleTracking}
|
||||
className={cn(
|
||||
'text-[11px] px-2 py-0.5 rounded-full border transition-colors',
|
||||
isTracked
|
||||
? 'bg-success/15 border-success/30 text-success'
|
||||
: 'bg-muted border-border text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{isTracked ? 'Tracking' : 'Track'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
{/* Time range toggles */}
|
||||
<div className="flex gap-1 mb-2">
|
||||
{([24, 168, 720] as TimeRange[]).map((h) => (
|
||||
<button
|
||||
key={h}
|
||||
type="button"
|
||||
onClick={() => setRange(h)}
|
||||
className={cn(
|
||||
'text-[11px] px-2 py-0.5 rounded transition-colors',
|
||||
range === h
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
{RANGE_LABELS[h]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<p className="text-sm text-muted-foreground italic">Loading...</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
{!loading && !error && entries && entries.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No history yet. Fetch telemetry above to record a data point
|
||||
{!isTracked && ', or enable tracking for hourly collection'}.
|
||||
</p>
|
||||
)}
|
||||
<div ref={chartRef} className={cn(entries && entries.length > 0 ? '' : 'hidden')} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Metric, { label: string; unit: string; color: string }> = {
|
||||
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<Metric>('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 (
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
|
||||
<h3 className="text-sm font-medium">Telemetry History</h3>
|
||||
<span className="text-[10px] text-muted-foreground">{entries.length} samples</span>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
{/* Metric selector */}
|
||||
<div className="flex gap-1 mb-2">
|
||||
{(Object.keys(METRIC_CONFIG) as Metric[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setMetric(m)}
|
||||
className={cn(
|
||||
'text-[11px] px-2 py-0.5 rounded transition-colors',
|
||||
metric === m
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
{METRIC_CONFIG[m].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No history yet. Fetch status above to record data points.
|
||||
</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<AreaChart data={chartData} margin={{ top: 4, right: 4, bottom: 0, left: -8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
type="number"
|
||||
domain={['dataMin', 'dataMax']}
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={formatTime}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(v) =>
|
||||
metric === 'uptime_seconds' ? formatUptime(v) : `${v}`
|
||||
}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
{...TOOLTIP_STYLE}
|
||||
cursor={{
|
||||
stroke: 'hsl(var(--muted-foreground))',
|
||||
strokeWidth: 1,
|
||||
strokeDasharray: '3 3',
|
||||
}}
|
||||
labelFormatter={(ts) => 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) => (
|
||||
<Area
|
||||
key={key}
|
||||
type="linear"
|
||||
dataKey={key}
|
||||
stroke={metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color}
|
||||
fill={metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color}
|
||||
fillOpacity={0.15}
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
activeDot={{
|
||||
r: 4,
|
||||
fill: metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color,
|
||||
strokeWidth: 2,
|
||||
stroke: 'hsl(var(--popover))',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -198,7 +198,6 @@ const baseSettings = {
|
||||
flood_scope: '',
|
||||
blocked_keys: [],
|
||||
blocked_names: [],
|
||||
telemetry_tracked_keys: [],
|
||||
};
|
||||
|
||||
const publicChannel = {
|
||||
|
||||
@@ -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(<RepeaterDashboard {...defaultProps} />);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user