mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 17:23:05 +02:00
Offer multiple timing windows for repeater telemetry pickup. Closes #192.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user