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
+19 -4
View File
@@ -449,6 +449,7 @@ int MyMesh::getRecentlyHeard(AdvertPath dest[], int max_num) {
}
void MyMesh::onContactPathUpdated(const ContactInfo &contact) {
if (_path_kept) return; // a kept route: the path heard isn't taken (onContactPathRecv)
out_frame[0] = PUSH_CODE_PATH_UPDATED;
memcpy(&out_frame[1], contact.id.pub_key, PUB_KEY_SIZE);
_serial->writeFrame(out_frame, 1 + PUB_KEY_SIZE); // NOTE: app may not be connected
@@ -1055,19 +1056,21 @@ bool MyMesh::onContactPathRecv(ContactInfo& contact, uint8_t* in_path, uint8_t i
_path_same = contact.out_path_len == out_path_len && path_bytes <= MAX_PATH_SIZE
&& memcmp(contact.out_path, out_path, path_bytes) == 0;
// A route kept by hand stays: the base class takes the path and handles the
// ACK / response riding on it, then the held route goes back (nothing is
// written for it: _path_same).
// ACK / response riding on it, then the held route goes back -- with its
// lastmod, and neither written nor announced to the app (_path_kept).
const bool pinned = (contact.flags & CONTACT_FLAG_ROUTE_PINNED) != 0 && contact.out_path_len != OUT_PATH_UNKNOWN;
uint8_t held_len = contact.out_path_len;
uint32_t held_mod = contact.lastmod;
uint8_t held[MAX_PATH_SIZE];
if (pinned) {
memcpy(held, contact.out_path, sizeof(held));
_path_same = true;
_path_same = _path_kept = true;
}
bool r = BaseChatMesh::onContactPathRecv(contact, in_path, in_path_len, out_path, out_path_len, extra_type, extra, extra_len);
_path_same = false;
_path_same = _path_kept = false;
if (pinned) {
contact.out_path_len = held_len;
contact.lastmod = held_mod;
memcpy(contact.out_path, held, sizeof(held));
}
// A flood DM's ACK comes back folded into the return path, which the base
@@ -1849,8 +1852,20 @@ void MyMesh::handleCmdFrame(size_t len) {
uint32_t last_mod = getRTCClock()->getCurrentTime(); // fallback value if not present in cmd_frame
if (recipient) {
uint8_t saved_type = recipient->type; // type is authoritative from advert, not app
// A route kept on the device (CONTACT_FLAG_ROUTE_PINNED) is the device's
// own: the app's copy of the flags may predate it. Kept while the app
// leaves the route as it is; a route the app sets ends it.
const bool kept = (recipient->flags & CONTACT_FLAG_ROUTE_PINNED) != 0;
uint8_t held_len = recipient->out_path_len;
uint8_t held[MAX_PATH_SIZE];
memcpy(held, recipient->out_path, sizeof(held));
updateContactFromFrame(*recipient, last_mod, cmd_frame, len);
if (saved_type != ADV_TYPE_NONE) recipient->type = saved_type;
int held_bytes = held_len == OUT_PATH_UNKNOWN ? 0 : (held_len & 63) * ((held_len >> 6) + 1);
const bool same_route = recipient->out_path_len == held_len && held_bytes <= MAX_PATH_SIZE &&
memcmp(recipient->out_path, held, held_bytes) == 0;
if (kept && same_route) recipient->flags |= CONTACT_FLAG_ROUTE_PINNED;
else recipient->flags &= ~CONTACT_FLAG_ROUTE_PINNED;
recipient->lastmod = last_mod;
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
writeOKFrame();
+2 -1
View File
@@ -263,7 +263,7 @@ public:
// only reaches us if the last hop's retransmission is heard, so a route to
// a far node goes out and back (A B dest B A). Returns the tag, 0 on failure.
uint32_t sendTrace(const uint8_t* path, uint8_t hops, uint8_t hash_width, uint32_t* est_timeout = nullptr);
// Writes a route into a stored contact (hash_size 1..3 bytes, `hops` of them).
// Writes a route into a stored contact (hash_size 1..3 bytes, `hops` of them; 0: direct).
// `pin` keeps it (CONTACT_FLAG_ROUTE_PINNED): not replaced by a heard path or a failed send.
bool setContactPinned(const uint8_t* pub_key, bool pin); // keep / release the stored route
bool setContactPath(const uint8_t* pub_key, const uint8_t* path, uint8_t hash_size, uint8_t hops, bool pin = false);
@@ -693,6 +693,7 @@ private:
unsigned long _last_input_ms = 0;
unsigned long _last_rtc_save_ms = 0;
bool _path_same = false; // onContactPathRecv(): the path heard is the one held
bool _path_kept = false; // onContactPathRecv(): a kept route, the path heard is dropped
unsigned long _bot_last_ch_reply_ms;
unsigned long _bot_last_room_reply_ms;
unsigned long _next_auto_advert_ms;
+2 -2
View File
@@ -86,11 +86,11 @@ bool MyMesh::setContactPinned(const uint8_t* pub_key, bool pin) {
}
// A route typed on the device (ui-core/PathEditor.h): `hops` hashes of
// `hash_size` bytes into the contact's out_path, the packed length byte as
// `hash_size` bytes into the contact's out_path (0 hops: direct), the packed length byte as
// the mesh stores a learned path (top 2 bits: size - 1). lastmod bumped, as
// for resetContactPath(), so the app's copy follows.
bool MyMesh::setContactPath(const uint8_t* pub_key, const uint8_t* path, uint8_t hash_size, uint8_t hops, bool pin) {
if (hash_size < 1 || hash_size > 3 || hops == 0 || hops * hash_size > MAX_PATH_SIZE) return false;
if (hash_size < 1 || hash_size > 3 || hops > 63 || hops * hash_size > MAX_PATH_SIZE) return false;
ContactInfo* c = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
if (!c) return false;
memcpy(c->out_path, path, hops * hash_size);
+43
View File
@@ -682,6 +682,49 @@ extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_open_admin_with_first_repeater() {
}
#endif
#ifdef DISPLAY_CLASS
// Opens Path / trace (ui-new) or Route (ui-lvgl) for the first contact of
// `type` (ADV_TYPE_*), as the node menu does. 1 if found, 0 if not yet.
extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_open_route(int type) {
if (!g_sim_ready) return 0;
int n = the_mesh.getNumContacts();
for (int i = 0; i < n; i++) {
ContactInfo ci;
if (the_mesh.getContactByIdx(MAX_ANON_CONTACTS + i, ci) && ci.type == type) {
ui_task.openRouteFor(ci.id.pub_key);
return 1;
}
}
return 0;
}
#endif
// A hashtag channel into the first free slot ("#name": the name is the
// passphrase, as chanctl::addHashtag does). The slot, or -1.
extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_add_channel(const char* name) {
if (!g_sim_ready || !name || !name[0]) return -1;
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
ChannelDetails ch;
if (the_mesh.getChannel(i, ch) && ch.name[0]) continue;
memset(&ch, 0, sizeof(ch));
strncpy(ch.name, name, sizeof(ch.name) - 1);
uint8_t digest[32];
mesh::Utils::sha256(digest, sizeof(digest), (const uint8_t*)name, (int)strlen(name));
memcpy(ch.channel.secret, digest, 16);
return the_mesh.setChannelLocal((uint8_t)i, ch) ? i : -1;
}
return -1;
}
#ifdef DISPLAY_CLASS
// The messages list (ui-lvgl: Messages; ui-new: the Messages screen).
extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_open_messages() {
if (!g_sim_ready) return 0;
ui_task.openMessagesList();
return 1;
}
#endif
// Submits a login against the first known repeater/room contact via
// MyMesh::sendRoomLogin() -- the exact same function AdminScreen's own
// submit button calls (examples/companion_radio/ui-new/AdminScreen.h) --
+188 -80
View File
@@ -1,18 +1,27 @@
#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.
// Shared by both frontends (ui-new's Path screen, ui-lvgl's Route screen and
// its "pick on the map" mode); the view only draws rows and calls the actions
// below.
//
// begin(contact) -- loads the contact's stored route (out_path), if any
// open(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
// trace() -- a TRACE out and back over the list
// probe() -- after a trace with no reply: traces to the 1st, 2nd,
// ... repeater, to find the link where it stops
// poll() -- advance a running trace (call every UI pass)
// save() -- the list becomes the contact's out_path
//
// A TRACE is answered only by the node that hears the last retransmission, so
// a route is traced there and back (A B dest B A): every node of it has to
// retransmit. Repeaters do; the node at the end only when it is a repeater
// too, or a companion with repeat on -- toNode() off traces just the
// repeaters (A B A), which also tells whether the way to the node is good.
//
// Results are per link, between node k-1 and node k of the chain (node 0 is
// this device): how well k heard k-1 (out) and k-1 heard k (back).
//
// 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.
@@ -22,9 +31,13 @@
class PathEditor {
public:
static const int MAX_HOPS = 7; // repeaters; out and back is 2n+1 <= TRACE_SNR_MAX entries
static const int MAX_NODES = MAX_HOPS + 1;
static const int NAME_LEN = 24;
enum State : uint8_t { IDLE, RUNNING, DONE, TIMEOUT, FAILED };
// RUNNING covers a probe's every step (probing()); PROBED: the probe is
// over, reached() nodes answered.
enum State : uint8_t { IDLE, RUNNING, DONE, TIMEOUT, FAILED, PROBED };
enum Fail : uint8_t { FAIL_NONE, FAIL_NO_KEY, FAIL_BUSY, FAIL_SEND, FAIL_EMPTY };
struct Hop {
uint8_t key[4];
@@ -38,10 +51,15 @@ public:
bool open(const uint8_t* pub_key) {
ContactInfo* c = the_mesh.lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
if (!c) return false;
cancelTrace();
memcpy(_target_key, c->id.pub_key, PUB_KEY_SIZE);
snprintf(_target_name, sizeof(_target_name), "%s", c->name);
_target_repeats = c->type == ADV_TYPE_REPEATER;
_to_node = _target_repeats;
_n = 0;
_state = IDLE;
_fail = FAIL_NONE;
_links = 0;
_stored = c->out_path_len != OUT_PATH_UNKNOWN;
_pinned = _stored && (c->flags & CONTACT_FLAG_ROUTE_PINNED) != 0;
if (_stored) {
@@ -49,19 +67,24 @@ public:
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);
}
_edited = false;
return true;
}
const uint8_t* targetKey() const { return _target_key; }
const char* targetName() const { return _target_name; }
bool hasStoredPath() const { return _stored; }
// The list differs from the stored route (or there is none and hops were added).
bool edited() const { return _edited; }
// 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.
// when the route is already stored. True when it took effect now.
bool pinned() const { return _pinned; }
void setPinned(bool on) {
bool setPinned(bool on) {
_pinned = on;
if (_stored) the_mesh.setContactPinned(_target_key, on);
if (!_stored) return false;
the_mesh.setContactPinned(_target_key, on);
return true;
}
// ── the list ────────────────────────────────────────────────────────────────
@@ -76,11 +99,12 @@ public:
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;
if (memcmp(c->id.pub_key, _target_key, 4) == 0) return false; // the node itself
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();
changed();
return true;
}
@@ -88,24 +112,25 @@ public:
if (i < 0 || i >= _n) return;
for (int k = i; k + 1 < _n; k++) _hops[k] = _hops[k + 1];
_n--;
invalidate();
changed();
}
void removeLast() { remove(_n - 1); }
// Moves hop i one place (dir -1 up, +1 down).
void move(int i, int dir) {
// Moves hop i one place (dir -1 up, +1 down); false at either end.
bool move(int i, int dir) {
int j = i + dir;
if (i < 0 || i >= _n || j < 0 || j >= _n) return;
if (i < 0 || i >= _n || j < 0 || j >= _n) return false;
Hop t = _hops[i];
_hops[i] = _hops[j];
_hops[j] = t;
invalidate();
changed();
return true;
}
void clear() {
_n = 0;
invalidate();
changed();
}
// Is this repeater (by key) already in the list? (map picking marks it)
@@ -117,8 +142,20 @@ public:
// ── trace ───────────────────────────────────────────────────────────────────
State state() const { return _state; }
Fail failure() const { return _fail; }
bool probing() const { return _state == RUNNING && _probe; }
uint32_t timeoutMs() const { return _ping ? _ping->timeoutMs() : 3000; }
// Whether the trace goes on to the node (else it turns at the last repeater).
// On by default for a repeater: other nodes retransmit only with repeat on.
bool toNode() const { return _to_node || _n == 0; }
void setToNode(bool on) {
if (_to_node == on) return;
_to_node = on;
invalidate();
}
bool targetRepeats() const { return _target_repeats; }
// 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 {
@@ -129,100 +166,99 @@ public:
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; }
// Nodes of the chain a trace() goes to (not counting this device).
int chainNodes() const { return toNode() ? _n + 1 : _n; }
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;
if (_state == RUNNING) return false;
_probe = false;
_links = 0;
if (chainNodes() == 0) return fail(FAIL_EMPTY);
return send(chainNodes());
}
// After a trace with no reply: the repeaters one by one (to the 1st, then
// to the 2nd, ...), until one doesn't answer. reached() is then how many
// nodes of the chain did; all repeaters answering with toNode() on puts the
// break at the node itself.
bool canProbe() const { return _state == TIMEOUT && _n > 0; }
bool probe() {
if (_state == RUNNING || _n == 0) return false;
_probe = true;
_reached = 0;
_links = 0;
return send(1);
}
int reached() const { return _reached; }
// 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;
if (!_ping->isActive()) { // the reply came (the engine released its slot), or it was dropped
if (_ping->hopCount() > 0) {
capture();
if (!_probe) { _state = DONE; return; }
_reached = _sent_nodes;
if (_sent_nodes < _n && send(_sent_nodes + 1)) return;
_probe = false;
_state = PROBED;
} else {
stepTimedOut();
}
} else if (millis() - _started_ms > _ping->timeoutMs()) {
_ping->clear();
_state = TIMEOUT;
stepTimedOut();
}
}
void cancelTrace() {
if (_state == RUNNING && _ping) _ping->clear();
if (_state == RUNNING) _state = IDLE;
_probe = false;
}
// 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);
// ── results ─────────────────────────────────────────────────────────────────
// Links with a result: after a trace, chainNodes(); after a probe, reached().
int links() const { return _links; }
// Link k (1..links()): node k heard node k-1 at `out`, node k-1 heard node k at `back` (SNR x4).
bool linkSnrX4(int k, int16_t& out, int16_t& back) const {
if (k < 1 || k > _links) return false;
out = _out_x4[k - 1];
back = _back_x4[k - 1];
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;
// Name of node k of the chain (0: this device -> "").
const char* nodeName(int k) const {
if (k >= 1 && k <= _n) return _hops[k - 1].name;
if (k == _n + 1) return _target_name;
return "";
}
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;
uint32_t rttMs() const { return _rtt_ms; }
// The weakest SNR of any link either way, x4; false without a result.
bool worstX4(int16_t& v) const {
for (int k = 0; k < _links; k++) {
int16_t m = _out_x4[k] < _back_x4[k] ? _out_x4[k] : _back_x4[k];
if (k == 0 || m < v) v = m;
}
return any;
return _links > 0;
}
// ── 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; }
// The list as the contact's stored route; an empty list stores "direct"
// (no repeaters, sent straight to the node). False when the hops share no
// usable width.
bool canSave() const { return saveWidth() > 0; }
bool save() {
uint8_t w = saveWidth();
if (_n == 0 || w == 0) return false;
if (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;
_edited = false;
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;
@@ -233,9 +269,71 @@ private:
return want < known ? want : known;
}
void changed() {
_edited = true;
invalidate();
}
void invalidate() {
if (_state == RUNNING && _ping) _ping->clear();
_state = IDLE;
_probe = false;
_links = 0;
}
bool fail(Fail f) {
_fail = f;
_state = FAILED;
_probe = false;
return false;
}
// A trace to node `nodes` of the chain and back: nodes 1..nodes out, then
// nodes-1..1. The chain's last node is the target when it reaches past the
// repeaters.
bool send(int nodes) {
if (!_ping) return fail(FAIL_BUSY);
uint8_t w = traceWidth();
if (w == 0) return fail(FAIL_NO_KEY);
uint8_t path[TRACE_SNR_MAX * 4];
int e = 0;
auto key = [&](int k) -> const uint8_t* { return k <= _n ? _hops[k - 1].key : _target_key; };
for (int k = 1; k <= nodes; k++) memcpy(&path[w * e++], key(k), w);
for (int k = nodes - 1; k >= 1; k--) memcpy(&path[w * e++], key(k), w);
PingEngine::StartResult r = _ping->startTrace(path, (uint8_t)e, w);
if (r != PingEngine::STARTED) return fail(r == PingEngine::BUSY ? FAIL_BUSY : FAIL_SEND);
_fail = FAIL_NONE;
_state = RUNNING;
_started_ms = millis();
_sent_nodes = nodes;
return true;
}
void stepTimedOut() {
if (_probe) {
_probe = false;
_state = PROBED; // _reached nodes answered, the next one didn't
} else {
_state = TIMEOUT;
}
}
// The reply's per-entry SNRs as links. Over M nodes the path has 2M-1
// entries: entry k-1 is node k hearing k-1 on the way out; node k-1 hearing
// node k on the way back is entry 2M-k (k >= 2), and for k = 1 it is this
// device hearing the reply.
void capture() {
const int M = _sent_nodes;
int16_t o, back_us; uint32_t rtt;
_ping->getResult(o, back_us, rtt);
_rtt_ms = rtt;
_links = 0;
if (_ping->hopCount() < 2 * M - 1) return;
for (int k = 1; k <= M; k++) {
_out_x4[k - 1] = _ping->hopSnrX4(k - 1);
_back_x4[k - 1] = k == 1 ? back_us : _ping->hopSnrX4(2 * M - k);
}
_links = M;
}
// A hash of a stored path: the contact it belongs to by key prefix, else the bytes.
@@ -264,11 +362,21 @@ private:
PingEngine* _ping = nullptr;
uint8_t _target_key[PUB_KEY_SIZE];
char _target_name[32];
bool _target_repeats = false;
bool _to_node = false;
Hop _hops[MAX_HOPS];
int _n = 0;
State _state = IDLE;
Fail _fail = FAIL_NONE;
bool _stored = false;
bool _pinned = false;
int _entries = 0;
bool _edited = false;
bool _probe = false;
int _reached = 0;
int _sent_nodes = 0;
uint32_t _started_ms = 0;
uint32_t _rtt_ms = 0;
int _links = 0;
int16_t _out_x4[MAX_NODES];
int16_t _back_x4[MAX_NODES];
};
+14 -3
View File
@@ -5,7 +5,8 @@
// which is a plain function pointer -- routed here via a single static instance.
//
// The view gives up after timeoutMs() (from the airtime, so it scales with
// the spreading factor) and calls clear().
// the spreading factor) and calls clear(). A view that went away without it
// doesn't hold the engine: a start well past the timeout takes the slot over.
class PingEngine {
public:
@@ -14,7 +15,7 @@ public:
void begin(NodePrefs* prefs) { _prefs = prefs; s_instance = this; }
StartResult start(const uint8_t* pub_key) {
if (_active || !pub_key) return BUSY;
if (!pub_key || !takeSlot()) return BUSY;
_active = true;
_tag = 0;
@@ -41,7 +42,7 @@ public:
// `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;
if (!path || !takeSlot()) return BUSY;
_active = true;
_tag = 0;
_snr_out_x4 = _snr_back_x4 = 0;
@@ -76,6 +77,15 @@ public:
}
private:
// Free, or held by a ping / trace long past its timeout (its view left
// without clear()): released and taken.
bool takeSlot() {
if (_active && millis() - _started_ms < _timeout_ms + 2000) return false;
if (_active) clear();
_started_ms = millis();
return true;
}
void handleResult(uint32_t tag, int16_t snr_out_x4, int16_t snr_back_x4, uint32_t rtt_ms) {
if (_active && _tag == tag) {
_snr_out_x4 = snr_out_x4;
@@ -103,6 +113,7 @@ private:
int16_t _snr_back_x4 = 0;
uint32_t _rtt_ms = 0;
uint32_t _timeout_ms = 3000;
uint32_t _started_ms = 0;
uint8_t _n_hops = 0;
int16_t _hop_snr_x4[TRACE_SNR_MAX];
};
+2 -2
View File
@@ -874,9 +874,9 @@ void UITask::layoutMapMarks(double left, double top, int w, int h) {
const int half = MARK_D / 2;
const int lh = lv_font_get_line_height(THEME_FONT_SMALL) + 2;
struct Box { int16_t x1, y1, x2, y2; };
static Box used[MAX_MARKS + 8];
static Box used[6 + MAX_MARKS + MARK_LABELS_MAX]; // the map's controls, the dots, the names
int nu = 0;
const int bottom = _map_nav ? navmap::BAR_H : 0;
const int bottom = _map_nav || _route_pick ? navmap::BAR_H : 0; // the nav bar, or the route's
const int16_t W = (int16_t)w, H = (int16_t)(h - bottom);
used[nu++] = { 0, 0, 50, 144 }; // button columns
used[nu++] = { (int16_t)(W - 50), 0, W, 144 };
+204 -112
View File
@@ -1,17 +1,18 @@
#pragma once
// Route: the repeaters between this node and one contact, edited, traced and
// saved (node detail > Route). The list, the out-and-back trace and the save
// are the UI Core's PathEditor (`_core->route`); ui-new's Path screen is a
// view over the same. This draws the rows, a picker of repeaters by distance,
// and -- from the map -- picking the hops by tapping them (MapScreen.h,
// saved (node detail > Route). The list, the out-and-back trace, the search
// for where a route stops and the save are the UI Core's PathEditor
// (`_core->route`); ui-new's Path screen is a view over the same. This draws
// the chain with each link's signal, a picker of repeaters by distance, and
// -- from the map -- picking the hops by tapping them (MapScreen.h,
// `_route_pick`).
//
// Single-TU fragment: included by ui-lvgl/UITask.cpp after AdminScreen.h.
namespace routeview {
enum : uint8_t { RT_ADD, RT_MAP, RT_TRACE, RT_SAVE, RT_PIN };
enum : uint8_t { OP_UP, OP_DOWN, OP_DEL };
enum : uint8_t { RT_ADD, RT_MAP, RT_TRACE, RT_SAVE, RT_PIN, RT_PROBE };
enum : uint8_t { OP_UP, OP_DOWN, OP_DEL, OP_SEL };
static const int PICK_MAX = 96;
struct Pick { uint16_t idx; float km; }; // raw contact index, distance (<0: no position)
@@ -24,8 +25,19 @@ static int cmpPick(const void* a, const void* b) {
return x < y ? -1 : x > y ? 1 : 0;
}
// A hop's text from an SNR x4.
static void snrText(char* out, size_t n, int16_t snr_x4) { snprintf(out, n, "%.1f", snr_x4 / 4.0f); }
// A link's colour from its weaker direction (SNR x4): good, fair, weak.
static uint32_t linkColour(int16_t out_x4, int16_t back_x4) {
int16_t m = out_x4 < back_x4 ? out_x4 : back_x4;
return m >= 0 ? theme::OK : m >= -7 * 4 ? theme::ACCENT : theme::FAIL;
}
// "6.5 / 4.0": how the node heard the one before it, and back (dB).
static void linkText(char* out, size_t n, int16_t out_x4, int16_t back_x4) {
snprintf(out, n, "%.1f / %.1f", out_x4 / 4.0f, back_x4 / 4.0f);
}
static int s_sel = -1; // the hop showing its move / remove buttons
static const int ROW_TARGET = 0xFE; // a row's tap code: the node (trace to it or not)
} // namespace routeview
@@ -33,12 +45,15 @@ static void onRouteAct(lv_event_t* e) { s_ui->routeAct((uint8_t)(uintptr_t)l
static void onRouteOp(lv_event_t* e) { s_ui->routeOp((int)(uintptr_t)lv_event_get_user_data(e)); }
static void onRoutePickRow(lv_event_t* e) { s_ui->routePick((int)vlist::arg(e)); }
// A small square button on a hop's card: user data = op << 8 | hop index.
static lv_obj_t* routeSquare(lv_obj_t* card, const char* sym, int code) {
lv_obj_t* b = lv_button_create(card);
lv_obj_set_size(b, 30, 30);
static void onRouteRow(lv_event_t* e) { s_ui->routeOp(routeview::OP_SEL << 8 | (int)(uintptr_t)lv_event_get_user_data(e)); }
static void onRouteKeep(lv_event_t* e) { (void)e; s_ui->routeAct(routeview::RT_PIN); }
// A small square button on a hop's row: user data = op << 8 | hop index.
static lv_obj_t* routeSquare(lv_obj_t* row, const char* sym, int code) {
lv_obj_t* b = lv_button_create(row);
lv_obj_set_size(b, 30, 26);
lv_obj_set_style_pad_all(b, 0, 0);
lv_obj_set_style_radius(b, theme::RADIUS, 0);
lv_obj_set_style_radius(b, theme::RADIUS_SM, 0);
lv_obj_set_style_shadow_width(b, 0, 0);
lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE_2), 0);
lv_obj_add_event_cb(b, onRouteOp, LV_EVENT_CLICKED, (void*)(uintptr_t)code);
@@ -46,34 +61,44 @@ static lv_obj_t* routeSquare(lv_obj_t* card, const char* sym, int code) {
return b;
}
// One row of the route: a number, the name and what the trace heard.
static lv_obj_t* routeCard(lv_obj_t* list, const char* num, const char* name, const char* sub, uint32_t col) {
lv_obj_t* c = lv_obj_create(list);
styleSurface(c, theme::SURFACE);
lv_obj_remove_flag(c, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_size(c, LV_PCT(100), 44);
lv_obj_set_style_radius(c, theme::RADIUS, 0);
lv_obj_set_style_pad_hor(c, 8, 0);
lv_obj_set_style_pad_ver(c, 0, 0);
lv_obj_set_flex_flow(c, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(c, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_column(c, 6, 0);
lv_obj_t* n = label(c, num, THEME_FONT_BODY, theme::ACCENT);
lv_obj_set_width(n, 18);
lv_obj_t* box = lv_obj_create(c);
lv_obj_remove_style_all(box);
lv_obj_set_height(box, LV_SIZE_CONTENT);
lv_obj_set_flex_grow(box, 1);
lv_obj_set_flex_flow(box, LV_FLEX_FLOW_COLUMN);
lv_obj_t* nm = label(box, name, THEME_FONT_BODY, col);
lv_label_set_long_mode(nm, LV_LABEL_LONG_DOT);
lv_obj_set_width(nm, LV_PCT(100));
if (sub && sub[0]) {
lv_obj_t* s = label(box, sub, THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_long_mode(s, LV_LABEL_LONG_DOT);
lv_obj_set_width(s, LV_PCT(100));
// One node of the chain on one line: a number, the name, and on the right
// the link that reaches it (or why there is none). `tap` >= 0: the row is
// tappable with that code (a hop's index, or ROW_TARGET).
static lv_obj_t* routeRow(lv_obj_t* list, int h, const char* num, const char* name, uint32_t name_col,
const char* right, uint32_t right_col, int tap) {
lv_obj_t* r = lv_obj_create(list);
styleSurface(r, theme::SURFACE);
lv_obj_remove_flag(r, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_size(r, LV_PCT(100), h);
lv_obj_set_style_radius(r, theme::RADIUS_SM, 0);
lv_obj_set_style_pad_hor(r, 8, 0);
lv_obj_set_style_pad_ver(r, 0, 0);
lv_obj_set_flex_flow(r, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(r, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_column(r, 6, 0);
if (tap >= 0) {
lv_obj_add_flag(r, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_style_bg_color(r, lv_color_hex(theme::SURFACE_2), LV_STATE_PRESSED);
lv_obj_add_event_cb(r, onRouteRow, LV_EVENT_CLICKED, (void*)(uintptr_t)tap);
}
return c;
lv_obj_t* n = label(r, num, THEME_FONT_BODY, theme::ACCENT);
lv_obj_set_width(n, 14);
lv_obj_t* nm = label(r, name, THEME_FONT_BODY, name_col);
lv_label_set_long_mode(nm, LV_LABEL_LONG_DOT);
lv_obj_set_width(nm, 1);
lv_obj_set_flex_grow(nm, 1);
if (right && right[0]) label(r, right, THEME_FONT_SMALL, right_col);
return r;
}
// Keep (the header's button, a toggle): restyled, the status line follows.
void UITask::routeKeep() {
PathEditor& R = _core->route;
bool on = !R.pinned();
bool now = R.setPinned(on);
showToast(!on ? "The route is learned again" : now ? "Route kept" : "Kept once you save it");
if (_route_keep_btn) styleSelected(_route_keep_btn, R.pinned());
routeRebuild();
}
// Node detail > Route.
@@ -84,97 +109,162 @@ void UITask::showRoute(const uint8_t* pub_key) {
buildRoute();
}
// Keep in the header, the status line, the chain (rebuilt as the trace
// goes), the actions at the foot.
void UITask::buildRoute() {
PathEditor& R = _core->route;
lv_obj_t* body = newScreen("Route", true);
_route_status = noteLabel(body, "", THEME_FONT_SMALL, theme::TEXT_MUTED);
_route_list = scrollList(body);
_route_keep_btn = _header ? headerButton(_header, UI_SYMBOL_TACK " Keep", onRouteKeep, 4, NULL) : nullptr;
if (_route_keep_btn) styleSelected(_route_keep_btn, R.pinned());
routeview::s_sel = -1;
// The status line, with "Find where it stops" beside it after a trace with no reply.
lv_obj_t* top = flexBox(body, LV_FLEX_FLOW_ROW);
lv_obj_set_width(top, LV_PCT(100));
lv_obj_set_flex_align(top, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_column(top, 6, 0);
_route_status = noteLabel(top, "", THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_obj_set_width(_route_status, 1);
lv_obj_set_flex_grow(_route_status, 1);
_route_probe_row = pillButton(top, LV_SYMBOL_GPS " Find where", theme::ACCENT, onRouteAct, routeview::RT_PROBE);
styleSelected(_route_probe_row, true);
lv_obj_t* scroll = scrollList(body);
lv_obj_set_style_pad_row(scroll, 4, 0);
_route_list = flexBox(scroll, LV_FLEX_FLOW_COLUMN);
lv_obj_set_width(_route_list, LV_PCT(100));
lv_obj_set_style_pad_row(_route_list, 4, 0);
_route_note = noteLabel(scroll, "", THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_obj_t* bar = buttonBar(body);
barButton(bar, LV_SYMBOL_PLUS " Add", onRouteAct, routeview::RT_ADD);
barButton(bar, UI_SYMBOL_MAP " Map", onRouteAct, routeview::RT_MAP);
_route_trace_btn = barButton(bar, LV_SYMBOL_LOOP " Trace", onRouteAct, routeview::RT_TRACE, true);
barButton(bar, LV_SYMBOL_OK " Save", onRouteAct, routeview::RT_SAVE);
_route_pin_btn = barButton(bar, UI_SYMBOL_TACK " Keep", onRouteAct, routeview::RT_PIN, _core->route.pinned());
_route_shown_state = 255;
_route_save_btn = barButton(bar, LV_SYMBOL_SAVE " Save", onRouteAct, routeview::RT_SAVE);
routeRebuild();
}
// The rows and the line above them, from the editor's state.
// What the chain shows: the state, the links with a result, a probe's step.
uint32_t UITask::routeSig() {
PathEditor& R = _core->route;
return (uint32_t)R.state() | (uint32_t)R.links() << 4 | (uint32_t)R.reached() << 8 | (uint32_t)R.probing() << 12 |
(uint32_t)R.edited() << 13 | (uint32_t)R.toNode() << 14 | (uint32_t)R.count() << 16;
}
// The chain, from the editor's state: the repeaters and the node (this
// device before them goes without saying), one line each with the link that
// reaches it ("out / back" dB, coloured by
// its weaker way). A tapped hop shows its move / remove buttons instead; a
// tap on the node traces to it or not.
void UITask::routeRebuild() {
if (!_route_list || !_route_status) return;
using namespace routeview;
PathEditor& R = _core->route;
lv_obj_clean(_route_list);
const int n = R.count();
const bool done = R.state() == PathEditor::DONE;
char sub[48], a[12], b[12];
int16_t back = 0;
sub[0] = '\0';
if (done && R.snrBackX4(back)) { routeview::snrText(a, sizeof(a), back); snprintf(sub, sizeof(sub), "" LV_SYMBOL_LEFT " %s dB", a); }
routeCard(_route_list, "", "This device", sub, theme::TEXT_MUTED);
if (s_sel >= n) s_sel = -1;
const bool probed = R.state() == PathEditor::PROBED;
const int stop = probed ? R.reached() + 1 : 0; // the node that didn't answer the probe
char right[40];
uint32_t col;
auto link = [&](int k) { // the link to node k, or why there's none
int16_t o, b;
right[0] = '\0';
col = theme::TEXT_MUTED;
if (R.linkSnrX4(k, o, b)) { linkText(right, sizeof(right), o, b); col = linkColour(o, b); }
else if (k == stop) { snprintf(right, sizeof(right), "no answer"); col = theme::FAIL; }
};
for (int i = 0; i < n; i++) {
int16_t out = 0, ret = 0;
sub[0] = '\0';
if (done && R.hopSnrX4(i, out)) {
routeview::snrText(a, sizeof(a), out);
if (R.hopSnrX4(2 * n - i, ret)) {
routeview::snrText(b, sizeof(b), ret);
snprintf(sub, sizeof(sub), "" LV_SYMBOL_RIGHT " %s " LV_SYMBOL_LEFT " %s dB", a, b);
} else {
snprintf(sub, sizeof(sub), "" LV_SYMBOL_RIGHT " %s dB", a);
}
} else if (R.hop(i).known < 4) {
snprintf(sub, sizeof(sub), "not a known contact");
}
char num[4];
snprintf(num, sizeof(num), "%d", i + 1);
lv_obj_t* c = routeCard(_route_list, num, R.hop(i).name, sub, theme::TEXT);
if (i > 0) routeSquare(c, LV_SYMBOL_UP, routeview::OP_UP << 8 | i);
if (i < n - 1) routeSquare(c, LV_SYMBOL_DOWN, routeview::OP_DOWN << 8 | i);
routeSquare(c, LV_SYMBOL_CLOSE, routeview::OP_DEL << 8 | i);
link(i + 1);
if (!right[0] && R.hop(i).known < 4) snprintf(right, sizeof(right), "hash only");
const bool sel = i == s_sel;
lv_obj_t* r = routeRow(_route_list, 34, num, R.hop(i).name, theme::TEXT, sel ? NULL : right, col, i);
if (sel) {
if (i > 0) routeSquare(r, LV_SYMBOL_UP, OP_UP << 8 | i);
if (i < n - 1) routeSquare(r, LV_SYMBOL_DOWN, OP_DOWN << 8 | i);
routeSquare(r, LV_SYMBOL_CLOSE, OP_DEL << 8 | i);
}
}
if (R.toNode()) {
link(n + 1);
if (probed && R.reached() == n && !right[0]) { snprintf(right, sizeof(right), "no answer"); col = theme::FAIL; }
} else {
snprintf(right, sizeof(right), "not traced");
col = theme::TEXT_MUTED;
}
routeRow(_route_list, 34, "", R.targetName()[0] ? R.targetName() : "(unnamed)", theme::ACCENT, right, col,
n > 0 ? ROW_TARGET : -1);
int16_t out = 0;
sub[0] = '\0';
if (done && R.hopSnrX4(n, out)) { routeview::snrText(a, sizeof(a), out); snprintf(sub, sizeof(sub), "" LV_SYMBOL_RIGHT " %s dB", a); }
routeCard(_route_list, "", R.targetName()[0] ? R.targetName() : "(unnamed)", sub, theme::ACCENT);
const char* note = "";
if (n == 0)
noteLabel(_route_list, R.hasStoredPath()
? "Direct: no repeaters between. Save with repeaters to route through them."
: "No route stored, so messages are flooded. Add the repeaters the node should be reached through.",
THEME_FONT_SMALL, theme::TEXT_MUTED);
note = R.hasStoredPath() && !R.edited()
? "Direct: sent straight to the node. Add the repeaters to route through them."
: "Add the repeaters the node is reached through, in order (Add, or tap them on the Map). "
"Saved empty, messages go straight to the node.";
else if (R.state() == PathEditor::IDLE && R.links() == 0)
note = R.toNode() ? "Tap a hop to move or remove it. The trace goes to the node and back: tap it to stop at the last repeater."
: "Tap a hop to move or remove it. The node is left out: only one that repeats can answer. Tap it to include it.";
else if (R.links() > 0)
note = "Out / back dB: how each one heard the one before it (this device first), and the other way.";
setText(_route_note, note);
lv_obj_set_flag(_route_note, LV_OBJ_FLAG_HIDDEN, !note[0]);
lv_obj_set_flag(_route_probe_row, LV_OBJ_FLAG_HIDDEN, !R.canProbe());
routeStatus();
}
void UITask::routeStatus() {
PathEditor& R = _core->route;
char t[96];
const int n = R.count();
char t[112];
switch (R.state()) {
case PathEditor::RUNNING: snprintf(t, sizeof(t), "Tracing %d hops, out and back...", R.traceEntries()); break;
case PathEditor::RUNNING:
if (R.probing()) snprintf(t, sizeof(t), "Finding where it stops: to %s...", R.nodeName(R.links() + 1));
else snprintf(t, sizeof(t), "Tracing %d node%s out and back...", R.chainNodes(), R.chainNodes() == 1 ? "" : "s");
break;
case PathEditor::DONE: {
int16_t worst = 0;
if (R.worstOutX4(worst)) snprintf(t, sizeof(t), "Heard back in %lu ms - weakest hop %.1f dB", (unsigned long)R.rttMs(), worst / 4.0f);
if (R.worstX4(worst)) snprintf(t, sizeof(t), "Heard back in %lu ms, weakest link %.1f dB", (unsigned long)R.rttMs(), worst / 4.0f);
else snprintf(t, sizeof(t), "Heard back in %lu ms", (unsigned long)R.rttMs());
break;
}
case PathEditor::TIMEOUT: snprintf(t, sizeof(t), "No reply: a hop didn't answer, or its answer wasn't heard here"); break;
case PathEditor::FAILED: snprintf(t, sizeof(t), "Can't trace: a repeater has no key known (add it from contacts)"); break;
case PathEditor::TIMEOUT:
if (n > 0) snprintf(t, sizeof(t), "No reply: one of them didn't answer");
else snprintf(t, sizeof(t), "No reply. %s answers only if it repeats and hears this device", R.targetName());
break;
case PathEditor::PROBED:
if (R.reached() < n)
snprintf(t, sizeof(t), "Stops after %s: %s didn't answer", R.reached() ? R.nodeName(R.reached()) : "this device",
R.nodeName(R.reached() + 1));
else if (R.toNode())
snprintf(t, sizeof(t), "Every repeater answered: %s didn't (it may not repeat)", R.targetName());
else
snprintf(t, sizeof(t), "Every repeater answered this time");
break;
case PathEditor::FAILED:
switch (R.failure()) {
case PathEditor::FAIL_NO_KEY: snprintf(t, sizeof(t), "Can't trace: a repeater has no key known (add it from contacts)"); break;
case PathEditor::FAIL_BUSY: snprintf(t, sizeof(t), "A ping is on its way: try again in a moment"); break;
case PathEditor::FAIL_EMPTY: snprintf(t, sizeof(t), "Nothing to trace: add a repeater, or trace to the node"); break;
default: snprintf(t, sizeof(t), "The trace couldn't be sent"); break;
}
break;
default:
snprintf(t, sizeof(t), "Route to %s: %d repeater%s%s", R.targetName(), R.count(), R.count() == 1 ? "" : "s",
R.pinned() ? " (kept)" : "");
if (R.edited()) snprintf(t, sizeof(t), "%d repeater%s, not saved yet", n, n == 1 ? "" : "s");
else if (!R.hasStoredPath()) snprintf(t, sizeof(t), "No route stored: messages are flooded");
else snprintf(t, sizeof(t), "Stored route: %d repeater%s%s", n, n == 1 ? "" : "s", R.pinned() ? ", kept" : "");
}
setText(_route_status, t);
if (_route_trace_btn) setText(lv_obj_get_child(_route_trace_btn, 0), R.state() == PathEditor::RUNNING ? LV_SYMBOL_CLOSE " Stop" : LV_SYMBOL_LOOP " Trace");
_route_shown_state = R.state();
if (_route_save_btn) barButtonOn(_route_save_btn, R.edited());
_route_shown = routeSig();
}
// From loop() on SCR_ROUTE: a running trace's reply or timeout.
// From loop() on SCR_ROUTE: a running trace's reply or timeout, a probe's steps.
void UITask::routePoll() {
PathEditor& R = _core->route;
R.poll();
if (R.state() != _route_shown_state) routeRebuild();
_core->route.poll();
if (routeSig() != _route_shown) routeRebuild();
}
void UITask::routeAct(uint8_t act) {
@@ -182,6 +272,7 @@ void UITask::routeAct(uint8_t act) {
switch (act) {
case routeview::RT_ADD:
R.cancelTrace();
if (R.full()) { showToast("The route is full"); break; }
showRoutePick();
break;
case routeview::RT_MAP:
@@ -191,24 +282,20 @@ void UITask::routeAct(uint8_t act) {
routeMapFrame();
break;
case routeview::RT_TRACE:
if (R.state() == PathEditor::RUNNING) { R.cancelTrace(); routeRebuild(); break; }
if (R.trace()) {
_next_route_ms = millis() + 200;
routeRebuild();
} else if (R.state() == PathEditor::FAILED) {
routeRebuild();
} else {
showToast("Busy, try again");
}
if (R.state() == PathEditor::RUNNING) R.cancelTrace();
else if (R.trace()) _next_route_ms = millis() + 200;
routeRebuild();
break;
case routeview::RT_PIN: // kept: not replaced by a path heard, nor dropped after a failed send
R.setPinned(!R.pinned());
barButtonOn(_route_pin_btn, R.pinned());
showToast(R.pinned() ? (R.hasStoredPath() ? "Route kept" : "Kept once you save it") : "Route is learned again");
case routeview::RT_PROBE:
if (R.probe()) _next_route_ms = millis() + 200;
routeRebuild();
break;
case routeview::RT_PIN:
routeKeep();
break;
case routeview::RT_SAVE:
if (R.count() == 0) { showToast("Add a repeater first (Reset path floods)"); break; }
showToast(R.save() ? "Route saved" : "Can't save this route");
if (!R.save()) { showToast("Can't save this route"); break; }
showToast(R.count() ? (R.pinned() ? "Route saved and kept" : "Route saved") : "Saved: direct to the node");
routeRebuild();
break;
}
@@ -217,9 +304,14 @@ void UITask::routeAct(uint8_t act) {
void UITask::routeOp(int code) {
PathEditor& R = _core->route;
int i = code & 0xFF, op = code >> 8;
if (op == routeview::OP_UP) R.move(i, -1);
else if (op == routeview::OP_DOWN) R.move(i, +1);
else if (op == routeview::OP_DEL) R.remove(i);
int& sel = routeview::s_sel;
if (op == routeview::OP_SEL) {
if (i == routeview::ROW_TARGET) R.setToNode(!R.toNode());
else sel = sel == i ? -1 : i;
}
else if (op == routeview::OP_UP) { if (R.move(i, -1)) sel = i - 1; }
else if (op == routeview::OP_DOWN) { if (R.move(i, +1)) sel = i + 1; }
else if (op == routeview::OP_DEL) { R.remove(i); sel = -1; }
routeRebuild();
}
@@ -272,7 +364,7 @@ void UITask::routePick(int row) {
if (!the_mesh.getContactByIdx(routeview::s_pick[row].idx, c)) return;
PathEditor& R = _core->route;
if (R.full()) { showToast("The route is full"); return; }
if (!R.addRepeater(c.id.pub_key)) { showToast("That repeater is already the last hop"); return; }
if (!R.addRepeater(c.id.pub_key)) { showToast("Already the last hop, or the node itself"); return; }
_screen = SCR_ROUTE;
_nav_back = true;
buildRoute();
@@ -289,7 +381,7 @@ void UITask::routeMapTap(const uint8_t* pub_key, uint8_t type) {
int at = R.indexOf(pub_key);
if (at >= 0) R.remove(at);
else if (R.full()) { showToast("The route is full"); return; }
else if (!R.addRepeater(pub_key)) { showToast("That repeater is already the last hop"); return; }
else if (!R.addRepeater(pub_key)) { showToast("Already the last hop, or the node itself"); return; }
routeMapRefresh();
}
+9 -2
View File
@@ -117,9 +117,11 @@ public:
void buildRoute();
void routeRebuild();
void routeStatus();
uint32_t routeSig();
void routePoll();
void routeAct(uint8_t act);
void routeOp(int code);
void routeKeep();
void showRoutePick();
void buildRoutePick();
void routePick(int row);
@@ -362,6 +364,8 @@ public:
// Kept for main.cpp's sim hooks (ui-new API).
void openContactDM(const ContactInfo& ci) { openDM(ci.id.pub_key); }
void openAdminFor(const ContactInfo& ci, bool from_picker) { (void)from_picker; openAdmin(ci.id.pub_key); }
void openRouteFor(const uint8_t* pub_key) { showRoute(pub_key); }
void openMessagesList() { showChats(); } // sim harness
private:
enum Screen : uint8_t { SCR_HOME, SCR_CHATS, SCR_CONTACTS, SCR_THREAD, SCR_SETTINGS, SCR_NEARBY, SCR_NODE, SCR_MAP, SCR_WIFI, SCR_SETTINGS_NAV, SCR_CLOCK, SCR_RADIO, SCR_CHANNEL_EDIT, SCR_ADMIN, SCR_BOT, SCR_DIAG, SCR_COMPASS, SCR_SCOPES, SCR_REPEATER, SCR_MELODY, SCR_QUICK, SCR_ADMIN_PICK, SCR_OTA, SCR_STORAGE, SCR_GPS, SCR_BATT, SCR_ROUTE, SCR_ROUTE_PICK };
@@ -631,8 +635,11 @@ private:
lv_obj_t* _route_status = nullptr; // Route screen (RouteScreen.h)
lv_obj_t* _route_list = nullptr;
lv_obj_t* _route_trace_btn = nullptr;
lv_obj_t* _route_pin_btn = nullptr;
uint8_t _route_shown_state = 255;
lv_obj_t* _route_save_btn = nullptr;
lv_obj_t* _route_probe_row = nullptr; // "Find where it stops", after a trace with no reply
lv_obj_t* _route_note = nullptr;
lv_obj_t* _route_keep_btn = nullptr; // header: Keep (accent while kept)
uint32_t _route_shown = 0xFFFFFFFF; // what routeRebuild() drew (routeSig())
uint32_t _next_route_ms = 0;
bool _route_pick = false; // the nodes map taps repeaters into the route
lv_obj_t* _route_bar = nullptr;
@@ -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();