19 Commits
ble ... basic

Author SHA1 Message Date
pelgraine
3ac5570ebb Repeater acks for sent messages in Sent message popup now included 2026-02-08 00:27:13 +11:00
pelgraine
e194f6d307 Fixed render battery indicator so it uses the same linear mapping for the UI as the BLE app 2026-02-07 20:42:32 +11:00
pelgraine
af9f41a541 Updated version and date in mymesh 2026-02-07 16:24:10 +11:00
pelgraine
0a746cdca5 Merge branch 'main' into dev 2026-02-07 16:22:51 +11:00
pelgraine
3a5c48f440 "Battery UI changes - percentage display and icon size" 2026-02-07 16:20:33 +11:00
pelgraine
e40d9ced4a Merge branch 'dev' 2026-02-04 12:45:42 +11:00
pelgraine
b8de2d0d16 "updated mymesh h with firmware version details" 2026-02-04 12:45:06 +11:00
pelgraine
9fbc3202f6 "fixed reocurring BLE queue bug that popped up in v0.6.1. Improved keyboard responsiveness" 2026-02-04 12:44:17 +11:00
pelgraine
9d91f48797 Merge branch 'dev' 2026-02-02 21:28:48 +11:00
pelgraine
21eb385763 "Updated version date on mymesh.h and fixed modem_power_EN so 4G modem made inactive and annoying red LED Status light disabled when using firmware via Launcher mode" 2026-02-02 21:28:08 +11:00
pelgraine
4b81e596d2 "Fixed the queueSentChannelMessage BLE history ommission" 2026-02-01 20:26:27 +11:00
pelgraine
a5f2e8d055 "updated readme.md roadmap details" 2026-02-01 19:52:42 +11:00
pelgraine
462b1cb642 "Removed Preview Message overlay in favour of short popup that shows you which channel you've received a new message in" 2026-02-01 19:49:30 +11:00
pelgraine
0b270c0e1a "Changed word wrapping in channel view screen to boundary wrapping" 2026-02-01 19:38:26 +11:00
pelgraine
2730c05329 "Updated readme and firmware version" 2026-02-01 18:18:21 +11:00
pelgraine
02d2fb08fb "Added symbol capability" 2026-02-01 18:09:24 +11:00
pelgraine
b0003e1896 "Added additional channel compose functionality - can switch channels now. Minor ui changes for nav bar" 2026-02-01 17:51:17 +11:00
pelgraine
0be77ef759 "updated firmware version on mymesh" 2026-01-29 22:06:41 +11:00
pelgraine
c5df40cefd Added basic Public channel only view message history and compose - bugs still present 2026-01-29 21:33:14 +11:00
11 changed files with 1240 additions and 38 deletions

View File

@@ -1,7 +1,64 @@
## Meshcore + Fork = Meck
This fork was created specifically to focus on enabling BLE companion firmware for the LilyGo T-Deck Pro. Created with the assistance of Claude AI using Meshcore v1.11 code.
***Please note as of 28 Jan 2026, the T-Deck Pro repeater & usb firmware has not been finalised nor confirmed as functioning.*** ⭐
***Please note as of 1 Feb 2026, the T-Deck Pro repeater & usb firmware has not been finalised nor confirmed as functioning.*** ⭐
## T-Deck Pro Keyboard Controls
The T-Deck Pro BLE companion firmware includes full keyboard support for standalone messaging without a phone.
### Navigation (Home Screen)
| Key | Action |
|-----|--------|
| W / A | Previous page |
| S / D | Next page |
| Enter | Select / Confirm |
| M | Open channel messages |
| Q | Back to home screen |
### Channel Message Screen
| Key | Action |
|-----|--------|
| W / S | Scroll messages up/down |
| A / D | Switch between channels |
| C | Compose new message |
| Q | Back to home screen |
### Compose Mode
| Key | Action |
|-----|--------|
| A / D | Switch destination channel (when message is empty) |
| Enter | Send message |
| Backspace | Delete last character |
| Shift + Backspace | Cancel and exit compose mode |
### Symbol Entry (Sym Key)
Press the **Sym** key then the letter key to enter numbers and symbols:
| Key | Sym+ | | Key | Sym+ | | Key | Sym+ |
|-----|------|-|-----|------|-|-----|------|
| Q | # | | A | * | | Z | 7 |
| W | 1 | | S | 4 | | X | 8 |
| E | 2 | | D | 5 | | C | 9 |
| R | 3 | | F | 6 | | V | ? |
| T | ( | | G | / | | B | ! |
| Y | ) | | H | : | | N | , |
| U | _ | | J | ; | | M | . |
| I | - | | K | ' | | Mic | 0 |
| O | + | | L | " | | $ | (dedicated) |
| P | @ | | | | | | |
### Other Keys
| Key | Action |
|-----|--------|
| Shift | Uppercase next letter |
| Alt | Same as Sym (for numbers/symbols) |
| Space | Space character / Next in navigation |
## About MeshCore
@@ -18,10 +75,10 @@ MeshCore provides the ability to create wireless mesh networks, similar to Mesht
* Devices can forward messages across multiple nodes, extending range beyond a single radio's reach.
* Supports up to a configurable number of hops to balance network efficiency and prevent excessive traffic.
* Nodes use fixed roles where "Companion" nodes are not repeating messages at all to prevent adverse routing paths from being used.
* Supports LoRa Radios Works with Heltec, RAK Wireless, and other LoRa-based hardware.
* Decentralized & Resilient No central server or internet required; the network is self-healing.
* Low Power Consumption Ideal for battery-powered or solar-powered devices.
* Simple to Deploy Pre-built example applications make it easy to get started.
* Supports LoRa Radios Works with Heltec, RAK Wireless, and other LoRa-based hardware.
* Decentralized & Resilient No central server or internet required; the network is self-healing.
* Low Power Consumption Ideal for battery-powered or solar-powered devices.
* Simple to Deploy Pre-built example applications make it easy to get started.
## What Can You Use MeshCore For?
@@ -47,19 +104,19 @@ For developers;
## MeshCore Flasher
We have prebuilt firmware ready to flash on supported devices.
Download a copy of the Meck firmware bin from https://github.com/pelgraine/Meck/releases, then:
- Launch https://flasher.meshcore.co.uk
- Select a supported device
- Flash one of the firmware types:
- Companion, Repeater or Room Server
- Select Custom Firmware
- Select the .bin file you just downloaded, and click Open or press Enter.
- Click Flash, then select your device in the popup window (eg. USB JTAG/serial debug unit cu.usbmodem101 as an example), then click Connect.
- Once flashing is complete, you can connect with one of the MeshCore clients below.
## MeshCore Clients
**Companion Firmware**
The companion firmware can be connected to via BLE, USB or WiFi depending on the firmware type you flashed.
The companion firmware can be connected to via BLE. USB is planned for a future update.
- Web: https://app.meshcore.nz
- Android: https://play.google.com/store/apps/details?id=com.liamcottle.meshcore.android
@@ -89,14 +146,14 @@ Here are some general principals you should try to adhere to:
There are a number of fairly major features in the pipeline, with no particular time-frames attached yet. In partly chronological order:
- [X] Companion radio: BLE
- [X] Text entry for Public channel messages Companion BLE firmware
- [X] View and compose all channel messages Companion BLE firmware
- [ ] Standalone DM functionality for Companion BLE firmware
- [ ] Companion radio: USB
- [ ] Simple Repeater firmware for the T-Deck Pro
- [ ] Get pin 45 with the screen backlight functioning for the T-Deck Pro v1.1
- [ ] Canned messages function for Companion BLE firmware
- [ ] Text entry for Companion BLE firmware
## 📞 Get Support
- Join [MeshCore Discord](https://discord.gg/BMwCtwHj5V) to chat with the developers and get help from the community.
- Join [MeshCore Discord](https://discord.gg/BMwCtwHj5V) to chat with the developers and get help from the community.

View File

@@ -43,4 +43,6 @@ public:
virtual void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) = 0;
virtual void notify(UIEventType t = UIEventType::none) = 0;
virtual void loop() = 0;
};
virtual void showAlert(const char* text, int duration_millis) {}
virtual void forceRefresh() {}
};

