feat(route): path editor with out-and-back trace and a kept route; map markers as one drawn layer

Core: PathEditor (UiCore::route) loads, edits, traces and saves the route to
a contact; MyMesh::sendTrace over an explicit route with per-hop SNRs,
setContactPath / setContactPinned. A route can be kept
(CONTACT_FLAG_ROUTE_PINNED, ContactInfo::flags bit 4): a path heard from the
node doesn't replace it and the last DM resend doesn't drop it.

ui-lvgl: Route screen (node detail), repeater picker by distance, picking
hops by tapping the nodes map (numbered badges, line, Undo / Done). The
map's markers are one drawn layer with hit-testing and name decluttering
instead of three objects per node.

ui-new: Path / trace view in the Nodes action menu; Add reuses the list
filtered to repeaters.

First cut: built for L2 LVGL, L1 OLED and L1 e-ink, tried on L2 only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-10-13 10:18:05 +02:00
co-authored by Claude Sonnet 5.5
parent c180814960
commit 140ea7c008
17 changed files with 1306 additions and 76 deletions
@@ -503,7 +503,8 @@ public:
// Last attempt over a direct path that went unanswered every time: drop
// the path and flood it, as the phone app does -- the route may be gone,
// and the flood finds a new one (the ACK's return path re-learns it).
if (e.resends_left == 1 && c.out_path_len != OUT_PATH_UNKNOWN &&
// A route kept by hand isn't dropped: it was chosen over the flood.
if (e.resends_left == 1 && c.out_path_len != OUT_PATH_UNKNOWN && !(c.flags & CONTACT_FLAG_ROUTE_PINNED) &&
the_mesh.resetContactPath(c.id.pub_key)) {
c.out_path_len = OUT_PATH_UNKNOWN;
}
@@ -0,0 +1,274 @@
#pragma once
// Path editor: the route to one node as a list of repeaters, traced and saved.
// Shared by both frontends (ui-new's Path screen, ui-lvgl's path editor and its
// "pick on the map" mode); the view only draws `hop()` rows and calls the
// actions below.
//
// begin(contact) -- loads the contact's stored route (out_path), if any
// addRepeater(key) -- appends a repeater (a contact of type repeater)
// remove / move -- edit the list
// trace() -- a TRACE out and back over the list (A B dest B A):
// a reply needs the last retransmission to be heard,
// so a far node is traced on its way there and back
// poll() -- advance a running trace (call every UI pass)
// save() -- the list becomes the contact's out_path
//
// A hop keeps up to 4 bytes of the repeater's key, so the route can be traced
// with 1, 2 or 4 byte hashes and stored with 1, 2 or 3; a hop loaded from a
// stored path whose repeater isn't a contact keeps only the bytes it had.
//
// Header-only Core, single TU (see UiCore.h).
class PathEditor {
public:
static const int MAX_HOPS = 7; // repeaters; out and back is 2n+1 <= TRACE_SNR_MAX entries
static const int NAME_LEN = 24;
enum State : uint8_t { IDLE, RUNNING, DONE, TIMEOUT, FAILED };
struct Hop {
uint8_t key[4];
uint8_t known; // bytes of key that are real (1..4)
char name[NAME_LEN];
};
void begin(NodePrefs* prefs, PingEngine* ping) { _prefs = prefs; _ping = ping; }
// Opens the editor for a node: its key and name, and its stored route.
bool open(const uint8_t* pub_key) {
ContactInfo* c = the_mesh.lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
if (!c) return false;
memcpy(_target_key, c->id.pub_key, PUB_KEY_SIZE);
snprintf(_target_name, sizeof(_target_name), "%s", c->name);
_n = 0;
_state = IDLE;
_stored = c->out_path_len != OUT_PATH_UNKNOWN;
_pinned = _stored && (c->flags & CONTACT_FLAG_ROUTE_PINNED) != 0;
if (_stored) {
uint8_t size = (c->out_path_len >> 6) + 1;
uint8_t cnt = c->out_path_len & 63;
for (uint8_t i = 0; i < cnt && _n < MAX_HOPS; i++) loadHash(&c->out_path[i * size], size);
}
return true;
}
const uint8_t* targetKey() const { return _target_key; }
const char* targetName() const { return _target_name; }
bool hasStoredPath() const { return _stored; }
// A kept route isn't replaced by a path heard from the node or dropped after a
// failed send (CONTACT_FLAG_ROUTE_PINNED); written with save(), or at once
// when the route is already stored.
bool pinned() const { return _pinned; }
void setPinned(bool on) {
_pinned = on;
if (_stored) the_mesh.setContactPinned(_target_key, on);
}
// ── the list ────────────────────────────────────────────────────────────────
int count() const { return _n; }
const Hop& hop(int i) const { return _hops[i]; }
bool full() const { return _n >= MAX_HOPS; }
// Appends a repeater by its key; false if it isn't a repeater, the list is
// full or it is the one just before (a hop twice in a row can't be heard).
bool addRepeater(const uint8_t* pub_key) {
if (_n >= MAX_HOPS) return false;
ContactInfo* c = the_mesh.lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
if (!c || c->type != ADV_TYPE_REPEATER) return false;
if (_n > 0 && _hops[_n - 1].known >= 1 && memcmp(_hops[_n - 1].key, c->id.pub_key, _hops[_n - 1].known) == 0) return false;
Hop& h = _hops[_n++];
memcpy(h.key, c->id.pub_key, 4);
h.known = 4;
snprintf(h.name, sizeof(h.name), "%s", c->name);
invalidate();
return true;
}
void remove(int i) {
if (i < 0 || i >= _n) return;
for (int k = i; k + 1 < _n; k++) _hops[k] = _hops[k + 1];
_n--;
invalidate();
}
void removeLast() { remove(_n - 1); }
// Moves hop i one place (dir -1 up, +1 down).
void move(int i, int dir) {
int j = i + dir;
if (i < 0 || i >= _n || j < 0 || j >= _n) return;
Hop t = _hops[i];
_hops[i] = _hops[j];
_hops[j] = t;
invalidate();
}
void clear() {
_n = 0;
invalidate();
}
// Is this repeater (by key) already in the list? (map picking marks it)
int indexOf(const uint8_t* pub_key) const {
for (int i = 0; i < _n; i++)
if (memcmp(_hops[i].key, pub_key, _hops[i].known) == 0) return i;
return -1;
}
// ── trace ───────────────────────────────────────────────────────────────────
State state() const { return _state; }
uint32_t timeoutMs() const { return _ping ? _ping->timeoutMs() : 3000; }
// The hash width a trace uses: the node's path hash mode, narrowed to what
// every hop has bytes for. 0 if a hop has nothing usable.
uint8_t traceWidth() const {
uint8_t want = _prefs ? (_prefs->path_hash_mode >= 2 ? 4 : _prefs->path_hash_mode + 1) : 1;
uint8_t known = 4;
for (int i = 0; i < _n; i++) if (_hops[i].known < known) known = _hops[i].known;
while (want > known) want = want == 4 ? 2 : 1;
return known >= 1 ? want : 0;
}
// Path entries of a round trip: the repeaters, the node, the repeaters back.
int traceEntries() const { return 2 * _n + 1; }
bool trace() {
if (!_ping || _state == RUNNING) return false;
uint8_t w = traceWidth();
if (w == 0) { _state = FAILED; return false; }
uint8_t path[TRACE_SNR_MAX * 4];
int e = 0;
auto put = [&](const uint8_t* key) { memcpy(&path[e * w], key, w); e++; };
for (int i = 0; i < _n; i++) put(_hops[i].key);
put(_target_key);
for (int i = _n - 1; i >= 0; i--) put(_hops[i].key);
if (_ping->startTrace(path, (uint8_t)e, w) != PingEngine::STARTED) { _state = FAILED; return false; }
_state = RUNNING;
_started_ms = millis();
_entries = e;
return true;
}
// Call every UI pass while a trace may be running.
void poll() {
if (_state != RUNNING || !_ping) return;
if (!_ping->isActive()) { // the reply came (the engine released its slot)
_state = _ping->hopCount() > 0 ? DONE : TIMEOUT;
} else if (millis() - _started_ms > _ping->timeoutMs()) {
_ping->clear();
_state = TIMEOUT;
}
}
void cancelTrace() {
if (_state == RUNNING && _ping) _ping->clear();
if (_state == RUNNING) _state = IDLE;
}
// Result of the last trace: SNR x4 at path entry i (0..traceEntries()-1),
// false where it wasn't heard. Entry n is the node, entries after it the
// way back; snrBackX4() is us hearing the last retransmission.
bool hopSnrX4(int entry, int16_t& snr_x4) const {
if (_state != DONE || !_ping || entry < 0 || entry >= _ping->hopCount()) return false;
snr_x4 = _ping->hopSnrX4(entry);
return true;
}
bool snrBackX4(int16_t& snr_x4) const {
if (_state != DONE || !_ping) return false;
int16_t o, b; uint32_t r;
_ping->getResult(o, b, r);
snr_x4 = b;
return true;
}
uint32_t rttMs() const {
int16_t o, b; uint32_t r = 0;
if (_ping) _ping->getResult(o, b, r);
return r;
}
// The weakest SNR of the way there (repeaters and the node) in x4, for a
// one-line verdict; false without a result.
bool worstOutX4(int16_t& v) const {
bool any = false;
for (int i = 0; i <= _n; i++) {
int16_t s;
if (!hopSnrX4(i, s)) continue;
if (!any || s < v) v = s;
any = true;
}
return any;
}
// ── save ────────────────────────────────────────────────────────────────────
// The list as the contact's stored route. False with an empty list (that is
// Reset path / flood) or when the hops share no usable width.
bool canSave() const { return _n > 0 && saveWidth() > 0; }
bool save() {
uint8_t w = saveWidth();
if (_n == 0 || w == 0) return false;
uint8_t path[MAX_HOPS * 3];
for (int i = 0; i < _n; i++) memcpy(&path[i * w], _hops[i].key, w);
if (!the_mesh.setContactPath(_target_key, path, w, (uint8_t)_n, _pinned)) return false;
_stored = true;
return true;
}
// Text of path entry i of a round trip for a result row: "A", "A >", etc. is
// the view's job; this gives the name of the entry (a repeater, the node,
// a repeater on the way back).
const char* entryName(int i) const {
if (i < _n) return _hops[i].name;
if (i == _n) return _target_name;
int back = 2 * _n - i; // n+1 -> n-1 ... 2n -> 0
return back >= 0 && back < _n ? _hops[back].name : "";
}
private:
uint8_t saveWidth() const { // 1..3 bytes of a stored path, the hash mode narrowed to what every hop has
uint8_t want = _prefs ? _prefs->path_hash_mode + 1 : 1;
if (want > 3) want = 3;
uint8_t known = 4;
for (int i = 0; i < _n; i++) if (_hops[i].known < known) known = _hops[i].known;
if (known > 3) known = 3;
return want < known ? want : known;
}
void invalidate() {
if (_state == RUNNING && _ping) _ping->clear();
_state = IDLE;
}
// A hash of a stored path: the contact it belongs to by key prefix, else the bytes.
void loadHash(const uint8_t* hash, uint8_t size) {
Hop& h = _hops[_n++];
memset(h.key, 0, sizeof(h.key));
h.known = size > 4 ? 4 : size;
memcpy(h.key, hash, h.known);
h.name[0] = '\0';
for (int idx = MAX_ANON_CONTACTS; ; idx++) {
ContactInfo c;
if (!the_mesh.getContactByIdx(idx, c)) break;
if (c.type == ADV_TYPE_REPEATER && c.id.isHashMatch(hash, size)) {
memcpy(h.key, c.id.pub_key, 4);
h.known = 4;
snprintf(h.name, sizeof(h.name), "%s", c.name);
return;
}
}
char hex[9] = {0};
for (uint8_t k = 0; k < h.known; k++) snprintf(hex + k * 2, 3, "%02X", h.key[k]);
snprintf(h.name, sizeof(h.name), "?%s", hex);
}
NodePrefs* _prefs = nullptr;
PingEngine* _ping = nullptr;
uint8_t _target_key[PUB_KEY_SIZE];
char _target_name[32];
Hop _hops[MAX_HOPS];
int _n = 0;
State _state = IDLE;
bool _stored = false;
bool _pinned = false;
int _entries = 0;
uint32_t _started_ms = 0;
};
@@ -21,6 +21,7 @@ public:
_snr_out_x4 = 0;
_snr_back_x4 = 0;
_rtt_ms = 0;
_n_hops = 0;
// Always install the callback before sending so the response cannot race it.
the_mesh.setPingCallback(onPingResult, NULL);
@@ -36,6 +37,29 @@ public:
return STARTED;
}
// A trace over an explicit route (ui-core/PathEditor.h): `hops` hashes of
// `width` (1, 2 or 4) bytes. The reply carries the SNR at every hop, kept
// after the slot is released (hopCount() / hopSnrX4()).
StartResult startTrace(const uint8_t* path, uint8_t hops, uint8_t width) {
if (_active || !path) return BUSY;
_active = true;
_tag = 0;
_snr_out_x4 = _snr_back_x4 = 0;
_rtt_ms = 0;
_n_hops = 0;
the_mesh.setPingCallback(onPingResult, NULL);
_timeout_ms = 3000;
_tag = the_mesh.sendTrace(path, hops, width, &_timeout_ms);
if (_tag == 0) {
clear();
return SEND_FAILED;
}
return STARTED;
}
uint8_t hopCount() const { return _n_hops; }
int16_t hopSnrX4(int i) const { return i >= 0 && i < _n_hops ? _hop_snr_x4[i] : 0; }
// Release the in-flight slot (result values are kept).
void clear() {
if (_tag != 0) the_mesh.clearPingResult(_tag);
@@ -57,6 +81,10 @@ private:
_snr_out_x4 = snr_out_x4;
_snr_back_x4 = snr_back_x4;
_rtt_ms = rtt_ms;
// Called before the mesh frees the slot: the per-hop SNRs are still there.
const MyMesh::PingResult* r = the_mesh.getPingResult(tag);
_n_hops = r ? r->n_snr : 0;
for (int i = 0; i < _n_hops; i++) _hop_snr_x4[i] = (int16_t)r->snrs_x4[i];
// Release the in-flight slot immediately; the view keeps the result values.
clear();
}
@@ -75,6 +103,8 @@ private:
int16_t _snr_back_x4 = 0;
uint32_t _rtt_ms = 0;
uint32_t _timeout_ms = 3000;
uint8_t _n_hops = 0;
int16_t _hop_snr_x4[TRACE_SNR_MAX];
};
// Header-only Core, single TU (see UiCore.h) -- the one definition lives here.
+4 -1
View File
@@ -19,6 +19,7 @@
#include "UiCoreHost.h"
#include "ClockEngine.h"
#include "PingEngine.h"
#include "PathEditor.h"
#include "CourseEngine.h"
#include "LiveShareEngine.h"
#include "LocatorEngine.h"
@@ -37,6 +38,7 @@ public:
_sensors = sensors;
clock.begin(prefs, &events);
ping.begin(prefs);
route.begin(prefs, &ping);
course.begin(sensors);
live_share.begin(prefs, &course, &events);
locator.begin(prefs, &course, &live_share, &events);
@@ -63,7 +65,8 @@ public:
// ── Engines ───────────────────────────────────────────────────────────────
ClockEngine clock; // alarm / countdown / ring
PingEngine ping; // single in-flight ping + last result
PingEngine ping; // single in-flight ping / trace + last result
PathEditor route; // route to a node: edit, trace out and back, save
CourseEngine course; // GPS position + course-over-ground ring
LiveShareEngine live_share; // [LOC] auto-share session + peers' shared positions
LocatorEngine locator; // active target, geofence crossings, proximity beeper