mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-07-26 14:58:12 +00:00
Merge branch 'bot-actions-gpio'
# Conflicts: # release-notes.md
This commit is contained in:
@@ -51,7 +51,7 @@ All firmware files are published on the [releases page](https://github.com/Marek
|
||||
|
||||
- [Screen Lock](./docs/solo_features/screen_lock/screen_lock.md) — lock the device to prevent accidental keypresses, with a lock screen showing time and sensor data
|
||||
|
||||
- [Tools Screen](./docs/solo_features/tools_screen/tools_screen.md) — GPS trail & waypoints, compass, nearby nodes (with ping & navigate), ringtone editor, auto-reply bot, auto-advert, live location sharing, locator, diagnostics, repeater, remote admin
|
||||
- [Tools Screen](./docs/solo_features/tools_screen/tools_screen.md) — GPS trail & waypoints, compass, nearby nodes (with ping & navigate), ringtone editor, remote bot, auto-advert, live location sharing, locator, diagnostics, repeater, remote admin
|
||||
|
||||
- **Battery saving (radio)** — two optional, independent toggles under Settings › Radio:
|
||||
- **Pwr save** — hardware duty-cycle receive (SX126x `SetRxDutyCycle`): the radio cycles RX↔sleep on its own and wakes on a preamble, cutting average RX current with only a little added receive latency
|
||||
@@ -99,7 +99,7 @@ Updating to a newer version usually does not require erasing flash unless the re
|
||||
| [Clock Screen](./docs/solo_features/clock_screen/clock_screen.md) | Clock page, date, configurable data fields, alarm / timer / stopwatch |
|
||||
| [Settings Screen](./docs/solo_features/settings_screen/settings_screen.md) | All settings sections with values and interactions |
|
||||
| [Screen Lock](./docs/solo_features/screen_lock/screen_lock.md) | Lock/unlock sequence, lock screen, auto-lock |
|
||||
| [Tools Screen](./docs/solo_features/tools_screen/tools_screen.md) | GPS trail & waypoints, compass, navigation, nearby nodes, ringtone editor, auto-reply bot, auto-advert, live location sharing, locator, diagnostics, repeater, remote admin |
|
||||
| [Tools Screen](./docs/solo_features/tools_screen/tools_screen.md) | GPS trail & waypoints, compass, navigation, nearby nodes, ringtone editor, remote bot, auto-advert, live location sharing, locator, diagnostics, repeater, remote admin |
|
||||
| [Solo UI framework](./docs/design/solo_ui_framework.md) | **Developer guide** — the reusable building blocks (screens, lists, popups, mini-icons, geo/persistence helpers) and how to add a new feature |
|
||||
|
||||
### Upstream MeshCore
|
||||
|
||||
@@ -16,15 +16,17 @@ Screen lock prevents accidental keypresses. While locked the display turns off a
|
||||
|
||||
**Hold Back** and press **Enter** three times within 3 seconds. The sequence works in both directions — the same combination locks and unlocks.
|
||||
|
||||
If the display is off when the sequence begins, it turns on automatically so the hint is visible. Each press in the sequence extends the display-on timer by 5 seconds.
|
||||
On boards with an optional CardKB (I2C keyboard) attached, a single **Fn+Esc** does the same thing, in either direction — no repetition needed, since Fn+Esc is already a deliberate two-key combo. Esc rather than the adjacent Backspace, since Fn and Backspace sit right next to each other on CardKB's layout and would be too easy to hit by accident.
|
||||
|
||||
The hint popup at the bottom of the lock screen guides through the sequence:
|
||||
If the display is off when the sequence begins, it turns on automatically so the hint is visible. Each press in the physical sequence extends the display-on timer by 5 seconds.
|
||||
|
||||
| Step | Hint |
|
||||
| -------------- | --------------------- |
|
||||
| Not started | _Hold Back + 3×Enter_ |
|
||||
| 1 press done | _Enter ×2 more…_ |
|
||||
| 2 presses done | _Enter ×1 more…_ |
|
||||
The hint popup at the bottom of the lock screen guides through the physical sequence:
|
||||
|
||||
| Step | Hint |
|
||||
| -------------- | ------------------------------------------------------------ |
|
||||
| Not started | _Hold Back + 3×Enter_ (_Back+3xEnter/Fn+Esc_ with CardKB attached) |
|
||||
| 1 press done | _Enter ×2 more…_ |
|
||||
| 2 presses done | _Enter ×1 more…_ |
|
||||
|
||||
If no press is made for 3 seconds, the counter resets.
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
| :-----------------------: | :-----------------------: |
|
||||
|  |  |
|
||||
|
||||
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 |
|
||||
| :-----------------------: | :-----------------------: |
|
||||
@@ -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) -->
|
||||
|
||||
Automatically replies to incoming messages that contain a configured trigger word (case-insensitive, contains match). The bot has three independent targets — **DM**, a monitored **Channel**, and a monitored **Room** — each with its own trigger/reply pair.
|
||||
Automatically replies to incoming messages that contain a configured trigger word (case-insensitive, contains match). Multiple trigger phrases can be packed into one Trigger field, comma-separated (e.g. `hi,hello there,yo`) — matching any one of them is enough; spaces around each phrase are trimmed, so `hi, hello there` and `hi,hello there` behave the same. The bot has three independent targets — **DM**, a monitored **Channel**, and a monitored **Room** — each with its own trigger/reply pair.
|
||||
|
||||
The screen is a **circular tab carousel**, the same style as Tools › Nearby Nodes' filter tabs: **LEFT/RIGHT** switches between the **Channel** / **Room** / **Direct** / **Other** tabs (opens on Channel), **UP/DOWN** moves between the rows within the active tab, and **Enter** acts on the selected row (LEFT/RIGHT is reserved entirely for tab-switching, so every row's value is changed via Enter, not by cycling it in place).
|
||||
|
||||
@@ -443,6 +443,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.
|
||||
|
||||
### 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
|
||||
@@ -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) -->
|
||||
|
||||
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 |
|
||||
| --- | ----- |
|
||||
@@ -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
|
||||
|
||||
| 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.
|
||||
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 |
|
||||
| --- | ---- |
|
||||
|
||||
@@ -54,6 +54,22 @@ public:
|
||||
// Text reply to an on-device-UI-triggered MyMesh::sendAdminCommand() arrived
|
||||
// (see AdminScreen). pub_key is the contact's key prefix (>=4 bytes valid).
|
||||
virtual void onAdminReply(const uint8_t* pub_key, const char* text) { (void)pub_key; (void)text; }
|
||||
// Bot action commands (!gps/!buzz, see MyMesh::botCommandReply) -- device
|
||||
// state changes triggered remotely, gated by the bot_actions_* prefs.
|
||||
// Default no-op so UI variants that don't wire these up just ignore them.
|
||||
virtual void botSetGPS(bool on) { (void)on; }
|
||||
virtual void botBuzz(int seconds) { (void)seconds; }
|
||||
// !gpio1..!gpio4 (idx 1-4). botSetGPIO returns false if the pin isn't
|
||||
// currently configured as an Output (or the board has none) -- lets the
|
||||
// bot reply distinguish "set" from "ignored". botGetGPIO returns false if
|
||||
// the pin is Off/unsupported; on true, fills is_output (current direction)
|
||||
// and value (live level).
|
||||
virtual bool botSetGPIO(int idx, bool on) { (void)idx; (void)on; return false; }
|
||||
virtual bool botGetGPIO(int idx, bool& is_output, bool& value) { (void)idx; (void)is_output; (void)value; return false; }
|
||||
// Analog read for pins that support it (GPIO1/GPIO2 on Wio Tracker L1 --
|
||||
// the nRF52840's AIN0/AIN5). Returns false if the pin isn't in Analog mode
|
||||
// or doesn't support it; on true, fills millivolts with the reading.
|
||||
virtual bool botGetGPIOAnalog(int idx, int& millivolts) { (void)idx; (void)millivolts; return false; }
|
||||
// True only when a BLE central is actually bonded/connected. On a dual
|
||||
// (BLE+USB) interface hasConnection() is always true (USB counts), so use
|
||||
// this for BLE-specific UI like the pairing-PIN prompt.
|
||||
|
||||
@@ -526,6 +526,37 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
|
||||
rd(&_prefs.keyboard_main_alphabet, sizeof(_prefs.keyboard_main_alphabet));
|
||||
if (_prefs.keyboard_main_alphabet >= NodePrefs::KB_ALPHABET_COUNT) _prefs.keyboard_main_alphabet = 0;
|
||||
|
||||
// → 0xC0DE0021: append the per-target bot-actions toggles at the tail. A
|
||||
// pre-0x21 file has neither byte here; clamp to 0 (off) -- these gate
|
||||
// state-changing bot commands (!buzz/!gps/!advert), so an upgrader must
|
||||
// opt in deliberately rather than get them silently enabled.
|
||||
rd(&_prefs.bot_actions_dm, sizeof(_prefs.bot_actions_dm));
|
||||
if (_prefs.bot_actions_dm > 1) _prefs.bot_actions_dm = 0;
|
||||
rd(&_prefs.bot_actions_ch, sizeof(_prefs.bot_actions_ch));
|
||||
if (_prefs.bot_actions_ch > 1) _prefs.bot_actions_ch = 0;
|
||||
rd(&_prefs.bot_actions_room, sizeof(_prefs.bot_actions_room));
|
||||
if (_prefs.bot_actions_room > 1) _prefs.bot_actions_room = 0;
|
||||
|
||||
// → 0xC0DE0022: user-assignable GPIO pin modes (0=Off 1=In 2=Out-low
|
||||
// 3=Out-high 4=Analog). A pre-0x22 file has none of these bytes; clamp to
|
||||
// 0 (off). gpio1/gpio2 (AIN0/AIN5) allow mode 4; gpio3/gpio4 have no ADC
|
||||
// channel, so their clamp stops at 3 -- a stray 4 there (corrupt file,
|
||||
// schema mismatch) falls back to Off rather than doing something undefined.
|
||||
rd(&_prefs.gpio1_mode, sizeof(_prefs.gpio1_mode));
|
||||
if (_prefs.gpio1_mode > 4) _prefs.gpio1_mode = 0;
|
||||
rd(&_prefs.gpio2_mode, sizeof(_prefs.gpio2_mode));
|
||||
if (_prefs.gpio2_mode > 4) _prefs.gpio2_mode = 0;
|
||||
rd(&_prefs.gpio3_mode, sizeof(_prefs.gpio3_mode));
|
||||
if (_prefs.gpio3_mode > 3) _prefs.gpio3_mode = 0;
|
||||
rd(&_prefs.gpio4_mode, sizeof(_prefs.gpio4_mode));
|
||||
if (_prefs.gpio4_mode > 3) _prefs.gpio4_mode = 0;
|
||||
|
||||
// → 0xC0DE0023: append the external-keyboard compact-display toggle at the
|
||||
// tail. A pre-0x23 file has no byte here; clamp to 0 (full grid, unchanged
|
||||
// behaviour for upgraders).
|
||||
rd(&_prefs.keyboard_cardkb_compact, sizeof(_prefs.keyboard_cardkb_compact));
|
||||
if (_prefs.keyboard_cardkb_compact > 1) _prefs.keyboard_cardkb_compact = 0;
|
||||
|
||||
// Schema sentinel: bumped on layout changes. Mismatch means an older file
|
||||
// (or a different schema); rd() already zero-inits any fields not present,
|
||||
// so we just log it — next savePrefs writes the current sentinel.
|
||||
@@ -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_room, sizeof(_prefs.bot_commands_room));
|
||||
file.write((uint8_t *)&_prefs.keyboard_main_alphabet, sizeof(_prefs.keyboard_main_alphabet));
|
||||
file.write((uint8_t *)&_prefs.bot_actions_dm, sizeof(_prefs.bot_actions_dm));
|
||||
file.write((uint8_t *)&_prefs.bot_actions_ch, sizeof(_prefs.bot_actions_ch));
|
||||
file.write((uint8_t *)&_prefs.bot_actions_room, sizeof(_prefs.bot_actions_room));
|
||||
file.write((uint8_t *)&_prefs.gpio1_mode, sizeof(_prefs.gpio1_mode));
|
||||
file.write((uint8_t *)&_prefs.gpio2_mode, sizeof(_prefs.gpio2_mode));
|
||||
file.write((uint8_t *)&_prefs.gpio3_mode, sizeof(_prefs.gpio3_mode));
|
||||
file.write((uint8_t *)&_prefs.gpio4_mode, sizeof(_prefs.gpio4_mode));
|
||||
file.write((uint8_t *)&_prefs.keyboard_cardkb_compact, sizeof(_prefs.keyboard_cardkb_compact));
|
||||
|
||||
// Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL. Its write is
|
||||
// the one we check: once the flash fills, writes return 0, so a good
|
||||
|
||||
@@ -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));
|
||||
_bot_reply_count = 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();
|
||||
next_ack_idx = 0;
|
||||
sign_data = NULL;
|
||||
@@ -3083,6 +3090,8 @@ void MyMesh::loop() {
|
||||
}
|
||||
}
|
||||
|
||||
tickLocFix();
|
||||
|
||||
if (_cli_rescue) {
|
||||
checkCLIRescueCmd();
|
||||
} else {
|
||||
|
||||
@@ -328,8 +328,17 @@ private:
|
||||
bool tryBotCommand(const ContactInfo& from, const char* text, uint8_t hops); // DM commands
|
||||
bool tryBotChannelCommand(uint8_t channel_idx, const char* text, uint8_t hops); // channel commands
|
||||
bool tryBotRoomCommand(const ContactInfo& from, const uint8_t* sender_prefix, const char* text, uint8_t hops); // room commands
|
||||
bool botCommandReply(const char* cmd, uint8_t hops, uint32_t ts, char* out, int out_len, const char* sender_name); // one command → reply text
|
||||
int botScanCommands(const char* body, uint8_t hops, uint32_t ts, char* out, int out_len, const char* sender_name); // scan "!word"s → combined reply, returns count
|
||||
bool botCommandReply(const char* cmd, const char* arg, const char* arg2, bool actions_allowed, uint8_t hops, uint32_t ts, char* out, int out_len, const char* sender_name); // one command → reply text
|
||||
int botScanCommands(const char* body, uint8_t hops, uint32_t ts, char* out, int out_len, const char* sender_name, bool actions_allowed); // scan "!word"s → combined reply, returns count
|
||||
// !gps fix -- single-shot "wait for a stabilised GPS fix, then push a follow-up
|
||||
// message" action. botCommandReply() only sets _locfix_requested (it doesn't know
|
||||
// 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 botInQuietHours() const; // true when auto-replies should stay silent
|
||||
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];
|
||||
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;
|
||||
|
||||
uint8_t cmd_frame[MAX_FRAME_SIZE + 1];
|
||||
|
||||
@@ -11,9 +11,11 @@ static const unsigned long BOT_REPLY_COOLDOWN_MS = 10000UL;
|
||||
// incoming message (MAX_TEXT_LEN ~160) plus the 140-byte reply templates.
|
||||
static const int BOT_SCRATCH = 200;
|
||||
|
||||
// Case-insensitive (ASCII) substring test: does `body` contain `trigger`? A lone
|
||||
// "*" trigger means "match everything" (away / reply-to-all mode); honoured only
|
||||
// where allow_wildcard is set. DM, channel and room each pass their own trigger string.
|
||||
// Case-insensitive (ASCII) substring test: does `body` contain any one of
|
||||
// `trigger`'s comma-separated phrases? A lone "*" trigger (the whole field,
|
||||
// not one comma-separated phrase) means "match everything" (away / reply-to-
|
||||
// all mode); honoured only where allow_wildcard is set. DM, channel and room
|
||||
// each pass their own trigger string.
|
||||
bool MyMesh::botTriggerMatches(const char* trigger, const char* body, bool allow_wildcard) const {
|
||||
if (!trigger[0]) return false; // empty trigger would match everything
|
||||
if (trigger[0] == '*' && trigger[1] == '\0')
|
||||
@@ -25,12 +27,28 @@ bool MyMesh::botTriggerMatches(const char* trigger, const char* body, bool allow
|
||||
for (size_t i = 0; i < n; i++) low[i] = (char)tolower((uint8_t)body[i]);
|
||||
low[n] = '\0';
|
||||
|
||||
char low_trigger[64];
|
||||
size_t tlen = strnlen(trigger, sizeof(low_trigger) - 1);
|
||||
for (size_t i = 0; i < tlen; i++) low_trigger[i] = (char)tolower((uint8_t)trigger[i]);
|
||||
low_trigger[tlen] = '\0';
|
||||
// Multiple phrases can be packed into one Trigger field, comma-separated
|
||||
// ("hi,hello there,yo") -- any single phrase matching is enough. Each
|
||||
// phrase is trimmed of surrounding spaces so "foo, bar" and "foo,bar"
|
||||
// behave the same; empty phrases (a stray/trailing comma) are skipped.
|
||||
for (const char* p = trigger; *p; ) {
|
||||
while (*p == ' ' || *p == ',') p++; // skip separators / leading space
|
||||
if (!*p) break;
|
||||
const char* start = p;
|
||||
while (*p && *p != ',') p++; // phrase runs to the next comma (or end)
|
||||
const char* end = p;
|
||||
while (end > start && end[-1] == ' ') end--; // trim trailing space
|
||||
size_t plen = (size_t)(end - start);
|
||||
if (plen == 0) continue;
|
||||
|
||||
return strstr(low, low_trigger) != NULL;
|
||||
char low_trigger[64];
|
||||
if (plen >= sizeof(low_trigger)) plen = sizeof(low_trigger) - 1;
|
||||
for (size_t i = 0; i < plen; i++) low_trigger[i] = (char)tolower((uint8_t)start[i]);
|
||||
low_trigger[plen] = '\0';
|
||||
|
||||
if (strstr(low, low_trigger)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// DM allow-list gate: when bot_dm_scope is favourites-only, ignore triggers/
|
||||
@@ -238,13 +256,107 @@ void MyMesh::tryBotReplyRoom(const ContactInfo& from, const uint8_t* sender_pref
|
||||
// reply without needing a template); the rest expand a static template via
|
||||
// expandMsg's placeholders, including {name}/{hops} if the sender wrote them
|
||||
// into a custom reply — not used by any of the built-in templates below today.
|
||||
bool MyMesh::botCommandReply(const char* cmd, uint8_t hops, uint32_t ts, char* out, int out_len, const char* sender_name) {
|
||||
bool MyMesh::botCommandReply(const char* cmd, const char* arg, const char* arg2, bool actions_allowed,
|
||||
uint8_t hops, uint32_t ts, char* out, int out_len, const char* sender_name) {
|
||||
if (!strcmp(cmd, "hops")) {
|
||||
if (hops == 0) strncpy(out, "direct", out_len); // heard directly (0 repeaters)
|
||||
else snprintf(out, out_len, "%u hops", (unsigned)hops);
|
||||
out[out_len - 1] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
// Action commands change device state, unlike every query below — gated by
|
||||
// this target's own bot_actions_* toggle (nested under Commands being on),
|
||||
// not by the templating/expandMsg mechanism the queries share.
|
||||
if (actions_allowed) {
|
||||
if (!strcmp(cmd, "gps")) {
|
||||
// "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;
|
||||
if (!strcmp(cmd, "ping")) tmpl = "pong";
|
||||
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, "temp")) tmpl = "{temp}";
|
||||
else if (!strcmp(cmd, "status")) tmpl = "batt {batt} loc {loc} at {time}";
|
||||
else if (!strcmp(cmd, "help")) tmpl = "cmds: !ping !batt !loc !time !temp !hops !status";
|
||||
else if (!strcmp(cmd, "help")) tmpl = actions_allowed
|
||||
? "cmds: !ping !batt !loc !time !temp !hops !status !gps !buzz !advert !gpio1-4"
|
||||
: "cmds: !ping !batt !loc !time !temp !hops !status";
|
||||
else return false; // unknown — let the trigger bot try
|
||||
|
||||
expandMsg(tmpl, out, out_len,
|
||||
@@ -268,7 +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
|
||||
// combined reply (segments joined by " | ") into out. Returns how many commands
|
||||
// were recognised. Shared by the DM, channel and room command paths.
|
||||
int MyMesh::botScanCommands(const char* body, uint8_t hops, uint32_t ts, char* out, int out_len, const char* sender_name) {
|
||||
// actions_allowed gates the state-changing commands (!gps/!buzz/!advert) --
|
||||
// the read-only queries are always reachable once a target's Commands is on.
|
||||
int MyMesh::botScanCommands(const char* body, uint8_t hops, uint32_t ts, char* out, int out_len,
|
||||
const char* sender_name, bool actions_allowed) {
|
||||
resetPendingBotActions(); // pending actions/flags below are set by tokens in this scan;
|
||||
// caller applies or clears them once it knows if the reply sent
|
||||
// Reads one space-delimited token at p into buf (lowercased, truncated to
|
||||
// cap-1 chars with any overflow consumed too), leaving p on the space/NUL
|
||||
// that ended it. Shared by the command name and its two optional args.
|
||||
auto readToken = [](const char*& p, char* buf, int cap) {
|
||||
int i = 0;
|
||||
while (*p && *p != ' ' && i < cap - 1) buf[i++] = (char)tolower((uint8_t)*p++);
|
||||
buf[i] = '\0';
|
||||
while (*p && *p != ' ') p++;
|
||||
};
|
||||
int oi = 0;
|
||||
int matched = 0;
|
||||
for (const char* p = body; *p; ) {
|
||||
@@ -279,15 +407,27 @@ int MyMesh::botScanCommands(const char* body, uint8_t hops, uint32_t ts, char* o
|
||||
}
|
||||
p++; // step past '!'
|
||||
char cmd[16];
|
||||
int ci = 0;
|
||||
while (*p && *p != ' ' && ci < (int)sizeof(cmd) - 1)
|
||||
cmd[ci++] = (char)tolower((uint8_t)*p++);
|
||||
cmd[ci] = '\0';
|
||||
while (*p && *p != ' ') p++; // consume any overflow of this token
|
||||
readToken(p, cmd, sizeof(cmd));
|
||||
if (!cmd[0]) continue;
|
||||
|
||||
char seg[64];
|
||||
if (!botCommandReply(cmd, hops, ts, seg, sizeof(seg), sender_name)) continue; // unknown
|
||||
// Up to two optional arguments: the next one or two space-delimited
|
||||
// tokens, unless a token is itself the start of another command ("!").
|
||||
// E.g. "!gps fix 120 !batt" -- "fix"/"120" are !gps's arguments, "!batt"
|
||||
// starts the next command. Almost every command only reads arg1 (arg2 is
|
||||
// always "" for them) -- a stray second word after e.g. "!gpio1 on foo"
|
||||
// just gets consumed as arg2 and ignored, same net effect as before this
|
||||
// supported two tokens. Only "!gps fix" currently reads arg2 (an optional
|
||||
// timeout override, see botCommandReply).
|
||||
while (*p == ' ') p++;
|
||||
char 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
|
||||
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();
|
||||
char out[BOT_SCRATCH];
|
||||
if (botScanCommands(text, hops, ts, out, sizeof(out), from.name) == 0) return false; // no commands
|
||||
if (!botDmAllowed(from.id.pub_key)) return true; // throttled
|
||||
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)) { resetPendingBotActions(); return true; } // throttled
|
||||
|
||||
uint32_t expected_ack, est_timeout;
|
||||
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
|
||||
if (_ui) _ui->addDMMsg(from.id.pub_key, true, out);
|
||||
#endif
|
||||
if (_locfix_requested) startLocFix(LOCFIX_DEST_CONTACT, from.id.pub_key, 0);
|
||||
applyPendingBotActions();
|
||||
}
|
||||
resetPendingBotActions();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -340,12 +483,12 @@ bool MyMesh::tryBotChannelCommand(uint8_t channel_idx, const char* text, uint8_t
|
||||
|
||||
uint32_t ts = getRTCClock()->getCurrentTime();
|
||||
char out[BOT_SCRATCH];
|
||||
if (botScanCommands(msg, hops, ts, out, sizeof(out), sender_name) == 0) return false; // no commands
|
||||
if (botInQuietHours()) return true; // quiet hours
|
||||
if (millis() - _bot_last_ch_reply_ms <= BOT_REPLY_COOLDOWN_MS) return true; // throttled
|
||||
if (botScanCommands(msg, hops, ts, out, sizeof(out), sender_name, _prefs.bot_actions_ch != 0) == 0) return false; // no commands
|
||||
if (botInQuietHours()) { resetPendingBotActions(); return true; } // quiet hours
|
||||
if (millis() - _bot_last_ch_reply_ms <= BOT_REPLY_COOLDOWN_MS) { resetPendingBotActions(); return true; } // throttled
|
||||
|
||||
ChannelDetails ch;
|
||||
if (!getChannel(channel_idx, ch)) return true;
|
||||
if (!getChannel(channel_idx, ch)) { resetPendingBotActions(); return true; }
|
||||
int rlen = strlen(out);
|
||||
if (sendGroupMessage(ts, ch.channel, _prefs.node_name, out, rlen)) {
|
||||
_bot_last_ch_reply_ms = millis();
|
||||
@@ -357,7 +500,10 @@ bool MyMesh::tryBotChannelCommand(uint8_t channel_idx, const char* text, uint8_t
|
||||
_ui->addChannelMsg(channel_idx, with_sender);
|
||||
}
|
||||
#endif
|
||||
if (_locfix_requested) startLocFix(LOCFIX_DEST_CHANNEL, nullptr, channel_idx);
|
||||
applyPendingBotActions();
|
||||
}
|
||||
resetPendingBotActions();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -375,9 +521,9 @@ bool MyMesh::tryBotRoomCommand(const ContactInfo& from, const uint8_t* sender_pr
|
||||
|
||||
uint32_t ts = getRTCClock()->getCurrentTime();
|
||||
char out[BOT_SCRATCH];
|
||||
if (botScanCommands(text, hops, ts, out, sizeof(out), sender_name) == 0) return false; // no commands
|
||||
if (botInQuietHours()) return true; // quiet hours
|
||||
if (millis() - _bot_last_room_reply_ms <= BOT_REPLY_COOLDOWN_MS) return true; // throttled
|
||||
if (botScanCommands(text, hops, ts, out, sizeof(out), sender_name, _prefs.bot_actions_room != 0) == 0) return false; // no commands
|
||||
if (botInQuietHours()) { resetPendingBotActions(); return true; } // quiet hours
|
||||
if (millis() - _bot_last_room_reply_ms <= BOT_REPLY_COOLDOWN_MS) { resetPendingBotActions(); return true; } // throttled
|
||||
|
||||
uint32_t expected_ack, est_timeout;
|
||||
if (sendMessage(from, ts, 0, out, expected_ack, est_timeout) != MSG_SEND_FAILED) {
|
||||
@@ -386,6 +532,145 @@ bool MyMesh::tryBotRoomCommand(const ContactInfo& from, const uint8_t* sender_pr
|
||||
#ifdef DISPLAY_CLASS
|
||||
if (_ui) _ui->addDMMsg(from.id.pub_key, true, out);
|
||||
#endif
|
||||
if (_locfix_requested) startLocFix(LOCFIX_DEST_CONTACT, from.id.pub_key, 0);
|
||||
applyPendingBotActions();
|
||||
}
|
||||
resetPendingBotActions();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Actually runs the state-changing bot actions botCommandReply() recorded
|
||||
// this scan (!gps on|off, !buzz, !advert, !gpio1..4 on|off) -- called by the
|
||||
// tryBot*Command() wrappers only after quiet-hours/cooldown/per-contact
|
||||
// throttle passed and the ack itself sent, so those gates genuinely block the
|
||||
// action, not just the reply text (see the _bot_*_action_* fields in
|
||||
// MyMesh.h). !gps fix's startLocFix() is armed separately by the caller (it
|
||||
// needs the destination, which this function doesn't have).
|
||||
void MyMesh::applyPendingBotActions() {
|
||||
if (_bot_gps_action_pending && _ui) _ui->botSetGPS(_bot_gps_action_on);
|
||||
if (_bot_buzz_action_secs > 0 && _ui) _ui->botBuzz(_bot_buzz_action_secs);
|
||||
if (_bot_advert_action_pending) advert();
|
||||
for (int i = 0; i < 4; i++) {
|
||||
if (_bot_gpio_action[i] >= 0 && _ui) _ui->botSetGPIO(i + 1, _bot_gpio_action[i] != 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Clears every pending-action flag botCommandReply() may have set this scan,
|
||||
// without running them -- used both at scan start and on every throttled/
|
||||
// aborted path, so a suppressed reply never leaves a stale action armed for
|
||||
// next time.
|
||||
void MyMesh::resetPendingBotActions() {
|
||||
_locfix_requested = false;
|
||||
_locfix_requested_timeout_ms = LOCFIX_TIMEOUT_MS;
|
||||
_bot_gps_action_pending = false;
|
||||
_bot_buzz_action_secs = 0;
|
||||
_bot_advert_action_pending = false;
|
||||
for (int i = 0; i < 4; i++) _bot_gpio_action[i] = -1;
|
||||
}
|
||||
|
||||
// Arms the "!gps fix" wait: turns GPS on (remembering whether it already was,
|
||||
// so tickLocFix() restores rather than assumes "off"), and resets the
|
||||
// averaging accumulator. pub_key is only read for LOCFIX_DEST_CONTACT (DM or
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,6 +399,40 @@ struct NodePrefs { // persisted to file
|
||||
// main behaviour for upgraders.
|
||||
uint8_t keyboard_main_alphabet;
|
||||
|
||||
// Per-target toggle for bot *action* commands (!buzz/!gps/!advert) --
|
||||
// separate from bot_commands_ch/bot_commands_room above, which only gate
|
||||
// the read-only query commands (!ping/!batt/...). Nested under that
|
||||
// Commands toggle (Commands=off means neither queries nor actions run for
|
||||
// that target); Actions=on additionally lets the state-changing commands
|
||||
// through. Default 0 (off) -- these change device behaviour remotely, so
|
||||
// upgraders don't get them silently enabled.
|
||||
uint8_t bot_actions_dm;
|
||||
uint8_t bot_actions_ch;
|
||||
uint8_t bot_actions_room;
|
||||
|
||||
// User-assignable GPIO pins (board-specific, see PIN_GPIO1..4 in the
|
||||
// variant header -- currently Wio Tracker L1 only). One byte per pin packs
|
||||
// direction + output level (+ analog, gpio1/2 only) into one field:
|
||||
// 0=Off 1=Input 2=Output(low) 3=Output(high) 4=Analog. Mode 4 is only
|
||||
// meaningful for gpio1/gpio2 (the nRF52840's AIN0/AIN5 -- gpio3/gpio4 have
|
||||
// no ADC channel), so those two fields are clamped to 0-3 on load, not 0-4
|
||||
// (see DataStore::loadPrefsInt). Default 0 (off/disconnected) on upgrade.
|
||||
uint8_t gpio1_mode;
|
||||
uint8_t gpio2_mode;
|
||||
uint8_t gpio3_mode;
|
||||
uint8_t gpio4_mode;
|
||||
|
||||
// Settings > Keyboard's "Ext. KB" row (boards with a second I2C bus for an
|
||||
// optional CardKB, see ENV_PIN_SDA/ENV_PIN_SCL, only). When on, the
|
||||
// on-screen keyboard skips drawing its full letter grid + special-row icons
|
||||
// -- an external-keyboard typist never looks at them -- and shows a compact
|
||||
// one-line status (current script/page, caps) instead; the accent and
|
||||
// placeholder popups still render on top exactly as before (see
|
||||
// KeyboardWidget::render()). Manual toggle rather than auto-detected, so it
|
||||
// stays put even if the module is briefly unplugged. Default 0 (full grid,
|
||||
// unchanged behaviour) on upgrade.
|
||||
uint8_t keyboard_cardkb_compact;
|
||||
|
||||
// Single source of truth for the live-share option tables (shared by the Map
|
||||
// UI labels and the auto-send engine in UITask).
|
||||
static const uint8_t LOC_SHARE_MOVE_COUNT = 4;
|
||||
@@ -461,7 +495,7 @@ struct NodePrefs { // persisted to file
|
||||
// adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so
|
||||
// older saves are detected on load and skipped (zero-init defaults kept).
|
||||
// High 24 bits identify the file format; low byte is the schema revision.
|
||||
static const uint32_t SCHEMA_SENTINEL = 0xC0DE0020;
|
||||
static const uint32_t SCHEMA_SENTINEL = 0xC0DE0023;
|
||||
|
||||
// Bit-index for each home page. Used by page_order (entries store bit+1) and
|
||||
// by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage
|
||||
@@ -558,10 +592,15 @@ struct NodePrefs { // persisted to file
|
||||
// 3. clamp it on load (an upgrader's file lacks it → stray bytes)
|
||||
// 4. bump SCHEMA_SENTINEL's low byte
|
||||
// (Padding can also shift sizeof; a "false" trip just means re-check + rebump.)
|
||||
// keyboard_main_alphabet (added alongside this bump) landed in existing tail
|
||||
// padding -- confirmed via a real build's sizeof() -- so the size is
|
||||
// unchanged from before that field existed.
|
||||
static_assert(sizeof(NodePrefs) == 2712,
|
||||
// keyboard_cardkb_compact (0xC0DE0023) also landed in existing tail padding --
|
||||
// confirmed via a real build -- leaving sizeof unchanged at 2720.
|
||||
// keyboard_main_alphabet (added in an earlier bump) landed in existing tail
|
||||
// padding -- confirmed via a real build's sizeof() -- so that bump left the
|
||||
// size unchanged. bot_actions_dm/ch/room and gpio1..4_mode (the last two
|
||||
// bumps, 7 more uint8_t total) added 8 bytes, not 7 -- one byte of tail
|
||||
// padding got consumed along the way. 2720 confirmed via a real
|
||||
// WioTrackerL1Eink_companion_solo_dual build.
|
||||
static_assert(sizeof(NodePrefs) == 2720,
|
||||
"NodePrefs layout changed — sync DataStore save/load + clamp, bump "
|
||||
"SCHEMA_SENTINEL, then update this size (see steps above).");
|
||||
|
||||
|
||||
@@ -16,9 +16,9 @@ class BotScreen : public UIScreen {
|
||||
enum Tab : uint8_t { TAB_CHANNEL, TAB_ROOM, TAB_DIRECT, TAB_OTHER, TAB_COUNT };
|
||||
static const char* TAB_LABELS[TAB_COUNT];
|
||||
|
||||
enum Kind : uint8_t { ENABLE_DM, DM_SCOPE, COMMANDS_DM, TRIGGER_DM, REPLY_DM,
|
||||
ENABLE_CH, CHANNEL, COMMANDS_CH, TRIGGER_CH, REPLY_CH,
|
||||
ENABLE_ROOM, ROOM, COMMANDS_ROOM, TRIGGER_ROOM, REPLY_ROOM,
|
||||
enum Kind : uint8_t { ENABLE_DM, DM_SCOPE, COMMANDS_DM, ACTIONS_DM, TRIGGER_DM, REPLY_DM,
|
||||
ENABLE_CH, CHANNEL, COMMANDS_CH, ACTIONS_CH, TRIGGER_CH, REPLY_CH,
|
||||
ENABLE_ROOM, ROOM, COMMANDS_ROOM, ACTIONS_ROOM, TRIGGER_ROOM, REPLY_ROOM,
|
||||
QUIET_FROM, QUIET_TO };
|
||||
struct Row { Kind kind; const char* label; };
|
||||
|
||||
@@ -33,6 +33,7 @@ class BotScreen : public UIScreen {
|
||||
{ ENABLE_DM, "Enable" },
|
||||
{ DM_SCOPE, "DM allow" },
|
||||
{ COMMANDS_DM, "Commands" },
|
||||
{ ACTIONS_DM, "Actions" },
|
||||
{ TRIGGER_DM, "Trigger" },
|
||||
{ REPLY_DM, "Reply" },
|
||||
};
|
||||
@@ -40,6 +41,7 @@ class BotScreen : public UIScreen {
|
||||
{ ENABLE_CH, "Enable" },
|
||||
{ CHANNEL, "Channel" },
|
||||
{ COMMANDS_CH, "Commands" },
|
||||
{ ACTIONS_CH, "Actions" },
|
||||
{ TRIGGER_CH, "Trigger" },
|
||||
{ REPLY_CH, "Reply" },
|
||||
};
|
||||
@@ -47,6 +49,7 @@ class BotScreen : public UIScreen {
|
||||
{ ENABLE_ROOM, "Enable" },
|
||||
{ ROOM, "Room" },
|
||||
{ COMMANDS_ROOM, "Commands" },
|
||||
{ ACTIONS_ROOM, "Actions" },
|
||||
{ TRIGGER_ROOM, "Trigger" },
|
||||
{ REPLY_ROOM, "Reply" },
|
||||
};
|
||||
@@ -218,6 +221,15 @@ public:
|
||||
case COMMANDS_ROOM:
|
||||
display.print(_prefs->bot_commands_room ? "ON" : "OFF");
|
||||
break;
|
||||
case ACTIONS_DM:
|
||||
display.print(_prefs->bot_actions_dm ? "ON" : "OFF");
|
||||
break;
|
||||
case ACTIONS_CH:
|
||||
display.print(_prefs->bot_actions_ch ? "ON" : "OFF");
|
||||
break;
|
||||
case ACTIONS_ROOM:
|
||||
display.print(_prefs->bot_actions_room ? "ON" : "OFF");
|
||||
break;
|
||||
case QUIET_FROM:
|
||||
case QUIET_TO: {
|
||||
bool off = (_prefs->bot_quiet_start == _prefs->bot_quiet_end);
|
||||
@@ -300,6 +312,9 @@ public:
|
||||
case COMMANDS_DM: _prefs->bot_commands_enabled ^= 1; _dirty = true; return true;
|
||||
case COMMANDS_CH: _prefs->bot_commands_ch ^= 1; _dirty = true; return true;
|
||||
case COMMANDS_ROOM: _prefs->bot_commands_room ^= 1; _dirty = true; return true;
|
||||
case ACTIONS_DM: _prefs->bot_actions_dm ^= 1; _dirty = true; return true;
|
||||
case ACTIONS_CH: _prefs->bot_actions_ch ^= 1; _dirty = true; return true;
|
||||
case ACTIONS_ROOM: _prefs->bot_actions_room ^= 1; _dirty = true; return true;
|
||||
case CHANNEL:
|
||||
// Full browsable channel picker (same experience as Live Share's "To"
|
||||
// row); which channel is *active* is now the separate Enable row.
|
||||
@@ -368,4 +383,4 @@ private:
|
||||
};
|
||||
|
||||
const char* BotScreen::TAB_LABELS[BotScreen::TAB_COUNT] = { "Channel", "Room", "Direct", "Other" };
|
||||
const int BotScreen::ROWS_PER_TAB[BotScreen::TAB_COUNT] = { 5, 5, 5, 2 };
|
||||
const int BotScreen::ROWS_PER_TAB[BotScreen::TAB_COUNT] = { 6, 6, 6, 2 };
|
||||
|
||||
153
examples/companion_radio/ui-new/GpioScreen.h
Normal file
153
examples/companion_radio/ui-new/GpioScreen.h
Normal 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
|
||||
@@ -526,81 +526,106 @@ struct KeyboardWidget {
|
||||
return 50;
|
||||
}
|
||||
|
||||
// character grid
|
||||
if (isT9()) {
|
||||
for (int r = 0; r < rows; r++) {
|
||||
int y = chars_y + r * cell_h;
|
||||
for (int c = 0; c < cols; c++) {
|
||||
bool sel = (row == r && col == c);
|
||||
int cell = r * cols + c;
|
||||
// Label the cell "<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);
|
||||
}
|
||||
// Compact mode (Settings > Keyboard's "Ext. KB" row): an external-keyboard
|
||||
// typist never looks at the letter grid or special-row icons, so skip
|
||||
// drawing them and show a one-line status (current script/page, caps)
|
||||
// instead. row/col/page keep updating exactly as before even while this
|
||||
// is on (arrows and Fn+letter both still work; the accent popup below
|
||||
// still anchors on `row`), so nothing breaks if physical buttons get used
|
||||
// meanwhile -- it just won't be visible which cell is selected.
|
||||
if (prefs && prefs->keyboard_cardkb_compact) {
|
||||
const int hh = lh + 2;
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(0, chars_y, display.width(), hh);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
const char* script_name = pageIsSymbols(page) ? "Symbols"
|
||||
: (scriptAt(page) == NodePrefs::KB_ALPHABET_CYRILLIC) ? "Cyrillic"
|
||||
: (scriptAt(page) == NodePrefs::KB_ALPHABET_GREEK) ? "Greek"
|
||||
: "Latin";
|
||||
char status[32];
|
||||
if (pageIsSymbols(page)) snprintf(status, sizeof(status), "%s%s", script_name, caps ? " CAPS" : "");
|
||||
else snprintf(status, sizeof(status), "%s %s%s", script_name, isT9() ? "T9" : "ABC", caps ? " CAPS" : "");
|
||||
display.drawTextCentered(display.width() / 2, chars_y + 1, status);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawTextCentered(display.width() / 2, chars_y + hh + 2, "Fn+Tab menu");
|
||||
display.drawTextCentered(display.width() / 2, chars_y + hh + 2 + lh, "Fn+letter accent");
|
||||
} else {
|
||||
// character grid
|
||||
if (isT9()) {
|
||||
for (int r = 0; r < rows; r++) {
|
||||
int y = chars_y + r * cell_h;
|
||||
for (int c = 0; c < cols; c++) {
|
||||
bool sel = (row == r && col == c);
|
||||
int cell = r * cols + c;
|
||||
// Label the cell "<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
|
||||
@@ -645,6 +670,27 @@ struct KeyboardWidget {
|
||||
return 50;
|
||||
}
|
||||
|
||||
// CardKB's Fn+letter ("alt") gesture, see UITask::pollCardKB(): open the
|
||||
// accent popup for this base Latin letter directly, skipping the
|
||||
// arrow-hunt to find its cell first. `base` always comes from CardKB's own
|
||||
// physical QWERTY layout, so this only makes sense -- same gating as the
|
||||
// physical Hold-Enter-on-a-letter-cell path just below -- while the
|
||||
// keyboard is idle on its plain Latin page: on T9, symbols, or a non-Latin
|
||||
// main/alt script (Cyrillic, Greek, ...), the on-screen grid isn't showing
|
||||
// Latin letters at all, so it's a no-op rather than inserting a stray
|
||||
// Latin accent into unrelated text.
|
||||
bool openAccentFor(char base) {
|
||||
if (!isVisible() || _ph_menu.active || cursor_mode || accent_active) return false;
|
||||
if (isT9() || pageIsSymbols(page) || scriptAt(page) != NodePrefs::KB_ALPHABET_LATIN_ONLY) return false;
|
||||
int gi = findAccentGroup(base);
|
||||
if (gi < 0) return false;
|
||||
accent_active = true;
|
||||
accent_group = gi;
|
||||
accent_sel = 0;
|
||||
t9_cell = -1;
|
||||
return true;
|
||||
}
|
||||
|
||||
Result handleInput(char c) {
|
||||
// placeholder overlay consumes all input
|
||||
if (_ph_menu.active) {
|
||||
@@ -721,6 +767,31 @@ struct KeyboardWidget {
|
||||
|
||||
if (c == KEY_CANCEL) return CANCELLED;
|
||||
|
||||
// Direct-typing passthrough (CardKB or similar literal-ASCII input
|
||||
// source, see UITask::pollCardKB()). Printable characters insert
|
||||
// straight at the cursor, bypassing the on-screen grid entirely -- no
|
||||
// caps re-application, the source already sends the correct case.
|
||||
// Backspace deletes the previous character. KEY_KB_ENTER (only ever
|
||||
// emitted 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 cols = gridCols();
|
||||
|
||||
@@ -798,6 +869,10 @@ struct KeyboardWidget {
|
||||
return NONE;
|
||||
}
|
||||
if (c == KEY_ENTER) {
|
||||
// Plain Enter always acts on the grid (repeat a letter, cycle T9,
|
||||
// switch page/script, reach the special row's DONE cell) -- same as a
|
||||
// physical button. CardKB submits via the separate KEY_KB_ENTER
|
||||
// (Fn+Enter, see pollCardKB()) instead, so there's no ambiguity here.
|
||||
if (row < rows && isT9()) {
|
||||
int cell = row * cols + col;
|
||||
const char* group = t9GroupStr(cell);
|
||||
|
||||
@@ -136,7 +136,10 @@ class MessagesScreen : public UIScreen {
|
||||
struct HistScroll { bool need; int reserve; long total_px, scroll_px; int view_px; };
|
||||
|
||||
// `getBody(idx)` returns the body text for list item idx (the part that wraps),
|
||||
// or nullptr to fall back to a fixed 2-line box.
|
||||
// or nullptr to fall back to a fixed 2-line box. Must return exactly what the
|
||||
// real per-item render pass wraps (sender split off, reply prefix stripped) --
|
||||
// this function doesn't reprocess it, so a mismatch here just means this sizing
|
||||
// pass and the real render disagree on line count.
|
||||
template <class GetBody>
|
||||
HistScroll computeHistScroll(DisplayDriver& display, bool portrait, int count, int scroll,
|
||||
int hist_start_y, int cby, int lh, GetBody getBody) {
|
||||
@@ -161,7 +164,7 @@ class MessagesScreen : public UIScreen {
|
||||
auto boxH = [&](int idx, int rsv) -> int {
|
||||
const char* body = getBody(idx);
|
||||
if (!body) return fixed_bh;
|
||||
display.translateUTF8ToBlocks(s_wrap_trans, skipReplyPrefix(body), sizeof(s_wrap_trans));
|
||||
display.translateUTF8ToBlocks(s_wrap_trans, body, sizeof(s_wrap_trans));
|
||||
int nl = FullscreenMsgView::wrapLines(display, s_wrap_trans, display.width() - 6 - rsv, s_wrap_lines, 8);
|
||||
return (1 + (nl > 0 ? nl : 1)) * lh + 1;
|
||||
};
|
||||
@@ -191,9 +194,20 @@ class MessagesScreen : public UIScreen {
|
||||
// stored "Sender: text" (MyMesh::queueMessage); split that off so each line is
|
||||
// attributed to its guest. Outgoing → "Me"; plain DMs (or no separator) keep
|
||||
// the contact name and the text unchanged.
|
||||
//
|
||||
// Does NOT strip a leading "@[nick] " reply prefix — that's the caller's call,
|
||||
// and it must be made exactly once: FullscreenMsgView::render() parses it
|
||||
// itself (for the "To:" header), so callers feeding it must pass this
|
||||
// function's result straight through; the compact list view has no such
|
||||
// parsing of its own, so those callers must wrap the result in
|
||||
// skipReplyPrefix(). Stripping it in here unconditionally used to double-strip
|
||||
// the DM case (hiding FullscreenMsgView's "To:" header entirely) while never
|
||||
// stripping the room case (the split happens after this used to run), which is
|
||||
// why replies' addressee went undetected inconsistently between DM/room/list/
|
||||
// fullscreen.
|
||||
const char* dmDisplayParts(const DmHistEntry& e, bool is_room, const char* contact_name,
|
||||
char* sender_buf, int sender_cap) const {
|
||||
const char* body = skipReplyPrefix(e.text);
|
||||
const char* body = e.text;
|
||||
if (e.outgoing) {
|
||||
strncpy(sender_buf, "Me", sender_cap - 1);
|
||||
} else if (is_room) {
|
||||
@@ -1033,6 +1047,8 @@ public:
|
||||
if (ring_pos >= 0) {
|
||||
const DmHistEntry& e = _history.dmAtPos(ring_pos);
|
||||
char sender_buf[33];
|
||||
// No skipReplyPrefix() here -- _dm_fs.render() parses "@[nick] " itself
|
||||
// (for the "To:" header); stripping it here first would hide it there.
|
||||
const char* body = dmDisplayParts(e, _sel_contact.type == ADV_TYPE_ROOM,
|
||||
filtered_name, sender_buf, sizeof(sender_buf));
|
||||
const char* sender = sender_buf;
|
||||
@@ -1075,7 +1091,15 @@ public:
|
||||
hist_start_y, cby, lh,
|
||||
[&](int idx) -> const char* {
|
||||
int rp = _history.dmHistEntryForContact(_sel_contact.id.pub_key, idx);
|
||||
return rp >= 0 ? _history.dmAtPos(rp).text : nullptr;
|
||||
if (rp < 0) return nullptr;
|
||||
// Must match the per-item body extraction below (dmDisplayParts +
|
||||
// skipReplyPrefix) exactly, or this sizing pass and the real render
|
||||
// pass disagree on wrapped line count for room posts -- previously
|
||||
// this returned the raw "Sender: text" unsplit, wrapping the sender
|
||||
// name in with the body and mis-sizing the box.
|
||||
char tmp_sender[33];
|
||||
return skipReplyPrefix(dmDisplayParts(_history.dmAtPos(rp), is_room, filtered_name,
|
||||
tmp_sender, sizeof(tmp_sender)));
|
||||
});
|
||||
int reserve = hs.reserve;
|
||||
{
|
||||
@@ -1093,7 +1117,7 @@ public:
|
||||
int rp = _history.dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_scroll + ii);
|
||||
if (rp >= 0) {
|
||||
char hsb[33];
|
||||
const char* hbody = dmDisplayParts(_history.dmAtPos(rp), is_room, filtered_name, hsb, sizeof(hsb));
|
||||
const char* hbody = skipReplyPrefix(dmDisplayParts(_history.dmAtPos(rp), is_room, filtered_name, hsb, sizeof(hsb)));
|
||||
display.translateUTF8ToBlocks(s_wrap_trans, hbody, sizeof(s_wrap_trans));
|
||||
int nl = FullscreenMsgView::wrapLines(display, s_wrap_trans, display.width() - 6 - reserve, s_wrap_lines, 8);
|
||||
bh = (1 + (nl > 0 ? nl : 1)) * lh + 1;
|
||||
@@ -1117,7 +1141,7 @@ public:
|
||||
|
||||
const DmHistEntry& e = _history.dmAtPos(ring_pos);
|
||||
char sender_buf[33];
|
||||
const char* body = dmDisplayParts(e, is_room, filtered_name, sender_buf, sizeof(sender_buf));
|
||||
const char* body = skipReplyPrefix(dmDisplayParts(e, is_room, filtered_name, sender_buf, sizeof(sender_buf)));
|
||||
const char* sender = sender_buf;
|
||||
|
||||
char age[6]; geo::fmtAgeShort(age, sizeof(age), now_ts, e.timestamp);
|
||||
@@ -1235,7 +1259,7 @@ public:
|
||||
if (rp < 0) return nullptr;
|
||||
const char* t = _history.chAtPos(rp).text;
|
||||
const char* s = strstr(t, ": ");
|
||||
return s ? s + 2 : t;
|
||||
return skipReplyPrefix(s ? s + 2 : t);
|
||||
});
|
||||
int reserve = hs.reserve;
|
||||
{
|
||||
|
||||
@@ -73,6 +73,9 @@ class SettingsScreen : public UIScreen {
|
||||
KEYBOARD_TYPE,
|
||||
KEYBOARD_MAIN_ALPHABET,
|
||||
KEYBOARD_ALPHABET,
|
||||
#if defined(ENV_PIN_SDA) && defined(ENV_PIN_SCL)
|
||||
KEYBOARD_CARDKB_COMPACT,
|
||||
#endif
|
||||
// Contacts section
|
||||
SECTION_CONTACTS, DM_FILTER, CH_FILTER, ROOM_FILTER,
|
||||
// Messages section
|
||||
@@ -574,6 +577,12 @@ class SettingsScreen : public UIScreen {
|
||||
display.print("Additional");
|
||||
display.setCursor(valCol(display), y);
|
||||
display.print(NodePrefs::keyboardAlphabetLabel(p ? p->keyboard_alt_alphabet : 0));
|
||||
#if defined(ENV_PIN_SDA) && defined(ENV_PIN_SCL)
|
||||
} else if (item == KEYBOARD_CARDKB_COMPACT) {
|
||||
display.print("Ext. KB");
|
||||
display.setCursor(valCol(display), y);
|
||||
display.print((p && p->keyboard_cardkb_compact) ? "Compact" : "Full");
|
||||
#endif
|
||||
} else if (item == BATT_DISPLAY) {
|
||||
display.print("BattDisp");
|
||||
display.setCursor(valCol(display), y);
|
||||
@@ -945,6 +954,13 @@ public:
|
||||
_dirty = true;
|
||||
return true;
|
||||
}
|
||||
#if defined(ENV_PIN_SDA) && defined(ENV_PIN_SCL)
|
||||
if (_selected == KEYBOARD_CARDKB_COMPACT && p && (left || right || enter)) {
|
||||
p->keyboard_cardkb_compact ^= 1;
|
||||
_dirty = true;
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
if (_selected == DM_RESEND && p) {
|
||||
int n = p->dm_resend_count;
|
||||
if (right || enter) n = (n + 1) % 6; // 0..5, wraps
|
||||
|
||||
@@ -16,6 +16,9 @@ class ToolsScreen : public UIScreen {
|
||||
ACT_NEARBY, ACT_LIVESHARE, ACT_TRAIL, ACT_LOCATOR, ACT_COMPASS,
|
||||
ACT_BOT, ACT_AUTOADVERT, ACT_REPEATER, ACT_ADMIN,
|
||||
ACT_CLOCK, ACT_RINGTONE, ACT_DIAGNOSTICS
|
||||
#if defined(PIN_GPIO1)
|
||||
, ACT_GPIO
|
||||
#endif
|
||||
};
|
||||
struct Tool { const char* label; const MiniIcon* icon; Action action; };
|
||||
struct Section { const char* name; const MiniIcon* icon; const Tool* tools; uint8_t count; };
|
||||
@@ -53,6 +56,9 @@ class ToolsScreen : public UIScreen {
|
||||
case ACT_CLOCK: _task->gotoClockTools(); break;
|
||||
case ACT_RINGTONE: _task->gotoRingtoneEditor(); break;
|
||||
case ACT_DIAGNOSTICS: _task->gotoDiagnosticsScreen(); break;
|
||||
#if defined(PIN_GPIO1)
|
||||
case ACT_GPIO: _task->gotoGpioScreen(); break;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +125,7 @@ const ToolsScreen::Tool ToolsScreen::LOCATION_TOOLS[] = {
|
||||
{ "Compass", &ICON_MAP_NORTH, ACT_COMPASS },
|
||||
};
|
||||
const ToolsScreen::Tool ToolsScreen::COMMS_TOOLS[] = {
|
||||
{ "Auto-Reply Bot", &ICON_BOT, ACT_BOT },
|
||||
{ "Remote Bot", &ICON_BOT, ACT_BOT },
|
||||
{ "Auto-Advert", &ICON_ADVERT, ACT_AUTOADVERT },
|
||||
{ "Repeater", &ICON_REPEATER, ACT_REPEATER },
|
||||
{ "Admin", &ICON_GEAR, ACT_ADMIN },
|
||||
@@ -128,9 +134,12 @@ const ToolsScreen::Tool ToolsScreen::SYSTEM_TOOLS[] = {
|
||||
{ "Clock Tools", &ICON_ALARM, ACT_CLOCK },
|
||||
{ "Ringtone Editor", &ICON_NOTE, ACT_RINGTONE },
|
||||
{ "Diagnostics", &ICON_CHART, ACT_DIAGNOSTICS },
|
||||
#if defined(PIN_GPIO1)
|
||||
{ "GPIO", &ICON_GEAR, ACT_GPIO },
|
||||
#endif
|
||||
};
|
||||
const ToolsScreen::Section ToolsScreen::SECTIONS[] = {
|
||||
{ "Location", &ICON_MAP_CONTACT, LOCATION_TOOLS, 5 },
|
||||
{ "Comms", &ICON_ADVERT, COMMS_TOOLS, 4 },
|
||||
{ "System", &ICON_GEAR, SYSTEM_TOOLS, 3 },
|
||||
{ "System", &ICON_GEAR, SYSTEM_TOOLS, (uint8_t)(sizeof(SYSTEM_TOOLS)/sizeof(SYSTEM_TOOLS[0])) },
|
||||
};
|
||||
|
||||
@@ -146,6 +146,9 @@ static const int QUICK_MSGS_MAX = 10;
|
||||
#include "CompassScreen.h"
|
||||
#include "DiagnosticsScreen.h"
|
||||
#include "RepeaterScreen.h"
|
||||
#if defined(PIN_GPIO1)
|
||||
#include "GpioScreen.h"
|
||||
#endif
|
||||
#include "ToolsScreen.h"
|
||||
#include "ClockToolsScreen.h" // Alarm / Timer / Stopwatch (Clock page › Enter)
|
||||
|
||||
@@ -1383,6 +1386,13 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
|
||||
uint32_t aoff = autoOffMillis();
|
||||
_auto_off = millis() + (aoff > 0 ? aoff : AUTO_OFF_MILLIS);
|
||||
|
||||
#if defined(ENV_PIN_SDA) && defined(ENV_PIN_SCL)
|
||||
// Wire1 is already brought up by sensors.begin() (EnvironmentSensorManager),
|
||||
// which runs before this -- just probe for a CardKB sitting on it.
|
||||
Wire1.beginTransmission(0x5F);
|
||||
_has_cardkb = (Wire1.endTransmission() == 0);
|
||||
#endif
|
||||
|
||||
#if defined(PIN_USER_BTN)
|
||||
user_btn.begin();
|
||||
#endif
|
||||
@@ -1463,10 +1473,14 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
|
||||
diag_screen = new DiagnosticsScreen(this);
|
||||
repeater_screen = new RepeaterScreen(this);
|
||||
clock_tools = new ClockToolsScreen(this, node_prefs);
|
||||
#if defined(PIN_GPIO1)
|
||||
gpio_screen = new GpioScreen(this, node_prefs);
|
||||
#endif
|
||||
applyBrightness();
|
||||
applyFont();
|
||||
applyRotation();
|
||||
applyFullRefreshInterval();
|
||||
applyAllGpioModes(); // restore persisted pin modes to hardware before any UI/bot use
|
||||
setCurrScreen(splash);
|
||||
}
|
||||
|
||||
@@ -1491,6 +1505,11 @@ void UITask::gotoCompassScreen() { setCurrScreen(compass_screen); }
|
||||
void UITask::gotoDiagnosticsScreen() { setCurrScreen(diag_screen); }
|
||||
void UITask::gotoRepeaterScreen() { setCurrScreen(repeater_screen); }
|
||||
void UITask::gotoClockTools() { setCurrScreen(clock_tools); }
|
||||
void UITask::gotoGpioScreen() {
|
||||
#if defined(PIN_GPIO1)
|
||||
setCurrScreen(gpio_screen);
|
||||
#endif
|
||||
}
|
||||
void UITask::gotoLiveShareScreen() { setCurrScreen(live_share_screen); }
|
||||
|
||||
// ── Clock tools engine (alarm / countdown / ring) ───────────────────────────
|
||||
@@ -2051,6 +2070,108 @@ bool UITask::dequeueKey(char& c) {
|
||||
return true;
|
||||
}
|
||||
|
||||
#if defined(ENV_PIN_SDA) && defined(ENV_PIN_SCL)
|
||||
// CardKB's "fn" column (key_map in M5Stack's unit_CardKB.cpp): Fn+<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() {
|
||||
// Background delivery: resend pending on-device DMs whose ACK timed out, and
|
||||
// finalise the ✗ marker — runs regardless of which screen is active.
|
||||
@@ -2147,6 +2268,7 @@ void UITask::loop() {
|
||||
_analogue_pin_read_millis = millis();
|
||||
}
|
||||
#endif
|
||||
pollCardKB();
|
||||
#if defined(BACKLIGHT_BTN)
|
||||
if (millis() > next_backlight_btn_check) {
|
||||
bool touch_state = digitalRead(PIN_BUTTON2);
|
||||
@@ -2266,8 +2388,13 @@ void UITask::loop() {
|
||||
}
|
||||
// Hint popup at bottom (like alert style)
|
||||
_display->setTextSize(1);
|
||||
#if defined(ENV_PIN_SDA) && defined(ENV_PIN_SCL)
|
||||
const char* hint = _lock_seq_count == 0 ? (_has_cardkb ? "Back+3xEnter/Fn+Esc" : "Hold Back + 3xEnter") :
|
||||
_lock_seq_count == 1 ? "Enter x2 more..." : "Enter x1 more...";
|
||||
#else
|
||||
const char* hint = _lock_seq_count == 0 ? "Hold Back + 3xEnter" :
|
||||
_lock_seq_count == 1 ? "Enter x2 more..." : "Enter x1 more...";
|
||||
#endif
|
||||
int p = 3;
|
||||
int hy = _display->height() - lk_lh - p * 2;
|
||||
int hw = _display->getTextWidth(hint);
|
||||
@@ -2893,29 +3020,229 @@ bool UITask::hasGPS() {
|
||||
}
|
||||
|
||||
void UITask::toggleGPS() {
|
||||
if (_sensors != NULL) {
|
||||
// toggle GPS on/off
|
||||
int num = _sensors->getNumSettings();
|
||||
for (int i = 0; i < num; i++) {
|
||||
if (strcmp(_sensors->getSettingName(i), "gps") == 0) {
|
||||
if (strcmp(_sensors->getSettingValue(i), "1") == 0) {
|
||||
_sensors->setSettingValue("gps", "0");
|
||||
_node_prefs->gps_enabled = 0;
|
||||
notify(UIEventType::ack);
|
||||
} else {
|
||||
_sensors->setSettingValue("gps", "1");
|
||||
_node_prefs->gps_enabled = 1;
|
||||
notify(UIEventType::ack);
|
||||
}
|
||||
the_mesh.savePrefs();
|
||||
showAlert(_node_prefs->gps_enabled ? "GPS: Enabled" : "GPS: Disabled", 800);
|
||||
_next_refresh = 0;
|
||||
break;
|
||||
}
|
||||
if (_node_prefs) applyGpsState(_node_prefs->gps_enabled == 0);
|
||||
}
|
||||
|
||||
// Sets GPS to an absolute state (vs. toggleGPS()'s flip) -- shared by the
|
||||
// Home-page manual toggle and the bot's !gps on/off command, which needs to
|
||||
// set a specific state rather than flip whatever it currently is.
|
||||
void UITask::applyGpsState(bool on) {
|
||||
if (_sensors == NULL) return;
|
||||
int num = _sensors->getNumSettings();
|
||||
for (int i = 0; i < num; i++) {
|
||||
if (strcmp(_sensors->getSettingName(i), "gps") == 0) {
|
||||
_sensors->setSettingValue("gps", on ? "1" : "0");
|
||||
_node_prefs->gps_enabled = on ? 1 : 0;
|
||||
notify(UIEventType::ack);
|
||||
the_mesh.savePrefs();
|
||||
showAlert(_node_prefs->gps_enabled ? "GPS: Enabled" : "GPS: Disabled", 800);
|
||||
_next_refresh = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void UITask::botSetGPS(bool on) {
|
||||
applyGpsState(on);
|
||||
}
|
||||
|
||||
// Bot !buzz [seconds] -- a find-me signal, so it deliberately uses
|
||||
// playForced() (bypasses the buzzer_quiet mute) rather than play(): a
|
||||
// find-me beep that respects mute defeats its own purpose. Builds a simple
|
||||
// repeating beep/rest RTTTL string sized to the requested duration into the
|
||||
// persistent _bot_buzz_buf -- the nRF52 RTTTL player keeps a raw pointer into
|
||||
// whatever buffer it's given and reads from it across loop() calls for the
|
||||
// whole playback (same constraint as _notif_mel_buf), so this can't be a
|
||||
// local/stack buffer.
|
||||
void UITask::botBuzz(int seconds) {
|
||||
#if defined(PIN_BUZZER)
|
||||
if (seconds < 1) seconds = 5;
|
||||
if (seconds > 30) seconds = 30;
|
||||
int pairs = seconds * 2; // b=120: an "8c,8p," pair is 250+250 = 500ms
|
||||
int n = snprintf(_bot_buzz_buf, sizeof(_bot_buzz_buf), "Buzz:b=120:");
|
||||
for (int i = 0; i < pairs && n < (int)sizeof(_bot_buzz_buf) - 7; i++)
|
||||
n += snprintf(_bot_buzz_buf + n, sizeof(_bot_buzz_buf) - n, "8c,8p,");
|
||||
buzzer.playForced(_bot_buzz_buf);
|
||||
#endif
|
||||
}
|
||||
|
||||
#if defined(PIN_GPIO1)
|
||||
static uint32_t gpioPin(int idx) { // idx 1..4
|
||||
static const uint32_t pins[4] = { PIN_GPIO1, PIN_GPIO2, PIN_GPIO3, PIN_GPIO4 };
|
||||
return (idx >= 1 && idx <= 4) ? pins[idx - 1] : 0xFFFFFFFF;
|
||||
}
|
||||
|
||||
static uint8_t* gpioModeField(NodePrefs* p, int idx) { // idx 1..4
|
||||
switch (idx) {
|
||||
case 1: return &p->gpio1_mode;
|
||||
case 2: return &p->gpio2_mode;
|
||||
case 3: return &p->gpio3_mode;
|
||||
case 4: return &p->gpio4_mode;
|
||||
default: return NULL;
|
||||
}
|
||||
}
|
||||
|
||||
// Push a saved mode value to the actual pin hardware -- shared by
|
||||
// setGpioMode() (live edits from the UI) and applyAllGpioModes() (boot
|
||||
// restore), which differ only in whether the mode gets persisted. Mode 4
|
||||
// (Analog) uses the same "leave it alone" config as Off: the SAADC reads the
|
||||
// pin directly regardless of the GPIO block's state, and cfg_default (no
|
||||
// pull, disconnected buffer) is exactly what Nordic recommends for an ADC
|
||||
// input to avoid extra leakage current -- there's nothing separate to set up
|
||||
// here, unlike Input/Output.
|
||||
static void applyGpioModeToPin(uint32_t pin, uint8_t mode) {
|
||||
switch (mode) {
|
||||
case 1: nrf_gpio_cfg_input(pin, NRF_GPIO_PIN_PULLUP); break; // Input
|
||||
case 2: nrf_gpio_cfg_output(pin); nrf_gpio_pin_clear(pin); break; // Output, off
|
||||
case 3: nrf_gpio_cfg_output(pin); nrf_gpio_pin_set(pin); break; // Output, on
|
||||
default: nrf_gpio_cfg_default(pin); break; // Off / Analog
|
||||
}
|
||||
}
|
||||
|
||||
// GPIO1 (P0.02) = AIN0, GPIO2 (P0.29) = AIN5 -- the only two user pins wired
|
||||
// to the nRF52840's SAADC (confirmed against wiring_analog_nRF52.c's own
|
||||
// pin->channel switch). GPIO3/GPIO4 (P0.09/P0.10) have no ADC channel.
|
||||
static uint32_t gpioAnalogPsel(int idx) { // idx 1..4; 0 (NC) if unsupported
|
||||
if (idx == 1) return SAADC_CH_PSELP_PSELP_AnalogInput0;
|
||||
if (idx == 2) return SAADC_CH_PSELP_PSELP_AnalogInput5;
|
||||
return SAADC_CH_PSELP_PSELP_NC;
|
||||
}
|
||||
|
||||
// One-shot SAADC read, bypassing Arduino's analogRead() -- that function
|
||||
// treats its argument as an ARDUINO PIN INDEX (looked up through
|
||||
// g_ADigitalPinMap[]), not a raw channel, and no Arduino index maps to our
|
||||
// raw GPIO1/GPIO2 pins (same reason digitalWrite()/pinMode() can't be used
|
||||
// for these pins either -- see the file-level notes on PIN_GPIO1..4).
|
||||
// Mirrors wiring_analog_nRF52.c's analogRead_internal() exactly (10-bit,
|
||||
// 0.6V internal reference, 1/6 gain -> 0-3.6V range) so the numbers read the
|
||||
// same as a normal analogRead() would, just addressing the SAADC channel
|
||||
// directly instead of going through the pin-index dispatch.
|
||||
static uint16_t readAnalogMv(uint32_t psel) {
|
||||
NRF_SAADC->RESOLUTION = SAADC_RESOLUTION_VAL_10bit;
|
||||
NRF_SAADC->ENABLE = (SAADC_ENABLE_ENABLE_Enabled << SAADC_ENABLE_ENABLE_Pos);
|
||||
for (int i = 0; i < 8; i++) {
|
||||
NRF_SAADC->CH[i].PSELN = SAADC_CH_PSELP_PSELP_NC;
|
||||
NRF_SAADC->CH[i].PSELP = SAADC_CH_PSELP_PSELP_NC;
|
||||
}
|
||||
NRF_SAADC->CH[0].CONFIG =
|
||||
((SAADC_CH_CONFIG_RESP_Bypass << SAADC_CH_CONFIG_RESP_Pos) & SAADC_CH_CONFIG_RESP_Msk)
|
||||
| ((SAADC_CH_CONFIG_RESP_Bypass << SAADC_CH_CONFIG_RESN_Pos) & SAADC_CH_CONFIG_RESN_Msk)
|
||||
| ((SAADC_CH_CONFIG_GAIN_Gain1_6 << SAADC_CH_CONFIG_GAIN_Pos) & SAADC_CH_CONFIG_GAIN_Msk)
|
||||
| ((SAADC_CH_CONFIG_REFSEL_Internal << SAADC_CH_CONFIG_REFSEL_Pos) & SAADC_CH_CONFIG_REFSEL_Msk)
|
||||
| ((SAADC_CH_CONFIG_TACQ_3us << SAADC_CH_CONFIG_TACQ_Pos) & SAADC_CH_CONFIG_TACQ_Msk)
|
||||
| ((SAADC_CH_CONFIG_MODE_SE << SAADC_CH_CONFIG_MODE_Pos) & SAADC_CH_CONFIG_MODE_Msk);
|
||||
NRF_SAADC->CH[0].PSELN = psel;
|
||||
NRF_SAADC->CH[0].PSELP = psel;
|
||||
|
||||
volatile int16_t value = 0;
|
||||
NRF_SAADC->RESULT.PTR = (uint32_t)&value;
|
||||
NRF_SAADC->RESULT.MAXCNT = 1;
|
||||
|
||||
NRF_SAADC->TASKS_START = 1;
|
||||
while (!NRF_SAADC->EVENTS_STARTED);
|
||||
NRF_SAADC->EVENTS_STARTED = 0;
|
||||
|
||||
NRF_SAADC->TASKS_SAMPLE = 1;
|
||||
while (!NRF_SAADC->EVENTS_END);
|
||||
NRF_SAADC->EVENTS_END = 0;
|
||||
|
||||
NRF_SAADC->TASKS_STOP = 1;
|
||||
while (!NRF_SAADC->EVENTS_STOPPED);
|
||||
NRF_SAADC->EVENTS_STOPPED = 0;
|
||||
|
||||
NRF_SAADC->ENABLE = (SAADC_ENABLE_ENABLE_Disabled << SAADC_ENABLE_ENABLE_Pos);
|
||||
|
||||
if (value < 0) value = 0;
|
||||
// 10-bit, 1/6 gain, 0.6V internal ref -> full-scale = 0.6V / (1/6) = 3.6V
|
||||
return (uint16_t)(((uint32_t)value * 3600) / 1024);
|
||||
}
|
||||
#endif
|
||||
|
||||
// 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() {
|
||||
if (_node_prefs == NULL) return;
|
||||
// With APC on, tx_power_dbm is the ceiling — re-baseline the controller to it
|
||||
|
||||
@@ -45,6 +45,10 @@ class UITask : public AbstractUITask {
|
||||
bool _lock_seq_used; // true = suppress next back_btn CLICK (post-sequence release)
|
||||
char _alert[80];
|
||||
char _notif_mel_buf[220]; // persistent RTTTL buffer for custom notification melodies
|
||||
// Persistent RTTTL buffer for the bot !buzz command (see botBuzz()) -- sized
|
||||
// for the full 30s cap: "Buzz:b=120:" (11B) + up to 60 "8c,8p," pairs (6B
|
||||
// each) + NUL = 372B, rounded up with margin.
|
||||
char _bot_buzz_buf[400];
|
||||
KeyboardWidget _kb; // shared across all screens — only one active at a time
|
||||
unsigned long _alert_expiry;
|
||||
int _msgcount;
|
||||
@@ -91,6 +95,9 @@ class UITask : public AbstractUITask {
|
||||
UIScreen* diag_screen = nullptr;
|
||||
UIScreen* repeater_screen = nullptr;
|
||||
UIScreen* clock_tools = nullptr;
|
||||
#if defined(PIN_GPIO1)
|
||||
UIScreen* gpio_screen = nullptr;
|
||||
#endif
|
||||
UIScreen* curr = nullptr;
|
||||
CayenneLPP _dash_lpp;
|
||||
TrailStore _trail;
|
||||
@@ -186,6 +193,21 @@ class UITask : public AbstractUITask {
|
||||
void enqueueKey(char c);
|
||||
bool dequeueKey(char& c);
|
||||
|
||||
// Optional M5Stack CardKB (I2C keyboard, addr 0x5F) on the Grove/Wire1 bus
|
||||
// -- reuses ENV_PIN_SDA/ENV_PIN_SCL (already brought up for
|
||||
// EnvironmentSensorManager) as the "this board has a second I2C bus" gate,
|
||||
// rather than a new board-specific pin define. No-op entirely on boards
|
||||
// without that bus, or when nothing ACKs 0x5F at boot.
|
||||
#if defined(ENV_PIN_SDA) && defined(ENV_PIN_SCL)
|
||||
bool _has_cardkb = false;
|
||||
// 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);
|
||||
|
||||
// Centred alert overlay (the showAlert() box). Wraps long text to up to
|
||||
@@ -287,6 +309,7 @@ public:
|
||||
void gotoCompassScreen();
|
||||
void gotoDiagnosticsScreen();
|
||||
void gotoRepeaterScreen();
|
||||
void gotoGpioScreen(); // no-op on boards without user GPIO pins (see PIN_GPIO1)
|
||||
void gotoClockTools(); // Alarm / Timer / Stopwatch (from the home Clock page)
|
||||
// Wake the display for an alarm/timer ring (force an immediate refresh).
|
||||
void wakeForAlarm();
|
||||
@@ -410,6 +433,17 @@ public:
|
||||
bool getGPSState();
|
||||
bool hasGPS(); // true if this board exposes a toggleable GPS (distinct from GPS being off)
|
||||
void toggleGPS();
|
||||
void applyGpsState(bool on); // shared by toggleGPS() and botSetGPS()
|
||||
void botSetGPS(bool on) override;
|
||||
void botBuzz(int seconds) override;
|
||||
// User GPIO (!gpio1..!gpio4 + Tools > GPIO screen). idx is 1-4. Bodies are
|
||||
// no-ops / return false on boards without PIN_GPIO1 defined.
|
||||
bool botSetGPIO(int idx, bool on) override;
|
||||
bool botGetGPIO(int idx, bool& is_output, bool& value) override;
|
||||
bool botGetGPIOAnalog(int idx, int& millivolts) override;
|
||||
bool gpioSupportsAnalog(int idx) const; // true only for GPIO1/GPIO2 (AIN0/AIN5)
|
||||
void setGpioMode(int idx, uint8_t mode); // 0=Off 1=In 2=Out-low 3=Out-high 4=Analog; applies + persists
|
||||
void applyAllGpioModes(); // boot-time restore from NodePrefs, called from begin()
|
||||
void applyBrightness();
|
||||
void setBrightnessLevel(uint8_t level);
|
||||
uint8_t getBrightnessLevel() const { return _node_prefs ? _node_prefs->display_brightness : 2; }
|
||||
|
||||
@@ -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
|
||||
|
||||
### Fixes
|
||||
|
||||
@@ -14,6 +14,12 @@ public:
|
||||
virtual long getLongitude() = 0;
|
||||
virtual long getAltitude() = 0;
|
||||
virtual long satellitesCount() = 0;
|
||||
// Horizontal Dilution of Precision, in tenths (11 == HDOP 1.1) -- lower is
|
||||
// better, and a much more direct read on fix quality than satellite count
|
||||
// alone (few satellites in good geometry can beat many in poor geometry).
|
||||
// -1 means this provider doesn't expose it; callers fall back to
|
||||
// satellitesCount() in that case.
|
||||
virtual long getHDOP() { return -1; }
|
||||
virtual bool isValid() = 0;
|
||||
virtual long getTimestamp() = 0;
|
||||
virtual void sendSentence(const char * sentence);
|
||||
|
||||
@@ -120,6 +120,7 @@ public :
|
||||
return alt;
|
||||
}
|
||||
long satellitesCount() override { return nmea.getNumSatellites(); }
|
||||
long getHDOP() override { return nmea.getHDOP(); }
|
||||
bool isValid() override { return nmea.isValid(); }
|
||||
|
||||
long getTimestamp() override {
|
||||
|
||||
@@ -13,6 +13,13 @@
|
||||
#define KEY_NEXT 0xF1
|
||||
#define KEY_PREV 0xF2
|
||||
#define KEY_CONTEXT_MENU 0xF3
|
||||
// A literal-ASCII input source's (e.g. an I2C CardKB) Enter, translated only
|
||||
// when the on-screen keyboard's plain grid state is active (see
|
||||
// UITask::pollCardKB()) -- means "submit the field", not KEY_ENTER's usual
|
||||
// "commit whatever grid cell (row,col) is currently selected", which would
|
||||
// otherwise insert a stray character since direct typing never touches the
|
||||
// grid.
|
||||
#define KEY_KB_ENTER 0x05
|
||||
|
||||
// "Previous"/"Next" navigation keys: the directional key and its rotary-encoder
|
||||
// twin. Decrement / increment a value, or step a selection. (Cancel and the
|
||||
|
||||
@@ -63,7 +63,31 @@ const uint32_t g_ADigitalPinMap[] = {
|
||||
0xFF, // 37 MISO
|
||||
};
|
||||
|
||||
// GPIO3/GPIO4 (P0.09/P0.10) are the nRF52840's NFC1/NFC2 pins. Factory UICR
|
||||
// reserves them for NFC antenna operation -- nrf_gpio_cfg_output/cfg_input
|
||||
// alone won't work on them until UICR.NFCPINS.PROTECT flips from NFC to
|
||||
// GPIO, which only takes effect after a reset. This clears a single bit
|
||||
// (1=NFC -> 0=GPIO), which flash permits without an erase cycle, so no other
|
||||
// UICR field (BOOTLOADERADDR, APPROTECT, ...) is touched. One-way: going back
|
||||
// to NFC needs a full chip erase via debugger. No-ops on every boot after the
|
||||
// first (reads as GPIO already). Adapted from Adafruit's own
|
||||
// Bluefruit52Lib/examples/Hardware/nfc_to_gpio example.
|
||||
static void ensureNfcPinsAsGpio() {
|
||||
if ((NRF_UICR->NFCPINS & UICR_NFCPINS_PROTECT_Msk) ==
|
||||
(UICR_NFCPINS_PROTECT_NFC << UICR_NFCPINS_PROTECT_Pos)) {
|
||||
NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Wen << NVMC_CONFIG_WEN_Pos;
|
||||
while (NRF_NVMC->READY == NVMC_READY_READY_Busy);
|
||||
NRF_UICR->NFCPINS &= ~UICR_NFCPINS_PROTECT_Msk;
|
||||
while (NRF_NVMC->READY == NVMC_READY_READY_Busy);
|
||||
NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Ren << NVMC_CONFIG_WEN_Pos;
|
||||
while (NRF_NVMC->READY == NVMC_READY_READY_Busy);
|
||||
NVIC_SystemReset(); // UICR is only latched at boot
|
||||
}
|
||||
}
|
||||
|
||||
void initVariant() {
|
||||
ensureNfcPinsAsGpio(); // must run first: may reset the board
|
||||
|
||||
pinMode(PIN_QSPI_CS, OUTPUT);
|
||||
digitalWrite(PIN_QSPI_CS, HIGH);
|
||||
|
||||
|
||||
@@ -91,6 +91,17 @@
|
||||
#define PIN_GPS_STANDBY (0)
|
||||
#define PIN_GPS_EN (PIN_GPS_STANDBY)
|
||||
|
||||
// User-assignable GPIO: 4 pins with no board function, exposed for bot
|
||||
// commands (!gpio1..!gpio4) and the Tools > GPIO screen. Raw nRF52 pin
|
||||
// addressing via NRF_GPIO_PIN_MAP (NOT Arduino pin numbers -- these aren't in
|
||||
// g_ADigitalPinMap[], so pinMode()/digitalWrite() would silently hit whatever
|
||||
// Arduino pin index happens to equal the raw value instead). See
|
||||
// initVariant()'s ensureNfcPinsAsGpio() for the GPIO3/GPIO4 NFC-pin caveat.
|
||||
#define PIN_GPIO1 NRF_GPIO_PIN_MAP(0, 2)
|
||||
#define PIN_GPIO2 NRF_GPIO_PIN_MAP(0, 29)
|
||||
#define PIN_GPIO3 NRF_GPIO_PIN_MAP(0, 9) // NFC1
|
||||
#define PIN_GPIO4 NRF_GPIO_PIN_MAP(0, 10) // NFC2
|
||||
|
||||
// QSPI Pins
|
||||
#define PIN_QSPI_SCK (19)
|
||||
#define PIN_QSPI_CS (20)
|
||||
|
||||
Reference in New Issue
Block a user