Rebase migrations, add some tests, API endpoint (whoops said we didn't need that and we did lol)

This commit is contained in:
Jack Kingsman
2026-04-02 12:12:35 -07:00
parent 5e1bdb2cc1
commit a2547d7f62
12 changed files with 631 additions and 20 deletions
+30
View File
@@ -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
if version < 50:
logger.info("Applying migration 50: repeater telemetry history table")
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,26 @@ 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.
Uses ON DELETE CASCADE so contact deletion automatically cleans up rows.
"""
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_history_key_ts "
"ON repeater_telemetry_history(public_key, timestamp DESC)"
)
await conn.commit()
+8
View File
@@ -530,6 +530,14 @@ 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 TelemetryHistoryEntry(BaseModel):
timestamp: int
data: dict
class RepeaterNodeInfoResponse(BaseModel):
+2
View File
@@ -8,6 +8,7 @@ from app.repository.contacts import (
from app.repository.fanout import FanoutConfigRepository
from app.repository.messages import MessageRepository
from app.repository.raw_packets import RawPacketRepository
from app.repository.repeater_telemetry import RepeaterTelemetryRepository
from app.repository.settings import AppSettingsRepository, StatisticsRepository
__all__ = [
@@ -20,5 +21,6 @@ __all__ = [
"FanoutConfigRepository",
"MessageRepository",
"RawPacketRepository",
"RepeaterTelemetryRepository",
"StatisticsRepository",
]
+75
View File
@@ -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
]
+40 -2
View File
@@ -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: