diff --git a/docs/solo/contacts.md b/docs/solo/contacts.md index 136480d4..8fe1dcc6 100644 --- a/docs/solo/contacts.md +++ b/docs/solo/contacts.md @@ -87,6 +87,23 @@ 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. +**Discover** finds the route for you. It asks the node by flood, and the +reply says which repeaters the flood went through to reach it, and which the +reply took back. These come from the mesh itself, not from anyone's position +on the map. On the **Discover** row, **Left / Right** sets 1, 3 or 5 tries +and **Enter** starts it (or stops it). Each try waits for its reply or its +timeout, then 20 seconds more, so the floods don't come all at once. Each +route found is listed under it with how many times it came; **Enter** on one +puts it in the list, ready to **Trace** and save. A repeater you don't have +as a contact shows as its hash (`?8D`). Nothing is saved until you save it. + +Only a node that answers requests can be asked: a companion whose +Settings › Privacy › **Ask status** lets you (**Everyone**, or **Allowed** +with Telem given to you), or a repeater or room you're logged in to (a guest +login is enough). Your own Ask status decides the same for others finding a +route to you. Not to be confused with **Discover scan** in the node options, +which looks for nodes in direct range. + **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. @@ -99,6 +116,10 @@ straight to the node. The route a node sends back usually replaces it again; > 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. +> **Discover** is under the route, in **Find the route**, with the number of +> tries beside it and where it is (the try waiting for its reply, or the +> seconds to the next one). Tap a route found to use it; the way back is under +> it. ## Your own contact diff --git a/docs/solo/messages.md b/docs/solo/messages.md index 869b5b3d..3a66121e 100644 --- a/docs/solo/messages.md +++ b/docs/solo/messages.md @@ -30,10 +30,20 @@ older and newer one. contains a location (see [Navigation](./navigation.md)). - **Path** on a received message: the repeaters it came through. **Relayed by** on your own channel post: the repeaters heard repeating it. +- **Show route**: when the message has a route written in it, such as a + bot's `!path` reply (`3f:a1:c8`, see [Remote bot](./tools.md#remote-bot)). + It draws the route on a small map: you, each repeater numbered in order, + and the sender, with how many of the hops could be placed (a repeater + needs a position). In a direct message the route starts from you, because + it's the way your message went; on a channel it's someone else's, so it + doesn't. **Back** closes it. > [!NOTE] > **Wio Tracker L2:** hold a bubble. The card shows when it was sent, the hop -> count, the path as a diagram, and **Reply** / **Set target**. +> count, the path as a diagram, and **Reply** / **Set target**, plus **Route** +> when the message has a route in it: the nodes map opens with the route drawn +> and its hops numbered, and the names along the bottom; **Done** goes back to +> the conversation. ### How much is kept diff --git a/docs/solo/settings.md b/docs/solo/settings.md index d04d6abb..b7793c73 100644 --- a/docs/solo/settings.md +++ b/docs/solo/settings.md @@ -68,7 +68,10 @@ nodes aren't added until you make room. **Allowed** (the contacts you gave that permission: **Telem** in a node's options in [Nodes](./contacts.md#nearby-nodes), or the phone app) or **Everyone**. Status is the battery; a request for location or - sensors is answered only where status is allowed too. + sensors is answered only where status is allowed too. Status also lets + others find a route to you (**Discover** in + [Path and trace](./contacts.md#path-and-trace), or the phone app's path + discovery). - **BT PIN**: the six-digit Bluetooth pairing PIN. Leave it empty for a new random one at each start, shown on screen. A new PIN is used from the next start; the phone pairs again after that. diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index a1b44275..b968c560 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1058,11 +1058,44 @@ namespace { }; } +// 'Path Discovery' is just a special case of flood + Telemetry req: the +// reply comes back as a path return carrying both ways (onContactPathRecv()). +int MyMesh::sendDiscoveryReq(ContactInfo& recipient, uint32_t& tag, uint32_t& est_timeout) { + uint8_t req_data[9]; + req_data[0] = REQ_TYPE_GET_TELEMETRY_DATA; + req_data[1] = ~(TELEM_PERM_BASE); // NEW: inverse permissions mask (ie. we only want BASE telemetry) + memset(&req_data[2], 0, 3); // reserved + getRNG()->random(&req_data[5], 4); // random blob to help make packet-hash unique + auto save = recipient.out_path_len; // temporarily force sendRequest() to flood + recipient.out_path_len = OUT_PATH_UNKNOWN; + int result = sendRequest(recipient, req_data, sizeof(req_data), tag, est_timeout); + recipient.out_path_len = save; + return result; +} + +uint32_t MyMesh::sendPathDiscovery(const uint8_t* pub_key, DiscoverCallback cb, uint32_t* est_timeout) { + ContactInfo* recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); + if (!recipient) return 0; + uint32_t tag, est; + if (sendDiscoveryReq(*recipient, tag, est) == MSG_SEND_FAILED) return 0; + _ui_discovery = tag; + _discover_cb = cb; + if (est_timeout) *est_timeout = est; + return tag; +} + bool MyMesh::onContactPathRecv(ContactInfo& contact, uint8_t* in_path, uint8_t in_path_len, uint8_t* out_path, uint8_t out_path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) { if (extra_type == PAYLOAD_TYPE_RESPONSE && extra_len > 4) { uint32_t tag; memcpy(&tag, extra, 4); + if (_ui_discovery && tag == _ui_discovery) { // this device's own (sendPathDiscovery()) + _ui_discovery = 0; + if (_discover_cb && mesh::Packet::isValidPathLen(in_path_len) && mesh::Packet::isValidPathLen(out_path_len)) + _discover_cb(contact.id.pub_key, out_path, out_path_len, in_path, in_path_len); + return false; // DON'T send reciprocal path, nor take it: the user picks + } + if (tag == pending_discovery) { // check for matching response tag) pending_discovery = 0; @@ -2244,16 +2277,7 @@ void MyMesh::handleCmdFrame(size_t len) { ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); if (recipient) { uint32_t tag, est_timeout; - // 'Path Discovery' is just a special case of flood + Telemetry req - uint8_t req_data[9]; - req_data[0] = REQ_TYPE_GET_TELEMETRY_DATA; - req_data[1] = ~(TELEM_PERM_BASE); // NEW: inverse permissions mask (ie. we only want BASE telemetry) - memset(&req_data[2], 0, 3); // reserved - getRNG()->random(&req_data[5], 4); // random blob to help make packet-hash unique - auto save = recipient->out_path_len; // temporarily force sendRequest() to flood - recipient->out_path_len = OUT_PATH_UNKNOWN; - int result = sendRequest(*recipient, req_data, sizeof(req_data), tag, est_timeout); - recipient->out_path_len = save; + int result = sendDiscoveryReq(*recipient, tag, est_timeout); if (result == MSG_SEND_FAILED) { writeErrFrame(ERR_CODE_TABLE_FULL); } else { diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 0679eada..81379963 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -277,6 +277,15 @@ public: 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); + // Path discovery from this device's UI (PathEditor::discover()): the node is + // asked by flood, and its reply brings the way the flood reached it (out) + // and the way the reply came back (in), as encoded path lengths and hashes, + // to the callback. The tag, 0 when not sent. One at a time; the app's own + // discovery (CMD_SEND_PATH_DISCOVERY_REQ) is answered to the app as before. + typedef void (*DiscoverCallback)(const uint8_t* pub_key, const uint8_t* out_path, uint8_t out_path_len, + const uint8_t* in_path, uint8_t in_path_len); + uint32_t sendPathDiscovery(const uint8_t* pub_key, DiscoverCallback cb, uint32_t* est_timeout = nullptr); + void cancelPathDiscovery() { _ui_discovery = 0; } void clearPingResult(uint32_t tag); PingResult* getPingResult(uint32_t tag); PingCallback getPingCallback() const { return _ping_callback; } @@ -669,6 +678,9 @@ private: uint32_t ui_pending_admin_reply; // pub_key prefix of the contact AdminScreen's sendAdminCommand() is awaiting a CLI reply from uint32_t pending_status; uint32_t pending_telemetry, pending_discovery; // pending _TELEMETRY_REQ + uint32_t _ui_discovery = 0; // sendPathDiscovery()'s, answered to _discover_cb + DiscoverCallback _discover_cb = nullptr; + int sendDiscoveryReq(ContactInfo& recipient, uint32_t& tag, uint32_t& est_timeout); uint32_t pending_req; // pending _BINARY_REQ BaseSerialInterface *_serial; Listener* _listener; diff --git a/examples/companion_radio/ui-core/PathEditor.h b/examples/companion_radio/ui-core/PathEditor.h index 846fedfe..04536eb6 100644 --- a/examples/companion_radio/ui-core/PathEditor.h +++ b/examples/companion_radio/ui-core/PathEditor.h @@ -12,6 +12,9 @@ // ... 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 +// discover(tries) -- asks the node by flood which way it's reached (path +// discovery), `tries` times with a pause between; the +// routes found, useFound() puts one in the list // // 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 @@ -52,6 +55,9 @@ public: ContactInfo* c = the_mesh.lookupContactByPubKey(pub_key, PUB_KEY_SIZE); if (!c) return false; cancelTrace(); + stopDiscover(); + _found_n = 0; + _disc_done = _disc_answered = 0; 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; @@ -195,6 +201,7 @@ public: // Call every UI pass while a trace may be running. void poll() { + pollDiscover(); if (_state != RUNNING || !_ping) return; if (!_ping->isActive()) { // the reply came (the engine released its slot), or it was dropped if (_ping->hopCount() > 0) { @@ -245,6 +252,83 @@ public: return _links > 0; } + // ── discover ──────────────────────────────────────────────────────────────── + // The node is asked by flood (MyMesh::sendPathDiscovery()); its reply says + // which repeaters the flood went through to reach it, and which the reply + // took back -- learned from the mesh, not from anyone's position. A flood + // goes everywhere, so several tries wait for each other's reply (or its + // timeout) and then DISCOVER_GAP_MS more. Each different route is kept + // with how many times it came; the fastest is not always the best, so the + // user picks one (useFound()) and can trace it. + static const int FOUND_MAX = 4; + static const int FOUND_HOPS = 8; // hops kept of a route (one longer than the list holds: "too long") + static const uint32_t DISCOVER_GAP_MS = 20000; + struct Found { + uint8_t out_len, back_len; // encoded path lengths (hop count | (hash size - 1) << 6) + uint8_t out[FOUND_HOPS * 3], back[FOUND_HOPS * 3]; + uint8_t times; + }; + + bool discover(int tries) { + if (tries < 1) tries = 1; + stopDiscover(); + _found_n = 0; + _disc_tries = (uint8_t)tries; + _disc_done = _disc_answered = 0; + return discoverSend(); + } + void stopDiscover() { + if (_disc_waiting) the_mesh.cancelPathDiscovery(); + _disc_waiting = false; + _disc_tries = 0; + } + bool discovering() const { return _disc_tries > 0 && _disc_done < _disc_tries; } + bool discoverWaiting() const { return _disc_waiting; } // a try is out, its reply awaited + int discoverTry() const { return _disc_done + (_disc_waiting ? 1 : 0); } // 1-based while waiting + int discoverTries() const { return _disc_tries; } + int discoverDone() const { return _disc_done; } + int discoverAnswered() const { return _disc_answered; } + bool discoverFailed() const { return _disc_failed; } + // Seconds to the next try, between tries. + int discoverNextIn() const { + if (!discovering() || _disc_waiting) return 0; + int32_t ms = (int32_t)(_disc_at - millis()); + return ms > 0 ? (ms + 999) / 1000 : 0; + } + + int foundCount() const { return _found_n; } + const Found& found(int i) const { return _found[i]; } + static int hopsOf(uint8_t len) { return len & 63; } + // Route i's hops, "Name > Name" (hash "?3F" for one not a contact), cut to fit. + void foundText(int i, bool back, char* out, size_t n) const { + const Found& f = _found[i]; + const uint8_t len = back ? f.back_len : f.out_len; + const uint8_t* p = back ? f.back : f.out; + const int hops = hopsOf(len), size = (len >> 6) + 1; + size_t o = 0; + out[0] = '\0'; + if (hops == 0) { snprintf(out, n, "direct"); return; } + for (int h = 0; h < hops && h < FOUND_HOPS && o + 4 < n; h++) { + char nm[NAME_LEN]; + hashName(&p[h * size], size, nm, sizeof(nm)); + o += snprintf(out + o, n - o, "%s%s", h ? " > " : "", nm); + } + if (hops > FOUND_HOPS && o + 4 < n) snprintf(out + o, n - o, " ..."); + } + // Route i as the list (its repeaters by key where they're contacts). + bool useFound(int i) { + if (i < 0 || i >= _found_n) return false; + const Found& f = _found[i]; + const int hops = hopsOf(f.out_len), size = (f.out_len >> 6) + 1; + cancelTrace(); + _n = 0; + for (int h = 0; h < hops && h < MAX_HOPS; h++) loadHash(&f.out[h * size], size); + _too_long = hops > MAX_HOPS; + _edited = true; + invalidate(); + return true; + } + // ── save ──────────────────────────────────────────────────────────────────── // 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 @@ -265,6 +349,86 @@ public: } private: + // The editor a discovery reply goes to (one editor, one discovery at a time). + static PathEditor*& discOwner() { static PathEditor* p = nullptr; return p; } + + bool discoverSend() { + uint32_t est = 0; + discOwner() = this; + _disc_failed = false; + if (!the_mesh.sendPathDiscovery(_target_key, onDiscovered, &est)) { + _disc_failed = true; + _disc_tries = 0; + return false; + } + _disc_waiting = true; + uint32_t wait = est + 4000; // the flood there and the reply's flood back + if (wait < 8000) wait = 8000; + _disc_at = millis() + wait; + return true; + } + + void pollDiscover() { + if (_disc_tries == 0) return; + if (_disc_waiting) { + if ((int32_t)(millis() - _disc_at) < 0) return; + the_mesh.cancelPathDiscovery(); // no reply in time + tryOver(); + } else if (_disc_done < _disc_tries && (int32_t)(millis() - _disc_at) >= 0) { + discoverSend(); + } + } + + // A try answered or timed out: the next one after the pause. + void tryOver() { + _disc_waiting = false; + _disc_done++; + _disc_at = millis() + DISCOVER_GAP_MS; + } + + static void onDiscovered(const uint8_t* key, const uint8_t* out, uint8_t out_len, const uint8_t* back, uint8_t back_len) { + PathEditor* e = discOwner(); + if (!e || !e->_disc_waiting || memcmp(key, e->_target_key, PUB_KEY_SIZE) != 0) return; + e->record(out, out_len, back, back_len); + e->_disc_answered++; + e->tryOver(); + } + + // A route found: another time for one already found (the latest way back + // with it), else a new one while there's room. + void record(const uint8_t* out, uint8_t out_len, const uint8_t* back, uint8_t back_len) { + const int osize = (out_len >> 6) + 1, bsize = (back_len >> 6) + 1; + if (osize > 3 || bsize > 3) return; + const int obytes = (hopsOf(out_len) < FOUND_HOPS ? hopsOf(out_len) : FOUND_HOPS) * osize; + const int bbytes = (hopsOf(back_len) < FOUND_HOPS ? hopsOf(back_len) : FOUND_HOPS) * bsize; + Found* f = nullptr; + for (int i = 0; i < _found_n && !f; i++) + if (_found[i].out_len == out_len && memcmp(_found[i].out, out, obytes) == 0) f = &_found[i]; + if (f) { + if (f->times < 255) f->times++; + } else { + if (_found_n >= FOUND_MAX) return; + f = &_found[_found_n++]; + f->out_len = out_len; + memcpy(f->out, out, obytes); + f->times = 1; + } + f->back_len = back_len; + memcpy(f->back, back, bbytes); + } + + // A hash's repeater by name, else "?3F". + static void hashName(const uint8_t* hash, uint8_t size, char* out, size_t n) { + 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)) { snprintf(out, n, "%s", c.name); return; } + } + char hex[9] = {0}; + for (uint8_t k = 0; k < size && k < 4; k++) snprintf(hex + k * 2, 3, "%02X", hash[k]); + snprintf(out, n, "?%s", hex); + } + 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; @@ -386,4 +550,9 @@ private: int _links = 0; int16_t _out_x4[MAX_NODES]; int16_t _back_x4[MAX_NODES]; + Found _found[FOUND_MAX]; + int _found_n = 0; + uint8_t _disc_tries = 0, _disc_done = 0, _disc_answered = 0; + bool _disc_waiting = false, _disc_failed = false; + uint32_t _disc_at = 0; // waiting: the reply's deadline; between tries: the next one's time }; diff --git a/examples/companion_radio/ui-lvgl/RouteScreen.h b/examples/companion_radio/ui-lvgl/RouteScreen.h index cde12540..d87b4779 100644 --- a/examples/companion_radio/ui-lvgl/RouteScreen.h +++ b/examples/companion_radio/ui-lvgl/RouteScreen.h @@ -39,6 +39,11 @@ static void linkText(char* out, size_t n, int16_t out_x4, int16_t back_x4) { 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) +// Discover: how many tries (TRIES[s_tries]), kept while the device runs. +static const char* TRIES_MAP[] = { "1", "3", "5", "" }; +static const int TRIES[] = { 1, 3, 5 }; +static int s_tries = 1; + } // namespace routeview static void onRouteAct(lv_event_t* e) { s_ui->routeAct((uint8_t)(uintptr_t)lv_event_get_user_data(e)); } @@ -47,6 +52,11 @@ static void onRoutePickRow(lv_event_t* e) { s_ui->routePick((int)vlist::arg(e)); 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); } +static void onRouteDiscover(lv_event_t* e) { (void)e; s_ui->routeDiscover(); } +static void onRouteFound(lv_event_t* e) { s_ui->routeUseFound((int)(uintptr_t)lv_event_get_user_data(e)); } +static void onRouteTries(lv_event_t* e) { + s_ui->routeDiscTries((int)lv_buttonmatrix_get_selected_button((lv_obj_t*)lv_event_get_target(e))); +} // 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) { @@ -134,6 +144,9 @@ void UITask::buildRoute() { lv_obj_set_style_pad_row(_route_list, 4, 0); _route_note = noteLabel(scroll, "", THEME_FONT_SMALL, theme::TEXT_MUTED); + _route_disc = flexBox(scroll, LV_FLEX_FLOW_COLUMN); + lv_obj_set_width(_route_disc, LV_PCT(100)); + _route_disc_shown = 0xFFFFFFFF; lv_obj_t* bar = buttonBar(body); barButton(bar, LV_SYMBOL_PLUS " Add", onRouteAct, routeview::RT_ADD); @@ -141,6 +154,7 @@ void UITask::buildRoute() { _route_trace_btn = barButton(bar, LV_SYMBOL_LOOP " Trace", onRouteAct, routeview::RT_TRACE, true); _route_save_btn = barButton(bar, LV_SYMBOL_SAVE " Save", onRouteAct, routeview::RT_SAVE); routeRebuild(); + routeDiscRebuild(); } // What the chain shows: the state, the links with a result, a probe's step. @@ -265,6 +279,83 @@ void UITask::routeStatus() { void UITask::routePoll() { _core->route.poll(); if (routeSig() != _route_shown) routeRebuild(); + if (routeDiscSig() != _route_disc_shown) routeDiscRebuild(); +} + +// ── Discover ────────────────────────────────────────────────────────────────── + +uint32_t UITask::routeDiscSig() { + PathEditor& R = _core->route; + return (uint32_t)R.discovering() | (uint32_t)R.discoverWaiting() << 1 | (uint32_t)R.discoverDone() << 2 | + (uint32_t)R.discoverAnswered() << 6 | (uint32_t)R.foundCount() << 10 | (uint32_t)R.discoverNextIn() << 13 | + (uint32_t)R.discoverFailed() << 20 | (uint32_t)routeview::s_tries << 21 | (uint32_t)(R.discoverTries() > 0) << 23; +} + +// Under the chain: how many tries and Discover (Stop while it runs), where +// it is, then each route found -- its repeaters, how often it came, the way +// back -- a tap putting it in the list above. +void UITask::routeDiscRebuild() { + if (!_route_disc) return; + using namespace routeview; + PathEditor& R = _core->route; + lv_obj_clean(_route_disc); + lv_obj_t* g = group(_route_disc, "FIND THE ROUTE"); + lv_obj_t* row = groupLine(g, false); + groupText(row, "Discover", "Ask the node which way it's reached: a flood each try, 20 s apart"); + lv_obj_t* seg = segmented(row, TRIES_MAP, s_tries, 84, 30); + lv_obj_add_event_cb(seg, onRouteTries, LV_EVENT_VALUE_CHANGED, NULL); + + char t[160]; + t[0] = '\0'; + if (R.discoverFailed()) snprintf(t, sizeof(t), "Couldn't send the request"); + else if (R.discoverWaiting()) + snprintf(t, sizeof(t), "Try %d of %d: waiting for the reply...", R.discoverTry(), R.discoverTries()); + else if (R.discovering()) + snprintf(t, sizeof(t), "%d of %d answered, next try in %d s", R.discoverAnswered(), R.discoverDone(), R.discoverNextIn()); + else if (R.discoverTries() > 0 && R.discoverAnswered()) + snprintf(t, sizeof(t), "%d of %d answered", R.discoverAnswered(), R.discoverDone()); + else if (R.discoverTries() > 0) + snprintf(t, sizeof(t), "No answer to %d tr%s. A companion answers only if its Settings > Privacy > Status lets you, a repeater after a login", + R.discoverDone(), R.discoverDone() == 1 ? "y" : "ies"); + if (t[0]) { + lv_obj_t* l = groupLine(g, false); + noteLabel(l, t, THEME_FONT_SMALL, R.discoverFailed() ? theme::FAIL : theme::TEXT_MUTED); + } + for (int i = 0; i < R.foundCount(); i++) { + const PathEditor::Found& f = R.found(i); + char route[96], back[64], sub[96]; + R.foundText(i, false, route, sizeof(route)); + R.foundText(i, true, back, sizeof(back)); + const int hops = PathEditor::hopsOf(f.out_len); + snprintf(sub, sizeof(sub), "%d hop%s%s, found %u time%s. Back: %s", hops, hops == 1 ? "" : "s", + hops > PathEditor::MAX_HOPS ? " (too long to use)" : "", (unsigned)f.times, f.times == 1 ? "" : "s", back); + lv_obj_t* r = groupLine(g, true); + groupText(r, route, sub); + label(r, "Use", THEME_FONT_SMALL, theme::ACCENT); + lv_obj_add_event_cb(r, onRouteFound, LV_EVENT_CLICKED, (void*)(uintptr_t)i); + } + lv_obj_t* bar = buttonBar(_route_disc); + barButton(bar, R.discovering() ? LV_SYMBOL_CLOSE " Stop" : LV_SYMBOL_REFRESH " Discover", onRouteDiscover, 0, !R.discovering()); + _route_disc_shown = routeDiscSig(); +} + +void UITask::routeDiscover() { + PathEditor& R = _core->route; + if (R.discovering()) R.stopDiscover(); + else R.discover(routeview::TRIES[routeview::s_tries]); + routeDiscRebuild(); +} + +void UITask::routeDiscTries(int sel) { + if (sel >= 0 && sel < 3) routeview::s_tries = sel; +} + +void UITask::routeUseFound(int i) { + PathEditor& R = _core->route; + if (PathEditor::hopsOf(R.found(i).out_len) > PathEditor::MAX_HOPS) { showToast("Too long: up to 7 repeaters"); return; } + if (!R.useFound(i)) return; + showToast("In the list: Trace to check it, Save to use it"); + routeRebuild(); } void UITask::routeAct(uint8_t act) { diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index a1dd1805..e6346df8 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -127,6 +127,11 @@ public: void routeStatus(); uint32_t routeSig(); void routePoll(); + void routeDiscRebuild(); + uint32_t routeDiscSig(); + void routeDiscover(); + void routeDiscTries(int sel); + void routeUseFound(int i); void routeAct(uint8_t act); void routeOp(int code); void routeKeep(); @@ -664,6 +669,8 @@ private: 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_disc = nullptr; // Discover: the tries, its progress, the routes found + uint32_t _route_disc_shown = 0xFFFFFFFF; // what routeDiscRebuild() drew (routeDiscSig()) 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; diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index a8688907..56ada9e5 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -21,12 +21,16 @@ class RouteView { 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 + bool _seen_disc = false; // a discovery running at the last pass: its end said once + uint8_t _tries = 1; // Discover: triesOf(_tries) tries - enum Row : uint8_t { R_HOP, R_NODE, R_ADD, R_TRACE, R_PROBE, R_TO_NODE, R_SAVE, R_KEEP }; + // R_FOUND before R_NODE: like the hops, not an action the cursor follows. + enum Row : uint8_t { R_HOP, R_FOUND, R_NODE, R_ADD, R_TRACE, R_PROBE, R_TO_NODE, R_SAVE, R_KEEP, R_DISCOVER }; + static int triesOf(int i) { return i == 0 ? 1 : i == 1 ? 3 : 5; } PathEditor& R() { return _task->core().route; } // The rows: the hops, the node, then the actions (Find break only after a - // trace with no reply). + // trace with no reply), Discover and the routes it found. int rows(Row* out) { PathEditor& r = R(); int k = 0; @@ -38,9 +42,17 @@ class RouteView { 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; + out[k++] = R_DISCOVER; + for (int i = 0; i < r.foundCount(); i++) out[k++] = R_FOUND; return k; } - static const int ROWS_MAX = PathEditor::MAX_HOPS + 7; + static const int ROWS_MAX = PathEditor::MAX_HOPS + 8 + PathEditor::FOUND_MAX; + // Which found route row `idx` is. + static int foundIndex(const Row* kind, int idx) { + int f = 0; + for (int i = 0; i < idx; i++) if (kind[i] == R_FOUND) f++; + return f; + } void openHopMenu(int i) { _menu_hop = i; @@ -58,6 +70,13 @@ class RouteView { // What a result means, said once when it comes. void announce() { PathEditor& r = R(); + if (_seen_disc && !r.discovering() && r.discoverTries() > 0) { // a discovery just ended + char t[32]; + if (r.discoverAnswered()) snprintf(t, sizeof(t), "%d/%d answered", r.discoverAnswered(), r.discoverDone()); + else snprintf(t, sizeof(t), "No answer"); + _task->showAlert(t, 1500); + } + _seen_disc = r.discovering(); if (r.state() == _seen_state) return; _seen_state = r.state(); char t[48]; @@ -99,6 +118,7 @@ public: _menu.active = false; _seen_state = PathEditor::IDLE; _sel_kind = 0xFF; + _seen_disc = false; return true; } // Back from picking a repeater: the cursor on Add, for the next one. @@ -108,7 +128,7 @@ public: _sel_kind = R_ADD; } void close() { - if (_task) R().cancelTrace(); + if (_task) { R().cancelTrace(); R().stopDiscover(); } _active = false; _menu.active = false; } @@ -158,6 +178,20 @@ public: 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; + case R_DISCOVER: + if (r.discoverWaiting()) snprintf(left, sizeof(left), "Discover %d/%d...", r.discoverTry(), r.discoverTries()); + else if (r.discovering()) snprintf(left, sizeof(left), "Next try %ds", r.discoverNextIn()); + else snprintf(left, sizeof(left), "Discover"); + snprintf(right, sizeof(right), "%s", r.discovering() ? "Stop" : _tries == 0 ? "1 try" : _tries == 1 ? "3 tries" : "5 tries"); + break; + case R_FOUND: { + const int f = foundIndex(kind, idx); + char route[40]; + r.foundText(f, false, route, sizeof(route)); + snprintf(left, sizeof(left), "Use %s", route); + snprintf(right, sizeof(right), "%ux", (unsigned)r.found(f).times); + break; + } } const int rw = right[0] ? display.getTextWidth(right) + display.getCharWidth() : 0; display.drawTextEllipsized(2, y, display.width() - reserve - 4 - rw, left, sel); @@ -167,7 +201,7 @@ public: } }); if (_menu.active) _menu.render(display); - return r.state() == PathEditor::RUNNING ? 100 : 2000; + return r.state() == PathEditor::RUNNING ? 100 : r.discovering() ? 500 : 2000; } // Returns true: the key was used (the view is modal). @@ -196,6 +230,7 @@ public: 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(); + else if (k == R_DISCOVER && !r.discovering()) _tries = (uint8_t)((_tries + 3 + dir) % 3); return true; } if (c != KEY_ENTER && c != KEY_CONTEXT_MENU) return true; @@ -220,6 +255,17 @@ public: else _task->showAlert(r.count() ? "Path saved" : "Saved: direct", 1000); break; case R_KEEP: toggleKeep(); break; + case R_DISCOVER: + if (r.discovering()) r.stopDiscover(); + else if (!r.discover(triesOf(_tries))) _task->showAlert("Send failed", 1000); + else _seen_disc = true; + break; + case R_FOUND: { + const int f = foundIndex(kind, _sel); + if (PathEditor::hopsOf(r.found(f).out_len) > PathEditor::MAX_HOPS) { _task->showAlert("Too long", 1000); break; } + if (r.useFound(f)) { _task->showAlert("In list: Trace, Save", 1200); _sel = 0; } + break; + } } return true; }