feat(#186): topology service + readout + RX ingestion + route-builder UX

pull/205/head
Strycher 2 weeks ago
parent b33e5221f2
commit 4af5394a88

@ -1,3 +1,7 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false

@ -0,0 +1,118 @@
# Observer Settings — Offband Meshcore **client** design
- **Feature:** OffbandMesh/meshcore-client#63 · **Planning epic:** #64
- **Firmware counterpart:** OffbandMesh/meshcore-firmware#142 (impl) + #143 (agreed wire contract)
- **Scope:** the **client** side only. Firmware-owned wire constants are **TBD-from-firmware** (RedCreek).
- **Status:** revised after Gemini adversarial review (2026-06-21, verdict *reconsider* → all findings addressed below, §11) → owner sign-off (AGREE) → implementation.
Legend: **[F]** Fact (code/contract) · **[A]** Assumption · **[U]** Uncertainty (needs decision/firmware input).
---
## 1. Context
When connected to an **Observer** device (Offband firmware variant bridging LoRa↔MQTT over WiFi), configure its **MQTT brokers, WiFi, display, region** from client forms instead of `_sys` text commands. Firmware decision (locked, #143 "option-2"): one Offband-owned `config` companion command (sub-type set/get/view + paginated broker list), key/value flat settings, contacts-style broker pagination, `_sys` fallback, capability-gated, device-NVS source of truth, secrets write-only/redacted. **[F]**
**Client today:** zero MQTT/WiFi/Observer support. Shell ready: `settings_shell.dart` (responsive master-detail); categories built in `settings_screen.dart:_categories()` (~L72131); device read via connector getters, written via `setX()`/`sendFrame()` + refresh; long-press menu pattern exists (`contacts_screen.dart` `onLongPress` + `onSecondaryTapUp`). **[F]**
## 2. Config contract (from #143)
- `wifi`: `ssid`, `pwd`(**WO**), `enabled`
- `mqtt` global: `iata`, `status_int`
- `mqtt.broker.<09>`: `enabled`, `url`, `port`, `transport`(tcp/tls/wss), `auth_type`(none/basic/jwt), `username`, `password`(**WO**), `topic_prefix`, `iata_override`, `jwt_aud`, `jwt_refresh`, `jwt_owner`, `jwt_email`, `ca_cert`, `jwt_token`(**WO**)
- `display`: `always_on`, `rotation`(0/180)
Secrets never returned by GET. Pool = 10 slots. **[F]**
## 3. Phasing (revised — de-risk pagination early)
- **Phase 1 — flat settings** (`wifi`/`display`/`iata`/`status_int`) **+ a non-UI broker-list GET in the integration test (T6)** to prove the multi-frame `START→SLOT×N→END` decode — the riskiest path — before any Phase-2 UI is built. (Review finding #4.)
- **Phase 2 — MQTT broker pool UI** (list + editor), building on the now-proven pagination decode.
## 4. Connector layer
### 4a. `ObserverConfigClient` codec (new `lib/connector/observer_config_client.dart`)
Owns encode/decode of the `config` command only, so UI/models never touch wire bytes. **[A → justified by review #1: a binary, externally-owned, still-TBD wire contract is exactly when an Adapter earns its keep; pragmatic exception to "no helper until 3+ places".]**
- `setFlat(key, value)` / `getFlat(key)` — flat key/value.
- `getBrokers()` → decode the paginated stream into `BrokerConfig[]` (secrets absent).
- `setBrokerField(slot, field, value)` — single field (firmware contract is field-at-a-time).
- `brokerOp(slot, BrokerOp)` — enable/disable/clear.
**Secrets:** the codec only emits a secret key when the model's intent is `set`/`clear`, never for `unchanged` (see §5) — prevents the blank-field wipe footgun. **[F, review #1]**
**TBD-from-firmware (gate coding, not design) [U]:** config CMD code + sub-type bytes; broker-slot frame layout + enum encodings; pagination framing.
### 4b. Capability gating (revised — two-factor, review #3)
`bool get supportsObserverConfig` is true iff **BOTH**: (1) `FIRMWARE_VER_CODE ≥` the firmware gate, **AND** (2) a **`WIFI_OBSERVER_SUPPORT` capability flag** in the `CMD_DEVICE_QUERY` response is set. Version code alone cannot tell a build with `wifi_observer` compiled out from one with it — gating on version only yields a dead category. The category is shown and `config` is sent only when both hold; on reconnect the flag is re-read before the category renders (no stale-handshake flicker). **[F, review #3]** → **firmware-coordination item (§10):** confirm/define the capability flag with RedCreek.
### 4c. State + refresh
Connector holds parsed `ObserverConfig?` + a getter + `refreshObserverConfig()`. A failed refresh sets a **stale flag** the UI surfaces (never silently shows stale data). `notifyListeners()` on update. **[F, review #6/#7]**
## 5. Data models (new `lib/models/observer_config.dart`)
`ObserverConfig { WifiConfig wifi; MqttGlobalConfig mqtt; List<BrokerConfig> brokers; DisplayConfig display; }`, `MqttGlobalConfig`, `DisplayConfig`, enums `BrokerTransport`/`BrokerAuthType`/`BrokerOp`.
**Secret fields use a three-state intent type** (not a bare `String?`): `SecretField { unchanged | clear | set(value) }`. GET yields `unchanged`; the codec sends nothing for `unchanged`, an empty value for `clear`, the value for `set`. This makes "leave / clear / replace" explicit end-to-end. **[F, review #1]**
**Storage: none** — device NVS is the source of truth; the client reads/writes the device, no `lib/storage/` copy. **[F]**
## 6. UI (new `lib/screens/settings/observer_settings_view.dart`)
### 6a. Category
One `SettingsCategory(title:"Observer", builder:_observerPane)` added to `_categories()`, **conditionally** on `connector.supportsObserverConfig`. **[F]**
### 6b. Save model — ONE rule for the whole pane (review #5)
**Every control edits staged local state; nothing is sent to the device until an explicit "Save".** Toggles flip visually on tap but only transmit (`setFlat`) on Save, alongside all other pending changes. Consistent, predictable; no "did my toggle apply?" ambiguity. A per-pane Save (✓/disk) + Refresh live in the app bar. **[F, review #5]**
### 6c. WiFi (P1)
SSID (text), Password (**three-state secret** — renders `•••• (set, hidden)`; untouched ⇒ not sent; clear button ⇒ `(cleared)` ⇒ sends empty; typing ⇒ `set`), Enabled (toggle). Status line (`get wifi.status`) + refresh. Save sends only changed fields. **[F, review #1/#5]**
### 6d. Display + Region/status (P1)
`always_on`, `rotation` (segmented), `iata`, `status_int` (103600) — all staged, sent on Save.
### 6e. MQTT brokers (P2) — owner UX
- **List shows all 10 slots** (resource constraint visible); empty slots greyed with a "Configure"/ affordance, populated slots show url + enabled badge. **[F, review #8]**
- **Tap** → broker editor (app-bar Refresh + Save).
- **Long-press / desktop right-click** → menu **Enable / Disable / Edit / Clear**`brokerOp()` / open editor. Reuses the contacts pattern. **[F]**
### 6f. Broker editor (P2)
Fields per `BrokerConfig`; `transport`/`auth_type` dropdowns; auth fields conditional; secrets three-state. **Save = send all changed fields**, then **on ANY field failure, immediately auto-`getBrokers()` and render the device's actual (possibly partial) state + a clear "save was partial" error** — never leave the UI asserting a state the device doesn't hold. **[F, review #2 — mitigates the non-atomic field-at-a-time contract.]**
## 7. State / disconnect / errors
- **In-flight edits on disconnect:** preserve the user's staged input, **disable Save**, show a "reconnect to save" banner; restore on reconnect — never silently clear the form. **[F, review #7]**
- **Error visibility (SAFELANE §6):** every `config` round-trip is `await`ed in `try/catch`; failures surface a **persistent** (dismissible) SnackBar/banner in the pane **and** `_appDebugLogService.error(…)`. No fire-and-forget sends, no silent refresh no-ops. **[F, review #6]**
## 8. File-overlap analysis (File-Convergence Rule)
| Area | Files |
|---|---|
| Models | `lib/models/observer_config.dart` *(new)* |
| Codec | `lib/connector/observer_config_client.dart` *(new)* + `lib/connector/meshcore_connector.dart` *(hook + capability + state)* |
| UI | `lib/screens/settings/observer_settings_view.dart` *(new)* + `lib/screens/settings_screen.dart` *(registration)* |
**Serialize** tasks touching `meshcore_connector.dart` and `settings_screen.dart`; new files parallelize. **[F]**
## 9. Task breakdown (13 files each)
**Phase 1** (dep: firmware F1 constants + capability flag):
- **T1** models *(new)*.
- **T2** `ObserverConfigClient` flat set/get **+ `getBrokers()` decode** *(new + connector hook)*. dep: T1, firmware constants.
- **T3** two-factor capability gating *(connector)*. dep: T2.
- **T4** Observer category + WiFi pane (staged-save model) *(settings_screen.dart + observer_settings_view.dart)*. dep: T2, T3.
- **T5** Display + Region/status panes *(observer_settings_view.dart)*. dep: T4.
- **T6 integration test** — Phase-1 round-trip **+ a non-UI `getBrokers()` decode** against a firmware F1 build (proves pagination early). dep: T4, T5.
**Phase 2** (dep: T6):
- **T7** broker list UI (all-10-slots) *(observer_settings_view.dart)*. dep: T6.
- **T8** broker editor + staged save + partial-failure auto-refresh *(observer_settings_view.dart)*. dep: T7.
- **T9** long-press Enable/Disable/Edit/Clear *(observer_settings_view.dart)*. dep: T7.
- **T10 integration test** — Phase-2 round-trip. dep: T8, T9.
## 10. Firmware-owned constants + coordination (RedCreek / #142)
TBD constants: (1) `config` CMD code + sub-type bytes; (2) broker-slot frame layout + enum encodings; (3) `app_target_ver` gate. **Coordination items raised by the review:** (4) a **`WIFI_OBSERVER_SUPPORT` capability flag** in `CMD_DEVICE_QUERY` (version code alone is insufficient — review #3); (5) confirm the broker SET is field-at-a-time (no transactional `setBroker`) so the client must own the partial-failure mitigation (review #2). (6) a Phase-1 build to test against. Client coding holds until (1)(3) land.
## 11. Review responses (Gemini gemini-2.5-pro, 2026-06-21 — standards#145: fix or justify, no silent dismissals)
| # | Finding | Sev | Disposition |
|---|---|---|---|
| 1 | Write-only secret UX ambiguous → accidental wipe | BLOCKER | **Fixed** — three-state `SecretField` (§5) + three-state input UX (§6c/§6f); codec never sends `unchanged` (§4a). |
| 2 | Field-at-a-time broker save → partial/corrupt state | BLOCKER | **Fixed (client mitigation)** — on any field failure, auto-`getBrokers()` + render actual state + partial-error (§6f). **Coordinating** firmware transactional-set feasibility (§10.5). |
| 3 | Version-code-only capability gating → dead UI | MAJOR | **Fixed** — two-factor: version + `WIFI_OBSERVER_SUPPORT` flag (§4b); re-read on reconnect. **Coordinating** the flag with firmware (§10.4). |
| 4 | Phasing defers riskiest (pagination) to P2 | MAJOR | **Fixed** — minimal non-UI `getBrokers()` decode pulled into P1 integration test T6 (§3, §9). |
| 5 | Inconsistent immediate-vs-explicit save | MAJOR | **Fixed** — single rule: stage locally, commit on explicit per-pane Save (§6b). |
| — | In-flight edits lost on disconnect | (ans 7) | **Fixed** — preserve input, disable Save, restore on reconnect (§7). |
| — | Broker list all-10 vs populated | (ans 8) | **Fixed** — show all 10, empty slots distinct (§6e). |
| — | Silent-failure surfaces | (ans 6) | **Fixed** — awaited try/catch + persistent surfacing + stale-flag on refresh fail (§4c/§7). |
Verdict was *reconsider*; all BLOCKER/MAJOR findings are now addressed in-design above.

@ -0,0 +1,93 @@
# Observer Config (client) — Phase 1 plan **v2 (REVISED, security-first)**
- **Epic:** OffbandMesh/meshcore-client **#64** · **Feature:** #63 · **Firmware contract:** F1 #160 (delivered, `OffbandConfigProtocol.h`)
- **Date:** 2026-06-22 · **Standards:** @96da86c · **Status:** awaiting Ben AGREE
- **v1 was rejected.** v1 self-validated my own code ("MET, analyze-clean") and had no threat model. A Gemini adversarial review then found **2 BLOCKERs in the very layers v1 graded "MET"** plus 2 MAJOR / 2 MINOR (`docs/llm-consultations/2026-06-22-observer-phase1-security-review.md`). This version treats the existing commits as an **UNTRUSTED DRAFT**, builds a threat model first, turns every finding into a sized fixing task with the adversarial test **written first**, and gates a device test behind a *re-review*, not behind my word.
---
## 1. Threat model (this is what v1 was missing)
| | |
|---|---|
| **Assets** | Write-only secrets (MQTT/JWT credentials, `wifi.pwd`, `ca_cert`); client availability/stability; config integrity. |
| **Adversary** | A malicious or compromised observer device. It controls **every inbound byte and the order of frames** over BLE/USB/TCP. |
| **Capabilities** | Send arbitrary/malformed frames; omit `END`; stream unbounded `BROKER_KV`; spoof codes/sub-types; race/duplicate frames; supply garbage for numeric/enum fields; out-of-range slot indices. |
| **Trust boundary** | The transport. **Everything inbound is untrusted.** `observer_config_client.parse()` + `BrokerListDecoder` + `ObserverConfigService` ARE the parsing trust boundary — they must be hostile-input-safe, not just contract-happy. |
| **Non-goals (this phase)** | Transport encryption/auth (owned by the BLE/companion layer); broker enable/clear (firmware gap). |
---
## 2. Gemini findings → fixing task (nothing dropped)
| # | Sev | Finding | Fixed by |
|---|-----|---------|----------|
| 1 | **BLOCKER** | Race: "one request in flight" assumed, not enforced; shared `receivedFrames` + order-correlation → wrong `Completer` completes | **S1** (single-flight queue) + **A2** (proves it) |
| 2 | **BLOCKER** | DoS: `BrokerListDecoder` grows unbounded on a no-`END` `BROKER_KV` flood → OOM | **C1** (decoder bounds) + **A1** (proves it) |
| 3 | MAJOR | Plan has no threat model / adversarial tests | **TM** (§1) + **A1/A2** (this plan) |
| 4 | MAJOR | `offband_caps` byte-82 offset unverified; short-frame robustness | **N1** (guard + device-verify) |
| 5 | MINOR | Broker slot index not range-checked (`slot: 200` accepted) | **C1** (validate `< brokerSlotCount`) |
| 6 | MINOR | Secret **keys** leak into error strings (`SET wifi.pwd failed…`) | **S1** (redact secret keys) |
---
## 3. Revised sized tasks (13 files; **adversarial tests written FIRST**)
| ID | Title | Files | Depends on |
|----|-------|-------|------------|
| **TM** | Threat model + this revised plan (AGREE gate) | *(this doc)* | — |
| **A1** | **Adversarial codec test suite** — malformed/short frames, missing NUL, OOB slot, non-numeric numerics, no-`END`/unbounded `BROKER_KV`, duplicate `START`. Must FAIL on current code first. | `test/observer_config_client_test.dart` *(new)* | TM |
| **C1** | Harden codec: validate slot `< brokerSlotCount` in `parse`; bound `BrokerListDecoder` (cap entries at 10, drop OOB, cap fields/slot). Fixes #2, #5. | `lib/connector/observer_config_client.dart` | A1 |
| **A2** | **Service test suite** — overlapping `refresh()`/`setFlat()` must serialize (no cross-talk); secret-key redaction; timeout releases decoder. Must FAIL first. | `test/observer_config_service_test.dart` *(new)* | TM |
| **S1** | Service single-flight queue/mutex (fixes #1) + redact secret keys in errors (fixes #6). | `lib/services/observer_config_service.dart` | A2, C1 |
| **M1** | Models (existing draft; minor). | `lib/models/observer_config.dart` | TM |
| **N1** | Connector cap-read: guard short frames; **verify byte-82 offset against real device-info frames** (device sub-gate). Fixes #4. | `lib/connector/meshcore_connector.dart` | TM |
| **P1** | Provider wiring. | `lib/main.dart` | S1 |
| **U1** | UI + capability-gated category. | `lib/screens/settings/observer_settings_view.dart`, `lib/screens/settings_screen.dart` | S1, P1 |
| **G1** | **Re-run Gemini adversarial review** on hardened code — must clear both BLOCKERs. | *(gate)* | A1, A2, C1, S1 |
| **G2** | **Human device test on a live observer node (Ben).** Integration gate. | *(gate)* | G1, N1, U1 |
| **PR** | ONE PR at completion + Ben approval → merge. | *(gate)* | G2 |
**File-overlap:** every source file is owned by exactly one task (codec→C1, service→S1, connector→N1, main→P1, UI→U1, models→M1; tests are new files). No intra-epic conflicts.
**Dependency chain**
```
TM(threat model + AGREE)
├─ A1 ─ C1 ─┐
├─ A2 ──────┴─ S1 ─ P1 ─ U1 ─┐
├─ M1 │
└─ N1 (device-verify offset) ─┤
└─ G1 (re-Gemini, clears BLOCKERs) ─ G2 (Ben device test) ─ PR
```
---
## 4. Gates (in order, none skippable)
1. **AGREE** — Ben approves THIS plan before any code (the gate I skipped in v1).
2. One claimed Citadel task per ID; each is a child issue of #64 on board #2.
3. **Adversarial tests (A1/A2) written and failing before the fix; passing after.**
4. **G1 re-review** must clear both BLOCKERs.
5. **G2 — Ben device test** is the integration gate. I cannot clear it.
6. **ONE PR** at completion, after G2, with Ben's explicit approval.
---
## 5. The guardrails I skipped → the exact vulnerability each would have caught
This is the honest accounting. Every one of these was a guardrail I bypassed, and every bypass let a specific defect through:
| Guardrail I skipped | What it would have caught |
|---|---|
| **AGREE on a plan w/ threat model before code** | Forces modeling the adversary before writing an untrusted-input parser → the **DoS** and **race** are designed out, not discovered later. |
| **Adversarial / Gemini review before publishing** | Gemini caught **both BLOCKERs in one pass**. I self-validated and graded the buggy codec + service "MET." |
| **TDD / adversarial tests first** | A malformed-frame + unbounded-stream test would have **failed immediately** — instead the OOB slot and unbounded decoder shipped *analyze-clean*. |
| **Human-test gate before PR** | I put code that **crashes under normal use** (the race) and **crashes on a hostile device** (the DoS) onto the merge track, and asked you to merge it. |
| **"analyze-clean ≠ tested/secure"** | I treated a clean static analysis as done. Two crash-class bugs passed it without a whimper. |
| **Treat device input as untrusted** | The whole bug class — I wrote a parser for attacker-controlled bytes as if the bytes were trusted. |
---
## 6. Status of the existing commits (`1983ed9`, `12f5f97`, PR #67)
**Untrusted draft — not a basis to test.** They are analyze-clean but contain both BLOCKERs. They are useful as a starting point for M1/U1/etc., but each file is rewritten/hardened under its task and must pass A1/A2 + G1 before G2. PR #67 should be pulled down (mid-epic, pre-test, contains the BLOCKERs).
**Nothing proceeds until Ben AGREEs this v2.**

@ -0,0 +1,132 @@
# Passive Mesh Topology → Inferred Trace Routes (design spec)
**Status:** proposal / design review (2026-07-05). Owner: Offband. Related: #150 (trace), the RX-fed nearest-repeater work, `meshcore-firmware/docs/trace-path-format.md`.
## 1. The problem we're solving
A MeshCore TRACE only walks a route **the caller hands it**. The firmware never discovers or reverses a route (`trace-path-format.md`), and the reference apps make the user **hand-build** the path (pick nodes from a list, or click them on a map). So multi-hop trace today is either a clunky manual chore or it silently fails. No client discovers the route for you — that's the gap.
**The opportunity:** we *already* parse every received packet's full routing path (for the new RX-fed nearest-repeater detection in `_handleRxData`). That firehose is a **passive map of the mesh**. If we accumulate it into a topology graph, we can **infer** a route to a target we've observed — and auto-suggest it, with a builder to confirm/tweak. The firmware can't discover routes; the *client* can remember what it has already seen. That's the thing no client does.
## 2. Core idea, in one line
Every received packet carries the ordered list of repeaters it crossed. Merge those observations into a graph; to trace node *T*, find a path to *T* on the graph and hand it to the existing round-trip builder.
## 3. What we already have (reuse, don't rebuild)
| Existing | Role in this feature |
|---|---|
| `_handleRxData``pathBytes` (per packet) | The observation firehose — the ordered hops a packet crossed. Already parsed; today only adverts are used. |
| `_handlePayloadAdvertReceived`, `_handleLogRxData` | Additional path observations (adverts, channel messages). |
| `DirectRepeater` list (RX-fed) | The **first ring** of the graph — our direct neighbours + their SNR. The only hops we can SNR-rank. |
| `PathHistoryService` | Per-contact observed ACK/flood paths — another observation input; the graph generalises it. |
| `PathHelper.buildTraceRoundTrip(routingPath, targetPrefix, throughTarget, …)` | Assembles the out-and-back frame from a route. Inference output feeds straight into this. |
| `PathSelectionDialog` | The manual route builder — becomes the confirm/edit surface, pre-filled from inference. |
## 4. Path orientation (get this right)
A received flooded/routed packet's `pathBytes` = `[h1, h2, …, hk]`, where the packet travelled `origin → h1 → … → hk → us`. So:
- `hk` (= `pathBytes.last`) is **our direct neighbour** (the last hop before us).
- `h1` is the hop closest to the origin.
- The reversed path `[hk, …, h1]` is a **known route from us toward `origin`**.
Adjacencies contributed by one observation: `us~hk`, `hk~h(k-1)`, …, `h2~h1`, and `h1~origin` when the sender prefix is known.
## 5. Data model — `MeshTopologyService` (new, ChangeNotifier)
Graph, scoped per device key (like the other stores).
```
Node {
prefix : Uint8List // width-byte repeater hash (the key)
lastSeen : int // unix seconds, refreshed on every observation
neighbourSnr : double? // only set for OUR direct neighbours (from RX SNR); null for deep hops
seenCount : int
// name is resolved on demand from contacts — NOT stored here
}
Edge { // undirected link A~B ("observed adjacent in a path")
a, b : Uint8List // the two endpoints (store canonically ordered)
lastSeen : int
count : int // observation confidence
lastFailed : int? // set when a trace routed through this link timed out (self-healing)
}
```
Sizes (why storage is a non-issue): ~8 B/node, ~12 B/edge. Scales with **mesh size** (distinct repeaters × their links), **not** hop depth or packet volume — each node/link is stored once and merely refreshed. 100 repeaters ≈ 6 KB; 1,000 ≈ 80 KB (≈200 KB as JSON). See §9 for the hard bound.
## 6. Ingestion
On every parsed RX path (from `_handleRxData` and the advert/channel handlers), call:
```
bool observePath(List<int> path, {int width, double? lastHopSnr, Uint8List? senderPrefix, int ts})
```
- Chunk `path` into width-byte hops.
- Upsert a `Node` per hop (refresh `lastSeen`, bump `seenCount`); set `neighbourSnr` on `hk` from `lastHopSnr`.
- Upsert the `us~hk … h1~origin` `Edge`s (refresh `lastSeen`, bump `count`).
- Return `true` only on a **notify-worthy change** (new node/edge, or a materially newer observation) — same notify-on-change discipline as `DirectRepeater.recordHop`.
- Cost: `O(path length)` ≤ 64. The parse already happened; this is a handful of map writes. No new per-packet parsing.
`DirectRepeater.recordHop` and `observePath` can share the same call site — the direct-neighbour list is just the graph's first ring.
## 7. Route inference
```
List<Uint8List>? inferRoute(Uint8List target) // ordered hops us→…→(excluding target); null if unreachable
```
- Target is a **direct neighbour** → return `[]` (one-hop ping is correct).
- Else run a shortest-path search (BFS by hop count) from `us` to `target` over the **fresh** subgraph (edges within the freshness window §9). Among equal-length routes, prefer: (1) strongest **first-hop** SNR (we can rank the entry neighbour), (2) freshest edges, (3) highest `count`, (4) no recent `lastFailed`.
- Return the intermediate hops `[n1, …, n(m-1)]`. The trace launch then calls `buildTraceRoundTrip(routingPath: that, targetPrefix: target, throughTarget: true)` — existing, tested code.
- `null` when `target` isn't in the fresh graph → manual builder.
**Honest limit:** we measure SNR only for **our own** receptions, i.e. the first hop. Deep-link quality isn't observable from here, so intermediate hops are chosen topologically (shortest + freshest + most-seen), not by signal. That's still far better than a blind guess or a manual build, and the first hop — the one most likely to matter — *is* SNR-optimised.
## 8. Trace UX (replaces the guess)
Repeater / contact **Path Trace**:
1. Committed `contact.path` exists → trace it (today's `hasRoute`).
2. Else `inferRoute(target)`:
- **Route found** → open the builder **pre-filled** with the suggestion (`Suggested: A → B → target`), one tap to trace or edit first. (Setting: auto-run vs always-confirm.)
- **No route** → open the **empty** builder with an honest hint: *"No route learned yet — build one, or send it a flood ping / catch an advert to learn it."*
3. **Delete the strongest-repeater guess entirely.** The builder is always the transparent surface; inference just pre-fills it.
## 9. Persistence & bounding (storage is capped by design)
- Persist the **graph** (deduped nodes + edges), never raw paths — raw paths are the only thing that would bloat.
- **Freshness prune:** on load/save, drop nodes/edges whose `lastSeen` is older than `W` (default 7 days; configurable). Keeps the map to the *live* mesh, not all-time.
- **Hard cap:** LRU by `lastSeen` at a fixed ceiling (default ~2,000 nodes / ~256 KB). Physically can't run away, even pathologically.
- Store: `topology_store` keyed by the first 10 hex of the device pubkey, like the others.
## 10. Failure modes & honesty
- **Stale route** (topology moved): the trace just times out like any wrong route; we stamp `lastFailed` on that route's edges so the next inference avoids it. Self-healing over time.
- **Asymmetric / duty-cycled return leg** (firmware `#2`): inference can't fully solve this — a route good outbound may not return. Preferring recently- and frequently-observed links mitigates it; a later refinement can weight bidirectionally-seen links higher.
- **Unobserved target:** never heard, directly or as an intermediate → no inference → manual builder. Expected and honest.
## 11. Why this is the differentiator
The firmware *cannot* discover routes (no flood-trace; empty route = error 1). The reference clients push that cost onto the user (manual build). We turn traffic we're **already parsing** into a route oracle that **improves the longer the app runs** — auto-suggesting routes nobody else can. It degrades gracefully to the same manual builder everyone else has, so we're never worse and usually better.
## 12. Phased build (epic → issues, filed once this design is approved)
| Phase | Deliverable |
|---|---|
| **A — design** | This spec. |
| **B — core** | `MeshTopologyService` (graph, `observePath`, `inferRoute`) + unit tests (orientation, dedup, pathfind, bound). Wire ingestion into `_handleRxData`/advert/channel. No UI. |
| **C — UX** | Trace launch uses `inferRoute` → pre-filled `PathSelectionDialog`; delete the guess. |
| **D — persist + bound** | `topology_store`, freshness prune, LRU cap. |
| **E — polish** | `lastFailed` confidence decay; optional on-map topology overlay; auto-run vs confirm setting. |
Phases BD are the working feature; A is done; E is gravy.
## 13. Decisions & open questions
**Decided (Ben, 2026-07-05):**
- **Separate services.** `PathHistoryService` stays the message-router weighter; `MeshTopologyService` is a distinct trace oracle that *reads* PathHistory's observations as one input — no merge.
- **Confirm-first.** An inferred route **pre-fills the builder** and the user confirms/edits before it traces (transparent). Auto-run is a later opt-in setting, not the default.
**Still open (tuning, not blocking):**
- **Freshness window `W`** — 7 days is a starting guess; tune against real advert/traffic cadence (repeaters now advert ~12 h).

@ -0,0 +1,84 @@
# Windows distribution, logs, versioning, signing — notes (2026-06-23)
Reference for shipping the Offband Meshcore Windows desktop build. Answers four
questions raised before the first `feat/64` Windows compile.
## 1. Installer / short-term distribution
A Flutter Windows release is a **folder**, not a single exe:
`build/windows/x64/runner/Release/` = `meshcore_open.exe` + `flutter_windows.dll`
+ plugin DLLs + `data/` (`app.so`, `flutter_assets/`, `icudtl.dat`). The exe will
**not** run on its own — it needs the whole folder.
**Short-term (zero tooling):** zip the entire `Release/` folder → user extracts →
runs the exe. This is effectively what we do now (running a copied exe).
- Caveat: a truly clean machine may need the **MSVC runtime** (VC++ redistributable).
Present on most Win10/11; bundle the redist or note it if a tester hits a DLL error.
- Optional polish without an installer: a **self-extracting zip** (7-Zip SFX) so
the user double-clicks one .exe that unpacks + can auto-launch.
**Real installers, when ready (rough effort):**
| Option | Effort | Notes |
|---|---|---|
| **MSIX** (`msix` Dart pkg) | Low | `dart run msix:create` packages `Release/``.msix`; clean install/uninstall; self-sign or CA-sign; Store-ready. Easiest path, ties in signing. |
| **Inno Setup** | LowMed | Free; short `.iss` script → familiar `setup.exe`. Most control over UX/shortcuts. |
| **MSI / WiX** | High | Enterprise/group-policy deployment. Overkill for us now. |
**Recommendation:** zip the folder for testing now; **MSIX** when we want a clean
install + signing in one step (or Inno Setup if we want a traditional setup.exe).
## 2. Where log files go (today: nowhere)
There are **no log files on disk.** Logging is two in-app, in-memory ring buffers:
- **BLE/frame debug log**`BleDebugLogService`, **500-entry** ring, Copy-to-clipboard
(the "RX CODE_192 …" dumps). Captures every transport (BLE/USB/TCP).
- **App debug log**`AppDebugLogService`, structured app messages.
`debugPrint` goes to stdout, which a **double-clicked release exe has no console for**,
and is largely stripped in release mode anyway. So on a release Windows build the
**only** diagnostics are the in-app log viewers (copy/paste).
**Gap for desktop + real users:** no findable log file for support. If we want one,
add a small file logger writing to `getApplicationSupportDirectory()`
`%APPDATA%\app.offband.meshcore\logs\` on Windows (rotating, capped). Small task;
worth it before non-technical users are on desktop. (Filed as a follow-up if wanted.)
## 3. Versioning — current state + the gap
- **pubspec:** `1.1.1+18` (feat/64 == dev).
- **Tags present:** `v1.0.0`, `v1.1.0-rc.1/2/3`, `v1.1.2-ble-test`, `v1.1.2-queuediag`
(+ old upstream `Alpha*`). The `observer-g2-rc8/rc9` GitHub pre-releases I made this
session are remote-only tags (not semver).
- **The gap (honest):** I have **not** been versioning per SAFELANE §Versioning.
- This session's commits (#82, #88, #89, #90, #93) did **not** bump pubspec.
- rc8/rc9 APKs shipped as `+28`/`+29` via `--build-number` overrides, so the
pubspec build number (`+18`) lags what shipped.
- Tags already point at **1.1.2** but pubspec still says **1.1.1** — inconsistent.
**Cleanup proposal:**
1. Decide the marketing version for this batch. Tags imply **1.1.2** — bump pubspec
to `1.1.2+30` (next build after the +29 that shipped).
2. Going forward: bump pubspec **in the milestone commit** and tag `vX.Y.Z`
(or `-rc.N`) — stop relying on `--build-number` without a matching pubspec bump.
3. Or wire build-time stamping (`git rev-list --count HEAD`) for the build number so
it can't drift (CLAUDE.local notes this as preferred).
## 4. Windows code signing
Unsigned exe **runs**, but SmartScreen shows "Windows protected your PC / Unknown
publisher" → user clicks **More info → Run anyway**. Annoying, not blocking.
**Cert options (separate from our Android keystore):**
| Cert | Cost | SmartScreen |
|---|---|---|
| **Self-signed** | free | warns unless the user installs/trusts the cert first (OK for known testers; MSIX sideload trusts it once) |
| **OV** (Sectigo/DigiCert…) | ~$100300/yr | removes "unknown publisher"; reputation builds with downloads over time |
| **EV** (+ hardware token/HSM) | ~$300600/yr | **instant** SmartScreen trust, no warning day one |
**Signing mechanics are easy** (`signtool sign /fd SHA256 /f cert.pfx ...`, or the
`msix` package signs for you). The cost/effort is **getting a trusted cert** (CA
identity verification + annual fee + an EV token if EV).
**Do we bother?**
- **Short-term (you + a few testers):** No. The one-time "Run anyway" is fine.
- **Public release:** Yes — at least an **OV** cert (or **EV** for zero-warning).

@ -26,6 +26,7 @@ import '../services/linux_ble_pairing_service_stub.dart'
if (dart.library.io) '../services/linux_ble_pairing_service.dart';
import '../services/message_retry_service.dart';
import '../services/path_history_service.dart';
import '../services/mesh_topology_service.dart';
import '../services/app_settings_service.dart';
import '../services/background_service.dart';
import '../services/timeout_prediction_service.dart';
@ -105,6 +106,55 @@ class DirectRepeater {
return DateTime.now().difference(lastUpdated) >
const Duration(minutes: maxAgeMinutes);
}
/// Records a directly-heard hop [prefix] at [snr] into [list] the SNR-ranked
/// set of nearest direct repeaters, capped at [cap]. Updates an existing
/// entry's SNR + timestamp, or adds a new one, evicting the weakest by SNR
/// only when the newcomer is stronger. Returns true when the change is worth a
/// notify: a new entry, an eviction, or an SNR move of at least [snrDelta] dB;
/// sub-threshold jitter refreshes the value silently and returns false. Fed
/// from every routed RX packet (not just adverts), so it tracks live traffic
/// rather than the ~12h advert cadence. (#150)
static bool recordHop(
List<DirectRepeater> list,
Uint8List prefix,
double snr, {
int cap = 5,
double snrDelta = 1.0,
}) {
if (prefix.isEmpty) return false;
for (final r in list) {
if (_prefixEquals(r.pubkeyPrefix, prefix)) {
final moved = (r.snr - snr).abs() >= snrDelta;
r.update(snr);
return moved;
}
}
if (list.length >= cap) {
var weakest = list.first;
for (final r in list) {
if (r.snr < weakest.snr) weakest = r;
}
if (snr <= weakest.snr) return false; // too weak to displace anyone
list.remove(weakest);
}
list.add(
DirectRepeater(
pubkeyFirstByte: prefix.last,
pubkeyPrefix: Uint8List.fromList(prefix),
snr: snr,
),
);
return true;
}
static bool _prefixEquals(List<int> a, List<int> b) {
if (a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
}
enum MeshCoreConnectionState {
@ -312,6 +362,7 @@ class MeshCoreConnector extends ChangeNotifier {
// Services
MessageRetryService? _retryService;
PathHistoryService? _pathHistoryService;
MeshTopologyService? _topologyService;
AppSettingsService? _appSettingsService;
BackgroundService? _backgroundService;
final NotificationService _notificationService = NotificationService();
@ -415,6 +466,7 @@ class MeshCoreConnector extends ChangeNotifier {
bool get hasLoadedChannels => _hasLoadedChannels;
Stream<Uint8List> get receivedFrames => _receivedFramesController.stream;
Uint8List? get selfPublicKey => _selfPublicKey;
MeshTopologyService? get topology => _topologyService;
String get selfPublicKeyHex => pubKeyToHex(_selfPublicKey ?? Uint8List(0));
String? get selfName => _selfName;
double? get selfLatitude => _selfLatitude;
@ -947,6 +999,7 @@ class MeshCoreConnector extends ChangeNotifier {
void initialize({
required MessageRetryService retryService,
required PathHistoryService pathHistoryService,
MeshTopologyService? topologyService,
AppSettingsService? appSettingsService,
TranslationService? translationService,
BleDebugLogService? bleDebugLogService,
@ -956,6 +1009,7 @@ class MeshCoreConnector extends ChangeNotifier {
}) {
_retryService = retryService;
_pathHistoryService = pathHistoryService;
_topologyService = topologyService;
_appSettingsService = appSettingsService;
_translationService = translationService;
_bleDebugLogService = bleDebugLogService;
@ -6451,6 +6505,27 @@ class MeshCoreConnector extends ChangeNotifier {
final pathBytes = packet.readBytes(pathByteLen);
final payload = packet.readBytes(packet.remaining);
// #186: feed the passive topology map from every routed packet's path —
// the same firehose that feeds nearest-repeater detection. Cheap: the
// path is already parsed here. Guarded so a topology-side fault can never
// stall the RX dispatch that also drives message handling.
if (pathBytes.isNotEmpty) {
try {
final sp = _selfPublicKey;
if (sp != null) _topologyService?.setSelf(sp, _pathHashByteWidth);
_topologyService?.observePath(
pathBytes,
width: _pathHashByteWidth,
lastHopSnr: snr,
);
} catch (e) {
_appDebugLogService?.warn(
'topology ingest failed: $e',
tag: 'Topology',
);
}
}
final rawPacket = frame.sublist(3);
switch (payloadType) {
case payloadTypeADVERT:
@ -6463,6 +6538,21 @@ class MeshCoreConnector extends ChangeNotifier {
);
break;
default:
// #150: every routed packet's last hop is a directly-heard relay.
// Feed the nearest-repeater SNR from it so detection tracks live
// traffic, not just the ~12h advert cadence. SNR + path are already
// parsed above; recordHop is O(<=5) and notifies only on change.
if (pathBytes.isNotEmpty) {
final width = _pathHashByteWidth;
final prefix = Uint8List.fromList(
pathBytes.sublist(
pathBytes.length >= width ? pathBytes.length - width : 0,
),
);
if (DirectRepeater.recordHop(_directRepeaters, prefix, snr)) {
notifyListeners();
}
}
}
} catch (e) {
appLogger.warn('Malformed RX frame: $e', tag: 'Connector');
@ -6689,62 +6779,34 @@ class MeshCoreConnector extends ChangeNotifier {
}
void _updateDirectRepeater(Contact contact, double snr, Uint8List path) {
final pubkeyFirstByte = path.isNotEmpty
? path.last
: contact.publicKey.first;
// Capture the last hop's FULL configured-width prefix (the directly-heard
// repeater), not just one byte, so wide-prefix meshes resolve it correctly. (#151)
// A chat/sensor advert with no path gives us no last-hop repeater to learn.
if ((contact.type == advTypeChat || contact.type == advTypeSensor) &&
path.isEmpty) {
return;
}
// The last hop's full configured-width prefix is the directly-heard
// repeater; fall back to the node's own prefix for a zero-hop advert. (#151)
final width = _pathHashByteWidth;
final Uint8List pubkeyPrefix;
final Uint8List prefix;
if (path.isNotEmpty) {
pubkeyPrefix = Uint8List.fromList(
prefix = Uint8List.fromList(
path.sublist(path.length >= width ? path.length - width : 0),
);
} else {
final pk = contact.publicKey;
pubkeyPrefix = Uint8List.fromList(
prefix = Uint8List.fromList(
pk.length >= width ? pk.sublist(0, width) : pk,
);
}
_directRepeaters.removeWhere((r) => r.isStale());
//We can use adverts from chat and sensor nodes, but only if the advert has a path to get the last hop.
if ((contact.type == advTypeChat || contact.type == advTypeSensor) &&
path.isEmpty) {
// No 30-min hard eviction: a known direct repeater stays usable for routing
// between reads. recordHop self-caps the list at 5 (weakest-SNR), and
// isStale()/ranking still mark freshness for display. Notify only on a real
// change to avoid per-packet UI churn. (#150)
if (DirectRepeater.recordHop(_directRepeaters, prefix, snr)) {
notifyListeners();
return;
}
final isTracked = _directRepeaters.where(
(r) => listEquals(r.pubkeyPrefix, pubkeyPrefix),
);
final sortedRepeaters = List<DirectRepeater>.from(_directRepeaters)
..sort((a, b) => b.snr.compareTo(a.snr));
final weakestRepeater = sortedRepeaters.isNotEmpty
? sortedRepeaters.last
: null;
if (_directRepeaters.length >= 5 &&
weakestRepeater != null &&
isTracked.isEmpty) {
_directRepeaters.remove(weakestRepeater);
}
if (isTracked.isNotEmpty) {
final repeater = isTracked.first;
repeater.update(snr);
} else if (_directRepeaters.length < 5) {
_directRepeaters.add(
DirectRepeater(
pubkeyFirstByte: pubkeyFirstByte,
pubkeyPrefix: pubkeyPrefix,
snr: snr,
),
);
}
notifyListeners();
}
void _handleAutoAddConfig(Uint8List frame) {

@ -1151,6 +1151,8 @@
},
"path_enterCustomPath": "Enter Custom Path",
"path_currentPathLabel": "Current path",
"path_suggestedRoute": "Suggested route (shortest heard)",
"path_searchRepeaters": "Search repeaters",
"path_hexPrefixInstructions": "Enter each hop's hex prefix, separated by commas.",
"path_hexPrefixExample": "Tap nodes below, or enter each hop's hex prefix, comma-separated.",
"path_labelHexPrefixes": "Path (hex prefixes)",
@ -2310,6 +2312,12 @@
"contacts_ping": "Ping",
"contacts_repeaterPathTrace": "Path trace to repeater",
"contacts_repeaterPing": "Ping repeater",
"contacts_repeaterPathTraceVia": "Path trace via {prefix}",
"@contacts_repeaterPathTraceVia": {
"placeholders": {
"prefix": { "type": "String" }
}
},
"contacts_roomPathTrace": "Path trace to room server",
"contacts_roomPing": "Ping room server",
"contacts_chatTraceRoute": "Path trace route",

@ -3831,6 +3831,18 @@ abstract class AppLocalizations {
/// **'Current path'**
String get path_currentPathLabel;
/// No description provided for @path_suggestedRoute.
///
/// In en, this message translates to:
/// **'Suggested route (shortest heard)'**
String get path_suggestedRoute;
/// No description provided for @path_searchRepeaters.
///
/// In en, this message translates to:
/// **'Search repeaters'**
String get path_searchRepeaters;
/// No description provided for @path_hexPrefixInstructions.
///
/// In en, this message translates to:
@ -7000,6 +7012,12 @@ abstract class AppLocalizations {
/// **'Ping repeater'**
String get contacts_repeaterPing;
/// No description provided for @contacts_repeaterPathTraceVia.
///
/// In en, this message translates to:
/// **'Path trace via {prefix}'**
String contacts_repeaterPathTraceVia(String prefix);
/// No description provided for @contacts_roomPathTrace.
///
/// In en, this message translates to:

@ -2143,6 +2143,12 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get path_currentPathLabel => 'Текущ път';
@override
String get path_suggestedRoute => 'Suggested route (shortest heard)';
@override
String get path_searchRepeaters => 'Search repeaters';
@override
String get path_hexPrefixInstructions =>
'Въведете 2-символни шестнадесетични префикси за всеки хоп, разделени с кама.';
@ -4070,6 +4076,11 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get contacts_repeaterPing => 'Пингване на повторителя';
@override
String contacts_repeaterPathTraceVia(String prefix) {
return 'Path trace via $prefix';
}
@override
String get contacts_roomPathTrace => 'Трасиране на път до съ';

@ -2142,6 +2142,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get path_currentPathLabel => 'Aktueller Pfad';
@override
String get path_suggestedRoute => 'Suggested route (shortest heard)';
@override
String get path_searchRepeaters => 'Search repeaters';
@override
String get path_hexPrefixInstructions =>
'Gebe für jeden Zwischen-Hop das 2-stellige Hex-Präfix ein, getrennt durch Kommas.';
@ -4081,6 +4087,11 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get contacts_repeaterPing => 'Repeater pingen';
@override
String contacts_repeaterPathTraceVia(String prefix) {
return 'Path trace via $prefix';
}
@override
String get contacts_roomPathTrace => 'Pfadverfolgung zum Raumserver';

@ -2102,6 +2102,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get path_currentPathLabel => 'Current path';
@override
String get path_suggestedRoute => 'Suggested route (shortest heard)';
@override
String get path_searchRepeaters => 'Search repeaters';
@override
String get path_hexPrefixInstructions =>
'Enter each hop\'s hex prefix, separated by commas.';
@ -4004,6 +4010,11 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get contacts_repeaterPing => 'Ping repeater';
@override
String contacts_repeaterPathTraceVia(String prefix) {
return 'Path trace via $prefix';
}
@override
String get contacts_roomPathTrace => 'Path trace to room server';

@ -2138,6 +2138,12 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get path_currentPathLabel => 'Ruta actual';
@override
String get path_suggestedRoute => 'Suggested route (shortest heard)';
@override
String get path_searchRepeaters => 'Search repeaters';
@override
String get path_hexPrefixInstructions =>
'Introduzca los prefijos hexadecimales de 2 caracteres para cada salto, separados por comas.';
@ -4069,6 +4075,11 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get contacts_repeaterPing => 'Pingar repetidor';
@override
String contacts_repeaterPathTraceVia(String prefix) {
return 'Path trace via $prefix';
}
@override
String get contacts_roomPathTrace =>
'Rastreo de ruta al servidor de la habitación';

@ -2149,6 +2149,12 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get path_currentPathLabel => 'Chemin actuel';
@override
String get path_suggestedRoute => 'Suggested route (shortest heard)';
@override
String get path_searchRepeaters => 'Search repeaters';
@override
String get path_hexPrefixInstructions =>
'Entrez les préfixes hexadécimaux de 2 caractères pour chaque saut, séparés par des virgules.';
@ -4092,6 +4098,11 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get contacts_repeaterPing => 'Pinguer le répéteur';
@override
String contacts_repeaterPathTraceVia(String prefix) {
return 'Path trace via $prefix';
}
@override
String get contacts_roomPathTrace => 'Traçage du chemin vers le room server';

@ -2151,6 +2151,12 @@ class AppLocalizationsHu extends AppLocalizations {
@override
String get path_currentPathLabel => 'Jelenlegi útvonal';
@override
String get path_suggestedRoute => 'Suggested route (shortest heard)';
@override
String get path_searchRepeaters => 'Search repeaters';
@override
String get path_hexPrefixInstructions =>
'Adja meg a 2 karakteres hexadecimális előtagokat minden lépéshez, tagolva kommával.';
@ -4085,6 +4091,11 @@ class AppLocalizationsHu extends AppLocalizations {
@override
String get contacts_repeaterPing => 'Ping-szinkronizáló';
@override
String contacts_repeaterPathTraceVia(String prefix) {
return 'Path trace via $prefix';
}
@override
String get contacts_roomPathTrace => 'Kapcsolat a szobai szerverrel';

@ -2140,6 +2140,12 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get path_currentPathLabel => 'Percorso corrente';
@override
String get path_suggestedRoute => 'Suggested route (shortest heard)';
@override
String get path_searchRepeaters => 'Search repeaters';
@override
String get path_hexPrefixInstructions =>
'Inserire i prefissi esadecimali a 2 caratteri per ogni salto, separati da virgole.';
@ -4074,6 +4080,11 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get contacts_repeaterPing => 'Ripetitore ping';
@override
String contacts_repeaterPathTraceVia(String prefix) {
return 'Path trace via $prefix';
}
@override
String get contacts_roomPathTrace =>
'Traccia del percorso al server della stanza';

@ -2056,6 +2056,12 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get path_currentPathLabel => '現在の経路';
@override
String get path_suggestedRoute => 'Suggested route (shortest heard)';
@override
String get path_searchRepeaters => 'Search repeaters';
@override
String get path_hexPrefixInstructions =>
'各ホップに対して、2文字の16進数プレフィックスをカンマで区切って入力してください。';
@ -3873,6 +3879,11 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get contacts_repeaterPing => 'PING 繰り返し';
@override
String contacts_repeaterPathTraceVia(String prefix) {
return 'Path trace via $prefix';
}
@override
String get contacts_roomPathTrace => '部屋のサーバーへの経路を追跡する';

@ -2051,6 +2051,12 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get path_currentPathLabel => '현재 경로';
@override
String get path_suggestedRoute => 'Suggested route (shortest heard)';
@override
String get path_searchRepeaters => 'Search repeaters';
@override
String get path_hexPrefixInstructions =>
'각 단계에 대한 2자리 헥사데진 접두사를 쉼표로 구분하여 입력하세요.';
@ -3875,6 +3881,11 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get contacts_repeaterPing => '핑 반복';
@override
String contacts_repeaterPathTraceVia(String prefix) {
return 'Path trace via $prefix';
}
@override
String get contacts_roomPathTrace => '방 서버로의 경로 추적';

@ -2127,6 +2127,12 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get path_currentPathLabel => 'Huidige pad';
@override
String get path_suggestedRoute => 'Suggested route (shortest heard)';
@override
String get path_searchRepeaters => 'Search repeaters';
@override
String get path_hexPrefixInstructions =>
'Voer 2-letter hex-voorgiffen voor elke hop in, gescheiden door komma\'s.';
@ -4055,6 +4061,11 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get contacts_repeaterPing => 'Ping-repeater';
@override
String contacts_repeaterPathTraceVia(String prefix) {
return 'Path trace via $prefix';
}
@override
String get contacts_roomPathTrace => 'Padtrace naar room server';

@ -2156,6 +2156,12 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get path_currentPathLabel => 'Aktualna ścieżka';
@override
String get path_suggestedRoute => 'Suggested route (shortest heard)';
@override
String get path_searchRepeaters => 'Search repeaters';
@override
String get path_hexPrefixInstructions =>
'Wprowadź 2-znakowe prefiksy szesnastkowe dla każdego skoku, oddzielone przecinkami.';
@ -4085,6 +4091,11 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get contacts_repeaterPing => 'Ping przekaźnika';
@override
String contacts_repeaterPathTraceVia(String prefix) {
return 'Path trace via $prefix';
}
@override
String get contacts_roomPathTrace =>
'Śledzenie ścieżki do serwera pokojowego';

@ -2137,6 +2137,12 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get path_currentPathLabel => 'Caminho atual';
@override
String get path_suggestedRoute => 'Suggested route (shortest heard)';
@override
String get path_searchRepeaters => 'Search repeaters';
@override
String get path_hexPrefixInstructions =>
'Insira os prefixos hexadecimais de 2 caracteres para cada salto, separados por vírgulas.';
@ -4067,6 +4073,11 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get contacts_repeaterPing => 'Pingar repetidor';
@override
String contacts_repeaterPathTraceVia(String prefix) {
return 'Path trace via $prefix';
}
@override
String get contacts_roomPathTrace => 'Traçar caminho para o servidor da sala';

@ -2144,6 +2144,12 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get path_currentPathLabel => 'Текущий маршрут';
@override
String get path_suggestedRoute => 'Suggested route (shortest heard)';
@override
String get path_searchRepeaters => 'Search repeaters';
@override
String get path_hexPrefixInstructions =>
'Введите 2-символьные шестнадцатеричные префиксы для каждого хопа, разделённые запятыми.';
@ -4076,6 +4082,11 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get contacts_repeaterPing => 'Пинговать повторитель';
@override
String contacts_repeaterPathTraceVia(String prefix) {
return 'Path trace via $prefix';
}
@override
String get contacts_roomPathTrace => 'Трассировка пути к серверу комнаты';

@ -2128,6 +2128,12 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get path_currentPathLabel => 'Aktuálny priebeh';
@override
String get path_suggestedRoute => 'Suggested route (shortest heard)';
@override
String get path_searchRepeaters => 'Search repeaters';
@override
String get path_hexPrefixInstructions =>
'Zadajte 2-miestne hexové predpony pre každú fázu, oddelené čiarkami.';
@ -4051,6 +4057,11 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get contacts_repeaterPing => 'Pingovať opakovač';
@override
String contacts_repeaterPathTraceVia(String prefix) {
return 'Path trace via $prefix';
}
@override
String get contacts_roomPathTrace => 'Sledovanie cesty k serveru miestnosti';

@ -2125,6 +2125,12 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get path_currentPathLabel => 'Trenutna pot';
@override
String get path_suggestedRoute => 'Suggested route (shortest heard)';
@override
String get path_searchRepeaters => 'Search repeaters';
@override
String get path_hexPrefixInstructions =>
'Vnesite 2-karakterne heksadecimalne prefixe za vsako skopo, ločeno z zvezekami.';
@ -4046,6 +4052,11 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get contacts_repeaterPing => 'Pinguj ponavljalnik';
@override
String contacts_repeaterPathTraceVia(String prefix) {
return 'Path trace via $prefix';
}
@override
String get contacts_roomPathTrace => 'Sledenje poti do strežnika sobe';

@ -2114,6 +2114,12 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get path_currentPathLabel => 'Nuvarande sökväg';
@override
String get path_suggestedRoute => 'Suggested route (shortest heard)';
@override
String get path_searchRepeaters => 'Search repeaters';
@override
String get path_hexPrefixInstructions =>
'Ange 2-tecknets hex-prefett för varje hopp, åtskilda med komma.';
@ -4024,6 +4030,11 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get contacts_repeaterPing => 'Ping-repeater';
@override
String contacts_repeaterPathTraceVia(String prefix) {
return 'Path trace via $prefix';
}
@override
String get contacts_roomPathTrace => 'Vägspårning till rumserver';

@ -2138,6 +2138,12 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get path_currentPathLabel => 'Поточний шлях';
@override
String get path_suggestedRoute => 'Suggested route (shortest heard)';
@override
String get path_searchRepeaters => 'Search repeaters';
@override
String get path_hexPrefixInstructions =>
'Введіть 2-символьні hex-префікси для кожного переходу, розділені комами.';
@ -4078,6 +4084,11 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get contacts_repeaterPing => 'Пінгувати ретранслятор';
@override
String contacts_repeaterPathTraceVia(String prefix) {
return 'Path trace via $prefix';
}
@override
String get contacts_roomPathTrace => 'Трасування шляху до серверу кімнати';

@ -2015,6 +2015,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get path_currentPathLabel => '当前路径';
@override
String get path_suggestedRoute => 'Suggested route (shortest heard)';
@override
String get path_searchRepeaters => 'Search repeaters';
@override
String get path_hexPrefixInstructions => '请输入每个中继节点的2字符十六进制前缀用逗号分隔。';
@ -3776,6 +3782,11 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get contacts_repeaterPing => 'Ping 转发节点';
@override
String contacts_repeaterPathTraceVia(String prefix) {
return 'Path trace via $prefix';
}
@override
String get contacts_roomPathTrace => 'Trace 房间服务器';

@ -13,6 +13,7 @@ import 'screens/scanner_screen.dart';
import 'services/storage_service.dart';
import 'services/message_retry_service.dart';
import 'services/path_history_service.dart';
import 'services/mesh_topology_service.dart';
import 'services/app_settings_service.dart';
import 'services/notification_service.dart';
import 'services/ble_debug_log_service.dart';
@ -41,6 +42,7 @@ void main() async {
final storage = StorageService();
final connector = MeshCoreConnector();
final pathHistoryService = PathHistoryService(storage);
final meshTopologyService = MeshTopologyService();
final retryService = MessageRetryService();
final appSettingsService = AppSettingsService();
final bleDebugLogService = BleDebugLogService();
@ -79,6 +81,7 @@ void main() async {
connector.initialize(
retryService: retryService,
pathHistoryService: pathHistoryService,
topologyService: meshTopologyService,
appSettingsService: appSettingsService,
translationService: translationService,
bleDebugLogService: bleDebugLogService,
@ -100,6 +103,7 @@ void main() async {
connector: connector,
retryService: retryService,
pathHistoryService: pathHistoryService,
meshTopologyService: meshTopologyService,
storage: storage,
appSettingsService: appSettingsService,
bleDebugLogService: bleDebugLogService,
@ -138,6 +142,7 @@ class MeshCoreApp extends StatelessWidget {
final MeshCoreConnector connector;
final MessageRetryService retryService;
final PathHistoryService pathHistoryService;
final MeshTopologyService meshTopologyService;
final StorageService storage;
final AppSettingsService appSettingsService;
final BleDebugLogService bleDebugLogService;
@ -153,6 +158,7 @@ class MeshCoreApp extends StatelessWidget {
required this.connector,
required this.retryService,
required this.pathHistoryService,
required this.meshTopologyService,
required this.storage,
required this.appSettingsService,
required this.bleDebugLogService,
@ -171,6 +177,7 @@ class MeshCoreApp extends StatelessWidget {
ChangeNotifierProvider.value(value: connector),
ChangeNotifierProvider.value(value: retryService),
ChangeNotifierProvider.value(value: pathHistoryService),
ChangeNotifierProvider.value(value: meshTopologyService),
ChangeNotifierProvider.value(value: appSettingsService),
ChangeNotifierProvider.value(value: bleDebugLogService),
ChangeNotifierProvider.value(value: appDebugLogService),

@ -26,6 +26,7 @@ import '../utils/route_transitions.dart';
import '../widgets/list_filter_widget.dart';
import '../widgets/empty_state.dart';
import '../widgets/quick_switch_bar.dart';
import '../widgets/path_selection_dialog.dart';
import '../widgets/repeater_login_dialog.dart';
import '../widgets/room_login_dialog.dart';
import '../widgets/sync_progress_overlay.dart';
@ -1267,31 +1268,100 @@ class _ContactsScreenState extends State<ContactsScreen>
ListTile(
leading: const Icon(Icons.radar, color: Colors.green),
title: Text(context.l10n.contacts_pathTrace),
onTap: () {
final hw = context
.read<MeshCoreConnector>()
.pathHashByteWidth;
// A flood route is stored as all-zero bytes = "no real route",
// so trace the target directly instead of routing through (and
// sending) an all-zero/empty path. (#150)
final route = contact.pathBytesForDisplay;
final hasRoute = route.isNotEmpty && route.any((b) => b != 0);
Navigator.push(
context,
onTap: () async {
final connector = context.read<MeshCoreConnector>();
final navigator = Navigator.of(context);
final l10n = context.l10n;
final hw = connector.pathHashByteWidth;
final hopBytes = PathHelper.traceHopBytes(hw);
String hopsToHex(List<int> hops) => hops
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
// A committed route is stored non-zero; a flood route is
// all-zero = "no real route". (#150)
final committed = contact.pathBytesForDisplay;
final hasRoute =
committed.isNotEmpty && committed.any((b) => b != 0);
Uint8List? path;
var flip = false;
var title = l10n.contacts_repeaterPing;
if (hasRoute) {
// Firmware already committed a route through the mesh.
path = committed;
flip = true;
title = l10n.contacts_repeaterPathTrace;
} else {
// No committed route. Ask the passive topology oracle for a
// route inferred from traffic we've already heard. (#186)
final inferred = connector.topology?.inferRoute(
contact.publicKey,
);
final direct =
(inferred?.isEmpty ?? false) ||
connector.directRepeaters.any(
(r) => r.matchesPathStart(contact.publicKey),
);
if (direct) {
// A one-hop ping reaches a direct neighbour.
path = Uint8List.fromList(
contact.publicKey.sublist(0, hopBytes),
);
} else {
// Confirm-first: pre-fill the route builder with the
// inferred route when we have one, else an empty builder.
// The user always confirms we never fire a silent guess.
// (#186; replaces the old strongest-repeater guess.)
final suggested =
(inferred != null && inferred.isNotEmpty)
? inferred.map(hopsToHex).join(',')
: null;
String? suggestedLabel;
if (inferred != null && inferred.isNotEmpty) {
// Resolve the inferred hops to repeater/contact NAMES so
// the user can read + verify the route instead of hex.
// (#186)
final names = PathHelper.resolvePathNames(
inferred.expand((h) => h).toList(),
connector.allContacts,
hw,
);
suggestedLabel =
'${l10n.pathTrace_you}$names${contact.name}';
}
final picked = await PathSelectionDialog.show(
context,
availableContacts: connector.allContacts
.where(
(c) => c.publicKeyHex != contact.publicKeyHex,
)
.toList(),
pathHashByteWidth: hw,
initialPath: suggested,
suggestedRouteLabel: suggestedLabel,
title: l10n.contacts_repeaterPathTrace,
);
if (picked == null || picked.isEmpty) return; // cancelled
path = picked;
flip = true;
final fh = picked.length >= hopBytes
? picked.sublist(0, hopBytes)
: picked;
title = l10n.contacts_repeaterPathTraceVia(
hopsToHex(fh).toUpperCase(),
);
}
}
navigator.push(
MaterialPageRoute(
builder: (context) => PathTraceMapScreen(
title: hasRoute
? context.l10n.contacts_repeaterPathTrace
: context.l10n.contacts_repeaterPing,
path: hasRoute
? route
: Uint8List.fromList(
contact.publicKey.sublist(
0,
PathHelper.traceHopBytes(hw),
),
),
flipPathAround: hasRoute,
title: title,
path: path!,
flipPathAround: flip,
targetContact: contact,
pathHashByteWidth: hw,
),

@ -112,12 +112,6 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
bool _showNodeLabels = true;
Contact? _targetContact;
String _formatPathPrefixes(Uint8List pathBytes) {
return pathBytes
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(',');
}
@override
void initState() {
super.initState();
@ -279,7 +273,8 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
}
appLogger.info(
'Initiating path trace with path: ${_formatPathPrefixes(path)}',
'Initiating path trace with path: '
'${PathHelper.formatPathHex(path, PathHelper.traceHopBytes(widget.pathHashByteWidth))}',
tag: 'PathTraceMapScreen',
noNotify: !mounted,
);
@ -576,7 +571,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
_initialZoom = _points.isNotEmpty ? 13.0 : 2.0;
_bounds = _points.length > 1 ? LatLngBounds.fromPoints(_points) : null;
_mapKey = ValueKey(
'${context.l10n.pathTrace_you},${_formatPathPrefixes(_traceData!.pathData)}',
'${context.l10n.pathTrace_you},${PathHelper.formatPathHex(_traceData!.pathData, _traceData!.hopBytes)}',
);
_pathDistanceMeters = getPathDistanceMeters(_points);
});

@ -290,9 +290,9 @@ class _ScannerScreenState extends State<ScannerScreen> {
MeshCoreConnector connector,
ScanResult result,
) async {
final name = result.device.platformName.isNotEmpty
? result.device.platformName
: result.advertisementData.advName;
// Prefer the live advertised name over the cached platformName. (#189)
final adv = result.advertisementData.advName;
final name = adv.isNotEmpty ? adv : result.device.platformName;
try {
await connector.connect(
result.device,

@ -19,6 +19,7 @@ import 'settings/message_settings_view.dart';
import 'settings/observer_settings_view.dart';
import 'app_debug_log_screen.dart';
import 'ble_debug_log_screen.dart';
import 'topology_debug_screen.dart';
import 'companion_radio_stats_screen.dart';
import '../widgets/sync_progress_overlay.dart';
@ -587,6 +588,23 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
},
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.hub_outlined),
title: const Text('Mesh topology'),
subtitle: const Text(
'Passively-learned node/edge graph (trace inference)',
),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const TopologyDebugScreen(),
),
);
},
),
],
),
);

@ -0,0 +1,313 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../helpers/snack_bar_builder.dart';
import '../models/contact.dart';
import '../services/mesh_topology_service.dart';
import '../widgets/adaptive_app_bar_title.dart';
/// Read-only diagnostic view of the passively-learned mesh topology (#196 /
/// epic #186). It answers one question on real hardware: is
/// [MeshTopologyService] actually ingesting? Node/edge counts climb as routed
/// packets arrive; stuck at zero while traffic flows means the RX ingestion
/// wiring is dead. Strings are hardcoded EN on purpose this is a dev-facing
/// diagnostic, deliberately not part of the localized UX surface.
class TopologyDebugScreen extends StatelessWidget {
const TopologyDebugScreen({super.key});
@override
Widget build(BuildContext context) {
final contacts = context.read<MeshCoreConnector>().contacts;
return Consumer<MeshTopologyService>(
builder: (context, topo, _) {
final now = topo.clockSeconds;
final self = topo.self;
final nodes = topo.nodes.toList()
..sort((a, b) => b.lastSeen.compareTo(a.lastSeen));
final edges = topo.edges.toList()
..sort((a, b) => b.lastSeen.compareTo(a.lastSeen));
final hasData = nodes.isNotEmpty || edges.isNotEmpty;
return Scaffold(
appBar: AppBar(
title: const AdaptiveAppBarTitle('Mesh topology'),
centerTitle: true,
actions: [
IconButton(
tooltip: 'Copy dump',
icon: const Icon(Icons.copy),
onPressed: !hasData
? null
: () async {
await Clipboard.setData(
ClipboardData(text: _dump(topo, contacts, now)),
);
if (!context.mounted) return;
showDismissibleSnackBar(
context,
content: const Text('Topology copied'),
);
},
),
],
),
body: SafeArea(
top: false,
child: ListView(
children: [
_summary(context, topo, self, contacts),
const Divider(height: 1),
if (nodes.isEmpty)
_empty(context)
else ...[
_sectionHeader(context, 'Nodes (${nodes.length})'),
for (final n in nodes)
_nodeTile(context, n, self, contacts, now),
const Divider(height: 1),
_sectionHeader(context, 'Edges (${edges.length})'),
for (final e in edges) _edgeTile(context, e, contacts, now),
],
],
),
),
);
},
);
}
Widget _summary(
BuildContext context,
MeshTopologyService topo,
Uint8List? self,
List<Contact> contacts,
) {
final selfLabel = self == null
? 'not set yet — waiting for first packet'
: (_nameOf(self, contacts) == null
? _hex(self)
: '${_nameOf(self, contacts)} (${_hex(self)})');
return Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
_stat(context, '${topo.nodeCount}', 'nodes'),
_stat(context, '${topo.edgeCount}', 'edges'),
_stat(context, '${topo.freshnessSeconds ~/ 86400}d', 'freshness'),
],
),
const SizedBox(height: 10),
Text(
'You: $selfLabel',
style: TextStyle(
fontSize: 12,
fontFamily: 'monospace',
color: Colors.grey[700],
),
),
const SizedBox(height: 6),
Text(
'Passive map of routed-packet paths. Counts climb as traffic '
'arrives; stuck at 0 while messages flow means ingestion is dead.',
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
),
],
),
);
}
Widget _stat(BuildContext context, String value, String label) {
return Expanded(
child: Column(
children: [
Text(
value,
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
Text(label, style: TextStyle(fontSize: 11, color: Colors.grey[600])),
],
),
);
}
Widget _sectionHeader(BuildContext context, String text) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
text,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold),
),
);
}
Widget _nodeTile(
BuildContext context,
TopoNode n,
Uint8List? self,
List<Contact> contacts,
int now,
) {
final hex = _hex(n.prefix);
final isSelf = self != null && hex == _hex(self);
final name = isSelf ? 'You' : _nameOf(n.prefix, contacts);
final snr = n.neighbourSnr;
final meta = <String>[
'seen ×${n.seenCount}',
_age(n.lastSeen, now),
if (snr != null) 'SNR ${snr.toStringAsFixed(1)}',
].join(' · ');
return ListTile(
dense: true,
leading: Icon(
isSelf
? Icons.my_location
: (snr != null ? Icons.cell_tower : Icons.hub_outlined),
size: 18,
color: isSelf
? Colors.blue
: (snr != null ? Colors.green : Colors.grey),
),
title: Text(
name ?? hex,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
),
subtitle: Text(
name != null ? '$hex · $meta' : meta,
style: TextStyle(
fontSize: 11,
fontFamily: 'monospace',
color: Colors.grey[600],
),
),
);
}
Widget _edgeTile(
BuildContext context,
TopoEdge e,
List<Contact> contacts,
int now,
) {
final a = _nameOf(e.a, contacts) ?? _hex(e.a);
final b = _nameOf(e.b, contacts) ?? _hex(e.b);
final failed = e.lastFailed != null;
final meta = <String>[
'×${e.count}',
_age(e.lastSeen, now),
if (failed) 'failed ${_age(e.lastFailed!, now)}',
].join(' · ');
return ListTile(
dense: true,
leading: Icon(
failed ? Icons.link_off : Icons.link,
size: 18,
color: failed ? Colors.orange : Colors.grey,
),
title: Text('$a ~ $b', style: const TextStyle(fontSize: 13)),
subtitle: Text(
meta,
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
),
);
}
Widget _empty(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 24),
child: Column(
children: [
Icon(Icons.hub_outlined, size: 56, color: Colors.grey[400]),
const SizedBox(height: 12),
Text(
'No topology learned yet',
style: TextStyle(fontSize: 15, color: Colors.grey[600]),
),
const SizedBox(height: 6),
Text(
'Listening — this fills as routed packets arrive from the mesh. '
'Leave it connected a few minutes.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
),
],
),
);
}
// pure helpers (also used by the clipboard dump)
static String? _nameOf(List<int> prefix, List<Contact> contacts) {
final matches = contacts
.where(
(c) =>
c.publicKey.length >= prefix.length &&
_startsWith(c.publicKey, prefix) &&
(c.type == advTypeRepeater || c.type == advTypeRoom),
)
.toList();
if (matches.isEmpty) return null;
if (matches.length == 1) return matches.first.name;
return matches.map((c) => c.name).join(' | ');
}
static bool _startsWith(List<int> pubkey, List<int> prefix) {
for (var i = 0; i < prefix.length; i++) {
if (pubkey[i] != prefix[i]) return false;
}
return true;
}
static String _hex(List<int> b) =>
b.map((x) => x.toRadixString(16).padLeft(2, '0').toUpperCase()).join();
static String _age(int lastSeen, int now) {
final s = now - lastSeen;
if (s < 0) return 'now';
if (s < 60) return '${s}s ago';
if (s < 3600) return '${s ~/ 60}m ago';
if (s < 86400) return '${s ~/ 3600}h ago';
return '${s ~/ 86400}d ago';
}
static String _dump(
MeshTopologyService topo,
List<Contact> contacts,
int now,
) {
final self = topo.self;
final buf = StringBuffer()
..writeln(
'Mesh topology — ${topo.nodeCount} nodes, ${topo.edgeCount} edges',
)
..writeln('self: ${self == null ? "(unset)" : _hex(self)}')
..writeln('freshness: ${topo.freshnessSeconds ~/ 86400}d')
..writeln('')
..writeln('NODES:');
for (final n
in topo.nodes.toList()
..sort((a, b) => b.lastSeen.compareTo(a.lastSeen))) {
final nm = _nameOf(n.prefix, contacts);
final snr = n.neighbourSnr;
buf.writeln(
' ${_hex(n.prefix)}${nm != null ? " ($nm)" : ""} '
'seen×${n.seenCount} ${_age(n.lastSeen, now)}'
'${snr != null ? " SNR ${snr.toStringAsFixed(1)}" : ""}',
);
}
buf.writeln('EDGES:');
for (final e
in topo.edges.toList()
..sort((a, b) => b.lastSeen.compareTo(a.lastSeen))) {
buf.writeln(
' ${_hex(e.a)} ~ ${_hex(e.b)} ×${e.count} '
'${_age(e.lastSeen, now)}${e.lastFailed != null ? " FAILED" : ""}',
);
}
return buf.toString();
}
}

@ -0,0 +1,323 @@
import 'dart:collection';
import 'package:flutter/foundation.dart';
/// A node (repeater/relay) in the passively-observed mesh topology, keyed by its
/// width-byte path-hash prefix. (#186 Phase B see docs/trace-topology-spec.md)
class TopoNode {
final Uint8List prefix;
int lastSeen; // unix seconds
/// Set ONLY for our own direct neighbours (the last hop of a packet we heard),
/// where we actually measured the reception SNR. Null for deeper hops, whose
/// link quality we can't observe from here.
double? neighbourSnr;
int seenCount;
TopoNode({
required this.prefix,
required this.lastSeen,
this.neighbourSnr,
this.seenCount = 1,
});
}
/// An undirected observed adjacency between two nodes ("seen consecutive in a
/// packet's path"). Stored once, canonically keyed.
class TopoEdge {
final Uint8List a;
final Uint8List b;
int lastSeen;
int count;
/// Set when a trace routed over this link timed out inference deprioritises
/// it so the graph self-heals as topology drifts.
int? lastFailed;
TopoEdge({
required this.a,
required this.b,
required this.lastSeen,
this.count = 1,
this.lastFailed,
});
}
/// Accumulates the routing paths of received packets into a topology graph and
/// infers trace routes from it the thing the firmware can't do (it only walks
/// a caller-supplied route) and the reference apps push onto the user (manual
/// build). Deliberately SEPARATE from PathHistoryService (Ben, 2026-07-05):
/// PathHistory weights message routing; this is the trace oracle.
///
/// Storage is bounded by design: the graph is deduplicated (a node/edge stored
/// once, refreshed), pruned by freshness, and hard-capped by node count so it
/// scales with mesh size, not hop depth or packet volume. (#186)
class MeshTopologyService extends ChangeNotifier {
static const int defaultFreshnessSeconds = 7 * 24 * 3600; // 7 days
static const int defaultMaxNodes = 2000;
final int freshnessSeconds;
final int maxNodes;
final int Function() _now;
final Map<String, TopoNode> _nodes = {};
final Map<String, TopoEdge> _edges = {};
final Map<String, Set<String>> _adj = {}; // nodeHex -> neighbour nodeHex set
Uint8List? _self; // our own node prefix (width bytes)
MeshTopologyService({
int? freshnessSeconds,
int? maxNodes,
int Function()? now,
}) : freshnessSeconds = freshnessSeconds ?? defaultFreshnessSeconds,
maxNodes = maxNodes ?? defaultMaxNodes,
_now = now ?? _defaultNow;
static int _defaultNow() => DateTime.now().millisecondsSinceEpoch ~/ 1000;
int get nodeCount => _nodes.length;
int get edgeCount => _edges.length;
/// Our own node prefix (the BFS origin), or null until the first RX set it.
Uint8List? get self => _self;
/// The service's clock in unix seconds, so a debug/inspection UI computes
/// last-heard ages against the same clock the graph is stamped with.
int get clockSeconds => _now();
/// Read-only snapshot of learned nodes, for debug/inspection UIs only.
List<TopoNode> get nodes => List.unmodifiable(_nodes.values);
/// Read-only snapshot of observed edges, for debug/inspection UIs only.
List<TopoEdge> get edges => List.unmodifiable(_edges.values);
bool hasNode(List<int> prefix) => _nodes.containsKey(_hex(prefix));
/// The measured reception SNR for a node, set only when it was our direct
/// neighbour (the last hop of a packet we heard); null for deeper hops.
double? neighbourSnrOf(List<int> prefix) =>
_nodes[_hex(prefix)]?.neighbourSnr;
/// Our own node prefix the BFS origin for inference and the anchor for the
/// `us ~ direct-neighbour` edges.
void setSelf(List<int> prefix, int width) {
final w = width < 1 ? 1 : width;
_self = Uint8List.fromList(
prefix.sublist(0, w > prefix.length ? prefix.length : w),
);
}
static String _hex(List<int> b) =>
b.map((x) => x.toRadixString(16).padLeft(2, '0')).join();
static String _edgeKey(String x, String y) =>
x.compareTo(y) <= 0 ? '$x|$y' : '$y|$x';
/// Ingests one received packet's routing path (as received:
/// `origin h1 hk us`, so `hk` = `path`'s last hop = our direct
/// neighbour). [lastHopSnr] is the SNR of our reception (attributed to `hk`).
/// Returns true on a notify-worthy STRUCTURAL change (new node/edge). Cheap:
/// O(path length 64) map writes the path was already parsed upstream.
bool observePath(
List<int> path, {
required int width,
double? lastHopSnr,
int? ts,
}) {
final w = width < 1 ? 1 : width;
if (path.length < w) return false;
final now = ts ?? _now();
final hops = <Uint8List>[];
for (var i = 0; i + w <= path.length; i += w) {
hops.add(Uint8List.fromList(path.sublist(i, i + w)));
}
if (hops.isEmpty) return false;
var changed = false;
for (var i = 0; i < hops.length; i++) {
final isLastHop = i == hops.length - 1; // our direct neighbour
changed |= _upsertNode(hops[i], now, isLastHop ? lastHopSnr : null);
}
for (var i = 0; i + 1 < hops.length; i++) {
changed |= _upsertEdge(hops[i], hops[i + 1], now);
}
final self = _self;
if (self != null && self.length >= w) {
changed |= _upsertNode(self, now, null);
changed |= _upsertEdge(self, hops.last, now); // us ~ last hop
}
_enforceCap();
if (changed) notifyListeners();
return changed;
}
bool _upsertNode(Uint8List prefix, int now, double? neighbourSnr) {
final k = _hex(prefix);
final existing = _nodes[k];
if (existing == null) {
_nodes[k] = TopoNode(
prefix: Uint8List.fromList(prefix),
lastSeen: now,
neighbourSnr: neighbourSnr,
);
_adj.putIfAbsent(k, () => <String>{});
return true; // structural growth
}
existing.lastSeen = now;
existing.seenCount++;
if (neighbourSnr != null) existing.neighbourSnr = neighbourSnr;
return false; // refresh only
}
bool _upsertEdge(Uint8List a, Uint8List b, int now) {
final ha = _hex(a);
final hb = _hex(b);
if (ha == hb) return false; // no self-loops
final k = _edgeKey(ha, hb);
final existing = _edges[k];
if (existing == null) {
_edges[k] = TopoEdge(
a: Uint8List.fromList(a),
b: Uint8List.fromList(b),
lastSeen: now,
);
_adj.putIfAbsent(ha, () => <String>{}).add(hb);
_adj.putIfAbsent(hb, () => <String>{}).add(ha);
return true;
}
existing.lastSeen = now;
existing.count++;
return false;
}
/// The ordered intermediate hops from us to [target], EXCLUDING both us and
/// the target feed straight into
/// `PathHelper.buildTraceRoundTrip(routingPath: result, targetPrefix: target)`.
///
/// - `[]` target is a direct neighbour (a one-hop ping is correct).
/// - `null` target isn't reachable in the FRESH graph (→ manual builder).
///
/// Shortest hop count over edges within the freshness window, biased at each
/// step toward stronger first-hop SNR / fresher / more-seen links. (Phase B
/// uses ordered-BFS; a weighted search is a later refinement.)
List<Uint8List>? inferRoute(List<int> target, {int? nowTs}) {
final self = _self;
if (self == null) return null;
final now = nowTs ?? _now();
final selfHex = _hex(self);
final targetHex = _hex(target);
if (targetHex == selfHex) return <Uint8List>[];
if (!_nodes.containsKey(targetHex)) return null; // never observed
final visited = <String>{selfHex};
final parent = <String, String>{};
final queue = Queue<String>()..add(selfHex);
while (queue.isNotEmpty) {
final cur = queue.removeFirst();
if (cur == targetHex) break;
for (final nb in _freshNeighbours(cur, now, selfHex)) {
if (visited.add(nb)) {
parent[nb] = cur;
queue.add(nb);
}
}
}
if (!visited.contains(targetHex)) return null;
final chain = <String>[];
var node = targetHex;
while (node != selfHex) {
chain.add(node);
final p = parent[node];
if (p == null) return null;
node = p;
}
// chain = [target, , n1]; reverse and drop self(implicit)+target ends.
final ordered = chain.reversed.toList(); // [n1, , target]
if (ordered.isEmpty) return <Uint8List>[];
final intermediates = ordered.sublist(0, ordered.length - 1); // drop target
return intermediates.map((h) => _nodes[h]!.prefix).toList();
}
List<String> _freshNeighbours(String nodeHex, int now, String selfHex) {
final adj = _adj[nodeHex];
if (adj == null || adj.isEmpty) return const [];
final out = <String>[];
for (final nb in adj) {
final e = _edges[_edgeKey(nodeHex, nb)];
if (e == null) continue;
if (now - e.lastSeen > freshnessSeconds) continue; // stale link
out.add(nb);
}
final isSelfRing = nodeHex == selfHex;
out.sort((x, y) {
if (isSelfRing) {
final sx = _nodes[x]?.neighbourSnr ?? -1e9;
final sy = _nodes[y]?.neighbourSnr ?? -1e9;
if (sx != sy) return sy.compareTo(sx); // stronger first hop first
}
final ex = _edges[_edgeKey(nodeHex, x)]!;
final ey = _edges[_edgeKey(nodeHex, y)]!;
// Deprioritise links a trace recently failed over, so inference self-heals
// as topology drifts; failures decay out of the freshness window. (#186)
final fx =
(ex.lastFailed != null && now - ex.lastFailed! < freshnessSeconds)
? 1
: 0;
final fy =
(ey.lastFailed != null && now - ey.lastFailed! < freshnessSeconds)
? 1
: 0;
if (fx != fy) return fx.compareTo(fy);
final byFresh = ey.lastSeen.compareTo(ex.lastSeen);
if (byFresh != 0) return byFresh;
return ey.count.compareTo(ex.count);
});
return out;
}
/// Flags every link on a failed route so inference deprioritises it next time.
void markRouteFailed(List<List<int>> hopChain, {int? ts}) {
final now = ts ?? _now();
for (var i = 0; i + 1 < hopChain.length; i++) {
final e = _edges[_edgeKey(_hex(hopChain[i]), _hex(hopChain[i + 1]))];
if (e != null) e.lastFailed = now;
}
}
/// Drops nodes/edges not heard within the freshness window for persistence
/// bounds (Phase D). Inference already ignores stale links at query time.
void pruneStale([int? nowTs]) {
final now = nowTs ?? _now();
final dead = _nodes.entries
.where((e) => now - e.value.lastSeen > freshnessSeconds)
.map((e) => e.key)
.toList();
for (final k in dead) {
_removeNode(k);
}
}
void _enforceCap() {
if (_nodes.length <= maxNodes) return;
final byAge = _nodes.entries.toList()
..sort((a, b) => a.value.lastSeen.compareTo(b.value.lastSeen));
final removeCount = _nodes.length - maxNodes;
for (var i = 0; i < removeCount; i++) {
_removeNode(byAge[i].key);
}
}
void _removeNode(String hex) {
_nodes.remove(hex);
final adj = _adj.remove(hex);
if (adj != null) {
for (final nb in adj) {
_adj[nb]?.remove(hex);
_edges.remove(_edgeKey(hex, nb));
}
}
}
}

@ -14,9 +14,11 @@ class DeviceTile extends StatelessWidget {
Widget build(BuildContext context) {
final device = scanResult.device;
final rssi = scanResult.rssi;
final name = device.platformName.isNotEmpty
? device.platformName
: scanResult.advertisementData.advName;
// Prefer the LIVE advertised name over the OS-cached platformName, which can
// be a stale/default cache (e.g. "nimble", the NimBLE stack default) that
// masks the device's real advertised name. (#189)
final adv = scanResult.advertisementData.advName;
final name = adv.isNotEmpty ? adv : device.platformName;
return ListTile(
leading: _buildSignalIcon(rssi),

@ -14,6 +14,11 @@ class PathSelectionDialog extends StatefulWidget {
final VoidCallback? onRefresh;
final int pathHashByteWidth;
/// A human-readable, name-resolved rendering of a pre-filled suggested route
/// (e.g. "You → HomeRepeater → Backbone → Target"), shown prominently so the
/// user can read/verify the inferred route instead of raw hex. (#186)
final String? suggestedRouteLabel;
const PathSelectionDialog({
super.key,
required this.availableContacts,
@ -22,6 +27,7 @@ class PathSelectionDialog extends StatefulWidget {
this.initialPath,
this.currentPathLabel,
this.onRefresh,
this.suggestedRouteLabel,
});
@override
@ -35,6 +41,7 @@ class PathSelectionDialog extends StatefulWidget {
String? initialPath,
String? currentPathLabel,
VoidCallback? onRefresh,
String? suggestedRouteLabel,
}) {
return showDialog<Uint8List?>(
context: context,
@ -45,6 +52,7 @@ class PathSelectionDialog extends StatefulWidget {
initialPath: initialPath,
currentPathLabel: currentPathLabel,
onRefresh: onRefresh,
suggestedRouteLabel: suggestedRouteLabel,
),
);
}
@ -81,6 +89,7 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
late TextEditingController _controller;
final List<Contact> _selectedContacts = [];
List<Contact> _validContacts = [];
String _search = '';
// Hop prefix width in bytes (clamped >= 1) and in hex chars, from the device's
// configured path-hash width. (#155)
@ -104,9 +113,22 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
}
void _filterValidContacts() {
_validContacts = widget.availableContacts
.where((c) => c.type == advTypeRepeater || c.type == advTypeRoom)
.toList();
// Repeaters/rooms only, filtered by the search box, sorted by name so the
// list is coherent and findable instead of raw contact-store order. (#186)
final q = _search.trim().toLowerCase();
_validContacts =
widget.availableContacts
.where((c) => c.type == advTypeRepeater || c.type == advTypeRoom)
.where(
(c) =>
q.isEmpty ||
c.name.toLowerCase().contains(q) ||
c.publicKeyHex.toLowerCase().startsWith(q),
)
.toList()
..sort(
(a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
);
}
void _updateTextFromContacts() {
@ -205,6 +227,54 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.suggestedRouteLabel != null) ...[
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.route,
size: 16,
color: Theme.of(
context,
).colorScheme.onSecondaryContainer,
),
const SizedBox(width: 6),
Text(
l10n.path_suggestedRoute,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(
context,
).colorScheme.onSecondaryContainer,
),
),
],
),
const SizedBox(height: 4),
Text(
widget.suggestedRouteLabel!,
style: TextStyle(
fontSize: 13,
color: Theme.of(
context,
).colorScheme.onSecondaryContainer,
),
),
],
),
),
],
if (widget.currentPathLabel != null) ...[
Row(
children: [
@ -272,6 +342,19 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
],
),
const SizedBox(height: 8),
TextField(
onChanged: (v) => setState(() {
_search = v;
_filterValidContacts();
}),
decoration: InputDecoration(
isDense: true,
prefixIcon: const Icon(Icons.search, size: 18),
hintText: l10n.path_searchRepeaters,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 8),
if (_validContacts.isEmpty) ...[
Center(
child: Padding(

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.1.2-rc.1+51
version: 1.1.2-rc.1+46
environment:
sdk: ^3.9.2

@ -112,4 +112,86 @@ void main() {
expect(repeater([0x84]).matchesPathStart(<int>[]), isFalse);
});
});
group(
'DirectRepeater.recordHop (#150 — RX-fed nearest-repeater detection)',
() {
DirectRepeater rep(List<int> prefix, double snr) => DirectRepeater(
pubkeyFirstByte: prefix.last,
pubkeyPrefix: Uint8List.fromList(prefix),
snr: snr,
);
test('adds a new hop to an empty list', () {
final list = <DirectRepeater>[];
final changed = DirectRepeater.recordHop(
list,
Uint8List.fromList([0x6a, 0x3d]),
5.0,
);
expect(changed, isTrue);
expect(list.length, 1);
expect(list.single.pubkeyPrefix, [0x6a, 0x3d]);
expect(list.single.snr, 5.0);
});
test('an empty prefix is ignored', () {
final list = <DirectRepeater>[];
expect(DirectRepeater.recordHop(list, Uint8List(0), 5.0), isFalse);
expect(list, isEmpty);
});
test('refreshes an existing hop; notifies only on a >= 1 dB move', () {
final list = [
rep([0x6a, 0x3d], 5.0),
];
// Sub-threshold jitter: value refreshes silently (no notify).
expect(
DirectRepeater.recordHop(list, Uint8List.fromList([0x6a, 0x3d]), 5.5),
isFalse,
);
expect(list.single.snr, 5.5);
expect(list.length, 1);
// >= 1 dB move: worth a notify.
expect(
DirectRepeater.recordHop(list, Uint8List.fromList([0x6a, 0x3d]), 7.0),
isTrue,
);
expect(list.single.snr, 7.0);
});
test('at cap, a stronger newcomer evicts the weakest', () {
final list = [
rep([0x01, 0x01], 1.0),
rep([0x02, 0x02], 2.0),
rep([0x03, 0x03], 3.0),
rep([0x04, 0x04], 4.0),
rep([0x05, 0x05], 5.0),
];
expect(
DirectRepeater.recordHop(list, Uint8List.fromList([0x06, 0x06]), 6.0),
isTrue,
);
expect(list.length, 5);
expect(list.any((r) => r.snr == 1.0), isFalse); // weakest evicted
expect(list.any((r) => r.pubkeyPrefix[0] == 0x06), isTrue);
});
test('at cap, a newcomer no stronger than the weakest is dropped', () {
final list = [
rep([0x01, 0x01], 1.0),
rep([0x02, 0x02], 2.0),
rep([0x03, 0x03], 3.0),
rep([0x04, 0x04], 4.0),
rep([0x05, 0x05], 5.0),
];
expect(
DirectRepeater.recordHop(list, Uint8List.fromList([0x06, 0x06]), 0.5),
isFalse,
);
expect(list.length, 5);
expect(list.any((r) => r.pubkeyPrefix[0] == 0x06), isFalse);
});
},
);
}

@ -146,6 +146,24 @@ void main() {
);
});
test(
'buildTraceRoundTrip — via-repeater: [via]+target → [via,target,via]',
() {
// #150: with no committed route, trace a repeater through our strongest
// direct repeater as the first hop (Ben's mesh: 6A3D fronting AC01).
expect(
PathHelper.buildTraceRoundTrip(
routingPath: [0x6a, 0x3d],
routingWidth: 2,
hopBytes: 2,
throughTarget: true,
targetPrefix: [0xac, 0x01],
),
[0x6a, 0x3d, 0xac, 0x01, 0x6a, 0x3d],
);
},
);
test('buildTraceRoundTrip — chat: far hop is the turnaround', () {
expect(
PathHelper.buildTraceRoundTrip(

@ -0,0 +1,115 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/services/mesh_topology_service.dart';
/// #186 Phase B the passive-topology route oracle.
/// Path orientation used throughout: a received path is `origin h1 hk us`,
/// so `hk` (the path's LAST hop) is our direct neighbour.
void main() {
// 2-byte path-hash width fixtures.
const self = [0x00, 0x01];
const r1 = [0x11, 0x11];
const r2 = [0x22, 0x22];
const r3 = [0x33, 0x33];
const t = [0xCC, 0xCC]; // target only ever seen relaying
const u = [0x99, 0x99]; // never observed
List<int> path(List<List<int>> hops) => hops.expand((h) => h).toList();
List<List<int>>? asLists(List<dynamic>? r) =>
r?.map((u) => (u as List).cast<int>()).toList();
MeshTopologyService svc({int? maxNodes}) {
final s = MeshTopologyService(maxNodes: maxNodes);
s.setSelf(self, 2);
return s;
}
group('observePath — orientation + graph growth', () {
test('last hop is our neighbour (SNR); mids are relays (no SNR)', () {
final s = svc();
// packet: origin R2 T R1 us (R1 = neighbour, T relayed)
final changed = s.observePath(
path([r2, t, r1]),
width: 2,
lastHopSnr: 6.5,
);
expect(changed, isTrue);
expect(s.nodeCount, 4); // self, R2, T, R1
expect(s.edgeCount, 3); // R2~T, T~R1, self~R1
expect(s.neighbourSnrOf(r1), 6.5); // last hop = measured neighbour
expect(s.neighbourSnrOf(t), isNull); // relayed no measurable SNR
expect(s.neighbourSnrOf(r2), isNull);
});
test('re-observing the same path is a silent refresh (no growth)', () {
final s = svc();
s.observePath(path([r2, t, r1]), width: 2);
final n = s.nodeCount, e = s.edgeCount;
final changed = s.observePath(path([r2, t, r1]), width: 2);
expect(changed, isFalse);
expect(s.nodeCount, n);
expect(s.edgeCount, e);
});
});
group('inferRoute', () {
test('direct neighbour → empty route (one-hop ping is correct)', () {
final s = svc();
s.observePath(path([r1]), width: 2); // us ~ R1
expect(asLists(s.inferRoute(r1)), isEmpty);
});
test('KEY: routes to a node only ever heard RELAYING', () {
final s = svc();
// T never arrives as a last hop only mid-path yet we can reach it.
s.observePath(path([r2, t, r1]), width: 2); // originR2TR1us
expect(s.neighbourSnrOf(t), isNull); // proof T was never our neighbour
// us R1 T
expect(asLists(s.inferRoute(t)), [r1]);
});
test('unobserved target → null (→ manual builder)', () {
final s = svc();
s.observePath(path([r2, t, r1]), width: 2);
expect(s.inferRoute(u), isNull);
});
test('picks the shorter of two known routes', () {
final s = svc();
s.observePath(path([t, r1]), width: 2); // usR1T (2 hops)
s.observePath(path([t, r3, r2]), width: 2); // usR2R3T (3 hops)
expect(asLists(s.inferRoute(t)), [r1]);
});
});
group('freshness + bounds', () {
test('a stale-only route is not inferred', () {
const now = 1000000000;
const eightDays = 8 * 24 * 3600;
final s = svc();
s.observePath(path([t, r1]), width: 2, ts: now - eightDays); // stale
expect(s.inferRoute(t, nowTs: now), isNull); // stale link excluded
// a fresh observation of the same link makes it routable again
s.observePath(path([t, r1]), width: 2, ts: now);
expect(asLists(s.inferRoute(t, nowTs: now)), [r1]);
});
test('node count is hard-capped (oldest evicted)', () {
final s = svc(maxNodes: 3);
s.observePath(path([r1]), width: 2, ts: 100); // self, R1
s.observePath(path([r2]), width: 2, ts: 200); // + R2 3
s.observePath(path([r3]), width: 2, ts: 300); // + R3 4 evict oldest
expect(s.nodeCount, 3);
expect(s.hasNode(r1), isFalse); // R1 was the oldest
expect(s.hasNode(r3), isTrue);
});
test('pruneStale drops nodes past the freshness window', () {
const now = 1000000000;
final s = svc();
s.observePath(path([r2, t, r1]), width: 2, ts: now - 8 * 24 * 3600);
expect(s.nodeCount, greaterThan(0));
s.pruneStale(now);
expect(s.nodeCount, 0);
});
});
}

@ -20,6 +20,20 @@
"appSettings_clock24h",
"chat_replyWithRoute",
"channel_heardTooltip",
"path_suggestedRoute",
"path_searchRepeaters",
"channels_shareQr",
"channels_shareQrTitle",
"channels_shareQrInstructions",
"channels_scanQr",
"channels_scanQrDesc",
"channels_scanQrInstructions",
"channels_invalidQr",
"channels_noFreeSlot",
"channels_qrAddConfirm",
"channels_qrAdded",
"channels_qrExists",
"contacts_repeaterPathTraceVia",
"snrIndicator_noNeighbors"
],
@ -44,6 +58,20 @@
"appSettings_clock24h",
"chat_replyWithRoute",
"channel_heardTooltip",
"path_suggestedRoute",
"path_searchRepeaters",
"channels_shareQr",
"channels_shareQrTitle",
"channels_shareQrInstructions",
"channels_scanQr",
"channels_scanQrDesc",
"channels_scanQrInstructions",
"channels_invalidQr",
"channels_noFreeSlot",
"channels_qrAddConfirm",
"channels_qrAdded",
"channels_qrExists",
"contacts_repeaterPathTraceVia",
"snrIndicator_noNeighbors"
],
@ -68,6 +96,20 @@
"appSettings_clock24h",
"chat_replyWithRoute",
"channel_heardTooltip",
"path_suggestedRoute",
"path_searchRepeaters",
"channels_shareQr",
"channels_shareQrTitle",
"channels_shareQrInstructions",
"channels_scanQr",
"channels_scanQrDesc",
"channels_scanQrInstructions",
"channels_invalidQr",
"channels_noFreeSlot",
"channels_qrAddConfirm",
"channels_qrAdded",
"channels_qrExists",
"contacts_repeaterPathTraceVia",
"snrIndicator_noNeighbors"
],
@ -92,6 +134,20 @@
"appSettings_clock24h",
"chat_replyWithRoute",
"channel_heardTooltip",
"path_suggestedRoute",
"path_searchRepeaters",
"channels_shareQr",
"channels_shareQrTitle",
"channels_shareQrInstructions",
"channels_scanQr",
"channels_scanQrDesc",
"channels_scanQrInstructions",
"channels_invalidQr",
"channels_noFreeSlot",
"channels_qrAddConfirm",
"channels_qrAdded",
"channels_qrExists",
"contacts_repeaterPathTraceVia",
"snrIndicator_noNeighbors"
],
@ -116,6 +172,20 @@
"appSettings_clock24h",
"chat_replyWithRoute",
"channel_heardTooltip",
"path_suggestedRoute",
"path_searchRepeaters",
"channels_shareQr",
"channels_shareQrTitle",
"channels_shareQrInstructions",
"channels_scanQr",
"channels_scanQrDesc",
"channels_scanQrInstructions",
"channels_invalidQr",
"channels_noFreeSlot",
"channels_qrAddConfirm",
"channels_qrAdded",
"channels_qrExists",
"contacts_repeaterPathTraceVia",
"snrIndicator_noNeighbors"
],
@ -140,6 +210,20 @@
"appSettings_clock24h",
"chat_replyWithRoute",
"channel_heardTooltip",
"path_suggestedRoute",
"path_searchRepeaters",
"channels_shareQr",
"channels_shareQrTitle",
"channels_shareQrInstructions",
"channels_scanQr",
"channels_scanQrDesc",
"channels_scanQrInstructions",
"channels_invalidQr",
"channels_noFreeSlot",
"channels_qrAddConfirm",
"channels_qrAdded",
"channels_qrExists",
"contacts_repeaterPathTraceVia",
"snrIndicator_noNeighbors"
],
@ -164,6 +248,20 @@
"appSettings_clock24h",
"chat_replyWithRoute",
"channel_heardTooltip",
"path_suggestedRoute",
"path_searchRepeaters",
"channels_shareQr",
"channels_shareQrTitle",
"channels_shareQrInstructions",
"channels_scanQr",
"channels_scanQrDesc",
"channels_scanQrInstructions",
"channels_invalidQr",
"channels_noFreeSlot",
"channels_qrAddConfirm",
"channels_qrAdded",
"channels_qrExists",
"contacts_repeaterPathTraceVia",
"snrIndicator_noNeighbors"
],
@ -188,6 +286,20 @@
"appSettings_clock24h",
"chat_replyWithRoute",
"channel_heardTooltip",
"path_suggestedRoute",
"path_searchRepeaters",
"channels_shareQr",
"channels_shareQrTitle",
"channels_shareQrInstructions",
"channels_scanQr",
"channels_scanQrDesc",
"channels_scanQrInstructions",
"channels_invalidQr",
"channels_noFreeSlot",
"channels_qrAddConfirm",
"channels_qrAdded",
"channels_qrExists",
"contacts_repeaterPathTraceVia",
"snrIndicator_noNeighbors"
],
@ -212,6 +324,20 @@
"appSettings_clock24h",
"chat_replyWithRoute",
"channel_heardTooltip",
"path_suggestedRoute",
"path_searchRepeaters",
"channels_shareQr",
"channels_shareQrTitle",
"channels_shareQrInstructions",
"channels_scanQr",
"channels_scanQrDesc",
"channels_scanQrInstructions",
"channels_invalidQr",
"channels_noFreeSlot",
"channels_qrAddConfirm",
"channels_qrAdded",
"channels_qrExists",
"contacts_repeaterPathTraceVia",
"snrIndicator_noNeighbors"
],
@ -236,6 +362,20 @@
"appSettings_clock24h",
"chat_replyWithRoute",
"channel_heardTooltip",
"path_suggestedRoute",
"path_searchRepeaters",
"channels_shareQr",
"channels_shareQrTitle",
"channels_shareQrInstructions",
"channels_scanQr",
"channels_scanQrDesc",
"channels_scanQrInstructions",
"channels_invalidQr",
"channels_noFreeSlot",
"channels_qrAddConfirm",
"channels_qrAdded",
"channels_qrExists",
"contacts_repeaterPathTraceVia",
"snrIndicator_noNeighbors"
],
@ -260,6 +400,20 @@
"appSettings_clock24h",
"chat_replyWithRoute",
"channel_heardTooltip",
"path_suggestedRoute",
"path_searchRepeaters",
"channels_shareQr",
"channels_shareQrTitle",
"channels_shareQrInstructions",
"channels_scanQr",
"channels_scanQrDesc",
"channels_scanQrInstructions",
"channels_invalidQr",
"channels_noFreeSlot",
"channels_qrAddConfirm",
"channels_qrAdded",
"channels_qrExists",
"contacts_repeaterPathTraceVia",
"snrIndicator_noNeighbors"
],
@ -284,6 +438,20 @@
"appSettings_clock24h",
"chat_replyWithRoute",
"channel_heardTooltip",
"path_suggestedRoute",
"path_searchRepeaters",
"channels_shareQr",
"channels_shareQrTitle",
"channels_shareQrInstructions",
"channels_scanQr",
"channels_scanQrDesc",
"channels_scanQrInstructions",
"channels_invalidQr",
"channels_noFreeSlot",
"channels_qrAddConfirm",
"channels_qrAdded",
"channels_qrExists",
"contacts_repeaterPathTraceVia",
"snrIndicator_noNeighbors"
],
@ -308,6 +476,20 @@
"appSettings_clock24h",
"chat_replyWithRoute",
"channel_heardTooltip",
"path_suggestedRoute",
"path_searchRepeaters",
"channels_shareQr",
"channels_shareQrTitle",
"channels_shareQrInstructions",
"channels_scanQr",
"channels_scanQrDesc",
"channels_scanQrInstructions",
"channels_invalidQr",
"channels_noFreeSlot",
"channels_qrAddConfirm",
"channels_qrAdded",
"channels_qrExists",
"contacts_repeaterPathTraceVia",
"snrIndicator_noNeighbors"
],
@ -332,6 +514,20 @@
"appSettings_clock24h",
"chat_replyWithRoute",
"channel_heardTooltip",
"path_suggestedRoute",
"path_searchRepeaters",
"channels_shareQr",
"channels_shareQrTitle",
"channels_shareQrInstructions",
"channels_scanQr",
"channels_scanQrDesc",
"channels_scanQrInstructions",
"channels_invalidQr",
"channels_noFreeSlot",
"channels_qrAddConfirm",
"channels_qrAdded",
"channels_qrExists",
"contacts_repeaterPathTraceVia",
"snrIndicator_noNeighbors"
],
@ -356,6 +552,20 @@
"appSettings_clock24h",
"chat_replyWithRoute",
"channel_heardTooltip",
"path_suggestedRoute",
"path_searchRepeaters",
"channels_shareQr",
"channels_shareQrTitle",
"channels_shareQrInstructions",
"channels_scanQr",
"channels_scanQrDesc",
"channels_scanQrInstructions",
"channels_invalidQr",
"channels_noFreeSlot",
"channels_qrAddConfirm",
"channels_qrAdded",
"channels_qrExists",
"contacts_repeaterPathTraceVia",
"snrIndicator_noNeighbors"
],
@ -380,6 +590,20 @@
"appSettings_clock24h",
"chat_replyWithRoute",
"channel_heardTooltip",
"path_suggestedRoute",
"path_searchRepeaters",
"channels_shareQr",
"channels_shareQrTitle",
"channels_shareQrInstructions",
"channels_scanQr",
"channels_scanQrDesc",
"channels_scanQrInstructions",
"channels_invalidQr",
"channels_noFreeSlot",
"channels_qrAddConfirm",
"channels_qrAdded",
"channels_qrExists",
"contacts_repeaterPathTraceVia",
"snrIndicator_noNeighbors"
],
@ -404,6 +628,20 @@
"appSettings_clock24h",
"chat_replyWithRoute",
"channel_heardTooltip",
"path_suggestedRoute",
"path_searchRepeaters",
"channels_shareQr",
"channels_shareQrTitle",
"channels_shareQrInstructions",
"channels_scanQr",
"channels_scanQrDesc",
"channels_scanQrInstructions",
"channels_invalidQr",
"channels_noFreeSlot",
"channels_qrAddConfirm",
"channels_qrAdded",
"channels_qrExists",
"contacts_repeaterPathTraceVia",
"snrIndicator_noNeighbors"
]
}

Loading…
Cancel
Save

Powered by TurnKey Linux.