From 047d713003aa482dc002da92023559e9394de066 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 27 Apr 2026 09:35:45 -0700 Subject: [PATCH] Permit hourly checks for direct/routed repeaters. Closes #226. --- .../_061_telemetry_routed_hourly.py | 20 + app/models.py | 7 + app/radio_sync.py | 49 ++- app/repository/settings.py | 16 +- app/routers/settings.py | 49 ++- .../repeater/RepeaterTelemetryHistoryPane.tsx | 14 +- .../settings/SettingsDatabaseSection.tsx | 72 +++- frontend/src/test/fanoutSection.test.tsx | 2 + frontend/src/test/settingsModal.test.tsx | 70 ++++ frontend/src/types.ts | 4 + tests/test_migrations/conftest.py | 2 +- tests/test_radio_sync.py | 363 ++++++++++++++++++ tests/test_settings_router.py | 63 +++ 13 files changed, 697 insertions(+), 34 deletions(-) create mode 100644 app/migrations/_061_telemetry_routed_hourly.py diff --git a/app/migrations/_061_telemetry_routed_hourly.py b/app/migrations/_061_telemetry_routed_hourly.py new file mode 100644 index 0000000..c84b3df --- /dev/null +++ b/app/migrations/_061_telemetry_routed_hourly.py @@ -0,0 +1,20 @@ +import logging + +import aiosqlite + +logger = logging.getLogger(__name__) + + +async def migrate(conn: aiosqlite.Connection) -> None: + """Add telemetry_routed_hourly boolean column to app_settings.""" + tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'") + if "app_settings" not in {row[0] for row in await tables_cursor.fetchall()}: + await conn.commit() + return + col_cursor = await conn.execute("PRAGMA table_info(app_settings)") + columns = {row[1] for row in await col_cursor.fetchall()} + if "telemetry_routed_hourly" not in columns: + await conn.execute( + "ALTER TABLE app_settings ADD COLUMN telemetry_routed_hourly INTEGER DEFAULT 0" + ) + await conn.commit() diff --git a/app/models.py b/app/models.py index 46d128a..2e58eb6 100644 --- a/app/models.py +++ b/app/models.py @@ -855,6 +855,13 @@ class AppSettings(BaseModel): "tracked repeaters so daily checks stay under a 24/day ceiling." ), ) + telemetry_routed_hourly: bool = Field( + default=False, + description=( + "When enabled, tracked repeaters with a direct or routed (non-flood) " + "path are polled every hour instead of on the normal scheduled interval." + ), + ) auto_resend_channel: bool = Field( default=False, description=( diff --git a/app/radio_sync.py b/app/radio_sync.py index 01d4d02..4c217ab 100644 --- a/app/radio_sync.py +++ b/app/radio_sync.py @@ -1890,8 +1890,13 @@ async def _collect_repeater_telemetry(mc: MeshCore, contact: Contact) -> bool: return False -async def _run_telemetry_cycle() -> None: - """Collect one telemetry sample from every tracked repeater.""" +async def _run_telemetry_cycle(*, routed_only: bool = False) -> None: + """Collect one telemetry sample from tracked repeaters. + + When *routed_only* is True, only repeaters whose effective route is + ``"direct"`` or ``"override"`` (i.e. not ``"flood"``) are collected. + This is used by the hourly routed-path fast-poll feature. + """ if not radio_manager.is_connected: logger.debug("Telemetry collect: radio not connected, skipping cycle") return @@ -1901,9 +1906,7 @@ async def _run_telemetry_cycle() -> None: if not tracked: return - logger.info("Telemetry collect: starting cycle for %d repeater(s)", len(tracked)) - collected = 0 - + candidates: list[tuple[str, Contact]] = [] for pub_key in tracked: contact = await ContactRepository.get_by_key(pub_key) if not contact or contact.type != 2: @@ -1912,7 +1915,24 @@ async def _run_telemetry_cycle() -> None: pub_key[:12], ) continue + if routed_only and (not contact.effective_route or contact.effective_route.path_len < 0): + continue + candidates.append((pub_key, contact)) + if not candidates: + if routed_only: + logger.debug("Telemetry collect: no routed repeaters to poll this hour") + return + + label = "routed" if routed_only else "full" + logger.info( + "Telemetry collect: starting %s cycle for %d repeater(s)", + label, + len(candidates), + ) + collected = 0 + + for _pub_key, contact in candidates: try: async with radio_manager.radio_operation( "telemetry_collect", @@ -1924,13 +1944,14 @@ async def _run_telemetry_cycle() -> None: except RadioOperationBusyError: logger.debug( "Telemetry collect: radio busy, skipping %s", - pub_key[:12], + contact.public_key[:12], ) logger.info( - "Telemetry collect: cycle complete, %d/%d successful", + "Telemetry collect: %s cycle complete, %d/%d successful", + label, collected, - len(tracked), + len(candidates), ) @@ -1960,9 +1981,15 @@ async def _maybe_run_scheduled_cycle(now: datetime) -> None: effective_hours = clamp_telemetry_interval(app_settings.telemetry_interval_hours, tracked_count) if effective_hours <= 0: return - if now.hour % effective_hours != 0: - return - await _run_telemetry_cycle() + + is_normal_cycle = now.hour % effective_hours == 0 + + if is_normal_cycle: + # Normal scheduled boundary: collect ALL tracked repeaters. + await _run_telemetry_cycle() + elif app_settings.telemetry_routed_hourly: + # Hourly routed-path fast-poll: only repeaters with a non-flood route. + await _run_telemetry_cycle(routed_only=True) async def _telemetry_collect_loop() -> None: diff --git a/app/repository/settings.py b/app/repository/settings.py index 7405eaf..0670714 100644 --- a/app/repository/settings.py +++ b/app/repository/settings.py @@ -42,7 +42,7 @@ class AppSettingsRepository: advert_interval, last_advert_time, flood_scope, blocked_keys, blocked_names, discovery_blocked_types, tracked_telemetry_repeaters, auto_resend_channel, - telemetry_interval_hours + telemetry_interval_hours, telemetry_routed_hourly FROM app_settings WHERE id = 1 """ ) as cursor: @@ -113,6 +113,12 @@ class AppSettingsRepository: except (KeyError, TypeError, ValueError): telemetry_interval_hours = DEFAULT_TELEMETRY_INTERVAL_HOURS + # Parse telemetry_routed_hourly boolean + try: + telemetry_routed_hourly = bool(row["telemetry_routed_hourly"]) + except (KeyError, TypeError): + telemetry_routed_hourly = False + return AppSettings( max_radio_contacts=row["max_radio_contacts"], auto_decrypt_dm_on_advert=bool(row["auto_decrypt_dm_on_advert"]), @@ -126,6 +132,7 @@ class AppSettingsRepository: tracked_telemetry_repeaters=tracked_telemetry_repeaters, auto_resend_channel=auto_resend_channel, telemetry_interval_hours=telemetry_interval_hours, + telemetry_routed_hourly=telemetry_routed_hourly, ) @staticmethod @@ -144,6 +151,7 @@ class AppSettingsRepository: tracked_telemetry_repeaters: list[str] | None = None, auto_resend_channel: bool | None = None, telemetry_interval_hours: int | None = None, + telemetry_routed_hourly: bool | None = None, ) -> None: """Apply field updates using an already-acquired connection. @@ -201,6 +209,10 @@ class AppSettingsRepository: updates.append("telemetry_interval_hours = ?") params.append(telemetry_interval_hours) + if telemetry_routed_hourly is not None: + updates.append("telemetry_routed_hourly = ?") + params.append(1 if telemetry_routed_hourly else 0) + if updates: query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1" async with conn.execute(query, params): @@ -229,6 +241,7 @@ class AppSettingsRepository: tracked_telemetry_repeaters: list[str] | None = None, auto_resend_channel: bool | None = None, telemetry_interval_hours: int | None = None, + telemetry_routed_hourly: bool | None = None, ) -> AppSettings: """Update app settings. Only provided fields are updated.""" async with db.tx() as conn: @@ -246,6 +259,7 @@ class AppSettingsRepository: tracked_telemetry_repeaters=tracked_telemetry_repeaters, auto_resend_channel=auto_resend_channel, telemetry_interval_hours=telemetry_interval_hours, + telemetry_routed_hourly=telemetry_routed_hourly, ) return await AppSettingsRepository._get_in_conn(conn) diff --git a/app/routers/settings.py b/app/routers/settings.py index b99d046..da18a50 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -73,6 +73,13 @@ class AppSettingsUpdate(BaseModel): "based on the current tracked-repeater count." ), ) + telemetry_routed_hourly: bool | None = Field( + default=None, + description=( + "When enabled, tracked repeaters with a direct or routed (non-flood) " + "path are polled every hour instead of on the normal scheduled interval." + ), + ) class BlockKeyRequest(BaseModel): @@ -126,7 +133,18 @@ class TelemetrySchedule(BaseModel): max_tracked: int = Field(description="Maximum number of repeaters that can be tracked") next_run_at: int | None = Field( default=None, - description="Unix timestamp (UTC seconds) of the next scheduled cycle", + description="Unix timestamp (UTC seconds) of the next scheduled flood cycle", + ) + routed_hourly: bool = Field( + default=False, + description="Whether hourly routed/direct-path telemetry is enabled", + ) + next_routed_run_at: int | None = Field( + default=None, + description=( + "Unix timestamp (UTC seconds) of the next hourly routed/direct check, " + "or None when routed_hourly is off or no repeaters are tracked" + ), ) @@ -140,20 +158,27 @@ class TrackedTelemetryResponse(BaseModel): schedule: TelemetrySchedule = Field(description="Current scheduling state") -def _build_schedule(tracked_count: int, preferred_hours: int | None) -> TelemetrySchedule: +def _build_schedule( + tracked_count: int, + preferred_hours: int | None, + routed_hourly: bool = False, +) -> TelemetrySchedule: pref = ( preferred_hours if preferred_hours in TELEMETRY_INTERVAL_OPTIONS_HOURS else DEFAULT_TELEMETRY_INTERVAL_HOURS ) effective = clamp_telemetry_interval(pref, tracked_count) + has_tracked = tracked_count > 0 return TelemetrySchedule( preferred_hours=pref, effective_hours=effective, options=legal_interval_options(tracked_count), tracked_count=tracked_count, max_tracked=MAX_TRACKED_TELEMETRY_REPEATERS, - next_run_at=next_run_timestamp_utc(effective) if tracked_count > 0 else None, + next_run_at=next_run_timestamp_utc(effective) if has_tracked else None, + routed_hourly=routed_hourly, + next_routed_run_at=(next_run_timestamp_utc(1) if has_tracked and routed_hourly else None), ) @@ -216,6 +241,11 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings: logger.info("Updating telemetry_interval_hours to %d", raw_interval) kwargs["telemetry_interval_hours"] = raw_interval + # Telemetry routed hourly + if update.telemetry_routed_hourly is not None: + logger.info("Updating telemetry_routed_hourly to %s", update.telemetry_routed_hourly) + kwargs["telemetry_routed_hourly"] = update.telemetry_routed_hourly + # Flood scope flood_scope_changed = False if update.flood_scope is not None: @@ -328,7 +358,11 @@ async def toggle_tracked_telemetry(request: TrackedTelemetryRequest) -> TrackedT return TrackedTelemetryResponse( tracked_telemetry_repeaters=new_list, names=await _resolve_names(new_list), - schedule=_build_schedule(len(new_list), settings.telemetry_interval_hours), + schedule=_build_schedule( + len(new_list), + settings.telemetry_interval_hours, + settings.telemetry_routed_hourly, + ), ) # Validate it's a repeater @@ -355,7 +389,11 @@ async def toggle_tracked_telemetry(request: TrackedTelemetryRequest) -> TrackedT return TrackedTelemetryResponse( tracked_telemetry_repeaters=new_list, names=await _resolve_names(new_list), - schedule=_build_schedule(len(new_list), settings.telemetry_interval_hours), + schedule=_build_schedule( + len(new_list), + settings.telemetry_interval_hours, + settings.telemetry_routed_hourly, + ), ) @@ -371,4 +409,5 @@ async def get_telemetry_schedule() -> TelemetrySchedule: return _build_schedule( len(app_settings.tracked_telemetry_repeaters), app_settings.telemetry_interval_hours, + app_settings.telemetry_routed_hourly, ) diff --git a/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx b/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx index 3bbd6c4..63f8acd 100644 --- a/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx +++ b/frontend/src/components/repeater/RepeaterTelemetryHistoryPane.tsx @@ -221,16 +221,16 @@ export function TelemetryHistoryPane({ via the repeater pane, API calls to the endpoint ( POST /api/contacts/<key>/repeater/status ), or when the repeater is opted into interval telemetry polling, in which case the - repeater will be polled for metrics every 8 hours. You can see which repeaters are opted - into this flow in the{' '} + repeater will be polled for metrics automatically. Fetch frequency can be configured in{' '} - Database & Messaging - {' '} - settings pane. A maximum of {MAX_TRACKED} repeaters may be opted into this for the sake - of keeping mesh congestion reasonable. + Settings → Database & Messaging + + , where you can also see which repeaters are currently opted in. A maximum of{' '} + {MAX_TRACKED} repeaters may be opted into this for the sake of keeping mesh congestion + reasonable.

