mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-11-01 10:21:59 +00:00
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:
+10
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(); }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user