feat(bot): Actions commands, multi-trigger, and user GPIO pins

- Auto-Reply Bot gains Actions (!buzz/!gps/!advert) behind a new per-target
  toggle nested under Commands (bot_actions_dm/ch/room); off by default.
- Bot Trigger fields accept comma-separated multiple phrases, matching any
  one fires the reply.
- New user-assignable GPIO feature (Wio Tracker L1): !gpio1..!gpio4 bot
  commands plus a Tools > GPIO screen. Each pin cycles Off/Input/Output;
  GPIO1/GPIO2 (P0.02/P0.29, the nRF52840's AIN0/AIN5) also offer a read-only
  Analog mode via direct SAADC access. GPIO3/GPIO4 (P0.09/P0.10) are the
  chip's NFC1/NFC2 pins, repurposed as plain GPIO via a one-time UICR
  NFCPINS bit-clear in initVariant() (adapted from Adafruit's own
  nfc_to_gpio example) -- confirmed working on real hardware.
- Fix: DM/room reply-prefix ("@[nick] ") stripping happened at the wrong
  layer, hiding the "To:" header on DM replies and leaking the raw prefix
  into room messages' list view; a related mismatch had the history
  scrollbar's sizing pass wrap room messages with the sender name still
  attached, disagreeing with the actual rendered text.

Build-verified: WioTrackerL1_companion_solo_dual and
WioTrackerL1Eink_companion_solo_dual both compile and link clean
(sizeof(NodePrefs) confirmed 2720 via real build, not guessed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-07-21 20:30:03 +02:00
co-authored by Claude Sonnet 5
parent 7113d34ea1
commit 5bfebc6559
15 changed files with 729 additions and 55 deletions
+19 -4
View File
@@ -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 };
@@ -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
@@ -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) {
@@ -1031,6 +1045,8 @@ public:
if (ring_pos >= 0) {
const DmHistEntry& e = _history.dmAtPos(ring_pos);
char sender_buf[33];
// No skipReplyPrefix() here -- _dm_fs.render() parses "@[nick] " itself
// (for the "To:" header); stripping it here first would hide it there.
const char* body = dmDisplayParts(e, _sel_contact.type == ADV_TYPE_ROOM,
filtered_name, sender_buf, sizeof(sender_buf));
const char* sender = sender_buf;
@@ -1073,7 +1089,15 @@ public:
hist_start_y, cby, lh,
[&](int idx) -> const char* {
int rp = _history.dmHistEntryForContact(_sel_contact.id.pub_key, idx);
return rp >= 0 ? _history.dmAtPos(rp).text : nullptr;
if (rp < 0) return nullptr;
// Must match the per-item body extraction below (dmDisplayParts +
// skipReplyPrefix) exactly, or this sizing pass and the real render
// pass disagree on wrapped line count for room posts -- previously
// this returned the raw "Sender: text" unsplit, wrapping the sender
// name in with the body and mis-sizing the box.
char tmp_sender[33];
return skipReplyPrefix(dmDisplayParts(_history.dmAtPos(rp), is_room, filtered_name,
tmp_sender, sizeof(tmp_sender)));
});
int reserve = hs.reserve;
{
@@ -1091,7 +1115,7 @@ public:
int rp = _history.dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_scroll + ii);
if (rp >= 0) {
char hsb[33];
const char* hbody = dmDisplayParts(_history.dmAtPos(rp), is_room, filtered_name, hsb, sizeof(hsb));
const char* hbody = skipReplyPrefix(dmDisplayParts(_history.dmAtPos(rp), is_room, filtered_name, hsb, sizeof(hsb)));
display.translateUTF8ToBlocks(s_wrap_trans, hbody, sizeof(s_wrap_trans));
int nl = FullscreenMsgView::wrapLines(display, s_wrap_trans, display.width() - 6 - reserve, s_wrap_lines, 8);
bh = (1 + (nl > 0 ? nl : 1)) * lh + 1;
@@ -1115,7 +1139,7 @@ public:
const DmHistEntry& e = _history.dmAtPos(ring_pos);
char sender_buf[33];
const char* body = dmDisplayParts(e, is_room, filtered_name, sender_buf, sizeof(sender_buf));
const char* body = skipReplyPrefix(dmDisplayParts(e, is_room, filtered_name, sender_buf, sizeof(sender_buf)));
const char* sender = sender_buf;
char age[6]; geo::fmtAgeShort(age, sizeof(age), now_ts, e.timestamp);
@@ -1233,7 +1257,7 @@ public:
if (rp < 0) return nullptr;
const char* t = _history.chAtPos(rp).text;
const char* s = strstr(t, ": ");
return s ? s + 2 : t;
return skipReplyPrefix(s ? s + 2 : t);
});
int reserve = hs.reserve;
{
+10 -1
View File
@@ -16,6 +16,9 @@ class ToolsScreen : public UIScreen {
ACT_NEARBY, ACT_LIVESHARE, ACT_TRAIL, ACT_LOCATOR, ACT_COMPASS,
ACT_BOT, ACT_AUTOADVERT, ACT_REPEATER, ACT_ADMIN,
ACT_CLOCK, ACT_RINGTONE, ACT_DIAGNOSTICS
#if defined(PIN_GPIO1)
, ACT_GPIO
#endif
};
struct Tool { const char* label; const MiniIcon* icon; Action action; };
struct Section { const char* name; const MiniIcon* icon; const Tool* tools; uint8_t count; };
@@ -53,6 +56,9 @@ class ToolsScreen : public UIScreen {
case ACT_CLOCK: _task->gotoClockTools(); break;
case ACT_RINGTONE: _task->gotoRingtoneEditor(); break;
case ACT_DIAGNOSTICS: _task->gotoDiagnosticsScreen(); break;
#if defined(PIN_GPIO1)
case ACT_GPIO: _task->gotoGpioScreen(); break;
#endif
}
}
@@ -128,9 +134,12 @@ const ToolsScreen::Tool ToolsScreen::SYSTEM_TOOLS[] = {
{ "Clock Tools", &ICON_ALARM, ACT_CLOCK },
{ "Ringtone Editor", &ICON_NOTE, ACT_RINGTONE },
{ "Diagnostics", &ICON_CHART, ACT_DIAGNOSTICS },
#if defined(PIN_GPIO1)
{ "GPIO", &ICON_GEAR, ACT_GPIO },
#endif
};
const ToolsScreen::Section ToolsScreen::SECTIONS[] = {
{ "Location", &ICON_MAP_CONTACT, LOCATION_TOOLS, 5 },
{ "Comms", &ICON_ADVERT, COMMS_TOOLS, 4 },
{ "System", &ICON_GEAR, SYSTEM_TOOLS, 3 },
{ "System", &ICON_GEAR, SYSTEM_TOOLS, (uint8_t)(sizeof(SYSTEM_TOOLS)/sizeof(SYSTEM_TOOLS[0])) },
};
+229 -19
View File
@@ -146,6 +146,9 @@ static const int QUICK_MSGS_MAX = 10;
#include "CompassScreen.h"
#include "DiagnosticsScreen.h"
#include "RepeaterScreen.h"
#if defined(PIN_GPIO1)
#include "GpioScreen.h"
#endif
#include "ToolsScreen.h"
#include "ClockToolsScreen.h" // Alarm / Timer / Stopwatch (Clock page Enter)
@@ -1463,10 +1466,14 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
diag_screen = new DiagnosticsScreen(this);
repeater_screen = new RepeaterScreen(this);
clock_tools = new ClockToolsScreen(this, node_prefs);
#if defined(PIN_GPIO1)
gpio_screen = new GpioScreen(this, node_prefs);
#endif
applyBrightness();
applyFont();
applyRotation();
applyFullRefreshInterval();
applyAllGpioModes(); // restore persisted pin modes to hardware before any UI/bot use
setCurrScreen(splash);
}
@@ -1491,6 +1498,11 @@ void UITask::gotoCompassScreen() { setCurrScreen(compass_screen); }
void UITask::gotoDiagnosticsScreen() { setCurrScreen(diag_screen); }
void UITask::gotoRepeaterScreen() { setCurrScreen(repeater_screen); }
void UITask::gotoClockTools() { setCurrScreen(clock_tools); }
void UITask::gotoGpioScreen() {
#if defined(PIN_GPIO1)
setCurrScreen(gpio_screen);
#endif
}
void UITask::gotoLiveShareScreen() { setCurrScreen(live_share_screen); }
// ── Clock tools engine (alarm / countdown / ring) ───────────────────────────
@@ -2893,29 +2905,227 @@ bool UITask::hasGPS() {
}
void UITask::toggleGPS() {
if (_sensors != NULL) {
// toggle GPS on/off
int num = _sensors->getNumSettings();
for (int i = 0; i < num; i++) {
if (strcmp(_sensors->getSettingName(i), "gps") == 0) {
if (strcmp(_sensors->getSettingValue(i), "1") == 0) {
_sensors->setSettingValue("gps", "0");
_node_prefs->gps_enabled = 0;
notify(UIEventType::ack);
} else {
_sensors->setSettingValue("gps", "1");
_node_prefs->gps_enabled = 1;
notify(UIEventType::ack);
}
the_mesh.savePrefs();
showAlert(_node_prefs->gps_enabled ? "GPS: Enabled" : "GPS: Disabled", 800);
_next_refresh = 0;
break;
}
if (_node_prefs) applyGpsState(_node_prefs->gps_enabled == 0);
}
// Sets GPS to an absolute state (vs. toggleGPS()'s flip) -- shared by the
// Home-page manual toggle and the bot's !gps on/off command, which needs to
// set a specific state rather than flip whatever it currently is.
void UITask::applyGpsState(bool on) {
if (_sensors == NULL) return;
int num = _sensors->getNumSettings();
for (int i = 0; i < num; i++) {
if (strcmp(_sensors->getSettingName(i), "gps") == 0) {
_sensors->setSettingValue("gps", on ? "1" : "0");
_node_prefs->gps_enabled = on ? 1 : 0;
notify(UIEventType::ack);
the_mesh.savePrefs();
showAlert(_node_prefs->gps_enabled ? "GPS: Enabled" : "GPS: Disabled", 800);
_next_refresh = 0;
break;
}
}
}
void UITask::botSetGPS(bool on) {
applyGpsState(on);
}
// Bot !buzz [seconds] -- a find-me signal, so it deliberately uses
// playForced() (bypasses the buzzer_quiet mute) rather than play(): a
// find-me beep that respects mute defeats its own purpose. Builds a simple
// repeating beep/rest RTTTL string sized to the requested duration into the
// persistent _bot_buzz_buf -- the nRF52 RTTTL player keeps a raw pointer into
// whatever buffer it's given and reads from it across loop() calls for the
// whole playback (same constraint as _notif_mel_buf), so this can't be a
// local/stack buffer.
void UITask::botBuzz(int seconds) {
#if defined(PIN_BUZZER)
if (seconds < 1) seconds = 5;
if (seconds > 30) seconds = 30;
int pairs = seconds * 2; // b=120: an "8c,8p," pair is 250+250 = 500ms
int n = snprintf(_bot_buzz_buf, sizeof(_bot_buzz_buf), "Buzz:b=120:");
for (int i = 0; i < pairs && n < (int)sizeof(_bot_buzz_buf) - 7; i++)
n += snprintf(_bot_buzz_buf + n, sizeof(_bot_buzz_buf) - n, "8c,8p,");
buzzer.playForced(_bot_buzz_buf);
#endif
}
#if defined(PIN_GPIO1)
static uint32_t gpioPin(int idx) { // idx 1..4
static const uint32_t pins[4] = { PIN_GPIO1, PIN_GPIO2, PIN_GPIO3, PIN_GPIO4 };
return (idx >= 1 && idx <= 4) ? pins[idx - 1] : 0xFFFFFFFF;
}
static uint8_t* gpioModeField(NodePrefs* p, int idx) { // idx 1..4
switch (idx) {
case 1: return &p->gpio1_mode;
case 2: return &p->gpio2_mode;
case 3: return &p->gpio3_mode;
case 4: return &p->gpio4_mode;
default: return NULL;
}
}
// Push a saved mode value to the actual pin hardware -- shared by
// setGpioMode() (live edits from the UI) and applyAllGpioModes() (boot
// restore), which differ only in whether the mode gets persisted. Mode 4
// (Analog) uses the same "leave it alone" config as Off: the SAADC reads the
// pin directly regardless of the GPIO block's state, and cfg_default (no
// pull, disconnected buffer) is exactly what Nordic recommends for an ADC
// input to avoid extra leakage current -- there's nothing separate to set up
// here, unlike Input/Output.
static void applyGpioModeToPin(uint32_t pin, uint8_t mode) {
switch (mode) {
case 1: nrf_gpio_cfg_input(pin, NRF_GPIO_PIN_PULLUP); break; // Input
case 2: nrf_gpio_cfg_output(pin); nrf_gpio_pin_clear(pin); break; // Output, off
case 3: nrf_gpio_cfg_output(pin); nrf_gpio_pin_set(pin); break; // Output, on
default: nrf_gpio_cfg_default(pin); break; // Off / Analog
}
}
// GPIO1 (P0.02) = AIN0, GPIO2 (P0.29) = AIN5 -- the only two user pins wired
// to the nRF52840's SAADC (confirmed against wiring_analog_nRF52.c's own
// pin->channel switch). GPIO3/GPIO4 (P0.09/P0.10) have no ADC channel.
static uint32_t gpioAnalogPsel(int idx) { // idx 1..4; 0 (NC) if unsupported
if (idx == 1) return SAADC_CH_PSELP_PSELP_AnalogInput0;
if (idx == 2) return SAADC_CH_PSELP_PSELP_AnalogInput5;
return SAADC_CH_PSELP_PSELP_NC;
}
// One-shot SAADC read, bypassing Arduino's analogRead() -- that function
// treats its argument as an ARDUINO PIN INDEX (looked up through
// g_ADigitalPinMap[]), not a raw channel, and no Arduino index maps to our
// raw GPIO1/GPIO2 pins (same reason digitalWrite()/pinMode() can't be used
// for these pins either -- see the file-level notes on PIN_GPIO1..4).
// Mirrors wiring_analog_nRF52.c's analogRead_internal() exactly (10-bit,
// 0.6V internal reference, 1/6 gain -> 0-3.6V range) so the numbers read the
// same as a normal analogRead() would, just addressing the SAADC channel
// directly instead of going through the pin-index dispatch.
static uint16_t readAnalogMv(uint32_t psel) {
NRF_SAADC->RESOLUTION = SAADC_RESOLUTION_VAL_10bit;
NRF_SAADC->ENABLE = (SAADC_ENABLE_ENABLE_Enabled << SAADC_ENABLE_ENABLE_Pos);
for (int i = 0; i < 8; i++) {
NRF_SAADC->CH[i].PSELN = SAADC_CH_PSELP_PSELP_NC;
NRF_SAADC->CH[i].PSELP = SAADC_CH_PSELP_PSELP_NC;
}
NRF_SAADC->CH[0].CONFIG =
((SAADC_CH_CONFIG_RESP_Bypass << SAADC_CH_CONFIG_RESP_Pos) & SAADC_CH_CONFIG_RESP_Msk)
| ((SAADC_CH_CONFIG_RESP_Bypass << SAADC_CH_CONFIG_RESN_Pos) & SAADC_CH_CONFIG_RESN_Msk)
| ((SAADC_CH_CONFIG_GAIN_Gain1_6 << SAADC_CH_CONFIG_GAIN_Pos) & SAADC_CH_CONFIG_GAIN_Msk)
| ((SAADC_CH_CONFIG_REFSEL_Internal << SAADC_CH_CONFIG_REFSEL_Pos) & SAADC_CH_CONFIG_REFSEL_Msk)
| ((SAADC_CH_CONFIG_TACQ_3us << SAADC_CH_CONFIG_TACQ_Pos) & SAADC_CH_CONFIG_TACQ_Msk)
| ((SAADC_CH_CONFIG_MODE_SE << SAADC_CH_CONFIG_MODE_Pos) & SAADC_CH_CONFIG_MODE_Msk);
NRF_SAADC->CH[0].PSELN = psel;
NRF_SAADC->CH[0].PSELP = psel;
volatile int16_t value = 0;
NRF_SAADC->RESULT.PTR = (uint32_t)&value;
NRF_SAADC->RESULT.MAXCNT = 1;
NRF_SAADC->TASKS_START = 1;
while (!NRF_SAADC->EVENTS_STARTED);
NRF_SAADC->EVENTS_STARTED = 0;
NRF_SAADC->TASKS_SAMPLE = 1;
while (!NRF_SAADC->EVENTS_END);
NRF_SAADC->EVENTS_END = 0;
NRF_SAADC->TASKS_STOP = 1;
while (!NRF_SAADC->EVENTS_STOPPED);
NRF_SAADC->EVENTS_STOPPED = 0;
NRF_SAADC->ENABLE = (SAADC_ENABLE_ENABLE_Disabled << SAADC_ENABLE_ENABLE_Pos);
if (value < 0) value = 0;
// 10-bit, 1/6 gain, 0.6V internal ref -> full-scale = 0.6V / (1/6) = 3.6V
return (uint16_t)(((uint32_t)value * 3600) / 1024);
}
#endif
// Cycle a user GPIO pin's mode (Off/In/Out-low/Out-high), apply it to the
// actual pin, and persist. Called by GpioScreen on Enter.
void UITask::setGpioMode(int idx, uint8_t mode) {
#if defined(PIN_GPIO1)
if (!_node_prefs) return;
uint8_t* f = gpioModeField(_node_prefs, idx);
uint32_t pin = gpioPin(idx);
if (!f || pin == 0xFFFFFFFF) return;
if (mode == 4 && !gpioSupportsAnalog(idx)) mode = 0; // no ADC channel on this pin -- fall back to Off
*f = mode;
applyGpioModeToPin(pin, mode);
the_mesh.savePrefs();
#else
(void)idx; (void)mode;
#endif
}
// Boot-time restore: push each pin's saved mode to hardware before any UI/bot
// interaction (mirrors MyMesh::applyGpsPrefs()'s role for the GPS toggle --
// there's no generic "restore all settings" hook in this codebase, each
// persisted hardware toggle gets its own bespoke boot call). Deliberately
// doesn't call savePrefs() -- nothing changed, just re-applying what's
// already on disk.
void UITask::applyAllGpioModes() {
#if defined(PIN_GPIO1)
if (!_node_prefs) return;
for (int i = 1; i <= 4; i++) {
uint8_t* f = gpioModeField(_node_prefs, i);
if (f) applyGpioModeToPin(gpioPin(i), *f);
}
#endif
}
bool UITask::botSetGPIO(int idx, bool on) {
#if defined(PIN_GPIO1)
if (!_node_prefs) return false;
uint8_t* f = gpioModeField(_node_prefs, idx);
if (!f || (*f != 2 && *f != 3)) return false; // not configured as Output
setGpioMode(idx, on ? 3 : 2);
return true;
#else
(void)idx; (void)on;
return false;
#endif
}
bool UITask::botGetGPIO(int idx, bool& is_output, bool& value) {
#if defined(PIN_GPIO1)
if (!_node_prefs) return false;
uint8_t* f = gpioModeField(_node_prefs, idx);
uint32_t pin = gpioPin(idx);
if (!f || *f == 0 || *f == 4 || pin == 0xFFFFFFFF) return false; // Off / Analog / unsupported
is_output = (*f == 2 || *f == 3);
value = is_output ? (nrf_gpio_pin_out_read(pin) != 0) : (nrf_gpio_pin_read(pin) != 0);
return true;
#else
(void)idx; (void)is_output; (void)value;
return false;
#endif
}
bool UITask::gpioSupportsAnalog(int idx) const {
#if defined(PIN_GPIO1)
return idx == 1 || idx == 2;
#else
(void)idx;
return false;
#endif
}
bool UITask::botGetGPIOAnalog(int idx, int& millivolts) {
#if defined(PIN_GPIO1)
if (!_node_prefs || !gpioSupportsAnalog(idx)) return false;
uint8_t* f = gpioModeField(_node_prefs, idx);
if (!f || *f != 4) return false; // not in Analog mode
millivolts = readAnalogMv(gpioAnalogPsel(idx));
return true;
#else
(void)idx; (void)millivolts;
return false;
#endif
}
void UITask::applyTxPower() {
if (_node_prefs == NULL) return;
// With APC on, tx_power_dbm is the ceiling — re-baseline the controller to it
+19
View File
@@ -45,6 +45,10 @@ class UITask : public AbstractUITask {
bool _lock_seq_used; // true = suppress next back_btn CLICK (post-sequence release)
char _alert[80];
char _notif_mel_buf[220]; // persistent RTTTL buffer for custom notification melodies
// Persistent RTTTL buffer for the bot !buzz command (see botBuzz()) -- sized
// for the full 30s cap: "Buzz:b=120:" (11B) + up to 60 "8c,8p," pairs (6B
// each) + NUL = 372B, rounded up with margin.
char _bot_buzz_buf[400];
KeyboardWidget _kb; // shared across all screens — only one active at a time
unsigned long _alert_expiry;
int _msgcount;
@@ -91,6 +95,9 @@ class UITask : public AbstractUITask {
UIScreen* diag_screen = nullptr;
UIScreen* repeater_screen = nullptr;
UIScreen* clock_tools = nullptr;
#if defined(PIN_GPIO1)
UIScreen* gpio_screen = nullptr;
#endif
UIScreen* curr = nullptr;
CayenneLPP _dash_lpp;
TrailStore _trail;
@@ -287,6 +294,7 @@ public:
void gotoCompassScreen();
void gotoDiagnosticsScreen();
void gotoRepeaterScreen();
void gotoGpioScreen(); // no-op on boards without user GPIO pins (see PIN_GPIO1)
void gotoClockTools(); // Alarm / Timer / Stopwatch (from the home Clock page)
// Wake the display for an alarm/timer ring (force an immediate refresh).
void wakeForAlarm();
@@ -410,6 +418,17 @@ public:
bool getGPSState();
bool hasGPS(); // true if this board exposes a toggleable GPS (distinct from GPS being off)
void toggleGPS();
void applyGpsState(bool on); // shared by toggleGPS() and botSetGPS()
void botSetGPS(bool on) override;
void botBuzz(int seconds) override;
// User GPIO (!gpio1..!gpio4 + Tools > GPIO screen). idx is 1-4. Bodies are
// no-ops / return false on boards without PIN_GPIO1 defined.
bool botSetGPIO(int idx, bool on) override;
bool botGetGPIO(int idx, bool& is_output, bool& value) override;
bool botGetGPIOAnalog(int idx, int& millivolts) override;
bool gpioSupportsAnalog(int idx) const; // true only for GPIO1/GPIO2 (AIN0/AIN5)
void setGpioMode(int idx, uint8_t mode); // 0=Off 1=In 2=Out-low 3=Out-high 4=Analog; applies + persists
void applyAllGpioModes(); // boot-time restore from NodePrefs, called from begin()
void applyBrightness();
void setBrightnessLevel(uint8_t level);
uint8_t getBrightnessLevel() const { return _node_prefs ? _node_prefs->display_brightness : 2; }