fix(bot,ui): defer bot actions past throttle, fix CardKB lock bypass, add !gps fix timeout arg

- Bot Actions (!buzz/!gps/!advert/!gpio1-4) ran their side effect
  immediately during botScanCommands(), before quiet-hours/cooldown/
  per-contact throttle were checked -- those gates only suppressed the
  reply text, not the actual buzz/GPS toggle/advert/pin write. botCommandReply()
  now only records what was requested; applyPendingBotActions() runs the
  deferred effects once a wrapper's throttle checks pass and the ack sent,
  mirroring the existing _locfix_requested pattern. resetPendingBotActions()
  clears everything on every throttled/aborted path.

- CardKB's Fn+<letter> accent-popup shortcut bypassed the locked-input gate
  (it called into KeyboardWidget directly instead of through the
  enqueueKey()/dequeue path every other key uses, so it wasn't discarded
  while _locked). Now checks _locked itself.

- Since a locked device now correctly ignores CardKB entirely, Fn+Esc
  (single press) is added as CardKB's own lock/unlock gesture -- otherwise
  a CardKB-only setup had no way to unlock. Esc rather than the adjacent
  Fn+Backspace, to avoid an accidental press.

- botScanCommands() now parses up to two arguments per command instead of
  one. Used by "!gps fix [seconds]" to override the default 90s timeout
  (clamped 15-300s) for a poor sky view where 90s isn't always enough to
  reach isLocFixReady()'s HDOP/satellite bar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-07-24 23:51:31 +02:00
parent 7b6ae8e2cb
commit 0a61b7ef15
7 changed files with 173 additions and 41 deletions

View File

@@ -16,13 +16,15 @@ Screen lock prevents accidental keypresses. While locked the display turns off a
**Hold Back** and press **Enter** three times within 3 seconds. The sequence works in both directions — the same combination locks and unlocks.
If the display is off when the sequence begins, it turns on automatically so the hint is visible. Each press in the sequence extends the display-on timer by 5 seconds.
On boards with an optional CardKB (I2C keyboard) attached, a single **Fn+Esc** does the same thing, in either direction — no repetition needed, since Fn+Esc is already a deliberate two-key combo. Esc rather than the adjacent Backspace, since Fn and Backspace sit right next to each other on CardKB's layout and would be too easy to hit by accident.
The hint popup at the bottom of the lock screen guides through the sequence:
If the display is off when the sequence begins, it turns on automatically so the hint is visible. Each press in the physical sequence extends the display-on timer by 5 seconds.
The hint popup at the bottom of the lock screen guides through the physical sequence:
| Step | Hint |
| -------------- | --------------------- |
| Not started | _Hold Back + 3×Enter_ |
| -------------- | ------------------------------------------------------------ |
| Not started | _Hold Back + 3×Enter_ (_Back+3xEnter/Fn+Esc_ with CardKB attached) |
| 1 press done | _Enter ×2 more…_ |
| 2 presses done | _Enter ×1 more…_ |

View File

