feat(route): trace per link, to the node or the last repeater, find where it stops; channel order re-sorts on entry

Core: PathEditor results are links (node k hearing k-1 and back); toNode()
off traces only the repeaters (a companion answers only with repeat on);
probe() traces to each repeater in turn after no reply; failures carry a
reason; an empty list saves "direct". PingEngine takes over a slot left past
its timeout. A kept route: the dropped heard path isn't announced to the app
nor bumps lastmod; an app update keeps the flag only if it leaves the route.
Marker boxes sized for every dot and name (overflow).

ui-lvgl Route: one line per node with out / back dB coloured by quality, tap
a hop to move / remove, tap the node to include it, Keep in the header, Find
where beside a no-reply status. ui-new Path: To node / Find break rows,
result alerts, cursor kept on its action.

Channels: ui-new re-sorts the list on entering it, cursor on the same channel.
Docs: Path and trace, Channel order. Sim hooks: open route, add channel,
open messages.

Checked in the browser sim (L2 + ui-new peer + repeater); not on hardware.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-10-14 11:20:55 +02:00
co-authored by Claude Opus 5.5
parent 24dd025086
commit 120ddfc830
17 changed files with 701 additions and 256 deletions
@@ -839,6 +839,16 @@ class MessagesScreen : public UIScreen {
_num_channels = chanctl::order(p, _history, _channel_indices, p && p->ch_fav_only);
}
// Into the channel list, sorted afresh (a "Newest" order follows the last
// message), the cursor on channel `keep_ch` (a slot) wherever it now is.
void enterChannelList(int keep_ch) {
buildChannelList();
for (int i = 0; i < _num_channels; i++)
if (_channel_indices[i] == keep_ch) { _channel_sel = i; break; }
if (_channel_sel > _num_channels) _channel_sel = _num_channels; // the Add row at most
_phase = CHANNEL_PICK;
}
// Advance one of the contact menu's value rows. dir is +1 for RIGHT and for
// Enter (which PopupMenu reports as VALUE_NEXT on a value row), -1 for LEFT.
void cycleContactCtxValue(int sel, int dir) {
@@ -1953,7 +1963,7 @@ public:
}
if (c == KEY_ENTER) {
if (_mode_sel == 1) {
_phase = CHANNEL_PICK;
enterChannelList(_channel_sel < _num_channels ? _channel_indices[_channel_sel] : -1);
} else {
_room_mode = (_mode_sel == 2);
if (_room_mode) _task->clearRoomUnread();
@@ -2448,7 +2458,7 @@ public:
}
if (c == KEY_CANCEL) {
if (_direct_entry) { _direct_entry = false; _task->gotoHomeScreen(); }
else { _phase = CHANNEL_PICK; }
else { enterChannelList(_sel_channel_idx); }
return true;
}
// Newest (index 0) now renders at the bottom, oldest at the top (see the
+163 -46
View File
@@ -7,10 +7,11 @@
// ── Path (Nodes > a contact > Path) ───────────────────────────────────────────
// The repeaters between this node and a contact as a list: edit it, trace it
// out and back (the SNR of every hop), save it as the contact's route. The
// list, the trace and the save are the UI Core's PathEditor (`core().route`);
// ui-lvgl's Route screen is a view over the same. Picking a repeater to add
// hands the Nodes list over for a moment (NearbyScreen: `_route_pick`).
// out and back (each link's signal both ways), find where a route stops,
// save it as the contact's route. The list, the trace and the save are the UI
// Core's PathEditor (`core().route`); ui-lvgl's Route screen is a view over
// the same. Picking a repeater to add hands the Nodes list over for a moment
// (NearbyScreen: `_route_pick`).
class RouteView {
UITask* _task = nullptr;
bool _active = false;
@@ -18,11 +19,28 @@ class RouteView {
int _sel = 0, _scroll = 0;
PopupMenu _menu; // one hop: up / down / remove
int _menu_hop = 0;
uint8_t _seen_state = PathEditor::IDLE; // a result's alert, once
uint8_t _sel_kind = 0xFF; // the action row under the cursor, kept as rows come and go
enum Row : uint8_t { R_HOP, R_NODE, R_ADD, R_TRACE, R_PROBE, R_TO_NODE, R_SAVE, R_KEEP };
PathEditor& R() { return _task->core().route; }
// Rows: the hops, Add, Trace, Save, Keep.
int rows() { return R().count() + 4; }
int addRow() { return R().count(); }
// The rows: the hops, the node, then the actions (Find break only after a
// trace with no reply).
int rows(Row* out) {
PathEditor& r = R();
int k = 0;
for (int i = 0; i < r.count(); i++) out[k++] = R_HOP;
out[k++] = R_NODE;
out[k++] = R_ADD;
out[k++] = R_TRACE;
if (r.canProbe()) out[k++] = R_PROBE;
if (r.count() > 0) out[k++] = R_TO_NODE; // with no repeaters the trace is to the node
out[k++] = R_SAVE;
out[k++] = R_KEEP;
return k;
}
static const int ROWS_MAX = PathEditor::MAX_HOPS + 7;
void openHopMenu(int i) {
_menu_hop = i;
@@ -32,6 +50,42 @@ class RouteView {
_menu.addItem("Remove");
}
// A link's signal, the weaker way rounded: "6/4" (out/back dB).
static void linkText(char* out, size_t n, int16_t o, int16_t b) {
snprintf(out, n, "%d/%d", (int)lroundf(o / 4.0f), (int)lroundf(b / 4.0f));
}
// What a result means, said once when it comes.
void announce() {
PathEditor& r = R();
if (r.state() == _seen_state) return;
_seen_state = r.state();
char t[48];
switch (r.state()) {
case PathEditor::DONE: {
int16_t w = 0;
if (r.worstX4(w)) snprintf(t, sizeof(t), "Weakest %d dB", (int)lroundf(w / 4.0f));
else return;
break;
}
case PathEditor::TIMEOUT:
snprintf(t, sizeof(t), "%s", r.count() ? "No reply: Find break" : "No reply");
break;
case PathEditor::PROBED:
if (r.reached() < r.count()) snprintf(t, sizeof(t), "Stops at %.14s", r.nodeName(r.reached() + 1));
else if (r.toNode()) snprintf(t, sizeof(t), "Node not repeating?");
else snprintf(t, sizeof(t), "All answered");
break;
case PathEditor::FAILED:
snprintf(t, sizeof(t), "%s", r.failure() == PathEditor::FAIL_NO_KEY ? "Repeater key unknown"
: r.failure() == PathEditor::FAIL_BUSY ? "Busy, try again"
: r.failure() == PathEditor::FAIL_EMPTY ? "Nothing to trace" : "Send failed");
break;
default: return;
}
_task->showAlert(t, 1500);
}
public:
bool active() const { return _active; }
bool takePick() { bool p = _pick; _pick = false; return p; }
@@ -43,9 +97,16 @@ public:
_pick = false;
_sel = _scroll = 0;
_menu.active = false;
_seen_state = PathEditor::IDLE;
_sel_kind = 0xFF;
return true;
}
void show() { _active = true; } // back from picking a repeater: the list as it was
// Back from picking a repeater: the cursor on Add, for the next one.
void show() {
_active = true;
_sel = R().count() + 1;
_sel_kind = R_ADD;
}
void close() {
if (_task) R().cancelTrace();
_active = false;
@@ -55,31 +116,48 @@ public:
int render(DisplayDriver& display) {
PathEditor& r = R();
r.poll();
announce();
const int n = r.count();
char title[32];
switch (r.state()) {
case PathEditor::RUNNING: snprintf(title, sizeof(title), "Tracing..."); break;
case PathEditor::DONE: snprintf(title, sizeof(title), "Path %lums", (unsigned long)r.rttMs()); break;
case PathEditor::TIMEOUT: snprintf(title, sizeof(title), "Path: no reply"); break;
case PathEditor::FAILED: snprintf(title, sizeof(title), "Path: no key"); break;
default: snprintf(title, sizeof(title), "Path: %s", r.targetName());
case PathEditor::RUNNING:
if (r.probing()) snprintf(title, sizeof(title), "Probe %d/%d", r.links() + 1, n);
else snprintf(title, sizeof(title), "Tracing...");
break;
case PathEditor::DONE: snprintf(title, sizeof(title), "OK %lums", (unsigned long)r.rttMs()); break;
case PathEditor::TIMEOUT: snprintf(title, sizeof(title), "No reply"); break;
case PathEditor::PROBED: snprintf(title, sizeof(title), "Reached %d/%d", r.reached(), n); break;
case PathEditor::FAILED: snprintf(title, sizeof(title), "Can't trace"); break;
default: snprintf(title, sizeof(title), "%s%s", r.edited() ? "*" : "", r.targetName());
}
drawScreenHeader(display, title, -1, 0, true, false);
const bool done = r.state() == PathEditor::DONE;
drawList(display, rows(), _sel, _scroll, [&](int idx, int y, bool sel, int reserve) {
Row kind[ROWS_MAX];
const int nrows = rows(kind);
followSelection(kind, nrows);
const int stop = r.state() == PathEditor::PROBED ? r.reached() + 1 : 0; // the node that didn't answer
drawList(display, nrows, _sel, _scroll, [&](int idx, int y, bool sel, int reserve) {
drawRowSelection(display, y, sel, reserve);
char left[40], right[16] = "";
if (idx < n) {
snprintf(left, sizeof(left), "%d %s", idx + 1, r.hop(idx).name);
int16_t o, b;
if (done && r.hopSnrX4(idx, o) && r.hopSnrX4(2 * n - idx, b)) snprintf(right, sizeof(right), "%d/%d", (int)lroundf(o / 4.0f), (int)lroundf(b / 4.0f));
else if (r.hop(idx).known < 4) snprintf(right, sizeof(right), "?");
} else {
const int a = idx - n; // 0 Add, 1 Trace, 2 Save
if (a == 0) snprintf(left, sizeof(left), "+ Add repeater");
else if (a == 1) snprintf(left, sizeof(left), "%s", r.state() == PathEditor::RUNNING ? "Stop trace" : "Trace");
else if (a == 2) snprintf(left, sizeof(left), "Save path");
else snprintf(left, sizeof(left), "Keep path: %s", r.pinned() ? "ON" : "OFF");
int16_t o, b;
switch (kind[idx]) {
case R_HOP:
snprintf(left, sizeof(left), "%d %s", idx + 1, r.hop(idx).name);
if (r.linkSnrX4(idx + 1, o, b)) linkText(right, sizeof(right), o, b);
else if (idx + 1 == stop) snprintf(right, sizeof(right), "x");
else if (r.hop(idx).known < 4) snprintf(right, sizeof(right), "?");
break;
case R_NODE:
snprintf(left, sizeof(left), "> %s", r.targetName());
if (!r.toNode()) snprintf(right, sizeof(right), "-");
else if (r.linkSnrX4(n + 1, o, b)) linkText(right, sizeof(right), o, b);
else if (stop == n + 1) snprintf(right, sizeof(right), "x");
break;
case R_ADD: snprintf(left, sizeof(left), "+ Add repeater"); break;
case R_TRACE: snprintf(left, sizeof(left), "%s", r.state() == PathEditor::RUNNING ? "Stop" : "Trace"); break;
case R_PROBE: snprintf(left, sizeof(left), "Find break"); break;
case R_TO_NODE: snprintf(left, sizeof(left), "To node"); snprintf(right, sizeof(right), "%s", r.toNode() ? "ON" : "OFF"); break;
case R_SAVE: snprintf(left, sizeof(left), "%s", n ? "Save path" : "Save direct"); break;
case R_KEEP: snprintf(left, sizeof(left), "Keep path"); snprintf(right, sizeof(right), "%s", r.pinned() ? "ON" : "OFF"); break;
}
const int rw = right[0] ? display.getTextWidth(right) + display.getCharWidth() : 0;
display.drawTextEllipsized(2, y, display.width() - reserve - 4 - rw, left, sel);
@@ -99,44 +177,73 @@ public:
auto res = _menu.handleInput(c);
if (res == PopupMenu::SELECTED) {
int i = _menu.selectedIndex();
if (i == 0) { r.move(_menu_hop, -1); if (_sel > 0) _sel--; }
else if (i == 1) { r.move(_menu_hop, +1); if (_sel < r.count() - 1) _sel++; }
if (i == 0) { if (r.move(_menu_hop, -1)) _sel = _menu_hop - 1; }
else if (i == 1) { if (r.move(_menu_hop, +1)) _sel = _menu_hop + 1; }
else r.remove(_menu_hop);
if (_sel >= rows()) _sel = rows() - 1;
_menu.active = false;
}
return true;
}
Row kind[ROWS_MAX];
const int nrows = rows(kind);
if (c == KEY_CANCEL) { close(); return true; }
if (c == KEY_UP) { _sel = _sel > 0 ? _sel - 1 : rows() - 1; return true; }
if (c == KEY_DOWN) { _sel = _sel < rows() - 1 ? _sel + 1 : 0; return true; }
const int n = r.count();
if (_sel < n && (keyIsPrev(c) || keyIsNext(c))) { // reorder in place
int dir = keyIsNext(c) ? 1 : -1;
int to = _sel + dir;
if (to >= 0 && to < n) { r.move(_sel, dir); _sel = to; }
if (c == KEY_UP) { _sel = _sel > 0 ? _sel - 1 : nrows - 1; _sel_kind = kind[_sel]; return true; }
if (c == KEY_DOWN) { _sel = _sel < nrows - 1 ? _sel + 1 : 0; _sel_kind = kind[_sel]; return true; }
if (_sel >= nrows) _sel = nrows - 1;
const Row k = kind[_sel];
if (keyIsPrev(c) || keyIsNext(c)) {
const int dir = keyIsNext(c) ? 1 : -1;
if (k == R_HOP) { if (r.move(_sel, dir)) _sel += dir; } // reorder in place
else if (k == R_TO_NODE) r.setToNode(!r.toNode());
else if (k == R_KEEP) toggleKeep();
return true;
}
if (c != KEY_ENTER && c != KEY_CONTEXT_MENU) return true;
if (_sel < n) { openHopMenu(_sel); return true; }
switch (_sel - n) {
case 0:
switch (k) {
case R_HOP: openHopMenu(_sel); break;
case R_NODE: break;
case R_ADD:
if (r.full()) _task->showAlert("Path is full", 1000);
else { r.cancelTrace(); _pick = true; _active = false; }
break;
case 1:
case R_TRACE:
if (r.state() == PathEditor::RUNNING) r.cancelTrace();
else if (!r.trace() && r.state() != PathEditor::FAILED) _task->showAlert("Busy", 800);
else { _seen_state = PathEditor::RUNNING; r.trace(); }
break;
case 2:
if (r.count() == 0) _task->showAlert("Add a repeater", 1000);
else _task->showAlert(r.save() ? "Path saved" : "Can't save", 1000);
case R_PROBE:
_seen_state = PathEditor::RUNNING;
r.probe();
break;
default: // Keep: not replaced by a path heard, nor dropped after a failed send
r.setPinned(!r.pinned());
case R_TO_NODE: r.setToNode(!r.toNode()); break;
case R_SAVE:
if (!r.save()) _task->showAlert("Can't save", 1000);
else _task->showAlert(r.count() ? "Path saved" : "Saved: direct", 1000);
break;
case R_KEEP: toggleKeep(); break;
}
return true;
}
private:
// The cursor stays on the action it was on when a row before it comes or
// goes (Find break after a trace with no reply); from Find break, once
// it's gone, to Trace.
void followSelection(const Row* kind, int nrows) {
if (_sel >= nrows) _sel = nrows - 1;
if (_sel_kind != 0xFF && _sel_kind > R_NODE && kind[_sel] != _sel_kind) {
int at = -1;
for (int i = 0; i < nrows && at < 0; i++) if (kind[i] == _sel_kind) at = i;
for (int i = 0; i < nrows && at < 0; i++) if (_sel_kind == R_PROBE && kind[i] == R_TRACE) at = i;
if (at >= 0) _sel = at;
}
_sel_kind = kind[_sel];
}
void toggleKeep() {
PathEditor& r = R();
bool now = r.setPinned(!r.pinned());
if (r.pinned() && !now) _task->showAlert("Kept once saved", 1000);
}
};
// ── Nearby Nodes ──────────────────────────────────────────────────────────────
@@ -1113,6 +1220,16 @@ public:
return (mq_delay > 0 && mq_delay < ret) ? mq_delay : ret;
}
// Path / trace for a contact, from outside the list (the sim harness).
bool openRoute(const uint8_t* pub_key) {
int i = findContact(pub_key);
if (i < 0 || !_route.open(_task, pub_key)) return false;
_sel = i;
_detail = true;
memcpy(_route_key, pub_key, PUB_KEY_SIZE);
return true;
}
bool handleInput(char c) override {
// ── path view: modal; Add hands the Nodes list over for one repeater ──────
if (_route.active()) {
@@ -256,6 +256,10 @@ void UITask::openAdminFor(const ContactInfo& ci, bool from_picker) {
setCurrScreen(admin_screen); // runs AdminScreen::onShow()'s reset first
((AdminScreen*)admin_screen)->startFor(ci, from_picker);
}
void UITask::openRouteFor(const uint8_t* pub_key) {
setCurrScreen(nearby_screen); // its onShow() reset first
((NearbyScreen*)nearby_screen)->openRoute(pub_key);
}
void UITask::gotoDashboardConfig() { setCurrScreen(dashboard_config); }
void UITask::gotoTrailScreen() { setCurrScreen(trail_screen); }
void UITask::gotoCompassScreen() { setCurrScreen(compass_screen); }
+2
View File
@@ -329,6 +329,8 @@ public:
void gotoBotScreen();
void pickAdminTarget(); // Admin is remote-only: open Nodes to pick a repeater/room
void openAdminFor(const ContactInfo& ci, bool from_picker); // canonical Admin entry for a specific target (Nodes' Hold-Enter menu or the picker above)
void openRouteFor(const uint8_t* pub_key); // Nodes > a contact > Path / trace (sim harness)
void openMessagesList() { gotoMessagesScreen(); } // sim harness
void gotoNearbyScreen();
void gotoDashboardConfig();
void gotoAutoAdvertScreen();