From 3ae988c0bbeb2befa857c1ab40c7d58251dac563 Mon Sep 17 00:00:00 2001 From: pelgraine <140762863+pelgraine@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:23:05 +1100 Subject: [PATCH] t5s3 cardkb support; update firmware build date --- examples/companion_radio/MyMesh.h | 2 +- examples/companion_radio/main.cpp | 139 ++++++++++++++++++ .../companion_radio/ui-new/CardKBKeyboard.h | 98 ++++++++++++ examples/companion_radio/ui-new/UITask.cpp | 24 +++ examples/companion_radio/ui-new/UITask.h | 8 + .../companion_radio/ui-new/virtualkeyboard.h | 26 ++++ .../lilygo_t5s3_epaper_pro/platformio.ini | 7 +- variants/lilygo_t5s3_epaper_pro/variant.h | 1 + 8 files changed, 301 insertions(+), 4 deletions(-) create mode 100644 examples/companion_radio/ui-new/CardKBKeyboard.h diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 40b9b08..0ada55e 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -8,7 +8,7 @@ #define FIRMWARE_VER_CODE 10 #ifndef FIRMWARE_BUILD_DATE -#define FIRMWARE_BUILD_DATE "19 March 2026" +#define FIRMWARE_BUILD_DATE "20 March 2026" #endif #ifndef FIRMWARE_VERSION diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 1382cc8..619249a 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -364,6 +364,13 @@ static bool gt911Ready = false; static bool sdCardReady = false; // T5S3 SD card state +#ifdef MECK_CARDKB + #include "CardKBKeyboard.h" + static CardKBKeyboard cardkb; + static unsigned long lastCardKBProbe = 0; + #define CARDKB_PROBE_INTERVAL_MS 5000 // Re-probe for hot-plug every 5s +#endif + // Read GT911 in landscape orientation (960×540) static bool readTouchLandscape(int16_t* outX, int16_t* outY) { if (!gt911Ready) return false; @@ -1397,6 +1404,16 @@ void setup() { } #endif + // Initialize CardKB external keyboard (if connected via QWIIC) + #if defined(LilyGo_T5S3_EPaper_Pro) && defined(MECK_CARDKB) + if (cardkb.begin()) { + ui_task.setCardKBDetected(true); + Serial.println("setup() - CardKB detected at 0x5F"); + } else { + Serial.println("setup() - CardKB not detected (will re-probe)"); + } + #endif + // RTC diagnostic + boot-time serial clock sync (T5S3 has no GPS) #if defined(LilyGo_T5S3_EPaper_Pro) { @@ -1978,6 +1995,128 @@ void loop() { #endif #endif // MECK_TOUCH_ENABLED + // --------------------------------------------------------------------------- + // CardKB external keyboard polling (T5S3 only, via QWIIC) + // When VKB is active: typed characters feed into the VKB text buffer. + // When VKB is not active: navigation keys route through injectKey(). + // ESC key maps to 'q' (back) when no VKB is active. + // --------------------------------------------------------------------------- +#if defined(LilyGo_T5S3_EPaper_Pro) && defined(MECK_CARDKB) + { + // Hot-plug detection: re-probe periodically + if (millis() - lastCardKBProbe >= CARDKB_PROBE_INTERVAL_MS) { + lastCardKBProbe = millis(); + bool wasDetected = cardkb.isDetected(); + bool nowDetected = cardkb.probe(); + if (nowDetected != wasDetected) { + ui_task.setCardKBDetected(nowDetected); + Serial.printf("[CardKB] %s\n", nowDetected ? "Connected" : "Disconnected"); + } + } + + // Poll for keypress + char ckb = cardkb.readKey(); + if (ckb != 0) { + // Block input while locked (same as touch) + if (!ui_task.isLocked()) { + cpuPower.setBoost(); + ui_task.keepAlive(); + + if (ui_task.isVKBActive()) { + // VKB is open — feed character into VKB text buffer + ui_task.feedCardKBChar(ckb); + } else if (ckb == 0x1B) { + // ESC → back (same as 'q' on T-Deck Pro) + ui_task.injectKey('q'); + } else if (ui_task.isOnHomeScreen()) { + // Home screen: letter shortcuts open tiles, arrows cycle pages + switch (ckb) { + case 'm': ui_task.gotoChannelScreen(); break; + case 'c': ui_task.gotoContactsScreen(); break; + case 'e': ui_task.gotoTextReader(); break; + case 'n': ui_task.gotoNotesScreen(); break; + case 's': ui_task.gotoSettingsScreen(); break; + case 'f': ui_task.gotoDiscoveryScreen(); break; + case 'h': ui_task.gotoLastHeardScreen(); break; +#ifdef MECK_WEB_READER + case 'b': ui_task.gotoWebReader(); break; +#endif +#if HAS_GPS + case 'g': ui_task.gotoMapScreen(); break; +#endif + default: ui_task.injectKey(ckb); break; + } + } else { + // Non-home screens: handle Enter for compose, remap arrows to WASD. + // Screens respond to w/s (scroll up/down) and a/d (prev/next channel) + // but not to KEY_LEFT/KEY_RIGHT constants. + if (ckb == '\r') { + // Enter key — screen-specific compose or select + if (ui_task.isOnChannelScreen()) { + // Open VKB for channel message compose + uint8_t chIdx = ui_task.getChannelScreenViewIdx(); + ChannelDetails ch; + if (the_mesh.getChannel(chIdx, ch)) { + char label[40]; + snprintf(label, sizeof(label), "To: %s", ch.name); + ui_task.showVirtualKeyboard(VKB_CHANNEL_MSG, label, "", 137, chIdx); + } + } else if (ui_task.isOnContactsScreen()) { + // DM compose for chat contacts, admin for repeaters + ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen(); + if (cs) { + int idx = cs->getSelectedContactIdx(); + uint8_t ctype = cs->getSelectedContactType(); + if (idx >= 0 && ctype == ADV_TYPE_CHAT) { + char dname[32]; + cs->getSelectedContactName(dname, sizeof(dname)); + char label[40]; + snprintf(label, sizeof(label), "DM: %s", dname); + ui_task.showVirtualKeyboard(VKB_DM, label, "", 137, idx); + } else if (idx >= 0 && ctype == ADV_TYPE_REPEATER) { + ui_task.gotoRepeaterAdmin(idx); + } + } + } else if (ui_task.isOnRepeaterAdmin()) { + // Open VKB for password or CLI entry + RepeaterAdminScreen* admin = (RepeaterAdminScreen*)ui_task.getRepeaterAdminScreen(); + if (admin) { + RepeaterAdminScreen::AdminState astate = admin->getState(); + if (astate == RepeaterAdminScreen::STATE_PASSWORD_ENTRY) { + ui_task.showVirtualKeyboard(VKB_ADMIN_PASSWORD, "Admin Password", "", 32); + } else { + ui_task.showVirtualKeyboard(VKB_ADMIN_CLI, "Admin Command", "", 137); + } + } + } else if (ui_task.isOnNotesScreen()) { + // Open VKB for note editing + NotesScreen* notesScr = (NotesScreen*)ui_task.getNotesScreen(); + if (notesScr && notesScr->isEditing()) { + ui_task.showVirtualKeyboard(VKB_NOTES, "Edit Note", "", 137); + } else { + ui_task.injectKey('\r'); // File list: select/open + } + } else { + // All other screens: pass Enter through for native handling + // (settings toggle, discovery add-contact, last heard, text reader file select, etc.) + ui_task.injectKey('\r'); + } + } else { + // Non-Enter keys: remap arrows to WASD, pass others through + switch (ckb) { + case (char)0xF2: ui_task.injectKey('w'); break; // Up → scroll up + case (char)0xF1: ui_task.injectKey('s'); break; // Down → scroll down + case (char)0xF3: ui_task.injectKey('a'); break; // Left → prev channel/category + case (char)0xF4: ui_task.injectKey('d'); break; // Right → next channel/category + default: ui_task.injectKey(ckb); break; + } + } + } + } + } + } +#endif + // Poll touch input for phone dialer numpad // Hybrid debounce: finger-up detection + 150ms minimum between accepted taps. // The CST328 INT pin is pulse-based (not level), so getPoint() can return diff --git a/examples/companion_radio/ui-new/CardKBKeyboard.h b/examples/companion_radio/ui-new/CardKBKeyboard.h new file mode 100644 index 0000000..e152ff7 --- /dev/null +++ b/examples/companion_radio/ui-new/CardKBKeyboard.h @@ -0,0 +1,98 @@ +#pragma once +// ============================================================================= +// CardKBKeyboard — M5Stack CardKB (or compatible) I2C keyboard driver +// +// Polls 0x5F on the shared I2C bus via QWIIC connector. +// Maps CardKB special key codes to Meck key constants. +// +// Usage: +// CardKBKeyboard cardkb; +// if (cardkb.begin()) { /* detected */ } +// char key = cardkb.readKey(); // returns 0 if no key +// ============================================================================= + +#if defined(LilyGo_T5S3_EPaper_Pro) && defined(MECK_CARDKB) +#ifndef CARDKB_KEYBOARD_H +#define CARDKB_KEYBOARD_H + +#include +#include + +// I2C address (defined in variant.h, fallback here) +#ifndef CARDKB_I2C_ADDR +#define CARDKB_I2C_ADDR 0x5F +#endif + +// CardKB special key codes (from M5Stack documentation) +#define CARDKB_KEY_UP 0xB5 +#define CARDKB_KEY_DOWN 0xB6 +#define CARDKB_KEY_LEFT 0xB4 +#define CARDKB_KEY_RIGHT 0xB7 +#define CARDKB_KEY_TAB 0x09 +#define CARDKB_KEY_ESC 0x1B +#define CARDKB_KEY_BS 0x08 +#define CARDKB_KEY_ENTER 0x0D +#define CARDKB_KEY_DEL 0x7F +#define CARDKB_KEY_FN 0x00 // Fn modifier (swallowed by CardKB internally) + +class CardKBKeyboard { +public: + CardKBKeyboard() : _detected(false) {} + + // Probe for CardKB on the I2C bus. Call after Wire.begin(). + bool begin() { + Wire.beginTransmission(CARDKB_I2C_ADDR); + _detected = (Wire.endTransmission() == 0); + if (_detected) { + Serial.println("[CardKB] Detected at 0x5F"); + } + return _detected; + } + + // Re-probe (e.g. for hot-plug detection every few seconds) + bool probe() { + Wire.beginTransmission(CARDKB_I2C_ADDR); + _detected = (Wire.endTransmission() == 0); + return _detected; + } + + bool isDetected() const { return _detected; } + + // Poll for a keypress. Returns 0 if no key available. + // Returns raw ASCII for printable chars, or Meck KEY_* constants for nav keys. + char readKey() { + if (!_detected) return 0; + + Wire.requestFrom((uint8_t)CARDKB_I2C_ADDR, (uint8_t)1); + if (!Wire.available()) return 0; + + uint8_t raw = Wire.read(); + if (raw == 0) return 0; + + // Map CardKB special keys to Meck constants + switch (raw) { + case CARDKB_KEY_UP: return 0xF2; // KEY_PREV + case CARDKB_KEY_DOWN: return 0xF1; // KEY_NEXT + case CARDKB_KEY_LEFT: return 0xF3; // KEY_LEFT + case CARDKB_KEY_RIGHT: return 0xF4; // KEY_RIGHT + case CARDKB_KEY_ENTER: return '\r'; + case CARDKB_KEY_BS: return '\b'; + case CARDKB_KEY_DEL: return '\b'; // Treat delete same as backspace + case CARDKB_KEY_ESC: return 0x1B; // ESC — handled by caller + case CARDKB_KEY_TAB: return 0x09; // Tab — available for future use + default: + // Printable ASCII — pass through unchanged + if (raw >= 0x20 && raw <= 0x7E) { + return (char)raw; + } + // Unknown code — ignore + return 0; + } + } + +private: + bool _detected; +}; + +#endif // CARDKB_KEYBOARD_H +#endif // LilyGo_T5S3_EPaper_Pro && MECK_CARDKB \ No newline at end of file diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 19a9328..c951a6e 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -2032,6 +2032,30 @@ void UITask::onVKBCancel() { display.invalidateFrameCRC(); Serial.println("[UI] VKB cancelled"); } + +#ifdef MECK_CARDKB +void UITask::feedCardKBChar(char c) { + if (_vkbActive) { + // VKB is open — feed character into its text buffer + if (_vkb.feedChar(c)) { + _next_refresh = 0; // Redraw VKB immediately + _auto_off = millis() + 120000; // Extend timeout while typing + // Check if feedChar triggered submit or cancel + if (_vkb.status() == VKB_SUBMITTED) { + onVKBSubmit(); + } else if (_vkb.status() == VKB_CANCELLED) { + onVKBCancel(); + } + } else { + // feedChar returned false — nav keys (arrows) while VKB is active + // Not consumed; could be used for cursor movement in future + } + } else { + // No VKB active — route as normal navigation key + injectKey(c); + } +} +#endif #endif bool UITask::getGPSState() { diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 23001fd..227b06f 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -103,6 +103,9 @@ class UITask : public AbstractUITask { bool _vkbActive = false; UIScreen* _screenBeforeVKB = nullptr; unsigned long _vkbOpenedAt = 0; +#ifdef MECK_CARDKB + bool _cardkbDetected = false; +#endif #elif defined(LilyGo_TDeck_Pro) UIScreen* lock_screen; // Lock screen (big clock + battery + unread) UIScreen* _screenBeforeLock = nullptr; @@ -197,6 +200,11 @@ public: void showVirtualKeyboard(VKBPurpose purpose, const char* label, const char* initial, int maxLen, int contextIdx = 0); void onVKBSubmit(); void onVKBCancel(); +#ifdef MECK_CARDKB + void setCardKBDetected(bool v) { _cardkbDetected = v; } + bool hasCardKB() const { return _cardkbDetected; } + void feedCardKBChar(char c); +#endif #endif #ifdef MECK_WEB_READER bool isOnWebReader() const { return curr == web_reader; } diff --git a/examples/companion_radio/ui-new/virtualkeyboard.h b/examples/companion_radio/ui-new/virtualkeyboard.h index 65fbe0f..2f2a551 100644 --- a/examples/companion_radio/ui-new/virtualkeyboard.h +++ b/examples/companion_radio/ui-new/virtualkeyboard.h @@ -217,6 +217,32 @@ public: // Swipe up on keyboard = cancel void cancel() { _status = VKB_CANCELLED; } + // --- Feed a raw ASCII character from an external physical keyboard --- + // Maps standard ASCII control chars to internal VKB actions. + // Returns true if the character was consumed. +#ifdef MECK_CARDKB + bool feedChar(char c) { + if (_status != VKB_EDITING) return false; + switch (c) { + case '\r': processKey('>'); return true; // Enter → submit + case '\b': processKey('<'); return true; // Backspace + case 0x7F: processKey('<'); return true; // Delete → backspace + case 0x1B: _status = VKB_CANCELLED; return true; // ESC → cancel + case ' ': processKey('~'); return true; // Space + default: + // Printable ASCII → insert directly + if (c >= 0x20 && c <= 0x7E) { + if (_textLen < _maxLen) { + _text[_textLen++] = c; + _text[_textLen] = '\0'; + } + return true; + } + return false; // Non-printable / nav keys — not consumed + } + } +#endif + private: VKBStatus _status; VKBPurpose _purpose; diff --git a/variants/lilygo_t5s3_epaper_pro/platformio.ini b/variants/lilygo_t5s3_epaper_pro/platformio.ini index 2fba8f4..20f1780 100644 --- a/variants/lilygo_t5s3_epaper_pro/platformio.ini +++ b/variants/lilygo_t5s3_epaper_pro/platformio.ini @@ -79,7 +79,7 @@ build_flags = -D CHANNEL_MSG_HISTORY_SIZE=800 -D DISPLAY_CLASS=FastEPDDisplay -D USE_EINK - ; Font family: comment/uncomment to toggle (delete .indexes on SD after switching) + -D MECK_CARDKB ; -D MECK_SERIF_FONT ; FreeSerif (Times New Roman-like) ; ; Default (no flag): FreeSans (Arial-like) build_src_filter = ${LilyGo_T5S3_EPaper_Pro.build_src_filter} @@ -111,6 +111,7 @@ build_flags = -D OFFLINE_QUEUE_SIZE=256 -D DISPLAY_CLASS=FastEPDDisplay -D USE_EINK + -D MECK_CARDKB ; -D MECK_SERIF_FONT build_src_filter = ${LilyGo_T5S3_EPaper_Pro.build_src_filter} + @@ -144,6 +145,7 @@ build_flags = -D OFFLINE_QUEUE_SIZE=256 -D DISPLAY_CLASS=FastEPDDisplay -D USE_EINK + -D MECK_CARDKB ; -D MECK_SERIF_FONT build_src_filter = ${LilyGo_T5S3_EPaper_Pro.build_src_filter} + @@ -156,5 +158,4 @@ lib_deps = densaugeo/base64 @ ~1.4.0 adafruit/Adafruit GFX Library@^1.11.0 https://github.com/mverch67/FastEPD/archive/0df1bff329b6fc782e062f611758880762340647.zip - https://github.com/lewisxhe/SensorLib/archive/refs/tags/v0.3.4.zip - + https://github.com/lewisxhe/SensorLib/archive/refs/tags/v0.3.4.zip \ No newline at end of file diff --git a/variants/lilygo_t5s3_epaper_pro/variant.h b/variants/lilygo_t5s3_epaper_pro/variant.h index 24fc110..6874368 100644 --- a/variants/lilygo_t5s3_epaper_pro/variant.h +++ b/variants/lilygo_t5s3_epaper_pro/variant.h @@ -29,6 +29,7 @@ #define I2C_ADDR_BQ27220 0x55 // Fuel gauge #define I2C_ADDR_BQ25896 0x6B // Battery charger #define I2C_ADDR_TPS65185 0x68 // E-ink power driver +#define CARDKB_I2C_ADDR 0x5F // M5Stack CardKB (external, via QWIIC) // ----------------------------------------------------------------------------- // SPI Bus — shared by LoRa and SD card