feat(companion): geo-alert geofence + trail auto-pause

Geo Alert (Tools › Geo Alert): a single geofence around a snapshotted
waypoint. Beeps + alerts on crossing into (arrive) / out of (leave) the
radius per mode, with edge hysteresis and silent first-eval seeding. Adds
a proximity Beeper that ticks faster the closer to the target while inside
the radius — independent of the discrete arrive/leave alert.

Trail auto-pause (Trail › Settings): freezes the trail timer + sampling
after the device sits still for a configurable delay, resuming on the next
real movement. New paused state in TrailStore (distinct from Stop); Summary
shows "paused".

Persisted via new NodePrefs fields; schema bumped 0xC0DE0012 → 0xC0DE0014
with clamps so upgraders start with both features off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MarekZegare4
2026-06-22 10:45:00 +02:00
co-authored by Claude Opus 4.8
parent 1d2f2beaba
commit 408dfd494b
8 changed files with 436 additions and 13 deletions
@@ -0,0 +1,171 @@
#pragma once
// Geo-alert config tool. Tools Geo Alert.
// A single geofence around a saved waypoint: when armed, the device beeps and
// shows an alert as it crosses into (arrive) or out of (leave) the radius. The
// target is snapshotted from a waypoint (coord + label), so the alert keeps
// working even if that waypoint is later edited or deleted. The crossing engine
// itself lives in UITask::evaluateGeoAlert().
// Included by UITask.cpp after LiveShareScreen.h.
#include "../NodePrefs.h"
#include "../Waypoint.h"
#include "icons.h" // scrollIndicatorReserve / drawScrollIndicator
class GeoAlertScreen : public UIScreen {
UITask* _task;
NodePrefs* _prefs;
bool _dirty = false;
int _sel = 0;
int _scroll = 0;
enum Kind : uint8_t { K_ENABLE, K_TARGET, K_RADIUS, K_MODE, K_BEEPER };
struct Row { Kind kind; const char* label; };
static const int ROW_COUNT = 5;
static Row rows(int i) {
static const Row R[ROW_COUNT] = {
{ K_ENABLE, "Alert" },
{ K_TARGET, "Target" },
{ K_RADIUS, "Radius" },
{ K_MODE, "Mode" },
{ K_BEEPER, "Beeper" },
};
return R[i];
}
public:
GeoAlertScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {}
void enter() { _dirty = false; _sel = 0; _scroll = 0; }
void valueLabel(Kind k, char* buf, int n) {
switch (k) {
case K_ENABLE:
snprintf(buf, n, "%s", (_prefs && _prefs->geo_alert_enabled) ? "ON" : "OFF");
break;
case K_TARGET:
if (_prefs && _prefs->geo_alert_has_target && _prefs->geo_alert_label[0])
snprintf(buf, n, "%s", _prefs->geo_alert_label);
else if (_prefs && _prefs->geo_alert_has_target)
snprintf(buf, n, "(unnamed)");
else
snprintf(buf, n, "none");
break;
case K_RADIUS: {
uint16_t r = NodePrefs::geoAlertRadiusMeters(_prefs ? _prefs->geo_alert_radius_idx : 1);
if (r < 1000) snprintf(buf, n, "%um", (unsigned)r);
else snprintf(buf, n, "%.1fkm", r / 1000.0f);
break;
}
case K_MODE:
snprintf(buf, n, "%s", NodePrefs::geoAlertModeLabel(_prefs ? _prefs->geo_alert_mode : 0));
break;
case K_BEEPER:
snprintf(buf, n, "%s", (_prefs && _prefs->geo_alert_beeper) ? "ON" : "OFF");
break;
default: buf[0] = '\0';
}
}
int render(DisplayDriver& display) override {
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
display.drawCenteredHeader("GEO ALERT");
const int y0 = display.listStart();
const int step = display.lineStep();
int vis = (display.height() - y0) / step;
if (vis < 1) vis = 1;
if (_sel < _scroll) _scroll = _sel;
if (_sel >= _scroll + vis) _scroll = _sel - vis + 1;
if (_scroll < 0) _scroll = 0;
const int reserve = scrollIndicatorReserve(display, ROW_COUNT, vis);
const int valx = display.width() / 2 + 6;
for (int i = _scroll; i < ROW_COUNT && i < _scroll + vis; i++) {
int y = y0 + (i - _scroll) * step;
Row r = rows(i);
bool sel = (i == _sel);
display.drawSelectionRow(0, y - 1, display.width() - reserve, step - 1, sel);
display.setCursor(4, y);
display.print(r.label);
char val[24];
valueLabel(r.kind, val, sizeof(val));
if (val[0]) display.drawTextEllipsized(valx, y, display.width() - valx - reserve, val);
}
drawScrollIndicator(display, y0, vis * step, ROW_COUNT, vis, _scroll);
return 500;
}
void moveSel(int dir) { _sel = (_sel + dir + ROW_COUNT) % ROW_COUNT; }
void activate(int dir) {
if (!_prefs) return;
switch (rows(_sel).kind) {
case K_ENABLE:
_prefs->geo_alert_enabled ^= 1;
if (_prefs->geo_alert_enabled && !_prefs->geo_alert_has_target)
_task->showAlert("Pick a target", 1200);
_dirty = true;
break;
case K_TARGET:
cycleTarget(dir);
break;
case K_RADIUS:
_prefs->geo_alert_radius_idx = (uint8_t)((_prefs->geo_alert_radius_idx
+ (dir >= 0 ? 1 : NodePrefs::GEO_ALERT_RADIUS_COUNT - 1)) % NodePrefs::GEO_ALERT_RADIUS_COUNT);
_dirty = true;
break;
case K_MODE:
_prefs->geo_alert_mode = (uint8_t)((_prefs->geo_alert_mode
+ (dir >= 0 ? 1 : NodePrefs::GEO_ALERT_MODE_COUNT - 1)) % NodePrefs::GEO_ALERT_MODE_COUNT);
_dirty = true;
break;
case K_BEEPER:
_prefs->geo_alert_beeper ^= 1;
_dirty = true;
break;
}
}
// Cycle the target through the saved waypoints, snapshotting the chosen one's
// coordinate + label into prefs so the alert is independent of later edits.
void cycleTarget(int dir) {
WaypointStore& wp = _task->waypoints();
int total = wp.count();
if (total == 0) { _task->showAlert("Mark a waypoint first", 1400); return; }
// Locate the current target among the waypoints (by coordinate match).
int cur = -1;
if (_prefs->geo_alert_has_target) {
for (int i = 0; i < total; i++)
if (wp.at(i).lat_1e6 == _prefs->geo_alert_lat_1e6 &&
wp.at(i).lon_1e6 == _prefs->geo_alert_lon_1e6) { cur = i; break; }
}
int nx = (cur < 0) ? (dir >= 0 ? 0 : total - 1)
: ((cur + (dir >= 0 ? 1 : total - 1)) % total);
const Waypoint& w = wp.at(nx);
_prefs->geo_alert_lat_1e6 = w.lat_1e6;
_prefs->geo_alert_lon_1e6 = w.lon_1e6;
snprintf(_prefs->geo_alert_label, sizeof(_prefs->geo_alert_label), "%s", w.label);
_prefs->geo_alert_has_target = 1;
_dirty = true;
}
bool handleInput(char c) override {
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) {
if (_dirty) { the_mesh.savePrefs(); _task->resetGeoAlert(); _dirty = false; }
_task->gotoToolsScreen();
return true;
}
if (c == KEY_UP) { moveSel(-1); return true; }
if (c == KEY_DOWN) { moveSel(+1); return true; }
if (keyIsPrev(c)) { activate(-1); return true; }
if (keyIsNext(c)) { activate(+1); return true; }
if (c == KEY_ENTER) { activate(+1); return true; }
return false;
}
};