Compare commits
90 Commits
firstbuild
...
three-vers
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
306e9815b4 | ||
|
|
0a892f2dad | ||
|
|
b1e3f2ac28 | ||
|
|
4683711877 | ||
|
|
9610277b83 | ||
|
|
745efc4cc1 | ||
|
|
7223395740 | ||
|
|
9ef1fa4f1b | ||
|
|
2dd5c4f59f | ||
|
|
ee2a27258b | ||
|
|
5b868d51ca | ||
|
|
220006c229 | ||
|
|
a60f4146d5 | ||
|
|
017b170e81 | ||
|
|
9b0c13fd4c | ||
|
|
5e3a252748 | ||
|
|
6c3fb569f4 | ||
|
|
fa747bfce2 | ||
|
|
f0dc218a57 | ||
|
|
a23b65730a | ||
|
|
569794d2fe | ||
|
|
ea1ca315b8 | ||
|
|
83b3ea6275 | ||
|
|
9c6d5138b0 | ||
|
|
15165bb429 | ||
|
|
c4b9952d95 | ||
|
|
ce37bf6b90 | ||
|
|
8e98132506 | ||
|
|
33c2758a87 | ||
|
|
f644892b07 | ||
|
|
8f558b130f | ||
|
|
04462b93bc | ||
|
|
d42c283fb4 | ||
|
|
87a5f185d3 | ||
|
|
2972d1ffb4 | ||
|
|
fe1c1931ab | ||
|
|
3af2770af2 | ||
|
|
e030a61244 | ||
|
|
f630cf3a5a | ||
|
|
ac3fb337e2 | ||
|
|
1d4555a064 | ||
|
|
3d716605dc | ||
|
|
500f59abca | ||
|
|
6e60c56d48 | ||
|
|
b9a68f0f99 | ||
|
|
a8675ceda9 | ||
|
|
f20435329b | ||
|
|
33304c7bec | ||
|
|
cca984be08 | ||
|
|
a0fef8a970 | ||
|
|
9e70630727 | ||
|
|
54e74caa96 | ||
|
|
69e73440db | ||
|
|
4c4a218b32 | ||
|
|
c719df5737 | ||
|
|
57e13ecfa8 | ||
|
|
39b43bde11 | ||
|
|
89d24662ff | ||
|
|
abafefb3f7 | ||
|
|
0b94a56fae | ||
|
|
8f1a936c39 | ||
|
|
9eadb0a3fe | ||
|
|
6f23cd612c | ||
|
|
af9f41a541 | ||
|
|
0a746cdca5 | ||
|
|
3a5c48f440 | ||
|
|
e40d9ced4a | ||
|
|
b8de2d0d16 | ||
|
|
9fbc3202f6 | ||
|
|
9d91f48797 | ||
|
|
21eb385763 | ||
|
|
4b81e596d2 | ||
|
|
a5f2e8d055 | ||
|
|
462b1cb642 | ||
|
|
0b270c0e1a | ||
|
|
2730c05329 | ||
|
|
02d2fb08fb | ||
|
|
b0003e1896 | ||
|
|
0be77ef759 | ||
|
|
c5df40cefd | ||
|
|
5bdcbb25b6 | ||
|
|
53fe89b216 | ||
|
|
e194c2c48c | ||
|
|
9d401f76d3 | ||
|
|
15f392c80e | ||
|
|
621f9f9568 | ||
|
|
8c9106ca86 | ||
|
|
f4b9c89d9f | ||
|
|
a4f5328113 | ||
|
|
2ad02f49e6 |
2
.vscode/extensions.json
vendored
2
.vscode/extensions.json
vendored
@@ -1,4 +1,6 @@
|
||||
{
|
||||
// See http://go.microsoft.com/fwlink/?LinkId=827846
|
||||
// for the documentation about the extensions.json format
|
||||
"recommendations": [
|
||||
"pioarduino.pioarduino-ide",
|
||||
"platformio.platformio-ide"
|
||||
|
||||
280
README.md
280
README.md
@@ -1,24 +1,221 @@
|
||||
## Meshcore + Fork = Meck
|
||||
This fork was created specifically to focus on enabling BLE companion firmware for the LilyGo T-Deck Pro. Created with the assistance of Claude AI using Meshcore v1.11 code.
|
||||
|
||||
⭐ ***Please note as of 1 Feb 2026, the T-Deck Pro repeater & usb firmware has not been finalised nor confirmed as functioning.*** ⭐
|
||||
|
||||
### Contents
|
||||
- [T-Deck Pro Keyboard Controls](#t-deck-pro-keyboard-controls)
|
||||
- [Navigation (Home Screen)](#navigation-home-screen)
|
||||
- [Bluetooth (BLE)](#bluetooth-ble)
|
||||
- [Clock & Timezone](#clock--timezone)
|
||||
- [Channel Message Screen](#channel-message-screen)
|
||||
- [Contacts Screen](#contacts-screen)
|
||||
- [Sending a Direct Message](#sending-a-direct-message)
|
||||
- [Repeater Admin Screen](#repeater-admin-screen)
|
||||
- [Settings Screen](#settings-screen)
|
||||
- [Compose Mode](#compose-mode)
|
||||
- [Symbol Entry (Sym Key)](#symbol-entry-sym-key)
|
||||
- [Emoji Picker](#emoji-picker)
|
||||
- [About MeshCore](#about-meshcore)
|
||||
- [What is MeshCore?](#what-is-meshcore)
|
||||
- [Key Features](#key-features)
|
||||
- [What Can You Use MeshCore For?](#what-can-you-use-meshcore-for)
|
||||
- [How to Get Started](#how-to-get-started)
|
||||
- [MeshCore Flasher](#meshcore-flasher)
|
||||
- [MeshCore Clients](#meshcore-clients)
|
||||
- [Hardware Compatibility](#-hardware-compatibility)
|
||||
- [License](#-license)
|
||||
- [Contributing](#contributing)
|
||||
- [Road-Map / To-Do](#road-map--to-do)
|
||||
- [Get Support](#-get-support)
|
||||
|
||||
## T-Deck Pro Keyboard Controls
|
||||
|
||||
The T-Deck Pro BLE companion firmware includes full keyboard support for standalone messaging without a phone.
|
||||
|
||||
### Navigation (Home Screen)
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| W / A | Previous page |
|
||||
| D | Next page |
|
||||
| Enter | Select / Confirm |
|
||||
| M | Open channel messages |
|
||||
| C | Open contacts list |
|
||||
| E | Open e-book reader |
|
||||
| S | Open settings |
|
||||
| Q | Back to home screen |
|
||||
|
||||
### Bluetooth (BLE)
|
||||
|
||||
BLE is **disabled by default** at boot to support standalone-first operation. The device is fully functional without a phone — you can send and receive messages, browse contacts, read e-books, and set your timezone directly from the keyboard.
|
||||
|
||||
To connect to the MeshCore companion app, navigate to the **Bluetooth** home page (use D to page through) and press **Enter** to toggle BLE on. The BLE PIN will be displayed on screen. Toggle it off again the same way when you're done.
|
||||
|
||||
### Clock & Timezone
|
||||
|
||||
The T-Deck Pro does not include a dedicated RTC chip, so after each reboot the device clock starts unset. The clock will appear in the nav bar (between node name and battery) once the time has been synced by one of two methods:
|
||||
|
||||
1. **GPS fix** (standalone) — Once the GPS acquires a satellite fix, the time is automatically synced from the NMEA data. No phone or BLE connection required. Typical time to first fix is 30–90 seconds outdoors with clear sky.
|
||||
2. **BLE companion app** — If BLE is enabled and connected to the MeshCore companion app, the app will push the current time to the device.
|
||||
|
||||
**Setting your timezone:**
|
||||
|
||||
The UTC offset can be set from the **Settings** screen (press **S** from the home screen), or from the **GPS** home page by pressing **U** to open the UTC offset editor.
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| W | Increase offset (+1 hour) |
|
||||
| S | Decrease offset (-1 hour) |
|
||||
| Enter | Save and exit |
|
||||
| Q | Cancel and exit |
|
||||
|
||||
The UTC offset is persisted to flash and survives reboots — you only need to set it once. The valid range is UTC-12 to UTC+14. For example, AEST is UTC+10 and AEDT is UTC+11.
|
||||
|
||||
The GPS page also shows the current time, satellite count, position, altitude, and your configured UTC offset for reference.
|
||||
|
||||
### Channel Message Screen
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| W / S | Scroll messages up/down |
|
||||
| A / D | Switch between channels |
|
||||
| Enter | Compose new message |
|
||||
| Q | Back to home screen |
|
||||
|
||||
### Contacts Screen
|
||||
|
||||
Press **C** 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 | Open DM compose (Chat contact) or repeater admin (Repeater contact) |
|
||||
| Q | Back to home screen |
|
||||
|
||||
### Sending a Direct Message
|
||||
|
||||
Select a **Chat** contact in the contacts list and press **Enter** 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.
|
||||
|
||||
### Repeater Admin Screen
|
||||
|
||||
Select a **Repeater** contact in the contacts list and press **Enter** to open the repeater admin screen. You'll be prompted for the repeater's admin password. Characters briefly appear as you type them before being masked, making it easier to enter symbols and numbers on the T-Deck Pro keyboard.
|
||||
|
||||
After a successful login, you'll see a menu with the following remote administration commands:
|
||||
|
||||
| Menu Item | Description |
|
||||
|-----------|-------------|
|
||||
| Clock Sync | Push your device's clock time to the repeater |
|
||||
| Send Advert | Trigger the repeater to broadcast an advertisement |
|
||||
| Neighbors | View other repeaters heard via zero-hop adverts |
|
||||
| Get Clock | Read the repeater's current clock value |
|
||||
| Version | Query the repeater's firmware version |
|
||||
| Get Status | Retrieve repeater status information |
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| W / S | Navigate menu items |
|
||||
| Enter | Execute selected command |
|
||||
| Q | Back to contacts (from menu) or cancel login |
|
||||
|
||||
Command responses are displayed in a scrollable view. Use **W / S** to scroll long responses and **Q** to return to the menu.
|
||||
|
||||
### Settings Screen
|
||||
|
||||
Press **S** from the home screen to open settings. On first boot (when the device name is still the default hex ID), the settings screen launches automatically as an onboarding wizard to set your device name and radio preset.
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| W / S | Navigate up / down through settings |
|
||||
| Enter | Edit selected setting |
|
||||
| Q | Back to home screen |
|
||||
|
||||
**Available settings:**
|
||||
|
||||
| Setting | Edit Method |
|
||||
|---------|-------------|
|
||||
| Device Name | Text entry — type a name, Enter to confirm |
|
||||
| Radio Preset | A / D to cycle presets (MeshCore Default, Long Range, Fast/Short, EU Default), Enter to apply |
|
||||
| Frequency | W / S to adjust, Enter to confirm |
|
||||
| Bandwidth | W / S to cycle standard values (31.25 / 62.5 / 125 / 250 / 500 kHz), Enter to confirm |
|
||||
| Spreading Factor | W / S to adjust (5–12), Enter to confirm |
|
||||
| Coding Rate | W / S to adjust (5–8), Enter to confirm |
|
||||
| TX Power | W / S to adjust (1–20 dBm), Enter to confirm |
|
||||
| UTC Offset | W / S to adjust (-12 to +14), Enter to confirm |
|
||||
| Channels | View existing channels, add hashtag channels, or delete non-primary channels (X) |
|
||||
| Device Info | Public key and firmware version (read-only) |
|
||||
|
||||
When adding a hashtag channel, type the channel name and press Enter. The channel secret is automatically derived from the name via SHA-256, matching the standard MeshCore hashtag convention.
|
||||
|
||||
If you've changed radio parameters, pressing Q will prompt you to apply changes before exiting.
|
||||
|
||||
### Compose Mode
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| A / D | Switch destination channel (when message is empty, channel compose only) |
|
||||
| Enter | Send message |
|
||||
| Backspace | Delete last character |
|
||||
| Shift + Backspace | Cancel and exit compose mode |
|
||||
|
||||
### Symbol Entry (Sym Key)
|
||||
|
||||
Press the **Sym** key then the letter key to enter numbers and symbols:
|
||||
|
||||
| Key | Sym+ | | Key | Sym+ | | Key | Sym+ |
|
||||
|-----|------|-|-----|------|-|-----|------|
|
||||
| Q | # | | A | * | | Z | 7 |
|
||||
| W | 1 | | S | 4 | | X | 8 |
|
||||
| E | 2 | | D | 5 | | C | 9 |
|
||||
| R | 3 | | F | 6 | | V | ? |
|
||||
| T | ( | | G | / | | B | ! |
|
||||
| Y | ) | | H | : | | N | , |
|
||||
| U | _ | | J | ; | | M | . |
|
||||
| I | - | | K | ' | | Mic | 0 |
|
||||
| O | + | | L | " | | $ | Emoji picker (Sym+$ for literal $) |
|
||||
| P | @ | | | | | | |
|
||||
|
||||
### Other Keys
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| Shift | Uppercase next letter |
|
||||
| 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.
|
||||
|
||||
## 🔍 What is MeshCore?
|
||||
## What is MeshCore?
|
||||
|
||||
MeshCore now supports a range of LoRa devices, allowing for easy flashing without the need to compile firmware manually. Users can flash a pre-built binary using tools like Adafruit ESPTool and interact with the network through a serial console.
|
||||
MeshCore provides the ability to create wireless mesh networks, similar to Meshtastic and Reticulum but with a focus on lightweight multi-hop packet routing for embedded projects. Unlike Meshtastic, which is tailored for casual LoRa communication, or Reticulum, which offers advanced networking, MeshCore balances simplicity with scalability, making it ideal for custom embedded solutions., where devices (nodes) can communicate over long distances by relaying messages through intermediate nodes. This is especially useful in off-grid, emergency, or tactical situations where traditional communication infrastructure is unavailable.
|
||||
|
||||
## ⚡ Key Features
|
||||
## Key Features
|
||||
|
||||
* Multi-Hop Packet Routing
|
||||
* Devices can forward messages across multiple nodes, extending range beyond a single radio's reach.
|
||||
* Supports up to a configurable number of hops to balance network efficiency and prevent excessive traffic.
|
||||
* Nodes use fixed roles where "Companion" nodes are not repeating messages at all to prevent adverse routing paths from being used.
|
||||
* Supports LoRa Radios – Works with Heltec, RAK Wireless, and other LoRa-based hardware.
|
||||
* Decentralized & Resilient – No central server or internet required; the network is self-healing.
|
||||
* Low Power Consumption – Ideal for battery-powered or solar-powered devices.
|
||||
* Simple to Deploy – Pre-built example applications make it easy to get started.
|
||||
* Supports LoRa Radios — Works with Heltec, RAK Wireless, and other LoRa-based hardware.
|
||||
* Decentralized & Resilient — No central server or internet required; the network is self-healing.
|
||||
* Low Power Consumption — Ideal for battery-powered or solar-powered devices.
|
||||
* Simple to Deploy — Pre-built example applications make it easy to get started.
|
||||
|
||||
## 🎯 What Can You Use MeshCore For?
|
||||
## What Can You Use MeshCore For?
|
||||
|
||||
* Off-Grid Communication: Stay connected even in remote areas.
|
||||
* Emergency Response & Disaster Recovery: Set up instant networks where infrastructure is down.
|
||||
@@ -26,7 +223,7 @@ MeshCore provides the ability to create wireless mesh networks, similar to Mesht
|
||||
* Tactical & Security Applications: Military, law enforcement, and private security use cases.
|
||||
* IoT & Sensor Networks: Collect data from remote sensors and relay it back to a central location.
|
||||
|
||||
## 🚀 How to Get Started
|
||||
## How to Get Started
|
||||
|
||||
- Watch the [MeshCore Intro Video](https://www.youtube.com/watch?v=t1qne8uJBAc) by Andy Kirby.
|
||||
- Read through our [Frequently Asked Questions](./docs/faq.md) section.
|
||||
@@ -39,27 +236,24 @@ For developers;
|
||||
- Clone and open the MeshCore repository in Visual Studio Code.
|
||||
- See the example applications you can modify and run:
|
||||
- [Companion Radio](./examples/companion_radio) - For use with an external chat app, over BLE, USB or WiFi.
|
||||
- [Simple Repeater](./examples/simple_repeater) - Extends network coverage by relaying messages.
|
||||
- [Simple Room Server](./examples/simple_room_server) - A simple BBS server for shared Posts.
|
||||
- [Simple Secure Chat](./examples/simple_secure_chat) - Secure terminal based text communication between devices.
|
||||
|
||||
The Simple Secure Chat example can be interacted with through the Serial Monitor in Visual Studio Code, or with a Serial USB Terminal on Android.
|
||||
## MeshCore Flasher
|
||||
|
||||
## ⚡️ MeshCore Flasher
|
||||
|
||||
We have prebuilt firmware ready to flash on supported devices.
|
||||
Download a copy of the Meck firmware bin from https://github.com/pelgraine/Meck/releases, then:
|
||||
|
||||
- Launch https://flasher.meshcore.co.uk
|
||||
- Select a supported device
|
||||
- Flash one of the firmware types:
|
||||
- Companion, Repeater or Room Server
|
||||
- Select Custom Firmware
|
||||
- Select the .bin file you just downloaded, and click Open or press Enter.
|
||||
- Click Flash, then select your device in the popup window (eg. USB JTAG/serial debug unit cu.usbmodem101 as an example), then click Connect.
|
||||
- Once flashing is complete, you can connect with one of the MeshCore clients below.
|
||||
|
||||
## 📱 MeshCore Clients
|
||||
## MeshCore Clients
|
||||
|
||||
**Companion Firmware**
|
||||
|
||||
The companion firmware can be connected to via BLE, USB or WiFi depending on the firmware type you flashed.
|
||||
The companion firmware can be connected to via BLE. USB is planned for a future update.
|
||||
|
||||
> **Note:** On the T-Deck Pro, BLE is disabled by default at boot. Navigate to the Bluetooth home page and press Enter to enable BLE before connecting with a companion app.
|
||||
|
||||
- Web: https://app.meshcore.nz
|
||||
- Android: https://play.google.com/store/apps/details?id=com.liamcottle.meshcore.android
|
||||
@@ -67,14 +261,6 @@ The companion firmware can be connected to via BLE, USB or WiFi depending on the
|
||||
- NodeJS: https://github.com/liamcottle/meshcore.js
|
||||
- Python: https://github.com/fdlamotte/meshcore-cli
|
||||
|
||||
**Repeater and Room Server Firmware**
|
||||
|
||||
The repeater and room server firmwares can be setup via USB in the web config tool.
|
||||
|
||||
- https://config.meshcore.dev
|
||||
|
||||
They can also be managed via LoRa in the mobile app by using the Remote Management feature.
|
||||
|
||||
## 🛠 Hardware Compatibility
|
||||
|
||||
MeshCore is designed for devices listed in the [MeshCore Flasher](https://flasher.meshcore.co.uk)
|
||||
@@ -95,30 +281,20 @@ Here are some general principals you should try to adhere to:
|
||||
|
||||
## Road-Map / To-Do
|
||||
|
||||
There are a number of fairly major features in the pipeline, with no particular time-frames attached yet. In very rough chronological order:
|
||||
- [X] Companion radio: UI redesign
|
||||
- [ ] Repeater + Room Server: add ACL's (like Sensor Node has)
|
||||
- [ ] Standardise Bridge mode for repeaters
|
||||
- [ ] Repeater/Bridge: Standardise the Transport Codes for zoning/filtering
|
||||
- [ ] Core + Repeater: enhanced zero-hop neighbour discovery
|
||||
- [ ] Core: round-trip manual path support
|
||||
- [ ] Companion + Apps: support for multiple sub-meshes (and 'off-grid' client repeat mode)
|
||||
- [ ] Core + Apps: support for LZW message compression
|
||||
- [ ] Core: dynamic CR (Coding Rate) for weak vs strong hops
|
||||
- [ ] Core: new framework for hosting multiple virtual nodes on one physical device
|
||||
- [ ] V2 protocol spec: discussion and consensus around V2 packet protocol, including path hashes, new encryption specs, etc
|
||||
There are a number of fairly major features in the pipeline, with no particular time-frames attached yet. In partly chronological order:
|
||||
- [X] Companion radio: BLE
|
||||
- [X] Text entry for Public channel messages Companion BLE firmware
|
||||
- [X] View and compose all channel messages Companion BLE firmware
|
||||
- [X] Standalone DM functionality for Companion BLE firmware
|
||||
- [X] Contacts list with filtering for Companion BLE firmware
|
||||
- [X] Standalone repeater admin access for Companion BLE firmware
|
||||
- [X] GPS time sync with on-device timezone setting
|
||||
- [X] Settings screen with radio presets, channel management, and first-boot onboarding
|
||||
- [ ] Companion radio: USB
|
||||
- [ ] Simple Repeater firmware for the T-Deck Pro
|
||||
- [ ] Get pin 45 with the screen backlight functioning for the T-Deck Pro v1.1
|
||||
- [ ] Canned messages function for Companion BLE firmware
|
||||
|
||||
## 📞 Get Support
|
||||
|
||||
- Report bugs and request features on the [GitHub Issues](https://github.com/ripplebiz/MeshCore/issues) page.
|
||||
- Find additional guides and components on [my site](https://buymeacoffee.com/ripplebiz).
|
||||
- Join [MeshCore Discord](https://discord.gg/BMwCtwHj5V) to chat with the developers and get help from the community.
|
||||
|
||||
## RAK Wireless Board Support in PlatformIO
|
||||
|
||||
Before building/flashing the RAK4631 targets in this project, there is, unfortunately, some patching you have to do to your platformIO packages to make it work. There is a guide here on the process:
|
||||
[RAK Wireless: How to Perform Installation of Board Support Package in PlatformIO](https://learn.rakwireless.com/hc/en-us/articles/26687276346775-How-To-Perform-Installation-of-Board-Support-Package-in-PlatformIO)
|
||||
|
||||
After building, you will need to convert the output firmware.hex file into a .uf2 file you can copy over to your RAK4631 device (after doing a full erase) by using the command `uf2conv.py -f 0xADA52840 -c firmware.hex` with the python script available from:
|
||||
[GitHub: Microsoft - uf2](https://github.com/Microsoft/uf2/blob/master/utils/uf2conv.py)
|
||||
|
||||
- Join [MeshCore Discord](https://discord.gg/BMwCtwHj5V) to chat with the developers and get help from the community.
|
||||
116
TXT & EPUB Reader Guide.md
Normal file
116
TXT & EPUB Reader Guide.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# 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
|
||||
|
||||
**Key Mapping:**
|
||||
| Context | Key | Action |
|
||||
|---------|-----|--------|
|
||||
| Home screen | E | 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
|
||||
@@ -43,4 +43,11 @@ public:
|
||||
virtual void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) = 0;
|
||||
virtual void notify(UIEventType t = UIEventType::none) = 0;
|
||||
virtual void loop() = 0;
|
||||
};
|
||||
virtual void showAlert(const char* text, int duration_millis) {}
|
||||
virtual void forceRefresh() {}
|
||||
virtual void addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) {}
|
||||
|
||||
// Repeater admin callbacks (from MyMesh)
|
||||
virtual void onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) {}
|
||||
virtual void onAdminCliResponse(const char* from_name, const char* text) {}
|
||||
};
|
||||
@@ -228,6 +228,7 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
|
||||
file.read((uint8_t *)&_prefs.gps_enabled, sizeof(_prefs.gps_enabled)); // 85
|
||||
file.read((uint8_t *)&_prefs.gps_interval, sizeof(_prefs.gps_interval)); // 86
|
||||
file.read((uint8_t *)&_prefs.autoadd_config, sizeof(_prefs.autoadd_config)); // 87
|
||||
file.read((uint8_t *)&_prefs.utc_offset_hours, sizeof(_prefs.utc_offset_hours)); // 88
|
||||
|
||||
file.close();
|
||||
}
|
||||
@@ -263,6 +264,7 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
|
||||
file.write((uint8_t *)&_prefs.gps_enabled, sizeof(_prefs.gps_enabled)); // 85
|
||||
file.write((uint8_t *)&_prefs.gps_interval, sizeof(_prefs.gps_interval)); // 86
|
||||
file.write((uint8_t *)&_prefs.autoadd_config, sizeof(_prefs.autoadd_config)); // 87
|
||||
file.write((uint8_t *)&_prefs.utc_offset_hours, sizeof(_prefs.utc_offset_hours)); // 88
|
||||
|
||||
file.close();
|
||||
}
|
||||
@@ -598,4 +600,4 @@ bool DataStore::putBlobByKey(const uint8_t key[], int key_len, const uint8_t src
|
||||
}
|
||||
return false; // error
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -446,9 +446,37 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe
|
||||
}
|
||||
|
||||
bool MyMesh::filterRecvFloodPacket(mesh::Packet* packet) {
|
||||
// REVISIT: try to determine which Region (from transport_codes[1]) that Sender is indicating for replies/responses
|
||||
// if unknown, fallback to finding Region from transport_codes[0], the 'scope' used by Sender
|
||||
return false;
|
||||
// Check if this incoming flood packet is a repeat of a message we recently sent
|
||||
if (packet->payload_len >= SENT_FINGERPRINT_SIZE) {
|
||||
unsigned long now = millis();
|
||||
for (int i = 0; i < SENT_TRACK_SIZE; i++) {
|
||||
SentMsgTrack* t = &_sent_track[i];
|
||||
if (!t->active) continue;
|
||||
|
||||
// Expire old entries
|
||||
if ((now - t->sent_millis) > SENT_TRACK_EXPIRY_MS) {
|
||||
t->active = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compare payload fingerprint
|
||||
if (memcmp(packet->payload, t->fingerprint, SENT_FINGERPRINT_SIZE) == 0) {
|
||||
t->repeat_count++;
|
||||
MESH_DEBUG_PRINTLN("SentTrack: heard repeat #%d (SNR=%.1f)", t->repeat_count, packet->getSNR());
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
if (_ui) {
|
||||
char buf[40];
|
||||
snprintf(buf, sizeof(buf), "Sent! (%d)", t->repeat_count);
|
||||
_ui->showAlert(buf, 2000); // show/extend alert with updated count
|
||||
}
|
||||
#endif
|
||||
break; // found match, no need to check other entries
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false; // never filter  let normal processing continue
|
||||
}
|
||||
|
||||
void MyMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis) {
|
||||
@@ -463,6 +491,17 @@ void MyMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, ui
|
||||
}
|
||||
}
|
||||
void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis) {
|
||||
// Capture payload fingerprint for repeat tracking before sending
|
||||
if (pkt->payload_len >= SENT_FINGERPRINT_SIZE) {
|
||||
SentMsgTrack* t = &_sent_track[_sent_track_idx];
|
||||
memcpy(t->fingerprint, pkt->payload, SENT_FINGERPRINT_SIZE);
|
||||
t->repeat_count = 0;
|
||||
t->sent_millis = millis();
|
||||
t->active = true;
|
||||
_sent_track_idx = (_sent_track_idx + 1) % SENT_TRACK_SIZE;
|
||||
MESH_DEBUG_PRINTLN("SentTrack: captured fingerprint for channel msg");
|
||||
}
|
||||
|
||||
// TODO: have per-channel send_scope
|
||||
if (send_scope.isNull()) {
|
||||
sendFlood(pkt, delay_millis);
|
||||
@@ -541,6 +580,76 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe
|
||||
#endif
|
||||
}
|
||||
|
||||
void MyMesh::queueSentChannelMessage(uint8_t channel_idx, uint32_t timestamp, const char* sender, const char* text) {
|
||||
// Format message the same way as onChannelMessageRecv for BLE app sync
|
||||
// This allows sent messages from device keyboard to appear in the app
|
||||
int i = 0;
|
||||
if (app_target_ver >= 3) {
|
||||
out_frame[i++] = RESP_CODE_CHANNEL_MSG_RECV_V3;
|
||||
out_frame[i++] = 0; // SNR not applicable for sent messages
|
||||
out_frame[i++] = 0; // reserved1
|
||||
out_frame[i++] = 0; // reserved2
|
||||
} else {
|
||||
out_frame[i++] = RESP_CODE_CHANNEL_MSG_RECV;
|
||||
}
|
||||
|
||||
out_frame[i++] = channel_idx;
|
||||
out_frame[i++] = 0; // path_len = 0 indicates local/sent message
|
||||
|
||||
out_frame[i++] = TXT_TYPE_PLAIN;
|
||||
memcpy(&out_frame[i], ×tamp, 4);
|
||||
i += 4;
|
||||
|
||||
// Format as "sender: text" like the app expects
|
||||
char formatted[MAX_FRAME_SIZE];
|
||||
snprintf(formatted, sizeof(formatted), "%s: %s", sender, text);
|
||||
int tlen = strlen(formatted);
|
||||
if (i + tlen > MAX_FRAME_SIZE) {
|
||||
tlen = MAX_FRAME_SIZE - i;
|
||||
}
|
||||
memcpy(&out_frame[i], formatted, tlen);
|
||||
i += tlen;
|
||||
|
||||
addToOfflineQueue(out_frame, i);
|
||||
|
||||
// If app is connected, send push notification
|
||||
if (_serial->isConnected()) {
|
||||
uint8_t frame[1];
|
||||
frame[0] = PUSH_CODE_MSG_WAITING;
|
||||
_serial->writeFrame(frame, 1);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -786,6 +895,8 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
|
||||
dirty_contacts_expiry = 0;
|
||||
memset(advert_paths, 0, sizeof(advert_paths));
|
||||
memset(send_scope.key, 0, sizeof(send_scope.key));
|
||||
memset(_sent_track, 0, sizeof(_sent_track));
|
||||
_sent_track_idx = 0;
|
||||
|
||||
// defaults
|
||||
memset(&_prefs, 0, sizeof(_prefs));
|
||||
@@ -839,6 +950,7 @@ void MyMesh::begin(bool has_display) {
|
||||
_prefs.buzzer_quiet = constrain(_prefs.buzzer_quiet, 0, 1); // Ensure boolean 0 or 1
|
||||
_prefs.gps_enabled = constrain(_prefs.gps_enabled, 0, 1); // Ensure boolean 0 or 1
|
||||
_prefs.gps_interval = constrain(_prefs.gps_interval, 0, 86400); // Max 24 hours
|
||||
_prefs.utc_offset_hours = constrain(_prefs.utc_offset_hours, -12, 14); // Valid timezone range
|
||||
|
||||
#ifdef BLE_PIN_CODE // 123456 by default
|
||||
if (_prefs.ble_pin == 0) {
|
||||
@@ -1622,6 +1734,12 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
savePrefs();
|
||||
}
|
||||
#endif
|
||||
// UTC offset for local clock display (works regardless of GPS)
|
||||
if (strcmp(sp, "utc_offset") == 0) {
|
||||
int offset = atoi(np);
|
||||
_prefs.utc_offset_hours = constrain(offset, -12, 14);
|
||||
savePrefs();
|
||||
}
|
||||
writeOKFrame();
|
||||
} else {
|
||||
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
||||
@@ -1979,4 +2097,4 @@ bool MyMesh::advert() {
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,11 @@
|
||||
#define FIRMWARE_VER_CODE 8
|
||||
|
||||
#ifndef FIRMWARE_BUILD_DATE
|
||||
#define FIRMWARE_BUILD_DATE "28 Jan 2026"
|
||||
#define FIRMWARE_BUILD_DATE "14 Feb 2026"
|
||||
#endif
|
||||
|
||||
#ifndef FIRMWARE_VERSION
|
||||
#define FIRMWARE_VERSION "Meck v0.3"
|
||||
#define FIRMWARE_VERSION "Meck v0.8.7"
|
||||
#endif
|
||||
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
@@ -101,6 +101,18 @@ public:
|
||||
void enterCLIRescue();
|
||||
|
||||
int getRecentlyHeard(AdvertPath dest[], int max_num);
|
||||
|
||||
// Queue a sent channel message for BLE app sync
|
||||
void queueSentChannelMessage(uint8_t channel_idx, uint32_t timestamp, const char* sender, const char* text);
|
||||
|
||||
// Send a direct message from the UI (no BLE dependency)
|
||||
bool uiSendDirectMessage(uint32_t contact_idx, const char* text);
|
||||
|
||||
// Repeater admin - UI-initiated operations
|
||||
bool uiLoginToRepeater(uint32_t contact_idx, const char* password);
|
||||
bool uiSendCliCommand(uint32_t contact_idx, const char* command);
|
||||
int getAdminContactIdx() const { return _admin_contact_idx; }
|
||||
|
||||
|
||||
protected:
|
||||
float getAirtimeBudgetFactor() const override;
|
||||
@@ -158,6 +170,12 @@ protected:
|
||||
|
||||
public:
|
||||
void savePrefs() { _store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon); }
|
||||
void saveChannels() {
|
||||
_store->saveChannels(this);
|
||||
}
|
||||
void saveContacts() {
|
||||
_store->saveContacts(this);
|
||||
}
|
||||
|
||||
private:
|
||||
void writeOKFrame();
|
||||
@@ -177,10 +195,6 @@ private:
|
||||
void checkCLIRescueCmd();
|
||||
void checkSerialInterface();
|
||||
|
||||
// helpers, short-cuts
|
||||
void saveChannels() { _store->saveChannels(this); }
|
||||
void saveContacts() { _store->saveContacts(this); }
|
||||
|
||||
DataStore* _store;
|
||||
NodePrefs _prefs;
|
||||
uint32_t pending_login;
|
||||
@@ -228,6 +242,20 @@ private:
|
||||
|
||||
#define ADVERT_PATH_TABLE_SIZE 16
|
||||
AdvertPath advert_paths[ADVERT_PATH_TABLE_SIZE]; // circular table
|
||||
|
||||
// Sent message repeat tracking
|
||||
#define SENT_TRACK_SIZE 4
|
||||
#define SENT_FINGERPRINT_SIZE 12
|
||||
#define SENT_TRACK_EXPIRY_MS 30000 // stop tracking after 30 seconds
|
||||
struct SentMsgTrack {
|
||||
uint8_t fingerprint[SENT_FINGERPRINT_SIZE];
|
||||
uint8_t repeat_count;
|
||||
unsigned long sent_millis;
|
||||
bool active;
|
||||
};
|
||||
SentMsgTrack _sent_track[SENT_TRACK_SIZE];
|
||||
int _sent_track_idx; // next slot in circular buffer
|
||||
int _admin_contact_idx; // contact index for active admin session (-1 if none)
|
||||
};
|
||||
|
||||
extern MyMesh the_mesh;
|
||||
extern MyMesh the_mesh;
|
||||
@@ -28,4 +28,5 @@ struct NodePrefs { // persisted to file
|
||||
uint8_t gps_enabled; // GPS enabled flag (0=disabled, 1=enabled)
|
||||
uint32_t gps_interval; // GPS read interval in seconds
|
||||
uint8_t autoadd_config; // bitmask for auto-add contacts config
|
||||
int8_t utc_offset_hours; // UTC offset in hours (-12 to +14), default 0
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
551
examples/companion_radio/ui-new/ChannelScreen.h
Normal file
551
examples/companion_radio/ui-new/ChannelScreen.h
Normal file
@@ -0,0 +1,551 @@
|
||||
#pragma once
|
||||
|
||||
#include <helpers/ui/UIScreen.h>
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include <helpers/ChannelDetails.h>
|
||||
#include <MeshCore.h>
|
||||
#include "EmojiSprites.h"
|
||||
|
||||
// SD card message persistence
|
||||
#if defined(HAS_SDCARD) && defined(ESP32)
|
||||
#include <SD.h>
|
||||
#endif
|
||||
|
||||
// Maximum messages to store in history
|
||||
#define CHANNEL_MSG_HISTORY_SIZE 300
|
||||
#define CHANNEL_MSG_TEXT_LEN 160
|
||||
|
||||
#ifndef MAX_GROUP_CHANNELS
|
||||
#define MAX_GROUP_CHANNELS 20
|
||||
#endif
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// On-disk format for message persistence (SD card)
|
||||
// ---------------------------------------------------------------------------
|
||||
#define MSG_FILE_MAGIC 0x4D434853 // "MCHS" - MeshCore History Store
|
||||
#define MSG_FILE_VERSION 1
|
||||
#define MSG_FILE_PATH "/meshcore/messages.bin"
|
||||
|
||||
struct __attribute__((packed)) MsgFileHeader {
|
||||
uint32_t magic;
|
||||
uint16_t version;
|
||||
uint16_t capacity;
|
||||
uint16_t count;
|
||||
int16_t newestIdx;
|
||||
// 12 bytes total
|
||||
};
|
||||
|
||||
struct __attribute__((packed)) MsgFileRecord {
|
||||
uint32_t timestamp;
|
||||
uint8_t path_len;
|
||||
uint8_t channel_idx;
|
||||
uint8_t valid;
|
||||
uint8_t reserved;
|
||||
char text[CHANNEL_MSG_TEXT_LEN];
|
||||
// 168 bytes total
|
||||
};
|
||||
|
||||
class UITask; // Forward declaration
|
||||
class MyMesh; // Forward declaration
|
||||
extern MyMesh the_mesh;
|
||||
|
||||
class ChannelScreen : public UIScreen {
|
||||
public:
|
||||
struct ChannelMessage {
|
||||
uint32_t timestamp;
|
||||
uint8_t path_len;
|
||||
uint8_t channel_idx; // Which channel this message belongs to
|
||||
char text[CHANNEL_MSG_TEXT_LEN];
|
||||
bool valid;
|
||||
};
|
||||
|
||||
private:
|
||||
UITask* _task;
|
||||
mesh::RTCClock* _rtc;
|
||||
|
||||
ChannelMessage _messages[CHANNEL_MSG_HISTORY_SIZE];
|
||||
int _msgCount; // Total messages stored
|
||||
int _newestIdx; // Index of newest message (circular buffer)
|
||||
int _scrollPos; // Current scroll position (0 = newest)
|
||||
int _msgsPerPage; // Messages that fit on screen
|
||||
uint8_t _viewChannelIdx; // Which channel we're currently viewing
|
||||
bool _sdReady; // SD card is available for persistence
|
||||
|
||||
public:
|
||||
ChannelScreen(UITask* task, mesh::RTCClock* rtc)
|
||||
: _task(task), _rtc(rtc), _msgCount(0), _newestIdx(-1), _scrollPos(0),
|
||||
_msgsPerPage(CHANNEL_MSG_HISTORY_SIZE), _viewChannelIdx(0), _sdReady(false) {
|
||||
// Initialize all messages as invalid
|
||||
for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) {
|
||||
_messages[i].valid = false;
|
||||
}
|
||||
}
|
||||
|
||||
void setSDReady(bool ready) { _sdReady = ready; }
|
||||
|
||||
// Add a new message to the history
|
||||
void addMessage(uint8_t channel_idx, uint8_t path_len, const char* sender, const char* text) {
|
||||
// Move to next slot in circular buffer
|
||||
_newestIdx = (_newestIdx + 1) % CHANNEL_MSG_HISTORY_SIZE;
|
||||
|
||||
ChannelMessage* msg = &_messages[_newestIdx];
|
||||
msg->timestamp = _rtc->getCurrentTime();
|
||||
msg->path_len = path_len;
|
||||
msg->channel_idx = channel_idx;
|
||||
msg->valid = true;
|
||||
|
||||
// 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);
|
||||
|
||||
if (_msgCount < CHANNEL_MSG_HISTORY_SIZE) {
|
||||
_msgCount++;
|
||||
}
|
||||
|
||||
// Reset scroll to show newest message
|
||||
_scrollPos = 0;
|
||||
|
||||
// Persist to SD card
|
||||
saveToSD();
|
||||
}
|
||||
|
||||
// Get count of messages for the currently viewed channel
|
||||
int getMessageCountForChannel() const {
|
||||
int count = 0;
|
||||
for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) {
|
||||
if (_messages[i].valid && _messages[i].channel_idx == _viewChannelIdx) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
int getMessageCount() const { return _msgCount; }
|
||||
|
||||
uint8_t getViewChannelIdx() const { return _viewChannelIdx; }
|
||||
void setViewChannelIdx(uint8_t idx) { _viewChannelIdx = idx; _scrollPos = 0; }
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// SD card persistence
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Save the entire message buffer to SD card.
|
||||
// File: /meshcore/messages.bin (~50 KB for 300 messages)
|
||||
void saveToSD() {
|
||||
#if defined(HAS_SDCARD) && defined(ESP32)
|
||||
if (!_sdReady) return;
|
||||
|
||||
// Ensure directory exists
|
||||
if (!SD.exists("/meshcore")) {
|
||||
SD.mkdir("/meshcore");
|
||||
}
|
||||
|
||||
File f = SD.open(MSG_FILE_PATH, "w", true);
|
||||
if (!f) {
|
||||
Serial.println("ChannelScreen: SD save failed - can't open file");
|
||||
return;
|
||||
}
|
||||
|
||||
// Write header
|
||||
MsgFileHeader hdr;
|
||||
hdr.magic = MSG_FILE_MAGIC;
|
||||
hdr.version = MSG_FILE_VERSION;
|
||||
hdr.capacity = CHANNEL_MSG_HISTORY_SIZE;
|
||||
hdr.count = (uint16_t)_msgCount;
|
||||
hdr.newestIdx = (int16_t)_newestIdx;
|
||||
f.write((uint8_t*)&hdr, sizeof(hdr));
|
||||
|
||||
// Write all message slots (including invalid ones - preserves circular buffer layout)
|
||||
for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) {
|
||||
MsgFileRecord rec;
|
||||
rec.timestamp = _messages[i].timestamp;
|
||||
rec.path_len = _messages[i].path_len;
|
||||
rec.channel_idx = _messages[i].channel_idx;
|
||||
rec.valid = _messages[i].valid ? 1 : 0;
|
||||
rec.reserved = 0;
|
||||
memcpy(rec.text, _messages[i].text, CHANNEL_MSG_TEXT_LEN);
|
||||
f.write((uint8_t*)&rec, sizeof(rec));
|
||||
}
|
||||
|
||||
f.close();
|
||||
digitalWrite(SDCARD_CS, HIGH); // Release SD CS
|
||||
#endif
|
||||
}
|
||||
|
||||
// Load message buffer from SD card. Returns true if messages were loaded.
|
||||
bool loadFromSD() {
|
||||
#if defined(HAS_SDCARD) && defined(ESP32)
|
||||
if (!_sdReady) return false;
|
||||
|
||||
if (!SD.exists(MSG_FILE_PATH)) {
|
||||
Serial.println("ChannelScreen: No saved messages on SD");
|
||||
return false;
|
||||
}
|
||||
|
||||
File f = SD.open(MSG_FILE_PATH, "r");
|
||||
if (!f) {
|
||||
Serial.println("ChannelScreen: SD load failed - can't open file");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read and validate header
|
||||
MsgFileHeader hdr;
|
||||
if (f.read((uint8_t*)&hdr, sizeof(hdr)) != sizeof(hdr)) {
|
||||
Serial.println("ChannelScreen: SD load failed - short header");
|
||||
f.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hdr.magic != MSG_FILE_MAGIC) {
|
||||
Serial.printf("ChannelScreen: SD load failed - bad magic 0x%08X\n", hdr.magic);
|
||||
f.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hdr.version != MSG_FILE_VERSION) {
|
||||
Serial.printf("ChannelScreen: SD load failed - version %d (expected %d)\n",
|
||||
hdr.version, MSG_FILE_VERSION);
|
||||
f.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hdr.capacity != CHANNEL_MSG_HISTORY_SIZE) {
|
||||
Serial.printf("ChannelScreen: SD load failed - capacity %d (expected %d)\n",
|
||||
hdr.capacity, CHANNEL_MSG_HISTORY_SIZE);
|
||||
f.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read message records
|
||||
int loaded = 0;
|
||||
for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) {
|
||||
MsgFileRecord rec;
|
||||
if (f.read((uint8_t*)&rec, sizeof(rec)) != sizeof(rec)) {
|
||||
Serial.printf("ChannelScreen: SD load - short read at record %d\n", i);
|
||||
break;
|
||||
}
|
||||
_messages[i].timestamp = rec.timestamp;
|
||||
_messages[i].path_len = rec.path_len;
|
||||
_messages[i].channel_idx = rec.channel_idx;
|
||||
_messages[i].valid = (rec.valid != 0);
|
||||
memcpy(_messages[i].text, rec.text, CHANNEL_MSG_TEXT_LEN);
|
||||
if (_messages[i].valid) loaded++;
|
||||
}
|
||||
|
||||
_msgCount = (int)hdr.count;
|
||||
_newestIdx = (int)hdr.newestIdx;
|
||||
_scrollPos = 0;
|
||||
|
||||
// Sanity-check restored state
|
||||
if (_newestIdx < -1 || _newestIdx >= CHANNEL_MSG_HISTORY_SIZE) _newestIdx = -1;
|
||||
if (_msgCount < 0 || _msgCount > CHANNEL_MSG_HISTORY_SIZE) _msgCount = loaded;
|
||||
|
||||
f.close();
|
||||
digitalWrite(SDCARD_CS, HIGH); // Release SD CS
|
||||
Serial.printf("ChannelScreen: Loaded %d messages from SD (count=%d, newest=%d)\n",
|
||||
loaded, _msgCount, _newestIdx);
|
||||
return loaded > 0;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Rendering
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
char tmp[40];
|
||||
|
||||
// Header - show current channel name
|
||||
display.setCursor(0, 0);
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
|
||||
// Get channel name
|
||||
ChannelDetails channel;
|
||||
if (the_mesh.getChannel(_viewChannelIdx, channel)) {
|
||||
display.print(channel.name);
|
||||
} else {
|
||||
sprintf(tmp, "Channel %d", _viewChannelIdx);
|
||||
display.print(tmp);
|
||||
}
|
||||
|
||||
// Message count for this channel on right
|
||||
int channelMsgCount = getMessageCountForChannel();
|
||||
sprintf(tmp, "[%d]", channelMsgCount);
|
||||
display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0);
|
||||
display.print(tmp);
|
||||
|
||||
// Divider line
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
if (channelMsgCount == 0) {
|
||||
display.setTextSize(0); // Tiny font for body text
|
||||
display.setCursor(0, 20);
|
||||
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("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 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;
|
||||
|
||||
int y = headerHeight;
|
||||
|
||||
// Build list of messages for this channel (newest first)
|
||||
int channelMsgs[CHANNEL_MSG_HISTORY_SIZE];
|
||||
int numChannelMsgs = 0;
|
||||
|
||||
for (int i = 0; i < _msgCount && numChannelMsgs < CHANNEL_MSG_HISTORY_SIZE; i++) {
|
||||
int idx = _newestIdx - i;
|
||||
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
|
||||
idx = idx % CHANNEL_MSG_HISTORY_SIZE;
|
||||
|
||||
if (_messages[idx].valid && _messages[idx].channel_idx == _viewChannelIdx) {
|
||||
channelMsgs[numChannelMsgs++] = idx;
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
int msgsDrawn = 0;
|
||||
bool screenFull = false;
|
||||
for (int i = startIdx; i < numChannelMsgs && y + lineHeight <= maxY; i++) {
|
||||
int idx = channelMsgs[i];
|
||||
ChannelMessage* msg = &_messages[idx];
|
||||
|
||||
// Time indicator with hop count - inline on same line as message start
|
||||
display.setCursor(0, y);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
|
||||
uint32_t age = _rtc->getCurrentTime() - msg->timestamp;
|
||||
if (age < 60) {
|
||||
sprintf(tmp, "(%d) %ds ", msg->path_len == 0xFF ? 0 : msg->path_len, age);
|
||||
} else if (age < 3600) {
|
||||
sprintf(tmp, "(%d) %dm ", msg->path_len == 0xFF ? 0 : msg->path_len, age / 60);
|
||||
} else if (age < 86400) {
|
||||
sprintf(tmp, "(%d) %dh ", msg->path_len == 0xFF ? 0 : msg->path_len, age / 3600);
|
||||
} else {
|
||||
sprintf(tmp, "(%d) %dd ", msg->path_len == 0xFF ? 0 : msg->path_len, age / 86400);
|
||||
}
|
||||
display.print(tmp);
|
||||
// DO NOT advance y - message text continues on the same line
|
||||
|
||||
// Message text with character wrapping and inline emoji support
|
||||
// (continues after timestamp on first line)
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
|
||||
int textLen = strlen(msg->text);
|
||||
int pos = 0;
|
||||
int linesForThisMsg = 0;
|
||||
int maxLinesPerMsg = 8;
|
||||
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};
|
||||
|
||||
while (pos < textLen && linesForThisMsg < maxLinesPerMsg && y + lineHeight <= maxY) {
|
||||
uint8_t b = (uint8_t)msg->text[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 (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) {
|
||||
y += lineHeight;
|
||||
}
|
||||
|
||||
y += 2; // Small gap between messages
|
||||
msgsDrawn++;
|
||||
if (y + lineHeight > maxY) screenFull = true;
|
||||
}
|
||||
|
||||
// Only update _msgsPerPage when the screen actually filled up.
|
||||
// If we ran out of messages before filling the screen, keep the
|
||||
// previous (higher) value so startIdx doesn't under-count.
|
||||
if (screenFull && msgsDrawn > 0) {
|
||||
_msgsPerPage = msgsDrawn;
|
||||
}
|
||||
|
||||
display.setTextSize(1); // Restore for footer
|
||||
}
|
||||
|
||||
// Footer with controls
|
||||
int footerY = display.height() - 12;
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setCursor(0, footerY);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
|
||||
// Left side: Q:Back A/D:Ch
|
||||
display.print("Q:Back A/D:Ch");
|
||||
|
||||
// Right side: Entr:New
|
||||
const char* rightText = "Entr:New";
|
||||
display.setCursor(display.width() - display.getTextWidth(rightText) - 2, footerY);
|
||||
display.print(rightText);
|
||||
|
||||
#if AUTO_OFF_MILLIS == 0 // e-ink
|
||||
return 5000;
|
||||
#else
|
||||
return 1000;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
int channelMsgCount = getMessageCountForChannel();
|
||||
|
||||
// W or KEY_PREV - scroll up (older messages)
|
||||
if (c == 0xF2 || c == 'w' || c == 'W') {
|
||||
if (_scrollPos + _msgsPerPage < channelMsgCount) {
|
||||
_scrollPos++;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// S or KEY_NEXT - scroll down (newer messages)
|
||||
if (c == 0xF1 || c == 's' || c == 'S') {
|
||||
if (_scrollPos > 0) {
|
||||
_scrollPos--;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// A - previous channel
|
||||
if (c == 'a' || c == 'A') {
|
||||
if (_viewChannelIdx > 0) {
|
||||
_viewChannelIdx--;
|
||||
} else {
|
||||
// Wrap to last valid channel
|
||||
for (uint8_t i = MAX_GROUP_CHANNELS - 1; i > 0; i--) {
|
||||
ChannelDetails ch;
|
||||
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') {
|
||||
_viewChannelIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_scrollPos = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
// D - next channel
|
||||
if (c == 'd' || c == 'D') {
|
||||
ChannelDetails ch;
|
||||
uint8_t nextIdx = _viewChannelIdx + 1;
|
||||
if (the_mesh.getChannel(nextIdx, ch) && ch.name[0] != '\0') {
|
||||
_viewChannelIdx = nextIdx;
|
||||
} else {
|
||||
_viewChannelIdx = 0;
|
||||
}
|
||||
_scrollPos = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reset scroll position to newest
|
||||
void resetScroll() {
|
||||
_scrollPos = 0;
|
||||
}
|
||||
};
|
||||
348
examples/companion_radio/ui-new/Contactsscreen.h
Normal file
348
examples/companion_radio/ui-new/Contactsscreen.h
Normal file
@@ -0,0 +1,348 @@
|
||||
#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: All → total/max, filtered → matched/total
|
||||
if (_filter == FILTER_ALL) {
|
||||
snprintf(tmp, sizeof(tmp), "%d/%d", (int)the_mesh.getNumContacts(), MAX_CONTACTS);
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
};
|
||||
547
examples/companion_radio/ui-new/Emojisprites.h
Normal file
547
examples/companion_radio/ui-new/Emojisprites.h
Normal file
@@ -0,0 +1,547 @@
|
||||
#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
|
||||
{ 0x1F030, 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;
|
||||
}
|
||||
538
examples/companion_radio/ui-new/EpubZipReader.h
Normal file
538
examples/companion_radio/ui-new/EpubZipReader.h
Normal file
@@ -0,0 +1,538 @@
|
||||
#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.
|
||||
//
|
||||
// =============================================================================
|
||||
888
examples/companion_radio/ui-new/Epubprocessor.h
Normal file
888
examples/companion_radio/ui-new/Epubprocessor.h
Normal file
@@ -0,0 +1,888 @@
|
||||
#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"
|
||||
#include "Utf8CP437.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, accented chars, etc.)
|
||||
// These appear as raw bytes in XHTML. Typographic chars are mapped to ASCII;
|
||||
// accented Latin chars are preserved as UTF-8 for CP437 rendering on e-ink.
|
||||
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 displayable equivalents
|
||||
// Typographic chars → ASCII, accented chars → preserved as UTF-8
|
||||
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 if (unicodeToCP437(codepoint)) {
|
||||
// Accented character that the e-ink font can render via CP437.
|
||||
// Preserve as UTF-8 in the output; the text reader will decode
|
||||
// and map to CP437 at render time.
|
||||
if (codepoint <= 0x7FF) {
|
||||
output[outPos++] = 0xC0 | (codepoint >> 6);
|
||||
output[outPos++] = 0x80 | (codepoint & 0x3F);
|
||||
} else if (codepoint <= 0xFFFF) {
|
||||
output[outPos++] = 0xE0 | (codepoint >> 12);
|
||||
output[outPos++] = 0x80 | ((codepoint >> 6) & 0x3F);
|
||||
output[outPos++] = 0x80 | (codepoint & 0x3F);
|
||||
}
|
||||
lastWasNewline = false;
|
||||
lastWasSpace = false;
|
||||
continue; // Already wrote to output
|
||||
} 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 '"';
|
||||
|
||||
// Common accented character entities → CP437 bytes for built-in font
|
||||
if (entityLen == 6 && strncmp(entity, "eacute", 6) == 0) return (char)0x82; // é
|
||||
if (entityLen == 6 && strncmp(entity, "egrave", 6) == 0) return (char)0x8A; // è
|
||||
if (entityLen == 5 && strncmp(entity, "ecirc", 5) == 0) return (char)0x88; // ê
|
||||
if (entityLen == 4 && strncmp(entity, "euml", 4) == 0) return (char)0x89; // ë
|
||||
if (entityLen == 6 && strncmp(entity, "agrave", 6) == 0) return (char)0x85; // à
|
||||
if (entityLen == 6 && strncmp(entity, "aacute", 6) == 0) return (char)0xA0; // á
|
||||
if (entityLen == 5 && strncmp(entity, "acirc", 5) == 0) return (char)0x83; // â
|
||||
if (entityLen == 4 && strncmp(entity, "auml", 4) == 0) return (char)0x84; // ä
|
||||
if (entityLen == 6 && strncmp(entity, "ccedil", 6) == 0) return (char)0x87; // ç
|
||||
if (entityLen == 6 && strncmp(entity, "iacute", 6) == 0) return (char)0xA1; // í
|
||||
if (entityLen == 5 && strncmp(entity, "icirc", 5) == 0) return (char)0x8C; // î
|
||||
if (entityLen == 4 && strncmp(entity, "iuml", 4) == 0) return (char)0x8B; // ï
|
||||
if (entityLen == 6 && strncmp(entity, "igrave", 6) == 0) return (char)0x8D; // ì
|
||||
if (entityLen == 6 && strncmp(entity, "oacute", 6) == 0) return (char)0xA2; // ó
|
||||
if (entityLen == 5 && strncmp(entity, "ocirc", 5) == 0) return (char)0x93; // ô
|
||||
if (entityLen == 4 && strncmp(entity, "ouml", 4) == 0) return (char)0x94; // ö
|
||||
if (entityLen == 6 && strncmp(entity, "ograve", 6) == 0) return (char)0x95; // ò
|
||||
if (entityLen == 6 && strncmp(entity, "uacute", 6) == 0) return (char)0xA3; // ú
|
||||
if (entityLen == 5 && strncmp(entity, "ucirc", 5) == 0) return (char)0x96; // û
|
||||
if (entityLen == 4 && strncmp(entity, "uuml", 4) == 0) return (char)0x81; // ü
|
||||
if (entityLen == 6 && strncmp(entity, "ugrave", 6) == 0) return (char)0x97; // ù
|
||||
if (entityLen == 6 && strncmp(entity, "ntilde", 6) == 0) return (char)0xA4; // ñ
|
||||
if (entityLen == 6 && strncmp(entity, "Eacute", 6) == 0) return (char)0x90; // É
|
||||
if (entityLen == 6 && strncmp(entity, "Ccedil", 6) == 0) return (char)0x80; // Ç
|
||||
if (entityLen == 6 && strncmp(entity, "Ntilde", 6) == 0) return (char)0xA5; // Ñ
|
||||
if (entityLen == 4 && strncmp(entity, "Auml", 4) == 0) return (char)0x8E; // Ä
|
||||
if (entityLen == 4 && strncmp(entity, "Ouml", 4) == 0) return (char)0x99; // Ö
|
||||
if (entityLen == 4 && strncmp(entity, "Uuml", 4) == 0) return (char)0x9A; // Ü
|
||||
if (entityLen == 5 && strncmp(entity, "szlig", 5) == 0) return (char)0xE1; // ß
|
||||
|
||||
// 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 displayable character (best effort)
|
||||
if (codepoint >= 32 && codepoint < 127) return (char)codepoint;
|
||||
if (codepoint == 160) return ' '; // non-breaking space
|
||||
// Try CP437 mapping for accented characters.
|
||||
// The byte value will be passed through to the built-in font.
|
||||
uint8_t cp437 = unicodeToCP437(codepoint);
|
||||
if (cp437) return (char)cp437;
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
1299
examples/companion_radio/ui-new/Notesscreen.h
Normal file
1299
examples/companion_radio/ui-new/Notesscreen.h
Normal file
File diff suppressed because it is too large
Load Diff
615
examples/companion_radio/ui-new/Repeateradminscreen.h
Normal file
615
examples/companion_radio/ui-new/Repeateradminscreen.h
Normal file
@@ -0,0 +1,615 @@
|
||||
#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;
|
||||
|
||||
#define ADMIN_PASSWORD_MAX 32
|
||||
#define ADMIN_RESPONSE_MAX 512 // CLI responses can be multi-line
|
||||
#define ADMIN_TIMEOUT_MS 15000 // 15s timeout for login/commands
|
||||
|
||||
class RepeaterAdminScreen : public UIScreen {
|
||||
public:
|
||||
enum AdminState {
|
||||
STATE_PASSWORD_ENTRY, // Typing admin password
|
||||
STATE_LOGGING_IN, // Waiting for login response
|
||||
STATE_MENU, // Main admin menu
|
||||
STATE_COMMAND_PENDING, // Waiting for CLI response
|
||||
STATE_RESPONSE_VIEW, // Displaying CLI response
|
||||
STATE_ERROR // Error state (timeout, send fail)
|
||||
};
|
||||
|
||||
// Menu items
|
||||
enum MenuItem {
|
||||
MENU_CLOCK_SYNC = 0,
|
||||
MENU_ADVERT,
|
||||
MENU_NEIGHBORS,
|
||||
MENU_GET_CLOCK,
|
||||
MENU_GET_VER,
|
||||
MENU_GET_STATUS,
|
||||
MENU_COUNT
|
||||
};
|
||||
|
||||
private:
|
||||
UITask* _task;
|
||||
mesh::RTCClock* _rtc;
|
||||
|
||||
AdminState _state;
|
||||
int _contactIdx; // Contact table index of the repeater
|
||||
char _repeaterName[32]; // Cached repeater name
|
||||
uint8_t _permissions; // Login permissions (0=guest, 3=admin)
|
||||
uint32_t _serverTime; // Server timestamp from login response
|
||||
|
||||
// Password entry
|
||||
char _password[ADMIN_PASSWORD_MAX];
|
||||
int _pwdLen;
|
||||
unsigned long _lastCharAt; // millis() when last char typed (for brief reveal)
|
||||
|
||||
// Menu
|
||||
int _menuSel; // Currently selected menu item
|
||||
|
||||
// Response buffer
|
||||
char _response[ADMIN_RESPONSE_MAX];
|
||||
int _responseLen;
|
||||
int _responseScroll; // Scroll offset for long responses
|
||||
|
||||
// Timing
|
||||
unsigned long _cmdSentAt; // millis() when command was sent
|
||||
bool _waitingForLogin;
|
||||
|
||||
// Password cache - remembers passwords per repeater within session
|
||||
static const int PWD_CACHE_SIZE = 8;
|
||||
struct PwdCacheEntry {
|
||||
int contactIdx;
|
||||
char password[ADMIN_PASSWORD_MAX];
|
||||
};
|
||||
PwdCacheEntry _pwdCache[PWD_CACHE_SIZE];
|
||||
int _pwdCacheCount;
|
||||
|
||||
// Look up cached password for a contact, returns nullptr if not found
|
||||
const char* getCachedPassword(int contactIdx) {
|
||||
for (int i = 0; i < _pwdCacheCount; i++) {
|
||||
if (_pwdCache[i].contactIdx == contactIdx) return _pwdCache[i].password;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Save password to cache (update existing or add new, evict oldest if full)
|
||||
void cachePassword(int contactIdx, const char* pwd) {
|
||||
// Update existing entry
|
||||
for (int i = 0; i < _pwdCacheCount; i++) {
|
||||
if (_pwdCache[i].contactIdx == contactIdx) {
|
||||
strncpy(_pwdCache[i].password, pwd, ADMIN_PASSWORD_MAX - 1);
|
||||
_pwdCache[i].password[ADMIN_PASSWORD_MAX - 1] = '\0';
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Add new entry, evict oldest if full
|
||||
if (_pwdCacheCount < PWD_CACHE_SIZE) {
|
||||
int slot = _pwdCacheCount++;
|
||||
_pwdCache[slot].contactIdx = contactIdx;
|
||||
strncpy(_pwdCache[slot].password, pwd, ADMIN_PASSWORD_MAX - 1);
|
||||
_pwdCache[slot].password[ADMIN_PASSWORD_MAX - 1] = '\0';
|
||||
} else {
|
||||
// Shift entries down to evict oldest
|
||||
for (int i = 0; i < PWD_CACHE_SIZE - 1; i++) {
|
||||
_pwdCache[i] = _pwdCache[i + 1];
|
||||
}
|
||||
_pwdCache[PWD_CACHE_SIZE - 1].contactIdx = contactIdx;
|
||||
strncpy(_pwdCache[PWD_CACHE_SIZE - 1].password, pwd, ADMIN_PASSWORD_MAX - 1);
|
||||
_pwdCache[PWD_CACHE_SIZE - 1].password[ADMIN_PASSWORD_MAX - 1] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
static const char* menuLabel(MenuItem m) {
|
||||
switch (m) {
|
||||
case MENU_CLOCK_SYNC: return "Clock Sync";
|
||||
case MENU_ADVERT: return "Send Advert";
|
||||
case MENU_NEIGHBORS: return "Neighbors";
|
||||
case MENU_GET_CLOCK: return "Get Clock";
|
||||
case MENU_GET_VER: return "Version";
|
||||
case MENU_GET_STATUS: return "Get Status";
|
||||
default: return "?";
|
||||
}
|
||||
}
|
||||
|
||||
static const char* menuCommand(MenuItem m) {
|
||||
switch (m) {
|
||||
case MENU_CLOCK_SYNC: return "clock sync";
|
||||
case MENU_ADVERT: return "advert";
|
||||
case MENU_NEIGHBORS: return "neighbors";
|
||||
case MENU_GET_CLOCK: return "clock";
|
||||
case MENU_GET_VER: return "ver";
|
||||
case MENU_GET_STATUS: return "get status";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
// Format epoch as HH:MM:SS
|
||||
static void formatTime(char* buf, size_t bufLen, uint32_t epoch) {
|
||||
if (epoch == 0) {
|
||||
strncpy(buf, "--:--:--", bufLen);
|
||||
return;
|
||||
}
|
||||
uint32_t secs = epoch % 60;
|
||||
uint32_t mins = (epoch / 60) % 60;
|
||||
uint32_t hrs = (epoch / 3600) % 24;
|
||||
snprintf(buf, bufLen, "%02d:%02d:%02d", (int)hrs, (int)mins, (int)secs);
|
||||
}
|
||||
|
||||
public:
|
||||
RepeaterAdminScreen(UITask* task, mesh::RTCClock* rtc)
|
||||
: _task(task), _rtc(rtc), _state(STATE_PASSWORD_ENTRY),
|
||||
_contactIdx(-1), _permissions(0), _serverTime(0),
|
||||
_pwdLen(0), _lastCharAt(0), _menuSel(0),
|
||||
_responseLen(0), _responseScroll(0),
|
||||
_cmdSentAt(0), _waitingForLogin(false), _pwdCacheCount(0) {
|
||||
_password[0] = '\0';
|
||||
_repeaterName[0] = '\0';
|
||||
_response[0] = '\0';
|
||||
}
|
||||
|
||||
// Called when entering the screen for a specific repeater contact
|
||||
void openForContact(int contactIdx, const char* name) {
|
||||
_contactIdx = contactIdx;
|
||||
strncpy(_repeaterName, name, sizeof(_repeaterName) - 1);
|
||||
_repeaterName[sizeof(_repeaterName) - 1] = '\0';
|
||||
|
||||
// Reset state
|
||||
_state = STATE_PASSWORD_ENTRY;
|
||||
_lastCharAt = 0;
|
||||
_menuSel = 0;
|
||||
_permissions = 0;
|
||||
_serverTime = 0;
|
||||
_responseLen = 0;
|
||||
_responseScroll = 0;
|
||||
_response[0] = '\0';
|
||||
_waitingForLogin = false;
|
||||
|
||||
// Pre-fill from password cache if available
|
||||
const char* cached = getCachedPassword(contactIdx);
|
||||
if (cached) {
|
||||
strncpy(_password, cached, ADMIN_PASSWORD_MAX - 1);
|
||||
_password[ADMIN_PASSWORD_MAX - 1] = '\0';
|
||||
_pwdLen = strlen(_password);
|
||||
} else {
|
||||
_pwdLen = 0;
|
||||
_password[0] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
int getContactIdx() const { return _contactIdx; }
|
||||
AdminState getState() const { return _state; }
|
||||
|
||||
// Called by UITask when a login response is received
|
||||
void onLoginResult(bool success, uint8_t permissions, uint32_t server_time) {
|
||||
_waitingForLogin = false;
|
||||
if (success) {
|
||||
_permissions = permissions;
|
||||
_serverTime = server_time;
|
||||
_state = STATE_MENU;
|
||||
cachePassword(_contactIdx, _password); // remember for next time
|
||||
} else {
|
||||
snprintf(_response, sizeof(_response), "Login failed.\nCheck password.");
|
||||
_responseLen = strlen(_response);
|
||||
_state = STATE_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
// Called by UITask when a CLI response is received
|
||||
void onCliResponse(const char* text) {
|
||||
if (_state != STATE_COMMAND_PENDING) return;
|
||||
|
||||
int tlen = strlen(text);
|
||||
if (tlen >= ADMIN_RESPONSE_MAX) tlen = ADMIN_RESPONSE_MAX - 1;
|
||||
memcpy(_response, text, tlen);
|
||||
_response[tlen] = '\0';
|
||||
_responseLen = tlen;
|
||||
_responseScroll = 0;
|
||||
_state = STATE_RESPONSE_VIEW;
|
||||
}
|
||||
|
||||
// Poll for timeouts
|
||||
void poll() override {
|
||||
if ((_state == STATE_LOGGING_IN || _state == STATE_COMMAND_PENDING) &&
|
||||
_cmdSentAt > 0 && (millis() - _cmdSentAt) > ADMIN_TIMEOUT_MS) {
|
||||
snprintf(_response, sizeof(_response), "Timeout - no response.");
|
||||
_responseLen = strlen(_response);
|
||||
_state = STATE_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
char tmp[64];
|
||||
|
||||
// === Header ===
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setCursor(0, 0);
|
||||
|
||||
// Truncate name if needed to fit header
|
||||
snprintf(tmp, sizeof(tmp), "Admin: %.16s", _repeaterName);
|
||||
display.print(tmp);
|
||||
|
||||
// Show permissions if logged in
|
||||
if (_state >= STATE_MENU && _state <= STATE_RESPONSE_VIEW) {
|
||||
const char* perm = (_permissions & 0x03) >= 3 ? "ADM" :
|
||||
(_permissions & 0x03) >= 2 ? "R/W" : "R/O";
|
||||
display.setCursor(display.width() - display.getTextWidth(perm) - 2, 0);
|
||||
display.print(perm);
|
||||
}
|
||||
|
||||
display.drawRect(0, 11, display.width(), 1); // divider
|
||||
|
||||
// === Body - depends on state ===
|
||||
int bodyY = 14;
|
||||
int footerY = display.height() - 12;
|
||||
int bodyHeight = footerY - bodyY - 4;
|
||||
|
||||
switch (_state) {
|
||||
case STATE_PASSWORD_ENTRY:
|
||||
renderPasswordEntry(display, bodyY);
|
||||
break;
|
||||
case STATE_LOGGING_IN:
|
||||
renderWaiting(display, bodyY, "Logging in...");
|
||||
break;
|
||||
case STATE_MENU:
|
||||
renderMenu(display, bodyY, bodyHeight);
|
||||
break;
|
||||
case STATE_COMMAND_PENDING:
|
||||
renderWaiting(display, bodyY, "Waiting...");
|
||||
break;
|
||||
case STATE_RESPONSE_VIEW:
|
||||
renderResponse(display, bodyY, bodyHeight);
|
||||
break;
|
||||
case STATE_ERROR:
|
||||
renderResponse(display, bodyY, bodyHeight); // reuse response renderer for errors
|
||||
break;
|
||||
}
|
||||
|
||||
// === Footer ===
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setTextSize(1);
|
||||
display.setCursor(0, footerY);
|
||||
|
||||
switch (_state) {
|
||||
case STATE_PASSWORD_ENTRY:
|
||||
display.print("Q:Back");
|
||||
{
|
||||
const char* right = "Enter:Login";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
}
|
||||
break;
|
||||
case STATE_LOGGING_IN:
|
||||
case STATE_COMMAND_PENDING:
|
||||
display.print("Q:Cancel");
|
||||
break;
|
||||
case STATE_MENU:
|
||||
display.print("Q:Back");
|
||||
{
|
||||
const char* mid = "W/S:Sel";
|
||||
display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY);
|
||||
display.print(mid);
|
||||
const char* right = "Ent:Run";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
}
|
||||
break;
|
||||
case STATE_RESPONSE_VIEW:
|
||||
case STATE_ERROR:
|
||||
display.print("Q:Menu");
|
||||
if (_responseLen > bodyHeight / 9) { // if scrollable
|
||||
const char* right = "W/S:Scrll";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (_state == STATE_LOGGING_IN || _state == STATE_COMMAND_PENDING) return 1000;
|
||||
// During password reveal, refresh when the reveal expires
|
||||
if (_state == STATE_PASSWORD_ENTRY && _lastCharAt > 0 && (millis() - _lastCharAt) < 800) {
|
||||
return _lastCharAt + 800 - millis() + 50; // refresh shortly after reveal ends
|
||||
}
|
||||
return 5000;
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
switch (_state) {
|
||||
case STATE_PASSWORD_ENTRY:
|
||||
return handlePasswordInput(c);
|
||||
case STATE_LOGGING_IN:
|
||||
case STATE_COMMAND_PENDING:
|
||||
// Q to cancel and go back
|
||||
if (c == 'q' || c == 'Q') {
|
||||
_state = (_state == STATE_LOGGING_IN) ? STATE_PASSWORD_ENTRY : STATE_MENU;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
case STATE_MENU:
|
||||
return handleMenuInput(c);
|
||||
case STATE_RESPONSE_VIEW:
|
||||
case STATE_ERROR:
|
||||
return handleResponseInput(c);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private:
|
||||
// --- Password Entry ---
|
||||
void renderPasswordEntry(DisplayDriver& display, int y) {
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(0, y);
|
||||
display.print("Password:");
|
||||
|
||||
y += 14;
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setCursor(0, y);
|
||||
|
||||
// Show asterisks for password characters, with brief reveal of last char
|
||||
char masked[ADMIN_PASSWORD_MAX];
|
||||
int i;
|
||||
bool revealing = (_pwdLen > 0 && (millis() - _lastCharAt) < 800);
|
||||
int revealIdx = revealing ? _pwdLen - 1 : -1;
|
||||
for (i = 0; i < _pwdLen && i < ADMIN_PASSWORD_MAX - 1; i++) {
|
||||
masked[i] = (i == revealIdx) ? _password[i] : '*';
|
||||
}
|
||||
masked[i] = '\0';
|
||||
display.print(masked);
|
||||
|
||||
// Cursor indicator
|
||||
display.print("_");
|
||||
}
|
||||
|
||||
bool handlePasswordInput(char c) {
|
||||
// Q without any password typed = go back (return false to signal "not handled")
|
||||
if ((c == 'q' || c == 'Q') && _pwdLen == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Enter to submit
|
||||
if (c == '\r' || c == '\n' || c == KEY_ENTER) {
|
||||
if (_pwdLen > 0) {
|
||||
return doLogin();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Backspace
|
||||
if (c == 0x08 || c == 0x7F) {
|
||||
if (_pwdLen > 0) {
|
||||
_pwdLen--;
|
||||
_password[_pwdLen] = '\0';
|
||||
_lastCharAt = 0; // no reveal after delete
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Printable character
|
||||
if (c >= 32 && c < 127 && _pwdLen < ADMIN_PASSWORD_MAX - 1) {
|
||||
_password[_pwdLen++] = c;
|
||||
_password[_pwdLen] = '\0';
|
||||
_lastCharAt = millis(); // start brief reveal
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool doLogin(); // Defined below, calls into MyMesh
|
||||
|
||||
// --- Menu ---
|
||||
void renderMenu(DisplayDriver& display, int y, int bodyHeight) {
|
||||
display.setTextSize(0); // tiny font for compact rows
|
||||
int lineHeight = 9;
|
||||
|
||||
// Show server time comparison if available
|
||||
if (_serverTime > 0) {
|
||||
char ourTime[12], srvTime[12];
|
||||
uint32_t now = _rtc->getCurrentTime();
|
||||
formatTime(ourTime, sizeof(ourTime), now);
|
||||
formatTime(srvTime, sizeof(srvTime), _serverTime);
|
||||
|
||||
int drift = (int)(now - _serverTime);
|
||||
char driftStr[24];
|
||||
if (abs(drift) < 2) {
|
||||
snprintf(driftStr, sizeof(driftStr), "Synced");
|
||||
} else {
|
||||
snprintf(driftStr, sizeof(driftStr), "Drift:%+ds", drift);
|
||||
}
|
||||
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(0, y);
|
||||
char info[48];
|
||||
snprintf(info, sizeof(info), "Rpt:%s Us:%s %s", srvTime, ourTime, driftStr);
|
||||
display.print(info);
|
||||
y += lineHeight + 2;
|
||||
}
|
||||
|
||||
// Menu items
|
||||
for (int i = 0; i < MENU_COUNT && y + lineHeight <= display.height() - 16; i++) {
|
||||
bool selected = (i == _menuSel);
|
||||
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(0, y + 5, display.width(), lineHeight);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
|
||||
display.setCursor(2, y);
|
||||
char label[32];
|
||||
snprintf(label, sizeof(label), "%s %s", selected ? ">" : " ", menuLabel((MenuItem)i));
|
||||
display.print(label);
|
||||
|
||||
y += lineHeight;
|
||||
}
|
||||
|
||||
display.setTextSize(1);
|
||||
}
|
||||
|
||||
bool handleMenuInput(char c) {
|
||||
// W/up - scroll up
|
||||
if (c == 'w' || c == 'W' || c == 0xF2) {
|
||||
if (_menuSel > 0) _menuSel--;
|
||||
return true;
|
||||
}
|
||||
// S/down - scroll down
|
||||
if (c == 's' || c == 'S' || c == 0xF1) {
|
||||
if (_menuSel < MENU_COUNT - 1) _menuSel++;
|
||||
return true;
|
||||
}
|
||||
// Enter - execute selected command
|
||||
if (c == '\r' || c == '\n' || c == KEY_ENTER) {
|
||||
return executeMenuCommand((MenuItem)_menuSel);
|
||||
}
|
||||
// Q - back to contacts
|
||||
if (c == 'q' || c == 'Q') {
|
||||
return false; // let UITask handle back navigation
|
||||
}
|
||||
// Number keys for quick selection
|
||||
if (c >= '1' && c <= '0' + MENU_COUNT) {
|
||||
_menuSel = c - '1';
|
||||
return executeMenuCommand((MenuItem)_menuSel);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool executeMenuCommand(MenuItem item); // Defined below, calls into MyMesh
|
||||
|
||||
// --- Response View ---
|
||||
void renderResponse(DisplayDriver& display, int y, int bodyHeight) {
|
||||
display.setTextSize(0); // tiny font for more content
|
||||
int lineHeight = 9;
|
||||
|
||||
display.setColor((_state == STATE_ERROR) ? DisplayDriver::YELLOW : DisplayDriver::LIGHT);
|
||||
|
||||
// Render response text with word wrapping and scroll support
|
||||
int maxLines = bodyHeight / lineHeight;
|
||||
int lineCount = 0;
|
||||
int skipLines = _responseScroll;
|
||||
|
||||
const char* p = _response;
|
||||
char lineBuf[80];
|
||||
int lineWidth = display.width() - 4;
|
||||
|
||||
while (*p && lineCount < maxLines + skipLines) {
|
||||
// Extract next line (up to newline or screen width)
|
||||
int i = 0;
|
||||
while (*p && *p != '\n' && i < 79) {
|
||||
lineBuf[i++] = *p++;
|
||||
}
|
||||
lineBuf[i] = '\0';
|
||||
if (*p == '\n') p++;
|
||||
|
||||
if (lineCount >= skipLines && lineCount < skipLines + maxLines) {
|
||||
display.setCursor(2, y);
|
||||
display.print(lineBuf);
|
||||
y += lineHeight;
|
||||
}
|
||||
lineCount++;
|
||||
}
|
||||
|
||||
display.setTextSize(1);
|
||||
}
|
||||
|
||||
bool handleResponseInput(char c) {
|
||||
// W/up - scroll up
|
||||
if (c == 'w' || c == 'W' || c == 0xF2) {
|
||||
if (_responseScroll > 0) {
|
||||
_responseScroll--;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// S/down - scroll down
|
||||
if (c == 's' || c == 'S' || c == 0xF1) {
|
||||
_responseScroll++;
|
||||
return true;
|
||||
}
|
||||
// Q - back to menu (or back to password on error)
|
||||
if (c == 'q' || c == 'Q') {
|
||||
if (_state == STATE_ERROR && _permissions == 0) {
|
||||
// Not yet logged in, go back to password
|
||||
_state = STATE_PASSWORD_ENTRY;
|
||||
} else {
|
||||
_state = STATE_MENU;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Enter - also go back to menu
|
||||
if (c == '\r' || c == '\n' || c == KEY_ENTER) {
|
||||
_state = STATE_MENU;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Waiting spinner ---
|
||||
void renderWaiting(DisplayDriver& display, int y, const char* msg) {
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
|
||||
int cx = (display.width() - display.getTextWidth(msg)) / 2;
|
||||
int cy = y + 20;
|
||||
display.setCursor(cx, cy);
|
||||
display.print(msg);
|
||||
|
||||
// Show elapsed time
|
||||
if (_cmdSentAt > 0) {
|
||||
char elapsed[16];
|
||||
unsigned long secs = (millis() - _cmdSentAt) / 1000;
|
||||
snprintf(elapsed, sizeof(elapsed), "%lus", secs);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor((display.width() - display.getTextWidth(elapsed)) / 2, cy + 14);
|
||||
display.print(elapsed);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// --- Implementations that require MyMesh (the_mesh is declared extern above) ---
|
||||
|
||||
inline bool RepeaterAdminScreen::doLogin() {
|
||||
if (_contactIdx < 0 || _pwdLen == 0) return false;
|
||||
|
||||
if (the_mesh.uiLoginToRepeater(_contactIdx, _password)) {
|
||||
_state = STATE_LOGGING_IN;
|
||||
_cmdSentAt = millis();
|
||||
_waitingForLogin = true;
|
||||
return true;
|
||||
} else {
|
||||
snprintf(_response, sizeof(_response), "Send failed.\nCheck contact path.");
|
||||
_responseLen = strlen(_response);
|
||||
_state = STATE_ERROR;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
inline bool RepeaterAdminScreen::executeMenuCommand(MenuItem item) {
|
||||
if (_contactIdx < 0) return false;
|
||||
|
||||
const char* cmd = menuCommand(item);
|
||||
if (cmd[0] == '\0') return false;
|
||||
|
||||
if (the_mesh.uiSendCliCommand(_contactIdx, cmd)) {
|
||||
_state = STATE_COMMAND_PENDING;
|
||||
_cmdSentAt = millis();
|
||||
_response[0] = '\0';
|
||||
_responseLen = 0;
|
||||
_responseScroll = 0;
|
||||
return true;
|
||||
} else {
|
||||
snprintf(_response, sizeof(_response), "Send failed.");
|
||||
_responseLen = strlen(_response);
|
||||
_state = STATE_ERROR;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
868
examples/companion_radio/ui-new/Settingsscreen.h
Normal file
868
examples/companion_radio/ui-new/Settingsscreen.h
Normal file
@@ -0,0 +1,868 @@
|
||||
#pragma once
|
||||
|
||||
#include <helpers/ui/UIScreen.h>
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include <helpers/ChannelDetails.h>
|
||||
#include <MeshCore.h>
|
||||
#include "../NodePrefs.h"
|
||||
|
||||
// Forward declarations
|
||||
class UITask;
|
||||
class MyMesh;
|
||||
extern MyMesh the_mesh;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Radio presets
|
||||
// ---------------------------------------------------------------------------
|
||||
struct RadioPreset {
|
||||
const char* name;
|
||||
float freq;
|
||||
float bw;
|
||||
uint8_t sf;
|
||||
uint8_t cr;
|
||||
uint8_t tx_power;
|
||||
};
|
||||
|
||||
static const RadioPreset RADIO_PRESETS[] = {
|
||||
{ "Australia", 915.800f, 250.0f, 10, 5, 22 },
|
||||
{ "Australia (Narrow)", 916.575f, 62.5f, 7, 8, 22 },
|
||||
{ "Australia: SA, WA", 923.125f, 62.5f, 8, 8, 22 },
|
||||
{ "Australia: QLD", 923.125f, 62.5f, 8, 5, 22 },
|
||||
{ "EU/UK (Narrow)", 869.618f, 62.5f, 8, 8, 14 },
|
||||
{ "EU/UK (Long Range)", 869.525f, 250.0f, 11, 5, 14 },
|
||||
{ "EU/UK (Medium Range)", 869.525f, 250.0f, 10, 5, 14 },
|
||||
{ "Czech Republic (Narrow)",869.432f, 62.5f, 7, 5, 14 },
|
||||
{ "EU 433 (Long Range)", 433.650f, 250.0f, 11, 5, 14 },
|
||||
{ "New Zealand", 917.375f, 250.0f, 11, 5, 22 },
|
||||
{ "New Zealand (Narrow)", 917.375f, 62.5f, 7, 5, 22 },
|
||||
{ "Portugal 433", 433.375f, 62.5f, 9, 6, 14 },
|
||||
{ "Portugal 868", 869.618f, 62.5f, 7, 6, 14 },
|
||||
{ "Switzerland", 869.618f, 62.5f, 8, 8, 14 },
|
||||
{ "USA/Canada (Recommended)",910.525f, 62.5f, 7, 5, 22 },
|
||||
{ "Vietnam", 920.250f, 250.0f, 11, 5, 22 },
|
||||
};
|
||||
#define NUM_RADIO_PRESETS (sizeof(RADIO_PRESETS) / sizeof(RADIO_PRESETS[0]))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings row types
|
||||
// ---------------------------------------------------------------------------
|
||||
enum SettingsRowType : uint8_t {
|
||||
ROW_NAME, // Device name (text editor)
|
||||
ROW_RADIO_PRESET, // Radio preset picker
|
||||
ROW_FREQ, // Frequency (float)
|
||||
ROW_BW, // Bandwidth (float)
|
||||
ROW_SF, // Spreading factor (5-12)
|
||||
ROW_CR, // Coding rate (5-8)
|
||||
ROW_TX_POWER, // TX power (1-20 dBm)
|
||||
ROW_UTC_OFFSET, // UTC offset (-12 to +14)
|
||||
ROW_CH_HEADER, // "--- Channels ---" separator
|
||||
ROW_CHANNEL, // A channel entry (dynamic, index stored separately)
|
||||
ROW_ADD_CHANNEL, // "+ Add Hashtag Channel"
|
||||
ROW_INFO_HEADER, // "--- Info ---" separator
|
||||
ROW_PUB_KEY, // Public key display
|
||||
ROW_FIRMWARE, // Firmware version
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Editing modes
|
||||
// ---------------------------------------------------------------------------
|
||||
enum EditMode : uint8_t {
|
||||
EDIT_NONE, // Just browsing
|
||||
EDIT_TEXT, // Typing into a text buffer (name, channel name)
|
||||
EDIT_PICKER, // A/D cycles options (radio preset)
|
||||
EDIT_NUMBER, // W/S adjusts value (freq, BW, SF, CR, TX, UTC)
|
||||
EDIT_CONFIRM, // Confirmation dialog (delete channel, apply radio)
|
||||
};
|
||||
|
||||
// Max rows in the settings list
|
||||
#define SETTINGS_MAX_ROWS 40
|
||||
#define SETTINGS_TEXT_BUF 33 // 32 chars + null
|
||||
|
||||
class SettingsScreen : public UIScreen {
|
||||
private:
|
||||
UITask* _task;
|
||||
mesh::RTCClock* _rtc;
|
||||
NodePrefs* _prefs;
|
||||
|
||||
// Row table — rebuilt whenever channels change
|
||||
struct Row {
|
||||
SettingsRowType type;
|
||||
uint8_t param; // channel index for ROW_CHANNEL, preset index for ROW_RADIO_PRESET
|
||||
};
|
||||
Row _rows[SETTINGS_MAX_ROWS];
|
||||
int _numRows;
|
||||
|
||||
// Cursor & scroll
|
||||
int _cursor; // selected row
|
||||
int _scrollTop; // first visible row
|
||||
|
||||
// Editing state
|
||||
EditMode _editMode;
|
||||
char _editBuf[SETTINGS_TEXT_BUF];
|
||||
int _editPos;
|
||||
int _editPickerIdx; // for preset picker
|
||||
float _editFloat; // for freq/BW editing
|
||||
int _editInt; // for SF/CR/TX/UTC editing
|
||||
int _confirmAction; // 0=none, 1=delete channel, 2=apply radio
|
||||
|
||||
// Onboarding mode
|
||||
bool _onboarding;
|
||||
|
||||
// Dirty flag for radio params — prompt to apply
|
||||
bool _radioChanged;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row table management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void rebuildRows() {
|
||||
_numRows = 0;
|
||||
|
||||
addRow(ROW_NAME);
|
||||
addRow(ROW_RADIO_PRESET);
|
||||
addRow(ROW_FREQ);
|
||||
addRow(ROW_BW);
|
||||
addRow(ROW_SF);
|
||||
addRow(ROW_CR);
|
||||
addRow(ROW_TX_POWER);
|
||||
addRow(ROW_UTC_OFFSET);
|
||||
addRow(ROW_CH_HEADER);
|
||||
|
||||
// Enumerate current channels
|
||||
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
|
||||
ChannelDetails ch;
|
||||
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') {
|
||||
addRow(ROW_CHANNEL, i);
|
||||
} else {
|
||||
break; // channels are contiguous
|
||||
}
|
||||
}
|
||||
|
||||
addRow(ROW_ADD_CHANNEL);
|
||||
addRow(ROW_INFO_HEADER);
|
||||
addRow(ROW_PUB_KEY);
|
||||
addRow(ROW_FIRMWARE);
|
||||
|
||||
// Clamp cursor
|
||||
if (_cursor >= _numRows) _cursor = _numRows - 1;
|
||||
if (_cursor < 0) _cursor = 0;
|
||||
skipNonSelectable(1);
|
||||
}
|
||||
|
||||
void addRow(SettingsRowType type, uint8_t param = 0) {
|
||||
if (_numRows < SETTINGS_MAX_ROWS) {
|
||||
_rows[_numRows].type = type;
|
||||
_rows[_numRows].param = param;
|
||||
_numRows++;
|
||||
}
|
||||
}
|
||||
|
||||
bool isSelectable(int idx) const {
|
||||
if (idx < 0 || idx >= _numRows) return false;
|
||||
SettingsRowType t = _rows[idx].type;
|
||||
return t != ROW_CH_HEADER && t != ROW_INFO_HEADER;
|
||||
}
|
||||
|
||||
void skipNonSelectable(int dir) {
|
||||
while (_cursor >= 0 && _cursor < _numRows && !isSelectable(_cursor)) {
|
||||
_cursor += dir;
|
||||
}
|
||||
if (_cursor < 0) _cursor = 0;
|
||||
if (_cursor >= _numRows) _cursor = _numRows - 1;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Radio preset detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
int detectCurrentPreset() const {
|
||||
for (int i = 0; i < (int)NUM_RADIO_PRESETS; i++) {
|
||||
const RadioPreset& p = RADIO_PRESETS[i];
|
||||
if (fabsf(_prefs->freq - p.freq) < 0.01f &&
|
||||
fabsf(_prefs->bw - p.bw) < 0.01f &&
|
||||
_prefs->sf == p.sf &&
|
||||
_prefs->cr == p.cr &&
|
||||
_prefs->tx_power_dbm == p.tx_power) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1; // Custom
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hashtag channel creation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void createHashtagChannel(const char* name) {
|
||||
// Build channel name with # prefix if not already present
|
||||
char chanName[32];
|
||||
if (name[0] == '#') {
|
||||
strncpy(chanName, name, sizeof(chanName));
|
||||
} else {
|
||||
chanName[0] = '#';
|
||||
strncpy(&chanName[1], name, sizeof(chanName) - 1);
|
||||
}
|
||||
chanName[31] = '\0';
|
||||
|
||||
// Generate 128-bit PSK from SHA-256 of channel name
|
||||
ChannelDetails newCh;
|
||||
memset(&newCh, 0, sizeof(newCh));
|
||||
strncpy(newCh.name, chanName, sizeof(newCh.name));
|
||||
newCh.name[31] = '\0';
|
||||
|
||||
// SHA-256 the channel name → first 16 bytes become the secret
|
||||
uint8_t hash[32];
|
||||
mesh::Utils::sha256(hash, 32, (const uint8_t*)chanName, strlen(chanName));
|
||||
memcpy(newCh.channel.secret, hash, 16);
|
||||
// Upper 16 bytes left as zero → setChannel uses 128-bit mode
|
||||
|
||||
// Find next empty slot
|
||||
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
|
||||
ChannelDetails existing;
|
||||
if (!the_mesh.getChannel(i, existing) || existing.name[0] == '\0') {
|
||||
if (the_mesh.setChannel(i, newCh)) {
|
||||
the_mesh.saveChannels();
|
||||
Serial.printf("Settings: Created hashtag channel '%s' at idx %d\n", chanName, i);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void deleteChannel(uint8_t idx) {
|
||||
// Clear the channel by writing an empty ChannelDetails
|
||||
// Then compact: shift all channels above it down by one
|
||||
ChannelDetails empty;
|
||||
memset(&empty, 0, sizeof(empty));
|
||||
|
||||
// Find total channel count
|
||||
int total = 0;
|
||||
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
|
||||
ChannelDetails ch;
|
||||
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') {
|
||||
total = i + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Shift channels down
|
||||
for (int i = idx; i < total - 1; i++) {
|
||||
ChannelDetails next;
|
||||
if (the_mesh.getChannel(i + 1, next)) {
|
||||
the_mesh.setChannel(i, next);
|
||||
}
|
||||
}
|
||||
// Clear the last slot
|
||||
the_mesh.setChannel(total - 1, empty);
|
||||
the_mesh.saveChannels();
|
||||
Serial.printf("Settings: Deleted channel at idx %d, compacted %d channels\n", idx, total);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Apply radio parameters live
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void applyRadioParams() {
|
||||
radio_set_params(_prefs->freq, _prefs->bw, _prefs->sf, _prefs->cr);
|
||||
radio_set_tx_power(_prefs->tx_power_dbm);
|
||||
the_mesh.savePrefs();
|
||||
_radioChanged = false;
|
||||
Serial.printf("Settings: Radio params applied - %.3f/%g/%d/%d TX:%d\n",
|
||||
_prefs->freq, _prefs->bw, _prefs->sf, _prefs->cr, _prefs->tx_power_dbm);
|
||||
}
|
||||
|
||||
public:
|
||||
SettingsScreen(UITask* task, mesh::RTCClock* rtc, NodePrefs* prefs)
|
||||
: _task(task), _rtc(rtc), _prefs(prefs),
|
||||
_numRows(0), _cursor(0), _scrollTop(0),
|
||||
_editMode(EDIT_NONE), _editPos(0), _editPickerIdx(0),
|
||||
_editFloat(0), _editInt(0), _confirmAction(0),
|
||||
_onboarding(false), _radioChanged(false) {
|
||||
memset(_editBuf, 0, sizeof(_editBuf));
|
||||
}
|
||||
|
||||
void enter() {
|
||||
_editMode = EDIT_NONE;
|
||||
_cursor = 0;
|
||||
_scrollTop = 0;
|
||||
_radioChanged = false;
|
||||
rebuildRows();
|
||||
}
|
||||
|
||||
void enterOnboarding() {
|
||||
enter();
|
||||
_onboarding = true;
|
||||
// Start editing the device name immediately
|
||||
_cursor = 0; // ROW_NAME
|
||||
startEditText(_prefs->node_name);
|
||||
}
|
||||
|
||||
bool isOnboarding() const { return _onboarding; }
|
||||
bool isEditing() const { return _editMode != EDIT_NONE; }
|
||||
bool hasRadioChanges() const { return _radioChanged; }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit mode starters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void startEditText(const char* initial) {
|
||||
_editMode = EDIT_TEXT;
|
||||
strncpy(_editBuf, initial, SETTINGS_TEXT_BUF - 1);
|
||||
_editBuf[SETTINGS_TEXT_BUF - 1] = '\0';
|
||||
_editPos = strlen(_editBuf);
|
||||
}
|
||||
|
||||
void startEditPicker(int initialIdx) {
|
||||
_editMode = EDIT_PICKER;
|
||||
_editPickerIdx = initialIdx;
|
||||
}
|
||||
|
||||
void startEditFloat(float initial) {
|
||||
_editMode = EDIT_NUMBER;
|
||||
_editFloat = initial;
|
||||
}
|
||||
|
||||
void startEditInt(int initial) {
|
||||
_editMode = EDIT_NUMBER;
|
||||
_editInt = initial;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
char tmp[64];
|
||||
|
||||
// === Header ===
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setCursor(0, 0);
|
||||
if (_onboarding) {
|
||||
display.print("Welcome! Setup");
|
||||
} else {
|
||||
display.print("Settings");
|
||||
}
|
||||
|
||||
// Right side: row indicator
|
||||
snprintf(tmp, sizeof(tmp), "%d/%d", _cursor + 1, _numRows);
|
||||
display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0);
|
||||
display.print(tmp);
|
||||
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
// === Body ===
|
||||
display.setTextSize(0); // tiny font
|
||||
int lineHeight = 9;
|
||||
int headerH = 14;
|
||||
int footerH = 14;
|
||||
int maxY = display.height() - footerH;
|
||||
|
||||
// Center scroll window around cursor
|
||||
int maxVisible = (maxY - headerH) / lineHeight;
|
||||
if (maxVisible < 3) maxVisible = 3;
|
||||
_scrollTop = max(0, min(_cursor - maxVisible / 2, _numRows - maxVisible));
|
||||
int endIdx = min(_numRows, _scrollTop + maxVisible);
|
||||
|
||||
int y = headerH;
|
||||
|
||||
for (int i = _scrollTop; i < endIdx && y + lineHeight <= maxY; i++) {
|
||||
bool selected = (i == _cursor);
|
||||
bool editing = selected && (_editMode != EDIT_NONE);
|
||||
|
||||
// Selection highlight
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(0, y + 5, display.width(), lineHeight);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
|
||||
display.setCursor(0, y);
|
||||
|
||||
switch (_rows[i].type) {
|
||||
case ROW_NAME:
|
||||
if (editing && _editMode == EDIT_TEXT) {
|
||||
snprintf(tmp, sizeof(tmp), "Name: %s_", _editBuf);
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), "Name: %s", _prefs->node_name);
|
||||
}
|
||||
display.print(tmp);
|
||||
break;
|
||||
|
||||
case ROW_RADIO_PRESET: {
|
||||
int preset = detectCurrentPreset();
|
||||
if (editing && _editMode == EDIT_PICKER) {
|
||||
if (_editPickerIdx >= 0 && _editPickerIdx < (int)NUM_RADIO_PRESETS) {
|
||||
snprintf(tmp, sizeof(tmp), "< %s >", RADIO_PRESETS[_editPickerIdx].name);
|
||||
} else {
|
||||
strcpy(tmp, "< Custom >");
|
||||
}
|
||||
} else {
|
||||
if (preset >= 0) {
|
||||
snprintf(tmp, sizeof(tmp), "Preset: %s", RADIO_PRESETS[preset].name);
|
||||
} else {
|
||||
strcpy(tmp, "Preset: Custom");
|
||||
}
|
||||
}
|
||||
display.print(tmp);
|
||||
break;
|
||||
}
|
||||
|
||||
case ROW_FREQ:
|
||||
if (editing && _editMode == EDIT_TEXT) {
|
||||
snprintf(tmp, sizeof(tmp), "Freq: %s_ MHz", _editBuf);
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), "Freq: %.3f MHz", _prefs->freq);
|
||||
}
|
||||
display.print(tmp);
|
||||
break;
|
||||
|
||||
case ROW_BW:
|
||||
if (editing && _editMode == EDIT_NUMBER) {
|
||||
snprintf(tmp, sizeof(tmp), "BW: %.1f <W/S>", _editFloat);
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), "BW: %.1f kHz", _prefs->bw);
|
||||
}
|
||||
display.print(tmp);
|
||||
break;
|
||||
|
||||
case ROW_SF:
|
||||
if (editing && _editMode == EDIT_NUMBER) {
|
||||
snprintf(tmp, sizeof(tmp), "SF: %d <W/S>", _editInt);
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), "SF: %d", _prefs->sf);
|
||||
}
|
||||
display.print(tmp);
|
||||
break;
|
||||
|
||||
case ROW_CR:
|
||||
if (editing && _editMode == EDIT_NUMBER) {
|
||||
snprintf(tmp, sizeof(tmp), "CR: %d <W/S>", _editInt);
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), "CR: %d", _prefs->cr);
|
||||
}
|
||||
display.print(tmp);
|
||||
break;
|
||||
|
||||
case ROW_TX_POWER:
|
||||
if (editing && _editMode == EDIT_NUMBER) {
|
||||
snprintf(tmp, sizeof(tmp), "TX: %d dBm <W/S>", _editInt);
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), "TX: %d dBm", _prefs->tx_power_dbm);
|
||||
}
|
||||
display.print(tmp);
|
||||
break;
|
||||
|
||||
case ROW_UTC_OFFSET:
|
||||
if (editing && _editMode == EDIT_NUMBER) {
|
||||
snprintf(tmp, sizeof(tmp), "UTC: %+d <W/S>", _editInt);
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), "UTC Offset: %+d", _prefs->utc_offset_hours);
|
||||
}
|
||||
display.print(tmp);
|
||||
break;
|
||||
|
||||
case ROW_CH_HEADER:
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.print("--- Channels ---");
|
||||
break;
|
||||
|
||||
case ROW_CHANNEL: {
|
||||
uint8_t chIdx = _rows[i].param;
|
||||
ChannelDetails ch;
|
||||
if (the_mesh.getChannel(chIdx, ch)) {
|
||||
if (chIdx == 0) {
|
||||
// Public channel - not deletable
|
||||
snprintf(tmp, sizeof(tmp), " %s", ch.name);
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), " %s", ch.name);
|
||||
if (selected) {
|
||||
// Show delete hint on right
|
||||
const char* hint = "Del:X";
|
||||
int hintW = display.getTextWidth(hint);
|
||||
display.setCursor(display.width() - hintW - 2, y);
|
||||
display.print(hint);
|
||||
display.setCursor(0, y);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), " (empty)");
|
||||
}
|
||||
display.print(tmp);
|
||||
break;
|
||||
}
|
||||
|
||||
case ROW_ADD_CHANNEL:
|
||||
if (editing && _editMode == EDIT_TEXT) {
|
||||
snprintf(tmp, sizeof(tmp), "# %s_", _editBuf);
|
||||
} else {
|
||||
display.setColor(selected ? DisplayDriver::DARK : DisplayDriver::GREEN);
|
||||
strcpy(tmp, "+ Add Hashtag Channel");
|
||||
}
|
||||
display.print(tmp);
|
||||
break;
|
||||
|
||||
case ROW_INFO_HEADER:
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.print("--- Device Info ---");
|
||||
break;
|
||||
|
||||
case ROW_PUB_KEY: {
|
||||
// Show first 8 bytes of pub key as hex (16 chars)
|
||||
char hexBuf[17];
|
||||
mesh::Utils::toHex(hexBuf, the_mesh.self_id.pub_key, 8);
|
||||
snprintf(tmp, sizeof(tmp), "ID: %s", hexBuf);
|
||||
display.print(tmp);
|
||||
break;
|
||||
}
|
||||
|
||||
case ROW_FIRMWARE:
|
||||
snprintf(tmp, sizeof(tmp), "FW: %s", FIRMWARE_VERSION);
|
||||
display.print(tmp);
|
||||
break;
|
||||
}
|
||||
|
||||
y += lineHeight;
|
||||
}
|
||||
|
||||
display.setTextSize(1);
|
||||
|
||||
// === Confirmation overlay ===
|
||||
if (_editMode == EDIT_CONFIRM) {
|
||||
int bx = 4, by = 30, bw = display.width() - 8, bh = 36;
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
display.fillRect(bx, by, bw, bh);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(bx, by, bw, bh);
|
||||
|
||||
display.setTextSize(0);
|
||||
if (_confirmAction == 1) {
|
||||
uint8_t chIdx = _rows[_cursor].param;
|
||||
ChannelDetails ch;
|
||||
the_mesh.getChannel(chIdx, ch);
|
||||
snprintf(tmp, sizeof(tmp), "Delete %s?", ch.name);
|
||||
display.drawTextCentered(display.width() / 2, by + 4, tmp);
|
||||
} else if (_confirmAction == 2) {
|
||||
display.drawTextCentered(display.width() / 2, by + 4, "Apply radio changes?");
|
||||
}
|
||||
display.drawTextCentered(display.width() / 2, by + bh - 14, "Enter:Yes Q:No");
|
||||
display.setTextSize(1);
|
||||
}
|
||||
|
||||
// === Footer ===
|
||||
int footerY = display.height() - 12;
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setCursor(0, footerY);
|
||||
|
||||
if (_editMode == EDIT_TEXT) {
|
||||
display.print("Type, Enter:Ok Q:Cancel");
|
||||
} else if (_editMode == EDIT_PICKER) {
|
||||
display.print("A/D:Choose Enter:Ok");
|
||||
} else if (_editMode == EDIT_NUMBER) {
|
||||
display.print("W/S:Adj Enter:Ok Q:Cancel");
|
||||
} else if (_editMode == EDIT_CONFIRM) {
|
||||
// Footer already covered by overlay
|
||||
} else {
|
||||
display.print("Q:Bck");
|
||||
const char* r = "W/S:Up/Dwn Entr:Chng";
|
||||
display.setCursor(display.width() - display.getTextWidth(r) - 2, footerY);
|
||||
display.print(r);
|
||||
}
|
||||
|
||||
return _editMode != EDIT_NONE ? 700 : 1000;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Handle a keyboard character. Returns true if the screen consumed the input.
|
||||
bool handleKeyInput(char c) {
|
||||
// --- Confirmation dialog ---
|
||||
if (_editMode == EDIT_CONFIRM) {
|
||||
if (c == '\r' || c == 13) {
|
||||
if (_confirmAction == 1) {
|
||||
// Delete channel
|
||||
uint8_t chIdx = _rows[_cursor].param;
|
||||
deleteChannel(chIdx);
|
||||
rebuildRows();
|
||||
} else if (_confirmAction == 2) {
|
||||
applyRadioParams();
|
||||
}
|
||||
_editMode = EDIT_NONE;
|
||||
_confirmAction = 0;
|
||||
return true;
|
||||
}
|
||||
if (c == 'q' || c == 'Q') {
|
||||
_editMode = EDIT_NONE;
|
||||
_confirmAction = 0;
|
||||
return true;
|
||||
}
|
||||
return true; // consume all keys in confirm mode
|
||||
}
|
||||
|
||||
// --- Text editing mode ---
|
||||
if (_editMode == EDIT_TEXT) {
|
||||
if (c == '\r' || c == 13) {
|
||||
// Confirm text edit
|
||||
SettingsRowType type = _rows[_cursor].type;
|
||||
if (type == ROW_NAME) {
|
||||
if (_editPos > 0) {
|
||||
strncpy(_prefs->node_name, _editBuf, sizeof(_prefs->node_name));
|
||||
_prefs->node_name[31] = '\0';
|
||||
the_mesh.savePrefs();
|
||||
Serial.printf("Settings: Name set to '%s'\n", _prefs->node_name);
|
||||
}
|
||||
_editMode = EDIT_NONE;
|
||||
if (_onboarding) {
|
||||
// Move to radio preset selection
|
||||
_cursor = 1; // ROW_RADIO_PRESET
|
||||
startEditPicker(max(0, detectCurrentPreset()));
|
||||
}
|
||||
} else if (type == ROW_FREQ) {
|
||||
if (_editPos > 0) {
|
||||
float f = strtof(_editBuf, nullptr);
|
||||
f = constrain(f, 400.0f, 2500.0f);
|
||||
_prefs->freq = f;
|
||||
_radioChanged = true;
|
||||
Serial.printf("Settings: Freq typed to %.3f\n", f);
|
||||
}
|
||||
_editMode = EDIT_NONE;
|
||||
} else if (type == ROW_ADD_CHANNEL) {
|
||||
if (_editPos > 0) {
|
||||
createHashtagChannel(_editBuf);
|
||||
rebuildRows();
|
||||
}
|
||||
_editMode = EDIT_NONE;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (c == 'q' || c == 'Q' || c == 27) {
|
||||
_editMode = EDIT_NONE;
|
||||
return true;
|
||||
}
|
||||
if (c == '\b') {
|
||||
if (_editPos > 0) {
|
||||
_editPos--;
|
||||
_editBuf[_editPos] = '\0';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Printable character
|
||||
if (c >= 32 && c < 127 && _editPos < SETTINGS_TEXT_BUF - 1) {
|
||||
_editBuf[_editPos++] = c;
|
||||
_editBuf[_editPos] = '\0';
|
||||
return true;
|
||||
}
|
||||
return true; // consume all keys in text edit
|
||||
}
|
||||
|
||||
// --- Picker mode (radio preset) ---
|
||||
if (_editMode == EDIT_PICKER) {
|
||||
if (c == 'a' || c == 'A') {
|
||||
_editPickerIdx--;
|
||||
if (_editPickerIdx < 0) _editPickerIdx = (int)NUM_RADIO_PRESETS - 1;
|
||||
return true;
|
||||
}
|
||||
if (c == 'd' || c == 'D') {
|
||||
_editPickerIdx++;
|
||||
if (_editPickerIdx >= (int)NUM_RADIO_PRESETS) _editPickerIdx = 0;
|
||||
return true;
|
||||
}
|
||||
if (c == '\r' || c == 13) {
|
||||
// Apply preset
|
||||
if (_editPickerIdx >= 0 && _editPickerIdx < (int)NUM_RADIO_PRESETS) {
|
||||
const RadioPreset& p = RADIO_PRESETS[_editPickerIdx];
|
||||
_prefs->freq = p.freq;
|
||||
_prefs->bw = p.bw;
|
||||
_prefs->sf = p.sf;
|
||||
_prefs->cr = p.cr;
|
||||
_prefs->tx_power_dbm = p.tx_power;
|
||||
_radioChanged = true;
|
||||
}
|
||||
_editMode = EDIT_NONE;
|
||||
if (_onboarding) {
|
||||
// Apply and finish onboarding
|
||||
applyRadioParams();
|
||||
_onboarding = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (c == 'q' || c == 'Q') {
|
||||
_editMode = EDIT_NONE;
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Number editing mode ---
|
||||
if (_editMode == EDIT_NUMBER) {
|
||||
SettingsRowType type = _rows[_cursor].type;
|
||||
|
||||
if (c == 'w' || c == 'W') {
|
||||
switch (type) {
|
||||
case ROW_BW:
|
||||
// Cycle through common bandwidths
|
||||
if (_editFloat < 31.25f) _editFloat = 31.25f;
|
||||
else if (_editFloat < 62.5f) _editFloat = 62.5f;
|
||||
else if (_editFloat < 125.0f) _editFloat = 125.0f;
|
||||
else if (_editFloat < 250.0f) _editFloat = 250.0f;
|
||||
else _editFloat = 500.0f;
|
||||
break;
|
||||
case ROW_SF: if (_editInt < 12) _editInt++; break;
|
||||
case ROW_CR: if (_editInt < 8) _editInt++; break;
|
||||
case ROW_TX_POWER: if (_editInt < MAX_LORA_TX_POWER) _editInt++; break;
|
||||
case ROW_UTC_OFFSET: if (_editInt < 14) _editInt++; break;
|
||||
default: break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (c == 's' || c == 'S') {
|
||||
switch (type) {
|
||||
case ROW_BW:
|
||||
if (_editFloat > 250.0f) _editFloat = 250.0f;
|
||||
else if (_editFloat > 125.0f) _editFloat = 125.0f;
|
||||
else if (_editFloat > 62.5f) _editFloat = 62.5f;
|
||||
else _editFloat = 31.25f;
|
||||
break;
|
||||
case ROW_SF: if (_editInt > 5) _editInt--; break;
|
||||
case ROW_CR: if (_editInt > 5) _editInt--; break;
|
||||
case ROW_TX_POWER: if (_editInt > 1) _editInt--; break;
|
||||
case ROW_UTC_OFFSET: if (_editInt > -12) _editInt--; break;
|
||||
default: break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (c == '\r' || c == 13) {
|
||||
// Confirm number edit
|
||||
switch (type) {
|
||||
case ROW_BW:
|
||||
_prefs->bw = _editFloat;
|
||||
_radioChanged = true;
|
||||
break;
|
||||
case ROW_SF:
|
||||
_prefs->sf = (uint8_t)constrain(_editInt, 5, 12);
|
||||
_radioChanged = true;
|
||||
break;
|
||||
case ROW_CR:
|
||||
_prefs->cr = (uint8_t)constrain(_editInt, 5, 8);
|
||||
_radioChanged = true;
|
||||
break;
|
||||
case ROW_TX_POWER:
|
||||
_prefs->tx_power_dbm = (uint8_t)constrain(_editInt, 1, MAX_LORA_TX_POWER);
|
||||
_radioChanged = true;
|
||||
break;
|
||||
case ROW_UTC_OFFSET:
|
||||
_prefs->utc_offset_hours = (int8_t)constrain(_editInt, -12, 14);
|
||||
the_mesh.savePrefs();
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
_editMode = EDIT_NONE;
|
||||
return true;
|
||||
}
|
||||
if (c == 'q' || c == 'Q') {
|
||||
_editMode = EDIT_NONE;
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Normal browsing mode ---
|
||||
|
||||
// W/S: navigate
|
||||
if (c == 'w' || c == 'W') {
|
||||
if (_cursor > 0) {
|
||||
_cursor--;
|
||||
skipNonSelectable(-1);
|
||||
}
|
||||
Serial.printf("Settings: cursor=%d/%d row=%d\n", _cursor, _numRows, _rows[_cursor].type);
|
||||
return true;
|
||||
}
|
||||
if (c == 's' || c == 'S') {
|
||||
if (_cursor < _numRows - 1) {
|
||||
_cursor++;
|
||||
skipNonSelectable(1);
|
||||
}
|
||||
Serial.printf("Settings: cursor=%d/%d row=%d\n", _cursor, _numRows, _rows[_cursor].type);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Enter: start editing the selected row
|
||||
if (c == '\r' || c == 13) {
|
||||
SettingsRowType type = _rows[_cursor].type;
|
||||
switch (type) {
|
||||
case ROW_NAME:
|
||||
startEditText(_prefs->node_name);
|
||||
break;
|
||||
case ROW_RADIO_PRESET:
|
||||
startEditPicker(max(0, detectCurrentPreset()));
|
||||
break;
|
||||
case ROW_FREQ: {
|
||||
// Use text input so user can type exact frequencies like 916.575
|
||||
char freqStr[16];
|
||||
snprintf(freqStr, sizeof(freqStr), "%.3f", _prefs->freq);
|
||||
startEditText(freqStr);
|
||||
break;
|
||||
}
|
||||
case ROW_BW:
|
||||
startEditFloat(_prefs->bw);
|
||||
break;
|
||||
case ROW_SF:
|
||||
startEditInt(_prefs->sf);
|
||||
break;
|
||||
case ROW_CR:
|
||||
startEditInt(_prefs->cr);
|
||||
break;
|
||||
case ROW_TX_POWER:
|
||||
startEditInt(_prefs->tx_power_dbm);
|
||||
break;
|
||||
case ROW_UTC_OFFSET:
|
||||
startEditInt(_prefs->utc_offset_hours);
|
||||
break;
|
||||
case ROW_ADD_CHANNEL:
|
||||
startEditText("");
|
||||
break;
|
||||
case ROW_CHANNEL:
|
||||
case ROW_PUB_KEY:
|
||||
case ROW_FIRMWARE:
|
||||
// Not directly editable on Enter
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// X: delete channel (when on a channel row, idx > 0)
|
||||
if (c == 'x' || c == 'X') {
|
||||
if (_rows[_cursor].type == ROW_CHANNEL && _rows[_cursor].param > 0) {
|
||||
_editMode = EDIT_CONFIRM;
|
||||
_confirmAction = 1;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Q: back — if radio changed, prompt to apply first
|
||||
if (c == 'q' || c == 'Q') {
|
||||
if (_radioChanged) {
|
||||
_editMode = EDIT_CONFIRM;
|
||||
_confirmAction = 2;
|
||||
return true;
|
||||
}
|
||||
_onboarding = false;
|
||||
return false; // Let the caller handle navigation back
|
||||
}
|
||||
|
||||
return true; // Consume all other keys (don't let caller exit)
|
||||
}
|
||||
|
||||
// Override handleInput for UIScreen compatibility (used by injectKey)
|
||||
bool handleInput(char c) override {
|
||||
return handleKeyInput(c);
|
||||
}
|
||||
};
|
||||
1236
examples/companion_radio/ui-new/Textreaderscreen.h
Normal file
1236
examples/companion_radio/ui-new/Textreaderscreen.h
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,9 @@
|
||||
#include "UITask.h"
|
||||
#include <helpers/TxtDataHelpers.h>
|
||||
#include "../MyMesh.h"
|
||||
#include "NotesScreen.h"
|
||||
#include "target.h"
|
||||
#include "GPSDutyCycle.h"
|
||||
#ifdef WIFI_SSID
|
||||
#include <WiFi.h>
|
||||
#endif
|
||||
@@ -30,6 +32,10 @@
|
||||
#endif
|
||||
|
||||
#include "icons.h"
|
||||
#include "ChannelScreen.h"
|
||||
#include "ContactsScreen.h"
|
||||
#include "TextReaderScreen.h"
|
||||
#include "SettingsScreen.h"
|
||||
|
||||
class SplashScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
@@ -99,33 +105,63 @@ class HomeScreen : public UIScreen {
|
||||
NodePrefs* _node_prefs;
|
||||
uint8_t _page;
|
||||
bool _shutdown_init;
|
||||
bool _editing_utc;
|
||||
int8_t _saved_utc_offset; // for cancel/undo
|
||||
AdvertPath recent[UI_RECENT_LIST_SIZE];
|
||||
|
||||
|
||||
void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) {
|
||||
// Convert millivolts to percentage
|
||||
const int minMilliVolts = 3000; // Minimum voltage (e.g., 3.0V)
|
||||
const int maxMilliVolts = 4200; // Maximum voltage (e.g., 4.2V)
|
||||
int batteryPercentage = ((batteryMilliVolts - minMilliVolts) * 100) / (maxMilliVolts - minMilliVolts);
|
||||
if (batteryPercentage < 0) batteryPercentage = 0; // Clamp to 0%
|
||||
if (batteryPercentage > 100) batteryPercentage = 100; // Clamp to 100%
|
||||
void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts, int* outIconX = nullptr) {
|
||||
// Use the BQ27220 fuel gauge SOC register for accurate percentage.
|
||||
// Falls back to voltage estimation if the fuel gauge is uncalibrated.
|
||||
uint8_t batteryPercentage = board.getBatteryPercent();
|
||||
|
||||
// Sanity check: if voltage says full but gauge disagrees significantly,
|
||||
// the gauge hasn't calibrated yet — fall back to voltage estimate
|
||||
int voltagePct = 0;
|
||||
if (batteryMilliVolts > 0) {
|
||||
voltagePct = ((batteryMilliVolts - 3000) * 100) / (4200 - 3000);
|
||||
if (voltagePct < 0) voltagePct = 0;
|
||||
if (voltagePct > 100) voltagePct = 100;
|
||||
}
|
||||
|
||||
if (batteryPercentage == 0 || abs((int)batteryPercentage - voltagePct) > 30) {
|
||||
batteryPercentage = (uint8_t)voltagePct;
|
||||
}
|
||||
|
||||
// battery icon
|
||||
int iconWidth = 22;
|
||||
int iconHeight = 8;
|
||||
int iconX = display.width() - iconWidth - 5; // Position the icon near the top-right corner
|
||||
int iconY = 0;
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
|
||||
// battery icon dimensions (smaller to match tiny percentage text)
|
||||
int iconWidth = 16;
|
||||
int iconHeight = 6;
|
||||
|
||||
// measure percentage text width to position icon + text together at right edge
|
||||
display.setTextSize(0);
|
||||
char pctStr[5];
|
||||
sprintf(pctStr, "%d%%", batteryPercentage);
|
||||
uint16_t textWidth = display.getTextWidth(pctStr);
|
||||
|
||||
// layout: [icon 16px][cap 2px][gap 2px][text][margin 2px]
|
||||
int totalWidth = iconWidth + 2 + 2 + textWidth + 2;
|
||||
int iconX = display.width() - totalWidth;
|
||||
int iconY = 0; // vertically align with node name text
|
||||
|
||||
// battery outline
|
||||
display.drawRect(iconX, iconY, iconWidth, iconHeight);
|
||||
|
||||
// battery "cap"
|
||||
display.fillRect(iconX + iconWidth, iconY + (iconHeight / 4), 3, iconHeight / 2);
|
||||
display.fillRect(iconX + iconWidth, iconY + (iconHeight / 4), 2, iconHeight / 2);
|
||||
|
||||
// fill the battery based on the percentage
|
||||
int fillWidth = (batteryPercentage * (iconWidth - 4)) / 100;
|
||||
display.fillRect(iconX + 2, iconY + 2, fillWidth, iconHeight - 4);
|
||||
|
||||
// draw percentage text after the battery cap, offset upward to center with icon
|
||||
// (setCursor adds +5 internally for baseline, so compensate for the tiny font)
|
||||
int textX = iconX + iconWidth + 2 + 2; // after cap + gap
|
||||
int textY = iconY - 3; // offset up to vertically center with icon
|
||||
display.setCursor(textX, textY);
|
||||
display.print(pctStr);
|
||||
display.setTextSize(1); // restore default text size
|
||||
}
|
||||
|
||||
CayenneLPP sensors_lpp;
|
||||
@@ -158,7 +194,15 @@ class HomeScreen : public UIScreen {
|
||||
public:
|
||||
HomeScreen(UITask* task, mesh::RTCClock* rtc, SensorManager* sensors, NodePrefs* node_prefs)
|
||||
: _task(task), _rtc(rtc), _sensors(sensors), _node_prefs(node_prefs), _page(0),
|
||||
_shutdown_init(false), sensors_lpp(200) { }
|
||||
_shutdown_init(false), _editing_utc(false), _saved_utc_offset(0), sensors_lpp(200) { }
|
||||
|
||||
bool isEditingUTC() const { return _editing_utc; }
|
||||
void cancelEditUTC() {
|
||||
if (_editing_utc) {
|
||||
_node_prefs->utc_offset_hours = _saved_utc_offset;
|
||||
_editing_utc = false;
|
||||
}
|
||||
}
|
||||
|
||||
void poll() override {
|
||||
if (_shutdown_init && !_task->isButtonPressed()) { // must wait for USR button to be released
|
||||
@@ -179,6 +223,29 @@ public:
|
||||
// battery voltage
|
||||
renderBatteryIndicator(display, _task->getBattMilliVolts());
|
||||
|
||||
// centered clock (tinyfont) - only show when time is valid
|
||||
{
|
||||
uint32_t now = _rtc->getCurrentTime();
|
||||
if (now > 1700000000) { // valid timestamp (after ~Nov 2023)
|
||||
// Apply UTC offset from prefs
|
||||
int32_t local = (int32_t)now + ((int32_t)_node_prefs->utc_offset_hours * 3600);
|
||||
int hrs = (local / 3600) % 24;
|
||||
if (hrs < 0) hrs += 24;
|
||||
int mins = (local / 60) % 60;
|
||||
if (mins < 0) mins += 60;
|
||||
|
||||
char timeBuf[6];
|
||||
sprintf(timeBuf, "%02d:%02d", hrs, mins);
|
||||
|
||||
display.setTextSize(0); // tinyfont
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
uint16_t tw = display.getTextWidth(timeBuf);
|
||||
int clockX = (display.width() - tw) / 2;
|
||||
display.setCursor(clockX, -3); // align with battery text Y
|
||||
display.print(timeBuf);
|
||||
display.setTextSize(1); // restore
|
||||
}
|
||||
}
|
||||
// curr page indicator
|
||||
int y = 14;
|
||||
int x = display.width() / 2 - 5 * (HomePage::Count-1);
|
||||
@@ -270,21 +337,37 @@ public:
|
||||
display.drawTextCentered(display.width() / 2, 64 - 11, "advert: " PRESS_LABEL);
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
} else if (_page == HomePage::GPS) {
|
||||
extern GPSDutyCycle gpsDuty;
|
||||
extern GPSStreamCounter gpsStream;
|
||||
LocationProvider* nmea = sensors.getLocationProvider();
|
||||
char buf[50];
|
||||
int y = 18;
|
||||
bool gps_state = _task->getGPSState();
|
||||
#ifdef PIN_GPS_SWITCH
|
||||
bool hw_gps_state = digitalRead(PIN_GPS_SWITCH);
|
||||
if (gps_state != hw_gps_state) {
|
||||
strcpy(buf, gps_state ? "gps off(hw)" : "gps off(sw)");
|
||||
|
||||
// GPS state line with duty cycle info
|
||||
if (!_node_prefs->gps_enabled) {
|
||||
strcpy(buf, "gps off");
|
||||
} else {
|
||||
strcpy(buf, gps_state ? "gps on" : "gps off");
|
||||
switch (gpsDuty.getState()) {
|
||||
case GPSDutyState::ACQUIRING: {
|
||||
uint32_t elapsed = gpsDuty.acquireElapsedSecs();
|
||||
sprintf(buf, "acquiring %us", (unsigned)elapsed);
|
||||
break;
|
||||
}
|
||||
case GPSDutyState::SLEEPING: {
|
||||
uint32_t remain = gpsDuty.sleepRemainingSecs();
|
||||
if (remain >= 60) {
|
||||
sprintf(buf, "sleep %um%02us", (unsigned)(remain / 60), (unsigned)(remain % 60));
|
||||
} else {
|
||||
sprintf(buf, "sleep %us", (unsigned)remain);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
strcpy(buf, "gps off");
|
||||
}
|
||||
}
|
||||
#else
|
||||
strcpy(buf, gps_state ? "gps on" : "gps off");
|
||||
#endif
|
||||
display.drawTextLeftAlign(0, y, buf);
|
||||
|
||||
if (nmea == NULL) {
|
||||
y = y + 12;
|
||||
display.drawTextLeftAlign(0, y, "Can't access GPS");
|
||||
@@ -296,6 +379,19 @@ public:
|
||||
sprintf(buf, "%d", nmea->satellitesCount());
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y = y + 12;
|
||||
|
||||
// NMEA sentence counter  confirms baud rate and data flow
|
||||
display.drawTextLeftAlign(0, y, "sentences");
|
||||
if (gpsDuty.isHardwareOn()) {
|
||||
uint16_t sps = gpsStream.getSentencesPerSec();
|
||||
uint32_t total = gpsStream.getSentenceCount();
|
||||
sprintf(buf, "%u/s (%lu)", sps, (unsigned long)total);
|
||||
} else {
|
||||
strcpy(buf, "hw off");
|
||||
}
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y = y + 12;
|
||||
|
||||
display.drawTextLeftAlign(0, y, "pos");
|
||||
sprintf(buf, "%.4f %.4f",
|
||||
nmea->getLatitude()/1000000., nmea->getLongitude()/1000000.);
|
||||
@@ -306,6 +402,42 @@ public:
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y = y + 12;
|
||||
}
|
||||
// Show RTC time and UTC offset on GPS page
|
||||
{
|
||||
uint32_t now = _rtc->getCurrentTime();
|
||||
if (now > 1700000000) {
|
||||
int32_t local = (int32_t)now + ((int32_t)_node_prefs->utc_offset_hours * 3600);
|
||||
int hrs = (local / 3600) % 24;
|
||||
if (hrs < 0) hrs += 24;
|
||||
int mins = (local / 60) % 60;
|
||||
if (mins < 0) mins += 60;
|
||||
display.drawTextLeftAlign(0, y, "time(U)");
|
||||
sprintf(buf, "%02d:%02d UTC%+d", hrs, mins, _node_prefs->utc_offset_hours);
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
} else {
|
||||
display.drawTextLeftAlign(0, y, "time(U)");
|
||||
display.drawTextRightAlign(display.width()-1, y, "no sync");
|
||||
}
|
||||
}
|
||||
// UTC offset editor overlay
|
||||
if (_editing_utc) {
|
||||
// Draw background box
|
||||
int bx = 4, by = 20, bw = display.width() - 8, bh = 40;
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
display.fillRect(bx, by, bw, bh);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(bx, by, bw, bh);
|
||||
|
||||
// Show current offset value
|
||||
display.setTextSize(2);
|
||||
sprintf(buf, "UTC%+d", _node_prefs->utc_offset_hours);
|
||||
display.drawTextCentered(display.width() / 2, by + 4, buf);
|
||||
|
||||
// Show controls hint
|
||||
display.setTextSize(0);
|
||||
display.drawTextCentered(display.width() / 2, by + bh - 10, "W/S:adj Enter:ok Q:cancel");
|
||||
display.setTextSize(1);
|
||||
}
|
||||
#endif
|
||||
#if UI_SENSORS_PAGE == 1
|
||||
} else if (_page == HomePage::SENSORS) {
|
||||
@@ -389,10 +521,44 @@ public:
|
||||
display.drawTextCentered(display.width() / 2, 64 - 11, "hibernate:" PRESS_LABEL);
|
||||
}
|
||||
}
|
||||
return 5000; // next render after 5000 ms
|
||||
return _editing_utc ? 700 : 5000; // match e-ink refresh cycle while editing UTC
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
// UTC offset editing mode - intercept all keys
|
||||
if (_editing_utc) {
|
||||
if (c == 'w' || c == KEY_PREV) {
|
||||
// Increment offset
|
||||
if (_node_prefs->utc_offset_hours < 14) {
|
||||
_node_prefs->utc_offset_hours++;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (c == 's' || c == KEY_NEXT) {
|
||||
// Decrement offset
|
||||
if (_node_prefs->utc_offset_hours > -12) {
|
||||
_node_prefs->utc_offset_hours--;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_ENTER) {
|
||||
// Save and exit
|
||||
Serial.printf("UTC offset saving: %d\n", _node_prefs->utc_offset_hours);
|
||||
the_mesh.savePrefs();
|
||||
_editing_utc = false;
|
||||
_task->showAlert("UTC offset saved", 800);
|
||||
Serial.println("UTC offset save complete");
|
||||
return true;
|
||||
}
|
||||
if (c == 'q' || c == 'u') {
|
||||
// Cancel - restore original value
|
||||
_node_prefs->utc_offset_hours = _saved_utc_offset;
|
||||
_editing_utc = false;
|
||||
return true;
|
||||
}
|
||||
return true; // Consume all other keys while editing
|
||||
}
|
||||
|
||||
if (c == KEY_LEFT || c == KEY_PREV) {
|
||||
_page = (_page + HomePage::Count - 1) % HomePage::Count;
|
||||
return true;
|
||||
@@ -426,6 +592,11 @@ public:
|
||||
_task->toggleGPS();
|
||||
return true;
|
||||
}
|
||||
if (c == 'u' && _page == HomePage::GPS) {
|
||||
_editing_utc = true;
|
||||
_saved_utc_offset = _node_prefs->utc_offset_hours;
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
#if UI_SENSORS_PAGE == 1
|
||||
if (c == KEY_ENTER && _page == HomePage::SENSORS) {
|
||||
@@ -580,12 +751,18 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
|
||||
splash = new SplashScreen(this);
|
||||
home = new HomeScreen(this, &rtc_clock, sensors, node_prefs);
|
||||
msg_preview = new MsgPreviewScreen(this, &rtc_clock);
|
||||
channel_screen = new ChannelScreen(this, &rtc_clock);
|
||||
contacts_screen = new ContactsScreen(this, &rtc_clock);
|
||||
text_reader = new TextReaderScreen(this);
|
||||
notes_screen = new NotesScreen(this);
|
||||
settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs);
|
||||
setCurrScreen(splash);
|
||||
}
|
||||
|
||||
void UITask::showAlert(const char* text, int duration_millis) {
|
||||
strcpy(_alert, text);
|
||||
_alert_expiry = millis() + duration_millis;
|
||||
_next_refresh = millis() + 100; // trigger re-render to show updated text
|
||||
}
|
||||
|
||||
void UITask::notify(UIEventType t) {
|
||||
@@ -628,8 +805,34 @@ void UITask::msgRead(int msgcount) {
|
||||
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) {
|
||||
_msgcount = msgcount;
|
||||
|
||||
// Add to preview screen (for notifications on non-keyboard devices)
|
||||
((MsgPreviewScreen *) msg_preview)->addPreview(path_len, from_name, text);
|
||||
|
||||
// Determine channel index by looking up the channel name
|
||||
// For channel messages, from_name is the channel name
|
||||
// For contact messages, from_name is the contact name (channel_idx = 0xFF)
|
||||
uint8_t channel_idx = 0xFF; // Default: unknown/contact message
|
||||
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
|
||||
ChannelDetails ch;
|
||||
if (the_mesh.getChannel(i, ch) && strcmp(ch.name, from_name) == 0) {
|
||||
channel_idx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Add to channel history screen with channel index
|
||||
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text);
|
||||
|
||||
#if defined(LilyGo_TDeck_Pro)
|
||||
// T-Deck Pro: Don't interrupt user with popup - just show brief notification
|
||||
// Messages are stored in channel history, accessible via 'M' key
|
||||
char alertBuf[40];
|
||||
snprintf(alertBuf, sizeof(alertBuf), "New: %s", from_name);
|
||||
showAlert(alertBuf, 2000);
|
||||
#else
|
||||
// Other devices: Show full preview screen (legacy behavior)
|
||||
setCurrScreen(msg_preview);
|
||||
#endif
|
||||
|
||||
if (_display != NULL) {
|
||||
if (!_display->isOn() && !hasConnection()) {
|
||||
@@ -872,39 +1075,36 @@ char UITask::handleTripleClick(char c) {
|
||||
}
|
||||
|
||||
bool UITask::getGPSState() {
|
||||
if (_sensors != NULL) {
|
||||
int num = _sensors->getNumSettings();
|
||||
for (int i = 0; i < num; i++) {
|
||||
if (strcmp(_sensors->getSettingName(i), "gps") == 0) {
|
||||
return !strcmp(_sensors->getSettingValue(i), "1");
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
return _node_prefs != NULL && _node_prefs->gps_enabled;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
void UITask::toggleGPS() {
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
extern GPSDutyCycle gpsDuty;
|
||||
|
||||
if (_sensors != NULL) {
|
||||
// toggle GPS on/off
|
||||
int num = _sensors->getNumSettings();
|
||||
for (int i = 0; i < num; i++) {
|
||||
if (strcmp(_sensors->getSettingName(i), "gps") == 0) {
|
||||
if (strcmp(_sensors->getSettingValue(i), "1") == 0) {
|
||||
_sensors->setSettingValue("gps", "0");
|
||||
_node_prefs->gps_enabled = 0;
|
||||
notify(UIEventType::ack);
|
||||
} else {
|
||||
_sensors->setSettingValue("gps", "1");
|
||||
_node_prefs->gps_enabled = 1;
|
||||
notify(UIEventType::ack);
|
||||
}
|
||||
the_mesh.savePrefs();
|
||||
showAlert(_node_prefs->gps_enabled ? "GPS: Enabled" : "GPS: Disabled", 800);
|
||||
_next_refresh = 0;
|
||||
break;
|
||||
if (_node_prefs->gps_enabled) {
|
||||
// Disable GPS  cut hardware power
|
||||
_sensors->setSettingValue("gps", "0");
|
||||
_node_prefs->gps_enabled = 0;
|
||||
gpsDuty.disable();
|
||||
notify(UIEventType::ack);
|
||||
} else {
|
||||
// Enable GPS  start duty cycle
|
||||
_sensors->setSettingValue("gps", "1");
|
||||
_node_prefs->gps_enabled = 1;
|
||||
gpsDuty.enable();
|
||||
notify(UIEventType::ack);
|
||||
}
|
||||
the_mesh.savePrefs();
|
||||
showAlert(_node_prefs->gps_enabled ? "GPS: Enabled" : "GPS: Disabled", 800);
|
||||
_next_refresh = 0;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void UITask::toggleBuzzer() {
|
||||
@@ -922,3 +1122,118 @@ void UITask::toggleBuzzer() {
|
||||
_next_refresh = 0; // trigger refresh
|
||||
#endif
|
||||
}
|
||||
|
||||
void UITask::injectKey(char c) {
|
||||
if (c != 0 && curr) {
|
||||
// Turn on display if it's off
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
curr->handleInput(c);
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer
|
||||
// Debounce refresh when editing UTC offset - e-ink takes 644ms per refresh
|
||||
// so don't queue another render until the current one could have finished
|
||||
if (isEditingHomeScreen()) {
|
||||
unsigned long earliest = millis() + 700;
|
||||
if (_next_refresh < earliest) {
|
||||
_next_refresh = earliest;
|
||||
}
|
||||
} else {
|
||||
_next_refresh = 100; // trigger refresh
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void UITask::gotoHomeScreen() {
|
||||
// Cancel any active editing state when navigating to home
|
||||
((HomeScreen *) home)->cancelEditUTC();
|
||||
setCurrScreen(home);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
bool UITask::isEditingHomeScreen() const {
|
||||
return curr == home && ((HomeScreen *) home)->isEditingUTC();
|
||||
}
|
||||
|
||||
void UITask::gotoChannelScreen() {
|
||||
((ChannelScreen *) channel_screen)->resetScroll();
|
||||
setCurrScreen(channel_screen);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
void UITask::gotoNotesScreen() {
|
||||
NotesScreen* notes = (NotesScreen*)notes_screen;
|
||||
if (_display != NULL) {
|
||||
notes->enter(*_display);
|
||||
}
|
||||
setCurrScreen(notes_screen);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
void UITask::gotoSettingsScreen() {
|
||||
((SettingsScreen *) settings_screen)->enter();
|
||||
setCurrScreen(settings_screen);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
void UITask::gotoOnboarding() {
|
||||
((SettingsScreen *) settings_screen)->enterOnboarding();
|
||||
setCurrScreen(settings_screen);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
uint8_t UITask::getChannelScreenViewIdx() const {
|
||||
return ((ChannelScreen *) channel_screen)->getViewChannelIdx();
|
||||
}
|
||||
|
||||
void UITask::addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) {
|
||||
// Format the message as "Sender: message"
|
||||
char formattedMsg[CHANNEL_MSG_TEXT_LEN];
|
||||
snprintf(formattedMsg, sizeof(formattedMsg), "%s: %s", sender, text);
|
||||
|
||||
// Add to channel history with path_len=0 (local message)
|
||||
((ChannelScreen *) channel_screen)->addMessage(channel_idx, 0, sender, formattedMsg);
|
||||
}
|
||||
@@ -51,6 +51,11 @@ class UITask : public AbstractUITask {
|
||||
UIScreen* splash;
|
||||
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* notes_screen; // Notes editor screen
|
||||
UIScreen* settings_screen; // Settings/onboarding screen
|
||||
UIScreen* curr;
|
||||
|
||||
void userLedHandler();
|
||||
@@ -72,16 +77,46 @@ public:
|
||||
}
|
||||
void begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs);
|
||||
|
||||
void gotoHomeScreen() { setCurrScreen(home); }
|
||||
void showAlert(const char* text, int duration_millis);
|
||||
void gotoHomeScreen();
|
||||
void gotoChannelScreen(); // Navigate to channel message screen
|
||||
void gotoContactsScreen(); // Navigate to contacts list
|
||||
void gotoTextReader(); // *** NEW: Navigate to text reader ***
|
||||
void gotoNotesScreen(); // Navigate to notes editor
|
||||
void gotoSettingsScreen(); // Navigate to settings
|
||||
void gotoOnboarding(); // Navigate to settings in onboarding mode
|
||||
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 ***
|
||||
bool isOnNotesScreen() const { return curr == notes_screen; }
|
||||
bool isOnSettingsScreen() const { return curr == settings_screen; }
|
||||
uint8_t getChannelScreenViewIdx() const;
|
||||
|
||||
void toggleBuzzer();
|
||||
bool getGPSState();
|
||||
void toggleGPS();
|
||||
|
||||
// Check if home screen is in an editing mode (e.g. UTC offset editor)
|
||||
bool isEditingHomeScreen() const;
|
||||
|
||||
// Inject a key press from external source (e.g., keyboard)
|
||||
void injectKey(char c);
|
||||
|
||||
// Add a sent message to the channel screen history
|
||||
void addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) override;
|
||||
|
||||
// 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* getNotesScreen() const { return notes_screen; }
|
||||
UIScreen* getContactsScreen() const { return contacts_screen; }
|
||||
UIScreen* getChannelScreen() const { return channel_screen; }
|
||||
UIScreen* getSettingsScreen() const { return settings_screen; }
|
||||
|
||||
// from AbstractUITask
|
||||
void msgRead(int msgcount) override;
|
||||
@@ -90,4 +125,4 @@ public:
|
||||
void loop() override;
|
||||
|
||||
void shutdown(bool restart = false);
|
||||
};
|
||||
};
|
||||
152
examples/companion_radio/ui-new/Utf8cp437.h
Normal file
152
examples/companion_radio/ui-new/Utf8cp437.h
Normal file
@@ -0,0 +1,152 @@
|
||||
#pragma once
|
||||
// =============================================================================
|
||||
// Utf8CP437.h - UTF-8 decoding and Unicode-to-CP437 mapping
|
||||
//
|
||||
// The Adafruit GFX built-in 6x8 font uses the CP437 character set for codes
|
||||
// 128-255. This header provides utilities to:
|
||||
// 1. Decode UTF-8 multi-byte sequences into Unicode codepoints
|
||||
// 2. Map Unicode codepoints to CP437 byte values for display
|
||||
//
|
||||
// Used by both EpubProcessor (at XHTML→text conversion time) and
|
||||
// TextReaderScreen (at render time for plain .txt files).
|
||||
// =============================================================================
|
||||
|
||||
// Map a Unicode codepoint to its CP437 equivalent byte.
|
||||
// Returns the CP437 byte (0x80-0xFF) for supported accented characters,
|
||||
// the codepoint itself for ASCII (0x20-0x7E), or 0 if unmappable.
|
||||
inline uint8_t unicodeToCP437(uint32_t cp) {
|
||||
// ASCII passthrough
|
||||
if (cp >= 0x20 && cp < 0x7F) return (uint8_t)cp;
|
||||
|
||||
switch (cp) {
|
||||
// Uppercase accented
|
||||
case 0x00C7: return 0x80; // Ç
|
||||
case 0x00C9: return 0x90; // É
|
||||
case 0x00C4: return 0x8E; // Ä
|
||||
case 0x00C5: return 0x8F; // Å
|
||||
case 0x00C6: return 0x92; // Æ
|
||||
case 0x00D6: return 0x99; // Ö
|
||||
case 0x00DC: return 0x9A; // Ü
|
||||
case 0x00D1: return 0xA5; // Ñ
|
||||
|
||||
// Lowercase accented
|
||||
case 0x00E9: return 0x82; // é
|
||||
case 0x00E2: return 0x83; // â
|
||||
case 0x00E4: return 0x84; // ä
|
||||
case 0x00E0: return 0x85; // à
|
||||
case 0x00E5: return 0x86; // å
|
||||
case 0x00E7: return 0x87; // ç
|
||||
case 0x00EA: return 0x88; // ê
|
||||
case 0x00EB: return 0x89; // ë
|
||||
case 0x00E8: return 0x8A; // è
|
||||
case 0x00EF: return 0x8B; // ï
|
||||
case 0x00EE: return 0x8C; // î
|
||||
case 0x00EC: return 0x8D; // ì
|
||||
case 0x00E6: return 0x91; // æ
|
||||
case 0x00F4: return 0x93; // ô
|
||||
case 0x00F6: return 0x94; // ö
|
||||
case 0x00F2: return 0x95; // ò
|
||||
case 0x00FB: return 0x96; // û
|
||||
case 0x00F9: return 0x97; // ù
|
||||
case 0x00FF: return 0x98; // ÿ
|
||||
case 0x00FC: return 0x81; // ü
|
||||
case 0x00E1: return 0xA0; // á
|
||||
case 0x00ED: return 0xA1; // í
|
||||
case 0x00F3: return 0xA2; // ó
|
||||
case 0x00FA: return 0xA3; // ú
|
||||
case 0x00F1: return 0xA4; // ñ
|
||||
|
||||
// Currency / symbols
|
||||
case 0x00A2: return 0x9B; // ¢
|
||||
case 0x00A3: return 0x9C; // £
|
||||
case 0x00A5: return 0x9D; // ¥
|
||||
case 0x00BF: return 0xA8; // ¿
|
||||
case 0x00A1: return 0xAD; // ¡
|
||||
case 0x00AB: return 0xAE; // «
|
||||
case 0x00BB: return 0xAF; // »
|
||||
case 0x00B0: return 0xF8; // °
|
||||
case 0x00B1: return 0xF1; // ±
|
||||
case 0x00B5: return 0xE6; // µ
|
||||
case 0x00DF: return 0xE1; // ß
|
||||
|
||||
// Typographic (smart quotes, dashes, etc.)
|
||||
case 0x2018: case 0x2019: return '\''; // Smart single quotes
|
||||
case 0x201C: case 0x201D: return '"'; // Smart double quotes
|
||||
case 0x2013: case 0x2014: return '-'; // En/em dash
|
||||
case 0x2010: case 0x2011: case 0x2012: case 0x2015: return '-'; // Hyphens/bars
|
||||
case 0x2026: return 0xFD; // Ellipsis (CP437 has no …, use ²? no, skip)
|
||||
case 0x2022: return 0x07; // Bullet → CP437 bullet
|
||||
case 0x00A0: return ' '; // Non-breaking space
|
||||
case 0x2039: case 0x203A: return '\''; // Single guillemets
|
||||
case 0x2032: return '\''; // Prime
|
||||
case 0x2033: return '"'; // Double prime
|
||||
|
||||
default: return 0; // Unmappable
|
||||
}
|
||||
}
|
||||
|
||||
// Decode a single UTF-8 character from a byte buffer.
|
||||
// Returns the Unicode codepoint and advances *pos past the full sequence.
|
||||
// If the sequence is invalid, returns 0xFFFD (replacement char) and advances by 1.
|
||||
//
|
||||
// buf: input buffer
|
||||
// bufLen: total buffer length
|
||||
// pos: pointer to current position (updated on return)
|
||||
inline uint32_t decodeUtf8Char(const char* buf, int bufLen, int* pos) {
|
||||
int i = *pos;
|
||||
if (i >= bufLen) return 0;
|
||||
|
||||
uint8_t c = (uint8_t)buf[i];
|
||||
|
||||
// ASCII (single byte)
|
||||
if (c < 0x80) {
|
||||
*pos = i + 1;
|
||||
return c;
|
||||
}
|
||||
|
||||
// Continuation byte without lead byte — skip
|
||||
if (c < 0xC0) {
|
||||
*pos = i + 1;
|
||||
return 0xFFFD;
|
||||
}
|
||||
|
||||
uint32_t codepoint;
|
||||
int extraBytes;
|
||||
|
||||
if ((c & 0xE0) == 0xC0) {
|
||||
codepoint = c & 0x1F;
|
||||
extraBytes = 1;
|
||||
} else if ((c & 0xF0) == 0xE0) {
|
||||
codepoint = c & 0x0F;
|
||||
extraBytes = 2;
|
||||
} else if ((c & 0xF8) == 0xF0) {
|
||||
codepoint = c & 0x07;
|
||||
extraBytes = 3;
|
||||
} else {
|
||||
*pos = i + 1;
|
||||
return 0xFFFD;
|
||||
}
|
||||
|
||||
// Verify we have enough bytes and they're valid continuation bytes
|
||||
if (i + extraBytes >= bufLen) {
|
||||
*pos = i + 1;
|
||||
return 0xFFFD;
|
||||
}
|
||||
|
||||
for (int b = 1; b <= extraBytes; b++) {
|
||||
uint8_t cb = (uint8_t)buf[i + b];
|
||||
if ((cb & 0xC0) != 0x80) {
|
||||
*pos = i + 1;
|
||||
return 0xFFFD;
|
||||
}
|
||||
codepoint = (codepoint << 6) | (cb & 0x3F);
|
||||
}
|
||||
|
||||
*pos = i + 1 + extraBytes;
|
||||
return codepoint;
|
||||
}
|
||||
|
||||
// Check if a byte is a UTF-8 continuation byte (10xxxxxx)
|
||||
inline bool isUtf8Continuation(uint8_t c) {
|
||||
return (c & 0xC0) == 0x80;
|
||||
}
|
||||
197
examples/companion_radio/ui-new/emojipicker.h
Normal file
197
examples/companion_radio/ui-new/emojipicker.h
Normal file
@@ -0,0 +1,197 @@
|
||||
#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
File diff suppressed because it is too large
Load Diff
@@ -249,4 +249,4 @@ size_t SerialBLEInterface::checkRecvFrame(uint8_t dest[]) {
|
||||
|
||||
bool SerialBLEInterface::isConnected() const {
|
||||
return deviceConnected; //pServer != NULL && pServer->getConnectedCount() > 0;
|
||||
}
|
||||
}
|
||||
@@ -88,4 +88,4 @@ public:
|
||||
#else
|
||||
#define BLE_DEBUG_PRINT(...) {}
|
||||
#define BLE_DEBUG_PRINTLN(...) {}
|
||||
#endif
|
||||
#endif
|
||||
@@ -84,6 +84,10 @@ void GxEPDDisplay::startFrame(Color bkg) {
|
||||
void GxEPDDisplay::setTextSize(int sz) {
|
||||
display_crc.update<int>(sz);
|
||||
switch(sz) {
|
||||
case 0: // Tiny - built-in 6x8 pixel font
|
||||
display.setFont(NULL);
|
||||
display.setTextSize(1);
|
||||
break;
|
||||
case 1: // Small - use 9pt (was 9pt)
|
||||
display.setFont(&FreeSans9pt7b);
|
||||
break;
|
||||
|
||||
70
variants/lilygo_tdeck_pro/CPUPowerManager.h
Normal file
70
variants/lilygo_tdeck_pro/CPUPowerManager.h
Normal file
@@ -0,0 +1,70 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
// CPU Frequency Scaling for ESP32-S3
|
||||
//
|
||||
// Typical current draw (CPU only, rough):
|
||||
// 240 MHz ~70-80 mA
|
||||
// 160 MHz ~50-60 mA
|
||||
// 80 MHz ~30-40 mA
|
||||
//
|
||||
// SPI peripherals and UART use their own clock dividers from the APB clock,
|
||||
// so LoRa, e-ink, and GPS serial all work fine at 80MHz.
|
||||
|
||||
#ifdef ESP32
|
||||
|
||||
#ifndef CPU_FREQ_IDLE
|
||||
#define CPU_FREQ_IDLE 80 // MHz — normal mesh listening
|
||||
#endif
|
||||
|
||||
#ifndef CPU_FREQ_BOOST
|
||||
#define CPU_FREQ_BOOST 240 // MHz — heavy processing
|
||||
#endif
|
||||
|
||||
#ifndef CPU_BOOST_TIMEOUT_MS
|
||||
#define CPU_BOOST_TIMEOUT_MS 10000 // 10 seconds
|
||||
#endif
|
||||
|
||||
class CPUPowerManager {
|
||||
public:
|
||||
CPUPowerManager() : _boosted(false), _boost_started(0) {}
|
||||
|
||||
void begin() {
|
||||
setCpuFrequencyMhz(CPU_FREQ_IDLE);
|
||||
_boosted = false;
|
||||
MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
if (_boosted && (millis() - _boost_started >= CPU_BOOST_TIMEOUT_MS)) {
|
||||
setIdle();
|
||||
}
|
||||
}
|
||||
|
||||
void setBoost() {
|
||||
if (!_boosted) {
|
||||
setCpuFrequencyMhz(CPU_FREQ_BOOST);
|
||||
_boosted = true;
|
||||
MESH_DEBUG_PRINTLN("CPU power: boosted to %d MHz", CPU_FREQ_BOOST);
|
||||
}
|
||||
_boost_started = millis();
|
||||
}
|
||||
|
||||
void setIdle() {
|
||||
if (_boosted) {
|
||||
setCpuFrequencyMhz(CPU_FREQ_IDLE);
|
||||
_boosted = false;
|
||||
MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE);
|
||||
}
|
||||
}
|
||||
|
||||
bool isBoosted() const { return _boosted; }
|
||||
uint32_t getFrequencyMHz() const { return getCpuFrequencyMhz(); }
|
||||
|
||||
private:
|
||||
bool _boosted;
|
||||
unsigned long _boost_started;
|
||||
};
|
||||
|
||||
#endif // ESP32
|
||||
185
variants/lilygo_tdeck_pro/GPSDutyCycle.h
Normal file
185
variants/lilygo_tdeck_pro/GPSDutyCycle.h
Normal file
@@ -0,0 +1,185 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include "variant.h"
|
||||
#include "GPSStreamCounter.h"
|
||||
|
||||
// GPS Duty Cycle Manager
|
||||
// Controls the hardware GPS enable pin (PIN_GPS_EN) to save power.
|
||||
// When enabled, cycles between acquiring a fix and sleeping with power cut.
|
||||
//
|
||||
// States:
|
||||
// OFF – User has disabled GPS. Hardware power is cut.
|
||||
// ACQUIRING – GPS module powered on, waiting for a fix or timeout.
|
||||
// SLEEPING – GPS module powered off, timer counting down to next cycle.
|
||||
|
||||
#if HAS_GPS
|
||||
|
||||
// How long to leave GPS powered on while acquiring a fix (ms)
|
||||
#ifndef GPS_ACQUIRE_TIMEOUT_MS
|
||||
#define GPS_ACQUIRE_TIMEOUT_MS 180000 // 3 minutes
|
||||
#endif
|
||||
|
||||
// How long to sleep between acquisition cycles (ms)
|
||||
#ifndef GPS_SLEEP_DURATION_MS
|
||||
#define GPS_SLEEP_DURATION_MS 900000 // 15 minutes
|
||||
#endif
|
||||
|
||||
// If we get a fix quickly, power off immediately but still respect
|
||||
// a minimum on-time so the RTC can sync properly
|
||||
#ifndef GPS_MIN_ON_TIME_MS
|
||||
#define GPS_MIN_ON_TIME_MS 5000 // 5 seconds after fix
|
||||
#endif
|
||||
|
||||
enum class GPSDutyState : uint8_t {
|
||||
OFF = 0, // User-disabled, hardware power off
|
||||
ACQUIRING, // Hardware on, waiting for fix
|
||||
SLEEPING // Hardware off, timer running
|
||||
};
|
||||
|
||||
class GPSDutyCycle {
|
||||
public:
|
||||
GPSDutyCycle() : _state(GPSDutyState::OFF), _state_entered(0),
|
||||
_last_fix_time(0), _got_fix(false), _time_synced(false),
|
||||
_stream(nullptr) {}
|
||||
|
||||
// Attach the stream counter so we can reset it on power cycles
|
||||
void setStreamCounter(GPSStreamCounter* stream) { _stream = stream; }
|
||||
|
||||
// Call once in setup() after board.begin() and GPS serial init.
|
||||
void begin(bool initial_enable) {
|
||||
if (initial_enable) {
|
||||
_powerOn();
|
||||
_setState(GPSDutyState::ACQUIRING);
|
||||
} else {
|
||||
_powerOff();
|
||||
_setState(GPSDutyState::OFF);
|
||||
}
|
||||
}
|
||||
|
||||
// Call every iteration of loop().
|
||||
// Returns true if GPS hardware is currently powered on.
|
||||
bool loop() {
|
||||
switch (_state) {
|
||||
case GPSDutyState::OFF:
|
||||
return false;
|
||||
|
||||
case GPSDutyState::ACQUIRING: {
|
||||
unsigned long elapsed = millis() - _state_entered;
|
||||
|
||||
if (_got_fix && elapsed >= GPS_MIN_ON_TIME_MS) {
|
||||
MESH_DEBUG_PRINTLN("GPS duty: fix acquired, powering off for %u min",
|
||||
(unsigned)(GPS_SLEEP_DURATION_MS / 60000));
|
||||
_powerOff();
|
||||
_setState(GPSDutyState::SLEEPING);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (elapsed >= GPS_ACQUIRE_TIMEOUT_MS) {
|
||||
MESH_DEBUG_PRINTLN("GPS duty: acquire timeout (%us), sleeping",
|
||||
(unsigned)(GPS_ACQUIRE_TIMEOUT_MS / 1000));
|
||||
_powerOff();
|
||||
_setState(GPSDutyState::SLEEPING);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
case GPSDutyState::SLEEPING: {
|
||||
if (millis() - _state_entered >= GPS_SLEEP_DURATION_MS) {
|
||||
MESH_DEBUG_PRINTLN("GPS duty: waking up for next acquisition cycle");
|
||||
_got_fix = false;
|
||||
_powerOn();
|
||||
_setState(GPSDutyState::ACQUIRING);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void notifyFix() {
|
||||
if (_state == GPSDutyState::ACQUIRING && !_got_fix) {
|
||||
_got_fix = true;
|
||||
_last_fix_time = millis();
|
||||
MESH_DEBUG_PRINTLN("GPS duty: fix notification received");
|
||||
}
|
||||
}
|
||||
|
||||
void notifyTimeSync() {
|
||||
_time_synced = true;
|
||||
}
|
||||
|
||||
void enable() {
|
||||
if (_state == GPSDutyState::OFF) {
|
||||
_got_fix = false;
|
||||
_powerOn();
|
||||
_setState(GPSDutyState::ACQUIRING);
|
||||
MESH_DEBUG_PRINTLN("GPS duty: enabled, starting acquisition");
|
||||
}
|
||||
}
|
||||
|
||||
void disable() {
|
||||
_powerOff();
|
||||
_setState(GPSDutyState::OFF);
|
||||
_got_fix = false;
|
||||
MESH_DEBUG_PRINTLN("GPS duty: disabled, power off");
|
||||
}
|
||||
|
||||
void forceWake() {
|
||||
if (_state == GPSDutyState::SLEEPING) {
|
||||
_got_fix = false;
|
||||
_powerOn();
|
||||
_setState(GPSDutyState::ACQUIRING);
|
||||
MESH_DEBUG_PRINTLN("GPS duty: forced wake for user request");
|
||||
}
|
||||
}
|
||||
|
||||
GPSDutyState getState() const { return _state; }
|
||||
bool isHardwareOn() const { return _state == GPSDutyState::ACQUIRING; }
|
||||
bool hadFix() const { return _got_fix; }
|
||||
bool hasTimeSynced() const { return _time_synced; }
|
||||
|
||||
uint32_t sleepRemainingSecs() const {
|
||||
if (_state != GPSDutyState::SLEEPING) return 0;
|
||||
unsigned long elapsed = millis() - _state_entered;
|
||||
if (elapsed >= GPS_SLEEP_DURATION_MS) return 0;
|
||||
return (GPS_SLEEP_DURATION_MS - elapsed) / 1000;
|
||||
}
|
||||
|
||||
uint32_t acquireElapsedSecs() const {
|
||||
if (_state != GPSDutyState::ACQUIRING) return 0;
|
||||
return (millis() - _state_entered) / 1000;
|
||||
}
|
||||
|
||||
private:
|
||||
void _powerOn() {
|
||||
#ifdef PIN_GPS_EN
|
||||
digitalWrite(PIN_GPS_EN, GPS_EN_ACTIVE);
|
||||
delay(10);
|
||||
#endif
|
||||
if (_stream) _stream->resetCounters();
|
||||
}
|
||||
|
||||
void _powerOff() {
|
||||
#ifdef PIN_GPS_EN
|
||||
digitalWrite(PIN_GPS_EN, !GPS_EN_ACTIVE);
|
||||
#endif
|
||||
}
|
||||
|
||||
void _setState(GPSDutyState s) {
|
||||
_state = s;
|
||||
_state_entered = millis();
|
||||
}
|
||||
|
||||
GPSDutyState _state;
|
||||
unsigned long _state_entered;
|
||||
unsigned long _last_fix_time;
|
||||
bool _got_fix;
|
||||
bool _time_synced;
|
||||
GPSStreamCounter* _stream;
|
||||
};
|
||||
|
||||
#endif // HAS_GPS
|
||||
72
variants/lilygo_tdeck_pro/GPSStreamCounter.h
Normal file
72
variants/lilygo_tdeck_pro/GPSStreamCounter.h
Normal file
@@ -0,0 +1,72 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
// Transparent Stream wrapper that counts NMEA sentences (newline-delimited)
|
||||
// flowing from the GPS serial port to the MicroNMEA parser.
|
||||
//
|
||||
// Usage: Instead of MicroNMEALocationProvider gps(Serial2, &rtc_clock);
|
||||
// Use: GPSStreamCounter gpsStream(Serial2);
|
||||
// MicroNMEALocationProvider gps(gpsStream, &rtc_clock);
|
||||
//
|
||||
// Every read() call passes through to the underlying stream; when a '\n'
|
||||
// is seen the sentence counter increments. This lets the UI display a
|
||||
// live "nmea" count so users can confirm the baud rate is correct and
|
||||
// the GPS module is actually sending data.
|
||||
|
||||
class GPSStreamCounter : public Stream {
|
||||
public:
|
||||
GPSStreamCounter(Stream& inner)
|
||||
: _inner(inner), _sentences(0), _sentences_snapshot(0),
|
||||
_last_snapshot(0), _sentences_per_sec(0) {}
|
||||
|
||||
// --- Stream read interface (passes through) ---
|
||||
int available() override { return _inner.available(); }
|
||||
int peek() override { return _inner.peek(); }
|
||||
|
||||
int read() override {
|
||||
int c = _inner.read();
|
||||
if (c == '\n') {
|
||||
_sentences++;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
// --- Stream write interface (pass through for NMEA commands if needed) ---
|
||||
size_t write(uint8_t b) override { return _inner.write(b); }
|
||||
|
||||
// --- Sentence counting API ---
|
||||
|
||||
// Total sentences received since boot (or last reset)
|
||||
uint32_t getSentenceCount() const { return _sentences; }
|
||||
|
||||
// Sentences received per second (updated each time you call it,
|
||||
// with a 1-second rolling window)
|
||||
uint16_t getSentencesPerSec() {
|
||||
unsigned long now = millis();
|
||||
unsigned long elapsed = now - _last_snapshot;
|
||||
if (elapsed >= 1000) {
|
||||
uint32_t delta = _sentences - _sentences_snapshot;
|
||||
// Scale to per-second if interval wasn't exactly 1000ms
|
||||
_sentences_per_sec = (uint16_t)((delta * 1000UL) / elapsed);
|
||||
_sentences_snapshot = _sentences;
|
||||
_last_snapshot = now;
|
||||
}
|
||||
return _sentences_per_sec;
|
||||
}
|
||||
|
||||
// Reset all counters (e.g. when GPS hardware power cycles)
|
||||
void resetCounters() {
|
||||
_sentences = 0;
|
||||
_sentences_snapshot = 0;
|
||||
_sentences_per_sec = 0;
|
||||
_last_snapshot = millis();
|
||||
}
|
||||
|
||||
private:
|
||||
Stream& _inner;
|
||||
volatile uint32_t _sentences;
|
||||
uint32_t _sentences_snapshot;
|
||||
unsigned long _last_snapshot;
|
||||
uint16_t _sentences_per_sec;
|
||||
};
|
||||
@@ -1,24 +1,25 @@
|
||||
#include <Arduino.h>
|
||||
#include "variant.h"
|
||||
#include "TDeckBoard.h"
|
||||
#include <Mesh.h> // For MESH_DEBUG_PRINTLN
|
||||
|
||||
uint32_t deviceOnline = 0x00;
|
||||
|
||||
void TDeckBoard::begin() {
|
||||
|
||||
Serial.println("TDeckBoard::begin() - starting");
|
||||
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - starting");
|
||||
|
||||
// Enable peripheral power (keyboard, sensors, etc.) FIRST
|
||||
// This powers the BQ27220 fuel gauge and other I2C devices
|
||||
pinMode(PIN_PERF_POWERON, OUTPUT);
|
||||
digitalWrite(PIN_PERF_POWERON, HIGH);
|
||||
delay(50); // Allow peripherals to power up before I2C init
|
||||
Serial.println("TDeckBoard::begin() - peripheral power enabled");
|
||||
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - peripheral power enabled");
|
||||
|
||||
// Initialize I2C with correct pins for T-Deck Pro
|
||||
Wire.begin(I2C_SDA, I2C_SCL);
|
||||
Wire.setClock(100000); // 100kHz for reliable fuel gauge communication
|
||||
Serial.println("TDeckBoard::begin() - I2C initialized");
|
||||
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - I2C initialized");
|
||||
|
||||
// Now call parent class begin (after power and I2C are ready)
|
||||
ESP32Board::begin();
|
||||
@@ -28,7 +29,7 @@ void TDeckBoard::begin() {
|
||||
pinMode(P_LORA_EN, OUTPUT);
|
||||
digitalWrite(P_LORA_EN, HIGH);
|
||||
delay(10); // Allow module to power up
|
||||
Serial.println("TDeckBoard::begin() - LoRa power enabled");
|
||||
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - LoRa power enabled");
|
||||
#endif
|
||||
|
||||
// Enable GPS module power and initialize Serial2
|
||||
@@ -37,14 +38,20 @@ void TDeckBoard::begin() {
|
||||
pinMode(PIN_GPS_EN, OUTPUT);
|
||||
digitalWrite(PIN_GPS_EN, GPS_EN_ACTIVE); // GPS_EN_ACTIVE is 1 (HIGH)
|
||||
delay(100); // Allow GPS to power up
|
||||
Serial.println("TDeckBoard::begin() - GPS power enabled");
|
||||
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - GPS power enabled");
|
||||
#endif
|
||||
|
||||
// Initialize Serial2 for GPS with correct pins
|
||||
Serial2.begin(GPS_BAUDRATE, SERIAL_8N1, GPS_RX_PIN, GPS_TX_PIN);
|
||||
Serial.print("TDeckBoard::begin() - GPS Serial2 initialized at ");
|
||||
Serial.print(GPS_BAUDRATE);
|
||||
Serial.println(" baud");
|
||||
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - GPS Serial2 initialized at %d baud", GPS_BAUDRATE);
|
||||
#endif
|
||||
|
||||
// Disable 4G modem power (only present on 4G version, not audio version)
|
||||
// This turns off the red status LED on the modem module
|
||||
#ifdef MODEM_POWER_EN
|
||||
pinMode(MODEM_POWER_EN, OUTPUT);
|
||||
digitalWrite(MODEM_POWER_EN, LOW); // Cut power to modem
|
||||
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - 4G modem power disabled");
|
||||
#endif
|
||||
|
||||
// Configure user button
|
||||
@@ -68,12 +75,10 @@ void TDeckBoard::begin() {
|
||||
// Test BQ27220 communication
|
||||
#if HAS_BQ27220
|
||||
uint16_t voltage = getBattMilliVolts();
|
||||
Serial.print("TDeckBoard::begin() - Battery voltage: ");
|
||||
Serial.print(voltage);
|
||||
Serial.println(" mV");
|
||||
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - Battery voltage: %d mV", voltage);
|
||||
#endif
|
||||
|
||||
Serial.println("TDeckBoard::begin() - complete");
|
||||
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - complete");
|
||||
}
|
||||
|
||||
uint16_t TDeckBoard::getBattMilliVolts() {
|
||||
@@ -81,13 +86,13 @@ uint16_t TDeckBoard::getBattMilliVolts() {
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(BQ27220_REG_VOLTAGE);
|
||||
if (Wire.endTransmission(false) != 0) {
|
||||
Serial.println("BQ27220: I2C error reading voltage");
|
||||
MESH_DEBUG_PRINTLN("BQ27220: I2C error reading voltage");
|
||||
return 0;
|
||||
}
|
||||
|
||||
uint8_t count = Wire.requestFrom((uint8_t)BQ27220_I2C_ADDR, (uint8_t)2);
|
||||
if (count != 2) {
|
||||
Serial.println("BQ27220: Read error - wrong byte count");
|
||||
MESH_DEBUG_PRINTLN("BQ27220: Read error - wrong byte count");
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,13 @@ class TDeckBoard : public ESP32Board {
|
||||
public:
|
||||
void begin();
|
||||
|
||||
void powerOff() override {
|
||||
// Stop Bluetooth before power off
|
||||
btStop();
|
||||
// Don't call parent or enterDeepSleep - let normal shutdown continue
|
||||
// Display will show "hibernating..." text
|
||||
}
|
||||
|
||||
void enterDeepSleep(uint32_t secs, int pin_wake_btn) {
|
||||
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON);
|
||||
|
||||
|
||||
331
variants/lilygo_tdeck_pro/Tca8418keyboard.h
Normal file
331
variants/lilygo_tdeck_pro/Tca8418keyboard.h
Normal file
@@ -0,0 +1,331 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <Wire.h>
|
||||
|
||||
// TCA8418 Register addresses
|
||||
#define TCA8418_REG_CFG 0x01
|
||||
#define TCA8418_REG_INT_STAT 0x02
|
||||
#define TCA8418_REG_KEY_LCK_EC 0x03
|
||||
#define TCA8418_REG_KEY_EVENT_A 0x04
|
||||
#define TCA8418_REG_KP_GPIO1 0x1D
|
||||
#define TCA8418_REG_KP_GPIO2 0x1E
|
||||
#define TCA8418_REG_KP_GPIO3 0x1F
|
||||
#define TCA8418_REG_DEBOUNCE 0x29
|
||||
#define TCA8418_REG_GPI_EM1 0x20
|
||||
#define TCA8418_REG_GPI_EM2 0x21
|
||||
#define TCA8418_REG_GPI_EM3 0x22
|
||||
|
||||
// Key codes for special keys
|
||||
#define KB_KEY_NONE 0
|
||||
#define KB_KEY_BACKSPACE '\b'
|
||||
#define KB_KEY_ENTER '\r'
|
||||
#define KB_KEY_SPACE ' '
|
||||
#define KB_KEY_EMOJI 0x01 // Non-printable code for $ key (emoji picker)
|
||||
|
||||
class TCA8418Keyboard {
|
||||
private:
|
||||
uint8_t _addr;
|
||||
TwoWire* _wire;
|
||||
bool _initialized;
|
||||
bool _shiftActive; // Sticky shift (one-shot or held)
|
||||
bool _shiftConsumed; // Was shift active for the last returned key
|
||||
bool _shiftHeld; // Shift key physically held down
|
||||
bool _shiftUsedWhileHeld; // Was shift consumed by any key while held
|
||||
bool _altActive; // Sticky alt (one-shot)
|
||||
bool _symActive; // Sticky sym (one-shot)
|
||||
unsigned long _lastShiftTime; // For Shift+key combos
|
||||
|
||||
uint8_t readReg(uint8_t reg) {
|
||||
_wire->beginTransmission(_addr);
|
||||
_wire->write(reg);
|
||||
_wire->endTransmission();
|
||||
_wire->requestFrom(_addr, (uint8_t)1);
|
||||
return _wire->available() ? _wire->read() : 0;
|
||||
}
|
||||
|
||||
void writeReg(uint8_t reg, uint8_t val) {
|
||||
_wire->beginTransmission(_addr);
|
||||
_wire->write(reg);
|
||||
_wire->write(val);
|
||||
_wire->endTransmission();
|
||||
}
|
||||
|
||||
// Map raw key codes to characters (from working reader firmware)
|
||||
char getKeyChar(uint8_t keyCode) {
|
||||
switch (keyCode) {
|
||||
// Row 1 - QWERTYUIOP
|
||||
case 10: return 'q'; // Q (was 97 on different hardware)
|
||||
case 9: return 'w';
|
||||
case 8: return 'e';
|
||||
case 7: return 'r';
|
||||
case 6: return 't';
|
||||
case 5: return 'y';
|
||||
case 4: return 'u';
|
||||
case 3: return 'i';
|
||||
case 2: return 'o';
|
||||
case 1: return 'p';
|
||||
|
||||
// Row 2 - ASDFGHJKL + Backspace
|
||||
case 20: return 'a'; // A (was 98 on different hardware)
|
||||
case 19: return 's';
|
||||
case 18: return 'd';
|
||||
case 17: return 'f';
|
||||
case 16: return 'g';
|
||||
case 15: return 'h';
|
||||
case 14: return 'j';
|
||||
case 13: return 'k';
|
||||
case 12: return 'l';
|
||||
case 11: return '\b'; // Backspace
|
||||
|
||||
// Row 3 - Alt ZXCVBNM Sym Enter
|
||||
case 30: return 0; // Alt - handled separately
|
||||
case 29: return 'z';
|
||||
case 28: return 'x';
|
||||
case 27: return 'c';
|
||||
case 26: return 'v';
|
||||
case 25: return 'b';
|
||||
case 24: return 'n';
|
||||
case 23: return 'm';
|
||||
case 22: return 0; // Symbol key - handled separately
|
||||
case 21: return '\r'; // Enter
|
||||
|
||||
// Row 4 - Shift Mic Space Sym Shift
|
||||
case 35: return 0; // Left shift - handled separately
|
||||
case 34: return 0; // Mic
|
||||
case 33: return ' '; // Space
|
||||
case 32: return 0; // Sym - handled separately
|
||||
case 31: return 0; // Right shift - handled separately
|
||||
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Map key with Alt modifier - same as Sym for this keyboard
|
||||
char getAltChar(uint8_t keyCode) {
|
||||
return getSymChar(keyCode); // Alt does same as Sym
|
||||
}
|
||||
|
||||
// Map key with Sym modifier - based on actual T-Deck Pro keyboard silk-screen
|
||||
char getSymChar(uint8_t keyCode) {
|
||||
switch (keyCode) {
|
||||
// Row 1: Q W E R T Y U I O P
|
||||
case 10: return '#'; // Q -> #
|
||||
case 9: return '1'; // W -> 1
|
||||
case 8: return '2'; // E -> 2
|
||||
case 7: return '3'; // R -> 3
|
||||
case 6: return '('; // T -> (
|
||||
case 5: return ')'; // Y -> )
|
||||
case 4: return '_'; // U -> _
|
||||
case 3: return '-'; // I -> -
|
||||
case 2: return '+'; // O -> +
|
||||
case 1: return '@'; // P -> @
|
||||
|
||||
// Row 2: A S D F G H J K L
|
||||
case 20: return '*'; // A -> *
|
||||
case 19: return '4'; // S -> 4
|
||||
case 18: return '5'; // D -> 5
|
||||
case 17: return '6'; // F -> 6
|
||||
case 16: return '/'; // G -> /
|
||||
case 15: return ':'; // H -> :
|
||||
case 14: return ';'; // J -> ;
|
||||
case 13: return '\''; // K -> '
|
||||
case 12: return '"'; // L -> "
|
||||
|
||||
// Row 3: Z X C V B N M
|
||||
case 29: return '7'; // Z -> 7
|
||||
case 28: return '8'; // X -> 8
|
||||
case 27: return '9'; // C -> 9
|
||||
case 26: return '?'; // V -> ?
|
||||
case 25: return '!'; // B -> !
|
||||
case 24: return ','; // N -> ,
|
||||
case 23: return '.'; // M -> .
|
||||
|
||||
// Row 4: Mic key -> 0
|
||||
case 34: return '0'; // Mic -> 0
|
||||
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public:
|
||||
TCA8418Keyboard(uint8_t addr = 0x34, TwoWire* wire = &Wire)
|
||||
: _addr(addr), _wire(wire), _initialized(false),
|
||||
_shiftActive(false), _shiftConsumed(false), _shiftHeld(false), _shiftUsedWhileHeld(false), _altActive(false), _symActive(false), _lastShiftTime(0) {}
|
||||
|
||||
bool begin() {
|
||||
// Check if device responds
|
||||
_wire->beginTransmission(_addr);
|
||||
if (_wire->endTransmission() != 0) {
|
||||
Serial.println("TCA8418: Device not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Configure keyboard matrix (8 rows x 10 cols)
|
||||
writeReg(TCA8418_REG_KP_GPIO1, 0xFF); // Rows 0-7 as keypad
|
||||
writeReg(TCA8418_REG_KP_GPIO2, 0xFF); // Cols 0-7 as keypad
|
||||
writeReg(TCA8418_REG_KP_GPIO3, 0x03); // Cols 8-9 as keypad
|
||||
|
||||
// Enable keypad with FIFO overflow detection
|
||||
writeReg(TCA8418_REG_CFG, 0x11); // KE_IEN + INT_CFG
|
||||
|
||||
// Set debounce
|
||||
writeReg(TCA8418_REG_DEBOUNCE, 0x03);
|
||||
|
||||
// Clear any pending interrupts
|
||||
writeReg(TCA8418_REG_INT_STAT, 0x1F);
|
||||
|
||||
// Flush the FIFO
|
||||
while (readReg(TCA8418_REG_KEY_LCK_EC) & 0x0F) {
|
||||
readReg(TCA8418_REG_KEY_EVENT_A);
|
||||
}
|
||||
|
||||
_initialized = true;
|
||||
Serial.println("TCA8418: Keyboard initialized OK");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Read a key press - returns character or 0 if no key
|
||||
char readKey() {
|
||||
if (!_initialized) return 0;
|
||||
|
||||
// Check for key events in FIFO
|
||||
uint8_t keyCount = readReg(TCA8418_REG_KEY_LCK_EC) & 0x0F;
|
||||
if (keyCount == 0) return 0;
|
||||
|
||||
// Read key event from FIFO
|
||||
uint8_t keyEvent = readReg(TCA8418_REG_KEY_EVENT_A);
|
||||
|
||||
// Bit 7: 1 = press, 0 = release
|
||||
bool pressed = (keyEvent & 0x80) != 0;
|
||||
uint8_t keyCode = keyEvent & 0x7F;
|
||||
|
||||
// Clear interrupt
|
||||
writeReg(TCA8418_REG_INT_STAT, 0x1F);
|
||||
|
||||
Serial.printf("KB raw: event=0x%02X code=%d pressed=%d count=%d\n",
|
||||
keyEvent, keyCode, pressed, keyCount);
|
||||
|
||||
// Track shift release (before the general release-ignore)
|
||||
if (!pressed && (keyCode == 35 || keyCode == 31)) {
|
||||
_shiftHeld = false;
|
||||
// If shift was used while held (e.g. cursor nav), clear it completely
|
||||
// so the next bare keypress isn't treated as shifted.
|
||||
// If shift was NOT used (tap-then-release), keep _shiftActive for one-shot.
|
||||
if (_shiftUsedWhileHeld) {
|
||||
_shiftActive = false;
|
||||
}
|
||||
_shiftUsedWhileHeld = false;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Only act on key press, not release
|
||||
if (!pressed || keyCode == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Handle modifier keys - set sticky state and return 0
|
||||
if (keyCode == 35 || keyCode == 31) { // Shift keys
|
||||
_shiftActive = true;
|
||||
_shiftHeld = true;
|
||||
_shiftUsedWhileHeld = false;
|
||||
_lastShiftTime = millis();
|
||||
Serial.println("KB: Shift activated");
|
||||
return 0;
|
||||
}
|
||||
if (keyCode == 30) { // Alt key
|
||||
_altActive = true;
|
||||
Serial.println("KB: Alt activated");
|
||||
return 0;
|
||||
}
|
||||
if (keyCode == 32) { // Sym key (bottom row)
|
||||
_symActive = true;
|
||||
Serial.println("KB: Sym activated");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Handle dedicated $ key (key code 22, next to M)
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Handle Mic key - produces 0 with Sym, otherwise ignore
|
||||
if (keyCode == 34) {
|
||||
if (_symActive) {
|
||||
_symActive = false;
|
||||
Serial.println("KB: Sym+Mic -> '0'");
|
||||
return '0';
|
||||
}
|
||||
return 0; // Ignore mic without Sym
|
||||
}
|
||||
|
||||
// Get the character
|
||||
char c = 0;
|
||||
|
||||
if (_altActive) {
|
||||
c = getAltChar(keyCode);
|
||||
_altActive = false; // Reset sticky alt
|
||||
if (c != 0) {
|
||||
Serial.printf("KB: Alt+key -> '%c'\n", c);
|
||||
return c;
|
||||
}
|
||||
}
|
||||
|
||||
if (_symActive) {
|
||||
c = getSymChar(keyCode);
|
||||
_symActive = false; // Reset sticky sym
|
||||
if (c != 0) {
|
||||
Serial.printf("KB: Sym+key -> '%c'\n", c);
|
||||
return c;
|
||||
}
|
||||
}
|
||||
|
||||
c = getKeyChar(keyCode);
|
||||
|
||||
if (c != 0 && _shiftActive) {
|
||||
// Apply shift - uppercase letters
|
||||
if (c >= 'a' && c <= 'z') {
|
||||
c = c - 'a' + 'A';
|
||||
}
|
||||
// Track that shift was used while physically held
|
||||
if (_shiftHeld) {
|
||||
_shiftUsedWhileHeld = true;
|
||||
}
|
||||
// Only clear shift if it's one-shot (tap), not held down
|
||||
if (!_shiftHeld) {
|
||||
_shiftActive = false;
|
||||
}
|
||||
_shiftConsumed = true; // Record that shift was active for this key
|
||||
} else {
|
||||
_shiftConsumed = false;
|
||||
}
|
||||
|
||||
if (c != 0) {
|
||||
Serial.printf("KB: code %d -> '%c' (0x%02X)\n", keyCode, c >= 32 ? c : '?', c);
|
||||
} else {
|
||||
Serial.printf("KB: code %d -> UNMAPPED\n", keyCode);
|
||||
}
|
||||
|
||||
return c;
|
||||
}
|
||||
|
||||
bool isReady() const { return _initialized; }
|
||||
|
||||
// Check if shift was pressed within the last N milliseconds
|
||||
bool wasShiftRecentlyPressed(unsigned long withinMs = 500) const {
|
||||
return (millis() - _lastShiftTime) < withinMs;
|
||||
}
|
||||
|
||||
// Check if shift was active when the most recent key was produced
|
||||
// (immune to e-ink refresh timing unlike wasShiftRecentlyPressed)
|
||||
bool wasShiftConsumed() const {
|
||||
return _shiftConsumed;
|
||||
}
|
||||
};
|
||||
@@ -80,6 +80,7 @@ build_flags =
|
||||
-D PIN_DISPLAY_BL=45
|
||||
-D PIN_USER_BTN=0
|
||||
-D CST328_PIN_RST=38
|
||||
-D FIRMWARE_VERSION='"Meck v0.8.7"'
|
||||
build_src_filter = ${esp32_base.build_src_filter}
|
||||
+<../variants/LilyGo_TDeck_Pro>
|
||||
+<helpers/sensors/*.cpp>
|
||||
@@ -113,9 +114,9 @@ extends = LilyGo_TDeck_Pro
|
||||
build_flags =
|
||||
${LilyGo_TDeck_Pro.build_flags}
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=350
|
||||
-D MAX_GROUP_CHANNELS=40
|
||||
-D BLE_PIN_CODE=234567
|
||||
-D MAX_CONTACTS=400
|
||||
-D MAX_GROUP_CHANNELS=20
|
||||
-D BLE_PIN_CODE=123456
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
|
||||
@@ -17,12 +17,13 @@ ESP32RTCClock fallback_clock;
|
||||
AutoDiscoverRTCClock rtc_clock(fallback_clock);
|
||||
|
||||
#if HAS_GPS
|
||||
MicroNMEALocationProvider gps(Serial2, &rtc_clock);
|
||||
// Wrap Serial2 with a sentence counter so the UI can show NMEA throughput.
|
||||
// MicroNMEALocationProvider reads through this wrapper transparently.
|
||||
GPSStreamCounter gpsStream(Serial2);
|
||||
MicroNMEALocationProvider gps(gpsStream, &rtc_clock);
|
||||
EnvironmentSensorManager sensors(gps);
|
||||
#pragma message "GPS enabled - using EnvironmentSensorManager with MicroNMEALocationProvider"
|
||||
#else
|
||||
SensorManager sensors;
|
||||
#pragma message "GPS disabled - using basic SensorManager"
|
||||
#endif
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
@@ -31,38 +32,37 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock);
|
||||
#endif
|
||||
|
||||
bool radio_init() {
|
||||
Serial.println("radio_init() - starting");
|
||||
MESH_DEBUG_PRINTLN("radio_init() - starting");
|
||||
|
||||
// NOTE: board.begin() is called by main.cpp setup() before radio_init()
|
||||
// I2C is already initialized there with correct pins
|
||||
|
||||
fallback_clock.begin();
|
||||
Serial.println("radio_init() - fallback_clock started");
|
||||
MESH_DEBUG_PRINTLN("radio_init() - fallback_clock started");
|
||||
|
||||
// Wire already initialized in board.begin() - just use it for RTC
|
||||
rtc_clock.begin(Wire);
|
||||
Serial.println("radio_init() - rtc_clock started");
|
||||
|
||||
// Debug GPS status
|
||||
#if HAS_GPS
|
||||
Serial.println("radio_init() - HAS_GPS is defined");
|
||||
Serial.print("radio_init() - gps object address: ");
|
||||
Serial.println((uint32_t)&gps, HEX);
|
||||
#else
|
||||
Serial.println("radio_init() - HAS_GPS is NOT defined");
|
||||
#endif
|
||||
MESH_DEBUG_PRINTLN("radio_init() - rtc_clock started");
|
||||
|
||||
#if defined(P_LORA_SCLK)
|
||||
Serial.println("radio_init() - initializing LoRa SPI...");
|
||||
MESH_DEBUG_PRINTLN("radio_init() - initializing LoRa SPI...");
|
||||
loraSpi.begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI, P_LORA_NSS);
|
||||
Serial.println("radio_init() - SPI initialized, calling radio.std_init()...");
|
||||
MESH_DEBUG_PRINTLN("radio_init() - SPI initialized, calling radio.std_init()...");
|
||||
bool result = radio.std_init(&loraSpi);
|
||||
Serial.print("radio_init() - radio.std_init() returned: ");
|
||||
Serial.println(result ? "SUCCESS" : "FAILED");
|
||||
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
|
||||
Serial.println("radio_init() - calling radio.std_init() without custom SPI...");
|
||||
return radio.std_init();
|
||||
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;
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -84,4 +84,8 @@ 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);
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
#if HAS_GPS
|
||||
#include "helpers/sensors/EnvironmentSensorManager.h"
|
||||
#include "helpers/sensors/MicroNMEALocationProvider.h"
|
||||
#include "GPSStreamCounter.h"
|
||||
#else
|
||||
#include <helpers/SensorManager.h>
|
||||
#endif
|
||||
@@ -27,6 +28,7 @@ extern WRAPPER_CLASS radio_driver;
|
||||
extern AutoDiscoverRTCClock rtc_clock;
|
||||
|
||||
#if HAS_GPS
|
||||
extern GPSStreamCounter gpsStream;
|
||||
extern EnvironmentSensorManager sensors;
|
||||
#else
|
||||
extern SensorManager sensors;
|
||||
@@ -41,4 +43,5 @@ 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();
|
||||
mesh::LocalIdentity radio_new_identity();
|
||||
void radio_reset_agc();
|
||||
@@ -1,31 +0,0 @@
|
||||
#include <Arduino.h>
|
||||
#include <Wire.h>
|
||||
|
||||
#include "TechoBoard.h"
|
||||
|
||||
#ifdef LILYGO_TECHO
|
||||
|
||||
void TechoBoard::begin() {
|
||||
NRF52Board::begin();
|
||||
|
||||
Wire.begin();
|
||||
|
||||
pinMode(SX126X_POWER_EN, OUTPUT);
|
||||
digitalWrite(SX126X_POWER_EN, HIGH);
|
||||
delay(10); // give sx1262 some time to power up
|
||||
}
|
||||
|
||||
uint16_t TechoBoard::getBattMilliVolts() {
|
||||
int adcvalue = 0;
|
||||
|
||||
analogReference(AR_INTERNAL_3_0);
|
||||
analogReadResolution(12);
|
||||
delay(10);
|
||||
|
||||
// ADC range is 0..3000mV and resolution is 12-bit (0..4095)
|
||||
adcvalue = analogRead(PIN_VBAT_READ);
|
||||
// Convert the raw value to compensated mv, taking the resistor-
|
||||
// divider into account (providing the actual LIPO voltage)
|
||||
return (uint16_t)((float)adcvalue * REAL_VBAT_MV_PER_LSB);
|
||||
}
|
||||
#endif
|
||||
@@ -1,44 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <MeshCore.h>
|
||||
#include <Arduino.h>
|
||||
#include <helpers/NRF52Board.h>
|
||||
|
||||
// built-ins
|
||||
#define VBAT_MV_PER_LSB (0.73242188F) // 3.0V ADC range and 12-bit ADC resolution = 3000mV/4096
|
||||
|
||||
#define VBAT_DIVIDER (0.5F) // 150K + 150K voltage divider on VBAT
|
||||
#define VBAT_DIVIDER_COMP (2.0F) // Compensation factor for the VBAT divider
|
||||
|
||||
#define PIN_VBAT_READ (4)
|
||||
#define REAL_VBAT_MV_PER_LSB (VBAT_DIVIDER_COMP * VBAT_MV_PER_LSB)
|
||||
|
||||
class TechoBoard : public NRF52BoardOTA {
|
||||
public:
|
||||
TechoBoard() : NRF52BoardOTA("TECHO_OTA") {}
|
||||
void begin();
|
||||
uint16_t getBattMilliVolts() override;
|
||||
|
||||
const char* getManufacturerName() const override {
|
||||
return "LilyGo T-Echo";
|
||||
}
|
||||
|
||||
void powerOff() override {
|
||||
#ifdef LED_RED
|
||||
digitalWrite(LED_RED, LOW);
|
||||
#endif
|
||||
#ifdef LED_GREEN
|
||||
digitalWrite(LED_GREEN, LOW);
|
||||
#endif
|
||||
#ifdef LED_BLUE
|
||||
digitalWrite(LED_BLUE, LOW);
|
||||
#endif
|
||||
#ifdef DISP_BACKLIGHT
|
||||
digitalWrite(DISP_BACKLIGHT, LOW);
|
||||
#endif
|
||||
#ifdef PIN_PWR_EN
|
||||
digitalWrite(PIN_PWR_EN, LOW);
|
||||
#endif
|
||||
sd_power_system_off();
|
||||
}
|
||||
};
|
||||
@@ -1,98 +0,0 @@
|
||||
[LilyGo_T-Echo-Lite]
|
||||
extends = nrf52_base
|
||||
board = t-echo
|
||||
board_build.ldscript = boards/nrf52840_s140_v6.ld
|
||||
build_flags = ${nrf52_base.build_flags}
|
||||
-I variants/lilygo_techo_lite
|
||||
-I src/helpers/nrf52
|
||||
-I lib/nrf52/s140_nrf52_6.1.1_API/include
|
||||
-I lib/nrf52/s140_nrf52_6.1.1_API/include/nrf52
|
||||
-D LILYGO_TECHO
|
||||
-D RADIO_CLASS=CustomSX1262
|
||||
-D WRAPPER_CLASS=CustomSX1262Wrapper
|
||||
-D LORA_TX_POWER=22
|
||||
-D SX126X_POWER_EN=30
|
||||
-D SX126X_CURRENT_LIMIT=140
|
||||
-D SX126X_RX_BOOSTED_GAIN=1
|
||||
-D P_LORA_TX_LED=LED_GREEN
|
||||
-D DISABLE_DIAGNOSTIC_OUTPUT
|
||||
-D ENV_INCLUDE_GPS=1
|
||||
-D GPS_BAUD_RATE=9600
|
||||
-D PIN_GPS_EN=GPS_EN
|
||||
-D DISPLAY_CLASS=GxEPDDisplay
|
||||
-D EINK_DISPLAY_MODEL=GxEPD2_122_T61
|
||||
-D EINK_SCALE_X=1.5f
|
||||
-D EINK_SCALE_Y=2.0f
|
||||
-D EINK_X_OFFSET=0
|
||||
-D EINK_Y_OFFSET=10
|
||||
-D DISPLAY_ROTATION=4
|
||||
-D AUTO_OFF_MILLIS=0
|
||||
build_src_filter = ${nrf52_base.build_src_filter}
|
||||
+<helpers/*.cpp>
|
||||
+<TechoBoard.cpp>
|
||||
+<helpers/sensors/EnvironmentSensorManager.cpp>
|
||||
+<helpers/ui/GxEPDDisplay.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<../variants/lilygo_techo_lite>
|
||||
lib_deps =
|
||||
${nrf52_base.lib_deps}
|
||||
stevemarple/MicroNMEA @ ^2.0.6
|
||||
adafruit/Adafruit BME280 Library @ ^2.3.0
|
||||
https://github.com/SoulOfNoob/GxEPD2.git
|
||||
bakercp/CRC32 @ ^2.0.0
|
||||
debug_tool = jlink
|
||||
upload_protocol = nrfutil
|
||||
|
||||
[env:LilyGo_T-Echo-Lite_repeater]
|
||||
extends = LilyGo_T-Echo-Lite
|
||||
build_src_filter = ${LilyGo_T-Echo-Lite.build_src_filter}
|
||||
+<../examples/simple_repeater>
|
||||
build_flags =
|
||||
${LilyGo_T-Echo-Lite.build_flags}
|
||||
-D ADVERT_NAME='"T-Echo-Lite Repeater"'
|
||||
-D ADVERT_LAT=0.0
|
||||
-D ADVERT_LON=0.0
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
; -D MESH_DEBUG=1
|
||||
|
||||
[env:LilyGo_T-Echo-Lite_room_server]
|
||||
extends = LilyGo_T-Echo-Lite
|
||||
build_src_filter = ${LilyGo_T-Echo-Lite.build_src_filter}
|
||||
+<../examples/simple_room_server>
|
||||
build_flags =
|
||||
${LilyGo_T-Echo-Lite.build_flags}
|
||||
-D ADVERT_NAME='"T-Echo-Lite Room"'
|
||||
-D ADVERT_LAT=0.0
|
||||
-D ADVERT_LON=0.0
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
; -D MESH_DEBUG=1
|
||||
|
||||
[env:LilyGo_T-Echo-Lite_companion_radio_ble]
|
||||
extends = LilyGo_T-Echo-Lite
|
||||
board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld
|
||||
board_upload.maximum_size = 712704
|
||||
build_flags =
|
||||
${LilyGo_T-Echo-Lite.build_flags}
|
||||
-I src/helpers/ui
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=350
|
||||
-D MAX_GROUP_CHANNELS=40
|
||||
; -D QSPIFLASH=1
|
||||
-D BLE_PIN_CODE=123456
|
||||
; -D BLE_DEBUG_LOGGING=1
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D UI_RECENT_LIST_SIZE=9
|
||||
-D UI_SENSORS_PAGE=1
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
; -D MESH_DEBUG=1
|
||||
-D AUTO_SHUTDOWN_MILLIVOLTS=3300
|
||||
build_src_filter = ${LilyGo_T-Echo-Lite.build_src_filter}
|
||||
+<helpers/nrf52/SerialBLEInterface.cpp>
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-new/*.cpp>
|
||||
lib_deps =
|
||||
${LilyGo_T-Echo-Lite.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
@@ -1,52 +0,0 @@
|
||||
#include <Arduino.h>
|
||||
#include "target.h"
|
||||
#include <helpers/ArduinoHelpers.h>
|
||||
#include <helpers/sensors/MicroNMEALocationProvider.h>
|
||||
|
||||
TechoBoard board;
|
||||
|
||||
RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, SPI);
|
||||
|
||||
WRAPPER_CLASS radio_driver(radio, board);
|
||||
|
||||
VolatileRTCClock fallback_clock;
|
||||
AutoDiscoverRTCClock rtc_clock(fallback_clock);
|
||||
|
||||
#ifdef ENV_INCLUDE_GPS
|
||||
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock);
|
||||
EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea);
|
||||
#else
|
||||
EnvironmentSensorManager sensors = EnvironmentSensorManager();
|
||||
#endif
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
DISPLAY_CLASS display;
|
||||
MomentaryButton user_btn(PIN_USER_BTN, 1000, true);
|
||||
#endif
|
||||
|
||||
bool radio_init() {
|
||||
rtc_clock.begin(Wire);
|
||||
|
||||
return radio.std_init(&SPI);
|
||||
}
|
||||
|
||||
uint32_t radio_get_rng_seed() {
|
||||
return radio.random(0x7FFFFFFF);
|
||||
}
|
||||
|
||||
void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr) {
|
||||
radio.setFrequency(freq);
|
||||
radio.setSpreadingFactor(sf);
|
||||
radio.setBandwidth(bw);
|
||||
radio.setCodingRate(cr);
|
||||
}
|
||||
|
||||
void radio_set_tx_power(uint8_t dbm) {
|
||||
radio.setOutputPower(dbm);
|
||||
}
|
||||
|
||||
mesh::LocalIdentity radio_new_identity() {
|
||||
RadioNoiseListener rng(radio);
|
||||
return mesh::LocalIdentity(&rng); // create new random identity
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#define RADIOLIB_STATIC_ONLY 1
|
||||
#include <RadioLib.h>
|
||||
#include <helpers/radiolib/RadioLibWrappers.h>
|
||||
#include <TechoBoard.h>
|
||||
#include <helpers/radiolib/CustomSX1262Wrapper.h>
|
||||
#include <helpers/AutoDiscoverRTCClock.h>
|
||||
#include <helpers/SensorManager.h>
|
||||
#include <helpers/sensors/EnvironmentSensorManager.h>
|
||||
#include <helpers/sensors/LocationProvider.h>
|
||||
#ifdef DISPLAY_CLASS
|
||||
#include <helpers/ui/GxEPDDisplay.h>
|
||||
#include <helpers/ui/MomentaryButton.h>
|
||||
#endif
|
||||
|
||||
extern TechoBoard board;
|
||||
extern WRAPPER_CLASS radio_driver;
|
||||
extern AutoDiscoverRTCClock rtc_clock;
|
||||
extern EnvironmentSensorManager sensors;
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
extern DISPLAY_CLASS display;
|
||||
extern MomentaryButton user_btn;
|
||||
#endif
|
||||
|
||||
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();
|
||||
@@ -1,39 +0,0 @@
|
||||
#include "variant.h"
|
||||
#include "wiring_constants.h"
|
||||
#include "wiring_digital.h"
|
||||
|
||||
const int MISO = PIN_SPI1_MISO;
|
||||
const int MOSI = PIN_SPI1_MOSI;
|
||||
const int SCK = PIN_SPI1_SCK;
|
||||
|
||||
const uint32_t g_ADigitalPinMap[] = {
|
||||
0xff, 0xff, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13,
|
||||
14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
|
||||
27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39,
|
||||
40, 41, 42, 43, 44, 45, 46, 47
|
||||
};
|
||||
|
||||
void initVariant() {
|
||||
pinMode(PIN_PWR_EN, OUTPUT);
|
||||
digitalWrite(PIN_PWR_EN, HIGH);
|
||||
|
||||
pinMode(PIN_BUTTON1, INPUT_PULLUP);
|
||||
pinMode(PIN_BUTTON2, INPUT_PULLUP);
|
||||
|
||||
pinMode(LED_RED, OUTPUT);
|
||||
pinMode(LED_GREEN, OUTPUT);
|
||||
pinMode(LED_BLUE, OUTPUT);
|
||||
digitalWrite(LED_BLUE, HIGH);
|
||||
digitalWrite(LED_GREEN, HIGH);
|
||||
digitalWrite(LED_RED, HIGH);
|
||||
|
||||
// pinMode(PIN_TXCO, OUTPUT);
|
||||
// digitalWrite(PIN_TXCO, HIGH);
|
||||
|
||||
pinMode(DISP_POWER, OUTPUT);
|
||||
digitalWrite(DISP_POWER, LOW);
|
||||
|
||||
// shutdown gps
|
||||
pinMode(GPS_EN, OUTPUT);
|
||||
digitalWrite(GPS_EN, LOW);
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
/*
|
||||
* variant.h
|
||||
* Copyright (C) 2023 Seeed K.K.
|
||||
* MIT License
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#define _PINNUM(port, pin) ((port) * 32 + (pin))
|
||||
|
||||
#include "WVariant.h"
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Low frequency clock source
|
||||
|
||||
#define USE_LFXO // 32.768 kHz crystal oscillator
|
||||
#define VARIANT_MCK (64000000ul)
|
||||
|
||||
#define WIRE_INTERFACES_COUNT (1)
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Power
|
||||
|
||||
#define PIN_PWR_EN _PINNUM(0, 30) // RT9080_EN
|
||||
|
||||
#define BATTERY_PIN _PINNUM(0, 2)
|
||||
#define ADC_MULTIPLIER (4.90F)
|
||||
|
||||
#define ADC_RESOLUTION (14)
|
||||
#define BATTERY_SENSE_RES (12)
|
||||
|
||||
#define AREF_VOLTAGE (3.0)
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Number of pins
|
||||
|
||||
#define PINS_COUNT (48)
|
||||
#define NUM_DIGITAL_PINS (48)
|
||||
#define NUM_ANALOG_INPUTS (1)
|
||||
#define NUM_ANALOG_OUTPUTS (0)
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// UART pin definition
|
||||
|
||||
#define PIN_SERIAL1_RX PIN_GPS_TX
|
||||
#define PIN_SERIAL1_TX PIN_GPS_RX
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// I2C pin definition
|
||||
|
||||
#define PIN_WIRE_SDA _PINNUM(0, 4) // (SDA)
|
||||
#define PIN_WIRE_SCL _PINNUM(0, 2) // (SCL)
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// SPI pin definition
|
||||
|
||||
#define SPI_INTERFACES_COUNT _PINNUM(0, 2)
|
||||
|
||||
#define PIN_SPI_MISO _PINNUM(0, 17) // (MISO)
|
||||
#define PIN_SPI_MOSI _PINNUM(0, 15) // (MOSI)
|
||||
#define PIN_SPI_SCK _PINNUM(0, 13) // (SCK)
|
||||
#define PIN_SPI_NSS (-1)
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// QSPI FLASH
|
||||
|
||||
#define PIN_QSPI_SCK _PINNUM(0, 4)
|
||||
#define PIN_QSPI_CS _PINNUM(0, 12)
|
||||
#define PIN_QSPI_IO0 _PINNUM(0, 6)
|
||||
#define PIN_QSPI_IO1 _PINNUM(0, 8)
|
||||
#define PIN_QSPI_IO2 _PINNUM(1, 9)
|
||||
#define PIN_QSPI_IO3 _PINNUM(0, 26)
|
||||
|
||||
#define EXTERNAL_FLASH_DEVICES ZD25WQ32CEIGR
|
||||
#define EXTERNAL_FLASH_USE_QSPI
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Builtin LEDs
|
||||
|
||||
#define LED_RED _PINNUM(1, 14) // LED_3
|
||||
#define LED_BLUE _PINNUM(1, 5) // LED_2
|
||||
#define LED_GREEN _PINNUM(1, 7) // LED_1
|
||||
|
||||
//#define PIN_STATUS_LED LED_BLUE
|
||||
#define LED_BUILTIN (-1)
|
||||
#define LED_PIN LED_BUILTIN
|
||||
#define LED_STATE_ON LOW
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Builtin buttons
|
||||
|
||||
#define PIN_BUTTON1 _PINNUM(0, 24) // BOOT
|
||||
#define BUTTON_PIN PIN_BUTTON1
|
||||
#define PIN_USER_BTN BUTTON_PIN
|
||||
|
||||
#define PIN_BUTTON2 _PINNUM(0, 18)
|
||||
#define BUTTON_PIN2 PIN_BUTTON2
|
||||
|
||||
#define EXTERNAL_FLASH_DEVICES MX25R1635F
|
||||
#define EXTERNAL_FLASH_USE_QSPI
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Lora
|
||||
|
||||
#define USE_SX1262
|
||||
#define LORA_CS _PINNUM(0, 11)
|
||||
#define SX126X_POWER_EN _PINNUM(0, 30)
|
||||
#define SX126X_DIO1 _PINNUM(1, 8)
|
||||
#define SX126X_BUSY _PINNUM(0, 14)
|
||||
#define SX126X_RESET _PINNUM(0, 7)
|
||||
#define SX126X_RF_VC1 _PINNUM(0, 27)
|
||||
#define SX126X_RF_VC2 _PINNUM(0, 33)
|
||||
|
||||
#define P_LORA_DIO_1 SX126X_DIO1
|
||||
#define P_LORA_NSS LORA_CS
|
||||
#define P_LORA_RESET SX126X_RESET
|
||||
#define P_LORA_BUSY SX126X_BUSY
|
||||
#define P_LORA_SCLK PIN_SPI_SCK
|
||||
#define P_LORA_MISO PIN_SPI_MISO
|
||||
#define P_LORA_MOSI PIN_SPI_MOSI
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// SPI1
|
||||
|
||||
#define PIN_SPI1_MISO (-1) // Not used for Display
|
||||
#define PIN_SPI1_MOSI _PINNUM(0, 20)
|
||||
#define PIN_SPI1_SCK _PINNUM(0, 19)
|
||||
|
||||
// GxEPD2 needs that for a panel that is not even used !
|
||||
extern const int MISO;
|
||||
extern const int MOSI;
|
||||
extern const int SCK;
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Display
|
||||
|
||||
// #define DISP_MISO (-1) // Not used for Display
|
||||
#define DISP_MOSI _PINNUM(0, 20)
|
||||
#define DISP_SCLK _PINNUM(0, 19)
|
||||
#define DISP_CS _PINNUM(0, 22)
|
||||
#define DISP_DC _PINNUM(0, 21)
|
||||
#define DISP_RST _PINNUM(0, 28)
|
||||
#define DISP_BUSY _PINNUM(0, 3)
|
||||
#define DISP_POWER _PINNUM(1, 12)
|
||||
// #define DISP_BACKLIGHT (-1) // Display has no backlight
|
||||
|
||||
#define PIN_DISPLAY_CS DISP_CS
|
||||
#define PIN_DISPLAY_DC DISP_DC
|
||||
#define PIN_DISPLAY_RST DISP_RST
|
||||
#define PIN_DISPLAY_BUSY DISP_BUSY
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// GPS
|
||||
|
||||
#define PIN_GPS_RX _PINNUM(1, 13) // RXD
|
||||
#define PIN_GPS_TX _PINNUM(1, 15) // TXD
|
||||
#define GPS_EN _PINNUM(1, 11) // POWER_RT9080_EN
|
||||
#define PIN_GPS_STANDBY _PINNUM(1, 10)
|
||||
#define PIN_GPS_PPS _PINNUM(0, 29) // 1PPS
|
||||
Reference in New Issue
Block a user