Offer multiple timing windows for repeater telemetry pickup. Closes #192.

This commit is contained in:
Jack Kingsman
2026-04-16 13:55:01 -07:00
parent 8efbbd97bd
commit 4b69ec4519
15 changed files with 1046 additions and 135 deletions
+1 -1
View File
@@ -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 = 56
LATEST_SCHEMA_VERSION = 57
+299
View File
@@ -1880,6 +1880,305 @@ class TestCollectRepeaterTelemetryLpp:
assert "lpp_sensors" not in recorded_data
# ---------------------------------------------------------------------------
# _telemetry_collect_loop — UTC modulo scheduler
# ---------------------------------------------------------------------------
class TestTelemetryCollectSchedulerDecision:
"""Verify the scheduler's run/skip decision at an hourly wake.
We test the decision logic by stubbing the sleep + datetime functions
and asserting ``_run_telemetry_cycle`` is called exactly on matching
hours. Full end-to-end of the loop is covered implicitly by the
existing telemetry-collect tests; what we're pinning here is the
hour-modulo gate the new scheduler depends on.
"""
@pytest.mark.asyncio
async def test_skips_when_hour_modulo_mismatch(self):
"""At 09:00 UTC with interval 8h, the loop must NOT run a cycle."""
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,
)
ran = False
async def fake_cycle():
nonlocal ran
ran = True
def make_fake_datetime(hour: int):
class FakeDatetime:
@classmethod
def now(cls, tz=None):
import datetime as real_datetime
return real_datetime.datetime(2026, 4, 16, hour, 0, 0, tzinfo=real_datetime.UTC)
return FakeDatetime
sleep_count = 0
async def fake_sleep(_duration):
# The loop does: (1) initial-delay sleep, (2) sleep-to-top-of-hour,
# then evaluates the run/skip decision. Allow both sleeps to
# pass, then cancel on the 3rd (next iteration's top-of-hour sleep).
nonlocal sleep_count
sleep_count += 1
if sleep_count >= 3:
raise asyncio.CancelledError()
with (
patch(
"app.radio_sync.AppSettingsRepository.get",
new_callable=AsyncMock,
return_value=settings,
),
patch("app.radio_sync._run_telemetry_cycle", new=fake_cycle),
patch("app.radio_sync.asyncio.sleep", new=fake_sleep),
patch("app.radio_sync.datetime", new=make_fake_datetime(9)),
):
try:
await radio_sync._telemetry_collect_loop()
except asyncio.CancelledError:
pass
assert ran is False, "09:00 UTC is not a multiple of 8h; cycle must not run"
@pytest.mark.asyncio
async def test_runs_when_hour_modulo_matches(self):
"""At 16:00 UTC with interval 8h, the loop must run a cycle."""
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,
)
ran = False
async def fake_cycle():
nonlocal ran
ran = True
class FakeDatetime:
@classmethod
def now(cls, tz=None):
import datetime as real_datetime
return real_datetime.datetime(2026, 4, 16, 16, 0, 0, tzinfo=real_datetime.UTC)
sleep_count = 0
async def fake_sleep(_duration):
# Let the loop's initial-delay + top-of-hour sleeps pass; cancel
# on the third sleep (next iteration's top-of-hour wake).
nonlocal sleep_count
sleep_count += 1
if sleep_count >= 3:
raise asyncio.CancelledError()
with (
patch(
"app.radio_sync.AppSettingsRepository.get",
new_callable=AsyncMock,
return_value=settings,
),
patch("app.radio_sync._run_telemetry_cycle", new=fake_cycle),
patch("app.radio_sync.asyncio.sleep", new=fake_sleep),
patch("app.radio_sync.datetime", new=FakeDatetime),
):
try:
await radio_sync._telemetry_collect_loop()
except asyncio.CancelledError:
pass
assert ran is True, "16:00 UTC is a multiple of 8h; cycle must run"
@pytest.mark.asyncio
async def test_skips_when_no_repeaters_tracked(self):
"""Empty tracked list short-circuits regardless of modulo match."""
from unittest.mock import AsyncMock, patch
from app import radio_sync
from app.models import AppSettings
settings = AppSettings(tracked_telemetry_repeaters=[], telemetry_interval_hours=8)
ran = False
async def fake_cycle():
nonlocal ran
ran = True
class FakeDatetime:
@classmethod
def now(cls, tz=None):
import datetime as real_datetime
return real_datetime.datetime(2026, 4, 16, 16, 0, 0, tzinfo=real_datetime.UTC)
sleep_count = 0
async def fake_sleep(_duration):
# Let the loop's initial-delay + top-of-hour sleeps pass; cancel
# on the third sleep (next iteration's top-of-hour wake).
nonlocal sleep_count
sleep_count += 1
if sleep_count >= 3:
raise asyncio.CancelledError()
with (
patch(
"app.radio_sync.AppSettingsRepository.get",
new_callable=AsyncMock,
return_value=settings,
),
patch("app.radio_sync._run_telemetry_cycle", new=fake_cycle),
patch("app.radio_sync.asyncio.sleep", new=fake_sleep),
patch("app.radio_sync.datetime", new=FakeDatetime),
):
try:
await radio_sync._telemetry_collect_loop()
except asyncio.CancelledError:
pass
assert ran is False, "No tracked repeaters: no cycle regardless of hour"
@pytest.mark.asyncio
async def test_runs_on_boundary_immediately_after_initial_delay(self):
"""Regression test: if the post-boot initial delay finishes inside a
matching hour, the cycle must run even if the first
sleep-to-next-top-of-hour would otherwise carry us past the boundary.
Scenario: server starts at 23:59:30 UTC with a 24-hour interval. The
60-second boot guard pushes the first check into 00:00:30 — a matching
hour that we must NOT skip. Before the fix, the loop went straight to
sleeping until 01:00 and then failing the modulo, missing the entire
day's only scheduled collection.
"""
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=24, # daily cadence; only matching hour is 00
)
ran = False
async def fake_cycle():
nonlocal ran
ran = True
class FakeDatetime:
@classmethod
def now(cls, tz=None):
import datetime as real_datetime
# Simulates "initial delay just ended at 00:00:30 UTC on a
# restart that began at 23:59:30." Without the post-boot
# boundary check, the loop would have skipped this.
return real_datetime.datetime(2026, 4, 16, 0, 0, 30, tzinfo=real_datetime.UTC)
sleep_count = 0
async def fake_sleep(_duration):
# Let the initial delay pass, then cancel before the first
# top-of-hour sleep so we isolate the post-boot check as the
# only opportunity to run.
nonlocal sleep_count
sleep_count += 1
if sleep_count >= 2:
raise asyncio.CancelledError()
with (
patch(
"app.radio_sync.AppSettingsRepository.get",
new_callable=AsyncMock,
return_value=settings,
),
patch("app.radio_sync._run_telemetry_cycle", new=fake_cycle),
patch("app.radio_sync.asyncio.sleep", new=fake_sleep),
patch("app.radio_sync.datetime", new=FakeDatetime),
):
try:
await radio_sync._telemetry_collect_loop()
except asyncio.CancelledError:
pass
assert ran is True, (
"Post-boot check must fire the due 00:00 cycle; otherwise a "
"restart near midnight suppresses the whole day's collection."
)
@pytest.mark.asyncio
async def test_clamps_up_when_preferred_illegal_for_current_count(self):
"""5 tracked repeaters with saved pref 1h: scheduler should use 6h.
At 02:00 UTC: 2 % 6 == 2 (not a run), so cycle must not fire.
If clamping were skipped, 2 % 1 == 0 and cycle would incorrectly run.
"""
from unittest.mock import AsyncMock, patch
from app import radio_sync
from app.models import AppSettings
settings = AppSettings(
tracked_telemetry_repeaters=["aa" * 32] * 5,
telemetry_interval_hours=1, # illegal at N=5; shortest legal is 6h
)
ran = False
async def fake_cycle():
nonlocal ran
ran = True
class FakeDatetime:
@classmethod
def now(cls, tz=None):
import datetime as real_datetime
return real_datetime.datetime(2026, 4, 16, 2, 0, 0, tzinfo=real_datetime.UTC)
sleep_count = 0
async def fake_sleep(_duration):
# Let the loop's initial-delay + top-of-hour sleeps pass; cancel
# on the third sleep (next iteration's top-of-hour wake).
nonlocal sleep_count
sleep_count += 1
if sleep_count >= 3:
raise asyncio.CancelledError()
with (
patch(
"app.radio_sync.AppSettingsRepository.get",
new_callable=AsyncMock,
return_value=settings,
),
patch("app.radio_sync._run_telemetry_cycle", new=fake_cycle),
patch("app.radio_sync.asyncio.sleep", new=fake_sleep),
patch("app.radio_sync.datetime", new=FakeDatetime),
):
try:
await radio_sync._telemetry_collect_loop()
except asyncio.CancelledError:
pass
assert ran is False, (
"Clamping to 6h must prevent the 02:00 run that 1h cadence would've triggered"
)
# ---------------------------------------------------------------------------
# get_contacts_selected_for_radio_sync — DM-active prioritization
# ---------------------------------------------------------------------------
+86
View File
@@ -11,6 +11,7 @@ from app.routers.settings import (
AppSettingsUpdate,
FavoriteRequest,
TrackedTelemetryRequest,
get_telemetry_schedule,
toggle_favorite,
toggle_tracked_telemetry,
update_settings,
@@ -244,3 +245,88 @@ class TestToggleTrackedTelemetry:
result = await toggle_tracked_telemetry(TrackedTelemetryRequest(public_key=keys[0]))
assert keys[0] not in result.tracked_telemetry_repeaters
assert len(result.tracked_telemetry_repeaters) == 7
@pytest.mark.asyncio
async def test_toggle_response_includes_schedule(self, test_db):
"""After toggle, response must carry the schedule derivation so the UI
can update the interval dropdown without a follow-up fetch."""
key = "aa" * 32
await self._create_repeater(key)
result = await toggle_tracked_telemetry(TrackedTelemetryRequest(public_key=key))
assert result.schedule.tracked_count == 1
# N=1 unlocks the full menu including 1h
assert 1 in result.schedule.options
assert result.schedule.max_tracked == 8
class TestTelemetryIntervalValidation:
"""PATCH /settings validation for telemetry_interval_hours."""
@pytest.mark.asyncio
async def test_accepts_valid_interval(self, test_db):
result = await update_settings(AppSettingsUpdate(telemetry_interval_hours=4))
assert result.telemetry_interval_hours == 4
@pytest.mark.asyncio
async def test_invalid_interval_falls_back_to_default(self, test_db):
"""Non-menu values are defaulted rather than 400-ing to keep stale
clients from getting stuck on a save error."""
result = await update_settings(AppSettingsUpdate(telemetry_interval_hours=99))
assert result.telemetry_interval_hours == 8 # DEFAULT_TELEMETRY_INTERVAL_HOURS
@pytest.mark.asyncio
async def test_preference_is_preserved_even_when_illegal_for_count(self, test_db):
"""User picks 1h at N=5 tracked: stored pref must stay 1h. Scheduler
handles the clamping at run time; storage is verbatim."""
# Seed 5 tracked repeaters
keys = [f"{i:02x}" * 32 for i in range(5)]
for k in keys:
await ContactRepository.upsert(
ContactUpsert(public_key=k, name=f"R{k[:4]}", type=CONTACT_TYPE_REPEATER)
)
await AppSettingsRepository.update(tracked_telemetry_repeaters=keys)
result = await update_settings(AppSettingsUpdate(telemetry_interval_hours=1))
assert result.telemetry_interval_hours == 1
# But the GET schedule endpoint should report the clamped effective value.
schedule = await get_telemetry_schedule()
assert schedule.preferred_hours == 1
assert schedule.effective_hours == 6 # N=5 -> shortest legal = 6h
class TestTelemetryScheduleEndpoint:
"""GET /settings/tracked-telemetry/schedule."""
@pytest.mark.asyncio
async def test_schedule_with_no_tracked_repeaters(self, test_db):
"""No tracked repeaters means nothing to schedule; next_run_at is None.
At N=0 the clamp helper returns the default 8h, which is a fine
display value for an empty state. Options start at 8h for the same
reason any lower shortest-legal only makes sense once the user
has at least one repeater tracked.
"""
schedule = await get_telemetry_schedule()
assert schedule.tracked_count == 0
assert schedule.next_run_at is None
# At N=0 shortest-legal defaults to 8h.
assert schedule.options == [8, 12, 24]
@pytest.mark.asyncio
async def test_schedule_filters_options_by_tracked_count(self, test_db):
keys = [f"{i:02x}" * 32 for i in range(5)]
for k in keys:
await ContactRepository.upsert(
ContactUpsert(public_key=k, name=f"R{k[:4]}", type=CONTACT_TYPE_REPEATER)
)
await AppSettingsRepository.update(tracked_telemetry_repeaters=keys)
schedule = await get_telemetry_schedule()
assert schedule.tracked_count == 5
assert schedule.options == [6, 8, 12, 24]
assert schedule.next_run_at is not None
+116
View File
@@ -0,0 +1,116 @@
"""Tests for the telemetry interval math helpers.
These helpers back both the PATCH validation and the scheduler clamping,
so regressions here silently corrupt cadence for every operator. Keep this
suite fast, pure, and focused on the boundary values in the N=1..8 table.
"""
from datetime import UTC, datetime, timezone
import pytest
from app.telemetry_interval import (
DAILY_CHECK_CEILING,
DEFAULT_TELEMETRY_INTERVAL_HOURS,
TELEMETRY_INTERVAL_OPTIONS_HOURS,
clamp_telemetry_interval,
legal_interval_options,
next_run_timestamp_utc,
shortest_legal_interval_hours,
)
@pytest.mark.parametrize(
("n", "expected_hours"),
[
(1, 1),
(2, 2),
(3, 3),
(4, 4),
(5, 6),
(6, 6),
(7, 8),
(8, 8),
],
)
def test_shortest_legal_interval_table(n: int, expected_hours: int):
"""The N=1..8 table must match the user-facing design exactly."""
assert shortest_legal_interval_hours(n) == expected_hours
def test_shortest_legal_interval_above_ceiling_falls_back_to_24h():
# Not reachable today (max 8 tracked), but verify the math terminates
# gracefully if the limit is ever raised above DAILY_CHECK_CEILING.
assert shortest_legal_interval_hours(DAILY_CHECK_CEILING + 1) == 24
def test_shortest_legal_interval_zero_returns_default():
# No repeaters tracked: loop skips the cycle regardless, but the math
# must terminate with a sane value (otherwise div-by-zero).
assert shortest_legal_interval_hours(0) == DEFAULT_TELEMETRY_INTERVAL_HOURS
def test_clamp_respects_user_pref_when_legal():
# User picks 2h with N=2 tracked -> 2h is the shortest legal, keep it.
assert clamp_telemetry_interval(2, 2) == 2
def test_clamp_pushes_up_when_pref_illegal():
# User picked 1h, then grew to 5 tracked. 5 repeaters' shortest legal is
# 6h, so the scheduler should be using 6h while the saved pref is still 1.
assert clamp_telemetry_interval(1, 5) == 6
def test_clamp_unrecognized_value_falls_back_to_default():
# A malformed saved value (e.g. from a hand-edited DB row) should default,
# not error. Default 8h still gets clamped up if illegal for N.
assert clamp_telemetry_interval(99, 1) == DEFAULT_TELEMETRY_INTERVAL_HOURS
def test_clamp_preserves_longer_than_shortest_legal():
# 24h is always legal at any N.
assert clamp_telemetry_interval(24, 8) == 24
def test_legal_options_filters_menu():
assert legal_interval_options(5) == [6, 8, 12, 24]
assert legal_interval_options(1) == list(TELEMETRY_INTERVAL_OPTIONS_HOURS)
assert legal_interval_options(8) == [8, 12, 24]
def test_next_run_is_strictly_future_even_on_boundary():
# Exactly at a matching top-of-hour (8:00 UTC with interval=8), we want
# the *next* one (16:00), never "now". Prevents a double-run in the same
# minute if code mishandles equality.
now = datetime(2026, 4, 16, 8, 0, 0, tzinfo=UTC)
result = next_run_timestamp_utc(8, now=now)
expected = datetime(2026, 4, 16, 16, 0, 0, tzinfo=UTC)
assert result == int(expected.timestamp())
def test_next_run_rounds_up_from_mid_hour():
# 14:37 UTC with interval=8 -> next matching hour is 16:00.
now = datetime(2026, 4, 16, 14, 37, 0, tzinfo=UTC)
result = next_run_timestamp_utc(8, now=now)
expected = datetime(2026, 4, 16, 16, 0, 0, tzinfo=UTC)
assert result == int(expected.timestamp())
def test_next_run_crosses_midnight():
# 23:12 UTC with interval=8 -> midnight (00:00 next day) is legal.
now = datetime(2026, 4, 16, 23, 12, 0, tzinfo=UTC)
result = next_run_timestamp_utc(8, now=now)
expected = datetime(2026, 4, 17, 0, 0, 0, tzinfo=UTC)
assert result == int(expected.timestamp())
def test_next_run_accepts_non_utc_input():
# Non-UTC input should be normalized internally.
from datetime import timedelta
pst = timezone(timedelta(hours=-8))
# 08:00 PST == 16:00 UTC, a matching boundary for interval=8 -> next is 00:00 UTC.
now = datetime(2026, 4, 16, 8, 0, 0, tzinfo=pst)
result = next_run_timestamp_utc(8, now=now)
expected = datetime(2026, 4, 17, 0, 0, 0, tzinfo=UTC)
assert result == int(expected.timestamp())