diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 34086a13..89e44745 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -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; diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 7b86da49..60d55eb5 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -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 diff --git a/examples/companion_radio/Trail.h b/examples/companion_radio/Trail.h index 0407e52c..cc8144f4 100644 --- a/examples/companion_radio/Trail.h +++ b/examples/companion_radio/Trail.h @@ -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 diff --git a/examples/companion_radio/ui-new/GeoAlertScreen.h b/examples/companion_radio/ui-new/GeoAlertScreen.h new file mode 100644 index 00000000..0f043ebc --- /dev/null +++ b/examples/companion_radio/ui-new/GeoAlertScreen.h @@ -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; + } +}; diff --git a/examples/companion_radio/ui-new/ToolsScreen.h b/examples/companion_radio/ui-new/ToolsScreen.h index 7f78c6d5..4aeb4d6d 100644 --- a/examples/companion_radio/ui-new/ToolsScreen.h +++ b/examples/companion_radio/ui-new/ToolsScreen.h @@ -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" }; diff --git a/examples/companion_radio/ui-new/TrailScreen.h b/examples/companion_radio/ui-new/TrailScreen.h index 1fa7255c..a1efe22f 100644 --- a/examples/companion_radio/ui-new/TrailScreen.h +++ b/examples/companion_radio/ui-new/TrailScreen.h @@ -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); diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 5afd5c70..64ac7d95 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -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 diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index ed2bcaaa..ccd2823f 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -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();