Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 49 additions & 12 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,9 @@ PROTOCOL="meshtastic"

# --- PROTOCOL=reticulum only -------------------------------------------------
# Reticulum (RNS) config directory for the passive announce listener.
# Defaults to an app-owned directory ($XDG_CONFIG_HOME/potato-mesh/reticulum,
# else ~/.config/potato-mesh/reticulum) so the ingestor never adopts the
# operator's own ~/.reticulum transport-node configuration. Set this only to
# deliberately share an existing RNS stack.
# RETICULUM_CONFIG_DIR="/var/lib/potato-mesh/reticulum"
# Defaults to ~/.reticulum, the stack rnsd already uses. Point it elsewhere
# only if the ingestor should read a different RNS config.
# RETICULUM_CONFIG_DIR="/home/you/.reticulum"

# Restrict announce ingestion to named RNS interfaces (comma-separated,
# case-insensitive substring match, e.g. "rnode"). An RNS stack may carry LoRa
Expand All @@ -48,9 +46,9 @@ PROTOCOL="meshtastic"

# INGESTOR_NODE_ID (see below) is an OVERRIDE here, not a requirement. Reticulum
# has no handshake revealing "our" node id, so the ingestor owns an RNS identity
# of its own -- stored as potato_mesh_identity inside RETICULUM_CONFIG_DIR on
# first run -- and derives its !xxxxxxxx from it, the same way it derives every
# peer's. The heartbeat and the packets/hour stats therefore come up unaided.
# of its own: it derives its !xxxxxxxx from the transport identity already in
# RETICULUM_CONFIG_DIR, so the heartbeat and the packets/hour stats come up
# unaided. Set INGESTOR_NODE_ID only to override that.
#
# CONNECTION does not apply. It names one serial/TCP/BLE endpoint, which an RNS
# stack of many interfaces does not have. Its counterparts are the two variables
Expand All @@ -66,12 +64,27 @@ PROTOCOL="meshtastic"
# Your mesh network name
SITE_NAME="My Meshtastic Network"

# Default Meshtastic channel
CHANNEL="#LongFast"
# Meshtastic radio preset shown in the join strip (CHANNEL is a deprecated alias)
MESHTASTIC_PRESET="#LongFast"

# Default frequency for your region
# Meshtastic frequency for your region (FREQUENCY is a deprecated alias)
# Common frequencies: 868MHz (Europe), 915MHz (US), 433MHz (Worldwide)
FREQUENCY="915MHz"
MESHTASTIC_FREQ="915MHz"

# Meshcore preset and frequency. The join strip hides the Meshcore line until
# both are set.
# MESHCORE_PRESET="MediumFast"
# MESHCORE_FREQ="867.5MHz"

# Announcement banner rendered above the header on every page
# ANNOUNCEMENT="Net every Tuesday 20:00"

# Directory for static, custom-content pages served at /pages/:slug
# PAGES_DIR=./pages

# Absolute http(s) URL for the social preview image. Replaces the generated
# /og-image.png for deployments without Chromium.
# OG_IMAGE_URL="https://cdn.example.org/preview.png"

# Map center coordinates (latitude, longitude)
# Berlin, Germany: 52.502889, 13.404194
Expand All @@ -96,6 +109,19 @@ FEDERATION=1
# Hide public mesh messages from unauthenticated visitors (1=hidden, 0=public)
PRIVATE=0

# Comma-separated channel names to ingest. Unset = ingest all channels.
# ALLOWED_CHANNELS=LongFast,Ops

# Comma-separated channel names to drop. Applied after ALLOWED_CHANNELS.
# HIDDEN_CHANNELS=Private

# Live-update SSE stream at /api/events (1=on, 0=off -> clients poll instead)
EVENTS=1

# Comma-separated node ids to expose as per-node Prometheus gauges.
# Unset exports none. See PROMETHEUS.md.
# PROM_REPORT_IDS=!abcd1234,!beef5678


# =============================================================================
# TRANSMIT SETTINGS (your ingestor putting traffic on the air)
Expand Down Expand Up @@ -134,6 +160,17 @@ DEBUG=0
# Energy saving mode — sleep between ingestion cycles (0=off, 1=on)
ENERGY_SAVING=0

# Channel index to ingest from
CHANNEL_INDEX=0

# Seconds between Meshcore host self-telemetry reads over the companion link
# (no airtime). 0 disables.
# MESHCORE_SELF_TELEMETRY_SECONDS=3600

