mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-07-26 14:58:12 +00:00
feat(bot): !gps fix -- single-shot GPS location
Turns GPS on (if it wasn't already), waits for a stabilised fix (isValid() + >=8 satellites, then averages 10s of readings), 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 any samples) or plain failure. Replies in two parts since a fix takes seconds-to-minutes, unlike every other bot command here: an immediate "acquiring fix..." ack (through the existing synchronous command path), then the actual position as a separate follow-up message once ready, delivered to whichever destination (DM/room/channel) the request came from. Only one fix can be in flight at a time -- a second request while one is pending gets an immediate "already pending" instead of silently replacing it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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`.
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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+`<letter>`** 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, …).
|
||||
|
||||
Reference in New Issue
Block a user