data,web: Reticulum finalization — radio metadata, chat tags, federation zeros (#900)

* data,web: Reticulum finalization — radio metadata, chat tags, federation zeros

* web: purge destinations with their nodes
This commit is contained in:
l5y authored and GitHub committed 2026-08-31 12:01:36 +02:00
1 parent d61cca93c0
commit 39e5d01d26
24 files changed
+1205 -34

No files matched your search

+4
View File
@@ -225,3 +225,7 @@ POTATOMESH_IMAGE_TAG="latest"
# Multicast group/port for "Mesh via UDP" (defaults shown).
# MESH_UDP_GROUP=224.0.0.69
# MESH_UDP_PORT=4403
# Frequency and preset shown for Reticulum nodes. Both are read from the first
# RNodeInterface in your RNS config; set these only to override that.
# RETICULUM_FREQ="867.2MHz"
# RETICULUM_PRESET="SF8/BW125/CR5"
+140 -4
View File
@@ -5638,8 +5638,10 @@ git grep -n "colorForNodeCount" -- web/public/assets/js/app/federation-page.js
area fills, per-line weight/opacity, gridline and axis restyle) is deferred to
its own feature and must not leak in here. The second confirms the federation map
still colours markers by total active node count, so violet never means a bucket
there. The JS suite confirms the Reticulum column renders an **em dash for a
zero or absent count**, not a tile beside a `0`.
there. **Superseded by RL4**, which replaced this with one rule for all three
protocol columns: absent renders an em dash, zero a muted `0`, non-zero the tile
and count. The clause below described the interim behaviour where a Reticulum
zero also dashed; the JS suite now confirms the RL4 rule instead.
**This is a behaviour change, not a preservation.** Before this feature the
column rendered a tile beside every count including `0`, because
@@ -6153,11 +6155,29 @@ fails **open** and silently, which is why neither side may be changed alone.
### RA-A10 - Row controls do not set row height; the caret sits with the + - RA11
```bash
git grep -n "min-height: 0" -- web/public/assets/styles/base.css
git grep -c "not(.node-extra-toggle):not(.identity-disclosure)" -- web/public/assets/styles/base.css
git grep -n "identity-disclosure" -- web/public/assets/js/app/main.js
git grep -c "background: #7b61ff" -- web/public/assets/styles/base.css
git grep -n "font-family" -- web/public/assets/styles/base.css | grep -c "cell-empty" || \
awk '/^\.cell-empty \{/,/^\}/' web/public/assets/styles/base.css | grep -c font-family
```
**Expected:** the first two hit; the third returns `0` for the disclosure rule.
**Expected:** the first two hit, the third returns `0` for the disclosure rule,
and `.cell-empty` pins a `font-family`.
**Both controls must be excluded from the global `button` rule.** Measured on a
rendered page, `.identity-disclosure` and `.node-extra-toggle` are each
**18.0 x 18.0** with `padding: 0`, and a Reticulum parent row is the same height
as a Meshtastic one (25.2 px both). Setting the size on the controls alone is
**not** sufficient: `button:not(.chat-tab):not(.sort-button)` has specificity
(0,2,1) against the controls' (0,1,0), so its `padding: 6px 10px` won and the
buttons rendered as stretched rectangles with an off-centre glyph. They are
excluded from it by name, exactly as `.chat-tab` and `.sort-button` already are.
**One dash, one glyph.** `.cell-empty` pins the body font because `#nodes
td.num` is monospace: without it the identical em dash rendered as two visibly
different glyphs down the same table, depending on whether the column was
numeric. An empty cell has no number to align, so it loses nothing by leaving
the tabular face.
Both row controls sit inside the row's line box, so a Reticulum parent is the
same height as every other row (a 24 px `min-height` made it ~33 px against
~24 px); the 44 px touch target is restored on coarse pointers by a transparent
@@ -6208,3 +6228,119 @@ violet ramp and WCAG floor, now with several badges per row); **RD-A6/RD-A7**
(shape channel and legend column, whose header string changes); **RD-A8** (the
scope boundary — panel 2d stays out); and **RE-A5/RE-A11** (identity keying and
headline preference, which the parent row reads rather than re-derives).
## Feature: Reticulum finalization — radio metadata, chat tags, federation zeros
### RL-A1 - Reticulum reports a frequency and preset, config first, env override - RL1
```bash
( . .venv/bin/activate && pytest -q tests/test_reticulum_unit.py -k "radio_metadata or reticulum_config" )
```
**Expected:** pass. With an `RNodeInterface` section in the shared RNS config,
the ingestor resolves `config.LORA_FREQ` and `config.MODEM_PRESET` from its
`frequency` / `bandwidth` / `spreadingfactor` / `codingrate` keys, so the
heartbeat carries them exactly as the Meshtastic path does.
`RETICULUM_FREQ` / `RETICULUM_PRESET` override the parsed values; an absent or
unparseable config leaves both `None` and every downstream field keeps its dash
rather than inventing a number.
The config file is the **only** available source, not a preference: the RPC
`get_interface_stats` payload contains `bitrate`, airtime and channel load but
**no** frequency, bandwidth, SF or CR, and a shared-instance client's own
`Transport.interfaces` holds only its local-client interface. This carries
RA12's staleness caveat — a file is not the running stack — with no RPC
alternative to prefer.
### RL-A2 - The preset names a Meshtastic preset only on an exact match - RL2
```bash
( . .venv/bin/activate && pytest -q tests/test_reticulum_unit.py -k "preset" )
```
**Expected:** pass. A BW/SF/CR triple matching a Meshtastic preset exactly
yields that preset's CamelCase name; anything else yields
`SF{sf}/BW{bw}/CR{cr}` from the existing `_custom_preset_label`, so no second
radio-parameter format enters the codebase.
The mapping table is **hand-maintained** — the Meshtastic Python package defines
the preset enum but not its parameters, which live in firmware — so a test
states each row's provenance. Treat a mismatch with upstream as a table bug, not
a test bug. The name is a label, **not** an interoperability claim: a Reticulum
radio on `LongFast` parameters cannot talk to a Meshtastic `LongFast` mesh.
### RL-A3 - The chat and log tags fill from the same values - RL3
```bash
( cd web && node --test public/assets/js/app/__tests__/main-protocol.test.js )
( . .venv/bin/activate && pytest -q tests/test_reticulum_unit.py -k "radio_metadata" )
```
**Expected:** both pass. A Reticulum node announcement renders its frequency
prefix and preset tag from the ingested values rather than two empty brackets —
the symptom that prompted this feature. No **renderer** gains a Reticulum
branch: the tags were always driven by the node's `lora_freq` / `modem_preset`.
**The provider does change, though**, which this criterion exists to record.
Meshtastic and MeshCore reach `nodes.lora_freq` / `nodes.modem_preset` through
their **position and telemetry** payloads (`positions.rb:55`,
`telemetry.rb:588`), and a Reticulum announce carries neither — so the values
RL1/RL2 resolve never left the ingestor heartbeat. `_attach_radio_metadata`
stamps them onto every node record the provider emits, including the host's own
discovered destinations. The node upsert already read both keys
(`node_writes.rb:468`); only the sender was missing.
An unresolved value is **omitted** rather than sent as null, so the column keeps
its dash instead of rendering an empty string.
### RL-A4 - Federation counts distinguish zero from absent, for every protocol - RL4
```bash
( cd web && node --test public/assets/js/app/__tests__/federation-page.test.js )
```
**Expected:** pass. Each protocol column renders `—` when the count is absent, a
muted `0` with **no** tile when it is zero, and tile + count when non-zero — one
rule for all three, replacing MeshCore/Meshtastic's `== null` test and
Reticulum's `!value` test.
Both halves matter. Distinguishing zero from absent is the honest part; dropping
the tile at zero is what answers the objection the previous em-dash rule was
built on, since during rollout most instances report `0` and the column would
otherwise fill with tiles carrying no information. Reticulum's
`normalize_instance_row` still serves `|| 0` for signature canonicalisation
(FS2), so its column shows a zero rather than a dash — unchanged.
### RL-A5 - A destination's identifier reaches its identity page - RL5
```bash
( cd web && node --test public/assets/js/app/node-page/__tests__/destinations.test.js )
( cd web && node --test public/assets/js/app/main/__tests__/identity-groups.test.js )
( cd web && bundle exec rspec spec/reticulum_spec.rb -e "canonicalises to its identity page" )
```
**Expected:** all pass. The `[!xxxxxxxx]` in a table sub-row and the destination
hash on the identity page are **links** to `/nodes/!<destination>`, and the
identity page's destination rows carry anchors so a link lands on the row it
names. The route already resolved these (RA5) and is covered by the rspec
example; until now nothing in the interface emitted the link, so the behaviour
was reachable only by typing a URL — a spec claim no interface exercises is
indistinguishable from one that is false.
### RL-A6 - A destination never outlives its node - RL6
```bash
( cd web && bundle exec rspec spec/retention_spec.rb )
git grep -n '\["destinations", "last_heard"\]' -- web/lib/potato_mesh/application/retention.rb
```
**Expected:** pass, and the grep hits. `destinations` is a retention target keyed
on `last_heard`, the same column its node uses, so the two age out together.
The table carries **no foreign key** (`data/destinations.sql`), so nothing
cascades when a node is purged: without this a destination row survived its node
indefinitely, invisible to every view except an unfiltered
`GET /api/destinations`, in a table that only ever grew. The spec seeds one
fresh and one stale destination so the sweep is shown to keep one and drop the
other rather than emptying the table.
### RL-R1 - Regression: prior acceptance still holds
```bash
( . .venv/bin/activate && pytest -q tests/ ) && ( cd web && bundle exec rspec ) && ( cd web && npm test )
```
**Expected:** all green. At risk and explicitly required to remain green:
**UX-A10** (preset config migration and the join strip, now with a third
protocol pair); **RE-A7** (protocol-scoped settings say which protocol they
apply to); **S-A5 / MA-A5 / F2-A1** (the stats scopes, amended once already when
`reticulum` stopped being a zero stub); **RD-A3** (one colour code across table,
meta row and figure, now that Reticulum renders a preset tag); **RA-A1/RA-A2**
(the table rows and counts); and the federation instance-table criteria, whose
zero rendering RL4 changes for **all three** protocols, not only Reticulum.
+4
View File
@@ -9,6 +9,10 @@
* 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/Web: **Reticulum reports a frequency and a LoRa preset.** Both are read from the first `RNodeInterface` in the shared RNS config (`RETICULUM_FREQ`/`RETICULUM_PRESET` override), stamped onto every node record heard over that radio, and rendered in the nodes table and the chat/log tags — which previously showed two empty brackets on every Reticulum line. A preset whose bandwidth, spreading factor and coding rate match a Meshtastic preset shows that name; anything else shows `SF8/BW125/CR5`. The name describes radio settings, not interoperability with a Meshtastic mesh (SPEC RL1–RL3)
* Web: **federation node counts distinguish zero from "not reported"** — absent renders an em dash, zero a muted `0`, non-zero the protocol tile and the count, uniformly for all three protocols. Previously MeshCore and Meshtastic drew a tile beside a real `0` while Reticulum rendered `0` and absent identically; **supersedes** the earlier rule that dashed a Reticulum zero (SPEC RL4)
* Web: **a destination's identifier links to its identity page** and lands on the row it names. The route resolved these already, but nothing in the interface emitted the link (SPEC RL5)
* Web: **Reticulum rows match every other row's height**, the nodes table uses one dash glyph throughout, and destination sub-rows colour their role like the row above them (SPEC RA11)
* 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)
+11
View File
@@ -339,6 +339,8 @@ appear in the allowlist.
| `MESH_UDP_PORT` | `4403` | Multicast port joined in UDP transport. |
| `INGESTOR_NODE_ID` | _unset_ | `!xxxxxxxx` id used for the ingestor heartbeat. Required for the UDP transport, which cannot auto-detect "self". Optional for `PROTOCOL=reticulum`, which derives one from your primary announced identity; set it there only to override that. |
| `RETICULUM_CONFIG_DIR` | `~/.reticulum` | Which RNS config the ingestor uses, and so which interfaces it can see. Point it at the directory your `rnsd` uses. See [Reticulum](#reticulum). |
| `RETICULUM_FREQ` | _from RNS config_ | Frequency shown for Reticulum nodes. Overrides the value read from your `RNodeInterface` section. |
| `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_SELF_TELEMETRY_SECONDS` | `3600` | Seconds between Meshcore host self-telemetry reads (battery/sensors over the companion link, no airtime). Set `0` to disable. |
@@ -462,6 +464,15 @@ and not the hash `rnstatus` prints as "Transport Instance".
`/api/ingestors` without heartbeats until it ages out. Leaving the variable
as-is across upgrades is inert.
**Frequency and preset** are read from the first `RNodeInterface` in your RNS
config; `RETICULUM_FREQ` and `RETICULUM_PRESET` override them. A preset whose
bandwidth, spreading factor and coding rate match a Meshtastic preset shows that
name — it describes the radio settings, not compatibility with a Meshtastic
mesh — otherwise it shows `SF8/BW125/CR5`.
The values are read once at startup. Change them in the RNS config and restart
the ingestor, or the dashboard keeps showing the old ones.
A node appears once per announced destination, so one peer running LXMF and a
nomadnet node shows up as two entries. `GET /api/destinations` lists them and
groups them by identity.
+28 -1
View File
@@ -1433,7 +1433,7 @@ explicitly rather than absorbed silently.
| **RD5** | **`reticulumRoleColors` is a four-step violet ramp**, light to dark: `PEER #cabffa`, `NODE #a08bff`, `TRANSPORT #8b74ff`, `PROPAGATION #4a32b8`, joining `roleColors` and `meshcoreRoleColors` behind `getRoleColors`. Ties the badges to the tile. **Measured** against `getContrastTextColor`: 10.86 / 6.71 / 5.31 / 8.61 - all clear the UX2 4.5:1 floor - while the tile's own `#7b61ff` lands at **4.39:1** and fails, which is exactly why `TRANSPORT` is one step lighter than the tile rather than equal to it. `TRANSPORT` ships in the ramp although RD4 leaves it unpopulated, so the ramp is complete the moment a source exists. Until this ramp existed, Reticulum nodes inherited the Meshtastic palette. | design (turn 1) + measured |
| **RD6** | **Hexagon is Reticulum's shape channel - legend-only in practice** (extends UX7/LC1). `clip-path: polygon(50% 0%, 100% 27%, 100% 73%, 50% 100%, 0% 73%, 0% 27%)` on the 12 px swatch, taken from the design rather than re-derived. **Stated honestly:** a Reticulum announce carries no position, so an RNS node never renders a map marker - the shape exists so the legend swatch can key protocol the way LC1 requires, and `nodeMarkerShapeForProtocol` gains a branch whose map output is currently unreachable. Chosen over the design prose's triangle. | design + interview |
| **RD7** | **Third legend column and README badge.** The legend gains an RNS column carrying the tile, label, live 7-day count and the four role filters, inheriting UX8's pressed-state and `aria-pressed` conventions unchanged. The README gains a Reticulum badge in the tile's own `#7b61ff` - where MeshCore's badge uses `#1f2937` rather than a true black that would vanish against the badge row, violet needs no such adjustment. **The badge reads `supported`, not the design's `planned`**: the brief chose `planned` on the premise that the ingestor did not exist and instructed "swap the word when `PROTOCOL=reticulum` ships", which it did in #890. This changes the badge colour from the `#1f2937` that #893 shipped. | design (turn 2c) + interview |
| **RD8** | **Scope boundary.** Panel **2d** - the mesh-activity figure redesign (line-end labels, 10% area fills, per-line weight and opacity, gridline and axis restyle) - is **deferred to its own feature**: it is a figure redesign that stands alone, it would land as options on the shared `node-page-charts` renderers, and no other figure on the page needs any of it. The **federation map needs no protocol work**: its markers colour by total active node count through `colorForNodeCount` and its legend keys those buckets, so violet never means a bucket there. The instances column gains one **deliberate behaviour change** (the only user-visible one outside the assets): an instance with no RNS nodes now renders an em dash rather than a tile beside a zero. This was *not* the prior behaviour - `normalize_instance_row` serves `reticulum_nodes_count` as `|| 0` so a crawler re-verifying a relayed v2 record rebuilds the canonical the sender signed (FS2), which left the renderer's `== null` em-dash branch unreachable for this column alone, so every federated row carried a tile beside its count. RD1 made that a bright violet tile, and during rollout almost every federated instance reports zero - a tile in every cell would make the column look broken rather than new. The two sibling columns can still tell absent from zero and are untouched. | interview + design |
| **RD8** | **Scope boundary.** *(Its federation-column clause — an em dash for a zero RNS count — is **superseded by RL4**, which distinguishes absent from zero for all three protocols.)* Panel **2d** - the mesh-activity figure redesign (line-end labels, 10% area fills, per-line weight and opacity, gridline and axis restyle) - is **deferred to its own feature**: it is a figure redesign that stands alone, it would land as options on the shared `node-page-charts` renderers, and no other figure on the page needs any of it. The **federation map needs no protocol work**: its markers colour by total active node count through `colorForNodeCount` and its legend keys those buckets, so violet never means a bucket there. The instances column gains one **deliberate behaviour change** (the only user-visible one outside the assets): an instance with no RNS nodes now renders an em dash rather than a tile beside a zero. This was *not* the prior behaviour - `normalize_instance_row` serves `reticulum_nodes_count` as `|| 0` so a crawler re-verifying a relayed v2 record rebuilds the canonical the sender signed (FS2), which left the renderer's `== null` em-dash branch unreachable for this column alone, so every federated row carried a tile beside its count. RD1 made that a bright violet tile, and during rollout almost every federated instance reports zero - a tile in every cell would make the column look broken rather than new. The two sibling columns can still tell absent from zero and are untouched. | interview + design |
## Bugfix: Reticulum end-to-end field findings (#893 hardware test)
@@ -1537,3 +1537,30 @@ instead of Meshtastic's `CLIENT`).
| **RA11** | **The row controls do not set the row's height, and the caret sits with the `+`.** A 24 px `min-height` on the disclosure made a Reticulum parent ~33 px against ~24 px for every other row; both controls now sit inside the row's line box, with the 44 px touch target restored on coarse pointers via a transparent `::after` — the pattern `.short-name` already uses. The caret moves from the protocol cell to the trailing cell beside `+`, which **restores the protocol tile** to Reticulum rows: swapping the glyph for the caret (as RA1 first specified) cost them their only protocol marker in the table. The trailing column is therefore always present — a table column cannot be shown per row — while the `+` keeps its own responsive rule, so its behaviour is unchanged. The caret carries **no violet fill**: RD5 pins `#7b61ff` as failing the UX2 4.5:1 floor behind text (4.39:1 at best), and with the tile restored the caret no longer needs to carry the protocol's colour. **Amends RA1.** | field report |
| **RA12** | **The transport gate asks the stack, not the process (corrects RE9).** `RNS.Reticulum.transport_enabled()` answers "does **this process** route traffic". On connecting to a shared instance RNS *forces* the client's flag to `False` regardless of the config file, so an ingestor attached to `rnsd` read `False` even with `enable_transport = Yes` — and the operator's own host never grew its `TRANSPORT` destination. The stack's answer comes from `get_interface_stats`, which RPCs to the shared instance and reports a `transport_id` **only** when that instance is routing. A standalone transport node still short-circuits on its own flag with no RPC. The distinction matters beyond this gate: any "is the stack doing X" question asked from a client process has the same trap. | field report |
## Feature: Reticulum finalization — radio metadata, chat tags, federation zeros (#888)
Closes the last places where Reticulum is structurally emptier than its two
siblings: it reported no frequency and no preset, so the nodes table, the
ingestor heartbeat and the chat/log tags all rendered blanks for it.
*I (local LoRa only)* — **unaffected**: reads a local config file and the rows
already ingested; no new network path.
*II (privacy & consent)* — **unaffected**: radio parameters are the operator's
own settings, already published for the other two protocols.
*III (federation)* — **touched**: RL4 changes how every protocol's node count
renders in the instances table. The signed record and its canonicalisation are
untouched; only presentation moves.
*IV (parity)* — **this is the point**: frequency and preset were the last two
columns where Reticulum could not answer. RL4 likewise makes one rule serve all
three protocols instead of Reticulum having its own.
| # | Decision | Source |
| --- | --- | --- |
| **RL1** | **Reticulum radio metadata comes from the shared RNS config, with env override.** `frequency`, `bandwidth`, `spreadingfactor` and `codingrate` are read from the `RNodeInterface` section of the config dir the ingestor already shares with `rnsd` (RE3); `RETICULUM_FREQ` and `RETICULUM_PRESET` override them, mirroring how `FREQUENCY` already overrides the auto-detected `LORA_FREQ`. The file is the **only** source available: `get_interface_stats` carries `bitrate`, airtime and channel load but **no** radio parameters (zero occurrences of frequency/sf/cr in the RPC payload), and a shared-instance client's own `Transport.interfaces` holds only its local-client interface, never the RNode. **Known cost (RA12's shape):** a config file is not the running stack, so editing the config and reloading `rnsd` without restarting the ingestor leaves the reported values stale. Unlike the transport gate there is no RPC alternative to prefer. | interview |
| **RL2** | **The preset label is a Meshtastic preset name when the parameters match exactly, else `SF/BW/CR`.** The fallback reuses `_custom_preset_label` in `interfaces/radio.py`, which already emits `SF{sf}/BW{bw}/CR{cr}` — the same shape MeshCore's decoder produces — so no second format is invented. **Two costs, accepted deliberately.** (a) The name borrows another protocol's vocabulary: a Reticulum radio labelled `LongFast` shares air parameters with a Meshtastic `LongFast` mesh but is **not** interoperable with it, and the label invites that inference. (b) The BW/SF/CR→preset table is **hand-maintained**: the Meshtastic Python package defines the preset *enum* but not its parameters, which live in firmware, so nothing in the dependency tree can verify the table and it can drift from upstream. It is therefore pinned by an explicit test that states its provenance. | interview |
| **RL3** | **The chat and log frequency/preset tags need no code of their own.** `createAnnouncementEntry` builds them from `formatNodeAnnouncementPrefix` and `formatChatPresetTag({presetCode: metadata.presetCode})`; both were blank for Reticulum only because the underlying values were absent, so RL1/RL2 fill them. Recorded as a decision rather than left implicit so the behaviour is pinned by a test and cannot silently regress if the tag renderers are refactored. | interview |
| **RL4** | **Federation node counts: absent is a dash, zero is a muted zero, non-zero gets the tile.** One rule for all three protocol columns, replacing two: MeshCore and Meshtastic tested `== null` (so a real `0` drew a tile) while Reticulum tested `!value` (so `0` and absent were indistinguishable). Zero and "not reported" now read differently, which is the honest distinction, and the tile appears only where it carries information — answering the objection that motivated the previous em-dash rule, since during rollout most instances report `0` and would otherwise fill the column with tiles. **Supersedes** the CHANGELOG-recorded dash rule from the visual-identity work. Reticulum's `normalize_instance_row` still serves `\|\| 0` for signature canonicalisation (FS2), so its column renders a zero rather than a dash — unchanged and intentional. | interview |
| **RL5** | **A destination's identifier links to its identity page.** RA5 specified that `/nodes/!<destination>` canonicalises to the owning identity, and the route does — but nothing in the UI ever emitted that link, so the behaviour was reachable only by typing a URL. The `[!xxxxxxxx]` in a table sub-row and the destination hash on the identity page become links, and the identity page's destination rows carry anchors so a link lands on the row it names. A claim in the spec that no interface exercises is indistinguishable from a claim that is false. | review follow-up |
| **RL6** | **`destinations` is a retention target.** The table carries no foreign key to `nodes`, so nothing cascaded when a node was purged and a destination row outlived it indefinitely — invisible to every view except an unfiltered `GET /api/destinations`, in a table that only grew. It is swept on `last_heard`, the same column its node uses, so the two age out together. Delete order is immaterial precisely because there is no constraint between them. | operator report |
+10
View File
@@ -85,6 +85,8 @@ TX_ENABLED=$(grep "^TX_ENABLED=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' |
TX_ANNOUNCE=$(grep "^TX_ANNOUNCE=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "0")
RETICULUM_CONFIG_DIR=$(grep "^RETICULUM_CONFIG_DIR=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "")
RETICULUM_INTERFACES=$(grep "^RETICULUM_INTERFACES=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "")
RETICULUM_FREQ=$(grep "^RETICULUM_FREQ=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "")
RETICULUM_PRESET=$(grep "^RETICULUM_PRESET=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "")
FEDERATION=$(grep "^FEDERATION=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "1")
PRIVATE=$(grep "^PRIVATE=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "0")
HIDDEN_CHANNELS=$(grep "^HIDDEN_CHANNELS=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "")
@@ -182,6 +184,10 @@ if [ "$PROTOCOL" = "reticulum" ]; then
echo "Restrict ingestion to named interfaces (case-insensitive substring)."
echo "Leave blank to ingest announces from every interface."
read_with_default "Reticulum interfaces" "$RETICULUM_INTERFACES" RETICULUM_INTERFACES
echo "Frequency and preset are read from your RNS config; leave blank unless"
echo "you want to override what the dashboard displays."
read_with_default "Reticulum frequency (blank = from RNS config)" "$RETICULUM_FREQ" RETICULUM_FREQ
read_with_default "Reticulum preset (blank = from RNS config)" "$RETICULUM_PRESET" RETICULUM_PRESET
else
echo "Define how the mesh ingestor connects to your device."
echo "Use serial devices like /dev/ttyACM0, TCP endpoints such as tcp://host:port,"
@@ -258,6 +264,8 @@ update_env "TX_ENABLED" "$TX_ENABLED"
update_env "TX_ANNOUNCE" "$TX_ANNOUNCE"
update_env_optional "RETICULUM_CONFIG_DIR" "$RETICULUM_CONFIG_DIR"
update_env_optional "RETICULUM_INTERFACES" "$RETICULUM_INTERFACES"
update_env_optional "RETICULUM_FREQ" "$RETICULUM_FREQ"
update_env_optional "RETICULUM_PRESET" "$RETICULUM_PRESET"
update_env "MAP_CENTER" "\"$MAP_CENTER\""
if [ -n "$MAP_ZOOM" ]; then
update_env "MAP_ZOOM" "$MAP_ZOOM"
@@ -327,6 +335,8 @@ case "$PROTOCOL" in
reticulum)
echo " RNS Config Dir: ${RETICULUM_CONFIG_DIR:-'~/.reticulum'}"
echo " Interfaces: ${RETICULUM_INTERFACES:-'All'}"
echo " Frequency: ${RETICULUM_FREQ:-'From RNS config'}"
echo " Preset: ${RETICULUM_PRESET:-'From RNS config'}"
;;
*)
echo " Preset: $MESHTASTIC_PRESET"
+4
View File
@@ -76,6 +76,8 @@ ENV CONNECTION=/dev/ttyACM0 \
XDG_CONFIG_HOME=/app/.config \
RETICULUM_CONFIG_DIR=/app/.config/potato-mesh/reticulum \
RETICULUM_INTERFACES="" \
RETICULUM_FREQ="" \
RETICULUM_PRESET="" \
INGESTOR_NODE_ID="" \
TRANSPORT=api \
PRIMARY_CHANNEL_ONLY=0 \
@@ -122,6 +124,8 @@ ENV CONNECTION=/dev/ttyACM0 \
XDG_CONFIG_HOME=/app/.config \
RETICULUM_CONFIG_DIR=/app/.config/potato-mesh/reticulum \
RETICULUM_INTERFACES="" \
RETICULUM_FREQ="" \
RETICULUM_PRESET="" \
INGESTOR_NODE_ID="" \
TRANSPORT=api \
PRIMARY_CHANNEL_ONLY=0 \
+23
View File
@@ -373,6 +373,29 @@ bound), and a `before` newer than "now" is a no-op. A non-positive or non-intege
is protocol-neutral. The per-id routes (`GET /api/.../:id`) and `GET /api/instances`
do **not** accept `before`.
### Reticulum radio metadata (SPEC RL1/RL3)
Reticulum announces carry no radio parameters, so the provider supplies them
from the operator's own stack.
- Source: the first `RNodeInterface` block of the shared RNS config
(`frequency`, `bandwidth`, `spreadingfactor`, `codingrate`). **Only those four
keys are read** — the same file holds `rpc_key`, which is
never read or logged.
- RNS stores both frequencies in **Hz**; they are emitted as MHz and kHz.
- `RETICULUM_FREQ` / `RETICULUM_PRESET` override the parsed values.
- The preset is a Meshtastic preset **name** when the BW/SF/CR triple matches
one exactly, else `SF{sf}/BW{bw}/CR{cr}`. The name describes radio settings
and is **not** an interoperability claim.
- Every node record the provider emits carries `lora_freq` / `modem_preset`.
The other protocols reach `nodes.lora_freq` through their position and
telemetry payloads; an announce carries neither, so without this the values
never leave the ingestor heartbeat. An unresolved value is **omitted**, never
sent as null.
- Read **once at startup**: a config file is not the running stack, and
`get_interface_stats` exposes no radio parameters to ask instead. Reconfiguring
`rnsd` without restarting the ingestor leaves the reported values stale.
### Host-owned destinations (SPEC RE8)
The ingestor's own aspects never arrive as announces -- nothing relays our own
+17
View File
@@ -275,6 +275,21 @@ lowercased substring of the interface's string form, e.g. ``rnode``).
**Empty (the default) ingests every interface**, preserving the behaviour the
provider shipped with (#888)."""
RETICULUM_FREQ: str | None = (
_clean_env_fragment(os.environ.get("RETICULUM_FREQ", "")) or None
)
"""Operator override for the Reticulum LoRa frequency (SPEC RL1).
Wins over the value parsed from the RNS config, mirroring how
:envvar:`FREQUENCY` overrides the auto-detected :data:`LORA_FREQ`.
"""
RETICULUM_PRESET: str | None = (
_clean_env_fragment(os.environ.get("RETICULUM_PRESET", "")) or None
)
"""Operator override for the Reticulum radio preset label (SPEC RL1)."""
_raw_transport = os.environ.get("TRANSPORT", "api").strip().lower()
if _raw_transport not in ("api", "udp"):
raise ValueError(f"Unknown TRANSPORT={_raw_transport!r}. Valid options: api, udp")
@@ -625,7 +640,9 @@ __all__ = [
"MESH_UDP_PORT",
"INGESTOR_NODE_ID",
"RETICULUM_CONFIG_DIR",
"RETICULUM_FREQ",
"RETICULUM_INTERFACES",
"RETICULUM_PRESET",
"TX_ENABLED",
"TX_ANNOUNCE",
"RX_ONLY",
+265
View File
@@ -95,6 +95,7 @@ web upsert refuses to overwrite a real name with.
from __future__ import annotations
import math
import os
import threading
import time
@@ -506,6 +507,11 @@ def _announce_to_node_dict(
"protocol": "reticulum",
"user": user,
}
# Radio metadata rides on the node record (SPEC RL3). The web upsert already
# reads these keys; a Reticulum node never received them because the other
# protocols stamp them from position and telemetry payloads, and an announce
# carries neither -- so the table's Frequency and LoRa Preset columns and the
# chat/log tags all rendered blanks.
identity_hash = _reticulum_hash_hex(getattr(identity, "hash", None))
if identity_hash:
node["identityHash"] = identity_hash
@@ -517,6 +523,9 @@ def _announce_to_node_dict(
node["destination"] = {"id": hash_hex, "aspect": aspect, "role": role}
if interface:
node["interface"] = interface
# After the interface is known: the values describe the host's LoRa radio
# only, so a peer heard over an IP interface must not be stamped with them.
_attach_radio_metadata(node)
if hops is not None:
node["hopsAway"] = hops
return node
@@ -678,6 +687,252 @@ class _ReticulumInterface:
pass
_MESHTASTIC_PRESETS: dict[tuple[int, int, int], str] = {
# (bandwidth kHz, spreading factor, coding-rate denominator) -> preset name.
#
# Hand-maintained (SPEC RL2): the Meshtastic Python package defines the
# preset *enum* but not its radio parameters, which live in firmware, so
# nothing in the dependency tree can verify this table. Values are the
# region-independent modem settings each preset selects. A mismatch with
# upstream firmware is a bug in this table, not in the test that pins it.
(250, 7, 5): "ShortFast",
(250, 8, 5): "ShortSlow",
(250, 9, 5): "MediumFast",
(250, 10, 5): "MediumSlow",
(250, 11, 5): "LongFast",
(125, 12, 8): "LongSlow",
(125, 11, 8): "LongModerate",
(500, 7, 5): "ShortTurbo",
}
"""Meshtastic modem presets keyed on their (BW kHz, SF, CR) triple.
**Deliberately partial.** The firmware enum carries fourteen presets
(``meshtastic.protobuf.config_pb2``); this table holds the eight whose radio
parameters could be stated with confidence. Everything else -- ``LongTurbo``,
the ``Lite*`` and ``Narrow*`` families, and ``VeryLongSlow`` (whose 62.5 kHz
bandwidth does not key cleanly on integer kHz) -- falls through to the
``SF/BW/CR`` form, which is never *wrong*, only less friendly. Adding a row
whose parameters cannot be verified would be worse than the fallback: it would
put a confident wrong name on a radio.
"""
def _reticulum_preset_label(
bandwidth_khz: object, sf: object, cr: object
) -> str | None:
"""Name a radio configuration, preferring a Meshtastic preset (SPEC RL2).
An **exact** parameter match yields that preset's name; anything else falls
back to ``SF{sf}/BW{bw}/CR{cr}``, the format
:func:`~data.mesh_ingestor.interfaces.radio._custom_preset_label` already
produces, so no second radio-parameter format enters the codebase.
The name is a label, **not** an interoperability claim: a Reticulum radio on
``LongFast`` parameters cannot talk to a Meshtastic ``LongFast`` mesh.
Parameters:
bandwidth_khz: Bandwidth in kHz.
sf: Spreading factor.
cr: Coding-rate denominator.
Returns:
Preset name, ``SF/BW/CR`` string, or ``None`` when a value is missing.
"""
try:
bw_i, sf_i, cr_i = int(bandwidth_khz), int(sf), int(cr)
except (TypeError, ValueError):
return None
if not (bw_i and sf_i and cr_i):
return None
preset = _MESHTASTIC_PRESETS.get((bw_i, sf_i, cr_i))
if preset:
return preset
return f"SF{sf_i}/BW{bw_i}/CR{cr_i}"
def _parse_rnode_radio_config(text: str) -> dict | None:
"""Extract radio parameters from the first ``RNodeInterface`` in *text*.
Parses the RNS config's indented ``[[name]]`` interface blocks looking for
``type = RNodeInterface`` and its ``frequency`` / ``bandwidth`` /
``spreadingfactor`` / ``codingrate`` keys. **Only those four keys are
read** — the file also holds the shared-instance RPC key, which must never
be logged or carried anywhere (SPEC RL1).
RNS stores both frequencies in **Hz** (its own annotated example reads
``frequency = 867200000`` for 867.2 MHz and ``bandwidth = 125000`` for
125 kHz), so they are converted to MHz and kHz here.
Parameters:
text: Contents of the RNS config file.
Returns:
``{"frequency_mhz", "bandwidth_khz", "sf", "cr"}`` for the first
RNodeInterface found, or ``None`` when there is none.
"""
current: dict = {}
best: dict | None = None
for raw in text.splitlines():
line = raw.split("#", 1)[0].strip()
if not line:
continue
if line.startswith("["):
# A new section ends the one being collected.
if current.get("_is_rnode") and best is None:
best = current
current = {}
continue
if "=" not in line:
continue
key, _, value = line.partition("=")
key, value = key.strip().lower(), value.strip()
if key == "type":
current["_is_rnode"] = value == "RNodeInterface"
elif key in ("frequency", "bandwidth", "spreadingfactor", "codingrate"):
try:
current[key] = int(value)
except ValueError:
continue
if current.get("_is_rnode") and best is None:
best = current
if not best:
return None
frequency = best.get("frequency")
bandwidth = best.get("bandwidth")
return {
# Integer MHz, floored. `nodes.lora_freq` and `ingestors.lora_freq` are
# INTEGER columns and the field orients a reader in a band (433 vs 868)
# rather than stating an exact frequency -- MeshCore's 869.525 is
# likewise stored as 869. A float here was silently truncated on write.
"frequency_mhz": int(frequency // 1_000_000) if frequency else None,
"bandwidth_khz": int(bandwidth / 1000) if bandwidth else None,
"sf": best.get("spreadingfactor"),
"cr": best.get("codingrate"),
}
def _coerce_band_mhz(value: object) -> int | None:
"""Coerce an operator-supplied frequency to floored integer MHz.
Accepts a bare number or one carrying a unit suffix (``867.2MHz``), because
that is the form the other frequency settings are written in and the form
documented for this one. Both floor to the same band: the column is
``INTEGER`` and a non-integer value was silently stored as ``NULL``, so the
documented override did nothing at all.
Parameters:
value: Raw :envvar:`RETICULUM_FREQ` value.
Returns:
Integer MHz, or ``None`` when nothing usable was supplied.
"""
if value is None:
return None
text = str(value).strip().lower()
if not text:
return None
for suffix in ("mhz", "m"):
if text.endswith(suffix):
text = text[: -len(suffix)].strip()
break
try:
parsed = float(text)
except ValueError:
return None
if not math.isfinite(parsed) or parsed <= 0:
return None
return int(parsed)
def _read_reticulum_radio_metadata() -> tuple[object, str | None]:
"""Resolve the Reticulum frequency and preset label (SPEC RL1).
Precedence: :envvar:`RETICULUM_FREQ` / :envvar:`RETICULUM_PRESET`, then the
shared RNS config, then ``None`` — mirroring how :envvar:`FREQUENCY`
overrides the auto-detected :data:`~data.mesh_ingestor.config.LORA_FREQ`.
The config *file* is the only source available: ``get_interface_stats``
carries no radio parameters at all, and a shared-instance client's own
``Transport.interfaces`` holds only its local-client interface. It is
therefore a snapshot, and goes stale if ``rnsd`` is reconfigured without
restarting the ingestor (the RA12 caveat, with no RPC alternative).
Returns:
``(frequency, preset)``, either of which may be ``None``.
"""
frequency: object = _coerce_band_mhz(config.RETICULUM_FREQ)
preset: str | None = config.RETICULUM_PRESET
if frequency is not None and preset is not None:
return frequency, preset
parsed = None
config_dir = config.RETICULUM_CONFIG_DIR
if isinstance(config_dir, str) and config_dir.strip():
try:
path = os.path.join(os.path.expanduser(config_dir), "config")
with open(path, "r", encoding="utf-8", errors="replace") as handle:
parsed = _parse_rnode_radio_config(handle.read())
except OSError:
# Absent or unreadable config: every downstream field keeps its
# dash rather than inventing a number.
parsed = None
if parsed:
if frequency is None:
frequency = parsed["frequency_mhz"]
if preset is None:
preset = _reticulum_preset_label(
parsed["bandwidth_khz"], parsed["sf"], parsed["cr"]
)
return frequency, preset
def _is_lora_interface(interface: object) -> bool:
"""Report whether an interface name denotes the host's own LoRa radio.
The resolved frequency and preset describe **one** interface -- the
``RNodeInterface`` they were parsed from. A peer heard over
``AutoInterface``, ``TCPInterface`` or ``LocalInterface`` reached us with no
LoRa involved, so attributing the operator's radio settings to it would
publish (and federate) a claim about that peer that is simply untrue.
Parameters:
interface: Interface string an announce arrived on, if known.
Returns:
``True`` only for an RNode interface.
"""
return isinstance(interface, str) and "rnode" in interface.lower()
def _attach_radio_metadata(node: dict) -> None:
"""Stamp the resolved LoRa frequency and preset onto a node record.
The other protocols reach ``nodes.lora_freq`` / ``nodes.modem_preset``
through their position and telemetry payloads; a Reticulum announce carries
neither, so without this the values the ingestor resolved (SPEC RL1/RL2)
never left the heartbeat and every per-node radio field stayed blank.
Applied **only** to a record that arrived over the host's LoRa radio: these
values describe that one interface, and an RNS stack routinely carries IP
interfaces alongside it. A record with no interface at all is the host's
own (its destinations are discovered, not heard), so it keeps them.
Mutates *node* in place, omitting either key that is unresolved so an absent
value keeps its dash rather than being written as null.
Parameters:
node: Node dict destined for ``POST /api/nodes``.
"""
interface = node.get("interface")
if interface is not None and not _is_lora_interface(interface):
return
frequency = getattr(config, "LORA_FREQ", None)
preset = getattr(config, "MODEM_PRESET", None)
if frequency is not None:
node["lora_freq"] = frequency
if preset is not None:
node["modem_preset"] = preset
def _local_path_entries() -> list[dict]:
"""Return the running stack's 0-hop path-table entries.
@@ -871,6 +1126,7 @@ def _host_destination_nodes(identity_hash: str) -> list[dict]:
interface = local.get(dest_hex)
if interface:
record["interface"] = interface
_attach_radio_metadata(record)
records.append(record)
transport = _transport_identity_hash()
if transport and _transport_enabled():
@@ -893,6 +1149,7 @@ def _host_destination_nodes(identity_hash: str) -> list[dict]:
},
}
)
_attach_radio_metadata(records[-1])
return records
@@ -1008,6 +1265,14 @@ class ReticulumProvider:
iface._announce_handlers.append(handler)
iface.isConnected = True
# Radio metadata (SPEC RL1): Reticulum has no equivalent of the
# Meshtastic localConfig read, so the shared RNS config supplies the
# frequency and preset the heartbeat and every downstream column need.
radio_freq, radio_preset = _read_reticulum_radio_metadata()
if radio_freq is not None and getattr(config, "LORA_FREQ", None) is None:
config.LORA_FREQ = radio_freq
if radio_preset is not None and getattr(config, "MODEM_PRESET", None) is None:
config.MODEM_PRESET = radio_preset
# Resolve the host id for the log rather than reading
# +iface.host_node_id+, which is a constant None: the startup line
# printed node_id=None on every run regardless of what discovery would
+3
View File
@@ -76,6 +76,9 @@ x-ingestor-base: &ingestor-base
# as literal text, so `:-""` yields a two-character value that parses as a
# one-entry allowlist matching nothing (i.e. ingests no announces at all).
RETICULUM_INTERFACES: ${RETICULUM_INTERFACES:-}
# Read from the RNS config when unset; these only override it.
RETICULUM_FREQ: ${RETICULUM_FREQ:-}
RETICULUM_PRESET: ${RETICULUM_PRESET:-}
# Required for PROTOCOL=reticulum and TRANSPORT=udp: without it the ingestor
# heartbeat never registers and the protocol's packets/hour stays at zero.
INGESTOR_NODE_ID: ${INGESTOR_NODE_ID:-}
+16
View File
@@ -381,6 +381,18 @@
description = "RNS config directory for protocol=reticulum; defaults to ~/.reticulum. Point it at the directory rnsd uses so interface filtering resolves names";
};
reticulumFreq = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Frequency shown for Reticulum nodes; overrides the value read from the RNS config";
};
reticulumPreset = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Radio preset shown for Reticulum nodes; overrides the value derived from the RNS config's bandwidth/spreading factor/coding rate";
};
reticulumInterfaces = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
@@ -555,6 +567,10 @@
INGESTOR_NODE_ID = cfg.ingestor.nodeId;
} // lib.optionalAttrs (cfg.ingestor.reticulumConfigDir != null) {
RETICULUM_CONFIG_DIR = cfg.ingestor.reticulumConfigDir;
} // lib.optionalAttrs (cfg.ingestor.reticulumFreq != null) {
RETICULUM_FREQ = cfg.ingestor.reticulumFreq;
} // lib.optionalAttrs (cfg.ingestor.reticulumPreset != null) {
RETICULUM_PRESET = cfg.ingestor.reticulumPreset;
} // lib.optionalAttrs (cfg.ingestor.reticulumInterfaces != null) {
RETICULUM_INTERFACES = cfg.ingestor.reticulumInterfaces;
} // lib.optionalAttrs (cfg.allowedChannels != null) {
+343
View File
@@ -1989,3 +1989,346 @@ def test_snapshot_includes_host_destinations_not_heard_as_announces(monkeypatch)
by_aspect["nomadnetwork.node"]["user"]["longName"]
== "Department of Decentralization"
)
# ---------------------------------------------------------------------------
# Radio metadata from the shared RNS config (SPEC RL1/RL2)
# ---------------------------------------------------------------------------
# RNS's own annotated example (RNS/Utilities/rnsd.py): "Set frequency to
# 867.2 MHz" -> 867200000, "Set LoRa bandwidth to 125 KHz" -> 125000. Both are
# stored in Hz, which is why the parser converts.
_RNS_CONFIG = """
[reticulum]
enable_transport = Yes
rpc_key = deadbeefdeadbeefdeadbeefdeadbeef
[interfaces]
[[Default Interface]]
type = AutoInterface
interface_enabled = True
[[RNode LoRa Interface]]
type = RNodeInterface
interface_enabled = True
port = /dev/ttyUSB0
frequency = 867200000
bandwidth = 125000
txpower = 7
spreadingfactor = 8
codingrate = 5
"""
def test_rnode_radio_config_is_parsed_in_the_units_rns_stores(tmp_path, monkeypatch):
"""Frequency and bandwidth are Hz in the config; MHz and kHz downstream."""
parsed = _mod._parse_rnode_radio_config(_RNS_CONFIG)
assert parsed == {
# Floored integer MHz: the column is INTEGER and the field orients a
# reader in a band rather than stating an exact frequency.
"frequency_mhz": 867,
"bandwidth_khz": 125,
"sf": 8,
"cr": 5,
}
def test_rnode_radio_config_ignores_non_rnode_interfaces():
"""An AutoInterface carries no radio, and must not be read as one."""
only_auto = """
[interfaces]
[[Default Interface]]
type = AutoInterface
interface_enabled = True
"""
assert _mod._parse_rnode_radio_config(only_auto) is None
assert _mod._parse_rnode_radio_config("") is None
def test_rnode_radio_config_survives_a_malformed_value():
"""A non-integer value is skipped rather than crashing the connect path."""
broken = """
[interfaces]
[[RNode]]
type = RNodeInterface
frequency = not-a-number
bandwidth = 125000
spreadingfactor = 8
codingrate = 5
"""
parsed = _mod._parse_rnode_radio_config(broken)
assert parsed["frequency_mhz"] is None
assert parsed["bandwidth_khz"] == 125
def test_reticulum_radio_metadata_prefers_the_env_override(tmp_path, monkeypatch):
"""RETICULUM_FREQ/RETICULUM_PRESET win over the parsed config (SPEC RL1)."""
(tmp_path / "config").write_text(_RNS_CONFIG, encoding="utf-8")
monkeypatch.setattr(_mod.config, "RETICULUM_CONFIG_DIR", str(tmp_path))
monkeypatch.setattr(_mod.config, "RETICULUM_FREQ", None)
monkeypatch.setattr(_mod.config, "RETICULUM_PRESET", None)
assert _mod._read_reticulum_radio_metadata() == (867, "SF8/BW125/CR5")
monkeypatch.setattr(_mod.config, "RETICULUM_FREQ", "868MHz")
monkeypatch.setattr(_mod.config, "RETICULUM_PRESET", "Custom")
assert _mod._read_reticulum_radio_metadata() == (868, "Custom")
def test_reticulum_radio_metadata_is_none_without_a_readable_config(monkeypatch):
"""No config, no numbers -- the columns keep their dash (SPEC RL1)."""
monkeypatch.setattr(_mod.config, "RETICULUM_FREQ", None)
monkeypatch.setattr(_mod.config, "RETICULUM_PRESET", None)
monkeypatch.setattr(_mod.config, "RETICULUM_CONFIG_DIR", "/nonexistent/rns-dir")
assert _mod._read_reticulum_radio_metadata() == (None, None)
# An unset config dir must not raise on the connect path either.
monkeypatch.setattr(_mod.config, "RETICULUM_CONFIG_DIR", None)
assert _mod._read_reticulum_radio_metadata() == (None, None)
def test_preset_label_names_a_meshtastic_preset_only_on_an_exact_match():
"""SPEC RL2, with each row's provenance stated.
The table is hand-maintained: the Meshtastic Python package defines the
preset *enum* but not its radio parameters, which live in firmware, so
nothing in the dependency tree can verify these. A mismatch with upstream
is a bug in the table, not in this test.
"""
label = _mod._reticulum_preset_label
assert label(250, 11, 5) == "LongFast"
assert label(250, 10, 5) == "MediumSlow"
assert label(250, 9, 5) == "MediumFast"
assert label(250, 8, 5) == "ShortSlow"
assert label(250, 7, 5) == "ShortFast"
assert label(500, 7, 5) == "ShortTurbo"
assert label(125, 12, 8) == "LongSlow"
# One parameter off the triple is not that preset.
assert label(125, 11, 5) == "SF11/BW125/CR5"
assert label(250, 11, 8) == "SF11/BW250/CR8"
# Missing or unusable values name nothing rather than guessing.
assert label(None, 11, 5) is None
assert label(250, 0, 5) is None
assert label("x", 11, 5) is None
def test_preset_fallback_matches_the_shape_the_repo_already_emits():
"""The fallback is the existing SF/BW/CR format, not a second one."""
from data.mesh_ingestor.interfaces import radio as radio_mod
class _Lora:
spread_factor = 8
bandwidth = 125
coding_rate = 5
assert radio_mod._custom_preset_label(_Lora()) == "SF8/BW125/CR5"
assert _mod._reticulum_preset_label(125, 8, 5) == "SF8/BW125/CR5"
def test_rnode_radio_config_takes_the_first_rnode_of_several():
"""Two RNodes: the first wins, and a later section cannot overwrite it.
Exercises the section boundary -- a second interface block must close the
first rather than merging keys across them.
"""
two = """
[interfaces]
[[RNode A]]
type = RNodeInterface
frequency = 867200000
bandwidth = 125000
spreadingfactor = 8
codingrate = 5
[[RNode B]]
type = RNodeInterface
frequency = 915000000
bandwidth = 500000
spreadingfactor = 7
codingrate = 5
"""
parsed = _mod._parse_rnode_radio_config(two)
assert parsed["frequency_mhz"] == 867
assert parsed["bandwidth_khz"] == 125
# A line with no '=' (a bare token) is skipped rather than mis-split.
assert _mod._parse_rnode_radio_config("[interfaces]\n bare-token\n") is None
def test_connect_populates_radio_metadata_from_the_config(tmp_path, monkeypatch):
"""The connect path fills LORA_FREQ/MODEM_PRESET for the heartbeat (RL1)."""
(tmp_path / "config").write_text(_RNS_CONFIG, encoding="utf-8")
fake, _state = _fake_rns(existing_instance=object())
monkeypatch.setattr(_mod, "RNS", fake)
monkeypatch.setattr(_mod.config, "RETICULUM_CONFIG_DIR", str(tmp_path))
monkeypatch.setattr(_mod.config, "RETICULUM_FREQ", None)
monkeypatch.setattr(_mod.config, "RETICULUM_PRESET", None)
monkeypatch.setattr(_mod.config, "LORA_FREQ", None)
monkeypatch.setattr(_mod.config, "MODEM_PRESET", None)
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
ReticulumProvider().connect(active_candidate=None)
assert _mod.config.LORA_FREQ == 867
assert _mod.config.MODEM_PRESET == "SF8/BW125/CR5"
def test_connect_never_overwrites_metadata_already_resolved(tmp_path, monkeypatch):
"""An operator-set or already-detected value is left alone."""
(tmp_path / "config").write_text(_RNS_CONFIG, encoding="utf-8")
fake, _state = _fake_rns(existing_instance=object())
monkeypatch.setattr(_mod, "RNS", fake)
monkeypatch.setattr(_mod.config, "RETICULUM_CONFIG_DIR", str(tmp_path))
monkeypatch.setattr(_mod.config, "RETICULUM_FREQ", None)
monkeypatch.setattr(_mod.config, "RETICULUM_PRESET", None)
monkeypatch.setattr(_mod.config, "LORA_FREQ", 915.0)
monkeypatch.setattr(_mod.config, "MODEM_PRESET", "AlreadySet")
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
ReticulumProvider().connect(active_candidate=None)
assert _mod.config.LORA_FREQ == 915.0
assert _mod.config.MODEM_PRESET == "AlreadySet"
def test_node_records_carry_the_resolved_radio_metadata(monkeypatch):
"""SPEC RL3: the values must ride on the node, not just the heartbeat.
Meshtastic and MeshCore reach ``nodes.lora_freq`` / ``nodes.modem_preset``
through their position and telemetry payloads. A Reticulum announce carries
neither, so without stamping them here the table's Frequency and LoRa Preset
columns and the chat/log tags all stayed blank while the ingestor knew the
answer -- the symptom this feature was opened for.
"""
monkeypatch.setattr(_mod.config, "LORA_FREQ", 867)
monkeypatch.setattr(_mod.config, "MODEM_PRESET", "SF8/BW125/CR5")
node = _announce_to_node_dict(
_DEST_HASH, b"Alice", identity=_FakeIdentity(), aspect="lxmf.delivery"
)
assert node["lora_freq"] == 867
assert node["modem_preset"] == "SF8/BW125/CR5"
def test_node_records_omit_radio_metadata_when_unresolved(monkeypatch):
"""An absent value is left out entirely, so the column keeps its dash."""
monkeypatch.setattr(_mod.config, "LORA_FREQ", None)
monkeypatch.setattr(_mod.config, "MODEM_PRESET", None)
node = _announce_to_node_dict(
_DEST_HASH, b"Alice", identity=_FakeIdentity(), aspect="lxmf.delivery"
)
assert "lora_freq" not in node
assert "modem_preset" not in node
def test_host_destination_records_carry_radio_metadata_too(monkeypatch):
"""The host's own discovered aspects are node records like any other."""
_local_stack(
monkeypatch,
{_FIELD_LXMF: _FIELD_PRIMARY, _FIELD_NOMADNET: _FIELD_PRIMARY},
transport_enabled=True,
)
monkeypatch.setattr(_mod.config, "LORA_FREQ", 867)
monkeypatch.setattr(_mod.config, "MODEM_PRESET", "LongFast")
records = ReticulumProvider().host_destination_nodes()
assert records, "expected host destinations"
assert all(r["lora_freq"] == 867 for r in records)
assert all(r["modem_preset"] == "LongFast" for r in records)
def test_radio_metadata_partial_override_still_reads_the_config(tmp_path, monkeypatch):
"""One variable set and the other unset is the case an operator hits.
The earlier test covered only both-unset and both-set, leaving the mixed
branches uncovered -- and it is the mixed case that decides whether the
config is consulted at all.
"""
(tmp_path / "config").write_text(_RNS_CONFIG, encoding="utf-8")
monkeypatch.setattr(_mod.config, "RETICULUM_CONFIG_DIR", str(tmp_path))
# Frequency pinned, preset still derived from the config.
monkeypatch.setattr(_mod.config, "RETICULUM_FREQ", "868MHz")
monkeypatch.setattr(_mod.config, "RETICULUM_PRESET", None)
assert _mod._read_reticulum_radio_metadata() == (868, "SF8/BW125/CR5")
# Preset pinned, frequency still derived.
monkeypatch.setattr(_mod.config, "RETICULUM_FREQ", None)
monkeypatch.setattr(_mod.config, "RETICULUM_PRESET", "LongFast")
assert _mod._read_reticulum_radio_metadata() == (867, "LongFast")
def test_band_coercion_survives_the_integer_column():
"""`nodes.lora_freq` is INTEGER, so a float override stored as NULL.
The documented `RETICULUM_FREQ="867.2MHz"` form did nothing at all until it
was floored here: `coerce_integer` rejects the suffixed string outright and
truncates a float, so the column dashed or silently lost 200 kHz.
"""
coerce = _mod._coerce_band_mhz
assert coerce("867.2MHz") == 867
assert coerce("868") == 868
assert coerce(915.9) == 915
assert coerce("433 mhz") == 433
assert coerce("") is None
assert coerce(None) is None
assert coerce("not-a-frequency") is None
assert coerce("-5") is None
def test_radio_metadata_is_scoped_to_the_lora_interface(monkeypatch):
"""A peer heard over IP must not be stamped with the operator's radio.
The resolved values describe one interface -- the RNodeInterface they were
parsed from -- and an RNS stack routinely carries IP interfaces alongside
it. Attributing them to a peer heard over AutoInterface publishes, and
federates, a claim about that peer that is untrue (SPEC RL1).
"""
monkeypatch.setattr(_mod.config, "LORA_FREQ", 867)
monkeypatch.setattr(_mod.config, "MODEM_PRESET", "SF8/BW125/CR5")
def node_for(interface):
return _announce_to_node_dict(
_DEST_HASH,
b"A",
identity=_FakeIdentity(),
aspect="lxmf.delivery",
interface=interface,
)
lora = node_for("RNodeInterface[RNode Reticulum Berlin]")
assert lora["lora_freq"] == 867 and lora["modem_preset"] == "SF8/BW125/CR5"
for other in (
"AutoInterface[Default Interface]",
"TCPInterface[peer]",
"LocalInterface[rns/default]",
):
node = node_for(other)
assert "lora_freq" not in node, other
assert "modem_preset" not in node, other
# The host's own discovered destinations carry no interface and keep them.
own = node_for(None)
assert own["lora_freq"] == 867
def test_rnode_radio_config_skips_a_line_without_an_assignment():
"""A bare token inside an interface block is skipped, not mis-split."""
odd = """
[interfaces]
[[RNode]]
type = RNodeInterface
bare-token-with-no-equals
frequency = 867200000
"""
assert _mod._parse_rnode_radio_config(odd)["frequency_mhz"] == 867
def test_radio_metadata_short_circuits_when_both_are_overridden(monkeypatch):
"""With both pinned the config is never opened at all."""
monkeypatch.setattr(_mod.config, "RETICULUM_FREQ", "868")
monkeypatch.setattr(_mod.config, "RETICULUM_PRESET", "LongFast")
def _explode(*_a, **_k):
raise AssertionError("the config must not be read when both are pinned")
monkeypatch.setattr(_mod, "_parse_rnode_radio_config", _explode)
assert _mod._read_reticulum_radio_metadata() == (868, "LongFast")
@@ -54,6 +54,12 @@ module PotatoMesh
["ingestor_activity", "at"],
["waypoints", "rx_time"],
["nodes", "last_heard"],
# A destination outlives the node it belongs to otherwise: the table
# carries no foreign key, so nothing cascades when its node is purged
# and the row is invisible to every view except an unfiltered
# /api/destinations. Keyed on the same column as its node, so the two
# age out together.
["destinations", "last_heard"],
].freeze
# Run a single retention sweep, deleting rows older than +cutoff+ from
@@ -274,18 +274,26 @@ const fetchImpl = async () => ({
assert.match(secondRowHtml, /2\.0\.0/);
assert.match(secondRowHtml, />2</);
assert.match(secondRowHtml, /d ago/);
// A peer reporting a zero RNS count renders the dash too, not a violet
// tile beside a 0 — otherwise nearly every federated instance would carry
// one during rollout and the column would read as broken (SPEC RD8).
// SPEC RL4: zero is a muted 0 without a tile — distinct from absent, which
// is still the dash. The tile appears only where the count carries
// information, so a rollout full of zeros does not fill the column with
// markers. This supersedes the earlier rule that dashed a zero.
const thirdRowHtml = rows[2].innerHTML;
assert.match(thirdRowHtml, /instances-col--reticulum-nodes mono"><em>—<\/em></);
assert.match(
thirdRowHtml,
/instances-col--reticulum-nodes mono"><span class="cell-empty">0<\/span></,
);
// Its MeshCore count is non-zero, so that column still renders its tile —
// proving the dash comes from the value, not from the row.
// proving the rendering comes from the value, not from the row.
assert.match(thirdRowHtml, /instances-col--meshcore-nodes mono"><img[^>]*> 7</);
// An instance without a reticulum count renders the muted dash, exactly
// like the other per-protocol columns.
// An instance carrying no reticulum count at all still dashes — the whole
// point of RL4 is that absent and zero now read differently, and this row
// is the absent half against the zero-valued third row above.
assert.match(secondRowHtml, /instances-col--reticulum-nodes mono"><em>—<\/em></);
// The same rule governs its two siblings, not just Reticulum.
assert.match(secondRowHtml, /instances-col--meshcore-nodes mono"><em>—<\/em></);
assert.match(secondRowHtml, /instances-col--meshtastic-nodes mono"><em>—<\/em></);
assert.deepEqual(mapFitBoundsCalls[0][0], [[10.12345, -20.98765]]);
assert.equal(circleMarkerCalls[0].options.fillColor, roleColors.CLIENT_HIDDEN);
@@ -1005,3 +1013,34 @@ test('federation table sanitises <a> tags and strips other HTML in contact field
cleanup();
}
});
// --- SPEC RL4: one node-count rule for every protocol column ---
test('a protocol count renders absent, zero and non-zero distinctly', async () => {
const { renderProtocolCountCell } = await import('../federation-page.js');
const tile = () => '<img src="/assets/img/reticulum.svg">';
// Absent: the instance reported nothing.
assert.equal(renderProtocolCountCell(null, tile), '<em>—</em>');
// Zero: a fact, and it reads as one — but the tile would be a protocol
// marker beside a count carrying no information, and during rollout most
// instances report zero.
assert.equal(renderProtocolCountCell(0, tile), '<span class="cell-empty">0</span>');
// Non-zero: tile then count.
assert.match(renderProtocolCountCell(7, tile), /reticulum\.svg">\s7$/);
});
test('the count rule is protocol-agnostic', async () => {
// The bug RL4 fixes was three columns with two different rules: MeshCore and
// Meshtastic tested `== null` (so a real 0 drew a tile) while Reticulum
// tested `!value` (so 0 and absent were indistinguishable).
const { renderProtocolCountCell } = await import('../federation-page.js');
for (const name of ['meshcore', 'meshtastic', 'reticulum']) {
const tile = () => `<img src="/assets/img/${name}.svg">`;
assert.equal(renderProtocolCountCell(null, tile), '<em>—</em>', name);
assert.equal(
renderProtocolCountCell(0, tile), '<span class="cell-empty">0</span>', name,
);
assert.match(renderProtocolCountCell(2, tile), new RegExp(`${name}\\.svg">\\s2$`), name);
}
});
@@ -392,3 +392,54 @@ test('renderShortHtml adds single space padding for plain 2-char name', () => {
assert.ok(html.includes('&nbsp;ab&nbsp;'), '2-char name should have one space on each side');
});
});
// --- SPEC RL3: the chat/log tags fill from the node's own radio metadata ---
test('a Reticulum node announcement fills its frequency and preset tags', () => {
// The reported symptom was two empty brackets on every Reticulum line:
// [18:53:11][ ][ ] 2771 ☀️ New node: …
// No renderer needed a Reticulum branch — the tags were always driven by the
// node's lora_freq/modem_preset, which the provider simply never sent.
withApp((t) => {
const div = t.createAnnouncementEntry({
timestampSeconds: 1000,
shortName: '2771',
longName: 'Department of Decentralization',
role: 'NODE',
metadataSource: {
protocol: 'reticulum',
// Integer MHz: `nodes.lora_freq` is an INTEGER column and the field
// orients a reader in a band rather than stating an exact frequency.
lora_freq: 867,
modem_preset: 'LongFast',
},
nodeData: null,
messageHtml: 'New node',
});
const html = innerHtml(div);
// Assert the values, not the absence of blanks: an empty slot serialises as
// `&nbsp;` entities, so a /\s/-based "not blank" check can never fail and
// pinned nothing at all.
assert.match(html, /\[867\]/, `frequency slot not filled: ${html}`);
assert.match(html, /\[LF\]/, `preset slot not filled: ${html}`);
});
});
test('a Reticulum announcement without radio metadata keeps its empty slots', () => {
// Absent stays absent: the slots hold their width rather than inventing a
// number, exactly as they do for any other protocol that reported none.
withApp((t) => {
const div = t.createAnnouncementEntry({
timestampSeconds: 1000,
shortName: '2771',
longName: 'Department of Decentralization',
role: 'NODE',
metadataSource: { protocol: 'reticulum' },
nodeData: null,
messageHtml: 'New node',
});
const html = innerHtml(div);
assert.doesNotMatch(html, /867/);
assert.doesNotMatch(html, /\[LF\]/);
});
});
+41 -14
View File
@@ -38,6 +38,32 @@ import {
*/
let relativeTimeTicker = null;
/**
* Render one protocol node-count cell (SPEC RL4).
*
* Three states, one rule for every protocol:
*
* - **absent** (`null`) — a muted em dash: the instance reported nothing.
* - **zero** — a muted `0` with **no** tile. Zero is a fact and reads as one,
* but the tile is a protocol marker beside a count that carries no
* information, and during rollout most instances report zero.
* - **non-zero** — the protocol tile followed by the count.
*
* Reticulum's `normalize_instance_row` serves its count as `|| 0` so a crawler
* re-verifying a relayed record rebuilds the canonical the sender signed (FS2),
* so that column shows a zero where the others may show a dash. That is the
* data differing, not the rule.
*
* @param {?number} value Coerced count, or `null` when absent.
* @param {Function} iconHtml Protocol tile renderer.
* @returns {string} Cell markup.
*/
export function renderProtocolCountCell(value, iconHtml) {
if (value == null) return '<em>—</em>';
if (!value) return '<span class="cell-empty">0</span>';
return `${iconHtml()} ${escapeHtml(String(value))}`;
}
/**
* Expose the page's ticker handle for unit tests (SPEC RT5).
*
@@ -518,20 +544,21 @@ export async function initializeFederationPage(options = {}) {
const contactHtml = renderContactHtml(instance.contact_link ?? instance.contactLink);
const nodesCountValue = toFiniteNumber(instance.nodes_count ?? instance.nodesCount);
const nodesCountText = nodesCountValue == null ? '<em>—</em>' : escapeHtml(String(nodesCountValue));
const mcNodesVal = toFiniteNumber(instance.meshcore_nodes_count ?? instance.meshcoreNodesCount);
const mcNodesText = mcNodesVal == null ? '<em>—</em>' : `${meshcoreIconHtml()} ${escapeHtml(String(mcNodesVal))}`;
const mtNodesVal = toFiniteNumber(instance.meshtastic_nodes_count ?? instance.meshtasticNodesCount);
const mtNodesText = mtNodesVal == null ? '<em>—</em>' : `${meshtasticIconHtml()} ${escapeHtml(String(mtNodesVal))}`;
// Reticulum alone treats 0 as "nothing to show" (SPEC RD8). Its two
// siblings can distinguish absent from zero, but `normalize_instance_row`
// serves `reticulum_nodes_count` as `|| 0` so a crawler re-verifying a
// relayed v2 record rebuilds the canonical the sender signed (FS2) — so on
// this column the two are indistinguishable, and every federated instance
// would otherwise show a violet tile beside a 0. During rollout nearly all
// of them are exactly that, which would make the column read as broken
// rather than as new.
const rtNodesVal = toFiniteNumber(instance.reticulum_nodes_count ?? instance.reticulumNodesCount);
const rtNodesText = !rtNodesVal ? '<em>—</em>' : `${reticulumIconHtml()} ${escapeHtml(String(rtNodesVal))}`;
// One rule for all three protocol columns (SPEC RL4). Previously MeshCore
// and Meshtastic tested `== null` (so a real 0 drew a tile) while
// Reticulum tested `!value` (so 0 and absent were indistinguishable).
const mcNodesText = renderProtocolCountCell(
toFiniteNumber(instance.meshcore_nodes_count ?? instance.meshcoreNodesCount),
meshcoreIconHtml,
);
const mtNodesText = renderProtocolCountCell(
toFiniteNumber(instance.meshtastic_nodes_count ?? instance.meshtasticNodesCount),
meshtasticIconHtml,
);
const rtNodesText = renderProtocolCountCell(
toFiniteNumber(instance.reticulum_nodes_count ?? instance.reticulumNodesCount),
reticulumIconHtml,
);
// Cell order mirrors `_instances_table.erb`'s traveler-first header
// order (SPEC UX9/UX12): where + settings + alive lead; coordinates
@@ -292,3 +292,37 @@ test('a sub-row escapes a hostile destination name', () => {
assert.doesNotMatch(html, /<img/);
assert.match(html, /&lt;img/);
});
test('a sub-row renders its role as a coloured chip, like its parent', () => {
// A sub-row that spells its role in plain text while the row above it uses
// colour reads as two different kinds of value (field report).
const html = subRowCellsHtml(FIELD.nomadnet, '!27716218', '<td class="ts"></td>', badge);
assert.match(html, /class="role-chip"/);
assert.match(html, new RegExp(reticulumRoleColors.NODE));
assert.match(html, />NODE</);
});
test('a sub-row with no role dashes rather than rendering an empty chip', () => {
const html = subRowCellsHtml(
{ id: 'c'.repeat(32), aspect: 'lxmf.delivery', role: null, name: 'x' },
'!27716218', '<td class="ts"></td>', badge,
);
assert.doesNotMatch(html, /role-chip/);
assert.match(html, /nodes-col--role"><span class="cell-empty">/);
});
test('a sub-row identifier links to the destination page and its anchor (SPEC RL5)', () => {
const html = subRowCellsHtml(FIELD.lxmf, '!27716218', '<td class="ts"></td>', badge);
// Links to the destination's own canonical id, which resolves server-side to
// the identity that owns it, and carries the fragment for that row.
assert.match(html, /href="\/nodes\/!4cf985bf#dest-4cf985bf"/);
assert.match(html, />\[!4cf985bf\]</);
});
test('a sub-row with no id emits plain text, not an empty link', () => {
const html = subRowCellsHtml(
{ aspect: 'lxmf.delivery', role: 'PEER', name: 'x' },
'!27716218', '<td class="ts"></td>', badge,
);
assert.doesNotMatch(html, /<a /);
});
@@ -312,7 +312,12 @@ export function subRowCellsHtml(destination, parentNodeId, lastSeenCellHtml, ren
const name = typeof destination?.name === 'string' ? destination.name.trim() : '';
const nameCell = [
name ? escapeHtml(name) : formatTableCell(''),
shortId ? `<span class="nodes-subrow__id mono">[${escapeHtml(shortId)}]</span>` : '',
// A link, not decoration (SPEC RL5): /nodes/!<destination> canonicalises to
// the owning identity (RA5), and until this existed the route was reachable
// only by typing a URL.
shortId
? `<a class="nodes-subrow__id mono" href="/nodes/${encodeURIComponent(shortId)}#dest-${escapeHtml(destinationId.slice(0, 8))}">[${escapeHtml(shortId)}]</a>`
: '',
].filter(Boolean).join(' ');
const blank = formatTableCell('');
const blanks = [
@@ -332,7 +337,12 @@ export function subRowCellsHtml(destination, parentNodeId, lastSeenCellHtml, ren
`<td class="nodes-col nodes-col--long-name">${nameCell}</td>` +
blanks +
lastSeenCellHtml +
`<td class="nodes-col nodes-col--role">${formatTableCell(escapeHtml(destination?.role || ''))}</td>` +
// The role renders as a chip in its protocol colour, exactly as it does on
// the parent row -- a sub-row that spells its role in plain text while the
// row above it uses colour reads as two different kinds of value.
`<td class="nodes-col nodes-col--role">${
destination?.role ? roleChipsHtml([destination], 'reticulum') : formatTableCell('')
}</td>` +
trailing +
'<td class="nodes-col nodes-col--more"></td>'
);
@@ -159,3 +159,61 @@ test('the Destinations section is pushed into the node detail (SPEC RA5)', async
});
assert.doesNotMatch(without, /node-detail__destinations/);
});
test('a destination hash links to its identity page and its row is anchored (SPEC RL5)', () => {
// RA5 specified that /nodes/!<destination> canonicalises to the owning
// identity, and the route does — but nothing in the interface ever emitted
// the link, so the behaviour was reachable only by typing a URL. A spec claim
// no interface exercises is indistinguishable from one that is false.
const html = renderDestinationsSection(DESTINATIONS, { nowSeconds: NOW });
for (const d of DESTINATIONS) {
const short = d.id.slice(0, 8);
assert.ok(
html.includes(`<a href="/nodes/!${short}">${d.id}</a>`),
`${d.id} is not a link to its identity page`,
);
assert.ok(html.includes(`<tr id="dest-${short}">`), `${d.id} has no anchor`);
}
});
test('a destination with no id renders neither a link nor an anchor', () => {
const html = renderDestinationsSection(
[{ node_id: '!27716218', aspect: 'lxmf.delivery', role: 'PEER', name: null }],
{ nowSeconds: NOW },
);
assert.doesNotMatch(html, /<a href="\/nodes\//);
assert.doesNotMatch(html, /<tr id="dest-/);
});
test('the fragment is re-targeted after the table renders (SPEC RL5)', async () => {
// The destinations table is built after several awaited fetches, so the
// browser resolved `#dest-…` against a page that did not yet contain the row.
// Without this the link resolved but landed at the top of the page — the
// "lands on the row it names" half of RL5 silently did nothing.
const { scrollToHashTarget } = await import('../bootstrap.js');
const scrolled = [];
const doc = {
location: { hash: '#dest-4cf985bf' },
getElementById: id => (id === 'dest-4cf985bf'
? { scrollIntoView: () => scrolled.push(id) }
: null),
};
assert.equal(scrollToHashTarget(doc), true);
assert.deepEqual(scrolled, ['dest-4cf985bf']);
});
test('re-targeting is a no-op without a fragment or a matching row', async () => {
const { scrollToHashTarget } = await import('../bootstrap.js');
const missing = { location: { hash: '#dest-nope' }, getElementById: () => null };
assert.equal(scrollToHashTarget(missing), false);
assert.equal(
scrollToHashTarget({ location: { hash: '' }, getElementById: () => ({}) }),
false,
);
// An element without scrollIntoView (or no document at all) must not throw.
assert.equal(
scrollToHashTarget({ location: { hash: '#x' }, getElementById: () => ({}) }),
false,
);
assert.equal(scrollToHashTarget(null), false);
});
+28
View File
@@ -27,6 +27,28 @@ import { numberOrNull, stringOrNull } from '../value-helpers.js';
import { buildNeighborRoleIndex } from './role-index.js';
import { buildTraceRoleIndex } from './traces.js';
import { renderNodeDetailHtml } from './detail-html.js';
/**
* Bring the element named by the current URL fragment into view.
*
* A no-op without a fragment, without a matching element, or in an environment
* with no `scrollIntoView` — the page is already correct in each case, the link
* simply lands at the top.
*
* @param {Document} documentRef Document to resolve the fragment against.
* @returns {boolean} `true` when an element was scrolled to.
*/
export function scrollToHashTarget(documentRef) {
const hash = documentRef?.location?.hash
?? documentRef?.defaultView?.location?.hash
?? (typeof globalThis !== 'undefined' ? globalThis.location?.hash : '');
const id = typeof hash === 'string' ? hash.replace(/^#/, '') : '';
if (!id || typeof documentRef?.getElementById !== 'function') return false;
const target = documentRef.getElementById(id);
if (!target || typeof target.scrollIntoView !== 'function') return false;
target.scrollIntoView();
return true;
}
import { startRelativeTimeTicker } from '../main/relative-time-ticker.js';
const RENDER_WAIT_INTERVAL_MS = 20;
@@ -225,6 +247,12 @@ export async function initializeNodeDetailPage(options = {}) {
privateMode,
});
root.innerHTML = html;
// The destinations table is built after several awaited fetches, so the
// browser has already resolved any `#dest-…` fragment against a page that
// did not yet contain the row (SPEC RL5). Re-target it once the row exists,
// otherwise a destination link lands at the top of the page instead of on
// the row it names.
scrollToHashTarget(documentRef);
// One shared presentation clock keeps the rendered last-seen /
// last-position ages counting up in place (SPEC RT1/RT2); re-initialising
// replaces the previous ticker so the page never runs two clocks.
@@ -97,11 +97,20 @@ export function renderDestinationsSection(destinations, { nowSeconds = Date.now(
const body = rows.map(row => {
const label = aspectLabel(row?.aspect);
const id = typeof row?.id === 'string' ? row.id.trim() : '';
// The anchor a destination link targets, so arriving from a sub-row lands
// on the row it names rather than the top of the table.
const anchor = id ? ` id="dest-${escapeHtml(id.slice(0, 8))}"` : '';
return (
'<tr>' +
`<tr${anchor}>` +
`<td class="destinations__aspect">${label ? escapeHtml(label) : EMPTY_CELL_HTML}</td>` +
// The full hash, deliberately: this is the one view that shows it.
`<td class="destinations__id mono">${id ? escapeHtml(id) : EMPTY_CELL_HTML}</td>` +
// The full hash links to its own /nodes/!<short>, which canonicalises
// back to this page and lands on this row's anchor (SPEC RL5).
`<td class="destinations__id mono">${
id
? `<a href="/nodes/!${escapeHtml(id.slice(0, 8))}">${escapeHtml(id)}</a>`
: EMPTY_CELL_HTML
}</td>` +
`<td class="destinations__name">${cell(row?.name)}</td>` +
`<td class="destinations__role">${cell(row?.role)}</td>` +
`<td class="destinations__interface">${cell(row?.interface)}</td>` +
+32 -4
View File
@@ -1687,7 +1687,7 @@ body.view-chat .page-shell {
outline-offset: 2px;
}
button:not(.chat-tab):not(.sort-button) {
button:not(.chat-tab):not(.sort-button):not(.node-extra-toggle):not(.identity-disclosure) {
padding: 6px 10px;
border: 1px solid #ccc;
background: #333;
@@ -1743,7 +1743,7 @@ button:not(.chat-tab):not(.sort-button) {
transition: filter 0.15s ease;
}
button:not(.chat-tab):not(.sort-button):hover {
button:not(.chat-tab):not(.sort-button):not(.node-extra-toggle):not(.identity-disclosure):hover {
background: #444;
}
@@ -2848,6 +2848,12 @@ button.legend-item[aria-pressed="true"] {
/* Absent table values render the overlay's muted dash (UX4, D-020). */
.cell-empty {
color: var(--muted);
/* Pin the font: `#nodes td.num` is monospace while every other column is the
body sans, so the identical em dash rendered as two visibly different
glyphs down the same table. The placeholder has no number to align, so it
loses nothing by leaving the tabular face. */
font-family: system-ui, Segoe UI, Roboto, Ubuntu, Arial, sans-serif;
font-variant-numeric: normal;
}
/* Freshness buckets (UX5, D-005): stale rows dim, live rows carry a 3 px
@@ -2991,6 +2997,15 @@ button.legend-item[aria-pressed="true"] {
font-size: 12px;
line-height: 1;
vertical-align: middle;
border: 1px solid #444;
background: #333;
color: #eee;
cursor: pointer;
}
.node-extra-toggle:hover,
.identity-disclosure:hover {
background: #444;
}
@media (pointer: coarse) {
@@ -3275,8 +3290,6 @@ button.legend-item[aria-pressed="true"] {
background (its best case is 4.39:1), and the row now keeps its protocol
tile, so the caret no longer needs to carry the protocol's colour. Plain
foreground-on-transparent clears the floor by the page's own contrast. */
color: var(--fg);
background: transparent;
border-radius: 3px;
}
@@ -3359,3 +3372,18 @@ button.legend-item[aria-pressed="true"] {
.destinations__id {
font-size: 0.9em;
}
/* Destination identifiers are links (SPEC RL5) but sit inside dense table
cells, so they stay quiet until hovered rather than painting every row with
link colour. */
.nodes-subrow__id,
.destinations__id a {
color: var(--muted);
text-decoration: none;
}
.nodes-subrow__id:hover,
.destinations__id a:hover {
color: var(--fg);
text-decoration: underline;
}
+18
View File
@@ -105,6 +105,17 @@ RSpec.describe PotatoMesh::App::Retention do
["!aaaaa001", 0xaaaaa001, "ST", "Stale Node", stale, stale, "CLIENT"],
)
# A fresh and a stale destination, so the sweep is proven to keep one and
# drop the other rather than emptying the table.
db.execute(
"INSERT INTO destinations(id, node_id, aspect, role, first_heard, last_heard) VALUES (?,?,?,?,?,?)",
["a" * 32, "!fffff001", "lxmf.delivery", "PEER", fresh, fresh],
)
db.execute(
"INSERT INTO destinations(id, node_id, aspect, role, first_heard, last_heard) VALUES (?,?,?,?,?,?)",
["b" * 32, "!aaaaa001", "lxmf.delivery", "PEER", stale, stale],
)
db.execute(
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, text) VALUES (?,?,?,?,?,?)",
[1, fresh, Time.at(fresh).utc.iso8601, "!fffff001", "!ffffffff", "fresh"],
@@ -173,6 +184,13 @@ RSpec.describe PotatoMesh::App::Retention do
expect(node_ids).to include("!fffff001")
expect(node_ids).not_to include("!aaaaa001")
# A destination must not outlive its node: the table carries no foreign
# key, so nothing cascades when a node is purged and the row would
# linger invisibly forever.
dest_ids = db.execute("SELECT id FROM destinations ORDER BY id").flatten
expect(dest_ids).to eq(["a" * 32])
expect(removed["destinations"]).to eq(1)
msg_ids = db.execute("SELECT id FROM messages ORDER BY id").flatten
expect(msg_ids).to eq([1])