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:
Jakub
2026-07-24 17:57:05 +02:00
co-authored by Claude Sonnet 5
parent 176094d32e
commit 220d46de8e
5 changed files with 162 additions and 8 deletions
+30
View File
@@ -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];