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
parent 1d2f2beaba
commit 408dfd494b
8 changed files with 436 additions and 13 deletions

View File

@@ -375,6 +375,25 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
if (_prefs.loc_share_move_idx >= NodePrefs::LOC_SHARE_MOVE_COUNT) _prefs.loc_share_move_idx = 1;
if (_prefs.loc_share_interval_idx >= NodePrefs::LOC_SHARE_INTERVAL_COUNT) _prefs.loc_share_interval_idx = 1;
if (_prefs.loc_share_heartbeat_idx >= NodePrefs::LOC_SHARE_HEARTBEAT_COUNT) _prefs.loc_share_heartbeat_idx = 0;
// → 0xC0DE0013: geo-alert + trail auto-pause. Pre-0x13 files leave stray bytes
// here; clamp each back to its default so upgraders start with both off.
rd(&_prefs.geo_alert_enabled, sizeof(_prefs.geo_alert_enabled));
rd(&_prefs.geo_alert_has_target, sizeof(_prefs.geo_alert_has_target));
rd(&_prefs.geo_alert_radius_idx, sizeof(_prefs.geo_alert_radius_idx));
rd(&_prefs.geo_alert_mode, sizeof(_prefs.geo_alert_mode));
rd(&_prefs.geo_alert_lat_1e6, sizeof(_prefs.geo_alert_lat_1e6));
rd(&_prefs.geo_alert_lon_1e6, sizeof(_prefs.geo_alert_lon_1e6));
rd(_prefs.geo_alert_label, sizeof(_prefs.geo_alert_label));
rd(&_prefs.trail_autopause_idx, sizeof(_prefs.trail_autopause_idx));
if (_prefs.geo_alert_enabled > 1) _prefs.geo_alert_enabled = 0;
if (_prefs.geo_alert_has_target > 1) _prefs.geo_alert_has_target = 0;
if (_prefs.geo_alert_radius_idx >= NodePrefs::GEO_ALERT_RADIUS_COUNT) _prefs.geo_alert_radius_idx = 1;
if (_prefs.geo_alert_mode >= NodePrefs::GEO_ALERT_MODE_COUNT) _prefs.geo_alert_mode = 0;
if (_prefs.trail_autopause_idx >= NodePrefs::TRAIL_AUTOPAUSE_COUNT) _prefs.trail_autopause_idx = 0;
_prefs.geo_alert_label[sizeof(_prefs.geo_alert_label) - 1] = '\0';
// → 0xC0DE0014: geo-alert proximity beeper.
rd(&_prefs.geo_alert_beeper, sizeof(_prefs.geo_alert_beeper));
if (_prefs.geo_alert_beeper > 1) _prefs.geo_alert_beeper = 0;
// Pre-0x10 files leave stray sentinel bytes here, same as a never-configured
// device. Either way there's no valid saved profile, so default to a profile
// in the same band as the companion's own network (_prefs.freq, already read
@@ -562,6 +581,15 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
file.write((uint8_t *)&_prefs.loc_share_move_idx, sizeof(_prefs.loc_share_move_idx));
file.write((uint8_t *)&_prefs.loc_share_interval_idx, sizeof(_prefs.loc_share_interval_idx));
file.write((uint8_t *)&_prefs.loc_share_heartbeat_idx, sizeof(_prefs.loc_share_heartbeat_idx));
file.write((uint8_t *)&_prefs.geo_alert_enabled, sizeof(_prefs.geo_alert_enabled));
file.write((uint8_t *)&_prefs.geo_alert_has_target, sizeof(_prefs.geo_alert_has_target));
file.write((uint8_t *)&_prefs.geo_alert_radius_idx, sizeof(_prefs.geo_alert_radius_idx));
file.write((uint8_t *)&_prefs.geo_alert_mode, sizeof(_prefs.geo_alert_mode));
file.write((uint8_t *)&_prefs.geo_alert_lat_1e6, sizeof(_prefs.geo_alert_lat_1e6));
file.write((uint8_t *)&_prefs.geo_alert_lon_1e6, sizeof(_prefs.geo_alert_lon_1e6));
file.write((uint8_t *)_prefs.geo_alert_label, sizeof(_prefs.geo_alert_label));
file.write((uint8_t *)&_prefs.trail_autopause_idx, sizeof(_prefs.trail_autopause_idx));
file.write((uint8_t *)&_prefs.geo_alert_beeper, sizeof(_prefs.geo_alert_beeper));
// Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL.
uint32_t sentinel = NodePrefs::SCHEMA_SENTINEL;

