Merge branch 'bot-actions-gpio'

# Conflicts:
#	release-notes.md
This commit is contained in:
Jakub
2026-07-25 18:46:34 +02:00
23 changed files with 1350 additions and 154 deletions

View File

@@ -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 - [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: - **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 - **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 | | [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 | | [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 | | [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 | | [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 ### Upstream MeshCore

View File

@@ -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. **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 | The hint popup at the bottom of the lock screen guides through the physical sequence:
| -------------- | --------------------- |
| Not started | _Hold Back + 3×Enter_ | | Step | Hint |
| 1 press done | _Enter ×2 more…_ | | -------------- | ------------------------------------------------------------ |
| 2 presses done | _Enter ×1 more…_ | | 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. If no press is made for 3 seconds, the counter resets.

View File

@@ -8,7 +8,7 @@
| :-----------------------: | :-----------------------: | | :-----------------------: | :-----------------------: |
| ![](./overview_oled.png) | ![](./overview_eink.png) | | ![](./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 | | OLED | E-Ink |
| :-----------------------: | :-----------------------: | | :-----------------------: | :-----------------------: |
@@ -367,7 +367,7 @@ Melodies can be assigned in **Settings Sound** (global default) or overridde
<!-- screenshots pending: these predate the tab-carousel layout below (still show the old flat grouped list) --> <!-- screenshots pending: these predate the tab-carousel layout below (still show the old flat grouped list) -->
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). 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,21 @@ 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. 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. |
| `!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.
On boards with user GPIO (see **GPIO** under System tools below), the same Actions gate also covers `!gpio1`..`!gpio4`.
--- ---
## Diagnostics ## Diagnostics
@@ -453,7 +468,7 @@ Each target's Commands toggle is independent — e.g. answer `!ping` in DMs but
<!-- screenshot pending: Diagnostics — live device/mesh stats rows (uptime, rx/tx counters, heap, RSSI/SNR, queue, errors) --> <!-- screenshot pending: Diagnostics — live device/mesh stats rows (uptime, rx/tx counters, heap, RSSI/SNR, queue, errors) -->
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 | | Tab | Shows |
| --- | ----- | | --- | ----- |
@@ -486,6 +501,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 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).
---
## Repeater ## Repeater
| OLED | E-Ink | | OLED | E-Ink |
@@ -529,7 +558,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. 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". 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 | | Tab | Rows |
| --- | ---- | | --- | ---- |

View File

@@ -54,6 +54,22 @@ public:
// Text reply to an on-device-UI-triggered MyMesh::sendAdminCommand() arrived // Text reply to an on-device-UI-triggered MyMesh::sendAdminCommand() arrived
// (see AdminScreen). pub_key is the contact's key prefix (>=4 bytes valid). // (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; } 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 // True only when a BLE central is actually bonded/connected. On a dual
// (BLE+USB) interface hasConnection() is always true (USB counts), so use // (BLE+USB) interface hasConnection() is always true (USB counts), so use
// this for BLE-specific UI like the pairing-PIN prompt. // this for BLE-specific UI like the pairing-PIN prompt.

View File

@@ -526,6 +526,37 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
rd(&_prefs.keyboard_main_alphabet, sizeof(_prefs.keyboard_main_alphabet)); rd(&_prefs.keyboard_main_alphabet, sizeof(_prefs.keyboard_main_alphabet));
if (_prefs.keyboard_main_alphabet >= NodePrefs::KB_ALPHABET_COUNT) _prefs.keyboard_main_alphabet = 0; 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;
// → 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 // Schema sentinel: bumped on layout changes. Mismatch means an older file
// (or a different schema); rd() already zero-inits any fields not present, // (or a different schema); rd() already zero-inits any fields not present,
// so we just log it — next savePrefs writes the current sentinel. // so we just log it — next savePrefs writes the current sentinel.
@@ -730,6 +761,14 @@ 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_ch, sizeof(_prefs.bot_commands_ch));
file.write((uint8_t *)&_prefs.bot_commands_room, sizeof(_prefs.bot_commands_room)); 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.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));
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 // 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 // the one we check: once the flash fills, writes return 0, so a good

View File

@@ -1550,6 +1550,13 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
memset(_bot_dm_log, 0, sizeof(_bot_dm_log)); memset(_bot_dm_log, 0, sizeof(_bot_dm_log));
_bot_reply_count = 0; _bot_reply_count = 0;
_next_auto_advert_ms = 0; _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(); clearPendingReqs();
next_ack_idx = 0; next_ack_idx = 0;
sign_data = NULL; sign_data = NULL;
@@ -3083,6 +3090,8 @@ void MyMesh::loop() {
} }
} }
tickLocFix();
if (_cli_rescue) { if (_cli_rescue) {
checkCLIRescueCmd(); checkCLIRescueCmd();
} else { } else {

View File

@@ -328,8 +328,17 @@ private:
bool tryBotCommand(const ContactInfo& from, const char* text, uint8_t hops); // DM commands 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 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 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 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); // scan "!word"s → combined reply, returns count 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);
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 botTriggerMatches(const char* trigger, const char* body, bool allow_wildcard) const;
bool botInQuietHours() const; // true when auto-replies should stay silent bool botInQuietHours() const; // true when auto-replies should stay silent
bool botDmAllowed(const uint8_t* pubkey); // per-contact DM throttle: ok to reply? bool botDmAllowed(const uint8_t* pubkey); // per-contact DM throttle: ok to reply?
@@ -431,6 +440,52 @@ private:
BotReplyLog _bot_dm_log[BOT_DM_LOG_SIZE]; BotReplyLog _bot_dm_log[BOT_DM_LOG_SIZE];
uint16_t _bot_reply_count; // total auto-replies sent since boot 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.
// 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)
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
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; TransportKey send_scope;
uint8_t cmd_frame[MAX_FRAME_SIZE + 1]; uint8_t cmd_frame[MAX_FRAME_SIZE + 1];

View File

@@ -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. // incoming message (MAX_TEXT_LEN ~160) plus the 140-byte reply templates.
static const int BOT_SCRATCH = 200; static const int BOT_SCRATCH = 200;
// Case-insensitive (ASCII) substring test: does `body` contain `trigger`? A lone // Case-insensitive (ASCII) substring test: does `body` contain any one of
// "*" trigger means "match everything" (away / reply-to-all mode); honoured only // `trigger`'s comma-separated phrases? A lone "*" trigger (the whole field,
// where allow_wildcard is set. DM, channel and room each pass their own trigger string. // 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 { 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]) return false; // empty trigger would match everything
if (trigger[0] == '*' && trigger[1] == '\0') 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]); for (size_t i = 0; i < n; i++) low[i] = (char)tolower((uint8_t)body[i]);
low[n] = '\0'; low[n] = '\0';
char low_trigger[64]; // Multiple phrases can be packed into one Trigger field, comma-separated
size_t tlen = strnlen(trigger, sizeof(low_trigger) - 1); // ("hi,hello there,yo") -- any single phrase matching is enough. Each
for (size_t i = 0; i < tlen; i++) low_trigger[i] = (char)tolower((uint8_t)trigger[i]); // phrase is trimmed of surrounding spaces so "foo, bar" and "foo,bar"
low_trigger[tlen] = '\0'; // 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/ // DM allow-list gate: when bot_dm_scope is favourites-only, ignore triggers/
@@ -238,13 +256,107 @@ void MyMesh::tryBotReplyRoom(const ContactInfo& from, const uint8_t* sender_pref
// reply without needing a template); the rest expand a static template via // reply without needing a template); the rest expand a static template via
// expandMsg's placeholders, including {name}/{hops} if the sender wrote them // 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. // 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, 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 (!strcmp(cmd, "hops")) {
if (hops == 0) strncpy(out, "direct", out_len); // heard directly (0 repeaters) if (hops == 0) strncpy(out, "direct", out_len); // heard directly (0 repeaters)
else snprintf(out, out_len, "%u hops", (unsigned)hops); else snprintf(out, out_len, "%u hops", (unsigned)hops);
out[out_len - 1] = '\0'; out[out_len - 1] = '\0';
return true; 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")) {
// "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; }
// 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;
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")) {
// 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"). 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; }
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);
}
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; const char* tmpl = nullptr;
if (!strcmp(cmd, "ping")) tmpl = "pong"; if (!strcmp(cmd, "ping")) tmpl = "pong";
else if (!strcmp(cmd, "batt")) tmpl = "{batt}"; else if (!strcmp(cmd, "batt")) tmpl = "{batt}";
@@ -252,7 +364,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, "time")) tmpl = "{time}";
else if (!strcmp(cmd, "temp")) tmpl = "{temp}"; else if (!strcmp(cmd, "temp")) tmpl = "{temp}";
else if (!strcmp(cmd, "status")) tmpl = "batt {batt} loc {loc} at {time}"; 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 else return false; // unknown — let the trigger bot try
expandMsg(tmpl, out, out_len, expandMsg(tmpl, out, out_len,
@@ -268,7 +382,21 @@ 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 // Scans body for any number of space-separated "!word" tokens, building a single
// combined reply (segments joined by " | ") into out. Returns how many commands // combined reply (segments joined by " | ") into out. Returns how many commands
// were recognised. Shared by the DM, channel and room command paths. // 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) {
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 oi = 0;
int matched = 0; int matched = 0;
for (const char* p = body; *p; ) { for (const char* p = body; *p; ) {
@@ -279,15 +407,27 @@ int MyMesh::botScanCommands(const char* body, uint8_t hops, uint32_t ts, char* o
} }
p++; // step past '!' p++; // step past '!'
char cmd[16]; char cmd[16];
int ci = 0; readToken(p, cmd, sizeof(cmd));
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
if (!cmd[0]) continue; if (!cmd[0]) continue;
char seg[64]; // Up to two optional arguments: the next one or two space-delimited
if (!botCommandReply(cmd, hops, ts, seg, sizeof(seg), sender_name)) continue; // unknown // 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 arg1[16] = "", arg2[16] = "";
if (*p && *p != '!') {
readToken(p, arg1, sizeof(arg1));
while (*p == ' ') p++;
if (*p && *p != '!') readToken(p, arg2, sizeof(arg2));
}
char seg[80];
if (!botCommandReply(cmd, arg1, arg2, actions_allowed, hops, ts, seg, sizeof(seg), sender_name)) continue; // unknown
if (matched > 0 && oi + 3 < out_len) { // " | " separator if (matched > 0 && oi + 3 < out_len) { // " | " separator
out[oi++] = ' '; out[oi++] = '|'; out[oi++] = ' '; out[oi++] = ' '; out[oi++] = '|'; out[oi++] = ' ';
@@ -311,8 +451,8 @@ bool MyMesh::tryBotCommand(const ContactInfo& from, const char* text, uint8_t ho
uint32_t ts = getRTCClock()->getCurrentTime(); uint32_t ts = getRTCClock()->getCurrentTime();
char out[BOT_SCRATCH]; 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 if (!botDmAllowed(from.id.pub_key)) { resetPendingBotActions(); return true; } // throttled
uint32_t expected_ack, est_timeout; uint32_t expected_ack, est_timeout;
if (sendMessage(from, ts, 0, out, expected_ack, est_timeout) != MSG_SEND_FAILED) { if (sendMessage(from, ts, 0, out, expected_ack, est_timeout) != MSG_SEND_FAILED) {
@@ -321,7 +461,10 @@ bool MyMesh::tryBotCommand(const ContactInfo& from, const char* text, uint8_t ho
#ifdef DISPLAY_CLASS #ifdef DISPLAY_CLASS
if (_ui) _ui->addDMMsg(from.id.pub_key, true, out); if (_ui) _ui->addDMMsg(from.id.pub_key, true, out);
#endif #endif
if (_locfix_requested) startLocFix(LOCFIX_DEST_CONTACT, from.id.pub_key, 0);
applyPendingBotActions();
} }
resetPendingBotActions();
return true; return true;
} }
@@ -340,12 +483,12 @@ bool MyMesh::tryBotChannelCommand(uint8_t channel_idx, const char* text, uint8_t
uint32_t ts = getRTCClock()->getCurrentTime(); uint32_t ts = getRTCClock()->getCurrentTime();
char out[BOT_SCRATCH]; 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 (botInQuietHours()) { resetPendingBotActions(); return true; } // quiet hours
if (millis() - _bot_last_ch_reply_ms <= BOT_REPLY_COOLDOWN_MS) return true; // throttled if (millis() - _bot_last_ch_reply_ms <= BOT_REPLY_COOLDOWN_MS) { resetPendingBotActions(); return true; } // throttled
ChannelDetails ch; ChannelDetails ch;
if (!getChannel(channel_idx, ch)) return true; if (!getChannel(channel_idx, ch)) { resetPendingBotActions(); return true; }
int rlen = strlen(out); int rlen = strlen(out);
if (sendGroupMessage(ts, ch.channel, _prefs.node_name, out, rlen)) { if (sendGroupMessage(ts, ch.channel, _prefs.node_name, out, rlen)) {
_bot_last_ch_reply_ms = millis(); _bot_last_ch_reply_ms = millis();
@@ -357,7 +500,10 @@ bool MyMesh::tryBotChannelCommand(uint8_t channel_idx, const char* text, uint8_t
_ui->addChannelMsg(channel_idx, with_sender); _ui->addChannelMsg(channel_idx, with_sender);
} }
#endif #endif
if (_locfix_requested) startLocFix(LOCFIX_DEST_CHANNEL, nullptr, channel_idx);
applyPendingBotActions();
} }
resetPendingBotActions();
return true; return true;
} }
@@ -375,9 +521,9 @@ bool MyMesh::tryBotRoomCommand(const ContactInfo& from, const uint8_t* sender_pr
uint32_t ts = getRTCClock()->getCurrentTime(); uint32_t ts = getRTCClock()->getCurrentTime();
char out[BOT_SCRATCH]; 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 (botInQuietHours()) { resetPendingBotActions(); return true; } // quiet hours
if (millis() - _bot_last_room_reply_ms <= BOT_REPLY_COOLDOWN_MS) return true; // throttled if (millis() - _bot_last_room_reply_ms <= BOT_REPLY_COOLDOWN_MS) { resetPendingBotActions(); return true; } // throttled
uint32_t expected_ack, est_timeout; uint32_t expected_ack, est_timeout;
if (sendMessage(from, ts, 0, out, expected_ack, est_timeout) != MSG_SEND_FAILED) { if (sendMessage(from, ts, 0, out, expected_ack, est_timeout) != MSG_SEND_FAILED) {
@@ -386,6 +532,145 @@ bool MyMesh::tryBotRoomCommand(const ContactInfo& from, const uint8_t* sender_pr
#ifdef DISPLAY_CLASS #ifdef DISPLAY_CLASS
if (_ui) _ui->addDMMsg(from.id.pub_key, true, out); if (_ui) _ui->addDMMsg(from.id.pub_key, true, out);
#endif #endif
if (_locfix_requested) startLocFix(LOCFIX_DEST_CONTACT, from.id.pub_key, 0);
applyPendingBotActions();
} }
resetPendingBotActions();
return true; 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
// 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_requested_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 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 = isLocFixReady(loc);
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
}
}
}

View File

@@ -399,6 +399,40 @@ struct NodePrefs { // persisted to file
// main behaviour for upgraders. // main behaviour for upgraders.
uint8_t keyboard_main_alphabet; 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;
// 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 // Single source of truth for the live-share option tables (shared by the Map
// UI labels and the auto-send engine in UITask). // UI labels and the auto-send engine in UITask).
static const uint8_t LOC_SHARE_MOVE_COUNT = 4; static const uint8_t LOC_SHARE_MOVE_COUNT = 4;
@@ -461,7 +495,7 @@ struct NodePrefs { // persisted to file
// adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so // adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so
// older saves are detected on load and skipped (zero-init defaults kept). // 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. // 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 = 0xC0DE0023;
// Bit-index for each home page. Used by page_order (entries store bit+1) and // 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 // by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage
@@ -558,10 +592,15 @@ struct NodePrefs { // persisted to file
// 3. clamp it on load (an upgrader's file lacks it → stray bytes) // 3. clamp it on load (an upgrader's file lacks it → stray bytes)
// 4. bump SCHEMA_SENTINEL's low byte // 4. bump SCHEMA_SENTINEL's low byte
// (Padding can also shift sizeof; a "false" trip just means re-check + rebump.) // (Padding can also shift sizeof; a "false" trip just means re-check + rebump.)
// keyboard_main_alphabet (added alongside this bump) landed in existing tail // keyboard_cardkb_compact (0xC0DE0023) also landed in existing tail padding --
// padding -- confirmed via a real build's sizeof() -- so the size is // confirmed via a real build -- leaving sizeof unchanged at 2720.
// unchanged from before that field existed. // keyboard_main_alphabet (added in an earlier bump) landed in existing tail
static_assert(sizeof(NodePrefs) == 2712, // 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 " "NodePrefs layout changed — sync DataStore save/load + clamp, bump "
"SCHEMA_SENTINEL, then update this size (see steps above)."); "SCHEMA_SENTINEL, then update this size (see steps above).");

View File

@@ -16,9 +16,9 @@ class BotScreen : public UIScreen {
enum Tab : uint8_t { TAB_CHANNEL, TAB_ROOM, TAB_DIRECT, TAB_OTHER, TAB_COUNT }; enum Tab : uint8_t { TAB_CHANNEL, TAB_ROOM, TAB_DIRECT, TAB_OTHER, TAB_COUNT };
static const char* TAB_LABELS[TAB_COUNT]; static const char* TAB_LABELS[TAB_COUNT];
enum Kind : uint8_t { ENABLE_DM, DM_SCOPE, COMMANDS_DM, TRIGGER_DM, REPLY_DM, enum Kind : uint8_t { ENABLE_DM, DM_SCOPE, COMMANDS_DM, ACTIONS_DM, TRIGGER_DM, REPLY_DM,
ENABLE_CH, CHANNEL, COMMANDS_CH, TRIGGER_CH, REPLY_CH, ENABLE_CH, CHANNEL, COMMANDS_CH, ACTIONS_CH, TRIGGER_CH, REPLY_CH,
ENABLE_ROOM, ROOM, COMMANDS_ROOM, TRIGGER_ROOM, REPLY_ROOM, ENABLE_ROOM, ROOM, COMMANDS_ROOM, ACTIONS_ROOM, TRIGGER_ROOM, REPLY_ROOM,
QUIET_FROM, QUIET_TO }; QUIET_FROM, QUIET_TO };
struct Row { Kind kind; const char* label; }; struct Row { Kind kind; const char* label; };
@@ -33,6 +33,7 @@ class BotScreen : public UIScreen {
{ ENABLE_DM, "Enable" }, { ENABLE_DM, "Enable" },
{ DM_SCOPE, "DM allow" }, { DM_SCOPE, "DM allow" },
{ COMMANDS_DM, "Commands" }, { COMMANDS_DM, "Commands" },
{ ACTIONS_DM, "Actions" },
{ TRIGGER_DM, "Trigger" }, { TRIGGER_DM, "Trigger" },
{ REPLY_DM, "Reply" }, { REPLY_DM, "Reply" },
}; };
@@ -40,6 +41,7 @@ class BotScreen : public UIScreen {
{ ENABLE_CH, "Enable" }, { ENABLE_CH, "Enable" },
{ CHANNEL, "Channel" }, { CHANNEL, "Channel" },
{ COMMANDS_CH, "Commands" }, { COMMANDS_CH, "Commands" },
{ ACTIONS_CH, "Actions" },
{ TRIGGER_CH, "Trigger" }, { TRIGGER_CH, "Trigger" },
{ REPLY_CH, "Reply" }, { REPLY_CH, "Reply" },
}; };
@@ -47,6 +49,7 @@ class BotScreen : public UIScreen {
{ ENABLE_ROOM, "Enable" }, { ENABLE_ROOM, "Enable" },
{ ROOM, "Room" }, { ROOM, "Room" },
{ COMMANDS_ROOM, "Commands" }, { COMMANDS_ROOM, "Commands" },
{ ACTIONS_ROOM, "Actions" },
{ TRIGGER_ROOM, "Trigger" }, { TRIGGER_ROOM, "Trigger" },
{ REPLY_ROOM, "Reply" }, { REPLY_ROOM, "Reply" },
}; };
@@ -218,6 +221,15 @@ public:
case COMMANDS_ROOM: case COMMANDS_ROOM:
display.print(_prefs->bot_commands_room ? "ON" : "OFF"); display.print(_prefs->bot_commands_room ? "ON" : "OFF");
break; 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_FROM:
case QUIET_TO: { case QUIET_TO: {
bool off = (_prefs->bot_quiet_start == _prefs->bot_quiet_end); 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_DM: _prefs->bot_commands_enabled ^= 1; _dirty = true; return true;
case COMMANDS_CH: _prefs->bot_commands_ch ^= 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 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: case CHANNEL:
// Full browsable channel picker (same experience as Live Share's "To" // Full browsable channel picker (same experience as Live Share's "To"
// row); which channel is *active* is now the separate Enable row. // 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 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 };

View File

@@ -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 <helpers/ui/DisplayDriver.h>
#include <helpers/ui/UIScreen.h>
#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

View File

@@ -526,81 +526,106 @@ struct KeyboardWidget {
return 50; return 50;
} }
// character grid // Compact mode (Settings > Keyboard's "Ext. KB" row): an external-keyboard
if (isT9()) { // typist never looks at the letter grid or special-row icons, so skip
for (int r = 0; r < rows; r++) { // drawing them and show a one-line status (current script/page, caps)
int y = chars_y + r * cell_h; // instead. row/col/page keep updating exactly as before even while this
for (int c = 0; c < cols; c++) { // is on (arrows and Fn+letter both still work; the accent popup below
bool sel = (row == r && col == c); // still anchors on `row`), so nothing breaks if physical buttons get used
int cell = r * cols + c; // meanwhile -- it just won't be visible which cell is selected.
// Label the cell "<digit><group>" so it reads like a phone keypad. The if (prefs && prefs->keyboard_cardkb_compact) {
// digit is what the multi-tap cycle lands on after the letters (see const int hh = lh + 2;
// 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); 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 "<digit><group>" 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 // Accent popup: floats over the still-visible grid (same idea as the
@@ -645,6 +670,27 @@ struct KeyboardWidget {
return 50; 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) { Result handleInput(char c) {
// placeholder overlay consumes all input // placeholder overlay consumes all input
if (_ph_menu.active) { if (_ph_menu.active) {
@@ -721,6 +767,31 @@ struct KeyboardWidget {
if (c == KEY_CANCEL) return CANCELLED; 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 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) {
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 rows = gridRows();
const int cols = gridCols(); const int cols = gridCols();
@@ -798,6 +869,10 @@ struct KeyboardWidget {
return NONE; return NONE;
} }
if (c == KEY_ENTER) { 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()) { if (row < rows && isT9()) {
int cell = row * cols + col; int cell = row * cols + col;
const char* group = t9GroupStr(cell); const char* group = t9GroupStr(cell);

View File

@@ -136,7 +136,10 @@ class MessagesScreen : public UIScreen {
struct HistScroll { bool need; int reserve; long total_px, scroll_px; int view_px; }; 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), // `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 <class GetBody> template <class GetBody>
HistScroll computeHistScroll(DisplayDriver& display, bool portrait, int count, int scroll, HistScroll computeHistScroll(DisplayDriver& display, bool portrait, int count, int scroll,
int hist_start_y, int cby, int lh, GetBody getBody) { 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 { auto boxH = [&](int idx, int rsv) -> int {
const char* body = getBody(idx); const char* body = getBody(idx);
if (!body) return fixed_bh; 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); int nl = FullscreenMsgView::wrapLines(display, s_wrap_trans, display.width() - 6 - rsv, s_wrap_lines, 8);
return (1 + (nl > 0 ? nl : 1)) * lh + 1; 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 // stored "Sender: text" (MyMesh::queueMessage); split that off so each line is
// attributed to its guest. Outgoing → "Me"; plain DMs (or no separator) keep // attributed to its guest. Outgoing → "Me"; plain DMs (or no separator) keep
// the contact name and the text unchanged. // 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, const char* dmDisplayParts(const DmHistEntry& e, bool is_room, const char* contact_name,
char* sender_buf, int sender_cap) const { char* sender_buf, int sender_cap) const {
const char* body = skipReplyPrefix(e.text); const char* body = e.text;
if (e.outgoing) { if (e.outgoing) {
strncpy(sender_buf, "Me", sender_cap - 1); strncpy(sender_buf, "Me", sender_cap - 1);
} else if (is_room) { } else if (is_room) {
@@ -1033,6 +1047,8 @@ public:
if (ring_pos >= 0) { if (ring_pos >= 0) {
const DmHistEntry& e = _history.dmAtPos(ring_pos); const DmHistEntry& e = _history.dmAtPos(ring_pos);
char sender_buf[33]; 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, const char* body = dmDisplayParts(e, _sel_contact.type == ADV_TYPE_ROOM,
filtered_name, sender_buf, sizeof(sender_buf)); filtered_name, sender_buf, sizeof(sender_buf));
const char* sender = sender_buf; const char* sender = sender_buf;
@@ -1075,7 +1091,15 @@ public:
hist_start_y, cby, lh, hist_start_y, cby, lh,
[&](int idx) -> const char* { [&](int idx) -> const char* {
int rp = _history.dmHistEntryForContact(_sel_contact.id.pub_key, idx); 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; int reserve = hs.reserve;
{ {
@@ -1093,7 +1117,7 @@ public:
int rp = _history.dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_scroll + ii); int rp = _history.dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_scroll + ii);
if (rp >= 0) { if (rp >= 0) {
char hsb[33]; 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)); 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); int nl = FullscreenMsgView::wrapLines(display, s_wrap_trans, display.width() - 6 - reserve, s_wrap_lines, 8);
bh = (1 + (nl > 0 ? nl : 1)) * lh + 1; bh = (1 + (nl > 0 ? nl : 1)) * lh + 1;
@@ -1117,7 +1141,7 @@ public:
const DmHistEntry& e = _history.dmAtPos(ring_pos); const DmHistEntry& e = _history.dmAtPos(ring_pos);
char sender_buf[33]; 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; const char* sender = sender_buf;
char age[6]; geo::fmtAgeShort(age, sizeof(age), now_ts, e.timestamp); char age[6]; geo::fmtAgeShort(age, sizeof(age), now_ts, e.timestamp);
@@ -1235,7 +1259,7 @@ public:
if (rp < 0) return nullptr; if (rp < 0) return nullptr;
const char* t = _history.chAtPos(rp).text; const char* t = _history.chAtPos(rp).text;
const char* s = strstr(t, ": "); const char* s = strstr(t, ": ");
return s ? s + 2 : t; return skipReplyPrefix(s ? s + 2 : t);
}); });
int reserve = hs.reserve; int reserve = hs.reserve;
{ {

View File

@@ -73,6 +73,9 @@ class SettingsScreen : public UIScreen {
KEYBOARD_TYPE, KEYBOARD_TYPE,
KEYBOARD_MAIN_ALPHABET, KEYBOARD_MAIN_ALPHABET,
KEYBOARD_ALPHABET, KEYBOARD_ALPHABET,
#if defined(ENV_PIN_SDA) && defined(ENV_PIN_SCL)
KEYBOARD_CARDKB_COMPACT,
#endif
// Contacts section // Contacts section
SECTION_CONTACTS, DM_FILTER, CH_FILTER, ROOM_FILTER, SECTION_CONTACTS, DM_FILTER, CH_FILTER, ROOM_FILTER,
// Messages section // Messages section
@@ -574,6 +577,12 @@ class SettingsScreen : public UIScreen {
display.print("Additional"); display.print("Additional");
display.setCursor(valCol(display), y); display.setCursor(valCol(display), y);
display.print(NodePrefs::keyboardAlphabetLabel(p ? p->keyboard_alt_alphabet : 0)); 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) { } else if (item == BATT_DISPLAY) {
display.print("BattDisp"); display.print("BattDisp");
display.setCursor(valCol(display), y); display.setCursor(valCol(display), y);
@@ -945,6 +954,13 @@ public:
_dirty = true; _dirty = true;
return 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) { if (_selected == DM_RESEND && p) {
int n = p->dm_resend_count; int n = p->dm_resend_count;
if (right || enter) n = (n + 1) % 6; // 0..5, wraps if (right || enter) n = (n + 1) % 6; // 0..5, wraps

View File

@@ -16,6 +16,9 @@ class ToolsScreen : public UIScreen {
ACT_NEARBY, ACT_LIVESHARE, ACT_TRAIL, ACT_LOCATOR, ACT_COMPASS, ACT_NEARBY, ACT_LIVESHARE, ACT_TRAIL, ACT_LOCATOR, ACT_COMPASS,
ACT_BOT, ACT_AUTOADVERT, ACT_REPEATER, ACT_ADMIN, ACT_BOT, ACT_AUTOADVERT, ACT_REPEATER, ACT_ADMIN,
ACT_CLOCK, ACT_RINGTONE, ACT_DIAGNOSTICS ACT_CLOCK, ACT_RINGTONE, ACT_DIAGNOSTICS
#if defined(PIN_GPIO1)
, ACT_GPIO
#endif
}; };
struct Tool { const char* label; const MiniIcon* icon; Action action; }; struct Tool { const char* label; const MiniIcon* icon; Action action; };
struct Section { const char* name; const MiniIcon* icon; const Tool* tools; uint8_t count; }; 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_CLOCK: _task->gotoClockTools(); break;
case ACT_RINGTONE: _task->gotoRingtoneEditor(); break; case ACT_RINGTONE: _task->gotoRingtoneEditor(); break;
case ACT_DIAGNOSTICS: _task->gotoDiagnosticsScreen(); break; case ACT_DIAGNOSTICS: _task->gotoDiagnosticsScreen(); break;
#if defined(PIN_GPIO1)
case ACT_GPIO: _task->gotoGpioScreen(); break;
#endif
} }
} }
@@ -119,7 +125,7 @@ const ToolsScreen::Tool ToolsScreen::LOCATION_TOOLS[] = {
{ "Compass", &ICON_MAP_NORTH, ACT_COMPASS }, { "Compass", &ICON_MAP_NORTH, ACT_COMPASS },
}; };
const ToolsScreen::Tool ToolsScreen::COMMS_TOOLS[] = { 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 }, { "Auto-Advert", &ICON_ADVERT, ACT_AUTOADVERT },
{ "Repeater", &ICON_REPEATER, ACT_REPEATER }, { "Repeater", &ICON_REPEATER, ACT_REPEATER },
{ "Admin", &ICON_GEAR, ACT_ADMIN }, { "Admin", &ICON_GEAR, ACT_ADMIN },
@@ -128,9 +134,12 @@ const ToolsScreen::Tool ToolsScreen::SYSTEM_TOOLS[] = {
{ "Clock Tools", &ICON_ALARM, ACT_CLOCK }, { "Clock Tools", &ICON_ALARM, ACT_CLOCK },
{ "Ringtone Editor", &ICON_NOTE, ACT_RINGTONE }, { "Ringtone Editor", &ICON_NOTE, ACT_RINGTONE },
{ "Diagnostics", &ICON_CHART, ACT_DIAGNOSTICS }, { "Diagnostics", &ICON_CHART, ACT_DIAGNOSTICS },
#if defined(PIN_GPIO1)
{ "GPIO", &ICON_GEAR, ACT_GPIO },
#endif
}; };
const ToolsScreen::Section ToolsScreen::SECTIONS[] = { const ToolsScreen::Section ToolsScreen::SECTIONS[] = {
{ "Location", &ICON_MAP_CONTACT, LOCATION_TOOLS, 5 }, { "Location", &ICON_MAP_CONTACT, LOCATION_TOOLS, 5 },
{ "Comms", &ICON_ADVERT, COMMS_TOOLS, 4 }, { "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])) },
}; };

View File

@@ -146,6 +146,9 @@ static const int QUICK_MSGS_MAX = 10;
#include "CompassScreen.h" #include "CompassScreen.h"
#include "DiagnosticsScreen.h" #include "DiagnosticsScreen.h"
#include "RepeaterScreen.h" #include "RepeaterScreen.h"
#if defined(PIN_GPIO1)
#include "GpioScreen.h"
#endif
#include "ToolsScreen.h" #include "ToolsScreen.h"
#include "ClockToolsScreen.h" // Alarm / Timer / Stopwatch (Clock page Enter) #include "ClockToolsScreen.h" // Alarm / Timer / Stopwatch (Clock page Enter)
@@ -1383,6 +1386,13 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
uint32_t aoff = autoOffMillis(); uint32_t aoff = autoOffMillis();
_auto_off = millis() + (aoff > 0 ? aoff : AUTO_OFF_MILLIS); _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) #if defined(PIN_USER_BTN)
user_btn.begin(); user_btn.begin();
#endif #endif
@@ -1463,10 +1473,14 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
diag_screen = new DiagnosticsScreen(this); diag_screen = new DiagnosticsScreen(this);
repeater_screen = new RepeaterScreen(this); repeater_screen = new RepeaterScreen(this);
clock_tools = new ClockToolsScreen(this, node_prefs); clock_tools = new ClockToolsScreen(this, node_prefs);
#if defined(PIN_GPIO1)
gpio_screen = new GpioScreen(this, node_prefs);
#endif
applyBrightness(); applyBrightness();
applyFont(); applyFont();
applyRotation(); applyRotation();
applyFullRefreshInterval(); applyFullRefreshInterval();
applyAllGpioModes(); // restore persisted pin modes to hardware before any UI/bot use
setCurrScreen(splash); setCurrScreen(splash);
} }
@@ -1491,6 +1505,11 @@ void UITask::gotoCompassScreen() { setCurrScreen(compass_screen); }
void UITask::gotoDiagnosticsScreen() { setCurrScreen(diag_screen); } void UITask::gotoDiagnosticsScreen() { setCurrScreen(diag_screen); }
void UITask::gotoRepeaterScreen() { setCurrScreen(repeater_screen); } void UITask::gotoRepeaterScreen() { setCurrScreen(repeater_screen); }
void UITask::gotoClockTools() { setCurrScreen(clock_tools); } void UITask::gotoClockTools() { setCurrScreen(clock_tools); }
void UITask::gotoGpioScreen() {
#if defined(PIN_GPIO1)
setCurrScreen(gpio_screen);
#endif
}
void UITask::gotoLiveShareScreen() { setCurrScreen(live_share_screen); } void UITask::gotoLiveShareScreen() { setCurrScreen(live_share_screen); }
// ── Clock tools engine (alarm / countdown / ring) ─────────────────────────── // ── Clock tools engine (alarm / countdown / ring) ───────────────────────────
@@ -2051,6 +2070,108 @@ bool UITask::dequeueKey(char& c) {
return true; return true;
} }
#if defined(ENV_PIN_SDA) && defined(ENV_PIN_SCL)
// CardKB's "fn" column (key_map in M5Stack's unit_CardKB.cpp): Fn+<physical
// key> 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. 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+<letter> 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;
// 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 == _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 == 0xA3) { // Fn+Enter -- submit the field
key = KEY_KB_ENTER;
} 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) {
// Fn+Esc -- CardKB's lock/unlock gesture: a single press toggles _locked
// directly (unlike the physical Hold-Back+3xEnter combo's 3-press
// sequence), so it works to unlock a locked device too, where every
// other CardKB key is correctly discarded (see the Fn+<letter> branch
// below). Esc, not the adjacent Fn+Backspace, on purpose: Fn and
// Backspace sit right next to each other on CardKB's layout, making that
// combo too easy to hit by accident; Esc is on the opposite side of the
// keyboard. One press is enough -- Fn+Esc is already a deliberate
// two-key combo, so it doesn't need the physical combo's extra 3x
// repetition to guard against accidental triggering.
if (_display && !_display->isOn()) _display->turnOn();
_locked = !_locked;
if (_locked) {
_lock_wake_until = millis() + 2000;
} else {
uint32_t aoff = autoOffMillis();
if (aoff > 0) _auto_off = millis() + aoff;
}
_next_refresh = 0;
return;
} else if (raw >= 0x80 && raw <= 0xAF) { // Fn+<letter> -- open its accent popup
char base = CARDKB_FN_BASE[raw - 0x80];
if (base == 0) return; // Fn+digit/symbol/arrow -- not used by this UI
char woke = checkDisplayOn(base);
// 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
// to a physical keystroke, no CardKB-specific state
}
enqueueKey(checkDisplayOn(key));
#endif
}
void UITask::loop() { void UITask::loop() {
// Background delivery: resend pending on-device DMs whose ACK timed out, and // Background delivery: resend pending on-device DMs whose ACK timed out, and
// finalise the ✗ marker — runs regardless of which screen is active. // finalise the ✗ marker — runs regardless of which screen is active.
@@ -2147,6 +2268,7 @@ void UITask::loop() {
_analogue_pin_read_millis = millis(); _analogue_pin_read_millis = millis();
} }
#endif #endif
pollCardKB();
#if defined(BACKLIGHT_BTN) #if defined(BACKLIGHT_BTN)
if (millis() > next_backlight_btn_check) { if (millis() > next_backlight_btn_check) {
bool touch_state = digitalRead(PIN_BUTTON2); bool touch_state = digitalRead(PIN_BUTTON2);
@@ -2266,8 +2388,13 @@ void UITask::loop() {
} }
// Hint popup at bottom (like alert style) // Hint popup at bottom (like alert style)
_display->setTextSize(1); _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" : const char* hint = _lock_seq_count == 0 ? "Hold Back + 3xEnter" :
_lock_seq_count == 1 ? "Enter x2 more..." : "Enter x1 more..."; _lock_seq_count == 1 ? "Enter x2 more..." : "Enter x1 more...";
#endif
int p = 3; int p = 3;
int hy = _display->height() - lk_lh - p * 2; int hy = _display->height() - lk_lh - p * 2;
int hw = _display->getTextWidth(hint); int hw = _display->getTextWidth(hint);
@@ -2893,29 +3020,229 @@ bool UITask::hasGPS() {
} }
void UITask::toggleGPS() { void UITask::toggleGPS() {
if (_sensors != NULL) { if (_node_prefs) applyGpsState(_node_prefs->gps_enabled == 0);
// toggle GPS on/off }
int num = _sensors->getNumSettings();
for (int i = 0; i < num; i++) { // Sets GPS to an absolute state (vs. toggleGPS()'s flip) -- shared by the
if (strcmp(_sensors->getSettingName(i), "gps") == 0) { // Home-page manual toggle and the bot's !gps on/off command, which needs to
if (strcmp(_sensors->getSettingValue(i), "1") == 0) { // set a specific state rather than flip whatever it currently is.
_sensors->setSettingValue("gps", "0"); void UITask::applyGpsState(bool on) {
_node_prefs->gps_enabled = 0; if (_sensors == NULL) return;
notify(UIEventType::ack); int num = _sensors->getNumSettings();
} else { for (int i = 0; i < num; i++) {
_sensors->setSettingValue("gps", "1"); if (strcmp(_sensors->getSettingName(i), "gps") == 0) {
_node_prefs->gps_enabled = 1; _sensors->setSettingValue("gps", on ? "1" : "0");
notify(UIEventType::ack); _node_prefs->gps_enabled = on ? 1 : 0;
} notify(UIEventType::ack);
the_mesh.savePrefs(); the_mesh.savePrefs();
showAlert(_node_prefs->gps_enabled ? "GPS: Enabled" : "GPS: Disabled", 800); showAlert(_node_prefs->gps_enabled ? "GPS: Enabled" : "GPS: Disabled", 800);
_next_refresh = 0; _next_refresh = 0;
break; 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
// 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;
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() { void UITask::applyTxPower() {
if (_node_prefs == NULL) return; if (_node_prefs == NULL) return;
// With APC on, tx_power_dbm is the ceiling — re-baseline the controller to it // With APC on, tx_power_dbm is the ceiling — re-baseline the controller to it

View File

@@ -45,6 +45,10 @@ class UITask : public AbstractUITask {
bool _lock_seq_used; // true = suppress next back_btn CLICK (post-sequence release) bool _lock_seq_used; // true = suppress next back_btn CLICK (post-sequence release)
char _alert[80]; char _alert[80];
char _notif_mel_buf[220]; // persistent RTTTL buffer for custom notification melodies 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 KeyboardWidget _kb; // shared across all screens — only one active at a time
unsigned long _alert_expiry; unsigned long _alert_expiry;
int _msgcount; int _msgcount;
@@ -91,6 +95,9 @@ class UITask : public AbstractUITask {
UIScreen* diag_screen = nullptr; UIScreen* diag_screen = nullptr;
UIScreen* repeater_screen = nullptr; UIScreen* repeater_screen = nullptr;
UIScreen* clock_tools = nullptr; UIScreen* clock_tools = nullptr;
#if defined(PIN_GPIO1)
UIScreen* gpio_screen = nullptr;
#endif
UIScreen* curr = nullptr; UIScreen* curr = nullptr;
CayenneLPP _dash_lpp; CayenneLPP _dash_lpp;
TrailStore _trail; TrailStore _trail;
@@ -186,6 +193,21 @@ class UITask : public AbstractUITask {
void enqueueKey(char c); void enqueueKey(char c);
bool dequeueKey(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;
// 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();
void setCurrScreen(UIScreen* c); void setCurrScreen(UIScreen* c);
// Centred alert overlay (the showAlert() box). Wraps long text to up to // Centred alert overlay (the showAlert() box). Wraps long text to up to
@@ -287,6 +309,7 @@ public:
void gotoCompassScreen(); void gotoCompassScreen();
void gotoDiagnosticsScreen(); void gotoDiagnosticsScreen();
void gotoRepeaterScreen(); 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) void gotoClockTools(); // Alarm / Timer / Stopwatch (from the home Clock page)
// Wake the display for an alarm/timer ring (force an immediate refresh). // Wake the display for an alarm/timer ring (force an immediate refresh).
void wakeForAlarm(); void wakeForAlarm();
@@ -410,6 +433,17 @@ public:
bool getGPSState(); bool getGPSState();
bool hasGPS(); // true if this board exposes a toggleable GPS (distinct from GPS being off) bool hasGPS(); // true if this board exposes a toggleable GPS (distinct from GPS being off)
void toggleGPS(); 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 applyBrightness();
void setBrightnessLevel(uint8_t level); void setBrightnessLevel(uint8_t level);
uint8_t getBrightnessLevel() const { return _node_prefs ? _node_prefs->display_brightness : 2; } uint8_t getBrightnessLevel() const { return _node_prefs ? _node_prefs->display_brightness : 2; }

View File

@@ -1,3 +1,23 @@
## Unreleased
### What's new
- **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 [seconds]` — single-shot GPS location, no need to leave GPS running.** Turns GPS on (if it wasn't already), waits for a stabilised fix (HDOP ≤ 2.0 — falls back to ≥8 satellites on GPS hardware that doesn't report HDOP — then averages for 10s), sends the position, and restores GPS to whatever state it was in before — up to a 90s timeout by default (override with an optional argument, clamped to 15-300s, for a poor sky view where 90s isn't always enough), after which it reports a partial fix (if it got at least some samples) or failure. Replies in two parts: an immediate "acquiring fix..." ack, then the actual position as a follow-up message once ready. Only one fix request can be in flight at a time (a second one gets "fix already pending"); same Actions toggle as `!buzz`/`!gps`/`!advert`.
- **Bot Trigger fields accept multiple phrases.** Pack several trigger words into one Trigger field, comma-separated (`hi,hello there,yo`) — matching any one of them fires the reply, same as before for a single phrase.
- **Remote Bot Actions gain `!gpio1`..`!gpio4`** (Wio Tracker L1 only — 4 otherwise-unused pins). Each pin is independently set to Off/Input/Output (GPIO1/GPIO2 also offer Analog) from a new **Tools GPIO** screen; `!gpio1 on`/`!gpio1 off` drives an Output pin remotely, a bare `!gpio1` reports the current mode and reading (including a millivolt value in Analog mode). Gated by the same per-target Actions toggle as `!buzz`/`!gps`/`!advert`.
- **Optional CardKB support (Wio Tracker L1, Grove I2C) — full keyboard-only navigation.** Plug an M5Stack CardKB into the Grove connector and it's auto-detected at boot — no setting to flip. Typing goes straight into the message/name field instead of navigating the on-screen keyboard grid; arrows and Esc work as expected everywhere else too (including inside the placeholder and accent popups). Enter acts like the physical centre button (advances the on-screen grid selection) rather than submitting, so **Fn+Enter** sends the message/confirms the field from anywhere, **Fn+`<letter>`** opens that letter's accent popup directly (no arrow-hunting needed — handy for Polish/Czech/etc. diacritics), and **Fn+Tab** opens the Hold-Enter equivalent (Shift-lock, clear field) for whatever's selected; plain **Tab** still opens the Hold-Enter context menu everywhere else (message reply/navigate, Bot/Admin/Repeater menus, …).
- **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
- **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.
- **Remote Bot Actions (`!buzz`/`!gps`/`!advert`/`!gpio1`..`!gpio4`) ran immediately on every matching message, before quiet hours/cooldown/per-contact throttle were checked** — those gates only suppressed the reply text, not the actual buzz/GPS toggle/advert/pin write, so e.g. `!buzz` still audibly buzzed during configured quiet hours, and repeating a command wasn't rate-limited at all. Actions now only run once those checks have passed.
- **CardKB's Fn+`<letter>` accent-popup shortcut could open the accent popup while the device was locked**, unlike every other key on the keyboard (all of which are correctly discarded while locked) — it went straight to the keyboard widget instead of through the normal locked-input gate. Now respects the lock like everything else. Since a locked device now correctly ignores CardKB entirely, **Fn+Esc** (single press) is added as CardKB's own lock/unlock gesture — otherwise a CardKB-only setup had no way to unlock at all.
---
## MeshCore Solo Companion Firmware v1.23.1 ## MeshCore Solo Companion Firmware v1.23.1
### Fixes ### Fixes

View File

@@ -14,6 +14,12 @@ public:
virtual long getLongitude() = 0; virtual long getLongitude() = 0;
virtual long getAltitude() = 0; virtual long getAltitude() = 0;
virtual long satellitesCount() = 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 bool isValid() = 0;
virtual long getTimestamp() = 0; virtual long getTimestamp() = 0;
virtual void sendSentence(const char * sentence); virtual void sendSentence(const char * sentence);

View File

@@ -120,6 +120,7 @@ public :
return alt; return alt;
} }
long satellitesCount() override { return nmea.getNumSatellites(); } long satellitesCount() override { return nmea.getNumSatellites(); }
long getHDOP() override { return nmea.getHDOP(); }
bool isValid() override { return nmea.isValid(); } bool isValid() override { return nmea.isValid(); }
long getTimestamp() override { long getTimestamp() override {

View File

@@ -13,6 +13,13 @@
#define KEY_NEXT 0xF1 #define KEY_NEXT 0xF1
#define KEY_PREV 0xF2 #define KEY_PREV 0xF2
#define KEY_CONTEXT_MENU 0xF3 #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 // "Previous"/"Next" navigation keys: the directional key and its rotary-encoder
// twin. Decrement / increment a value, or step a selection. (Cancel and the // twin. Decrement / increment a value, or step a selection. (Cancel and the

View File

@@ -63,7 +63,31 @@ const uint32_t g_ADigitalPinMap[] = {
0xFF, // 37 MISO 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() { void initVariant() {
ensureNfcPinsAsGpio(); // must run first: may reset the board
pinMode(PIN_QSPI_CS, OUTPUT); pinMode(PIN_QSPI_CS, OUTPUT);
digitalWrite(PIN_QSPI_CS, HIGH); digitalWrite(PIN_QSPI_CS, HIGH);

View File

@@ -91,6 +91,17 @@
#define PIN_GPS_STANDBY (0) #define PIN_GPS_STANDBY (0)
#define PIN_GPS_EN (PIN_GPS_STANDBY) #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 // QSPI Pins
#define PIN_QSPI_SCK (19) #define PIN_QSPI_SCK (19)
#define PIN_QSPI_CS (20) #define PIN_QSPI_CS (20)