diff --git a/ACCEPTANCE.md b/ACCEPTANCE.md index bc583326..58fbcc5d 100644 --- a/ACCEPTANCE.md +++ b/ACCEPTANCE.md @@ -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 `` = the target's `GET /api/stats` `.nodes.day` and `` = line is truncated to the protocol's character limit. `` = 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index fc2a1ddb..ce42284a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `.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) diff --git a/README.md b/README.md index 9e0e31b8..3d0f8d1a 100644 --- a/README.md +++ b/README.md @@ -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 (`" activity in the last 24h: active nodes, packets/hour. "`) 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. | diff --git a/SPEC.md b/SPEC.md index caad3b26..7472951f 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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 `.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 `.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: `" activity in the last 24h: active nodes, packets/hour. https://"`. `` and `` are fetched **from the target instance** (`GET /api/stats` → `.nodes.day` and `.packets.hour`), never computed from the ingestor's local view — because one ingestor may not see the whole mesh. `` 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 `/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 `/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 | diff --git a/data/mesh_ingestor/announce.py b/data/mesh_ingestor/announce.py index 3c3731f1..b6a1c640 100644 --- a/data/mesh_ingestor/announce.py +++ b/data/mesh_ingestor/announce.py @@ -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) diff --git a/data/mesh_ingestor/config.py b/data/mesh_ingestor/config.py index 62116cc4..16e7c5e9 100644 --- a/data/mesh_ingestor/config.py +++ b/data/mesh_ingestor/config.py @@ -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" ) diff --git a/data/mesh_ingestor/daemon.py b/data/mesh_ingestor/daemon.py index 678b6643..5e6114c0 100644 --- a/data/mesh_ingestor/daemon.py +++ b/data/mesh_ingestor/daemon.py @@ -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 diff --git a/tests/test_announce_unit.py b/tests/test_announce_unit.py index 1b3da463..1ba98236 100644 --- a/tests/test_announce_unit.py +++ b/tests/test_announce_unit.py @@ -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