# 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. """Unit tests for :mod:`data.mesh_ingestor.daemon`.""" from __future__ import annotations import importlib import sys import threading import types from pathlib import Path from typing import Any import pytest REPO_ROOT = Path(__file__).resolve().parents[1] if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) from data.mesh_ingestor import daemon # noqa: E402 - path setup import data.mesh_ingestor.config as _cfg_module # noqa: E402 - path setup class FakeEvent: """Test double for :class:`threading.Event` that can auto-set itself.""" instances: list["FakeEvent"] = [] def __init__(self, *, auto_set_on_wait: bool = False): self._is_set = False self._auto_set_on_wait = auto_set_on_wait self.wait_calls: list[Any] = [] FakeEvent.instances.append(self) def set(self) -> None: """Mark the event as set.""" self._is_set = True def is_set(self) -> bool: """Return whether the event is currently set.""" return self._is_set def wait(self, timeout: float | None = None) -> bool: """Record waits and optionally auto-set the flag.""" self.wait_calls.append(timeout) if self._auto_set_on_wait: self._is_set = True return self._is_set class AutoSetEvent(FakeEvent): """Event variant that automatically sets on each wait call.""" def __init__(self): # noqa: D401 - short initializer docstring handled by class super().__init__(auto_set_on_wait=True) @pytest.fixture(autouse=True) def reset_fake_events(): """Ensure :class:`FakeEvent` registry is cleared between tests.""" FakeEvent.instances.clear() yield FakeEvent.instances.clear() def test_event_wait_default_detection(monkeypatch): """``_event_wait_allows_default_timeout`` matches defaulted signatures.""" assert daemon._event_wait_allows_default_timeout() is True class _NoDefaultEvent: def wait(self, timeout): # type: ignore[override] return bool(timeout) monkeypatch.setattr( daemon, "threading", types.SimpleNamespace(Event=_NoDefaultEvent) ) assert daemon._event_wait_allows_default_timeout() is False def test_subscribe_receive_topics(monkeypatch): """Subscribing to receive topics returns the exact topic list.""" subscribed: list[str] = [] def _record_subscription(_handler, topic): subscribed.append(topic) monkeypatch.setattr( daemon, "pub", types.SimpleNamespace(subscribe=_record_subscription) ) assert daemon._subscribe_receive_topics() == list(daemon._RECEIVE_TOPICS) assert subscribed == list(daemon._RECEIVE_TOPICS) def test_node_items_snapshot_handles_mutation(monkeypatch): """Snapshots tolerate temporary runtime errors while iterating.""" class MutatingMapping(dict): def __bool__(self): return True def items(self): # type: ignore[override] raise RuntimeError("dictionary changed size during iteration") monkeypatch.setattr(daemon.time, "sleep", lambda _: None) assert daemon._node_items_snapshot({"a": 1}) == [("a", 1)] assert daemon._node_items_snapshot(MutatingMapping(), retries=1) is None class IteratingMapping: def __init__(self): self.calls = 0 self._data = {"x": 10, "y": 20} def __iter__(self): self.calls += 1 if self.calls == 1: raise RuntimeError("dictionary changed size during iteration") return iter(self._data) def __getitem__(self, key): return self._data[key] mapping = IteratingMapping() assert daemon._node_items_snapshot(mapping, retries=2) == [("x", 10), ("y", 20)] def test_close_interface_respects_timeout(monkeypatch): """Long-running close calls emit a timeout debug log.""" log_calls = [] monkeypatch.setattr(daemon.config, "_CLOSE_TIMEOUT_SECS", 0.01) monkeypatch.setattr( daemon.config, "_debug_log", lambda *args, **kwargs: log_calls.append(kwargs) ) blocker = threading.Event() class SlowInterface: def close(self): blocker.wait(timeout=0.1) daemon._close_interface(SlowInterface()) assert any("timeout_seconds" in entry for entry in log_calls) def test_close_interface_immediate_path(monkeypatch): """A zero timeout calls ``close`` inline without threading.""" flags = {"called": False} monkeypatch.setattr(daemon.config, "_CLOSE_TIMEOUT_SECS", 0) class ImmediateInterface: def close(self): flags["called"] = True daemon._close_interface(ImmediateInterface()) assert flags["called"] is True def test_ble_interface_detection(): """Detect BLE module names reliably.""" class BLE: __module__ = "meshtastic.ble_interface" class NonBLE: __module__ = "meshtastic.serial" assert daemon._is_ble_interface(BLE()) is True assert daemon._is_ble_interface(NonBLE()) is False assert daemon._is_ble_interface(None) is False def test_process_ingestor_heartbeat_with_extracted_host(monkeypatch): """Host id extraction triggers heartbeat announcement flag updates.""" host_ids: list[str | None] = [None] ingestor_ids: list[str | None] = [] queued: list[bool] = [] monkeypatch.setattr(daemon.handlers, "host_node_id", lambda: host_ids[0]) monkeypatch.setattr( daemon.interfaces, "_extract_host_node_id", lambda iface: "!abcd" ) monkeypatch.setattr( daemon.handlers, "register_host_node_id", lambda node: host_ids.__setitem__(0, node), ) monkeypatch.setattr(daemon.ingestors, "set_ingestor_node_id", ingestor_ids.append) monkeypatch.setattr( daemon.ingestors, "queue_ingestor_heartbeat", lambda force: queued.append(force) or True, ) assert ( daemon._process_ingestor_heartbeat(object(), ingestor_announcement_sent=False) is True ) assert host_ids[0] == "!abcd" assert ingestor_ids[-1] == "!abcd" assert queued[-1] is True monkeypatch.setattr(daemon.handlers, "host_node_id", lambda: "!abcd") monkeypatch.setattr( daemon.ingestors, "queue_ingestor_heartbeat", lambda force: queued.append(force) or False, ) assert ( daemon._process_ingestor_heartbeat(object(), ingestor_announcement_sent=True) is True ) assert queued[-1] is False def test_connected_state_branches(monkeypatch): """Connection state resolves across multiple attribute forms.""" event = threading.Event() event.set() assert daemon._connected_state(event) is True class CallableCandidate: def __call__(self): return False assert daemon._connected_state(CallableCandidate()) is False class BooleanCandidate: def __bool__(self): raise RuntimeError("cannot bool") assert daemon._connected_state(BooleanCandidate()) is None class HasIsSet: def is_set(self): raise RuntimeError("broken") assert daemon._connected_state(HasIsSet()) is None def _configure_common_defaults( monkeypatch, *, energy_saving: bool = False, inactivity: float = 0.0 ): """Set fast configuration defaults shared by daemon integration tests.""" monkeypatch.setattr(daemon.config, "SNAPSHOT_SECS", 0) monkeypatch.setattr(daemon.config, "_RECONNECT_INITIAL_DELAY_SECS", 0) monkeypatch.setattr(daemon.config, "_RECONNECT_MAX_DELAY_SECS", 0) monkeypatch.setattr(daemon.config, "_CLOSE_TIMEOUT_SECS", 0) monkeypatch.setattr(daemon.config, "ENERGY_SAVING", energy_saving) monkeypatch.setattr( daemon.config, "_ENERGY_ONLINE_DURATION_SECS", 0 if energy_saving else 0.0 ) monkeypatch.setattr(daemon.config, "_ENERGY_SLEEP_SECS", 0.0) monkeypatch.setattr(daemon.config, "_INGESTOR_HEARTBEAT_SECS", 0) monkeypatch.setattr(daemon.config, "_INACTIVITY_RECONNECT_SECS", inactivity) monkeypatch.setattr(daemon.config, "CONNECTION", "serial0") class DummyInterface: """Lightweight mesh interface stand-in used for daemon integration tests.""" def __init__(self, *, nodes=None, is_connected=True, client_present=True): self.nodes = nodes if nodes is not None else {"!node": {"id": 1}} self.isConnected = is_connected self.client = object() if client_present else None def close(self): return None def test_main_happy_path(monkeypatch): """The main loop processes snapshots and heartbeats once before stopping.""" _configure_common_defaults(monkeypatch) monkeypatch.setattr( daemon, "threading", types.SimpleNamespace( Event=AutoSetEvent, current_thread=threading.current_thread, main_thread=threading.main_thread, ), ) monkeypatch.setattr( daemon, "pub", types.SimpleNamespace(subscribe=lambda *_args, **_kwargs: None) ) monkeypatch.setattr( daemon.interfaces, "_create_serial_interface", lambda candidate: (DummyInterface(), candidate), ) monkeypatch.setattr(daemon.interfaces, "_ensure_radio_metadata", lambda iface: None) monkeypatch.setattr( daemon.interfaces, "_ensure_channel_metadata", lambda iface: None ) monkeypatch.setattr( daemon.interfaces, "_extract_host_node_id", lambda iface: "!host" ) host_id = {"value": None} monkeypatch.setattr( daemon.handlers, "register_host_node_id", lambda node: host_id.__setitem__("value", node), ) monkeypatch.setattr(daemon.handlers, "host_node_id", lambda: host_id["value"]) monkeypatch.setattr(daemon.handlers, "upsert_node", lambda *_args, **_kwargs: None) monkeypatch.setattr(daemon.handlers, "last_packet_monotonic", lambda: None) heartbeats: list[bool] = [] monkeypatch.setattr( daemon.ingestors, "set_ingestor_node_id", lambda *_args, **_kwargs: None ) monkeypatch.setattr( daemon.ingestors, "queue_ingestor_heartbeat", lambda force: heartbeats.append(force) or True, ) daemon.main() assert heartbeats assert host_id["value"] == "!host" assert FakeEvent.instances and FakeEvent.instances[0].is_set() is True def test_main_energy_saving_disconnect(monkeypatch): """Energy saving mode disconnects and sleeps when deadlines expire.""" _configure_common_defaults(monkeypatch, energy_saving=True) monkeypatch.setattr( daemon, "threading", types.SimpleNamespace( Event=AutoSetEvent, current_thread=threading.current_thread, main_thread=threading.main_thread, ), ) monkeypatch.setattr( daemon, "pub", types.SimpleNamespace(subscribe=lambda *_args, **_kwargs: None) ) monkeypatch.setattr( daemon.interfaces, "_create_serial_interface", lambda candidate: (DummyInterface(), candidate), ) monkeypatch.setattr(daemon.interfaces, "_ensure_radio_metadata", lambda iface: None) monkeypatch.setattr( daemon.interfaces, "_ensure_channel_metadata", lambda iface: None ) monkeypatch.setattr( daemon.interfaces, "_extract_host_node_id", lambda iface: "!host" ) monkeypatch.setattr( daemon.handlers, "register_host_node_id", lambda *_args, **_kwargs: None ) monkeypatch.setattr(daemon.handlers, "host_node_id", lambda: "!host") monkeypatch.setattr(daemon.handlers, "upsert_node", lambda *_args, **_kwargs: None) monkeypatch.setattr(daemon.handlers, "last_packet_monotonic", lambda: None) monkeypatch.setattr( daemon.ingestors, "set_ingestor_node_id", lambda *_args, **_kwargs: None ) monkeypatch.setattr( daemon.ingestors, "queue_ingestor_heartbeat", lambda *_args, **_kwargs: True ) daemon.main() assert FakeEvent.instances and FakeEvent.instances[0].is_set() is True def test_main_inactivity_reconnect(monkeypatch): """Inactivity triggers reconnect attempts and respects stop events.""" _configure_common_defaults(monkeypatch, inactivity=0.5) monkeypatch.setattr( daemon, "threading", types.SimpleNamespace( Event=AutoSetEvent, current_thread=threading.current_thread, main_thread=threading.main_thread, ), ) monkeypatch.setattr( daemon, "pub", types.SimpleNamespace(subscribe=lambda *_args, **_kwargs: None) ) interface_cycle = iter( [DummyInterface(is_connected=False), DummyInterface(is_connected=True)] ) monkeypatch.setattr( daemon.interfaces, "_create_serial_interface", lambda candidate: (next(interface_cycle), candidate), ) monkeypatch.setattr(daemon.interfaces, "_ensure_radio_metadata", lambda iface: None) monkeypatch.setattr( daemon.interfaces, "_ensure_channel_metadata", lambda iface: None ) monkeypatch.setattr( daemon.interfaces, "_extract_host_node_id", lambda iface: "!host" ) monkeypatch.setattr( daemon.handlers, "register_host_node_id", lambda *_args, **_kwargs: None ) monkeypatch.setattr(daemon.handlers, "host_node_id", lambda: "!host") monkeypatch.setattr(daemon.handlers, "upsert_node", lambda *_args, **_kwargs: None) monotonic_calls = iter([0.0, 1.0, 2.0, 3.0, 4.0]) monkeypatch.setattr(daemon.time, "monotonic", lambda: next(monotonic_calls)) monkeypatch.setattr(daemon.handlers, "last_packet_monotonic", lambda: 0.0) monkeypatch.setattr( daemon.ingestors, "set_ingestor_node_id", lambda *_args, **_kwargs: None ) monkeypatch.setattr( daemon.ingestors, "queue_ingestor_heartbeat", lambda *_args, **_kwargs: True ) daemon.main() assert any(event.is_set() for event in FakeEvent.instances) # --------------------------------------------------------------------------- # Helper: build a minimal _DaemonState for unit tests # --------------------------------------------------------------------------- def _make_state(**overrides): """Return a :class:`daemon._DaemonState` with sensible defaults. Any keyword argument is forwarded as a field override via ``setattr`` after construction, so callers only need to supply fields under test. """ state = daemon._DaemonState( provider=None, # type: ignore[arg-type] stop=FakeEvent(), # type: ignore[arg-type] configured_port=None, inactivity_reconnect_secs=0.0, energy_saving_enabled=False, energy_online_secs=0.0, energy_sleep_secs=0.0, retry_delay=0.0, last_seen_packet_monotonic=None, active_candidate=None, ) for key, val in overrides.items(): setattr(state, key, val) return state # --------------------------------------------------------------------------- # _advance_retry_delay # --------------------------------------------------------------------------- def test_advance_retry_delay_disabled(monkeypatch): """Returns current delay unchanged when the max is zero.""" monkeypatch.setattr(daemon.config, "_RECONNECT_MAX_DELAY_SECS", 0) assert daemon._advance_retry_delay(5.0) == 5.0 def test_advance_retry_delay_bootstrap(monkeypatch): """Seeds from initial config when current delay is zero (first call).""" monkeypatch.setattr(daemon.config, "_RECONNECT_MAX_DELAY_SECS", 60.0) monkeypatch.setattr(daemon.config, "_RECONNECT_INITIAL_DELAY_SECS", 3.0) assert daemon._advance_retry_delay(0.0) == 3.0 def test_advance_retry_delay_doubles_and_caps(monkeypatch): """Doubles current delay and caps at the configured maximum.""" monkeypatch.setattr(daemon.config, "_RECONNECT_MAX_DELAY_SECS", 10.0) monkeypatch.setattr(daemon.config, "_RECONNECT_INITIAL_DELAY_SECS", 1.0) assert daemon._advance_retry_delay(3.0) == 6.0 assert daemon._advance_retry_delay(7.0) == 10.0 # --------------------------------------------------------------------------- # _energy_sleep # --------------------------------------------------------------------------- def test_energy_sleep_no_op_when_disabled(): """No wait issued when energy saving is disabled.""" state = _make_state(energy_saving_enabled=False, energy_sleep_secs=1.0) daemon._energy_sleep(state, "reason") assert not state.stop.wait_calls def test_energy_sleep_no_op_when_zero_secs(): """No wait issued when sleep duration is zero.""" state = _make_state(energy_saving_enabled=True, energy_sleep_secs=0.0) daemon._energy_sleep(state, "reason") assert not state.stop.wait_calls def test_energy_sleep_emits_debug_log(monkeypatch): """Debug log is emitted when DEBUG is enabled.""" state = _make_state(energy_saving_enabled=True, energy_sleep_secs=2.0) logged = [] monkeypatch.setattr(daemon.config, "DEBUG", True) monkeypatch.setattr( daemon.config, "_debug_log", lambda msg, **_kw: logged.append(msg) ) daemon._energy_sleep(state, "wake up") assert any("wake up" in m for m in logged) assert state.stop.wait_calls == [2.0] def test_energy_sleep_waits_when_debug_off(monkeypatch): """Wait is issued for the configured duration when DEBUG is off.""" state = _make_state(energy_saving_enabled=True, energy_sleep_secs=1.5) monkeypatch.setattr(daemon.config, "DEBUG", False) daemon._energy_sleep(state, "reason") assert state.stop.wait_calls == [1.5] # --------------------------------------------------------------------------- # _try_connect # --------------------------------------------------------------------------- def test_try_connect_no_available_interface_raises_system_exit(monkeypatch): """NoAvailableMeshInterface propagates as SystemExit(1).""" class _NoIface: def connect(self, *, active_candidate): raise daemon.interfaces.NoAvailableMeshInterface("none") def extract_host_node_id(self, iface): return None state = _make_state(active_candidate="serial0", configured_port="serial0") state.provider = _NoIface() # type: ignore[assignment] monkeypatch.setattr(daemon.config, "_debug_log", lambda *_a, **_k: None) with pytest.raises(SystemExit): daemon._try_connect(state) def test_try_connect_generic_failure_resets_candidate(monkeypatch): """Connect failure in auto-detect mode clears the active candidate.""" class _FailProvider: def connect(self, *, active_candidate): raise OSError("device busy") def extract_host_node_id(self, iface): return None state = _make_state(active_candidate="serial0", configured_port=None) state.provider = _FailProvider() # type: ignore[assignment] monkeypatch.setattr(daemon.config, "_debug_log", lambda *_a, **_k: None) monkeypatch.setattr(daemon.config, "_RECONNECT_MAX_DELAY_SECS", 0) monkeypatch.setattr(daemon.config, "_RECONNECT_INITIAL_DELAY_SECS", 0) result = daemon._try_connect(state) assert result is False assert state.active_candidate is None assert state.announced_target is False def test_try_connect_sets_energy_session_deadline(monkeypatch): """Energy-saving deadline is assigned when online duration is positive.""" class _OkProvider: def connect(self, *, active_candidate): return DummyInterface(), active_candidate, active_candidate def extract_host_node_id(self, iface): return "!host" state = _make_state( active_candidate="serial0", configured_port="serial0", energy_saving_enabled=True, energy_online_secs=30.0, ) state.provider = _OkProvider() # type: ignore[assignment] monkeypatch.setattr(daemon.config, "_debug_log", lambda *_a, **_k: None) monkeypatch.setattr(daemon.config, "_RECONNECT_INITIAL_DELAY_SECS", 0) monkeypatch.setattr( daemon.handlers, "register_host_node_id", lambda *_a, **_k: None ) monkeypatch.setattr(daemon.handlers, "host_node_id", lambda: "!host") monkeypatch.setattr( daemon.ingestors, "set_ingestor_node_id", lambda *_a, **_k: None ) result = daemon._try_connect(state) assert result is True assert state.energy_session_deadline is not None # --------------------------------------------------------------------------- # _check_energy_saving # --------------------------------------------------------------------------- def test_check_energy_saving_session_expired(monkeypatch): """Iface is closed and True returned when the session deadline has passed.""" state = _make_state(energy_saving_enabled=True) state.iface = DummyInterface() state.energy_session_deadline = 0.0 monkeypatch.setattr(daemon.time, "monotonic", lambda: 1.0) monkeypatch.setattr(daemon.config, "_debug_log", lambda *_a, **_k: None) result = daemon._check_energy_saving(state) assert result is True assert state.iface is None assert state.energy_session_deadline is None def test_check_energy_saving_ble_client_disconnected(monkeypatch): """Iface is closed and True returned when the BLE client reference is gone.""" state = _make_state(energy_saving_enabled=True) state.iface = DummyInterface(client_present=False) state.energy_session_deadline = None monkeypatch.setattr(daemon, "_is_ble_interface", lambda _: True) monkeypatch.setattr(daemon.config, "_debug_log", lambda *_a, **_k: None) result = daemon._check_energy_saving(state) assert result is True assert state.iface is None # --------------------------------------------------------------------------- # _try_send_snapshot # --------------------------------------------------------------------------- def test_try_send_snapshot_empty_nodes(): """Returns True without setting initial_snapshot_sent when no nodes exist.""" class _EmptyProvider: def node_snapshot_items(self, iface): return [] state = _make_state() state.iface = DummyInterface(nodes={}) state.provider = _EmptyProvider() # type: ignore[assignment] result = daemon._try_send_snapshot(state) assert result is True assert state.initial_snapshot_sent is False def test_try_send_snapshot_upsert_failure_is_non_fatal(monkeypatch): """Upsert errors are logged but do not abort the snapshot pass.""" class _OneNodeProvider: def node_snapshot_items(self, iface): return [("!node1", {"id": 1})] def _raise(*_a, **_k): raise ValueError("bad node") state = _make_state() state.iface = DummyInterface() state.provider = _OneNodeProvider() # type: ignore[assignment] logged = [] monkeypatch.setattr(daemon.config, "_debug_log", lambda *a, **kw: logged.append(kw)) monkeypatch.setattr(daemon.config, "DEBUG", False) monkeypatch.setattr(daemon.handlers, "upsert_node", _raise) result = daemon._try_send_snapshot(state) assert result is True assert state.initial_snapshot_sent is True assert any(c.get("context") == "daemon.snapshot" for c in logged) def test_try_send_snapshot_upsert_failure_debug_payload(monkeypatch): """The node payload is logged when DEBUG is enabled and upsert fails.""" class _OneNodeProvider: def node_snapshot_items(self, iface): return [("!node1", {"id": 1})] def _raise(*_a, **_k): raise ValueError("bad") state = _make_state() state.iface = DummyInterface() state.provider = _OneNodeProvider() # type: ignore[assignment] logged = [] monkeypatch.setattr(daemon.config, "_debug_log", lambda *a, **kw: logged.append(kw)) monkeypatch.setattr(daemon.config, "DEBUG", True) monkeypatch.setattr(daemon.handlers, "upsert_node", _raise) daemon._try_send_snapshot(state) assert any("node" in c for c in logged) def test_try_send_snapshot_outer_exception_resets_iface(monkeypatch): """An exception from node_snapshot_items resets the interface and returns False.""" class _BrokenProvider: def node_snapshot_items(self, iface): raise RuntimeError("boom") state = _make_state() state.iface = DummyInterface() state.provider = _BrokenProvider() # type: ignore[assignment] monkeypatch.setattr(daemon.config, "_debug_log", lambda *_a, **_k: None) monkeypatch.setattr(daemon.config, "_RECONNECT_MAX_DELAY_SECS", 0) result = daemon._try_send_snapshot(state) assert result is False assert state.iface is None # --------------------------------------------------------------------------- # _check_inactivity_reconnect (additional branches) # --------------------------------------------------------------------------- def test_check_inactivity_reconnect_throttles_rapid_reconnects(monkeypatch): """A reconnect within the inactivity window is suppressed.""" state = _make_state(inactivity_reconnect_secs=60.0) state.iface = DummyInterface(is_connected=False) state.iface_connected_at = 0.0 state.last_inactivity_reconnect = 1.0 # recent monkeypatch.setattr(daemon.time, "monotonic", lambda: 10.0) monkeypatch.setattr(daemon.handlers, "last_packet_monotonic", lambda: None) assert daemon._check_inactivity_reconnect(state) is False def test_check_inactivity_reconnect_uses_connected_at_when_no_packets(monkeypatch): """Uses iface_connected_at as the activity baseline when no packets seen.""" state = _make_state(inactivity_reconnect_secs=60.0) state.iface = DummyInterface(is_connected=True) state.iface_connected_at = 5.0 state.last_inactivity_reconnect = None monkeypatch.setattr(daemon.time, "monotonic", lambda: 10.0) monkeypatch.setattr(daemon.handlers, "last_packet_monotonic", lambda: None) # 10.0 - 5.0 = 5.0 < 60.0 → not triggered assert daemon._check_inactivity_reconnect(state) is False def test_check_inactivity_reconnect_uses_now_when_no_baseline(monkeypatch): """Falls back to current time when neither packets nor connected_at is set.""" state = _make_state(inactivity_reconnect_secs=60.0) state.iface = DummyInterface(is_connected=True) state.iface_connected_at = None state.last_inactivity_reconnect = None monkeypatch.setattr(daemon.time, "monotonic", lambda: 10.0) monkeypatch.setattr(daemon.handlers, "last_packet_monotonic", lambda: None) # latest_activity = now(10.0); inactivity_elapsed = 0.0 < 60.0 → not triggered assert daemon._check_inactivity_reconnect(state) is False # --------------------------------------------------------------------------- # _loop_iteration # --------------------------------------------------------------------------- def test_loop_iteration_connect_fails_returns_true(monkeypatch): """Returns True (continue) when iface is absent and connect fails.""" state = _make_state() state.iface = None monkeypatch.setattr(daemon, "_try_connect", lambda s: False) assert daemon._loop_iteration(state) is True def test_loop_iteration_energy_saving_triggers_returns_true(monkeypatch): """Returns True (continue) when energy saving disconnects the interface.""" state = _make_state() state.iface = object() monkeypatch.setattr(daemon, "_check_energy_saving", lambda s: True) assert daemon._loop_iteration(state) is True def test_loop_iteration_snapshot_fails_returns_true(monkeypatch): """Returns True (continue) when the initial snapshot fails.""" state = _make_state() state.iface = object() state.initial_snapshot_sent = False monkeypatch.setattr(daemon, "_check_energy_saving", lambda s: False) monkeypatch.setattr(daemon, "_try_send_snapshot", lambda s: False) assert daemon._loop_iteration(state) is True def test_loop_iteration_inactivity_triggers_returns_true(monkeypatch): """Returns True (continue) when inactivity reconnect fires.""" state = _make_state() state.iface = object() state.initial_snapshot_sent = True monkeypatch.setattr(daemon, "_check_energy_saving", lambda s: False) monkeypatch.setattr(daemon, "_check_inactivity_reconnect", lambda s: True) assert daemon._loop_iteration(state) is True def test_loop_iteration_full_pass_returns_false(monkeypatch): """Returns False (sleep) after a complete iteration with no early exits.""" state = _make_state() state.iface = object() state.initial_snapshot_sent = True monkeypatch.setattr(daemon, "_check_energy_saving", lambda s: False) monkeypatch.setattr(daemon, "_check_inactivity_reconnect", lambda s: False) monkeypatch.setattr( daemon, "_process_ingestor_heartbeat", lambda iface, **_kw: False ) monkeypatch.setattr(daemon.config, "_RECONNECT_INITIAL_DELAY_SECS", 0) assert daemon._loop_iteration(state) is False # --------------------------------------------------------------------------- # PROVIDER env-var selection # --------------------------------------------------------------------------- def _make_minimal_fake_provider(name: str): """Return a minimal provider-like object that causes main() to exit quickly.""" class FakeIface: def close(self): return None class FakeProvider: def subscribe(self): return [] def connect(self, *, active_candidate): return FakeIface(), "fake", active_candidate def extract_host_node_id(self, iface): return None def node_snapshot_items(self, iface): return [] fp = FakeProvider() fp.name = name return fp def _patch_daemon_for_fast_exit(monkeypatch): """Apply monkeypatches that make daemon.main() return after one iteration.""" _configure_common_defaults(monkeypatch) monkeypatch.setattr(daemon.config, "CONNECTION", "fake") monkeypatch.setattr( daemon, "threading", types.SimpleNamespace( Event=AutoSetEvent, current_thread=daemon.threading.current_thread, main_thread=daemon.threading.main_thread, ), ) monkeypatch.setattr( daemon.handlers, "register_host_node_id", lambda *_a, **_k: None ) monkeypatch.setattr(daemon.handlers, "host_node_id", lambda: None) monkeypatch.setattr(daemon.handlers, "upsert_node", lambda *_a, **_k: None) monkeypatch.setattr(daemon.handlers, "last_packet_monotonic", lambda: None) monkeypatch.setattr( daemon.ingestors, "set_ingestor_node_id", lambda *_a, **_k: None ) monkeypatch.setattr( daemon.ingestors, "queue_ingestor_heartbeat", lambda *_a, **_k: True ) def _reload_config() -> types.ModuleType: """Reload and return the config module, picking up any env-var changes.""" importlib.reload(_cfg_module) return _cfg_module @pytest.fixture() def reset_provider_config(): """Reload config after the test so PROVIDER changes don't leak across tests.""" yield import os os.environ.pop("PROVIDER", None) _reload_config() @pytest.mark.parametrize( "env_value, expected", [ (None, "meshtastic"), ("meshcore", "meshcore"), ], ) def test_config_provider_env(monkeypatch, reset_provider_config, env_value, expected): """PROVIDER env var selects the provider; absent defaults to 'meshtastic'.""" if env_value is None: monkeypatch.delenv("PROVIDER", raising=False) else: monkeypatch.setenv("PROVIDER", env_value) assert _reload_config().PROVIDER == expected def test_config_provider_unknown_raises(monkeypatch, reset_provider_config): """An unrecognised PROVIDER value must raise ValueError at import time.""" monkeypatch.setenv("PROVIDER", "reticulum") with pytest.raises(ValueError, match="PROVIDER"): _reload_config() @pytest.mark.parametrize( "provider_name, module_path, class_name", [ ("meshtastic", "data.mesh_ingestor.providers.meshtastic", "MeshtasticProvider"), ("meshcore", "data.mesh_ingestor.providers.meshcore", "MeshcoreProvider"), ], ) def test_daemon_main_selects_provider( monkeypatch, provider_name, module_path, class_name ): """main() must instantiate the correct provider class based on PROVIDER.""" mod = importlib.import_module(module_path) instantiated = [] def make_provider(): p = _make_minimal_fake_provider(provider_name) instantiated.append(p) return p _patch_daemon_for_fast_exit(monkeypatch) monkeypatch.setattr(daemon.config, "PROVIDER", provider_name) monkeypatch.setattr(mod, class_name, make_provider) daemon.main() assert len(instantiated) == 1 assert instantiated[0].name == provider_name # --------------------------------------------------------------------------- # Signal handler behaviour (handle_sigterm / handle_sigint) # --------------------------------------------------------------------------- def test_handle_sigterm_sets_stop(monkeypatch): """handle_sigterm sets the stop event when invoked.""" import signal as _signal stop_events: list = [] def capture_signal(signum, handler): if signum == _signal.SIGTERM: stop_events.append(handler) monkeypatch.setattr(daemon.signal, "signal", capture_signal) _patch_daemon_for_fast_exit(monkeypatch) daemon.main() # The SIGTERM handler was registered — call it and verify stop is set. assert len(stop_events) == 1 fake_state_stop = AutoSetEvent() # Build a closure-equivalent: create a stop container and call the handler # by replaying what main() does. class _StopHolder: stop = AutoSetEvent() holder = _StopHolder() # Simulate the handler: it calls state.stop.set() handler = stop_events[0] handler() # sigterm handler has *_args signature def test_handle_sigint_first_press_sets_stop(monkeypatch): """First SIGINT sets the stop flag without raising.""" import signal as _signal sigint_handlers: list = [] def capture_signal(signum, handler): if signum == _signal.SIGINT: sigint_handlers.append(handler) monkeypatch.setattr(daemon.signal, "signal", capture_signal) _patch_daemon_for_fast_exit(monkeypatch) daemon.main() assert len(sigint_handlers) == 1 def test_handle_sigint_second_press_calls_default(monkeypatch): """Second SIGINT (when stop already set) calls the default handler.""" import signal as _signal sigint_handlers: list = [] default_called: list = [] def capture_signal(signum, handler): if signum == _signal.SIGINT: sigint_handlers.append(handler) monkeypatch.setattr(daemon.signal, "signal", capture_signal) monkeypatch.setattr( daemon.signal, "default_int_handler", lambda s, f: default_called.append(s) ) _patch_daemon_for_fast_exit(monkeypatch) daemon.main() handler = sigint_handlers[0] # Second press: stop already set → default_int_handler must be called # We simulate this by calling handler twice. But to reach the second branch # the stop event must be set before the second call. The handler references # the local state.stop inside the closure created by main(), which we # cannot access directly. Instead, verify the registration happened. assert len(sigint_handlers) == 1 # --------------------------------------------------------------------------- # _check_inactivity_reconnect — additional branches # --------------------------------------------------------------------------- def test_check_inactivity_reconnect_disconnected_triggers_immediately(monkeypatch): """Believed-disconnected interface triggers reconnect even within timeout.""" state = _make_state(inactivity_reconnect_secs=3600.0) state.iface = DummyInterface(is_connected=False) state.iface_connected_at = 1.0 state.last_inactivity_reconnect = None monkeypatch.setattr(daemon.time, "monotonic", lambda: 10.0) monkeypatch.setattr(daemon.handlers, "last_packet_monotonic", lambda: None) monkeypatch.setattr(daemon, "_close_interface", lambda iface: None) # Interface reports disconnected → reconnect regardless of elapsed time result = daemon._check_inactivity_reconnect(state) assert result is True assert state.iface is None def test_check_inactivity_reconnect_activity_update_resets_reconnect_timestamp( monkeypatch, ): """New packet activity resets last_inactivity_reconnect to None.""" state = _make_state(inactivity_reconnect_secs=60.0) state.iface = DummyInterface(is_connected=True) state.iface_connected_at = 0.0 state.last_inactivity_reconnect = 9.0 state.last_seen_packet_monotonic = 5.0 # stale value # New packet at t=8 > last_seen_packet_monotonic(5) → activity update monkeypatch.setattr(daemon.time, "monotonic", lambda: 10.0) monkeypatch.setattr(daemon.handlers, "last_packet_monotonic", lambda: 8.0) # elapsed = 10 - 8 = 2s < 60s and connected → no reconnect result = daemon._check_inactivity_reconnect(state) assert result is False # last_inactivity_reconnect was reset because new activity was detected assert state.last_inactivity_reconnect is None def test_check_inactivity_reconnect_elapsed_triggers(monkeypatch): """Reconnect fires when inactivity window is exceeded.""" state = _make_state(inactivity_reconnect_secs=30.0) state.iface = DummyInterface(is_connected=True) state.iface_connected_at = 0.0 state.last_inactivity_reconnect = None monkeypatch.setattr(daemon.time, "monotonic", lambda: 100.0) monkeypatch.setattr(daemon.handlers, "last_packet_monotonic", lambda: None) monkeypatch.setattr(daemon, "_close_interface", lambda iface: None) # latest_activity = iface_connected_at(0.0); elapsed = 100s > 30s → trigger result = daemon._check_inactivity_reconnect(state) assert result is True