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 && (