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