Files
potato-mesh/tests/test_snapshot_clock_unit.py
T
Afri ddae287ee6 data: keep snapshot node ages when the radio clock is off (#1007)
* data: keep snapshot node ages when the radio clock is off

* data: add the snapshot clock unit tests left out of the first commit
2026-10-08 15:55:12 +02:00

887 lines
31 KiB
Python

# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Regression guard: the Meshtastic node-list snapshot posts ``lastHeard``
inside the web app's read windows when the radio's clock is wrong (SPEC RK4).
The snapshot posts each nodeDB entry's ``lastHeard``, stamped by the radio's
clock. The field host's radio (meshtasticd, no RTC) ran 97 days slow, so a
node it heard 10 minutes ago was posted 97 days old: outside the 7-day
``GET /api/nodes`` window and the 28-day per-id window, and stored as the
node's ``first_heard`` for good. The radio stamps its own entry with its
clock's reading while it sends the nodeDB at connect, so the snapshot reads
the offset there and posts every entry with the age the radio's clock gives
it, counted from the host clock.
The nodeDB is loaded through the pinned meshtastic library's
``_handleFromRadio`` the way a radio sends it, and
``daemon._try_send_snapshot`` runs with the real :class:`MeshtasticProvider`.
The host clock is frozen; only the HTTP queue is replaced. The cases after
the regression guard pin the rule: the 1-hour tolerance in both directions,
the skip of entries from an earlier boot, the snapshot without a clock
reading, another node's entry under the host id, a ``lastHeard`` at or
shifted to 0 or below, the radio's uptime telling an earlier boot's entries
apart, one warning per snapshot, and the library's dicts left as they were.
"""
from __future__ import annotations
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path: # pragma: no cover - conftest adds it
sys.path.insert(0, str(REPO_ROOT))
from meshtastic.protobuf import mesh_pb2 # noqa: E402
import data.mesh_ingestor.config as config # noqa: E402
import data.mesh_ingestor.daemon as daemon # noqa: E402
import data.mesh_ingestor.handlers as handlers # noqa: E402
import data.mesh_ingestor.handlers.receive_time as receive_time # noqa: E402
from daemon_fakes import make_state # noqa: E402 - shared daemon doubles
from data.mesh_ingestor.protocols.meshtastic import MeshtasticProvider # noqa: E402
from data.mesh_ingestor.serialization import _iso # noqa: E402
from test_channel_scope_unit import ( # noqa: E402,F401 - shared fixtures
SECONDARY,
SENDER,
SENDER_ID,
_node_info_frame,
_snapshot,
radio,
scope,
)
DAY = 86_400
"""Seconds in a day."""
HOST_NOW = 1_791_460_800
"""Ingestor host clock for every case: 2026-10-08T12:00:00Z."""
RADIO_OFFSET = -97 * DAY
"""The field radio's clock minus the host clock: 97 days slow (-8380800 s)."""
RADIO_NOW = HOST_NOW + RADIO_OFFSET
"""The field radio's clock reading at connect: 2026-07-03T12:00:00Z."""
TOLERANCE = 60 * 60
"""SPEC RK2's tolerance in seconds, which the snapshot reuses (RK4)."""
HOST_NUM = 0x0D15EA5E
"""Node number of the ingestor's own radio."""
HOST_ID = "!0d15ea5e"
"""Canonical id of :data:`HOST_NUM`, as ``daemon._try_connect`` registers it."""
PREV_BOOT_NUM = 0x0B007B00
"""Node last heard during the radio's previous boot."""
PREV_BOOT_ID = "!0b007b00"
"""Canonical id of :data:`PREV_BOOT_NUM`."""
IMPOSTOR_NUM = 0x0BADBEEF
"""Node whose NodeInfo names :data:`HOST_ID`, so the library files its entry
under the host id (SPEC NI1)."""
AHEAD_NUM = 0x0FA57000
"""Node a fast clock stamped 61 minutes ahead of the host clock."""
AHEAD_ID = "!0fa57000"
"""Canonical id of :data:`AHEAD_NUM`."""
FAST_OFFSET = HOST_NOW
"""A radio clock ahead of the host clock by as long as the host clock is past
1970: shifted by it, an entry stamped at the host clock would post at 0."""
FIELD_HOST_NOW = 1_791_455_465
"""The field host's clock at its snapshot with the uptime read:
2026-10-08T10:31:05Z."""
FIELD_READING = 1_783_067_010
"""The field radio's own ``lastHeard`` at that snapshot, its clock's reading."""
FIELD_OFFSET = FIELD_READING - FIELD_HOST_NOW
"""The field radio's clock minus the host clock: -8388455 s, 97.1 days slow."""
FIELD_UPTIME = 343_965
"""The field radio's own ``deviceMetrics.uptimeSeconds``: up 3.98 days."""
FIELD_BOOT_START = FIELD_READING - FIELD_UPTIME
"""When the field radio's boot started, by its own clock: 1782723045."""
OLD_BOOT_NUM = 0x0B00701D
"""Node last heard during an earlier boot, before the field radio's boot."""
OLD_BOOT_ID = "!0b00701d"
"""Canonical id of :data:`OLD_BOOT_NUM`."""
OLD_BOOT_LAST_HEARD = FIELD_HOST_NOW - 120 * DAY
"""Heard 120 days ago by an earlier boot's right clock: 1781087465, more than
an hour before :data:`FIELD_BOOT_START`."""
REMOTE_LAST_HEARD = RADIO_NOW - 600
""":data:`SENDER` heard 10 minutes ago, stamped by the field radio's clock."""
PREV_BOOT_LAST_HEARD = HOST_NOW - 5 * DAY - 4_294_967
"""Heard 5 days ago during the previous boot, whose clock ran 2^32 ms behind.
That is 42.3 days after the field radio's reading: no single shift fits both
boots, and shifted by this boot's offset it would lie 42 days in the future.
"""
BULK_WINDOW = 7 * DAY
"""Floor of ``GET /api/nodes``: a node last heard earlier is not listed."""
FIELD_WARNING = {
"message": "Radio clock is off; posting snapshot lastHeard shifted to the "
"ingestor clock",
"severity": "warn",
"offset_secs": -8_380_800,
"shifted": 2,
"skipped": 1,
}
"""The one warning the field snapshot logs: own entry and :data:`SENDER`
shifted, the previous-boot entry skipped."""
FIELD_UPTIME_WARNING = {
**FIELD_WARNING,
"offset_secs": -8_388_455,
"unshifted": 1,
}
"""The one warning of the field host's snapshot with the uptime read: own
entry and :data:`SENDER` shifted, the previous-boot entry skipped, and the
entry from before the boot posted as stamped."""
@pytest.fixture
def host_now():
"""Start the shared ``host_clock`` fixture (``tests/conftest.py``) at
:data:`HOST_NOW`, where every case keeps it."""
return HOST_NOW
@pytest.fixture
def snapshot_log(monkeypatch):
"""Capture the daemon's snapshot log lines instead of printing them.
Returns:
A list that collects each ``daemon.snapshot`` line's message,
severity and metadata.
"""
lines: list[dict] = []
def record(message, *, context=None, severity="debug", **metadata):
"""Keep the snapshot lines and drop every other log line."""
if context == "daemon.snapshot":
lines.append({"message": message, "severity": severity, **metadata})
monkeypatch.setattr(config, "_debug_log", record)
return lines
def _load_node_db(
radio,
own_last_heard: int | None,
entries: dict[int, int],
*,
register_host: bool = True,
own_uptime: int = 0,
) -> None:
"""Send ``radio`` its nodeDB the way a radio sends it at connect.
Parameters:
radio: The ``radio`` fixture's namespace.
own_last_heard: ``lastHeard`` of the radio's own entry, its clock's
reading. ``0`` sends the entry without one; ``None`` sends
neither ``my_info`` nor the entry.
entries: ``lastHeard`` of each other entry, by node number.
register_host: Register the host id afterwards, as
``daemon._try_connect`` does right before the snapshot.
own_uptime: ``deviceMetrics.uptimeSeconds`` of the own entry, the
radio's uptime; ``0`` sends no device metrics.
"""
iface = radio.iface
if own_last_heard is not None:
my_info = mesh_pb2.MyNodeInfo(my_node_num=HOST_NUM)
iface._handleFromRadio(mesh_pb2.FromRadio(my_info=my_info).SerializeToString())
iface._handleFromRadio(
_node_info_frame(
HOST_NUM, last_heard=own_last_heard, uptime_seconds=own_uptime
)
)
for num, last_heard in entries.items():
iface._handleFromRadio(_node_info_frame(num, last_heard=last_heard))
if register_host:
handlers.register_host_node_id(HOST_ID)
def _load_field_host(radio, host_clock, entries: dict[int, int]) -> None:
"""Set the field host's clock and send its radio's nodeDB (SPEC RK4).
The own entry reads :data:`FIELD_READING` and carries
:data:`FIELD_UPTIME`.
Parameters:
radio: The ``radio`` fixture's namespace.
host_clock: The shared ``host_clock`` fixture, moved to
:data:`FIELD_HOST_NOW`.
entries: ``lastHeard`` of each other entry, by node number.
"""
host_clock.now = FIELD_HOST_NOW
_load_node_db(radio, FIELD_READING, entries, own_uptime=FIELD_UPTIME)
@pytest.fixture
def field(radio, host_clock):
"""The field host's radio at connect, its clock 97 days slow.
Its nodeDB holds its own entry, stamped with the clock's reading, a node it
heard 10 minutes ago and one heard during the previous boot. The own
entry's stamp is read in meshtastic/firmware master: ``PhoneAPI.cpp``
``STATE_SEND_MY_INFO`` calls ``refreshLocalMeshNode``, which sets its
``last_heard`` to the radio's clock. On the field host it read
:data:`FIELD_READING` against the host clock :data:`FIELD_HOST_NOW`.
"""
_load_node_db(
radio,
RADIO_NOW,
{SENDER: REMOTE_LAST_HEARD, PREV_BOOT_NUM: PREV_BOOT_LAST_HEARD},
)
return radio
def _warnings(lines: list[dict]) -> list[dict]:
"""Return the ``warn`` lines of a :func:`snapshot_log` capture."""
return [line for line in lines if line["severity"] == "warn"]
def _skipped(lines: list[dict]) -> dict[str, str]:
"""Return the reason of each entry a :func:`snapshot_log` capture skipped."""
return {
line["node_id"]: line["reason"] for line in lines if line["severity"] == "debug"
}
def _file_impostor_under_host_id(radio, last_heard: int) -> None:
"""Send :data:`IMPOSTOR_NUM`'s entry, which names the host id (SPEC NI1).
The library files it under :data:`HOST_ID`, replacing the radio's own
entry there; ``getMyNodeInfo`` still finds the own entry by number.
Parameters:
radio: The ``radio`` fixture's namespace.
last_heard: The impostor's ``lastHeard``.
"""
radio.iface._handleFromRadio(
_node_info_frame(IMPOSTOR_NUM, last_heard=last_heard, user_id=HOST_ID)
)
# ---------------------------------------------------------------------------
# Regression guard: the field case
# ---------------------------------------------------------------------------
def test_snapshot_last_heard_lands_inside_bulk_window(field):
"""A node the radio heard 10 minutes ago is posted inside the 7-day
``/api/nodes`` window, 10 minutes old by the host clock."""
posted = _snapshot(field)[SENDER_ID]["lastHeard"]
assert HOST_NOW - BULK_WINDOW <= posted <= HOST_NOW, (
f"snapshot lastHeard {posted} ({_iso(posted)}) is the radio clock, "
f"{(HOST_NOW - posted) / DAY:.0f} days behind the ingestor clock "
f"({_iso(HOST_NOW)}); GET /api/nodes does not list it (7-day bulk and "
"28-day per-id windows)"
)
assert posted == HOST_NOW - 600
def test_own_entry_is_posted_at_the_host_clock(field):
"""The radio's own entry, its clock's reading, is posted as the host clock."""
assert _snapshot(field)[HOST_ID]["lastHeard"] == HOST_NOW
def test_entry_from_an_earlier_boot_is_skipped_as_clock_epoch(field, snapshot_log):
"""An entry stamped more than an hour after the radio's reading was stamped
during an earlier boot: it is skipped with reason ``clock-epoch``."""
upserts = _snapshot(field)
assert set(upserts) == {HOST_ID, SENDER_ID}
skipped = [line for line in snapshot_log if line["severity"] == "debug"]
assert skipped == [
{
"message": "Skipped snapshot node",
"severity": "debug",
"node_id": PREV_BOOT_ID,
"reason": "clock-epoch",
}
]
def test_one_warning_per_snapshot(field, snapshot_log):
"""Each snapshot logs one warning with the offset and both counts; the
counts start again with the next connection's snapshot, which reuses the
provider."""
state = make_state(provider=MeshtasticProvider(), iface=field.iface)
assert daemon._try_send_snapshot(state) is True
assert daemon._try_send_snapshot(state) is True
assert _warnings(snapshot_log) == [FIELD_WARNING, FIELD_WARNING]
def test_library_dicts_keep_the_radio_clock(field):
"""The shift is made on copies: the library's nodeDB keeps the radio's times."""
nodes = field.iface.nodes
library_entry = nodes[SENDER_ID]
upserts = _snapshot(field)
assert upserts[SENDER_ID]["lastHeard"] == HOST_NOW - 600
assert nodes[SENDER_ID] is library_entry
assert library_entry["lastHeard"] == REMOTE_LAST_HEARD
assert nodes[HOST_ID]["lastHeard"] == RADIO_NOW
assert nodes[PREV_BOOT_ID]["lastHeard"] == PREV_BOOT_LAST_HEARD
assert field.iface.getMyNodeInfo() is nodes[HOST_ID]
# ---------------------------------------------------------------------------
# The rule: tolerance, earlier boots, no reading
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("minutes", "shifted"),
[(-61, True), (-60, False), (-59, False), (59, False), (60, False), (61, True)],
)
def test_radio_clock_offset_boundaries(
radio, host_clock, snapshot_log, minutes, shifted
):
"""A radio clock 59 or exactly 60 minutes off the host clock, behind or
ahead, leaves the entries as stamped; 61 minutes off shifts them by the
offset, with one warning."""
reading = HOST_NOW + minutes * 60
_load_node_db(radio, reading, {SENDER: reading - 600})
posted = _snapshot(radio)[SENDER_ID]["lastHeard"]
assert posted == (HOST_NOW - 600 if shifted else reading - 600)
expected = [{**FIELD_WARNING, "offset_secs": minutes * 60, "skipped": 0}]
assert _warnings(snapshot_log) == (expected if shifted else [])
@pytest.mark.parametrize(("minutes", "posted"), [(59, True), (60, True), (61, False)])
def test_entry_ahead_of_the_radio_reading_boundaries(
radio, host_clock, minutes, posted
):
"""An entry stamped up to 60 minutes after the radio's reading is posted,
shifted; one 61 minutes after is skipped."""
stamp = RADIO_NOW + minutes * 60
_load_node_db(radio, RADIO_NOW, {SENDER: stamp})
upserts = _snapshot(radio)
if posted:
assert upserts[SENDER_ID]["lastHeard"] == stamp - RADIO_OFFSET
else:
assert SENDER_ID not in upserts
@pytest.mark.parametrize(("shifted", "posted"), [(1, True), (0, False), (-1, False)])
def test_entry_shifted_to_zero_or_below_is_skipped(
radio, host_clock, snapshot_log, shifted, posted
):
"""An entry the shift would post at 0 or below, 1970 or before by the host
clock, was stamped by another clock than this boot's: it is skipped as
``clock-epoch`` and counted in the warning. One the shift posts at 1 is
posted."""
_load_node_db(radio, HOST_NOW + FAST_OFFSET, {SENDER: FAST_OFFSET + shifted})
upserts = _snapshot(radio)
if posted:
assert upserts[SENDER_ID]["lastHeard"] == shifted
else:
assert _skipped(snapshot_log) == {SENDER_ID: "clock-epoch"}
expected = {
**FIELD_WARNING,
"offset_secs": FAST_OFFSET,
"shifted": 2 if posted else 1,
"skipped": 0 if posted else 1,
}
assert _warnings(snapshot_log) == [expected]
@pytest.mark.parametrize(
"own_last_heard", [0, None], ids=["own entry without lastHeard", "no own entry"]
)
def test_without_a_reading_entries_post_as_stamped(
radio, host_clock, snapshot_log, own_last_heard
):
"""Without a clock reading from the own entry, entries are posted as
stamped, a radio clock 97 days slow included, and nothing is logged."""
_load_node_db(radio, own_last_heard, {SENDER: REMOTE_LAST_HEARD})
upserts = _snapshot(radio)
assert upserts[SENDER_ID]["lastHeard"] == REMOTE_LAST_HEARD
assert snapshot_log == []
@pytest.mark.parametrize(("minutes", "posted"), [(59, True), (60, True), (61, False)])
def test_without_a_reading_an_entry_ahead_of_the_host_clock(
radio, host_clock, snapshot_log, minutes, posted
):
"""Without a clock reading, an entry stamped up to 60 minutes ahead of the
host clock is posted as stamped; one 61 minutes ahead is skipped as
``clock-epoch``, without a warning."""
stamp = HOST_NOW + minutes * 60
_load_node_db(radio, 0, {SENDER: stamp})
upserts = _snapshot(radio)
if posted:
assert upserts[SENDER_ID]["lastHeard"] == stamp
else:
assert SENDER_ID not in upserts
assert snapshot_log[0]["reason"] == "clock-epoch"
assert _warnings(snapshot_log) == []
def test_within_the_tolerance_every_entry_posts_as_stamped(radio, host_clock):
"""A radio clock within the tolerance changes nothing: an entry two hours
ahead of its reading is posted as stamped, as before RK4."""
reading = HOST_NOW + 30 * 60
_load_node_db(radio, reading, {SENDER: reading + 2 * TOLERANCE})
assert _snapshot(radio)[SENDER_ID]["lastHeard"] == reading + 2 * TOLERANCE
def test_reading_falls_back_to_get_my_node_info(radio, host_clock):
"""Before the host id is registered, the library's ``getMyNodeInfo`` finds
the own entry by the radio's node number."""
_load_node_db(radio, RADIO_NOW, {SENDER: REMOTE_LAST_HEARD}, register_host=False)
assert _snapshot(radio)[SENDER_ID]["lastHeard"] == HOST_NOW - 600
def test_mismatched_own_entry_falls_back_to_get_my_node_info(
radio, host_clock, snapshot_log
):
"""Another node's entry filed under the host id (SPEC NI1) carries that
node's time, not the radio's clock: the reading comes from
``getMyNodeInfo``. With the radio's clock right, a node heard 10 minutes
ago and one 61 minutes ahead of the host clock post as stamped, and
nothing is shifted."""
_load_node_db(
radio, HOST_NOW, {SENDER: HOST_NOW - 600, AHEAD_NUM: HOST_NOW + 61 * 60}
)
_file_impostor_under_host_id(radio, HOST_NOW - 3 * DAY)
upserts = _snapshot(radio)
assert _skipped(snapshot_log) == {HOST_ID: "num-mismatch"}
assert upserts[SENDER_ID]["lastHeard"] == HOST_NOW - 600
assert upserts[AHEAD_ID]["lastHeard"] == HOST_NOW + 61 * 60
assert _warnings(snapshot_log) == []
def test_mismatched_own_entry_without_a_fallback_gives_no_reading(
radio, host_clock, snapshot_log
):
"""Another node's entry under the host id, and no own entry for
``getMyNodeInfo`` to find, give no clock reading: a node heard 10 minutes
ago posts as stamped, one 61 minutes ahead of the host clock is skipped
as ``clock-epoch``, and no warning is logged."""
_load_node_db(radio, None, {SENDER: HOST_NOW - 600, AHEAD_NUM: HOST_NOW + 61 * 60})
_file_impostor_under_host_id(radio, HOST_NOW - 3 * DAY)
upserts = _snapshot(radio)
assert _skipped(snapshot_log) == {
HOST_ID: "num-mismatch",
AHEAD_ID: "clock-epoch",
}
assert upserts[SENDER_ID]["lastHeard"] == HOST_NOW - 600
assert _warnings(snapshot_log) == []
def test_mismatched_own_entry_without_get_my_node_info_gives_no_reading(
scope, host_clock
):
"""An interface without ``getMyNodeInfo`` leaves another node's entry
under the host id unread: there is no reading, so a node heard 10 minutes
ago is neither skipped by the impostor's time nor shifted."""
handlers.register_host_node_id(HOST_ID)
impostor = {"num": IMPOSTOR_NUM, "lastHeard": HOST_NOW - 3 * DAY}
recent = {"num": SENDER, "lastHeard": HOST_NOW - 600}
provider = MeshtasticProvider()
provider.node_snapshot_items(
SimpleNamespace(nodes={HOST_ID: impostor, SENDER_ID: recent})
)
assert provider.snapshot_filter_reason(HOST_ID, impostor) == "num-mismatch"
assert provider.snapshot_filter_reason(SENDER_ID, recent) is None
assert provider.snapshot_entry(SENDER_ID, recent) is recent
assert provider.snapshot_clock_warning is None
def _no_node_info():
"""A ``getMyNodeInfo`` that fails the way a nodeDB mutated mid-read does."""
raise RuntimeError("dictionary changed size during iteration")
class _ItemsOnly:
"""A nodes object that is no mapping: it offers ``items()`` only."""
def items(self):
"""Return the one entry, stamped 61 minutes ahead of the host clock."""
return [(SENDER_ID, {"num": SENDER, "lastHeard": HOST_NOW + 61 * 60})]
@pytest.mark.parametrize(
"make_iface",
[
lambda: SimpleNamespace(
nodes=dict(_ItemsOnly().items()), getMyNodeInfo=_no_node_info
),
lambda: SimpleNamespace(nodes=_ItemsOnly()),
],
ids=["getMyNodeInfo raises", "nodes without get"],
)
def test_unreadable_own_entry_counts_as_no_reading(scope, host_clock, make_iface):
"""A failing ``getMyNodeInfo``, or a nodes object without ``get`` and an
interface without ``getMyNodeInfo``, gives no clock reading: the snapshot
still runs, with the host-clock guard."""
handlers.register_host_node_id(HOST_ID)
provider = MeshtasticProvider()
((node_id, node),) = provider.node_snapshot_items(make_iface())
assert provider.snapshot_filter_reason(node_id, node) == "clock-epoch"
assert provider.snapshot_entry(node_id, node) is node
assert provider.snapshot_clock_warning is None
def test_channel_filter_reason_wins_over_clock_epoch(field, snapshot_log):
"""An entry from an earlier boot heard on a hidden channel is skipped as
``hidden-channel``; the warning counts only the ``clock-epoch`` skips."""
field.apply("hidden")
field.iface._handleFromRadio(
_node_info_frame(0x0E0E0E0E, channel=SECONDARY, last_heard=PREV_BOOT_LAST_HEARD)
)
_snapshot(field)
assert _skipped(snapshot_log) == {
"!0e0e0e0e": "hidden-channel",
PREV_BOOT_ID: "clock-epoch",
}
assert _warnings(snapshot_log) == [FIELD_WARNING]
# ---------------------------------------------------------------------------
# The radio's uptime: entries from before this boot
# ---------------------------------------------------------------------------
def test_field_host_posts_an_entry_older_than_the_boot_as_stamped(
radio, host_clock, snapshot_log
):
"""The field host: its radio 8388455 s slow and up 343965 s. A node
heard 10 minutes ago this boot is shifted, one stamped after the reading
is skipped, and one heard 120 days ago, before the boot started, is
posted as stamped; one warning counts it as ``unshifted``, and the
library's dicts keep their values."""
_load_field_host(
radio,
host_clock,
{
SENDER: FIELD_READING - 600,
PREV_BOOT_NUM: PREV_BOOT_LAST_HEARD,
OLD_BOOT_NUM: OLD_BOOT_LAST_HEARD,
},
)
nodes = radio.iface.nodes
old_entry = nodes[OLD_BOOT_ID]
upserts = _snapshot(radio)
assert upserts[HOST_ID]["lastHeard"] == FIELD_HOST_NOW
assert upserts[SENDER_ID]["lastHeard"] == FIELD_HOST_NOW - 600
assert upserts[OLD_BOOT_ID]["lastHeard"] == OLD_BOOT_LAST_HEARD
assert _skipped(snapshot_log) == {PREV_BOOT_ID: "clock-epoch"}
assert _warnings(snapshot_log) == [FIELD_UPTIME_WARNING]
assert nodes[OLD_BOOT_ID] is old_entry
assert old_entry["lastHeard"] == OLD_BOOT_LAST_HEARD
assert nodes[SENDER_ID]["lastHeard"] == FIELD_READING - 600
assert nodes[HOST_ID]["lastHeard"] == FIELD_READING
assert nodes[HOST_ID]["deviceMetrics"] == {"uptimeSeconds": FIELD_UPTIME}
@pytest.mark.parametrize(
("before_boot", "shifted"), [(TOLERANCE, True), (TOLERANCE + 1, False)]
)
def test_entry_before_the_boot_start_boundaries(
radio, host_clock, snapshot_log, before_boot, shifted
):
"""An entry stamped exactly 1 hour before the boot started is shifted; one
a second earlier is an earlier boot's, posted as stamped and counted as
``unshifted``."""
stamp = FIELD_BOOT_START - before_boot
_load_field_host(radio, host_clock, {SENDER: stamp})
posted = _snapshot(radio)[SENDER_ID]["lastHeard"]
assert posted == (stamp - FIELD_OFFSET if shifted else stamp)
expected = {
**FIELD_UPTIME_WARNING,
"shifted": 2 if shifted else 1,
"skipped": 0,
"unshifted": 0 if shifted else 1,
}
assert _warnings(snapshot_log) == [expected]
@pytest.mark.parametrize(("minutes", "posted"), [(60, True), (61, False)])
def test_earlier_boot_entry_ahead_of_the_host_clock(
radio, host_clock, snapshot_log, minutes, posted
):
"""With a radio clock 30 days fast and up 1 day, an entry from before its
boot up to 60 minutes ahead of the host clock is posted as stamped; one
61 minutes ahead is skipped as ``clock-epoch``, as without a reading."""
stamp = HOST_NOW + minutes * 60
_load_node_db(radio, HOST_NOW + 30 * DAY, {SENDER: stamp}, own_uptime=DAY)
upserts = _snapshot(radio)
if posted:
assert upserts[SENDER_ID]["lastHeard"] == stamp
else:
assert _skipped(snapshot_log) == {SENDER_ID: "clock-epoch"}
expected = {
**FIELD_WARNING,
"offset_secs": 30 * DAY,
"shifted": 1,
"skipped": 0 if posted else 1,
"unshifted": 1 if posted else 0,
}
assert _warnings(snapshot_log) == [expected]
@pytest.mark.parametrize(
"uptime",
[None, 0, -5, "junk", True, float(FIELD_UPTIME)],
ids=["absent", "zero", "negative", "junk", "bool", "float"],
)
def test_without_an_uptime_every_entry_is_shifted(
radio, host_clock, snapshot_log, uptime
):
"""An uptime that is absent or no positive int counts as none: the entry
from before the boot is shifted, as without the uptime, and the warning
has no ``unshifted``."""
_load_field_host(radio, host_clock, {OLD_BOOT_NUM: OLD_BOOT_LAST_HEARD})
own = radio.iface.nodes[HOST_ID]
if uptime is None:
del own["deviceMetrics"]
else:
own["deviceMetrics"] = {"uptimeSeconds": uptime}
posted = _snapshot(radio)[OLD_BOOT_ID]["lastHeard"]
assert posted == OLD_BOOT_LAST_HEARD - FIELD_OFFSET
expected = {**FIELD_WARNING, "offset_secs": FIELD_OFFSET, "skipped": 0}
assert _warnings(snapshot_log) == [expected]
# ---------------------------------------------------------------------------
# Mechanics: the hooks, the daemon's per-node handling
# ---------------------------------------------------------------------------
class _MutatingEntry(dict):
"""A nodeDB entry the library's thread mutates while it is copied."""
def keys(self):
"""Fail the way iterating a dict that changes size does."""
raise RuntimeError("dictionary changed size during iteration")
__iter__ = keys
def test_an_entry_failing_its_copy_costs_only_that_entry(
scope, host_clock, snapshot_log
):
"""``snapshot_entry`` runs inside the per-node error handling: an entry
that fails while it is copied is logged and skipped, the others are
posted, the latch is set and the interface stays open."""
iface = SimpleNamespace(
nodes={
HOST_ID: {"num": HOST_NUM, "lastHeard": RADIO_NOW},
"!0000000a": _MutatingEntry(num=10, lastHeard=RADIO_NOW - 60),
"!0000000b": {"num": 11, "lastHeard": RADIO_NOW - 120},
}
)
handlers.register_host_node_id(HOST_ID)
state = make_state(provider=MeshtasticProvider(), iface=iface)
assert daemon._try_send_snapshot(state) is True
assert state.iface is iface and state.initial_snapshot_sent is True
posted = {
node_id: entry["lastHeard"]
for _path, body in scope.posts
for node_id, entry in body.items()
if node_id.startswith("!")
}
assert posted == {HOST_ID: HOST_NOW, "!0000000b": HOST_NOW - 120}
failed = [line for line in _warnings(snapshot_log) if "node_id" in line]
assert [line["node_id"] for line in failed] == ["!0000000a"]
assert _warnings(snapshot_log)[-1] == {**FIELD_WARNING, "skipped": 0}
def test_hooks_leave_entries_alone_before_a_snapshot(scope):
"""A provider that has not read a snapshot's clock changes no entry."""
provider = MeshtasticProvider()
entry = {"num": SENDER, "lastHeard": HOST_NOW + 10 * DAY}
assert provider.snapshot_entry(SENDER_ID, entry) is entry
assert provider.snapshot_filter_reason(SENDER_ID, entry) is None
assert provider.snapshot_clock_warning is None
def test_snapshot_entry_passes_entries_without_a_time(field):
"""With an offset kept, an entry without ``lastHeard`` and one that is not
a mapping are returned as they are."""
provider = MeshtasticProvider()
provider.node_snapshot_items(field.iface)
untimed = {"num": 5}
opaque = SimpleNamespace(lastHeard=RADIO_NOW)
assert provider.snapshot_filter_reason("!00000005", untimed) is None
assert provider.snapshot_entry("!00000005", untimed) is untimed
assert provider.snapshot_entry("!00000006", opaque) is opaque
assert provider.snapshot_clock_warning == {
"offset_secs": RADIO_OFFSET,
"shifted": 0,
"skipped": 0,
}
@pytest.mark.parametrize("last_heard", [0, -5])
def test_last_heard_of_zero_or_below_posts_as_it_is(field, last_heard):
"""With an offset kept, a ``lastHeard`` of 0 or below counts as none: the
entry is not skipped, and it is posted as it is, not shifted to
``lastHeard - offset`` (8380800 for 0)."""
provider = MeshtasticProvider()
provider.node_snapshot_items(field.iface)
entry = {"num": 5, "lastHeard": last_heard}
assert provider.snapshot_filter_reason("!00000005", entry) is None
posted = provider.snapshot_entry("!00000005", entry)
assert posted["lastHeard"] == last_heard
assert posted is entry
assert provider.snapshot_clock_warning == {
"offset_secs": RADIO_OFFSET,
"shifted": 0,
"skipped": 0,
}
def test_failed_node_list_read_reports_no_warning(field):
"""A snapshot whose node list cannot be read keeps no offset from the
snapshot before it."""
class _AlwaysMutating:
"""A nodes object whose iteration never settles."""
def items(self):
"""Fail every attempt."""
raise RuntimeError("dictionary changed size during iteration")
provider = MeshtasticProvider()
provider.node_snapshot_items(field.iface)
assert provider.snapshot_clock_warning is not None
assert provider.node_snapshot_items(SimpleNamespace(nodes=_AlwaysMutating())) == []
assert provider.snapshot_clock_warning is None
# ---------------------------------------------------------------------------
# The pure offset helper
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("reading", "expected"),
[
(None, None),
("", None),
("junk", None),
(0, None),
(-5, None),
(HOST_NOW - TOLERANCE, 0),
(HOST_NOW + TOLERANCE, 0),
(HOST_NOW - TOLERANCE - 1, -TOLERANCE - 1),
(HOST_NOW + TOLERANCE + 1, TOLERANCE + 1),
(str(RADIO_NOW), RADIO_OFFSET),
],
)
def test_radio_clock_offset(reading, expected):
"""``None`` for no clock reading, ``0`` within the tolerance (the bound
included), else the reading minus the host clock."""
assert receive_time.radio_clock_offset(reading, HOST_NOW) == expected