View File

@@ -446,9 +446,37 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe
}
bool MyMesh::filterRecvFloodPacket(mesh::Packet* packet) {
// REVISIT: try to determine which Region (from transport_codes[1]) that Sender is indicating for replies/responses
// if unknown, fallback to finding Region from transport_codes[0], the 'scope' used by Sender
return false;
// Check if this incoming flood packet is a repeat of a message we recently sent
if (packet->payload_len >= SENT_FINGERPRINT_SIZE) {
unsigned long now = millis();
for (int i = 0; i < SENT_TRACK_SIZE; i++) {
SentMsgTrack* t = &_sent_track[i];
if (!t->active) continue;
// Expire old entries
if ((now - t->sent_millis) > SENT_TRACK_EXPIRY_MS) {
t->active = false;
continue;
}
// Compare payload fingerprint
if (memcmp(packet->payload, t->fingerprint, SENT_FINGERPRINT_SIZE) == 0) {
t->repeat_count++;
MESH_DEBUG_PRINTLN("SentTrack: heard repeat #%d (SNR=%.1f)", t->repeat_count, packet->getSNR());
#ifdef DISPLAY_CLASS
if (_ui) {
char buf[40];
snprintf(buf, sizeof(buf), "Sent! (%d)", t->repeat_count);
_ui->showAlert(buf, 2000); // show/extend alert with updated count
}
#endif
break; // found match, no need to check other entries
}
}
}
return false; // never filter — let normal processing continue
}
void MyMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis) {
@@ -463,6 +491,17 @@ void MyMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, ui
}
}
void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis) {
// Capture payload fingerprint for repeat tracking before sending
if (pkt->payload_len >= SENT_FINGERPRINT_SIZE) {
SentMsgTrack* t = &_sent_track[_sent_track_idx];
memcpy(t->fingerprint, pkt->payload, SENT_FINGERPRINT_SIZE);
t->repeat_count = 0;
t->sent_millis = millis();
t->active = true;
_sent_track_idx = (_sent_track_idx + 1) % SENT_TRACK_SIZE;
MESH_DEBUG_PRINTLN("SentTrack: captured fingerprint for channel msg");
}
// TODO: have per-channel send_scope
if (send_scope.isNull()) {
sendFlood(pkt, delay_millis);
@@ -541,6 +580,46 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe
#endif
}
void MyMesh::queueSentChannelMessage(uint8_t channel_idx, uint32_t timestamp, const char* sender, const char* text) {
// Format message the same way as onChannelMessageRecv for BLE app sync
// This allows sent messages from device keyboard to appear in the app
int i = 0;
if (app_target_ver >= 3) {
out_frame[i++] = RESP_CODE_CHANNEL_MSG_RECV_V3;
out_frame[i++] = 0; // SNR not applicable for sent messages
out_frame[i++] = 0; // reserved1
out_frame[i++] = 0; // reserved2
} else {
out_frame[i++] = RESP_CODE_CHANNEL_MSG_RECV;
}
out_frame[i++] = channel_idx;
out_frame[i++] = 0; // path_len = 0 indicates local/sent message
out_frame[i++] = TXT_TYPE_PLAIN;
memcpy(&out_frame[i], &timestamp, 4);
i += 4;
// Format as "sender: text" like the app expects
char formatted[MAX_FRAME_SIZE];
snprintf(formatted, sizeof(formatted), "%s: %s", sender, text);
int tlen = strlen(formatted);
if (i + tlen > MAX_FRAME_SIZE) {
tlen = MAX_FRAME_SIZE - i;
}
memcpy(&out_frame[i], formatted, tlen);
i += tlen;
addToOfflineQueue(out_frame, i);
// If app is connected, send push notification
if (_serial->isConnected()) {
uint8_t frame[1];
frame[0] = PUSH_CODE_MSG_WAITING;
_serial->writeFrame(frame, 1);
}
}
uint8_t MyMesh::onContactRequest(const ContactInfo &contact, uint32_t sender_timestamp, const uint8_t *data,
uint8_t len, uint8_t *reply) {
if (data[0] == REQ_TYPE_GET_TELEMETRY_DATA) {
@@ -786,6 +865,8 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
dirty_contacts_expiry = 0;
memset(advert_paths, 0, sizeof(advert_paths));
memset(send_scope.key, 0, sizeof(send_scope.key));
memset(_sent_track, 0, sizeof(_sent_track));
_sent_track_idx = 0;
// defaults
memset(&_prefs, 0, sizeof(_prefs));
@@ -1979,4 +2060,4 @@ bool MyMesh::advert() {
} else {
return false;
}
}
}

View File

@@ -8,11 +8,11 @@
#define FIRMWARE_VER_CODE 8
#ifndef FIRMWARE_BUILD_DATE
#define FIRMWARE_BUILD_DATE "29 Jan 2026"
#define FIRMWARE_BUILD_DATE "7 Feb 2026"
#endif
#ifndef FIRMWARE_VERSION
#define FIRMWARE_VERSION "Meck v0.4"
#define FIRMWARE_VERSION "Meck v0.6.4"
#endif
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
@@ -101,6 +101,9 @@ public:
void enterCLIRescue();
int getRecentlyHeard(AdvertPath dest[], int max_num);
// Queue a sent channel message for BLE app sync
void queueSentChannelMessage(uint8_t channel_idx, uint32_t timestamp, const char* sender, const char* text);
protected:
float getAirtimeBudgetFactor() const override;
@@ -228,6 +231,19 @@ private:
#define ADVERT_PATH_TABLE_SIZE 16
AdvertPath advert_paths[ADVERT_PATH_TABLE_SIZE]; // circular table
// Sent message repeat tracking
#define SENT_TRACK_SIZE 4
#define SENT_FINGERPRINT_SIZE 12
#define SENT_TRACK_EXPIRY_MS 30000 // stop tracking after 30 seconds
struct SentMsgTrack {
uint8_t fingerprint[SENT_FINGERPRINT_SIZE];
uint8_t repeat_count;
unsigned long sent_millis;
bool active;
};
SentMsgTrack _sent_track[SENT_TRACK_SIZE];
int _sent_track_idx; // next slot in circular buffer
};
extern MyMesh the_mesh;
extern MyMesh the_mesh;

