mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-10-31 03:51:58 +01:00
Add MESHCORE_TELEMETRY_POLL_24H_EXEMPT cooldown exemption for contact telemetry (#905)
This commit is contained in:
1 parent
3d89366e89
commit
0c81dbe073
6 files changed
+124
-3
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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). |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"})
|
||||
@@ -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
|
||||
Reference in new issue
Block a user