mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-10-31 03:51:58 +01:00
Make the activity announcement opt-in via ANNOUNCE (default off) (#886)
The periodic activity announcement was gated only by RX_ONLY, which defaults to off, so a stock ingestor deployed to feed a map starts broadcasting on its community's default channel without the operator having chosen to. The only opt-out, RX_ONLY=1, also silences the MeshCore telemetry polls, so "ingest but stay quiet" was not expressible. Add an ANNOUNCE boolean (default 0) as an opt-in gate on top of RX_ONLY, which keeps its meaning as the global TX kill switch and overrides ANNOUNCE=1. Amend SPEC MA7 (which had explicitly ruled out a separate enable env) and MA-A7 with the reasoning, document both flags in the README env table — the RX_ONLY row did not mention the announcement at all — and add gate unit tests for all four combinations plus the missing-attribute fail-closed path.
This commit is contained in:
1 parent
f43270d351
commit
376320b2e2
8 files changed
+72
-25
No files matched your search
+11
-10
@@ -4002,7 +4002,8 @@ merged `packets` figure, appends the per-interval delta to its hourly
|
||||
`MAX`-per-protocol 24 h packets/hour moving average (MA4–MA5). Each ingestor then
|
||||
periodically broadcasts a one-line activity summary — numbers **dogfed from the
|
||||
target instance's own API** — on its protocol's default channel (MA6), gated by
|
||||
`RX_ONLY` (reused as the transmit gate), the target's `/version` privacy flag
|
||||
the `ANNOUNCE` opt-in (default `0`) and `RX_ONLY` (the global TX kill switch,
|
||||
which overrides it), the target's `/version` privacy flag
|
||||
(fail-closed), and a ≥ 24 h post-start delay (MA7–MA8), via an **optional**
|
||||
duck-typed provider send
|
||||
that leaves `MeshProtocol` conformance intact (MA9). Unless a check says
|
||||
@@ -4087,19 +4088,19 @@ where `<N>` = the target's `GET /api/stats` `<protocol>.nodes.day` and `<M>` =
|
||||
line is truncated to the protocol's character limit. `<domain>` = the configured
|
||||
`INSTANCE_DOMAIN`.
|
||||
|
||||
### MA-A7 — Announcement gates: RX_ONLY, privacy fail-closed, 24 h — MA7
|
||||
### MA-A7 — Announcement gates: ANNOUNCE opt-in, RX_ONLY, privacy fail-closed, 24 h — MA7
|
||||
```bash
|
||||
( . .venv/bin/activate && pytest -q tests/test_announce_unit.py \
|
||||
-k "gate or private or rx_only or elapsed" )
|
||||
```
|
||||
**Expected:** pass. **No** announcement is sent when any gate fails: `RX_ONLY=1`
|
||||
(the **reused** receive-only flag — its existing MeshCore-poll TX suppression now
|
||||
also covers the announcement, so it is the sole opt-out; no separate `ENABLE_TX`
|
||||
env exists); the target `/version` reports `private_mode: true`; the `/version`
|
||||
fetch **errors or is unparseable** (fail-closed — treated as private/skip); or
|
||||
`< 24 h` have elapsed since ingestor start. With `RX_ONLY` unset (the default
|
||||
`0`), `private_mode: false`, and `≥ 24 h` elapsed, exactly one announcement per
|
||||
24 h per domain is transmitted.
|
||||
**Expected:** pass. **No** announcement is sent when any gate fails: `ANNOUNCE` is
|
||||
unset (the default `0` — the announcement is opt-in, so an ingestor deployed to
|
||||
feed a map never transmits on its own); `RX_ONLY=1` (the receive-only flag still
|
||||
forbids every ingestor TX and overrides `ANNOUNCE=1`); the target `/version`
|
||||
reports `private_mode: true`; the `/version` fetch **errors or is unparseable**
|
||||
(fail-closed — treated as private/skip); or `< 24 h` have elapsed since ingestor
|
||||
start. With `ANNOUNCE=1`, `RX_ONLY` unset, `private_mode: false`, and `≥ 24 h`
|
||||
elapsed, exactly one announcement per 24 h per domain is transmitted.
|
||||
|
||||
### MA-A8 — Default channel/scope + 24 h cadence — MA8
|
||||
```bash
|
||||
|
||||
@@ -26,6 +26,7 @@ table, and reported-only disclosure rows.
|
||||
* Web: nodes table gains grouped headers, curated mobile columns (Battery survives), a per-row disclosure of hidden fields, row hover/click, numeric alignment, captions/scopes
|
||||
|
||||
### Fixes
|
||||
* Ingestor: the periodic activity announcement is now **opt-in** via a new `ANNOUNCE` env var (default `0`) instead of on by default. It previously shipped with `RX_ONLY` as its only gate, so a stock ingestor deployed to feed a map began broadcasting on its community's default channel unprompted, and the sole opt-out also silenced the MeshCore telemetry polls. `RX_ONLY=1` still forbids every ingestor TX and overrides `ANNOUNCE=1`; the README env table now documents both (SPEC MA7, amended)
|
||||
* Web: mesh-activity card — idle card no longer paints an empty pill over the map on phones (≤659px), the mobile strip spans the map as a caption, the sparkline gains headroom and rebases with the protocol toggles, the card is a labelled `role="group"`, and the /charts intro heading drops the single-protocol badge (design-review remediation, SPEC MR1–MR8)
|
||||
* Web: neighbor/trace line legend toggles highlight when their lines are visible (was reversed — highlighted when hidden), matching the role chips (SPEC NT1)
|
||||
* Web/data: `GET /api/stats` exposes the mesh-activity packets/hour rate as `<scope>.packets.hour` (folded into the S1 scope→metric→window tree) rather than a top-level `packets_per_hour` map, and `total.packets.hour` now **sums** the per-protocol rates instead of taking a single MAX vantage (distinct protocols never share the air, so they add); the ingestor announcement dogfeed reads the new path (SPEC MA4/MA5, pre-release amendment of the merged-but-unreleased #859)
|
||||
|
||||
@@ -116,7 +116,8 @@ The web app can be configured with environment variables (defaults shown):
|
||||
| `INGESTOR_NODE_ID` | _unset_ | `!xxxxxxxx` id used for the ingestor heartbeat in UDP transport (which cannot auto-detect "self"). |
|
||||
| `MESHCORE_TELEMETRY_POLL_SECONDS` | `300` | 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_SELF_TELEMETRY_SECONDS` | `3600` | Seconds between MeshCore host self-telemetry reads (battery/sensors over the companion link, no airtime). Set `0` to disable. |
|
||||
| `RX_ONLY` | `0` | Set to `1` to forbid every ingestor-initiated mesh transmission (receive-only listening post). Currently disables the MeshCore contact telemetry/status polls; local companion-link reads (self telemetry, roster, channels) continue. |
|
||||
| `ANNOUNCE` | `0` | Set to `1` to let the ingestor broadcast a one-line activity summary (`"<Protocol> activity in the last 24h: <N> active nodes, <M> packets/hour. <INSTANCE_DOMAIN>"`) on its default channel — at most once per 24 h, and never in the first 24 h after start. **Off by default**: it is the ingestor's only unsolicited transmission on a shared human channel, and communities differ on whether automated traffic is welcome there. `RX_ONLY=1` overrides it. |
|
||||
| `RX_ONLY` | `0` | Set to `1` to forbid every ingestor-initiated mesh transmission (receive-only listening post). Disables the MeshCore contact telemetry/status polls and the `ANNOUNCE` activity broadcast; local companion-link reads (self telemetry, roster, channels) continue. |
|
||||
| `FEDERATION` | `1` | Set to `1` to announce your instance and crawl peers, or `0` to disable federation. Private mode overrides this. |
|
||||
| `PRIVATE` | `0` | Set to `1` to hide the chat UI, disable message APIs, and exclude hidden clients from public listings. |
|
||||
| `EVENTS` | `1` | Set to `0` to disable the live-update SSE stream (`GET /api/events`); clients then fall back to polling at the refresh interval. |
|
||||
|
||||
@@ -1100,7 +1100,7 @@ contradicted.
|
||||
| **MA4** | **Aggregation = MAX per protocol; `total` = SUM across protocols (dedup-free "in the air").** The mesh-wide packets/hour for a protocol is `MAX` over that protocol's ingestors of *(that ingestor's total `packets` reported in the last 24 h ÷ 24)*. A single radio can only hear ≤ what is actually transmitted, so the busiest single vantage is the best dedup-free estimate of unique air traffic and can never double-count a frame heard by two radios (true per-frame dedup is impossible anyway — ignored/errored frames carry no id). The **`total`** rate is the **SUM** of the per-protocol rates — different protocols ride different frequencies/channels, so their frames never overlap in the air and add rather than dedup. **Amended pre-release:** `total` first shipped as a MAX over *every* ingestor regardless of protocol, which under-counted it to the single busiest protocol (e.g. meshcore 44 + meshtastic 76 rendered as 76, not 120); corrected to the cross-protocol SUM. The fixed `÷ 24` denominator (not ÷ elapsed) keeps the rate stable and avoids divide-by-small spikes; an ingestor with < 24 h of data simply reads lower and ramps up (moot in practice — the announcement fires only ≥ 24 h after start, MA7). *Accepted limitation:* the per-protocol MAX under-estimates the true union when different radios are busiest in different hours. | interview |
|
||||
| **MA5** | **Exposure as an additive per-scope `packets` metric.** `GET /api/stats` exposes the MA4 24 h MAX-aggregated moving average under each scope as `<scope>.packets.hour` — a `packets` metric carrying a single `hour` window (it is a rate, not a windowed count, so no day/week/month keys), consistent with the S1 `scope → metric → window` layout. `reticulum.packets.hour` is a forward-looking `0` stub (S6); `total.packets.hour` is the **SUM** of the per-protocol rates (MA4). The rest of the S1 tree is untouched, so this needs **no** version bump. The announcement's active-node figure reuses the existing `<protocol>.nodes.day` count (already deduped by `node_id`); only packets/hour is new. **Amended pre-release:** the field originally shipped (unreleased, on `main`) as a top-level `packets_per_hour: { total, meshcore, meshtastic, reticulum }` map; it is superseded here by the per-scope `packets.hour` form for consistency with the S1 tree. Because it was merged but **not yet in any tagged release** and is **not** on the signed federation wire, the reshape carries **no** version bump, federation-compat fallback, or signature break — only the in-repo `announce.py` dogfeed reader (MA6) and the specs/docs move with it. | proposed (amended) |
|
||||
| **MA6** | **Announcement content, drawn from the instance's own API (dogfeeding).** Each ingestor broadcasts, for its own configured protocol, a single line formatted to that protocol's character limit: `"<Protocol> activity in the last 24h: <N> active nodes, <M> packets/hour. https://<domain>"`. `<N>` and `<M>` are fetched **from the target instance** (`GET /api/stats` → `<protocol>.nodes.day` and `<protocol>.packets.hour`), never computed from the ingestor's local view — because one ingestor may not see the whole mesh. `<domain>` is the configured `INSTANCE_DOMAIN`. Reporting (MA1–MA4) is independent and continues regardless of announcement state. | interview |
|
||||
| **MA7** | **Announcement is triple-gated.** An announcement is transmitted only when **all** hold: (a) `RX_ONLY` is unset — the **reused** receive-only flag (default `0`) is the single transmit gate; `RX_ONLY=1` forbids *every* ingestor TX (the MeshCore polls and the announcement alike), so it is the sole opt-out and **no separate `ENABLE_TX` env is added**; (b) the target instance reports **non-private** — the ingestor GETs `<domain>/version` and honors `config.private_mode`, re-checked every cycle, and **fails closed** (skips) on any fetch/parse error, so a privacy signal is never missed (Invariant II); (c) **≥ 24 h have elapsed since ingestor start** — so the first numbers are accurate over a full window and restarts cannot spam the channel. | interview |
|
||||
| **MA7** | **Announcement is quadruple-gated (opt-in).** An announcement is transmitted only when **all** hold: (a) `ANNOUNCE=1` — a dedicated boolean, **default `0`**, and `RX_ONLY` is unset; `RX_ONLY=1` still forbids *every* ingestor TX (the MeshCore polls and the announcement alike) and so overrides `ANNOUNCE`. **Amended:** MA7 originally made `RX_ONLY` the *single* transmit gate with **no separate enable env**, which left the announcement **on by default**. In the field that made every stock ingestor start talking on its community's default channel without its operator having chosen to, and the only opt-out — `RX_ONLY` — also silences the MeshCore telemetry polls, so an operator who wants ingestion but not chatter had no setting for it. Reversed to opt-in: unsolicited TX on a shared human channel is a decision each operator's local community norms must make, not a default (Invariant II, consent). `RX_ONLY` keeps its meaning as the global TX kill switch; (b) the target instance reports **non-private** — the ingestor GETs `<domain>/version` and honors `config.private_mode`, re-checked every cycle, and **fails closed** (skips) on any fetch/parse error, so a privacy signal is never missed (Invariant II); (c) **≥ 24 h have elapsed since ingestor start** — so the first numbers are accurate over a full window and restarts cannot spam the channel. | interview |
|
||||
| **MA8** | **Default channel/scope + 24 h cadence.** The announcement is broadcast on the protocol's **default channel and scope** — Meshtastic channel `CHANNEL_INDEX` (default `0`) via the interface text-send; MeshCore its public/default channel — honoring existing `ALLOWED_CHANNELS`/`HIDDEN_CHANNELS` intent. After the initial ≥ 24 h wait it repeats **every 24 h**, per configured instance domain (an ingestor with several `INSTANCE_DOMAIN` targets announces each with its own numbers and link). TX volume is negligible (~1 frame/day/domain). | proposed |
|
||||
| **MA9** | **Optional duck-typed provider send.** Transmission is exposed as a new **optional** provider method (e.g. `send_channel_announcement(iface, text)`) accessed via `getattr(provider, …, None)` — mirroring the existing optional `self_node_item` extension — so the `@runtime_checkable MeshProtocol` interface and its `A4b` isinstance conformance are unchanged, and a provider that cannot transmit (or a receive-only transport) simply omits it. Both `meshtastic` and `meshcore` providers implement it; neither protocol is privileged. | interview |
|
||||
| **MA10** | **Engineering bar (D9).** Every new/changed unit ships with 100 % unit tests (counter increment across RX families + TX; heartbeat delta + reset; activity-table write + retention; MAX-aggregation math incl. multi-ingestor; the `/api/stats` field; the four announcement gates incl. fail-closed privacy and the 24 h wait; per-protocol send), full PDoc/RDoc, Apache headers, `black`/`rufo` clean; `pytest`/`rspec`/`npm test` stay green; `CONTRACTS.md` documents the additive heartbeat field, the activity schema, and the `/api/stats` addition. | CLAUDE.md |
|
||||
|
||||
@@ -14,15 +14,16 @@
|
||||
|
||||
"""Activity announcement: dogfeed the instance API and build the broadcast text.
|
||||
|
||||
SPEC decision **MA6** — the ingestor periodically broadcasts a one-line activity
|
||||
summary on its protocol's default channel, drawing the numbers **back from the
|
||||
target instance's own API** (dogfeeding: one radio may not see the whole mesh).
|
||||
SPEC decision **MA6** — when the operator opts in (``ANNOUNCE=1``, MA7 a), the
|
||||
ingestor periodically broadcasts a one-line activity summary on its protocol's
|
||||
default channel, drawing the numbers **back from the target instance's own API**
|
||||
(dogfeeding: one radio may not see the whole mesh).
|
||||
|
||||
This module owns the read-only *dogfeed* HTTP client (GET ``/version`` and
|
||||
``/api/stats`` — both public, no auth) and the character-limited message builder.
|
||||
The transmit primitive lives on each provider (``send_channel_announcement``,
|
||||
MA9); the scheduling and the ``RX_ONLY`` / privacy / 24-hour gates live in the
|
||||
daemon (MA7/MA8). Every fetch fails **soft** — any network or
|
||||
MA9); the scheduling and the ``ANNOUNCE`` / ``RX_ONLY`` / privacy / 24-hour gates
|
||||
live in the daemon (MA7/MA8). Every fetch fails **soft** — any network or
|
||||
shape error returns ``None`` so the caller can fail closed.
|
||||
"""
|
||||
|
||||
@@ -224,14 +225,19 @@ ANNOUNCE_INTERVAL_SECS = 24 * 60 * 60
|
||||
def announcements_enabled() -> bool:
|
||||
"""Return whether announcements may be transmitted at all (SPEC MA7 a).
|
||||
|
||||
``True`` only when :data:`~data.mesh_ingestor.config.RX_ONLY` is off — the
|
||||
reused receive-only flag (default off) is the single transmit gate; there is
|
||||
no separate enable switch.
|
||||
Two gates, both of which must permit the send:
|
||||
:data:`~data.mesh_ingestor.config.ANNOUNCE` must be **on** (opt-in, default
|
||||
off — an unsolicited line on a shared human channel is not something an
|
||||
operator should get merely by deploying a map ingestor), and
|
||||
:data:`~data.mesh_ingestor.config.RX_ONLY` must be **off** (the global
|
||||
receive-only flag forbids every ingestor TX and so overrides ``ANNOUNCE``).
|
||||
|
||||
Returns:
|
||||
``True`` when the transmit gate permits an announcement.
|
||||
``True`` when the transmit gates permit an announcement.
|
||||
"""
|
||||
|
||||
if not getattr(config, "ANNOUNCE", False):
|
||||
return False
|
||||
return not getattr(config, "RX_ONLY", False)
|
||||
|
||||
|
||||
|
||||
@@ -150,6 +150,19 @@ only ingestor-initiated RF traffic. Local companion-link reads (host
|
||||
self-telemetry, contact roster, channel queries) are not transmissions and
|
||||
continue to work."""
|
||||
|
||||
ANNOUNCE = os.environ.get("ANNOUNCE") == "1"
|
||||
"""Opt in to the periodic activity announcement (SPEC MA7 a).
|
||||
|
||||
The announcement is the ingestor's only *unsolicited* transmission on a shared
|
||||
human channel, so it is **off by default**: an operator who deploys an ingestor
|
||||
to feed a map does not implicitly agree to also speak on their community's
|
||||
default channel, and local conventions on automated traffic differ widely. Set
|
||||
``ANNOUNCE=1`` to enable it.
|
||||
|
||||
This is an opt-in on top of :data:`RX_ONLY`, not a replacement for it:
|
||||
``RX_ONLY=1`` still forbids *every* ingestor-initiated transmission and wins
|
||||
over ``ANNOUNCE=1``."""
|
||||
|
||||
MESHCORE_TELEMETRY_POLL_SECONDS = int(
|
||||
os.environ.get("MESHCORE_TELEMETRY_POLL_SECONDS", "300").strip() or "300"
|
||||
)
|
||||
|
||||
@@ -582,7 +582,8 @@ def _try_send_self_node(state: _DaemonState) -> None:
|
||||
def _process_announcements(state: _DaemonState) -> float | None:
|
||||
"""Run the periodic activity-announcement cycle when scheduled (SPEC MA6-MA8).
|
||||
|
||||
Delegates the transmit gate (``RX_ONLY``), the per-instance fail-closed
|
||||
Delegates the transmit gates (the ``ANNOUNCE`` opt-in and the ``RX_ONLY``
|
||||
override), the per-instance fail-closed
|
||||
privacy check, the >=24h post-start delay, and the 24h cadence to
|
||||
:func:`~data.mesh_ingestor.announce.maybe_run_announcements`, which dogfeeds
|
||||
each configured instance's own API for the numbers it announces. A no-op
|
||||
|
||||
@@ -276,16 +276,36 @@ def _stub_dogfeed(monkeypatch, *, private=False, numbers=(12, 50)):
|
||||
class TestAnnouncementGates:
|
||||
"""Tests for :func:`announce.announcements_enabled` (SPEC MA7 a)."""
|
||||
|
||||
def test_gate_enabled_by_default(self, monkeypatch):
|
||||
"""Enabled when RX_ONLY is off (the default)."""
|
||||
def test_gate_disabled_by_default(self, monkeypatch):
|
||||
"""Off by default: ANNOUNCE unset means no unsolicited TX."""
|
||||
monkeypatch.setattr(announce.config, "ANNOUNCE", False)
|
||||
monkeypatch.setattr(announce.config, "RX_ONLY", False)
|
||||
assert announce.announcements_enabled() is False
|
||||
|
||||
def test_gate_enabled_when_opted_in(self, monkeypatch):
|
||||
"""Enabled when the operator opts in and RX_ONLY is off."""
|
||||
monkeypatch.setattr(announce.config, "ANNOUNCE", True)
|
||||
monkeypatch.setattr(announce.config, "RX_ONLY", False)
|
||||
assert announce.announcements_enabled() is True
|
||||
|
||||
def test_gate_disabled_when_rx_only(self, monkeypatch):
|
||||
"""RX_ONLY (reused as the single transmit gate) forbids the announcement."""
|
||||
"""RX_ONLY forbids every ingestor TX and so overrides the opt-in."""
|
||||
monkeypatch.setattr(announce.config, "ANNOUNCE", True)
|
||||
monkeypatch.setattr(announce.config, "RX_ONLY", True)
|
||||
assert announce.announcements_enabled() is False
|
||||
|
||||
def test_gate_disabled_when_rx_only_and_not_opted_in(self, monkeypatch):
|
||||
"""Both gates closed is still closed (no double-negative surprise)."""
|
||||
monkeypatch.setattr(announce.config, "ANNOUNCE", False)
|
||||
monkeypatch.setattr(announce.config, "RX_ONLY", True)
|
||||
assert announce.announcements_enabled() is False
|
||||
|
||||
def test_gate_defaults_to_off_when_attribute_missing(self, monkeypatch):
|
||||
"""A config module without ``ANNOUNCE`` fails closed, not open."""
|
||||
monkeypatch.delattr(announce.config, "ANNOUNCE", raising=False)
|
||||
monkeypatch.setattr(announce.config, "RX_ONLY", False)
|
||||
assert announce.announcements_enabled() is False
|
||||
|
||||
|
||||
class TestAnnounceDue:
|
||||
"""Tests for :func:`announce.announce_due` (SPEC MA7 d / MA8)."""
|
||||
@@ -474,6 +494,7 @@ class TestMaybeRunAnnouncements:
|
||||
|
||||
def test_gate_noop_when_not_enabled(self, monkeypatch):
|
||||
"""No cycle runs and last_announce is unchanged when RX_ONLY forbids TX."""
|
||||
monkeypatch.setattr(announce.config, "ANNOUNCE", True)
|
||||
monkeypatch.setattr(announce.config, "RX_ONLY", True)
|
||||
ran = []
|
||||
monkeypatch.setattr(
|
||||
@@ -487,6 +508,7 @@ class TestMaybeRunAnnouncements:
|
||||
|
||||
def test_cadence_noop_when_not_due(self, monkeypatch):
|
||||
"""No cycle runs before the 24 h delay; last_announce is unchanged."""
|
||||
monkeypatch.setattr(announce.config, "ANNOUNCE", True)
|
||||
monkeypatch.setattr(announce.config, "RX_ONLY", False)
|
||||
ran = []
|
||||
monkeypatch.setattr(
|
||||
@@ -501,6 +523,7 @@ class TestMaybeRunAnnouncements:
|
||||
|
||||
def test_cadence_runs_cycle_and_advances_timestamp(self, monkeypatch):
|
||||
"""When due, the cycle runs and last_announce advances to now."""
|
||||
monkeypatch.setattr(announce.config, "ANNOUNCE", True)
|
||||
monkeypatch.setattr(announce.config, "RX_ONLY", False)
|
||||
ran = []
|
||||
monkeypatch.setattr(
|
||||
@@ -517,6 +540,7 @@ class TestMaybeRunAnnouncements:
|
||||
|
||||
def test_cadence_defaults_now_to_wall_clock(self, monkeypatch):
|
||||
"""With ``now`` omitted the current wall clock is used (gate-off path)."""
|
||||
monkeypatch.setattr(announce.config, "ANNOUNCE", True)
|
||||
monkeypatch.setattr(announce.config, "RX_ONLY", True)
|
||||
result = announce.maybe_run_announcements(
|
||||
"P", "I", start_time=0, last_announce=7.0
|
||||
|
||||
Reference in new issue
Block a user