View File

@@ -4,6 +4,26 @@
#include "variant.h" // Board-specific defines (HAS_GPS, etc.)
#include "target.h" // For sensors, board, etc.
// T-Deck Pro Keyboard support
#if defined(LilyGo_TDeck_Pro)
#include "TCA8418Keyboard.h"
TCA8418Keyboard keyboard(I2C_ADDR_KEYBOARD, &Wire);
// Compose mode state
static bool composeMode = false;
static char composeBuffer[138]; // 137 chars max + null terminator
static int composePos = 0;
static uint8_t composeChannelIdx = 0; // Which channel to send to
static unsigned long lastComposeRefresh = 0;
static bool composeNeedsRefresh = false;
#define COMPOSE_REFRESH_INTERVAL 600 // ms between e-ink refreshes while typing (refresh takes ~650ms)
void initKeyboard();
void handleKeyboardInput();
void drawComposeScreen();
void sendComposedMessage();
#endif
// Believe it or not, this std C function is busted on some platforms!
static uint32_t _atoi(const char* sp) {
uint32_t n = 0;
@@ -303,6 +323,11 @@ void setup() {
MESH_DEBUG_PRINTLN("setup() - ui_task.begin() done");
#endif
// Initialize T-Deck Pro keyboard
#if defined(LilyGo_TDeck_Pro)
initKeyboard();
#endif
// Enable GPS by default on T-Deck Pro
#if HAS_GPS
// Set GPS enabled in both sensor manager and node prefs
@@ -319,7 +344,334 @@ void loop() {
the_mesh.loop();
sensors.loop();
#ifdef DISPLAY_CLASS
// Skip UITask rendering when in compose mode to prevent flickering
#if defined(LilyGo_TDeck_Pro)
if (!composeMode) {
ui_task.loop();
} else {
// Handle debounced compose screen refresh
if (composeNeedsRefresh && (millis() - lastComposeRefresh) >= COMPOSE_REFRESH_INTERVAL) {
drawComposeScreen();
lastComposeRefresh = millis();
composeNeedsRefresh = false;
}
}
#else
ui_task.loop();
#endif
#endif
rtc_clock.tick();
}
// Handle T-Deck Pro keyboard input
#if defined(LilyGo_TDeck_Pro)
handleKeyboardInput();
#endif
}
// ============================================================================
// T-DECK PRO KEYBOARD FUNCTIONS
// ============================================================================
#if defined(LilyGo_TDeck_Pro)
void initKeyboard() {
// Keyboard uses the same I2C bus as other peripherals (already initialized)
if (keyboard.begin()) {
MESH_DEBUG_PRINTLN("setup() - Keyboard initialized");
composeBuffer[0] = '\0';
composePos = 0;
composeMode = false;
composeNeedsRefresh = false;
lastComposeRefresh = 0;
} else {
MESH_DEBUG_PRINTLN("setup() - Keyboard initialization failed!");
}
}
void handleKeyboardInput() {
if (!keyboard.isReady()) return;
char key = keyboard.readKey();
if (key == 0) return;
Serial.printf("handleKeyboardInput: key='%c' (0x%02X) composeMode=%d\n",
key >= 32 ? key : '?', key, composeMode);
if (composeMode) {
// In compose mode - handle text input
if (key == '\r') {
// Enter - send the message
Serial.println("Compose: Enter pressed, sending...");
if (composePos > 0) {
sendComposedMessage();
}
composeMode = false;
composeBuffer[0] = '\0';
composePos = 0;
ui_task.gotoHomeScreen();
return;
}
if (key == '\b') {
// Backspace - check if shift was recently pressed for cancel combo
if (keyboard.wasShiftRecentlyPressed(500)) {
// Shift+Backspace = Cancel (works anytime)
Serial.println("Compose: Shift+Backspace, cancelling...");
composeMode = false;
composeBuffer[0] = '\0';
composePos = 0;
ui_task.gotoHomeScreen();
return;
}
// Regular backspace - delete last character
if (composePos > 0) {
composePos--;
composeBuffer[composePos] = '\0';
Serial.printf("Compose: Backspace, pos now %d\n", composePos);
composeNeedsRefresh = true; // Use debounced refresh
}
return;
}
// A/D keys switch channels (only when buffer is empty or as special function)
if ((key == 'a' || key == 'A') && composePos == 0) {
// Previous channel
if (composeChannelIdx > 0) {
composeChannelIdx--;
} else {
// Wrap to last valid channel
for (uint8_t i = MAX_GROUP_CHANNELS - 1; i > 0; i--) {
ChannelDetails ch;
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') {
composeChannelIdx = i;
break;
}
}
}
Serial.printf("Compose: Channel switched to %d\n", composeChannelIdx);
drawComposeScreen();
return;
}
if ((key == 'd' || key == 'D') && composePos == 0) {
// Next channel
ChannelDetails ch;
uint8_t nextIdx = composeChannelIdx + 1;
if (the_mesh.getChannel(nextIdx, ch) && ch.name[0] != '\0') {
composeChannelIdx = nextIdx;
} else {
composeChannelIdx = 0; // Wrap to first channel
}
Serial.printf("Compose: Channel switched to %d\n", composeChannelIdx);
drawComposeScreen();
return;
}
// Regular character input
if (key >= 32 && key < 127 && composePos < 137) {
composeBuffer[composePos++] = key;
composeBuffer[composePos] = '\0';
Serial.printf("Compose: Added '%c', pos now %d\n", key, composePos);
composeNeedsRefresh = true; // Use debounced refresh
}
return;
}
// Normal mode - not composing
switch (key) {
case 'c':
case 'C':
// Enter compose mode
composeMode = true;
composeBuffer[0] = '\0';
composePos = 0;
// If on channel screen, sync compose channel with viewed channel
if (ui_task.isOnChannelScreen()) {
composeChannelIdx = ui_task.getChannelScreenViewIdx();
}
Serial.printf("Entering compose mode, channel %d\n", composeChannelIdx);
drawComposeScreen();
break;
case 'm':
case 'M':
// Go to channel message screen
Serial.println("Opening channel messages");
ui_task.gotoChannelScreen();
break;
case 'w':
case 'W':
// Navigate up/previous (scroll on channel screen)
if (ui_task.isOnChannelScreen()) {
ui_task.injectKey('w'); // Pass directly for channel switching
} else {
Serial.println("Nav: Previous");
ui_task.injectKey(0xF2); // KEY_PREV
}
break;
case 's':
case 'S':
// Navigate down/next (scroll on channel screen)
if (ui_task.isOnChannelScreen()) {
ui_task.injectKey('s'); // Pass directly for channel switching
} else {
Serial.println("Nav: Next");
ui_task.injectKey(0xF1); // KEY_NEXT
}
break;
case 'a':
case 'A':
// Navigate left or switch channel (on channel screen)
if (ui_task.isOnChannelScreen()) {
ui_task.injectKey('a'); // Pass directly for channel switching
} else {
Serial.println("Nav: Previous");
ui_task.injectKey(0xF2); // KEY_PREV
}
break;
case 'd':
case 'D':
// Navigate right or switch channel (on channel screen)
if (ui_task.isOnChannelScreen()) {
ui_task.injectKey('d'); // Pass directly for channel switching
} else {
Serial.println("Nav: Next");
ui_task.injectKey(0xF1); // KEY_NEXT
}
break;
case '\r':
// Select/Enter
Serial.println("Nav: Enter/Select");
ui_task.injectKey(13); // KEY_ENTER
break;
case 'q':
case 'Q':
case '\b':
// Go back to home screen
Serial.println("Nav: Back to home");
ui_task.gotoHomeScreen();
break;
case ' ':
// Space - also acts as next/select
Serial.println("Nav: Space (Next)");
ui_task.injectKey(0xF1); // KEY_NEXT
break;
default:
Serial.printf("Unhandled key in normal mode: '%c' (0x%02X)\n", key, key);
break;
}
}
void drawComposeScreen() {
#ifdef DISPLAY_CLASS
display.startFrame();
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, 0);
// Get the channel name for display
ChannelDetails channel;
char headerBuf[40];
if (the_mesh.getChannel(composeChannelIdx, channel)) {
snprintf(headerBuf, sizeof(headerBuf), "To: %s", channel.name);
} else {
snprintf(headerBuf, sizeof(headerBuf), "To: Channel %d", composeChannelIdx);
}
display.print(headerBuf);
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, 11, display.width(), 1);
display.setCursor(0, 14);
display.setColor(DisplayDriver::LIGHT);
// Word wrap the compose buffer - calculate chars per line based on actual font width
int x = 0;
int y = 14;
uint16_t testWidth = display.getTextWidth("MMMMMMMMMM"); // 10 wide chars
int charsPerLine = (testWidth > 0) ? (display.width() * 10) / testWidth : 20;
if (charsPerLine < 12) charsPerLine = 12;
if (charsPerLine > 40) charsPerLine = 40;
char charStr[2] = {0, 0}; // Buffer for single character as string
for (int i = 0; i < composePos; i++) {
charStr[0] = composeBuffer[i];
display.print(charStr);
x++;
if (x >= charsPerLine) {
x = 0;
y += 11;
display.setCursor(0, y);
}
}
// Show cursor
display.print("_");
// Status bar
int statusY = display.height() - 12;
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, statusY - 2, display.width(), 1);
display.setCursor(0, statusY);
display.setColor(DisplayDriver::YELLOW);
char status[40];
if (composePos == 0) {
// Empty buffer - show channel switching hint
display.print("A/D:Ch");
sprintf(status, "Sh+Del:X");
display.setCursor(display.width() - display.getTextWidth(status) - 2, statusY);
display.print(status);
} else {
// Has text - show send/cancel hint
sprintf(status, "%d/137 Ent:Send", composePos);
display.print(status);
sprintf(status, "Sh+Del:X");
display.setCursor(display.width() - display.getTextWidth(status) - 2, statusY);
display.print(status);
}
display.endFrame();
#endif
}
void sendComposedMessage() {
if (composePos == 0) return;
// Get the selected channel
ChannelDetails channel;
if (the_mesh.getChannel(composeChannelIdx, channel)) {
uint32_t timestamp = rtc_clock.getCurrentTime();
// Send to channel
if (the_mesh.sendGroupMessage(timestamp, channel.channel,
the_mesh.getNodePrefs()->node_name,
composeBuffer, composePos)) {
// Add the sent message to local channel history so we can see what we sent
ui_task.addSentChannelMessage(composeChannelIdx,
the_mesh.getNodePrefs()->node_name,
composeBuffer);
// Queue message for BLE app sync (so sent messages appear in companion app)
the_mesh.queueSentChannelMessage(composeChannelIdx, timestamp,
the_mesh.getNodePrefs()->node_name,
composeBuffer);
ui_task.showAlert("Sent!", 1500);
} else {
ui_task.showAlert("Send failed!", 1500);
}
} else {
ui_task.showAlert("No channel!", 1500);
}
}
#endif // LilyGo_TDeck_Pro

