From 140ea7c008f6292865200d7044d889056c4ade46 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:18:05 +0200 Subject: [PATCH] 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 --- docs/solo/developer/ui-core.md | 1 + examples/companion_radio/MyMesh.cpp | 19 + examples/companion_radio/MyMesh.h | 14 +- examples/companion_radio/MyMeshContacts.h | 26 ++ examples/companion_radio/MyMeshPing.h | 26 +- .../companion_radio/ui-core/MessageHistory.h | 3 +- examples/companion_radio/ui-core/PathEditor.h | 274 +++++++++++ examples/companion_radio/ui-core/PingEngine.h | 30 ++ examples/companion_radio/ui-core/UiCore.h | 5 +- examples/companion_radio/ui-lvgl/MapScreen.h | 429 +++++++++++++++--- examples/companion_radio/ui-lvgl/NavMap.h | 5 +- examples/companion_radio/ui-lvgl/NodesView.h | 6 +- .../companion_radio/ui-lvgl/RouteScreen.h | 302 ++++++++++++ examples/companion_radio/ui-lvgl/UITask.cpp | 12 + examples/companion_radio/ui-lvgl/UITask.h | 29 +- .../companion_radio/ui-new/NearbyScreen.h | 195 +++++++- src/helpers/ContactInfo.h | 6 + 17 files changed, 1306 insertions(+), 76 deletions(-) create mode 100644 examples/companion_radio/ui-core/PathEditor.h create mode 100644 examples/companion_radio/ui-lvgl/RouteScreen.h diff --git a/docs/solo/developer/ui-core.md b/docs/solo/developer/ui-core.md index 0065065d..1c850628 100644 --- a/docs/solo/developer/ui-core.md +++ b/docs/solo/developer/ui-core.md @@ -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 diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index c1444225..08b786eb 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -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(); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 0764190f..7f55c7ae 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -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); diff --git a/examples/companion_radio/MyMeshContacts.h b/examples/companion_radio/MyMeshContacts.h index 968e95f2..e8623d7f 100644 --- a/examples/companion_radio/MyMeshContacts.h +++ b/examples/companion_radio/MyMeshContacts.h @@ -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; diff --git a/examples/companion_radio/MyMeshPing.h b/examples/companion_radio/MyMeshPing.h index ea19e024..f19c5a53 100644 --- a/examples/companion_radio/MyMeshPing.h +++ b/examples/companion_radio/MyMeshPing.h @@ -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; diff --git a/examples/companion_radio/ui-core/MessageHistory.h b/examples/companion_radio/ui-core/MessageHistory.h index a6b37dbd..a2d3ebf4 100644 --- a/examples/companion_radio/ui-core/MessageHistory.h +++ b/examples/companion_radio/ui-core/MessageHistory.h @@ -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; } diff --git a/examples/companion_radio/ui-core/PathEditor.h b/examples/companion_radio/ui-core/PathEditor.h new file mode 100644 index 00000000..d26cc9b4 --- /dev/null +++ b/examples/companion_radio/ui-core/PathEditor.h @@ -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; +}; diff --git a/examples/companion_radio/ui-core/PingEngine.h b/examples/companion_radio/ui-core/PingEngine.h index b47bf99c..d6670bb8 100644 --- a/examples/companion_radio/ui-core/PingEngine.h +++ b/examples/companion_radio/ui-core/PingEngine.h @@ -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. diff --git a/examples/companion_radio/ui-core/UiCore.h b/examples/companion_radio/ui-core/UiCore.h index 60422003..672940c9 100644 --- a/examples/companion_radio/ui-core/UiCore.h +++ b/examples/companion_radio/ui-core/UiCore.h @@ -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 diff --git a/examples/companion_radio/ui-lvgl/MapScreen.h b/examples/companion_radio/ui-lvgl/MapScreen.h index 3dc3cb00..0a2f0545 100644 --- a/examples/companion_radio/ui-lvgl/MapScreen.h +++ b/examples/companion_radio/ui-lvgl/MapScreen.h @@ -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(MAX_MARKS); +static MarkSig* s_sigs = psramBuf(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(); } diff --git a/examples/companion_radio/ui-lvgl/NavMap.h b/examples/companion_radio/ui-lvgl/NavMap.h index eb57906b..c53c2bce 100644 --- a/examples/companion_radio/ui-lvgl/NavMap.h +++ b/examples/companion_radio/ui-lvgl/NavMap.h @@ -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); diff --git a/examples/companion_radio/ui-lvgl/NodesView.h b/examples/companion_radio/ui-lvgl/NodesView.h index e90d498d..5dcf5f5d 100644 --- a/examples/companion_radio/ui-lvgl/NodesView.h +++ b/examples/companion_radio/ui-lvgl/NodesView.h @@ -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; diff --git a/examples/companion_radio/ui-lvgl/RouteScreen.h b/examples/companion_radio/ui-lvgl/RouteScreen.h new file mode 100644 index 00000000..ce20a726 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/RouteScreen.h @@ -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_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; +} diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index 9f30b699..236a269f 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -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" diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index ae109ae0..4bf7f7f6 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -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; diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index ddf2b01a..54082748 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -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; diff --git a/src/helpers/ContactInfo.h b/src/helpers/ContactInfo.h index ede977ca..a70fca11 100644 --- a/src/helpers/ContactInfo.h +++ b/src/helpers/ContactInfo.h @@ -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];