feat(#186): topology service + readout + RX ingestion + route-builder UX
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,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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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); // origin→R2→T→R1→us
|
||||
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); // us→R1→T (2 hops)
|
||||
s.observePath(path([t, r3, r2]), width: 2); // us→R2→R3→T (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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in new issue