View File

@@ -0,0 +1,292 @@
#pragma once
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include <helpers/ChannelDetails.h>
#include <MeshCore.h>
// Maximum messages to store in history
#define CHANNEL_MSG_HISTORY_SIZE 20
#define CHANNEL_MSG_TEXT_LEN 160
#ifndef MAX_GROUP_CHANNELS
#define MAX_GROUP_CHANNELS 20
#endif
class UITask; // Forward declaration
class MyMesh; // Forward declaration
extern MyMesh the_mesh;
class ChannelScreen : public UIScreen {
public:
struct ChannelMessage {
uint32_t timestamp;
uint8_t path_len;
uint8_t channel_idx; // Which channel this message belongs to
char text[CHANNEL_MSG_TEXT_LEN];
bool valid;
};
private:
UITask* _task;
mesh::RTCClock* _rtc;
ChannelMessage _messages[CHANNEL_MSG_HISTORY_SIZE];
int _msgCount; // Total messages stored
int _newestIdx; // Index of newest message (circular buffer)
int _scrollPos; // Current scroll position (0 = newest)
int _msgsPerPage; // Messages that fit on screen
uint8_t _viewChannelIdx; // Which channel we're currently viewing
public:
ChannelScreen(UITask* task, mesh::RTCClock* rtc)
: _task(task), _rtc(rtc), _msgCount(0), _newestIdx(-1), _scrollPos(0),
_msgsPerPage(3), _viewChannelIdx(0) {
// Initialize all messages as invalid
for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) {
_messages[i].valid = false;
}
}
// Add a new message to the history
void addMessage(uint8_t channel_idx, uint8_t path_len, const char* sender, const char* text) {
// Move to next slot in circular buffer
_newestIdx = (_newestIdx + 1) % CHANNEL_MSG_HISTORY_SIZE;
ChannelMessage* msg = &_messages[_newestIdx];
msg->timestamp = _rtc->getCurrentTime();
msg->path_len = path_len;
msg->channel_idx = channel_idx;
msg->valid = true;
// The text already contains "Sender: message" format, just store it
strncpy(msg->text, text, CHANNEL_MSG_TEXT_LEN - 1);
msg->text[CHANNEL_MSG_TEXT_LEN - 1] = '\0';
if (_msgCount < CHANNEL_MSG_HISTORY_SIZE) {
_msgCount++;
}
// Reset scroll to show newest message
_scrollPos = 0;
}
// Get count of messages for the currently viewed channel
int getMessageCountForChannel() const {
int count = 0;
for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) {
if (_messages[i].valid && _messages[i].channel_idx == _viewChannelIdx) {
count++;
}
}
return count;
}
int getMessageCount() const { return _msgCount; }
uint8_t getViewChannelIdx() const { return _viewChannelIdx; }
void setViewChannelIdx(uint8_t idx) { _viewChannelIdx = idx; _scrollPos = 0; }
int render(DisplayDriver& display) override {
char tmp[40];
// Header - show current channel name
display.setCursor(0, 0);
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
// Get channel name
ChannelDetails channel;
if (the_mesh.getChannel(_viewChannelIdx, channel)) {
display.print(channel.name);
} else {
sprintf(tmp, "Channel %d", _viewChannelIdx);
display.print(tmp);
}
// Message count for this channel on right
int channelMsgCount = getMessageCountForChannel();
sprintf(tmp, "[%d]", channelMsgCount);
display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0);
display.print(tmp);
// Divider line
display.drawRect(0, 11, display.width(), 1);
if (channelMsgCount == 0) {
display.setCursor(0, 25);
display.setColor(DisplayDriver::LIGHT);
display.print("No messages yet");
display.setCursor(0, 40);
display.print("A/D: Switch channel");
display.setCursor(0, 52);
display.print("C: Compose message");
} else {
int lineHeight = 10;
int headerHeight = 14;
int footerHeight = 14;
// Calculate chars per line based on actual font width (not assumed 6px)
// Measure a test string and scale accordingly
uint16_t testWidth = display.getTextWidth("MMMMMMMMMM"); // 10 wide chars
int charsPerLine = (testWidth > 0) ? (display.width() * 10) / testWidth : 20;
if (charsPerLine < 12) charsPerLine = 12; // Minimum reasonable
if (charsPerLine > 40) charsPerLine = 40; // Maximum reasonable
int y = headerHeight;
// Build list of messages for this channel (newest first)
int channelMsgs[CHANNEL_MSG_HISTORY_SIZE];
int numChannelMsgs = 0;
for (int i = 0; i < _msgCount && numChannelMsgs < CHANNEL_MSG_HISTORY_SIZE; i++) {
int idx = _newestIdx - i;
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
idx = idx % CHANNEL_MSG_HISTORY_SIZE;
if (_messages[idx].valid && _messages[idx].channel_idx == _viewChannelIdx) {
channelMsgs[numChannelMsgs++] = idx;
}
}
// Display messages from scroll position
int msgsDrawn = 0;
for (int i = _scrollPos; i < numChannelMsgs && y < display.height() - footerHeight - lineHeight; i++) {
int idx = channelMsgs[i];
ChannelMessage* msg = &_messages[idx];
// Time indicator with hop count
display.setCursor(0, y);
display.setColor(DisplayDriver::YELLOW);
uint32_t age = _rtc->getCurrentTime() - msg->timestamp;
if (age < 60) {
sprintf(tmp, "(%d) %ds", msg->path_len == 0xFF ? 0 : msg->path_len, age);
} else if (age < 3600) {
sprintf(tmp, "(%d) %dm", msg->path_len == 0xFF ? 0 : msg->path_len, age / 60);
} else if (age < 86400) {
sprintf(tmp, "(%d) %dh", msg->path_len == 0xFF ? 0 : msg->path_len, age / 3600);
} else {
sprintf(tmp, "(%d) %dd", msg->path_len == 0xFF ? 0 : msg->path_len, age / 86400);
}
display.print(tmp);
y += lineHeight;
// Message text with character wrapping (like compose screen - fills full width)
display.setColor(DisplayDriver::LIGHT);
int textLen = strlen(msg->text);
int pos = 0;
int linesForThisMsg = 0;
int maxLinesPerMsg = 6; // Allow more lines per message
int x = 0;
char charStr[2] = {0, 0};
display.setCursor(0, y);
while (pos < textLen && linesForThisMsg < maxLinesPerMsg && y < display.height() - footerHeight - 2) {
charStr[0] = msg->text[pos];
display.print(charStr);
x++;
pos++;
if (x >= charsPerLine) {
x = 0;
linesForThisMsg++;
y += lineHeight;
if (linesForThisMsg < maxLinesPerMsg && y < display.height() - footerHeight - 2) {
display.setCursor(0, y);
}
}
}
// If we didn't end on a full line, still count it
if (x > 0) {
y += lineHeight;
}
y += 2;
msgsDrawn++;
_msgsPerPage = msgsDrawn;
}
}
// Footer with controls
int footerY = display.height() - 12;
display.drawRect(0, footerY - 2, display.width(), 1);
display.setCursor(0, footerY);
display.setColor(DisplayDriver::YELLOW);
// Left side: Q:Back A/D:Ch
display.print("Q:Back A/D:Ch");
// Right side: C:New
const char* rightText = "C:New";
display.setCursor(display.width() - display.getTextWidth(rightText) - 2, footerY);
display.print(rightText);
#if AUTO_OFF_MILLIS == 0 // e-ink
return 5000;
#else
return 1000;
#endif
}
bool handleInput(char c) override {
int channelMsgCount = getMessageCountForChannel();
// W or KEY_PREV - scroll up (older messages)
if (c == 0xF2 || c == 'w' || c == 'W') {
if (_scrollPos + _msgsPerPage < channelMsgCount) {
_scrollPos++;
return true;
}
}
// S or KEY_NEXT - scroll down (newer messages)
if (c == 0xF1 || c == 's' || c == 'S') {
if (_scrollPos > 0) {
_scrollPos--;
return true;
}
}
// A - previous channel
if (c == 'a' || c == 'A') {
if (_viewChannelIdx > 0) {
_viewChannelIdx--;
} else {
// Wrap to last valid channel
for (uint8_t i = MAX_GROUP_CHANNELS - 1; i > 0; i--) {
ChannelDetails ch;
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') {
_viewChannelIdx = i;
break;
}
}
}
_scrollPos = 0;
return true;
}
// D - next channel
if (c == 'd' || c == 'D') {
ChannelDetails ch;
uint8_t nextIdx = _viewChannelIdx + 1;
if (the_mesh.getChannel(nextIdx, ch) && ch.name[0] != '\0') {
_viewChannelIdx = nextIdx;
} else {
_viewChannelIdx = 0;
}
_scrollPos = 0;
return true;
}
return false;
}
// Reset scroll position to newest
void resetScroll() {
_scrollPos = 0;
}
};

