Files
MeshCore-Solo/examples/companion_radio/ui-new/NearbyScreen.h
T
JakubandClaude Sonnet 4.6 24474f67fa feat: NearbyScreen, configurable clock dashboard, memory optimizations
- Add NearbyScreen: GPS contact list with haversine distance sort,
  type filter (ALL/Chat/Rpt/Room/Snsr), detail view with ping (RTT via
  ACK poll), node scan via advert(), context menu
- Add DashboardConfigScreen: configure up to 3 data fields on the clock
  page (Battery, Temp, Humidity, Pressure, GPS, Altitude, Lux, CO2,
  Contacts); entered via long-press ENTER on clock page
- Rework HP_CLOCK layout: full-screen clock (size 2 at y=0), date at
  y=19, separator at y=28, three data field rows at y=31/41/51;
  hide node name/battery indicator on clock page
- Persist dashboard_fields[3] in NodePrefs and DataStore
- Add isAckPending() to MyMesh for NearbyScreen ping detection
- Wire NearbyScreen into ToolsScreen and UITask
- Reduce NearbyScreen entry buffer from MAX_CONTACTS(100) to 32,
  saving ~3.3 KB RAM
- Switch haversine/bearing math from double to float (sinf/cosf/atan2f),
  eliminating double libm and saving ~5-10 KB flash

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:28:36 +02:00

354 lines
12 KiB
C++

