Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ac5570ebb | ||
|
|
e194f6d307 |
2
.vscode/extensions.json
vendored
2
.vscode/extensions.json
vendored
@@ -1,6 +1,4 @@
|
||||
{
|
||||
// See http://go.microsoft.com/fwlink/?LinkId=827846
|
||||
// for the documentation about the extensions.json format
|
||||
"recommendations": [
|
||||
"pioarduino.pioarduino-ide",
|
||||
"platformio.platformio-ide"
|
||||
|
||||
36
README.md
36
README.md
@@ -15,8 +15,6 @@ The T-Deck Pro BLE companion firmware includes full keyboard support for standal
|
||||
| S / D | Next page |
|
||||
| Enter | Select / Confirm |
|
||||
| M | Open channel messages |
|
||||
| N | Open contacts list |
|
||||
| R | Open e-book reader |
|
||||
| Q | Back to home screen |
|
||||
|
||||
### Channel Message Screen
|
||||
@@ -28,26 +26,11 @@ The T-Deck Pro BLE companion firmware includes full keyboard support for standal
|
||||
| C | Compose new message |
|
||||
| Q | Back to home screen |
|
||||
|
||||
### Contacts Screen
|
||||
|
||||
Press **N** from the home screen to open the contacts list. All known mesh contacts are shown sorted by most recently seen, with their type (Chat, Repeater, Room, Sensor), hop count, and time since last advert.
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| W / S | Scroll up / down through contacts |
|
||||
| A / D | Cycle filter: All → Chat → Repeater → Room → Sensor |
|
||||
| Enter / C | Open DM compose to selected chat contact |
|
||||
| Q | Back to home screen |
|
||||
|
||||
### Sending a Direct Message
|
||||
|
||||
Select a **Chat** contact in the contacts list and press **Enter** or **C** to start composing a direct message. The compose screen will show `DM: ContactName` in the header. Type your message and press **Enter** to send. The DM is sent encrypted directly to that contact (or flooded if no direct path is known). After sending or cancelling, you're returned to the contacts list.
|
||||
|
||||
### Compose Mode
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| A / D | Switch destination channel (when message is empty, channel compose only) |
|
||||
| A / D | Switch destination channel (when message is empty) |
|
||||
| Enter | Send message |
|
||||
| Backspace | Delete last character |
|
||||
| Shift + Backspace | Cancel and exit compose mode |
|
||||
@@ -66,7 +49,7 @@ Press the **Sym** key then the letter key to enter numbers and symbols:
|
||||
| Y | ) | | H | : | | N | , |
|
||||
| U | _ | | J | ; | | M | . |
|
||||
| I | - | | K | ' | | Mic | 0 |
|
||||
| O | + | | L | " | | $ | Emoji picker (Sym+$ for literal $) |
|
||||
| O | + | | L | " | | $ | (dedicated) |
|
||||
| P | @ | | | | | | |
|
||||
|
||||
### Other Keys
|
||||
@@ -77,17 +60,6 @@ Press the **Sym** key then the letter key to enter numbers and symbols:
|
||||
| Alt | Same as Sym (for numbers/symbols) |
|
||||
| Space | Space character / Next in navigation |
|
||||
|
||||
### Emoji Picker
|
||||
|
||||
While in compose mode, press the **$** key to open the emoji picker. A scrollable grid of 47 emoji is displayed in a 5-column layout.
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| W / S | Navigate up / down |
|
||||
| A / D | Navigate left / right |
|
||||
| Enter | Insert selected emoji |
|
||||
| $ / Q / Backspace | Cancel and return to compose |
|
||||
|
||||
## About MeshCore
|
||||
|
||||
MeshCore is a lightweight, portable C++ library that enables multi-hop packet routing for embedded projects using LoRa and other packet radios. It is designed for developers who want to create resilient, decentralized communication networks that work without the internet.
|
||||
@@ -176,9 +148,7 @@ There are a number of fairly major features in the pipeline, with no particular
|
||||
- [X] Companion radio: BLE
|
||||
- [X] Text entry for Public channel messages Companion BLE firmware
|
||||
- [X] View and compose all channel messages Companion BLE firmware
|
||||
- [X] Standalone DM functionality for Companion BLE firmware
|
||||
- [X] Contacts list with filtering for Companion BLE firmware
|
||||
- [ ] Standalone repeater admin access for 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
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
# Text & EPUB Reader Integration for Meck Firmware
|
||||
|
||||
## Overview
|
||||
|
||||
This adds a text reader accessible via the **R** key from the home screen.
|
||||
|
||||
**Features:**
|
||||
- Browse `.txt` and `.epub` files from `/books/` folder on SD card
|
||||
- Automatic EPUB-to-text conversion on first open (cached for instant re-opens)
|
||||
- Word-wrapped text rendering using tiny font (maximum text density)
|
||||
- Page navigation with W/S/A/D keys
|
||||
- Automatic reading position resume (persisted to SD card)
|
||||
- Index files cached to SD for instant re-opens
|
||||
- Bookmark indicator (`*`) on files with saved positions
|
||||
- Compose mode (`C`) still accessible from within reader
|
||||
|
||||
**Key Mapping:**
|
||||
| Context | Key | Action |
|
||||
|---------|-----|--------|
|
||||
| Home screen | R | Open text reader |
|
||||
| File list | W/S | Navigate up/down |
|
||||
| File list | Enter | Open selected file |
|
||||
| File list | Q | Back to home screen |
|
||||
| Reading | W/A | Previous page |
|
||||
| Reading | S/D/Space/Enter | Next page |
|
||||
| Reading | Q | Close book → file list |
|
||||
| Reading | C | Enter compose mode |
|
||||
|
||||
---
|
||||
|
||||
## SD Card Setup
|
||||
|
||||
Place `.txt` or `.epub` files in a `/books/` folder on the SD card root. The reader will:
|
||||
- Auto-create `/books/` if it doesn't exist
|
||||
- Auto-create `/.indexes/` for page index cache files
|
||||
- Auto-create `/books/.epub_cache/` for converted EPUB text
|
||||
- Skip macOS hidden files (`._*`, `.DS_Store`)
|
||||
- Support up to 50 files
|
||||
|
||||
**Index format** is compatible with the standalone reader (version 4), so if you've used the standalone reader previously, bookmarks and indexes will carry over.
|
||||
|
||||
---
|
||||
|
||||
## EPUB Support
|
||||
|
||||
### How It Works
|
||||
|
||||
EPUB files are transparently converted to plain text on first open. The conversion pipeline is:
|
||||
|
||||
1. **File list** — `scanFiles()` picks up both `.txt` and `.epub` files from `/books/`
|
||||
2. **First open** — `openBook()` detects the `.epub` extension and triggers conversion:
|
||||
- Shows a "Converting EPUB..." splash screen
|
||||
- Extracts the ZIP structure using ESP32-S3's built-in ROM `tinfl` decompressor (no external library needed)
|
||||
- Parses `META-INF/container.xml` → finds the OPF file
|
||||
- Parses the OPF manifest and spine to get chapters in reading order
|
||||
- Extracts each XHTML chapter, strips tags, decodes HTML entities
|
||||
- Writes concatenated plain text to `/books/.epub_cache/<filename>.txt`
|
||||
3. **Subsequent opens** — the cached `.txt` is found immediately and opened like any regular text file
|
||||
|
||||
### Cache Structure
|
||||
|
||||
```
|
||||
/books/
|
||||
MyBook.epub ← original EPUB (untouched)
|
||||
SomeStory.txt ← regular text file
|
||||
.epub_cache/
|
||||
MyBook.txt ← auto-generated from MyBook.epub
|
||||
/.indexes/
|
||||
MyBook.txt.idx ← page index for the converted text
|
||||
```
|
||||
|
||||
- The original `.epub` file is never modified
|
||||
- Deleting a cached `.txt` from `.epub_cache/` forces re-conversion on next open
|
||||
- Index files (`.idx`) work identically for both regular and EPUB-derived text files
|
||||
- Boot scan picks up previously cached EPUB text files so they appear in the file list even before the EPUB is re-opened
|
||||
|
||||
### EPUB Processing Details
|
||||
|
||||
The conversion is handled by three components:
|
||||
|
||||
| Component | Role |
|
||||
|-----------|------|
|
||||
| `EpubZipReader.h` | ZIP central directory parsing + `tinfl` decompression (supports Store and Deflate) |
|
||||
| `EpubProcessor.h` | EPUB structure parsing (container.xml → OPF → spine) and XHTML tag stripping |
|
||||
| `TextReaderScreen.h` | Integration: detects `.epub`, triggers conversion, redirects to cached `.txt` |
|
||||
|
||||
**XHTML stripping handles:**
|
||||
- Tag removal with block-element newlines (`<p>`, `<br>`, `<div>`, `<h1>`–`<h6>`, `<li>`, etc.)
|
||||
- `<head>`, `<style>`, `<script>` content skipped entirely
|
||||
- HTML entity decoding: named (`&`, `—`, `“`, etc.) and numeric (`—`, `—`)
|
||||
- Smart quote / em-dash / ellipsis → ASCII equivalents (e-ink font is ASCII-only)
|
||||
- Whitespace collapsing and cleanup
|
||||
|
||||
**Limits:**
|
||||
- Max 200 chapters in spine (`EPUB_MAX_CHAPTERS`)
|
||||
- Max 256 manifest items (`EPUB_MAX_MANIFEST`)
|
||||
- Manifest and chapter data are heap-allocated in PSRAM where available
|
||||
- Typical conversion time: 2–10 seconds depending on book size
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
| Symptom | Likely Cause |
|
||||
|---------|-------------|
|
||||
| "Convert failed!" splash | EPUB may be DRM-protected, corrupted, or use an unusual structure |
|
||||
| EPUB appears in list but opens as blank | Check serial output for `EpubProc:` messages; chapter count may be 0 |
|
||||
| Stale content after replacing an EPUB | Delete the matching `.txt` from `/books/.epub_cache/` to force re-conversion |
|
||||
|
||||
---
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- The reader renders through the standard `UIScreen::render()` framework, so no special bypass is needed in the main loop (unlike compose mode)
|
||||
- SD card uses the same HSPI bus as e-ink display and LoRa radio — CS pin management handles contention
|
||||
- Page content is pre-read from SD into a memory buffer during `handleInput()`, then rendered from buffer during `render()` — this avoids SPI bus conflicts during display refresh
|
||||
- Layout metrics (chars per line, lines per page) are calculated dynamically from the display driver's font metrics on first entry
|
||||
- EPUB conversion runs synchronously in `openBook()` — the e-ink splash screen keeps the user informed while the ESP32 processes the archive
|
||||
- ZIP extraction uses the ESP32-S3's hardware-optimised ROM `tinfl` inflate, avoiding external compression library dependencies and the linker conflicts they cause
|
||||
@@ -45,5 +45,4 @@ public:
|
||||
virtual void loop() = 0;
|
||||
virtual void showAlert(const char* text, int duration_millis) {}
|
||||
virtual void forceRefresh() {}
|
||||
virtual void addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) {}
|
||||
};
|
||||
@@ -476,7 +476,7 @@ bool MyMesh::filterRecvFloodPacket(mesh::Packet* packet) {
|
||||
}
|
||||
}
|
||||
|
||||
return false; // never filter — let normal processing continue
|
||||
return false; // never filter — let normal processing continue
|
||||
}
|
||||
|
||||
void MyMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis) {
|
||||
@@ -620,36 +620,6 @@ void MyMesh::queueSentChannelMessage(uint8_t channel_idx, uint32_t timestamp, co
|
||||
}
|
||||
}
|
||||
|
||||
bool MyMesh::uiSendDirectMessage(uint32_t contact_idx, const char* text) {
|
||||
ContactInfo contact;
|
||||
if (!getContactByIdx(contact_idx, contact)) return false;
|
||||
|
||||
ContactInfo* recipient = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE);
|
||||
if (!recipient) return false;
|
||||
|
||||
uint32_t timestamp = getRTCClock()->getCurrentTimeUnique();
|
||||
uint32_t expected_ack, est_timeout;
|
||||
int result = sendMessage(*recipient, timestamp, 0, text, expected_ack, est_timeout);
|
||||
|
||||
if (result == MSG_SEND_FAILED) {
|
||||
MESH_DEBUG_PRINTLN("UI: DM send failed to %s", recipient->name);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Track expected ACK for delivery confirmation
|
||||
if (expected_ack) {
|
||||
expected_ack_table[next_ack_idx].msg_sent = _ms->getMillis();
|
||||
expected_ack_table[next_ack_idx].ack = expected_ack;
|
||||
expected_ack_table[next_ack_idx].contact = recipient;
|
||||
next_ack_idx = (next_ack_idx + 1) % EXPECTED_ACK_TABLE_SIZE;
|
||||
}
|
||||
|
||||
MESH_DEBUG_PRINTLN("UI: DM sent to %s (%s), ack=0x%08X timeout=%dms",
|
||||
recipient->name, result == MSG_SEND_SENT_FLOOD ? "flood" : "direct",
|
||||
expected_ack, est_timeout);
|
||||
return true;
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
#define FIRMWARE_VER_CODE 8
|
||||
|
||||
#ifndef FIRMWARE_BUILD_DATE
|
||||
#define FIRMWARE_BUILD_DATE "10 Feb 2026"
|
||||
#define FIRMWARE_BUILD_DATE "7 Feb 2026"
|
||||
#endif
|
||||
|
||||
#ifndef FIRMWARE_VERSION
|
||||
#define FIRMWARE_VERSION "Meck v0.8"
|
||||
#define FIRMWARE_VERSION "Meck v0.6.4"
|
||||
#endif
|
||||
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
@@ -105,9 +105,6 @@ public:
|
||||
// Queue a sent channel message for BLE app sync
|
||||
void queueSentChannelMessage(uint8_t channel_idx, uint32_t timestamp, const char* sender, const char* text);
|
||||
|
||||
// Send a direct message from the UI (no BLE dependency)
|
||||
bool uiSendDirectMessage(uint32_t contact_idx, const char* text);
|
||||
|
||||
protected:
|
||||
float getAirtimeBudgetFactor() const override;
|
||||
int getInterferenceThreshold() const override;
|
||||
@@ -235,7 +232,7 @@ private:
|
||||
#define ADVERT_PATH_TABLE_SIZE 16
|
||||
AdvertPath advert_paths[ADVERT_PATH_TABLE_SIZE]; // circular table
|
||||
|
||||
// Sent message repeat tracking
|
||||
// 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
|
||||
|
||||
@@ -7,42 +7,20 @@
|
||||
// T-Deck Pro Keyboard support
|
||||
#if defined(LilyGo_TDeck_Pro)
|
||||
#include "TCA8418Keyboard.h"
|
||||
#include <SD.h>
|
||||
#include "TextReaderScreen.h"
|
||||
#include "ContactsScreen.h"
|
||||
extern SPIClass displaySpi; // From GxEPDDisplay.cpp, shared SPI bus
|
||||
|
||||
TCA8418Keyboard keyboard(I2C_ADDR_KEYBOARD, &Wire);
|
||||
|
||||
// Compose mode state
|
||||
static bool composeMode = false;
|
||||
static char composeBuffer[138]; // 137 bytes max + null terminator (matches BLE wire cost)
|
||||
static int composePos = 0; // Current wire-cost byte count
|
||||
static uint8_t composeChannelIdx = 0;
|
||||
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)
|
||||
|
||||
// DM compose mode (direct message to a specific contact)
|
||||
static bool composeDM = false;
|
||||
static int composeDMContactIdx = -1;
|
||||
static char composeDMName[32];
|
||||
// AGC reset - periodically re-assert RX boosted gain to prevent sensitivity drift
|
||||
#define AGC_RESET_INTERVAL_MS 500
|
||||
static unsigned long lastAGCReset = 0;
|
||||
|
||||
// Emoji picker state
|
||||
#include "EmojiPicker.h"
|
||||
static bool emojiPickerMode = false;
|
||||
static EmojiPicker emojiPicker;
|
||||
|
||||
// Text reader mode state
|
||||
static bool readerMode = false;
|
||||
|
||||
void initKeyboard();
|
||||
void handleKeyboardInput();
|
||||
void drawComposeScreen();
|
||||
void drawEmojiPicker();
|
||||
void sendComposedMessage();
|
||||
#endif
|
||||
|
||||
@@ -350,26 +328,6 @@ void setup() {
|
||||
initKeyboard();
|
||||
#endif
|
||||
|
||||
// Initialize SD card for text reader
|
||||
#if defined(LilyGo_TDeck_Pro) && defined(HAS_SDCARD)
|
||||
{
|
||||
pinMode(SDCARD_CS, OUTPUT);
|
||||
digitalWrite(SDCARD_CS, HIGH); // Deselect SD initially
|
||||
|
||||
if (SD.begin(SDCARD_CS, displaySpi, 4000000)) {
|
||||
MESH_DEBUG_PRINTLN("setup() - SD card initialized");
|
||||
// Tell the text reader that SD is ready, then pre-index books at boot
|
||||
TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen();
|
||||
if (reader) {
|
||||
reader->setSDReady(true);
|
||||
if (disp) {
|
||||
reader->bootIndex(*disp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Enable GPS by default on T-Deck Pro
|
||||
#if HAS_GPS
|
||||
// Set GPS enabled in both sensor manager and node prefs
|
||||
@@ -391,29 +349,19 @@ void loop() {
|
||||
if (!composeMode) {
|
||||
ui_task.loop();
|
||||
} else {
|
||||
// Handle debounced compose/emoji picker screen refresh
|
||||
// Handle debounced compose screen refresh
|
||||
if (composeNeedsRefresh && (millis() - lastComposeRefresh) >= COMPOSE_REFRESH_INTERVAL) {
|
||||
if (emojiPickerMode) {
|
||||
drawEmojiPicker();
|
||||
} else {
|
||||
drawComposeScreen();
|
||||
}
|
||||
drawComposeScreen();
|
||||
lastComposeRefresh = millis();
|
||||
composeNeedsRefresh = false;
|
||||
}
|
||||
}
|
||||
// Track reader mode state for key routing
|
||||
readerMode = ui_task.isOnTextReader();
|
||||
#else
|
||||
ui_task.loop();
|
||||
#endif
|
||||
#endif
|
||||
rtc_clock.tick();
|
||||
// Periodic AGC reset - re-assert boosted RX gain to prevent sensitivity drift
|
||||
if ((millis() - lastAGCReset) >= AGC_RESET_INTERVAL_MS) {
|
||||
radio_reset_agc();
|
||||
lastAGCReset = millis();
|
||||
}
|
||||
|
||||
// Handle T-Deck Pro keyboard input
|
||||
#if defined(LilyGo_TDeck_Pro)
|
||||
handleKeyboardInput();
|
||||
@@ -450,37 +398,6 @@ void handleKeyboardInput() {
|
||||
key >= 32 ? key : '?', key, composeMode);
|
||||
|
||||
if (composeMode) {
|
||||
// Emoji picker sub-mode
|
||||
if (emojiPickerMode) {
|
||||
uint8_t result = emojiPicker.handleInput(key);
|
||||
if (result == 0xFF) {
|
||||
// Cancelled - immediate draw to return to compose
|
||||
emojiPickerMode = false;
|
||||
drawComposeScreen();
|
||||
lastComposeRefresh = millis();
|
||||
composeNeedsRefresh = false;
|
||||
} else if (result >= EMOJI_ESCAPE_START && result <= EMOJI_ESCAPE_END) {
|
||||
// Emoji selected - insert escape byte + padding to match UTF-8 wire cost
|
||||
int cost = emojiUtf8Cost(result);
|
||||
if (composePos + cost <= 137) {
|
||||
composeBuffer[composePos++] = (char)result;
|
||||
for (int p = 1; p < cost; p++) {
|
||||
composeBuffer[composePos++] = (char)EMOJI_PAD_BYTE;
|
||||
}
|
||||
composeBuffer[composePos] = '\0';
|
||||
Serial.printf("Compose: Inserted emoji 0x%02X cost=%d, pos=%d\n", result, cost, composePos);
|
||||
}
|
||||
emojiPickerMode = false;
|
||||
drawComposeScreen();
|
||||
lastComposeRefresh = millis();
|
||||
composeNeedsRefresh = false;
|
||||
} else {
|
||||
// Navigation - debounce (don't draw immediately, let loop handle it)
|
||||
composeNeedsRefresh = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// In compose mode - handle text input
|
||||
if (key == '\r') {
|
||||
// Enter - send the message
|
||||
@@ -488,18 +405,10 @@ void handleKeyboardInput() {
|
||||
if (composePos > 0) {
|
||||
sendComposedMessage();
|
||||
}
|
||||
bool wasDM = composeDM;
|
||||
composeMode = false;
|
||||
emojiPickerMode = false;
|
||||
composeDM = false;
|
||||
composeDMContactIdx = -1;
|
||||
composeBuffer[0] = '\0';
|
||||
composePos = 0;
|
||||
if (wasDM) {
|
||||
ui_task.gotoContactsScreen();
|
||||
} else {
|
||||
ui_task.gotoHomeScreen();
|
||||
}
|
||||
ui_task.gotoHomeScreen();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -508,39 +417,24 @@ void handleKeyboardInput() {
|
||||
if (keyboard.wasShiftRecentlyPressed(500)) {
|
||||
// Shift+Backspace = Cancel (works anytime)
|
||||
Serial.println("Compose: Shift+Backspace, cancelling...");
|
||||
bool wasDM = composeDM;
|
||||
composeMode = false;
|
||||
emojiPickerMode = false;
|
||||
composeDM = false;
|
||||
composeDMContactIdx = -1;
|
||||
composeBuffer[0] = '\0';
|
||||
composePos = 0;
|
||||
if (wasDM) {
|
||||
ui_task.gotoContactsScreen();
|
||||
} else {
|
||||
ui_task.gotoHomeScreen();
|
||||
}
|
||||
ui_task.gotoHomeScreen();
|
||||
return;
|
||||
}
|
||||
// Regular backspace - delete last character (or entire emoji including pads)
|
||||
// Regular backspace - delete last character
|
||||
if (composePos > 0) {
|
||||
// Delete trailing pad bytes first, then the escape byte
|
||||
while (composePos > 0 && (uint8_t)composeBuffer[composePos - 1] == EMOJI_PAD_BYTE) {
|
||||
composePos--;
|
||||
}
|
||||
// Now delete the actual character (escape byte or regular char)
|
||||
if (composePos > 0) {
|
||||
composePos--;
|
||||
}
|
||||
composePos--;
|
||||
composeBuffer[composePos] = '\0';
|
||||
Serial.printf("Compose: Backspace, pos=%d\n", composePos);
|
||||
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, not in DM mode)
|
||||
if ((key == 'a' || key == 'A') && composePos == 0 && !composeDM) {
|
||||
// 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--;
|
||||
@@ -555,11 +449,11 @@ void handleKeyboardInput() {
|
||||
}
|
||||
}
|
||||
Serial.printf("Compose: Channel switched to %d\n", composeChannelIdx);
|
||||
composeNeedsRefresh = true; // Debounced refresh
|
||||
drawComposeScreen();
|
||||
return;
|
||||
}
|
||||
|
||||
if ((key == 'd' || key == 'D') && composePos == 0 && !composeDM) {
|
||||
if ((key == 'd' || key == 'D') && composePos == 0) {
|
||||
// Next channel
|
||||
ChannelDetails ch;
|
||||
uint8_t nextIdx = composeChannelIdx + 1;
|
||||
@@ -569,17 +463,7 @@ void handleKeyboardInput() {
|
||||
composeChannelIdx = 0; // Wrap to first channel
|
||||
}
|
||||
Serial.printf("Compose: Channel switched to %d\n", composeChannelIdx);
|
||||
composeNeedsRefresh = true; // Debounced refresh
|
||||
return;
|
||||
}
|
||||
|
||||
// '$' key (without Sym) opens emoji picker
|
||||
if (key == KB_KEY_EMOJI) {
|
||||
emojiPicker.reset();
|
||||
emojiPickerMode = true;
|
||||
drawEmojiPicker();
|
||||
lastComposeRefresh = millis();
|
||||
composeNeedsRefresh = false;
|
||||
drawComposeScreen();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -587,83 +471,26 @@ void handleKeyboardInput() {
|
||||
if (key >= 32 && key < 127 && composePos < 137) {
|
||||
composeBuffer[composePos++] = key;
|
||||
composeBuffer[composePos] = '\0';
|
||||
Serial.printf("Compose: Added '%c', pos=%d\n", key, composePos);
|
||||
Serial.printf("Compose: Added '%c', pos now %d\n", key, composePos);
|
||||
composeNeedsRefresh = true; // Use debounced refresh
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// *** TEXT READER MODE ***
|
||||
if (readerMode) {
|
||||
TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen();
|
||||
|
||||
// Q key: if reading, reader handles it (close book -> file list)
|
||||
// if on file list, exit reader entirely
|
||||
if (key == 'q' || key == 'Q') {
|
||||
if (reader->isReading()) {
|
||||
// Let the reader handle Q (close book, go to file list)
|
||||
ui_task.injectKey('q');
|
||||
} else {
|
||||
// On file list - exit reader, go home
|
||||
reader->exitReader();
|
||||
Serial.println("Exiting text reader");
|
||||
ui_task.gotoHomeScreen();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// C key: allow entering compose mode from reader
|
||||
if (key == 'c' || key == 'C') {
|
||||
composeDM = false;
|
||||
composeDMContactIdx = -1;
|
||||
composeMode = true;
|
||||
composeBuffer[0] = '\0';
|
||||
composePos = 0;
|
||||
Serial.printf("Entering compose mode from reader, channel %d\n", composeChannelIdx);
|
||||
drawComposeScreen();
|
||||
lastComposeRefresh = millis();
|
||||
return;
|
||||
}
|
||||
|
||||
// All other keys pass through to the reader screen
|
||||
ui_task.injectKey(key);
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal mode - not composing
|
||||
switch (key) {
|
||||
case 'c':
|
||||
case 'C':
|
||||
// Enter compose mode - DM if on contacts screen, channel otherwise
|
||||
if (ui_task.isOnContactsScreen()) {
|
||||
ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen();
|
||||
int idx = cs->getSelectedContactIdx();
|
||||
uint8_t ctype = cs->getSelectedContactType();
|
||||
if (idx >= 0 && ctype == ADV_TYPE_CHAT) {
|
||||
composeDM = true;
|
||||
composeDMContactIdx = idx;
|
||||
cs->getSelectedContactName(composeDMName, sizeof(composeDMName));
|
||||
composeMode = true;
|
||||
composeBuffer[0] = '\0';
|
||||
composePos = 0;
|
||||
Serial.printf("Entering DM compose to %s (idx %d)\n", composeDMName, idx);
|
||||
drawComposeScreen();
|
||||
lastComposeRefresh = millis();
|
||||
}
|
||||
} else {
|
||||
composeDM = false;
|
||||
composeDMContactIdx = -1;
|
||||
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();
|
||||
lastComposeRefresh = millis();
|
||||
// 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':
|
||||
@@ -673,25 +500,11 @@ void handleKeyboardInput() {
|
||||
ui_task.gotoChannelScreen();
|
||||
break;
|
||||
|
||||
case 'r':
|
||||
case 'R':
|
||||
// Open text reader
|
||||
Serial.println("Opening text reader");
|
||||
ui_task.gotoTextReader();
|
||||
break;
|
||||
|
||||
case 'n':
|
||||
case 'N':
|
||||
// Open contacts list
|
||||
Serial.println("Opening contacts");
|
||||
ui_task.gotoContactsScreen();
|
||||
break;
|
||||
|
||||
case 'w':
|
||||
case 'W':
|
||||
// Navigate up/previous (scroll on channel screen)
|
||||
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) {
|
||||
ui_task.injectKey('w'); // Pass directly for channel/contacts switching
|
||||
if (ui_task.isOnChannelScreen()) {
|
||||
ui_task.injectKey('w'); // Pass directly for channel switching
|
||||
} else {
|
||||
Serial.println("Nav: Previous");
|
||||
ui_task.injectKey(0xF2); // KEY_PREV
|
||||
@@ -701,8 +514,8 @@ void handleKeyboardInput() {
|
||||
case 's':
|
||||
case 'S':
|
||||
// Navigate down/next (scroll on channel screen)
|
||||
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) {
|
||||
ui_task.injectKey('s'); // Pass directly for channel/contacts switching
|
||||
if (ui_task.isOnChannelScreen()) {
|
||||
ui_task.injectKey('s'); // Pass directly for channel switching
|
||||
} else {
|
||||
Serial.println("Nav: Next");
|
||||
ui_task.injectKey(0xF1); // KEY_NEXT
|
||||
@@ -712,8 +525,8 @@ void handleKeyboardInput() {
|
||||
case 'a':
|
||||
case 'A':
|
||||
// Navigate left or switch channel (on channel screen)
|
||||
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) {
|
||||
ui_task.injectKey('a'); // Pass directly for channel/contacts switching
|
||||
if (ui_task.isOnChannelScreen()) {
|
||||
ui_task.injectKey('a'); // Pass directly for channel switching
|
||||
} else {
|
||||
Serial.println("Nav: Previous");
|
||||
ui_task.injectKey(0xF2); // KEY_PREV
|
||||
@@ -723,8 +536,8 @@ void handleKeyboardInput() {
|
||||
case 'd':
|
||||
case 'D':
|
||||
// Navigate right or switch channel (on channel screen)
|
||||
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) {
|
||||
ui_task.injectKey('d'); // Pass directly for channel/contacts switching
|
||||
if (ui_task.isOnChannelScreen()) {
|
||||
ui_task.injectKey('d'); // Pass directly for channel switching
|
||||
} else {
|
||||
Serial.println("Nav: Next");
|
||||
ui_task.injectKey(0xF1); // KEY_NEXT
|
||||
@@ -732,29 +545,9 @@ void handleKeyboardInput() {
|
||||
break;
|
||||
|
||||
case '\r':
|
||||
// Select/Enter - if on contacts screen, enter DM compose for chat contacts
|
||||
if (ui_task.isOnContactsScreen()) {
|
||||
ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen();
|
||||
int idx = cs->getSelectedContactIdx();
|
||||
uint8_t ctype = cs->getSelectedContactType();
|
||||
if (idx >= 0 && ctype == ADV_TYPE_CHAT) {
|
||||
composeDM = true;
|
||||
composeDMContactIdx = idx;
|
||||
cs->getSelectedContactName(composeDMName, sizeof(composeDMName));
|
||||
composeMode = true;
|
||||
composeBuffer[0] = '\0';
|
||||
composePos = 0;
|
||||
Serial.printf("Entering DM compose to %s (idx %d)\n", composeDMName, idx);
|
||||
drawComposeScreen();
|
||||
lastComposeRefresh = millis();
|
||||
} else if (idx >= 0) {
|
||||
// Non-chat contact selected (repeater, room, etc.) - future use
|
||||
Serial.printf("Selected non-chat contact type=%d idx=%d\n", ctype, idx);
|
||||
}
|
||||
} else {
|
||||
Serial.println("Nav: Enter/Select");
|
||||
ui_task.injectKey(13); // KEY_ENTER
|
||||
}
|
||||
// Select/Enter
|
||||
Serial.println("Nav: Enter/Select");
|
||||
ui_task.injectKey(13); // KEY_ENTER
|
||||
break;
|
||||
|
||||
case 'q':
|
||||
@@ -785,16 +578,12 @@ void drawComposeScreen() {
|
||||
display.setCursor(0, 0);
|
||||
|
||||
// Get the channel name for display
|
||||
ChannelDetails channel;
|
||||
char headerBuf[40];
|
||||
if (composeDM) {
|
||||
snprintf(headerBuf, sizeof(headerBuf), "DM: %s", composeDMName);
|
||||
if (the_mesh.getChannel(composeChannelIdx, channel)) {
|
||||
snprintf(headerBuf, sizeof(headerBuf), "To: %s", channel.name);
|
||||
} else {
|
||||
ChannelDetails channel;
|
||||
if (the_mesh.getChannel(composeChannelIdx, channel)) {
|
||||
snprintf(headerBuf, sizeof(headerBuf), "To: %s", channel.name);
|
||||
} else {
|
||||
snprintf(headerBuf, sizeof(headerBuf), "To: Channel %d", composeChannelIdx);
|
||||
}
|
||||
snprintf(headerBuf, sizeof(headerBuf), "To: Channel %d", composeChannelIdx);
|
||||
}
|
||||
display.print(headerBuf);
|
||||
|
||||
@@ -804,81 +593,27 @@ void drawComposeScreen() {
|
||||
display.setCursor(0, 14);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
|
||||
// Word wrap the compose buffer with word-boundary awareness
|
||||
// Uses advance width (cursor movement) not bounding box width for px tracking.
|
||||
// Advance = getTextWidth("cc") - getTextWidth("c") to get true cursor step.
|
||||
// Word wrap the compose buffer - calculate chars per line based on actual font width
|
||||
int x = 0;
|
||||
int y = 14;
|
||||
char charStr[2] = {0, 0};
|
||||
char dblStr[3] = {0, 0, 0};
|
||||
|
||||
int px = 0;
|
||||
int lineW = display.width();
|
||||
bool atWordBoundary = true;
|
||||
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++) {
|
||||
uint8_t b = (uint8_t)composeBuffer[i];
|
||||
|
||||
if (b == EMOJI_PAD_BYTE) continue;
|
||||
|
||||
// Word wrap: when starting a new text word, check if it fits on this line
|
||||
if (atWordBoundary && b != ' ' && !isEmojiEscape(b) && px > 0) {
|
||||
int wordW = 0;
|
||||
for (int j = i; j < composePos; j++) {
|
||||
uint8_t wb = (uint8_t)composeBuffer[j];
|
||||
if (wb == EMOJI_PAD_BYTE) continue;
|
||||
if (wb == ' ' || isEmojiEscape(wb)) break;
|
||||
dblStr[0] = dblStr[1] = (char)wb;
|
||||
charStr[0] = (char)wb;
|
||||
wordW += display.getTextWidth(dblStr) - display.getTextWidth(charStr);
|
||||
}
|
||||
if (px + wordW > lineW) {
|
||||
px = 0;
|
||||
y += 12;
|
||||
}
|
||||
}
|
||||
|
||||
if (isEmojiEscape(b)) {
|
||||
if (px + EMOJI_LG_W > lineW) {
|
||||
px = 0;
|
||||
y += 12;
|
||||
}
|
||||
const uint8_t* sprite = getEmojiSpriteLg(b);
|
||||
if (sprite) {
|
||||
display.drawXbm(px, y, sprite, EMOJI_LG_W, EMOJI_LG_H);
|
||||
}
|
||||
px += EMOJI_LG_W + 1;
|
||||
display.setCursor(px, y);
|
||||
atWordBoundary = true;
|
||||
} else if (b == ' ') {
|
||||
charStr[0] = ' ';
|
||||
dblStr[0] = dblStr[1] = ' ';
|
||||
int adv = display.getTextWidth(dblStr) - display.getTextWidth(charStr);
|
||||
if (px + adv > lineW) {
|
||||
px = 0;
|
||||
y += 12;
|
||||
} else {
|
||||
display.setCursor(px, y);
|
||||
display.print(charStr);
|
||||
px += adv;
|
||||
}
|
||||
atWordBoundary = true;
|
||||
} else {
|
||||
charStr[0] = (char)b;
|
||||
dblStr[0] = dblStr[1] = (char)b;
|
||||
int adv = display.getTextWidth(dblStr) - display.getTextWidth(charStr);
|
||||
if (px + adv > lineW) {
|
||||
px = 0;
|
||||
y += 12;
|
||||
}
|
||||
display.setCursor(px, y);
|
||||
display.print(charStr);
|
||||
px += adv;
|
||||
atWordBoundary = false;
|
||||
charStr[0] = composeBuffer[i];
|
||||
display.print(charStr);
|
||||
x++;
|
||||
if (x >= charsPerLine) {
|
||||
x = 0;
|
||||
y += 11;
|
||||
display.setCursor(0, y);
|
||||
}
|
||||
}
|
||||
|
||||
// Show cursor
|
||||
display.setCursor(px, y);
|
||||
display.print("_");
|
||||
|
||||
// Status bar
|
||||
@@ -891,13 +626,13 @@ void drawComposeScreen() {
|
||||
char status[40];
|
||||
if (composePos == 0) {
|
||||
// Empty buffer - show channel switching hint
|
||||
display.print("A/D:Ch $:Emoji");
|
||||
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 $:Emj", composePos);
|
||||
sprintf(status, "%d/137 Ent:Send", composePos);
|
||||
display.print(status);
|
||||
sprintf(status, "Sh+Del:X");
|
||||
display.setCursor(display.width() - display.getTextWidth(status) - 2, statusY);
|
||||
@@ -908,51 +643,27 @@ void drawComposeScreen() {
|
||||
#endif
|
||||
}
|
||||
|
||||
void drawEmojiPicker() {
|
||||
#ifdef DISPLAY_CLASS
|
||||
display.startFrame();
|
||||
emojiPicker.draw(display);
|
||||
display.endFrame();
|
||||
#endif
|
||||
}
|
||||
|
||||
void sendComposedMessage() {
|
||||
if (composePos == 0) return;
|
||||
|
||||
// Convert escape bytes back to UTF-8 for mesh transmission and BLE app
|
||||
char utf8Buf[512];
|
||||
emojiUnescape(composeBuffer, utf8Buf, sizeof(utf8Buf));
|
||||
|
||||
if (composeDM) {
|
||||
// Direct message to a specific contact
|
||||
if (composeDMContactIdx >= 0) {
|
||||
if (the_mesh.uiSendDirectMessage((uint32_t)composeDMContactIdx, utf8Buf)) {
|
||||
ui_task.showAlert("DM sent!", 1500);
|
||||
} else {
|
||||
ui_task.showAlert("DM failed!", 1500);
|
||||
}
|
||||
} else {
|
||||
ui_task.showAlert("No contact!", 1500);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Channel (group) message
|
||||
// Get the selected channel
|
||||
ChannelDetails channel;
|
||||
if (the_mesh.getChannel(composeChannelIdx, channel)) {
|
||||
uint32_t timestamp = rtc_clock.getCurrentTime();
|
||||
int utf8Len = strlen(utf8Buf);
|
||||
|
||||
// Send to channel
|
||||
if (the_mesh.sendGroupMessage(timestamp, channel.channel,
|
||||
the_mesh.getNodePrefs()->node_name,
|
||||
utf8Buf, utf8Len)) {
|
||||
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,
|
||||
utf8Buf);
|
||||
composeBuffer);
|
||||
|
||||
// Queue message for BLE app sync (so sent messages appear in companion app)
|
||||
the_mesh.queueSentChannelMessage(composeChannelIdx, timestamp,
|
||||
the_mesh.getNodePrefs()->node_name,
|
||||
utf8Buf);
|
||||
composeBuffer);
|
||||
|
||||
ui_task.showAlert("Sent!", 1500);
|
||||
} else {
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include <helpers/ChannelDetails.h>
|
||||
#include <MeshCore.h>
|
||||
#include "EmojiSprites.h"
|
||||
|
||||
// Maximum messages to store in history
|
||||
#define CHANNEL_MSG_HISTORY_SIZE 20
|
||||
@@ -60,9 +59,9 @@ public:
|
||||
msg->channel_idx = channel_idx;
|
||||
msg->valid = true;
|
||||
|
||||
// Sanitize emoji: replace UTF-8 emoji sequences with single-byte escape codes
|
||||
// The text already contains "Sender: message" format
|
||||
emojiSanitize(text, msg->text, CHANNEL_MSG_TEXT_LEN);
|
||||
// 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++;
|
||||
@@ -115,23 +114,24 @@ public:
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
if (channelMsgCount == 0) {
|
||||
display.setTextSize(0); // Tiny font for body text
|
||||
display.setCursor(0, 20);
|
||||
display.setCursor(0, 25);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.print("No messages yet");
|
||||
display.setCursor(0, 30);
|
||||
display.print("A/D: Switch channel");
|
||||
display.setCursor(0, 40);
|
||||
display.print("A/D: Switch channel");
|
||||
display.setCursor(0, 52);
|
||||
display.print("C: Compose message");
|
||||
display.setTextSize(1); // Restore for footer
|
||||
} else {
|
||||
display.setTextSize(0); // Tiny font for message body
|
||||
int lineHeight = 9; // 8px font + 1px spacing
|
||||
int lineHeight = 10;
|
||||
int headerHeight = 14;
|
||||
int footerHeight = 14;
|
||||
// Hard cutoff: no text may START at or beyond this y value
|
||||
// This ensures rendered glyphs (which extend lineHeight below y) stay above the footer
|
||||
int maxY = display.height() - footerHeight;
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -149,151 +149,66 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse to chronological order (oldest first, newest last at bottom)
|
||||
for (int l = 0, r = numChannelMsgs - 1; l < r; l++, r--) {
|
||||
int tmp = channelMsgs[l]; channelMsgs[l] = channelMsgs[r]; channelMsgs[r] = tmp;
|
||||
}
|
||||
|
||||
// Calculate start index so newest messages appear at the bottom
|
||||
// scrollPos=0 shows the most recent messages, scrollPos++ scrolls up to older
|
||||
int startIdx = numChannelMsgs - _msgsPerPage - _scrollPos;
|
||||
if (startIdx < 0) startIdx = 0;
|
||||
|
||||
// Display messages oldest-to-newest (top to bottom)
|
||||
// Display messages from scroll position
|
||||
int msgsDrawn = 0;
|
||||
for (int i = startIdx; i < numChannelMsgs && y + lineHeight <= maxY; i++) {
|
||||
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 - inline on same line as message start
|
||||
// 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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
sprintf(tmp, "(%d) %dd", msg->path_len == 0xFF ? 0 : msg->path_len, age / 86400);
|
||||
}
|
||||
display.print(tmp);
|
||||
// DO NOT advance y - message text continues on the same line
|
||||
y += lineHeight;
|
||||
|
||||
// Message text with character wrapping and inline emoji support
|
||||
// (continues after timestamp on first line)
|
||||
// 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 = 8;
|
||||
int maxLinesPerMsg = 6; // Allow more lines per message
|
||||
int x = 0;
|
||||
char charStr[2] = {0, 0};
|
||||
|
||||
// Track position in pixels for emoji placement
|
||||
// Uses advance width (cursor movement) not bounding box for px tracking
|
||||
int lineW = display.width();
|
||||
int px = display.getTextWidth(tmp); // Pixel X after timestamp
|
||||
char dblStr[3] = {0, 0, 0};
|
||||
display.setCursor(0, y);
|
||||
|
||||
while (pos < textLen && linesForThisMsg < maxLinesPerMsg && y + lineHeight <= maxY) {
|
||||
uint8_t b = (uint8_t)msg->text[pos];
|
||||
while (pos < textLen && linesForThisMsg < maxLinesPerMsg && y < display.height() - footerHeight - 2) {
|
||||
charStr[0] = msg->text[pos];
|
||||
display.print(charStr);
|
||||
x++;
|
||||
pos++;
|
||||
|
||||
if (b == EMOJI_PAD_BYTE) { pos++; continue; }
|
||||
|
||||
// Word wrap: when starting a new text word, check if it fits
|
||||
if (b != ' ' && !isEmojiEscape(b) && px > 0) {
|
||||
bool boundary = (pos == 0);
|
||||
if (!boundary) {
|
||||
for (int bp = pos - 1; bp >= 0; bp--) {
|
||||
uint8_t pb = (uint8_t)msg->text[bp];
|
||||
if (pb == EMOJI_PAD_BYTE) continue;
|
||||
boundary = (pb == ' ' || isEmojiEscape(pb));
|
||||
break;
|
||||
}
|
||||
if (x >= charsPerLine) {
|
||||
x = 0;
|
||||
linesForThisMsg++;
|
||||
y += lineHeight;
|
||||
if (linesForThisMsg < maxLinesPerMsg && y < display.height() - footerHeight - 2) {
|
||||
display.setCursor(0, y);
|
||||
}
|
||||
if (boundary) {
|
||||
int wordW = 0;
|
||||
for (int j = pos; j < textLen; j++) {
|
||||
uint8_t wb = (uint8_t)msg->text[j];
|
||||
if (wb == EMOJI_PAD_BYTE) continue;
|
||||
if (wb == ' ' || isEmojiEscape(wb)) break;
|
||||
charStr[0] = (char)wb;
|
||||
dblStr[0] = dblStr[1] = (char)wb;
|
||||
wordW += display.getTextWidth(dblStr) - display.getTextWidth(charStr);
|
||||
}
|
||||
if (px + wordW > lineW) {
|
||||
px = 0;
|
||||
linesForThisMsg++;
|
||||
y += lineHeight;
|
||||
if (linesForThisMsg >= maxLinesPerMsg || y + lineHeight > maxY) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isEmojiEscape(b)) {
|
||||
if (px + EMOJI_SM_W > lineW) {
|
||||
px = 0;
|
||||
linesForThisMsg++;
|
||||
y += lineHeight;
|
||||
if (linesForThisMsg >= maxLinesPerMsg || y + lineHeight > maxY) break;
|
||||
}
|
||||
const uint8_t* sprite = getEmojiSpriteSm(b);
|
||||
if (sprite) {
|
||||
display.drawXbm(px, y, sprite, EMOJI_SM_W, EMOJI_SM_H);
|
||||
}
|
||||
pos++;
|
||||
px += EMOJI_SM_W + 1;
|
||||
display.setCursor(px, y);
|
||||
} else if (b == ' ') {
|
||||
charStr[0] = ' ';
|
||||
dblStr[0] = dblStr[1] = ' ';
|
||||
int adv = display.getTextWidth(dblStr) - display.getTextWidth(charStr);
|
||||
if (px + adv > lineW) {
|
||||
px = 0;
|
||||
linesForThisMsg++;
|
||||
y += lineHeight;
|
||||
if (linesForThisMsg < maxLinesPerMsg && y + lineHeight <= maxY) {
|
||||
// skip trailing space at wrap
|
||||
} else break;
|
||||
} else {
|
||||
display.setCursor(px, y);
|
||||
display.print(charStr);
|
||||
px += adv;
|
||||
}
|
||||
pos++;
|
||||
} else {
|
||||
charStr[0] = (char)b;
|
||||
dblStr[0] = dblStr[1] = (char)b;
|
||||
int adv = display.getTextWidth(dblStr) - display.getTextWidth(charStr);
|
||||
if (px + adv > lineW) {
|
||||
px = 0;
|
||||
linesForThisMsg++;
|
||||
y += lineHeight;
|
||||
if (linesForThisMsg < maxLinesPerMsg && y + lineHeight <= maxY) {
|
||||
// continue to print below
|
||||
} else break;
|
||||
}
|
||||
display.setCursor(px, y);
|
||||
display.print(charStr);
|
||||
px += adv;
|
||||
pos++;
|
||||
}
|
||||
}
|
||||
|
||||
// If we didn't end on a full line, still count it
|
||||
if (px > 0) {
|
||||
if (x > 0) {
|
||||
y += lineHeight;
|
||||
}
|
||||
|
||||
y += 2; // Small gap between messages
|
||||
y += 2;
|
||||
msgsDrawn++;
|
||||
_msgsPerPage = msgsDrawn;
|
||||
}
|
||||
|
||||
display.setTextSize(1); // Restore for footer
|
||||
}
|
||||
|
||||
// Footer with controls
|
||||
|
||||
@@ -1,344 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <helpers/ui/UIScreen.h>
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include <MeshCore.h>
|
||||
|
||||
// Forward declarations
|
||||
class UITask;
|
||||
class MyMesh;
|
||||
extern MyMesh the_mesh;
|
||||
|
||||
class ContactsScreen : public UIScreen {
|
||||
public:
|
||||
// Filter modes for contact type
|
||||
enum FilterMode {
|
||||
FILTER_ALL = 0,
|
||||
FILTER_CHAT, // Companions / Chat nodes
|
||||
FILTER_REPEATER,
|
||||
FILTER_ROOM, // Room servers
|
||||
FILTER_SENSOR,
|
||||
FILTER_COUNT // keep last
|
||||
};
|
||||
|
||||
private:
|
||||
UITask* _task;
|
||||
mesh::RTCClock* _rtc;
|
||||
|
||||
int _scrollPos; // Index into filtered list (top visible row)
|
||||
FilterMode _filter; // Current filter mode
|
||||
|
||||
// Cached filtered contact indices for efficient scrolling
|
||||
// We rebuild this on filter change or when entering the screen
|
||||
static const int MAX_VISIBLE = 400; // matches MAX_CONTACTS build flag
|
||||
uint16_t _filteredIdx[MAX_VISIBLE]; // indices into contact table
|
||||
uint32_t _filteredTs[MAX_VISIBLE]; // cached last_advert_timestamp for sorting
|
||||
int _filteredCount; // how many contacts match current filter
|
||||
bool _cacheValid;
|
||||
|
||||
// How many rows fit on screen (computed during render)
|
||||
int _rowsPerPage;
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
static const char* filterLabel(FilterMode f) {
|
||||
switch (f) {
|
||||
case FILTER_ALL: return "All";
|
||||
case FILTER_CHAT: return "Chat";
|
||||
case FILTER_REPEATER: return "Rptr";
|
||||
case FILTER_ROOM: return "Room";
|
||||
case FILTER_SENSOR: return "Sens";
|
||||
default: return "?";
|
||||
}
|
||||
}
|
||||
|
||||
static char typeChar(uint8_t adv_type) {
|
||||
switch (adv_type) {
|
||||
case ADV_TYPE_CHAT: return 'C';
|
||||
case ADV_TYPE_REPEATER: return 'R';
|
||||
case ADV_TYPE_ROOM: return 'S'; // Server
|
||||
default: return '?';
|
||||
}
|
||||
}
|
||||
|
||||
bool matchesFilter(uint8_t adv_type) const {
|
||||
switch (_filter) {
|
||||
case FILTER_ALL: return true;
|
||||
case FILTER_CHAT: return adv_type == ADV_TYPE_CHAT;
|
||||
case FILTER_REPEATER: return adv_type == ADV_TYPE_REPEATER;
|
||||
case FILTER_ROOM: return adv_type == ADV_TYPE_ROOM;
|
||||
case FILTER_SENSOR: return (adv_type != ADV_TYPE_CHAT &&
|
||||
adv_type != ADV_TYPE_REPEATER &&
|
||||
adv_type != ADV_TYPE_ROOM);
|
||||
default: return true;
|
||||
}
|
||||
}
|
||||
|
||||
void rebuildCache() {
|
||||
_filteredCount = 0;
|
||||
uint32_t numContacts = the_mesh.getNumContacts();
|
||||
ContactInfo contact;
|
||||
for (uint32_t i = 0; i < numContacts && _filteredCount < MAX_VISIBLE; i++) {
|
||||
if (the_mesh.getContactByIdx(i, contact)) {
|
||||
if (matchesFilter(contact.type)) {
|
||||
_filteredIdx[_filteredCount] = (uint16_t)i;
|
||||
_filteredTs[_filteredCount] = contact.last_advert_timestamp;
|
||||
_filteredCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sort by last_advert_timestamp descending (most recently seen first)
|
||||
// Simple insertion sort — fine for up to 400 entries on ESP32
|
||||
for (int i = 1; i < _filteredCount; i++) {
|
||||
uint16_t tmpIdx = _filteredIdx[i];
|
||||
uint32_t tmpTs = _filteredTs[i];
|
||||
int j = i - 1;
|
||||
while (j >= 0 && _filteredTs[j] < tmpTs) {
|
||||
_filteredIdx[j + 1] = _filteredIdx[j];
|
||||
_filteredTs[j + 1] = _filteredTs[j];
|
||||
j--;
|
||||
}
|
||||
_filteredIdx[j + 1] = tmpIdx;
|
||||
_filteredTs[j + 1] = tmpTs;
|
||||
}
|
||||
_cacheValid = true;
|
||||
// Clamp scroll position
|
||||
if (_scrollPos >= _filteredCount) {
|
||||
_scrollPos = (_filteredCount > 0) ? _filteredCount - 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Format seconds-ago as compact string: "3s" "5m" "2h" "4d" "??"
|
||||
static void formatAge(char* buf, size_t bufLen, uint32_t now, uint32_t timestamp) {
|
||||
if (timestamp == 0) {
|
||||
strncpy(buf, "--", bufLen);
|
||||
return;
|
||||
}
|
||||
int secs = (int)(now - timestamp);
|
||||
if (secs < 0) secs = 0;
|
||||
if (secs < 60) {
|
||||
snprintf(buf, bufLen, "%ds", secs);
|
||||
} else if (secs < 3600) {
|
||||
snprintf(buf, bufLen, "%dm", secs / 60);
|
||||
} else if (secs < 86400) {
|
||||
snprintf(buf, bufLen, "%dh", secs / 3600);
|
||||
} else {
|
||||
snprintf(buf, bufLen, "%dd", secs / 86400);
|
||||
}
|
||||
}
|
||||
|
||||
public:
|
||||
ContactsScreen(UITask* task, mesh::RTCClock* rtc)
|
||||
: _task(task), _rtc(rtc), _scrollPos(0), _filter(FILTER_ALL),
|
||||
_filteredCount(0), _cacheValid(false), _rowsPerPage(5) {}
|
||||
|
||||
void invalidateCache() { _cacheValid = false; }
|
||||
|
||||
void resetScroll() {
|
||||
_scrollPos = 0;
|
||||
_cacheValid = false;
|
||||
}
|
||||
|
||||
FilterMode getFilter() const { return _filter; }
|
||||
|
||||
// Get the raw contact table index for the currently highlighted item
|
||||
// Returns -1 if no valid selection
|
||||
int getSelectedContactIdx() const {
|
||||
if (_filteredCount == 0) return -1;
|
||||
return _filteredIdx[_scrollPos];
|
||||
}
|
||||
|
||||
// Get the adv_type of the currently highlighted contact
|
||||
// Returns 0xFF if no valid selection
|
||||
uint8_t getSelectedContactType() const {
|
||||
if (_filteredCount == 0) return 0xFF;
|
||||
ContactInfo contact;
|
||||
if (!the_mesh.getContactByIdx(_filteredIdx[_scrollPos], contact)) return 0xFF;
|
||||
return contact.type;
|
||||
}
|
||||
|
||||
// Copy the name of the currently highlighted contact into buf
|
||||
// Returns false if no valid selection
|
||||
bool getSelectedContactName(char* buf, size_t bufLen) const {
|
||||
if (_filteredCount == 0) return false;
|
||||
ContactInfo contact;
|
||||
if (!the_mesh.getContactByIdx(_filteredIdx[_scrollPos], contact)) return false;
|
||||
strncpy(buf, contact.name, bufLen);
|
||||
buf[bufLen - 1] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
if (!_cacheValid) rebuildCache();
|
||||
|
||||
char tmp[48];
|
||||
|
||||
// === Header ===
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setCursor(0, 0);
|
||||
snprintf(tmp, sizeof(tmp), "Contacts [%s]", filterLabel(_filter));
|
||||
display.print(tmp);
|
||||
|
||||
// Count on right
|
||||
snprintf(tmp, sizeof(tmp), "%d/%d", _filteredCount, (int)the_mesh.getNumContacts());
|
||||
display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0);
|
||||
display.print(tmp);
|
||||
|
||||
// Divider
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
// === Body - contact rows ===
|
||||
display.setTextSize(0); // tiny font for compact rows
|
||||
int lineHeight = 9; // 8px font + 1px gap
|
||||
int headerHeight = 14;
|
||||
int footerHeight = 14;
|
||||
int maxY = display.height() - footerHeight;
|
||||
int y = headerHeight;
|
||||
|
||||
uint32_t now = _rtc->getCurrentTime();
|
||||
int rowsDrawn = 0;
|
||||
|
||||
if (_filteredCount == 0) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(0, y);
|
||||
display.print("No contacts");
|
||||
display.setCursor(0, y + lineHeight);
|
||||
display.print("A/D: Change filter");
|
||||
} else {
|
||||
// Center visible window around selected item (TextReaderScreen pattern)
|
||||
int maxVisible = (maxY - headerHeight) / lineHeight;
|
||||
if (maxVisible < 3) maxVisible = 3;
|
||||
int startIdx = max(0, min(_scrollPos - maxVisible / 2,
|
||||
_filteredCount - maxVisible));
|
||||
int endIdx = min(_filteredCount, startIdx + maxVisible);
|
||||
|
||||
for (int i = startIdx; i < endIdx && y + lineHeight <= maxY; i++) {
|
||||
ContactInfo contact;
|
||||
if (!the_mesh.getContactByIdx(_filteredIdx[i], contact)) continue;
|
||||
|
||||
bool selected = (i == _scrollPos);
|
||||
|
||||
// Highlight: fill LIGHT rect first, then draw DARK text on top
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(0, y + 5, display.width(), lineHeight);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
|
||||
// Set cursor AFTER fillRect so text draws on top of highlight
|
||||
display.setCursor(0, y);
|
||||
|
||||
// Prefix: "> " for selected, type char + space for others
|
||||
char prefix[4];
|
||||
if (selected) {
|
||||
snprintf(prefix, sizeof(prefix), ">%c", typeChar(contact.type));
|
||||
} else {
|
||||
snprintf(prefix, sizeof(prefix), " %c", typeChar(contact.type));
|
||||
}
|
||||
display.print(prefix);
|
||||
|
||||
// Contact name (truncated to fit)
|
||||
char filteredName[32];
|
||||
display.translateUTF8ToBlocks(filteredName, contact.name, sizeof(filteredName));
|
||||
|
||||
// Reserve space for hops + age on right side
|
||||
char hopStr[6];
|
||||
if (contact.out_path_len == 0xFF || contact.out_path_len == 0) {
|
||||
strcpy(hopStr, "D"); // direct
|
||||
} else {
|
||||
snprintf(hopStr, sizeof(hopStr), "%d", contact.out_path_len);
|
||||
}
|
||||
|
||||
char ageStr[6];
|
||||
formatAge(ageStr, sizeof(ageStr), now, contact.last_advert_timestamp);
|
||||
|
||||
// Build right-side string: "hops age"
|
||||
char rightStr[14];
|
||||
snprintf(rightStr, sizeof(rightStr), "%sh %s", hopStr, ageStr);
|
||||
int rightWidth = display.getTextWidth(rightStr) + 2;
|
||||
|
||||
// Name region: after prefix + small gap, before right info
|
||||
int nameX = display.getTextWidth(prefix) + 2;
|
||||
int nameMaxW = display.width() - nameX - rightWidth - 2;
|
||||
display.drawTextEllipsized(nameX, y, nameMaxW, filteredName);
|
||||
|
||||
// Right-aligned: hops + age
|
||||
display.setCursor(display.width() - rightWidth, y);
|
||||
display.print(rightStr);
|
||||
|
||||
y += lineHeight;
|
||||
rowsDrawn++;
|
||||
}
|
||||
_rowsPerPage = (rowsDrawn > 0) ? rowsDrawn : 1;
|
||||
}
|
||||
|
||||
display.setTextSize(1); // restore for footer
|
||||
|
||||
// === Footer ===
|
||||
int footerY = display.height() - 12;
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
|
||||
// Left: Q:Back
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Q:Back");
|
||||
|
||||
// Center: A/D:Filter
|
||||
const char* mid = "A/D:Filtr";
|
||||
display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY);
|
||||
display.print(mid);
|
||||
|
||||
// Right: W/S:Scroll
|
||||
const char* right = "W/S:Scrll";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
|
||||
return 5000; // e-ink: next render after 5s
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
// W - scroll up (previous contact)
|
||||
if (c == 'w' || c == 'W' || c == 0xF2) {
|
||||
if (_scrollPos > 0) {
|
||||
_scrollPos--;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// S - scroll down (next contact)
|
||||
if (c == 's' || c == 'S' || c == 0xF1) {
|
||||
if (_scrollPos < _filteredCount - 1) {
|
||||
_scrollPos++;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// A - previous filter
|
||||
if (c == 'a' || c == 'A') {
|
||||
_filter = (FilterMode)(((int)_filter + FILTER_COUNT - 1) % FILTER_COUNT);
|
||||
_scrollPos = 0;
|
||||
_cacheValid = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// D - next filter
|
||||
if (c == 'd' || c == 'D') {
|
||||
_filter = (FilterMode)(((int)_filter + 1) % FILTER_COUNT);
|
||||
_scrollPos = 0;
|
||||
_cacheValid = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Enter - select contact (future: open RepeaterAdmin for repeaters)
|
||||
if (c == 13 || c == KEY_ENTER) {
|
||||
// TODO Phase 3: if selected contact is a repeater, open RepeaterAdminScreen
|
||||
// For now, just acknowledge the selection
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -1,547 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
// Emoji sprites for e-ink display - dual size
|
||||
// Large (12x12) for compose/picker, Small (10x10) for channel view
|
||||
// MSB-first, 2 bytes per row
|
||||
// 46 total emoji: joy/thumbsup/frown first, then 43 original (telephone removed)
|
||||
|
||||
#include <stdint.h>
|
||||
#ifdef ESP32
|
||||
#include <pgmspace.h>
|
||||
#endif
|
||||
|
||||
#define EMOJI_LG_W 12
|
||||
#define EMOJI_LG_H 12
|
||||
#define EMOJI_SM_W 10
|
||||
#define EMOJI_SM_H 10
|
||||
|
||||
#define EMOJI_COUNT 46
|
||||
|
||||
// Escape codes in 0x80+ range - safe from keyboard ASCII (32-126)
|
||||
#define EMOJI_ESCAPE_START 0x80
|
||||
#define EMOJI_ESCAPE_END 0xAD // 0x80 + 45
|
||||
#define EMOJI_PAD_BYTE 0x7F // DEL, not typeable (key < 127 guard)
|
||||
|
||||
// ======== LARGE 12x12 SPRITES ========
|
||||
|
||||
// [0] joy (most common mesh emoji)
|
||||
static const uint8_t emoji_lg_joy[] PROGMEM = {
|
||||
0x1F,0x80, 0x20,0x40, 0x59,0xA0, 0x59,0xA0, 0x80,0x10, 0xA0,0x50, 0x9F,0x90, 0x40,0x20, 0x20,0x40, 0x1F,0x80, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
// [1] thumbsup
|
||||
static const uint8_t emoji_lg_thumbsup[] PROGMEM = {
|
||||
0x00,0x00, 0x70,0x00, 0x70,0x00, 0x70,0x00, 0x7F,0x80, 0xFF,0x80, 0xFF,0x80, 0x7F,0x80, 0x3F,0x80, 0x1F,0x00, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
// [2] frown
|
||||
static const uint8_t emoji_lg_frown[] PROGMEM = {
|
||||
0x1F,0x80, 0x20,0x40, 0x59,0xA0, 0x59,0xA0, 0x80,0x10, 0x9F,0x90, 0xA0,0x50, 0x40,0x20, 0x20,0x40, 0x1F,0x80, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
// [3] wireless
|
||||
static const uint8_t emoji_lg_wireless[] PROGMEM = {
|
||||
0x00,0x00, 0x3F,0xC0, 0x60,0x60, 0xC0,0x30, 0x0F,0x00, 0x19,0x80, 0x30,0xC0, 0x00,0x00, 0x06,0x00, 0x0F,0x00, 0x06,0x00, 0x00,0x00,
|
||||
};
|
||||
// [4] infinity
|
||||
static const uint8_t emoji_lg_infinity[] PROGMEM = {
|
||||
0x00,0x00, 0x00,0x00, 0x61,0x80, 0x92,0x40, 0x8C,0x40, 0x8C,0x40, 0x92,0x40, 0x61,0x80, 0x00,0x00, 0x00,0x00, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
// [5] trex
|
||||
static const uint8_t emoji_lg_trex[] PROGMEM = {
|
||||
0x03,0xE0, 0x06,0xA0, 0x07,0xE0, 0x0C,0x00, 0x5C,0x00, 0x7C,0x00, 0x3C,0x00, 0x38,0x00, 0x3C,0x00, 0x36,0x00, 0x22,0x00, 0x33,0x00,
|
||||
};
|
||||
// [6] skull
|
||||
static const uint8_t emoji_lg_skull[] PROGMEM = {
|
||||
0x1F,0x80, 0x20,0x40, 0x59,0xA0, 0x59,0xA0, 0x40,0x20, 0x49,0x20, 0x2F,0x40, 0x1F,0x80, 0x96,0x90, 0x66,0x60, 0x36,0xC0, 0x96,0x90,
|
||||
};
|
||||
// [7] cross
|
||||
static const uint8_t emoji_lg_cross[] PROGMEM = {
|
||||
0x0F,0x00, 0x0F,0x00, 0x0F,0x00, 0x3F,0xC0, 0x3F,0xC0, 0x3F,0xC0, 0x0F,0x00, 0x0F,0x00, 0x0F,0x00, 0x0F,0x00, 0x0F,0x00, 0x0F,0x00,
|
||||
};
|
||||
// [8] lightning
|
||||
static const uint8_t emoji_lg_lightning[] PROGMEM = {
|
||||
0x03,0x00, 0x07,0x00, 0x0E,0x00, 0x1C,0x00, 0x3F,0x80, 0x01,0x80, 0x03,0x00, 0x06,0x00, 0x0C,0x00, 0x18,0x00, 0x30,0x00, 0x00,0x00,
|
||||
};
|
||||
// [9] tophat
|
||||
static const uint8_t emoji_lg_tophat[] PROGMEM = {
|
||||
0x00,0x00, 0x1F,0x80, 0x3F,0xC0, 0x3F,0xC0, 0x3F,0xC0, 0x3F,0xC0, 0x3F,0xC0, 0x20,0x40, 0x7F,0xE0, 0xFF,0xF0, 0xFF,0xF0, 0x00,0x00,
|
||||
};
|
||||
// [10] motorcycle
|
||||
static const uint8_t emoji_lg_motorcycle[] PROGMEM = {
|
||||
0x00,0x00, 0x00,0x00, 0x0F,0x00, 0x1F,0x80, 0x7F,0xE0, 0xDF,0xB0, 0xDF,0xB0, 0xDF,0xB0, 0xDF,0xB0, 0x60,0x60, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
// [11] seedling
|
||||
static const uint8_t emoji_lg_seedling[] PROGMEM = {
|
||||
0x00,0x00, 0x30,0x00, 0x79,0x80, 0x7B,0xC0, 0x33,0xC0, 0x1F,0x80, 0x06,0x00, 0x06,0x00, 0x06,0x00, 0x06,0x00, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
// [12] flag_au
|
||||
static const uint8_t emoji_lg_flag_au[] PROGMEM = {
|
||||
0x00,0x00, 0x32,0x40, 0x4A,0x40, 0x4A,0x40, 0x7A,0x40, 0x4A,0x40, 0x49,0x80, 0x00,0x00, 0xFF,0xF0, 0x00,0x00, 0xFF,0xF0, 0x00,0x00,
|
||||
};
|
||||
// [13] umbrella
|
||||
static const uint8_t emoji_lg_umbrella[] PROGMEM = {
|
||||
0x06,0x00, 0x1F,0x80, 0x3F,0xC0, 0x7F,0xE0, 0xFF,0xF0, 0xDB,0x70, 0x06,0x00, 0x06,0x00, 0x06,0x00, 0x06,0x00, 0x46,0x00, 0x3C,0x00,
|
||||
};
|
||||
// [14] nazar
|
||||
static const uint8_t emoji_lg_nazar[] PROGMEM = {
|
||||
0x1F,0x80, 0x20,0x40, 0x4F,0x20, 0x99,0x90, 0xB6,0xD0, 0xB6,0xD0, 0xB6,0xD0, 0x99,0x90, 0x4F,0x20, 0x20,0x40, 0x1F,0x80, 0x00,0x00,
|
||||
};
|
||||
// [15] globe
|
||||
static const uint8_t emoji_lg_globe[] PROGMEM = {
|
||||
0x1F,0x80, 0x34,0xC0, 0x66,0x60, 0x4F,0x20, 0x8E,0x10, 0x86,0x10, 0x80,0x30, 0x46,0x60, 0x43,0xE0, 0x30,0xC0, 0x1F,0x80, 0x00,0x00,
|
||||
};
|
||||
// [16] radioactive
|
||||
static const uint8_t emoji_lg_radioactive[] PROGMEM = {
|
||||
0x00,0x00, 0x22,0x40, 0x32,0xC0, 0x32,0xC0, 0x1B,0x40, 0x00,0x00, 0x0F,0x00, 0x0F,0x00, 0x00,0x00, 0x60,0x20, 0x39,0xC0, 0x0F,0x00,
|
||||
};
|
||||
// [17] cow
|
||||
static const uint8_t emoji_lg_cow[] PROGMEM = {
|
||||
0x00,0x00, 0xC0,0x60, 0x6E,0xC0, 0x3F,0x80, 0x2A,0x80, 0x3F,0x80, 0x3F,0x80, 0x7F,0xC0, 0x5F,0x40, 0x5F,0x40, 0x11,0x00, 0x31,0x80,
|
||||
};
|
||||
// [18] alien
|
||||
static const uint8_t emoji_lg_alien[] PROGMEM = {
|
||||
0x1F,0x80, 0x3F,0xC0, 0x7F,0xE0, 0x76,0xE0, 0xF6,0xF0, 0x96,0x90, 0x7F,0xE0, 0x36,0xC0, 0x3F,0xC0, 0x16,0x80, 0x0F,0x00, 0x06,0x00,
|
||||
};
|
||||
// [19] invader
|
||||
static const uint8_t emoji_lg_invader[] PROGMEM = {
|
||||
0x10,0x80, 0x09,0x00, 0x1F,0x80, 0x36,0xC0, 0x7F,0xE0, 0x5F,0xA0, 0x50,0xA0, 0x50,0xA0, 0x19,0x80, 0x19,0x80, 0x30,0xC0, 0x00,0x00,
|
||||
};
|
||||
// [20] dagger
|
||||
static const uint8_t emoji_lg_dagger[] PROGMEM = {
|
||||
0x01,0x80, 0x01,0x40, 0x01,0xA0, 0x01,0xC0, 0x01,0x80, 0x03,0x00, 0x06,0x00, 0x0C,0x00, 0x18,0x00, 0x30,0x00, 0x60,0x00, 0x40,0x00,
|
||||
};
|
||||
// [21] grimace
|
||||
static const uint8_t emoji_lg_grimace[] PROGMEM = {
|
||||
0x1F,0x80, 0x20,0x40, 0x59,0xA0, 0x59,0xA0, 0x40,0x20, 0x40,0x20, 0x5F,0xA0, 0x55,0x40, 0x5F,0xA0, 0x20,0x40, 0x1F,0x80, 0x00,0x00,
|
||||
};
|
||||
// [22] mountain
|
||||
static const uint8_t emoji_lg_mountain[] PROGMEM = {
|
||||
0x00,0x00, 0x00,0x00, 0x06,0x00, 0x0F,0x00, 0x19,0x80, 0x30,0xC0, 0x66,0x60, 0xCF,0x30, 0x9F,0x90, 0xFF,0xF0, 0xFF,0xF0, 0x00,0x00,
|
||||
};
|
||||
// [23] end_arrow
|
||||
static const uint8_t emoji_lg_end_arrow[] PROGMEM = {
|
||||
0x00,0x00, 0x7B,0x60, 0x43,0x60, 0x42,0xA0, 0x72,0xA0, 0x43,0x60, 0x43,0x60, 0x7B,0x60, 0x00,0x00, 0x06,0x00, 0x0F,0x00, 0x06,0x00,
|
||||
};
|
||||
// [24] hollow_circle
|
||||
static const uint8_t emoji_lg_hollow_circle[] PROGMEM = {
|
||||
0x1F,0x80, 0x20,0x40, 0x40,0x20, 0x80,0x10, 0x80,0x10, 0x80,0x10, 0x80,0x10, 0x80,0x10, 0x40,0x20, 0x20,0x40, 0x1F,0x80, 0x00,0x00,
|
||||
};
|
||||
// [25] dragon
|
||||
static const uint8_t emoji_lg_dragon[] PROGMEM = {
|
||||
0x60,0x00, 0xF0,0x00, 0x76,0x00, 0x3F,0x00, 0x1F,0x00, 0x0F,0x00, 0x1F,0x80, 0x3F,0xC0, 0x79,0xE0, 0x30,0xC0, 0x20,0x40, 0x30,0xC0,
|
||||
};
|
||||
// [26] globe_meridians
|
||||
static const uint8_t emoji_lg_globe_meridians[] PROGMEM = {
|
||||
0x1F,0x80, 0x26,0x40, 0x46,0x20, 0x86,0x10, 0xFF,0xF0, 0x86,0x10, 0x86,0x10, 0x46,0x20, 0x26,0x40, 0x1F,0x80, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
// [27] eggplant
|
||||
static const uint8_t emoji_lg_eggplant[] PROGMEM = {
|
||||
0x01,0x80, 0x03,0x00, 0x07,0x00, 0x0F,0x00, 0x1F,0x00, 0x3F,0x00, 0x3F,0x00, 0x7E,0x00, 0x7C,0x00, 0x78,0x00, 0x30,0x00, 0x00,0x00,
|
||||
};
|
||||
// [28] shield
|
||||
static const uint8_t emoji_lg_shield[] PROGMEM = {
|
||||
0x00,0x00, 0x7F,0xE0, 0x7F,0xE0, 0x6F,0x60, 0x6F,0x60, 0x6F,0x60, 0x36,0xC0, 0x3F,0xC0, 0x1F,0x80, 0x0F,0x00, 0x06,0x00, 0x00,0x00,
|
||||
};
|
||||
// [29] goggles
|
||||
static const uint8_t emoji_lg_goggles[] PROGMEM = {
|
||||
0x00,0x00, 0x00,0x00, 0x00,0x00, 0x79,0xE0, 0xCF,0x30, 0x86,0x10, 0x86,0x10, 0xCF,0x30, 0x79,0xE0, 0x00,0x00, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
// [30] lizard
|
||||
static const uint8_t emoji_lg_lizard[] PROGMEM = {
|
||||
0x00,0x00, 0x03,0x80, 0x07,0xC0, 0x8F,0x00, 0x7F,0x00, 0x3E,0x00, 0x3F,0x80, 0x23,0xC0, 0x41,0xC0, 0x00,0xC0, 0x00,0x60, 0x00,0x20,
|
||||
};
|
||||
// [31] zany_face
|
||||
static const uint8_t emoji_lg_zany_face[] PROGMEM = {
|
||||
0x1F,0x80, 0x20,0x40, 0x59,0x20, 0x58,0xA0, 0x40,0x20, 0x40,0x20, 0x4F,0x20, 0x50,0xA0, 0x20,0x40, 0x1F,0x80, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
// [32] kangaroo
|
||||
static const uint8_t emoji_lg_kangaroo[] PROGMEM = {
|
||||
0x0E,0x00, 0x1F,0x00, 0x1F,0x00, 0x0E,0x00, 0x0F,0x00, 0x07,0x80, 0x47,0x80, 0x65,0x80, 0x3C,0x80, 0x18,0x80, 0x10,0xC0, 0x18,0xF0,
|
||||
};
|
||||
// [33] feather
|
||||
static const uint8_t emoji_lg_feather[] PROGMEM = {
|
||||
0x00,0x20, 0x00,0x60, 0x00,0xC0, 0x01,0x80, 0x03,0x00, 0x06,0x00, 0x0C,0x00, 0x18,0x00, 0x30,0x00, 0x60,0x00, 0x70,0x00, 0x00,0x00,
|
||||
};
|
||||
// [34] bright
|
||||
static const uint8_t emoji_lg_bright[] PROGMEM = {
|
||||
0x06,0x00, 0x26,0x40, 0x16,0x80, 0x0F,0x00, 0x6F,0x60, 0x6F,0x60, 0x0F,0x00, 0x16,0x80, 0x26,0x40, 0x06,0x00, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
// [35] part_alt
|
||||
static const uint8_t emoji_lg_part_alt[] PROGMEM = {
|
||||
0xC0,0xC0, 0xE1,0xC0, 0xF3,0xC0, 0xDE,0xC0, 0xCC,0xC0, 0xCC,0xC0, 0xC0,0xC0, 0xC0,0xC0, 0xC0,0xC0, 0xC0,0xC0, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
// [36] motorboat
|
||||
static const uint8_t emoji_lg_motorboat[] PROGMEM = {
|
||||
0x00,0x00, 0x00,0x00, 0x02,0x00, 0x07,0x00, 0x0F,0x80, 0x1F,0xC0, 0xFF,0xF0, 0x7F,0xE0, 0x3F,0xC0, 0x1F,0x80, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
// [37] domino
|
||||
static const uint8_t emoji_lg_domino[] PROGMEM = {
|
||||
0xFF,0xF0, 0x99,0x90, 0x80,0x10, 0x99,0x90, 0x80,0x10, 0x99,0x90, 0xFF,0xF0, 0x80,0x10, 0x80,0x10, 0x86,0x10, 0x80,0x10, 0xFF,0xF0,
|
||||
};
|
||||
// [38] satellite
|
||||
static const uint8_t emoji_lg_satellite[] PROGMEM = {
|
||||
0x78,0x00, 0xCC,0x00, 0x84,0x00, 0xCD,0x00, 0x7B,0x00, 0x03,0x80, 0x01,0xC0, 0x00,0xE0, 0x00,0x60, 0x00,0x20, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
// [39] customs
|
||||
static const uint8_t emoji_lg_customs[] PROGMEM = {
|
||||
0x1F,0x80, 0x20,0x40, 0x40,0x20, 0x4F,0x20, 0x50,0xA0, 0x50,0xA0, 0x4F,0x20, 0x42,0x20, 0x22,0x40, 0x1F,0x80, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
// [40] cowboy
|
||||
static const uint8_t emoji_lg_cowboy[] PROGMEM = {
|
||||
0x0F,0x00, 0x0F,0x00, 0x7F,0xE0, 0xFF,0xF0, 0x00,0x00, 0x3F,0xC0, 0x59,0xA0, 0x40,0x20, 0x4F,0x20, 0x20,0x40, 0x1F,0x80, 0x00,0x00,
|
||||
};
|
||||
// [41] wheel
|
||||
static const uint8_t emoji_lg_wheel[] PROGMEM = {
|
||||
0x1F,0x80, 0x26,0x40, 0x46,0x20, 0x9F,0x90, 0xB6,0xD0, 0xFF,0xF0, 0xB6,0xD0, 0x9F,0x90, 0x46,0x20, 0x26,0x40, 0x1F,0x80, 0x00,0x00,
|
||||
};
|
||||
// [42] koala
|
||||
static const uint8_t emoji_lg_koala[] PROGMEM = {
|
||||
0x60,0x60, 0xF0,0xF0, 0xF0,0xF0, 0x76,0xE0, 0x26,0x40, 0x2F,0x40, 0x26,0x40, 0x30,0xC0, 0x1F,0x80, 0x00,0x00, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
// [43] control_knobs
|
||||
static const uint8_t emoji_lg_control_knobs[] PROGMEM = {
|
||||
0x00,0x00, 0x33,0x30, 0x33,0x30, 0x33,0x30, 0x33,0x30, 0x33,0x30, 0x33,0x30, 0x7B,0x30, 0x37,0xB0, 0x33,0x70, 0x33,0x30, 0x00,0x00,
|
||||
};
|
||||
// [44] peach
|
||||
static const uint8_t emoji_lg_peach[] PROGMEM = {
|
||||
0x06,0x00, 0x0C,0x00, 0x1E,0x00, 0x3F,0x00, 0x7F,0x80, 0x7B,0xC0, 0x7B,0xC0, 0x7B,0xC0, 0x3F,0xC0, 0x1F,0x80, 0x0F,0x00, 0x00,0x00,
|
||||
};
|
||||
// [45] racing_car
|
||||
static const uint8_t emoji_lg_racing_car[] PROGMEM = {
|
||||
0x00,0x00, 0x00,0x00, 0x00,0x00, 0x07,0x80, 0x0F,0xC0, 0x7F,0xE0, 0xFF,0xF0, 0xFF,0xF0, 0x6F,0x60, 0x49,0x20, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
|
||||
static const uint8_t* const EMOJI_SPRITES_LG[] PROGMEM = {
|
||||
emoji_lg_joy, emoji_lg_thumbsup, emoji_lg_frown,
|
||||
emoji_lg_wireless, emoji_lg_infinity, emoji_lg_trex, emoji_lg_skull, emoji_lg_cross,
|
||||
emoji_lg_lightning, emoji_lg_tophat, emoji_lg_motorcycle, emoji_lg_seedling, emoji_lg_flag_au,
|
||||
emoji_lg_umbrella, emoji_lg_nazar, emoji_lg_globe, emoji_lg_radioactive, emoji_lg_cow,
|
||||
emoji_lg_alien, emoji_lg_invader, emoji_lg_dagger, emoji_lg_grimace,
|
||||
emoji_lg_mountain, emoji_lg_end_arrow, emoji_lg_hollow_circle, emoji_lg_dragon, emoji_lg_globe_meridians,
|
||||
emoji_lg_eggplant, emoji_lg_shield, emoji_lg_goggles, emoji_lg_lizard, emoji_lg_zany_face,
|
||||
emoji_lg_kangaroo, emoji_lg_feather, emoji_lg_bright, emoji_lg_part_alt, emoji_lg_motorboat,
|
||||
emoji_lg_domino, emoji_lg_satellite, emoji_lg_customs, emoji_lg_cowboy, emoji_lg_wheel,
|
||||
emoji_lg_koala, emoji_lg_control_knobs, emoji_lg_peach, emoji_lg_racing_car,
|
||||
};
|
||||
|
||||
// ======== SMALL 10x10 SPRITES ========
|
||||
|
||||
static const uint8_t emoji_sm_joy[] PROGMEM = {
|
||||
0x3F,0x00, 0x61,0x80, 0xF3,0xC0, 0x80,0x40, 0xA1,0x40, 0x9E,0x40, 0x40,0x80, 0x3F,0x00, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_thumbsup[] PROGMEM = {
|
||||
0x70,0x00, 0x70,0x00, 0x70,0x00, 0x7F,0x00, 0xFF,0x00, 0xFF,0x00, 0x7F,0x00, 0x3E,0x00, 0x1C,0x00, 0x00,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_frown[] PROGMEM = {
|
||||
0x3F,0x00, 0x61,0x80, 0xF3,0xC0, 0x80,0x40, 0x9E,0x40, 0xA1,0x40, 0x40,0x80, 0x3F,0x00, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_wireless[] PROGMEM = {
|
||||
0x00,0x00, 0x7F,0x80, 0xC0,0xC0, 0x1E,0x00, 0x33,0x00, 0x21,0x00, 0x00,0x00, 0x0C,0x00, 0x0C,0x00, 0x00,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_infinity[] PROGMEM = {
|
||||
0x00,0x00, 0x00,0x00, 0xE7,0x00, 0x99,0x00, 0x99,0x00, 0xA5,0x00, 0x42,0x00, 0x00,0x00, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_trex[] PROGMEM = {
|
||||
0x07,0x80, 0x0F,0x80, 0x0F,0x80, 0x58,0x00, 0x78,0x00, 0x38,0x00, 0x38,0x00, 0x3C,0x00, 0x24,0x00, 0x26,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_skull[] PROGMEM = {
|
||||
0x3F,0x00, 0x61,0x80, 0x73,0x80, 0x40,0x80, 0x52,0x80, 0x3F,0x00, 0x3F,0x00, 0xED,0xC0, 0x6D,0x80, 0xAD,0x40,
|
||||
};
|
||||
static const uint8_t emoji_sm_cross[] PROGMEM = {
|
||||
0x1E,0x00, 0x1E,0x00, 0x3F,0x00, 0x3F,0x00, 0x3F,0x00, 0x1E,0x00, 0x1E,0x00, 0x1E,0x00, 0x1E,0x00, 0x1E,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_lightning[] PROGMEM = {
|
||||
0x06,0x00, 0x0E,0x00, 0x1C,0x00, 0x3E,0x00, 0x03,0x00, 0x06,0x00, 0x0C,0x00, 0x18,0x00, 0x30,0x00, 0x00,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_tophat[] PROGMEM = {
|
||||
0x00,0x00, 0x3F,0x00, 0x3F,0x00, 0x3F,0x00, 0x3F,0x00, 0x21,0x00, 0x7F,0x80, 0xFF,0xC0, 0xFF,0xC0, 0x00,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_motorcycle[] PROGMEM = {
|
||||
0x00,0x00, 0x1E,0x00, 0x7F,0x80, 0xDE,0xC0, 0xDE,0xC0, 0xDE,0xC0, 0xDE,0xC0, 0x61,0x80, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_seedling[] PROGMEM = {
|
||||
0x00,0x00, 0x70,0x00, 0x77,0x00, 0x77,0x00, 0x3F,0x00, 0x0C,0x00, 0x0C,0x00, 0x0C,0x00, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_flag_au[] PROGMEM = {
|
||||
0x00,0x00, 0x75,0x00, 0x55,0x00, 0x75,0x00, 0x55,0x00, 0x53,0x00, 0x00,0x00, 0xFF,0xC0, 0xFF,0xC0, 0x00,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_umbrella[] PROGMEM = {
|
||||
0x0C,0x00, 0x3F,0x00, 0x7F,0x80, 0xFF,0xC0, 0xF7,0xC0, 0x0C,0x00, 0x0C,0x00, 0x0C,0x00, 0x4C,0x00, 0x78,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_nazar[] PROGMEM = {
|
||||
0x3F,0x00, 0x40,0x80, 0x9E,0x40, 0xBF,0x40, 0xAD,0x40, 0xBF,0x40, 0x9E,0x40, 0x4C,0x80, 0x3F,0x00, 0x00,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_globe[] PROGMEM = {
|
||||
0x3F,0x00, 0x69,0x80, 0x4C,0x80, 0x9C,0x40, 0x8C,0x40, 0x80,0xC0, 0x4D,0x80, 0x67,0x80, 0x3F,0x00, 0x00,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_radioactive[] PROGMEM = {
|
||||
0x00,0x00, 0x25,0x00, 0x25,0x00, 0x37,0x00, 0x00,0x00, 0x1E,0x00, 0x1E,0x00, 0x40,0x00, 0x73,0x80, 0x1E,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_cow[] PROGMEM = {
|
||||
0x00,0x00, 0xC1,0x80, 0x7F,0x00, 0x3F,0x00, 0x3F,0x00, 0x7F,0x00, 0x7F,0x00, 0x7F,0x00, 0x36,0x00, 0x23,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_alien[] PROGMEM = {
|
||||
0x3F,0x00, 0x7F,0x80, 0x7F,0x80, 0xED,0xC0, 0xAD,0x40, 0x7F,0x80, 0x3F,0x00, 0x3F,0x00, 0x1E,0x00, 0x0C,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_invader[] PROGMEM = {
|
||||
0x33,0x00, 0x1E,0x00, 0x3F,0x00, 0x7F,0x80, 0x7F,0x80, 0x61,0x80, 0x73,0x80, 0x33,0x00, 0x33,0x00, 0x00,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_dagger[] PROGMEM = {
|
||||
0x03,0x00, 0x03,0x80, 0x03,0x80, 0x03,0x00, 0x06,0x00, 0x0C,0x00, 0x18,0x00, 0x30,0x00, 0x60,0x00, 0x40,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_grimace[] PROGMEM = {
|
||||
0x3F,0x00, 0x61,0x80, 0x73,0x80, 0x40,0x80, 0x40,0x80, 0x7F,0x80, 0x55,0x00, 0x7F,0x80, 0x3F,0x00, 0x00,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_mountain[] PROGMEM = {
|
||||
0x00,0x00, 0x00,0x00, 0x0C,0x00, 0x1E,0x00, 0x33,0x00, 0x6D,0x80, 0xDE,0xC0, 0xFF,0xC0, 0xFF,0xC0, 0x00,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_end_arrow[] PROGMEM = {
|
||||
0x00,0x00, 0x77,0x80, 0x47,0x80, 0x65,0x80, 0x47,0x80, 0x47,0x80, 0x76,0x80, 0x0C,0x00, 0x1E,0x00, 0x0C,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_hollow_circle[] PROGMEM = {
|
||||
0x3F,0x00, 0x40,0x80, 0x80,0x40, 0x80,0x40, 0x80,0x40, 0x80,0x40, 0x80,0x40, 0x40,0x80, 0x3F,0x00, 0x00,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_dragon[] PROGMEM = {
|
||||
0x60,0x00, 0xE0,0x00, 0x7C,0x00, 0x3E,0x00, 0x1E,0x00, 0x3F,0x00, 0x7F,0x80, 0x73,0x80, 0x21,0x00, 0x21,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_globe_meridians[] PROGMEM = {
|
||||
0x3F,0x00, 0x4C,0x80, 0x8C,0x40, 0xFF,0xC0, 0x8C,0x40, 0x8C,0x40, 0x4C,0x80, 0x3F,0x00, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_eggplant[] PROGMEM = {
|
||||
0x03,0x00, 0x06,0x00, 0x0E,0x00, 0x1E,0x00, 0x3E,0x00, 0x7E,0x00, 0x7C,0x00, 0x78,0x00, 0x70,0x00, 0x00,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_shield[] PROGMEM = {
|
||||
0x00,0x00, 0xFF,0xC0, 0xFF,0xC0, 0xDE,0xC0, 0xDE,0xC0, 0x6D,0x80, 0x7F,0x80, 0x3F,0x00, 0x1E,0x00, 0x0C,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_goggles[] PROGMEM = {
|
||||
0x00,0x00, 0x00,0x00, 0x73,0x80, 0xDE,0xC0, 0x8C,0x40, 0x8C,0x40, 0xDE,0xC0, 0x73,0x80, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_lizard[] PROGMEM = {
|
||||
0x00,0x00, 0x07,0x00, 0x9E,0x00, 0x7E,0x00, 0x3E,0x00, 0x27,0x80, 0x43,0x00, 0x01,0x80, 0x00,0x80, 0x00,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_zany_face[] PROGMEM = {
|
||||
0x3F,0x00, 0x60,0x80, 0x72,0x80, 0x40,0x80, 0x40,0x80, 0x5E,0x80, 0x61,0x80, 0x3F,0x00, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_kangaroo[] PROGMEM = {
|
||||
0x1C,0x00, 0x3E,0x00, 0x1C,0x00, 0x1E,0x00, 0x0F,0x00, 0x4F,0x00, 0x6B,0x00, 0x39,0x00, 0x31,0x00, 0x31,0xC0,
|
||||
};
|
||||
static const uint8_t emoji_sm_feather[] PROGMEM = {
|
||||
0x00,0x80, 0x01,0x80, 0x03,0x00, 0x06,0x00, 0x0C,0x00, 0x18,0x00, 0x30,0x00, 0x60,0x00, 0x60,0x00, 0x00,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_bright[] PROGMEM = {
|
||||
0x0C,0x00, 0x2D,0x00, 0x1E,0x00, 0x5E,0x80, 0x7F,0x80, 0x1E,0x00, 0x2D,0x00, 0x0C,0x00, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_part_alt[] PROGMEM = {
|
||||
0xC3,0x00, 0xE7,0x00, 0xDB,0x00, 0xDB,0x00, 0xC3,0x00, 0xC3,0x00, 0xC3,0x00, 0xC3,0x00, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_motorboat[] PROGMEM = {
|
||||
0x00,0x00, 0x00,0x00, 0x0C,0x00, 0x1E,0x00, 0x3F,0x00, 0xFF,0xC0, 0x7F,0x80, 0x3F,0x00, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_domino[] PROGMEM = {
|
||||
0xFF,0xC0, 0xB6,0x40, 0xB6,0x40, 0xB6,0x40, 0xFF,0xC0, 0x80,0x40, 0x8C,0x40, 0x80,0x40, 0xFF,0xC0, 0x00,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_satellite[] PROGMEM = {
|
||||
0x70,0x00, 0xD8,0x00, 0x88,0x00, 0xFE,0x00, 0x07,0x00, 0x03,0x80, 0x01,0x80, 0x00,0x80, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_customs[] PROGMEM = {
|
||||
0x3F,0x00, 0x40,0x80, 0x4C,0x80, 0x52,0x80, 0x61,0x80, 0x5E,0x80, 0x44,0x80, 0x3F,0x00, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_cowboy[] PROGMEM = {
|
||||
0x1E,0x00, 0x1E,0x00, 0xFF,0xC0, 0x00,0x00, 0x3F,0x00, 0x73,0x80, 0x40,0x80, 0x4C,0x80, 0x3F,0x00, 0x00,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_wheel[] PROGMEM = {
|
||||
0x3F,0x00, 0x4C,0x80, 0x9E,0x40, 0xBF,0x40, 0xFF,0xC0, 0xBF,0x40, 0x9E,0x40, 0x4C,0x80, 0x3F,0x00, 0x00,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_koala[] PROGMEM = {
|
||||
0x61,0x80, 0xE1,0xC0, 0xED,0xC0, 0x6D,0x80, 0x3F,0x00, 0x2D,0x00, 0x33,0x00, 0x1E,0x00, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_control_knobs[] PROGMEM = {
|
||||
0x00,0x00, 0x26,0xC0, 0x26,0xC0, 0x26,0xC0, 0x26,0xC0, 0x76,0xC0, 0x7E,0xC0, 0x2F,0xC0, 0x26,0xC0, 0x00,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_peach[] PROGMEM = {
|
||||
0x0C,0x00, 0x18,0x00, 0x3C,0x00, 0x7E,0x00, 0x77,0x00, 0x77,0x00, 0x7F,0x00, 0x3F,0x00, 0x1E,0x00, 0x00,0x00,
|
||||
};
|
||||
static const uint8_t emoji_sm_racing_car[] PROGMEM = {
|
||||
0x00,0x00, 0x00,0x00, 0x0E,0x00, 0x1F,0x00, 0x7F,0x80, 0xFF,0xC0, 0xFF,0xC0, 0x5E,0x80, 0x00,0x00, 0x00,0x00,
|
||||
};
|
||||
|
||||
static const uint8_t* const EMOJI_SPRITES_SM[] PROGMEM = {
|
||||
emoji_sm_joy, emoji_sm_thumbsup, emoji_sm_frown,
|
||||
emoji_sm_wireless, emoji_sm_infinity, emoji_sm_trex, emoji_sm_skull, emoji_sm_cross,
|
||||
emoji_sm_lightning, emoji_sm_tophat, emoji_sm_motorcycle, emoji_sm_seedling, emoji_sm_flag_au,
|
||||
emoji_sm_umbrella, emoji_sm_nazar, emoji_sm_globe, emoji_sm_radioactive, emoji_sm_cow,
|
||||
emoji_sm_alien, emoji_sm_invader, emoji_sm_dagger, emoji_sm_grimace,
|
||||
emoji_sm_mountain, emoji_sm_end_arrow, emoji_sm_hollow_circle, emoji_sm_dragon, emoji_sm_globe_meridians,
|
||||
emoji_sm_eggplant, emoji_sm_shield, emoji_sm_goggles, emoji_sm_lizard, emoji_sm_zany_face,
|
||||
emoji_sm_kangaroo, emoji_sm_feather, emoji_sm_bright, emoji_sm_part_alt, emoji_sm_motorboat,
|
||||
emoji_sm_domino, emoji_sm_satellite, emoji_sm_customs, emoji_sm_cowboy, emoji_sm_wheel,
|
||||
emoji_sm_koala, emoji_sm_control_knobs, emoji_sm_peach, emoji_sm_racing_car,
|
||||
};
|
||||
|
||||
// ---- Codepoint lookup for UTF-8 conversion ----
|
||||
struct EmojiCodepoint { uint32_t cp; uint32_t cp2; uint8_t escape; };
|
||||
|
||||
static const EmojiCodepoint EMOJI_CODEPOINTS[EMOJI_COUNT] = {
|
||||
{ 0x1F602, 0x0000, 0x80 }, // joy
|
||||
{ 0x1F44D, 0x0000, 0x81 }, // thumbsup
|
||||
{ 0x2639, 0x0000, 0x82 }, // frown
|
||||
{ 0x1F6DC, 0x0000, 0x83 }, // wireless
|
||||
{ 0x267E, 0x0000, 0x84 }, // infinity
|
||||
{ 0x1F996, 0x0000, 0x85 }, // trex
|
||||
{ 0x2620, 0x0000, 0x86 }, // skull
|
||||
{ 0x271D, 0x0000, 0x87 }, // cross
|
||||
{ 0x26A1, 0x0000, 0x88 }, // lightning
|
||||
{ 0x1F3A9, 0x0000, 0x89 }, // tophat
|
||||
{ 0x1F3CD, 0x0000, 0x8A }, // motorcycle
|
||||
{ 0x1F331, 0x0000, 0x8B }, // seedling
|
||||
{ 0x1F1E6, 0x1F1FA, 0x8C }, // flag_au
|
||||
{ 0x2602, 0x0000, 0x8D }, // umbrella
|
||||
{ 0x1F9FF, 0x0000, 0x8E }, // nazar
|
||||
{ 0x1F30F, 0x0000, 0x8F }, // globe
|
||||
{ 0x2622, 0x0000, 0x90 }, // radioactive
|
||||
{ 0x1F404, 0x0000, 0x91 }, // cow
|
||||
{ 0x1F47D, 0x0000, 0x92 }, // alien
|
||||
{ 0x1F47E, 0x0000, 0x93 }, // invader
|
||||
{ 0x1F5E1, 0x0000, 0x94 }, // dagger
|
||||
{ 0x1F62C, 0x0000, 0x95 }, // grimace
|
||||
{ 0x26F0, 0x0000, 0x96 }, // mountain
|
||||
{ 0x1F51A, 0x0000, 0x97 }, // end_arrow
|
||||
{ 0x2B55, 0x0000, 0x98 }, // hollow_circle
|
||||
{ 0x1F409, 0x0000, 0x99 }, // dragon
|
||||
{ 0x1F310, 0x0000, 0x9A }, // globe_meridians
|
||||
{ 0x1F346, 0x0000, 0x9B }, // eggplant
|
||||
{ 0x1F6E1, 0x0000, 0x9C }, // shield
|
||||
{ 0x1F97D, 0x0000, 0x9D }, // goggles
|
||||
{ 0x1F98E, 0x0000, 0x9E }, // lizard
|
||||
{ 0x1F92A, 0x0000, 0x9F }, // zany_face
|
||||
{ 0x1F998, 0x0000, 0xA0 }, // kangaroo
|
||||
{ 0x1FAB6, 0x0000, 0xA1 }, // feather
|
||||
{ 0x1F506, 0x0000, 0xA2 }, // bright
|
||||
{ 0x303D, 0x0000, 0xA3 }, // part_alt
|
||||
{ 0x1F6E5, 0x0000, 0xA4 }, // motorboat
|
||||
{ 0x1F0CE, 0x0000, 0xA5 }, // domino
|
||||
{ 0x1F4E1, 0x0000, 0xA6 }, // satellite
|
||||
{ 0x1F6C3, 0x0000, 0xA7 }, // customs
|
||||
{ 0x1F920, 0x0000, 0xA8 }, // cowboy
|
||||
{ 0x1F6DE, 0x0000, 0xA9 }, // wheel
|
||||
{ 0x1F428, 0x0000, 0xAA }, // koala
|
||||
{ 0x1F39B, 0x0000, 0xAB }, // control_knobs
|
||||
{ 0x1F351, 0x0000, 0xAC }, // peach
|
||||
{ 0x1F3CE, 0x0000, 0xAD }, // racing_car
|
||||
};
|
||||
|
||||
// ---- Helper functions ----
|
||||
|
||||
static uint32_t emojiDecodeUtf8(const uint8_t* s, int remaining, int* bytes_consumed) {
|
||||
uint8_t b0 = s[0];
|
||||
if (b0 < 0x80) { *bytes_consumed = 1; return b0; }
|
||||
if ((b0 & 0xE0) == 0xC0 && remaining >= 2) {
|
||||
*bytes_consumed = 2;
|
||||
return ((uint32_t)(b0 & 0x1F) << 6) | (s[1] & 0x3F);
|
||||
}
|
||||
if ((b0 & 0xF0) == 0xE0 && remaining >= 3) {
|
||||
*bytes_consumed = 3;
|
||||
return ((uint32_t)(b0 & 0x0F) << 12) | ((uint32_t)(s[1] & 0x3F) << 6) | (s[2] & 0x3F);
|
||||
}
|
||||
if ((b0 & 0xF8) == 0xF0 && remaining >= 4) {
|
||||
*bytes_consumed = 4;
|
||||
return ((uint32_t)(b0 & 0x07) << 18) | ((uint32_t)(s[1] & 0x3F) << 12) | ((uint32_t)(s[2] & 0x3F) << 6) | (s[3] & 0x3F);
|
||||
}
|
||||
*bytes_consumed = 1;
|
||||
return 0xFFFD;
|
||||
}
|
||||
|
||||
// Convert UTF-8 text to internal format (emoji codepoints -> escape bytes)
|
||||
// Now handles ALL multi-byte UTF-8 (>= 0x80) to prevent raw high bytes in buffer
|
||||
static void emojiSanitize(const char* src, char* dst, int dstLen) {
|
||||
const uint8_t* s = (const uint8_t*)src;
|
||||
int si = 0, di = 0;
|
||||
int srcLen = strlen(src);
|
||||
while (si < srcLen && di < dstLen - 1) {
|
||||
uint8_t b = s[si];
|
||||
if (b >= 0x80) {
|
||||
int consumed;
|
||||
uint32_t cp = emojiDecodeUtf8(s + si, srcLen - si, &consumed);
|
||||
if (cp == 0xFE0F) { si += consumed; continue; }
|
||||
bool found = false;
|
||||
for (int e = 0; e < EMOJI_COUNT; e++) {
|
||||
if (EMOJI_CODEPOINTS[e].cp == cp) {
|
||||
if (EMOJI_CODEPOINTS[e].cp2 != 0) {
|
||||
int consumed2;
|
||||
if (si + consumed < srcLen) {
|
||||
uint32_t cp2 = emojiDecodeUtf8(s + si + consumed, srcLen - si - consumed, &consumed2);
|
||||
if (cp2 == EMOJI_CODEPOINTS[e].cp2) {
|
||||
dst[di++] = EMOJI_CODEPOINTS[e].escape;
|
||||
si += consumed + consumed2;
|
||||
found = true; break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
dst[di++] = EMOJI_CODEPOINTS[e].escape;
|
||||
si += consumed;
|
||||
// Skip trailing variation selector U+FE0F
|
||||
if (si + 2 < srcLen && s[si] == 0xEF && s[si+1] == 0xB8 && s[si+2] == 0x8F) si += 3;
|
||||
found = true; break;
|
||||
}
|
||||
}
|
||||
if (!found) si += consumed; // Skip unknown multi-byte chars
|
||||
} else {
|
||||
dst[di++] = (char)b;
|
||||
si++;
|
||||
}
|
||||
}
|
||||
dst[di] = '\0';
|
||||
}
|
||||
|
||||
static inline bool isEmojiEscape(uint8_t b) {
|
||||
return b >= EMOJI_ESCAPE_START && b <= EMOJI_ESCAPE_END;
|
||||
}
|
||||
|
||||
static int emojiEncodeUtf8(uint32_t cp, uint8_t* dst) {
|
||||
if (cp < 0x80) { dst[0] = (uint8_t)cp; return 1; }
|
||||
if (cp < 0x800) { dst[0] = 0xC0|(cp>>6); dst[1] = 0x80|(cp&0x3F); return 2; }
|
||||
if (cp < 0x10000) { dst[0] = 0xE0|(cp>>12); dst[1] = 0x80|((cp>>6)&0x3F); dst[2] = 0x80|(cp&0x3F); return 3; }
|
||||
dst[0] = 0xF0|(cp>>18); dst[1] = 0x80|((cp>>12)&0x3F); dst[2] = 0x80|((cp>>6)&0x3F); dst[3] = 0x80|(cp&0x3F); return 4;
|
||||
}
|
||||
|
||||
static void emojiUnescape(const char* src, char* dst, int dstLen) {
|
||||
int si = 0, di = 0;
|
||||
int srcLen = strlen(src);
|
||||
while (si < srcLen && di < dstLen - 1) {
|
||||
uint8_t b = (uint8_t)src[si];
|
||||
if (b == EMOJI_PAD_BYTE) { si++; continue; }
|
||||
if (isEmojiEscape(b)) {
|
||||
int idx = b - EMOJI_ESCAPE_START;
|
||||
if (idx < EMOJI_COUNT) {
|
||||
uint8_t utf8[8];
|
||||
int len = emojiEncodeUtf8(EMOJI_CODEPOINTS[idx].cp, utf8);
|
||||
if (EMOJI_CODEPOINTS[idx].cp2 != 0)
|
||||
len += emojiEncodeUtf8(EMOJI_CODEPOINTS[idx].cp2, utf8 + len);
|
||||
if (di + len < dstLen) { memcpy(dst + di, utf8, len); di += len; } else break;
|
||||
}
|
||||
si++;
|
||||
} else { dst[di++] = src[si++]; }
|
||||
}
|
||||
dst[di] = '\0';
|
||||
}
|
||||
|
||||
static inline const uint8_t* getEmojiSpriteLg(uint8_t escape_byte) {
|
||||
if (!isEmojiEscape(escape_byte)) return nullptr;
|
||||
return (const uint8_t*)pgm_read_ptr(&EMOJI_SPRITES_LG[escape_byte - EMOJI_ESCAPE_START]);
|
||||
}
|
||||
|
||||
static inline const uint8_t* getEmojiSpriteSm(uint8_t escape_byte) {
|
||||
if (!isEmojiEscape(escape_byte)) return nullptr;
|
||||
return (const uint8_t*)pgm_read_ptr(&EMOJI_SPRITES_SM[escape_byte - EMOJI_ESCAPE_START]);
|
||||
}
|
||||
|
||||
static inline int emojiUtf8Cost(uint8_t escape_byte) {
|
||||
if (!isEmojiEscape(escape_byte)) return 1;
|
||||
int idx = escape_byte - EMOJI_ESCAPE_START;
|
||||
uint32_t cp = EMOJI_CODEPOINTS[idx].cp;
|
||||
int cost = (cp < 0x80) ? 1 : (cp < 0x800) ? 2 : (cp < 0x10000) ? 3 : 4;
|
||||
if (EMOJI_CODEPOINTS[idx].cp2 != 0) {
|
||||
uint32_t cp2 = EMOJI_CODEPOINTS[idx].cp2;
|
||||
cost += (cp2 < 0x80) ? 1 : (cp2 < 0x800) ? 2 : (cp2 < 0x10000) ? 3 : 4;
|
||||
}
|
||||
return cost;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,8 +31,6 @@
|
||||
|
||||
#include "icons.h"
|
||||
#include "ChannelScreen.h"
|
||||
#include "ContactsScreen.h"
|
||||
#include "TextReaderScreen.h"
|
||||
|
||||
class SplashScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
@@ -606,8 +604,6 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
|
||||
home = new HomeScreen(this, &rtc_clock, sensors, node_prefs);
|
||||
msg_preview = new MsgPreviewScreen(this, &rtc_clock);
|
||||
channel_screen = new ChannelScreen(this, &rtc_clock);
|
||||
contacts_screen = new ContactsScreen(this, &rtc_clock);
|
||||
text_reader = new TextReaderScreen(this);
|
||||
setCurrScreen(splash);
|
||||
}
|
||||
|
||||
@@ -990,15 +986,6 @@ void UITask::injectKey(char c) {
|
||||
}
|
||||
}
|
||||
|
||||
void UITask::gotoHomeScreen() {
|
||||
setCurrScreen(home);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
void UITask::gotoChannelScreen() {
|
||||
((ChannelScreen *) channel_screen)->resetScroll();
|
||||
setCurrScreen(channel_screen);
|
||||
@@ -1009,29 +996,6 @@ void UITask::gotoChannelScreen() {
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
void UITask::gotoContactsScreen() {
|
||||
((ContactsScreen *) contacts_screen)->resetScroll();
|
||||
setCurrScreen(contacts_screen);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
void UITask::gotoTextReader() {
|
||||
TextReaderScreen* reader = (TextReaderScreen*)text_reader;
|
||||
if (_display != NULL) {
|
||||
reader->enter(*_display);
|
||||
}
|
||||
setCurrScreen(text_reader);
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -52,8 +52,6 @@ class UITask : public AbstractUITask {
|
||||
UIScreen* home;
|
||||
UIScreen* msg_preview;
|
||||
UIScreen* channel_screen; // Channel message history screen
|
||||
UIScreen* contacts_screen; // Contacts list screen
|
||||
UIScreen* text_reader; // *** NEW: Text reader screen ***
|
||||
UIScreen* curr;
|
||||
|
||||
void userLedHandler();
|
||||
@@ -75,18 +73,14 @@ public:
|
||||
}
|
||||
void begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs);
|
||||
|
||||
void gotoHomeScreen();
|
||||
void gotoHomeScreen() { setCurrScreen(home); }
|
||||
void gotoChannelScreen(); // Navigate to channel message screen
|
||||
void gotoContactsScreen(); // Navigate to contacts list
|
||||
void gotoTextReader(); // *** NEW: Navigate to text reader ***
|
||||
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; }
|
||||
bool isOnContactsScreen() const { return curr == contacts_screen; }
|
||||
bool isOnTextReader() const { return curr == text_reader; } // *** NEW ***
|
||||
uint8_t getChannelScreenViewIdx() const;
|
||||
|
||||
void toggleBuzzer();
|
||||
@@ -97,13 +91,11 @@ public:
|
||||
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) override;
|
||||
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; }
|
||||
UIScreen* getTextReaderScreen() const { return text_reader; } // *** NEW ***
|
||||
UIScreen* getContactsScreen() const { return contacts_screen; }
|
||||
|
||||
// from AbstractUITask
|
||||
void msgRead(int msgcount) override;
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
// Emoji Picker with scrolling grid and scroll bar
|
||||
// 5 columns, 4 visible rows, scrollable through all 46 emoji
|
||||
// WASD navigation, Enter to select, $/Q/Backspace to cancel
|
||||
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include "EmojiSprites.h"
|
||||
|
||||
#define EMOJI_PICKER_COLS 5
|
||||
#define EMOJI_PICKER_VISIBLE_ROWS 4
|
||||
#define EMOJI_PICKER_TOTAL_ROWS ((EMOJI_COUNT + EMOJI_PICKER_COLS - 1) / EMOJI_PICKER_COLS)
|
||||
|
||||
static const char* EMOJI_LABELS[EMOJI_COUNT] = {
|
||||
"Lol", // 0 joy
|
||||
"Like", // 1 thumbsup
|
||||
"Sad", // 2 frown
|
||||
"WiFi", // 3 wireless
|
||||
"Inf", // 4 infinity
|
||||
"Rex", // 5 trex
|
||||
"Skul", // 6 skull
|
||||
"Cros", // 7 cross
|
||||
"Bolt", // 8 lightning
|
||||
"Hat", // 9 tophat
|
||||
"Moto", // 10 motorcycle
|
||||
"Leaf", // 11 seedling
|
||||
"AU", // 12 flag_au
|
||||
"Umbr", // 13 umbrella
|
||||
"Eye", // 14 nazar
|
||||
"Glob", // 15 globe
|
||||
"Rad", // 16 radioactive
|
||||
"Cow", // 17 cow
|
||||
"ET", // 18 alien
|
||||
"Inv", // 19 invader
|
||||
"Dagr", // 20 dagger
|
||||
"Grim", // 21 grimace
|
||||
"Mtn", // 22 mountain
|
||||
"End", // 23 end_arrow
|
||||
"Ring", // 24 hollow_circle
|
||||
"Drag", // 25 dragon
|
||||
"Web", // 26 globe_meridians
|
||||
"Eggp", // 27 eggplant
|
||||
"Shld", // 28 shield
|
||||
"Gogl", // 29 goggles
|
||||
"Lzrd", // 30 lizard
|
||||
"Zany", // 31 zany_face
|
||||
"Roo", // 32 kangaroo
|
||||
"Fthr", // 33 feather
|
||||
"Sun", // 34 bright
|
||||
"Wave", // 35 part_alt
|
||||
"Boat", // 36 motorboat
|
||||
"Domi", // 37 domino
|
||||
"Dish", // 38 satellite
|
||||
"Pass", // 39 customs
|
||||
"Cowb", // 40 cowboy
|
||||
"Whl", // 41 wheel
|
||||
"Koal", // 42 koala
|
||||
"Knob", // 43 control_knobs
|
||||
"Pch", // 44 peach
|
||||
"Race", // 45 racing_car
|
||||
};
|
||||
|
||||
struct EmojiPicker {
|
||||
int cursor;
|
||||
int scrollRow;
|
||||
|
||||
EmojiPicker() : cursor(0), scrollRow(0) {}
|
||||
|
||||
void reset() { cursor = 0; scrollRow = 0; }
|
||||
|
||||
void ensureVisible() {
|
||||
int cursorRow = cursor / EMOJI_PICKER_COLS;
|
||||
if (cursorRow < scrollRow) scrollRow = cursorRow;
|
||||
else if (cursorRow >= scrollRow + EMOJI_PICKER_VISIBLE_ROWS)
|
||||
scrollRow = cursorRow - EMOJI_PICKER_VISIBLE_ROWS + 1;
|
||||
int maxScroll = EMOJI_PICKER_TOTAL_ROWS - EMOJI_PICKER_VISIBLE_ROWS;
|
||||
if (maxScroll < 0) maxScroll = 0;
|
||||
if (scrollRow > maxScroll) scrollRow = maxScroll;
|
||||
if (scrollRow < 0) scrollRow = 0;
|
||||
}
|
||||
|
||||
// Returns emoji escape byte, 0xFF for cancel, 0 for no action
|
||||
uint8_t handleInput(char key) {
|
||||
int row = cursor / EMOJI_PICKER_COLS;
|
||||
int col = cursor % EMOJI_PICKER_COLS;
|
||||
|
||||
switch (key) {
|
||||
case 'w': case 'W': case 0xF2:
|
||||
if (row > 0) cursor -= EMOJI_PICKER_COLS;
|
||||
break;
|
||||
case 's': case 'S': case 0xF1:
|
||||
if (cursor + EMOJI_PICKER_COLS < EMOJI_COUNT)
|
||||
cursor += EMOJI_PICKER_COLS;
|
||||
else if (row < EMOJI_PICKER_TOTAL_ROWS - 1)
|
||||
cursor = EMOJI_COUNT - 1;
|
||||
break;
|
||||
case 'a': case 'A':
|
||||
if (cursor > 0) cursor--;
|
||||
break;
|
||||
case 'd': case 'D':
|
||||
if (cursor + 1 < EMOJI_COUNT) cursor++;
|
||||
break;
|
||||
case '\r':
|
||||
ensureVisible();
|
||||
return (uint8_t)(EMOJI_ESCAPE_START + cursor);
|
||||
case '\b': case 'q': case 'Q': case KB_KEY_EMOJI:
|
||||
return 0xFF;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
ensureVisible();
|
||||
return 0;
|
||||
}
|
||||
|
||||
void draw(DisplayDriver& display) {
|
||||
display.setTextSize(1);
|
||||
display.setCursor(0, 0);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.print("Select Emoji");
|
||||
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
display.setTextSize(0);
|
||||
|
||||
int startY = 14;
|
||||
int scrollBarW = 4;
|
||||
int gridW = display.width() - scrollBarW - 1;
|
||||
int cellW = gridW / EMOJI_PICKER_COLS;
|
||||
int footerHeight = 14;
|
||||
int gridH = display.height() - startY - footerHeight;
|
||||
int cellH = gridH / EMOJI_PICKER_VISIBLE_ROWS;
|
||||
|
||||
for (int vr = 0; vr < EMOJI_PICKER_VISIBLE_ROWS; vr++) {
|
||||
int absRow = scrollRow + vr;
|
||||
if (absRow >= EMOJI_PICKER_TOTAL_ROWS) break;
|
||||
|
||||
for (int col = 0; col < EMOJI_PICKER_COLS; col++) {
|
||||
int idx = absRow * EMOJI_PICKER_COLS + col;
|
||||
if (idx >= EMOJI_COUNT) break;
|
||||
|
||||
int cx = col * cellW;
|
||||
int cy = startY + vr * cellH;
|
||||
|
||||
if (idx == cursor) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(cx, cy, cellW, cellH);
|
||||
display.drawRect(cx + 1, cy + 1, cellW - 2, cellH - 2);
|
||||
}
|
||||
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
const uint8_t* sprite = (const uint8_t*)pgm_read_ptr(&EMOJI_SPRITES_LG[idx]);
|
||||
if (sprite) {
|
||||
int spriteX = cx + (cellW - EMOJI_LG_W) / 2;
|
||||
int spriteY = cy + 1;
|
||||
display.drawXbm(spriteX, spriteY, sprite, EMOJI_LG_W, EMOJI_LG_H);
|
||||
}
|
||||
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
uint16_t labelW = display.getTextWidth(EMOJI_LABELS[idx]);
|
||||
int labelX = cx + (cellW - (int)labelW) / 2;
|
||||
if (labelX < cx) labelX = cx;
|
||||
display.setCursor(labelX, cy + 14);
|
||||
display.print(EMOJI_LABELS[idx]);
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll bar
|
||||
int sbX = display.width() - scrollBarW;
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(sbX, startY, scrollBarW, gridH);
|
||||
|
||||
if (EMOJI_PICKER_TOTAL_ROWS > EMOJI_PICKER_VISIBLE_ROWS) {
|
||||
int thumbH = (EMOJI_PICKER_VISIBLE_ROWS * gridH) / EMOJI_PICKER_TOTAL_ROWS;
|
||||
if (thumbH < 4) thumbH = 4;
|
||||
int maxScroll = EMOJI_PICKER_TOTAL_ROWS - EMOJI_PICKER_VISIBLE_ROWS;
|
||||
int thumbY = startY + (scrollRow * (gridH - thumbH)) / maxScroll;
|
||||
for (int y = thumbY + 1; y < thumbY + thumbH - 1; y++)
|
||||
display.drawRect(sbX + 1, y, scrollBarW - 2, 1);
|
||||
} else {
|
||||
for (int y = startY + 1; y < startY + gridH - 1; y++)
|
||||
display.drawRect(sbX + 1, y, scrollBarW - 2, 1);
|
||||
}
|
||||
|
||||
// Footer
|
||||
display.setTextSize(1);
|
||||
int footerY = display.height() - 12;
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setCursor(0, footerY);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.print("WASD:Nav Ent:Pick");
|
||||
const char* ct = "$:Back";
|
||||
display.setCursor(display.width() - display.getTextWidth(ct) - 2, footerY);
|
||||
display.print(ct);
|
||||
}
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,538 +0,0 @@
|
||||
#pragma once
|
||||
// =============================================================================
|
||||
// EpubZipReader.h - Minimal ZIP reader for EPUB files on ESP32-S3
|
||||
//
|
||||
// Parses ZIP archives directly from SD card File objects.
|
||||
// Uses the ESP32 ROM's built-in tinfl decompressor for DEFLATE.
|
||||
// No external library dependencies.
|
||||
//
|
||||
// Supports:
|
||||
// - STORED (method 0) entries - direct copy
|
||||
// - DEFLATED (method 8) entries - ROM tinfl decompression
|
||||
// - ZIP64 is NOT supported (EPUBs don't need it)
|
||||
//
|
||||
// Memory: Allocates decompression buffers from PSRAM when available.
|
||||
// Typical EPUB chapter is 5-50KB, well within ESP32-S3's 8MB PSRAM.
|
||||
// =============================================================================
|
||||
|
||||
#include <SD.h>
|
||||
#include <FS.h>
|
||||
|
||||
// ROM tinfl decompressor - built into ESP32/ESP32-S3 ROM
|
||||
// If this include fails on your platform, see the fallback note at bottom
|
||||
#if __has_include(<rom/miniz.h>)
|
||||
#include <rom/miniz.h>
|
||||
#define HAS_ROM_TINFL 1
|
||||
#elif __has_include(<esp32s3/rom/miniz.h>)
|
||||
#include <esp32s3/rom/miniz.h>
|
||||
#define HAS_ROM_TINFL 1
|
||||
#elif __has_include(<esp32/rom/miniz.h>)
|
||||
#include <esp32/rom/miniz.h>
|
||||
#define HAS_ROM_TINFL 1
|
||||
#else
|
||||
#warning "ROM miniz not found - DEFLATED entries will not be supported"
|
||||
#define HAS_ROM_TINFL 0
|
||||
#endif
|
||||
|
||||
// ---- ZIP format constants ----
|
||||
#define ZIP_LOCAL_FILE_HEADER_SIG 0x04034b50
|
||||
#define ZIP_CENTRAL_DIR_SIG 0x02014b50
|
||||
#define ZIP_END_OF_CENTRAL_DIR_SIG 0x06054b50
|
||||
|
||||
#define ZIP_METHOD_STORED 0
|
||||
#define ZIP_METHOD_DEFLATED 8
|
||||
|
||||
// Maximum files we track in a ZIP (EPUBs typically have 20-100 files)
|
||||
#define ZIP_MAX_ENTRIES 128
|
||||
|
||||
// Maximum filename length within the ZIP
|
||||
#define ZIP_MAX_FILENAME 128
|
||||
|
||||
// ---- Data structures ----
|
||||
|
||||
struct ZipEntry {
|
||||
char filename[ZIP_MAX_FILENAME];
|
||||
uint16_t compressionMethod; // 0=STORED, 8=DEFLATED
|
||||
uint32_t compressedSize;
|
||||
uint32_t uncompressedSize;
|
||||
uint32_t localHeaderOffset; // Offset to local file header in ZIP
|
||||
uint32_t crc32;
|
||||
};
|
||||
|
||||
// ---- Helper: read little-endian values from a byte buffer ----
|
||||
|
||||
static inline uint16_t zipRead16(const uint8_t* p) {
|
||||
return (uint16_t)p[0] | ((uint16_t)p[1] << 8);
|
||||
}
|
||||
|
||||
static inline uint32_t zipRead32(const uint8_t* p) {
|
||||
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) |
|
||||
((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EpubZipReader class
|
||||
// =============================================================================
|
||||
|
||||
class EpubZipReader {
|
||||
public:
|
||||
EpubZipReader() : _entryCount(0), _isOpen(false), _entries(nullptr) {
|
||||
// Allocate entries array from PSRAM to avoid stack overflow
|
||||
// (128 entries × ~146 bytes = ~19KB — too large for 8KB loopTask stack)
|
||||
#ifdef BOARD_HAS_PSRAM
|
||||
_entries = (ZipEntry*)ps_malloc(ZIP_MAX_ENTRIES * sizeof(ZipEntry));
|
||||
#endif
|
||||
if (!_entries) {
|
||||
_entries = (ZipEntry*)malloc(ZIP_MAX_ENTRIES * sizeof(ZipEntry));
|
||||
}
|
||||
if (!_entries) {
|
||||
Serial.println("ZipReader: FATAL - failed to allocate entry table");
|
||||
}
|
||||
}
|
||||
|
||||
~EpubZipReader() {
|
||||
if (_entries) {
|
||||
free(_entries);
|
||||
_entries = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Open a ZIP file and parse its central directory.
|
||||
// Returns true on success, false on error.
|
||||
// After open(), entries are available via getEntryCount()/getEntry().
|
||||
// ----------------------------------------------------------
|
||||
bool open(File& zipFile) {
|
||||
_isOpen = false;
|
||||
_entryCount = 0;
|
||||
|
||||
if (!_entries) {
|
||||
Serial.println("ZipReader: entry table not allocated");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!zipFile || !zipFile.available()) {
|
||||
Serial.println("ZipReader: file not valid");
|
||||
return false;
|
||||
}
|
||||
|
||||
_file = zipFile;
|
||||
uint32_t fileSize = _file.size();
|
||||
|
||||
if (fileSize < 22) {
|
||||
Serial.println("ZipReader: file too small for ZIP");
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---- Step 1: Find the End of Central Directory record ----
|
||||
// EOCD is at least 22 bytes, at end of file.
|
||||
// Search backwards from end for the EOCD signature.
|
||||
// Comment can be up to 65535 bytes, but EPUBs typically have none.
|
||||
uint32_t searchStart = (fileSize > 65557) ? (fileSize - 65557) : 0;
|
||||
uint32_t eocdOffset = 0;
|
||||
bool foundEocd = false;
|
||||
|
||||
// Read the last chunk into a buffer to search for EOCD signature
|
||||
uint32_t searchLen = fileSize - searchStart;
|
||||
// Cap search buffer to a reasonable size
|
||||
if (searchLen > 1024) {
|
||||
searchStart = fileSize - 1024;
|
||||
searchLen = 1024;
|
||||
}
|
||||
|
||||
uint8_t* searchBuf = (uint8_t*)_allocBuffer(searchLen);
|
||||
if (!searchBuf) {
|
||||
Serial.println("ZipReader: failed to alloc search buffer");
|
||||
return false;
|
||||
}
|
||||
|
||||
_file.seek(searchStart);
|
||||
if (_file.read(searchBuf, searchLen) != (int)searchLen) {
|
||||
free(searchBuf);
|
||||
Serial.println("ZipReader: failed to read EOCD area");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Scan backwards for EOCD signature (0x06054b50)
|
||||
for (int i = (int)searchLen - 22; i >= 0; i--) {
|
||||
if (zipRead32(&searchBuf[i]) == ZIP_END_OF_CENTRAL_DIR_SIG) {
|
||||
eocdOffset = searchStart + i;
|
||||
// Parse EOCD fields
|
||||
uint16_t totalEntries = zipRead16(&searchBuf[i + 10]);
|
||||
uint32_t cdSize = zipRead32(&searchBuf[i + 12]);
|
||||
uint32_t cdOffset = zipRead32(&searchBuf[i + 16]);
|
||||
|
||||
_cdOffset = cdOffset;
|
||||
_cdSize = cdSize;
|
||||
_totalEntries = totalEntries;
|
||||
foundEocd = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
free(searchBuf);
|
||||
|
||||
if (!foundEocd) {
|
||||
Serial.println("ZipReader: EOCD not found - not a valid ZIP");
|
||||
return false;
|
||||
}
|
||||
|
||||
Serial.printf("ZipReader: EOCD found at %u, %u entries, CD at %u (%u bytes)\n",
|
||||
eocdOffset, _totalEntries, _cdOffset, _cdSize);
|
||||
|
||||
// ---- Step 2: Parse Central Directory entries ----
|
||||
if (_cdSize == 0 || _cdSize > 512 * 1024) {
|
||||
Serial.println("ZipReader: central directory size unreasonable");
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t* cdBuf = (uint8_t*)_allocBuffer(_cdSize);
|
||||
if (!cdBuf) {
|
||||
Serial.printf("ZipReader: failed to alloc %u bytes for central directory\n", _cdSize);
|
||||
return false;
|
||||
}
|
||||
|
||||
_file.seek(_cdOffset);
|
||||
if (_file.read(cdBuf, _cdSize) != (int)_cdSize) {
|
||||
free(cdBuf);
|
||||
Serial.println("ZipReader: failed to read central directory");
|
||||
return false;
|
||||
}
|
||||
|
||||
uint32_t pos = 0;
|
||||
_entryCount = 0;
|
||||
|
||||
while (pos + 46 <= _cdSize && _entryCount < ZIP_MAX_ENTRIES) {
|
||||
if (zipRead32(&cdBuf[pos]) != ZIP_CENTRAL_DIR_SIG) {
|
||||
break; // No more central directory entries
|
||||
}
|
||||
|
||||
uint16_t method = zipRead16(&cdBuf[pos + 10]);
|
||||
uint32_t crc = zipRead32(&cdBuf[pos + 16]);
|
||||
uint32_t compSize = zipRead32(&cdBuf[pos + 20]);
|
||||
uint32_t uncompSize = zipRead32(&cdBuf[pos + 24]);
|
||||
uint16_t fnLen = zipRead16(&cdBuf[pos + 28]);
|
||||
uint16_t extraLen = zipRead16(&cdBuf[pos + 30]);
|
||||
uint16_t commentLen = zipRead16(&cdBuf[pos + 32]);
|
||||
uint32_t localOffset = zipRead32(&cdBuf[pos + 42]);
|
||||
|
||||
// Copy filename (truncate if necessary)
|
||||
int copyLen = (fnLen < ZIP_MAX_FILENAME - 1) ? fnLen : ZIP_MAX_FILENAME - 1;
|
||||
memcpy(_entries[_entryCount].filename, &cdBuf[pos + 46], copyLen);
|
||||
_entries[_entryCount].filename[copyLen] = '\0';
|
||||
|
||||
_entries[_entryCount].compressionMethod = method;
|
||||
_entries[_entryCount].compressedSize = compSize;
|
||||
_entries[_entryCount].uncompressedSize = uncompSize;
|
||||
_entries[_entryCount].localHeaderOffset = localOffset;
|
||||
_entries[_entryCount].crc32 = crc;
|
||||
|
||||
// Skip directories (filenames ending with '/')
|
||||
if (copyLen > 0 && _entries[_entryCount].filename[copyLen - 1] != '/') {
|
||||
_entryCount++;
|
||||
}
|
||||
|
||||
// Advance past this central directory entry
|
||||
pos += 46 + fnLen + extraLen + commentLen;
|
||||
}
|
||||
|
||||
free(cdBuf);
|
||||
|
||||
Serial.printf("ZipReader: parsed %d file entries\n", _entryCount);
|
||||
_isOpen = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Close the reader (does not close the underlying File).
|
||||
// ----------------------------------------------------------
|
||||
void close() {
|
||||
_isOpen = false;
|
||||
_entryCount = 0;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Get entry count and entries
|
||||
// ----------------------------------------------------------
|
||||
int getEntryCount() const { return _entryCount; }
|
||||
|
||||
const ZipEntry* getEntry(int index) const {
|
||||
if (index < 0 || index >= _entryCount) return nullptr;
|
||||
return &_entries[index];
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Find an entry by filename (case-sensitive).
|
||||
// Returns index, or -1 if not found.
|
||||
// ----------------------------------------------------------
|
||||
int findEntry(const char* filename) const {
|
||||
for (int i = 0; i < _entryCount; i++) {
|
||||
if (strcmp(_entries[i].filename, filename) == 0) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Find an entry by filename suffix (e.g., ".opf", ".ncx").
|
||||
// Returns index of first match, or -1 if not found.
|
||||
// ----------------------------------------------------------
|
||||
int findEntryBySuffix(const char* suffix) const {
|
||||
int suffixLen = strlen(suffix);
|
||||
for (int i = 0; i < _entryCount; i++) {
|
||||
int fnLen = strlen(_entries[i].filename);
|
||||
if (fnLen >= suffixLen &&
|
||||
strcasecmp(&_entries[i].filename[fnLen - suffixLen], suffix) == 0) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Find entries matching a path prefix (e.g., "OEBPS/").
|
||||
// Fills matchIndices[] up to maxMatches. Returns count found.
|
||||
// ----------------------------------------------------------
|
||||
int findEntriesByPrefix(const char* prefix, int* matchIndices, int maxMatches) const {
|
||||
int count = 0;
|
||||
int prefixLen = strlen(prefix);
|
||||
for (int i = 0; i < _entryCount && count < maxMatches; i++) {
|
||||
if (strncmp(_entries[i].filename, prefix, prefixLen) == 0) {
|
||||
matchIndices[count++] = i;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Extract a file entry to a newly allocated buffer.
|
||||
//
|
||||
// On success, returns a malloc'd buffer (caller must free!)
|
||||
// and sets *outSize to the uncompressed size.
|
||||
//
|
||||
// On failure, returns nullptr.
|
||||
//
|
||||
// The buffer is allocated from PSRAM if available.
|
||||
// ----------------------------------------------------------
|
||||
uint8_t* extractEntry(int index, uint32_t* outSize) {
|
||||
if (!_isOpen || index < 0 || index >= _entryCount) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const ZipEntry& entry = _entries[index];
|
||||
|
||||
// ---- Read the local file header to get actual data offset ----
|
||||
// Local header: 30 bytes fixed + variable filename + extra field
|
||||
uint8_t localHeader[30];
|
||||
_file.seek(entry.localHeaderOffset);
|
||||
if (_file.read(localHeader, 30) != 30) {
|
||||
Serial.println("ZipReader: failed to read local header");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
if (zipRead32(localHeader) != ZIP_LOCAL_FILE_HEADER_SIG) {
|
||||
Serial.println("ZipReader: bad local header signature");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
uint16_t localFnLen = zipRead16(&localHeader[26]);
|
||||
uint16_t localExtraLen = zipRead16(&localHeader[28]);
|
||||
uint32_t dataOffset = entry.localHeaderOffset + 30 + localFnLen + localExtraLen;
|
||||
|
||||
// ---- Handle based on compression method ----
|
||||
if (entry.compressionMethod == ZIP_METHOD_STORED) {
|
||||
return _extractStored(dataOffset, entry.uncompressedSize, outSize);
|
||||
}
|
||||
else if (entry.compressionMethod == ZIP_METHOD_DEFLATED) {
|
||||
return _extractDeflated(dataOffset, entry.compressedSize,
|
||||
entry.uncompressedSize, outSize);
|
||||
}
|
||||
else {
|
||||
Serial.printf("ZipReader: unsupported compression method %d for %s\n",
|
||||
entry.compressionMethod, entry.filename);
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Extract a file entry by filename.
|
||||
// Convenience wrapper around findEntry() + extractEntry().
|
||||
// ----------------------------------------------------------
|
||||
uint8_t* extractByName(const char* filename, uint32_t* outSize) {
|
||||
int idx = findEntry(filename);
|
||||
if (idx < 0) return nullptr;
|
||||
return extractEntry(idx, outSize);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Check if reader is open and valid
|
||||
// ----------------------------------------------------------
|
||||
bool isOpen() const { return _isOpen; }
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Debug: print all entries
|
||||
// ----------------------------------------------------------
|
||||
void printEntries() const {
|
||||
Serial.printf("ZIP contains %d files:\n", _entryCount);
|
||||
for (int i = 0; i < _entryCount; i++) {
|
||||
const ZipEntry& e = _entries[i];
|
||||
Serial.printf(" [%d] %s (%s, %u -> %u bytes)\n",
|
||||
i, e.filename,
|
||||
e.compressionMethod == 0 ? "STORED" : "DEFLATED",
|
||||
e.compressedSize, e.uncompressedSize);
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
File _file;
|
||||
ZipEntry* _entries; // Heap-allocated (PSRAM) entry table
|
||||
int _entryCount;
|
||||
bool _isOpen;
|
||||
uint32_t _cdOffset;
|
||||
uint32_t _cdSize;
|
||||
uint16_t _totalEntries;
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Allocate buffer, preferring PSRAM if available
|
||||
// ----------------------------------------------------------
|
||||
void* _allocBuffer(size_t size) {
|
||||
void* buf = nullptr;
|
||||
#ifdef BOARD_HAS_PSRAM
|
||||
buf = ps_malloc(size);
|
||||
#endif
|
||||
if (!buf) {
|
||||
buf = malloc(size);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Extract a STORED (uncompressed) entry
|
||||
// ----------------------------------------------------------
|
||||
uint8_t* _extractStored(uint32_t dataOffset, uint32_t size, uint32_t* outSize) {
|
||||
uint8_t* buf = (uint8_t*)_allocBuffer(size + 1); // +1 for null terminator
|
||||
if (!buf) {
|
||||
Serial.printf("ZipReader: failed to alloc %u bytes for stored entry\n", size);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
_file.seek(dataOffset);
|
||||
uint32_t bytesRead = _file.read(buf, size);
|
||||
if (bytesRead != size) {
|
||||
Serial.printf("ZipReader: short read (got %u, expected %u)\n", bytesRead, size);
|
||||
free(buf);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
buf[size] = '\0'; // Null-terminate for text files
|
||||
*outSize = size;
|
||||
|
||||
// Release SD CS pin for other SPI users
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
|
||||
return buf;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Extract a DEFLATED entry using ROM tinfl
|
||||
// ----------------------------------------------------------
|
||||
uint8_t* _extractDeflated(uint32_t dataOffset, uint32_t compSize,
|
||||
uint32_t uncompSize, uint32_t* outSize) {
|
||||
#if HAS_ROM_TINFL
|
||||
// Allocate compressed data buffer (from PSRAM)
|
||||
uint8_t* compBuf = (uint8_t*)_allocBuffer(compSize);
|
||||
if (!compBuf) {
|
||||
Serial.printf("ZipReader: failed to alloc %u bytes for compressed data\n", compSize);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Allocate output buffer (+1 for null terminator)
|
||||
uint8_t* outBuf = (uint8_t*)_allocBuffer(uncompSize + 1);
|
||||
if (!outBuf) {
|
||||
Serial.printf("ZipReader: failed to alloc %u bytes for decompressed data\n", uncompSize);
|
||||
free(compBuf);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Heap-allocate the decompressor (~11KB struct - too large for 8KB loopTask stack!)
|
||||
tinfl_decompressor* decomp = (tinfl_decompressor*)_allocBuffer(sizeof(tinfl_decompressor));
|
||||
if (!decomp) {
|
||||
Serial.printf("ZipReader: failed to alloc tinfl_decompressor (%u bytes)\n",
|
||||
(uint32_t)sizeof(tinfl_decompressor));
|
||||
free(compBuf);
|
||||
free(outBuf);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Read compressed data from file
|
||||
_file.seek(dataOffset);
|
||||
if (_file.read(compBuf, compSize) != (int)compSize) {
|
||||
Serial.println("ZipReader: failed to read compressed data");
|
||||
free(decomp);
|
||||
free(compBuf);
|
||||
free(outBuf);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Release SD CS pin for other SPI users
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
|
||||
// Decompress using ROM tinfl (low-level API to avoid stack allocation)
|
||||
// ZIP DEFLATE is raw deflate (no zlib header).
|
||||
tinfl_init(decomp);
|
||||
|
||||
size_t inBytes = compSize;
|
||||
size_t outBytes = uncompSize;
|
||||
tinfl_status status = tinfl_decompress(
|
||||
decomp,
|
||||
(const mz_uint8*)compBuf, // compressed input
|
||||
&inBytes, // in: available, out: consumed
|
||||
outBuf, // output buffer base
|
||||
outBuf, // current output position
|
||||
&outBytes, // in: available, out: produced
|
||||
TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF // raw deflate, single-shot
|
||||
);
|
||||
|
||||
free(decomp);
|
||||
free(compBuf);
|
||||
|
||||
if (status != TINFL_STATUS_DONE) {
|
||||
Serial.printf("ZipReader: DEFLATE failed (status %d)\n", (int)status);
|
||||
free(outBuf);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
outBuf[outBytes] = '\0'; // Null-terminate for text files
|
||||
*outSize = (uint32_t)outBytes;
|
||||
|
||||
if (outBytes != uncompSize) {
|
||||
Serial.printf("ZipReader: decompressed %u bytes, expected %u\n",
|
||||
(uint32_t)outBytes, uncompSize);
|
||||
}
|
||||
|
||||
return outBuf;
|
||||
|
||||
#else
|
||||
// No ROM tinfl available
|
||||
Serial.println("ZipReader: DEFLATE not supported (no ROM tinfl)");
|
||||
*outSize = 0;
|
||||
return nullptr;
|
||||
#endif
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// FALLBACK NOTE:
|
||||
//
|
||||
// If the ROM tinfl includes fail to compile on your ESP32 variant, you have
|
||||
// two options:
|
||||
//
|
||||
// 1. Install lbernstone/miniz-esp32 from PlatformIO:
|
||||
// lib_deps = https://github.com/lbernstone/miniz-esp32.git
|
||||
// Then change the includes above to: #include <miniz.h>
|
||||
//
|
||||
// 2. Copy just the tinfl source (~550 lines) from:
|
||||
// https://github.com/richgel999/miniz/blob/master/miniz_tinfl.c
|
||||
// into your project. Only tinfl_decompress_mem_to_mem() is needed.
|
||||
//
|
||||
// =============================================================================
|
||||
@@ -1,841 +0,0 @@
|
||||
#pragma once
|
||||
// =============================================================================
|
||||
// EpubProcessor.h - Convert EPUB files to plain text for TextReaderScreen
|
||||
//
|
||||
// Pipeline: EPUB (ZIP) → container.xml → OPF spine → extract chapters →
|
||||
// strip XHTML tags → concatenated plain text → cached .txt on SD
|
||||
//
|
||||
// The resulting .txt file is placed in /books/ and picked up automatically
|
||||
// by TextReaderScreen's existing pagination, indexing, and bookmarking.
|
||||
//
|
||||
// Dependencies: EpubZipReader.h (for ZIP extraction)
|
||||
// =============================================================================
|
||||
|
||||
#include <SD.h>
|
||||
#include <FS.h>
|
||||
#include "EpubZipReader.h"
|
||||
|
||||
// Maximum chapters in spine (most novels have 20-80)
|
||||
#define EPUB_MAX_CHAPTERS 200
|
||||
|
||||
// Maximum manifest items we track
|
||||
#define EPUB_MAX_MANIFEST 256
|
||||
|
||||
// Buffer size for reading OPF/container XML
|
||||
// (These are small files, typically 1-20KB)
|
||||
#define EPUB_XML_BUF_SIZE 64
|
||||
|
||||
class EpubProcessor {
|
||||
public:
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Process an EPUB file: extract text and write to SD cache.
|
||||
//
|
||||
// epubPath: source, e.g. "/books/The Iliad.epub"
|
||||
// txtPath: output, e.g. "/books/The Iliad by Homer.txt"
|
||||
//
|
||||
// Returns true if the .txt file was written successfully.
|
||||
// If txtPath already exists, returns true immediately (cached).
|
||||
// ----------------------------------------------------------
|
||||
static bool processToText(const char* epubPath, const char* txtPath) {
|
||||
// Check if already cached
|
||||
if (SD.exists(txtPath)) {
|
||||
Serial.printf("EpubProc: '%s' already cached\n", txtPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
Serial.printf("EpubProc: Processing '%s'\n", epubPath);
|
||||
unsigned long t0 = millis();
|
||||
|
||||
// Open the EPUB (ZIP archive)
|
||||
File epubFile = SD.open(epubPath, FILE_READ);
|
||||
if (!epubFile) {
|
||||
Serial.println("EpubProc: Cannot open EPUB file");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Heap-allocate zip reader (entries table is ~19KB)
|
||||
EpubZipReader* zip = new EpubZipReader();
|
||||
if (!zip) {
|
||||
epubFile.close();
|
||||
Serial.println("EpubProc: Cannot allocate ZipReader");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!zip->open(epubFile)) {
|
||||
delete zip;
|
||||
epubFile.close();
|
||||
Serial.println("EpubProc: Cannot parse ZIP structure");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Step 1: Find OPF path from container.xml
|
||||
char opfPath[EPUB_XML_BUF_SIZE];
|
||||
opfPath[0] = '\0';
|
||||
if (!_findOpfPath(zip, opfPath, sizeof(opfPath))) {
|
||||
delete zip;
|
||||
epubFile.close();
|
||||
Serial.println("EpubProc: Cannot find OPF path");
|
||||
return false;
|
||||
}
|
||||
Serial.printf("EpubProc: OPF at '%s'\n", opfPath);
|
||||
|
||||
// Determine the content base directory (e.g., "OEBPS/")
|
||||
char baseDir[EPUB_XML_BUF_SIZE];
|
||||
_getDirectory(opfPath, baseDir, sizeof(baseDir));
|
||||
|
||||
// Step 2: Parse OPF to get title and spine chapter order
|
||||
char title[128];
|
||||
title[0] = '\0';
|
||||
|
||||
// Chapter paths in spine order
|
||||
char** chapterPaths = nullptr;
|
||||
int chapterCount = 0;
|
||||
|
||||
if (!_parseOpf(zip, opfPath, baseDir, title, sizeof(title),
|
||||
&chapterPaths, &chapterCount)) {
|
||||
delete zip;
|
||||
epubFile.close();
|
||||
Serial.println("EpubProc: Cannot parse OPF");
|
||||
return false;
|
||||
}
|
||||
|
||||
Serial.printf("EpubProc: Title='%s', %d chapters\n", title, chapterCount);
|
||||
|
||||
// Step 3: Extract each chapter, strip XHTML, write to output .txt
|
||||
File outFile = SD.open(txtPath, FILE_WRITE);
|
||||
if (!outFile) {
|
||||
_freeChapterPaths(chapterPaths, chapterCount);
|
||||
delete zip;
|
||||
epubFile.close();
|
||||
Serial.printf("EpubProc: Cannot create '%s'\n", txtPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Write title as first line
|
||||
if (title[0]) {
|
||||
outFile.println(title);
|
||||
outFile.println();
|
||||
}
|
||||
|
||||
int chaptersWritten = 0;
|
||||
uint32_t totalBytes = 0;
|
||||
|
||||
for (int i = 0; i < chapterCount; i++) {
|
||||
int entryIdx = zip->findEntry(chapterPaths[i]);
|
||||
if (entryIdx < 0) {
|
||||
Serial.printf("EpubProc: Chapter not found: '%s'\n", chapterPaths[i]);
|
||||
continue;
|
||||
}
|
||||
|
||||
uint32_t rawSize = 0;
|
||||
uint8_t* rawData = zip->extractEntry(entryIdx, &rawSize);
|
||||
if (!rawData || rawSize == 0) {
|
||||
Serial.printf("EpubProc: Failed to extract chapter %d\n", i);
|
||||
if (rawData) free(rawData);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Strip XHTML tags and write plain text
|
||||
uint32_t textLen = 0;
|
||||
uint8_t* plainText = _stripXhtml(rawData, rawSize, &textLen);
|
||||
free(rawData);
|
||||
|
||||
if (plainText && textLen > 0) {
|
||||
outFile.write(plainText, textLen);
|
||||
// Add chapter separator
|
||||
outFile.print("\n\n");
|
||||
totalBytes += textLen + 2;
|
||||
chaptersWritten++;
|
||||
}
|
||||
if (plainText) free(plainText);
|
||||
}
|
||||
|
||||
outFile.flush();
|
||||
outFile.close();
|
||||
|
||||
// Release SD CS for other SPI users
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
|
||||
_freeChapterPaths(chapterPaths, chapterCount);
|
||||
delete zip;
|
||||
epubFile.close();
|
||||
|
||||
unsigned long elapsed = millis() - t0;
|
||||
Serial.printf("EpubProc: Done! %d chapters, %u bytes in %lu ms -> '%s'\n",
|
||||
chaptersWritten, totalBytes, elapsed, txtPath);
|
||||
|
||||
return chaptersWritten > 0;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Extract just the title from an EPUB (for display in file list).
|
||||
// Returns false if it can't be determined.
|
||||
// ----------------------------------------------------------
|
||||
static bool getTitle(const char* epubPath, char* titleBuf, int titleBufSize) {
|
||||
File epubFile = SD.open(epubPath, FILE_READ);
|
||||
if (!epubFile) return false;
|
||||
|
||||
EpubZipReader* zip = new EpubZipReader();
|
||||
if (!zip) { epubFile.close(); return false; }
|
||||
|
||||
if (!zip->open(epubFile)) {
|
||||
delete zip; epubFile.close(); return false;
|
||||
}
|
||||
|
||||
char opfPath[EPUB_XML_BUF_SIZE];
|
||||
if (!_findOpfPath(zip, opfPath, sizeof(opfPath))) {
|
||||
delete zip; epubFile.close(); return false;
|
||||
}
|
||||
|
||||
// Extract OPF and find <dc:title>
|
||||
int opfIdx = zip->findEntry(opfPath);
|
||||
if (opfIdx < 0) { delete zip; epubFile.close(); return false; }
|
||||
|
||||
uint32_t opfSize = 0;
|
||||
uint8_t* opfData = zip->extractEntry(opfIdx, &opfSize);
|
||||
delete zip;
|
||||
epubFile.close();
|
||||
|
||||
if (!opfData) return false;
|
||||
|
||||
bool found = _extractTagContent((const char*)opfData, opfSize,
|
||||
"dc:title", titleBuf, titleBufSize);
|
||||
free(opfData);
|
||||
return found;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Build a cache .txt path from an .epub path.
|
||||
// e.g., "/books/mybook.epub" -> "/books/.epub_cache/mybook.txt"
|
||||
// ----------------------------------------------------------
|
||||
static void buildCachePath(const char* epubPath, char* cachePath, int cachePathSize) {
|
||||
// Extract filename without extension
|
||||
const char* lastSlash = strrchr(epubPath, '/');
|
||||
const char* filename = lastSlash ? lastSlash + 1 : epubPath;
|
||||
|
||||
// Find the directory part
|
||||
char dir[128];
|
||||
if (lastSlash) {
|
||||
int dirLen = lastSlash - epubPath;
|
||||
if (dirLen >= (int)sizeof(dir)) dirLen = sizeof(dir) - 1;
|
||||
strncpy(dir, epubPath, dirLen);
|
||||
dir[dirLen] = '\0';
|
||||
} else {
|
||||
strcpy(dir, "/books");
|
||||
}
|
||||
|
||||
// Create cache directory if needed
|
||||
char cacheDir[160];
|
||||
snprintf(cacheDir, sizeof(cacheDir), "%s/.epub_cache", dir);
|
||||
if (!SD.exists(cacheDir)) {
|
||||
SD.mkdir(cacheDir);
|
||||
}
|
||||
|
||||
// Strip .epub extension
|
||||
char baseName[128];
|
||||
strncpy(baseName, filename, sizeof(baseName) - 1);
|
||||
baseName[sizeof(baseName) - 1] = '\0';
|
||||
char* dot = strrchr(baseName, '.');
|
||||
if (dot) *dot = '\0';
|
||||
|
||||
snprintf(cachePath, cachePathSize, "%s/%s.txt", cacheDir, baseName);
|
||||
}
|
||||
|
||||
private:
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Parse container.xml to find the OPF file path.
|
||||
// Returns true if found.
|
||||
// ----------------------------------------------------------
|
||||
static bool _findOpfPath(EpubZipReader* zip, char* opfPath, int opfPathSize) {
|
||||
int idx = zip->findEntry("META-INF/container.xml");
|
||||
if (idx < 0) {
|
||||
// Fallback: find any .opf file directly
|
||||
idx = zip->findEntryBySuffix(".opf");
|
||||
if (idx >= 0) {
|
||||
const ZipEntry* e = zip->getEntry(idx);
|
||||
strncpy(opfPath, e->filename, opfPathSize - 1);
|
||||
opfPath[opfPathSize - 1] = '\0';
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
uint32_t size = 0;
|
||||
uint8_t* data = zip->extractEntry(idx, &size);
|
||||
if (!data) return false;
|
||||
|
||||
// Find: full-path="OEBPS/content.opf"
|
||||
bool found = _extractAttribute((const char*)data, size,
|
||||
"full-path", opfPath, opfPathSize);
|
||||
free(data);
|
||||
return found;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Parse OPF to extract title, build manifest, and resolve spine.
|
||||
//
|
||||
// Populates chapterPaths (heap-allocated array of strings) with
|
||||
// full ZIP paths for each chapter in spine order.
|
||||
// Caller must free with _freeChapterPaths().
|
||||
// ----------------------------------------------------------
|
||||
static bool _parseOpf(EpubZipReader* zip, const char* opfPath,
|
||||
const char* baseDir, char* title, int titleSize,
|
||||
char*** outChapterPaths, int* outChapterCount) {
|
||||
int opfIdx = zip->findEntry(opfPath);
|
||||
if (opfIdx < 0) return false;
|
||||
|
||||
uint32_t opfSize = 0;
|
||||
uint8_t* opfData = zip->extractEntry(opfIdx, &opfSize);
|
||||
if (!opfData) return false;
|
||||
|
||||
const char* xml = (const char*)opfData;
|
||||
|
||||
// Extract title
|
||||
_extractTagContent(xml, opfSize, "dc:title", title, titleSize);
|
||||
|
||||
// Build manifest: map id -> href
|
||||
// We use two parallel arrays to avoid complex data structures
|
||||
struct ManifestItem {
|
||||
char id[64];
|
||||
char href[128];
|
||||
bool isContent; // has media-type containing "html" or "xml"
|
||||
};
|
||||
|
||||
// Heap-allocate manifest (could be large)
|
||||
ManifestItem* manifest = (ManifestItem*)ps_malloc(
|
||||
EPUB_MAX_MANIFEST * sizeof(ManifestItem));
|
||||
if (!manifest) {
|
||||
manifest = (ManifestItem*)malloc(EPUB_MAX_MANIFEST * sizeof(ManifestItem));
|
||||
}
|
||||
if (!manifest) {
|
||||
free(opfData);
|
||||
return false;
|
||||
}
|
||||
int manifestCount = 0;
|
||||
|
||||
// Parse <item> elements from <manifest>
|
||||
const char* manifestStart = _findTag(xml, opfSize, "<manifest");
|
||||
const char* manifestEnd = manifestStart ?
|
||||
_findTag(manifestStart, opfSize - (manifestStart - xml), "</manifest") : nullptr;
|
||||
if (!manifestEnd) manifestEnd = xml + opfSize;
|
||||
|
||||
if (manifestStart) {
|
||||
const char* pos = manifestStart;
|
||||
while (pos < manifestEnd && manifestCount < EPUB_MAX_MANIFEST) {
|
||||
pos = _findTag(pos, manifestEnd - pos, "<item");
|
||||
if (!pos || pos >= manifestEnd) break;
|
||||
|
||||
// Find the closing > of this <item ... />
|
||||
const char* tagEnd = (const char*)memchr(pos, '>', manifestEnd - pos);
|
||||
if (!tagEnd) break;
|
||||
tagEnd++;
|
||||
|
||||
ManifestItem& item = manifest[manifestCount];
|
||||
item.id[0] = '\0';
|
||||
item.href[0] = '\0';
|
||||
item.isContent = false;
|
||||
|
||||
_extractAttributeFromTag(pos, tagEnd - pos, "id",
|
||||
item.id, sizeof(item.id));
|
||||
_extractAttributeFromTag(pos, tagEnd - pos, "href",
|
||||
item.href, sizeof(item.href));
|
||||
|
||||
// Check media-type for content files
|
||||
char mediaType[64];
|
||||
mediaType[0] = '\0';
|
||||
_extractAttributeFromTag(pos, tagEnd - pos, "media-type",
|
||||
mediaType, sizeof(mediaType));
|
||||
item.isContent = (strstr(mediaType, "html") != nullptr ||
|
||||
strstr(mediaType, "xml") != nullptr);
|
||||
|
||||
if (item.id[0] && item.href[0]) {
|
||||
manifestCount++;
|
||||
}
|
||||
|
||||
pos = tagEnd;
|
||||
}
|
||||
}
|
||||
|
||||
Serial.printf("EpubProc: Manifest has %d items\n", manifestCount);
|
||||
|
||||
// Parse <spine> to get reading order
|
||||
// Spine contains <itemref idref="..."/> elements
|
||||
const char* spineStart = _findTag(xml, opfSize, "<spine");
|
||||
const char* spineEnd = spineStart ?
|
||||
_findTag(spineStart, opfSize - (spineStart - xml), "</spine") : nullptr;
|
||||
if (!spineEnd) spineEnd = xml + opfSize;
|
||||
|
||||
// Collect spine idrefs
|
||||
char** chapterPaths = (char**)ps_malloc(EPUB_MAX_CHAPTERS * sizeof(char*));
|
||||
if (!chapterPaths) chapterPaths = (char**)malloc(EPUB_MAX_CHAPTERS * sizeof(char*));
|
||||
if (!chapterPaths) {
|
||||
free(manifest);
|
||||
free(opfData);
|
||||
return false;
|
||||
}
|
||||
int chapterCount = 0;
|
||||
|
||||
if (spineStart) {
|
||||
const char* pos = spineStart;
|
||||
while (pos < spineEnd && chapterCount < EPUB_MAX_CHAPTERS) {
|
||||
pos = _findTag(pos, spineEnd - pos, "<itemref");
|
||||
if (!pos || pos >= spineEnd) break;
|
||||
|
||||
const char* tagEnd = (const char*)memchr(pos, '>', spineEnd - pos);
|
||||
if (!tagEnd) break;
|
||||
tagEnd++;
|
||||
|
||||
char idref[64];
|
||||
idref[0] = '\0';
|
||||
_extractAttributeFromTag(pos, tagEnd - pos, "idref",
|
||||
idref, sizeof(idref));
|
||||
|
||||
if (idref[0]) {
|
||||
// Look up in manifest
|
||||
for (int m = 0; m < manifestCount; m++) {
|
||||
if (strcmp(manifest[m].id, idref) == 0 && manifest[m].isContent) {
|
||||
// Build full path: baseDir + href
|
||||
int pathLen = strlen(baseDir) + strlen(manifest[m].href) + 1;
|
||||
char* fullPath = (char*)malloc(pathLen);
|
||||
if (fullPath) {
|
||||
snprintf(fullPath, pathLen, "%s%s", baseDir, manifest[m].href);
|
||||
chapterPaths[chapterCount++] = fullPath;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pos = tagEnd;
|
||||
}
|
||||
}
|
||||
|
||||
free(manifest);
|
||||
free(opfData);
|
||||
|
||||
*outChapterPaths = chapterPaths;
|
||||
*outChapterCount = chapterCount;
|
||||
|
||||
return chapterCount > 0;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Strip XHTML/HTML tags from raw content, producing plain text.
|
||||
//
|
||||
// Handles:
|
||||
// - Tag removal (everything between < and >)
|
||||
// - <p>, <br>, <div>, <h1>-<h6> → newlines
|
||||
// - HTML entity decoding (& < > " ' &#NNN; &#xHH;)
|
||||
// - Collapse multiple whitespace/newlines
|
||||
// - Skip <head>, <style>, <script> content entirely
|
||||
//
|
||||
// Returns heap-allocated buffer (caller must free).
|
||||
// ----------------------------------------------------------
|
||||
static uint8_t* _stripXhtml(const uint8_t* input, uint32_t inputLen,
|
||||
uint32_t* outLen) {
|
||||
// Output can't be larger than input
|
||||
uint8_t* output = (uint8_t*)ps_malloc(inputLen + 1);
|
||||
if (!output) output = (uint8_t*)malloc(inputLen + 1);
|
||||
if (!output) { *outLen = 0; return nullptr; }
|
||||
|
||||
uint32_t outPos = 0;
|
||||
bool inTag = false;
|
||||
bool skipContent = false; // Inside <head>, <style>, <script>
|
||||
char tagName[32];
|
||||
int tagNamePos = 0;
|
||||
bool tagNameDone = false;
|
||||
bool isClosingTag = false;
|
||||
bool lastWasNewline = false;
|
||||
bool lastWasSpace = false;
|
||||
|
||||
// Skip to <body> if present (ignore everything before it)
|
||||
const uint8_t* start = input;
|
||||
const uint8_t* inputEnd = input + inputLen;
|
||||
const char* bodyStart = _findTagCI((const char*)input, inputLen, "<body");
|
||||
if (bodyStart) {
|
||||
const char* bodyTagEnd = (const char*)memchr(bodyStart, '>',
|
||||
inputEnd - (const uint8_t*)bodyStart);
|
||||
if (bodyTagEnd) {
|
||||
start = (const uint8_t*)(bodyTagEnd + 1);
|
||||
}
|
||||
}
|
||||
const uint8_t* end = inputEnd;
|
||||
|
||||
for (const uint8_t* p = start; p < end; p++) {
|
||||
char c = (char)*p;
|
||||
|
||||
if (inTag) {
|
||||
// Collecting tag name
|
||||
if (!tagNameDone) {
|
||||
if (tagNamePos == 0 && c == '/') {
|
||||
isClosingTag = true;
|
||||
continue;
|
||||
}
|
||||
if (c == '>' || c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '/') {
|
||||
tagName[tagNamePos] = '\0';
|
||||
tagNameDone = true;
|
||||
} else if (tagNamePos < (int)sizeof(tagName) - 1) {
|
||||
tagName[tagNamePos++] = (c >= 'A' && c <= 'Z') ? (c + 32) : c;
|
||||
}
|
||||
}
|
||||
|
||||
if (c == '>') {
|
||||
inTag = false;
|
||||
|
||||
// Handle skip regions
|
||||
if (!isClosingTag) {
|
||||
if (strcmp(tagName, "head") == 0 ||
|
||||
strcmp(tagName, "style") == 0 ||
|
||||
strcmp(tagName, "script") == 0) {
|
||||
skipContent = true;
|
||||
}
|
||||
} else {
|
||||
if (strcmp(tagName, "head") == 0 ||
|
||||
strcmp(tagName, "style") == 0 ||
|
||||
strcmp(tagName, "script") == 0) {
|
||||
skipContent = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!skipContent) {
|
||||
// Block-level elements produce newlines
|
||||
if (strcmp(tagName, "p") == 0 ||
|
||||
strcmp(tagName, "div") == 0 ||
|
||||
strcmp(tagName, "br") == 0 ||
|
||||
strcmp(tagName, "h1") == 0 ||
|
||||
strcmp(tagName, "h2") == 0 ||
|
||||
strcmp(tagName, "h3") == 0 ||
|
||||
strcmp(tagName, "h4") == 0 ||
|
||||
strcmp(tagName, "h5") == 0 ||
|
||||
strcmp(tagName, "h6") == 0 ||
|
||||
strcmp(tagName, "li") == 0 ||
|
||||
strcmp(tagName, "tr") == 0 ||
|
||||
strcmp(tagName, "blockquote") == 0 ||
|
||||
strcmp(tagName, "hr") == 0) {
|
||||
if (outPos > 0 && !lastWasNewline) {
|
||||
output[outPos++] = '\n';
|
||||
lastWasNewline = true;
|
||||
lastWasSpace = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Not in a tag
|
||||
if (c == '<') {
|
||||
inTag = true;
|
||||
tagNamePos = 0;
|
||||
tagNameDone = false;
|
||||
isClosingTag = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (skipContent) continue;
|
||||
|
||||
// Handle HTML entities
|
||||
if (c == '&') {
|
||||
char decoded = _decodeEntity(p, end, &p);
|
||||
if (decoded) {
|
||||
c = decoded;
|
||||
// p now points to the ';' or last char of entity; loop will increment
|
||||
}
|
||||
}
|
||||
|
||||
// Handle UTF-8 multi-byte sequences (smart quotes, em dashes, etc.)
|
||||
// These appear as raw bytes in XHTML and must be mapped to ASCII
|
||||
// since the e-ink font only supports ASCII characters.
|
||||
if ((uint8_t)c >= 0xC0) {
|
||||
uint32_t codepoint = 0;
|
||||
int extraBytes = 0;
|
||||
|
||||
if (((uint8_t)c & 0xE0) == 0xC0) {
|
||||
// 2-byte sequence: 110xxxxx 10xxxxxx
|
||||
codepoint = (uint8_t)c & 0x1F;
|
||||
extraBytes = 1;
|
||||
} else if (((uint8_t)c & 0xF0) == 0xE0) {
|
||||
// 3-byte sequence: 1110xxxx 10xxxxxx 10xxxxxx
|
||||
codepoint = (uint8_t)c & 0x0F;
|
||||
extraBytes = 2;
|
||||
} else if (((uint8_t)c & 0xF8) == 0xF0) {
|
||||
// 4-byte sequence: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
|
||||
codepoint = (uint8_t)c & 0x07;
|
||||
extraBytes = 3;
|
||||
}
|
||||
|
||||
// Read continuation bytes
|
||||
bool valid = true;
|
||||
for (int b = 0; b < extraBytes && p + 1 + b < end; b++) {
|
||||
uint8_t cb = *(p + 1 + b);
|
||||
if ((cb & 0xC0) != 0x80) { valid = false; break; }
|
||||
codepoint = (codepoint << 6) | (cb & 0x3F);
|
||||
}
|
||||
|
||||
if (valid && extraBytes > 0) {
|
||||
p += extraBytes; // Skip continuation bytes (loop increments past lead byte)
|
||||
|
||||
// Map Unicode codepoints to ASCII equivalents
|
||||
char mapped = 0;
|
||||
switch (codepoint) {
|
||||
case 0x2018: case 0x2019: mapped = '\''; break; // Smart single quotes
|
||||
case 0x201C: case 0x201D: mapped = '"'; break; // Smart double quotes
|
||||
case 0x2013: case 0x2014: mapped = '-'; break; // En/em dash
|
||||
case 0x2026: mapped = '.'; break; // Ellipsis
|
||||
case 0x2022: mapped = '*'; break; // Bullet
|
||||
case 0x00A0: mapped = ' '; break; // Non-breaking space
|
||||
case 0x00AB: case 0x00BB: mapped = '"'; break; // Guillemets
|
||||
case 0x2032: mapped = '\''; break; // Prime
|
||||
case 0x2033: mapped = '"'; break; // Double prime
|
||||
case 0x2010: case 0x2011: mapped = '-'; break; // Hyphens
|
||||
case 0x2012: mapped = '-'; break; // Figure dash
|
||||
case 0x2015: mapped = '-'; break; // Horizontal bar
|
||||
case 0x2039: case 0x203A: mapped = '\''; break; // Single guillemets
|
||||
default:
|
||||
if (codepoint >= 0x20 && codepoint < 0x7F) {
|
||||
mapped = (char)codepoint; // Basic ASCII range
|
||||
} else {
|
||||
continue; // Skip unmappable characters
|
||||
}
|
||||
break;
|
||||
}
|
||||
c = mapped;
|
||||
} else {
|
||||
continue; // Skip malformed UTF-8
|
||||
}
|
||||
} else if ((uint8_t)c >= 0x80) {
|
||||
// Stray continuation byte (0x80-0xBF) — skip
|
||||
continue;
|
||||
}
|
||||
|
||||
// Whitespace collapsing
|
||||
if (c == '\n' || c == '\r') {
|
||||
if (!lastWasNewline && outPos > 0) {
|
||||
output[outPos++] = '\n';
|
||||
lastWasNewline = true;
|
||||
lastWasSpace = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == ' ' || c == '\t') {
|
||||
if (!lastWasSpace && !lastWasNewline && outPos > 0) {
|
||||
output[outPos++] = ' ';
|
||||
lastWasSpace = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular character
|
||||
output[outPos++] = c;
|
||||
lastWasNewline = false;
|
||||
lastWasSpace = false;
|
||||
}
|
||||
|
||||
// Trim trailing whitespace
|
||||
while (outPos > 0 && (output[outPos-1] == '\n' || output[outPos-1] == ' ')) {
|
||||
outPos--;
|
||||
}
|
||||
|
||||
output[outPos] = '\0';
|
||||
*outLen = outPos;
|
||||
return output;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Decode an HTML entity starting at '&'.
|
||||
// Advances *pos to the last character consumed.
|
||||
// Returns the decoded character, or '&' if not recognized.
|
||||
// ----------------------------------------------------------
|
||||
static char _decodeEntity(const uint8_t* p, const uint8_t* end,
|
||||
const uint8_t** outPos) {
|
||||
// Look for ';' within a reasonable range
|
||||
const uint8_t* semi = p + 1;
|
||||
int maxLen = 10;
|
||||
while (semi < end && semi < p + maxLen && *semi != ';') semi++;
|
||||
|
||||
if (*semi != ';' || semi >= end) {
|
||||
*outPos = p; // Not an entity, return '&' literal
|
||||
return '&';
|
||||
}
|
||||
|
||||
int entityLen = semi - p - 1; // Length between & and ;
|
||||
const char* entity = (const char*)(p + 1);
|
||||
|
||||
*outPos = semi; // Skip past ';'
|
||||
|
||||
// Named entities
|
||||
if (entityLen == 3 && strncmp(entity, "amp", 3) == 0) return '&';
|
||||
if (entityLen == 2 && strncmp(entity, "lt", 2) == 0) return '<';
|
||||
if (entityLen == 2 && strncmp(entity, "gt", 2) == 0) return '>';
|
||||
if (entityLen == 4 && strncmp(entity, "quot", 4) == 0) return '"';
|
||||
if (entityLen == 4 && strncmp(entity, "apos", 4) == 0) return '\'';
|
||||
if (entityLen == 4 && strncmp(entity, "nbsp", 4) == 0) return ' ';
|
||||
if (entityLen == 5 && strncmp(entity, "mdash", 5) == 0) return '-';
|
||||
if (entityLen == 5 && strncmp(entity, "ndash", 5) == 0) return '-';
|
||||
if (entityLen == 6 && strncmp(entity, "hellip", 6) == 0) return '.';
|
||||
if (entityLen == 5 && strncmp(entity, "lsquo", 5) == 0) return '\'';
|
||||
if (entityLen == 5 && strncmp(entity, "rsquo", 5) == 0) return '\'';
|
||||
if (entityLen == 5 && strncmp(entity, "ldquo", 5) == 0) return '"';
|
||||
if (entityLen == 5 && strncmp(entity, "rdquo", 5) == 0) return '"';
|
||||
|
||||
// Numeric entities: &#NNN; or &#xHH;
|
||||
if (entityLen >= 2 && entity[0] == '#') {
|
||||
int codepoint = 0;
|
||||
if (entity[1] == 'x' || entity[1] == 'X') {
|
||||
// Hex
|
||||
for (int i = 2; i < entityLen; i++) {
|
||||
char ch = entity[i];
|
||||
if (ch >= '0' && ch <= '9') codepoint = codepoint * 16 + (ch - '0');
|
||||
else if (ch >= 'a' && ch <= 'f') codepoint = codepoint * 16 + (ch - 'a' + 10);
|
||||
else if (ch >= 'A' && ch <= 'F') codepoint = codepoint * 16 + (ch - 'A' + 10);
|
||||
}
|
||||
} else {
|
||||
// Decimal
|
||||
for (int i = 1; i < entityLen; i++) {
|
||||
char ch = entity[i];
|
||||
if (ch >= '0' && ch <= '9') codepoint = codepoint * 10 + (ch - '0');
|
||||
}
|
||||
}
|
||||
// Map to ASCII (best effort - e-ink font is ASCII only)
|
||||
if (codepoint >= 32 && codepoint < 127) return (char)codepoint;
|
||||
if (codepoint == 160) return ' '; // non-breaking space
|
||||
if (codepoint == 8211 || codepoint == 8212) return '-'; // en/em dash
|
||||
if (codepoint == 8216 || codepoint == 8217) return '\''; // smart quotes
|
||||
if (codepoint == 8220 || codepoint == 8221) return '"'; // smart quotes
|
||||
if (codepoint == 8230) return '.'; // ellipsis
|
||||
if (codepoint == 8226) return '*'; // bullet
|
||||
// Unknown codepoint > 127: skip it
|
||||
return ' ';
|
||||
}
|
||||
|
||||
// Unknown entity - output as space
|
||||
return ' ';
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Find a tag in XML data (case-sensitive, e.g., "<manifest").
|
||||
// Returns pointer to '<' of found tag, or nullptr.
|
||||
// ----------------------------------------------------------
|
||||
static const char* _findTag(const char* data, int dataLen, const char* tag) {
|
||||
int tagLen = strlen(tag);
|
||||
const char* end = data + dataLen - tagLen;
|
||||
for (const char* p = data; p <= end; p++) {
|
||||
if (memcmp(p, tag, tagLen) == 0) return p;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Find a tag case-insensitively (for <body>, <BODY>, etc.).
|
||||
// ----------------------------------------------------------
|
||||
static const char* _findTagCI(const char* data, int dataLen, const char* tag) {
|
||||
int tagLen = strlen(tag);
|
||||
const char* end = data + dataLen - tagLen;
|
||||
for (const char* p = data; p <= end; p++) {
|
||||
if (strncasecmp(p, tag, tagLen) == 0) return p;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Extract an attribute value from a region of XML.
|
||||
// Scans for attr="value" and copies value to outBuf.
|
||||
// ----------------------------------------------------------
|
||||
static bool _extractAttribute(const char* data, int dataLen,
|
||||
const char* attrName, char* outBuf, int outBufSize) {
|
||||
int nameLen = strlen(attrName);
|
||||
const char* end = data + dataLen;
|
||||
for (const char* p = data; p < end - nameLen - 2; p++) {
|
||||
if (strncmp(p, attrName, nameLen) == 0 && p[nameLen] == '=') {
|
||||
p += nameLen + 1;
|
||||
char quote = *p;
|
||||
if (quote != '"' && quote != '\'') continue;
|
||||
p++;
|
||||
const char* valEnd = (const char*)memchr(p, quote, end - p);
|
||||
if (!valEnd) continue;
|
||||
int valLen = valEnd - p;
|
||||
if (valLen >= outBufSize) valLen = outBufSize - 1;
|
||||
memcpy(outBuf, p, valLen);
|
||||
outBuf[valLen] = '\0';
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Extract an attribute value from within a single tag string.
|
||||
// (More targeted version for parsing <item id="x" href="y"/>)
|
||||
// ----------------------------------------------------------
|
||||
static bool _extractAttributeFromTag(const char* tag, int tagLen,
|
||||
const char* attrName,
|
||||
char* outBuf, int outBufSize) {
|
||||
return _extractAttribute(tag, tagLen, attrName, outBuf, outBufSize);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Extract text content between <tagName>...</tagName>.
|
||||
// Works for simple cases like <dc:title>The Iliad</dc:title>.
|
||||
// ----------------------------------------------------------
|
||||
static bool _extractTagContent(const char* data, int dataLen,
|
||||
const char* tagName, char* outBuf, int outBufSize) {
|
||||
// Build open tag pattern: "<dc:title" (without >)
|
||||
char openTag[64];
|
||||
snprintf(openTag, sizeof(openTag), "<%s", tagName);
|
||||
|
||||
const char* start = _findTag(data, dataLen, openTag);
|
||||
if (!start) return false;
|
||||
|
||||
// Find the > that closes the opening tag
|
||||
const char* end = data + dataLen;
|
||||
const char* contentStart = (const char*)memchr(start, '>', end - start);
|
||||
if (!contentStart) return false;
|
||||
contentStart++; // Skip past '>'
|
||||
|
||||
// Find closing tag
|
||||
char closeTag[64];
|
||||
snprintf(closeTag, sizeof(closeTag), "</%s>", tagName);
|
||||
const char* contentEnd = _findTag(contentStart, end - contentStart, closeTag);
|
||||
if (!contentEnd) return false;
|
||||
|
||||
int len = contentEnd - contentStart;
|
||||
if (len >= outBufSize) len = outBufSize - 1;
|
||||
memcpy(outBuf, contentStart, len);
|
||||
outBuf[len] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Get directory portion of a path.
|
||||
// "OEBPS/content.opf" -> "OEBPS/"
|
||||
// "content.opf" -> ""
|
||||
// ----------------------------------------------------------
|
||||
static void _getDirectory(const char* path, char* dirBuf, int dirBufSize) {
|
||||
const char* lastSlash = strrchr(path, '/');
|
||||
if (lastSlash) {
|
||||
int len = lastSlash - path + 1; // Include trailing /
|
||||
if (len >= dirBufSize) len = dirBufSize - 1;
|
||||
memcpy(dirBuf, path, len);
|
||||
dirBuf[len] = '\0';
|
||||
} else {
|
||||
dirBuf[0] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Free the chapter paths array allocated by _parseOpf().
|
||||
// ----------------------------------------------------------
|
||||
static void _freeChapterPaths(char** paths, int count) {
|
||||
if (paths) {
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (paths[i]) free(paths[i]);
|
||||
}
|
||||
free(paths);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -21,7 +21,6 @@
|
||||
#define KB_KEY_BACKSPACE '\b'
|
||||
#define KB_KEY_ENTER '\r'
|
||||
#define KB_KEY_SPACE ' '
|
||||
#define KB_KEY_EMOJI 0x01 // Non-printable code for $ key (emoji picker)
|
||||
|
||||
class TCA8418Keyboard {
|
||||
private:
|
||||
@@ -227,15 +226,9 @@ public:
|
||||
}
|
||||
|
||||
// Handle dedicated $ key (key code 22, next to M)
|
||||
// Bare press = emoji picker, Sym+$ = literal '$'
|
||||
if (keyCode == 22) {
|
||||
if (_symActive) {
|
||||
_symActive = false;
|
||||
Serial.println("KB: Sym+$ -> '$'");
|
||||
return '$';
|
||||
}
|
||||
Serial.println("KB: $ key -> emoji");
|
||||
return KB_KEY_EMOJI;
|
||||
Serial.println("KB: $ key pressed");
|
||||
return '$';
|
||||
}
|
||||
|
||||
// Handle Mic key - produces 0 with Sym, otherwise ignore
|
||||
|
||||
@@ -46,20 +46,11 @@ bool radio_init() {
|
||||
loraSpi.begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI, P_LORA_NSS);
|
||||
MESH_DEBUG_PRINTLN("radio_init() - SPI initialized, calling radio.std_init()...");
|
||||
bool result = radio.std_init(&loraSpi);
|
||||
if (result) {
|
||||
radio.setPreambleLength(32);
|
||||
MESH_DEBUG_PRINTLN("radio_init() - preamble set to 32 symbols");
|
||||
}
|
||||
MESH_DEBUG_PRINTLN("radio_init() - radio.std_init() returned: %s", result ? "SUCCESS" : "FAILED");
|
||||
return result;
|
||||
#else
|
||||
MESH_DEBUG_PRINTLN("radio_init() - calling radio.std_init() without custom SPI...");
|
||||
bool result = radio.std_init();
|
||||
if (result) {
|
||||
radio.setPreambleLength(32);
|
||||
MESH_DEBUG_PRINTLN("radio_init() - preamble set to 32 symbols");
|
||||
}
|
||||
return result;
|
||||
return radio.std_init();
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -81,8 +72,4 @@ void radio_set_tx_power(uint8_t dbm) {
|
||||
mesh::LocalIdentity radio_new_identity() {
|
||||
RadioNoiseListener rng(radio);
|
||||
return mesh::LocalIdentity(&rng);
|
||||
}
|
||||
|
||||
void radio_reset_agc() {
|
||||
radio.setRxBoostedGainMode(true);
|
||||
}
|
||||
@@ -41,5 +41,4 @@ bool radio_init();
|
||||
uint32_t radio_get_rng_seed();
|
||||
void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr);
|
||||
void radio_set_tx_power(uint8_t dbm);
|
||||
mesh::LocalIdentity radio_new_identity();
|
||||
void radio_reset_agc();
|
||||
mesh::LocalIdentity radio_new_identity();
|
||||
Reference in New Issue
Block a user