Files
MeshCore-Solo/examples/companion_radio/ui-new/NearbyScreen.h
T
JakubandClaude Sonnet 4.6 744f263932 refactor: replace all hardcoded pixel positions with layout helpers
Add lineStep(), headerH(), listStart(), listVisible(), valCol() to
DisplayDriver so layout derives from getLineHeight() instead of fixed
OLED constants. Refactor all screens (SettingsScreen, NearbyScreen,
FullscreenMsgView, QuickMsgScreen, BotScreen, DashboardConfigScreen,
AutoAdvertScreen, RingtoneEditorScreen) and all HomeScreen pages plus
the lock screen and splash screen in UITask.cpp.

On OLED (lh=8) positions are unchanged; on landscape e-ink (lh=16) all
text and widgets now scale correctly to the 250×122 display.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 18:05:09 +02:00

560 lines
21 KiB
C++

#pragma once
#include <math.h>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
class NearbyScreen : public UIScreen {
UITask* _task;
int _visible = 4; // updated each render; used by handleInput for scroll clamping
static const int FILTER_COUNT = 6;
static const char* FILTER_LABELS[FILTER_COUNT];
static const uint8_t FILTER_TYPES[FILTER_COUNT];
// ── nearby list state ────────────────────────────────────────────────────────
struct Entry {
char name[32];
int32_t lat_e6, lon_e6;
float dist_km;
uint8_t type;
int contact_idx;
uint32_t lastmod;
};
static const int MAX_NEARBY = 32;
Entry _entries[MAX_NEARBY];
int _count;
int _sel;
int _scroll;
bool _detail;
int32_t _own_lat, _own_lon;
bool _own_gps;
uint8_t _filter;
unsigned long _detail_refresh_ms;
static const unsigned long DETAIL_REFRESH_MS = 10000UL;
PopupMenu _ctx_menu;
// ── discover sub-screen state ────────────────────────────────────────────────
bool _discover_mode;
bool _discovering;
unsigned long _discover_started_ms;
static const unsigned long DISCOVER_DURATION_MS = 8000UL;
DiscoverResult _dresults[DISCOVER_RESULTS_MAX];
int _dresult_count;
int _dscroll;
int _dsel;
bool _ddetail;
int _d_visible = 2; // updated each render; used by handleInputDiscover
// ── helpers ──────────────────────────────────────────────────────────────────
static void pubKeyToBase64(const uint8_t* key, char* out, int out_len) {
static const char T[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
int j = 0;
for (int i = 0; i < PUB_KEY_SIZE && j + 5 < out_len; i += 3) {
uint32_t b = ((uint32_t)key[i] << 16)
| (i+1 < PUB_KEY_SIZE ? (uint32_t)key[i+1] << 8 : 0)
| (i+2 < PUB_KEY_SIZE ? (uint32_t)key[i+2] : 0);
out[j++] = T[(b >> 18) & 63];
out[j++] = T[(b >> 12) & 63];
if (i+1 < PUB_KEY_SIZE) out[j++] = T[(b >> 6) & 63];
if (i+2 < PUB_KEY_SIZE) out[j++] = T[b & 63];
}
out[j] = '\0';
}
static float haversineKm(int32_t lat1, int32_t lon1, int32_t lat2, int32_t lon2) {
static const float DEG2RAD = (float)M_PI / 180.0f;
float la1 = lat1 * (1e-6f * DEG2RAD);
float la2 = lat2 * (1e-6f * DEG2RAD);
float dla = (lat2 - lat1) * (1e-6f * DEG2RAD);
float dlo = (lon2 - lon1) * (1e-6f * DEG2RAD);
float a = sinf(dla/2)*sinf(dla/2) + cosf(la1)*cosf(la2)*sinf(dlo/2)*sinf(dlo/2);
return 6371.0f * 2.0f * asinf(sqrtf(a));
}
static int bearingDeg(int32_t lat1, int32_t lon1, int32_t lat2, int32_t lon2) {
static const float DEG2RAD = (float)M_PI / 180.0f;
float la1 = lat1 * (1e-6f * DEG2RAD);
float la2 = lat2 * (1e-6f * DEG2RAD);
float dlo = (lon2 - lon1) * (1e-6f * DEG2RAD);
float b = atan2f(sinf(dlo)*cosf(la2),
cosf(la1)*sinf(la2) - sinf(la1)*cosf(la2)*cosf(dlo)) * (180.0f / (float)M_PI);
if (b < 0.0f) b += 360.0f;
return (int)(b + 0.5f) % 360;
}
static const char* bearingCardinal(int deg) {
static const char* dirs[] = { "N","NE","E","SE","S","SW","W","NW" };
return dirs[((deg + 22) % 360) / 45];
}
static void fmtAge(char* buf, int n, uint32_t lastmod) {
uint32_t now = rtc_clock.getCurrentTime();
if (now < lastmod || lastmod == 0) { snprintf(buf, n, "unknown"); return; }
uint32_t age = now - lastmod;
if (age < 60) snprintf(buf, n, "%us ago", age);
else if (age < 3600) snprintf(buf, n, "%um ago", age / 60);
else if (age < 86400) snprintf(buf, n, "%uh ago", age / 3600);
else snprintf(buf, n, ">1d ago");
}
static void fmtDist(char* buf, int n, float km) {
if (km < 1.0f) snprintf(buf, n, "%dm", (int)(km * 1000 + 0.5f));
else if (km < 100.0f) snprintf(buf, n, "%.1fkm", km);
else snprintf(buf, n, "%dkm", (int)(km + 0.5f));
}
static const char* typeName(uint8_t t) {
switch (t) {
case ADV_TYPE_CHAT: return "Companion";
case ADV_TYPE_REPEATER: return "Repeater";
case ADV_TYPE_ROOM: return "Room";
case ADV_TYPE_SENSOR: return "Sensor";
default: return "Unknown";
}
}
void refresh() {
_count = 0;
_own_gps = false;
_own_lat = _own_lon = 0;
#if ENV_INCLUDE_GPS == 1
LocationProvider* loc = sensors.getLocationProvider();
if (loc && loc->isValid()) {
_own_lat = loc->getLatitude();
_own_lon = loc->getLongitude();
_own_gps = true;
}
#endif
int nc = the_mesh.getNumContacts();
for (int i = 0; i < nc && _count < MAX_NEARBY; i++) {
ContactInfo ci;
if (!the_mesh.getContactByIdx(i, ci)) continue;
if (_filter == 0 && !(ci.flags & 1)) continue;
if (_filter >= 2 && ci.type != FILTER_TYPES[_filter]) continue;
Entry& e = _entries[_count++];
strncpy(e.name, ci.name, sizeof(e.name) - 1);
e.name[sizeof(e.name) - 1] = '\0';
e.lat_e6 = ci.gps_lat;
e.lon_e6 = ci.gps_lon;
bool remote_gps = (ci.gps_lat != 0 || ci.gps_lon != 0);
e.dist_km = (_own_gps && remote_gps)
? haversineKm(_own_lat, _own_lon, ci.gps_lat, ci.gps_lon)
: -1.0f;
e.type = ci.type;
e.contact_idx = i;
e.lastmod = ci.lastmod;
}
// sort by distance ascending; nodes without GPS go to the end
for (int i = 0; i < _count - 1; i++) {
int best = i;
for (int j = i + 1; j < _count; j++) {
float dj = _entries[j].dist_km, db = _entries[best].dist_km;
if (dj >= 0.0f && (db < 0.0f || dj < db)) best = j;
}
if (best != i) { Entry tmp = _entries[i]; _entries[i] = _entries[best]; _entries[best] = tmp; }
}
if (_count == 0) {
_sel = _scroll = 0;
} else if (_sel >= _count) {
_sel = _count - 1;
if (_scroll > _sel) _scroll = _sel;
}
}
// ── discover sub-screen ──────────────────────────────────────────────────────
void enterDiscoverMode() {
_discover_mode = true;
_discovering = true;
_ddetail = false;
_discover_started_ms = millis();
_dresult_count = 0;
_dscroll = 0;
_dsel = 0;
the_mesh.sendNodeDiscoverReq();
}
int renderDiscover(DisplayDriver& display) {
int lh = display.getLineHeight();
int hdr = display.headerH();
int d_box_h = 2 * lh + 3; // two text rows + padding
int d_item_h = d_box_h + 2;
int d_start_y = hdr;
_d_visible = display.listVisible(d_item_h);
if (_d_visible < 1) _d_visible = 1;
if (_ddetail) {
// ── full-screen detail for selected node ──────────────────────────────
const DiscoverResult& r = _dresults[_dsel];
const char* fullType = (r.type == ADV_TYPE_REPEATER) ? "Repeater" :
(r.type == ADV_TYPE_SENSOR) ? "Sensor" :
(r.type == ADV_TYPE_ROOM) ? "Room" : "Node";
// title bar: node name inverted
char label[32];
if (r.name[0]) { strncpy(label, r.name, 31); label[31] = '\0'; }
else { snprintf(label, sizeof(label), "[%s]", fullType); }
char filtered[32];
display.translateUTF8ToBlocks(filtered, label, sizeof(filtered));
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, 0, display.width(), hdr - 1);
display.setColor(DisplayDriver::DARK);
display.drawTextEllipsized(2, 1, display.width() - 4, filtered);
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, hdr - 1, display.width(), 1);
// public key as base64
char b64[48];
pubKeyToBase64(r.pub_key, b64, sizeof(b64));
display.drawTextEllipsized(2, hdr, display.width() - 4, b64);
int step = lh + 1;
char buf[32];
snprintf(buf, sizeof(buf), "RSSI: %d dBm", (int)r.rssi);
display.setCursor(2, hdr + step); display.print(buf);
snprintf(buf, sizeof(buf), "SNR: %d dB", (int)(r.snr_x4 / 4));
display.setCursor(2, hdr + step * 2); display.print(buf);
snprintf(buf, sizeof(buf), "Rem: %d dB", (int)(r.remote_snr_x4 / 4));
display.setCursor(2, hdr + step * 3); display.print(buf);
display.setCursor(2, hdr + step * 4);
display.print(r.is_known ? "Status: known" : "Status: new");
return 5000;
}
// ── list view ─────────────────────────────────────────────────────────────
_dresult_count = the_mesh.getDiscoverResults(_dresults, DISCOVER_RESULTS_MAX);
if (_discovering && millis() - _discover_started_ms >= DISCOVER_DURATION_MS)
_discovering = false;
display.setColor(DisplayDriver::LIGHT);
char title[28];
if (_discovering)
snprintf(title, sizeof(title), "SCANNING... (%d)", _dresult_count);
else if (_dresult_count == 0)
snprintf(title, sizeof(title), "DISCOVER: none");
else
snprintf(title, sizeof(title), "DISCOVER (%d found)", _dresult_count);
display.drawTextCentered(display.width() / 2, 0, title);
display.fillRect(0, hdr - 1, display.width(), 1);
if (_dresult_count == 0) {
display.drawTextCentered(display.width() / 2, display.height() / 2,
_discovering ? "Waiting for replies..." : "No nodes found");
} else {
if (_dsel >= _dresult_count) _dsel = _dresult_count - 1;
if (_dscroll > _dresult_count - _d_visible)
_dscroll = _dresult_count > _d_visible ? _dresult_count - _d_visible : 0;
if (_dscroll < 0) _dscroll = 0;
for (int i = 0; i < _d_visible && (_dscroll + i) < _dresult_count; i++) {
int idx = _dscroll + i;
bool sel = (idx == _dsel);
const DiscoverResult& r = _dresults[idx];
int y = d_start_y + i * d_item_h;
const char* typeStr = (r.type == ADV_TYPE_REPEATER) ? "Rpt" :
(r.type == ADV_TYPE_SENSOR) ? "Snsr" :
(r.type == ADV_TYPE_ROOM) ? "Room" : "?";
display.setColor(DisplayDriver::LIGHT);
if (sel) {
display.fillRect(0, y, display.width(), d_box_h);
display.setColor(DisplayDriver::DARK);
} else {
display.drawRect(0, y, display.width(), d_box_h);
display.fillRect(1, y + 1, display.width() - 2, lh);
display.setColor(DisplayDriver::DARK);
}
// header: name left, type right
char label[32];
if (r.name[0]) { strncpy(label, r.name, 31); label[31] = '\0'; }
else {
const char* ft = (r.type == ADV_TYPE_REPEATER) ? "Repeater" :
(r.type == ADV_TYPE_SENSOR) ? "Sensor" :
(r.type == ADV_TYPE_ROOM) ? "Room" : "Node";
snprintf(label, sizeof(label), "[%s]", ft);
}
char filtered[32];
display.translateUTF8ToBlocks(filtered, label, sizeof(filtered));
int tw = display.getTextWidth(typeStr);
display.drawTextEllipsized(3, y + 1, display.width() - 6 - tw, filtered);
display.setCursor(display.width() - 3 - tw, y + 1);
display.print(typeStr);
// body: RSSI + SNR
display.setColor(sel ? DisplayDriver::DARK : DisplayDriver::LIGHT);
char sig[24];
snprintf(sig, sizeof(sig), "RSSI:%d SNR:%d", (int)r.rssi, (int)(r.snr_x4 / 4));
display.drawTextEllipsized(3, y + lh + 2, display.width() - 6, sig);
}
display.setColor(DisplayDriver::LIGHT);
int cw = display.getCharWidth();
if (_dscroll > 0)
{ display.setCursor(display.width() - cw, d_start_y); display.print("^"); }
if (_dscroll + _d_visible < _dresult_count)
{ display.setCursor(display.width() - cw, d_start_y + (_d_visible - 1) * d_item_h); display.print("v"); }
}
return _discovering ? 200 : 2000;
}
bool handleInputDiscover(char c) {
if (_ddetail) {
if (c == KEY_CANCEL) { _ddetail = false; return true; }
if (c == KEY_CONTEXT_MENU || c == KEY_ENTER) {
enterDiscoverMode(); // re-scan; clears _ddetail
return true;
}
return true;
}
if (c == KEY_CANCEL) {
_discover_mode = false;
refresh();
return true;
}
if (c == KEY_ENTER && _dresult_count > 0) {
_ddetail = true;
return true;
}
if (c == KEY_CONTEXT_MENU) {
enterDiscoverMode(); // re-scan
return true;
}
if (c == KEY_UP && _dsel > 0) {
_dsel--;
if (_dsel < _dscroll) _dscroll = _dsel;
return true;
}
if (c == KEY_DOWN && _dsel < _dresult_count - 1) {
_dsel++;
if (_dsel >= _dscroll + _d_visible) _dscroll = _dsel - _d_visible + 1;
return true;
}
return true;
}
public:
NearbyScreen(UITask* task)
: _task(task), _count(0), _sel(0), _scroll(0), _detail(false),
_own_lat(0), _own_lon(0), _own_gps(false), _filter(0),
_detail_refresh_ms(0),
_discover_mode(false), _discovering(false), _discover_started_ms(0),
_dresult_count(0), _dscroll(0), _dsel(0), _ddetail(false) {}
void enter() {
_sel = _scroll = 0;
_detail = false;
_filter = 0;
_discover_mode = false;
_ddetail = false;
_dsel = 0;
_ctx_menu.active = false;
refresh();
}
int render(DisplayDriver& display) override {
display.setTextSize(1);
// ── discover sub-screen ──────────────────────────────────────────────────
if (_discover_mode) return renderDiscover(display);
// periodic refresh in detail view — preserve selected contact by idx
if (_detail && millis() - _detail_refresh_ms >= DETAIL_REFRESH_MS) {
int saved_contact_idx = (_sel < _count) ? _entries[_sel].contact_idx : -1;
refresh();
bool found = false;
if (saved_contact_idx >= 0) {
for (int i = 0; i < _count; i++) {
if (_entries[i].contact_idx == saved_contact_idx) { _sel = i; found = true; break; }
}
}
if (!found) _detail = false;
_detail_refresh_ms = millis();
}
// ── detail view ──────────────────────────────────────────────────────────
if (_detail && _sel < _count) {
const Entry& e = _entries[_sel];
int lh = display.getLineHeight();
int hdr = display.headerH();
int step = lh + 1;
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, 0, display.width(), hdr - 1);
display.setColor(DisplayDriver::DARK);
char filtered[32];
display.translateUTF8ToBlocks(filtered, e.name, sizeof(filtered));
display.drawTextEllipsized(2, 1, display.width() - 4, filtered);
display.setColor(DisplayDriver::LIGHT);
char buf[32];
snprintf(buf, sizeof(buf), "Lat: %.5f", e.lat_e6 / 1e6);
display.setCursor(2, hdr); display.print(buf);
snprintf(buf, sizeof(buf), "Lon: %.5f", e.lon_e6 / 1e6);
display.setCursor(2, hdr + step); display.print(buf);
if (e.dist_km >= 0.0f) {
char dist[12];
fmtDist(dist, sizeof(dist), e.dist_km);
int az = bearingDeg(_own_lat, _own_lon, e.lat_e6, e.lon_e6);
snprintf(buf, sizeof(buf), "Dist: %s", dist);
display.setCursor(2, hdr + step * 2); display.print(buf);
snprintf(buf, sizeof(buf), "Az: %dd (%s)", az, bearingCardinal(az));
display.setCursor(2, hdr + step * 3); display.print(buf);
} else {
display.setCursor(2, hdr + step * 2); display.print("Dist: no own GPS");
display.setCursor(2, hdr + step * 3); display.print("Az: unknown");
}
snprintf(buf, sizeof(buf), "Type: %s", typeName(e.type));
display.setCursor(2, hdr + step * 4); display.print(buf);
char age[16];
fmtAge(age, sizeof(age), e.lastmod);
snprintf(buf, sizeof(buf), "Seen: %s", age);
display.setCursor(2, hdr + step * 5); display.print(buf);
return 2000;
}
// ── list view ────────────────────────────────────────────────────────────
int item_h = display.lineStep();
int start_y = display.listStart();
int dist_col = display.width() - display.getCharWidth() * 7;
_visible = display.listVisible(item_h);
display.setColor(DisplayDriver::LIGHT);
char title[22];
snprintf(title, sizeof(title), "NEARBY[%s]", FILTER_LABELS[_filter]);
display.drawTextCentered(display.width() / 2, 0, title);
display.fillRect(0, display.headerH() - 1, display.width(), 1);
if (_count == 0) {
display.drawTextCentered(display.width() / 2, display.height() / 2 - display.lineStep() / 2, "No contacts found");
display.drawTextCentered(display.width() / 2, display.height() / 2 + display.lineStep() / 2, "[Enter]=Discover");
} else {
for (int i = 0; i < _visible && (_scroll + i) < _count; i++) {
int idx = _scroll + i;
bool sel = (idx == _sel);
int y = start_y + i * item_h;
const Entry& e = _entries[idx];
if (sel) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, y - 1, display.width(), item_h - 1);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
char filt[32];
display.translateUTF8ToBlocks(filt, e.name, sizeof(filt));
display.drawTextEllipsized(2, y, dist_col - 4, filt);
display.setColor(sel ? DisplayDriver::DARK : DisplayDriver::LIGHT);
char dist[10];
if (e.dist_km >= 0.0f) fmtDist(dist, sizeof(dist), e.dist_km);
else strncpy(dist, "?GPS", sizeof(dist));
display.setCursor(dist_col, y);
display.print(dist);
}
display.setColor(DisplayDriver::LIGHT);
int cw = display.getCharWidth();
if (_scroll > 0)
{ display.setCursor(display.width() - cw, start_y); display.print("^"); }
if (_scroll + _visible < _count)
{ display.setCursor(display.width() - cw, start_y + (_visible - 1) * item_h); display.print("v"); }
}
if (_ctx_menu.active) {
_ctx_menu.render(display);
return 50;
}
return _count == 0 ? 3000 : 2000;
}
bool handleInput(char c) override {
// ── discover sub-screen ──────────────────────────────────────────────────
if (_discover_mode) return handleInputDiscover(c);
// ── detail view ─────────────────────────────────────────────────────────
if (_detail) {
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { _detail = false; return true; }
return true;
}
// ── context menu ─────────────────────────────────────────────────────────
if (_ctx_menu.active) {
auto res = _ctx_menu.handleInput(c);
if (res == PopupMenu::SELECTED) {
if (_ctx_menu.selectedIndex() == 0)
enterDiscoverMode();
else
_task->gotoToolsScreen();
}
return true;
}
// ── list view ────────────────────────────────────────────────────────────
if (c == KEY_CANCEL) { _task->gotoToolsScreen(); return true; }
if (c == KEY_CONTEXT_MENU) {
_ctx_menu.begin("Options", 2);
_ctx_menu.addItem("Discover nearby");
_ctx_menu.addItem("Back");
return true;
}
if (c == KEY_UP && _sel > 0) {
_sel--;
if (_sel < _scroll) _scroll = _sel;
return true;
}
if (c == KEY_DOWN && _sel < _count - 1) {
_sel++;
if (_sel >= _scroll + _visible) _scroll = _sel - _visible + 1;
return true;
}
if (c == KEY_ENTER && _count == 0) { enterDiscoverMode(); return true; }
if (c == KEY_ENTER && _count > 0) {
_detail = true;
_detail_refresh_ms = millis();
return true;
}
if (c == KEY_LEFT) {
_filter = (_filter + FILTER_COUNT - 1) % FILTER_COUNT;
refresh();
return true;
}
if (c == KEY_RIGHT) {
_filter = (_filter + 1) % FILTER_COUNT;
refresh();
return true;
}
return false;
}
};
const char* NearbyScreen::FILTER_LABELS[6] = { "Fav", "ALL", "Comp", "Rpt", "Room", "Snsr" };
const uint8_t NearbyScreen::FILTER_TYPES[6] = { 0, 0, ADV_TYPE_CHAT, ADV_TYPE_REPEATER, ADV_TYPE_ROOM, ADV_TYPE_SENSOR };