View File

@@ -30,6 +30,7 @@
#endif
#include "icons.h"
#include "ChannelScreen.h"
class SplashScreen : public UIScreen {
UITask* _task;
@@ -102,30 +103,52 @@ class HomeScreen : public UIScreen {
AdvertPath recent[UI_RECENT_LIST_SIZE];
void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) {
// Convert millivolts to percentage
const int minMilliVolts = 3000; // Minimum voltage (e.g., 3.0V)
const int maxMilliVolts = 4200; // Maximum voltage (e.g., 4.2V)
int batteryPercentage = ((batteryMilliVolts - minMilliVolts) * 100) / (maxMilliVolts - minMilliVolts);
if (batteryPercentage < 0) batteryPercentage = 0; // Clamp to 0%
if (batteryPercentage > 100) batteryPercentage = 100; // Clamp to 100%
void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) {
// Use voltage-based estimation to match BLE app readings
uint8_t batteryPercentage = 0;
if (batteryMilliVolts > 0) {
const int minMilliVolts = 3000;
const int maxMilliVolts = 4200;
int pct = ((batteryMilliVolts - minMilliVolts) * 100) / (maxMilliVolts - minMilliVolts);
if (pct < 0) pct = 0;
if (pct > 100) pct = 100;
batteryPercentage = (uint8_t)pct;
}
// battery icon
int iconWidth = 22;
int iconHeight = 8;
int iconX = display.width() - iconWidth - 5; // Position the icon near the top-right corner
int iconY = 0;
display.setColor(DisplayDriver::GREEN);
// battery icon dimensions (smaller to match tiny percentage text)
int iconWidth = 16;
int iconHeight = 6;
// measure percentage text width to position icon + text together at right edge
display.setTextSize(0);
char pctStr[5];
sprintf(pctStr, "%d%%", batteryPercentage);
uint16_t textWidth = display.getTextWidth(pctStr);
// layout: [icon 16px][cap 2px][gap 2px][text][margin 2px]
int totalWidth = iconWidth + 2 + 2 + textWidth + 2;
int iconX = display.width() - totalWidth;
int iconY = 0; // vertically align with node name text
// battery outline
display.drawRect(iconX, iconY, iconWidth, iconHeight);
// battery "cap"
display.fillRect(iconX + iconWidth, iconY + (iconHeight / 4), 3, iconHeight / 2);
display.fillRect(iconX + iconWidth, iconY + (iconHeight / 4), 2, iconHeight / 2);
// fill the battery based on the percentage
int fillWidth = (batteryPercentage * (iconWidth - 4)) / 100;
display.fillRect(iconX + 2, iconY + 2, fillWidth, iconHeight - 4);
// draw percentage text after the battery cap, offset upward to center with icon
// (setCursor adds +5 internally for baseline, so compensate for the tiny font)
int textX = iconX + iconWidth + 2 + 2; // after cap + gap
int textY = iconY - 3; // offset up to vertically center with icon
display.setCursor(textX, textY);
display.print(pctStr);
display.setTextSize(1); // restore default text size
}
CayenneLPP sensors_lpp;
@@ -580,12 +603,14 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
splash = new SplashScreen(this);
home = new HomeScreen(this, &rtc_clock, sensors, node_prefs);
msg_preview = new MsgPreviewScreen(this, &rtc_clock);
channel_screen = new ChannelScreen(this, &rtc_clock);
setCurrScreen(splash);
}
void UITask::showAlert(const char* text, int duration_millis) {
strcpy(_alert, text);
_alert_expiry = millis() + duration_millis;
_next_refresh = millis() + 100; // trigger re-render to show updated text
}
void UITask::notify(UIEventType t) {
@@ -628,8 +653,34 @@ void UITask::msgRead(int msgcount) {
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) {
_msgcount = msgcount;
// Add to preview screen (for notifications on non-keyboard devices)
((MsgPreviewScreen *) msg_preview)->addPreview(path_len, from_name, text);
// Determine channel index by looking up the channel name
// For channel messages, from_name is the channel name
// For contact messages, from_name is the contact name (channel_idx = 0xFF)
uint8_t channel_idx = 0xFF; // Default: unknown/contact message
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
ChannelDetails ch;
if (the_mesh.getChannel(i, ch) && strcmp(ch.name, from_name) == 0) {
channel_idx = i;
break;
}
}
// Add to channel history screen with channel index
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text);
#if defined(LilyGo_TDeck_Pro)
// T-Deck Pro: Don't interrupt user with popup - just show brief notification
// Messages are stored in channel history, accessible via 'M' key
char alertBuf[40];
snprintf(alertBuf, sizeof(alertBuf), "New: %s", from_name);
showAlert(alertBuf, 2000);
#else
// Other devices: Show full preview screen (legacy behavior)
setCurrScreen(msg_preview);
#endif
if (_display != NULL) {
if (!_display->isOn() && !hasConnection()) {
@@ -922,3 +973,38 @@ void UITask::toggleBuzzer() {
_next_refresh = 0; // trigger refresh
#endif
}
void UITask::injectKey(char c) {
if (c != 0 && curr) {
// Turn on display if it's off
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
curr->handleInput(c);
_auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer
_next_refresh = 100; // trigger refresh
}
}
void UITask::gotoChannelScreen() {
((ChannelScreen *) channel_screen)->resetScroll();
setCurrScreen(channel_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
uint8_t UITask::getChannelScreenViewIdx() const {
return ((ChannelScreen *) channel_screen)->getViewChannelIdx();
}
void UITask::addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) {
// Format the message as "Sender: message"
char formattedMsg[CHANNEL_MSG_TEXT_LEN];
snprintf(formattedMsg, sizeof(formattedMsg), "%s: %s", sender, text);
// Add to channel history with path_len=0 (local message)
((ChannelScreen *) channel_screen)->addMessage(channel_idx, 0, sender, formattedMsg);
}

View File

@@ -51,6 +51,7 @@ class UITask : public AbstractUITask {
UIScreen* splash;
UIScreen* home;
UIScreen* msg_preview;
UIScreen* channel_screen; // Channel message history screen
UIScreen* curr;
void userLedHandler();
@@ -73,15 +74,28 @@ public:
void begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs);
void gotoHomeScreen() { setCurrScreen(home); }
void showAlert(const char* text, int duration_millis);
void gotoChannelScreen(); // Navigate to channel message screen
void showAlert(const char* text, int duration_millis) override;
void forceRefresh() override { _next_refresh = 100; }
int getMsgCount() const { return _msgcount; }
bool hasDisplay() const { return _display != NULL; }
bool isButtonPressed() const;
bool isOnChannelScreen() const { return curr == channel_screen; }
uint8_t getChannelScreenViewIdx() const;
void toggleBuzzer();
bool getGPSState();
void toggleGPS();
// Inject a key press from external source (e.g., keyboard)
void injectKey(char c);
// Add a sent message to the channel screen history
void addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text);
// Get current screen for checking state
UIScreen* getCurrentScreen() const { return curr; }
UIScreen* getMsgPreviewScreen() const { return msg_preview; }
// from AbstractUITask
void msgRead(int msgcount) override;
@@ -90,4 +104,4 @@ public:
void loop() override;
void shutdown(bool restart = false);
};
};

