diff --git a/docs/solo_features/tools_screen/tools_screen.md b/docs/solo_features/tools_screen/tools_screen.md index 63a82ecb..12f7d4b1 100644 --- a/docs/solo_features/tools_screen/tools_screen.md +++ b/docs/solo_features/tools_screen/tools_screen.md @@ -451,9 +451,10 @@ A separate **Actions** toggle, nested under Commands (Commands must be ON for Ac | ----------------- | -------------------------------------------------------------------- | | `!buzz [seconds]` | Sounds the buzzer as a find-me signal — default 5s, capped at 30s. Sounds even if the buzzer is muted in Settings (that's the point of a find-me signal). | | `!gps on` / `!gps off` | Enables/disables GPS, same effect as the Home page's GPS toggle. | +| `!gps fix` | Single-shot location: turns GPS on if it wasn't already, waits for a stabilised fix (≥8 satellites, averaged over 10s), sends the position, then restores GPS to whatever state it was in before. Replies in two parts — an immediate `GPS: acquiring fix...` ack, then the position (or `GPS: no fix (timeout)` / a partial fix) as a follow-up message up to 90s later. Only one `!gps fix` can be in flight at a time; a second one gets `GPS: fix already pending`. | | `!advert` | Sends an advert immediately, same as the Home page's manual advert action. | -Actions combine with Commands and each other in one message the same way — `!batt !gps on` answers with `4.10V | GPS: on` in a single reply. With Actions OFF for a target, `!buzz`/`!gps`/`!advert` are silently ignored (no reply, no effect) exactly like any other unrecognised command, and `!help`'s reply doesn't mention them. +Actions combine with Commands and each other in one message the same way — `!batt !gps on` answers with `4.10V | GPS: on` in a single reply. With Actions OFF for a target, `!buzz`/`!gps`/`!gps fix`/`!advert` are silently ignored (no reply, no effect) exactly like any other unrecognised command, and `!help`'s reply doesn't mention them. On boards with user GPIO (see **GPIO** under System tools below), the same Actions gate also covers `!gpio1`..`!gpio4`. diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 9fe83133..ece1f6da 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1550,6 +1550,8 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe memset(_bot_dm_log, 0, sizeof(_bot_dm_log)); _bot_reply_count = 0; _next_auto_advert_ms = 0; + _loc_fix.active = false; + _locfix_requested = false; clearPendingReqs(); next_ack_idx = 0; sign_data = NULL; @@ -3083,6 +3085,8 @@ void MyMesh::loop() { } } + tickLocFix(); + if (_cli_rescue) { checkCLIRescueCmd(); } else { diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 8cb83607..55dadba6 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -317,6 +317,14 @@ private: bool tryBotRoomCommand(const ContactInfo& from, const uint8_t* sender_prefix, const char* text, uint8_t hops); // room commands bool botCommandReply(const char* cmd, const char* arg, bool actions_allowed, uint8_t hops, uint32_t ts, char* out, int out_len, const char* sender_name); // one command → reply text int botScanCommands(const char* body, uint8_t hops, uint32_t ts, char* out, int out_len, const char* sender_name, bool actions_allowed); // scan "!word"s → combined reply, returns count + // !gps fix -- single-shot "wait for a stabilised GPS fix, then push a follow-up + // message" action. botCommandReply() only sets _locfix_requested (it doesn't know + // the destination); the tryBot*Command() wrappers call startLocFix() with the + // destination they each already have, but only once the immediate ack actually + // sent (so a throttled/suppressed ack never starts a fix nobody will hear about). + void tickLocFix(); // ticked every loop() while _loc_fix.active + void startLocFix(uint8_t dest_type, const uint8_t* pub_key, uint8_t channel_idx); + void sendLocFixResult(const char* msg); bool botTriggerMatches(const char* trigger, const char* body, bool allow_wildcard) const; bool botInQuietHours() const; // true when auto-replies should stay silent bool botDmAllowed(const uint8_t* pubkey); // per-contact DM throttle: ok to reply? @@ -418,6 +426,28 @@ private: BotReplyLog _bot_dm_log[BOT_DM_LOG_SIZE]; uint16_t _bot_reply_count; // total auto-replies sent since boot + // !gps fix state -- one global slot (one physical GPS): botCommandReply() + // rejects a second request outright while one is active, so this never + // needs to be an array. See tickLocFix()/startLocFix() in MyMeshBot.h. + static const int LOCFIX_MIN_SATS = 8; // readiness threshold + static const uint32_t LOCFIX_AVERAGE_MS = 10000; // once ready, keep averaging this long + static const uint32_t LOCFIX_TIMEOUT_MS = 90000; // hard stop covering both phases + enum { LOCFIX_DEST_CONTACT = 0, LOCFIX_DEST_CHANNEL = 1 }; // CONTACT covers DM and room alike (both reply via sendMessage) + struct PendingLocFix { + bool active; + bool gps_was_on; // restore to this when done, not unconditionally "off" + uint32_t deadline_ms; + uint32_t averaging_until_ms; // 0 while still acquiring; set once the sat threshold is first met + double sum_lat, sum_lon; + int sample_count; + uint8_t dest_type; + uint8_t pub_key[PUB_KEY_SIZE]; // dest_type == LOCFIX_DEST_CONTACT + uint8_t channel_idx; // dest_type == LOCFIX_DEST_CHANNEL + }; + PendingLocFix _loc_fix; + bool _locfix_requested; // transient: set by botCommandReply() when "!gps fix" was + // seen this scan, cleared by the tryBot*Command() wrapper + TransportKey send_scope; uint8_t cmd_frame[MAX_FRAME_SIZE + 1]; diff --git a/examples/companion_radio/MyMeshBot.h b/examples/companion_radio/MyMeshBot.h index c1b09545..4fdf6f3e 100644 --- a/examples/companion_radio/MyMeshBot.h +++ b/examples/companion_radio/MyMeshBot.h @@ -270,9 +270,21 @@ bool MyMesh::botCommandReply(const char* cmd, const char* arg, bool actions_allo // not by the templating/expandMsg mechanism the queries share. if (actions_allowed) { if (!strcmp(cmd, "gps")) { + // "fix" -- single-shot: turn GPS on (if not already), wait for a + // stabilised position, send it, then restore GPS to whatever it was + // before. This command can't reply synchronously like every other one + // here (a fix takes seconds-to-minutes), so it acks immediately and + // sets _locfix_requested; the tryBot*Command() wrapper that called us + // knows the destination and starts the actual wait (see MyMesh.h). + if (!strcmp(arg, "fix")) { + if (_loc_fix.active) { snprintf(out, out_len, "GPS: fix already pending"); return true; } + _locfix_requested = true; + snprintf(out, out_len, "GPS: acquiring fix..."); + return true; + } bool on = !strcmp(arg, "on"); // arg was lowercased while parsing (see botScanCommands) bool off = !strcmp(arg, "off"); - if (!on && !off) { snprintf(out, out_len, "gps: on|off?"); return true; } + if (!on && !off) { snprintf(out, out_len, "gps: on|off|fix?"); return true; } if (_ui) _ui->botSetGPS(on); snprintf(out, out_len, "GPS: %s", on ? "on" : "off"); return true; @@ -347,6 +359,7 @@ bool MyMesh::botCommandReply(const char* cmd, const char* arg, bool actions_allo // the read-only queries are always reachable once a target's Commands is on. int MyMesh::botScanCommands(const char* body, uint8_t hops, uint32_t ts, char* out, int out_len, const char* sender_name, bool actions_allowed) { + _locfix_requested = false; // set by a "!gps fix" token below; caller checks it after we return int oi = 0; int matched = 0; for (const char* p = body; *p; ) { @@ -405,7 +418,7 @@ bool MyMesh::tryBotCommand(const ContactInfo& from, const char* text, uint8_t ho uint32_t ts = getRTCClock()->getCurrentTime(); char out[BOT_SCRATCH]; if (botScanCommands(text, hops, ts, out, sizeof(out), from.name, _prefs.bot_actions_dm != 0) == 0) return false; // no commands - if (!botDmAllowed(from.id.pub_key)) return true; // throttled + if (!botDmAllowed(from.id.pub_key)) { _locfix_requested = false; return true; } // throttled uint32_t expected_ack, est_timeout; if (sendMessage(from, ts, 0, out, expected_ack, est_timeout) != MSG_SEND_FAILED) { @@ -414,7 +427,9 @@ bool MyMesh::tryBotCommand(const ContactInfo& from, const char* text, uint8_t ho #ifdef DISPLAY_CLASS if (_ui) _ui->addDMMsg(from.id.pub_key, true, out); #endif + if (_locfix_requested) startLocFix(LOCFIX_DEST_CONTACT, from.id.pub_key, 0); } + _locfix_requested = false; return true; } @@ -434,11 +449,11 @@ bool MyMesh::tryBotChannelCommand(uint8_t channel_idx, const char* text, uint8_t uint32_t ts = getRTCClock()->getCurrentTime(); char out[BOT_SCRATCH]; if (botScanCommands(msg, hops, ts, out, sizeof(out), sender_name, _prefs.bot_actions_ch != 0) == 0) return false; // no commands - if (botInQuietHours()) return true; // quiet hours - if (millis() - _bot_last_ch_reply_ms <= BOT_REPLY_COOLDOWN_MS) return true; // throttled + if (botInQuietHours()) { _locfix_requested = false; return true; } // quiet hours + if (millis() - _bot_last_ch_reply_ms <= BOT_REPLY_COOLDOWN_MS) { _locfix_requested = false; return true; } // throttled ChannelDetails ch; - if (!getChannel(channel_idx, ch)) return true; + if (!getChannel(channel_idx, ch)) { _locfix_requested = false; return true; } int rlen = strlen(out); if (sendGroupMessage(ts, ch.channel, _prefs.node_name, out, rlen)) { _bot_last_ch_reply_ms = millis(); @@ -450,7 +465,9 @@ bool MyMesh::tryBotChannelCommand(uint8_t channel_idx, const char* text, uint8_t _ui->addChannelMsg(channel_idx, with_sender); } #endif + if (_locfix_requested) startLocFix(LOCFIX_DEST_CHANNEL, nullptr, channel_idx); } + _locfix_requested = false; return true; } @@ -469,8 +486,8 @@ bool MyMesh::tryBotRoomCommand(const ContactInfo& from, const uint8_t* sender_pr uint32_t ts = getRTCClock()->getCurrentTime(); char out[BOT_SCRATCH]; if (botScanCommands(text, hops, ts, out, sizeof(out), sender_name, _prefs.bot_actions_room != 0) == 0) return false; // no commands - if (botInQuietHours()) return true; // quiet hours - if (millis() - _bot_last_room_reply_ms <= BOT_REPLY_COOLDOWN_MS) return true; // throttled + if (botInQuietHours()) { _locfix_requested = false; return true; } // quiet hours + if (millis() - _bot_last_room_reply_ms <= BOT_REPLY_COOLDOWN_MS) { _locfix_requested = false; return true; } // throttled uint32_t expected_ack, est_timeout; if (sendMessage(from, ts, 0, out, expected_ack, est_timeout) != MSG_SEND_FAILED) { @@ -479,6 +496,107 @@ bool MyMesh::tryBotRoomCommand(const ContactInfo& from, const uint8_t* sender_pr #ifdef DISPLAY_CLASS if (_ui) _ui->addDMMsg(from.id.pub_key, true, out); #endif + if (_locfix_requested) startLocFix(LOCFIX_DEST_CONTACT, from.id.pub_key, 0); } + _locfix_requested = false; return true; } + +// Arms the "!gps fix" wait: turns GPS on (remembering whether it already was, +// so tickLocFix() restores rather than assumes "off"), and resets the +// averaging accumulator. pub_key is only read for LOCFIX_DEST_CONTACT (DM or +// room -- both reply via sendMessage, see sendLocFixResult()); pass nullptr +// for LOCFIX_DEST_CHANNEL. +void MyMesh::startLocFix(uint8_t dest_type, const uint8_t* pub_key, uint8_t channel_idx) { + _loc_fix.active = true; + _loc_fix.dest_type = dest_type; + if (pub_key) memcpy(_loc_fix.pub_key, pub_key, PUB_KEY_SIZE); + _loc_fix.channel_idx = channel_idx; + _loc_fix.sum_lat = 0.0; + _loc_fix.sum_lon = 0.0; + _loc_fix.sample_count = 0; + _loc_fix.averaging_until_ms = 0; + _loc_fix.deadline_ms = futureMillis(LOCFIX_TIMEOUT_MS); + _loc_fix.gps_was_on = (_prefs.gps_enabled != 0); + if (!_loc_fix.gps_was_on && _ui) _ui->botSetGPS(true); +} + +// !gps fix state machine, ticked every MyMesh::loop() while _loc_fix.active. +// Phase 1 (acquire): wait for a valid fix with at least LOCFIX_MIN_SATS +// satellites. Phase 2 (average): once that's first met, keep summing +// node_lat/node_lon for LOCFIX_AVERAGE_MS more -- only on ticks where the +// threshold still holds, so a momentary drop below LOCFIX_MIN_SATS just skips +// a sample instead of aborting the whole wait. A hard LOCFIX_TIMEOUT_MS +// deadline covers both phases; on timeout with zero samples collected the +// result is a plain failure (no stale-cache fallback -- a cached node_lat/lon +// from long before this request would be actively misleading here). +void MyMesh::tickLocFix() { + if (!_loc_fix.active) return; + + LocationProvider* loc = sensors.getLocationProvider(); + bool ready = loc && loc->isValid() && loc->satellitesCount() >= LOCFIX_MIN_SATS; + + if (_loc_fix.averaging_until_ms == 0 && ready) { + _loc_fix.averaging_until_ms = futureMillis(LOCFIX_AVERAGE_MS); + } + if (_loc_fix.averaging_until_ms != 0 && ready) { + _loc_fix.sum_lat += sensors.node_lat; + _loc_fix.sum_lon += sensors.node_lon; + _loc_fix.sample_count++; + } + + bool averaging_done = _loc_fix.averaging_until_ms != 0 && millisHasNowPassed(_loc_fix.averaging_until_ms); + bool timed_out = millisHasNowPassed(_loc_fix.deadline_ms); + if (!averaging_done && !timed_out) return; // still waiting + + char msg[BOT_SCRATCH]; + if (_loc_fix.sample_count > 0) { + double avg_lat = _loc_fix.sum_lat / _loc_fix.sample_count; + double avg_lon = _loc_fix.sum_lon / _loc_fix.sample_count; + snprintf(msg, sizeof(msg), averaging_done ? "loc: %.5f,%.5f" : "loc: %.5f,%.5f (partial fix)", avg_lat, avg_lon); + } else { + snprintf(msg, sizeof(msg), "GPS: no fix (timeout)"); + } + sendLocFixResult(msg); + + if (!_loc_fix.gps_was_on && _ui) _ui->botSetGPS(false); + _loc_fix.active = false; +} + +// Delivers tickLocFix()'s result to wherever the original "!gps fix" came +// from. Not gated by quiet hours/cooldown -- that gate already ran once, on +// the immediate ack (see the tryBot*Command() wrappers); this is the tail of +// an already-approved interaction, not a new proactive reply. Still updates +// the same cooldown timestamps/counters on success so subsequent trigger/ +// command replies stay correctly throttled afterward. +void MyMesh::sendLocFixResult(const char* msg) { + uint32_t ts = getRTCClock()->getCurrentTime(); + if (_loc_fix.dest_type == LOCFIX_DEST_CHANNEL) { + ChannelDetails ch; + if (!getChannel(_loc_fix.channel_idx, ch)) return; + int mlen = strlen(msg); + if (sendGroupMessage(ts, ch.channel, _prefs.node_name, msg, mlen)) { + _bot_last_ch_reply_ms = millis(); + _bot_reply_count++; +#ifdef DISPLAY_CLASS + if (_ui) { + char with_sender[240]; + snprintf(with_sender, sizeof(with_sender), "%s: %s", _prefs.node_name, msg); + _ui->addChannelMsg(_loc_fix.channel_idx, with_sender); + } +#endif + } + } else { // LOCFIX_DEST_CONTACT -- DM or room, both go through sendMessage + ContactInfo* c = lookupContactByPubKey(_loc_fix.pub_key, PUB_KEY_SIZE); + if (!c) return; // contact removed while we were waiting + uint32_t expected_ack, est_timeout; + if (sendMessage(*c, ts, 0, msg, expected_ack, est_timeout) != MSG_SEND_FAILED) { + if (c->type == ADV_TYPE_ROOM) _bot_last_room_reply_ms = millis(); + else botDmRecord(c->id.pub_key); + _bot_reply_count++; +#ifdef DISPLAY_CLASS + if (_ui) _ui->addDMMsg(c->id.pub_key, true, msg); +#endif + } + } +} diff --git a/release-notes.md b/release-notes.md index 3f27d1a9..c2129dec 100644 --- a/release-notes.md +++ b/release-notes.md @@ -4,6 +4,7 @@ - **Auto-Reply Bot is renamed to Remote Bot.** It's grown beyond auto-replies into remote device control (Actions, below), so the Tools screen entry and docs now say "Remote Bot" — same screen, same settings, nothing to reconfigure. - **Remote Bot gains Actions: `!buzz`, `!gps`, `!advert`.** A new **Actions** toggle (per target — Direct/Channel/Room, nested under each target's existing **Commands** toggle) enables three commands that change the device's own behaviour instead of just reporting on it: `!buzz [seconds]` sounds the buzzer as a find-me signal (default 5s, capped at 30s, sounds even if the buzzer is muted), `!gps on`/`!gps off` toggles GPS, and `!advert` sends an immediate advert. Off by default; combines with the existing query commands in one message the same way (`!batt !gps on` → `4.10V | GPS: on`). +- **`!gps fix` — single-shot GPS location, no need to leave GPS running.** Turns GPS on (if it wasn't already), waits for a stabilised fix (≥8 satellites, then averages for 10s), sends the position, and restores GPS to whatever state it was in before — up to a 90s timeout, after which it reports a partial fix (if it got at least some samples) or failure. Replies in two parts: an immediate "acquiring fix..." ack, then the actual position as a follow-up message once ready. Only one fix request can be in flight at a time (a second one gets "fix already pending"); same Actions toggle as `!buzz`/`!gps`/`!advert`. - **Bot Trigger fields accept multiple phrases.** Pack several trigger words into one Trigger field, comma-separated (`hi,hello there,yo`) — matching any one of them fires the reply, same as before for a single phrase. - **Remote Bot Actions gain `!gpio1`..`!gpio4`** (Wio Tracker L1 only — 4 otherwise-unused pins). Each pin is independently set to Off/Input/Output (GPIO1/GPIO2 also offer Analog) from a new **Tools › GPIO** screen; `!gpio1 on`/`!gpio1 off` drives an Output pin remotely, a bare `!gpio1` reports the current mode and reading (including a millivolt value in Analog mode). Gated by the same per-target Actions toggle as `!buzz`/`!gps`/`!advert`. - **Optional CardKB support (Wio Tracker L1, Grove I2C) — full keyboard-only navigation.** Plug an M5Stack CardKB into the Grove connector and it's auto-detected at boot — no setting to flip. Typing goes straight into the message/name field instead of navigating the on-screen keyboard grid; arrows and Esc work as expected everywhere else too (including inside the placeholder and accent popups). Enter acts like the physical centre button (advances the on-screen grid selection) rather than submitting, so **Fn+Enter** sends the message/confirms the field from anywhere, **Fn+``** opens that letter's accent popup directly (no arrow-hunting needed — handy for Polish/Czech/etc. diacritics), and **Fn+Tab** opens the Hold-Enter equivalent (Shift-lock, clear field) for whatever's selected; plain **Tab** still opens the Hold-Enter context menu everywhere else (message reply/navigate, Bot/Admin/Repeater menus, …).