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
+1
View File
@@ -150,6 +150,7 @@ consumes keys only.
- Fonts ✅ Noto Sans generated by `ui-lvgl/fonts/generate.sh` (lv_font_conv): European Latin (Latin-1, Extended-A/B), Greek, Cyrillic + supplement, typographic punctuation, currency, LVGL symbols; 12/14/16/20 px, clock 40 px digits only. On-screen keyboard (`ui-lvgl/Keyboard.h`), phone-style: one layout per script (Latin QWERTY / Cyrillic ЙЦУКЕН / Greek) chosen by the same `keyboard_main_alphabet` / `keyboard_alt_alphabet` prefs as `ui-new` (globe key switches), hold a key for its variants; compose limit counted in UTF-8 bytes. Long-press variants and UTF-8 case mapping are shared with `ui-new` in `ui-core/KeyboardData.h`.
- Simulator ✅: `SIM_UI=lvgl variants/sim/build_wasm.sh` builds the same `ui-lvgl` code for the browser (`SimLcdDisplay` in `variants/sim/SimDisplayDriver.h` blits LVGL's RGB565 flush to a 320×240 canvas; the `SIM_PLATFORM` branch of `ui-lvgl/LvglPort.h` feeds the mouse in as touch). LVGL comes from the L2 env's PlatformIO libdeps; its objects are cached in `web/build/obj_lvgl/`. `web/lvgl.html` runs it next to a `ui-new` peer on a direct link (buttons: advert, peer DM, peer → Public, the board button) and exposes `window.sim` (`tap`, `hold`, `touch`, `peerDM`, `peerChannel`) for headless checks.
- Map (in progress): offline Web-Mercator raster tiles on the microSD (`/maps/{z}/{x}/{y}.png`, the Meshtastic MUI layout; optional per-column `.pak` from PR #3381's packer), SDMMC 1-bit mount in `LvglPort.h`. `ui-lvgl/map/TileProvider.h` is the swap point: the screen and `map/TileCache.h` (24 decoded RGB565 tiles in PSRAM, LRU) only call `renderTile(z, x, y, rgb565)`, so a vector renderer can replace the PNG provider later. `MapScreen.h`: pan by drag, +/-, re-centre/follow GPS, Nearby nodes with a position as markers (tap = node detail), one tile decoded per loop pass, never during a drag (the view fills in once the finger rests; an idle map decodes the ring around the view ahead, and a not-yet-decoded tile shows a cached coarser one meanwhile). `maps/attribution.txt` is behind a "©" on the map and in Settings > About. Two maps over the same tile view: Nearby's header opens the Nodes map (every node Nearby knows a position for), Home > Map the Navigation map (`NavMap.h`): saved waypoints, live shares, the recorded trail and the Locator target with a dashed line to it and a bar with distance / bearing / own course / ETA (`ui-core/EtaTracker.h`, shared with ui-new's NavView). Hold the map to drop a waypoint, the pin marks the GPS position; the bar opens the target list and the waypoint menu (go, rename, share as `[WAY]` into a conversation, delete). Node detail has Navigate (target followed by key); a person sharing on a channel is followed by sender name (Locator target kind 2). Settings > Navigation carries the `track_shared_loc` / `locator_enabled` switches ui-new has on its Live share / Locator screens. Tiles come from `tools/maps/fetch_tiles.py` (no default server: tile.openstreetmap.org blocks offline use) or on the device: the map's download button fetches the visible area (a few overview levels up to a chosen zoom) over WiFi (`map/TileDownloader.h`; skips tiles on the card, rejects non-PNG and placeholder answers; server from `maps/source.txt`, default OpenTopoMap). The HTTP GET runs on a worker task (`LvglPort.h`, keep-alive TLS); WiFi credentials (Settings > WiFi, with scan) live in NVS, and WiFi is on only during a download. The job (area + zoom range) stays in `maps/.job` until it completes, so a download cut short by power-off, lost WiFi or Stop is offered for Resume (tiles already on the card are skipped). Past the downloaded detail the map magnifies the nearest coarser tile it has (up to 2 levels -- more is just big pixels; zoom pill shows e.g. `z17 (map z15)`); known-missing tiles are remembered without a pixel buffer. Internal RAM is the constraint (Arduino-ESP32 2.0.17 / IDF 4.4: task stacks and, by default, mbedTLS buffers must be internal): with WiFi + Bluedroid up only ~13 KB was left, so the L2 LVGL env sets `MESH_IN_PSRAM` (`the_mesh`, ~120 KB, placement-new into PSRAM in `main.cpp`) and the fetch task points mbedTLS at PSRAM. The download popup shows IP, RSSI, free/largest internal heap and the last TLS/HTTP error. Arduino-ESP32 3.x (IDF 5: PSRAM stacks, NimBLE) is to be evaluated in the polishing phase. In the simulator the same code downloads through emscripten fetch.
- Route and trace ✅ (built, not yet tested on hardware): `ui-core/PathEditor.h` (`UiCore::route`) holds the repeaters between this node and a contact -- loaded from the contact's stored path, edited (add / move / remove), traced and saved (`MyMesh::setContactPath`). A trace goes out and back (A B dest B A) over `MyMesh::sendTrace`, because the reply only arrives if the last retransmission is heard; `PingEngine::startTrace` keeps the SNR of every hop (`PingResult::snrs_x4`, up to `TRACE_SNR_MAX`). Views: ui-lvgl's Route screen (node detail > Route and trace; `RouteScreen.h`) with a picker by distance and "Map" -- the nodes map where tapping repeaters in order builds the route (numbered badges, a line); ui-new's Path view (`RouteView` in `NearbyScreen.h`, the node's action menu > Path / trace) with Add handing the Nodes list over filtered to repeaters. A saved route can be kept (`CONTACT_FLAG_ROUTE_PINNED`, `ContactInfo::flags` bit 4; "Keep" in both views): `MyMesh::onContactPathRecv` puts the held path back after the base class took a heard one, and `tickDmResends` doesn't drop it before the last resend; Reset path (device or app) clears the flag. The ui-lvgl map's markers are one drawn layer (`mapview::drawMarks`, hit-tested by `hitMark`), not an object each.
- PR #3381's LVGL UI is a reference for platform glue (flush, GT911, PSRAM pool) only; its screens are not adopted.
## Migration plan
+19
View File
@@ -1054,8 +1054,22 @@ bool MyMesh::onContactPathRecv(ContactInfo& contact, uint8_t* in_path, uint8_t i
int path_bytes = (out_path_len & 63) * ((out_path_len >> 6) + 1);
_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).
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;
uint8_t held[MAX_PATH_SIZE];
if (pinned) {
memcpy(held, contact.out_path, sizeof(held));
_path_same = true;
}
bool r = BaseChatMesh::onContactPathRecv(contact, in_path, in_path_len, out_path, out_path_len, extra_type, extra, extra_len);
_path_same = false;
if (pinned) {
contact.out_path_len = held_len;
memcpy(contact.out_path, held, sizeof(held));
}
// A flood DM's ACK comes back folded into the return path, which the base
// class matches without going through onAckRecv() -- so the UI's delivery
// marker never heard of it and kept resending. Tell it here too; it only
@@ -1197,6 +1211,10 @@ void MyMesh::onTraceRecv(mesh::Packet *packet, uint32_t tag, uint32_t auth_code,
result.rtt_ms = 0xFFFFFFFF; // Overflow
}
uint8_t n = snr_count > TRACE_SNR_MAX ? TRACE_SNR_MAX : snr_count;
result.n_snr = n;
memcpy(result.snrs_x4, path_snrs, n);
result.received = true;
// Notify callback if set
@@ -1818,6 +1836,7 @@ void MyMesh::handleCmdFrame(size_t len) {
ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
if (recipient) {
recipient->out_path_len = OUT_PATH_UNKNOWN;
recipient->flags &= ~CONTACT_FLAG_ROUTE_PINNED;
// recipient->lastmod = ?? shouldn't be needed, app already has this version of contact
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
writeOKFrame();
+13 -1
View File
@@ -244,6 +244,7 @@ public:
#define PING_RESULT_MAX 4
typedef void (*PingCallback)(uint32_t tag, int16_t snr_out_x4, int16_t snr_back_x4, uint32_t rtt_ms);
#define TRACE_SNR_MAX 16 // per-hop SNRs kept of a trace (a round trip over up to 8 repeaters)
struct PingResult {
uint32_t tag;
uint32_t auth_code;
@@ -252,9 +253,20 @@ public:
uint32_t rtt_ms; // Round-trip time in milliseconds
bool received;
unsigned long sent_ms;
uint8_t n_snr; // hops of the trace that were heard back
int8_t snrs_x4[TRACE_SNR_MAX]; // SNR × 4 at each hop, in path order
};
uint32_t sendPing(const uint8_t* dest_pubkey, uint8_t hash_width = 1, uint32_t* est_timeout = nullptr);
// A trace over an explicit route: `path` is `hops` hashes of `hash_width`
// (1, 2 or 4) bytes each, in the order the packet visits them. The reply
// 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).
// `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);
void setPingCallback(PingCallback cb, void* arg);
void clearPingResult(uint32_t tag);
PingResult* getPingResult(uint32_t tag);
+26
View File
@@ -70,6 +70,32 @@ bool MyMesh::resetContactPath(const uint8_t* pub_key) {
ContactInfo* c = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
if (!c) return false;
c->out_path_len = OUT_PATH_UNKNOWN;
c->flags &= ~CONTACT_FLAG_ROUTE_PINNED; // a deliberate reset ends a kept route
c->lastmod = getRTCClock()->getCurrentTime();
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
return true;
}
bool MyMesh::setContactPinned(const uint8_t* pub_key, bool pin) {
ContactInfo* c = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
if (!c) return false;
if (pin) c->flags |= CONTACT_FLAG_ROUTE_PINNED; else c->flags &= ~CONTACT_FLAG_ROUTE_PINNED;
c->lastmod = getRTCClock()->getCurrentTime();
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
return true;
}
// 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
// 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;
ContactInfo* c = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
if (!c) return false;
memcpy(c->out_path, path, hops * hash_size);
c->out_path_len = (uint8_t)(((hash_size - 1) << 6) | hops);
if (pin) c->flags |= CONTACT_FLAG_ROUTE_PINNED; else c->flags &= ~CONTACT_FLAG_ROUTE_PINNED;
c->lastmod = getRTCClock()->getCurrentTime();
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
return true;
+12 -14
View File
@@ -25,9 +25,10 @@ int MyMesh::getDiscoverResults(DiscoverResult dest[], int max_count) {
// ── Ping/Trace functionality ─────────────────────────────────────────────────
uint32_t MyMesh::sendPing(const uint8_t* dest_pubkey, uint8_t hash_width, uint32_t* est_timeout) {
uint32_t MyMesh::sendTrace(const uint8_t* path, uint8_t hops, uint8_t hash_width, uint32_t* est_timeout) {
// A trace's hashes are 1 << flags bytes wide: 1, 2, 4 (no 3).
if (hash_width != 1 && hash_width != 2 && hash_width != 4) return 0;
if (hops == 0 || hops > TRACE_SNR_MAX || hops * hash_width > MAX_PATH_SIZE) return 0;
// Generate random tag and auth code
uint32_t tag, auth;
@@ -48,26 +49,17 @@ uint32_t MyMesh::sendPing(const uint8_t* dest_pubkey, uint8_t hash_width, uint32
// Initialize ping result tracking
PingResult& result = _ping_results[slot];
memset(&result, 0, sizeof(result));
result.tag = tag;
result.auth_code = auth;
result.snr_out_x4 = 0;
result.snr_back_x4 = 0;
result.rtt_ms = 0;
result.received = false;
result.sent_ms = millis();
// Create path hash from destination public key
uint8_t path_len = hash_width;
uint8_t path[MAX_PATH_SIZE];
memcpy(path, dest_pubkey, path_len);
// Create and send trace packet
auto pkt = createTrace(tag, auth, hash_width == 4 ? 2 : hash_width - 1); // flags = log2(hash_width)
if (pkt) {
sendDirect(pkt, path, path_len);
if (est_timeout) { // as CMD_SEND_TRACE_PATH answers the app: from the airtime, one hop
sendDirect(pkt, path, hops * hash_width);
if (est_timeout) { // as CMD_SEND_TRACE_PATH answers the app: from the airtime, per hop
uint32_t t = _radio->getEstAirtimeFor(pkt->payload_len + pkt->path_len + 2);
*est_timeout = calcDirectTimeoutMillisFor(t, 1);
*est_timeout = calcDirectTimeoutMillisFor(t, hops);
}
return tag;
}
@@ -77,6 +69,12 @@ uint32_t MyMesh::sendPing(const uint8_t* dest_pubkey, uint8_t hash_width, uint32
return 0;
}
// Ping: a trace over just the destination's hash (one hop, it must be heard directly).
uint32_t MyMesh::sendPing(const uint8_t* dest_pubkey, uint8_t hash_width, uint32_t* est_timeout) {
if (hash_width != 1 && hash_width != 2 && hash_width != 4) return 0;
return sendTrace(dest_pubkey, 1, hash_width, est_timeout);
}
void MyMesh::setPingCallback(PingCallback cb, void* arg) {
_ping_callback = cb;
_ping_callback_arg = arg;
@@ -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
+377 -52
View File
@@ -160,13 +160,37 @@ static void fillAroundMap(lv_event_t* e) {
if (a.x1 <= a.x2 && a.y1 <= a.y2) lv_draw_rect(layer, &d, &a);
}
// Markers, remembered so layout can reposition them. `idx` is the Nearby row
// Markers: one drawn layer, not an object per marker (64 nodes were ~190
// objects to lay out and invalidate on every pan). `idx` is the Nearby row
// (MK_NODE), the waypoint index (MK_WAYPOINT) or the live-share slot (MK_LIVE).
// layoutMapMarks() fills the placement (dot centre, name pill) from the view;
// the layer's draw callback paints it; a tap is hit-tested by hitMark().
enum : uint8_t { MK_NODE, MK_WAYPOINT, MK_LIVE };
struct Mark { int32_t lat_e6, lon_e6; int idx; uint8_t kind; uint32_t col, text_hash; lv_obj_t* obj; lv_obj_t* dot; };
static const int MARK_NAME = 31;
struct Mark {
int32_t lat_e6, lon_e6; int idx; uint8_t kind; uint32_t col, text_hash;
char name[MARK_NAME + 1];
int16_t tw; // the name's width in px
int16_t x, y; // dot centre, layer px (valid when vis)
int16_t lx, ly, lw, lh; // name pill, layer px (lw 0: no room for it)
uint8_t order; // 1.. while it is a hop of the route being picked, else 0
bool vis; // inside the view
};
// What was drawn for a slot last time, to repaint only what changed.
struct MarkSig { int16_t x, y, lx, ly, lw; uint32_t col, hash; uint8_t order; bool vis; };
static const int MAX_MARKS = NearbyModel::MAX_NEARBY > WaypointStore::CAPACITY + LiveTrackStore::CAPACITY
? NearbyModel::MAX_NEARBY : WaypointStore::CAPACITY + LiveTrackStore::CAPACITY; // Nearby map, or waypoints + live shares
static Mark* s_marks = psramBuf<Mark>(MAX_MARKS);
static MarkSig* s_sigs = psramBuf<MarkSig>(MAX_MARKS);
static int s_sig_n = 0;
static lv_obj_t* s_mark_layer = nullptr;
// The route being picked (Route > Map): its points as lat/lon, and in layer px
// from the last layout, drawn as a line under the markers.
static const int ROUTE_PTS = 10;
static int32_t s_route_lat[ROUTE_PTS], s_route_lon[ROUTE_PTS];
static int s_route_n = 0;
static lv_point_precise_t s_route_px[ROUTE_PTS];
static bool s_route_on = false;
static int s_mark_count = 0;
static int s_mark_prev = 0; // during a rebuild: how many the last one left, reused where unchanged
static int s_drag = 0; // px moved in the current press: a drag isn't a tap
@@ -235,6 +259,100 @@ static bool prefetchOne(double left, double top, int w, int h, int z) {
}
static const int MARK_D = 12; // marker dot diameter
static const int MARK_LABELS_MAX = 20; // names shown at once: the rest stay dots
// LV_EVENT_DRAW_MAIN of the marker layer: the name pills, then the dots over them.
static void drawMarks(lv_event_t* e) {
lv_obj_t* o = (lv_obj_t*)lv_event_get_target(e);
lv_layer_t* layer = lv_event_get_layer(e);
lv_area_t a;
lv_obj_get_coords(o, &a);
if (s_route_on && s_route_n >= 2) {
lv_draw_line_dsc_t ln;
lv_draw_line_dsc_init(&ln);
ln.color = lv_color_hex(theme::ACCENT);
ln.width = 3;
ln.round_start = ln.round_end = 1;
for (int i = 0; i + 1 < s_route_n; i++) {
ln.p1 = { s_route_px[i].x + a.x1, s_route_px[i].y + a.y1 };
ln.p2 = { s_route_px[i + 1].x + a.x1, s_route_px[i + 1].y + a.y1 };
lv_draw_line(layer, &ln);
}
}
lv_draw_rect_dsc_t pill;
lv_draw_rect_dsc_init(&pill);
pill.bg_color = lv_color_hex(theme::BG);
pill.bg_opa = LV_OPA_COVER;
pill.radius = 4;
lv_draw_label_dsc_t ld;
lv_draw_label_dsc_init(&ld);
ld.font = THEME_FONT_SMALL;
ld.color = lv_color_hex(theme::TEXT);
for (int k = 0; k < s_mark_count; k++) {
const Mark& m = s_marks[k];
if (!m.vis || !m.lw) continue;
lv_area_t pa = { a.x1 + m.lx, a.y1 + m.ly, a.x1 + m.lx + m.lw - 1, a.y1 + m.ly + m.lh - 1 };
lv_draw_rect(layer, &pill, &pa);
lv_area_t ta = { pa.x1 + 7, pa.y1 + 1, pa.x2 - 7, pa.y2 - 1 };
ld.text = m.name;
lv_draw_label(layer, &ld, &ta);
}
lv_draw_rect_dsc_t dot;
lv_draw_rect_dsc_init(&dot);
dot.bg_opa = LV_OPA_COVER;
dot.radius = LV_RADIUS_CIRCLE;
dot.border_color = lv_color_hex(theme::BG);
dot.border_opa = LV_OPA_COVER;
dot.border_width = 2;
const int half = MARK_D / 2;
for (int k = 0; k < s_mark_count; k++) {
const Mark& m = s_marks[k];
if (!m.vis) continue;
dot.bg_color = lv_color_hex(m.col);
lv_area_t da = { a.x1 + m.x - half, a.y1 + m.y - half, a.x1 + m.x + half - 1, a.y1 + m.y + half - 1 };
lv_draw_rect(layer, &dot, &da);
}
// Hop numbers of the route being picked: a badge at the dot's top right.
lv_draw_rect_dsc_t badge;
lv_draw_rect_dsc_init(&badge);
badge.bg_color = lv_color_hex(theme::ACCENT);
badge.bg_opa = LV_OPA_COVER;
badge.radius = LV_RADIUS_CIRCLE;
lv_draw_label_dsc_t bl;
lv_draw_label_dsc_init(&bl);
bl.font = THEME_FONT_SMALL;
bl.color = lv_color_hex(theme::BG);
bl.align = LV_TEXT_ALIGN_CENTER;
for (int k = 0; k < s_mark_count; k++) {
const Mark& m = s_marks[k];
if (!m.vis || !m.order) continue;
lv_area_t ba = { a.x1 + m.x + 1, a.y1 + m.y - 17, a.x1 + m.x + 15, a.y1 + m.y - 3 };
lv_draw_rect(layer, &badge, &ba);
char t[4];
snprintf(t, sizeof(t), "%d", m.order);
bl.text = t;
bl.text_local = true; // t is on this stack
lv_area_t ta = { ba.x1, ba.y1 + 0, ba.x2, ba.y2 };
lv_draw_label(layer, &bl, &ta);
}
}
// The marker at a point of the map area (px relative to it): a name pill under
// the point, else the nearest dot within a finger's reach; -1 if none.
static int hitMark(int px, int py) {
for (int k = 0; k < s_mark_count; k++) {
const Mark& m = s_marks[k];
if (m.vis && m.lw && px >= m.lx - 3 && px < m.lx + m.lw + 3 && py >= m.ly - 3 && py < m.ly + m.lh + 3) return k;
}
int best = -1, best_d = 18 * 18 + 1;
for (int k = 0; k < s_mark_count; k++) {
const Mark& m = s_marks[k];
if (!m.vis) continue;
int d = (m.x - px) * (m.x - px) + (m.y - py) * (m.y - py);
if (d < best_d) { best_d = d; best = k; }
}
return best;
}
} // namespace mapview
@@ -280,9 +398,14 @@ static void onDlResume(lv_event_t* e) { (void)e; s_ui->mapDownloadResume(); }
static void onDlDiscard(lv_event_t* e) { (void)e; s_ui->mapDownloadDiscard(); }
static void onNavTools(lv_event_t* e); // NavMap.h
static void onMapCredits(lv_event_t* e) { (void)e; s_ui->showToast(creditText(mapview::s_provider->attribution()), 4000); }
static void onMapMarker(lv_event_t* e) {
if (mapview::s_drag > 8) return; // the press was a pan that ended on a marker
s_ui->mapOpenMarker((int)(uintptr_t)lv_event_get_user_data(e));
static void onMapClick(lv_event_t* e) {
if (mapview::s_drag > 8) return; // the press was a pan
lv_point_t p;
lv_indev_get_point(lv_indev_active(), &p);
lv_area_t a;
lv_obj_get_coords((lv_obj_t*)lv_event_get_target(e), &a);
int k = mapview::hitMark(p.x - a.x1, p.y - a.y1);
if (k >= 0) s_ui->mapOpenMarker(k);
}
// The centre-on-me button in the accent colour while the map follows you.
@@ -394,6 +517,7 @@ void UITask::buildMap() {
lv_obj_add_event_cb(body, onMapPress, LV_EVENT_PRESSED, NULL);
lv_obj_add_event_cb(body, onMapPress, LV_EVENT_PRESSING, NULL);
lv_obj_add_event_cb(body, onMapLongPress, LV_EVENT_LONG_PRESSED, NULL);
lv_obj_add_event_cb(body, onMapClick, LV_EVENT_CLICKED, NULL); // a marker, hit-tested
_map_area = body;
for (int i = 0; i < mapview::GRID_COLS * mapview::GRID_ROWS; i++) {
@@ -431,6 +555,14 @@ void UITask::buildMap() {
lv_obj_remove_flag(_map_me, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_flag(_map_me, LV_OBJ_FLAG_HIDDEN);
mapview::s_mark_layer = lv_obj_create(_map_marks); // markers over the trail, target and own position
lv_obj_remove_style_all(mapview::s_mark_layer);
lv_obj_set_size(mapview::s_mark_layer, LV_PCT(100), LV_PCT(100));
lv_obj_remove_flag(mapview::s_mark_layer, LV_OBJ_FLAG_CLICKABLE);
lv_obj_remove_flag(mapview::s_mark_layer, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_event_cb(mapview::s_mark_layer, mapview::drawMarks, LV_EVENT_DRAW_MAIN, NULL);
mapview::s_sig_n = 0;
areaBuildLayer(body); // download frame / area outlines, over the tiles (MapAreas.h)
// Controls
@@ -499,6 +631,8 @@ void UITask::buildMap() {
lv_obj_align(_map_hint, LV_ALIGN_TOP_MID, 0, 50); // clear of own position, mid-map
lv_obj_add_flag(_map_hint, LV_OBJ_FLAG_HIDDEN);
if (_map_nav) buildNavControls(body);
_route_bar = _route_lbl = nullptr;
if (_route_pick) buildRouteBar(body);
layoutNow(body);
mapview::s_mark_count = 0; // the previous markers went with the previous screen
@@ -587,14 +721,7 @@ void UITask::layoutMap() {
lv_obj_add_flag(_map_me, LV_OBJ_FLAG_HIDDEN);
}
followShown(_map_center_btn, _map_follow);
const int half = mapview::MARK_D / 2;
for (int k = 0; k < mapview::s_mark_count; k++) {
const mapview::Mark& m = mapview::s_marks[k];
int dy = lv_obj_get_y(m.dot); // the dot within the marker (vertically centred on the name)
double x = mapview::lonToTileX(m.lon_e6 / 1e6, _map_z) * mapview::TILE_PX - left;
double y = mapview::latToTileY(m.lat_e6 / 1e6, _map_z) * mapview::TILE_PX - top;
lv_obj_set_pos(m.obj, (int)lround(x) - half, (int)lround(y) - half - dy); // the dot's centre on the spot
}
layoutMapMarks(left, top, w, h);
if (_map_nav) layoutNav();
if (over) setTextFmt(_map_zoom_lbl, "z%d (map z%d)", _map_z, _map_z - over); // magnified
@@ -696,9 +823,7 @@ void UITask::rebuildMapMarkers() {
mapview::s_mark_count = 0;
if (_map_nav) rebuildNavMarkers();
if (_map_nodes || !_map_nav) rebuildNodeMarkers();
for (int k = mapview::s_mark_count; k < mapview::s_mark_prev; k++) lv_obj_delete(mapview::s_marks[k].obj);
mapview::s_mark_prev = 0;
layoutNow(_map_marks); // so layoutMap() knows where each dot sits in its marker
}
void UITask::rebuildNodeMarkers() {
@@ -712,48 +837,142 @@ void UITask::rebuildNodeMarkers() {
}
}
// One clickable object per marker: a dot plus the name beside it. The object
// is placed so the dot's centre is on the spot, from where layout actually put
// the dot inside it (Mark::dot) -- the name may be taller than the dot. The
// markers are rebuilt every few seconds: one the same as the last time in its
// slot is kept as it is, so an unchanged map isn't redrawn under it.
// A marker is a record: where it is, its colour and name. Placement and
// painting are layoutMapMarks() and mapview::drawMarks(). The name's width is
// measured here, not per pan: a slot whose name is unchanged keeps it.
void UITask::addMapMark(uint8_t kind, int idx, int32_t lat_e6, int32_t lon_e6, uint32_t col, const char* text) {
if (!_map_marks || mapview::s_mark_count >= mapview::MAX_MARKS) return;
uint32_t h = 2166136261u; // FNV-1a of the name
for (const char* c = text; *c; c++) h = (h ^ (uint8_t)*c) * 16777619u;
int k = mapview::s_mark_count;
if (k < mapview::s_mark_prev) {
mapview::Mark& old = mapview::s_marks[k];
if (old.kind == kind && old.idx == idx && old.lat_e6 == lat_e6 && old.lon_e6 == lon_e6 && old.col == col && old.text_hash == h) {
mapview::s_mark_count++;
return;
}
lv_obj_delete(old.obj);
int k = mapview::s_mark_count++;
mapview::Mark& mk = mapview::s_marks[k];
bool same_name = k < mapview::s_mark_prev && mk.text_hash == h;
mk.lat_e6 = lat_e6; mk.lon_e6 = lon_e6; mk.idx = idx; mk.kind = kind; mk.col = col; mk.text_hash = h;
mk.vis = false; mk.lw = 0; mk.order = 0;
if (same_name) return;
size_t n = strlen(text);
if (n > (size_t)mapview::MARK_NAME) { // cut at a character boundary
n = mapview::MARK_NAME;
while (n > 0 && ((uint8_t)text[n] & 0xC0) == 0x80) n--;
}
lv_obj_t* m = lv_obj_create(_map_marks);
lv_obj_remove_style_all(m);
lv_obj_set_size(m, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_flex_flow(m, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(m, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_column(m, 3, 0);
lv_obj_add_flag(m, LV_OBJ_FLAG_EVENT_BUBBLE); // a drag starting here still pans
lv_obj_add_event_cb(m, onMapMarker, LV_EVENT_CLICKED, (void*)(uintptr_t)mapview::s_mark_count);
lv_obj_t* dot = lv_obj_create(m);
lv_obj_remove_style_all(dot);
lv_obj_set_size(dot, mapview::MARK_D, mapview::MARK_D);
lv_obj_set_style_radius(dot, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_color(dot, lv_color_hex(col), 0);
lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, 0);
lv_obj_set_style_border_color(dot, lv_color_hex(theme::BG), 0);
lv_obj_set_style_border_width(dot, 2, 0);
lv_obj_remove_flag(dot, LV_OBJ_FLAG_CLICKABLE);
lv_obj_t* name = mapPill(m, text);
lv_obj_remove_flag(name, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_style_pad_ver(name, 1, 0); // a marker's name stays small
lv_obj_set_style_radius(name, 4, 0);
memcpy(mk.name, text, n);
mk.name[n] = '\0';
lv_point_t sz;
lv_text_get_size(&sz, mk.name, THEME_FONT_SMALL, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_NONE);
mk.tw = (int16_t)sz.x;
}
mapview::Mark& mk = mapview::s_marks[mapview::s_mark_count++];
mk.lat_e6 = lat_e6; mk.lon_e6 = lon_e6; mk.idx = idx; mk.kind = kind; mk.col = col; mk.text_hash = h; mk.obj = m; mk.dot = dot;
// Places the markers for the view: a dot on each spot inside it, and the name
// beside it (right, left, above, below) where it covers neither the map's
// controls, nor another name, nor another dot -- in marker order, so
// waypoints and live shares keep their names before the many nodes. Only the
// markers that changed since the last layout are repainted: a rebuild that
// changed nothing leaves the tiles under them alone.
void UITask::layoutMapMarks(double left, double top, int w, int h) {
using namespace mapview;
if (!s_mark_layer) return;
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];
int nu = 0;
const int bottom = _map_nav ? navmap::BAR_H : 0;
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 };
used[nu++] = { 0, 0, W, 36 }; // the going pills
used[nu++] = { 0, (int16_t)(H - 34), 180, (int16_t)h }; // credit / scale
used[nu++] = { (int16_t)(W - 50), (int16_t)(H - 50), W, (int16_t)h }; // centre button
if (bottom) used[nu++] = { 0, H, W, (int16_t)h };
for (int k = 0; k < s_mark_count; k++) {
Mark& m = s_marks[k];
double x = lonToTileX(m.lon_e6 / 1e6, _map_z) * TILE_PX - left;
double y = latToTileY(m.lat_e6 / 1e6, _map_z) * TILE_PX - top;
m.vis = x > -half - 4 && x < w + half + 4 && y > -half - 4 && y < h + half + 4;
m.lw = 0;
m.order = 0;
if (_route_pick && m.vis && m.kind == MK_NODE && m.idx < _nearby->count()) {
const NearbyModel::Entry& ne = _nearby->at(m.idx);
int at = ne.has_key ? _core->route.indexOf(ne.pub_key) : -1;
if (at >= 0) m.order = (uint8_t)(at + 1);
}
if (!m.vis) continue;
m.x = (int16_t)lround(x);
m.y = (int16_t)lround(y);
used[nu++] = { (int16_t)(m.x - half), (int16_t)(m.y - half), (int16_t)(m.x + half), (int16_t)(m.y + half) };
}
auto clearOf = [&](int x1, int y1, int x2, int y2) {
if (x1 < 2 || y1 < 2 || x2 > w - 2 || y2 > h - 2) return false;
for (int i = 0; i < nu; i++)
if (x1 <= used[i].x2 && x2 >= used[i].x1 && y1 <= used[i].y2 && y2 >= used[i].y1) return false;
return true;
};
int named = 0;
for (int k = 0; k < s_mark_count && named < MARK_LABELS_MAX; k++) {
Mark& m = s_marks[k];
if (!m.vis || !m.name[0]) continue;
int lw = m.tw + 14;
const struct { int x, y; } at[4] = {
{ m.x + half + 3, m.y - lh / 2 }, { m.x - half - 3 - lw, m.y - lh / 2 },
{ m.x - lw / 2, m.y - half - 2 - lh }, { m.x - lw / 2, m.y + half + 2 } };
for (const auto& c : at) {
if (!clearOf(c.x, c.y, c.x + lw - 1, c.y + lh - 1)) continue;
m.lx = (int16_t)c.x; m.ly = (int16_t)c.y; m.lw = (int16_t)lw; m.lh = (int16_t)lh;
used[nu++] = { m.lx, m.ly, (int16_t)(m.lx + lw - 1), (int16_t)(m.ly + lh - 1) };
named++;
break;
}
}
// What changed since the last layout, repainted (a pan changes all: the layer at once).
lv_area_t la;
lv_obj_get_coords(s_mark_layer, &la);
auto bbox = [&](int x, int y, int lx, int ly, int lw, lv_area_t& a) {
int x1 = x - half, y1 = y - half, x2 = x + half, y2 = y + half;
if (lw) { x1 = min(x1, lx); y1 = min(y1, ly); x2 = max(x2, lx + lw); y2 = max(y2, ly + lh); }
a = { la.x1 + x1 - 1, la.y1 + y1 - 1, la.x1 + x2 + 1, la.y1 + y2 + 1 };
};
s_route_on = _route_pick;
if (_route_pick) { // the route's points, in px (any distance away: the line is clipped by the layer)
for (int i = 0; i < s_route_n; i++) {
double rx = lonToTileX(s_route_lon[i] / 1e6, _map_z) * TILE_PX - left;
double ry = latToTileY(s_route_lat[i] / 1e6, _map_z) * TILE_PX - top;
s_route_px[i] = { (lv_value_precise_t)lround(max(-30000.0, min(30000.0, rx))), (lv_value_precise_t)lround(max(-30000.0, min(30000.0, ry))) };
}
lv_obj_invalidate(s_mark_layer); // the line may have moved: a pan repaints it anyway
}
int changed = 0;
const int n = max(s_mark_count, s_sig_n);
for (int k = 0; k < n; k++) {
bool have = k < s_mark_count, had = k < s_sig_n;
const Mark* m = have ? &s_marks[k] : nullptr;
MarkSig* g = had ? &s_sigs[k] : nullptr;
bool now_vis = have && m->vis, was_vis = had && g->vis;
bool same = now_vis == was_vis && (!now_vis || (m->x == g->x && m->y == g->y && m->lx == g->lx && m->ly == g->ly &&
m->lw == g->lw && m->col == g->col && m->text_hash == g->hash && m->order == g->order));
if (!same) changed++;
}
if (changed > 6) {
lv_obj_invalidate(s_mark_layer);
} else if (changed) {
for (int k = 0; k < n; k++) {
bool have = k < s_mark_count, had = k < s_sig_n;
const Mark* m = have ? &s_marks[k] : nullptr;
MarkSig* g = had ? &s_sigs[k] : nullptr;
bool now_vis = have && m->vis, was_vis = had && g->vis;
if (now_vis == was_vis && (!now_vis || (m->x == g->x && m->y == g->y && m->lx == g->lx && m->ly == g->ly &&
m->lw == g->lw && m->col == g->col && m->text_hash == g->hash && m->order == g->order))) continue;
lv_area_t a;
if (was_vis) { bbox(g->x, g->y, g->lx, g->ly, g->lw, a); lv_obj_invalidate_area(s_mark_layer, &a); }
if (now_vis) { bbox(m->x, m->y, m->lx, m->ly, m->lw, a); lv_obj_invalidate_area(s_mark_layer, &a); }
}
}
for (int k = 0; k < s_mark_count; k++) {
const Mark& m = s_marks[k];
s_sigs[k] = { m.x, m.y, m.lx, m.ly, m.lw, m.col, m.text_hash, m.order, m.vis };
}
s_sig_n = s_mark_count;
}
void UITask::mapPan(int dx, int dy) {
@@ -787,6 +1006,13 @@ void UITask::mapCenterOnMe() {
void UITask::mapOpenMarker(int idx) {
if (idx < 0 || idx >= mapview::s_mark_count) return;
const mapview::Mark& m = mapview::s_marks[idx];
if (_route_pick) { // Route > Map: a repeater tapped in (or out again)
if (m.kind != mapview::MK_NODE || m.idx >= _nearby->count()) return;
const NearbyModel::Entry& e = _nearby->at(m.idx);
if (!e.has_key) { showToast("Pick repeaters"); return; }
routeMapTap(e.pub_key, e.type);
return;
}
if (m.kind == mapview::MK_WAYPOINT) { navWaypointMenu(m.idx); return; }
if (m.kind == mapview::MK_LIVE) { navPick(navmap::code(navmap::T_LIVE, m.idx)); return; }
openNode(m.idx);
@@ -804,7 +1030,106 @@ void UITask::mapPointToLatLon(int x, int y, int32_t& lat_e6, int32_t& lon_e6) co
}
void UITask::mapLongPress(int x, int y) {
if (_map_nav) navDropAt(x, y);
if (_map_nav && !_route_pick) navDropAt(x, y);
}
// ── Picking a route (Route > Map) ─────────────────────────────────────────────
static void onRouteUndo(lv_event_t* e) { (void)e; s_ui->routeMapUndo(); }
static void onRouteDone(lv_event_t* e) { (void)e; s_ui->routeMapDone(); }
// A bar over the nav bar: the route so far, Undo (last hop out) and Done.
void UITask::buildRouteBar(lv_obj_t* body) {
_route_bar = lv_obj_create(body);
lv_obj_set_size(_route_bar, LV_PCT(100), navmap::BAR_H);
lv_obj_align(_route_bar, LV_ALIGN_BOTTOM_MID, 0, 0);
lv_obj_remove_flag(_route_bar, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_radius(_route_bar, 0, 0);
lv_obj_set_style_bg_color(_route_bar, lv_color_hex(theme::BG), 0);
lv_obj_set_style_bg_opa(_route_bar, LV_OPA_COVER, 0);
lv_obj_set_style_border_side(_route_bar, LV_BORDER_SIDE_TOP, 0);
lv_obj_set_style_border_color(_route_bar, lv_color_hex(theme::ACCENT), 0);
lv_obj_set_style_border_width(_route_bar, 2, 0);
lv_obj_set_style_pad_all(_route_bar, 4, 0);
_route_lbl = label(_route_bar, "", THEME_FONT_SMALL, theme::TEXT);
lv_label_set_long_mode(_route_lbl, LV_LABEL_LONG_WRAP);
lv_obj_set_width(_route_lbl, 150);
lv_obj_align(_route_lbl, LV_ALIGN_LEFT_MID, 2, 0);
lv_obj_t* done = lv_button_create(_route_bar);
lv_obj_set_size(done, 70, 34);
lv_obj_align(done, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_set_style_shadow_width(done, 0, 0);
lv_obj_set_style_radius(done, theme::RADIUS, 0);
lv_obj_add_event_cb(done, onRouteDone, LV_EVENT_CLICKED, NULL);
lv_obj_center(label(done, LV_SYMBOL_OK " Done", THEME_FONT_SMALL, theme::TEXT));
stylePrimary(done);
lv_obj_t* undo = lv_button_create(_route_bar);
lv_obj_set_size(undo, 64, 34);
lv_obj_align(undo, LV_ALIGN_RIGHT_MID, -76, 0);
lv_obj_set_style_shadow_width(undo, 0, 0);
lv_obj_set_style_radius(undo, theme::RADIUS, 0);
lv_obj_set_style_bg_color(undo, lv_color_hex(theme::SURFACE), 0);
lv_obj_add_event_cb(undo, onRouteUndo, LV_EVENT_CLICKED, NULL);
lv_obj_center(label(undo, LV_SYMBOL_CLOSE " Undo", THEME_FONT_SMALL, theme::TEXT));
routeMapRefresh();
}
void UITask::routeMapUndo() {
_core->route.removeLast();
routeMapRefresh();
}
// The route changed: its points for the line, the text, the markers.
void UITask::routeMapRefresh() {
using namespace mapview;
PathEditor& R = _core->route;
s_route_n = 0;
auto add = [&](int32_t lat, int32_t lon) {
if ((lat != 0 || lon != 0) && s_route_n < ROUTE_PTS) { s_route_lat[s_route_n] = lat; s_route_lon[s_route_n] = lon; s_route_n++; }
};
int32_t mlat, mlon;
if (_core->course.currentLocation(mlat, mlon)) add(mlat, mlon);
for (int i = 0; i < R.count(); i++) {
if (R.hop(i).known < 4) continue;
ContactInfo* c = the_mesh.lookupContactByPubKey(R.hop(i).key, 4);
if (c) add(c->gps_lat, c->gps_lon);
}
ContactInfo* d = the_mesh.lookupContactByPubKey(R.targetKey(), PUB_KEY_SIZE);
if (d) add(d->gps_lat, d->gps_lon);
if (_route_lbl) {
char t[96];
int o = snprintf(t, sizeof(t), "%d hop%s: ", R.count(), R.count() == 1 ? "" : "s");
for (int i = 0; i < R.count() && o < (int)sizeof(t) - 8; i++)
o += snprintf(t + o, sizeof(t) - o, "%s%.10s", i ? " > " : "", R.hop(i).name);
if (R.count() == 0) snprintf(t, sizeof(t), "Tap the repeaters in order, towards %.12s", R.targetName());
lv_label_set_text(_route_lbl, t);
}
if (_map_area) layoutMap();
}
// Frames this device and the node, so the hops between them are on screen.
void UITask::routeMapFrame() {
using namespace mapview;
routeMapRefresh();
if (s_route_n == 0 || !_map_area) return;
int32_t la0 = s_route_lat[0], la1 = s_route_lat[0], lo0 = s_route_lon[0], lo1 = s_route_lon[0];
for (int i = 1; i < s_route_n; i++) {
la0 = min(la0, s_route_lat[i]); la1 = max(la1, s_route_lat[i]);
lo0 = min(lo0, s_route_lon[i]); lo1 = max(lo1, s_route_lon[i]);
}
int w = lv_obj_get_width(_map_area), h = lv_obj_get_height(_map_area) - navmap::BAR_H;
int z = MAX_Z;
for (; z > MIN_Z; z--) {
double dx = fabs(lonToTileX(lo1 / 1e6, z) - lonToTileX(lo0 / 1e6, z)) * TILE_PX;
double dy = fabs(latToTileY(la0 / 1e6, z) - latToTileY(la1 / 1e6, z)) * TILE_PX;
if (dx < w * 0.7 && dy < h * 0.6) break;
}
_map_z = z;
_map_follow = false;
_map_cx = lonToTileX((lo0 + lo1) / 2e6, z);
_map_cy = latToTileY((la0 + la1) / 2e6, z) + mapCenterBias();
layoutMap();
}
+2 -3
View File
@@ -343,10 +343,9 @@ void UITask::layoutNav() {
lv_obj_set_pos(navmap::s_target_ring, tx - navmap::RING_D / 2, ty - navmap::RING_D / 2);
lv_obj_remove_flag(navmap::s_target_ring, LV_OBJ_FLAG_HIDDEN);
bool marked = false; // a waypoint / live marker already sits on the target
const int half = mapview::MARK_D / 2;
for (int k = 0; k < mapview::s_mark_count && !marked; k++) {
lv_obj_t* m = mapview::s_marks[k].obj;
marked = abs(lv_obj_get_x(m) + half - tx) <= 3 && abs(lv_obj_get_y(m) + lv_obj_get_y(mapview::s_marks[k].dot) + half - ty) <= 3;
const mapview::Mark& m = mapview::s_marks[k];
marked = m.vis && abs(m.x - tx) <= 3 && abs(m.y - ty) <= 3;
}
if (marked) lv_obj_add_flag(navmap::s_target_dot, LV_OBJ_FLAG_HIDDEN);
else lv_obj_remove_flag(navmap::s_target_dot, LV_OBJ_FLAG_HIDDEN);
+5 -1
View File
@@ -12,7 +12,7 @@ static NearbyModel::Entry s_node; // the node open in SCR_NODE (a copy: the li
// Its info card's values (UITask::_node_info is the card).
static lv_obj_t *s_nd_type, *s_nd_status, *s_nd_dist, *s_nd_pos, *s_nd_heard, *s_nd_signal, *s_nd_id;
enum : uint8_t { NODE_MSG, NODE_PING, NODE_FAV, NODE_ADD, NODE_DELETE, NODE_NAV, NODE_ADMIN, NODE_WAYPOINT, NODE_PIN,
NODE_RESET_PATH, NODE_SHARE };
NODE_RESET_PATH, NODE_SHARE, NODE_ROUTE };
static void onNearbyChip(lv_event_t* e) { s_ui->setNearbyFilter((uint8_t)(uintptr_t)lv_event_get_user_data(e)); }
static void onNearbySort(lv_event_t* e) { (void)e; s_ui->toggleNearbySort(); }
@@ -358,6 +358,7 @@ void UITask::buildNode() {
nodeFavText();
if (contact && e.has_key) actionRow(g, UI_SYMBOL_TACK " Pin to Home", onNodeAction, (void*)(uintptr_t)NODE_PIN);
ContactInfo* ci = (contact && e.has_key) ? the_mesh.lookupContactByPubKey(e.pub_key, PUB_KEY_SIZE) : nullptr;
if (ci) actionRow(g, UI_SYMBOL_ROUTE " Route and trace", onNodeAction, (void*)(uintptr_t)NODE_ROUTE);
if (ci && contactctl::hasPath(*ci))
_node_path_lbl = actionRow(g, LV_SYMBOL_REFRESH " Reset path", onNodeAction, (void*)(uintptr_t)NODE_RESET_PATH);
if (ci) actionRow(g, LV_SYMBOL_UPLOAD " Share with nodes in range", onNodeAction, (void*)(uintptr_t)NODE_SHARE);
@@ -524,6 +525,9 @@ void UITask::nodeAction(uint8_t action) {
refreshNode();
}
break;
case NODE_ROUTE:
showRoute(e.pub_key);
break;
case NODE_SHARE:
showToast(contactctl::share(e.pub_key) ? "Sent to nodes in range" : "No advert saved to send");
break;
@@ -0,0 +1,302 @@
#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,
// `_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 };
static const int PICK_MAX = 96;
struct Pick { uint16_t idx; float km; }; // raw contact index, distance (<0: no position)
static Pick* s_pick = psramBuf<Pick>(PICK_MAX);
static int s_pick_n = 0;
static int cmpPick(const void* a, const void* b) {
float x = ((const Pick*)a)->km, y = ((const Pick*)b)->km;
if ((x < 0) != (y < 0)) return x < 0 ? 1 : -1; // those with no position last
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); }
} // namespace routeview
static void onRouteAct(lv_event_t* e) { s_ui->routeAct((uint8_t)(uintptr_t)lv_event_get_user_data(e)); }
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);
lv_obj_set_style_pad_all(b, 0, 0);
lv_obj_set_style_radius(b, theme::RADIUS, 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);
lv_obj_center(label(b, sym, THEME_FONT_SMALL, theme::TEXT));
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));
}
return c;
}
// Node detail > Route.
void UITask::showRoute(const uint8_t* pub_key) {
if (!_core->route.open(pub_key)) { showToast("Not a contact"); return; }
_route_pick = false;
_screen = SCR_ROUTE;
buildRoute();
}
void UITask::buildRoute() {
lv_obj_t* body = newScreen("Route", true);
_route_status = noteLabel(body, "", THEME_FONT_SMALL, theme::TEXT_MUTED);
_route_list = scrollList(body);
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;
routeRebuild();
}
// The rows and the line above them, from the editor's state.
void UITask::routeRebuild() {
if (!_route_list || !_route_status) return;
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);
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);
}
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);
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);
routeStatus();
}
void UITask::routeStatus() {
PathEditor& R = _core->route;
char t[96];
switch (R.state()) {
case PathEditor::RUNNING: snprintf(t, sizeof(t), "Tracing %d hops, out and back...", R.traceEntries()); 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);
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;
default:
snprintf(t, sizeof(t), "Route to %s: %d repeater%s%s", R.targetName(), R.count(), R.count() == 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();
}
// From loop() on SCR_ROUTE: a running trace's reply or timeout.
void UITask::routePoll() {
PathEditor& R = _core->route;
R.poll();
if (R.state() != _route_shown_state) routeRebuild();
}
void UITask::routeAct(uint8_t act) {
PathEditor& R = _core->route;
switch (act) {
case routeview::RT_ADD:
R.cancelTrace();
showRoutePick();
break;
case routeview::RT_MAP:
R.cancelTrace();
_route_pick = true;
openMap(false); // the nodes map: repeaters are tapped into the route
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");
}
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");
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");
routeRebuild();
break;
}
}
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);
routeRebuild();
}
// ── Picker: repeaters by distance ─────────────────────────────────────────────
void UITask::showRoutePick() {
_screen = SCR_ROUTE_PICK;
buildRoutePick();
}
void UITask::buildRoutePick() {
lv_obj_t* body = newScreen("Add repeater", true);
vlist::attach(body);
vlist::begin(body);
int32_t mlat, mlon;
bool own = _core->course.currentLocation(mlat, mlon);
routeview::s_pick_n = 0;
for (int i = 0; i < the_mesh.getNumContacts() && routeview::s_pick_n < routeview::PICK_MAX; i++) {
ContactInfo c;
if (!the_mesh.getContactByIdx(MAX_ANON_CONTACTS + i, c) || c.type != ADV_TYPE_REPEATER) continue;
bool has_pos = c.gps_lat != 0 || c.gps_lon != 0;
routeview::s_pick[routeview::s_pick_n++] = { (uint16_t)(MAX_ANON_CONTACTS + i),
own && has_pos ? geo::haversineKm(mlat, mlon, c.gps_lat, c.gps_lon) : -1.0f };
}
qsort(routeview::s_pick, routeview::s_pick_n, sizeof(routeview::Pick), routeview::cmpPick);
static const vlist::Kind ROW = {
theme::ROW_H,
[](lv_obj_t* list) { return vRow(list, onRoutePickRow, NULL); },
[](lv_obj_t* row, intptr_t i) {
ContactInfo c;
if (!the_mesh.getContactByIdx(routeview::s_pick[i].idx, c)) { rowSet(row, "?", NULL); return; }
char sub[40] = "";
float km = routeview::s_pick[i].km;
if (km >= 0) geo::fmtDist(sub, sizeof(sub), km, (s_ui->_prefs && s_ui->_prefs->units_imperial));
else snprintf(sub, sizeof(sub), "no position");
int at = s_ui->_core->route.indexOf(c.id.pub_key);
if (at >= 0) { char t[48]; snprintf(t, sizeof(t), "%s - hop %d", sub, at + 1); rowSet(row, c.name, t); }
else rowSet(row, c.name, sub);
},
};
for (int i = 0; i < routeview::s_pick_n; i++) vlist::add(body, &ROW, i);
if (!routeview::s_pick_n)
vlist::own(body, noteLabel(body, "No repeaters yet. They show up here once their advert is heard.", THEME_FONT_BODY, theme::TEXT_MUTED));
vlist::end(body);
}
void UITask::routePick(int row) {
if (row < 0 || row >= routeview::s_pick_n) return;
ContactInfo c;
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; }
_screen = SCR_ROUTE;
_nav_back = true;
buildRoute();
_nav_back = false;
}
// ── From the map ──────────────────────────────────────────────────────────────
// A tap on a repeater of the nodes map while the route is picked: in, or out
// again if it is already a hop.
void UITask::routeMapTap(const uint8_t* pub_key, uint8_t type) {
PathEditor& R = _core->route;
if (type != ADV_TYPE_REPEATER) { showToast("Pick repeaters"); return; }
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; }
routeMapRefresh();
}
void UITask::routeMapDone() {
_route_pick = false;
_screen = SCR_ROUTE;
_nav_back = true;
buildRoute();
_nav_back = false;
}
@@ -344,6 +344,10 @@ void UITask::loop() {
if (_screen == SCR_WIFI) pollWifiScan();
if (_screen == SCR_STORAGE) pollStorage();
if (_screen == SCR_ADMIN) adminPoll();
if (_screen == SCR_ROUTE && (int32_t)(millis() - _next_route_ms) >= 0) {
_next_route_ms = millis() + 200;
routePoll();
}
roomPoll();
if (_screen == SCR_CLOCK && (int32_t)(millis() - _next_clock_ms) >= 0) {
_next_clock_ms = millis() + 100; // stopwatch tenths
@@ -1085,6 +1089,12 @@ void UITask::back() {
case SCR_BOT: if (_nav_overlay) navClosePopup(); else showHome(); break;
case SCR_DIAG: if (_nav_overlay) navClosePopup(); else showHome(); break;
case SCR_ADMIN_PICK: showHome(); break;
case SCR_ROUTE: // back to the node; a trace in flight is dropped
_core->route.cancelTrace();
_screen = SCR_NODE;
buildNode();
break;
case SCR_ROUTE_PICK: _screen = SCR_ROUTE; buildRoute(); break;
case SCR_OTA: otaLeave(); break;
case SCR_COMPASS: showHome(); break;
case SCR_BATT: showSchemaSettings(PG_POWER); break;
@@ -1141,6 +1151,7 @@ void UITask::back() {
if (_node_from_scan) showScanPopup();
break;
case SCR_MAP:
if (_route_pick) { routeMapDone(); break; }
if (_nav_overlay) navClosePopup();
else if (areaSelecting()) areaSelectEnd();
else if (_dl_overlay) mapDownloadClose();
@@ -1194,6 +1205,7 @@ static void onOpenSchemaPage(lv_event_t* e); // Radio > Advanced
#include "WifiScreen.h"
#include "ChannelScreen.h"
#include "AdminScreen.h"
#include "RouteScreen.h"
#include "BotScreen.h"
#include "ThreadView.h"
+28 -1
View File
@@ -112,6 +112,23 @@ public:
void mapCenterOnMe();
void mapPan(int dx, int dy);
void mapOpenMarker(int idx);
// Node detail > Route (RouteScreen.h) and picking its hops on the map
void showRoute(const uint8_t* pub_key);
void buildRoute();
void routeRebuild();
void routeStatus();
void routePoll();
void routeAct(uint8_t act);
void routeOp(int code);
void showRoutePick();
void buildRoutePick();
void routePick(int row);
void routeMapTap(const uint8_t* pub_key, uint8_t type);
void routeMapDone();
void routeMapRefresh();
void routeMapUndo();
void routeMapFrame();
void buildRouteBar(lv_obj_t* body);
void mapDownloadPopup();
// Map areas (MapAreas.h)
void areaBuildLayer(lv_obj_t* body);
@@ -347,7 +364,7 @@ public:
void openAdminFor(const ContactInfo& ci, bool from_picker) { (void)from_picker; openAdmin(ci.id.pub_key); }
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 };
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 };
void buildStatusBar();
void refreshStatusBar();
@@ -407,6 +424,7 @@ private:
void refreshScanPopup();
void buildMap();
void layoutMap();
void layoutMapMarks(double left, double top, int w, int h);
void mapLoop();
void rebuildMapMarkers();
void rebuildNodeMarkers();
@@ -610,6 +628,15 @@ private:
lv_obj_t* _node_delete_lbl = nullptr;
lv_obj_t* _node_path_lbl = nullptr;
bool _node_from_map = false;
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;
uint32_t _next_route_ms = 0;
bool _route_pick = false; // the nodes map taps repeaters into the route
lv_obj_t* _route_bar = nullptr;
lv_obj_t* _route_lbl = nullptr;
// Map (SCR_MAP): view centre in fractional tile coords at zoom _map_z
double _map_cx = 0, _map_cy = 0;
+193 -2
View File
@@ -5,6 +5,140 @@
#include "NavView.h"
#include "TabBar.h"
// ── 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`).
class RouteView {
UITask* _task = nullptr;
bool _active = false;
bool _pick = false; // the Add row was chosen: NearbyScreen takes over the pick
int _sel = 0, _scroll = 0;
PopupMenu _menu; // one hop: up / down / remove
int _menu_hop = 0;
PathEditor& R() { return _task->core().route; }
// Rows: the hops, Add, Trace, Save, Keep.
int rows() { return R().count() + 4; }
int addRow() { return R().count(); }
void openHopMenu(int i) {
_menu_hop = i;
_menu.begin("Hop", 3);
_menu.addItem("Move up");
_menu.addItem("Move down");
_menu.addItem("Remove");
}
public:
bool active() const { return _active; }
bool takePick() { bool p = _pick; _pick = false; return p; }
bool open(UITask* task, const uint8_t* pub_key) {
_task = task;
if (!task->core().route.open(pub_key)) return false;
_active = true;
_pick = false;
_sel = _scroll = 0;
_menu.active = false;
return true;
}
void show() { _active = true; } // back from picking a repeater: the list as it was
void close() {
if (_task) R().cancelTrace();
_active = false;
_menu.active = false;
}
int render(DisplayDriver& display) {
PathEditor& r = R();
r.poll();
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());
}
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) {
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");
}
const int rw = right[0] ? display.getTextWidth(right) + display.getCharWidth() : 0;
display.drawTextEllipsized(2, y, display.width() - reserve - 4 - rw, left, sel);
if (right[0]) {
display.setColor(sel ? DisplayDriver::DARK : DisplayDriver::LIGHT);
display.drawTextRightAlign(display.width() - reserve - 2, y, right);
}
});
if (_menu.active) _menu.render(display);
return r.state() == PathEditor::RUNNING ? 100 : 2000;
}
// Returns true: the key was used (the view is modal).
bool handleInput(char c) {
PathEditor& r = R();
if (_menu.active) {
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++; }
else r.remove(_menu_hop);
if (_sel >= rows()) _sel = rows() - 1;
_menu.active = false;
}
return true;
}
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; }
return true;
}
if (c != KEY_ENTER && c != KEY_CONTEXT_MENU) return true;
if (_sel < n) { openHopMenu(_sel); return true; }
switch (_sel - n) {
case 0:
if (r.full()) _task->showAlert("Path is full", 1000);
else { r.cancelTrace(); _pick = true; _active = false; }
break;
case 1:
if (r.state() == PathEditor::RUNNING) r.cancelTrace();
else if (!r.trace() && r.state() != PathEditor::FAILED) _task->showAlert("Busy", 800);
break;
case 2:
if (r.count() == 0) _task->showAlert("Add a repeater", 1000);
else _task->showAlert(r.save() ? "Path saved" : "Can't save", 1000);
break;
default: // Keep: not replaced by a path heard, nor dropped after a failed send
r.setPinned(!r.pinned());
}
return true;
}
};
// ── Nearby Nodes ──────────────────────────────────────────────────────────────
// One list / detail / action-menu interaction path over two sources:
// SRC_STORED — contacts known to the mesh (distance / bearing / last-heard)
@@ -22,7 +156,7 @@ class NearbyScreen : public UIScreen, protected NearbyModel {
// ── action-menu actions (matched by id, not by row index) ────────────────────
enum Action : uint8_t { ACT_NAV, ACT_PING, ACT_WAYPOINT, ACT_LOCATOR,
ACT_ADD, ACT_DELETE, ACT_FAV, ACT_PIN, ACT_ADMIN, ACT_SORT, ACT_SCAN, ACT_MAP,
ACT_TELEM, ACT_RESET_PATH, ACT_SHARE };
ACT_TELEM, ACT_RESET_PATH, ACT_SHARE, ACT_ROUTE };
// Set by UITask::pickAdminTarget() (Tools > Admin, which is remote-only):
// while true, ENTER on an eligible row (a stored repeater/room contact) hands
@@ -33,6 +167,13 @@ class NearbyScreen : public UIScreen, protected NearbyModel {
// startPickBotChannel()/startPickBotRoom() pick-mode idiom.
bool _pick_admin_target = false;
// Path view (RouteView above) and its repeater pick: Add hands the list over
// filtered to repeaters; Enter on one adds it, Back returns without.
RouteView _route;
bool _route_pick = false;
uint8_t _route_filter = F_ALL; // the filter to restore after the pick
uint8_t _route_key[PUB_KEY_SIZE];
int _sel;
int _scroll;
bool _detail;
@@ -374,6 +515,7 @@ class NearbyScreen : public UIScreen, protected NearbyModel {
buildTelemLabel(contactctl::telemetry(*ci));
addValue(_telem_label, ACT_TELEM);
}
if (ci) add("Path / trace", ACT_ROUTE);
if (ci && contactctl::hasPath(*ci)) add("Reset path", ACT_RESET_PATH);
if (ci) add("Share nearby", ACT_SHARE);
if (is_admin_target) add("Admin", ACT_ADMIN);
@@ -457,6 +599,14 @@ class NearbyScreen : public UIScreen, protected NearbyModel {
if (e && contactctl::resetPath(e->pub_key)) _task->showAlert("Path reset", 800);
break;
}
case ACT_ROUTE: {
const Entry* e = selected();
if (e && e->has_key && _route.open(_task, e->pub_key)) {
memcpy(_route_key, e->pub_key, PUB_KEY_SIZE);
_detail = true; // Back from the path lands on the node
}
break;
}
case ACT_SHARE: {
const Entry* e = selected();
_task->showAlert(e && contactctl::share(e->pub_key) ? "Shared nearby" : "No advert saved", 1000);
@@ -487,6 +637,25 @@ class NearbyScreen : public UIScreen, protected NearbyModel {
}
}
// Add on the Path view: the list, repeaters only, until one is picked.
void startRoutePick() {
_route_pick = true;
_route_filter = _filter;
_filter = F_RPT;
_detail = false;
_sel = _scroll = 0;
refresh();
}
void endRoutePick() {
_route_pick = false;
_filter = _route_filter;
refresh();
int i = findContact(_route_key); // the node whose path it is, selected again
if (i >= 0) _sel = i;
_detail = true;
_route.show();
}
// ── detail rendering ──────────────────────────────────────────────────────────
void renderStoredDetail(DisplayDriver& display) {
const Entry& e = _entries[_sel];
@@ -762,6 +931,8 @@ public:
_confirm.active = false;
_pinging = false;
_pick_admin_target = false; // stale pick-mode from a previous visit shouldn't linger
_route.close();
_route_pick = false;
resetPingLines();
_task->clearPing();
refreshStored();
@@ -788,6 +959,9 @@ public:
_detail_refresh_ms = millis();
}
// ── path view (over the detail) ──────────────────────────────────────────
if (_route.active()) return _route.render(display);
// ── navigate-to-node view ────────────────────────────────────────────────
if (_nav && _sel < _count) {
const Entry& e = _entries[_sel];
@@ -940,6 +1114,13 @@ public:
}
bool handleInput(char c) override {
// ── path view: modal; Add hands the Nodes list over for one repeater ──────
if (_route.active()) {
_route.handleInput(c);
if (_route.takePick()) startRoutePick();
return true;
}
// ── navigate-to-node view — any nav key returns to detail ─────────────────
if (_nav) {
if (c == KEY_CANCEL) _nav = false; // only Back leaves a navigate view
@@ -998,16 +1179,25 @@ public:
// ── list view ───────────────────────────────────────────────────────────
if (c == KEY_CANCEL) {
if (_route_pick) { endRoutePick(); return true; }
if (_pick_admin_target) { _pick_admin_target = false; _task->leaveTool(); return true; }
if (_source == SRC_SCAN) leaveScan();
else _task->leaveTool();
return true;
}
if (c == KEY_CONTEXT_MENU) { openActionMenu(); return true; }
if (c == KEY_CONTEXT_MENU) { if (!_route_pick) openActionMenu(); return true; }
// drawList() reclamps _scroll from _sel every render.
if (c == KEY_UP && _count > 0) { _sel = (_sel > 0) ? _sel - 1 : _count - 1; return true; }
if (c == KEY_DOWN && _count > 0) { _sel = (_sel < _count - 1) ? _sel + 1 : 0; return true; }
if (c == KEY_ENTER) {
if (_route_pick) { // a repeater into the path
const Entry* e = selected();
if (e && e->has_key && e->type == ADV_TYPE_REPEATER) {
if (!_task->core().route.addRepeater(e->pub_key)) _task->showAlert("Already the last hop", 1000);
endRoutePick();
}
return true;
}
if (_pick_admin_target) {
const Entry* e = selected();
ContactInfo ci;
@@ -1024,6 +1214,7 @@ public:
_detail_refresh_ms = millis();
return true;
}
if (_route_pick) return true; // the filter stays on repeaters
if (keyIsPrev(c)) { tabbar::turn(_task->getDisplay(), filterLabels(), _filter, -1); _filter = (_filter + F_COUNT - 1) % F_COUNT; refresh(); return true; }
if (keyIsNext(c)) { tabbar::turn(_task->getDisplay(), filterLabels(), _filter, +1); _filter = (_filter + 1) % F_COUNT; refresh(); return true; }
return false;
+6
View File
@@ -5,6 +5,12 @@
#define OUT_PATH_UNKNOWN 0xFF
// ContactInfo::flags bit 4: the route in out_path was set by hand (the device's
// Route editor) and is kept: a path heard from the contact, or a message that
// goes unanswered, doesn't replace it. Bit 0 is the favourite star, bits 1-3 the
// telemetry permissions. Reset path (or a new route saved) clears / sets it.
#define CONTACT_FLAG_ROUTE_PINNED 0x10
struct ContactInfo {
mesh::Identity id;
char name[32];