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] 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)