@@ -451,7 +451,7 @@ 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 (HDOP ≤ 2.0, or ≥8 satellites on GPS hardware that doesn't report HDOP, 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`. |
| `!gps fix [seconds]` | Single-shot location: turns GPS on if it wasn't already, waits for a stabilised fix (HDOP ≤ 2.0, or ≥8 satellites on GPS hardware that doesn't report HDOP, 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 `seconds` later (default 90s, clamped to 15-300s) — raise it under poor sky view, where 90s isn't always enough to reach the HDOP/satellite bar. 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`/`!gps fix`/`!advert` are silently ignored (no reply, no effect) exactly like any other unrecognised command, and `!help`'s reply doesn't mention them.

View File

@@ -1552,6 +1552,11 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
_next_auto_advert_ms = 0;
_loc_fix.active = false;
_locfix_requested = false;
_locfix_requested_timeout_ms = LOCFIX_TIMEOUT_MS;
_bot_gps_action_pending = false;
_bot_buzz_action_secs = 0;
_bot_advert_action_pending = false;
for (int i = 0; i < 4; i++) _bot_gpio_action[i] = -1;
clearPendingReqs();
next_ack_idx = 0;
sign_data = NULL;

View File

@@ -328,7 +328,7 @@ private:
bool tryBotCommand(const ContactInfo& from, const char* text, uint8_t hops); // DM commands
bool tryBotChannelCommand(uint8_t channel_idx, const char* text, uint8_t hops); // channel commands
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
bool botCommandReply(const char* cmd, const char* arg, const char* arg2, 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
@@ -465,6 +465,26 @@ private:
PendingLocFix _loc_fix;
bool _locfix_requested; // transient: set by botCommandReply() when "!gps fix" was
// seen this scan, cleared by the tryBot*Command() wrapper
uint32_t _locfix_requested_timeout_ms; // "!gps fix [seconds]" override, see startLocFix()
// Deferred bot actions (!gps on|off, !buzz, !advert, !gpio1..4 on|off) --
// botCommandReply() only records what was requested; the actual hardware/
// radio side effect happens in applyPendingBotActions(), called by the
// tryBot*Command() wrappers only once quiet-hours/cooldown/per-contact
// throttle have passed and the ack actually sent. Otherwise those gates
// would only suppress the reply text while the action fired unconditionally
// on every matching message (e.g. !buzz still buzzing during quiet hours,
// or an unthrottled !advert flooding the mesh). Mirrors the _locfix_requested
// pattern above; resetPendingBotActions() is the throttled/aborted-path
// twin of applyPendingBotActions(), used wherever _locfix_requested used to
// be cleared alone.
bool _bot_gps_action_pending;
bool _bot_gps_action_on;
int _bot_buzz_action_secs; // 0 = no !buzz requested this scan
bool _bot_advert_action_pending;
int8_t _bot_gpio_action[4]; // per pin: -1 none requested, 0 off, 1 on
void applyPendingBotActions();
void resetPendingBotActions();
TransportKey send_scope;

View File

@@ -256,7 +256,7 @@ void MyMesh::tryBotReplyRoom(const ContactInfo& from, const uint8_t* sender_pref
// reply without needing a template); the rest expand a static template via
// expandMsg's placeholders, including {name}/{hops} if the sender wrote them
// into a custom reply — not used by any of the built-in templates below today.
bool MyMesh::botCommandReply(const char* cmd, const char* arg, bool actions_allowed,
bool MyMesh::botCommandReply(const char* cmd, const char* arg, const char* arg2, bool actions_allowed,
uint8_t hops, uint32_t ts, char* out, int out_len, const char* sender_name) {
if (!strcmp(cmd, "hops")) {
if (hops == 0) strncpy(out, "direct", out_len); // heard directly (0 repeaters)
@@ -270,47 +270,74 @@ 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
// "fix [seconds]" -- 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).
// Optional 2nd argument overrides the default LOCFIX_TIMEOUT_MS budget
// -- useful under poor sky view, where 90s isn't always enough to reach
// isLocFixReady()'s HDOP/satellite bar. Clamped to a sane range: floor
// covers LOCFIX_AVERAGE_MS (no point requesting less than one averaging
// window), ceiling keeps a forgotten/misfired request from parking GPS
// on indefinitely.
if (!strcmp(arg, "fix")) {
if (_loc_fix.active) { snprintf(out, out_len, "GPS: fix already pending"); return true; }
uint32_t timeout_ms = LOCFIX_TIMEOUT_MS;
if (arg2[0]) {
int secs = atoi(arg2);
if (secs < 15) secs = 15;
if (secs > 300) secs = 300;
timeout_ms = (uint32_t)secs * 1000;
}
_locfix_requested = true;
_locfix_requested_timeout_ms = timeout_ms;
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|fix?"); return true; }
if (_ui) _ui->botSetGPS(on);
// Deferred: applyPendingBotActions() actually flips the GPS state, once
// quiet-hours/cooldown/throttle have passed (see MyMesh.h).
_bot_gps_action_pending = true;
_bot_gps_action_on = on;
snprintf(out, out_len, "GPS: %s", on ? "on" : "off");
return true;
}
if (!strcmp(cmd, "buzz")) {
int secs = arg[0] ? atoi(arg) : 5; // UITask::botBuzz() clamps to [1,30]
if (_ui) _ui->botBuzz(secs);
int secs = arg[0] ? atoi(arg) : 5;
if (secs < 1) secs = 5;
if (secs > 30) secs = 30;
_bot_buzz_action_secs = secs; // deferred -- see applyPendingBotActions()
snprintf(out, out_len, "Buzzing");
return true;
}
if (!strcmp(cmd, "advert")) {
snprintf(out, out_len, advert() ? "Advert sent" : "Advert failed");
// Deferred -- can no longer report advert()'s own success/failure here
// (it doesn't run until applyPendingBotActions()), so the ack is just
// an acknowledgement, same spirit as !gps fix's "acquiring fix...".
_bot_advert_action_pending = true;
snprintf(out, out_len, "Advert requested");
return true;
}
// !gpio1..!gpio4 -- user-assignable pins (see UITask::botSetGPIO/
// botGetGPIO/botGetGPIOAnalog; no-op on boards without them). Bare (no
// arg) reports current mode+level (millivolts if in Analog mode, GPIO1/
// GPIO2 only); on/off only takes effect if that pin's Tools > GPIO mode
// is currently Output (Analog/Input/Off all reply "not output").
// is currently Output (Analog/Input/Off all reply "not output"). The
// Output check itself is read-only (botGetGPIO), so it's safe to do now
// for an accurate reply; only the actual pin write is deferred.
if (!strncmp(cmd, "gpio", 4) && cmd[4] >= '1' && cmd[4] <= '4' && cmd[5] == '\0') {
int idx = cmd[4] - '0';
if (arg[0]) {
bool on = !strcmp(arg, "on");
bool off = !strcmp(arg, "off");
if (!on && !off) { snprintf(out, out_len, "gpio%d: on|off?", idx); return true; }
if (_ui && _ui->botSetGPIO(idx, on)) {
bool is_out = false, val = false;
if (_ui && _ui->botGetGPIO(idx, is_out, val) && is_out) {
_bot_gpio_action[idx - 1] = on ? 1 : 0; // deferred -- see applyPendingBotActions()
snprintf(out, out_len, "gpio%d: %s", idx, on ? "on" : "off");
} else {
snprintf(out, out_len, "gpio%d: not output", idx);
@@ -359,7 +386,8 @@ 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
resetPendingBotActions(); // pending actions/flags below are set by tokens in this scan;
// caller applies or clears them once it knows if the reply sent
int oi = 0;
int matched = 0;
for (const char* p = body; *p; ) {
@@ -377,23 +405,32 @@ int MyMesh::botScanCommands(const char* body, uint8_t hops, uint32_t ts, char* o
while (*p && *p != ' ') p++; // consume any overflow of this token
if (!cmd[0]) continue;
// Optional argument: the next space-delimited token, unless it's itself
// the start of another command ("!"). E.g. "!gps on !batt" -- "on" is
// !gps's argument, "!batt" starts the next command. Query commands
// ignore arg; a stray word after e.g. "!ping" (no command reads it) is
// just consumed here instead of by the "not a command token" branch on
// the next iteration -- same net effect, no behaviour change.
// Up to two optional arguments: the next one or two space-delimited
// tokens, unless a token is itself the start of another command ("!").
// E.g. "!gps fix 120 !batt" -- "fix"/"120" are !gps's arguments, "!batt"
// starts the next command. Almost every command only reads arg1 (arg2 is
// always "" for them) -- a stray second word after e.g. "!gpio1 on foo"
// just gets consumed as arg2 and ignored, same net effect as before this
// supported two tokens. Only "!gps fix" currently reads arg2 (an optional
// timeout override, see botCommandReply).
while (*p == ' ') p++;
char arg[16] = "";
char arg1[16] = "", arg2[16] = "";
if (*p && *p != '!') {
int ai = 0;
while (*p && *p != ' ' && ai < (int)sizeof(arg) - 1) arg[ai++] = (char)tolower((uint8_t)*p++);
arg[ai] = '\0';
while (*p && *p != ' ' && ai < (int)sizeof(arg1) - 1) arg1[ai++] = (char)tolower((uint8_t)*p++);
arg1[ai] = '\0';
while (*p && *p != ' ') p++; // consume any overflow of this token
while (*p == ' ') p++;
if (*p && *p != '!') {
int bi = 0;
while (*p && *p != ' ' && bi < (int)sizeof(arg2) - 1) arg2[bi++] = (char)tolower((uint8_t)*p++);
arg2[bi] = '\0';
while (*p && *p != ' ') p++; // consume any overflow of this token
}
}
char seg[80];
if (!botCommandReply(cmd, arg, actions_allowed, hops, ts, seg, sizeof(seg), sender_name)) continue; // unknown
if (!botCommandReply(cmd, arg1, arg2, actions_allowed, hops, ts, seg, sizeof(seg), sender_name)) continue; // unknown
if (matched > 0 && oi + 3 < out_len) { // " | " separator
out[oi++] = ' '; out[oi++] = '|'; out[oi++] = ' ';
@@ -418,7 +455,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)) { _locfix_requested = false; return true; } // throttled
if (!botDmAllowed(from.id.pub_key)) { resetPendingBotActions(); return true; } // throttled
uint32_t expected_ack, est_timeout;
if (sendMessage(from, ts, 0, out, expected_ack, est_timeout) != MSG_SEND_FAILED) {
@@ -428,8 +465,9 @@ bool MyMesh::tryBotCommand(const ContactInfo& from, const char* text, uint8_t ho
if (_ui) _ui->addDMMsg(from.id.pub_key, true, out);
#endif
if (_locfix_requested) startLocFix(LOCFIX_DEST_CONTACT, from.id.pub_key, 0);
applyPendingBotActions();
}
_locfix_requested = false;
resetPendingBotActions();
return true;
}
@@ -449,11 +487,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()) { _locfix_requested = false; return true; } // quiet hours
if (millis() - _bot_last_ch_reply_ms <= BOT_REPLY_COOLDOWN_MS) { _locfix_requested = false; return true; } // throttled
if (botInQuietHours()) { resetPendingBotActions(); return true; } // quiet hours
if (millis() - _bot_last_ch_reply_ms <= BOT_REPLY_COOLDOWN_MS) { resetPendingBotActions(); return true; } // throttled
ChannelDetails ch;
if (!getChannel(channel_idx, ch)) { _locfix_requested = false; return true; }
if (!getChannel(channel_idx, ch)) { resetPendingBotActions(); return true; }
int rlen = strlen(out);
if (sendGroupMessage(ts, ch.channel, _prefs.node_name, out, rlen)) {
_bot_last_ch_reply_ms = millis();
@@ -466,8 +504,9 @@ bool MyMesh::tryBotChannelCommand(uint8_t channel_idx, const char* text, uint8_t
}
#endif
if (_locfix_requested) startLocFix(LOCFIX_DEST_CHANNEL, nullptr, channel_idx);
applyPendingBotActions();
}
_locfix_requested = false;
resetPendingBotActions();
return true;
}
@@ -486,8 +525,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()) { _locfix_requested = false; return true; } // quiet hours
if (millis() - _bot_last_room_reply_ms <= BOT_REPLY_COOLDOWN_MS) { _locfix_requested = false; return true; } // throttled
if (botInQuietHours()) { resetPendingBotActions(); return true; } // quiet hours
if (millis() - _bot_last_room_reply_ms <= BOT_REPLY_COOLDOWN_MS) { resetPendingBotActions(); return true; } // throttled
uint32_t expected_ack, est_timeout;
if (sendMessage(from, ts, 0, out, expected_ack, est_timeout) != MSG_SEND_FAILED) {
@@ -497,11 +536,41 @@ bool MyMesh::tryBotRoomCommand(const ContactInfo& from, const uint8_t* sender_pr
if (_ui) _ui->addDMMsg(from.id.pub_key, true, out);
#endif
if (_locfix_requested) startLocFix(LOCFIX_DEST_CONTACT, from.id.pub_key, 0);
applyPendingBotActions();
}
_locfix_requested = false;
resetPendingBotActions();
return true;
}
// Actually runs the state-changing bot actions botCommandReply() recorded
// this scan (!gps on|off, !buzz, !advert, !gpio1..4 on|off) -- called by the
// tryBot*Command() wrappers only after quiet-hours/cooldown/per-contact
// throttle passed and the ack itself sent, so those gates genuinely block the
// action, not just the reply text (see the _bot_*_action_* fields in
// MyMesh.h). !gps fix's startLocFix() is armed separately by the caller (it
// needs the destination, which this function doesn't have).
void MyMesh::applyPendingBotActions() {
if (_bot_gps_action_pending && _ui) _ui->botSetGPS(_bot_gps_action_on);
if (_bot_buzz_action_secs > 0 && _ui) _ui->botBuzz(_bot_buzz_action_secs);
if (_bot_advert_action_pending) advert();
for (int i = 0; i < 4; i++) {
if (_bot_gpio_action[i] >= 0 && _ui) _ui->botSetGPIO(i + 1, _bot_gpio_action[i] != 0);
}
}
// Clears every pending-action flag botCommandReply() may have set this scan,
// without running them -- used both at scan start and on every throttled/
// aborted path, so a suppressed reply never leaves a stale action armed for
// next time.
void MyMesh::resetPendingBotActions() {
_locfix_requested = false;
_locfix_requested_timeout_ms = LOCFIX_TIMEOUT_MS;
_bot_gps_action_pending = false;
_bot_buzz_action_secs = 0;
_bot_advert_action_pending = false;
for (int i = 0; i < 4; i++) _bot_gpio_action[i] = -1;
}
// 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
@@ -516,7 +585,7 @@ void MyMesh::startLocFix(uint8_t dest_type, const uint8_t* pub_key, uint8_t chan
_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.deadline_ms = futureMillis(_locfix_requested_timeout_ms);
_loc_fix.gps_was_on = (_prefs.gps_enabled != 0);
if (!_loc_fix.gps_was_on && _ui) _ui->botSetGPS(true);
}

View File

@@ -2130,11 +2130,40 @@ void UITask::pollCardKB() {
} else if (raw == 0x09) { // plain Tab -- same, but only outside the keyboard
if (_kb.isVisible()) return;
key = KEY_CONTEXT_MENU;
} else if (raw == 0x80) {
// Fn+Esc -- CardKB's lock/unlock gesture: a single press toggles _locked
// directly (unlike the physical Hold-Back+3xEnter combo's 3-press
// sequence), so it works to unlock a locked device too, where every
// other CardKB key is correctly discarded (see the Fn+<letter> branch
// below). Esc, not the adjacent Fn+Backspace, on purpose: Fn and
// Backspace sit right next to each other on CardKB's layout, making that
// combo too easy to hit by accident; Esc is on the opposite side of the
// keyboard. One press is enough -- Fn+Esc is already a deliberate
// two-key combo, so it doesn't need the physical combo's extra 3x
// repetition to guard against accidental triggering.
if (_display && !_display->isOn()) _display->turnOn();
_locked = !_locked;
if (_locked) {
_lock_wake_until = millis() + 2000;
} else {
if (_display && !_display->isOn()) _display->turnOn();
uint32_t aoff = autoOffMillis();
if (aoff > 0) _auto_off = millis() + aoff;
}
_next_refresh = 0;
return;
} else if (raw >= 0x80 && raw <= 0xAF) { // Fn+<letter> -- open its accent popup
char base = CARDKB_FN_BASE[raw - 0x80];
if (base == 0) return; // Fn+digit/symbol/arrow -- not used by this UI
char woke = checkDisplayOn(base);
if (woke) _kb.openAccentFor(base);
// Every other key here goes through enqueueKey(), so it's naturally eaten
// while locked (see the dequeue-time "if (!_locked && curr)" gate in
// loop()). This path calls into the keyboard widget directly instead, so
// it needs its own _locked check -- otherwise a stray Fn+letter (e.g. the
// keyboard was left open before the device locked, or brushed against in
// a pocket) could pop the accent popup while the screen is supposed to
// ignore all input.
if (woke && !_locked) _kb.openAccentFor(base);
return;
} else {
key = (char)raw; // plain Enter/arrows/backspace/ASCII -- byte-identical
@@ -2360,8 +2389,13 @@ void UITask::loop() {
}
// Hint popup at bottom (like alert style)
_display->setTextSize(1);
#if defined(ENV_PIN_SDA) && defined(ENV_PIN_SCL)
const char* hint = _lock_seq_count == 0 ? (_has_cardkb ? "Back+3xEnter/Fn+Esc" : "Hold Back + 3xEnter") :
_lock_seq_count == 1 ? "Enter x2 more..." : "Enter x1 more...";
#else
const char* hint = _lock_seq_count == 0 ? "Hold Back + 3xEnter" :
_lock_seq_count == 1 ? "Enter x2 more..." : "Enter x1 more...";
#endif
int p = 3;
int hy = _display->height() - lk_lh - p * 2;
int hw = _display->getTextWidth(hint);

View File

@@ -4,7 +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 (HDOP ≤ 2.0 — falls back to ≥8 satellites on GPS hardware that doesn't report HDOP — 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`.
- **`!gps fix [seconds]` — single-shot GPS location, no need to leave GPS running.** Turns GPS on (if it wasn't already), waits for a stabilised fix (HDOP ≤ 2.0 — falls back to ≥8 satellites on GPS hardware that doesn't report HDOP — then averages for 10s), sends the position, and restores GPS to whatever state it was in before — up to a 90s timeout by default (override with an optional argument, clamped to 15-300s, for a poor sky view where 90s isn't always enough), 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, …).
@@ -17,6 +17,8 @@
- **Changing a remote's admin password from Tools Admin System didn't update this device's own saved copy** — the very next login attempt to that node retried the password you'd just replaced, hitting the "stuck on Logging in…" case above. The confirmation the remote echoes back is now saved as the new on-device password to match.
- **A slow-to-arrive Admin login reply, after you'd already given up on it (Cancel or the new timeout above), could get delivered to whatever screen you'd since moved to and silently overwrite your saved password for that node with unrelated data.** Giving up on a login now also stops tracking it mesh-side, so a late reply is simply dropped instead of misrouted.
- **Opening Admin on a second, password-less node while an earlier room/repeater login elsewhere was still in flight could wrongly show you as logged in as admin on the new node** — a reply meant for the earlier login was accepted as this node's own since both merely looked like "a login is pending," without checking it actually named this node. Now checked.
- **Remote Bot Actions (`!buzz`/`!gps`/`!advert`/`!gpio1`..`!gpio4`) ran immediately on every matching message, before quiet hours/cooldown/per-contact throttle were checked** — those gates only suppressed the reply text, not the actual buzz/GPS toggle/advert/pin write, so e.g. `!buzz` still audibly buzzed during configured quiet hours, and repeating a command wasn't rate-limited at all. Actions now only run once those checks have passed.
- **CardKB's Fn+`<letter>` accent-popup shortcut could open the accent popup while the device was locked**, unlike every other key on the keyboard (all of which are correctly discarded while locked) — it went straight to the keyboard widget instead of through the normal locked-input gate. Now respects the lock like everything else. Since a locked device now correctly ignores CardKB entirely, **Fn+Esc** (single press) is added as CardKB's own lock/unlock gesture — otherwise a CardKB-only setup had no way to unlock at all.
---