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
+6
View File
@@ -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;
+3
View File
@@ -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).
+22
View File
@@ -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;
+54 -2
View File
@@ -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);
+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 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();