mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-01 11:02:56 +02:00
Permit hourly checks for direct/routed repeaters. Closes #226.
This commit is contained in:
20
app/migrations/_061_telemetry_routed_hourly.py
Normal file
20
app/migrations/_061_telemetry_routed_hourly.py
Normal file
@@ -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()
|
||||
@@ -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=(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -221,16 +221,16 @@ export function TelemetryHistoryPane({
|
||||
via the repeater pane, API calls to the endpoint (
|
||||
<code className="text-[0.6875rem]">POST /api/contacts/<key>/repeater/status</code>
|
||||
), 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{' '}
|
||||
<a
|
||||
href="#settings/database"
|
||||
className="underline text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
Database & Messaging
|
||||
</a>{' '}
|
||||
settings pane. A maximum of {MAX_TRACKED} repeaters may be opted into this for the sake
|
||||
of keeping mesh congestion reasonable.
|
||||
Settings → Database & Messaging
|
||||
</a>
|
||||
, 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.
|
||||
</p>
|
||||
|
||||
{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'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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.
|
||||
</p>
|
||||
)}
|
||||
{schedule?.next_run_at != null && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Next run at {formatTime(schedule.next_run_at)} (UTC top of hour).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Routed hourly toggle */}
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={appSettings.telemetry_routed_hourly}
|
||||
onChange={() => {
|
||||
const next = !appSettings.telemetry_routed_hourly;
|
||||
void persistAppSettings({ telemetry_routed_hourly: next }, () => {});
|
||||
}}
|
||||
className="w-4 h-4 rounded border-input accent-primary mt-0.5"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm">Poll direct/routed-path repeaters hourly</span>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
When enabled, tracked repeaters with a direct or routed path (not flood) are polled
|
||||
every hour instead of on the scheduled interval above. Flood-only repeaters still
|
||||
follow the normal schedule.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{schedule?.next_run_at != null && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{schedule.routed_hourly ? 'Next flood run at' : 'Next run at'}{' '}
|
||||
{formatTime(schedule.next_run_at)} (UTC top of hour).
|
||||
</p>
|
||||
)}
|
||||
{schedule?.next_routed_run_at != null && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Next direct/routed run at {formatTime(schedule.next_routed_run_at)} (UTC top of hour).
|
||||
</p>
|
||||
)}
|
||||
|
||||
{trackedTelemetryRepeaters.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
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({
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm truncate block">{displayName}</span>
|
||||
<span className="text-[0.625rem] text-muted-foreground font-mono">
|
||||
{key.slice(0, 12)}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[0.625rem] text-muted-foreground font-mono">
|
||||
{key.slice(0, 12)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded font-medium ${routeColor}`}
|
||||
>
|
||||
{routeLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{onToggleTrackedTelemetry && (
|
||||
<Button
|
||||
|
||||
@@ -111,6 +111,7 @@ beforeEach(() => {
|
||||
tracked_telemetry_repeaters: [],
|
||||
auto_resend_channel: false,
|
||||
telemetry_interval_hours: 8,
|
||||
telemetry_routed_hourly: false,
|
||||
});
|
||||
mockedApi.getRadioConfig.mockResolvedValue({
|
||||
public_key: 'aa'.repeat(32),
|
||||
@@ -1050,6 +1051,7 @@ describe('SettingsFanoutSection', () => {
|
||||
tracked_telemetry_repeaters: ['cc'.repeat(32)],
|
||||
auto_resend_channel: false,
|
||||
telemetry_interval_hours: 8,
|
||||
telemetry_routed_hourly: false,
|
||||
});
|
||||
|
||||
renderSection();
|
||||
|
||||
@@ -5,6 +5,7 @@ import { SettingsModal } from '../components/SettingsModal';
|
||||
import type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
Contact,
|
||||
HealthStatus,
|
||||
RadioAdvertMode,
|
||||
RadioConfig,
|
||||
@@ -71,6 +72,7 @@ const baseSettings: AppSettings = {
|
||||
tracked_telemetry_repeaters: [],
|
||||
auto_resend_channel: false,
|
||||
telemetry_interval_hours: 8,
|
||||
telemetry_routed_hourly: false,
|
||||
};
|
||||
|
||||
function renderModal(overrides?: {
|
||||
@@ -89,6 +91,8 @@ function renderModal(overrides?: {
|
||||
meshDiscovery?: RadioDiscoveryResponse | null;
|
||||
meshDiscoveryLoadingTarget?: RadioDiscoveryTarget | null;
|
||||
onDiscoverMesh?: (target: RadioDiscoveryTarget) => Promise<void>;
|
||||
contacts?: Contact[];
|
||||
trackedTelemetryRepeaters?: string[];
|
||||
open?: boolean;
|
||||
pageMode?: boolean;
|
||||
externalSidebarNav?: boolean;
|
||||
@@ -127,6 +131,8 @@ function renderModal(overrides?: {
|
||||
onDiscoverMesh,
|
||||
onHealthRefresh: vi.fn(async () => {}),
|
||||
onRefreshAppSettings,
|
||||
contacts: overrides?.contacts,
|
||||
trackedTelemetryRepeaters: overrides?.trackedTelemetryRepeaters,
|
||||
};
|
||||
|
||||
const view = overrides?.externalSidebarNav
|
||||
@@ -794,4 +800,68 @@ describe('SettingsModal', () => {
|
||||
expect(screen.getByText('Network')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders routed hourly checkbox and calls save on toggle', async () => {
|
||||
const onSaveAppSettings = vi.fn(async () => {});
|
||||
|
||||
renderModal({
|
||||
externalSidebarNav: true,
|
||||
desktopSection: 'database',
|
||||
onSaveAppSettings,
|
||||
});
|
||||
|
||||
const checkbox = screen.getByRole('checkbox', {
|
||||
name: /Poll direct\/routed-path repeaters hourly/i,
|
||||
}) as HTMLInputElement;
|
||||
|
||||
expect(checkbox).toBeInTheDocument();
|
||||
expect(checkbox.checked).toBe(false);
|
||||
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSaveAppSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ telemetry_routed_hourly: true })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows route badge per tracked repeater', async () => {
|
||||
const directKey = 'bb'.repeat(32);
|
||||
|
||||
renderModal({
|
||||
externalSidebarNav: true,
|
||||
desktopSection: 'database',
|
||||
appSettings: {
|
||||
...baseSettings,
|
||||
tracked_telemetry_repeaters: [directKey],
|
||||
},
|
||||
trackedTelemetryRepeaters: [directKey],
|
||||
contacts: [
|
||||
{
|
||||
public_key: directKey,
|
||||
name: 'DirectRepeater',
|
||||
type: 2,
|
||||
flags: 0,
|
||||
direct_path: 'aabb',
|
||||
direct_path_len: 1,
|
||||
direct_path_hash_mode: 1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
effective_route: { path: 'aabb', path_len: 1, path_hash_mode: 1 },
|
||||
effective_route_source: 'direct',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(screen.getByText('DirectRepeater')).toBeInTheDocument();
|
||||
expect(screen.getByText('direct')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -359,6 +359,7 @@ export interface AppSettings {
|
||||
tracked_telemetry_repeaters: string[];
|
||||
auto_resend_channel: boolean;
|
||||
telemetry_interval_hours: number;
|
||||
telemetry_routed_hourly: boolean;
|
||||
}
|
||||
|
||||
export interface AppSettingsUpdate {
|
||||
@@ -371,6 +372,7 @@ export interface AppSettingsUpdate {
|
||||
blocked_names?: string[];
|
||||
discovery_blocked_types?: number[];
|
||||
telemetry_interval_hours?: number;
|
||||
telemetry_routed_hourly?: boolean;
|
||||
}
|
||||
|
||||
export interface TelemetrySchedule {
|
||||
@@ -380,6 +382,8 @@ export interface TelemetrySchedule {
|
||||
tracked_count: number;
|
||||
max_tracked: number;
|
||||
next_run_at: number | null;
|
||||
routed_hourly: boolean;
|
||||
next_routed_run_at: number | null;
|
||||
}
|
||||
|
||||
export interface TrackedTelemetryResponse {
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
# run ``run_migrations`` to completion assert ``get_version == LATEST`` and
|
||||
# ``applied == LATEST - starting_version`` so only this constant needs to
|
||||
# change, not every individual assertion.
|
||||
LATEST_SCHEMA_VERSION = 60
|
||||
LATEST_SCHEMA_VERSION = 61
|
||||
|
||||
@@ -2219,6 +2219,262 @@ class TestCollectRepeaterTelemetryLpp:
|
||||
assert "lpp_sensors" not in recorded_data
|
||||
|
||||
|
||||
class TestRunTelemetryCycleRoutedOnly:
|
||||
"""Verify that _run_telemetry_cycle(routed_only=True) skips flood repeaters."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_routed_only_skips_flood_contacts(self):
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from app.models import AppSettings, Contact
|
||||
from app.radio_sync import _run_telemetry_cycle
|
||||
|
||||
flood_key = "aa" * 32
|
||||
direct_key = "bb" * 32
|
||||
override_key = "cc" * 32
|
||||
|
||||
flood_contact = Contact(
|
||||
public_key=flood_key,
|
||||
name="Flood",
|
||||
type=2,
|
||||
direct_path=None,
|
||||
direct_path_len=-1,
|
||||
direct_path_hash_mode=-1,
|
||||
)
|
||||
direct_contact = Contact(
|
||||
public_key=direct_key,
|
||||
name="Direct",
|
||||
type=2,
|
||||
direct_path="aabb",
|
||||
direct_path_len=1,
|
||||
direct_path_hash_mode=1,
|
||||
)
|
||||
override_contact = Contact(
|
||||
public_key=override_key,
|
||||
name="Override",
|
||||
type=2,
|
||||
direct_path=None,
|
||||
direct_path_len=-1,
|
||||
direct_path_hash_mode=-1,
|
||||
route_override_path="ccdd",
|
||||
route_override_len=1,
|
||||
route_override_hash_mode=1,
|
||||
)
|
||||
|
||||
settings = AppSettings(
|
||||
tracked_telemetry_repeaters=[flood_key, direct_key, override_key],
|
||||
)
|
||||
|
||||
contact_map = {
|
||||
flood_key: flood_contact,
|
||||
direct_key: direct_contact,
|
||||
override_key: override_contact,
|
||||
}
|
||||
collected_keys: list[str] = []
|
||||
|
||||
async def fake_get_by_key(key):
|
||||
return contact_map.get(key)
|
||||
|
||||
async def fake_collect(mc, contact):
|
||||
collected_keys.append(contact.public_key)
|
||||
return True
|
||||
|
||||
fake_radio_manager = MagicMock()
|
||||
fake_radio_manager.is_connected = True
|
||||
fake_radio_manager.radio_operation = MagicMock()
|
||||
|
||||
# Make radio_operation an async context manager that yields a MagicMock
|
||||
fake_mc = MagicMock()
|
||||
|
||||
class FakeRadioOp:
|
||||
async def __aenter__(self):
|
||||
return fake_mc
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
pass
|
||||
|
||||
fake_radio_manager.radio_operation.return_value = FakeRadioOp()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.radio_sync.AppSettingsRepository.get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=settings,
|
||||
),
|
||||
patch(
|
||||
"app.radio_sync.ContactRepository.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=fake_get_by_key,
|
||||
),
|
||||
patch("app.radio_sync._collect_repeater_telemetry", new=fake_collect),
|
||||
patch("app.radio_sync.radio_manager", fake_radio_manager),
|
||||
):
|
||||
await _run_telemetry_cycle(routed_only=True)
|
||||
|
||||
# Flood contact should be skipped; direct and override should be collected
|
||||
assert flood_key not in collected_keys
|
||||
assert direct_key in collected_keys
|
||||
assert override_key in collected_keys
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_routed_only_skips_forced_flood_override(self):
|
||||
"""A contact with a forced-flood override (path_len=-1) should be
|
||||
treated as flood even though effective_route_source is 'override'."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from app.models import AppSettings, Contact
|
||||
from app.radio_sync import _run_telemetry_cycle
|
||||
|
||||
forced_flood_key = "aa" * 32
|
||||
direct_key = "bb" * 32
|
||||
|
||||
forced_flood_contact = Contact(
|
||||
public_key=forced_flood_key,
|
||||
name="ForcedFlood",
|
||||
type=2,
|
||||
direct_path=None,
|
||||
direct_path_len=-1,
|
||||
direct_path_hash_mode=-1,
|
||||
route_override_path="",
|
||||
route_override_len=-1,
|
||||
route_override_hash_mode=-1,
|
||||
)
|
||||
direct_contact = Contact(
|
||||
public_key=direct_key,
|
||||
name="Direct",
|
||||
type=2,
|
||||
direct_path="aabb",
|
||||
direct_path_len=1,
|
||||
direct_path_hash_mode=1,
|
||||
)
|
||||
|
||||
# Verify the forced-flood contact reports "override" source
|
||||
assert forced_flood_contact.effective_route_source == "override"
|
||||
|
||||
settings = AppSettings(
|
||||
tracked_telemetry_repeaters=[forced_flood_key, direct_key],
|
||||
)
|
||||
|
||||
contact_map = {forced_flood_key: forced_flood_contact, direct_key: direct_contact}
|
||||
collected_keys: list[str] = []
|
||||
|
||||
async def fake_get_by_key(key):
|
||||
return contact_map.get(key)
|
||||
|
||||
async def fake_collect(mc, contact):
|
||||
collected_keys.append(contact.public_key)
|
||||
return True
|
||||
|
||||
fake_radio_manager = MagicMock()
|
||||
fake_radio_manager.is_connected = True
|
||||
|
||||
fake_mc = MagicMock()
|
||||
|
||||
class FakeRadioOp:
|
||||
async def __aenter__(self):
|
||||
return fake_mc
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
pass
|
||||
|
||||
fake_radio_manager.radio_operation.return_value = FakeRadioOp()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.radio_sync.AppSettingsRepository.get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=settings,
|
||||
),
|
||||
patch(
|
||||
"app.radio_sync.ContactRepository.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=fake_get_by_key,
|
||||
),
|
||||
patch("app.radio_sync._collect_repeater_telemetry", new=fake_collect),
|
||||
patch("app.radio_sync.radio_manager", fake_radio_manager),
|
||||
):
|
||||
await _run_telemetry_cycle(routed_only=True)
|
||||
|
||||
# Forced-flood override should be excluded; direct should be collected
|
||||
assert forced_flood_key not in collected_keys
|
||||
assert direct_key in collected_keys
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_cycle_includes_all_contacts(self):
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from app.models import AppSettings, Contact
|
||||
from app.radio_sync import _run_telemetry_cycle
|
||||
|
||||
flood_key = "aa" * 32
|
||||
direct_key = "bb" * 32
|
||||
|
||||
flood_contact = Contact(
|
||||
public_key=flood_key,
|
||||
name="Flood",
|
||||
type=2,
|
||||
direct_path=None,
|
||||
direct_path_len=-1,
|
||||
direct_path_hash_mode=-1,
|
||||
)
|
||||
direct_contact = Contact(
|
||||
public_key=direct_key,
|
||||
name="Direct",
|
||||
type=2,
|
||||
direct_path="aabb",
|
||||
direct_path_len=1,
|
||||
direct_path_hash_mode=1,
|
||||
)
|
||||
|
||||
settings = AppSettings(
|
||||
tracked_telemetry_repeaters=[flood_key, direct_key],
|
||||
)
|
||||
|
||||
contact_map = {flood_key: flood_contact, direct_key: direct_contact}
|
||||
collected_keys: list[str] = []
|
||||
|
||||
async def fake_get_by_key(key):
|
||||
return contact_map.get(key)
|
||||
|
||||
async def fake_collect(mc, contact):
|
||||
collected_keys.append(contact.public_key)
|
||||
return True
|
||||
|
||||
fake_radio_manager = MagicMock()
|
||||
fake_radio_manager.is_connected = True
|
||||
|
||||
fake_mc = MagicMock()
|
||||
|
||||
class FakeRadioOp:
|
||||
async def __aenter__(self):
|
||||
return fake_mc
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
pass
|
||||
|
||||
fake_radio_manager.radio_operation.return_value = FakeRadioOp()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.radio_sync.AppSettingsRepository.get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=settings,
|
||||
),
|
||||
patch(
|
||||
"app.radio_sync.ContactRepository.get_by_key",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=fake_get_by_key,
|
||||
),
|
||||
patch("app.radio_sync._collect_repeater_telemetry", new=fake_collect),
|
||||
patch("app.radio_sync.radio_manager", fake_radio_manager),
|
||||
):
|
||||
await _run_telemetry_cycle(routed_only=False)
|
||||
|
||||
# Full cycle collects both
|
||||
assert flood_key in collected_keys
|
||||
assert direct_key in collected_keys
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _telemetry_collect_loop — UTC modulo scheduler
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2518,6 +2774,113 @@ class TestTelemetryCollectSchedulerDecision:
|
||||
)
|
||||
|
||||
|
||||
class TestRoutedHourlySchedulerDecision:
|
||||
"""Verify the routed_hourly feature in _maybe_run_scheduled_cycle."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_routed_hourly_fires_on_non_modulo_hour(self):
|
||||
"""At 09:00 UTC with 8h interval and routed_hourly=True, the scheduler
|
||||
should call _run_telemetry_cycle(routed_only=True)."""
|
||||
import datetime as real_datetime
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app import radio_sync
|
||||
from app.models import AppSettings
|
||||
|
||||
settings = AppSettings(
|
||||
tracked_telemetry_repeaters=["aa" * 32],
|
||||
telemetry_interval_hours=8,
|
||||
telemetry_routed_hourly=True,
|
||||
)
|
||||
calls = []
|
||||
|
||||
async def fake_cycle(*, routed_only=False):
|
||||
calls.append({"routed_only": routed_only})
|
||||
|
||||
now = real_datetime.datetime(2026, 4, 16, 9, 0, 0, tzinfo=real_datetime.UTC)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.radio_sync.AppSettingsRepository.get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=settings,
|
||||
),
|
||||
patch("app.radio_sync._run_telemetry_cycle", new=fake_cycle),
|
||||
):
|
||||
await radio_sync._maybe_run_scheduled_cycle(now)
|
||||
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["routed_only"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_routed_hourly_disabled_skips_non_modulo_hour(self):
|
||||
"""At 09:00 UTC with 8h interval and routed_hourly=False, nothing runs."""
|
||||
import datetime as real_datetime
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app import radio_sync
|
||||
from app.models import AppSettings
|
||||
|
||||
settings = AppSettings(
|
||||
tracked_telemetry_repeaters=["aa" * 32],
|
||||
telemetry_interval_hours=8,
|
||||
telemetry_routed_hourly=False,
|
||||
)
|
||||
calls = []
|
||||
|
||||
async def fake_cycle(*, routed_only=False):
|
||||
calls.append({"routed_only": routed_only})
|
||||
|
||||
now = real_datetime.datetime(2026, 4, 16, 9, 0, 0, tzinfo=real_datetime.UTC)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.radio_sync.AppSettingsRepository.get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=settings,
|
||||
),
|
||||
patch("app.radio_sync._run_telemetry_cycle", new=fake_cycle),
|
||||
):
|
||||
await radio_sync._maybe_run_scheduled_cycle(now)
|
||||
|
||||
assert len(calls) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modulo_hour_runs_full_cycle_even_with_routed_hourly(self):
|
||||
"""At 16:00 UTC with 8h interval, a normal full cycle runs regardless
|
||||
of whether routed_hourly is enabled — it covers all repeaters."""
|
||||
import datetime as real_datetime
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app import radio_sync
|
||||
from app.models import AppSettings
|
||||
|
||||
settings = AppSettings(
|
||||
tracked_telemetry_repeaters=["aa" * 32],
|
||||
telemetry_interval_hours=8,
|
||||
telemetry_routed_hourly=True,
|
||||
)
|
||||
calls = []
|
||||
|
||||
async def fake_cycle(*, routed_only=False):
|
||||
calls.append({"routed_only": routed_only})
|
||||
|
||||
now = real_datetime.datetime(2026, 4, 16, 16, 0, 0, tzinfo=real_datetime.UTC)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.radio_sync.AppSettingsRepository.get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=settings,
|
||||
),
|
||||
patch("app.radio_sync._run_telemetry_cycle", new=fake_cycle),
|
||||
):
|
||||
await radio_sync._maybe_run_scheduled_cycle(now)
|
||||
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["routed_only"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_contacts_selected_for_radio_sync — DM-active prioritization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -330,3 +330,66 @@ class TestTelemetryScheduleEndpoint:
|
||||
assert schedule.tracked_count == 5
|
||||
assert schedule.options == [6, 8, 12, 24]
|
||||
assert schedule.next_run_at is not None
|
||||
|
||||
|
||||
class TestRoutedHourlySetting:
|
||||
"""Tests for the telemetry_routed_hourly setting."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_defaults_to_false(self, test_db):
|
||||
settings = await AppSettingsRepository.get()
|
||||
assert settings.telemetry_routed_hourly is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_round_trip_via_patch(self, test_db):
|
||||
result = await update_settings(AppSettingsUpdate(telemetry_routed_hourly=True))
|
||||
assert result.telemetry_routed_hourly is True
|
||||
|
||||
result = await update_settings(AppSettingsUpdate(telemetry_routed_hourly=False))
|
||||
assert result.telemetry_routed_hourly is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schedule_includes_routed_fields_when_enabled(self, test_db):
|
||||
key = "aa" * 32
|
||||
await ContactRepository.upsert(
|
||||
ContactUpsert(public_key=key, name="R1", type=CONTACT_TYPE_REPEATER)
|
||||
)
|
||||
await AppSettingsRepository.update(
|
||||
tracked_telemetry_repeaters=[key],
|
||||
telemetry_routed_hourly=True,
|
||||
)
|
||||
|
||||
schedule = await get_telemetry_schedule()
|
||||
|
||||
assert schedule.routed_hourly is True
|
||||
assert schedule.next_routed_run_at is not None
|
||||
assert schedule.next_run_at is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schedule_omits_routed_run_when_disabled(self, test_db):
|
||||
key = "aa" * 32
|
||||
await ContactRepository.upsert(
|
||||
ContactUpsert(public_key=key, name="R1", type=CONTACT_TYPE_REPEATER)
|
||||
)
|
||||
await AppSettingsRepository.update(
|
||||
tracked_telemetry_repeaters=[key],
|
||||
telemetry_routed_hourly=False,
|
||||
)
|
||||
|
||||
schedule = await get_telemetry_schedule()
|
||||
|
||||
assert schedule.routed_hourly is False
|
||||
assert schedule.next_routed_run_at is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggle_response_carries_routed_hourly(self, test_db):
|
||||
key = "bb" * 32
|
||||
await ContactRepository.upsert(
|
||||
ContactUpsert(public_key=key, name="R2", type=CONTACT_TYPE_REPEATER)
|
||||
)
|
||||
await AppSettingsRepository.update(telemetry_routed_hourly=True)
|
||||
|
||||
result = await toggle_tracked_telemetry(TrackedTelemetryRequest(public_key=key))
|
||||
|
||||
assert result.schedule.routed_hourly is True
|
||||
assert result.schedule.next_routed_run_at is not None
|
||||
|
||||
Reference in New Issue
Block a user