From 0c81dbe073a076ca000bbb3f4b264e88befa2222 Mon Sep 17 00:00:00 2001 From: Nic Jansma Date: Mon, 7 Sep 2026 02:18:52 -0400 Subject: [PATCH] Add MESHCORE_TELEMETRY_POLL_24H_EXEMPT cooldown exemption for contact telemetry (#905) --- .env.example | 4 ++ README.md | 1 + data/mesh_ingestor/config.py | 54 +++++++++++++++++++ .../protocols/meshcore/telemetry.py | 21 ++++++-- tests/test_config_unit.py | 23 ++++++++ tests/test_provider_unit.py | 24 +++++++++ 6 files changed, 124 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 67ee8505..68c13e4a 100644 --- a/.env.example +++ b/.env.example @@ -175,6 +175,10 @@ CHANNEL_INDEX=0 # (polling other nodes transmits). 0 disables on-air polling. # MESHCORE_TELEMETRY_POLL_SECONDS=300 +# Comma-separated node ids exempt from the once-per-24h telemetry cooldown; +# listed nodes are re-polled every round-robin rotation. Requires TX_ENABLED=1. +# MESHCORE_TELEMETRY_POLL_24H_EXEMPT=!abc12345,!def67890 + # Default map zoom override # MAP_ZOOM=15 diff --git a/README.md b/README.md index b0f7e282..4f7bace0 100644 --- a/README.md +++ b/README.md @@ -295,6 +295,7 @@ Configure with the environment variables below. | `RETICULUM_PRESET` | _from RNS config_ | Radio preset shown for Reticulum nodes. Overrides the value derived from your bandwidth/spreading-factor/coding-rate. | | `RETICULUM_INTERFACES` | _unset_ | Which RNS interfaces to ingest from, e.g. `RNode`. Comma-separated, case-insensitive substring match against the names in `rnstatus`. Your own nodes are always ingested; empty ingests everything. See [Reticulum](#reticulum). | | `MESHCORE_TELEMETRY_POLL_SECONDS` | `300` | Requires `TX_ENABLED=1` (polling other nodes is a transmission). Seconds between Meshcore contact telemetry polls (one on-air request per interval, round-robin over the roster; each contact is additionally polled at most once per 24 h: when every contact is fresh the tick sends nothing). Set `0` to disable on-air polling. | +| `MESHCORE_TELEMETRY_POLL_24H_EXEMPT` | _unset_ | Comma-separated node ids exempt from the once-per-24 h cap; listed nodes are polled every round-robin rotation (still one request per interval). | | `MESHCORE_SELF_TELEMETRY_SECONDS` | `3600` | Seconds between Meshcore host self-telemetry reads (battery/sensors over the companion link, no airtime). Set `0` to disable. | | `TX_ENABLED` | `0` | Master switch for all ingestor transmissions. `0` = listen only. `1` = allow transmit - enables Meshcore on-air telemetry polling; does not by itself enable announcements. See [Transmitting on the mesh](#transmitting-on-the-mesh). | | `TX_ANNOUNCE` | `0` | Requires `TX_ENABLED=1`. Broadcasts a one-line activity summary at most once per 24 h, never in the first 24 h after start. See [Transmitting on the mesh](#transmitting-on-the-mesh). | diff --git a/data/mesh_ingestor/config.py b/data/mesh_ingestor/config.py index 80047a98..4879a68f 100644 --- a/data/mesh_ingestor/config.py +++ b/data/mesh_ingestor/config.py @@ -409,6 +409,60 @@ regardless of roster size. Values ``<= 0`` disable contact polling entirely ``MESHCORE_SELF_TELEMETRY_SECONDS``). Stripped with a default fallback like ``MESH_UDP_PORT`` so a blank value in a ``.env`` file cannot break startup.""" + +def _parse_24h_exempt_nodes(raw_value: str | None) -> frozenset[str]: + """Normalise a comma-separated node-id list into canonical ids. + + Backs :data:`MESHCORE_TELEMETRY_POLL_24H_EXEMPT`. Each fragment is + canonicalised via + :func:`~data.mesh_ingestor.node_identity.canonical_node_id` so operator + spellings (``!AABBCCDD``, ``0xdeadbeef``) all match the ``!xxxxxxxx`` ids + the poll loop derives from roster public keys. Unparseable fragments are + dropped with a warning rather than raising — a typo costs one allowlist + entry, never ingestor startup (the fail-safe posture of :func:`_env_flag`). + + Parameters: + raw_value: Raw environment string of comma-separated node ids. + + Returns: + Frozen set of canonical node ids; empty when unset or blank. + """ + + if not raw_value: + return frozenset() + # Imported lazily: node_identity is dependency-free, but config is imported + # extremely early and keeps its module graph minimal on purpose. + from .node_identity import canonical_node_id + + resolved: set[str] = set() + for part in raw_value.split(","): + fragment = part.strip() + if not fragment: + continue + canonical = canonical_node_id(fragment) + if canonical is None: + _debug_log( + "Unparseable MESHCORE_TELEMETRY_POLL_24H_EXEMPT entry dropped", + context="config", + severity="warning", + value=fragment, + ) + continue + resolved.add(canonical) + return frozenset(resolved) + + +MESHCORE_TELEMETRY_POLL_24H_EXEMPT = _parse_24h_exempt_nodes( + os.environ.get("MESHCORE_TELEMETRY_POLL_24H_EXEMPT") +) +"""Roster node ids exempt from the 24 h contact-telemetry cooldown. + +Listed nodes stay eligible on every round-robin rotation, so each is polled +roughly every ``eligible_nodes x MESHCORE_TELEMETRY_POLL_SECONDS`` instead of +once per 24 h. The one-request-per-interval airtime bound and the +``TX_ENABLED`` transmit gate (SPEC MA7) are unaffected. Unset means every +contact keeps the 24 h cooldown.""" + MESHCORE_SELF_TELEMETRY_SECONDS = int( os.environ.get("MESHCORE_SELF_TELEMETRY_SECONDS", "3600").strip() or "3600" ) diff --git a/data/mesh_ingestor/protocols/meshcore/telemetry.py b/data/mesh_ingestor/protocols/meshcore/telemetry.py index c9543985..f6789559 100644 --- a/data/mesh_ingestor/protocols/meshcore/telemetry.py +++ b/data/mesh_ingestor/protocols/meshcore/telemetry.py @@ -36,6 +36,7 @@ import time from collections.abc import Mapping from ... import activity, config, tx_policy +from .identity import _meshcore_node_id from .interface import _MeshcoreInterface from .messages import _derive_message_id @@ -47,7 +48,9 @@ round-robin would revisit each node every ``roster_size × interval`` — far more often than telemetry freshness needs. This per-node cooldown caps every contact at one poll per 24 h (counted from the poll attempt, so unreachable nodes are not hammered either); when every contact is fresh the tick sends -nothing. Deliberately a constant, not an environment knob.""" +nothing. Deliberately a constant, not an environment knob; the per-node escape +hatch is :data:`~data.mesh_ingestor.config.MESHCORE_TELEMETRY_POLL_24H_EXEMPT`, +which exempts listed nodes from this cooldown entirely.""" _LPP_TYPE_NAMES: dict[int, str] = { @@ -316,7 +319,10 @@ def _next_poll_contact(iface: _MeshcoreInterface, state: dict) -> dict | None: """Pick the next roster contact due for a telemetry poll, round-robin. Contacts polled within :data:`_TELEMETRY_NODE_COOLDOWN_SECONDS` are - skipped; the returned contact is stamped as polled immediately (before the + skipped — unless listed in + :data:`~data.mesh_ingestor.config.MESHCORE_TELEMETRY_POLL_24H_EXEMPT`, + which keeps them eligible on every rotation (still at most one request + per tick); the returned contact is stamped as polled immediately (before the request is sent), so failed or timed-out polls honour the cooldown too. Departed roster entries are pruned from the stamp table so a long-running process cannot accumulate stale state. @@ -346,7 +352,16 @@ def _next_poll_contact(iface: _MeshcoreInterface, state: dict) -> dict | None: contact = contacts[index] key = contact.get("public_key") stamp = last_polled.get(key) - if stamp is not None and now - stamp < _TELEMETRY_NODE_COOLDOWN_SECONDS: + # Operator-listed nodes (MESHCORE_TELEMETRY_POLL_24H_EXEMPT) skip the + # 24 h cooldown and stay eligible every rotation; the one-request-per + # tick airtime bound still applies because this picker returns one + # contact regardless. Read at call time so tests (and a future + # reload) observe the live set. + if ( + stamp is not None + and now - stamp < _TELEMETRY_NODE_COOLDOWN_SECONDS + and _meshcore_node_id(key) not in config.MESHCORE_TELEMETRY_POLL_24H_EXEMPT + ): continue state["cursor"] = index + 1 last_polled[key] = now diff --git a/tests/test_config_unit.py b/tests/test_config_unit.py index 4d41405c..db4b6bd2 100644 --- a/tests/test_config_unit.py +++ b/tests/test_config_unit.py @@ -845,3 +845,26 @@ class TestUdpTransportAllExports: """TRANSPORT, PRIMARY_CHANNEL_ONLY, and the UDP vars are in __all__.""" for name in _UDP_ENV_VARS: assert name in config.__all__ + + +# _parse_24h_exempt_nodes + + +class TestParse24hExemptNodes: + """Tests for :func:`config._parse_24h_exempt_nodes`.""" + + def test_none_empty_and_blank_fragments_yield_empty_set(self): + """Unset, empty, and separator-only values disable the exemption.""" + assert config._parse_24h_exempt_nodes(None) == frozenset() + assert config._parse_24h_exempt_nodes("") == frozenset() + assert config._parse_24h_exempt_nodes(" , ,") == frozenset() + + def test_entries_normalise_to_canonical_ids(self): + """Whitespace, case, and 0x-prefixed entries all canonicalise.""" + result = config._parse_24h_exempt_nodes(" !AABBCCDD , 0xDEADBEEF ") + assert result == frozenset({"!aabbccdd", "!deadbeef"}) + + def test_junk_entries_are_dropped_not_fatal(self): + """An unparseable entry is dropped; valid siblings survive.""" + result = config._parse_24h_exempt_nodes("!aabbccdd,not-a-node-id") + assert result == frozenset({"!aabbccdd"}) diff --git a/tests/test_provider_unit.py b/tests/test_provider_unit.py index 31d68904..e344a2b8 100644 --- a/tests/test_provider_unit.py +++ b/tests/test_provider_unit.py @@ -5448,3 +5448,27 @@ def test_reticulum_interface_close_is_idempotent(): iface.close() iface.close() # must not raise assert iface.isConnected is False + + +def test_next_poll_contact_24h_exempt_nodes_skip_cooldown(monkeypatch): + """Allowlisted nodes stay eligible every rotation while unlisted roster + contacts keep the 24 h cooldown, preserving one request per tick.""" + second_key = "bbccddeeff00" + "11" * 26 + mc_tel, iface, _stub, _captured = _telemetry_env( + monkeypatch, + contacts=[ + {"public_key": _TEST_CONTACT_KEY, "adv_name": "A"}, + {"public_key": second_key, "adv_name": "B"}, + ], + ) + monkeypatch.setattr( + mc_tel.config, "MESHCORE_TELEMETRY_POLL_24H_EXEMPT", frozenset({"!aabbccdd"}) + ) + state: dict = {} + # First rotation reaches both contacts, stamping each. + assert mc_tel._next_poll_contact(iface, state)["public_key"] == _TEST_CONTACT_KEY + assert mc_tel._next_poll_contact(iface, state)["public_key"] == second_key + # Later rotations: only the exempt contact is eligible again — repeatedly — + # while the unlisted contact stays inside its 24 h cooldown. + assert mc_tel._next_poll_contact(iface, state)["public_key"] == _TEST_CONTACT_KEY + assert mc_tel._next_poll_contact(iface, state)["public_key"] == _TEST_CONTACT_KEY