#pragma once
#include <math.h>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
class NearbyScreen : public UIScreen {
UITask* _task;
static const int VISIBLE = 4;
static const int ITEM_H = 12;
static const int START_Y = 12;
static const int DIST_COL = 86;
static const int FILTER_COUNT = 5;
static const char* FILTER_LABELS[FILTER_COUNT];
static const uint8_t FILTER_TYPES[FILTER_COUNT];
struct Entry {
char name[32];
int32_t lat_e6, lon_e6;
float dist_km;
uint8_t type;
int contact_idx;
};
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;
// scan state
bool _scanning;
unsigned long _scan_started_ms;
static const unsigned long SCAN_DURATION_MS = 4000UL;
// context menu (list view)
PopupMenu _ctx_menu;
// ping state (detail view)
enum PingState { PING_IDLE, PING_WAIT, PING_OK, PING_TIMEOUT };
PingState _ping_state;
uint32_t _ping_expected_ack;
unsigned long _ping_start_ms;
unsigned long _ping_rtt_ms;
static const unsigned long PING_TIMEOUT_MS = 30000UL;
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 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 "Chat";
case ADV_TYPE_REPEATER: return "Repeater";
case ADV_TYPE_ROOM: return "Room";
case ADV_TYPE_SENSOR: return "Sensor";
default: return "Unknown";
}
}
void startScan() {
the_mesh.advert();
_scanning = true;
_scan_started_ms = millis();
}
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 (ci.gps_lat == 0 && ci.gps_lon == 0) continue;
if (_filter > 0 && 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;
e.dist_km = _own_gps ? haversineKm(_own_lat, _own_lon, ci.gps_lat, ci.gps_lon) : -1.0f;
e.type = ci.type;
e.contact_idx = i;
}
if (_own_gps) {
for (int i = 0; i < _count - 1; i++) {
int best = i;
for (int j = i + 1; j < _count; j++)
if (_entries[j].dist_km < _entries[best].dist_km) 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;
}
}
public:
NearbyScreen(UITask* task)
: _task(task), _filter(0), _scanning(false), _ping_state(PING_IDLE) {}
void enter() {
_sel = _scroll = 0;
_detail = false;
_filter = 0;
_scanning = false;
_ctx_menu.active = false;
_ping_state = PING_IDLE;
refresh();
}
int render(DisplayDriver& display) override {
display.setTextSize(1);
// poll scan timer — only refresh list when not in detail view
if (_scanning && !_detail && millis() - _scan_started_ms >= SCAN_DURATION_MS) {
_scanning = false;
refresh();
}
// poll ping ack
if (_ping_state == PING_WAIT) {
if (!the_mesh.isAckPending(_ping_expected_ack)) {
_ping_rtt_ms = millis() - _ping_start_ms;
_ping_state = PING_OK;
} else if (millis() - _ping_start_ms >= PING_TIMEOUT_MS) {
_ping_state = PING_TIMEOUT;
}
}
// ── detail view ──────────────────────────────────────────────────────────
if (_detail && _sel < _count) {
const Entry& e = _entries[_sel];
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, 0, display.width(), 10);
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, 13); display.print(buf);
snprintf(buf, sizeof(buf), "Lon: %.5f", e.lon_e6 / 1e6);
display.setCursor(2, 22); display.print(buf);
if (e.dist_km >= 0.0f) {
char dist[12];
fmtDist(dist, sizeof(dist), e.dist_km);
snprintf(buf, sizeof(buf), "Dist:%s %d\xb0", dist, bearingDeg(_own_lat, _own_lon, e.lat_e6, e.lon_e6));
display.setCursor(2, 31); display.print(buf);
} else {
display.setCursor(2, 31); display.print("Dist: no own GPS");
}
display.setCursor(2, 40);
switch (_ping_state) {
case PING_IDLE: display.print("[OK]=Ping"); break;
case PING_WAIT: display.print("Pinging..."); break;
case PING_OK: snprintf(buf,sizeof(buf),"RTT:%lums",_ping_rtt_ms);
display.print(buf); break;
case PING_TIMEOUT: display.print("Ping timeout"); break;
}
snprintf(buf, sizeof(buf), "Type:%s", typeName(e.type));
display.setCursor(2, 49); display.print(buf);
return (_ping_state == PING_WAIT) ? 100 : 2000;
}
// ── list view ────────────────────────────────────────────────────────────
display.setColor(DisplayDriver::LIGHT);
if (_scanning) {
display.drawTextCentered(display.width() / 2, 0, "SCANNING...");
} else {
char title[22];
snprintf(title, sizeof(title), "NEARBY[%s]", FILTER_LABELS[_filter]);
display.drawTextCentered(display.width() / 2, 0, title);
}
display.fillRect(0, 10, display.width(), 1);
if (_count == 0) {
display.drawTextCentered(display.width() / 2, 28, _scanning ? "Waiting..." : "No GPS contacts");
display.drawTextCentered(display.width() / 2, 40, "[M]=Scan");
} 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);
if (_scroll > 0)
{ display.setCursor(display.width() - 6, START_Y); display.print("^"); }
if (_scroll + VISIBLE < _count)
{ display.setCursor(display.width() - 6, START_Y + (VISIBLE - 1) * ITEM_H); display.print("v"); }
}
// context menu overlay — drawn on top regardless of list state
if (_ctx_menu.active) {
_ctx_menu.render(display);
return 50;
}
return _scanning ? 100 : (_count == 0 ? 3000 : 2000);
}
bool handleInput(char c) override {
// ── detail view input ────────────────────────────────────────────────────
if (_detail) {
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) {
_detail = false;
_ping_state = PING_IDLE;
return true;
}
if (c == KEY_ENTER && _ping_state == PING_IDLE) {
ContactInfo ci;
if (the_mesh.getContactByIdx(_entries[_sel].contact_idx, ci)) {
uint32_t expected_ack, est_timeout;
int res = the_mesh.sendMessage(ci, rtc_clock.getCurrentTime(), 0, "", expected_ack, est_timeout);
if (res != MSG_SEND_FAILED && expected_ack != 0) {
_ping_expected_ack = expected_ack;
_ping_start_ms = millis();
_ping_state = PING_WAIT;
}
}
return true;
}
return true;
}
// ── list view — context menu ─────────────────────────────────────────────
if (_ctx_menu.active) {
auto res = _ctx_menu.handleInput(c);
if (res == PopupMenu::SELECTED) {
if (_ctx_menu.selectedIndex() == 0) {
startScan();
} else {
_task->gotoToolsScreen();
}
}
return true;
}
// ── list view — normal input ─────────────────────────────────────────────
if (c == KEY_CANCEL) { _task->gotoToolsScreen(); return true; }
if (c == KEY_CONTEXT_MENU) {
_ctx_menu.begin("Options", 2);
_ctx_menu.addItem("Scan for nodes");
_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) {
_detail = true;
_ping_state = PING_IDLE;
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[5] = { "ALL", "Chat", "Rpt", "Room", "Snsr" };
const uint8_t NearbyScreen::FILTER_TYPES[5] = { 0, ADV_TYPE_CHAT, ADV_TYPE_REPEATER, ADV_TYPE_ROOM, ADV_TYPE_SENSOR };