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
@@ -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,