From 120ddfc8305d61179f17e09da41f070c1175e779 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:20:55 +0200 Subject: [PATCH] feat(route): trace per link, to the node or the last repeater, find where it stops; channel order re-sorts on entry Core: PathEditor results are links (node k hearing k-1 and back); toNode() off traces only the repeaters (a companion answers only with repeat on); probe() traces to each repeater in turn after no reply; failures carry a reason; an empty list saves "direct". PingEngine takes over a slot left past its timeout. A kept route: the dropped heard path isn't announced to the app nor bumps lastmod; an app update keeps the flag only if it leaves the route. Marker boxes sized for every dot and name (overflow). ui-lvgl Route: one line per node with out / back dB coloured by quality, tap a hop to move / remove, tap the node to include it, Keep in the header, Find where beside a no-reply status. ui-new Path: To node / Find break rows, result alerts, cursor kept on its action. Channels: ui-new re-sorts the list on entering it, cursor on the same channel. Docs: Path and trace, Channel order. Sim hooks: open route, add channel, open messages. Checked in the browser sim (L2 + ui-new peer + repeater); not on hardware. Co-Authored-By: Claude Opus 5.5 --- docs/solo/contacts.md | 31 ++ docs/solo/developer/ui-core.md | 2 +- docs/solo/messages.md | 4 + docs/solo/settings.md | 2 +- examples/companion_radio/MyMesh.cpp | 23 +- examples/companion_radio/MyMesh.h | 3 +- examples/companion_radio/MyMeshContacts.h | 4 +- examples/companion_radio/main.cpp | 43 +++ examples/companion_radio/ui-core/PathEditor.h | 268 ++++++++++----- examples/companion_radio/ui-core/PingEngine.h | 17 +- examples/companion_radio/ui-lvgl/MapScreen.h | 4 +- .../companion_radio/ui-lvgl/RouteScreen.h | 316 +++++++++++------- examples/companion_radio/ui-lvgl/UITask.h | 11 +- .../companion_radio/ui-new/MessagesScreen.h | 14 +- .../companion_radio/ui-new/NearbyScreen.h | 209 +++++++++--- examples/companion_radio/ui-new/UITask.cpp | 4 + examples/companion_radio/ui-new/UITask.h | 2 + 17 files changed, 701 insertions(+), 256 deletions(-) diff --git a/docs/solo/contacts.md b/docs/solo/contacts.md index 60b05c38..f692e424 100644 --- a/docs/solo/contacts.md +++ b/docs/solo/contacts.md @@ -19,6 +19,7 @@ for what you can do with it: | Save waypoint, Set as target | Its position as a waypoint, or as the [Locator](./navigation.md#locator) target | | Fav, Pin to dial | Favourite, or a place on the Favourites page | | Telem | What this contact may ask for: None, Status, Stat+loc, Stat+sens, All. Shown when a [Privacy](./settings.md#privacy) setting is **Allowed** | +| Path / trace | The route to the node: edit it, trace it, save it (below) | | Reset path | Forgets the learned route; the next message floods and finds a new one. Shown when there is a route | | Share nearby | Sends the contact's last advert to the nodes in direct range, so they can add it | | Admin | Remote admin, for a repeater or room (below) | @@ -38,6 +39,36 @@ back out, **Enter** opens its details and **Hold Enter** its options. > isn't a contact yet and **Delete**. The telemetry permissions are three > switches under **Can ask for**. +## Path and trace + +**Path / trace** in a contact's options shows the repeaters its messages go +through, in order, and the node at the end. **+ Add repeater** picks one from +the Nodes list, **Enter** on a hop moves or removes it, and **Left / Right** +moves it up or down. + +**Trace** sends a trace along the route and back the same way. Each node +answers with how well it heard the one before it, so every hop shows two +numbers in dB: the way out and the way back. Only a node that repeats can +answer: repeaters do, companions only with repeat on. **To node** off traces +just the repeaters, which still tells you whether the way to the node is good. + +With no reply, **Find break** traces to the first repeater, then the second, +and so on, and shows where it stops. If every repeater answers, the node +itself didn't. + +**Save path** makes the list the contact's route; saved empty, messages go +straight to the node. The route a node sends back usually replaces it again; +**Keep path** stops that, and keeps the route after failed sends too. +**Reset path** forgets it, keep included. + +> [!NOTE] +> **Wio Tracker L2:** **Route** in a node's card. Each repeater and the node +> take one line, with the link's out / back dB on the right: green when good, +> amber when fair, red when weak. Tap a repeater to move or remove it, tap +> the node to trace to it or stop at the last repeater. **Keep** is in the +> header, **Find where** appears next to a "No reply". The repeaters can also +> be tapped on the map, in order (**Map**); **Undo** takes the last one out. + ## Your own contact **Status › System**, **Enter** shows your contact as a QR code: the phone diff --git a/docs/solo/developer/ui-core.md b/docs/solo/developer/ui-core.md index 1c850628..6f054fc8 100644 --- a/docs/solo/developer/ui-core.md +++ b/docs/solo/developer/ui-core.md @@ -150,7 +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. +- Route and trace ✅ (tested on the L2 to a direct neighbour): `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`; an empty list saves "direct"). A trace goes out and back over `MyMesh::sendTrace` (A B dest B A, or A B A with `toNode()` off: only a node that retransmits can answer, so a companion target needs repeat on); `PingEngine::startTrace` keeps the SNR of every entry (`PingResult::snrs_x4`, up to `TRACE_SNR_MAX`) and `PathEditor` turns them into links (`linkSnrX4(k)`: node k hearing k-1, and k-1 hearing k). After no reply, `probe()` traces to the 1st, 2nd, ... repeater and stops at the first that doesn't answer (`reached()`). Failures carry a reason (`failure()`: no key, busy, send, nothing to trace); `PingEngine` takes over a slot left past its timeout by a view that went away. 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 and its lastmod back after the base class took a heard one, without announcing it to the app (`_path_kept`); `tickDmResends` doesn't drop it before the last resend; an app update that leaves the route as it is keeps the flag, one that changes it ends it; Reset path (device or app) clears it. 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/docs/solo/messages.md b/docs/solo/messages.md index 2fe6103c..869b5b3d 100644 --- a/docs/solo/messages.md +++ b/docs/solo/messages.md @@ -124,6 +124,10 @@ correctly whatever the keyboard is set to. | Pin to dial | Puts it on the Favourites page | | Edit, Delete | Renames it or changes its secret; removes it | +The order of the list is in Settings › Contacts › **Channel order**: as the +channels were added (Slots), the one with the newest message first (Newest), +or by name. Favourites stay on top while **Favourites first** is on. + **+ Add channel** at the end of the list adds: - **Public**: the default public channel, if you deleted it. diff --git a/docs/solo/settings.md b/docs/solo/settings.md index f224bdb9..03fef91a 100644 --- a/docs/solo/settings.md +++ b/docs/solo/settings.md @@ -13,7 +13,7 @@ board supports are shown. | **Radio** | TX power, preset, frequency, SF / BW / CR, saved presets, Auto pwr, the scope list, then the advanced options (below) | | **System** | Device name, low-battery shutdown, GPS power saving, units, the battery curve, reboot, factory reset | | **Keyboard** | ABC or T9 layout, the two scripts, CardKB mode | -| **Contacts** | Show all or favourites only (DMs, channels, rooms), favourites on top, contact expiry and prune, which heard nodes are added | +| **Contacts** | Show all or favourites only (DMs, channels, rooms), favourites on top, the order of the channel list, contact expiry and prune, which heard nodes are added | | **Privacy** | Whether your adverts carry your position and which position, who may ask for your status, location and sensor readings, the Bluetooth PIN | | **Messages** | Automatic resend of direct messages, the ten quick messages | diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 08b786eb..21b7af6c 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -449,6 +449,7 @@ int MyMesh::getRecentlyHeard(AdvertPath dest[], int max_num) { } void MyMesh::onContactPathUpdated(const ContactInfo &contact) { + if (_path_kept) return; // a kept route: the path heard isn't taken (onContactPathRecv) out_frame[0] = PUSH_CODE_PATH_UPDATED; memcpy(&out_frame[1], contact.id.pub_key, PUB_KEY_SIZE); _serial->writeFrame(out_frame, 1 + PUB_KEY_SIZE); // NOTE: app may not be connected @@ -1055,19 +1056,21 @@ bool MyMesh::onContactPathRecv(ContactInfo& contact, uint8_t* in_path, uint8_t i _path_same = contact.out_path_len == out_path_len && path_bytes <= MAX_PATH_SIZE && memcmp(contact.out_path, out_path, path_bytes) == 0; // A route kept by hand stays: the base class takes the path and handles the - // ACK / response riding on it, then the held route goes back (nothing is - // written for it: _path_same). + // ACK / response riding on it, then the held route goes back -- with its + // lastmod, and neither written nor announced to the app (_path_kept). const bool pinned = (contact.flags & CONTACT_FLAG_ROUTE_PINNED) != 0 && contact.out_path_len != OUT_PATH_UNKNOWN; uint8_t held_len = contact.out_path_len; + uint32_t held_mod = contact.lastmod; uint8_t held[MAX_PATH_SIZE]; if (pinned) { memcpy(held, contact.out_path, sizeof(held)); - _path_same = true; + _path_same = _path_kept = true; } bool r = BaseChatMesh::onContactPathRecv(contact, in_path, in_path_len, out_path, out_path_len, extra_type, extra, extra_len); - _path_same = false; + _path_same = _path_kept = false; if (pinned) { contact.out_path_len = held_len; + contact.lastmod = held_mod; memcpy(contact.out_path, held, sizeof(held)); } // A flood DM's ACK comes back folded into the return path, which the base @@ -1849,8 +1852,20 @@ void MyMesh::handleCmdFrame(size_t len) { uint32_t last_mod = getRTCClock()->getCurrentTime(); // fallback value if not present in cmd_frame if (recipient) { uint8_t saved_type = recipient->type; // type is authoritative from advert, not app + // A route kept on the device (CONTACT_FLAG_ROUTE_PINNED) is the device's + // own: the app's copy of the flags may predate it. Kept while the app + // leaves the route as it is; a route the app sets ends it. + const bool kept = (recipient->flags & CONTACT_FLAG_ROUTE_PINNED) != 0; + uint8_t held_len = recipient->out_path_len; + uint8_t held[MAX_PATH_SIZE]; + memcpy(held, recipient->out_path, sizeof(held)); updateContactFromFrame(*recipient, last_mod, cmd_frame, len); if (saved_type != ADV_TYPE_NONE) recipient->type = saved_type; + int held_bytes = held_len == OUT_PATH_UNKNOWN ? 0 : (held_len & 63) * ((held_len >> 6) + 1); + const bool same_route = recipient->out_path_len == held_len && held_bytes <= MAX_PATH_SIZE && + memcmp(recipient->out_path, held, held_bytes) == 0; + if (kept && same_route) recipient->flags |= CONTACT_FLAG_ROUTE_PINNED; + else recipient->flags &= ~CONTACT_FLAG_ROUTE_PINNED; recipient->lastmod = last_mod; dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); writeOKFrame(); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 7f55c7ae..42b5e2d8 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -263,7 +263,7 @@ public: // only reaches us if the last hop's retransmission is heard, so a route to // a far node goes out and back (A B dest B A). Returns the tag, 0 on failure. uint32_t sendTrace(const uint8_t* path, uint8_t hops, uint8_t hash_width, uint32_t* est_timeout = nullptr); - // Writes a route into a stored contact (hash_size 1..3 bytes, `hops` of them). + // Writes a route into a stored contact (hash_size 1..3 bytes, `hops` of them; 0: direct). // `pin` keeps it (CONTACT_FLAG_ROUTE_PINNED): not replaced by a heard path or a failed send. bool setContactPinned(const uint8_t* pub_key, bool pin); // keep / release the stored route bool setContactPath(const uint8_t* pub_key, const uint8_t* path, uint8_t hash_size, uint8_t hops, bool pin = false); @@ -693,6 +693,7 @@ private: unsigned long _last_input_ms = 0; unsigned long _last_rtc_save_ms = 0; bool _path_same = false; // onContactPathRecv(): the path heard is the one held + bool _path_kept = false; // onContactPathRecv(): a kept route, the path heard is dropped unsigned long _bot_last_ch_reply_ms; unsigned long _bot_last_room_reply_ms; unsigned long _next_auto_advert_ms; diff --git a/examples/companion_radio/MyMeshContacts.h b/examples/companion_radio/MyMeshContacts.h index e8623d7f..d7c45dd3 100644 --- a/examples/companion_radio/MyMeshContacts.h +++ b/examples/companion_radio/MyMeshContacts.h @@ -86,11 +86,11 @@ bool MyMesh::setContactPinned(const uint8_t* pub_key, bool pin) { } // A route typed on the device (ui-core/PathEditor.h): `hops` hashes of -// `hash_size` bytes into the contact's out_path, the packed length byte as +// `hash_size` bytes into the contact's out_path (0 hops: direct), the packed length byte as // the mesh stores a learned path (top 2 bits: size - 1). lastmod bumped, as // for resetContactPath(), so the app's copy follows. bool MyMesh::setContactPath(const uint8_t* pub_key, const uint8_t* path, uint8_t hash_size, uint8_t hops, bool pin) { - if (hash_size < 1 || hash_size > 3 || hops == 0 || hops * hash_size > MAX_PATH_SIZE) return false; + if (hash_size < 1 || hash_size > 3 || hops > 63 || hops * hash_size > MAX_PATH_SIZE) return false; ContactInfo* c = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); if (!c) return false; memcpy(c->out_path, path, hops * hash_size); diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 78b0a69a..27501df1 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -682,6 +682,49 @@ extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_open_admin_with_first_repeater() { } #endif +#ifdef DISPLAY_CLASS +// Opens Path / trace (ui-new) or Route (ui-lvgl) for the first contact of +// `type` (ADV_TYPE_*), as the node menu does. 1 if found, 0 if not yet. +extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_open_route(int type) { + if (!g_sim_ready) return 0; + int n = the_mesh.getNumContacts(); + for (int i = 0; i < n; i++) { + ContactInfo ci; + if (the_mesh.getContactByIdx(MAX_ANON_CONTACTS + i, ci) && ci.type == type) { + ui_task.openRouteFor(ci.id.pub_key); + return 1; + } + } + return 0; +} +#endif + +// A hashtag channel into the first free slot ("#name": the name is the +// passphrase, as chanctl::addHashtag does). The slot, or -1. +extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_add_channel(const char* name) { + if (!g_sim_ready || !name || !name[0]) return -1; + for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { + ChannelDetails ch; + if (the_mesh.getChannel(i, ch) && ch.name[0]) continue; + memset(&ch, 0, sizeof(ch)); + strncpy(ch.name, name, sizeof(ch.name) - 1); + uint8_t digest[32]; + mesh::Utils::sha256(digest, sizeof(digest), (const uint8_t*)name, (int)strlen(name)); + memcpy(ch.channel.secret, digest, 16); + return the_mesh.setChannelLocal((uint8_t)i, ch) ? i : -1; + } + return -1; +} + +#ifdef DISPLAY_CLASS +// The messages list (ui-lvgl: Messages; ui-new: the Messages screen). +extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_open_messages() { + if (!g_sim_ready) return 0; + ui_task.openMessagesList(); + return 1; +} +#endif + // Submits a login against the first known repeater/room contact via // MyMesh::sendRoomLogin() -- the exact same function AdminScreen's own // submit button calls (examples/companion_radio/ui-new/AdminScreen.h) -- diff --git a/examples/companion_radio/ui-core/PathEditor.h b/examples/companion_radio/ui-core/PathEditor.h index d26cc9b4..85ea4c0e 100644 --- a/examples/companion_radio/ui-core/PathEditor.h +++ b/examples/companion_radio/ui-core/PathEditor.h @@ -1,18 +1,27 @@ #pragma once // Path editor: the route to one node as a list of repeaters, traced and saved. -// Shared by both frontends (ui-new's Path screen, ui-lvgl's path editor and its -// "pick on the map" mode); the view only draws `hop()` rows and calls the -// actions below. +// Shared by both frontends (ui-new's Path screen, ui-lvgl's Route screen and +// its "pick on the map" mode); the view only draws rows and calls the actions +// below. // -// begin(contact) -- loads the contact's stored route (out_path), if any +// open(contact) -- loads the contact's stored route (out_path), if any // addRepeater(key) -- appends a repeater (a contact of type repeater) // remove / move -- edit the list -// trace() -- a TRACE out and back over the list (A B dest B A): -// a reply needs the last retransmission to be heard, -// so a far node is traced on its way there and back +// trace() -- a TRACE out and back over the list +// probe() -- after a trace with no reply: traces to the 1st, 2nd, +// ... repeater, to find the link where it stops // poll() -- advance a running trace (call every UI pass) // save() -- the list becomes the contact's out_path // +// A TRACE is answered only by the node that hears the last retransmission, so +// a route is traced there and back (A B dest B A): every node of it has to +// retransmit. Repeaters do; the node at the end only when it is a repeater +// too, or a companion with repeat on -- toNode() off traces just the +// repeaters (A B A), which also tells whether the way to the node is good. +// +// Results are per link, between node k-1 and node k of the chain (node 0 is +// this device): how well k heard k-1 (out) and k-1 heard k (back). +// // A hop keeps up to 4 bytes of the repeater's key, so the route can be traced // with 1, 2 or 4 byte hashes and stored with 1, 2 or 3; a hop loaded from a // stored path whose repeater isn't a contact keeps only the bytes it had. @@ -22,9 +31,13 @@ class PathEditor { public: static const int MAX_HOPS = 7; // repeaters; out and back is 2n+1 <= TRACE_SNR_MAX entries + static const int MAX_NODES = MAX_HOPS + 1; static const int NAME_LEN = 24; - enum State : uint8_t { IDLE, RUNNING, DONE, TIMEOUT, FAILED }; + // RUNNING covers a probe's every step (probing()); PROBED: the probe is + // over, reached() nodes answered. + enum State : uint8_t { IDLE, RUNNING, DONE, TIMEOUT, FAILED, PROBED }; + enum Fail : uint8_t { FAIL_NONE, FAIL_NO_KEY, FAIL_BUSY, FAIL_SEND, FAIL_EMPTY }; struct Hop { uint8_t key[4]; @@ -38,10 +51,15 @@ public: bool open(const uint8_t* pub_key) { ContactInfo* c = the_mesh.lookupContactByPubKey(pub_key, PUB_KEY_SIZE); if (!c) return false; + cancelTrace(); memcpy(_target_key, c->id.pub_key, PUB_KEY_SIZE); snprintf(_target_name, sizeof(_target_name), "%s", c->name); + _target_repeats = c->type == ADV_TYPE_REPEATER; + _to_node = _target_repeats; _n = 0; _state = IDLE; + _fail = FAIL_NONE; + _links = 0; _stored = c->out_path_len != OUT_PATH_UNKNOWN; _pinned = _stored && (c->flags & CONTACT_FLAG_ROUTE_PINNED) != 0; if (_stored) { @@ -49,19 +67,24 @@ public: uint8_t cnt = c->out_path_len & 63; for (uint8_t i = 0; i < cnt && _n < MAX_HOPS; i++) loadHash(&c->out_path[i * size], size); } + _edited = false; return true; } const uint8_t* targetKey() const { return _target_key; } const char* targetName() const { return _target_name; } bool hasStoredPath() const { return _stored; } + // The list differs from the stored route (or there is none and hops were added). + bool edited() const { return _edited; } // A kept route isn't replaced by a path heard from the node or dropped after a // failed send (CONTACT_FLAG_ROUTE_PINNED); written with save(), or at once - // when the route is already stored. + // when the route is already stored. True when it took effect now. bool pinned() const { return _pinned; } - void setPinned(bool on) { + bool setPinned(bool on) { _pinned = on; - if (_stored) the_mesh.setContactPinned(_target_key, on); + if (!_stored) return false; + the_mesh.setContactPinned(_target_key, on); + return true; } // ── the list ──────────────────────────────────────────────────────────────── @@ -76,11 +99,12 @@ public: ContactInfo* c = the_mesh.lookupContactByPubKey(pub_key, PUB_KEY_SIZE); if (!c || c->type != ADV_TYPE_REPEATER) return false; if (_n > 0 && _hops[_n - 1].known >= 1 && memcmp(_hops[_n - 1].key, c->id.pub_key, _hops[_n - 1].known) == 0) return false; + if (memcmp(c->id.pub_key, _target_key, 4) == 0) return false; // the node itself Hop& h = _hops[_n++]; memcpy(h.key, c->id.pub_key, 4); h.known = 4; snprintf(h.name, sizeof(h.name), "%s", c->name); - invalidate(); + changed(); return true; } @@ -88,24 +112,25 @@ public: if (i < 0 || i >= _n) return; for (int k = i; k + 1 < _n; k++) _hops[k] = _hops[k + 1]; _n--; - invalidate(); + changed(); } void removeLast() { remove(_n - 1); } - // Moves hop i one place (dir -1 up, +1 down). - void move(int i, int dir) { + // Moves hop i one place (dir -1 up, +1 down); false at either end. + bool move(int i, int dir) { int j = i + dir; - if (i < 0 || i >= _n || j < 0 || j >= _n) return; + if (i < 0 || i >= _n || j < 0 || j >= _n) return false; Hop t = _hops[i]; _hops[i] = _hops[j]; _hops[j] = t; - invalidate(); + changed(); + return true; } void clear() { _n = 0; - invalidate(); + changed(); } // Is this repeater (by key) already in the list? (map picking marks it) @@ -117,8 +142,20 @@ public: // ── trace ─────────────────────────────────────────────────────────────────── State state() const { return _state; } + Fail failure() const { return _fail; } + bool probing() const { return _state == RUNNING && _probe; } uint32_t timeoutMs() const { return _ping ? _ping->timeoutMs() : 3000; } + // Whether the trace goes on to the node (else it turns at the last repeater). + // On by default for a repeater: other nodes retransmit only with repeat on. + bool toNode() const { return _to_node || _n == 0; } + void setToNode(bool on) { + if (_to_node == on) return; + _to_node = on; + invalidate(); + } + bool targetRepeats() const { return _target_repeats; } + // The hash width a trace uses: the node's path hash mode, narrowed to what // every hop has bytes for. 0 if a hop has nothing usable. uint8_t traceWidth() const { @@ -129,100 +166,99 @@ public: return known >= 1 ? want : 0; } - // Path entries of a round trip: the repeaters, the node, the repeaters back. - int traceEntries() const { return 2 * _n + 1; } + // Nodes of the chain a trace() goes to (not counting this device). + int chainNodes() const { return toNode() ? _n + 1 : _n; } bool trace() { - if (!_ping || _state == RUNNING) return false; - uint8_t w = traceWidth(); - if (w == 0) { _state = FAILED; return false; } - uint8_t path[TRACE_SNR_MAX * 4]; - int e = 0; - auto put = [&](const uint8_t* key) { memcpy(&path[e * w], key, w); e++; }; - for (int i = 0; i < _n; i++) put(_hops[i].key); - put(_target_key); - for (int i = _n - 1; i >= 0; i--) put(_hops[i].key); - if (_ping->startTrace(path, (uint8_t)e, w) != PingEngine::STARTED) { _state = FAILED; return false; } - _state = RUNNING; - _started_ms = millis(); - _entries = e; - return true; + if (_state == RUNNING) return false; + _probe = false; + _links = 0; + if (chainNodes() == 0) return fail(FAIL_EMPTY); + return send(chainNodes()); } + // After a trace with no reply: the repeaters one by one (to the 1st, then + // to the 2nd, ...), until one doesn't answer. reached() is then how many + // nodes of the chain did; all repeaters answering with toNode() on puts the + // break at the node itself. + bool canProbe() const { return _state == TIMEOUT && _n > 0; } + bool probe() { + if (_state == RUNNING || _n == 0) return false; + _probe = true; + _reached = 0; + _links = 0; + return send(1); + } + int reached() const { return _reached; } + // Call every UI pass while a trace may be running. void poll() { if (_state != RUNNING || !_ping) return; - if (!_ping->isActive()) { // the reply came (the engine released its slot) - _state = _ping->hopCount() > 0 ? DONE : TIMEOUT; + if (!_ping->isActive()) { // the reply came (the engine released its slot), or it was dropped + if (_ping->hopCount() > 0) { + capture(); + if (!_probe) { _state = DONE; return; } + _reached = _sent_nodes; + if (_sent_nodes < _n && send(_sent_nodes + 1)) return; + _probe = false; + _state = PROBED; + } else { + stepTimedOut(); + } } else if (millis() - _started_ms > _ping->timeoutMs()) { _ping->clear(); - _state = TIMEOUT; + stepTimedOut(); } } void cancelTrace() { if (_state == RUNNING && _ping) _ping->clear(); if (_state == RUNNING) _state = IDLE; + _probe = false; } - // Result of the last trace: SNR x4 at path entry i (0..traceEntries()-1), - // false where it wasn't heard. Entry n is the node, entries after it the - // way back; snrBackX4() is us hearing the last retransmission. - bool hopSnrX4(int entry, int16_t& snr_x4) const { - if (_state != DONE || !_ping || entry < 0 || entry >= _ping->hopCount()) return false; - snr_x4 = _ping->hopSnrX4(entry); + // ── results ───────────────────────────────────────────────────────────────── + // Links with a result: after a trace, chainNodes(); after a probe, reached(). + int links() const { return _links; } + // Link k (1..links()): node k heard node k-1 at `out`, node k-1 heard node k at `back` (SNR x4). + bool linkSnrX4(int k, int16_t& out, int16_t& back) const { + if (k < 1 || k > _links) return false; + out = _out_x4[k - 1]; + back = _back_x4[k - 1]; return true; } - bool snrBackX4(int16_t& snr_x4) const { - if (_state != DONE || !_ping) return false; - int16_t o, b; uint32_t r; - _ping->getResult(o, b, r); - snr_x4 = b; - return true; + // Name of node k of the chain (0: this device -> ""). + const char* nodeName(int k) const { + if (k >= 1 && k <= _n) return _hops[k - 1].name; + if (k == _n + 1) return _target_name; + return ""; } - uint32_t rttMs() const { - int16_t o, b; uint32_t r = 0; - if (_ping) _ping->getResult(o, b, r); - return r; - } - - // The weakest SNR of the way there (repeaters and the node) in x4, for a - // one-line verdict; false without a result. - bool worstOutX4(int16_t& v) const { - bool any = false; - for (int i = 0; i <= _n; i++) { - int16_t s; - if (!hopSnrX4(i, s)) continue; - if (!any || s < v) v = s; - any = true; + uint32_t rttMs() const { return _rtt_ms; } + // The weakest SNR of any link either way, x4; false without a result. + bool worstX4(int16_t& v) const { + for (int k = 0; k < _links; k++) { + int16_t m = _out_x4[k] < _back_x4[k] ? _out_x4[k] : _back_x4[k]; + if (k == 0 || m < v) v = m; } - return any; + return _links > 0; } // ── save ──────────────────────────────────────────────────────────────────── - // The list as the contact's stored route. False with an empty list (that is - // Reset path / flood) or when the hops share no usable width. - bool canSave() const { return _n > 0 && saveWidth() > 0; } + // The list as the contact's stored route; an empty list stores "direct" + // (no repeaters, sent straight to the node). False when the hops share no + // usable width. + bool canSave() const { return saveWidth() > 0; } bool save() { uint8_t w = saveWidth(); - if (_n == 0 || w == 0) return false; + if (w == 0) return false; uint8_t path[MAX_HOPS * 3]; for (int i = 0; i < _n; i++) memcpy(&path[i * w], _hops[i].key, w); if (!the_mesh.setContactPath(_target_key, path, w, (uint8_t)_n, _pinned)) return false; _stored = true; + _edited = false; return true; } - // Text of path entry i of a round trip for a result row: "A", "A >", etc. is - // the view's job; this gives the name of the entry (a repeater, the node, - // a repeater on the way back). - const char* entryName(int i) const { - if (i < _n) return _hops[i].name; - if (i == _n) return _target_name; - int back = 2 * _n - i; // n+1 -> n-1 ... 2n -> 0 - return back >= 0 && back < _n ? _hops[back].name : ""; - } - private: uint8_t saveWidth() const { // 1..3 bytes of a stored path, the hash mode narrowed to what every hop has uint8_t want = _prefs ? _prefs->path_hash_mode + 1 : 1; @@ -233,9 +269,71 @@ private: return want < known ? want : known; } + void changed() { + _edited = true; + invalidate(); + } + void invalidate() { if (_state == RUNNING && _ping) _ping->clear(); _state = IDLE; + _probe = false; + _links = 0; + } + + bool fail(Fail f) { + _fail = f; + _state = FAILED; + _probe = false; + return false; + } + + // A trace to node `nodes` of the chain and back: nodes 1..nodes out, then + // nodes-1..1. The chain's last node is the target when it reaches past the + // repeaters. + bool send(int nodes) { + if (!_ping) return fail(FAIL_BUSY); + uint8_t w = traceWidth(); + if (w == 0) return fail(FAIL_NO_KEY); + uint8_t path[TRACE_SNR_MAX * 4]; + int e = 0; + auto key = [&](int k) -> const uint8_t* { return k <= _n ? _hops[k - 1].key : _target_key; }; + for (int k = 1; k <= nodes; k++) memcpy(&path[w * e++], key(k), w); + for (int k = nodes - 1; k >= 1; k--) memcpy(&path[w * e++], key(k), w); + PingEngine::StartResult r = _ping->startTrace(path, (uint8_t)e, w); + if (r != PingEngine::STARTED) return fail(r == PingEngine::BUSY ? FAIL_BUSY : FAIL_SEND); + _fail = FAIL_NONE; + _state = RUNNING; + _started_ms = millis(); + _sent_nodes = nodes; + return true; + } + + void stepTimedOut() { + if (_probe) { + _probe = false; + _state = PROBED; // _reached nodes answered, the next one didn't + } else { + _state = TIMEOUT; + } + } + + // The reply's per-entry SNRs as links. Over M nodes the path has 2M-1 + // entries: entry k-1 is node k hearing k-1 on the way out; node k-1 hearing + // node k on the way back is entry 2M-k (k >= 2), and for k = 1 it is this + // device hearing the reply. + void capture() { + const int M = _sent_nodes; + int16_t o, back_us; uint32_t rtt; + _ping->getResult(o, back_us, rtt); + _rtt_ms = rtt; + _links = 0; + if (_ping->hopCount() < 2 * M - 1) return; + for (int k = 1; k <= M; k++) { + _out_x4[k - 1] = _ping->hopSnrX4(k - 1); + _back_x4[k - 1] = k == 1 ? back_us : _ping->hopSnrX4(2 * M - k); + } + _links = M; } // A hash of a stored path: the contact it belongs to by key prefix, else the bytes. @@ -264,11 +362,21 @@ private: PingEngine* _ping = nullptr; uint8_t _target_key[PUB_KEY_SIZE]; char _target_name[32]; + bool _target_repeats = false; + bool _to_node = false; Hop _hops[MAX_HOPS]; int _n = 0; State _state = IDLE; + Fail _fail = FAIL_NONE; bool _stored = false; bool _pinned = false; - int _entries = 0; + bool _edited = false; + bool _probe = false; + int _reached = 0; + int _sent_nodes = 0; uint32_t _started_ms = 0; + uint32_t _rtt_ms = 0; + int _links = 0; + int16_t _out_x4[MAX_NODES]; + int16_t _back_x4[MAX_NODES]; }; diff --git a/examples/companion_radio/ui-core/PingEngine.h b/examples/companion_radio/ui-core/PingEngine.h index d6670bb8..ada71338 100644 --- a/examples/companion_radio/ui-core/PingEngine.h +++ b/examples/companion_radio/ui-core/PingEngine.h @@ -5,7 +5,8 @@ // which is a plain function pointer -- routed here via a single static instance. // // The view gives up after timeoutMs() (from the airtime, so it scales with -// the spreading factor) and calls clear(). +// the spreading factor) and calls clear(). A view that went away without it +// doesn't hold the engine: a start well past the timeout takes the slot over. class PingEngine { public: @@ -14,7 +15,7 @@ public: void begin(NodePrefs* prefs) { _prefs = prefs; s_instance = this; } StartResult start(const uint8_t* pub_key) { - if (_active || !pub_key) return BUSY; + if (!pub_key || !takeSlot()) return BUSY; _active = true; _tag = 0; @@ -41,7 +42,7 @@ public: // `width` (1, 2 or 4) bytes. The reply carries the SNR at every hop, kept // after the slot is released (hopCount() / hopSnrX4()). StartResult startTrace(const uint8_t* path, uint8_t hops, uint8_t width) { - if (_active || !path) return BUSY; + if (!path || !takeSlot()) return BUSY; _active = true; _tag = 0; _snr_out_x4 = _snr_back_x4 = 0; @@ -76,6 +77,15 @@ public: } private: + // Free, or held by a ping / trace long past its timeout (its view left + // without clear()): released and taken. + bool takeSlot() { + if (_active && millis() - _started_ms < _timeout_ms + 2000) return false; + if (_active) clear(); + _started_ms = millis(); + return true; + } + void handleResult(uint32_t tag, int16_t snr_out_x4, int16_t snr_back_x4, uint32_t rtt_ms) { if (_active && _tag == tag) { _snr_out_x4 = snr_out_x4; @@ -103,6 +113,7 @@ private: int16_t _snr_back_x4 = 0; uint32_t _rtt_ms = 0; uint32_t _timeout_ms = 3000; + uint32_t _started_ms = 0; uint8_t _n_hops = 0; int16_t _hop_snr_x4[TRACE_SNR_MAX]; }; diff --git a/examples/companion_radio/ui-lvgl/MapScreen.h b/examples/companion_radio/ui-lvgl/MapScreen.h index 0a2f0545..c2fb4250 100644 --- a/examples/companion_radio/ui-lvgl/MapScreen.h +++ b/examples/companion_radio/ui-lvgl/MapScreen.h @@ -874,9 +874,9 @@ void UITask::layoutMapMarks(double left, double top, int w, int h) { const int half = MARK_D / 2; const int lh = lv_font_get_line_height(THEME_FONT_SMALL) + 2; struct Box { int16_t x1, y1, x2, y2; }; - static Box used[MAX_MARKS + 8]; + static Box used[6 + MAX_MARKS + MARK_LABELS_MAX]; // the map's controls, the dots, the names int nu = 0; - const int bottom = _map_nav ? navmap::BAR_H : 0; + const int bottom = _map_nav || _route_pick ? navmap::BAR_H : 0; // the nav bar, or the route's const int16_t W = (int16_t)w, H = (int16_t)(h - bottom); used[nu++] = { 0, 0, 50, 144 }; // button columns used[nu++] = { (int16_t)(W - 50), 0, W, 144 }; diff --git a/examples/companion_radio/ui-lvgl/RouteScreen.h b/examples/companion_radio/ui-lvgl/RouteScreen.h index ce20a726..2d57d1fd 100644 --- a/examples/companion_radio/ui-lvgl/RouteScreen.h +++ b/examples/companion_radio/ui-lvgl/RouteScreen.h @@ -1,17 +1,18 @@ #pragma once // Route: the repeaters between this node and one contact, edited, traced and -// saved (node detail > Route). The list, the out-and-back trace and the save -// are the UI Core's PathEditor (`_core->route`); ui-new's Path screen is a -// view over the same. This draws the rows, a picker of repeaters by distance, -// and -- from the map -- picking the hops by tapping them (MapScreen.h, +// saved (node detail > Route). The list, the out-and-back trace, the search +// for where a route stops and the save are the UI Core's PathEditor +// (`_core->route`); ui-new's Path screen is a view over the same. This draws +// the chain with each link's signal, a picker of repeaters by distance, and +// -- from the map -- picking the hops by tapping them (MapScreen.h, // `_route_pick`). // // Single-TU fragment: included by ui-lvgl/UITask.cpp after AdminScreen.h. namespace routeview { -enum : uint8_t { RT_ADD, RT_MAP, RT_TRACE, RT_SAVE, RT_PIN }; -enum : uint8_t { OP_UP, OP_DOWN, OP_DEL }; +enum : uint8_t { RT_ADD, RT_MAP, RT_TRACE, RT_SAVE, RT_PIN, RT_PROBE }; +enum : uint8_t { OP_UP, OP_DOWN, OP_DEL, OP_SEL }; static const int PICK_MAX = 96; struct Pick { uint16_t idx; float km; }; // raw contact index, distance (<0: no position) @@ -24,8 +25,19 @@ static int cmpPick(const void* a, const void* b) { return x < y ? -1 : x > y ? 1 : 0; } -// A hop's text from an SNR x4. -static void snrText(char* out, size_t n, int16_t snr_x4) { snprintf(out, n, "%.1f", snr_x4 / 4.0f); } +// A link's colour from its weaker direction (SNR x4): good, fair, weak. +static uint32_t linkColour(int16_t out_x4, int16_t back_x4) { + int16_t m = out_x4 < back_x4 ? out_x4 : back_x4; + return m >= 0 ? theme::OK : m >= -7 * 4 ? theme::ACCENT : theme::FAIL; +} + +// "6.5 / 4.0": how the node heard the one before it, and back (dB). +static void linkText(char* out, size_t n, int16_t out_x4, int16_t back_x4) { + snprintf(out, n, "%.1f / %.1f", out_x4 / 4.0f, back_x4 / 4.0f); +} + +static int s_sel = -1; // the hop showing its move / remove buttons +static const int ROW_TARGET = 0xFE; // a row's tap code: the node (trace to it or not) } // namespace routeview @@ -33,12 +45,15 @@ static void onRouteAct(lv_event_t* e) { s_ui->routeAct((uint8_t)(uintptr_t)l static void onRouteOp(lv_event_t* e) { s_ui->routeOp((int)(uintptr_t)lv_event_get_user_data(e)); } static void onRoutePickRow(lv_event_t* e) { s_ui->routePick((int)vlist::arg(e)); } -// A small square button on a hop's card: user data = op << 8 | hop index. -static lv_obj_t* routeSquare(lv_obj_t* card, const char* sym, int code) { - lv_obj_t* b = lv_button_create(card); - lv_obj_set_size(b, 30, 30); +static void onRouteRow(lv_event_t* e) { s_ui->routeOp(routeview::OP_SEL << 8 | (int)(uintptr_t)lv_event_get_user_data(e)); } +static void onRouteKeep(lv_event_t* e) { (void)e; s_ui->routeAct(routeview::RT_PIN); } + +// A small square button on a hop's row: user data = op << 8 | hop index. +static lv_obj_t* routeSquare(lv_obj_t* row, const char* sym, int code) { + lv_obj_t* b = lv_button_create(row); + lv_obj_set_size(b, 30, 26); lv_obj_set_style_pad_all(b, 0, 0); - lv_obj_set_style_radius(b, theme::RADIUS, 0); + lv_obj_set_style_radius(b, theme::RADIUS_SM, 0); lv_obj_set_style_shadow_width(b, 0, 0); lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE_2), 0); lv_obj_add_event_cb(b, onRouteOp, LV_EVENT_CLICKED, (void*)(uintptr_t)code); @@ -46,34 +61,44 @@ static lv_obj_t* routeSquare(lv_obj_t* card, const char* sym, int code) { return b; } -// One row of the route: a number, the name and what the trace heard. -static lv_obj_t* routeCard(lv_obj_t* list, const char* num, const char* name, const char* sub, uint32_t col) { - lv_obj_t* c = lv_obj_create(list); - styleSurface(c, theme::SURFACE); - lv_obj_remove_flag(c, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_size(c, LV_PCT(100), 44); - lv_obj_set_style_radius(c, theme::RADIUS, 0); - lv_obj_set_style_pad_hor(c, 8, 0); - lv_obj_set_style_pad_ver(c, 0, 0); - lv_obj_set_flex_flow(c, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(c, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_column(c, 6, 0); - lv_obj_t* n = label(c, num, THEME_FONT_BODY, theme::ACCENT); - lv_obj_set_width(n, 18); - lv_obj_t* box = lv_obj_create(c); - lv_obj_remove_style_all(box); - lv_obj_set_height(box, LV_SIZE_CONTENT); - lv_obj_set_flex_grow(box, 1); - lv_obj_set_flex_flow(box, LV_FLEX_FLOW_COLUMN); - lv_obj_t* nm = label(box, name, THEME_FONT_BODY, col); - lv_label_set_long_mode(nm, LV_LABEL_LONG_DOT); - lv_obj_set_width(nm, LV_PCT(100)); - if (sub && sub[0]) { - lv_obj_t* s = label(box, sub, THEME_FONT_SMALL, theme::TEXT_MUTED); - lv_label_set_long_mode(s, LV_LABEL_LONG_DOT); - lv_obj_set_width(s, LV_PCT(100)); +// One node of the chain on one line: a number, the name, and on the right +// the link that reaches it (or why there is none). `tap` >= 0: the row is +// tappable with that code (a hop's index, or ROW_TARGET). +static lv_obj_t* routeRow(lv_obj_t* list, int h, const char* num, const char* name, uint32_t name_col, + const char* right, uint32_t right_col, int tap) { + lv_obj_t* r = lv_obj_create(list); + styleSurface(r, theme::SURFACE); + lv_obj_remove_flag(r, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(r, LV_PCT(100), h); + lv_obj_set_style_radius(r, theme::RADIUS_SM, 0); + lv_obj_set_style_pad_hor(r, 8, 0); + lv_obj_set_style_pad_ver(r, 0, 0); + lv_obj_set_flex_flow(r, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(r, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(r, 6, 0); + if (tap >= 0) { + lv_obj_add_flag(r, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_bg_color(r, lv_color_hex(theme::SURFACE_2), LV_STATE_PRESSED); + lv_obj_add_event_cb(r, onRouteRow, LV_EVENT_CLICKED, (void*)(uintptr_t)tap); } - return c; + lv_obj_t* n = label(r, num, THEME_FONT_BODY, theme::ACCENT); + lv_obj_set_width(n, 14); + lv_obj_t* nm = label(r, name, THEME_FONT_BODY, name_col); + lv_label_set_long_mode(nm, LV_LABEL_LONG_DOT); + lv_obj_set_width(nm, 1); + lv_obj_set_flex_grow(nm, 1); + if (right && right[0]) label(r, right, THEME_FONT_SMALL, right_col); + return r; +} + +// Keep (the header's button, a toggle): restyled, the status line follows. +void UITask::routeKeep() { + PathEditor& R = _core->route; + bool on = !R.pinned(); + bool now = R.setPinned(on); + showToast(!on ? "The route is learned again" : now ? "Route kept" : "Kept once you save it"); + if (_route_keep_btn) styleSelected(_route_keep_btn, R.pinned()); + routeRebuild(); } // Node detail > Route. @@ -84,97 +109,162 @@ void UITask::showRoute(const uint8_t* pub_key) { buildRoute(); } +// Keep in the header, the status line, the chain (rebuilt as the trace +// goes), the actions at the foot. void UITask::buildRoute() { + PathEditor& R = _core->route; lv_obj_t* body = newScreen("Route", true); - _route_status = noteLabel(body, "", THEME_FONT_SMALL, theme::TEXT_MUTED); - _route_list = scrollList(body); + _route_keep_btn = _header ? headerButton(_header, UI_SYMBOL_TACK " Keep", onRouteKeep, 4, NULL) : nullptr; + if (_route_keep_btn) styleSelected(_route_keep_btn, R.pinned()); + routeview::s_sel = -1; + // The status line, with "Find where it stops" beside it after a trace with no reply. + lv_obj_t* top = flexBox(body, LV_FLEX_FLOW_ROW); + lv_obj_set_width(top, LV_PCT(100)); + lv_obj_set_flex_align(top, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(top, 6, 0); + _route_status = noteLabel(top, "", THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_obj_set_width(_route_status, 1); + lv_obj_set_flex_grow(_route_status, 1); + _route_probe_row = pillButton(top, LV_SYMBOL_GPS " Find where", theme::ACCENT, onRouteAct, routeview::RT_PROBE); + styleSelected(_route_probe_row, true); + lv_obj_t* scroll = scrollList(body); + lv_obj_set_style_pad_row(scroll, 4, 0); + _route_list = flexBox(scroll, LV_FLEX_FLOW_COLUMN); + lv_obj_set_width(_route_list, LV_PCT(100)); + lv_obj_set_style_pad_row(_route_list, 4, 0); + + _route_note = noteLabel(scroll, "", THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_obj_t* bar = buttonBar(body); barButton(bar, LV_SYMBOL_PLUS " Add", onRouteAct, routeview::RT_ADD); barButton(bar, UI_SYMBOL_MAP " Map", onRouteAct, routeview::RT_MAP); _route_trace_btn = barButton(bar, LV_SYMBOL_LOOP " Trace", onRouteAct, routeview::RT_TRACE, true); - barButton(bar, LV_SYMBOL_OK " Save", onRouteAct, routeview::RT_SAVE); - _route_pin_btn = barButton(bar, UI_SYMBOL_TACK " Keep", onRouteAct, routeview::RT_PIN, _core->route.pinned()); - _route_shown_state = 255; + _route_save_btn = barButton(bar, LV_SYMBOL_SAVE " Save", onRouteAct, routeview::RT_SAVE); routeRebuild(); } -// The rows and the line above them, from the editor's state. +// What the chain shows: the state, the links with a result, a probe's step. +uint32_t UITask::routeSig() { + PathEditor& R = _core->route; + return (uint32_t)R.state() | (uint32_t)R.links() << 4 | (uint32_t)R.reached() << 8 | (uint32_t)R.probing() << 12 | + (uint32_t)R.edited() << 13 | (uint32_t)R.toNode() << 14 | (uint32_t)R.count() << 16; +} + +// The chain, from the editor's state: the repeaters and the node (this +// device before them goes without saying), one line each with the link that +// reaches it ("out / back" dB, coloured by +// its weaker way). A tapped hop shows its move / remove buttons instead; a +// tap on the node traces to it or not. void UITask::routeRebuild() { if (!_route_list || !_route_status) return; + using namespace routeview; PathEditor& R = _core->route; lv_obj_clean(_route_list); const int n = R.count(); - const bool done = R.state() == PathEditor::DONE; - char sub[48], a[12], b[12]; - - int16_t back = 0; - sub[0] = '\0'; - if (done && R.snrBackX4(back)) { routeview::snrText(a, sizeof(a), back); snprintf(sub, sizeof(sub), "" LV_SYMBOL_LEFT " %s dB", a); } - routeCard(_route_list, "", "This device", sub, theme::TEXT_MUTED); + if (s_sel >= n) s_sel = -1; + const bool probed = R.state() == PathEditor::PROBED; + const int stop = probed ? R.reached() + 1 : 0; // the node that didn't answer the probe + char right[40]; + uint32_t col; + auto link = [&](int k) { // the link to node k, or why there's none + int16_t o, b; + right[0] = '\0'; + col = theme::TEXT_MUTED; + if (R.linkSnrX4(k, o, b)) { linkText(right, sizeof(right), o, b); col = linkColour(o, b); } + else if (k == stop) { snprintf(right, sizeof(right), "no answer"); col = theme::FAIL; } + }; for (int i = 0; i < n; i++) { - int16_t out = 0, ret = 0; - sub[0] = '\0'; - if (done && R.hopSnrX4(i, out)) { - routeview::snrText(a, sizeof(a), out); - if (R.hopSnrX4(2 * n - i, ret)) { - routeview::snrText(b, sizeof(b), ret); - snprintf(sub, sizeof(sub), "" LV_SYMBOL_RIGHT " %s " LV_SYMBOL_LEFT " %s dB", a, b); - } else { - snprintf(sub, sizeof(sub), "" LV_SYMBOL_RIGHT " %s dB", a); - } - } else if (R.hop(i).known < 4) { - snprintf(sub, sizeof(sub), "not a known contact"); - } char num[4]; snprintf(num, sizeof(num), "%d", i + 1); - lv_obj_t* c = routeCard(_route_list, num, R.hop(i).name, sub, theme::TEXT); - if (i > 0) routeSquare(c, LV_SYMBOL_UP, routeview::OP_UP << 8 | i); - if (i < n - 1) routeSquare(c, LV_SYMBOL_DOWN, routeview::OP_DOWN << 8 | i); - routeSquare(c, LV_SYMBOL_CLOSE, routeview::OP_DEL << 8 | i); + link(i + 1); + if (!right[0] && R.hop(i).known < 4) snprintf(right, sizeof(right), "hash only"); + const bool sel = i == s_sel; + lv_obj_t* r = routeRow(_route_list, 34, num, R.hop(i).name, theme::TEXT, sel ? NULL : right, col, i); + if (sel) { + if (i > 0) routeSquare(r, LV_SYMBOL_UP, OP_UP << 8 | i); + if (i < n - 1) routeSquare(r, LV_SYMBOL_DOWN, OP_DOWN << 8 | i); + routeSquare(r, LV_SYMBOL_CLOSE, OP_DEL << 8 | i); + } } + if (R.toNode()) { + link(n + 1); + if (probed && R.reached() == n && !right[0]) { snprintf(right, sizeof(right), "no answer"); col = theme::FAIL; } + } else { + snprintf(right, sizeof(right), "not traced"); + col = theme::TEXT_MUTED; + } + routeRow(_route_list, 34, "", R.targetName()[0] ? R.targetName() : "(unnamed)", theme::ACCENT, right, col, + n > 0 ? ROW_TARGET : -1); - int16_t out = 0; - sub[0] = '\0'; - if (done && R.hopSnrX4(n, out)) { routeview::snrText(a, sizeof(a), out); snprintf(sub, sizeof(sub), "" LV_SYMBOL_RIGHT " %s dB", a); } - routeCard(_route_list, "", R.targetName()[0] ? R.targetName() : "(unnamed)", sub, theme::ACCENT); - + const char* note = ""; if (n == 0) - noteLabel(_route_list, R.hasStoredPath() - ? "Direct: no repeaters between. Save with repeaters to route through them." - : "No route stored, so messages are flooded. Add the repeaters the node should be reached through.", - THEME_FONT_SMALL, theme::TEXT_MUTED); - + note = R.hasStoredPath() && !R.edited() + ? "Direct: sent straight to the node. Add the repeaters to route through them." + : "Add the repeaters the node is reached through, in order (Add, or tap them on the Map). " + "Saved empty, messages go straight to the node."; + else if (R.state() == PathEditor::IDLE && R.links() == 0) + note = R.toNode() ? "Tap a hop to move or remove it. The trace goes to the node and back: tap it to stop at the last repeater." + : "Tap a hop to move or remove it. The node is left out: only one that repeats can answer. Tap it to include it."; + else if (R.links() > 0) + note = "Out / back dB: how each one heard the one before it (this device first), and the other way."; + setText(_route_note, note); + lv_obj_set_flag(_route_note, LV_OBJ_FLAG_HIDDEN, !note[0]); + lv_obj_set_flag(_route_probe_row, LV_OBJ_FLAG_HIDDEN, !R.canProbe()); routeStatus(); } void UITask::routeStatus() { PathEditor& R = _core->route; - char t[96]; + const int n = R.count(); + char t[112]; switch (R.state()) { - case PathEditor::RUNNING: snprintf(t, sizeof(t), "Tracing %d hops, out and back...", R.traceEntries()); break; + case PathEditor::RUNNING: + if (R.probing()) snprintf(t, sizeof(t), "Finding where it stops: to %s...", R.nodeName(R.links() + 1)); + else snprintf(t, sizeof(t), "Tracing %d node%s out and back...", R.chainNodes(), R.chainNodes() == 1 ? "" : "s"); + break; case PathEditor::DONE: { int16_t worst = 0; - if (R.worstOutX4(worst)) snprintf(t, sizeof(t), "Heard back in %lu ms - weakest hop %.1f dB", (unsigned long)R.rttMs(), worst / 4.0f); + if (R.worstX4(worst)) snprintf(t, sizeof(t), "Heard back in %lu ms, weakest link %.1f dB", (unsigned long)R.rttMs(), worst / 4.0f); else snprintf(t, sizeof(t), "Heard back in %lu ms", (unsigned long)R.rttMs()); break; } - case PathEditor::TIMEOUT: snprintf(t, sizeof(t), "No reply: a hop didn't answer, or its answer wasn't heard here"); break; - case PathEditor::FAILED: snprintf(t, sizeof(t), "Can't trace: a repeater has no key known (add it from contacts)"); break; + case PathEditor::TIMEOUT: + if (n > 0) snprintf(t, sizeof(t), "No reply: one of them didn't answer"); + else snprintf(t, sizeof(t), "No reply. %s answers only if it repeats and hears this device", R.targetName()); + break; + case PathEditor::PROBED: + if (R.reached() < n) + snprintf(t, sizeof(t), "Stops after %s: %s didn't answer", R.reached() ? R.nodeName(R.reached()) : "this device", + R.nodeName(R.reached() + 1)); + else if (R.toNode()) + snprintf(t, sizeof(t), "Every repeater answered: %s didn't (it may not repeat)", R.targetName()); + else + snprintf(t, sizeof(t), "Every repeater answered this time"); + break; + case PathEditor::FAILED: + switch (R.failure()) { + case PathEditor::FAIL_NO_KEY: snprintf(t, sizeof(t), "Can't trace: a repeater has no key known (add it from contacts)"); break; + case PathEditor::FAIL_BUSY: snprintf(t, sizeof(t), "A ping is on its way: try again in a moment"); break; + case PathEditor::FAIL_EMPTY: snprintf(t, sizeof(t), "Nothing to trace: add a repeater, or trace to the node"); break; + default: snprintf(t, sizeof(t), "The trace couldn't be sent"); break; + } + break; default: - snprintf(t, sizeof(t), "Route to %s: %d repeater%s%s", R.targetName(), R.count(), R.count() == 1 ? "" : "s", - R.pinned() ? " (kept)" : ""); + if (R.edited()) snprintf(t, sizeof(t), "%d repeater%s, not saved yet", n, n == 1 ? "" : "s"); + else if (!R.hasStoredPath()) snprintf(t, sizeof(t), "No route stored: messages are flooded"); + else snprintf(t, sizeof(t), "Stored route: %d repeater%s%s", n, n == 1 ? "" : "s", R.pinned() ? ", kept" : ""); } setText(_route_status, t); if (_route_trace_btn) setText(lv_obj_get_child(_route_trace_btn, 0), R.state() == PathEditor::RUNNING ? LV_SYMBOL_CLOSE " Stop" : LV_SYMBOL_LOOP " Trace"); - _route_shown_state = R.state(); + if (_route_save_btn) barButtonOn(_route_save_btn, R.edited()); + _route_shown = routeSig(); } -// From loop() on SCR_ROUTE: a running trace's reply or timeout. +// From loop() on SCR_ROUTE: a running trace's reply or timeout, a probe's steps. void UITask::routePoll() { - PathEditor& R = _core->route; - R.poll(); - if (R.state() != _route_shown_state) routeRebuild(); + _core->route.poll(); + if (routeSig() != _route_shown) routeRebuild(); } void UITask::routeAct(uint8_t act) { @@ -182,6 +272,7 @@ void UITask::routeAct(uint8_t act) { switch (act) { case routeview::RT_ADD: R.cancelTrace(); + if (R.full()) { showToast("The route is full"); break; } showRoutePick(); break; case routeview::RT_MAP: @@ -191,24 +282,20 @@ void UITask::routeAct(uint8_t act) { routeMapFrame(); break; case routeview::RT_TRACE: - if (R.state() == PathEditor::RUNNING) { R.cancelTrace(); routeRebuild(); break; } - if (R.trace()) { - _next_route_ms = millis() + 200; - routeRebuild(); - } else if (R.state() == PathEditor::FAILED) { - routeRebuild(); - } else { - showToast("Busy, try again"); - } + if (R.state() == PathEditor::RUNNING) R.cancelTrace(); + else if (R.trace()) _next_route_ms = millis() + 200; + routeRebuild(); break; - case routeview::RT_PIN: // kept: not replaced by a path heard, nor dropped after a failed send - R.setPinned(!R.pinned()); - barButtonOn(_route_pin_btn, R.pinned()); - showToast(R.pinned() ? (R.hasStoredPath() ? "Route kept" : "Kept once you save it") : "Route is learned again"); + case routeview::RT_PROBE: + if (R.probe()) _next_route_ms = millis() + 200; + routeRebuild(); + break; + case routeview::RT_PIN: + routeKeep(); break; case routeview::RT_SAVE: - if (R.count() == 0) { showToast("Add a repeater first (Reset path floods)"); break; } - showToast(R.save() ? "Route saved" : "Can't save this route"); + if (!R.save()) { showToast("Can't save this route"); break; } + showToast(R.count() ? (R.pinned() ? "Route saved and kept" : "Route saved") : "Saved: direct to the node"); routeRebuild(); break; } @@ -217,9 +304,14 @@ void UITask::routeAct(uint8_t act) { void UITask::routeOp(int code) { PathEditor& R = _core->route; int i = code & 0xFF, op = code >> 8; - if (op == routeview::OP_UP) R.move(i, -1); - else if (op == routeview::OP_DOWN) R.move(i, +1); - else if (op == routeview::OP_DEL) R.remove(i); + int& sel = routeview::s_sel; + if (op == routeview::OP_SEL) { + if (i == routeview::ROW_TARGET) R.setToNode(!R.toNode()); + else sel = sel == i ? -1 : i; + } + else if (op == routeview::OP_UP) { if (R.move(i, -1)) sel = i - 1; } + else if (op == routeview::OP_DOWN) { if (R.move(i, +1)) sel = i + 1; } + else if (op == routeview::OP_DEL) { R.remove(i); sel = -1; } routeRebuild(); } @@ -272,7 +364,7 @@ void UITask::routePick(int row) { if (!the_mesh.getContactByIdx(routeview::s_pick[row].idx, c)) return; PathEditor& R = _core->route; if (R.full()) { showToast("The route is full"); return; } - if (!R.addRepeater(c.id.pub_key)) { showToast("That repeater is already the last hop"); return; } + if (!R.addRepeater(c.id.pub_key)) { showToast("Already the last hop, or the node itself"); return; } _screen = SCR_ROUTE; _nav_back = true; buildRoute(); @@ -289,7 +381,7 @@ void UITask::routeMapTap(const uint8_t* pub_key, uint8_t type) { int at = R.indexOf(pub_key); if (at >= 0) R.remove(at); else if (R.full()) { showToast("The route is full"); return; } - else if (!R.addRepeater(pub_key)) { showToast("That repeater is already the last hop"); return; } + else if (!R.addRepeater(pub_key)) { showToast("Already the last hop, or the node itself"); return; } routeMapRefresh(); } diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index 4bf7f7f6..2087f967 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -117,9 +117,11 @@ public: void buildRoute(); void routeRebuild(); void routeStatus(); + uint32_t routeSig(); void routePoll(); void routeAct(uint8_t act); void routeOp(int code); + void routeKeep(); void showRoutePick(); void buildRoutePick(); void routePick(int row); @@ -362,6 +364,8 @@ public: // Kept for main.cpp's sim hooks (ui-new API). void openContactDM(const ContactInfo& ci) { openDM(ci.id.pub_key); } void openAdminFor(const ContactInfo& ci, bool from_picker) { (void)from_picker; openAdmin(ci.id.pub_key); } + void openRouteFor(const uint8_t* pub_key) { showRoute(pub_key); } + void openMessagesList() { showChats(); } // sim harness private: enum Screen : uint8_t { SCR_HOME, SCR_CHATS, SCR_CONTACTS, SCR_THREAD, SCR_SETTINGS, SCR_NEARBY, SCR_NODE, SCR_MAP, SCR_WIFI, SCR_SETTINGS_NAV, SCR_CLOCK, SCR_RADIO, SCR_CHANNEL_EDIT, SCR_ADMIN, SCR_BOT, SCR_DIAG, SCR_COMPASS, SCR_SCOPES, SCR_REPEATER, SCR_MELODY, SCR_QUICK, SCR_ADMIN_PICK, SCR_OTA, SCR_STORAGE, SCR_GPS, SCR_BATT, SCR_ROUTE, SCR_ROUTE_PICK }; @@ -631,8 +635,11 @@ private: lv_obj_t* _route_status = nullptr; // Route screen (RouteScreen.h) lv_obj_t* _route_list = nullptr; lv_obj_t* _route_trace_btn = nullptr; - lv_obj_t* _route_pin_btn = nullptr; - uint8_t _route_shown_state = 255; + lv_obj_t* _route_save_btn = nullptr; + lv_obj_t* _route_probe_row = nullptr; // "Find where it stops", after a trace with no reply + lv_obj_t* _route_note = nullptr; + lv_obj_t* _route_keep_btn = nullptr; // header: Keep (accent while kept) + uint32_t _route_shown = 0xFFFFFFFF; // what routeRebuild() drew (routeSig()) uint32_t _next_route_ms = 0; bool _route_pick = false; // the nodes map taps repeaters into the route lv_obj_t* _route_bar = nullptr; diff --git a/examples/companion_radio/ui-new/MessagesScreen.h b/examples/companion_radio/ui-new/MessagesScreen.h index de058860..375b7bd1 100644 --- a/examples/companion_radio/ui-new/MessagesScreen.h +++ b/examples/companion_radio/ui-new/MessagesScreen.h @@ -839,6 +839,16 @@ class MessagesScreen : public UIScreen { _num_channels = chanctl::order(p, _history, _channel_indices, p && p->ch_fav_only); } + // Into the channel list, sorted afresh (a "Newest" order follows the last + // message), the cursor on channel `keep_ch` (a slot) wherever it now is. + void enterChannelList(int keep_ch) { + buildChannelList(); + for (int i = 0; i < _num_channels; i++) + if (_channel_indices[i] == keep_ch) { _channel_sel = i; break; } + if (_channel_sel > _num_channels) _channel_sel = _num_channels; // the Add row at most + _phase = CHANNEL_PICK; + } + // Advance one of the contact menu's value rows. dir is +1 for RIGHT and for // Enter (which PopupMenu reports as VALUE_NEXT on a value row), -1 for LEFT. void cycleContactCtxValue(int sel, int dir) { @@ -1953,7 +1963,7 @@ public: } if (c == KEY_ENTER) { if (_mode_sel == 1) { - _phase = CHANNEL_PICK; + enterChannelList(_channel_sel < _num_channels ? _channel_indices[_channel_sel] : -1); } else { _room_mode = (_mode_sel == 2); if (_room_mode) _task->clearRoomUnread(); @@ -2448,7 +2458,7 @@ public: } if (c == KEY_CANCEL) { if (_direct_entry) { _direct_entry = false; _task->gotoHomeScreen(); } - else { _phase = CHANNEL_PICK; } + else { enterChannelList(_sel_channel_idx); } return true; } // Newest (index 0) now renders at the bottom, oldest at the top (see the diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index 54082748..e3c54f15 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -7,10 +7,11 @@ // ── Path (Nodes > a contact > Path) ─────────────────────────────────────────── // The repeaters between this node and a contact as a list: edit it, trace it -// out and back (the SNR of every hop), save it as the contact's route. The -// list, the trace and the save are the UI Core's PathEditor (`core().route`); -// ui-lvgl's Route screen is a view over the same. Picking a repeater to add -// hands the Nodes list over for a moment (NearbyScreen: `_route_pick`). +// out and back (each link's signal both ways), find where a route stops, +// save it as the contact's route. The list, the trace and the save are the UI +// Core's PathEditor (`core().route`); ui-lvgl's Route screen is a view over +// the same. Picking a repeater to add hands the Nodes list over for a moment +// (NearbyScreen: `_route_pick`). class RouteView { UITask* _task = nullptr; bool _active = false; @@ -18,11 +19,28 @@ class RouteView { int _sel = 0, _scroll = 0; PopupMenu _menu; // one hop: up / down / remove int _menu_hop = 0; + uint8_t _seen_state = PathEditor::IDLE; // a result's alert, once + uint8_t _sel_kind = 0xFF; // the action row under the cursor, kept as rows come and go + + enum Row : uint8_t { R_HOP, R_NODE, R_ADD, R_TRACE, R_PROBE, R_TO_NODE, R_SAVE, R_KEEP }; PathEditor& R() { return _task->core().route; } - // Rows: the hops, Add, Trace, Save, Keep. - int rows() { return R().count() + 4; } - int addRow() { return R().count(); } + // The rows: the hops, the node, then the actions (Find break only after a + // trace with no reply). + int rows(Row* out) { + PathEditor& r = R(); + int k = 0; + for (int i = 0; i < r.count(); i++) out[k++] = R_HOP; + out[k++] = R_NODE; + out[k++] = R_ADD; + out[k++] = R_TRACE; + if (r.canProbe()) out[k++] = R_PROBE; + if (r.count() > 0) out[k++] = R_TO_NODE; // with no repeaters the trace is to the node + out[k++] = R_SAVE; + out[k++] = R_KEEP; + return k; + } + static const int ROWS_MAX = PathEditor::MAX_HOPS + 7; void openHopMenu(int i) { _menu_hop = i; @@ -32,6 +50,42 @@ class RouteView { _menu.addItem("Remove"); } + // A link's signal, the weaker way rounded: "6/4" (out/back dB). + static void linkText(char* out, size_t n, int16_t o, int16_t b) { + snprintf(out, n, "%d/%d", (int)lroundf(o / 4.0f), (int)lroundf(b / 4.0f)); + } + + // What a result means, said once when it comes. + void announce() { + PathEditor& r = R(); + if (r.state() == _seen_state) return; + _seen_state = r.state(); + char t[48]; + switch (r.state()) { + case PathEditor::DONE: { + int16_t w = 0; + if (r.worstX4(w)) snprintf(t, sizeof(t), "Weakest %d dB", (int)lroundf(w / 4.0f)); + else return; + break; + } + case PathEditor::TIMEOUT: + snprintf(t, sizeof(t), "%s", r.count() ? "No reply: Find break" : "No reply"); + break; + case PathEditor::PROBED: + if (r.reached() < r.count()) snprintf(t, sizeof(t), "Stops at %.14s", r.nodeName(r.reached() + 1)); + else if (r.toNode()) snprintf(t, sizeof(t), "Node not repeating?"); + else snprintf(t, sizeof(t), "All answered"); + break; + case PathEditor::FAILED: + snprintf(t, sizeof(t), "%s", r.failure() == PathEditor::FAIL_NO_KEY ? "Repeater key unknown" + : r.failure() == PathEditor::FAIL_BUSY ? "Busy, try again" + : r.failure() == PathEditor::FAIL_EMPTY ? "Nothing to trace" : "Send failed"); + break; + default: return; + } + _task->showAlert(t, 1500); + } + public: bool active() const { return _active; } bool takePick() { bool p = _pick; _pick = false; return p; } @@ -43,9 +97,16 @@ public: _pick = false; _sel = _scroll = 0; _menu.active = false; + _seen_state = PathEditor::IDLE; + _sel_kind = 0xFF; return true; } - void show() { _active = true; } // back from picking a repeater: the list as it was + // Back from picking a repeater: the cursor on Add, for the next one. + void show() { + _active = true; + _sel = R().count() + 1; + _sel_kind = R_ADD; + } void close() { if (_task) R().cancelTrace(); _active = false; @@ -55,31 +116,48 @@ public: int render(DisplayDriver& display) { PathEditor& r = R(); r.poll(); + announce(); const int n = r.count(); char title[32]; switch (r.state()) { - case PathEditor::RUNNING: snprintf(title, sizeof(title), "Tracing..."); break; - case PathEditor::DONE: snprintf(title, sizeof(title), "Path %lums", (unsigned long)r.rttMs()); break; - case PathEditor::TIMEOUT: snprintf(title, sizeof(title), "Path: no reply"); break; - case PathEditor::FAILED: snprintf(title, sizeof(title), "Path: no key"); break; - default: snprintf(title, sizeof(title), "Path: %s", r.targetName()); + case PathEditor::RUNNING: + if (r.probing()) snprintf(title, sizeof(title), "Probe %d/%d", r.links() + 1, n); + else snprintf(title, sizeof(title), "Tracing..."); + break; + case PathEditor::DONE: snprintf(title, sizeof(title), "OK %lums", (unsigned long)r.rttMs()); break; + case PathEditor::TIMEOUT: snprintf(title, sizeof(title), "No reply"); break; + case PathEditor::PROBED: snprintf(title, sizeof(title), "Reached %d/%d", r.reached(), n); break; + case PathEditor::FAILED: snprintf(title, sizeof(title), "Can't trace"); break; + default: snprintf(title, sizeof(title), "%s%s", r.edited() ? "*" : "", r.targetName()); } drawScreenHeader(display, title, -1, 0, true, false); - const bool done = r.state() == PathEditor::DONE; - drawList(display, rows(), _sel, _scroll, [&](int idx, int y, bool sel, int reserve) { + Row kind[ROWS_MAX]; + const int nrows = rows(kind); + followSelection(kind, nrows); + const int stop = r.state() == PathEditor::PROBED ? r.reached() + 1 : 0; // the node that didn't answer + drawList(display, nrows, _sel, _scroll, [&](int idx, int y, bool sel, int reserve) { drawRowSelection(display, y, sel, reserve); char left[40], right[16] = ""; - if (idx < n) { - snprintf(left, sizeof(left), "%d %s", idx + 1, r.hop(idx).name); - int16_t o, b; - if (done && r.hopSnrX4(idx, o) && r.hopSnrX4(2 * n - idx, b)) snprintf(right, sizeof(right), "%d/%d", (int)lroundf(o / 4.0f), (int)lroundf(b / 4.0f)); - else if (r.hop(idx).known < 4) snprintf(right, sizeof(right), "?"); - } else { - const int a = idx - n; // 0 Add, 1 Trace, 2 Save - if (a == 0) snprintf(left, sizeof(left), "+ Add repeater"); - else if (a == 1) snprintf(left, sizeof(left), "%s", r.state() == PathEditor::RUNNING ? "Stop trace" : "Trace"); - else if (a == 2) snprintf(left, sizeof(left), "Save path"); - else snprintf(left, sizeof(left), "Keep path: %s", r.pinned() ? "ON" : "OFF"); + int16_t o, b; + switch (kind[idx]) { + case R_HOP: + snprintf(left, sizeof(left), "%d %s", idx + 1, r.hop(idx).name); + if (r.linkSnrX4(idx + 1, o, b)) linkText(right, sizeof(right), o, b); + else if (idx + 1 == stop) snprintf(right, sizeof(right), "x"); + else if (r.hop(idx).known < 4) snprintf(right, sizeof(right), "?"); + break; + case R_NODE: + snprintf(left, sizeof(left), "> %s", r.targetName()); + if (!r.toNode()) snprintf(right, sizeof(right), "-"); + else if (r.linkSnrX4(n + 1, o, b)) linkText(right, sizeof(right), o, b); + else if (stop == n + 1) snprintf(right, sizeof(right), "x"); + break; + case R_ADD: snprintf(left, sizeof(left), "+ Add repeater"); break; + case R_TRACE: snprintf(left, sizeof(left), "%s", r.state() == PathEditor::RUNNING ? "Stop" : "Trace"); break; + case R_PROBE: snprintf(left, sizeof(left), "Find break"); break; + case R_TO_NODE: snprintf(left, sizeof(left), "To node"); snprintf(right, sizeof(right), "%s", r.toNode() ? "ON" : "OFF"); break; + case R_SAVE: snprintf(left, sizeof(left), "%s", n ? "Save path" : "Save direct"); break; + case R_KEEP: snprintf(left, sizeof(left), "Keep path"); snprintf(right, sizeof(right), "%s", r.pinned() ? "ON" : "OFF"); break; } const int rw = right[0] ? display.getTextWidth(right) + display.getCharWidth() : 0; display.drawTextEllipsized(2, y, display.width() - reserve - 4 - rw, left, sel); @@ -99,44 +177,73 @@ public: auto res = _menu.handleInput(c); if (res == PopupMenu::SELECTED) { int i = _menu.selectedIndex(); - if (i == 0) { r.move(_menu_hop, -1); if (_sel > 0) _sel--; } - else if (i == 1) { r.move(_menu_hop, +1); if (_sel < r.count() - 1) _sel++; } + if (i == 0) { if (r.move(_menu_hop, -1)) _sel = _menu_hop - 1; } + else if (i == 1) { if (r.move(_menu_hop, +1)) _sel = _menu_hop + 1; } else r.remove(_menu_hop); - if (_sel >= rows()) _sel = rows() - 1; _menu.active = false; } return true; } + Row kind[ROWS_MAX]; + const int nrows = rows(kind); if (c == KEY_CANCEL) { close(); return true; } - if (c == KEY_UP) { _sel = _sel > 0 ? _sel - 1 : rows() - 1; return true; } - if (c == KEY_DOWN) { _sel = _sel < rows() - 1 ? _sel + 1 : 0; return true; } - const int n = r.count(); - if (_sel < n && (keyIsPrev(c) || keyIsNext(c))) { // reorder in place - int dir = keyIsNext(c) ? 1 : -1; - int to = _sel + dir; - if (to >= 0 && to < n) { r.move(_sel, dir); _sel = to; } + if (c == KEY_UP) { _sel = _sel > 0 ? _sel - 1 : nrows - 1; _sel_kind = kind[_sel]; return true; } + if (c == KEY_DOWN) { _sel = _sel < nrows - 1 ? _sel + 1 : 0; _sel_kind = kind[_sel]; return true; } + if (_sel >= nrows) _sel = nrows - 1; + const Row k = kind[_sel]; + if (keyIsPrev(c) || keyIsNext(c)) { + const int dir = keyIsNext(c) ? 1 : -1; + if (k == R_HOP) { if (r.move(_sel, dir)) _sel += dir; } // reorder in place + else if (k == R_TO_NODE) r.setToNode(!r.toNode()); + else if (k == R_KEEP) toggleKeep(); return true; } if (c != KEY_ENTER && c != KEY_CONTEXT_MENU) return true; - if (_sel < n) { openHopMenu(_sel); return true; } - switch (_sel - n) { - case 0: + switch (k) { + case R_HOP: openHopMenu(_sel); break; + case R_NODE: break; + case R_ADD: if (r.full()) _task->showAlert("Path is full", 1000); else { r.cancelTrace(); _pick = true; _active = false; } break; - case 1: + case R_TRACE: if (r.state() == PathEditor::RUNNING) r.cancelTrace(); - else if (!r.trace() && r.state() != PathEditor::FAILED) _task->showAlert("Busy", 800); + else { _seen_state = PathEditor::RUNNING; r.trace(); } break; - case 2: - if (r.count() == 0) _task->showAlert("Add a repeater", 1000); - else _task->showAlert(r.save() ? "Path saved" : "Can't save", 1000); + case R_PROBE: + _seen_state = PathEditor::RUNNING; + r.probe(); break; - default: // Keep: not replaced by a path heard, nor dropped after a failed send - r.setPinned(!r.pinned()); + case R_TO_NODE: r.setToNode(!r.toNode()); break; + case R_SAVE: + if (!r.save()) _task->showAlert("Can't save", 1000); + else _task->showAlert(r.count() ? "Path saved" : "Saved: direct", 1000); + break; + case R_KEEP: toggleKeep(); break; } return true; } + +private: + // The cursor stays on the action it was on when a row before it comes or + // goes (Find break after a trace with no reply); from Find break, once + // it's gone, to Trace. + void followSelection(const Row* kind, int nrows) { + if (_sel >= nrows) _sel = nrows - 1; + if (_sel_kind != 0xFF && _sel_kind > R_NODE && kind[_sel] != _sel_kind) { + int at = -1; + for (int i = 0; i < nrows && at < 0; i++) if (kind[i] == _sel_kind) at = i; + for (int i = 0; i < nrows && at < 0; i++) if (_sel_kind == R_PROBE && kind[i] == R_TRACE) at = i; + if (at >= 0) _sel = at; + } + _sel_kind = kind[_sel]; + } + + void toggleKeep() { + PathEditor& r = R(); + bool now = r.setPinned(!r.pinned()); + if (r.pinned() && !now) _task->showAlert("Kept once saved", 1000); + } }; // ── Nearby Nodes ────────────────────────────────────────────────────────────── @@ -1113,6 +1220,16 @@ public: return (mq_delay > 0 && mq_delay < ret) ? mq_delay : ret; } + // Path / trace for a contact, from outside the list (the sim harness). + bool openRoute(const uint8_t* pub_key) { + int i = findContact(pub_key); + if (i < 0 || !_route.open(_task, pub_key)) return false; + _sel = i; + _detail = true; + memcpy(_route_key, pub_key, PUB_KEY_SIZE); + return true; + } + bool handleInput(char c) override { // ── path view: modal; Add hands the Nodes list over for one repeater ────── if (_route.active()) { diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 7444f34d..9c760985 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -256,6 +256,10 @@ void UITask::openAdminFor(const ContactInfo& ci, bool from_picker) { setCurrScreen(admin_screen); // runs AdminScreen::onShow()'s reset first ((AdminScreen*)admin_screen)->startFor(ci, from_picker); } +void UITask::openRouteFor(const uint8_t* pub_key) { + setCurrScreen(nearby_screen); // its onShow() reset first + ((NearbyScreen*)nearby_screen)->openRoute(pub_key); +} void UITask::gotoDashboardConfig() { setCurrScreen(dashboard_config); } void UITask::gotoTrailScreen() { setCurrScreen(trail_screen); } void UITask::gotoCompassScreen() { setCurrScreen(compass_screen); } diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 3c6abf1d..b3717d4e 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -329,6 +329,8 @@ public: void gotoBotScreen(); void pickAdminTarget(); // Admin is remote-only: open Nodes to pick a repeater/room void openAdminFor(const ContactInfo& ci, bool from_picker); // canonical Admin entry for a specific target (Nodes' Hold-Enter menu or the picker above) + void openRouteFor(const uint8_t* pub_key); // Nodes > a contact > Path / trace (sim harness) + void openMessagesList() { gotoMessagesScreen(); } // sim harness void gotoNearbyScreen(); void gotoDashboardConfig(); void gotoAutoAdvertScreen();