Files
MeshCore-Solo/examples/companion_radio/ui-lvgl/NodesView.h
T
JakubandClaude Opus 5.5 4e95647c0d refactor(ui-lvgl): widgets, the virtual list, chats, nodes, thread and settings out of UITask.cpp
A pure move: each section now sits in its own header, included where its
code was, so the order of definitions is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:26:59 +02:00

540 lines
23 KiB
C

#pragma once
// Included into UITask.cpp only, where it stands (it uses what is defined above it).
// Nodes: the nearby list and a node's detail card.
// ── Nearby ────────────────────────────────────────────────────────────────────
// Contacts / live shares / heard adverts from NearbyModel, filtered by type
// chips, sorted by distance or recency. Tap a row for detail. Scan (nodes that
// answer a discover request right now) is a popup over the list with its own
// model, since it is a different set: who is in range, not who is known.
static NearbyModel::Entry s_node; // the node open in SCR_NODE (a copy: the list re-sorts)
// Its info card's values (UITask::_node_info is the card).
static lv_obj_t *s_nd_type, *s_nd_status, *s_nd_dist, *s_nd_pos, *s_nd_heard, *s_nd_signal, *s_nd_id;
enum : uint8_t { NODE_MSG, NODE_PING, NODE_FAV, NODE_ADD, NODE_DELETE, NODE_NAV, NODE_ADMIN, NODE_WAYPOINT, NODE_PIN,
NODE_RESET_PATH, NODE_SHARE };
static void onNearbyChip(lv_event_t* e) { s_ui->setNearbyFilter((uint8_t)(uintptr_t)lv_event_get_user_data(e)); }
static void onNearbySort(lv_event_t* e) { (void)e; s_ui->toggleNearbySort(); }
static void onNearbyScan(lv_event_t* e) { (void)e; s_ui->startNearbyScan(); }
static void onNearbyRow(lv_event_t* e) { s_ui->openNode((int)vlist::arg(e)); }
static void onScanRow(lv_event_t* e) { s_ui->openScanNode((int)vlist::arg(e)); }
static void onScanClose(lv_event_t* e) { (void)e; s_ui->closeScanPopup(); }
static void onNodeAction(lv_event_t* e) { s_ui->nodeAction((uint8_t)(uintptr_t)lv_event_get_user_data(e)); }
static lv_obj_t* headerButton(lv_obj_t* hdr, const char* text, lv_event_cb_t cb, int right, lv_obj_t** label_out) {
lv_obj_t* b = lv_button_create(hdr);
lv_obj_set_size(b, LV_SIZE_CONTENT, 28);
lv_obj_set_style_pad_hor(b, 10, 0);
lv_obj_set_style_pad_ver(b, 0, 0);
lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE), 0);
lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE_2), LV_STATE_PRESSED);
lv_obj_set_style_shadow_width(b, 0, 0);
lv_obj_align(b, LV_ALIGN_RIGHT_MID, -right, 0);
lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, NULL);
lv_obj_t* l = label(b, text, THEME_FONT_SMALL, theme::TEXT);
lv_obj_center(l);
if (label_out) *label_out = l;
return b;
}
void UITask::showNearby() {
_screen = SCR_NEARBY;
_scanning = false;
buildNearby(); // filter / sort persist
}
void UITask::buildNearby() {
lv_obj_t* body = newScreen("Nodes", true);
lv_obj_set_style_pad_row(body, 4, 0);
if (_header) {
headerButton(_header, LV_SYMBOL_REFRESH, onNearbyScan, 4, NULL); // scan
lv_obj_set_width(headerButton(_header, "", onNearbySort, 46, &_nearby_sort_lbl), 62); // fits "Recent" / "Dist"
headerButton(_header, UI_SYMBOL_MAP, onOpenNodesMap, 114, NULL); // the Nodes map (the advert: the quick panel)
}
// Type filter chips
_nearby_chips = lv_obj_create(body);
styleSurface(_nearby_chips, theme::BG);
lv_obj_remove_flag(_nearby_chips, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_size(_nearby_chips, LV_PCT(100), 26);
lv_obj_set_flex_flow(_nearby_chips, LV_FLEX_FLOW_ROW);
lv_obj_set_style_pad_column(_nearby_chips, 4, 0);
for (uint8_t f = 0; f < NearbyModel::F_COUNT; f++) {
lv_obj_t* c = lv_button_create(_nearby_chips);
lv_obj_set_height(c, 26);
lv_obj_set_flex_grow(c, 1);
lv_obj_set_style_pad_all(c, 0, 0);
lv_obj_set_style_radius(c, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_shadow_width(c, 0, 0);
bool on = f == _nearby->filter();
lv_obj_add_event_cb(c, onNearbyChip, LV_EVENT_CLICKED, (void*)(uintptr_t)f);
lv_obj_center(label(c, NearbyModel::filterLabel(f), THEME_FONT_SMALL, theme::TEXT));
styleSelected(c, on); // (Theme.h)
}
_nearby_status = label(body, "", THEME_FONT_SMALL, theme::TEXT_MUTED);
_nearby_list = scrollList(body);
vlist::attach(_nearby_list);
_nearby_sig = 0;
refreshNearbyList();
}
void UITask::setNearbyFilter(uint8_t f) {
_nearby->setFilter(f);
buildNearby();
}
void UITask::toggleNearbySort() {
_nearby->setSortMode(_nearby->sortMode() == NearbyModel::SORT_DIST ? NearbyModel::SORT_TIME
: NearbyModel::SORT_DIST);
_nearby_sig = 0;
refreshNearbyList();
}
void UITask::startNearbyScan() {
_scanning = true;
_scan_until_ms = millis() + 8000;
_next_nearby_ms = millis() + 250;
the_mesh.sendNodeDiscoverReq();
if (!_scan_overlay) showScanPopup();
else { _scan_sig = 0; refreshScanPopup(); }
}
// Dimmed full-screen overlay (swallows taps) holding a panel with the results.
// A child of the current screen, so it goes away with it.
void UITask::showScanPopup() {
lv_obj_t* panel = popupOpen(screen(), POP_FULL, _scan_overlay);
lv_obj_t* hdr = lv_obj_create(panel);
styleSurface(hdr, theme::BG);
lv_obj_remove_flag(hdr, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_size(hdr, LV_PCT(100), 28);
lv_obj_align(label(hdr, "In range now", THEME_FONT_TITLE, theme::TEXT), LV_ALIGN_LEFT_MID, 0, 0);
headerButton(hdr, LV_SYMBOL_CLOSE, onScanClose, 0, NULL);
headerButton(hdr, LV_SYMBOL_REFRESH " Again", onNearbyScan, 44, NULL);
_scan_status = label(panel, "", THEME_FONT_SMALL, theme::TEXT_MUTED);
_scan_list = scrollList(panel);
vlist::attach(_scan_list);
_scan_sig = 0;
refreshScanPopup();
}
void UITask::closeScanPopup() {
if (_scan_overlay) lv_obj_delete_async(_scan_overlay); // may be closing from its own button
_scan_overlay = _scan_list = _scan_status = nullptr;
_scanning = false;
}
void UITask::refreshScanPopup() {
if (!_scan_list) return;
_scan->refreshScan();
int n = _scan->count();
if (_scanning) setTextFmt(_scan_status, LV_SYMBOL_REFRESH " Listening for replies... %d", n);
else setTextFmt(_scan_status, "%d node%s answered the discover request", n, n == 1 ? "" : "s");
uint32_t sig = (uint32_t)n + (_scanning ? 0x10000u : 0);
for (int i = 0; i < n; i++) {
const NearbyModel::Entry& e = _scan->at(i);
sig = sig * 31 + e.rssi * 7 + e.snr_x4 + e.remote_snr_x4 * 3 + e.type * 5 + e.is_known;
for (int k = 0; k < 4; k++) sig = sig * 31 + e.pub_key[k];
for (const char* p = e.name; *p; p++) sig = sig * 31 + (uint8_t)*p;
}
if (sig == _scan_sig || vlist::pressed(_scan_list)) return; // held: once it's let go
_scan_sig = sig;
static const vlist::Kind ROW = {
theme::ROW_H,
[](lv_obj_t* list) { return vRowValue(list, onScanRow); },
[](lv_obj_t* row, intptr_t i) {
const NearbyModel::Entry& e = s_ui->_scan->at((int)i);
char title[40], sub[48], right[12];
if (e.name[0]) snprintf(title, sizeof(title), "%s", e.name);
else snprintf(title, sizeof(title), "%s %02X%02X%02X%02X", NearbyModel::typeName(e.type),
e.pub_key[0], e.pub_key[1], e.pub_key[2], e.pub_key[3]);
snprintf(sub, sizeof(sub), "%s - SNR %.1f / %.1f%s", NearbyModel::typeName(e.type),
e.snr_x4 / 4.0f, e.remote_snr_x4 / 4.0f, e.is_known ? "" : " - new");
snprintf(right, sizeof(right), "%d dBm", e.rssi);
rowSet(row, title, sub);
rowValue(row, right, theme::TEXT_MUTED);
},
};
vlist::begin(_scan_list);
for (int i = 0; i < n; i++) vlist::add(_scan_list, &ROW, i);
if (n == 0) {
lv_obj_t* l = noteLabel(_scan_list, _scanning ? "Repeaters and rooms in range will answer."
: "Nobody answered. Try again later or move.",
THEME_FONT_BODY, theme::TEXT_MUTED);
lv_obj_set_style_pad_top(l, 8, 0);
vlist::own(_scan_list, l);
}
vlist::end(_scan_list);
}
void UITask::openScanNode(int row) {
if (row < 0 || row >= _scan->count()) return;
s_node = _scan->at(row);
_node_from_scan = true;
_node_from_map = false;
_scanning = false;
_pinging = false;
_screen = SCR_NODE;
buildNode();
}
// Changes whenever a rebuild would show something different.
uint32_t UITask::nearbySignature() const {
uint32_t sig = (uint32_t)_nearby->count() * 2654435761u + _nearby->sortMode() + (_scanning ? 7 : 0)
+ (_prefs && _prefs->units_imperial ? 13 : 0);
for (int i = 0; i < _nearby->count(); i++) {
const NearbyModel::Entry& e = _nearby->at(i);
sig = sig * 31 + e.contact_idx + (uint32_t)e.lastmod + (uint32_t)(e.dist_km * 100) + e.rssi + e.fav
+ e.type * 5 + (e.is_live ? 3 : 0);
for (const char* p = e.name; *p; p++) sig = sig * 31 + (uint8_t)*p;
}
// Ages are shown in minutes, so let the list re-render once a minute anyway.
return sig + rtc_clock.getCurrentTime() / 60;
}
// A Nearby row's texts: the name (a star on a favourite), what it is and how
// long ago it was heard, and on the right its distance (or the age).
struct NearbyText { char title[48], sub[48], right[16]; uint32_t right_col; };
static void nearbyText(const NearbyModel::Entry& e, uint32_t now, bool imperial, NearbyText& t) {
const char* name = e.name[0] ? e.name : "(unknown)";
snprintf(t.title, sizeof(t.title), "%s%s", e.fav ? UI_SYMBOL_STAR " " : "", name);
char age[8];
geo::fmtAgeShort(age, sizeof(age), now, e.lastmod);
t.right[0] = 0;
if (e.dist_km >= 0.0f) geo::fmtDist(t.right, sizeof(t.right), e.dist_km, imperial);
else if (age[0]) snprintf(t.right, sizeof(t.right), "%s", age);
const char* kind = e.contact_idx >= 0 ? NearbyModel::typeName(e.type) : "not a contact";
snprintf(t.sub, sizeof(t.sub), "%s%s%s%s", kind, e.is_live ? " - live" : "",
(e.dist_km >= 0.0f && age[0]) ? " - " : "", (e.dist_km >= 0.0f && age[0]) ? age : "");
t.right_col = e.is_live ? theme::OK : theme::TEXT_MUTED;
}
// A small arrow towards the node, drawn in the value label's left padding:
// its user data is the bearing + 1 (0: none).
static const int ROW_ARROW_W = 14;
static void onRowArrowDraw(lv_event_t* e) {
lv_obj_t* o = (lv_obj_t*)lv_event_get_target(e);
const int b = (int)(intptr_t)lv_obj_get_user_data(o) - 1;
if (b < 0) return;
lv_area_t a;
lv_obj_get_coords(o, &a);
const float cx = a.x1 + ROW_ARROW_W / 2 - 1, cy = (a.y1 + a.y2) / 2.0f;
const float ang = b * (float)M_PI / 180.0f, sx = sinf(ang), sy = -cosf(ang);
lv_draw_triangle_dsc_t tri;
lv_draw_triangle_dsc_init(&tri);
tri.color = lv_obj_get_style_text_color(o, LV_PART_MAIN);
tri.opa = LV_OPA_COVER;
tri.p[0].x = cx + sx * 6; tri.p[0].y = cy + sy * 6; // the tip
tri.p[1].x = cx - sx * 5 + sy * 4; tri.p[1].y = cy - sy * 5 - sx * 4;
tri.p[2].x = cx - sx * 5 - sy * 4; tri.p[2].y = cy - sy * 5 + sx * 4;
lv_draw_triangle(lv_event_get_layer(e), &tri);
}
void UITask::refreshNearbyList() {
if (!_nearby_list) return;
_nearby->refreshModel();
uint32_t sig = nearbySignature();
int n = _nearby->count();
if (_nearby_sort_lbl)
setText(_nearby_sort_lbl, _nearby->sortMode() == NearbyModel::SORT_TIME ? "Recent" : "Dist");
int32_t lat, lon;
bool gps = _nearby->ownPosition(lat, lon);
setTextFmt(_nearby_status, "%d node%s%s", n, n == 1 ? "" : "s",
gps ? "" : " - no GPS fix, distances unknown");
if (sig == _nearby_sig || vlist::pressed(_nearby_list)) return; // held: once it's let go
_nearby_sig = sig;
// The rows in view rewritten from the model (ages, distances, a new
// order); the list stays where it was scrolled.
static const vlist::Kind ROW = {
theme::ROW_H,
[](lv_obj_t* list) {
lv_obj_t* row = vRowValue(list, onNearbyRow);
lv_obj_add_event_cb(lv_obj_get_child(row, 2), onRowArrowDraw, LV_EVENT_DRAW_MAIN_END, NULL);
return row;
},
[](lv_obj_t* row, intptr_t i) {
const NearbyModel::Entry& e = s_ui->_nearby->at((int)i);
NearbyText t;
nearbyText(e, rtc_clock.getCurrentTime(), s_ui->_prefs && s_ui->_prefs->units_imperial, t);
rowSet(row, t.title, t.sub, e.fav ? theme::ACCENT : theme::TEXT);
rowValue(row, t.right, t.right_col);
// By the distance, an arrow towards it
int b = -1;
int32_t lat, lon;
if (e.dist_km >= 0.0f && s_ui->_nearby->ownPosition(lat, lon)) b = geo::bearingDeg(lat, lon, e.lat_e6, e.lon_e6);
lv_obj_t* v = lv_obj_get_child(row, 2);
if ((intptr_t)lv_obj_get_user_data(v) != b + 1) {
lv_obj_set_user_data(v, (void*)(intptr_t)(b + 1));
lv_obj_set_style_pad_left(v, b >= 0 ? ROW_ARROW_W : 0, 0);
lv_obj_invalidate(v);
}
},
};
vlist::begin(_nearby_list);
for (int i = 0; i < n; i++) vlist::add(_nearby_list, &ROW, i);
if (n == 0) {
lv_obj_t* l = label(_nearby_list, "Nobody here yet. Tap Scan to look around.",
THEME_FONT_BODY, theme::TEXT_MUTED);
lv_obj_set_style_pad_top(l, 12, 0);
vlist::own(_nearby_list, l);
}
vlist::end(_nearby_list);
}
// ── Node detail ───────────────────────────────────────────────────────────────
void UITask::openNode(int row) {
if (row < 0 || row >= _nearby->count()) return;
s_node = _nearby->at(row);
_node_from_scan = false;
_node_from_map = false;
_pinging = false;
_screen = SCR_NODE;
buildNode();
}
static lv_obj_t* s_nd_fav = nullptr;
static void nodeFavText() {
if (s_nd_fav) lv_label_set_text(s_nd_fav, s_node.fav ? UI_SYMBOL_STAR " Remove from favourites" : UI_SYMBOL_STAR " Add to favourites");
}
static lv_obj_t* switchRow(lv_obj_t* parent, const char* text, const char* sub, uint8_t* pref); // below
// One of the node's "Can ask for" switches; user data = its TELEM_PERM_* bit.
static void onNodeTelemSwitch(lv_event_t* e) {
ContactInfo* ci = the_mesh.lookupContactByPubKey(s_node.pub_key, PUB_KEY_SIZE);
if (!ci) return;
uint8_t bit = (uint8_t)(uintptr_t)lv_event_get_user_data(e);
uint8_t perms = contactctl::telemetry(*ci);
if (lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED)) perms |= bit; else perms &= ~bit;
contactctl::setTelemetry(s_node.pub_key, perms);
}
void UITask::buildNode() {
const NearbyModel::Entry& e = s_node;
lv_obj_t* body = newScreen(e.name[0] ? e.name : "(unknown)", true);
lv_obj_t* info = scrollList(body); // the info, scrolling above the actions at the bottom
s_nd_fav = nullptr;
_node_path_lbl = nullptr;
nodeVizBuild(info);
nodeviz::s_bearing = nodeviz::s_hops = -2; // nothing drawn yet: the first set fills both
_node_info = infoCard(info);
s_nd_type = infoRow(_node_info, "Type", "");
s_nd_status = infoRow(_node_info, "Status", "");
s_nd_dist = infoRow(_node_info, "Distance", "");
s_nd_pos = infoRow(_node_info, "Position", "");
s_nd_heard = infoRow(_node_info, "Heard", "");
s_nd_signal = infoRow(_node_info, "Signal", "");
s_nd_id = infoRow(_node_info, "ID", "");
_node_ping = label(info, "", THEME_FONT_BODY, theme::ACCENT);
// The bar: what the node is for -- a message, its admin, adding it -- a
// ping and the way there. The rest, less often, in a group under the info.
lv_obj_t* acts = buttonBar(body);
bool contact = e.contact_idx >= 0;
bool admin = contact && (e.type == ADV_TYPE_REPEATER || e.type == ADV_TYPE_ROOM);
bool pos = e.lat_e6 != 0 || e.lon_e6 != 0;
if (contact && e.type == ADV_TYPE_CHAT) barButton(acts, LV_SYMBOL_ENVELOPE " Message", onNodeAction, NODE_MSG, true);
if (admin) barButton(acts, LV_SYMBOL_SETTINGS " Admin", onNodeAction, NODE_ADMIN, true);
if (!contact && e.has_key && !e.is_known) barButton(acts, LV_SYMBOL_PLUS " Add", onNodeAction, NODE_ADD, true);
if (e.has_key) barButton(acts, LV_SYMBOL_LOOP " Ping", onNodeAction, NODE_PING);
if (pos) barButton(acts, UI_SYMBOL_COMPASS " Navigate", onNodeAction, NODE_NAV);
if (pos || contact) {
lv_obj_t* g = group(info, "More");
if (pos) actionRow(g, UI_SYMBOL_FLAG " Save as waypoint", onNodeAction, (void*)(uintptr_t)NODE_WAYPOINT);
if (contact) s_nd_fav = actionRow(g, "", onNodeAction, (void*)(uintptr_t)NODE_FAV);
nodeFavText();
if (contact && e.has_key) actionRow(g, UI_SYMBOL_TACK " Pin to Home", onNodeAction, (void*)(uintptr_t)NODE_PIN);
ContactInfo* ci = (contact && e.has_key) ? the_mesh.lookupContactByPubKey(e.pub_key, PUB_KEY_SIZE) : nullptr;
if (ci && contactctl::hasPath(*ci))
_node_path_lbl = actionRow(g, LV_SYMBOL_REFRESH " Reset path", onNodeAction, (void*)(uintptr_t)NODE_RESET_PATH);
if (ci) actionRow(g, LV_SYMBOL_UPLOAD " Share with nodes in range", onNodeAction, (void*)(uintptr_t)NODE_SHARE);
if (contact) _node_delete_lbl = actionRow(g, LV_SYMBOL_TRASH " Delete contact", onNodeAction,
(void*)(uintptr_t)NODE_DELETE, theme::FAIL);
}
// Where Settings > Privacy says "Allowed", what this contact may ask for.
ContactInfo* ci = (contact && e.has_key) ? the_mesh.lookupContactByPubKey(e.pub_key, PUB_KEY_SIZE) : nullptr;
if (ci && contactctl::telemetryPerContact(_prefs)) {
lv_obj_t* g = group(info, "Can ask for");
static const struct { const char* text; const char* sub; uint8_t bit; } T[] = {
{ "Status", "Battery; the two below need it", TELEM_PERM_BASE },
{ "Location", nullptr, TELEM_PERM_LOCATION },
{ "Sensors", nullptr, TELEM_PERM_ENVIRONMENT },
};
uint8_t perms = contactctl::telemetry(*ci);
for (const auto& t : T) {
lv_obj_t* sw = switchRow(g, t.text, t.sub, nullptr);
if (perms & t.bit) lv_obj_add_state(sw, LV_STATE_CHECKED);
lv_obj_add_event_cb(sw, onNodeTelemSwitch, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)t.bit);
}
}
refreshNode();
}
void UITask::refreshNode() {
if (!_node_info) return;
const NearbyModel::Entry& e = s_node;
// Refresh position / age / signal from its model while the node is still listed.
NearbyModel* model = _node_from_scan ? _scan : _nearby;
model->refreshModel();
for (int i = 0; i < model->count(); i++) {
const NearbyModel::Entry& m = model->at(i);
bool same = e.has_key ? (m.has_key && memcmp(m.pub_key, e.pub_key, PUB_KEY_SIZE) == 0)
: (e.contact_idx >= 0) ? m.contact_idx == e.contact_idx
: strncmp(m.name, e.name, sizeof(m.name)) == 0;
if (same) { s_node = m; break; }
}
// A scan row carries no contact index; is_known says whether it's in the contacts.
bool known = e.contact_idx >= 0 || (_node_from_scan && e.is_known);
char status[64] = "", dist[40] = "", pos[32] = "", heard[16] = "", sig[40] = "", id[12] = "";
int o = 0;
auto tag = [&](const char* t) { o += snprintf(status + o, sizeof(status) - o, "%s%s", o ? ", " : "", t); };
if (!known) tag("not a contact");
if (e.is_live) tag(e.live_verified ? "live position" : "live position (channel)");
if (e.fav) tag("favourite");
if (status[0] >= 'a' && status[0] <= 'z') status[0] -= 'a' - 'A';
int32_t lat, lon;
int bearing = -1;
if (e.lat_e6 != 0 || e.lon_e6 != 0) {
if (_nearby->ownPosition(lat, lon) && e.dist_km >= 0.0f) {
char d[16];
geo::fmtDist(d, sizeof(d), e.dist_km, _prefs && _prefs->units_imperial);
int az = geo::bearingDeg(lat, lon, e.lat_e6, e.lon_e6);
bearing = az;
snprintf(dist, sizeof(dist), "%s %d\xC2\xB0 %s", d, az, geo::bearingCardinal(az));
}
snprintf(pos, sizeof(pos), "%.5f, %.5f", e.lat_e6 / 1e6, e.lon_e6 / 1e6);
} else if (!_node_from_scan) {
snprintf(pos, sizeof(pos), "Not shared");
}
char age[8];
geo::fmtAgeShort(age, sizeof(age), rtc_clock.getCurrentTime(), e.lastmod);
if (age[0]) snprintf(heard, sizeof(heard), "%s ago", age);
if (_node_from_scan)
snprintf(sig, sizeof(sig), "%d dBm, SNR %.1f / %.1f", e.rssi, e.snr_x4 / 4.0f, e.remote_snr_x4 / 4.0f);
if (e.has_prefix) snprintf(id, sizeof(id), "%02X%02X%02X%02X", e.pub_key[0], e.pub_key[1], e.pub_key[2], e.pub_key[3]);
infoSet(s_nd_type, NearbyModel::typeName(e.type));
infoSet(s_nd_status, status);
infoSet(s_nd_dist, dist);
infoSet(s_nd_pos, pos);
infoSet(s_nd_heard, heard);
infoSet(s_nd_signal, sig);
infoSet(s_nd_id, id);
ContactInfo ci;
int hops = -1;
if (!_node_from_scan && e.contact_idx >= 0 && the_mesh.getContactByIdx(e.contact_idx, ci))
hops = ci.out_path_len == 0xFF ? 0xFF : (ci.out_path_len & 63);
nodeVizSet(bearing, hops);
// Ping result (PingEngine releases its slot on reply; the view owns the timeout)
if (_pinging) {
int16_t out = 0, back = 0; uint32_t rtt = 0;
_core->ping.getResult(out, back, rtt);
if (!_core->ping.isActive() && (out || back || rtt)) {
setTextFmt(_node_ping, "Ping %lu ms - SNR out %.1f back %.1f", (unsigned long)rtt,
out / 4.0f, back / 4.0f);
_pinging = false;
} else if (millis() - _ping_started_ms > 3000) {
_core->ping.clear();
setText(_node_ping, "Ping: no reply");
_pinging = false;
}
}
}
void UITask::nodeAction(uint8_t action) {
NearbyModel::Entry& e = s_node;
switch (action) {
case NODE_MSG:
openDM(e.pub_key);
break;
case NODE_PING: {
if (_pinging) break;
PingEngine::StartResult r = _core->ping.start(e.pub_key);
if (r == PingEngine::STARTED) {
_pinging = true;
_ping_started_ms = millis();
_next_nearby_ms = millis() + 250;
lv_label_set_text(_node_ping, "Pinging...");
} else {
lv_label_set_text(_node_ping, r == PingEngine::UNSUPPORTED ? "Ping needs 1-2 byte path hashes"
: "Ping failed");
}
break;
}
case NODE_FAV:
if (contactctl::setFavourite(e.pub_key, !e.fav)) { // in place: the list stays where it was scrolled
e.fav = !e.fav;
nodeFavText();
refreshNode();
}
break;
case NODE_ADD:
if (the_mesh.addDiscoveredContact(e.pub_key, e.name, e.type)) {
_screen = SCR_NEARBY;
buildNearby();
} else {
showToast("Contacts full");
}
break;
case NODE_NAV:
// Followed by key when there is one (the Locator resolves live share,
// then advert position); otherwise a fixed point where it was seen.
if (e.has_prefix) navToNode(e.pub_key, e.lat_e6, e.lon_e6, e.name[0] ? e.name : "Node");
else {
_core->locator.setTarget(0, nullptr, e.lat_e6, e.lon_e6, e.name[0] ? e.name : "Node");
prefsSave();
openMap(true);
navFrameTarget();
}
break;
case NODE_ADMIN:
openAdmin(e.pub_key);
break;
case NODE_WAYPOINT: {
if (_core->waypoints.full()) { showToast(waypointsFull()); break; }
char t[48];
if (_core->waypoints.add(e.lat_e6, e.lon_e6, rtc_clock.getCurrentTime(), e.name[0] ? e.name : "Node")) {
snprintf(t, sizeof(t), "Saved %s", _core->waypoints.at(_core->waypoints.count() - 1).label);
showToast(t);
}
break;
}
case NODE_PIN: // to the favourites dial (Home), as from a chat's options
pinPopup(false, 0, e.pub_key);
break;
case NODE_RESET_PATH: // the row goes, the chain above turns to flood
if (contactctl::resetPath(e.pub_key) && _node_path_lbl) {
lv_obj_add_flag(lv_obj_get_parent(lv_obj_get_parent(_node_path_lbl)), LV_OBJ_FLAG_HIDDEN); // label < text box < row
_node_path_lbl = nullptr;
refreshNode();
}
break;
case NODE_SHARE:
showToast(contactctl::share(e.pub_key) ? "Sent to nodes in range" : "No advert saved to send");
break;
case NODE_DELETE:
if (!tapConfirmed(_node_delete_lbl, LV_SYMBOL_TRASH " Tap again to delete")) break;
if (the_mesh.deleteContactByKey(e.pub_key)) {
_screen = SCR_NEARBY;
buildNearby();
}
break;
}
}