View File

@@ -235,6 +235,30 @@ struct NodePrefs { // persisted to file
uint8_t loc_share_interval_idx; // min send interval (index into locShareIntervalSecs)
uint8_t loc_share_heartbeat_idx; // stationary heartbeat (index into locShareHeartbeatSecs)
// Geo-alert — a single geofence around a saved point. When enabled the device
// watches its own GPS fix and beeps + shows an alert when it crosses into
// (arrive) or out of (leave) the radius. The target coordinate/label is a
// snapshot of a chosen waypoint, so the alert survives the waypoint being
// edited or deleted. Configured from Tools Geo Alert.
uint8_t geo_alert_enabled; // 0=off (default), 1=armed
uint8_t geo_alert_has_target; // 0=no target chosen yet, 1=target set
uint8_t geo_alert_radius_idx; // index into geoAlertRadiusMeters
uint8_t geo_alert_mode; // 0=arrive, 1=leave, 2=both
int32_t geo_alert_lat_1e6; // target latitude (1e6-scaled)
int32_t geo_alert_lon_1e6; // target longitude (1e6-scaled)
char geo_alert_label[12]; // target name for the alert text (WAYPOINT_LABEL_LEN)
// Trail auto-pause — when tracking, automatically freeze the trail (timer +
// sampling) after the device has sat still for this long, and resume on the
// next real movement. 0 = off. Index into trailAutoPauseSecs.
uint8_t trail_autopause_idx;
// Geo-alert proximity beeper — when on (and the alert is armed with a target),
// the device ticks while inside the radius and shortens the gap between ticks
// the closer it gets to the target, like a homing beeper. Independent of the
// discrete arrive/leave alert (geo_alert_mode).
uint8_t geo_alert_beeper; // 0=off (default), 1=on
// Single source of truth for the live-share option tables (shared by the Map
// UI labels and the auto-send engine in UITask).
static const uint8_t LOC_SHARE_MOVE_COUNT = 4;
@@ -253,11 +277,35 @@ struct NodePrefs { // persisted to file
return H[idx < LOC_SHARE_HEARTBEAT_COUNT ? idx : 0];
}
// Geo-alert option tables (shared by the Tools Geo Alert UI labels and the
// evaluation engine in UITask).
static const uint8_t GEO_ALERT_RADIUS_COUNT = 5;
static uint16_t geoAlertRadiusMeters(uint8_t idx) {
static const uint16_t R[GEO_ALERT_RADIUS_COUNT] = { 50, 100, 250, 500, 1000 };
return R[idx < GEO_ALERT_RADIUS_COUNT ? idx : 1];
}
static const uint8_t GEO_ALERT_MODE_COUNT = 3; // 0=arrive, 1=leave, 2=both
static const char* geoAlertModeLabel(uint8_t m) {
static const char* L[GEO_ALERT_MODE_COUNT] = { "Arrive", "Leave", "Both" };
return L[m < GEO_ALERT_MODE_COUNT ? m : 0];
}
// Trail auto-pause delays (seconds). 0 = off.
static const uint8_t TRAIL_AUTOPAUSE_COUNT = 4;
static uint16_t trailAutoPauseSecs(uint8_t idx) {
static const uint16_t S[TRAIL_AUTOPAUSE_COUNT] = { 0, 60, 120, 300 }; // off / 1 / 2 / 5 min
return S[idx < TRAIL_AUTOPAUSE_COUNT ? idx : 0];
}
static const char* trailAutoPauseLabel(uint8_t idx) {
static const char* L[TRAIL_AUTOPAUSE_COUNT] = { "Off", "1m", "2m", "5m" };
return L[idx < TRAIL_AUTOPAUSE_COUNT ? idx : 0];
}
// Tail sentinel written at the end of /new_prefs. Bump the low byte when
// adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so
// older saves are detected on load and skipped (zero-init defaults kept).
// High 24 bits identify the file format; low byte is the schema revision.
static const uint32_t SCHEMA_SENTINEL = 0xC0DE0012;
static const uint32_t SCHEMA_SENTINEL = 0xC0DE0014;
// Bit-index for each home page. Used by page_order (entries store bit+1) and
// by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage

View File

@@ -73,6 +73,7 @@ public:
if (a && !_active) {
// off → on: start a new session timer.
_session_start_ms = millis();
_paused = false;
} else if (_active && !a) {
// on → off: bank the elapsed of this session and arm a segment break
// so the renderer doesn't draw a straight line through the dead time.
@@ -81,10 +82,31 @@ public:
_session_start_ms = 0;
}
_pending_seg_break = true;
_paused = false;
}
_active = a;
}
// Auto-pause: freeze the active trail without ending the session. Banks the
// running session time (so elapsedSeconds() stops advancing) and arms a
// segment break so the map doesn't draw a line across the idle gap; resuming
// restarts the session timer. Distinct from setActive() — the trail stays
// "on" (UI shows "paused", home-screen blink keeps going). No-op if inactive.
bool isPaused() const { return _paused; }
void setPaused(bool p) {
if (!_active || p == _paused) return;
if (p) {
if (_session_start_ms != 0) {
_accumulated_ms += millis() - _session_start_ms;
_session_start_ms = 0;
}
_pending_seg_break = true;
} else {
_session_start_ms = millis(); // resume timing from now
}
_paused = p;
}
int count() const { return _count; }
bool empty() const { return _count == 0; }
@@ -98,6 +120,7 @@ public:
_pending_seg_break = false;
_accumulated_ms = 0;
_session_start_ms = 0;
_paused = false;
}
// Returns true if the point was stored (passed the min-delta gate).
@@ -202,6 +225,7 @@ public:
_active = false;
_session_start_ms = 0;
}
_paused = false;
_head = 0;
_count = 0;
for (int i = 0; i < cnt; i++) {
@@ -367,6 +391,7 @@ private:
int _head = 0;
int _count = 0;
bool _active = false;
bool _paused = false; // auto-paused (active but timer/sampling frozen)
bool _pending_seg_break = false; // next addPoint flags itself SEG_START
uint32_t _accumulated_ms = 0; // banked active time across previous sessions
uint32_t _session_start_ms = 0; // millis() of the current active session, 0 if none

View File

@@ -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;
}
};

