mirror of
https://github.com/pelgraine/Meck.git
synced 2026-03-28 17:42:44 +01:00
Compare commits
14 Commits
ble
...
ble-queue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e40d9ced4a | ||
|
|
b8de2d0d16 | ||
|
|
9fbc3202f6 | ||
|
|
9d91f48797 | ||
|
|
21eb385763 | ||
|
|
4b81e596d2 | ||
|
|
a5f2e8d055 | ||
|
|
462b1cb642 | ||
|
|
0b270c0e1a | ||
|
|
2730c05329 | ||
|
|
02d2fb08fb | ||
|
|
b0003e1896 | ||
|
|
0be77ef759 | ||
|
|
c5df40cefd |
85
README.md
85
README.md
@@ -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.
|
||||
@@ -541,6 +541,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], ×tamp, 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) {
|
||||
@@ -1979,4 +2019,4 @@ bool MyMesh::advert() {
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,11 @@
|
||||
#define FIRMWARE_VER_CODE 8
|
||||
|
||||
#ifndef FIRMWARE_BUILD_DATE
|
||||
#define FIRMWARE_BUILD_DATE "29 Jan 2026"
|
||||
#define FIRMWARE_BUILD_DATE "4 Feb 2026"
|
||||
#endif
|
||||
|
||||
#ifndef FIRMWARE_VERSION
|
||||
#define FIRMWARE_VERSION "Meck v0.4"
|
||||
#define FIRMWARE_VERSION "Meck v0.6.2"
|
||||
#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;
|
||||
@@ -230,4 +233,4 @@ private:
|
||||
AdvertPath advert_paths[ADVERT_PATH_TABLE_SIZE]; // circular table
|
||||
};
|
||||
|
||||
extern MyMesh the_mesh;
|
||||
extern MyMesh the_mesh;
|
||||
@@ -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
|
||||
292
examples/companion_radio/ui-new/ChannelScreen.h
Normal file
292
examples/companion_radio/ui-new/ChannelScreen.h
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -30,6 +30,7 @@
|
||||
#endif
|
||||
|
||||
#include "icons.h"
|
||||
#include "ChannelScreen.h"
|
||||
|
||||
class SplashScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
@@ -580,6 +581,7 @@ 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);
|
||||
}
|
||||
|
||||
@@ -628,8 +630,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 +950,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);
|
||||
}
|
||||
@@ -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,27 @@ public:
|
||||
void begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs);
|
||||
|
||||
void gotoHomeScreen() { setCurrScreen(home); }
|
||||
void gotoChannelScreen(); // Navigate to channel message screen
|
||||
void showAlert(const char* text, int duration_millis);
|
||||
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 +103,4 @@ public:
|
||||
void loop() override;
|
||||
|
||||
void shutdown(bool restart = false);
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
|
||||
290
variants/lilygo_tdeck_pro/Tca8418keyboard.h
Normal file
290
variants/lilygo_tdeck_pro/Tca8418keyboard.h
Normal 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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user