feat(map): packet flow - what the radio hears, as lines between nodes

Every packet heard runs from its origin through the repeaters with a
position to this device; a packet sent is a ring. ui-core/PacketFlow.h
resolves them (UiCore::flow, MyMesh logRx/logTx -> Listener). ui-lvgl
draws them on the map's marker layer and repaints only their area while
they move; ui-new draws the last minute on the Nodes map ("Flow" in its
options). Setting: Map options > On the map > Packet flow, CLI map.flow.
Off by default. Sim-checked only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-10-14 12:50:43 +02:00
co-authored by Claude Opus 5.5
parent 120ddfc830
commit a8440c890e
15 changed files with 478 additions and 7 deletions
+10 -1
View File
@@ -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 | | 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 **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 (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 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 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. 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] > [!NOTE]
> **Wio Tracker L2:** the **Nodes** app. Filter chips at the top, the sort in > **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 > the header, and a map button that shows every node with a position. A
+1
View File
@@ -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. - 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. - 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. - 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. - PR #3381's LVGL UI is a reference for platform glue (flush, GT911, PSRAM pool) only; its screens are not adopted.
## Migration plan ## Migration plan
+6
View File
@@ -188,6 +188,10 @@ public:
// its state and restarts. Returns false if it doesn't handle it, and // its state and restarts. Returns false if it doesn't handle it, and
// MyMesh then flushes and reboots by itself. // MyMesh then flushes and reboots by itself.
virtual bool requestShutdown(bool restart) { return false; } 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): // A companion CLI command MyMesh has no answer for (its settings keys):
// the reply into `reply` (n bytes), false when unknown. // the reply into `reply` (n bytes), false when unknown.
virtual bool onCliCommand(const char* command, char* reply, int n) { return false; } 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 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 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 isAutoAddEnabled() const override;
bool shouldAutoAddContactType(uint8_t type) const override; bool shouldAutoAddContactType(uint8_t type) const override;
bool shouldOverwriteWhenFull() const override; bool shouldOverwriteWhenFull() const override;
+3
View File
@@ -540,6 +540,9 @@ struct NodePrefs { // persisted to file
// 1 = latest message first, 2 = by name. See chanctl::order(). // 1 = latest message first, 2 = by name. See chanctl::order().
static const uint8_t CH_SORT_COUNT = 3; static const uint8_t CH_SORT_COUNT = 3;
uint8_t ch_sort; 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 // Single source of truth for the live-share option tables (shared by the Map
// UI labels and the auto-send engine in UITask). // UI labels and the auto-send engine in UITask).
+22
View File
@@ -699,6 +699,23 @@ extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_open_route(int type) {
} }
#endif #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 // A hashtag channel into the first free slot ("#name": the name is the
// passphrase, as chanctl::addHashtag does). The slot, or -1. // passphrase, as chanctl::addHashtag does). The slot, or -1.
extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_add_channel(const char* name) { 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 #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). // The messages list (ui-lvgl: Messages; ui-new: the Messages screen).
extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_open_messages() { extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_open_messages() {
if (!g_sim_ready) return 0; if (!g_sim_ready) return 0;
@@ -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;
};
@@ -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), 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.room", autoadd_config, 0x08), KB("autoadd.sensor", autoadd_config, 0x10),
KB("autoadd.overwrite", autoadd_config, 0x01), K("autoadd.max.hops", autoadd_max_hops), 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.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.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), K("display.text.size", ui_text_scale), K("display.icon.size", ui_icon_scale),
@@ -25,7 +25,7 @@ namespace settings {
// PG_ADVERT sits in Nearby's advert popup; the rest are Settings pages. // 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. // 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 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_DISPLAY, SEC_POWER, SEC_TIME, SEC_UNITS,
SEC_MESSAGES, SEC_CONTACTS, SEC_AUTO_ADD, SEC_MESSAGES, SEC_CONTACTS, SEC_AUTO_ADD,
SEC_SOUND, SEC_SOUND_FOR, SEC_QUIET, SEC_SOUND, SEC_SOUND_FOR, SEC_QUIET,
@@ -37,7 +37,7 @@ static const char* pageTitle(uint8_t p) {
return p < PG_COUNT ? T[p] : ""; return p < PG_COUNT ? T[p] : "";
} }
static const char* sectionTitle(uint8_t s) { 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", "DISPLAY", "POWER", "TIME", "UNITS",
"MESSAGES", "CONTACTS", "ADDING CONTACTS", "MESSAGES", "CONTACTS", "ADDING CONTACTS",
"SOUND", "PLAYS FOR", "QUIET HOURS", "SOUND", "PLAYS FOR", "QUIET HOURS",
@@ -45,7 +45,7 @@ static const char* sectionTitle(uint8_t s) {
return s < SEC_COUNT ? T[s] : ""; return s < SEC_COUNT ? T[s] : "";
} }
static uint8_t sectionPage(uint8_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_DEVICE, PG_DEVICE, PG_DEVICE, PG_DEVICE,
PG_MESSAGES, PG_MESSAGES, PG_MESSAGES, PG_MESSAGES, PG_MESSAGES, PG_MESSAGES,
PG_SOUND, PG_SOUND, PG_SOUND, PG_SOUND, PG_SOUND, PG_SOUND,
@@ -250,6 +250,8 @@ static const Setting ALL[] = {
NodePrefs::LOCATOR_MODE_COUNT, optLocMode, rearmLocator), NodePrefs::LOCATOR_MODE_COUNT, optLocMode, rearmLocator),
SW("Proximity beeper", "Ticks faster when closer", SEC_LOCATOR, locator_beeper, nullptr), 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, MAP("Auto-advert", "Your advert, on a timer", SEC_ADVERT, advert_auto_interval_sec, AUTO_ADVERT,
optAutoAdvert, nullptr), optAutoAdvert, nullptr),
@@ -21,6 +21,7 @@
#include "PingEngine.h" #include "PingEngine.h"
#include "PathEditor.h" #include "PathEditor.h"
#include "CourseEngine.h" #include "CourseEngine.h"
#include "PacketFlow.h"
#include "LiveShareEngine.h" #include "LiveShareEngine.h"
#include "LocatorEngine.h" #include "LocatorEngine.h"
#include "TrailEngine.h" #include "TrailEngine.h"
@@ -40,6 +41,7 @@ public:
ping.begin(prefs); ping.begin(prefs);
route.begin(prefs, &ping); route.begin(prefs, &ping);
course.begin(sensors); course.begin(sensors);
flow.begin(prefs, &course, sensors);
live_share.begin(prefs, &course, &events); live_share.begin(prefs, &course, &events);
locator.begin(prefs, &course, &live_share, &events); locator.begin(prefs, &course, &live_share, &events);
trail.begin(prefs, &course); trail.begin(prefs, &course);
@@ -68,6 +70,7 @@ public:
PingEngine ping; // single in-flight ping / trace + last result PingEngine ping; // single in-flight ping / trace + last result
PathEditor route; // route to a node: edit, trace out and back, save PathEditor route; // route to a node: edit, trace out and back, save
CourseEngine course; // GPS position + course-over-ground ring 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 LiveShareEngine live_share; // [LOC] auto-share session + peers' shared positions
LocatorEngine locator; // active target, geofence crossings, proximity beeper LocatorEngine locator; // active target, geofence crossings, proximity beeper
TrailEngine trail; // GPS trail store, sampling, auto-pause, shutdown save 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 onACKRecv(uint32_t ack_crc) override { history.markDmDelivered(ack_crc); }
void onAdvertHeard(bool was_flood) override { events.push(UiEventType::AdvertHeard, nullptr, was_flood); } 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; } 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, void onMessageRecv(mesh::Packet *pkt, const ContactInfo &from, uint8_t txt_type, uint32_t sender_timestamp,
@@ -258,6 +258,141 @@ static bool prefetchOne(double left, double top, int w, int h, int z) {
return true; 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_D = 12; // marker dot diameter
static const int MARK_LABELS_MAX = 20; // names shown at once: the rest stay dots 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); lv_draw_line(layer, &ln);
} }
} }
drawFlows(layer, a);
lv_draw_rect_dsc_t pill; lv_draw_rect_dsc_t pill;
lv_draw_rect_dsc_init(&pill); lv_draw_rect_dsc_init(&pill);
pill.bg_color = lv_color_hex(theme::BG); 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_CLICKABLE);
lv_obj_remove_flag(mapview::s_mark_layer, LV_OBJ_FLAG_SCROLLABLE); 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); 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; mapview::s_sig_n = 0;
areaBuildLayer(body); // download frame / area outlines, over the tiles (MapAreas.h) 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. // One tile decode per call, nearest to the centre first.
void UITask::mapLoop() { void UITask::mapLoop() {
mapFlowTick();
if (_map_follow && (int32_t)(millis() - _next_follow_ms) >= 0) { // following you: every fix if (_map_follow && (int32_t)(millis() - _next_follow_ms) >= 0) { // following you: every fix
_next_follow_ms = millis() + 1000; _next_follow_ms = millis() + 1000;
int32_t lat, lon; 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) { void UITask::layoutMapMarks(double left, double top, int w, int h) {
using namespace mapview; using namespace mapview;
if (!s_mark_layer) return; 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 half = MARK_D / 2;
const int lh = lv_font_get_line_height(THEME_FONT_SMALL) + 2; const int lh = lv_font_get_line_height(THEME_FONT_SMALL) + 2;
struct Box { int16_t x1, y1, x2, y2; }; 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) ───────────────────────────────────────────── // ── Picking a route (Route > Map) ─────────────────────────────────────────────
static void onRouteUndo(lv_event_t* e) { (void)e; s_ui->routeMapUndo(); } static void onRouteUndo(lv_event_t* e) { (void)e; s_ui->routeMapUndo(); }
@@ -131,6 +131,7 @@ public:
void routeMapUndo(); void routeMapUndo();
void routeMapFrame(); void routeMapFrame();
void buildRouteBar(lv_obj_t* body); void buildRouteBar(lv_obj_t* body);
void mapFlowTick();
void mapDownloadPopup(); void mapDownloadPopup();
// Map areas (MapAreas.h) // Map areas (MapAreas.h)
void areaBuildLayer(lv_obj_t* body); 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 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 openRouteFor(const uint8_t* pub_key) { showRoute(pub_key); }
void openMessagesList() { showChats(); } // sim harness void openMessagesList() { showChats(); } // sim harness
bool openNodesMap() { openMap(false); return true; } // sim harness
private: 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 }; 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) lv_obj_t* _route_keep_btn = nullptr; // header: Keep (accent while kept)
uint32_t _route_shown = 0xFFFFFFFF; // what routeRebuild() drew (routeSig()) uint32_t _route_shown = 0xFFFFFFFF; // what routeRebuild() drew (routeSig())
uint32_t _next_route_ms = 0; 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 bool _route_pick = false; // the nodes map taps repeaters into the route
lv_obj_t* _route_bar = nullptr; lv_obj_t* _route_bar = nullptr;
lv_obj_t* _route_lbl = nullptr; lv_obj_t* _route_lbl = nullptr;
@@ -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. // Midpoint-circle outline.
static inline void drawCircle(DisplayDriver& d, int cx, int cy, int r) { static inline void drawCircle(DisplayDriver& d, int cx, int cy, int r) {
int x = r, y = 0, err = 1 - r; int x = r, y = 0, err = 1 - r;
+54 -2
View File
@@ -263,7 +263,7 @@ class NearbyScreen : public UIScreen, protected NearbyModel {
// ── action-menu actions (matched by id, not by row index) ──────────────────── // ── action-menu actions (matched by id, not by row index) ────────────────────
enum Action : uint8_t { ACT_NAV, ACT_PING, ACT_WAYPOINT, ACT_LOCATOR, 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_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): // Set by UITask::pickAdminTarget() (Tools > Admin, which is remote-only):
// while true, ENTER on an eligible row (a stored repeater/room contact) hands // 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 _detail;
bool _nav = false; // full-screen navigate-to-node view (over 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) 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 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 navview::EtaTracker _nav_eta; // closing-speed/ETA for the navigate view
unsigned long _detail_refresh_ms; 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 _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 _pin_label[24]; // "Pin to dial" / "Unpin (slot N)" -- _menu stores the pointer
char _telem_label[20]; // "Telem: ..." -- rewritten in place by L/R 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 (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_gps) add("Navigate", ACT_NAV);
if (has_key) add("Ping", ACT_PING); if (has_key) add("Ping", ACT_PING);
if (has_gps) add("Save waypoint", ACT_WAYPOINT); 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 // 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`. // 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) { void cycleMenuValue(int i, int dir = 1) {
if (i < 0 || i >= _menu_action_count) return; if (i < 0 || i >= _menu_action_count) return;
if (_menu_actions[i] == ACT_SORT) { if (_menu_actions[i] == ACT_SORT) {
@@ -653,6 +665,13 @@ class NearbyScreen : public UIScreen, protected NearbyModel {
refresh(); refresh();
} else if (_menu_actions[i] == ACT_FAV) { } else if (_menu_actions[i] == ACT_FAV) {
toggleFavSelected(); 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) { } else if (_menu_actions[i] == ACT_TELEM) {
const Entry* e = selected(); const Entry* e = selected();
ContactInfo* ci = e ? the_mesh.lookupContactByPubKey(e->pub_key, PUB_KEY_SIZE) : nullptr; ContactInfo* ci = e ? the_mesh.lookupContactByPubKey(e->pub_key, PUB_KEY_SIZE) : nullptr;
@@ -733,6 +752,7 @@ class NearbyScreen : public UIScreen, protected NearbyModel {
break; break;
} }
case ACT_SORT: break; // value rows -- see cycleMenuValue() case ACT_SORT: break; // value rows -- see cycleMenuValue()
case ACT_FLOW: break;
case ACT_SCAN: enterScan(); break; case ACT_SCAN: enterScan(); break;
case ACT_MAP: { case ACT_MAP: {
_map = true; _map_zoom = 0; _map = true; _map_zoom = 0;
@@ -916,6 +936,25 @@ class NearbyScreen : public UIScreen, protected NearbyModel {
if (!inside(x, y)) continue; if (!inside(x, y)) continue;
miniIconDrawCentered(d, x, y, _entries[i].is_live ? ICON_MAP_CONTACT : ICON_MAP_DOT); 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 (_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); } 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; const int r = 4 * s;
@@ -1093,7 +1132,9 @@ public:
_detail_refresh_ms = millis(); _detail_refresh_ms = millis();
} }
renderMap(display); 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 ──────────────────────────────────────────────────────────── // ── list view ────────────────────────────────────────────────────────────
@@ -1220,6 +1261,17 @@ public:
return (mq_delay > 0 && mq_delay < ret) ? mq_delay : ret; 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). // Path / trace for a contact, from outside the list (the sim harness).
bool openRoute(const uint8_t* pub_key) { bool openRoute(const uint8_t* pub_key) {
int i = findContact(pub_key); int i = findContact(pub_key);
@@ -256,6 +256,10 @@ void UITask::openAdminFor(const ContactInfo& ci, bool from_picker) {
setCurrScreen(admin_screen); // runs AdminScreen::onShow()'s reset first setCurrScreen(admin_screen); // runs AdminScreen::onShow()'s reset first
((AdminScreen*)admin_screen)->startFor(ci, from_picker); ((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) { void UITask::openRouteFor(const uint8_t* pub_key) {
setCurrScreen(nearby_screen); // its onShow() reset first setCurrScreen(nearby_screen); // its onShow() reset first
((NearbyScreen*)nearby_screen)->openRoute(pub_key); ((NearbyScreen*)nearby_screen)->openRoute(pub_key);
+1
View File
@@ -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 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 openRouteFor(const uint8_t* pub_key); // Nodes > a contact > Path / trace (sim harness)
void openMessagesList() { gotoMessagesScreen(); } // sim harness void openMessagesList() { gotoMessagesScreen(); } // sim harness
bool openNodesMap(); // sim harness: Nodes > Map
void gotoNearbyScreen(); void gotoNearbyScreen();
void gotoDashboardConfig(); void gotoDashboardConfig();
void gotoAutoAdvertScreen(); void gotoAutoAdvertScreen();