View File

@@ -7,7 +7,7 @@ class ToolsScreen : public UIScreen {
int _sel;
int _scroll = 0;
static const int ITEM_COUNT = 9;
static const int ITEM_COUNT = 10;
static const char* ITEMS[ITEM_COUNT];
public:
@@ -37,11 +37,12 @@ public:
if (_sel == 3) { _task->gotoAutoAdvertScreen(); return true; }
if (_sel == 4) { _task->gotoLiveShareScreen(); return true; }
if (_sel == 5) { _task->gotoTrailScreen(); return true; }
if (_sel == 6) { _task->gotoCompassScreen(); return true; }
if (_sel == 7) { _task->gotoDiagnosticsScreen(); return true; }
if (_sel == 8) { _task->gotoRepeaterScreen(); return true; }
if (_sel == 6) { _task->gotoGeoAlertScreen(); return true; }
if (_sel == 7) { _task->gotoCompassScreen(); return true; }
if (_sel == 8) { _task->gotoDiagnosticsScreen(); return true; }
if (_sel == 9) { _task->gotoRepeaterScreen(); return true; }
}
return false;
}
};
const char* ToolsScreen::ITEMS[9] = { "Ringtone Editor", "Auto-Reply Bot", "Nearby Nodes", "Auto-Advert", "Live Share", "Trail", "Compass", "Diagnostics", "Repeater" };
const char* ToolsScreen::ITEMS[10] = { "Ringtone Editor", "Auto-Reply Bot", "Nearby Nodes", "Auto-Advert", "Live Share", "Trail", "Geo Alert", "Compass", "Diagnostics", "Repeater" };