# Seconds between Meshcore contact telemetry polls. Requires TX_ENABLED=1
# (polling other nodes transmits). 0 disables on-air polling.
# MESHCORE_TELEMETRY_POLL_SECONDS=300

# Default map zoom override
# MAP_ZOOM=15

Expand Down
356 changes: 336 additions & 20 deletions ACCEPTANCE.md

Large diffs are not rendered by default.

26 changes: 16 additions & 10 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,29 @@

### Features
* Web/Data: **Reticulum gets a visual identity of its own** — the placeholder tile (an outline triangle on near-black `#010101`, indistinguishable from MeshCore's at 12 px) becomes a trifoil on violet `#7b61ff`; MeshCore's tile moves off pure black to `#1f2937` so it finally agrees with its own badge; the `/charts` mesh-activity lines repoint at their tiles so the table and the figure teach one colour code; a four-step violet role ramp (`PEER`/`NODE`/`TRANSPORT`/`PROPAGATION`) joins the palettes, all clearing the 4.5:1 badge floor; a hexagon joins circle and diamond as the protocol shape channel; and the legend gains a third column (SPEC RD1–RD8)
* Data: Reticulum node roles are derived from the announce aspect and ranked — `lxmf.propagation` joins the ingested aspects, and a node's role is the highest-ranked aspect it announced this session rather than whichever arrived last, so a peer announcing on several aspects no longer flips between roles (SPEC RD4)
* Data: Reticulum node roles are derived from the announce aspect — `lxmf.propagation` joins the ingested aspects, and each destination row carries the role of the aspect it announced on, so a peer announcing on several aspects no longer flips between roles (SPEC RD4/RE1)
* Web: the federation table's Reticulum column renders an em dash for a zero count rather than a violet tile beside a `0` — the column is served as `|| 0` for signature canonicalisation, so during rollout nearly every federated instance would otherwise carry a tile (SPEC RD8)

### Fixes
* Data: **`PROTOCOL=reticulum` no longer needs `INGESTOR_NODE_ID`** — the ingestor now owns an RNS identity (generated once, stored `0600` as `potato_mesh_identity` inside `RETICULUM_CONFIG_DIR`) and derives its own `!xxxxxxxx` from it through the same identity-hash rule applied to every peer, so the heartbeat and the `reticulum` packets/hour figure come up unaided instead of staying dead until an operator set a variable they had no way to guess. The id is stable across restarts, the variable still overrides it, and the identity is never announced, so nothing new transmits (SPEC RN9)
* Data/Web: **Reticulum nodes are keyed on the announcing identity** — a peer announcing `lxmf.delivery`, `lxmf.propagation` and `nomadnetwork.node` is one node with three destinations, not three nodes, and the node count counts identities. The per-aspect names and roles live in `destinations`, and the node's headline name/role follow a fixed preference (`NODE` > `PEER` > `PROPAGATION` > `TRANSPORT`) rather than whichever announce arrived last (SPEC RE7/RE10)
* Data: **the ingestor's own node id is its primary announced identity**, discovered from the 0-hop path table, so the ingestor's row is one an operator recognises rather than a synthetic key. **Upgrade note:** this changes the id from earlier schemes, and the previous ingestor row stays in `/api/ingestors` without heartbeats until it ages out; setting, changing, or removing `INGESTOR_NODE_ID` has the same effect, while leaving it as-is is inert (SPEC RE8)
* Data: **the host's own aspects are ingested**, including its transport instance when the stack has `enable_transport` set — the `TRANSPORT` role is emitted for the ingestor's own host only, never inferred for a remote peer (SPEC RE9)
* Data: **`PROTOCOL=reticulum` no longer needs `INGESTOR_NODE_ID`** — the ingestor derives its own `!xxxxxxxx` from the transport identity already in `RETICULUM_CONFIG_DIR`, so the heartbeat and the `reticulum` packets/hour figure come up unaided instead of staying dead until an operator set a variable they had no way to guess. The id is stable across restarts and the variable still overrides it (SPEC RE5)
* Data: **a set `CONNECTION` is no longer silently ignored under `PROTOCOL=reticulum`** — it names one serial/TCP/BLE endpoint, which an RNS stack of many interfaces does not have, and the shipped image sets a serial default for every protocol; the ingestor now says at startup that it is dropping the value and names the two variables that do apply (`RETICULUM_CONFIG_DIR` for which stack, `RETICULUM_INTERFACES` for which interfaces) (SPEC RN10)
* Data: **`RETICULUM_INTERFACES` ingested nothing at all** wherever the ingestor shared an RNS instance — RNS keys the shared-instance socket on `instance_name`, not `configdir`, so an app-owned config dir did not stop it attaching to a running `rnsd`, and every announce then arrived over one `LocalInterface` that no per-interface allowlist could match. A scoped ingestor now seeds its own unshared config, and where it is on a shared instance anyway the allowlist fails open with a warning rather than silently filtering everything out (SPEC RN8)
* Data: **`RETICULUM_INTERFACES` ingested nothing at all** wherever the ingestor shared an RNS instance — every announce arrived over one `LocalInterface` that no per-interface allowlist could match. Interface names now resolve through the shared instance itself (`RNS.Reticulum` RPCs to the running `rnsd` and returns its view), so the allowlist works on a shared stack; RPC auth is keyed on the config directory, which is why the ingestor shares the operator's (SPEC RE3)
* Data: **a Reticulum announce carrying no display name overwrote the stored one** — the fallback named the destination that announced rather than the node, in a form the web upsert did not recognise as a placeholder. It is now `"Reticulum <SHORT>"`, matching the shape the upsert refuses to overwrite a real name with
* Web: **a destination hash carried by a stale record was dropped** rather than unioned — the union sat inside the upsert's freshness-guarded `ON CONFLICT` clause, so a second ingestor posting an older announce lost the hash the first had never heard. It is now its own forward-only statement, and still cannot regress `last_heard` or touch a real row from a synthetic placeholder (SPEC RN1)
* Docs: the Reticulum config directory is documented — what it is seeded with, what an `AutoInterface` does and does not reach, and that a scoped ingestor does not inherit your `rnsd`'s interfaces
* Docs: the Reticulum config directory is documented — that it defaults to `~/.reticulum`, that a container gets the `potatomesh_reticulum` volume whose stock `AutoInterface` reaches no radio on the default bridge network, and how to add interfaces to it
* Data: **`ALLOWED_CHANNELS` silently dropped every message on a stock Docker Compose deployment** — the packaged default was spelled `${ALLOWED_CHANNELS:-""}`, and Compose substitutes defaults as *literal text*, so the ingestor received the two-character string `""` and read it as a one-entry allowlist no real channel could match; every packet was then discarded as `disallowed-channel`. Defaults are now bare (`:-`), both list-valued parsers resolve a quote-*only* value to no filter while taking any fragment with content literally (a Meshtastic channel name is arbitrary UTF-8, so `Ops'` is a name, not a quoted one), and a guard discovers every Compose file in the repo and matches every quoted-default spelling, so the shape cannot return. `HIDDEN_CHANNELS` and `MAP_ZOOM` carried the same defect (both benign) and are fixed with it (ACCEPTANCE CH-A1–CH-A3)
* Data/Web: **Reticulum node ids now key on the peer's identity, not on a destination hash** — a destination hash is a truncated hash over the identity and name hashes, so a peer announcing both `lxmf.delivery` and `nomadnetwork.node` appeared as two unrelated node rows; one peer is now one row (SPEC RN1)
* Data/Web: **`user.publicKey` carries the identity's real public key**, and destination hashes move to their own `destHash` list (`nodes.dest_hash`, unioned across ingestors, stored but never served like `public_key`) (SPEC RN1/RN6)
* Data: **the Reticulum ingestor no longer adopts the operator's `~/.reticulum`** — `RETICULUM_CONFIG_DIR` defaults to an app-owned directory and reaches `.env.example`, `docker-compose.yml`, and `data/Dockerfile` (SPEC RN3)
* Data: new `RETICULUM_INTERFACES` allowlist bounds announce ingestion to named RNS interfaces; empty (the default) still ingests everything, so an `AutoInterface` LAN no longer has to mean the whole local Reticulum network in your dashboard (SPEC RN4)
* Data/Web: **one Reticulum node row per announced destination** — a peer's aspects carry different display names and roles (`lxmf.delivery` and `nomadnetwork.node` announce separately), so merging them produced a row named from one aspect and roled from another; `nodes.identity_hash` groups the rows back into one peer (SPEC RE1)
* Data/Web: **`user.publicKey` carries the identity's real public key**, and destinations get their own table (`id`, `node_id`, `identity_hash`, `name`, `aspect`, `role`, `interface`, `first_heard`, `last_heard`) served by `GET /api/destinations` (SPEC RE2)
* Data: new `RETICULUM_INTERFACES` allowlist bounds announce ingestion to named RNS interfaces, applied from one hop out; empty (the default) ingests everything, and a 0-hop announce — one made by an app on this machine — is always ingested so the operator's own nodes are never filtered away (SPEC RN4/RE4)
* Web: **the `/charts` mesh-activity figure draws the Reticulum series** — its line list was frozen to two protocols, so a reticulum-carrying mesh rendered two flat-zero lines with its real traffic invisible while the map card rendered the same payload correctly (SPEC RN2)
* Web: `nodes.dest_hash` and the `instances.reticulum_nodes_count` added in #889 both carry a standalone `data/migrations/*.sql` companion alongside their boot-time schema guard (SPEC RN7)
* Web: the `instances.reticulum_nodes_count` added in #889 carries a standalone `data/migrations/*.sql` companion alongside its boot-time schema guard (SPEC RN7)
* Docs: **every environment variable the ingestor or web app reads is documented** — `PROM_REPORT_IDS` and the `SSE_*`/`FEDERATION_*`/`REMOTE_INSTANCE_*`/Puma tuning knobs appeared in no document at all; the README now carries all 57, and the operator-facing subset reaches `.env.example`, Compose, both images, and the NixOS module (SPEC DOC2)
* Docs/Config: **`configure.sh` and the NixOS module are no longer Meshtastic-only** — the wizard asks which protocol to run and branches its radio, connection, and transmit prompts on the answer; the module gained `ingestor.protocol`, the Reticulum options, and the UDP options, so two of the three supported protocols stopped being unconfigurable through the documented tooling (SPEC DOC3)
* Docs: **the README documents every route**, including `/version`, `/metrics`, `/.well-known/potato-mesh`, the page routes, and that there is no health endpoint (SPEC DOC4)
* Docs: **the dual basemap-CDN egress disclosure was restored** — an earlier editorial pass had dropped it, leaving the doubled third-party tile request undisclosed (SPEC SB6)
* Docs: `CONTRACTS.md` corrected — `identityHash` is served by `GET /api/destinations`, whose read shape is now specified, and the `dest_hash` column it referenced no longer exists (SPEC DOC5)
* Docs: `ACCEPTANCE.md` S-A5 / MA-A5 / MA-FA2 / F2-A1 / A4a amended — each still asserted a zero-stub, non-whitelisted `reticulum` that SPEC S6/MA5/MA-F2/F2-2 had already stopped describing; README gains a Reticulum section

### Features
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ All code must be 100% unit tested — every line, branch, and code path must hav

All code must be 100% documented according to the language's API-doc standard (PDoc for Python, RDoc for Ruby, JSDoc for JavaScript, rustdoc for Rust, dartdoc for Dart). Documentation must be sufficient to generate complete API docs from source. In addition to API-level docs, add inline comments wherever the logic is not immediately self-evident.

**Operator-facing docs are task-oriented and short.** `README.md` and anything else an operator reads answers "do X to achieve Y" and stops. One or two sentences per setting: what to set, what happens, what breaks if you get it wrong. No mechanism, no rationale, no history, no reasoning about trade-offs, no explanation of *why* a default was chosen — that material belongs in API docs, inline comments, `SPEC.md`, or `CONTRACTS.md`, where the reader is a contributor rather than someone trying to get a radio working.

This is a standing correction, not a style preference: generated documentation drifts long and explanatory by default, and every extra paragraph an operator has to read to find the one command they need is a defect. If a README passage explains a design decision, move it. If it hedges or covers a case the reader cannot act on, cut it. When in doubt, cut — the detail is recoverable from the linked SPEC/CONTRACTS entry.

Every file in the repository must carry an Apache v2 license notice using the exact string `Copyright © 2025-26 l5yth & contributors`. **Source-code files** (`.rb`, `.py`, `.js`, `.rs`, `.dart`, etc.) must include the full Apache v2 license header block. **Non-source files** (docs, configs, YAML, TOML, Dockerfiles, etc.) must include a short 2-line Apache v2 notice (copyright line + license reference).

Run linters for Python (`black`) and Ruby (`rufo`) to ensure consistent code formatting.
Expand Down
Loading
Loading