diff --git a/docs/solo/contacts.md b/docs/solo/contacts.md index f0e107c4..136480d4 100644 --- a/docs/solo/contacts.md +++ b/docs/solo/contacts.md @@ -57,7 +57,11 @@ dash and dot. > is a ring around you. Colours by kind: adverts blue, direct messages > green, channels amber, requests violet, acks and paths teal, the rest > pink, your own white. The pill at the top counts each kind over the last -> minute. +> minute. A tap on it opens **Recent packets**: the last 10 that made a line, +> with what they were, who sent them, when, and how many hops they took. A tap +> on one plays it again on the map, every way it came, and moves the map to +> show it. **Play all** plays them one after another. The list is kept until a +> restart. > [!NOTE] > **Wio Tracker L2:** the **Nodes** app. Filter chips at the top, the sort in diff --git a/examples/companion_radio/ui-core/PacketFlow.h b/examples/companion_radio/ui-core/PacketFlow.h index c7a59c4c..8b637f8d 100644 --- a/examples/companion_radio/ui-core/PacketFlow.h +++ b/examples/companion_radio/ui-core/PacketFlow.h @@ -159,6 +159,7 @@ public: add(f, ola, olo); f.here = true; } + if (!f.here) remember(f, hc); push(f); } @@ -178,9 +179,107 @@ public: push(f); } - void clear() { _n = 0; _head = 0; _seq++; memset(_cnt, 0, sizeof(_cnt)); } + void clear() { _n = 0; _head = 0; _seq++; memset(_cnt, 0, sizeof(_cnt)); _hist_n = 0; _hist_head = 0; _rp_next = -1; } + + // ── Recent packets ── + // The last HIST packets heard that made a line, each with the ways it came + // (a copy per repeater that relayed it), for a list to play again. Kept + // only once a frontend asks (keepHistory(): the L2's map), so the others + // spend no memory on it. + static const int HIST = 10; + static const int HIST_WAYS = 3; + static const uint32_t REPLAY_GAP_MS = 1200; // replayAll(): one packet after another + struct Heard { + uint32_t id; // the packet (Flow::id) + uint32_t ts; // RTC time it was first heard + uint8_t kind; + uint8_t hops; // repeaters on its first copy + uint8_t heard; // copies heard + uint8_t ways; // distinct lines kept in `way` + Flow way[HIST_WAYS]; + uint8_t key[HIST_WAYS][MAX_PTS][4]; // each point's contact (key prefix): its index may change meanwhile + }; + + void keepHistory() { + if (_hist) return; +#if defined(ESP32) + _hist = (Heard*)heap_caps_calloc(HIST, sizeof(Heard), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); +#endif + if (!_hist) _hist = (Heard*)calloc(HIST, sizeof(Heard)); + } + int historyCount() const { return _hist ? _hist_n : 0; } + // i = 0 is the newest. + const Heard& history(int i) const { return _hist[(_hist_head + HIST - 1 - i) % HIST]; } + // Way w of a kept packet with its contacts' indices as they are now (a + // contact deleted since: -1, the point stays where it was). + Flow wayNow(const Heard& h, int w) const { + Flow f = h.way[w]; + for (int k = 0; k < f.n; k++) + if (f.node[k] >= 0) f.node[k] = indexOfPrefix(h.key[w][k]); + return f; + } + + // Packet i of the history on the map again, every way it came, as if heard now. + void replay(int i) { + if (i < 0 || i >= historyCount()) return; + const Heard& h = history(i); + for (int w = 0; w < h.ways; w++) { + Flow f = wayNow(h, w); + f.t_ms = millis(); + push(f); + } + } + // All of them, oldest first, one every REPLAY_GAP_MS (tick() plays them). + void replayAll() { _rp_next = historyCount() - 1; _rp_at = millis(); } + void replayStop() { _rp_next = -1; } + void tick() { + if (_rp_next < 0 || (int32_t)(millis() - _rp_at) < 0) return; + replay(_rp_next--); + _rp_at = millis() + REPLAY_GAP_MS; + } private: + void remember(const Flow& f, uint8_t hops) { + if (!_hist) return; + for (int i = 0; i < _hist_n; i++) { // another copy of one kept: its way, if new + Heard& h = _hist[(_hist_head + HIST - 1 - i) % HIST]; + if (h.id != f.id) continue; + if (h.heard < 255) h.heard++; + bool known = false; + for (int w = 0; w < h.ways && !known; w++) known = sameLine(h.way[w], f); + if (!known && h.ways < HIST_WAYS) keepWay(h, h.ways++, f); + return; + } + Heard& h = _hist[_hist_head]; + h.id = f.id; + h.ts = the_mesh.getRTCClock()->getCurrentTime(); + h.kind = f.kind; + h.hops = hops; + h.heard = 1; + h.ways = 1; + keepWay(h, 0, f); + _hist_head = (_hist_head + 1) % HIST; + if (_hist_n < HIST) _hist_n++; + // A replayAll() running: the one it plays next is a place older now (gone, + // when it was the oldest: then the one after it). + if (_rp_next >= 0 && ++_rp_next >= _hist_n) _rp_next = _hist_n - 1; + } + + static void keepWay(Heard& h, int w, const Flow& f) { + h.way[w] = f; + for (int k = 0; k < f.n; k++) { + ContactInfo c; + if (f.node[k] >= 0 && the_mesh.getContactByIdx(f.node[k], c)) memcpy(h.key[w][k], c.id.pub_key, 4); + else h.way[w].node[k] = -1; + } + } + + static bool sameLine(const Flow& a, const Flow& b) { + if (a.n != b.n) return false; + for (int k = 0; k < a.n; k++) + if (a.node[k] != b.node[k] || a.lat[k] != b.lat[k] || a.lon[k] != b.lon[k]) return false; + return true; + } static const int BUCKETS = 6; // the minute, in 10 s steps static const uint32_t BUCKET_MS = 10000; @@ -278,6 +377,13 @@ private: if (memcmp(c.id.pub_key, key, PUB_KEY_SIZE) == 0) return (int16_t)idx; } } + static int16_t indexOfPrefix(const uint8_t* prefix) { // by its first 4 bytes + for (int idx = MAX_ANON_CONTACTS; ; idx++) { + ContactInfo c; + if (!the_mesh.getContactByIdx(idx, c)) return -1; + if (memcmp(c.id.pub_key, prefix, 4) == 0) return (int16_t)idx; + } + } NodePrefs* _prefs = nullptr; CourseEngine* _course = nullptr; @@ -287,4 +393,8 @@ private: uint32_t _seq = 0; uint16_t _cnt[BUCKETS][K_COUNT] = {}; uint32_t _bucket_at[BUCKETS] = {}; + Heard* _hist = nullptr; + int _hist_head = 0, _hist_n = 0; + int _rp_next = -1; // replayAll(): the history index played next, -1 none + uint32_t _rp_at = 0; }; diff --git a/examples/companion_radio/ui-lvgl/MapScreen.h b/examples/companion_radio/ui-lvgl/MapScreen.h index a881e8de..5af85520 100644 --- a/examples/companion_radio/ui-lvgl/MapScreen.h +++ b/examples/companion_radio/ui-lvgl/MapScreen.h @@ -304,9 +304,11 @@ static uint32_t flowColour(uint8_t kind) { } } static const char* const FLOW_NAMES[PacketFlow::K_COUNT] = { "Adv", "DM", "Ch", "Req", "Ack", "Other", "Sent" }; -static lv_obj_t* s_flow_legend = nullptr; // top centre: each kind's count over the last minute +static const char* const FLOW_KINDS[PacketFlow::K_COUNT] = { "Advert", "Direct message", "Channel", "Request", "Ack", "Other", "Sent" }; +static lv_obj_t* s_flow_legend = nullptr; // top centre: each kind's count over the last minute; a tap: the recent packets -// "Adv 3 Ch 5 ...", each in its colour; hidden with nothing to count. +// "Adv 3 Ch 5 ...", each in its colour; with nothing to count, the way to +// the recent packets when there are some, else hidden. static void flowLegendRefresh() { if (!s_flow_legend) return; char t[160]; @@ -317,6 +319,7 @@ static void flowLegendRefresh() { o += snprintf(t + o, sizeof(t) - o, "%s#%06lX %s %d#", o ? " " : "", (unsigned long)flowColour(k), FLOW_NAMES[k], n); } t[o] = '\0'; + if (!o && s_flow && s_flow->enabled() && s_flow->historyCount() > 0) o = snprintf(t, sizeof(t), LV_SYMBOL_LIST " Recent packets"); if (!o) { lv_obj_add_flag(s_flow_legend, LV_OBJ_FLAG_HIDDEN); return; } setText(s_flow_legend, t); lv_obj_remove_flag(s_flow_legend, LV_OBJ_FLAG_HIDDEN); @@ -740,6 +743,7 @@ void UITask::openMap(bool nav) { _map_nodes = !nav; _route_show = false; // a message's route is shown by its own way in (messageAction) _core->hop_route.close(); + _core->flow.replayStop(); // a Play all left running last time showMap(); } @@ -867,6 +871,9 @@ void UITask::buildMap() { lv_obj_set_style_text_align(mapview::s_flow_legend, LV_TEXT_ALIGN_CENTER, 0); lv_obj_set_style_radius(mapview::s_flow_legend, 8, 0); lv_obj_add_flag(mapview::s_flow_legend, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(mapview::s_flow_legend, LV_OBJ_FLAG_CLICKABLE); // tap: the recent packets + lv_obj_set_ext_click_area(mapview::s_flow_legend, 6); + lv_obj_add_event_cb(mapview::s_flow_legend, [](lv_event_t*) { s_ui->flowHistoryPopup(); }, LV_EVENT_CLICKED, NULL); lv_obj_add_event_cb(mapview::s_flow_legend, [](lv_event_t*) { mapview::s_flow_legend = nullptr; }, LV_EVENT_DELETE, NULL); mapview::flowLegendRefresh(); // Bottom left, one pill about the map itself: the map data credit (the @@ -1329,6 +1336,7 @@ void UITask::mapFlowTick() { using namespace mapview; if (!s_mark_layer || (int32_t)(millis() - _next_flow_ms) < 0) return; _next_flow_ms = millis() + 50; + _core->flow.tick(); // Play all: the next one when it's due if (++_flow_legend_tick >= 10) { _flow_legend_tick = 0; flowLegendRefresh(); } // twice a second lv_area_t r; bool now_on = flowArea(r); @@ -1345,6 +1353,82 @@ void UITask::mapFlowTick() { s_flow_area_on = now_on; } +// ── Recent packets (PacketFlow history) ────────────────────────────────────── + +static void onFlowReplay(lv_event_t* e) { s_ui->flowReplay((int)(intptr_t)lv_event_get_user_data(e)); } +static void onFlowReplayAll(lv_event_t* e) { (void)e; s_ui->flowReplay(-1); } + +// The flow legend's tap: the last packets heard with a line, newest first -- +// what, from whom, when, how far it came; a tap plays one again on the map. +void UITask::flowHistoryPopup() { + using namespace mapview; + const PacketFlow& F = _core->flow; + lv_obj_t* panel = navPopupPanel("Recent packets", true); + lv_obj_t* list = scrollList(panel); + if (F.historyCount() == 0) { + noteLabel(list, "None yet: a packet is kept when its sender or a repeater on its way has a position."); + return; + } + lv_obj_t* g = group(list, nullptr); + for (int i = 0; i < F.historyCount(); i++) { + const PacketFlow::Heard& h = F.history(i); + const PacketFlow::Flow f = F.wayNow(h, 0); + char title[64], sub[64]; + ContactInfo c; + const bool named = f.node[0] >= 0 && the_mesh.getContactByIdx(f.node[0], c) && c.name[0]; + snprintf(title, sizeof(title), "%s%s%s%.24s", FLOW_KINDS[h.kind < PacketFlow::K_COUNT ? h.kind : PacketFlow::K_OTHER], + named ? " " : "", named && !f.origin ? "via " : "", named ? c.name : ""); + const uint32_t t = h.ts + (int32_t)_prefs->tz_offset_hours * 3600; + int o = snprintf(sub, sizeof(sub), "%02u:%02u:%02u ", (unsigned)(t / 3600 % 24), (unsigned)(t / 60 % 60), (unsigned)(t % 60)); + if (h.hops == 0) o += snprintf(sub + o, sizeof(sub) - o, "direct"); + else o += snprintf(sub + o, sizeof(sub) - o, "%u hop%s", (unsigned)h.hops, h.hops == 1 ? "" : "s"); + if (h.heard > 1) snprintf(sub + o, sizeof(sub) - o, ", heard %u times", (unsigned)h.heard); + lv_obj_t* row = groupLine(g, true); + lv_obj_t* dot = lv_obj_create(row); // its line's colour on the map + lv_obj_remove_style_all(dot); + lv_obj_set_size(dot, 11, 11); + lv_obj_set_style_radius(dot, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(dot, lv_color_hex(flowColour(h.kind)), 0); + lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, 0); + lv_obj_remove_flag(dot, LV_OBJ_FLAG_CLICKABLE); + groupText(row, title, sub); + label(row, LV_SYMBOL_PLAY, THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_obj_add_event_cb(row, onFlowReplay, LV_EVENT_CLICKED, (void*)(intptr_t)i); + } + lv_obj_t* acts = buttonBar(list); + barButton(acts, LV_SYMBOL_PLAY " Play all", onFlowReplayAll, 0, true); +} + +// Packet i of the history (-1: all, one after another) on the map again; the +// map moves to take it in when part of it is off screen. +void UITask::flowReplay(int i) { + using namespace mapview; + PacketFlow& F = _core->flow; + navClosePopup(); + if (i >= F.historyCount()) return; + bool any = false, off = false; + int32_t la0 = 0, la1 = 0, lo0 = 0, lo1 = 0; + const int w = _map_area ? lv_obj_get_width(_map_area) : 0, h = _map_area ? lv_obj_get_height(_map_area) - mapBottomH() : 0; + for (int j = i < 0 ? 0 : i; j < (i < 0 ? F.historyCount() : i + 1); j++) { + const PacketFlow::Heard& p = F.history(j); + for (int wy = 0; wy < p.ways; wy++) { + const PacketFlow::Flow f = F.wayNow(p, wy); + for (int k = 0; k < f.n; k++) { + int32_t lat, lon; + F.pointNow(f, k, lat, lon); + if (!any) { la0 = la1 = lat; lo0 = lo1 = lon; any = true; } + la0 = min(la0, lat); la1 = max(la1, lat); lo0 = min(lo0, lon); lo1 = max(lo1, lon); + int32_t x, y; + flowPx(lat, lon, x, y); + if (!s_view_ok || x < 12 || x > w - 12 || y < 12 || y > h - 12) off = true; + } + } + } + if (any && off) mapFit(la0, la1, lo0, lo1); + if (i < 0) F.replayAll(); + else F.replay(i); +} + // ── Picking a route (Route > Map) ───────────────────────────────────────────── static void onRouteUndo(lv_event_t* e) { (void)e; s_ui->routeMapUndo(); } @@ -1470,7 +1554,15 @@ void UITask::routeMapFrame() { la0 = min(la0, s_route_lat[i]); la1 = max(la1, s_route_lat[i]); lo0 = min(lo0, s_route_lon[i]); lo1 = max(lo1, s_route_lon[i]); } - int w = lv_obj_get_width(_map_area), h = lv_obj_get_height(_map_area) - navmap::BAR_H; + mapFit(la0, la1, lo0, lo1); +} + +// The map on a box (lat la0..la1, lon lo0..lo1): the closest zoom it fits in +// with a margin, above the bottom bar. +void UITask::mapFit(int32_t la0, int32_t la1, int32_t lo0, int32_t lo1) { + using namespace mapview; + if (!_map_area) return; + int w = lv_obj_get_width(_map_area), h = lv_obj_get_height(_map_area) - mapBottomH(); int z = MAX_Z; for (; z > MIN_Z; z--) { double dx = fabs(lonToTileX(lo1 / 1e6, z) - lonToTileX(lo0 / 1e6, z)) * TILE_PX; diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index 8d28a71f..530de000 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -145,6 +145,7 @@ void UITask::begin(DisplayDriver* display_drv, SensorManager* sensors, NodePrefs _core = new UiCore(); _core->begin(node_prefs, sensors, this); + _core->flow.keepHistory(); // the map's recent packets, to play again { // history kept on the SD card: back into the ring, then every new entry to it int hk = lvport::loadHistKeep(); s_archive.setKeep(histstore::KEEP[hk >= 0 && hk < histstore::KEEP_COUNT ? hk : histstore::KEEP_DEFAULT]); @@ -1312,10 +1313,10 @@ void UITask::back() { if (_node_from_scan) showScanPopup(); break; case SCR_MAP: + if (_nav_overlay) { navClosePopup(); break; } // a popup over the map first if (_route_pick) { routeMapDone(); break; } if (_route_show) { routeShowDone(); break; } - if (_nav_overlay) navClosePopup(); - else if (areaSelecting()) areaSelectEnd(); + if (areaSelecting()) areaSelectEnd(); else if (_dl_overlay) mapDownloadClose(); else if (_map_nodes || !_map_nav) showNearby(); // opened from Nodes else showHome(); diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index 45eed060..a1dd1805 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -142,6 +142,9 @@ public: void routeMapFrame(); void buildRouteBar(lv_obj_t* body); void mapFlowTick(); + void flowHistoryPopup(); + void flowReplay(int i); // a history index, -1 all + void mapFit(int32_t la0, int32_t la1, int32_t lo0, int32_t lo1); void mapDownloadPopup(); // Map areas (MapAreas.h) void areaBuildLayer(lv_obj_t* body);