From 5bfebc65596d699d4e374b26b7d7a9a13db5009a Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:30:03 +0200 Subject: [PATCH 01/13] feat(bot): Actions commands, multi-trigger, and user GPIO pins - Auto-Reply Bot gains Actions (!buzz/!gps/!advert) behind a new per-target toggle nested under Commands (bot_actions_dm/ch/room); off by default. - Bot Trigger fields accept comma-separated multiple phrases, matching any one fires the reply. - New user-assignable GPIO feature (Wio Tracker L1): !gpio1..!gpio4 bot commands plus a Tools > GPIO screen. Each pin cycles Off/Input/Output; GPIO1/GPIO2 (P0.02/P0.29, the nRF52840's AIN0/AIN5) also offer a read-only Analog mode via direct SAADC access. GPIO3/GPIO4 (P0.09/P0.10) are the chip's NFC1/NFC2 pins, repurposed as plain GPIO via a one-time UICR NFCPINS bit-clear in initVariant() (adapted from Adafruit's own nfc_to_gpio example) -- confirmed working on real hardware. - Fix: DM/room reply-prefix ("@[nick] ") stripping happened at the wrong layer, hiding the "To:" header on DM replies and leaking the raw prefix into room messages' list view; a related mismatch had the history scrollbar's sizing pass wrap room messages with the sender name still attached, disagreeing with the actual rendered text. Build-verified: WioTrackerL1_companion_solo_dual and WioTrackerL1Eink_companion_solo_dual both compile and link clean (sizeof(NodePrefs) confirmed 2720 via real build, not guessed). Co-Authored-By: Claude Sonnet 5 --- .../tools_screen/tools_screen.md | 30 ++- examples/companion_radio/AbstractUITask.h | 16 ++ examples/companion_radio/DataStore.cpp | 32 +++ examples/companion_radio/MyMesh.h | 4 +- examples/companion_radio/MyMeshBot.h | 125 +++++++-- examples/companion_radio/NodePrefs.h | 36 ++- examples/companion_radio/ui-new/BotScreen.h | 23 +- examples/companion_radio/ui-new/GpioScreen.h | 153 +++++++++++ .../companion_radio/ui-new/MessagesScreen.h | 38 ++- examples/companion_radio/ui-new/ToolsScreen.h | 11 +- examples/companion_radio/ui-new/UITask.cpp | 248 ++++++++++++++++-- examples/companion_radio/ui-new/UITask.h | 19 ++ release-notes.md | 14 + variants/wio-tracker-l1/variant.cpp | 24 ++ variants/wio-tracker-l1/variant.h | 11 + 15 files changed, 729 insertions(+), 55 deletions(-) create mode 100644 examples/companion_radio/ui-new/GpioScreen.h diff --git a/docs/solo_features/tools_screen/tools_screen.md b/docs/solo_features/tools_screen/tools_screen.md index 3e370969..1adbcac8 100644 --- a/docs/solo_features/tools_screen/tools_screen.md +++ b/docs/solo_features/tools_screen/tools_screen.md @@ -367,7 +367,7 @@ Melodies can be assigned in **Settings › Sound** (global default) or overridde -Automatically replies to incoming messages that contain a configured trigger word (case-insensitive, contains match). The bot has three independent targets — **DM**, a monitored **Channel**, and a monitored **Room** — each with its own trigger/reply pair. +Automatically replies to incoming messages that contain a configured trigger word (case-insensitive, contains match). Multiple trigger phrases can be packed into one Trigger field, comma-separated (e.g. `hi,hello there,yo`) — matching any one of them is enough; spaces around each phrase are trimmed, so `hi, hello there` and `hi,hello there` behave the same. The bot has three independent targets — **DM**, a monitored **Channel**, and a monitored **Room** — each with its own trigger/reply pair. The screen is a **circular tab carousel**, the same style as Tools › Nearby Nodes' filter tabs: **LEFT/RIGHT** switches between the **Channel** / **Room** / **Direct** / **Other** tabs (opens on Channel), **UP/DOWN** moves between the rows within the active tab, and **Enter** acts on the selected row (LEFT/RIGHT is reserved entirely for tab-switching, so every row's value is changed via Enter, not by cycling it in place). @@ -443,6 +443,20 @@ Several commands can be combined in one message — `!batt !time !hops` is answe Each target's Commands toggle is independent — e.g. answer `!ping` in DMs but stay quiet on a busy public channel. Channel and room replies are broadcast/posted to everyone there, so unlike DM commands they respect quiet hours and use their own shared cooldown. DM commands use the per-contact throttle and the **DM allow** scope above. +### Actions + +A separate **Actions** toggle, nested under Commands (Commands must be ON for Actions to do anything) — these commands change the device's own behaviour, not just report on it, so they default OFF and are kept independent of the read-only Commands toggle: + +| Command | Effect | +| ----------------- | -------------------------------------------------------------------- | +| `!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. | +| `!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. + +On boards with user GPIO (see **GPIO** under System tools below), the same Actions gate also covers `!gpio1`..`!gpio4`. + --- ## Diagnostics @@ -486,6 +500,20 @@ The counters make the repeater behaviour observable: **Forwarded** confirms the --- +## GPIO + +*Board-specific — currently Wio Tracker L1 only.* Four otherwise-unused pins (GPIO1-GPIO4) are exposed for general-purpose use. Each pin gets its own row showing its current mode; **Enter** (or LEFT/RIGHT) cycles it through **Off → Input → Output** and back to Off — GPIO1 and GPIO2 additionally step through **Analog** between Output and Off (GPIO3/GPIO4 have no ADC channel, so their cycle skips it). Switching a pin's mode shows a brief confirmation (`GPIO1: Input`, `GPIO1: Output`, …). + +Once a pin is set to **Output**, a second **State** row appears right underneath it — **Enter** toggles it **ON**/**OFF**, with its own confirmation. The direction (Mode row) and the on/off state (State row) are deliberately separate: changing one never surprises you by also changing the other. + +- **Input** shows its live level inline on the Mode row: `Input (High)` / `Input (Low)`, refreshed continuously. +- **Analog** (GPIO1/GPIO2 only) shows a live millivolt reading inline instead: e.g. `1650mV`. Has no State row — it's read-only. +- **Output** shows just `Output` on the Mode row; the actual ON/OFF value lives on the State row below it. + +The same 4 pins are reachable remotely via the Auto-Reply Bot's `!gpio1`..`!gpio4` commands (see **Actions** under Auto-Reply Bot below) — both paths read/write the same underlying state, so the Tools screen and the bot never disagree. A bare `!gpio1` reports the pin's current mode and reading (`gpio1: out on`, `gpio1: in on`, or `gpio1: 1650mV` in Analog mode); `!gpio1 on`/`!gpio1 off` only takes effect if that pin is currently set to Output here (otherwise the bot replies "not output", including when the pin is in Analog mode). + +--- + ## Repeater | OLED | E-Ink | diff --git a/examples/companion_radio/AbstractUITask.h b/examples/companion_radio/AbstractUITask.h index 1305b22f..0903c94d 100644 --- a/examples/companion_radio/AbstractUITask.h +++ b/examples/companion_radio/AbstractUITask.h @@ -54,6 +54,22 @@ public: // Text reply to an on-device-UI-triggered MyMesh::sendAdminCommand() arrived // (see AdminScreen). pub_key is the contact's key prefix (>=4 bytes valid). virtual void onAdminReply(const uint8_t* pub_key, const char* text) { (void)pub_key; (void)text; } + // Bot action commands (!gps/!buzz, see MyMesh::botCommandReply) -- device + // state changes triggered remotely, gated by the bot_actions_* prefs. + // Default no-op so UI variants that don't wire these up just ignore them. + virtual void botSetGPS(bool on) { (void)on; } + virtual void botBuzz(int seconds) { (void)seconds; } + // !gpio1..!gpio4 (idx 1-4). botSetGPIO returns false if the pin isn't + // currently configured as an Output (or the board has none) -- lets the + // bot reply distinguish "set" from "ignored". botGetGPIO returns false if + // the pin is Off/unsupported; on true, fills is_output (current direction) + // and value (live level). + virtual bool botSetGPIO(int idx, bool on) { (void)idx; (void)on; return false; } + virtual bool botGetGPIO(int idx, bool& is_output, bool& value) { (void)idx; (void)is_output; (void)value; return false; } + // Analog read for pins that support it (GPIO1/GPIO2 on Wio Tracker L1 -- + // the nRF52840's AIN0/AIN5). Returns false if the pin isn't in Analog mode + // or doesn't support it; on true, fills millivolts with the reading. + virtual bool botGetGPIOAnalog(int idx, int& millivolts) { (void)idx; (void)millivolts; return false; } // True only when a BLE central is actually bonded/connected. On a dual // (BLE+USB) interface hasConnection() is always true (USB counts), so use // this for BLE-specific UI like the pairing-PIN prompt. diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index c0958ff9..c0da5f34 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -526,6 +526,31 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no rd(&_prefs.keyboard_main_alphabet, sizeof(_prefs.keyboard_main_alphabet)); if (_prefs.keyboard_main_alphabet >= NodePrefs::KB_ALPHABET_COUNT) _prefs.keyboard_main_alphabet = 0; + // → 0xC0DE0021: append the per-target bot-actions toggles at the tail. A + // pre-0x21 file has neither byte here; clamp to 0 (off) -- these gate + // state-changing bot commands (!buzz/!gps/!advert), so an upgrader must + // opt in deliberately rather than get them silently enabled. + rd(&_prefs.bot_actions_dm, sizeof(_prefs.bot_actions_dm)); + if (_prefs.bot_actions_dm > 1) _prefs.bot_actions_dm = 0; + rd(&_prefs.bot_actions_ch, sizeof(_prefs.bot_actions_ch)); + if (_prefs.bot_actions_ch > 1) _prefs.bot_actions_ch = 0; + rd(&_prefs.bot_actions_room, sizeof(_prefs.bot_actions_room)); + if (_prefs.bot_actions_room > 1) _prefs.bot_actions_room = 0; + + // → 0xC0DE0022: user-assignable GPIO pin modes (0=Off 1=In 2=Out-low + // 3=Out-high 4=Analog). A pre-0x22 file has none of these bytes; clamp to + // 0 (off). gpio1/gpio2 (AIN0/AIN5) allow mode 4; gpio3/gpio4 have no ADC + // channel, so their clamp stops at 3 -- a stray 4 there (corrupt file, + // schema mismatch) falls back to Off rather than doing something undefined. + rd(&_prefs.gpio1_mode, sizeof(_prefs.gpio1_mode)); + if (_prefs.gpio1_mode > 4) _prefs.gpio1_mode = 0; + rd(&_prefs.gpio2_mode, sizeof(_prefs.gpio2_mode)); + if (_prefs.gpio2_mode > 4) _prefs.gpio2_mode = 0; + rd(&_prefs.gpio3_mode, sizeof(_prefs.gpio3_mode)); + if (_prefs.gpio3_mode > 3) _prefs.gpio3_mode = 0; + rd(&_prefs.gpio4_mode, sizeof(_prefs.gpio4_mode)); + if (_prefs.gpio4_mode > 3) _prefs.gpio4_mode = 0; + // Schema sentinel: bumped on layout changes. Mismatch means an older file // (or a different schema); rd() already zero-inits any fields not present, // so we just log it — next savePrefs writes the current sentinel. @@ -730,6 +755,13 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)&_prefs.bot_commands_ch, sizeof(_prefs.bot_commands_ch)); file.write((uint8_t *)&_prefs.bot_commands_room, sizeof(_prefs.bot_commands_room)); file.write((uint8_t *)&_prefs.keyboard_main_alphabet, sizeof(_prefs.keyboard_main_alphabet)); + file.write((uint8_t *)&_prefs.bot_actions_dm, sizeof(_prefs.bot_actions_dm)); + file.write((uint8_t *)&_prefs.bot_actions_ch, sizeof(_prefs.bot_actions_ch)); + file.write((uint8_t *)&_prefs.bot_actions_room, sizeof(_prefs.bot_actions_room)); + file.write((uint8_t *)&_prefs.gpio1_mode, sizeof(_prefs.gpio1_mode)); + file.write((uint8_t *)&_prefs.gpio2_mode, sizeof(_prefs.gpio2_mode)); + file.write((uint8_t *)&_prefs.gpio3_mode, sizeof(_prefs.gpio3_mode)); + file.write((uint8_t *)&_prefs.gpio4_mode, sizeof(_prefs.gpio4_mode)); // Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL. Its write is // the one we check: once the flash fills, writes return 0, so a good diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 5b37e55f..cfbc6e52 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -316,8 +316,8 @@ 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, 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); // scan "!word"s → combined reply, returns count + 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 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? diff --git a/examples/companion_radio/MyMeshBot.h b/examples/companion_radio/MyMeshBot.h index 5ea4ebc8..c1b09545 100644 --- a/examples/companion_radio/MyMeshBot.h +++ b/examples/companion_radio/MyMeshBot.h @@ -11,9 +11,11 @@ static const unsigned long BOT_REPLY_COOLDOWN_MS = 10000UL; // incoming message (MAX_TEXT_LEN ~160) plus the 140-byte reply templates. static const int BOT_SCRATCH = 200; -// Case-insensitive (ASCII) substring test: does `body` contain `trigger`? A lone -// "*" trigger means "match everything" (away / reply-to-all mode); honoured only -// where allow_wildcard is set. DM, channel and room each pass their own trigger string. +// Case-insensitive (ASCII) substring test: does `body` contain any one of +// `trigger`'s comma-separated phrases? A lone "*" trigger (the whole field, +// not one comma-separated phrase) means "match everything" (away / reply-to- +// all mode); honoured only where allow_wildcard is set. DM, channel and room +// each pass their own trigger string. bool MyMesh::botTriggerMatches(const char* trigger, const char* body, bool allow_wildcard) const { if (!trigger[0]) return false; // empty trigger would match everything if (trigger[0] == '*' && trigger[1] == '\0') @@ -25,12 +27,28 @@ bool MyMesh::botTriggerMatches(const char* trigger, const char* body, bool allow for (size_t i = 0; i < n; i++) low[i] = (char)tolower((uint8_t)body[i]); low[n] = '\0'; - char low_trigger[64]; - size_t tlen = strnlen(trigger, sizeof(low_trigger) - 1); - for (size_t i = 0; i < tlen; i++) low_trigger[i] = (char)tolower((uint8_t)trigger[i]); - low_trigger[tlen] = '\0'; + // Multiple phrases can be packed into one Trigger field, comma-separated + // ("hi,hello there,yo") -- any single phrase matching is enough. Each + // phrase is trimmed of surrounding spaces so "foo, bar" and "foo,bar" + // behave the same; empty phrases (a stray/trailing comma) are skipped. + for (const char* p = trigger; *p; ) { + while (*p == ' ' || *p == ',') p++; // skip separators / leading space + if (!*p) break; + const char* start = p; + while (*p && *p != ',') p++; // phrase runs to the next comma (or end) + const char* end = p; + while (end > start && end[-1] == ' ') end--; // trim trailing space + size_t plen = (size_t)(end - start); + if (plen == 0) continue; - return strstr(low, low_trigger) != NULL; + char low_trigger[64]; + if (plen >= sizeof(low_trigger)) plen = sizeof(low_trigger) - 1; + for (size_t i = 0; i < plen; i++) low_trigger[i] = (char)tolower((uint8_t)start[i]); + low_trigger[plen] = '\0'; + + if (strstr(low, low_trigger)) return true; + } + return false; } // DM allow-list gate: when bot_dm_scope is favourites-only, ignore triggers/ @@ -238,13 +256,68 @@ 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, uint8_t hops, uint32_t ts, char* out, int out_len, const char* sender_name) { +bool MyMesh::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) { if (!strcmp(cmd, "hops")) { if (hops == 0) strncpy(out, "direct", out_len); // heard directly (0 repeaters) else snprintf(out, out_len, "%u hops", (unsigned)hops); out[out_len - 1] = '\0'; return true; } + + // Action commands change device state, unlike every query below — gated by + // this target's own bot_actions_* toggle (nested under Commands being on), + // not by the templating/expandMsg mechanism the queries share. + if (actions_allowed) { + if (!strcmp(cmd, "gps")) { + 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 (_ui) _ui->botSetGPS(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); + snprintf(out, out_len, "Buzzing"); + return true; + } + if (!strcmp(cmd, "advert")) { + snprintf(out, out_len, advert() ? "Advert sent" : "Advert failed"); + 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"). + 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)) { + snprintf(out, out_len, "gpio%d: %s", idx, on ? "on" : "off"); + } else { + snprintf(out, out_len, "gpio%d: not output", idx); + } + return true; + } + int mv = 0; + bool is_out = false, val = false; + if (_ui && _ui->botGetGPIOAnalog(idx, mv)) { + snprintf(out, out_len, "gpio%d: %dmV", idx, mv); + } else if (_ui && _ui->botGetGPIO(idx, is_out, val)) { + snprintf(out, out_len, "gpio%d: %s %s", idx, is_out ? "out" : "in", val ? "on" : "off"); + } else { + snprintf(out, out_len, "gpio%d: off", idx); + } + return true; + } + } + const char* tmpl = nullptr; if (!strcmp(cmd, "ping")) tmpl = "pong"; else if (!strcmp(cmd, "batt")) tmpl = "{batt}"; @@ -252,7 +325,9 @@ bool MyMesh::botCommandReply(const char* cmd, uint8_t hops, uint32_t ts, char* o else if (!strcmp(cmd, "time")) tmpl = "{time}"; else if (!strcmp(cmd, "temp")) tmpl = "{temp}"; else if (!strcmp(cmd, "status")) tmpl = "batt {batt} loc {loc} at {time}"; - else if (!strcmp(cmd, "help")) tmpl = "cmds: !ping !batt !loc !time !temp !hops !status"; + else if (!strcmp(cmd, "help")) tmpl = actions_allowed + ? "cmds: !ping !batt !loc !time !temp !hops !status !gps !buzz !advert !gpio1-4" + : "cmds: !ping !batt !loc !time !temp !hops !status"; else return false; // unknown — let the trigger bot try expandMsg(tmpl, out, out_len, @@ -268,7 +343,10 @@ bool MyMesh::botCommandReply(const char* cmd, uint8_t hops, uint32_t ts, char* o // Scans body for any number of space-separated "!word" tokens, building a single // combined reply (segments joined by " | ") into out. Returns how many commands // were recognised. Shared by the DM, channel and room command paths. -int MyMesh::botScanCommands(const char* body, uint8_t hops, uint32_t ts, char* out, int out_len, const char* sender_name) { +// actions_allowed gates the state-changing commands (!gps/!buzz/!advert) -- +// 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) { int oi = 0; int matched = 0; for (const char* p = body; *p; ) { @@ -286,8 +364,23 @@ 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; - char seg[64]; - if (!botCommandReply(cmd, hops, ts, seg, sizeof(seg), sender_name)) continue; // unknown + // 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. + while (*p == ' ') p++; + char arg[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 != ' ') 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 (matched > 0 && oi + 3 < out_len) { // " | " separator out[oi++] = ' '; out[oi++] = '|'; out[oi++] = ' '; @@ -311,7 +404,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) == 0) return false; // no commands + 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 uint32_t expected_ack, est_timeout; @@ -340,7 +433,7 @@ 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) == 0) return false; // no commands + 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 @@ -375,7 +468,7 @@ 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) == 0) return false; // no commands + 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 diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 8ee93e30..f868d0e7 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -399,6 +399,29 @@ struct NodePrefs { // persisted to file // main behaviour for upgraders. uint8_t keyboard_main_alphabet; + // Per-target toggle for bot *action* commands (!buzz/!gps/!advert) -- + // separate from bot_commands_ch/bot_commands_room above, which only gate + // the read-only query commands (!ping/!batt/...). Nested under that + // Commands toggle (Commands=off means neither queries nor actions run for + // that target); Actions=on additionally lets the state-changing commands + // through. Default 0 (off) -- these change device behaviour remotely, so + // upgraders don't get them silently enabled. + uint8_t bot_actions_dm; + uint8_t bot_actions_ch; + uint8_t bot_actions_room; + + // User-assignable GPIO pins (board-specific, see PIN_GPIO1..4 in the + // variant header -- currently Wio Tracker L1 only). One byte per pin packs + // direction + output level (+ analog, gpio1/2 only) into one field: + // 0=Off 1=Input 2=Output(low) 3=Output(high) 4=Analog. Mode 4 is only + // meaningful for gpio1/gpio2 (the nRF52840's AIN0/AIN5 -- gpio3/gpio4 have + // no ADC channel), so those two fields are clamped to 0-3 on load, not 0-4 + // (see DataStore::loadPrefsInt). Default 0 (off/disconnected) on upgrade. + uint8_t gpio1_mode; + uint8_t gpio2_mode; + uint8_t gpio3_mode; + uint8_t gpio4_mode; + // 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; @@ -461,7 +484,7 @@ struct NodePrefs { // persisted to file // 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 = 0xC0DE0020; + static const uint32_t SCHEMA_SENTINEL = 0xC0DE0022; // 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 @@ -558,10 +581,13 @@ struct NodePrefs { // persisted to file // 3. clamp it on load (an upgrader's file lacks it → stray bytes) // 4. bump SCHEMA_SENTINEL's low byte // (Padding can also shift sizeof; a "false" trip just means re-check + rebump.) -// keyboard_main_alphabet (added alongside this bump) landed in existing tail -// padding -- confirmed via a real build's sizeof() -- so the size is -// unchanged from before that field existed. -static_assert(sizeof(NodePrefs) == 2712, +// keyboard_main_alphabet (added in the prior bump) landed in existing tail +// padding -- confirmed via a real build's sizeof() -- so that bump left the +// size unchanged. bot_actions_dm/ch/room and gpio1..4_mode (the last two +// bumps, 7 more uint8_t total) added 8 bytes, not 7 -- one byte of tail +// padding got consumed along the way. 2720 confirmed via a real +// WioTrackerL1Eink_companion_solo_dual build. +static_assert(sizeof(NodePrefs) == 2720, "NodePrefs layout changed — sync DataStore save/load + clamp, bump " "SCHEMA_SENTINEL, then update this size (see steps above)."); diff --git a/examples/companion_radio/ui-new/BotScreen.h b/examples/companion_radio/ui-new/BotScreen.h index 0a0eb791..4084080c 100644 --- a/examples/companion_radio/ui-new/BotScreen.h +++ b/examples/companion_radio/ui-new/BotScreen.h @@ -16,9 +16,9 @@ class BotScreen : public UIScreen { enum Tab : uint8_t { TAB_CHANNEL, TAB_ROOM, TAB_DIRECT, TAB_OTHER, TAB_COUNT }; static const char* TAB_LABELS[TAB_COUNT]; - enum Kind : uint8_t { ENABLE_DM, DM_SCOPE, COMMANDS_DM, TRIGGER_DM, REPLY_DM, - ENABLE_CH, CHANNEL, COMMANDS_CH, TRIGGER_CH, REPLY_CH, - ENABLE_ROOM, ROOM, COMMANDS_ROOM, TRIGGER_ROOM, REPLY_ROOM, + enum Kind : uint8_t { ENABLE_DM, DM_SCOPE, COMMANDS_DM, ACTIONS_DM, TRIGGER_DM, REPLY_DM, + ENABLE_CH, CHANNEL, COMMANDS_CH, ACTIONS_CH, TRIGGER_CH, REPLY_CH, + ENABLE_ROOM, ROOM, COMMANDS_ROOM, ACTIONS_ROOM, TRIGGER_ROOM, REPLY_ROOM, QUIET_FROM, QUIET_TO }; struct Row { Kind kind; const char* label; }; @@ -33,6 +33,7 @@ class BotScreen : public UIScreen { { ENABLE_DM, "Enable" }, { DM_SCOPE, "DM allow" }, { COMMANDS_DM, "Commands" }, + { ACTIONS_DM, "Actions" }, { TRIGGER_DM, "Trigger" }, { REPLY_DM, "Reply" }, }; @@ -40,6 +41,7 @@ class BotScreen : public UIScreen { { ENABLE_CH, "Enable" }, { CHANNEL, "Channel" }, { COMMANDS_CH, "Commands" }, + { ACTIONS_CH, "Actions" }, { TRIGGER_CH, "Trigger" }, { REPLY_CH, "Reply" }, }; @@ -47,6 +49,7 @@ class BotScreen : public UIScreen { { ENABLE_ROOM, "Enable" }, { ROOM, "Room" }, { COMMANDS_ROOM, "Commands" }, + { ACTIONS_ROOM, "Actions" }, { TRIGGER_ROOM, "Trigger" }, { REPLY_ROOM, "Reply" }, }; @@ -218,6 +221,15 @@ public: case COMMANDS_ROOM: display.print(_prefs->bot_commands_room ? "ON" : "OFF"); break; + case ACTIONS_DM: + display.print(_prefs->bot_actions_dm ? "ON" : "OFF"); + break; + case ACTIONS_CH: + display.print(_prefs->bot_actions_ch ? "ON" : "OFF"); + break; + case ACTIONS_ROOM: + display.print(_prefs->bot_actions_room ? "ON" : "OFF"); + break; case QUIET_FROM: case QUIET_TO: { bool off = (_prefs->bot_quiet_start == _prefs->bot_quiet_end); @@ -300,6 +312,9 @@ public: case COMMANDS_DM: _prefs->bot_commands_enabled ^= 1; _dirty = true; return true; case COMMANDS_CH: _prefs->bot_commands_ch ^= 1; _dirty = true; return true; case COMMANDS_ROOM: _prefs->bot_commands_room ^= 1; _dirty = true; return true; + case ACTIONS_DM: _prefs->bot_actions_dm ^= 1; _dirty = true; return true; + case ACTIONS_CH: _prefs->bot_actions_ch ^= 1; _dirty = true; return true; + case ACTIONS_ROOM: _prefs->bot_actions_room ^= 1; _dirty = true; return true; case CHANNEL: // Full browsable channel picker (same experience as Live Share's "To" // row); which channel is *active* is now the separate Enable row. @@ -368,4 +383,4 @@ private: }; const char* BotScreen::TAB_LABELS[BotScreen::TAB_COUNT] = { "Channel", "Room", "Direct", "Other" }; -const int BotScreen::ROWS_PER_TAB[BotScreen::TAB_COUNT] = { 5, 5, 5, 2 }; +const int BotScreen::ROWS_PER_TAB[BotScreen::TAB_COUNT] = { 6, 6, 6, 2 }; diff --git a/examples/companion_radio/ui-new/GpioScreen.h b/examples/companion_radio/ui-new/GpioScreen.h new file mode 100644 index 00000000..3918ccf9 --- /dev/null +++ b/examples/companion_radio/ui-new/GpioScreen.h @@ -0,0 +1,153 @@ +#pragma once +// Tools › GPIO — manual control for the 4 user-assignable pins (see +// PIN_GPIO1..4 in the board's variant header). Board-specific: this whole +// file only compiles where PIN_GPIO1 is defined (currently Wio Tracker L1). +// Mirrors the bot's !gpio1..!gpio4 commands (MyMeshBot.h) — both paths go +// through UITask::setGpioMode()/botGetGPIO()/botGetGPIOAnalog(), so UI and +// bot state never disagree. Only GPIO1/GPIO2 (the nRF52840's AIN0/AIN5) +// offer the Analog mode step in the cycle -- GPIO3/GPIO4 have no ADC +// channel (see UITask::gpioSupportsAnalog). +// Included by UITask.cpp. +#if defined(PIN_GPIO1) + +#include +#include +#include "icons.h" +#include "../NodePrefs.h" + +class GpioScreen : public UIScreen { + UITask* _task; + NodePrefs* _prefs; + int _sel; + int _scroll; + + static const int PIN_COUNT = 4; + enum Kind : uint8_t { ROW_MODE, ROW_STATE }; + struct Row { uint8_t pin; Kind kind; }; // pin is 0-based here, 1-based for UITask/bot calls + Row _items[PIN_COUNT * 2]; + int _item_count; + + // Direction (Off/Input/Output) and on/off state are separate concerns, so + // they're separate rows: a Mode row per pin, always shown, plus a State + // row right after it that only appears once that pin's Mode is Output — + // Input has nothing to toggle (its live level is read-only, shown inline + // on the Mode row instead). + void buildItems() { + _item_count = 0; + for (uint8_t i = 0; i < PIN_COUNT; i++) { + _items[_item_count++] = { i, ROW_MODE }; + uint8_t m = gpioModeOf(i); + if (m == 2 || m == 3) _items[_item_count++] = { i, ROW_STATE }; // Output only -- Analog has nothing to toggle + } + if (_sel >= _item_count) _sel = _item_count - 1; + if (_sel < 0) _sel = 0; + } + + uint8_t gpioModeOf(int idx) const { + if (!_prefs) return 0; + switch (idx) { + case 0: return _prefs->gpio1_mode; + case 1: return _prefs->gpio2_mode; + case 2: return _prefs->gpio3_mode; + default: return _prefs->gpio4_mode; + } + } + + void modeRowValue(int pin, char* buf, size_t n) const { + switch (gpioModeOf(pin)) { + case 1: { + bool is_out = false, val = false; + if (_task->botGetGPIO(pin + 1, is_out, val)) strncpy(buf, val ? "Input (High)" : "Input (Low)", n); + else strncpy(buf, "Input", n); + break; + } + case 2: + case 3: strncpy(buf, "Output", n); break; + case 4: { + int mv = 0; + if (_task->botGetGPIOAnalog(pin + 1, mv)) snprintf(buf, n, "%dmV", mv); + else strncpy(buf, "Analog", n); + break; + } + default: strncpy(buf, "Off", n); break; + } + buf[n - 1] = '\0'; + } + + // Off -> Input -> Output (starts OFF) -> [Analog, GPIO1/GPIO2 only] -> Off + // ... one press per step. Output's ON/OFF split lives on the State row + // below, not in this cycle; Analog is read-only so it has no State row. + void cycleMode(int pin) { + uint8_t mode = gpioModeOf(pin); + bool analog_ok = _task->gpioSupportsAnalog(pin + 1); + uint8_t next; + if (mode == 0) next = 1; + else if (mode == 1) next = 2; + else if (mode == 2 || mode == 3) next = analog_ok ? 4 : 0; + else next = 0; // was Analog -> back to Off + _task->setGpioMode(pin + 1, next); + const char* name = (next == 0) ? "Off" : (next == 1) ? "Input" : (next == 2) ? "Output" : "Analog"; + char msg[24]; + snprintf(msg, sizeof(msg), "GPIO%d: %s", pin + 1, name); + _task->showAlert(msg, 800); + buildItems(); + } + + void toggleState(int pin) { + bool on = (gpioModeOf(pin) == 3); + _task->setGpioMode(pin + 1, on ? 2 : 3); + char msg[24]; + snprintf(msg, sizeof(msg), "GPIO%d: %s", pin + 1, on ? "OFF" : "ON"); + _task->showAlert(msg, 800); + } + +public: + GpioScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs), _sel(0), _scroll(0), _item_count(1) {} + + void onShow() override { _sel = 0; _scroll = 0; buildItems(); } + + int render(DisplayDriver& display) override { + buildItems(); + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + display.drawCenteredHeader("GPIO"); + + bool any_live = false; + drawList(display, _item_count, _sel, _scroll, [&](int row, int y, bool sel, int reserve) { + const Row& it = _items[row]; + drawRowSelection(display, y, sel, reserve); + display.setCursor(2, y); + char val[16]; + if (it.kind == ROW_MODE) { + char lbl[8]; + snprintf(lbl, sizeof(lbl), "GPIO%d", it.pin + 1); + display.print(lbl); + modeRowValue(it.pin, val, sizeof(val)); + uint8_t m = gpioModeOf(it.pin); + if (m == 1 || m == 4) any_live = true; // Input / Analog need a live-refreshed reading + } else { + display.print(" State"); // indented: reads as GPIOn's sub-row + strncpy(val, gpioModeOf(it.pin) == 3 ? "ON" : "OFF", sizeof(val)); + } + display.drawTextRightAlign(display.width() - reserve - 2, y, val); + display.setColor(DisplayDriver::LIGHT); + }); + return any_live ? 500 : 2000; // faster refresh only while a row needs a live reading + } + + bool handleInput(char c) override { + if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { _task->gotoToolsScreen(); return true; } + if (c == KEY_UP) { _sel = (_sel > 0) ? _sel - 1 : _item_count - 1; return true; } + if (c == KEY_DOWN) { _sel = (_sel < _item_count - 1) ? _sel + 1 : 0; return true; } + if (!_prefs) return false; + if (c == KEY_ENTER || keyIsNext(c) || keyIsPrev(c)) { + const Row& it = _items[_sel]; + if (it.kind == ROW_MODE) cycleMode(it.pin); + else toggleState(it.pin); + return true; + } + return false; + } +}; + +#endif // PIN_GPIO1 diff --git a/examples/companion_radio/ui-new/MessagesScreen.h b/examples/companion_radio/ui-new/MessagesScreen.h index 3158bd90..c24c1058 100644 --- a/examples/companion_radio/ui-new/MessagesScreen.h +++ b/examples/companion_radio/ui-new/MessagesScreen.h @@ -136,7 +136,10 @@ class MessagesScreen : public UIScreen { struct HistScroll { bool need; int reserve; long total_px, scroll_px; int view_px; }; // `getBody(idx)` returns the body text for list item idx (the part that wraps), - // or nullptr to fall back to a fixed 2-line box. + // or nullptr to fall back to a fixed 2-line box. Must return exactly what the + // real per-item render pass wraps (sender split off, reply prefix stripped) -- + // this function doesn't reprocess it, so a mismatch here just means this sizing + // pass and the real render disagree on line count. template HistScroll computeHistScroll(DisplayDriver& display, bool portrait, int count, int scroll, int hist_start_y, int cby, int lh, GetBody getBody) { @@ -161,7 +164,7 @@ class MessagesScreen : public UIScreen { auto boxH = [&](int idx, int rsv) -> int { const char* body = getBody(idx); if (!body) return fixed_bh; - display.translateUTF8ToBlocks(s_wrap_trans, skipReplyPrefix(body), sizeof(s_wrap_trans)); + display.translateUTF8ToBlocks(s_wrap_trans, body, sizeof(s_wrap_trans)); int nl = FullscreenMsgView::wrapLines(display, s_wrap_trans, display.width() - 6 - rsv, s_wrap_lines, 8); return (1 + (nl > 0 ? nl : 1)) * lh + 1; }; @@ -191,9 +194,20 @@ class MessagesScreen : public UIScreen { // stored "Sender: text" (MyMesh::queueMessage); split that off so each line is // attributed to its guest. Outgoing → "Me"; plain DMs (or no separator) keep // the contact name and the text unchanged. + // + // Does NOT strip a leading "@[nick] " reply prefix — that's the caller's call, + // and it must be made exactly once: FullscreenMsgView::render() parses it + // itself (for the "To:" header), so callers feeding it must pass this + // function's result straight through; the compact list view has no such + // parsing of its own, so those callers must wrap the result in + // skipReplyPrefix(). Stripping it in here unconditionally used to double-strip + // the DM case (hiding FullscreenMsgView's "To:" header entirely) while never + // stripping the room case (the split happens after this used to run), which is + // why replies' addressee went undetected inconsistently between DM/room/list/ + // fullscreen. const char* dmDisplayParts(const DmHistEntry& e, bool is_room, const char* contact_name, char* sender_buf, int sender_cap) const { - const char* body = skipReplyPrefix(e.text); + const char* body = e.text; if (e.outgoing) { strncpy(sender_buf, "Me", sender_cap - 1); } else if (is_room) { @@ -1031,6 +1045,8 @@ public: if (ring_pos >= 0) { const DmHistEntry& e = _history.dmAtPos(ring_pos); char sender_buf[33]; + // No skipReplyPrefix() here -- _dm_fs.render() parses "@[nick] " itself + // (for the "To:" header); stripping it here first would hide it there. const char* body = dmDisplayParts(e, _sel_contact.type == ADV_TYPE_ROOM, filtered_name, sender_buf, sizeof(sender_buf)); const char* sender = sender_buf; @@ -1073,7 +1089,15 @@ public: hist_start_y, cby, lh, [&](int idx) -> const char* { int rp = _history.dmHistEntryForContact(_sel_contact.id.pub_key, idx); - return rp >= 0 ? _history.dmAtPos(rp).text : nullptr; + if (rp < 0) return nullptr; + // Must match the per-item body extraction below (dmDisplayParts + + // skipReplyPrefix) exactly, or this sizing pass and the real render + // pass disagree on wrapped line count for room posts -- previously + // this returned the raw "Sender: text" unsplit, wrapping the sender + // name in with the body and mis-sizing the box. + char tmp_sender[33]; + return skipReplyPrefix(dmDisplayParts(_history.dmAtPos(rp), is_room, filtered_name, + tmp_sender, sizeof(tmp_sender))); }); int reserve = hs.reserve; { @@ -1091,7 +1115,7 @@ public: int rp = _history.dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_scroll + ii); if (rp >= 0) { char hsb[33]; - const char* hbody = dmDisplayParts(_history.dmAtPos(rp), is_room, filtered_name, hsb, sizeof(hsb)); + const char* hbody = skipReplyPrefix(dmDisplayParts(_history.dmAtPos(rp), is_room, filtered_name, hsb, sizeof(hsb))); display.translateUTF8ToBlocks(s_wrap_trans, hbody, sizeof(s_wrap_trans)); int nl = FullscreenMsgView::wrapLines(display, s_wrap_trans, display.width() - 6 - reserve, s_wrap_lines, 8); bh = (1 + (nl > 0 ? nl : 1)) * lh + 1; @@ -1115,7 +1139,7 @@ public: const DmHistEntry& e = _history.dmAtPos(ring_pos); char sender_buf[33]; - const char* body = dmDisplayParts(e, is_room, filtered_name, sender_buf, sizeof(sender_buf)); + const char* body = skipReplyPrefix(dmDisplayParts(e, is_room, filtered_name, sender_buf, sizeof(sender_buf))); const char* sender = sender_buf; char age[6]; geo::fmtAgeShort(age, sizeof(age), now_ts, e.timestamp); @@ -1233,7 +1257,7 @@ public: if (rp < 0) return nullptr; const char* t = _history.chAtPos(rp).text; const char* s = strstr(t, ": "); - return s ? s + 2 : t; + return skipReplyPrefix(s ? s + 2 : t); }); int reserve = hs.reserve; { diff --git a/examples/companion_radio/ui-new/ToolsScreen.h b/examples/companion_radio/ui-new/ToolsScreen.h index f0595a38..c9ccc8b0 100644 --- a/examples/companion_radio/ui-new/ToolsScreen.h +++ b/examples/companion_radio/ui-new/ToolsScreen.h @@ -16,6 +16,9 @@ class ToolsScreen : public UIScreen { ACT_NEARBY, ACT_LIVESHARE, ACT_TRAIL, ACT_LOCATOR, ACT_COMPASS, ACT_BOT, ACT_AUTOADVERT, ACT_REPEATER, ACT_ADMIN, ACT_CLOCK, ACT_RINGTONE, ACT_DIAGNOSTICS +#if defined(PIN_GPIO1) + , ACT_GPIO +#endif }; struct Tool { const char* label; const MiniIcon* icon; Action action; }; struct Section { const char* name; const MiniIcon* icon; const Tool* tools; uint8_t count; }; @@ -53,6 +56,9 @@ class ToolsScreen : public UIScreen { case ACT_CLOCK: _task->gotoClockTools(); break; case ACT_RINGTONE: _task->gotoRingtoneEditor(); break; case ACT_DIAGNOSTICS: _task->gotoDiagnosticsScreen(); break; +#if defined(PIN_GPIO1) + case ACT_GPIO: _task->gotoGpioScreen(); break; +#endif } } @@ -128,9 +134,12 @@ const ToolsScreen::Tool ToolsScreen::SYSTEM_TOOLS[] = { { "Clock Tools", &ICON_ALARM, ACT_CLOCK }, { "Ringtone Editor", &ICON_NOTE, ACT_RINGTONE }, { "Diagnostics", &ICON_CHART, ACT_DIAGNOSTICS }, +#if defined(PIN_GPIO1) + { "GPIO", &ICON_GEAR, ACT_GPIO }, +#endif }; const ToolsScreen::Section ToolsScreen::SECTIONS[] = { { "Location", &ICON_MAP_CONTACT, LOCATION_TOOLS, 5 }, { "Comms", &ICON_ADVERT, COMMS_TOOLS, 4 }, - { "System", &ICON_GEAR, SYSTEM_TOOLS, 3 }, + { "System", &ICON_GEAR, SYSTEM_TOOLS, (uint8_t)(sizeof(SYSTEM_TOOLS)/sizeof(SYSTEM_TOOLS[0])) }, }; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 06cfe2e4..af658f1b 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -146,6 +146,9 @@ static const int QUICK_MSGS_MAX = 10; #include "CompassScreen.h" #include "DiagnosticsScreen.h" #include "RepeaterScreen.h" +#if defined(PIN_GPIO1) +#include "GpioScreen.h" +#endif #include "ToolsScreen.h" #include "ClockToolsScreen.h" // Alarm / Timer / Stopwatch (Clock page › Enter) @@ -1463,10 +1466,14 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no diag_screen = new DiagnosticsScreen(this); repeater_screen = new RepeaterScreen(this); clock_tools = new ClockToolsScreen(this, node_prefs); +#if defined(PIN_GPIO1) + gpio_screen = new GpioScreen(this, node_prefs); +#endif applyBrightness(); applyFont(); applyRotation(); applyFullRefreshInterval(); + applyAllGpioModes(); // restore persisted pin modes to hardware before any UI/bot use setCurrScreen(splash); } @@ -1491,6 +1498,11 @@ void UITask::gotoCompassScreen() { setCurrScreen(compass_screen); } void UITask::gotoDiagnosticsScreen() { setCurrScreen(diag_screen); } void UITask::gotoRepeaterScreen() { setCurrScreen(repeater_screen); } void UITask::gotoClockTools() { setCurrScreen(clock_tools); } +void UITask::gotoGpioScreen() { +#if defined(PIN_GPIO1) + setCurrScreen(gpio_screen); +#endif +} void UITask::gotoLiveShareScreen() { setCurrScreen(live_share_screen); } // ── Clock tools engine (alarm / countdown / ring) ─────────────────────────── @@ -2893,29 +2905,227 @@ bool UITask::hasGPS() { } void UITask::toggleGPS() { - if (_sensors != NULL) { - // toggle GPS on/off - int num = _sensors->getNumSettings(); - for (int i = 0; i < num; i++) { - if (strcmp(_sensors->getSettingName(i), "gps") == 0) { - if (strcmp(_sensors->getSettingValue(i), "1") == 0) { - _sensors->setSettingValue("gps", "0"); - _node_prefs->gps_enabled = 0; - notify(UIEventType::ack); - } else { - _sensors->setSettingValue("gps", "1"); - _node_prefs->gps_enabled = 1; - notify(UIEventType::ack); - } - the_mesh.savePrefs(); - showAlert(_node_prefs->gps_enabled ? "GPS: Enabled" : "GPS: Disabled", 800); - _next_refresh = 0; - break; - } + if (_node_prefs) applyGpsState(_node_prefs->gps_enabled == 0); +} + +// Sets GPS to an absolute state (vs. toggleGPS()'s flip) -- shared by the +// Home-page manual toggle and the bot's !gps on/off command, which needs to +// set a specific state rather than flip whatever it currently is. +void UITask::applyGpsState(bool on) { + if (_sensors == NULL) return; + int num = _sensors->getNumSettings(); + for (int i = 0; i < num; i++) { + if (strcmp(_sensors->getSettingName(i), "gps") == 0) { + _sensors->setSettingValue("gps", on ? "1" : "0"); + _node_prefs->gps_enabled = on ? 1 : 0; + notify(UIEventType::ack); + the_mesh.savePrefs(); + showAlert(_node_prefs->gps_enabled ? "GPS: Enabled" : "GPS: Disabled", 800); + _next_refresh = 0; + break; } } } +void UITask::botSetGPS(bool on) { + applyGpsState(on); +} + +// Bot !buzz [seconds] -- a find-me signal, so it deliberately uses +// playForced() (bypasses the buzzer_quiet mute) rather than play(): a +// find-me beep that respects mute defeats its own purpose. Builds a simple +// repeating beep/rest RTTTL string sized to the requested duration into the +// persistent _bot_buzz_buf -- the nRF52 RTTTL player keeps a raw pointer into +// whatever buffer it's given and reads from it across loop() calls for the +// whole playback (same constraint as _notif_mel_buf), so this can't be a +// local/stack buffer. +void UITask::botBuzz(int seconds) { +#if defined(PIN_BUZZER) + if (seconds < 1) seconds = 5; + if (seconds > 30) seconds = 30; + int pairs = seconds * 2; // b=120: an "8c,8p," pair is 250+250 = 500ms + int n = snprintf(_bot_buzz_buf, sizeof(_bot_buzz_buf), "Buzz:b=120:"); + for (int i = 0; i < pairs && n < (int)sizeof(_bot_buzz_buf) - 7; i++) + n += snprintf(_bot_buzz_buf + n, sizeof(_bot_buzz_buf) - n, "8c,8p,"); + buzzer.playForced(_bot_buzz_buf); +#endif +} + +#if defined(PIN_GPIO1) +static uint32_t gpioPin(int idx) { // idx 1..4 + static const uint32_t pins[4] = { PIN_GPIO1, PIN_GPIO2, PIN_GPIO3, PIN_GPIO4 }; + return (idx >= 1 && idx <= 4) ? pins[idx - 1] : 0xFFFFFFFF; +} + +static uint8_t* gpioModeField(NodePrefs* p, int idx) { // idx 1..4 + switch (idx) { + case 1: return &p->gpio1_mode; + case 2: return &p->gpio2_mode; + case 3: return &p->gpio3_mode; + case 4: return &p->gpio4_mode; + default: return NULL; + } +} + +// Push a saved mode value to the actual pin hardware -- shared by +// setGpioMode() (live edits from the UI) and applyAllGpioModes() (boot +// restore), which differ only in whether the mode gets persisted. Mode 4 +// (Analog) uses the same "leave it alone" config as Off: the SAADC reads the +// pin directly regardless of the GPIO block's state, and cfg_default (no +// pull, disconnected buffer) is exactly what Nordic recommends for an ADC +// input to avoid extra leakage current -- there's nothing separate to set up +// here, unlike Input/Output. +static void applyGpioModeToPin(uint32_t pin, uint8_t mode) { + switch (mode) { + case 1: nrf_gpio_cfg_input(pin, NRF_GPIO_PIN_PULLUP); break; // Input + case 2: nrf_gpio_cfg_output(pin); nrf_gpio_pin_clear(pin); break; // Output, off + case 3: nrf_gpio_cfg_output(pin); nrf_gpio_pin_set(pin); break; // Output, on + default: nrf_gpio_cfg_default(pin); break; // Off / Analog + } +} + +// GPIO1 (P0.02) = AIN0, GPIO2 (P0.29) = AIN5 -- the only two user pins wired +// to the nRF52840's SAADC (confirmed against wiring_analog_nRF52.c's own +// pin->channel switch). GPIO3/GPIO4 (P0.09/P0.10) have no ADC channel. +static uint32_t gpioAnalogPsel(int idx) { // idx 1..4; 0 (NC) if unsupported + if (idx == 1) return SAADC_CH_PSELP_PSELP_AnalogInput0; + if (idx == 2) return SAADC_CH_PSELP_PSELP_AnalogInput5; + return SAADC_CH_PSELP_PSELP_NC; +} + +// One-shot SAADC read, bypassing Arduino's analogRead() -- that function +// treats its argument as an ARDUINO PIN INDEX (looked up through +// g_ADigitalPinMap[]), not a raw channel, and no Arduino index maps to our +// raw GPIO1/GPIO2 pins (same reason digitalWrite()/pinMode() can't be used +// for these pins either -- see the file-level notes on PIN_GPIO1..4). +// Mirrors wiring_analog_nRF52.c's analogRead_internal() exactly (10-bit, +// 0.6V internal reference, 1/6 gain -> 0-3.6V range) so the numbers read the +// same as a normal analogRead() would, just addressing the SAADC channel +// directly instead of going through the pin-index dispatch. +static uint16_t readAnalogMv(uint32_t psel) { + NRF_SAADC->RESOLUTION = SAADC_RESOLUTION_VAL_10bit; + NRF_SAADC->ENABLE = (SAADC_ENABLE_ENABLE_Enabled << SAADC_ENABLE_ENABLE_Pos); + for (int i = 0; i < 8; i++) { + NRF_SAADC->CH[i].PSELN = SAADC_CH_PSELP_PSELP_NC; + NRF_SAADC->CH[i].PSELP = SAADC_CH_PSELP_PSELP_NC; + } + NRF_SAADC->CH[0].CONFIG = + ((SAADC_CH_CONFIG_RESP_Bypass << SAADC_CH_CONFIG_RESP_Pos) & SAADC_CH_CONFIG_RESP_Msk) + | ((SAADC_CH_CONFIG_RESP_Bypass << SAADC_CH_CONFIG_RESN_Pos) & SAADC_CH_CONFIG_RESN_Msk) + | ((SAADC_CH_CONFIG_GAIN_Gain1_6 << SAADC_CH_CONFIG_GAIN_Pos) & SAADC_CH_CONFIG_GAIN_Msk) + | ((SAADC_CH_CONFIG_REFSEL_Internal << SAADC_CH_CONFIG_REFSEL_Pos) & SAADC_CH_CONFIG_REFSEL_Msk) + | ((SAADC_CH_CONFIG_TACQ_3us << SAADC_CH_CONFIG_TACQ_Pos) & SAADC_CH_CONFIG_TACQ_Msk) + | ((SAADC_CH_CONFIG_MODE_SE << SAADC_CH_CONFIG_MODE_Pos) & SAADC_CH_CONFIG_MODE_Msk); + NRF_SAADC->CH[0].PSELN = psel; + NRF_SAADC->CH[0].PSELP = psel; + + volatile int16_t value = 0; + NRF_SAADC->RESULT.PTR = (uint32_t)&value; + NRF_SAADC->RESULT.MAXCNT = 1; + + NRF_SAADC->TASKS_START = 1; + while (!NRF_SAADC->EVENTS_STARTED); + NRF_SAADC->EVENTS_STARTED = 0; + + NRF_SAADC->TASKS_SAMPLE = 1; + while (!NRF_SAADC->EVENTS_END); + NRF_SAADC->EVENTS_END = 0; + + NRF_SAADC->TASKS_STOP = 1; + while (!NRF_SAADC->EVENTS_STOPPED); + NRF_SAADC->EVENTS_STOPPED = 0; + + NRF_SAADC->ENABLE = (SAADC_ENABLE_ENABLE_Disabled << SAADC_ENABLE_ENABLE_Pos); + + if (value < 0) value = 0; + // 10-bit, 1/6 gain, 0.6V internal ref -> full-scale = 0.6V / (1/6) = 3.6V + return (uint16_t)(((uint32_t)value * 3600) / 1024); +} +#endif + +// Cycle a user GPIO pin's mode (Off/In/Out-low/Out-high), apply it to the +// actual pin, and persist. Called by GpioScreen on Enter. +void UITask::setGpioMode(int idx, uint8_t mode) { +#if defined(PIN_GPIO1) + if (!_node_prefs) return; + uint8_t* f = gpioModeField(_node_prefs, idx); + uint32_t pin = gpioPin(idx); + if (!f || pin == 0xFFFFFFFF) return; + if (mode == 4 && !gpioSupportsAnalog(idx)) mode = 0; // no ADC channel on this pin -- fall back to Off + *f = mode; + applyGpioModeToPin(pin, mode); + the_mesh.savePrefs(); +#else + (void)idx; (void)mode; +#endif +} + +// Boot-time restore: push each pin's saved mode to hardware before any UI/bot +// interaction (mirrors MyMesh::applyGpsPrefs()'s role for the GPS toggle -- +// there's no generic "restore all settings" hook in this codebase, each +// persisted hardware toggle gets its own bespoke boot call). Deliberately +// doesn't call savePrefs() -- nothing changed, just re-applying what's +// already on disk. +void UITask::applyAllGpioModes() { +#if defined(PIN_GPIO1) + if (!_node_prefs) return; + for (int i = 1; i <= 4; i++) { + uint8_t* f = gpioModeField(_node_prefs, i); + if (f) applyGpioModeToPin(gpioPin(i), *f); + } +#endif +} + +bool UITask::botSetGPIO(int idx, bool on) { +#if defined(PIN_GPIO1) + if (!_node_prefs) return false; + uint8_t* f = gpioModeField(_node_prefs, idx); + if (!f || (*f != 2 && *f != 3)) return false; // not configured as Output + setGpioMode(idx, on ? 3 : 2); + return true; +#else + (void)idx; (void)on; + return false; +#endif +} + +bool UITask::botGetGPIO(int idx, bool& is_output, bool& value) { +#if defined(PIN_GPIO1) + if (!_node_prefs) return false; + uint8_t* f = gpioModeField(_node_prefs, idx); + uint32_t pin = gpioPin(idx); + if (!f || *f == 0 || *f == 4 || pin == 0xFFFFFFFF) return false; // Off / Analog / unsupported + is_output = (*f == 2 || *f == 3); + value = is_output ? (nrf_gpio_pin_out_read(pin) != 0) : (nrf_gpio_pin_read(pin) != 0); + return true; +#else + (void)idx; (void)is_output; (void)value; + return false; +#endif +} + +bool UITask::gpioSupportsAnalog(int idx) const { +#if defined(PIN_GPIO1) + return idx == 1 || idx == 2; +#else + (void)idx; + return false; +#endif +} + +bool UITask::botGetGPIOAnalog(int idx, int& millivolts) { +#if defined(PIN_GPIO1) + if (!_node_prefs || !gpioSupportsAnalog(idx)) return false; + uint8_t* f = gpioModeField(_node_prefs, idx); + if (!f || *f != 4) return false; // not in Analog mode + millivolts = readAnalogMv(gpioAnalogPsel(idx)); + return true; +#else + (void)idx; (void)millivolts; + return false; +#endif +} + void UITask::applyTxPower() { if (_node_prefs == NULL) return; // With APC on, tx_power_dbm is the ceiling — re-baseline the controller to it diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 2b165dee..4a3c1a00 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -45,6 +45,10 @@ class UITask : public AbstractUITask { bool _lock_seq_used; // true = suppress next back_btn CLICK (post-sequence release) char _alert[80]; char _notif_mel_buf[220]; // persistent RTTTL buffer for custom notification melodies + // Persistent RTTTL buffer for the bot !buzz command (see botBuzz()) -- sized + // for the full 30s cap: "Buzz:b=120:" (11B) + up to 60 "8c,8p," pairs (6B + // each) + NUL = 372B, rounded up with margin. + char _bot_buzz_buf[400]; KeyboardWidget _kb; // shared across all screens — only one active at a time unsigned long _alert_expiry; int _msgcount; @@ -91,6 +95,9 @@ class UITask : public AbstractUITask { UIScreen* diag_screen = nullptr; UIScreen* repeater_screen = nullptr; UIScreen* clock_tools = nullptr; +#if defined(PIN_GPIO1) + UIScreen* gpio_screen = nullptr; +#endif UIScreen* curr = nullptr; CayenneLPP _dash_lpp; TrailStore _trail; @@ -287,6 +294,7 @@ public: void gotoCompassScreen(); void gotoDiagnosticsScreen(); void gotoRepeaterScreen(); + void gotoGpioScreen(); // no-op on boards without user GPIO pins (see PIN_GPIO1) void gotoClockTools(); // Alarm / Timer / Stopwatch (from the home Clock page) // Wake the display for an alarm/timer ring (force an immediate refresh). void wakeForAlarm(); @@ -410,6 +418,17 @@ public: bool getGPSState(); bool hasGPS(); // true if this board exposes a toggleable GPS (distinct from GPS being off) void toggleGPS(); + void applyGpsState(bool on); // shared by toggleGPS() and botSetGPS() + void botSetGPS(bool on) override; + void botBuzz(int seconds) override; + // User GPIO (!gpio1..!gpio4 + Tools > GPIO screen). idx is 1-4. Bodies are + // no-ops / return false on boards without PIN_GPIO1 defined. + bool botSetGPIO(int idx, bool on) override; + bool botGetGPIO(int idx, bool& is_output, bool& value) override; + bool botGetGPIOAnalog(int idx, int& millivolts) override; + bool gpioSupportsAnalog(int idx) const; // true only for GPIO1/GPIO2 (AIN0/AIN5) + void setGpioMode(int idx, uint8_t mode); // 0=Off 1=In 2=Out-low 3=Out-high 4=Analog; applies + persists + void applyAllGpioModes(); // boot-time restore from NodePrefs, called from begin() void applyBrightness(); void setBrightnessLevel(uint8_t level); uint8_t getBrightnessLevel() const { return _node_prefs ? _node_prefs->display_brightness : 2; } diff --git a/release-notes.md b/release-notes.md index 1da14945..fed56252 100644 --- a/release-notes.md +++ b/release-notes.md @@ -1,3 +1,17 @@ +## Unreleased + +### What's new + +- **Auto-Reply 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`). +- **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. +- **Auto-Reply 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`. + +### Fixes + +- **DM/room replies sometimes didn't show who they were addressed to, and room messages could wrap incorrectly.** The reply-prefix (`@[nick] `) was being stripped at the wrong point for room posts and DMs feeding the fullscreen message view, so a DM reply's "To:" header could go missing while a room reply showed the raw `@[nick] ` marker as literal message text; a related mismatch meant the history list's scrollbar/box-height sizing pass wrapped room messages with their sender name still attached, disagreeing with the actual rendered text. + +--- + ## MeshCore Solo Companion Firmware v1.23 ### What's new diff --git a/variants/wio-tracker-l1/variant.cpp b/variants/wio-tracker-l1/variant.cpp index b039d08a..99376318 100644 --- a/variants/wio-tracker-l1/variant.cpp +++ b/variants/wio-tracker-l1/variant.cpp @@ -63,7 +63,31 @@ const uint32_t g_ADigitalPinMap[] = { 0xFF, // 37 MISO }; +// GPIO3/GPIO4 (P0.09/P0.10) are the nRF52840's NFC1/NFC2 pins. Factory UICR +// reserves them for NFC antenna operation -- nrf_gpio_cfg_output/cfg_input +// alone won't work on them until UICR.NFCPINS.PROTECT flips from NFC to +// GPIO, which only takes effect after a reset. This clears a single bit +// (1=NFC -> 0=GPIO), which flash permits without an erase cycle, so no other +// UICR field (BOOTLOADERADDR, APPROTECT, ...) is touched. One-way: going back +// to NFC needs a full chip erase via debugger. No-ops on every boot after the +// first (reads as GPIO already). Adapted from Adafruit's own +// Bluefruit52Lib/examples/Hardware/nfc_to_gpio example. +static void ensureNfcPinsAsGpio() { + if ((NRF_UICR->NFCPINS & UICR_NFCPINS_PROTECT_Msk) == + (UICR_NFCPINS_PROTECT_NFC << UICR_NFCPINS_PROTECT_Pos)) { + NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Wen << NVMC_CONFIG_WEN_Pos; + while (NRF_NVMC->READY == NVMC_READY_READY_Busy); + NRF_UICR->NFCPINS &= ~UICR_NFCPINS_PROTECT_Msk; + while (NRF_NVMC->READY == NVMC_READY_READY_Busy); + NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Ren << NVMC_CONFIG_WEN_Pos; + while (NRF_NVMC->READY == NVMC_READY_READY_Busy); + NVIC_SystemReset(); // UICR is only latched at boot + } +} + void initVariant() { + ensureNfcPinsAsGpio(); // must run first: may reset the board + pinMode(PIN_QSPI_CS, OUTPUT); digitalWrite(PIN_QSPI_CS, HIGH); diff --git a/variants/wio-tracker-l1/variant.h b/variants/wio-tracker-l1/variant.h index 86ad62ba..8062ec1e 100644 --- a/variants/wio-tracker-l1/variant.h +++ b/variants/wio-tracker-l1/variant.h @@ -91,6 +91,17 @@ #define PIN_GPS_STANDBY (0) #define PIN_GPS_EN (PIN_GPS_STANDBY) +// User-assignable GPIO: 4 pins with no board function, exposed for bot +// commands (!gpio1..!gpio4) and the Tools > GPIO screen. Raw nRF52 pin +// addressing via NRF_GPIO_PIN_MAP (NOT Arduino pin numbers -- these aren't in +// g_ADigitalPinMap[], so pinMode()/digitalWrite() would silently hit whatever +// Arduino pin index happens to equal the raw value instead). See +// initVariant()'s ensureNfcPinsAsGpio() for the GPIO3/GPIO4 NFC-pin caveat. +#define PIN_GPIO1 NRF_GPIO_PIN_MAP(0, 2) +#define PIN_GPIO2 NRF_GPIO_PIN_MAP(0, 29) +#define PIN_GPIO3 NRF_GPIO_PIN_MAP(0, 9) // NFC1 +#define PIN_GPIO4 NRF_GPIO_PIN_MAP(0, 10) // NFC2 + // QSPI Pins #define PIN_QSPI_SCK (19) #define PIN_QSPI_CS (20) From a57d5d67bb5c6c9dbf11b4450b4191ee639f9d9e Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:15:41 +0200 Subject: [PATCH 02/13] rename(ui): Auto-Reply Bot -> Remote Bot The bot outgrew "auto-reply" once it gained Actions (!buzz/!gps/!advert/ !gpio1-4) that control device hardware remotely, not just answer messages. Renames the Tools screen entry and all doc cross-references; already-shipped release notes (v1.23) are left as-is to match what actually shipped under that name. Co-Authored-By: Claude Sonnet 5 --- README.md | 4 ++-- docs/solo_features/tools_screen/tools_screen.md | 10 +++++----- examples/companion_radio/ui-new/ToolsScreen.h | 2 +- release-notes.md | 5 +++-- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 49c119ce..53db0609 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ All firmware files are published on the [releases page](https://github.com/Marek - [Screen Lock](./docs/solo_features/screen_lock/screen_lock.md) — lock the device to prevent accidental keypresses, with a lock screen showing time and sensor data -- [Tools Screen](./docs/solo_features/tools_screen/tools_screen.md) — GPS trail & waypoints, compass, nearby nodes (with ping & navigate), ringtone editor, auto-reply bot, auto-advert, live location sharing, locator, diagnostics, repeater, remote admin +- [Tools Screen](./docs/solo_features/tools_screen/tools_screen.md) — GPS trail & waypoints, compass, nearby nodes (with ping & navigate), ringtone editor, remote bot, auto-advert, live location sharing, locator, diagnostics, repeater, remote admin - **Battery saving (radio)** — two optional, independent toggles under Settings › Radio: - **Pwr save** — hardware duty-cycle receive (SX126x `SetRxDutyCycle`): the radio cycles RX↔sleep on its own and wakes on a preamble, cutting average RX current with only a little added receive latency @@ -99,7 +99,7 @@ Updating to a newer version usually does not require erasing flash unless the re | [Clock Screen](./docs/solo_features/clock_screen/clock_screen.md) | Clock page, date, configurable data fields, alarm / timer / stopwatch | | [Settings Screen](./docs/solo_features/settings_screen/settings_screen.md) | All settings sections with values and interactions | | [Screen Lock](./docs/solo_features/screen_lock/screen_lock.md) | Lock/unlock sequence, lock screen, auto-lock | -| [Tools Screen](./docs/solo_features/tools_screen/tools_screen.md) | GPS trail & waypoints, compass, navigation, nearby nodes, ringtone editor, auto-reply bot, auto-advert, live location sharing, locator, diagnostics, repeater, remote admin | +| [Tools Screen](./docs/solo_features/tools_screen/tools_screen.md) | GPS trail & waypoints, compass, navigation, nearby nodes, ringtone editor, remote bot, auto-advert, live location sharing, locator, diagnostics, repeater, remote admin | | [Solo UI framework](./docs/design/solo_ui_framework.md) | **Developer guide** — the reusable building blocks (screens, lists, popups, mini-icons, geo/persistence helpers) and how to add a new feature | ### Upstream MeshCore diff --git a/docs/solo_features/tools_screen/tools_screen.md b/docs/solo_features/tools_screen/tools_screen.md index 1adbcac8..63a82ecb 100644 --- a/docs/solo_features/tools_screen/tools_screen.md +++ b/docs/solo_features/tools_screen/tools_screen.md @@ -8,7 +8,7 @@ | :-----------------------: | :-----------------------: | | ![](./overview_oled.png) | ![](./overview_eink.png) | -The Tools screen is a hub for GPS trail recording, nearby node browsing, ringtone editing, auto-reply bot, auto-advert, live location sharing, locator, compass, clock tools (alarm / timer / stopwatch), device diagnostics, repeater mode, and remote admin. Tools are grouped into collapsible **Location** / **Comms** / **System** sections — the same fold-in-place model as Settings; Tools always opens folded back to the section list. Navigate with **UP/DOWN**, press **Enter** on a section header to expand or collapse it, or on a tool to open it. +The Tools screen is a hub for GPS trail recording, nearby node browsing, ringtone editing, the remote bot, auto-advert, live location sharing, locator, compass, clock tools (alarm / timer / stopwatch), device diagnostics, repeater mode, and remote admin. Tools are grouped into collapsible **Location** / **Comms** / **System** sections — the same fold-in-place model as Settings; Tools always opens folded back to the section list. Navigate with **UP/DOWN**, press **Enter** on a section header to expand or collapse it, or on a tool to open it. --- @@ -359,7 +359,7 @@ Melodies can be assigned in **Settings › Sound** (global default) or overridde --- -## Auto-Reply Bot +## Remote Bot | OLED | E-Ink | | :-----------------------: | :-----------------------: | @@ -467,7 +467,7 @@ On boards with user GPIO (see **GPIO** under System tools below), the same Actio -A circular tab carousel of live device and mesh stats, refreshed once a second (same tab idiom as Auto-Reply Bot / Nodes). **LEFT/RIGHT** switches tab; **UP/DOWN** scrolls within it on a small OLED — on a larger e-ink display a tab's rows all fit at once. +A circular tab carousel of live device and mesh stats, refreshed once a second (same tab idiom as Remote Bot / Nodes). **LEFT/RIGHT** switches tab; **UP/DOWN** scrolls within it on a small OLED — on a larger e-ink display a tab's rows all fit at once. | Tab | Shows | | --- | ----- | @@ -510,7 +510,7 @@ Once a pin is set to **Output**, a second **State** row appears right underneath - **Analog** (GPIO1/GPIO2 only) shows a live millivolt reading inline instead: e.g. `1650mV`. Has no State row — it's read-only. - **Output** shows just `Output` on the Mode row; the actual ON/OFF value lives on the State row below it. -The same 4 pins are reachable remotely via the Auto-Reply Bot's `!gpio1`..`!gpio4` commands (see **Actions** under Auto-Reply Bot below) — both paths read/write the same underlying state, so the Tools screen and the bot never disagree. A bare `!gpio1` reports the pin's current mode and reading (`gpio1: out on`, `gpio1: in on`, or `gpio1: 1650mV` in Analog mode); `!gpio1 on`/`!gpio1 off` only takes effect if that pin is currently set to Output here (otherwise the bot replies "not output", including when the pin is in Analog mode). +The same 4 pins are reachable remotely via the Remote Bot's `!gpio1`..`!gpio4` commands (see **Actions** under Remote Bot below) — both paths read/write the same underlying state, so the Tools screen and the bot never disagree. A bare `!gpio1` reports the pin's current mode and reading (`gpio1: out on`, `gpio1: in on`, or `gpio1: 1650mV` in Analog mode); `!gpio1 on`/`!gpio1 off` only takes effect if that pin is currently set to Output here (otherwise the bot replies "not output", including when the pin is in Analog mode). --- @@ -557,7 +557,7 @@ Send commands to a **repeater/room server you have admin permission on** — the 1. **Select a node** — opening **Tools › Admin** goes straight to **Tools › Nodes** (the same screen, filters, sort and live scan as browsing it normally) so picking a node for Admin looks exactly like using Nodes for anything else; **Enter** on a repeater/room row hands it to Admin, **Cancel** returns to Tools. Admin is also reachable directly from a node's own **Hold Enter** menu in Nodes. 2. **Log in** — type the node's **admin password** (the same login handshake Messages uses for room servers; a repeater's admin password is set with the `password` CLI command). If a password was already saved for this node from an earlier successful login, it retries silently instead of prompting. Only a login that comes back with **admin**-level permission unlocks the next step — anything less shows "Not admin on this node". -3. **Pick a category and a field** — a tab carousel (**LEFT/RIGHT** to switch category, **UP/DOWN** to move within it, same as Auto-Reply Bot's tabs), so common settings don't need the CLI grammar memorised: +3. **Pick a category and a field** — a tab carousel (**LEFT/RIGHT** to switch category, **UP/DOWN** to move within it, same as Remote Bot's tabs), so common settings don't need the CLI grammar memorised: | Tab | Rows | | --- | ---- | diff --git a/examples/companion_radio/ui-new/ToolsScreen.h b/examples/companion_radio/ui-new/ToolsScreen.h index c9ccc8b0..5dc95d49 100644 --- a/examples/companion_radio/ui-new/ToolsScreen.h +++ b/examples/companion_radio/ui-new/ToolsScreen.h @@ -125,7 +125,7 @@ const ToolsScreen::Tool ToolsScreen::LOCATION_TOOLS[] = { { "Compass", &ICON_MAP_NORTH, ACT_COMPASS }, }; const ToolsScreen::Tool ToolsScreen::COMMS_TOOLS[] = { - { "Auto-Reply Bot", &ICON_BOT, ACT_BOT }, + { "Remote Bot", &ICON_BOT, ACT_BOT }, { "Auto-Advert", &ICON_ADVERT, ACT_AUTOADVERT }, { "Repeater", &ICON_REPEATER, ACT_REPEATER }, { "Admin", &ICON_GEAR, ACT_ADMIN }, diff --git a/release-notes.md b/release-notes.md index fed56252..ec4412ab 100644 --- a/release-notes.md +++ b/release-notes.md @@ -2,9 +2,10 @@ ### What's new -- **Auto-Reply 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`). +- **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`). - **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. -- **Auto-Reply 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`. +- **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`. ### Fixes From 7cae6470bf590bd5fe7a2a5a4fe5b02f8b7ff559 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:43:33 +0200 Subject: [PATCH 03/13] feat(ui): CardKB (I2C keyboard) support with full keyboard-only navigation Auto-detected at boot on Wire1/Grove (addr 0x5F) -- no setting to flip, and a no-op on boards without that bus or with nothing attached. This UI's key codes (KEY_LEFT/UP/DOWN/RIGHT/ENTER/CANCEL) already match CardKB's own byte protocol, so most input needs zero translation and flows through the same key queue as physical buttons. Two bytes get remapped in UITask::pollCardKB(): - Enter, only when the on-screen keyboard's plain grid state is active (no placeholder/accent popup, not in cursor-mode), becomes a new KEY_KB_ENTER sentinel meaning "submit the field" -- reusing plain KEY_ENTER there would insert a stray character, since a CardKB typist's row/col never reflect an intentional grid selection. Everywhere else Enter is untouched, so selecting a placeholder or committing an accent still works normally. - Tab (otherwise unused) becomes KEY_CONTEXT_MENU, standing in for the "Hold-Enter" long-press gesture CardKB has no way to produce -- without it, ~30 context menus across the UI (message reply/navigate, Bot/Admin/ Repeater, ...) would be unreachable from the keyboard alone. KeyboardWidget gains a direct-typing path: printable ASCII inserts straight at the cursor bypassing the grid, Backspace deletes, KEY_KB_ENTER submits. Build-verified: WioTrackerL1_companion_solo_dual and WioTrackerL1Eink_companion_solo_dual both compile and link clean; also smoke-tested Heltec_mesh_solar_companion_radio_ble (no ENV_PIN_SDA/SCL) to confirm zero regression on boards without the feature. Co-Authored-By: Claude Sonnet 5 --- .../companion_radio/ui-new/KeyboardWidget.h | 24 +++++++++ examples/companion_radio/ui-new/UITask.cpp | 49 +++++++++++++++++++ examples/companion_radio/ui-new/UITask.h | 11 +++++ release-notes.md | 1 + src/helpers/ui/UIScreen.h | 7 +++ 5 files changed, 92 insertions(+) diff --git a/examples/companion_radio/ui-new/KeyboardWidget.h b/examples/companion_radio/ui-new/KeyboardWidget.h index fcdc9f9a..ade9cd6b 100644 --- a/examples/companion_radio/ui-new/KeyboardWidget.h +++ b/examples/companion_radio/ui-new/KeyboardWidget.h @@ -721,6 +721,30 @@ struct KeyboardWidget { if (c == KEY_CANCEL) return CANCELLED; + // Direct-typing passthrough (CardKB or similar literal-ASCII input + // source, see UITask::pollCardKB()). Printable characters insert + // straight at the cursor, bypassing the on-screen grid entirely -- no + // caps re-application, the source already sends the correct case. + // Backspace deletes the previous character. KEY_KB_ENTER (only ever + // emitted when the grid is in this exact plain state -- no popup, no + // cursor-mode, see pollCardKB()) submits the field, instead of + // KEY_ENTER's usual "commit grid cell (row,col)". + if (c == KEY_KB_ENTER) return DONE; + if (c == 0x08) { + if (cursor_pos > 0) { + int n = kbUtf8LastCharBytes(buf, cursor_pos); + memmove(buf + cursor_pos - n, buf + cursor_pos, len - cursor_pos); + len -= n; cursor_pos -= n; + buf[len] = '\0'; + } + return NONE; + } + if (c >= 0x20 && c <= 0x7E) { + char one[2] = { c, '\0' }; + insertGlyph(one, false); + return NONE; + } + const int rows = gridRows(); const int cols = gridCols(); diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index af658f1b..daf9d860 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1386,6 +1386,13 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no uint32_t aoff = autoOffMillis(); _auto_off = millis() + (aoff > 0 ? aoff : AUTO_OFF_MILLIS); +#if defined(ENV_PIN_SDA) && defined(ENV_PIN_SCL) + // Wire1 is already brought up by sensors.begin() (EnvironmentSensorManager), + // which runs before this -- just probe for a CardKB sitting on it. + Wire1.beginTransmission(0x5F); + _has_cardkb = (Wire1.endTransmission() == 0); +#endif + #if defined(PIN_USER_BTN) user_btn.begin(); #endif @@ -2063,6 +2070,47 @@ bool UITask::dequeueKey(char& c) { return true; } +// Poll an optional CardKB (I2C keyboard, addr 0x5F) on Wire1/Grove, feeding +// the same key queue as every physical button. Most of its output needs no +// translation at all: CardKB's own arrow/Enter/Esc byte codes are already +// identical to this UI's KEY_LEFT/UP/DOWN/RIGHT/ENTER/CANCEL (0xB4-0xB7, 13, +// 27), and Backspace (0x08) / printable ASCII (0x20-0x7E) collide with +// nothing that existed before. Two bytes get remapped: +// - Enter, while the on-screen keyboard's plain grid state is active (no +// placeholder/accent popup, not in cursor-mode) -- there it means "submit +// the field", not KEY_ENTER's usual "commit grid cell (row,col)", which +// would otherwise insert a stray character since direct typing never +// touches the grid. See KeyboardWidget::handleInput()'s KEY_KB_ENTER +// branch. +// - Tab (0x09, otherwise unused by any screen) -> KEY_CONTEXT_MENU, the +// "Hold-Enter" context-menu gesture used in ~30 places across the UI +// (message reply/navigate, Bot/Admin/Repeater menus, ...). CardKB has no +// press-duration concept to detect a long-press with, so Tab is the +// closest free key standing in for it -- full keyboard-only navigation +// would otherwise be unable to reach any of those menus at all. +void UITask::pollCardKB() { +#if defined(ENV_PIN_SDA) && defined(ENV_PIN_SCL) + if (!_has_cardkb) return; + if (millis() - _cardkb_poll_ms < 30) return; // ~33 Hz poll, plenty for typing + _cardkb_poll_ms = millis(); + Wire1.requestFrom(0x5F, 1); + if (!Wire1.available()) return; + uint8_t raw = Wire1.read(); + if (raw == 0) return; // no key down + + char key; + if (raw == 0x0D && + _kb.isVisible() && !_kb._ph_menu.active && !_kb.cursor_mode && !_kb.accent_active) { + key = KEY_KB_ENTER; + } else if (raw == 0x09) { + key = KEY_CONTEXT_MENU; + } else { + key = (char)raw; + } + enqueueKey(checkDisplayOn(key)); +#endif +} + void UITask::loop() { // Background delivery: resend pending on-device DMs whose ACK timed out, and // finalise the ✗ marker — runs regardless of which screen is active. @@ -2159,6 +2207,7 @@ void UITask::loop() { _analogue_pin_read_millis = millis(); } #endif + pollCardKB(); #if defined(BACKLIGHT_BTN) if (millis() > next_backlight_btn_check) { bool touch_state = digitalRead(PIN_BUTTON2); diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 4a3c1a00..106f3d0d 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -193,6 +193,17 @@ class UITask : public AbstractUITask { void enqueueKey(char c); bool dequeueKey(char& c); + // Optional M5Stack CardKB (I2C keyboard, addr 0x5F) on the Grove/Wire1 bus + // -- reuses ENV_PIN_SDA/ENV_PIN_SCL (already brought up for + // EnvironmentSensorManager) as the "this board has a second I2C bus" gate, + // rather than a new board-specific pin define. No-op entirely on boards + // without that bus, or when nothing ACKs 0x5F at boot. +#if defined(ENV_PIN_SDA) && defined(ENV_PIN_SCL) + bool _has_cardkb = false; + uint32_t _cardkb_poll_ms = 0; +#endif + void pollCardKB(); + void setCurrScreen(UIScreen* c); // Centred alert overlay (the showAlert() box). Wraps long text to up to diff --git a/release-notes.md b/release-notes.md index ec4412ab..57f2eb33 100644 --- a/release-notes.md +++ b/release-notes.md @@ -6,6 +6,7 @@ - **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`). - **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, Enter and Esc work as expected everywhere else too (including inside the placeholder and accent popups); Tab opens the Hold-Enter context menu (message reply/navigate, Bot/Admin/Repeater menus, …) — the whole interface is reachable without touching a physical button. ### Fixes diff --git a/src/helpers/ui/UIScreen.h b/src/helpers/ui/UIScreen.h index db2793b0..2bebaff3 100644 --- a/src/helpers/ui/UIScreen.h +++ b/src/helpers/ui/UIScreen.h @@ -13,6 +13,13 @@ #define KEY_NEXT 0xF1 #define KEY_PREV 0xF2 #define KEY_CONTEXT_MENU 0xF3 +// A literal-ASCII input source's (e.g. an I2C CardKB) Enter, translated only +// when the on-screen keyboard's plain grid state is active (see +// UITask::pollCardKB()) -- means "submit the field", not KEY_ENTER's usual +// "commit whatever grid cell (row,col) is currently selected", which would +// otherwise insert a stray character since direct typing never touches the +// grid. +#define KEY_KB_ENTER 0x05 // "Previous"/"Next" navigation keys: the directional key and its rotary-encoder // twin. Decrement / increment a value, or step a selection. (Cancel and the From ad4668242b80d61b5b9a6de11f808e05071b74c7 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:49:04 +0200 Subject: [PATCH 04/13] fix(ui): CardKB key handling - debounce, Fn modifier for submit/accents CardKB is level-triggered (repeats the held byte every poll) and its Enter key collided with the on-screen keyboard grid's own commit action, causing duplicate characters and accidental message sends. Debounce polling and use the CardKB v1.1 Fn modifier (confirmed working on real hardware) instead of tracking navigation state: plain Enter now behaves like the physical centre button, Fn+Enter submits, Fn+Tab opens the Hold-Enter equivalent, and Fn+ opens that letter's accent popup directly. Co-Authored-By: Claude Sonnet 5 --- .../companion_radio/ui-new/KeyboardWidget.h | 32 +++++++- examples/companion_radio/ui-new/UITask.cpp | 73 ++++++++++++++----- examples/companion_radio/ui-new/UITask.h | 6 +- release-notes.md | 2 +- 4 files changed, 88 insertions(+), 25 deletions(-) diff --git a/examples/companion_radio/ui-new/KeyboardWidget.h b/examples/companion_radio/ui-new/KeyboardWidget.h index ade9cd6b..10a5e673 100644 --- a/examples/companion_radio/ui-new/KeyboardWidget.h +++ b/examples/companion_radio/ui-new/KeyboardWidget.h @@ -645,6 +645,27 @@ struct KeyboardWidget { return 50; } + // CardKB's Fn+letter ("alt") gesture, see UITask::pollCardKB(): open the + // accent popup for this base Latin letter directly, skipping the + // arrow-hunt to find its cell first. `base` always comes from CardKB's own + // physical QWERTY layout, so this only makes sense -- same gating as the + // physical Hold-Enter-on-a-letter-cell path just below -- while the + // keyboard is idle on its plain Latin page: on T9, symbols, or a non-Latin + // main/alt script (Cyrillic, Greek, ...), the on-screen grid isn't showing + // Latin letters at all, so it's a no-op rather than inserting a stray + // Latin accent into unrelated text. + bool openAccentFor(char base) { + if (!isVisible() || _ph_menu.active || cursor_mode || accent_active) return false; + if (isT9() || pageIsSymbols(page) || scriptAt(page) != NodePrefs::KB_ALPHABET_LATIN_ONLY) return false; + int gi = findAccentGroup(base); + if (gi < 0) return false; + accent_active = true; + accent_group = gi; + accent_sel = 0; + t9_cell = -1; + return true; + } + Result handleInput(char c) { // placeholder overlay consumes all input if (_ph_menu.active) { @@ -726,9 +747,10 @@ struct KeyboardWidget { // straight at the cursor, bypassing the on-screen grid entirely -- no // caps re-application, the source already sends the correct case. // Backspace deletes the previous character. KEY_KB_ENTER (only ever - // emitted when the grid is in this exact plain state -- no popup, no - // cursor-mode, see pollCardKB()) submits the field, instead of - // KEY_ENTER's usual "commit grid cell (row,col)". + // emitted for CardKB's Fn+Enter, see pollCardKB()) submits the field + // directly -- plain Enter (byte-identical to a physical button's + // KEY_ENTER) always acts on the grid instead, same as a physical-button + // user, so there's no ambiguity to resolve here. if (c == KEY_KB_ENTER) return DONE; if (c == 0x08) { if (cursor_pos > 0) { @@ -822,6 +844,10 @@ struct KeyboardWidget { return NONE; } if (c == KEY_ENTER) { + // Plain Enter always acts on the grid (repeat a letter, cycle T9, + // switch page/script, reach the special row's DONE cell) -- same as a + // physical button. CardKB submits via the separate KEY_KB_ENTER + // (Fn+Enter, see pollCardKB()) instead, so there's no ambiguity here. if (row < rows && isT9()) { int cell = row * cols + col; const char* group = t9GroupStr(cell); diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index daf9d860..f18b4eb1 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -2070,42 +2070,75 @@ bool UITask::dequeueKey(char& c) { return true; } +#if defined(ENV_PIN_SDA) && defined(ENV_PIN_SCL) +// CardKB's "fn" column (key_map in M5Stack's unit_CardKB.cpp): Fn+ sends 0x80 + that key's row index, entirely disjoint from every other +// code this UI recognises. Indexed by (raw - 0x80); non-letter slots (digits, +// arrows, enter, tab, bs, space -- handled separately or unused) are 0. +static const char CARDKB_FN_BASE[48] = { + 0,0,0,0,0,0,0,0,0,0,0,0,0, // esc,1-0,bs,tab + 'q','w','e','r','t','y','u','i','o','p', 0, 0,0, // q-p, (unused), LEFT,UP + 'a','s','d','f','g','h','j','k','l', 0, 0,0, // a-l, enter, DOWN,RIGHT + 'z','x','c','v','b','n','m', 0,0,0, // z-m, comma,period,space +}; +#endif + // Poll an optional CardKB (I2C keyboard, addr 0x5F) on Wire1/Grove, feeding // the same key queue as every physical button. Most of its output needs no // translation at all: CardKB's own arrow/Enter/Esc byte codes are already // identical to this UI's KEY_LEFT/UP/DOWN/RIGHT/ENTER/CANCEL (0xB4-0xB7, 13, // 27), and Backspace (0x08) / printable ASCII (0x20-0x7E) collide with -// nothing that existed before. Two bytes get remapped: -// - Enter, while the on-screen keyboard's plain grid state is active (no -// placeholder/accent popup, not in cursor-mode) -- there it means "submit -// the field", not KEY_ENTER's usual "commit grid cell (row,col)", which -// would otherwise insert a stray character since direct typing never -// touches the grid. See KeyboardWidget::handleInput()'s KEY_KB_ENTER -// branch. -// - Tab (0x09, otherwise unused by any screen) -> KEY_CONTEXT_MENU, the -// "Hold-Enter" context-menu gesture used in ~30 places across the UI -// (message reply/navigate, Bot/Admin/Repeater menus, ...). CardKB has no -// press-duration concept to detect a long-press with, so Tab is the -// closest free key standing in for it -- full keyboard-only navigation -// would otherwise be unable to reach any of those menus at all. +// nothing that existed before. Plain Enter now always acts like the physical +// centre button (commit/repeat a grid cell) -- no more ambiguity to resolve, +// since Fn gives two clean, stateless modifiers instead: +// - Fn+Enter (0xA3) submits the field (KEY_KB_ENTER) from anywhere, without +// needing to navigate to the special row's DONE cell. +// - Fn+Tab (0x8C) opens the Hold-Enter equivalent (shift-lock, clear-all, +// accent popup on whatever cell is selected) -- plain Tab (otherwise +// unused) still does this for the ~30 non-keyboard Hold-Enter menus +// (message reply/navigate, Bot/Admin/Repeater, ...) but is inert while the +// on-screen keyboard itself is showing, where Fn+Tab/Fn+letter cover it. +// - Fn+ opens the accent popup for that base letter directly +// (KeyboardWidget::openAccentFor()) -- no arrow-hunting across the grid. +// CardKB is level-triggered (it keeps returning the held key's byte, not just +// once), so _cardkb_last_raw debounces it into one press per physical +// keypress, same as a MomentaryButton's CLICK event. void UITask::pollCardKB() { #if defined(ENV_PIN_SDA) && defined(ENV_PIN_SCL) if (!_has_cardkb) return; - if (millis() - _cardkb_poll_ms < 30) return; // ~33 Hz poll, plenty for typing - _cardkb_poll_ms = millis(); + // No artificial throttle: unlike a MomentaryButton (BUTTON_USE_INTERRUPTS + // latches every edge in an ISR ring buffer, so it survives a blocking e-ink + // refresh untouched), CardKB is plain I2C polling with no interrupt line on + // the Grove cable and no onboard queue -- it only ever reports "what's held + // right now". A press that starts and fully releases while curr->render() + // is blocked is physically unobservable, no software fix can recover it. + // Polling every loop() iteration (same as a digital button's check(), which + // has no throttle either) just shrinks that miss window down to exactly the + // render() duration instead of render()+30ms. Wire1.requestFrom(0x5F, 1); if (!Wire1.available()) return; uint8_t raw = Wire1.read(); - if (raw == 0) return; // no key down + if (raw == _cardkb_last_raw) return; // still held (or still released) -- no new edge + _cardkb_last_raw = raw; + if (raw == 0) return; // key just released, nothing to enqueue char key; - if (raw == 0x0D && - _kb.isVisible() && !_kb._ph_menu.active && !_kb.cursor_mode && !_kb.accent_active) { + if (raw == 0xA3) { // Fn+Enter -- submit the field key = KEY_KB_ENTER; - } else if (raw == 0x09) { + } else if (raw == 0x8C) { // Fn+Tab -- Hold-Enter equivalent, unconditionally key = KEY_CONTEXT_MENU; + } else if (raw == 0x09) { // plain Tab -- same, but only outside the keyboard + if (_kb.isVisible()) return; + key = KEY_CONTEXT_MENU; + } else if (raw >= 0x80 && raw <= 0xAF) { // Fn+ -- 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); + return; } else { - key = (char)raw; + key = (char)raw; // plain Enter/arrows/backspace/ASCII -- byte-identical + // to a physical keystroke, no CardKB-specific state } enqueueKey(checkDisplayOn(key)); #endif diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 106f3d0d..73606aef 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -200,7 +200,11 @@ class UITask : public AbstractUITask { // without that bus, or when nothing ACKs 0x5F at boot. #if defined(ENV_PIN_SDA) && defined(ENV_PIN_SCL) bool _has_cardkb = false; - uint32_t _cardkb_poll_ms = 0; + // CardKB is level-triggered, not edge-triggered -- it keeps returning the + // same byte for as long as the physical key is held, not just once. Track + // the last raw byte seen so a held key enqueues exactly one press instead + // of one per poll tick. + uint8_t _cardkb_last_raw = 0; #endif void pollCardKB(); diff --git a/release-notes.md b/release-notes.md index 57f2eb33..95f4dfb5 100644 --- a/release-notes.md +++ b/release-notes.md @@ -6,7 +6,7 @@ - **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`). - **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, Enter and Esc work as expected everywhere else too (including inside the placeholder and accent popups); Tab opens the Hold-Enter context menu (message reply/navigate, Bot/Admin/Repeater menus, …) — the whole interface is reachable without touching a physical button. +- **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, …). ### Fixes From a0b1c80c852c1b1c4efb95664ee25ee3638a2053 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:51:51 +0200 Subject: [PATCH 05/13] feat(ui): compact on-screen keyboard toggle for external keyboards Add Settings > Keyboard "Ext. KB" row (boards with a CardKB-capable I2C bus only): switching it to Compact hides the letter grid and special-row icons in favour of a one-line status (script/page, caps) plus a Fn-shortcut reminder, since an external-keyboard typist never looks at the on-screen grid. Accent/placeholder popups still render as before. Off by default. Co-Authored-By: Claude Sonnet 5 --- examples/companion_radio/DataStore.cpp | 7 + examples/companion_radio/NodePrefs.h | 17 +- .../companion_radio/ui-new/KeyboardWidget.h | 173 ++++++++++-------- .../companion_radio/ui-new/SettingsScreen.h | 16 ++ release-notes.md | 1 + 5 files changed, 138 insertions(+), 76 deletions(-) diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index c0da5f34..0c05b252 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -551,6 +551,12 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no rd(&_prefs.gpio4_mode, sizeof(_prefs.gpio4_mode)); if (_prefs.gpio4_mode > 3) _prefs.gpio4_mode = 0; + // → 0xC0DE0023: append the external-keyboard compact-display toggle at the + // tail. A pre-0x23 file has no byte here; clamp to 0 (full grid, unchanged + // behaviour for upgraders). + rd(&_prefs.keyboard_cardkb_compact, sizeof(_prefs.keyboard_cardkb_compact)); + if (_prefs.keyboard_cardkb_compact > 1) _prefs.keyboard_cardkb_compact = 0; + // Schema sentinel: bumped on layout changes. Mismatch means an older file // (or a different schema); rd() already zero-inits any fields not present, // so we just log it — next savePrefs writes the current sentinel. @@ -762,6 +768,7 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)&_prefs.gpio2_mode, sizeof(_prefs.gpio2_mode)); file.write((uint8_t *)&_prefs.gpio3_mode, sizeof(_prefs.gpio3_mode)); file.write((uint8_t *)&_prefs.gpio4_mode, sizeof(_prefs.gpio4_mode)); + file.write((uint8_t *)&_prefs.keyboard_cardkb_compact, sizeof(_prefs.keyboard_cardkb_compact)); // Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL. Its write is // the one we check: once the flash fills, writes return 0, so a good diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index f868d0e7..fa69eec3 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -422,6 +422,17 @@ struct NodePrefs { // persisted to file uint8_t gpio3_mode; uint8_t gpio4_mode; + // Settings > Keyboard's "Ext. KB" row (boards with a second I2C bus for an + // optional CardKB, see ENV_PIN_SDA/ENV_PIN_SCL, only). When on, the + // on-screen keyboard skips drawing its full letter grid + special-row icons + // -- an external-keyboard typist never looks at them -- and shows a compact + // one-line status (current script/page, caps) instead; the accent and + // placeholder popups still render on top exactly as before (see + // KeyboardWidget::render()). Manual toggle rather than auto-detected, so it + // stays put even if the module is briefly unplugged. Default 0 (full grid, + // unchanged behaviour) on upgrade. + uint8_t keyboard_cardkb_compact; + // 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; @@ -484,7 +495,7 @@ struct NodePrefs { // persisted to file // 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 = 0xC0DE0022; + static const uint32_t SCHEMA_SENTINEL = 0xC0DE0023; // 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 @@ -581,7 +592,9 @@ struct NodePrefs { // persisted to file // 3. clamp it on load (an upgrader's file lacks it → stray bytes) // 4. bump SCHEMA_SENTINEL's low byte // (Padding can also shift sizeof; a "false" trip just means re-check + rebump.) -// keyboard_main_alphabet (added in the prior bump) landed in existing tail +// keyboard_cardkb_compact (0xC0DE0023) also landed in existing tail padding -- +// confirmed via a real build -- leaving sizeof unchanged at 2720. +// keyboard_main_alphabet (added in an earlier bump) landed in existing tail // padding -- confirmed via a real build's sizeof() -- so that bump left the // size unchanged. bot_actions_dm/ch/room and gpio1..4_mode (the last two // bumps, 7 more uint8_t total) added 8 bytes, not 7 -- one byte of tail diff --git a/examples/companion_radio/ui-new/KeyboardWidget.h b/examples/companion_radio/ui-new/KeyboardWidget.h index 10a5e673..6234eece 100644 --- a/examples/companion_radio/ui-new/KeyboardWidget.h +++ b/examples/companion_radio/ui-new/KeyboardWidget.h @@ -526,81 +526,106 @@ struct KeyboardWidget { return 50; } - // character grid - if (isT9()) { - for (int r = 0; r < rows; r++) { - int y = chars_y + r * cell_h; - for (int c = 0; c < cols; c++) { - bool sel = (row == r && col == c); - int cell = r * cols + c; - // Label the cell "" so it reads like a phone keypad. The - // digit is what the multi-tap cycle lands on after the letters (see - // handleInput: '1'+cell). No separator space — the widest group - // (Cyrillic "деёжз"/"шщъыь", 5 letters x up to 2 UTF-8 bytes) + digit - // still fits with room to spare. - char group_shown[12]; - kbApplyCapsUtf8(t9GroupStr(cell), caps, group_shown, sizeof(group_shown)); - char label[14]; - snprintf(label, sizeof(label), "%c%s", (char)('1' + cell), group_shown); - int cx = c * cell_w; - display.drawSelectionRow(cx, y - 1, cell_w - 1, cell_h, sel); - int tw = display.getTextWidth(label); - display.setCursor(cx + (cell_w - tw) / 2, y); - display.print(label); - } - } - } else { - for (int r = 0; r < rows; r++) { - int y = chars_y + r * cell_h; - for (int c = 0; c < cols; c++) { - bool sel = (row == r && col == c); - char ch_buf[3]; - kbApplyCapsUtf8(cellStr(r, c), caps, ch_buf, sizeof(ch_buf)); - if (ch_buf[0] == ' ' && ch_buf[1] == '\0') ch_buf[0] = '_'; - int cx = c * cell_w; - display.drawSelectionRow(cx, y - 1, cell_w - 1, cell_h, sel); - int tw = display.getTextWidth(ch_buf); - display.setCursor(cx + (cell_w - tw) / 2, y); - display.print(ch_buf); - } - } - } - - // special row: caps ⇧ · space ⎵ · delete ⌫ · placeholders {} (text) · OK ✓ - const int s = miniIconScale(display); - const int icy = spec_y + (cell_h - lh) / 2; // centre icons within the cell - for (int i = 0; i < KB_SPECIAL; i++) { - bool sel = (row == rows && col == i); - bool active = (i == 0 && caps); - int sx = i * spec_w; - display.drawSelectionRow(sx, spec_y - 1, spec_w - 1, cell_h, sel || active); - if (i == 3 || i == 4) { // text keys: {} picker, page toggle - // Shows what pressing it lands on next, same "reads as the - // destination" convention as the original 2-page abc<->#@ toggle, - // generalized to however many pages are in the cycle right now. - const char* lbl; - if (i == 3) { - lbl = "{}"; - } else { - int next = (page + 1) % totalPages(); - lbl = pageIsSymbols(next) ? "#@" : scriptHint(scriptAt(next)); - } - int tw = display.getTextWidth(lbl); - display.setCursor(sx + (spec_w - tw) / 2, spec_y); - display.print(lbl); - } else if (i == 1) { // space ⎵ — two halves side by side - int icw = (ICON_SPACE_L.w + ICON_SPACE_R.w) * s; - int ix = sx + (spec_w - icw) / 2; - miniIconDraw(display, ix, icy, ICON_SPACE_L); - miniIconDraw(display, ix + ICON_SPACE_L.w * s, icy, ICON_SPACE_R); - } else { - const MiniIcon& ic = (i == 0) ? ICON_SHIFT - : (i == 2) ? ICON_BACKSPACE - : ICON_CHECK; // i == 5 → OK - int ix = sx + (spec_w - ic.w * s) / 2; - miniIconDraw(display, ix, icy, ic); - } + // Compact mode (Settings > Keyboard's "Ext. KB" row): an external-keyboard + // typist never looks at the letter grid or special-row icons, so skip + // drawing them and show a one-line status (current script/page, caps) + // instead. row/col/page keep updating exactly as before even while this + // is on (arrows and Fn+letter both still work; the accent popup below + // still anchors on `row`), so nothing breaks if physical buttons get used + // meanwhile -- it just won't be visible which cell is selected. + if (prefs && prefs->keyboard_cardkb_compact) { + const int hh = lh + 2; display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, chars_y, display.width(), hh); + display.setColor(DisplayDriver::DARK); + const char* script_name = pageIsSymbols(page) ? "Symbols" + : (scriptAt(page) == NodePrefs::KB_ALPHABET_CYRILLIC) ? "Cyrillic" + : (scriptAt(page) == NodePrefs::KB_ALPHABET_GREEK) ? "Greek" + : "Latin"; + char status[32]; + if (pageIsSymbols(page)) snprintf(status, sizeof(status), "%s%s", script_name, caps ? " CAPS" : ""); + else snprintf(status, sizeof(status), "%s %s%s", script_name, isT9() ? "T9" : "ABC", caps ? " CAPS" : ""); + display.drawTextCentered(display.width() / 2, chars_y + 1, status); + display.setColor(DisplayDriver::LIGHT); + display.drawTextCentered(display.width() / 2, chars_y + hh + 2, "Fn+Tab menu"); + display.drawTextCentered(display.width() / 2, chars_y + hh + 2 + lh, "Fn+letter accent"); + } else { + // character grid + if (isT9()) { + for (int r = 0; r < rows; r++) { + int y = chars_y + r * cell_h; + for (int c = 0; c < cols; c++) { + bool sel = (row == r && col == c); + int cell = r * cols + c; + // Label the cell "" so it reads like a phone keypad. The + // digit is what the multi-tap cycle lands on after the letters (see + // handleInput: '1'+cell). No separator space — the widest group + // (Cyrillic "деёжз"/"шщъыь", 5 letters x up to 2 UTF-8 bytes) + digit + // still fits with room to spare. + char group_shown[12]; + kbApplyCapsUtf8(t9GroupStr(cell), caps, group_shown, sizeof(group_shown)); + char label[14]; + snprintf(label, sizeof(label), "%c%s", (char)('1' + cell), group_shown); + int cx = c * cell_w; + display.drawSelectionRow(cx, y - 1, cell_w - 1, cell_h, sel); + int tw = display.getTextWidth(label); + display.setCursor(cx + (cell_w - tw) / 2, y); + display.print(label); + } + } + } else { + for (int r = 0; r < rows; r++) { + int y = chars_y + r * cell_h; + for (int c = 0; c < cols; c++) { + bool sel = (row == r && col == c); + char ch_buf[3]; + kbApplyCapsUtf8(cellStr(r, c), caps, ch_buf, sizeof(ch_buf)); + if (ch_buf[0] == ' ' && ch_buf[1] == '\0') ch_buf[0] = '_'; + int cx = c * cell_w; + display.drawSelectionRow(cx, y - 1, cell_w - 1, cell_h, sel); + int tw = display.getTextWidth(ch_buf); + display.setCursor(cx + (cell_w - tw) / 2, y); + display.print(ch_buf); + } + } + } + + // special row: caps ⇧ · space ⎵ · delete ⌫ · placeholders {} (text) · OK ✓ + const int s = miniIconScale(display); + const int icy = spec_y + (cell_h - lh) / 2; // centre icons within the cell + for (int i = 0; i < KB_SPECIAL; i++) { + bool sel = (row == rows && col == i); + bool active = (i == 0 && caps); + int sx = i * spec_w; + display.drawSelectionRow(sx, spec_y - 1, spec_w - 1, cell_h, sel || active); + if (i == 3 || i == 4) { // text keys: {} picker, page toggle + // Shows what pressing it lands on next, same "reads as the + // destination" convention as the original 2-page abc<->#@ toggle, + // generalized to however many pages are in the cycle right now. + const char* lbl; + if (i == 3) { + lbl = "{}"; + } else { + int next = (page + 1) % totalPages(); + lbl = pageIsSymbols(next) ? "#@" : scriptHint(scriptAt(next)); + } + int tw = display.getTextWidth(lbl); + display.setCursor(sx + (spec_w - tw) / 2, spec_y); + display.print(lbl); + } else if (i == 1) { // space ⎵ — two halves side by side + int icw = (ICON_SPACE_L.w + ICON_SPACE_R.w) * s; + int ix = sx + (spec_w - icw) / 2; + miniIconDraw(display, ix, icy, ICON_SPACE_L); + miniIconDraw(display, ix + ICON_SPACE_L.w * s, icy, ICON_SPACE_R); + } else { + const MiniIcon& ic = (i == 0) ? ICON_SHIFT + : (i == 2) ? ICON_BACKSPACE + : ICON_CHECK; // i == 5 → OK + int ix = sx + (spec_w - ic.w * s) / 2; + miniIconDraw(display, ix, icy, ic); + } + display.setColor(DisplayDriver::LIGHT); + } } // Accent popup: floats over the still-visible grid (same idea as the diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index 0e63f3f7..bcac09a5 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -73,6 +73,9 @@ class SettingsScreen : public UIScreen { KEYBOARD_TYPE, KEYBOARD_MAIN_ALPHABET, KEYBOARD_ALPHABET, +#if defined(ENV_PIN_SDA) && defined(ENV_PIN_SCL) + KEYBOARD_CARDKB_COMPACT, +#endif // Contacts section SECTION_CONTACTS, DM_FILTER, CH_FILTER, ROOM_FILTER, // Messages section @@ -574,6 +577,12 @@ class SettingsScreen : public UIScreen { display.print("Additional"); display.setCursor(valCol(display), y); display.print(NodePrefs::keyboardAlphabetLabel(p ? p->keyboard_alt_alphabet : 0)); +#if defined(ENV_PIN_SDA) && defined(ENV_PIN_SCL) + } else if (item == KEYBOARD_CARDKB_COMPACT) { + display.print("Ext. KB"); + display.setCursor(valCol(display), y); + display.print((p && p->keyboard_cardkb_compact) ? "Compact" : "Full"); +#endif } else if (item == BATT_DISPLAY) { display.print("BattDisp"); display.setCursor(valCol(display), y); @@ -945,6 +954,13 @@ public: _dirty = true; return true; } +#if defined(ENV_PIN_SDA) && defined(ENV_PIN_SCL) + if (_selected == KEYBOARD_CARDKB_COMPACT && p && (left || right || enter)) { + p->keyboard_cardkb_compact ^= 1; + _dirty = true; + return true; + } +#endif if (_selected == DM_RESEND && p) { int n = p->dm_resend_count; if (right || enter) n = (n + 1) % 6; // 0..5, wraps diff --git a/release-notes.md b/release-notes.md index 95f4dfb5..382e05ef 100644 --- a/release-notes.md +++ b/release-notes.md @@ -7,6 +7,7 @@ - **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, …). +- **Settings › Keyboard gets an "Ext. KB" row** (boards that support CardKB, above). Switching it to **Compact** hides the on-screen letter grid and special-row icons — a CardKB typist never looks at them — replacing them with a one-line status (current script/page, caps) plus a reminder of the Fn shortcuts; the accent and placeholder popups still show normally. Off (**Full**) by default. ### Fixes From 176094d32eaf8e287bcfeb30ecabfc9902a311e9 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:56:46 +0200 Subject: [PATCH 06/13] fix(ui): Admin login could hang forever on "Logging in..." AdminScreen's LOGIN phase had no timeout, unlike its COMMAND phase (_cmd_deadline_ms). If a login reply never arrived -- most commonly a saved password gone stale after the remote node's password changed, silently dropped instead of nacked -- the screen stayed stuck with only a manual Cancel to escape. sendRoomLogin() now returns the same est_timeout sendAdminCommand() already exposes; AdminScreen uses it to arm a deadline (poll(), mirroring the COMMAND-phase pattern) that forgets the stale password and returns to the picker on expiry, same as an explicit login rejection already does. Same fix as hotfix/admin-login-timeout (23f43cac), split out of this branch's other in-progress work. Co-Authored-By: Claude Sonnet 5 --- examples/companion_radio/MyMesh.h | 3 +- examples/companion_radio/ui-new/AdminScreen.h | 28 ++++++++++++++++--- .../companion_radio/ui-new/MessagesScreen.h | 4 ++- release-notes.md | 1 + 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index cfbc6e52..8cb83607 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -222,8 +222,7 @@ public: // command source, so this reuses the same sendLogin() the BLE CMD_SEND_LOGIN // path uses; the async result lands in onContactResponse() and is pushed to // the UI via AbstractUITask::onRoomLoginResult(). - bool sendRoomLogin(const ContactInfo& contact, const char* password) { - uint32_t est_timeout; + bool sendRoomLogin(const ContactInfo& contact, const char* password, uint32_t& est_timeout) { if (sendLogin(contact, password, est_timeout) == MSG_SEND_FAILED) return false; clearPendingReqs(); memcpy(&ui_pending_login, contact.id.pub_key, 4); // match this in onContactResponse() diff --git a/examples/companion_radio/ui-new/AdminScreen.h b/examples/companion_radio/ui-new/AdminScreen.h index 6a692f52..9e1c2d91 100644 --- a/examples/companion_radio/ui-new/AdminScreen.h +++ b/examples/companion_radio/ui-new/AdminScreen.h @@ -55,6 +55,12 @@ class AdminScreen : public UIScreen { // was submitted. Distinct from the keyboard being open (kb().render()/ // handleInput() only run while this is false). bool _login_waiting = false; + // Deadline for _login_waiting, mirroring _cmd_deadline_ms/_waiting in the + // COMMAND phase below -- without it, a login reply that never arrives (e.g. + // the remote's password changed since it was saved, and it silently drops + // instead of nacking) leaves the screen stuck on "Logging in..." forever + // with only a manual Cancel to escape. See poll(). + uint32_t _login_deadline_ms = 0; // One-slot memo: the last contact successfully admin-logged-into this visit, // so re-entering COMMAND for the same target right after doesn't require @@ -151,9 +157,10 @@ class AdminScreen : public UIScreen { void startLoginWithSaved(const char* password) { strncpy(_login_pw, password, sizeof(_login_pw) - 1); _login_pw[sizeof(_login_pw) - 1] = '\0'; - bool sent = the_mesh.sendRoomLogin(_target, _login_pw); + uint32_t est_timeout = 0; + bool sent = the_mesh.sendRoomLogin(_target, _login_pw, est_timeout); _task->showAlert(sent ? "Logging in..." : "Login failed", sent ? 1000 : 1500); - if (sent) { _login_waiting = true; _phase = LOGIN; } + if (sent) { _login_waiting = true; _login_deadline_ms = millis() + est_timeout + 4000; _phase = LOGIN; } } void sendCommand() { @@ -410,6 +417,18 @@ public: } void poll() override { + if (_phase == LOGIN && _login_waiting && (int32_t)(millis() - _login_deadline_ms) >= 0) { + _login_waiting = false; + // No response at all is ambiguous (could be a wrong/stale password, could + // just be out of range) -- but a saved password that's gone stale (the + // remote's password changed) is exactly this: silence, not a nack. Treat + // it the same as onRoomLoginResult()'s explicit-failure branch: forget it + // so the next attempt prompts fresh instead of retrying the same dead + // password forever. + the_mesh.forgetRoomPassword(_target.id.pub_key); + _task->showAlert("Login failed (timeout)", 1400); + returnToOrigin(); + } if (_phase == COMMAND && _waiting && (int32_t)(millis() - _cmd_deadline_ms) >= 0) { _waiting = false; if (_fetch_for_edit) { fallBackToBlankEdit(); _task->showAlert("Fetch failed - enter value", 1400); } @@ -488,9 +507,10 @@ public: } else if (r == KeyboardWidget::DONE) { strncpy(_login_pw, kb().buf, sizeof(_login_pw) - 1); _login_pw[sizeof(_login_pw) - 1] = '\0'; - bool sent = the_mesh.sendRoomLogin(_target, _login_pw); + uint32_t est_timeout = 0; + bool sent = the_mesh.sendRoomLogin(_target, _login_pw, est_timeout); _task->showAlert(sent ? "Logging in..." : "Login failed", sent ? 1000 : 1500); - if (sent) _login_waiting = true; else returnToOrigin(); + if (sent) { _login_waiting = true; _login_deadline_ms = millis() + est_timeout + 4000; } else returnToOrigin(); // else: stay in LOGIN until onRoomLoginResult() fires above. } return true; diff --git a/examples/companion_radio/ui-new/MessagesScreen.h b/examples/companion_radio/ui-new/MessagesScreen.h index c24c1058..4d71ba9f 100644 --- a/examples/companion_radio/ui-new/MessagesScreen.h +++ b/examples/companion_radio/ui-new/MessagesScreen.h @@ -722,7 +722,9 @@ public: void startRoomLogin(const char* password) { strncpy(_login_pw, password, sizeof(_login_pw) - 1); _login_pw[sizeof(_login_pw) - 1] = 0; - bool sent = the_mesh.sendRoomLogin(_sel_contact, password); + uint32_t est_timeout = 0; // this screen's login isn't a blocking wait (see + // onRoomLoginResult() below), so no deadline needed + bool sent = the_mesh.sendRoomLogin(_sel_contact, password, est_timeout); _task->showAlert(sent ? "Logging in..." : "Login failed", sent ? 1000 : 1500); } diff --git a/release-notes.md b/release-notes.md index 382e05ef..3f27d1a9 100644 --- a/release-notes.md +++ b/release-notes.md @@ -12,6 +12,7 @@ ### Fixes - **DM/room replies sometimes didn't show who they were addressed to, and room messages could wrap incorrectly.** The reply-prefix (`@[nick] `) was being stripped at the wrong point for room posts and DMs feeding the fullscreen message view, so a DM reply's "To:" header could go missing while a room reply showed the raw `@[nick] ` marker as literal message text; a related mismatch meant the history list's scrollbar/box-height sizing pass wrapped room messages with their sender name still attached, disagreeing with the actual rendered text. +- **Tools › Admin login could get stuck on "Logging in..." forever** if the remote node never sent back a reply — most commonly after its admin/room password was changed, since the on-device UI auto-retries the old saved password with no timeout to fall back on. It now gives up after the same estimated-timeout window the rest of Admin's commands use, forgets the stale saved password, and returns you to the picker to try again with the new one. --- From 220d46de8e2cb1a6e7e6895230c53ec5f51dfc46 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:57:05 +0200 Subject: [PATCH 07/13] 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 --- .../tools_screen/tools_screen.md | 3 +- examples/companion_radio/MyMesh.cpp | 4 + examples/companion_radio/MyMesh.h | 30 ++++ examples/companion_radio/MyMeshBot.h | 132 +++++++++++++++++- release-notes.md | 1 + 5 files changed, 162 insertions(+), 8 deletions(-) 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, …). From 0ab74bdd4102a4e05d8e6ada279cb2a046dffdfb Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:13:33 +0200 Subject: [PATCH 08/13] fix(ui): Admin password change didn't update the saved login copy Same fix as hotfix/admin-login-timeout (05609019). Tools > Admin > System > "Admin password" changes the remote's admin credential but never updated this device's saved copy, so the next login retried the password just replaced -- likely the actual trigger behind the "stuck on Logging in..." report. Parses CommonCLI's "password now: " success echo and saves that as the new on-device password. Co-Authored-By: Claude Sonnet 5 --- examples/companion_radio/ui-new/AdminScreen.h | 17 +++++++++++++++++ release-notes.md | 1 + 2 files changed, 18 insertions(+) diff --git a/examples/companion_radio/ui-new/AdminScreen.h b/examples/companion_radio/ui-new/AdminScreen.h index 9e1c2d91..42164184 100644 --- a/examples/companion_radio/ui-new/AdminScreen.h +++ b/examples/companion_radio/ui-new/AdminScreen.h @@ -410,6 +410,23 @@ public: openValueKb(trimmed); // _pending_set_prefix is already set from activateField() return; } + // The "Admin password" row (SYSTEM_FIELDS, set-only) just changed the + // remote's own admin credential -- CommonCLI::handleCommand() always + // echoes it back as "password now: " (see src/helpers/CommonCLI.cpp), + // truncation and all, so parsing that confirms the exact value now + // required to log back in. Without this, our saved password goes stale + // the instant this command succeeds, guaranteeing the next login attempt + // fails (see the LOGIN-phase timeout fix above for what that used to look + // like: a permanent "Logging in..." hang with a stale saved password). + static const char PW_ECHO_PREFIX[] = "password now: "; + if (!strncmp(text, PW_ECHO_PREFIX, sizeof(PW_ECHO_PREFIX) - 1)) { + char newpw[32]; + strncpy(newpw, text + sizeof(PW_ECHO_PREFIX) - 1, sizeof(newpw) - 1); + newpw[sizeof(newpw) - 1] = '\0'; + size_t n = strlen(newpw); + while (n > 0 && (newpw[n-1] == '\n' || newpw[n-1] == '\r' || newpw[n-1] == ' ')) newpw[--n] = '\0'; + the_mesh.saveRoomPassword(_target.id.pub_key, newpw); + } strncpy(_reply_text, text, sizeof(_reply_text) - 1); _reply_text[sizeof(_reply_text) - 1] = '\0'; _reply_view.begin(); diff --git a/release-notes.md b/release-notes.md index c2129dec..fbcda4db 100644 --- a/release-notes.md +++ b/release-notes.md @@ -14,6 +14,7 @@ - **DM/room replies sometimes didn't show who they were addressed to, and room messages could wrap incorrectly.** The reply-prefix (`@[nick] `) was being stripped at the wrong point for room posts and DMs feeding the fullscreen message view, so a DM reply's "To:" header could go missing while a room reply showed the raw `@[nick] ` marker as literal message text; a related mismatch meant the history list's scrollbar/box-height sizing pass wrapped room messages with their sender name still attached, disagreeing with the actual rendered text. - **Tools › Admin login could get stuck on "Logging in..." forever** if the remote node never sent back a reply — most commonly after its admin/room password was changed, since the on-device UI auto-retries the old saved password with no timeout to fall back on. It now gives up after the same estimated-timeout window the rest of Admin's commands use, forgets the stale saved password, and returns you to the picker to try again with the new one. +- **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. --- From 9932fb01df15208b57ff59b993d73fa90cf83be1 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:21:31 +0200 Subject: [PATCH 09/13] fix(ui): late Admin login reply could be misrouted after giving up Same fix as hotfix/admin-login-timeout (5a5ebe9f). UITask::onRoomLoginResult() dispatches by whichever screen is currently shown, not by who sent the request, so a reply arriving after AdminScreen gave up (Cancel or the timeout fix) could land on MessagesScreen instead and persist its own unrelated _login_pw as the "confirmed" password for that pubkey. MyMesh::cancelUiPendingLogin(pub_key) stops tracking the request on give-up so a late reply matches nothing instead. Co-Authored-By: Claude Sonnet 5 --- examples/companion_radio/MyMesh.h | 13 +++++++++++++ examples/companion_radio/ui-new/AdminScreen.h | 11 ++++++++++- release-notes.md | 1 + 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 55dadba6..c186c46c 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -229,6 +229,19 @@ public: return true; } + // Called by a screen that gave up waiting on its own sendRoomLogin() (Cancel + // or a timeout) so a reply that arrives after doesn't get misrouted: + // UITask::onRoomLoginResult() dispatches by whichever screen is *currently* + // showing, not by who actually sent the request, so a late reply landing + // after the requester navigated away is delivered to whatever screen the + // user is on instead -- which then persists its own unrelated login state + // (e.g. a different screen's _login_pw) as if it were that reply's answer. + // Pubkey-guarded so this is a no-op if a newer request (this screen retrying, + // or a different screen entirely) has since overwritten ui_pending_login. + void cancelUiPendingLogin(const uint8_t* pub_key) { + if (ui_pending_login && memcmp(&ui_pending_login, pub_key, 4) == 0) ui_pending_login = 0; + } + // On-device UI logout: drops the local keep-alive tracking (mirrors the app's // CMD_LOGOUT) and forgets the saved password, so the next room open prompts // for credentials again instead of silently re-using them. No packet is sent diff --git a/examples/companion_radio/ui-new/AdminScreen.h b/examples/companion_radio/ui-new/AdminScreen.h index 42164184..ac7dc54a 100644 --- a/examples/companion_radio/ui-new/AdminScreen.h +++ b/examples/companion_radio/ui-new/AdminScreen.h @@ -436,6 +436,11 @@ public: void poll() override { if (_phase == LOGIN && _login_waiting && (int32_t)(millis() - _login_deadline_ms) >= 0) { _login_waiting = false; + // Stop tracking this request on the MyMesh side too -- otherwise a reply + // that still arrives after we've given up gets misrouted to whatever + // screen the user is on by then (see cancelUiPendingLogin()'s comment), + // corrupting that screen's unrelated login state with our answer. + the_mesh.cancelUiPendingLogin(_target.id.pub_key); // No response at all is ambiguous (could be a wrong/stale password, could // just be out of range) -- but a saved password that's gone stale (the // remote's password changed) is exactly this: silence, not a nack. Treat @@ -515,7 +520,11 @@ public: bool handleInput(char c) override { if (_phase == LOGIN) { if (_login_waiting) { - if (c == KEY_CANCEL) { _login_waiting = false; returnToOrigin(); } + if (c == KEY_CANCEL) { + _login_waiting = false; + the_mesh.cancelUiPendingLogin(_target.id.pub_key); // see cancelUiPendingLogin()'s comment + returnToOrigin(); + } return true; } auto r = kb().handleInput(c); diff --git a/release-notes.md b/release-notes.md index fbcda4db..be0bed9e 100644 --- a/release-notes.md +++ b/release-notes.md @@ -15,6 +15,7 @@ - **DM/room replies sometimes didn't show who they were addressed to, and room messages could wrap incorrectly.** The reply-prefix (`@[nick] `) was being stripped at the wrong point for room posts and DMs feeding the fullscreen message view, so a DM reply's "To:" header could go missing while a room reply showed the raw `@[nick] ` marker as literal message text; a related mismatch meant the history list's scrollbar/box-height sizing pass wrapped room messages with their sender name still attached, disagreeing with the actual rendered text. - **Tools › Admin login could get stuck on "Logging in..." forever** if the remote node never sent back a reply — most commonly after its admin/room password was changed, since the on-device UI auto-retries the old saved password with no timeout to fall back on. It now gives up after the same estimated-timeout window the rest of Admin's commands use, forgets the stale saved password, and returns you to the picker to try again with the new one. - **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. --- From 2c656f5af7495fe24bc905e7a0f5e6b72847e06d Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:27:02 +0200 Subject: [PATCH 10/13] fix(ui): a stale login reply for a different node could be accepted Same fix as hotfix/admin-login-timeout (96b44460). AdminScreen's only guard was "_phase == LOGIN" (true for any node sat at the login screen), not that the reply actually named _target. Now also checks pub_key against _target.id.pub_key. Co-Authored-By: Claude Sonnet 5 --- examples/companion_radio/ui-new/AdminScreen.h | 11 ++++++++++- release-notes.md | 1 + 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/examples/companion_radio/ui-new/AdminScreen.h b/examples/companion_radio/ui-new/AdminScreen.h index ac7dc54a..b89d5d86 100644 --- a/examples/companion_radio/ui-new/AdminScreen.h +++ b/examples/companion_radio/ui-new/AdminScreen.h @@ -357,7 +357,16 @@ public: // Async result of the on-device login, routed here from UITask::onRoomLoginResult() // only while this screen is current (see that routing for why). void onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) { - if (_phase != LOGIN) return; // stale/unrelated result + // Being in LOGIN phase isn't enough on its own -- this only confirms *some* + // login is in flight for *this screen*, not that this particular reply is + // for _target. UITask::onRoomLoginResult() dispatches by whichever screen + // is current, not by who sent the request, so e.g. a slow MessagesScreen + // login reply arriving while the user has since opened Admin on a + // *different*, password-less node (still sat at the blank LOGIN keyboard, + // so _phase == LOGIN here too) would otherwise be accepted as if it were + // this node's own login -- flipping _admin_ok/_phase to COMMAND for + // _target without ever actually authenticating with it. + if (_phase != LOGIN || memcmp(_target.id.pub_key, pub_key, 4) != 0) return; // stale/unrelated result _login_waiting = false; if (success && (permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_ADMIN) { memcpy(_admin_ok_prefix, pub_key, 4); diff --git a/release-notes.md b/release-notes.md index be0bed9e..edf4917e 100644 --- a/release-notes.md +++ b/release-notes.md @@ -16,6 +16,7 @@ - **Tools › Admin login could get stuck on "Logging in..." forever** if the remote node never sent back a reply — most commonly after its admin/room password was changed, since the on-device UI auto-retries the old saved password with no timeout to fall back on. It now gives up after the same estimated-timeout window the rest of Admin's commands use, forgets the stale saved password, and returns you to the picker to try again with the new one. - **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. --- From 7b6ae8e2cbc21906455bd8f76393129933f652ea Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:49:17 +0200 Subject: [PATCH 11/13] feat(gps): use HDOP for !gps fix readiness, satellite count as fallback Satellite count alone is a poor proxy for fix quality -- few satellites in good geometry can beat many in poor geometry. LocationProvider now exposes getHDOP() (default -1 = unsupported); MicroNMEA implements it. isLocFixReady() prefers HDOP <= 2.0 when available, falling back to the old >=8 satellite threshold for providers that don't report it (e.g. RAK12500/u-blox). Co-Authored-By: Claude Sonnet 5 --- .../tools_screen/tools_screen.md | 2 +- examples/companion_radio/MyMesh.h | 7 ++++++- examples/companion_radio/MyMeshBot.h | 20 +++++++++++++------ release-notes.md | 2 +- src/helpers/sensors/LocationProvider.h | 6 ++++++ .../sensors/MicroNMEALocationProvider.h | 1 + 6 files changed, 29 insertions(+), 9 deletions(-) diff --git a/docs/solo_features/tools_screen/tools_screen.md b/docs/solo_features/tools_screen/tools_screen.md index 12f7d4b1..a4b56f6a 100644 --- a/docs/solo_features/tools_screen/tools_screen.md +++ b/docs/solo_features/tools_screen/tools_screen.md @@ -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 (≥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`. | +| `!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`. | | `!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. diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index c186c46c..1c8186c1 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -338,6 +338,7 @@ private: 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); + static bool isLocFixReady(LocationProvider* loc); // HDOP if the provider has it, else satellite count 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? @@ -442,7 +443,11 @@ private: // !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 + // Readiness: HDOP (tenths, lower=better) when the provider exposes it -- + // 20 == HDOP 2.0, the usual "good fix" cutoff -- else satellite count as a + // cruder fallback (see LocationProvider::getHDOP()'s -1 = "not available"). + static const int LOCFIX_MAX_HDOP = 20; + static const int LOCFIX_MIN_SATS = 8; // readiness threshold (HDOP-less fallback) 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) diff --git a/examples/companion_radio/MyMeshBot.h b/examples/companion_radio/MyMeshBot.h index 4fdf6f3e..8b8db6c6 100644 --- a/examples/companion_radio/MyMeshBot.h +++ b/examples/companion_radio/MyMeshBot.h @@ -522,19 +522,27 @@ void MyMesh::startLocFix(uint8_t dest_type, const uint8_t* pub_key, uint8_t chan } // !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 +// Phase 1 (acquire): wait for a valid fix good enough to trust -- HDOP (fix +// geometry) when the provider exposes it, satellite count as a cruder +// fallback when it doesn't (see isLocFixReady()). 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 dip 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). +bool MyMesh::isLocFixReady(LocationProvider* loc) { + if (!loc || !loc->isValid()) return false; + long hdop = loc->getHDOP(); + if (hdop >= 0) return hdop <= LOCFIX_MAX_HDOP; // preferred: real fix-quality signal + return loc->satellitesCount() >= LOCFIX_MIN_SATS; // provider has no HDOP -- cruder fallback +} + void MyMesh::tickLocFix() { if (!_loc_fix.active) return; LocationProvider* loc = sensors.getLocationProvider(); - bool ready = loc && loc->isValid() && loc->satellitesCount() >= LOCFIX_MIN_SATS; + bool ready = isLocFixReady(loc); if (_loc_fix.averaging_until_ms == 0 && ready) { _loc_fix.averaging_until_ms = futureMillis(LOCFIX_AVERAGE_MS); diff --git a/release-notes.md b/release-notes.md index edf4917e..b9ed4d31 100644 --- a/release-notes.md +++ b/release-notes.md @@ -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 (≥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`. +- **`!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`. - **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, …). diff --git a/src/helpers/sensors/LocationProvider.h b/src/helpers/sensors/LocationProvider.h index 81d08652..08d531a8 100644 --- a/src/helpers/sensors/LocationProvider.h +++ b/src/helpers/sensors/LocationProvider.h @@ -14,6 +14,12 @@ public: virtual long getLongitude() = 0; virtual long getAltitude() = 0; virtual long satellitesCount() = 0; + // Horizontal Dilution of Precision, in tenths (11 == HDOP 1.1) -- lower is + // better, and a much more direct read on fix quality than satellite count + // alone (few satellites in good geometry can beat many in poor geometry). + // -1 means this provider doesn't expose it; callers fall back to + // satellitesCount() in that case. + virtual long getHDOP() { return -1; } virtual bool isValid() = 0; virtual long getTimestamp() = 0; virtual void sendSentence(const char * sentence); diff --git a/src/helpers/sensors/MicroNMEALocationProvider.h b/src/helpers/sensors/MicroNMEALocationProvider.h index eec466d3..f18a31ed 100644 --- a/src/helpers/sensors/MicroNMEALocationProvider.h +++ b/src/helpers/sensors/MicroNMEALocationProvider.h @@ -120,6 +120,7 @@ public : return alt; } long satellitesCount() override { return nmea.getNumSatellites(); } + long getHDOP() override { return nmea.getHDOP(); } bool isValid() override { return nmea.isValid(); } long getTimestamp() override { From 0a61b7ef153374562b510f3f9386888e3e46d856 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 24 Jul 2026 23:51:31 +0200 Subject: [PATCH 12/13] 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+ 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 --- docs/solo_features/screen_lock/screen_lock.md | 16 ++- .../tools_screen/tools_screen.md | 2 +- examples/companion_radio/MyMesh.cpp | 5 + examples/companion_radio/MyMesh.h | 22 ++- examples/companion_radio/MyMeshBot.h | 129 ++++++++++++++---- examples/companion_radio/ui-new/UITask.cpp | 36 ++++- release-notes.md | 4 +- 7 files changed, 173 insertions(+), 41 deletions(-) diff --git a/docs/solo_features/screen_lock/screen_lock.md b/docs/solo_features/screen_lock/screen_lock.md index 398f3d5d..7e077798 100644 --- a/docs/solo_features/screen_lock/screen_lock.md +++ b/docs/solo_features/screen_lock/screen_lock.md @@ -16,15 +16,17 @@ 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. -| Step | Hint | -| -------------- | --------------------- | -| Not started | _Hold Back + 3×Enter_ | -| 1 press done | _Enter ×2 more…_ | -| 2 presses done | _Enter ×1 more…_ | +The hint popup at the bottom of the lock screen guides through the physical sequence: + +| Step | Hint | +| -------------- | ------------------------------------------------------------ | +| 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…_ | If no press is made for 3 seconds, the counter resets. diff --git a/docs/solo_features/tools_screen/tools_screen.md b/docs/solo_features/tools_screen/tools_screen.md index a4b56f6a..5f19a078 100644 --- a/docs/solo_features/tools_screen/tools_screen.md +++ b/docs/solo_features/tools_screen/tools_screen.md @@ -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. diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index ece1f6da..59373996 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -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; diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 1c8186c1..28e776b6 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -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; diff --git a/examples/companion_radio/MyMeshBot.h b/examples/companion_radio/MyMeshBot.h index 8b8db6c6..4eff22db 100644 --- a/examples/companion_radio/MyMeshBot.h +++ b/examples/companion_radio/MyMeshBot.h @@ -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); } diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index f18b4eb1..dc9bc858 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -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+ 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+ -- 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); diff --git a/release-notes.md b/release-notes.md index b9ed4d31..12bced20 100644 --- a/release-notes.md +++ b/release-notes.md @@ -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+``** 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+`` 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. --- From 5844fa23906514c43453416ab117df006847711e Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:00:07 +0200 Subject: [PATCH 13/13] refactor(bot,ui): dedupe bot token parsing, drop redundant turnOn, fix comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-review cleanups, no behaviour change: - botScanCommands() parsed the command name and its two args with three near-identical read-token loops; extracted a single readToken() lambda. - Fn+Esc lock branch turned the display on twice (the unlock arm repeated what the branch head already did); dropped the redundant call. - setGpioMode()'s comment said "Cycle" (cycling lives in GpioScreen); now describes what it actually does — set a specific mode + persist. Co-Authored-By: Claude Opus 4.8 --- examples/companion_radio/MyMeshBot.h | 27 ++++++++++------------ examples/companion_radio/ui-new/UITask.cpp | 7 +++--- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/examples/companion_radio/MyMeshBot.h b/examples/companion_radio/MyMeshBot.h index 4eff22db..06a39823 100644 --- a/examples/companion_radio/MyMeshBot.h +++ b/examples/companion_radio/MyMeshBot.h @@ -388,6 +388,15 @@ int MyMesh::botScanCommands(const char* body, uint8_t hops, uint32_t ts, char* o const char* sender_name, bool actions_allowed) { resetPendingBotActions(); // pending actions/flags below are set by tokens in this scan; // caller applies or clears them once it knows if the reply sent + // Reads one space-delimited token at p into buf (lowercased, truncated to + // cap-1 chars with any overflow consumed too), leaving p on the space/NUL + // that ended it. Shared by the command name and its two optional args. + auto readToken = [](const char*& p, char* buf, int cap) { + int i = 0; + while (*p && *p != ' ' && i < cap - 1) buf[i++] = (char)tolower((uint8_t)*p++); + buf[i] = '\0'; + while (*p && *p != ' ') p++; + }; int oi = 0; int matched = 0; for (const char* p = body; *p; ) { @@ -398,11 +407,7 @@ int MyMesh::botScanCommands(const char* body, uint8_t hops, uint32_t ts, char* o } p++; // step past '!' char cmd[16]; - int ci = 0; - while (*p && *p != ' ' && ci < (int)sizeof(cmd) - 1) - cmd[ci++] = (char)tolower((uint8_t)*p++); - cmd[ci] = '\0'; - while (*p && *p != ' ') p++; // consume any overflow of this token + readToken(p, cmd, sizeof(cmd)); if (!cmd[0]) continue; // Up to two optional arguments: the next one or two space-delimited @@ -416,17 +421,9 @@ int MyMesh::botScanCommands(const char* body, uint8_t hops, uint32_t ts, char* o while (*p == ' ') p++; char arg1[16] = "", arg2[16] = ""; if (*p && *p != '!') { - int 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 + readToken(p, arg1, sizeof(arg1)); 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 - } + if (*p && *p != '!') readToken(p, arg2, sizeof(arg2)); } char seg[80]; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index dc9bc858..49bc3630 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -2146,7 +2146,6 @@ void UITask::pollCardKB() { 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; } @@ -3159,8 +3158,10 @@ static uint16_t readAnalogMv(uint32_t psel) { } #endif -// Cycle a user GPIO pin's mode (Off/In/Out-low/Out-high), apply it to the -// actual pin, and persist. Called by GpioScreen on Enter. +// Set a user GPIO pin to a specific mode (0=Off 1=In 2=Out-low 3=Out-high +// 4=Analog), apply it to the actual pin, and persist. The Off->In->Out->... +// cycling itself lives in GpioScreen; the bot's !gpioN on/off and boot +// restore also route through here. void UITask::setGpioMode(int idx, uint8_t mode) { #if defined(PIN_GPIO1) if (!_node_prefs) return;