View File

@@ -39,7 +39,7 @@ class TrailScreen : public UIScreen {
// (Start/Stop tracking, Reset). PopupMenu stores label pointers verbatim, so
// the labels live in member buffers that get refreshed in openActionMenu()
// and after every LEFT/RIGHT cycle.
enum ActionId { ACT_MIN_DIST, ACT_UNITS, ACT_GRID, ACT_TOGGLE, ACT_SAVE, ACT_LOAD, ACT_RESET, ACT_EXPORT, ACT_EXPORT_SAVED, ACT_MARK, ACT_WAYPOINTS, ACT_FILE, ACT_SETTINGS,
enum ActionId { ACT_MIN_DIST, ACT_UNITS, ACT_GRID, ACT_AUTOPAUSE, ACT_TOGGLE, ACT_SAVE, ACT_LOAD, ACT_RESET, ACT_EXPORT, ACT_EXPORT_SAVED, ACT_MARK, ACT_WAYPOINTS, ACT_FILE, ACT_SETTINGS,
ACT_SHARE_NOW };
// The action popup is multi-level: a short main menu, plus "Trail file…" and
// "Settings…" submenus. _menu_level tracks which is open so input is routed
@@ -54,6 +54,7 @@ class TrailScreen : public UIScreen {
char _act_min_dist_label[24];
char _act_units_label[24];
char _act_grid_label[16];
char _act_autopause_label[24];
char _act_toggle_label[20];
static const int SUMMARY_ITEM_COUNT = 5;
@@ -126,7 +127,8 @@ public:
// Settings rows: Enter advances/toggles the value and keeps focus.
case ACT_MIN_DIST:
case ACT_UNITS:
case ACT_GRID: cycleSetting(act, 1); reopenSettingsAt(sel); return true;
case ACT_GRID:
case ACT_AUTOPAUSE: cycleSetting(act, 1); reopenSettingsAt(sel); return true;
case ACT_SHARE_NOW: shareMyLocationNow(); break;
case ACT_TOGGLE: handleToggle(); break;
case ACT_MARK: _wp.markHere(); break;
@@ -259,6 +261,8 @@ private:
"Readout: %s", (p && p->trail_show_pace) ? "Pace" : "Speed");
snprintf(_act_grid_label, sizeof(_act_grid_label),
"Grid: %s", _map_grid ? "ON" : "OFF");
snprintf(_act_autopause_label, sizeof(_act_autopause_label),
"Auto-pause: %s", NodePrefs::trailAutoPauseLabel(p ? p->trail_autopause_idx : 0));
snprintf(_act_toggle_label, sizeof(_act_toggle_label),
"%s tracking", _store->isActive() ? "Stop" : "Start");
}
@@ -310,8 +314,9 @@ private:
refreshActionLabels();
_menu_level = ML_SETTINGS;
_act_count = 0;
_action_menu.begin("Settings", 3);
pushAction(ACT_MIN_DIST, _act_min_dist_label);
_action_menu.begin("Settings", 4);
pushAction(ACT_MIN_DIST, _act_min_dist_label);
pushAction(ACT_AUTOPAUSE, _act_autopause_label);
if (_view == V_SUMMARY) pushAction(ACT_UNITS, _act_units_label);
if (_view == V_MAP) pushAction(ACT_GRID, _act_grid_label);
}
@@ -322,6 +327,7 @@ private:
if (act == ACT_MIN_DIST && p) { cycleMinDelta(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; }
if (act == ACT_UNITS && p) { cycleUnits(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; }
if (act == ACT_GRID) { _map_grid = !_map_grid; refreshActionLabels(); return true; }
if (act == ACT_AUTOPAUSE && p){ cycleAutoPause(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; }
return false;
}
@@ -357,6 +363,13 @@ private:
// The trail "Readout" row toggles speed vs pace; the metric/imperial unit
// comes from the global Settings preference, so this is a plain on/off flip.
void cycleUnits(NodePrefs* p, int /*dir*/) { p->trail_show_pace ^= 1; }
void cycleAutoPause(NodePrefs* p, int dir) {
uint8_t idx = p->trail_autopause_idx;
if (idx >= NodePrefs::TRAIL_AUTOPAUSE_COUNT) idx = 0;
idx = (dir > 0) ? (idx + 1) % NodePrefs::TRAIL_AUTOPAUSE_COUNT
: (idx + NodePrefs::TRAIL_AUTOPAUSE_COUNT - 1) % NodePrefs::TRAIL_AUTOPAUSE_COUNT;
p->trail_autopause_idx = idx;
}
// One-shot manual share: build "[LOC]lat,lon" and hand it to the Messages
// screen, where the user picks a DM or channel recipient. (Auto live-share
@@ -398,6 +411,7 @@ private:
case 0: {
const char* st;
if (!_store->isActive()) st = "stopped";
else if (_store->isPaused()) st = "paused";
else if (_store->empty()) st = "waiting fix";
else st = "tracking";
snprintf(buf, n, "Status: %s", st);

View File

@@ -124,6 +124,7 @@ static const int QUICK_MSGS_MAX = 10;
#include "DashboardConfigScreen.h"
#include "AutoAdvertScreen.h"
#include "LiveShareScreen.h"
#include "GeoAlertScreen.h"
#include "TrailScreen.h"
#include "CompassScreen.h"
#include "DiagnosticsScreen.h"
@@ -1294,6 +1295,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
dashboard_config = new DashboardConfigScreen(this, node_prefs);
auto_advert_screen = new AutoAdvertScreen(this, node_prefs);
live_share_screen = new LiveShareScreen(this, node_prefs);
geo_alert_screen = new GeoAlertScreen(this, node_prefs);
trail_screen = new TrailScreen(this, &_trail);
compass_screen = new CompassScreen(this);
diag_screen = new DiagnosticsScreen(this);
@@ -1363,6 +1365,11 @@ void UITask::gotoLiveShareScreen() {
setCurrScreen(live_share_screen);
}
void UITask::gotoGeoAlertScreen() {
((GeoAlertScreen*)geo_alert_screen)->enter();
setCurrScreen(geo_alert_screen);
}
void UITask::gotoAutoAdvertScreen() {
((AutoAdvertScreen*)auto_advert_screen)->enter();
setCurrScreen(auto_advert_screen);
@@ -2015,16 +2022,37 @@ void UITask::loop() {
// GPS trail sampling — runs in the background while the trail is
// active, independent of which screen is shown. Skips silently if no GPS
// fix; min-delta gate inside addPoint() avoids near-stationary spam.
if (!_trail.isActive()) _trail_pause_has_ref = false; // fresh ref on next start
if (_trail.isActive() && _node_prefs != NULL
&& (int32_t)(millis() - _next_trail_sample_ms) >= 0) {
_next_trail_sample_ms = millis() + (uint32_t)TrailStore::SAMPLING_SECS * 1000UL;
LocationProvider* loc = _sensors ? _sensors->getLocationProvider() : nullptr;
if (loc && loc->isValid()) {
int32_t la = (int32_t)loc->getLatitude();
int32_t lo = (int32_t)loc->getLongitude();
uint16_t md = TrailStore::minDeltaMeters(_node_prefs->trail_min_delta_idx,
_node_prefs->units_imperial);
_trail.addPoint((int32_t)loc->getLatitude(),
(int32_t)loc->getLongitude(),
(uint32_t)rtc_clock.getCurrentTime(), md);
// Auto-pause: freeze the trail once the device has sat within the
// min-delta gate for the configured delay; resume on the next real move.
uint16_t ap = NodePrefs::trailAutoPauseSecs(_node_prefs->trail_autopause_idx);
if (ap > 0) {
uint32_t now = millis();
float moved = _trail_pause_has_ref
? geo::haversineKm(_trail_pause_ref_lat, _trail_pause_ref_lon, la, lo) * 1000.0f
: 1e9f;
if (!_trail_pause_has_ref || moved >= (float)md) {
_trail_pause_ref_lat = la; _trail_pause_ref_lon = lo;
_trail_pause_has_ref = true;
_trail_last_move_ms = now;
if (_trail.isPaused()) _trail.setPaused(false);
} else if (!_trail.isPaused() && (now - _trail_last_move_ms) >= (uint32_t)ap * 1000UL) {
_trail.setPaused(true);
}
} else if (_trail.isPaused()) {
_trail.setPaused(false); // feature turned off → resume
}
if (!_trail.isPaused())
_trail.addPoint(la, lo, (uint32_t)rtc_clock.getCurrentTime(), md);
}
}
@@ -2076,6 +2104,87 @@ void UITask::loop() {
pushCogFix((int32_t)loc->getLatitude(), (int32_t)loc->getLongitude());
}
}
// Geo-alert — beep + alert when the device crosses into / out of the armed
// geofence. Cheap; a few seconds of latency at the boundary is fine.
if ((int32_t)(millis() - _next_geo_alert_ms) >= 0) {
_next_geo_alert_ms = millis() + 3000UL;
evaluateGeoAlert();
}
// Geo-alert proximity beeper — ticks faster the closer to the target. Runs on
// its own short cadence (the crossing check above is too coarse for this).
geoProximityBeeper();
}
// Evaluate the single geofence against the current GPS fix. Crossing the radius
// fires fireGeoAlert() according to the configured mode; a hysteresis band on
// the "leave" edge stops it chattering at the boundary, and the first reading
// after arming only seeds the inside/outside state (no spurious alert).
void UITask::evaluateGeoAlert() {
if (!_node_prefs || !_node_prefs->geo_alert_enabled || !_node_prefs->geo_alert_has_target) {
_geo_alert_known = false;
return;
}
int32_t lat, lon;
if (!currentLocation(lat, lon)) return;
float dist = geo::haversineKm(lat, lon,
_node_prefs->geo_alert_lat_1e6,
_node_prefs->geo_alert_lon_1e6) * 1000.0f;
float r = (float)NodePrefs::geoAlertRadiusMeters(_node_prefs->geo_alert_radius_idx);
bool inside;
if (!_geo_alert_known) inside = dist <= r; // seed state
else if (_geo_alert_inside) inside = dist <= r * 1.25f; // leave past band
else inside = dist <= r; // arrive at edge
if (_geo_alert_known && inside != _geo_alert_inside) {
uint8_t mode = _node_prefs->geo_alert_mode; // 0=arrive,1=leave,2=both
bool fire = inside ? (mode == 0 || mode == 2) : (mode == 1 || mode == 2);
if (fire) fireGeoAlert(inside);
}
_geo_alert_inside = inside;
_geo_alert_known = true;
}
void UITask::fireGeoAlert(bool arrived) {
const char* lbl = _node_prefs->geo_alert_label[0] ? _node_prefs->geo_alert_label : "target";
char msg[40];
snprintf(msg, sizeof(msg), arrived ? "Arrived: %s" : "Left: %s", lbl);
showAlert(msg, 3000);
if (!isBuzzerQuiet())
playMelody(arrived ? "geoarr:d=8,o=6,b=140:c,e,g" : "geolv:d=8,o=6,b=140:g,e,c");
}
// Homing beeper: while armed with a target and inside the radius, emit a short
// tick whose interval shrinks linearly with distance — slow at the edge, rapid
// near the centre. Polls distance a few times a second; silent outside the
// radius and when the buzzer is muted.
void UITask::geoProximityBeeper() {
static const uint32_t BEEP_MIN_MS = 150; // fastest cadence (at the target)
static const uint32_t BEEP_MAX_MS = 2000; // slowest cadence (at the edge)
if (!_node_prefs || !_node_prefs->geo_alert_enabled || !_node_prefs->geo_alert_beeper
|| !_node_prefs->geo_alert_has_target || isBuzzerQuiet()) {
return;
}
if ((int32_t)(millis() - _geo_beep_check_ms) < 0) return;
_geo_beep_check_ms = millis() + 250UL;
int32_t lat, lon;
if (!currentLocation(lat, lon)) return;
float dist = geo::haversineKm(lat, lon,
_node_prefs->geo_alert_lat_1e6,
_node_prefs->geo_alert_lon_1e6) * 1000.0f;
float r = (float)NodePrefs::geoAlertRadiusMeters(_node_prefs->geo_alert_radius_idx);
if (dist > r) { // outside the zone: stay quiet, beep on re-entry
_geo_beep_next_ms = millis();
return;
}
if ((int32_t)(millis() - _geo_beep_next_ms) < 0) return;
float frac = (r > 0) ? dist / r : 0; // 0 at centre, 1 at edge
if (frac < 0) frac = 0; else if (frac > 1) frac = 1;
uint32_t interval = BEEP_MIN_MS + (uint32_t)(frac * (BEEP_MAX_MS - BEEP_MIN_MS));
playMelody("geop:d=32,o=7,b=200:c");
_geo_beep_next_ms = millis() + interval;
}
// Insert a GPS fix into the course-over-ground ring, rejecting gross outliers

View File

@@ -79,6 +79,7 @@ class UITask : public AbstractUITask {
UIScreen* dashboard_config;
UIScreen* auto_advert_screen;
UIScreen* live_share_screen;
UIScreen* geo_alert_screen;
UIScreen* trail_screen;
UIScreen* compass_screen;
UIScreen* diag_screen;
@@ -98,6 +99,27 @@ class UITask : public AbstractUITask {
bool _loc_share_has_last = false;
bool _loc_share_was_enabled = false;
// Trail auto-pause engine state. _trail_pause_ref is the last position the
// device was considered "at"; if it doesn't move beyond the trail min-delta
// gate for the configured delay, the trail is auto-paused.
int32_t _trail_pause_ref_lat = 0, _trail_pause_ref_lon = 0;
bool _trail_pause_has_ref = false;
uint32_t _trail_last_move_ms = 0;
// Geo-alert engine state. _geo_alert_known guards the first evaluation after
// arming (initialise inside/outside silently, fire only on later crossings).
uint32_t _next_geo_alert_ms = 0;
bool _geo_alert_inside = false;
bool _geo_alert_known = false;
// Proximity beeper: ticks while inside the radius, faster the nearer the
// target. _geo_beep_check_ms throttles the distance poll; _geo_beep_next_ms
// is when the next tick is due.
uint32_t _geo_beep_check_ms = 0;
uint32_t _geo_beep_next_ms = 0;
void evaluateGeoAlert();
void fireGeoAlert(bool arrived);
void geoProximityBeeper();
// Course-over-ground ring — a heading source independent of trail recording.
// Filled from the same periodic GPS poll regardless of _trail.isActive().
// Heading = bearing across the window (oldest→newest) once the cumulative
@@ -165,6 +187,11 @@ public:
void gotoDashboardConfig();
void gotoAutoAdvertScreen();
void gotoLiveShareScreen();
void gotoGeoAlertScreen();
// Re-arm the geo-alert state machine so the next evaluation initialises
// silently (called by the Geo Alert tool after the target/radius changes,
// so re-entering the zone doesn't fire on a stale inside/outside state).
void resetGeoAlert() { _geo_alert_known = false; }
void gotoTrailScreen();
void gotoMapScreen(); // opens the Trail screen directly in its Map view
void gotoCompassScreen();