diff --git a/docs/solo/contacts.md b/docs/solo/contacts.md index f692e424..700f518c 100644 --- a/docs/solo/contacts.md +++ b/docs/solo/contacts.md @@ -26,12 +26,21 @@ for what you can do with it: | Discover scan | Asks the repeaters, rooms and sensors in direct range to answer, with their signal | **Map** shows the nodes of the current filter that have a position, with you -and the target, fitted to the screen, north up, with a scale. Nodes are dots +and the target, fitted to the screen, north up, with a scale. **Flow** in its +options draws what the radio hears: each packet as a line from the node that +sent it, through the repeaters it passed, to you, for a minute; a packet you +send is a ring around you. Only nodes with a position can be placed, so a +line may skip a hop. Nodes are dots (a diamond for a live share); the selected one is boxed, and the line under the map gives its name and distance, with **+n** when others sit on the same spot. **Left / Right** selects the next node, **Up / Down** zooms in on it and back out, **Enter** opens its details and **Hold Enter** its options. +> [!NOTE] +> **Wio Tracker L2:** **Packet flow** is in the map's options (On the map). +> Each packet runs along its line as a dot and fades, coloured by kind: +> adverts blue, direct messages green, channels amber, the rest violet. + > [!NOTE] > **Wio Tracker L2:** the **Nodes** app. Filter chips at the top, the sort in > the header, and a map button that shows every node with a position. A diff --git a/docs/solo/developer/ui-core.md b/docs/solo/developer/ui-core.md index 6f054fc8..94874d3c 100644 --- a/docs/solo/developer/ui-core.md +++ b/docs/solo/developer/ui-core.md @@ -151,6 +151,7 @@ consumes keys only. - 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 ✅ (tested on the L2 to a direct neighbour): `ui-core/PathEditor.h` (`UiCore::route`) holds the repeaters between this node and a contact -- loaded from the contact's stored path, edited (add / move / remove), traced and saved (`MyMesh::setContactPath`; an empty list saves "direct"). A trace goes out and back over `MyMesh::sendTrace` (A B dest B A, or A B A with `toNode()` off: only a node that retransmits can answer, so a companion target needs repeat on); `PingEngine::startTrace` keeps the SNR of every entry (`PingResult::snrs_x4`, up to `TRACE_SNR_MAX`) and `PathEditor` turns them into links (`linkSnrX4(k)`: node k hearing k-1, and k-1 hearing k). After no reply, `probe()` traces to the 1st, 2nd, ... repeater and stops at the first that doesn't answer (`reached()`). Failures carry a reason (`failure()`: no key, busy, send, nothing to trace); `PingEngine` takes over a slot left past its timeout by a view that went away. Views: ui-lvgl's Route screen (node detail > Route and trace; `RouteScreen.h`) with a picker by distance and "Map" -- the nodes map where tapping repeaters in order builds the route (numbered badges, a line); ui-new's Path view (`RouteView` in `NearbyScreen.h`, the node's action menu > Path / trace) with Add handing the Nodes list over filtered to repeaters. A saved route can be kept (`CONTACT_FLAG_ROUTE_PINNED`, `ContactInfo::flags` bit 4; "Keep" in both views): `MyMesh::onContactPathRecv` puts the held path and its lastmod back after the base class took a heard one, without announcing it to the app (`_path_kept`); `tickDmResends` doesn't drop it before the last resend; an app update that leaves the route as it is keeps the flag, one that changes it ends it; Reset path (device or app) clears it. The ui-lvgl map's markers are one drawn layer (`mapview::drawMarks`, hit-tested by `hitMark`), not an object each. +- Packet flow ✅ (sim only): `ui-core/PacketFlow.h` (`UiCore::flow`) turns every packet heard (`MyMesh::logRx` -> `Listener::onPacketHeard`) into a flow -- origin (an advert's position / a message's source hash), the path's repeaters with a position (resolved back from this device, each hash nearest the point after it), this device -- and a packet sent (`logTx`) into a pulse; ring of `MAX_FLOWS`, nothing resolved while `NodePrefs::flow_show` is off. ui-lvgl draws them on the map's marker layer (`mapview::drawFlows`: a dot running the line, a fading trail, rings) and repaints only their area every 50 ms (`UITask::mapFlowTick`); ui-new draws the last minute's lines on the Nodes map (`NearbyScreen::renderMap`, toggle "Flow" in its options). Setting: Map options > On the map > Packet flow, CLI `map.flow`. - PR #3381's LVGL UI is a reference for platform glue (flush, GT911, PSRAM pool) only; its screens are not adopted. ## Migration plan diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 42b5e2d8..e1946d0d 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -188,6 +188,10 @@ public: // its state and restarts. Returns false if it doesn't handle it, and // MyMesh then flushes and reboots by itself. virtual bool requestShutdown(bool restart) { return false; } + // Every packet heard / sent, for a picture of the mesh at work + // (ui-core/PacketFlow.h). Called before the packet is handled. + virtual void onPacketHeard(const mesh::Packet* pkt) { } + virtual void onPacketSent(const mesh::Packet* pkt) { } // A companion CLI command MyMesh has no answer for (its settings keys): // the reply into `reply` (n bytes), false when unknown. virtual bool onCliCommand(const char* command, char* reply, int n) { return false; } @@ -292,6 +296,8 @@ protected: void sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis=0) override; void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override; + void logRx(mesh::Packet* pkt, int len, float score) override { if (_listener) _listener->onPacketHeard(pkt); } + void logTx(mesh::Packet* pkt, int len) override { if (_listener) _listener->onPacketSent(pkt); } bool isAutoAddEnabled() const override; bool shouldAutoAddContactType(uint8_t type) const override; bool shouldOverwriteWhenFull() const override; diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index da5a9b36..da23988f 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -540,6 +540,9 @@ struct NodePrefs { // persisted to file // 1 = latest message first, 2 = by name. See chanctl::order(). static const uint8_t CH_SORT_COUNT = 3; uint8_t ch_sort; + // Map > options > "Packet flow": what the radio hears drawn on the map as + // lines between the nodes (ui-core/PacketFlow.h). 0 = off (default). + uint8_t flow_show; // Single source of truth for the live-share option tables (shared by the Map // UI labels and the auto-send engine in UITask). diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 27501df1..c57990d6 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -699,6 +699,23 @@ extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_open_route(int type) { } #endif +// Puts the first contact of `type` (ADV_TYPE_*) at a position, as if its +// advert had carried one (the map's packet flow needs nodes on the map). +extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_place_contact(int type, double lat, double lon) { + if (!g_sim_ready) return 0; + int n = the_mesh.getNumContacts(); + for (int i = 0; i < n; i++) { + ContactInfo ci; + if (!the_mesh.getContactByIdx(MAX_ANON_CONTACTS + i, ci) || ci.type != type) continue; + ContactInfo* c = the_mesh.lookupContactByPubKey(ci.id.pub_key, PUB_KEY_SIZE); + if (!c) return 0; + c->gps_lat = (int32_t)lround(lat * 1e6); + c->gps_lon = (int32_t)lround(lon * 1e6); + return 1; + } + return 0; +} + // A hashtag channel into the first free slot ("#name": the name is the // passphrase, as chanctl::addHashtag does). The slot, or -1. extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_add_channel(const char* name) { @@ -717,6 +734,11 @@ extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_add_channel(const char* name) { } #ifdef DISPLAY_CLASS +// The nodes map (ui-new: Nodes > Map; ui-lvgl: the map from Nodes). +extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_open_nodes_map() { + return g_sim_ready && ui_task.openNodesMap() ? 1 : 0; +} + // The messages list (ui-lvgl: Messages; ui-new: the Messages screen). extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_open_messages() { if (!g_sim_ready) return 0; diff --git a/examples/companion_radio/ui-core/PacketFlow.h b/examples/companion_radio/ui-core/PacketFlow.h new file mode 100644 index 00000000..b9866ef2 --- /dev/null +++ b/examples/companion_radio/ui-core/PacketFlow.h @@ -0,0 +1,183 @@ +#pragma once +// Packet flow: what the radio hears, as lines on the map -- a picture of the +// mesh at work, nothing more. Every packet heard (MyMesh::logRx) that can be +// placed becomes a flow: the node it came from, the repeaters it went +// through, this device; a packet sent is a pulse here. The frontends draw the +// recent flows over their map (ui-lvgl animates them, ui-new draws the lines). +// +// Placing a packet: +// - its path (a flood's repeaters, in order) -- each hash is matched to a +// repeater contact with a position; with several matches, the one nearest +// the point before it; +// - its origin, when the packet says: an advert carries the sender's key +// and position, a direct message / request / response / path the +// sender's one-byte hash (matched like a hop, to any contact); +// - its end: this device (the GPS, else the position set in Privacy). +// Points without a position are skipped, so a flow may be shorter than its +// path or none at all (a group message from a node in direct range). +// A direct packet heard on its way elsewhere carries the hops still ahead: +// a pulse at the next one. +// +// Off (NodePrefs::flow_show 0) nothing is resolved: a contact scan per hop is +// cheap, but not free on every packet. +// +// Header-only Core, single TU (see UiCore.h). + +class PacketFlow { +public: + static const int MAX_FLOWS = 12; + static const int MAX_PTS = 10; // origin, up to 8 repeaters, this device + + enum Kind : uint8_t { K_ADVERT, K_MSG, K_GROUP, K_OTHER, K_SENT, K_AHEAD }; + + struct Flow { + uint32_t t_ms; // when it was heard + uint8_t kind; + uint8_t n; // points (1 for a pulse) + bool origin; // point 0 is the sender (else the first repeater placed) + int32_t lat[MAX_PTS], lon[MAX_PTS]; + }; + + void begin(NodePrefs* prefs, CourseEngine* course, SensorManager* sensors) { + _prefs = prefs; _course = course; _sensors = sensors; + } + + bool enabled() const { return _prefs && _prefs->flow_show; } + + // Changes with every flow added: a view repaints when it moves. + uint32_t seq() const { return _seq; } + int count() const { return _n; } + // i = 0 is the newest. + const Flow& at(int i) const { return _flows[(_head + MAX_FLOWS - 1 - i) % MAX_FLOWS]; } + // Anything younger than `life_ms`? + bool active(uint32_t life_ms) const { return _n > 0 && millis() - at(0).t_ms < life_ms; } + + void onHeard(const mesh::Packet* pkt) { + if (!enabled() || !pkt) return; + Flow f; + f.t_ms = millis(); + f.n = 0; + f.origin = false; + const uint8_t type = pkt->getPayloadType(); + f.kind = type == PAYLOAD_TYPE_ADVERT ? K_ADVERT + : type == PAYLOAD_TYPE_TXT_MSG ? K_MSG + : type == PAYLOAD_TYPE_GRP_TXT || type == PAYLOAD_TYPE_GRP_DATA ? K_GROUP : K_OTHER; + const uint8_t hs = pkt->getPathHashSize(), hc = pkt->getPathHashCount(); + + if (pkt->isRouteDirect()) { // on its way elsewhere: a pulse at the next hop + int32_t lat, lon; + if (hc == 0 || !placeHash(pkt->path, hs, false, 0, 0, false, lat, lon)) return; + f.kind = K_AHEAD; + add(f, lat, lon); + push(f); + return; + } + + // Back from this device: each hash nearest the point after it, which + // it was heard by -- the last repeater is the one heard here. + int32_t lat[MAX_PTS], lon[MAX_PTS]; + int k = MAX_PTS; + int32_t ola, olo; + const bool own = ownPosition(ola, olo); + if (own) { k--; lat[k] = ola; lon[k] = olo; } + for (int i = hc - 1; i >= 0 && k > 1; i--) { + int32_t a, b; + const bool near = k < MAX_PTS; + if (placeHash(&pkt->path[i * hs], hs, true, near ? lat[k] : 0, near ? lon[k] : 0, near, a, b)) { + k--; lat[k] = a; lon[k] = b; + } + } + int32_t a, b; + if (k > 0 && origin(pkt, k < MAX_PTS, k < MAX_PTS ? lat[k] : 0, k < MAX_PTS ? lon[k] : 0, a, b)) { + k--; lat[k] = a; lon[k] = b; + f.origin = true; + } + for (int i = k; i < MAX_PTS; i++) add(f, lat[i], lon[i]); + if (f.n >= 2) push(f); + } + + void onSent(const mesh::Packet* pkt) { + if (!enabled() || !pkt) return; + int32_t lat, lon; + if (!ownPosition(lat, lon)) return; + Flow f; + f.t_ms = millis(); + f.kind = K_SENT; + f.n = 0; + f.origin = true; + add(f, lat, lon); + push(f); + } + + void clear() { _n = 0; _head = 0; _seq++; } + +private: + static void add(Flow& f, int32_t lat, int32_t lon) { + if (f.n > 0 && f.lat[f.n - 1] == lat && f.lon[f.n - 1] == lon) return; // the same spot twice + f.lat[f.n] = lat; f.lon[f.n] = lon; f.n++; + } + + void push(const Flow& f) { + _flows[_head] = f; + _head = (_head + 1) % MAX_FLOWS; + if (_n < MAX_FLOWS) _n++; + _seq++; + } + + bool ownPosition(int32_t& lat, int32_t& lon) const { + if (_course && _course->currentLocation(lat, lon)) return true; + if (!_sensors) return false; + lat = (int32_t)lround(_sensors->node_lat * 1e6); + lon = (int32_t)lround(_sensors->node_lon * 1e6); + return lat != 0 || lon != 0; + } + + // Where the packet came from, when it says (a one-byte hash: the contact + // nearest the point after it, when there is one). + static bool origin(const mesh::Packet* pkt, bool near, int32_t nlat, int32_t nlon, int32_t& lat, int32_t& lon) { + const uint8_t type = pkt->getPayloadType(); + if (type == PAYLOAD_TYPE_ADVERT) { + const int app = PUB_KEY_SIZE + 4 + SIGNATURE_SIZE; // key, timestamp, signature, then the app data + if (pkt->payload_len > app) { + AdvertDataParser p(&pkt->payload[app], pkt->payload_len - app); + if (p.isValid() && p.hasLatLon() && (p.getIntLat() || p.getIntLon())) { + lat = p.getIntLat(); lon = p.getIntLon(); + return true; + } + } + ContactInfo* c = the_mesh.lookupContactByPubKey(pkt->payload, PUB_KEY_SIZE); + if (c && (c->gps_lat || c->gps_lon)) { lat = c->gps_lat; lon = c->gps_lon; return true; } + return false; + } + if ((type == PAYLOAD_TYPE_TXT_MSG || type == PAYLOAD_TYPE_REQ || type == PAYLOAD_TYPE_RESPONSE || + type == PAYLOAD_TYPE_PATH) && pkt->payload_len >= 2) + return placeHash(&pkt->payload[1], 1, false, nlat, nlon, near, lat, lon); // [dest hash, src hash, ...] + return false; + } + + // A contact with a position whose key starts with `hash` (`size` bytes): + // repeaters only when `repeaters`; with several, the nearest to (nlat, nlon) + // when `near`, else the first. + static bool placeHash(const uint8_t* hash, uint8_t size, bool repeaters, int32_t nlat, int32_t nlon, bool near, + int32_t& lat, int32_t& lon) { + bool found = false; + float best = 0; + for (int idx = MAX_ANON_CONTACTS; ; idx++) { + ContactInfo c; + if (!the_mesh.getContactByIdx(idx, c)) break; + if (repeaters && c.type != ADV_TYPE_REPEATER) continue; + if ((!c.gps_lat && !c.gps_lon) || memcmp(c.id.pub_key, hash, size) != 0) continue; + float d = near ? geo::haversineKm(nlat, nlon, c.gps_lat, c.gps_lon) : 0; + if (!found || d < best) { best = d; lat = c.gps_lat; lon = c.gps_lon; found = true; } + if (!near) break; + } + return found; + } + + NodePrefs* _prefs = nullptr; + CourseEngine* _course = nullptr; + SensorManager* _sensors = nullptr; + Flow _flows[MAX_FLOWS]; + int _head = 0, _n = 0; + uint32_t _seq = 0; +}; diff --git a/examples/companion_radio/ui-core/SettingsCli.h b/examples/companion_radio/ui-core/SettingsCli.h index 7694266d..a6432cdd 100644 --- a/examples/companion_radio/ui-core/SettingsCli.h +++ b/examples/companion_radio/ui-core/SettingsCli.h @@ -21,7 +21,7 @@ static const Key KEYS[] = { K("autoadd", manual_add_contacts), KB("autoadd.chat", autoadd_config, 0x02), KB("autoadd.repeater", autoadd_config, 0x04), KB("autoadd.room", autoadd_config, 0x08), KB("autoadd.sensor", autoadd_config, 0x10), KB("autoadd.overwrite", autoadd_config, 0x01), K("autoadd.max.hops", autoadd_max_hops), - K("contacts.expiry", contact_expiry_idx), K("contacts.fav.first", fav_sort), K("contacts.channel.order", ch_sort), K("dm.resend", dm_resend_count), + K("contacts.expiry", contact_expiry_idx), K("contacts.fav.first", fav_sort), K("contacts.channel.order", ch_sort), K("map.flow", flow_show), K("dm.resend", dm_resend_count), K("display.brightness", display_brightness), K("display.off", auto_off_secs), K("display.wake", msg_wake), K("display.alert", msg_alert), K("lock", auto_lock), K("lock.clock", lock_compact), K("display.text.size", ui_text_scale), K("display.icon.size", ui_icon_scale), diff --git a/examples/companion_radio/ui-core/SettingsSchema.h b/examples/companion_radio/ui-core/SettingsSchema.h index 283b542a..01f1cd40 100644 --- a/examples/companion_radio/ui-core/SettingsSchema.h +++ b/examples/companion_radio/ui-core/SettingsSchema.h @@ -25,7 +25,7 @@ namespace settings { // PG_ADVERT sits in Nearby's advert popup; the rest are Settings pages. // PG_PRIVACY and PG_RADIO (Radio's advanced options) are Settings pages too. enum Page : uint8_t { PG_NAV, PG_ADVERT, PG_DEVICE, PG_MESSAGES, PG_SOUND, PG_PRIVACY, PG_RADIO, PG_COUNT }; -enum Section : uint8_t { SEC_TRAIL, SEC_LIVE_SHARE, SEC_LOCATOR, SEC_ADVERT, +enum Section : uint8_t { SEC_TRAIL, SEC_LIVE_SHARE, SEC_LOCATOR, SEC_MAP_VIEW, SEC_ADVERT, SEC_DISPLAY, SEC_POWER, SEC_TIME, SEC_UNITS, SEC_MESSAGES, SEC_CONTACTS, SEC_AUTO_ADD, SEC_SOUND, SEC_SOUND_FOR, SEC_QUIET, @@ -37,7 +37,7 @@ static const char* pageTitle(uint8_t p) { return p < PG_COUNT ? T[p] : ""; } static const char* sectionTitle(uint8_t s) { - static const char* T[SEC_COUNT] = { "TRAIL", "LIVE SHARE", "ARRIVAL ALERT", "AUTOMATIC", + static const char* T[SEC_COUNT] = { "TRAIL", "LIVE SHARE", "ARRIVAL ALERT", "ON THE MAP", "AUTOMATIC", "DISPLAY", "POWER", "TIME", "UNITS", "MESSAGES", "CONTACTS", "ADDING CONTACTS", "SOUND", "PLAYS FOR", "QUIET HOURS", @@ -45,7 +45,7 @@ static const char* sectionTitle(uint8_t s) { return s < SEC_COUNT ? T[s] : ""; } static uint8_t sectionPage(uint8_t s) { - static const uint8_t P[SEC_COUNT] = { PG_NAV, PG_NAV, PG_NAV, PG_ADVERT, + static const uint8_t P[SEC_COUNT] = { PG_NAV, PG_NAV, PG_NAV, PG_NAV, PG_ADVERT, PG_DEVICE, PG_DEVICE, PG_DEVICE, PG_DEVICE, PG_MESSAGES, PG_MESSAGES, PG_MESSAGES, PG_SOUND, PG_SOUND, PG_SOUND, @@ -250,6 +250,8 @@ static const Setting ALL[] = { NodePrefs::LOCATOR_MODE_COUNT, optLocMode, rearmLocator), SW("Proximity beeper", "Ticks faster when closer", SEC_LOCATOR, locator_beeper, nullptr), + SW("Packet flow", "What the radio hears, as lines between nodes", SEC_MAP_VIEW, flow_show, nullptr), + MAP("Auto-advert", "Your advert, on a timer", SEC_ADVERT, advert_auto_interval_sec, AUTO_ADVERT, optAutoAdvert, nullptr), diff --git a/examples/companion_radio/ui-core/UiCore.h b/examples/companion_radio/ui-core/UiCore.h index 672940c9..200b5733 100644 --- a/examples/companion_radio/ui-core/UiCore.h +++ b/examples/companion_radio/ui-core/UiCore.h @@ -21,6 +21,7 @@ #include "PingEngine.h" #include "PathEditor.h" #include "CourseEngine.h" +#include "PacketFlow.h" #include "LiveShareEngine.h" #include "LocatorEngine.h" #include "TrailEngine.h" @@ -40,6 +41,7 @@ public: ping.begin(prefs); route.begin(prefs, &ping); course.begin(sensors); + flow.begin(prefs, &course, sensors); live_share.begin(prefs, &course, &events); locator.begin(prefs, &course, &live_share, &events); trail.begin(prefs, &course); @@ -68,6 +70,7 @@ public: 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 + PacketFlow flow; // packets heard, as lines between nodes for the map LiveShareEngine live_share; // [LOC] auto-share session + peers' shared positions LocatorEngine locator; // active target, geofence crossings, proximity beeper TrailEngine trail; // GPS trail store, sampling, auto-pause, shutdown save @@ -198,6 +201,8 @@ public: void onACKRecv(uint32_t ack_crc) override { history.markDmDelivered(ack_crc); } void onAdvertHeard(bool was_flood) override { events.push(UiEventType::AdvertHeard, nullptr, was_flood); } + void onPacketHeard(const mesh::Packet* pkt) override { flow.onHeard(pkt); } + void onPacketSent(const mesh::Packet* pkt) override { flow.onSent(pkt); } bool requestShutdown(bool restart) override { _host->shutdown(restart); return true; } void onMessageRecv(mesh::Packet *pkt, const ContactInfo &from, uint8_t txt_type, uint32_t sender_timestamp, diff --git a/examples/companion_radio/ui-lvgl/MapScreen.h b/examples/companion_radio/ui-lvgl/MapScreen.h index c2fb4250..c7f73a30 100644 --- a/examples/companion_radio/ui-lvgl/MapScreen.h +++ b/examples/companion_radio/ui-lvgl/MapScreen.h @@ -258,6 +258,141 @@ static bool prefetchOne(double left, double top, int w, int h, int z) { return true; } +// ── Packet flow (ui-core/PacketFlow.h) ── +// Each packet heard runs along its line, sender -> repeaters -> here, as a +// dot leaving a trail that fades; a packet sent (and one heard on its way +// elsewhere) is a ring spreading from where it is. Drawn on the marker +// layer, under the names and dots. Only the area the flows cover is +// repainted while they move. +static PacketFlow* s_flow = nullptr; +static double s_view_left = 0, s_view_top = 0; // the view, from the last layout +static int s_view_z = 0; +static bool s_view_ok = false; +static const uint32_t FLOW_LIFE = 7000; // ms a flow stays on the map +static const uint32_t FLOW_TRAVEL = 1500; // ms the dot takes from end to end +static const uint32_t PULSE_MS = 1400; +static lv_area_t s_flow_area; // what the flows covered at the last tick +static bool s_flow_area_on = false; + +static uint32_t flowColour(uint8_t kind) { + switch (kind) { + case PacketFlow::K_ADVERT: return 0x4FC3F7; // blue + case PacketFlow::K_MSG: return theme::OK; // green + case PacketFlow::K_GROUP: return theme::ACCENT; + case PacketFlow::K_SENT: return theme::TEXT; + case PacketFlow::K_AHEAD: return theme::TEXT_MUTED; + default: return 0xB39DDB; // violet + } +} + +static void flowPx(int32_t lat, int32_t lon, int32_t& x, int32_t& y) { + double fx = lonToTileX(lon / 1e6, s_view_z) * TILE_PX - s_view_left; + double fy = latToTileY(lat / 1e6, s_view_z) * TILE_PX - s_view_top; + x = (int32_t)lround(fx < -30000 ? -30000 : fx > 30000 ? 30000 : fx); + y = (int32_t)lround(fy < -30000 ? -30000 : fy > 30000 ? 30000 : fy); +} + +// The area (layer px) the live flows cover, with room for a ring; false: none. +static bool flowArea(lv_area_t& r) { + if (!s_flow || !s_view_ok || !s_flow->enabled()) return false; + const uint32_t now = millis(); + bool any = false; + for (int i = 0; i < s_flow->count(); i++) { + const PacketFlow::Flow& f = s_flow->at(i); + if (now - f.t_ms >= FLOW_LIFE) break; // newest first: the rest are older + for (int k = 0; k < f.n; k++) { + int32_t x, y; + flowPx(f.lat[k], f.lon[k], x, y); + if (!any) { r = { x, y, x, y }; any = true; } + r.x1 = min(r.x1, x); r.y1 = min(r.y1, y); r.x2 = max(r.x2, x); r.y2 = max(r.y2, y); + } + } + if (any) { r.x1 -= 28; r.y1 -= 28; r.x2 += 28; r.y2 += 28; } + return any; +} + +static void drawFlows(lv_layer_t* layer, const lv_area_t& a) { + if (!s_flow || !s_view_ok || !s_flow->enabled()) return; + const uint32_t now = millis(); + lv_draw_line_dsc_t ln, halo; // the line, over a dark halo that lifts it off the map + lv_draw_line_dsc_init(&ln); + ln.width = 3; + ln.round_start = ln.round_end = 1; + halo = ln; + halo.width = 6; + halo.color = lv_color_hex(theme::BG); + lv_draw_arc_dsc_t ring; + lv_draw_arc_dsc_init(&ring); + ring.start_angle = 0; ring.end_angle = 360; + ring.width = 3; + lv_draw_rect_dsc_t dot; + lv_draw_rect_dsc_init(&dot); + dot.radius = LV_RADIUS_CIRCLE; + dot.bg_opa = LV_OPA_COVER; + dot.border_color = lv_color_hex(theme::BG); + dot.border_width = 2; + dot.border_opa = LV_OPA_COVER; + for (int i = s_flow->count() - 1; i >= 0; i--) { // oldest first: the newest on top + const PacketFlow::Flow& f = s_flow->at(i); + const uint32_t age = now - f.t_ms; + if (age >= FLOW_LIFE) continue; + const lv_color_t col = lv_color_hex(flowColour(f.kind)); + int32_t px[PacketFlow::MAX_PTS], py[PacketFlow::MAX_PTS]; + for (int k = 0; k < f.n; k++) { flowPx(f.lat[k], f.lon[k], px[k], py[k]); px[k] += a.x1; py[k] += a.y1; } + if (f.n == 1) { // a pulse + if (age >= PULSE_MS) continue; + ring.color = col; + ring.center.x = px[0]; ring.center.y = py[0]; + ring.radius = (uint16_t)(5 + 22 * age / PULSE_MS); + ring.opa = (lv_opa_t)(255 - 255 * age / PULSE_MS); + lv_draw_arc(layer, &ring); + continue; + } + // The trail behind the dot, fading over the flow's life. + float len[PacketFlow::MAX_PTS]; + float total = 0; + for (int k = 1; k < f.n; k++) { + float dx = px[k] - px[k - 1], dy = py[k] - py[k - 1]; + len[k] = sqrtf(dx * dx + dy * dy); + total += len[k]; + } + const float t = age >= FLOW_TRAVEL ? 1.0f : (float)age / FLOW_TRAVEL; + float left = total * t; + ln.color = col; + ln.opa = (lv_opa_t)(age < FLOW_TRAVEL ? 255 : 255 - 215 * (age - FLOW_TRAVEL) / (FLOW_LIFE - FLOW_TRAVEL)); + halo.opa = (lv_opa_t)(ln.opa * 2 / 3); + int32_t hx = px[0], hy = py[0]; // the dot + int32_t ex_[PacketFlow::MAX_PTS], ey_[PacketFlow::MAX_PTS]; + int segs = 0; + for (int k = 1; k < f.n && left > 0; k++, segs++) { + float part = left >= len[k] ? 1.0f : left / len[k]; + ex_[k] = px[k - 1] + (int32_t)lroundf((px[k] - px[k - 1]) * part); + ey_[k] = py[k - 1] + (int32_t)lroundf((py[k] - py[k - 1]) * part); + hx = ex_[k]; hy = ey_[k]; + left -= len[k]; + } + for (int pass = 0; pass < 2; pass++) // all the halo, then all the line: joints stay clean + for (int k = 1; k <= segs; k++) { + lv_draw_line_dsc_t& d = pass ? ln : halo; + d.p1 = { px[k - 1], py[k - 1] }; + d.p2 = { ex_[k], ey_[k] }; + lv_draw_line(layer, &d); + } + if (age < FLOW_TRAVEL) { + dot.bg_color = col; + lv_area_t d = { hx - 5, hy - 5, hx + 5, hy + 5 }; + lv_draw_rect(layer, &dot, &d); + } else if (age < FLOW_TRAVEL + PULSE_MS) { // arrived: a ring here + uint32_t p = age - FLOW_TRAVEL; + ring.color = col; + ring.center.x = px[f.n - 1]; ring.center.y = py[f.n - 1]; + ring.radius = (uint16_t)(5 + 16 * p / PULSE_MS); + ring.opa = (lv_opa_t)(220 - 220 * p / PULSE_MS); + lv_draw_arc(layer, &ring); + } + } +} + static const int MARK_D = 12; // marker dot diameter static const int MARK_LABELS_MAX = 20; // names shown at once: the rest stay dots @@ -279,6 +414,7 @@ static void drawMarks(lv_event_t* e) { lv_draw_line(layer, &ln); } } + drawFlows(layer, a); lv_draw_rect_dsc_t pill; lv_draw_rect_dsc_init(&pill); pill.bg_color = lv_color_hex(theme::BG); @@ -561,6 +697,9 @@ void UITask::buildMap() { 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_flow = &_core->flow; + mapview::s_view_ok = false; + mapview::s_flow_area_on = false; mapview::s_sig_n = 0; areaBuildLayer(body); // download frame / area outlines, over the tiles (MapAreas.h) @@ -744,6 +883,7 @@ void UITask::layoutMap() { // One tile decode per call, nearest to the centre first. void UITask::mapLoop() { + mapFlowTick(); if (_map_follow && (int32_t)(millis() - _next_follow_ms) >= 0) { // following you: every fix _next_follow_ms = millis() + 1000; int32_t lat, lon; @@ -871,6 +1011,9 @@ void UITask::addMapMark(uint8_t kind, int idx, int32_t lat_e6, int32_t lon_e6, u void UITask::layoutMapMarks(double left, double top, int w, int h) { using namespace mapview; if (!s_mark_layer) return; + if (s_view_ok && (s_view_left != left || s_view_top != top || s_view_z != _map_z) && flowArea(s_flow_area)) + lv_obj_invalidate(s_mark_layer); // the view moved under live flows + s_view_left = left; s_view_top = top; s_view_z = _map_z; s_view_ok = true; 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; }; @@ -1034,6 +1177,27 @@ void UITask::mapLongPress(int x, int y) { } +// Packet flow: while flows move, the area they cover (and covered) is +// repainted ~20 times a second; nothing at all when none is live. +void UITask::mapFlowTick() { + using namespace mapview; + if (!s_mark_layer || (int32_t)(millis() - _next_flow_ms) < 0) return; + _next_flow_ms = millis() + 50; + lv_area_t r; + bool now_on = flowArea(r); + if (!now_on && !s_flow_area_on) return; + lv_area_t la; + lv_obj_get_coords(s_mark_layer, &la); + auto inval = [&](const lv_area_t& b) { + lv_area_t x = { la.x1 + b.x1, la.y1 + b.y1, la.x1 + b.x2, la.y1 + b.y2 }; + lv_obj_invalidate_area(s_mark_layer, &x); + }; + if (s_flow_area_on) inval(s_flow_area); + if (now_on) inval(r); + s_flow_area = r; + s_flow_area_on = now_on; +} + // ── Picking a route (Route > Map) ───────────────────────────────────────────── static void onRouteUndo(lv_event_t* e) { (void)e; s_ui->routeMapUndo(); } diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index 2087f967..776f62b1 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -131,6 +131,7 @@ public: void routeMapUndo(); void routeMapFrame(); void buildRouteBar(lv_obj_t* body); + void mapFlowTick(); void mapDownloadPopup(); // Map areas (MapAreas.h) void areaBuildLayer(lv_obj_t* body); @@ -366,6 +367,7 @@ public: void openAdminFor(const ContactInfo& ci, bool from_picker) { (void)from_picker; openAdmin(ci.id.pub_key); } void openRouteFor(const uint8_t* pub_key) { showRoute(pub_key); } void openMessagesList() { showChats(); } // sim harness + bool openNodesMap() { openMap(false); return true; } // sim harness private: enum Screen : uint8_t { SCR_HOME, SCR_CHATS, SCR_CONTACTS, SCR_THREAD, SCR_SETTINGS, SCR_NEARBY, SCR_NODE, SCR_MAP, SCR_WIFI, SCR_SETTINGS_NAV, SCR_CLOCK, SCR_RADIO, SCR_CHANNEL_EDIT, SCR_ADMIN, SCR_BOT, SCR_DIAG, SCR_COMPASS, SCR_SCOPES, SCR_REPEATER, SCR_MELODY, SCR_QUICK, SCR_ADMIN_PICK, SCR_OTA, SCR_STORAGE, SCR_GPS, SCR_BATT, SCR_ROUTE, SCR_ROUTE_PICK }; @@ -641,6 +643,7 @@ private: 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; + uint32_t _next_flow_ms = 0; // the map's packet flow repaint bool _route_pick = false; // the nodes map taps repeaters into the route lv_obj_t* _route_bar = nullptr; lv_obj_t* _route_lbl = nullptr; diff --git a/examples/companion_radio/ui-new/GfxUtils.h b/examples/companion_radio/ui-new/GfxUtils.h index 2b8019b0..b9679d35 100644 --- a/examples/companion_radio/ui-new/GfxUtils.h +++ b/examples/companion_radio/ui-new/GfxUtils.h @@ -25,6 +25,22 @@ static inline void drawLine(DisplayDriver& d, int x0, int y0, int x1, int y1) { } } +// The part of a line inside a box (x, y, w, h), Liang-Barsky; nothing when +// it misses the box. For lines whose ends may lie far off the screen. +static inline void drawLineClipped(DisplayDriver& d, int x0, int y0, int x1, int y1, int bx, int by, int bw, int bh) { + float t0 = 0, t1 = 1; + const float dx = (float)(x1 - x0), dy = (float)(y1 - y0); + const float p[4] = { -dx, dx, -dy, dy }; + const float q[4] = { (float)(x0 - bx), (float)(bx + bw - 1 - x0), (float)(y0 - by), (float)(by + bh - 1 - y0) }; + for (int i = 0; i < 4; i++) { + if (p[i] == 0) { if (q[i] < 0) return; continue; } + float r = q[i] / p[i]; + if (p[i] < 0) { if (r > t1) return; if (r > t0) t0 = r; } + else { if (r < t0) return; if (r < t1) t1 = r; } + } + drawLine(d, x0 + (int)(t0 * dx), y0 + (int)(t0 * dy), x0 + (int)(t1 * dx), y0 + (int)(t1 * dy)); +} + // Midpoint-circle outline. static inline void drawCircle(DisplayDriver& d, int cx, int cy, int r) { int x = r, y = 0, err = 1 - r; diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index e3c54f15..ab41f599 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -263,7 +263,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_ROUTE }; + ACT_TELEM, ACT_RESET_PATH, ACT_SHARE, ACT_ROUTE, ACT_FLOW }; // Set by UITask::pickAdminTarget() (Tools > Admin, which is remote-only): // while true, ENTER on an eligible row (a stored repeater/room contact) hands @@ -286,6 +286,8 @@ class NearbyScreen : public UIScreen, protected NearbyModel { bool _detail; bool _nav = false; // full-screen navigate-to-node view (over detail) bool _map = false; // the nodes with a position on a map (under detail) + static const uint32_t FLOW_SHOW_MS = 60000; // packet flow lines stay this long + uint32_t _flow_seen = 0; int _map_zoom = 0; // 0: all of them in view; each step halves it, on the selected navview::EtaTracker _nav_eta; // closing-speed/ETA for the navigate view unsigned long _detail_refresh_ms; @@ -552,6 +554,7 @@ class NearbyScreen : public UIScreen, protected NearbyModel { } char _fav_label[12]; // "Fav: ON" / "Fav: OFF" -- rewritten in place by L/R + char _flow_label[12]; // "Flow: ON" / "Flow: OFF" -- the map's packet flow char _pin_label[24]; // "Pin to dial" / "Unpin (slot N)" -- _menu stores the pointer char _telem_label[20]; // "Telem: ..." -- rewritten in place by L/R @@ -598,6 +601,10 @@ class NearbyScreen : public UIScreen, protected NearbyModel { }; if (stored && !_map && positioned(0, 1) >= 0) add("Map", ACT_MAP); + if (_map) { // packet flow: what the radio hears, as lines (ui-core/PacketFlow.h) + buildFlowLabel(); + addValue(_flow_label, ACT_FLOW); + } if (has_gps) add("Navigate", ACT_NAV); if (has_key) add("Ping", ACT_PING); if (has_gps) add("Save waypoint", ACT_WAYPOINT); @@ -645,6 +652,11 @@ class NearbyScreen : public UIScreen, protected NearbyModel { // Advance the value on the menu's value rows (Sort, Fav, Telem). Sort and Fav // are two-state, so LEFT and RIGHT do the same there; Telem steps by `dir`. + void buildFlowLabel() { + NodePrefs* p = _task->getNodePrefs(); + snprintf(_flow_label, sizeof(_flow_label), "Flow: %s", p && p->flow_show ? "ON" : "OFF"); + } + void cycleMenuValue(int i, int dir = 1) { if (i < 0 || i >= _menu_action_count) return; if (_menu_actions[i] == ACT_SORT) { @@ -653,6 +665,13 @@ class NearbyScreen : public UIScreen, protected NearbyModel { refresh(); } else if (_menu_actions[i] == ACT_FAV) { toggleFavSelected(); + } else if (_menu_actions[i] == ACT_FLOW) { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return; + p->flow_show = p->flow_show ? 0 : 1; + the_mesh.savePrefs(); + if (!p->flow_show) _task->core().flow.clear(); + buildFlowLabel(); } else if (_menu_actions[i] == ACT_TELEM) { const Entry* e = selected(); ContactInfo* ci = e ? the_mesh.lookupContactByPubKey(e->pub_key, PUB_KEY_SIZE) : nullptr; @@ -733,6 +752,7 @@ class NearbyScreen : public UIScreen, protected NearbyModel { break; } case ACT_SORT: break; // value rows -- see cycleMenuValue() + case ACT_FLOW: break; case ACT_SCAN: enterScan(); break; case ACT_MAP: { _map = true; _map_zoom = 0; @@ -916,6 +936,25 @@ class NearbyScreen : public UIScreen, protected NearbyModel { if (!inside(x, y)) continue; miniIconDrawCentered(d, x, y, _entries[i].is_live ? ICON_MAP_CONTACT : ICON_MAP_DOT); } + // Packet flow (ui-core/PacketFlow.h): the lines of the packets heard in + // the last minute, the newest drawn twice as thick; a ring for a packet + // sent, or heard on its way to a repeater. + const PacketFlow& fl = _task->core().flow; + for (int i = 0; fl.enabled() && i < fl.count(); i++) { + const PacketFlow::Flow& f = fl.at(i); + if (millis() - f.t_ms > FLOW_SHOW_MS) break; // newest first + int px[PacketFlow::MAX_PTS], py[PacketFlow::MAX_PTS]; + for (int k = 0; k < f.n; k++) project(f.lat[k], f.lon[k], px[k], py[k]); + if (f.n == 1) { + if (inside(px[0], py[0])) gfx::drawCircle(d, px[0], py[0], 6 * s); + continue; + } + const bool bold = i < 2; + for (int k = 1; k < f.n; k++) { + gfx::drawLineClipped(d, px[k - 1], py[k - 1], px[k], py[k], ax, ay, aw, ah); + if (bold) gfx::drawLineClipped(d, px[k - 1] + 1, py[k - 1], px[k] + 1, py[k], ax, ay, aw, ah); + } + } if (_own_gps) { int x, y; project(_own_lat, _own_lon, x, y); if (inside(x, y)) miniIconDrawCentered(d, x, y, ICON_MAP_CURRENT); } if (target) { int x, y; project(tla, tlo, x, y); if (inside(x, y)) miniIconDrawCentered(d, x, y, ICON_MAP_TARGET); } const int r = 4 * s; @@ -1093,7 +1132,9 @@ public: _detail_refresh_ms = millis(); } renderMap(display); - return renderActivePopup(display) ? 50 : 2000; + if (renderActivePopup(display)) return 50; + const PacketFlow& fl = _task->core().flow; // new lines show within a second + return fl.enabled() && (fl.seq() != _flow_seen || fl.active(FLOW_SHOW_MS)) ? (_flow_seen = fl.seq(), 1000) : 2000; } // ── list view ──────────────────────────────────────────────────────────── @@ -1220,6 +1261,17 @@ public: return (mq_delay > 0 && mq_delay < ret) ? mq_delay : ret; } + // The nodes map, from outside the list (the sim harness). + bool openMap() { + int i = positioned(0, 1); + if (i < 0) return false; + _sel = i; + _detail = false; // the map is drawn from the list + _map = true; _map_zoom = 0; + _detail_refresh_ms = millis(); + return true; + } + // Path / trace for a contact, from outside the list (the sim harness). bool openRoute(const uint8_t* pub_key) { int i = findContact(pub_key); diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 9c760985..14c9e1c5 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -256,6 +256,10 @@ void UITask::openAdminFor(const ContactInfo& ci, bool from_picker) { setCurrScreen(admin_screen); // runs AdminScreen::onShow()'s reset first ((AdminScreen*)admin_screen)->startFor(ci, from_picker); } +bool UITask::openNodesMap() { + setCurrScreen(nearby_screen); + return ((NearbyScreen*)nearby_screen)->openMap(); +} void UITask::openRouteFor(const uint8_t* pub_key) { setCurrScreen(nearby_screen); // its onShow() reset first ((NearbyScreen*)nearby_screen)->openRoute(pub_key); diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index b3717d4e..853b6c41 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -331,6 +331,7 @@ public: void openAdminFor(const ContactInfo& ci, bool from_picker); // canonical Admin entry for a specific target (Nodes' Hold-Enter menu or the picker above) void openRouteFor(const uint8_t* pub_key); // Nodes > a contact > Path / trace (sim harness) void openMessagesList() { gotoMessagesScreen(); } // sim harness + bool openNodesMap(); // sim harness: Nodes > Map void gotoNearbyScreen(); void gotoDashboardConfig(); void gotoAutoAdvertScreen();