Add MESHCORE_TELEMETRY_POLL_24H_EXEMPT cooldown exemption for contact telemetry (#905)

This commit is contained in:
Nic Jansma authored and GitHub committed 2026-09-07 08:18:52 +02:00
1 parent 3d89366e89
commit 0c81dbe073
6 files changed
+124 -3

No files matched your search

+4
View File
@@ -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
+1
View File
@@ -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). |
+54
View File
@@ -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"
)
@@ -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
+23
View File
@@ -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"})
+24
View File
@@ -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