diff --git a/examples/companion_radio/ui-new/KeyboardWidget.h b/examples/companion_radio/ui-new/KeyboardWidget.h index fcdc9f9a..ade9cd6b 100644 --- a/examples/companion_radio/ui-new/KeyboardWidget.h +++ b/examples/companion_radio/ui-new/KeyboardWidget.h @@ -721,6 +721,30 @@ struct KeyboardWidget { if (c == KEY_CANCEL) return CANCELLED; + // Direct-typing passthrough (CardKB or similar literal-ASCII input + // source, see UITask::pollCardKB()). Printable characters insert + // straight at the cursor, bypassing the on-screen grid entirely -- no + // caps re-application, the source already sends the correct case. + // Backspace deletes the previous character. KEY_KB_ENTER (only ever + // emitted when the grid is in this exact plain state -- no popup, no + // cursor-mode, see pollCardKB()) submits the field, instead of + // KEY_ENTER's usual "commit grid cell (row,col)". + if (c == KEY_KB_ENTER) return DONE; + if (c == 0x08) { + if (cursor_pos > 0) { + int n = kbUtf8LastCharBytes(buf, cursor_pos); + memmove(buf + cursor_pos - n, buf + cursor_pos, len - cursor_pos); + len -= n; cursor_pos -= n; + buf[len] = '\0'; + } + return NONE; + } + if (c >= 0x20 && c <= 0x7E) { + char one[2] = { c, '\0' }; + insertGlyph(one, false); + return NONE; + } + const int rows = gridRows(); const int cols = gridCols(); diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index af658f1b..daf9d860 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1386,6 +1386,13 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no uint32_t aoff = autoOffMillis(); _auto_off = millis() + (aoff > 0 ? aoff : AUTO_OFF_MILLIS); +#if defined(ENV_PIN_SDA) && defined(ENV_PIN_SCL) + // Wire1 is already brought up by sensors.begin() (EnvironmentSensorManager), + // which runs before this -- just probe for a CardKB sitting on it. + Wire1.beginTransmission(0x5F); + _has_cardkb = (Wire1.endTransmission() == 0); +#endif + #if defined(PIN_USER_BTN) user_btn.begin(); #endif @@ -2063,6 +2070,47 @@ bool UITask::dequeueKey(char& c) { return true; } +// Poll an optional CardKB (I2C keyboard, addr 0x5F) on Wire1/Grove, feeding +// the same key queue as every physical button. Most of its output needs no +// translation at all: CardKB's own arrow/Enter/Esc byte codes are already +// identical to this UI's KEY_LEFT/UP/DOWN/RIGHT/ENTER/CANCEL (0xB4-0xB7, 13, +// 27), and Backspace (0x08) / printable ASCII (0x20-0x7E) collide with +// nothing that existed before. Two bytes get remapped: +// - Enter, while the on-screen keyboard's plain grid state is active (no +// placeholder/accent popup, not in cursor-mode) -- there it means "submit +// the field", not KEY_ENTER's usual "commit grid cell (row,col)", which +// would otherwise insert a stray character since direct typing never +// touches the grid. See KeyboardWidget::handleInput()'s KEY_KB_ENTER +// branch. +// - Tab (0x09, otherwise unused by any screen) -> KEY_CONTEXT_MENU, the +// "Hold-Enter" context-menu gesture used in ~30 places across the UI +// (message reply/navigate, Bot/Admin/Repeater menus, ...). CardKB has no +// press-duration concept to detect a long-press with, so Tab is the +// closest free key standing in for it -- full keyboard-only navigation +// would otherwise be unable to reach any of those menus at all. +void UITask::pollCardKB() { +#if defined(ENV_PIN_SDA) && defined(ENV_PIN_SCL) + if (!_has_cardkb) return; + if (millis() - _cardkb_poll_ms < 30) return; // ~33 Hz poll, plenty for typing + _cardkb_poll_ms = millis(); + Wire1.requestFrom(0x5F, 1); + if (!Wire1.available()) return; + uint8_t raw = Wire1.read(); + if (raw == 0) return; // no key down + + char key; + if (raw == 0x0D && + _kb.isVisible() && !_kb._ph_menu.active && !_kb.cursor_mode && !_kb.accent_active) { + key = KEY_KB_ENTER; + } else if (raw == 0x09) { + key = KEY_CONTEXT_MENU; + } else { + key = (char)raw; + } + enqueueKey(checkDisplayOn(key)); +#endif +} + void UITask::loop() { // Background delivery: resend pending on-device DMs whose ACK timed out, and // finalise the ✗ marker — runs regardless of which screen is active. @@ -2159,6 +2207,7 @@ void UITask::loop() { _analogue_pin_read_millis = millis(); } #endif + pollCardKB(); #if defined(BACKLIGHT_BTN) if (millis() > next_backlight_btn_check) { bool touch_state = digitalRead(PIN_BUTTON2); diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 4a3c1a00..106f3d0d 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -193,6 +193,17 @@ class UITask : public AbstractUITask { void enqueueKey(char c); bool dequeueKey(char& c); + // Optional M5Stack CardKB (I2C keyboard, addr 0x5F) on the Grove/Wire1 bus + // -- reuses ENV_PIN_SDA/ENV_PIN_SCL (already brought up for + // EnvironmentSensorManager) as the "this board has a second I2C bus" gate, + // rather than a new board-specific pin define. No-op entirely on boards + // without that bus, or when nothing ACKs 0x5F at boot. +#if defined(ENV_PIN_SDA) && defined(ENV_PIN_SCL) + bool _has_cardkb = false; + uint32_t _cardkb_poll_ms = 0; +#endif + void pollCardKB(); + void setCurrScreen(UIScreen* c); // Centred alert overlay (the showAlert() box). Wraps long text to up to diff --git a/release-notes.md b/release-notes.md index ec4412ab..57f2eb33 100644 --- a/release-notes.md +++ b/release-notes.md @@ -6,6 +6,7 @@ - **Remote Bot gains Actions: `!buzz`, `!gps`, `!advert`.** A new **Actions** toggle (per target — Direct/Channel/Room, nested under each target's existing **Commands** toggle) enables three commands that change the device's own behaviour instead of just reporting on it: `!buzz [seconds]` sounds the buzzer as a find-me signal (default 5s, capped at 30s, sounds even if the buzzer is muted), `!gps on`/`!gps off` toggles GPS, and `!advert` sends an immediate advert. Off by default; combines with the existing query commands in one message the same way (`!batt !gps on` → `4.10V | GPS: on`). - **Bot Trigger fields accept multiple phrases.** Pack several trigger words into one Trigger field, comma-separated (`hi,hello there,yo`) — matching any one of them fires the reply, same as before for a single phrase. - **Remote Bot Actions gain `!gpio1`..`!gpio4`** (Wio Tracker L1 only — 4 otherwise-unused pins). Each pin is independently set to Off/Input/Output (GPIO1/GPIO2 also offer Analog) from a new **Tools › GPIO** screen; `!gpio1 on`/`!gpio1 off` drives an Output pin remotely, a bare `!gpio1` reports the current mode and reading (including a millivolt value in Analog mode). Gated by the same per-target Actions toggle as `!buzz`/`!gps`/`!advert`. +- **Optional CardKB support (Wio Tracker L1, Grove I2C) — full keyboard-only navigation.** Plug an M5Stack CardKB into the Grove connector and it's auto-detected at boot — no setting to flip. Typing goes straight into the message/name field instead of navigating the on-screen keyboard grid; arrows, Enter and Esc work as expected everywhere else too (including inside the placeholder and accent popups); Tab opens the Hold-Enter context menu (message reply/navigate, Bot/Admin/Repeater menus, …) — the whole interface is reachable without touching a physical button. ### Fixes diff --git a/src/helpers/ui/UIScreen.h b/src/helpers/ui/UIScreen.h index db2793b0..2bebaff3 100644 --- a/src/helpers/ui/UIScreen.h +++ b/src/helpers/ui/UIScreen.h @@ -13,6 +13,13 @@ #define KEY_NEXT 0xF1 #define KEY_PREV 0xF2 #define KEY_CONTEXT_MENU 0xF3 +// A literal-ASCII input source's (e.g. an I2C CardKB) Enter, translated only +// when the on-screen keyboard's plain grid state is active (see +// UITask::pollCardKB()) -- means "submit the field", not KEY_ENTER's usual +// "commit whatever grid cell (row,col) is currently selected", which would +// otherwise insert a stray character since direct typing never touches the +// grid. +#define KEY_KB_ENTER 0x05 // "Previous"/"Next" navigation keys: the directional key and its rotary-encoder // twin. Decrement / increment a value, or step a selection. (Cancel and the