{isTracked ? ( @@ -259,7 +259,7 @@ export function TelemetryHistoryPane({ disabled={toggling} className="border-green-600/50 text-green-600 hover:bg-green-600/10" > - {toggling ? 'Updating...' : 'Opt Repeater into 8hr Interval Metrics Tracking'} + {toggling ? 'Updating...' : 'Opt Repeater into Interval Metrics Tracking'} )} diff --git a/frontend/src/components/settings/SettingsDatabaseSection.tsx b/frontend/src/components/settings/SettingsDatabaseSection.tsx index 0f693d4..32308a8 100644 --- a/frontend/src/components/settings/SettingsDatabaseSection.tsx +++ b/frontend/src/components/settings/SettingsDatabaseSection.tsx @@ -92,7 +92,11 @@ export function SettingsDatabaseSection({ return () => { cancelled = true; }; - }, [trackedTelemetryRepeaters.length, appSettings.telemetry_interval_hours]); + }, [ + trackedTelemetryRepeaters.length, + appSettings.telemetry_interval_hours, + appSettings.telemetry_routed_hourly, + ]); useEffect(() => { if (trackedTelemetryRepeaters.length === 0 || telemetryFetchedRef.current) return; @@ -346,13 +350,41 @@ export function SettingsDatabaseSection({ restored if you drop back to a supported count.

)} - {schedule?.next_run_at != null && ( -

- Next run at {formatTime(schedule.next_run_at)} (UTC top of hour). -

- )} + {/* Routed hourly toggle */} + + + {schedule?.next_run_at != null && ( +

+ {schedule.routed_hourly ? 'Next flood run at' : 'Next run at'}{' '} + {formatTime(schedule.next_run_at)} (UTC top of hour). +

+ )} + {schedule?.next_routed_run_at != null && ( +

+ Next direct/routed run at {formatTime(schedule.next_routed_run_at)} (UTC top of hour). +

+ )} + {trackedTelemetryRepeaters.length === 0 ? (

No repeaters are being tracked. Enable tracking from a repeater's dashboard. @@ -362,6 +394,21 @@ export function SettingsDatabaseSection({ {trackedTelemetryRepeaters.map((key) => { const contact = contacts.find((c) => c.public_key === key); const displayName = contact?.name ?? key.slice(0, 12); + const routeSource = contact?.effective_route_source ?? 'flood'; + // A forced-flood override (path_len < 0) still reports source + // "override", but the actual route is flood. Check the real path. + const hasRealPath = + contact?.effective_route != null && contact.effective_route.path_len >= 0; + const routeLabel = !hasRealPath + ? 'flood' + : routeSource === 'override' + ? 'routed' + : routeSource === 'direct' + ? 'direct' + : 'flood'; + const routeColor = hasRealPath + ? 'text-primary bg-primary/10' + : 'text-muted-foreground bg-muted'; const snap = latestTelemetry[key]; const d = snap?.data; return ( @@ -369,9 +416,16 @@ export function SettingsDatabaseSection({

{displayName} - - {key.slice(0, 12)} - +
+ + {key.slice(0, 12)} + + + {routeLabel} + +
{onToggleTrackedTelemetry && (