View File

@@ -84,6 +84,10 @@ void GxEPDDisplay::startFrame(Color bkg) {
void GxEPDDisplay::setTextSize(int sz) {
display_crc.update<int>(sz);
switch(sz) {
case 0: // Tiny - built-in 6x8 pixel font
display.setFont(NULL);
display.setTextSize(1);
break;
case 1: // Small - use 9pt (was 9pt)
display.setFont(&FreeSans9pt7b);
break;

View File

@@ -46,6 +46,14 @@ void TDeckBoard::begin() {
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - GPS Serial2 initialized at %d baud", GPS_BAUDRATE);
#endif
// Disable 4G modem power (only present on 4G version, not audio version)
// This turns off the red status LED on the modem module
#ifdef MODEM_POWER_EN
pinMode(MODEM_POWER_EN, OUTPUT);
digitalWrite(MODEM_POWER_EN, LOW); // Cut power to modem
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - 4G modem power disabled");
#endif
// Configure user button
pinMode(PIN_USER_BTN, INPUT);

View File

@@ -0,0 +1,290 @@
#pragma once
#include <Arduino.h>
#include <Wire.h>
// TCA8418 Register addresses
#define TCA8418_REG_CFG 0x01
#define TCA8418_REG_INT_STAT 0x02
#define TCA8418_REG_KEY_LCK_EC 0x03
#define TCA8418_REG_KEY_EVENT_A 0x04
#define TCA8418_REG_KP_GPIO1 0x1D
#define TCA8418_REG_KP_GPIO2 0x1E
#define TCA8418_REG_KP_GPIO3 0x1F
#define TCA8418_REG_DEBOUNCE 0x29
#define TCA8418_REG_GPI_EM1 0x20
#define TCA8418_REG_GPI_EM2 0x21
#define TCA8418_REG_GPI_EM3 0x22
// Key codes for special keys
#define KB_KEY_NONE 0
#define KB_KEY_BACKSPACE '\b'
#define KB_KEY_ENTER '\r'
#define KB_KEY_SPACE ' '
class TCA8418Keyboard {
private:
uint8_t _addr;
TwoWire* _wire;
bool _initialized;
bool _shiftActive; // Sticky shift (one-shot)
bool _altActive; // Sticky alt (one-shot)
bool _symActive; // Sticky sym (one-shot)
unsigned long _lastShiftTime; // For Shift+key combos
uint8_t readReg(uint8_t reg) {
_wire->beginTransmission(_addr);
_wire->write(reg);
_wire->endTransmission();
_wire->requestFrom(_addr, (uint8_t)1);
return _wire->available() ? _wire->read() : 0;
}
void writeReg(uint8_t reg, uint8_t val) {
_wire->beginTransmission(_addr);
_wire->write(reg);
_wire->write(val);
_wire->endTransmission();
}
// Map raw key codes to characters (from working reader firmware)
char getKeyChar(uint8_t keyCode) {
switch (keyCode) {
// Row 1 - QWERTYUIOP
case 10: return 'q'; // Q (was 97 on different hardware)
case 9: return 'w';
case 8: return 'e';
case 7: return 'r';
case 6: return 't';
case 5: return 'y';
case 4: return 'u';
case 3: return 'i';
case 2: return 'o';
case 1: return 'p';
// Row 2 - ASDFGHJKL + Backspace
case 20: return 'a'; // A (was 98 on different hardware)
case 19: return 's';
case 18: return 'd';
case 17: return 'f';
case 16: return 'g';
case 15: return 'h';
case 14: return 'j';
case 13: return 'k';
case 12: return 'l';
case 11: return '\b'; // Backspace
// Row 3 - Alt ZXCVBNM Sym Enter
case 30: return 0; // Alt - handled separately
case 29: return 'z';
case 28: return 'x';
case 27: return 'c';
case 26: return 'v';
case 25: return 'b';
case 24: return 'n';
case 23: return 'm';
case 22: return 0; // Symbol key - handled separately
case 21: return '\r'; // Enter
// Row 4 - Shift Mic Space Sym Shift
case 35: return 0; // Left shift - handled separately
case 34: return 0; // Mic
case 33: return ' '; // Space
case 32: return 0; // Sym - handled separately
case 31: return 0; // Right shift - handled separately
default: return 0;
}
}
// Map key with Alt modifier - same as Sym for this keyboard
char getAltChar(uint8_t keyCode) {
return getSymChar(keyCode); // Alt does same as Sym
}
// Map key with Sym modifier - based on actual T-Deck Pro keyboard silk-screen
char getSymChar(uint8_t keyCode) {
switch (keyCode) {
// Row 1: Q W E R T Y U I O P
case 10: return '#'; // Q -> #
case 9: return '1'; // W -> 1
case 8: return '2'; // E -> 2
case 7: return '3'; // R -> 3
case 6: return '('; // T -> (
case 5: return ')'; // Y -> )
case 4: return '_'; // U -> _
case 3: return '-'; // I -> -
case 2: return '+'; // O -> +
case 1: return '@'; // P -> @
// Row 2: A S D F G H J K L
case 20: return '*'; // A -> *
case 19: return '4'; // S -> 4
case 18: return '5'; // D -> 5
case 17: return '6'; // F -> 6
case 16: return '/'; // G -> /
case 15: return ':'; // H -> :
case 14: return ';'; // J -> ;
case 13: return '\''; // K -> '
case 12: return '"'; // L -> "
// Row 3: Z X C V B N M
case 29: return '7'; // Z -> 7
case 28: return '8'; // X -> 8
case 27: return '9'; // C -> 9
case 26: return '?'; // V -> ?
case 25: return '!'; // B -> !
case 24: return ','; // N -> ,
case 23: return '.'; // M -> .
// Row 4: Mic key -> 0
case 34: return '0'; // Mic -> 0
default: return 0;
}
}
public:
TCA8418Keyboard(uint8_t addr = 0x34, TwoWire* wire = &Wire)
: _addr(addr), _wire(wire), _initialized(false),
_shiftActive(false), _altActive(false), _symActive(false), _lastShiftTime(0) {}
bool begin() {
// Check if device responds
_wire->beginTransmission(_addr);
if (_wire->endTransmission() != 0) {
Serial.println("TCA8418: Device not found");
return false;
}
// Configure keyboard matrix (8 rows x 10 cols)
writeReg(TCA8418_REG_KP_GPIO1, 0xFF); // Rows 0-7 as keypad
writeReg(TCA8418_REG_KP_GPIO2, 0xFF); // Cols 0-7 as keypad
writeReg(TCA8418_REG_KP_GPIO3, 0x03); // Cols 8-9 as keypad
// Enable keypad with FIFO overflow detection
writeReg(TCA8418_REG_CFG, 0x11); // KE_IEN + INT_CFG
// Set debounce
writeReg(TCA8418_REG_DEBOUNCE, 0x03);
// Clear any pending interrupts
writeReg(TCA8418_REG_INT_STAT, 0x1F);
// Flush the FIFO
while (readReg(TCA8418_REG_KEY_LCK_EC) & 0x0F) {
readReg(TCA8418_REG_KEY_EVENT_A);
}
_initialized = true;
Serial.println("TCA8418: Keyboard initialized OK");
return true;
}
// Read a key press - returns character or 0 if no key
char readKey() {
if (!_initialized) return 0;
// Check for key events in FIFO
uint8_t keyCount = readReg(TCA8418_REG_KEY_LCK_EC) & 0x0F;
if (keyCount == 0) return 0;
// Read key event from FIFO
uint8_t keyEvent = readReg(TCA8418_REG_KEY_EVENT_A);
// Bit 7: 1 = press, 0 = release
bool pressed = (keyEvent & 0x80) != 0;
uint8_t keyCode = keyEvent & 0x7F;
// Clear interrupt
writeReg(TCA8418_REG_INT_STAT, 0x1F);
Serial.printf("KB raw: event=0x%02X code=%d pressed=%d count=%d\n",
keyEvent, keyCode, pressed, keyCount);
// Only act on key press, not release
if (!pressed || keyCode == 0) {
return 0;
}
// Handle modifier keys - set sticky state and return 0
if (keyCode == 35 || keyCode == 31) { // Shift keys
_shiftActive = true;
_lastShiftTime = millis();
Serial.println("KB: Shift activated");
return 0;
}
if (keyCode == 30) { // Alt key
_altActive = true;
Serial.println("KB: Alt activated");
return 0;
}
if (keyCode == 32) { // Sym key (bottom row)
_symActive = true;
Serial.println("KB: Sym activated");
return 0;
}
// Handle dedicated $ key (key code 22, next to M)
if (keyCode == 22) {
Serial.println("KB: $ key pressed");
return '$';
}
// Handle Mic key - produces 0 with Sym, otherwise ignore
if (keyCode == 34) {
if (_symActive) {
_symActive = false;
Serial.println("KB: Sym+Mic -> '0'");
return '0';
}
return 0; // Ignore mic without Sym
}
// Get the character
char c = 0;
if (_altActive) {
c = getAltChar(keyCode);
_altActive = false; // Reset sticky alt
if (c != 0) {
Serial.printf("KB: Alt+key -> '%c'\n", c);
return c;
}
}
if (_symActive) {
c = getSymChar(keyCode);
_symActive = false; // Reset sticky sym
if (c != 0) {
Serial.printf("KB: Sym+key -> '%c'\n", c);
return c;
}
}
c = getKeyChar(keyCode);
if (c != 0 && _shiftActive) {
// Apply shift - uppercase letters
if (c >= 'a' && c <= 'z') {
c = c - 'a' + 'A';
}
_shiftActive = false; // Reset sticky shift
}
if (c != 0) {
Serial.printf("KB: code %d -> '%c' (0x%02X)\n", keyCode, c >= 32 ? c : '?', c);
} else {
Serial.printf("KB: code %d -> UNMAPPED\n", keyCode);
}
return c;
}
bool isReady() const { return _initialized; }
// Check if shift was pressed within the last N milliseconds
bool wasShiftRecentlyPressed(unsigned long withinMs = 500) const {
return (millis() - _lastShiftTime) < withinMs;
}
};