mirror of
https://github.com/pelgraine/Meck.git
synced 2026-03-28 17:42:44 +01:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9283af7fc | ||
|
|
39cd30890b | ||
|
|
902577ed10 | ||
|
|
ce93cfa033 | ||
|
|
2be399f65a | ||
|
|
5679cda38e | ||
|
|
1ea883783c | ||
|
|
bf8cf32bc2 | ||
|
|
465a29bb23 | ||
|
|
81eca29b69 | ||
|
|
342cf4e745 | ||
|
|
c52a190ace | ||
|
|
a7bc7a4733 | ||
|
|
47a0d2cc95 | ||
|
|
5dda0b686e | ||
|
|
60dcd6a89e | ||
|
|
19efb52521 | ||
|
|
81ef3ea3c5 | ||
|
|
6f07b7a372 | ||
|
|
b0f74b101a | ||
|
|
06a064538e | ||
|
|
166a433353 |
71
README.md
71
README.md
@@ -1,6 +1,6 @@
|
||||
## Meshcore + Fork = Meck
|
||||
|
||||
A fork created specifically to focus on enabling BLE & WiFi companion firmware for the LilyGo T-Deck Pro & LilyGo T5 E-Paper S3 Pro. Created with the assistance of Claude AI using Meshcore v1.11 code.
|
||||
A fork created specifically to focus on enabling BLE & WiFi companion firmware for the LilyGo T-Deck Pro & LilyGo T5 E-Paper S3 Pro. Created wholly with Claude AI using Meshcore v1.11 code. 100% vibecoded.
|
||||
|
||||
[Check out the Meck discussion channel on the MeshCore Discord](https://discord.com/channels/1343693475589263471/1460136499390447670)
|
||||
|
||||
@@ -33,6 +33,7 @@ A fork created specifically to focus on enabling BLE & WiFi companion firmware f
|
||||
- [Emoji Picker](#emoji-picker)
|
||||
- [SMS & Phone App (4G only)](#sms--phone-app-4g-only)
|
||||
- [Web Browser & IRC](#web-browser--irc)
|
||||
- [Alarm Clock (Audio only)](#alarm-clock-audio-only)
|
||||
- [Lock Screen (T-Deck Pro)](#lock-screen-t-deck-pro)
|
||||
- [T5S3 E-Paper Pro](#t5s3-e-paper-pro)
|
||||
- [Build Variants](#t5s3-build-variants)
|
||||
@@ -138,7 +139,7 @@ If you're loading firmware from an SD card via the LilyGo Launcher firmware, use
|
||||
Once Meck is installed, you can update firmware directly from your phone — no computer or serial cable required. The device creates a temporary WiFi access point and you upload the new `.bin` via your phone's browser.
|
||||
|
||||
1. Download the new **non-merged** `.bin` to your phone (from GitHub Releases, Discord, etc.)
|
||||
2. On the device: **Settings → Firmware Update → Enter** (T-Deck Pro) or **tap** (T5S3)
|
||||
2. On the device: **Settings → OTA Tools → Firmware Update → Enter** (T-Deck Pro) or **tap** (T5S3)
|
||||
3. The device starts a WiFi network called `Meck-Update-XXXX` and displays connection details
|
||||
4. On your phone: connect to the `Meck-Update` WiFi network, open a browser, go to `192.168.4.1`
|
||||
5. Tap **Choose File**, select the `.bin`, tap **Upload**
|
||||
@@ -148,6 +149,8 @@ The partition layout supports dual OTA slots — the old firmware remains on the
|
||||
|
||||
> **Note:** Use the **non-merged** `.bin` for OTA updates. The merged binary is only needed for first-time USB flashing.
|
||||
|
||||
**OTA Tools (v1.5+):** The firmware update has moved into **Settings → OTA Tools**, a submenu that also contains the new **SD File Manager**. The file manager creates the same WiFi access point and serves a browser-based interface where you can browse, upload, download, and delete files on the SD card from your phone — useful for managing audiobooks, alarm sounds, e-books, and notes without ejecting the SD card. Both OTA tools work on all variants including standalone builds.
|
||||
|
||||
---
|
||||
|
||||
## Path Hash Mode (v0.9.9+)
|
||||
@@ -206,6 +209,7 @@ The T-Deck Pro firmware includes full keyboard support for standalone messaging
|
||||
| B | Open web browser (BLE and 4G variants only) |
|
||||
| T | Open SMS & Phone app (4G variant only) |
|
||||
| P | Open audiobook player (audio variant only) |
|
||||
| K | Open alarm clock (audio variant only) |
|
||||
| F | Open node discovery (search for nearby repeaters/nodes) |
|
||||
| H | Open last heard list (passive advert history) |
|
||||
| G | Open map screen (shows contacts with GPS positions) |
|
||||
@@ -348,6 +352,7 @@ Press **S** from the home screen to open settings. On first boot (when the devic
|
||||
| GPS Baud Rate | A / D to cycle (Default 38400 / 4800 / 9600 / 19200 / 38400 / 57600 / 115200), Enter to confirm. **Requires reboot to take effect.** |
|
||||
| Path Hash Mode | W / S to cycle (1-byte / 2-byte / 3-byte), Enter to confirm |
|
||||
| Dark Mode | Toggle inverted display — white text on black background (Enter to toggle) |
|
||||
| Larger Font | Toggle larger text size on channel messages, contacts, DM inbox, and repeater admin screens (Enter to toggle) |
|
||||
| Auto Lock | A / D to cycle timeout (None / 2 / 5 / 10 / 15 / 30 min), Enter to confirm |
|
||||
| Contacts >> | Opens the Contacts sub-screen (see below) |
|
||||
| Channels >> | Opens the Channels sub-screen (see below) |
|
||||
@@ -426,6 +431,55 @@ The browser is a text-centric reader best suited to text-heavy websites. It also
|
||||
|
||||
For full documentation including key mappings, WiFi setup, bookmarks, IRC configuration, and SD card structure, see the [Web App Guide](Web_App_Guide.md).
|
||||
|
||||
### Alarm Clock (Audio only)
|
||||
|
||||
Press **K** from the home screen to open the alarm clock. This is available on the audio variant of the T-Deck Pro (PCM5102A DAC). Set up to five daily alarms that play custom MP3 files through the headphone jack.
|
||||
|
||||
**Setup:**
|
||||
|
||||
1. Place MP3 files (44100 Hz sample rate) in `/alarms/` on the SD card
|
||||
2. Press **K** to open the alarm clock
|
||||
3. Select an alarm slot (1–5) with **W / S** and press **Enter** to edit
|
||||
4. Set the hour and minute, then choose an MP3 file from the list
|
||||
5. Press **Enter** to save the alarm
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| W / S | Navigate alarm slots / adjust time |
|
||||
| A / D | Switch between hour and minute fields |
|
||||
| Enter | Edit slot / save alarm / select MP3 |
|
||||
| X | Delete selected alarm |
|
||||
| Q | Back to home screen |
|
||||
|
||||
**When an alarm fires:**
|
||||
|
||||
The selected MP3 plays through the headphone jack, even if you're on another screen or playing an audiobook.
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| Z | Snooze for 5 minutes |
|
||||
| Any other key | Dismiss alarm |
|
||||
|
||||
Alarm configuration is stored in `/alarms/.alarmcfg` on the SD card. Alarms persist across reboots — if the RTC has valid time (via GPS or companion app sync), alarms fire at the correct time after a restart.
|
||||
|
||||
> **Note:** MP3 files should be encoded at **44100 Hz** sample rate. Lower sample rates may cause distortion due to ESP32-S3 I2S hardware limitations (same requirement as the audiobook player).
|
||||
|
||||
**SD Card Folder Structure:**
|
||||
|
||||
```
|
||||
SD Card
|
||||
├── alarms/
|
||||
│ ├── .alarmcfg (auto-created, stores alarm slot config)
|
||||
│ ├── morning-chime.mp3
|
||||
│ ├── rooster.mp3
|
||||
│ └── gentle-bells.mp3
|
||||
├── audiobooks/ (existing — audiobook player)
|
||||
│ └── ...
|
||||
├── books/ (existing — text reader)
|
||||
│ └── ...
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Lock Screen (T-Deck Pro)
|
||||
|
||||
Double-click the Boot button to lock the screen. The lock screen shows the current time, battery percentage, and unread message count. The CPU drops to 40 MHz while locked to reduce power consumption.
|
||||
@@ -530,6 +584,7 @@ The T5S3 Settings screen includes one additional display option not available on
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| **Dark Mode** | Inverts the display — white text on black background. Tap to toggle on/off. Available on both T-Deck Pro and T5S3. |
|
||||
| **Larger Font** | Increases text size on channel messages, contacts, DM inbox, and repeater admin screens. Tap to toggle on/off. Available on both T-Deck Pro and T5S3. |
|
||||
| **Portrait Mode** | Rotates the display 90° from landscape (960×540) to portrait (540×960). Touch coordinates are automatically remapped. Text reader layout recalculates on orientation change. T5S3 only. |
|
||||
|
||||
These settings are persisted and survive reboots.
|
||||
@@ -750,13 +805,17 @@ There are a number of fairly major features in the pipeline, with no particular
|
||||
- [X] Last heard passive advert list
|
||||
- [X] Touch-to-select on contacts, discovery, settings, text reader, notes screens
|
||||
- [X] Map screen with GPS tile rendering
|
||||
- [ ] Fix M4B rendering to enable chaptered audiobook playback
|
||||
- [ ] Better JPEG and PNG decoding
|
||||
- [ ] Improve EPUB rendering and EPUB format handling
|
||||
- [X] WiFi companion environment
|
||||
- [X] OTA firmware update via phone
|
||||
- [X] DM inbox with per-contact unread indicators
|
||||
- [X] Roomserver message handling and mark-read on login
|
||||
- [X] Alarm clock with custom MP3 sounds (audio variant)
|
||||
- [X] Customised user option for larger-font mode
|
||||
- [ ] Fix M4B rendering to enable chaptered audiobook playback
|
||||
- [ ] Better JPEG and PNG decoding
|
||||
- [ ] Improve EPUB rendering and EPUB format handling
|
||||
- [ ] Figure out a way to silence the ringtone
|
||||
- [ ] Figure out a way to customise the ringtone
|
||||
|
||||
**T5S3 E-Paper Pro:**
|
||||
- [X] Core port: display, touch input, LoRa, battery, RTC
|
||||
@@ -777,6 +836,8 @@ There are a number of fairly major features in the pipeline, with no particular
|
||||
- [X] OTA firmware update via phone (WiFi variant)
|
||||
- [X] DM inbox with per-contact unread indicators
|
||||
- [X] Roomserver message handling and mark-read on login
|
||||
- [X] Customised user option for larger-font mode
|
||||
- [ ] Improve EPUB rendering and EPUB format handling
|
||||
|
||||
## 📞 Get Support
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ All commands follow a simple pattern: `get` to read, `set` to write.
|
||||
| `get radio` | All radio params in one line |
|
||||
| `get utc` | UTC offset (hours) |
|
||||
| `get notify` | Keyboard flash notification (on/off) |
|
||||
| `get largefont` | Larger font mode (on/off) |
|
||||
| `get gps` | GPS status and interval |
|
||||
| `get pin` | BLE pairing PIN |
|
||||
| `get path.hash.mode` | Path hash size (0=1-byte, 1=2-byte, 2=3-byte) |
|
||||
@@ -64,6 +65,8 @@ All commands follow a simple pattern: `get` to read, `set` to write.
|
||||
| `get af` | Airtime factor |
|
||||
| `get multi.acks` | Redundant ACKs (0 or 1) |
|
||||
| `get int.thresh` | Interference threshold (0=disabled) |
|
||||
| `get tx.fail.reset` | TX fail reset threshold (0=disabled, default 3) |
|
||||
| `get rx.fail.reboot` | RX stuck reboot threshold (0=disabled, default 3) |
|
||||
| `get gps.baud` | GPS baud rate (0=compile-time default) |
|
||||
| `get channels` | List all channels with index numbers |
|
||||
| `get presets` | List all radio presets with parameters |
|
||||
@@ -164,6 +167,15 @@ set notify on
|
||||
set notify off
|
||||
```
|
||||
|
||||
#### Larger Font Mode
|
||||
|
||||
Toggle larger text on channel messages, contacts, DM inbox, and repeater admin screens:
|
||||
|
||||
```
|
||||
set largefont on
|
||||
set largefont off
|
||||
```
|
||||
|
||||
#### BLE PIN
|
||||
|
||||
```
|
||||
@@ -231,6 +243,28 @@ set int.thresh 0
|
||||
|
||||
Values: 0 (disabled, default) or 14+ (14 is the typical setting). Values between 1–13 are not functional and will be rejected.
|
||||
|
||||
#### TX Fail Reset Threshold (tx.fail.reset)
|
||||
|
||||
Automatically resets the radio hardware after this many consecutive failed transmission attempts. This recovers from "zombie radio" states where the SX1262 stops responding to send commands.
|
||||
|
||||
```
|
||||
set tx.fail.reset 3
|
||||
set tx.fail.reset 0
|
||||
```
|
||||
|
||||
Values: 0 (disabled) or 1–10 (default: 3). After the threshold is reached, the radio is reset and the failed packet is re-queued.
|
||||
|
||||
#### RX Stuck Reboot Threshold (rx.fail.reboot)
|
||||
|
||||
Automatically reboots the device after this many consecutive RX-stuck recovery failures. An RX-stuck event occurs when the radio is not in receive mode for 8 seconds despite automatic recovery attempts.
|
||||
|
||||
```
|
||||
set rx.fail.reboot 3
|
||||
set rx.fail.reboot 0
|
||||
```
|
||||
|
||||
Values: 0 (disabled) or 1–10 (default: 3). A full device reboot is a last resort — this should only trigger in rare cases of persistent radio hardware malfunction.
|
||||
|
||||
#### GPS Baud Rate (gps.baud)
|
||||
|
||||
Override the GPS serial baud rate. The default (0) uses the compile-time value of 38400. **Requires a reboot to take effect** — the GPS serial port is only configured at startup.
|
||||
|
||||
@@ -268,10 +268,26 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
|
||||
if (file.read((uint8_t *)&_prefs.auto_lock_minutes, sizeof(_prefs.auto_lock_minutes)) != sizeof(_prefs.auto_lock_minutes)) {
|
||||
_prefs.auto_lock_minutes = 0; // default: disabled
|
||||
}
|
||||
if (file.read((uint8_t *)&_prefs.hint_shown, sizeof(_prefs.hint_shown)) != sizeof(_prefs.hint_shown)) {
|
||||
_prefs.hint_shown = 0; // default: show boot hint
|
||||
}
|
||||
if (file.read((uint8_t *)&_prefs.large_font, sizeof(_prefs.large_font)) != sizeof(_prefs.large_font)) {
|
||||
_prefs.large_font = 0; // default: tiny font
|
||||
}
|
||||
if (file.read((uint8_t *)&_prefs.tx_fail_reset_threshold, sizeof(_prefs.tx_fail_reset_threshold)) != sizeof(_prefs.tx_fail_reset_threshold)) {
|
||||
_prefs.tx_fail_reset_threshold = 3; // default: 3
|
||||
}
|
||||
if (file.read((uint8_t *)&_prefs.rx_fail_reboot_threshold, sizeof(_prefs.rx_fail_reboot_threshold)) != sizeof(_prefs.rx_fail_reboot_threshold)) {
|
||||
_prefs.rx_fail_reboot_threshold = 3; // default: 3
|
||||
}
|
||||
|
||||
// Clamp to valid ranges
|
||||
if (_prefs.dark_mode > 1) _prefs.dark_mode = 0;
|
||||
if (_prefs.portrait_mode > 1) _prefs.portrait_mode = 0;
|
||||
if (_prefs.hint_shown > 1) _prefs.hint_shown = 0;
|
||||
if (_prefs.large_font > 1) _prefs.large_font = 0;
|
||||
if (_prefs.tx_fail_reset_threshold > 10) _prefs.tx_fail_reset_threshold = 3;
|
||||
if (_prefs.rx_fail_reboot_threshold > 10) _prefs.rx_fail_reboot_threshold = 3;
|
||||
// auto_lock_minutes: only accept known options (0, 2, 5, 10, 15, 30)
|
||||
{
|
||||
uint8_t alm = _prefs.auto_lock_minutes;
|
||||
@@ -324,6 +340,10 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
|
||||
file.write((uint8_t *)&_prefs.dark_mode, sizeof(_prefs.dark_mode)); // 98
|
||||
file.write((uint8_t *)&_prefs.portrait_mode, sizeof(_prefs.portrait_mode)); // 99
|
||||
file.write((uint8_t *)&_prefs.auto_lock_minutes, sizeof(_prefs.auto_lock_minutes)); // 100
|
||||
file.write((uint8_t *)&_prefs.hint_shown, sizeof(_prefs.hint_shown)); // 101
|
||||
file.write((uint8_t *)&_prefs.large_font, sizeof(_prefs.large_font)); // 102
|
||||
file.write((uint8_t *)&_prefs.tx_fail_reset_threshold, sizeof(_prefs.tx_fail_reset_threshold)); // 103
|
||||
file.write((uint8_t *)&_prefs.rx_fail_reboot_threshold, sizeof(_prefs.rx_fail_reboot_threshold)); // 104
|
||||
|
||||
file.close();
|
||||
}
|
||||
|
||||
@@ -264,6 +264,16 @@ int MyMesh::getInterferenceThreshold() const {
|
||||
return _prefs.interference_threshold;
|
||||
}
|
||||
|
||||
uint8_t MyMesh::getTxFailResetThreshold() const {
|
||||
return _prefs.tx_fail_reset_threshold;
|
||||
}
|
||||
uint8_t MyMesh::getRxFailRebootThreshold() const {
|
||||
return _prefs.rx_fail_reboot_threshold;
|
||||
}
|
||||
void MyMesh::onRxUnrecoverable() {
|
||||
board.reboot();
|
||||
}
|
||||
|
||||
int MyMesh::calcRxDelay(float score, uint32_t air_time) const {
|
||||
if (_prefs.rx_delay_base <= 0.0f) return 0;
|
||||
return (int)((pow(_prefs.rx_delay_base, 0.85f - score) - 1.0) * air_time);
|
||||
@@ -560,12 +570,12 @@ void MyMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, ui
|
||||
recipient.name, delay_millis, _prefs.path_hash_mode, _prefs.path_hash_mode + 1);
|
||||
// TODO: dynamic send_scope, depending on recipient and current 'home' Region
|
||||
if (send_scope.isNull()) {
|
||||
sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1);
|
||||
sendFlood(pkt, delay_millis, getPathHashSize());
|
||||
} else {
|
||||
uint16_t codes[2];
|
||||
codes[0] = send_scope.calcTransportCode(pkt);
|
||||
codes[1] = 0; // REVISIT: set to 'home' Region, for sender/return region?
|
||||
sendFlood(pkt, codes, delay_millis, _prefs.path_hash_mode + 1);
|
||||
sendFlood(pkt, codes, delay_millis, getPathHashSize());
|
||||
}
|
||||
}
|
||||
void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis) {
|
||||
@@ -582,12 +592,12 @@ void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pk
|
||||
|
||||
// TODO: have per-channel send_scope
|
||||
if (send_scope.isNull()) {
|
||||
sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1);
|
||||
sendFlood(pkt, delay_millis, getPathHashSize());
|
||||
} else {
|
||||
uint16_t codes[2];
|
||||
codes[0] = send_scope.calcTransportCode(pkt);
|
||||
codes[1] = 0; // REVISIT: set to 'home' Region, for sender/return region?
|
||||
sendFlood(pkt, codes, delay_millis, _prefs.path_hash_mode + 1);
|
||||
sendFlood(pkt, codes, delay_millis, getPathHashSize());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1240,6 +1250,7 @@ void MyMesh::begin(bool has_display) {
|
||||
if (_prefs.autoadd_max_hops > 64) _prefs.autoadd_max_hops = 0;
|
||||
if (_prefs.dark_mode > 1) _prefs.dark_mode = 0;
|
||||
if (_prefs.portrait_mode > 1) _prefs.portrait_mode = 0;
|
||||
if (_prefs.hint_shown > 1) _prefs.hint_shown = 0;
|
||||
|
||||
#ifdef BLE_PIN_CODE // 123456 by default
|
||||
if (_prefs.ble_pin == 0) {
|
||||
@@ -1489,7 +1500,7 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
if (pkt) {
|
||||
if (len >= 2 && cmd_frame[1] == 1) { // optional param (1 = flood, 0 = zero hop)
|
||||
unsigned long delay_millis = 0;
|
||||
sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1);
|
||||
sendFlood(pkt, delay_millis, getPathHashSize());
|
||||
} else {
|
||||
sendZeroHop(pkt);
|
||||
}
|
||||
@@ -2254,6 +2265,10 @@ void MyMesh::checkCLIRescueCmd() {
|
||||
Serial.printf(" > %d\n", _prefs.multi_acks);
|
||||
} else if (strcmp(key, "int.thresh") == 0) {
|
||||
Serial.printf(" > %d\n", _prefs.interference_threshold);
|
||||
} else if (strcmp(key, "tx.fail.threshold") == 0) {
|
||||
Serial.printf(" > %d\n", _prefs.tx_fail_reset_threshold);
|
||||
} else if (strcmp(key, "rx.fail.threshold") == 0) {
|
||||
Serial.printf(" > %d\n", _prefs.rx_fail_reboot_threshold);
|
||||
} else if (strcmp(key, "gps.baud") == 0) {
|
||||
uint32_t effective = _prefs.gps_baudrate ? _prefs.gps_baudrate : GPS_BAUDRATE;
|
||||
Serial.printf(" > %lu (effective: %lu)\n",
|
||||
@@ -2314,6 +2329,8 @@ void MyMesh::checkCLIRescueCmd() {
|
||||
Serial.printf(" af: %.1f\n", _prefs.airtime_factor);
|
||||
Serial.printf(" multi.acks: %d\n", _prefs.multi_acks);
|
||||
Serial.printf(" int.thresh: %d\n", _prefs.interference_threshold);
|
||||
Serial.printf(" tx.fail: %d\n", _prefs.tx_fail_reset_threshold);
|
||||
Serial.printf(" rx.fail: %d\n", _prefs.rx_fail_reboot_threshold);
|
||||
{
|
||||
uint32_t eff_baud = _prefs.gps_baudrate ? _prefs.gps_baudrate : GPS_BAUDRATE;
|
||||
Serial.printf(" gps.baud: %lu\n", (unsigned long)eff_baud);
|
||||
@@ -2709,6 +2726,30 @@ void MyMesh::checkCLIRescueCmd() {
|
||||
Serial.println(" Error: use 0 (disabled) or 14+ (typical: 14)");
|
||||
}
|
||||
|
||||
} else if (memcmp(config, "tx.fail.threshold ", 18) == 0) {
|
||||
int val = atoi(&config[18]);
|
||||
if (val < 0) val = 0;
|
||||
if (val > 10) val = 10;
|
||||
_prefs.tx_fail_reset_threshold = (uint8_t)val;
|
||||
savePrefs();
|
||||
if (val == 0) {
|
||||
Serial.println(" > tx fail reset disabled");
|
||||
} else {
|
||||
Serial.printf(" > tx fail reset after %d failures\n", val);
|
||||
}
|
||||
|
||||
} else if (memcmp(config, "rx.fail.threshold ", 18) == 0) {
|
||||
int val = atoi(&config[18]);
|
||||
if (val < 0) val = 0;
|
||||
if (val > 10) val = 10;
|
||||
_prefs.rx_fail_reboot_threshold = (uint8_t)val;
|
||||
savePrefs();
|
||||
if (val == 0) {
|
||||
Serial.println(" > rx fail reboot disabled");
|
||||
} else {
|
||||
Serial.printf(" > reboot after %d rx recovery failures\n", val);
|
||||
}
|
||||
|
||||
} else if (memcmp(config, "gps.baud ", 9) == 0) {
|
||||
uint32_t val = (uint32_t)atol(&config[9]);
|
||||
if (val == 0 || val == 4800 || val == 9600 || val == 19200 ||
|
||||
@@ -2806,6 +2847,8 @@ void MyMesh::checkCLIRescueCmd() {
|
||||
Serial.println(" af <0-9> Airtime factor");
|
||||
Serial.println(" multi.acks <0|1> Redundant ACKs (default: 1)");
|
||||
Serial.println(" int.thresh <0|14+> Interference threshold dB (0=off, 14=typical)");
|
||||
Serial.println(" tx.fail.threshold <0-10> TX fail radio reset (0=off, default 3)");
|
||||
Serial.println(" rx.fail.threshold <0-10> RX stuck reboot (0=off, default 3)");
|
||||
Serial.println(" gps.baud <rate> GPS baud (0=default, reboot to apply)");
|
||||
Serial.println("");
|
||||
Serial.println(" Clock:");
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
#define FIRMWARE_VER_CODE 10
|
||||
|
||||
#ifndef FIRMWARE_BUILD_DATE
|
||||
#define FIRMWARE_BUILD_DATE "22 March 2026"
|
||||
#define FIRMWARE_BUILD_DATE "28 March 2026"
|
||||
#endif
|
||||
|
||||
#ifndef FIRMWARE_VERSION
|
||||
#define FIRMWARE_VERSION "Meck v1.3"
|
||||
#define FIRMWARE_VERSION "Meck v1.5"
|
||||
#endif
|
||||
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
@@ -143,6 +143,9 @@ public:
|
||||
protected:
|
||||
float getAirtimeBudgetFactor() const override;
|
||||
int getInterferenceThreshold() const override;
|
||||
uint8_t getTxFailResetThreshold() const override;
|
||||
uint8_t getRxFailRebootThreshold() const override;
|
||||
void onRxUnrecoverable() override;
|
||||
int calcRxDelay(float score, uint32_t air_time) const override;
|
||||
uint32_t getRetransmitDelay(const mesh::Packet *packet) override;
|
||||
uint32_t getDirectRetransmitDelay(const mesh::Packet *packet) override;
|
||||
@@ -150,6 +153,7 @@ protected:
|
||||
uint8_t getAutoAddMaxHops() const override;
|
||||
bool filterRecvFloodPacket(mesh::Packet* packet) override;
|
||||
|
||||
uint8_t getPathHashSize() const override { return _prefs.path_hash_mode + 1; }
|
||||
void sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis=0) override;
|
||||
void sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis=0) override;
|
||||
|
||||
|
||||
@@ -38,4 +38,42 @@ struct NodePrefs { // persisted to file
|
||||
uint8_t dark_mode; // 0=off (white bg), 1=on (black bg)
|
||||
uint8_t portrait_mode; // 0=landscape, 1=portrait — T5S3 only
|
||||
uint8_t auto_lock_minutes; // 0=disabled, 2/5/10/15/30=auto-lock after idle
|
||||
uint8_t hint_shown; // 0=show nav hint on boot, 1=already shown (dismiss permanently)
|
||||
uint8_t large_font; // 0=tiny (built-in 6x8), 1=larger (FreeSans9pt) — T-Deck Pro only
|
||||
uint8_t tx_fail_reset_threshold; // 0=disabled, 1-10, default 3
|
||||
uint8_t rx_fail_reboot_threshold; // 0=disabled, 1-10, default 3
|
||||
|
||||
// --- Font helpers (inline, no overhead) ---
|
||||
// Returns the DisplayDriver text-size index for "small/body" text.
|
||||
// T-Deck Pro: 0 = built-in 6×8, 1 = FreeSans9pt.
|
||||
// T5S3: both 0 and 1 are 12pt fonts (regular vs bold) with identical line
|
||||
// height, so large_font has no layout effect there.
|
||||
inline uint8_t smallTextSize() const {
|
||||
return large_font ? 1 : 0;
|
||||
}
|
||||
|
||||
// Returns the virtual-coordinate line height matching smallTextSize().
|
||||
// T-Deck Pro size 0 → 9 (6×8 + 1px gap), size 1 → 11 (9pt ascent+descent).
|
||||
// T5S3 size 0/1 → same 12pt height → always 9 in virtual coords.
|
||||
inline int smallLineH() const {
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
return 9;
|
||||
#else
|
||||
return large_font ? 11 : 9;
|
||||
#endif
|
||||
}
|
||||
|
||||
// Returns the Y offset for selection highlight fillRect (T-Deck Pro only).
|
||||
// Size 0 (built-in font): cursor positions at top-left, +5 offset in
|
||||
// setCursor places text below → fillRect at y+5 aligns with text.
|
||||
// Size 1 (FreeSans9pt): cursor positions at baseline, ascenders render
|
||||
// upward → fillRect must start above baseline to cover ascenders.
|
||||
// T5S3: always 0 (both sizes use baseline fonts with highlight at y).
|
||||
inline int smallHighlightOff() const {
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
return 0;
|
||||
#else
|
||||
return large_font ? -2 : 5;
|
||||
#endif
|
||||
}
|
||||
};
|
||||
@@ -489,7 +489,7 @@
|
||||
static int16_t touchLastX = 0;
|
||||
static int16_t touchLastY = 0;
|
||||
static unsigned long lastTouchSeenMs = 0;
|
||||
#define TOUCH_LONG_PRESS_MS 500
|
||||
#define TOUCH_LONG_PRESS_MS 750
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
#define TOUCH_SWIPE_THRESHOLD 60 // T5S3: 960×540 — 60px ≈ 6% of width
|
||||
#else
|
||||
@@ -716,6 +716,12 @@ static void lastHeardToggleContact() {
|
||||
int vx, vy;
|
||||
touchToVirtual(x, y, vx, vy);
|
||||
|
||||
// Dismiss boot navigation hint on any tap
|
||||
if (ui_task.isHintActive()) {
|
||||
ui_task.dismissBootHint();
|
||||
return 0;
|
||||
}
|
||||
|
||||
// --- Status bar tap (top ~18 virtual units) → go home from any non-home screen ---
|
||||
// Exception: text reader reading mode uses full screen for content (no header)
|
||||
if (vy < 18 && !ui_task.isOnHomeScreen()) {
|
||||
@@ -925,6 +931,12 @@ static void lastHeardToggleContact() {
|
||||
return KEY_ENTER; // Editing mode or header/footer tap
|
||||
}
|
||||
|
||||
// SMS screen: dedicated dialer/touch handler runs separately (HAS_4G_MODEM block)
|
||||
// Return 0 so the general handler doesn't inject spurious keys
|
||||
#ifdef HAS_4G_MODEM
|
||||
if (ui_task.isOnSMSScreen()) return 0;
|
||||
#endif
|
||||
|
||||
// All other screens: tap = select
|
||||
return KEY_ENTER;
|
||||
}
|
||||
@@ -933,6 +945,11 @@ static void lastHeardToggleContact() {
|
||||
static char mapTouchSwipe(int16_t dx, int16_t dy) {
|
||||
bool horizontal = abs(dx) > abs(dy);
|
||||
|
||||
// SMS screen: dedicated touch handler covers all interaction
|
||||
#ifdef HAS_4G_MODEM
|
||||
if (ui_task.isOnSMSScreen()) return 0;
|
||||
#endif
|
||||
|
||||
// Reader (reading mode): swipe left/right for page turn
|
||||
if (ui_task.isOnTextReader()) {
|
||||
TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen();
|
||||
@@ -996,6 +1013,11 @@ static void lastHeardToggleContact() {
|
||||
|
||||
// Map a long press to a key
|
||||
static char mapTouchLongPress(int16_t x, int16_t y) {
|
||||
// SMS screen: dedicated touch handler covers all interaction
|
||||
#ifdef HAS_4G_MODEM
|
||||
if (ui_task.isOnSMSScreen()) return 0;
|
||||
#endif
|
||||
|
||||
// Home screen: long press = activate current page action
|
||||
// (BLE toggle, send advert, hibernate, GPS toggle, etc.)
|
||||
if (ui_task.isOnHomeScreen()) {
|
||||
@@ -1731,6 +1753,8 @@ void setup() {
|
||||
if (strcmp(prefs->node_name, defaultName) == 0) {
|
||||
MESH_DEBUG_PRINTLN("setup() - Default node name detected, launching onboarding");
|
||||
ui_task.gotoOnboarding();
|
||||
// Show hint immediately overlaid on the onboarding screen
|
||||
if (!prefs->hint_shown) ui_task.showBootHint(true);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1763,6 +1787,19 @@ void setup() {
|
||||
MESH_DEBUG_PRINTLN("setup() - BLE disabled at boot (standalone mode)");
|
||||
#endif
|
||||
|
||||
// Alarm clock: create at boot so config is loaded, background alarm check
|
||||
// works from first loop(), and the bell indicator is visible immediately.
|
||||
// Audio object is NOT created here — lazy-init when alarm fires or user opens player.
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
{
|
||||
AlarmScreen* alarmScr = new AlarmScreen(&ui_task);
|
||||
alarmScr->setSDReady(sdCardReady);
|
||||
// Audio pointer set later when needed (fireAlarm or 'k'/'p' key)
|
||||
ui_task.setAlarmScreen(alarmScr);
|
||||
Serial.printf("ALARM: Boot init, %d alarms enabled\n", alarmScr->enabledCount());
|
||||
}
|
||||
#endif
|
||||
|
||||
Serial.printf("setup() complete — free heap: %d, largest block: %d\n",
|
||||
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||||
MESH_DEBUG_PRINTLN("=== setup() - COMPLETE ===");
|
||||
@@ -1798,7 +1835,7 @@ void loop() {
|
||||
the_mesh.loop();
|
||||
#ifdef MECK_OTA_UPDATE
|
||||
} else {
|
||||
// OTA active — poll the web server from the main loop for fast response.
|
||||
// OTA/File Manager active — poll the web server from the main loop for fast response.
|
||||
// The render cycle on T5S3 (960×540 FastEPD) can block for 500ms+ during
|
||||
// e-ink refresh, causing the browser to timeout before handleClient() runs.
|
||||
// Polling here gives us ~1-5ms response time instead.
|
||||
@@ -1899,6 +1936,61 @@ void loop() {
|
||||
}
|
||||
#endif
|
||||
|
||||
// Alarm clock: background alarm check + audio tick
|
||||
#if defined(LilyGo_TDeck_Pro) && defined(MECK_AUDIO_VARIANT)
|
||||
{
|
||||
AlarmScreen* alarmScr = (AlarmScreen*)ui_task.getAlarmScreen();
|
||||
if (alarmScr) {
|
||||
// Service alarm audio decode (like audiobook audioTick)
|
||||
alarmScr->alarmAudioTick();
|
||||
if (alarmScr->isAlarmAudioActive()) {
|
||||
cpuPower.setBoost();
|
||||
}
|
||||
|
||||
// Periodic alarm check (~every 10 seconds)
|
||||
static unsigned long lastAlarmCheck = 0;
|
||||
if (millis() - lastAlarmCheck > ALARM_CHECK_INTERVAL_MS) {
|
||||
lastAlarmCheck = millis();
|
||||
uint32_t rtcNow = the_mesh.getRTCClock()->getCurrentTime();
|
||||
int fireSlot = alarmScr->checkAlarms(rtcNow, the_mesh.getNodePrefs()->utc_offset_hours);
|
||||
if (fireSlot >= 0 && !alarmScr->isRinging()) {
|
||||
// If audiobook is playing, the alarm will take over the shared Audio*
|
||||
// object. The audiobook auto-saves bookmarks every 30s, so at most
|
||||
// 30s of position is lost. User can resume from audiobook player after.
|
||||
AudiobookPlayerScreen* abPlayer =
|
||||
(AudiobookPlayerScreen*)ui_task.getAudiobookScreen();
|
||||
if (abPlayer && abPlayer->isAudioActive()) {
|
||||
Serial.println("ALARM: Audiobook active — alarm taking over Audio");
|
||||
}
|
||||
|
||||
// Ensure Audio object is shared
|
||||
if (!audio) audio = new Audio();
|
||||
alarmScr->setAudio(audio);
|
||||
|
||||
// Fire the alarm
|
||||
alarmScr->fireAlarm(fireSlot);
|
||||
alarmScr->setLastFiredEpoch(fireSlot, rtcNow);
|
||||
|
||||
// Let audio buffer fill before e-ink refresh blocks SPI
|
||||
for (int i = 0; i < 50; i++) {
|
||||
alarmScr->alarmAudioTick();
|
||||
delay(2);
|
||||
}
|
||||
|
||||
// Switch UI to alarm screen (ringing mode)
|
||||
ui_task.gotoAlarmScreen();
|
||||
|
||||
// Wake display if asleep
|
||||
ui_task.keepAlive();
|
||||
ui_task.forceRefresh();
|
||||
|
||||
Serial.printf("ALARM: Fired slot %d, switched to ringing screen\n", fireSlot);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// SMS: poll for incoming messages from modem
|
||||
#ifdef HAS_4G_MODEM
|
||||
{
|
||||
@@ -2102,7 +2194,7 @@ void loop() {
|
||||
// Gestures:
|
||||
// Tap = finger down + up with minimal movement → select/open
|
||||
// Swipe = finger drag > threshold → scroll/page turn
|
||||
// Long press = finger held > 500ms without moving → edit/enter
|
||||
// Long press = finger held > 750ms without moving → edit/enter
|
||||
// After processing an event, cooldown waits for finger lift before next event.
|
||||
// Touch is disabled while lock screen is active.
|
||||
// When virtual keyboard is active (T5S3), taps route to keyboard.
|
||||
@@ -2114,6 +2206,15 @@ void loop() {
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
touchBlocked = touchBlocked || ui_task.isVKBActive();
|
||||
#endif
|
||||
#ifdef HAS_4G_MODEM
|
||||
// SMS dialer has its own dedicated touch handler — don't consume touch data here
|
||||
if (smsMode) {
|
||||
SMSScreen* smsScr = (SMSScreen*)ui_task.getSMSScreen();
|
||||
if (smsScr && smsScr->getSubView() == SMSScreen::PHONE_DIALER) {
|
||||
touchBlocked = true;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
if (!touchBlocked)
|
||||
{
|
||||
@@ -2487,10 +2588,33 @@ void handleKeyboardInput() {
|
||||
// Block all keyboard input while lock screen is active.
|
||||
// Still read the key above to clear the TCA8418 buffer.
|
||||
if (ui_task.isLocked()) return;
|
||||
|
||||
// Dismiss boot navigation hint on any keypress
|
||||
if (ui_task.isHintActive()) {
|
||||
ui_task.dismissBootHint();
|
||||
return; // Consume the keypress (don't act on it)
|
||||
}
|
||||
|
||||
Serial.printf("handleKeyboardInput: key='%c' (0x%02X) composeMode=%d\n",
|
||||
key >= 32 ? key : '?', key, composeMode);
|
||||
|
||||
// Alarm ringing: ANY key dismisses (highest priority after lock screen)
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
{
|
||||
AlarmScreen* alarmScr = (AlarmScreen*)ui_task.getAlarmScreen();
|
||||
if (alarmScr && alarmScr->isRinging()) {
|
||||
if (key == 'z') {
|
||||
alarmScr->handleInput('z'); // Snooze
|
||||
} else {
|
||||
alarmScr->dismiss(); // Any other key = dismiss
|
||||
}
|
||||
ui_task.gotoHomeScreen();
|
||||
ui_task.forceRefresh();
|
||||
return; // Consume the key
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
if (composeMode) {
|
||||
// Emoji picker sub-mode
|
||||
if (emojiPickerMode) {
|
||||
@@ -2602,9 +2726,28 @@ void handleKeyboardInput() {
|
||||
|
||||
// A/D keys switch channels (only when buffer is empty, not in DM mode)
|
||||
if ((key == 'a') && composePos == 0 && !composeDM) {
|
||||
// Previous channel
|
||||
// Previous channel — skip gaps
|
||||
if (composeChannelIdx > 0) {
|
||||
composeChannelIdx--;
|
||||
bool found = false;
|
||||
for (uint8_t prev = composeChannelIdx - 1; ; prev--) {
|
||||
ChannelDetails ch;
|
||||
if (the_mesh.getChannel(prev, ch) && ch.name[0] != '\0') {
|
||||
composeChannelIdx = prev;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
if (prev == 0) break;
|
||||
}
|
||||
if (!found) {
|
||||
// Wrap to last valid channel
|
||||
for (uint8_t i = MAX_GROUP_CHANNELS - 1; i > 0; i--) {
|
||||
ChannelDetails ch;
|
||||
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') {
|
||||
composeChannelIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Wrap to last valid channel
|
||||
for (uint8_t i = MAX_GROUP_CHANNELS - 1; i > 0; i--) {
|
||||
@@ -2621,12 +2764,17 @@ void handleKeyboardInput() {
|
||||
}
|
||||
|
||||
if ((key == 'd') && composePos == 0 && !composeDM) {
|
||||
// Next channel
|
||||
ChannelDetails ch;
|
||||
uint8_t nextIdx = composeChannelIdx + 1;
|
||||
if (the_mesh.getChannel(nextIdx, ch) && ch.name[0] != '\0') {
|
||||
composeChannelIdx = nextIdx;
|
||||
} else {
|
||||
// Next channel — skip gaps
|
||||
bool found = false;
|
||||
for (uint8_t next = composeChannelIdx + 1; next < MAX_GROUP_CHANNELS; next++) {
|
||||
ChannelDetails ch;
|
||||
if (the_mesh.getChannel(next, ch) && ch.name[0] != '\0') {
|
||||
composeChannelIdx = next;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
composeChannelIdx = 0; // Wrap to first channel
|
||||
}
|
||||
Serial.printf("Compose: Channel switched to %d\n", composeChannelIdx);
|
||||
@@ -3074,7 +3222,7 @@ void handleKeyboardInput() {
|
||||
Serial.printf("Audiobook: lazy init - free heap: %d, largest block: %d\n",
|
||||
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||||
audio = new Audio();
|
||||
AudiobookPlayerScreen* abScreen = new AudiobookPlayerScreen(&ui_task, audio);
|
||||
AudiobookPlayerScreen* abScreen = new AudiobookPlayerScreen(&ui_task, audio, the_mesh.getNodePrefs());
|
||||
abScreen->setSDReady(sdCardReady);
|
||||
ui_task.setAudiobookScreen(abScreen);
|
||||
Serial.printf("Audiobook: init complete - free heap: %d\n", ESP.getFreeHeap());
|
||||
@@ -3083,6 +3231,23 @@ void handleKeyboardInput() {
|
||||
break;
|
||||
#endif
|
||||
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
case 'k':
|
||||
// Open alarm clock (screen created at boot; just ensure Audio* is available)
|
||||
Serial.println("Opening alarm clock");
|
||||
if (!audio) {
|
||||
Serial.printf("Alarm: lazy init Audio - free heap: %d, largest block: %d\n",
|
||||
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||||
audio = new Audio();
|
||||
}
|
||||
{
|
||||
AlarmScreen* alarmScr = (AlarmScreen*)ui_task.getAlarmScreen();
|
||||
if (alarmScr) alarmScr->setAudio(audio);
|
||||
}
|
||||
ui_task.gotoAlarmScreen();
|
||||
break;
|
||||
#endif
|
||||
|
||||
#ifdef HAS_4G_MODEM
|
||||
case 't':
|
||||
// Open SMS (4G variant only)
|
||||
@@ -3187,6 +3352,9 @@ void handleKeyboardInput() {
|
||||
|| ui_task.isOnWebReader()
|
||||
#endif
|
||||
|| ui_task.isOnMapScreen()
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
|| ui_task.isOnAlarmScreen()
|
||||
#endif
|
||||
) {
|
||||
ui_task.injectKey('s'); // Pass directly for scrolling
|
||||
} else {
|
||||
@@ -3203,6 +3371,9 @@ void handleKeyboardInput() {
|
||||
|| ui_task.isOnWebReader()
|
||||
#endif
|
||||
|| ui_task.isOnMapScreen()
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
|| ui_task.isOnAlarmScreen()
|
||||
#endif
|
||||
) {
|
||||
ui_task.injectKey('w'); // Pass directly for scrolling
|
||||
} else {
|
||||
@@ -3213,7 +3384,11 @@ void handleKeyboardInput() {
|
||||
|
||||
case 'a':
|
||||
// Navigate left or switch channel (on channel screen)
|
||||
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnMapScreen()) {
|
||||
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnMapScreen()
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
|| ui_task.isOnAlarmScreen()
|
||||
#endif
|
||||
) {
|
||||
ui_task.injectKey('a'); // Pass directly for channel/contacts switching
|
||||
} else {
|
||||
Serial.println("Nav: Previous");
|
||||
@@ -3223,7 +3398,11 @@ void handleKeyboardInput() {
|
||||
|
||||
case 'd':
|
||||
// Navigate right or switch channel (on channel screen)
|
||||
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnMapScreen()) {
|
||||
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnMapScreen()
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
|| ui_task.isOnAlarmScreen()
|
||||
#endif
|
||||
) {
|
||||
ui_task.injectKey('d'); // Pass directly for channel/contacts switching
|
||||
} else {
|
||||
Serial.println("Nav: Next");
|
||||
@@ -3437,9 +3616,9 @@ void handleKeyboardInput() {
|
||||
break;
|
||||
|
||||
case 'f':
|
||||
// Start discovery scan from contacts screen, or rescan on discovery screen
|
||||
if (ui_task.isOnContactsScreen()) {
|
||||
Serial.println("Contacts: Starting discovery scan...");
|
||||
// Start discovery scan from home/contacts screen, or rescan on discovery screen
|
||||
if (ui_task.isOnContactsScreen() || ui_task.isOnHomeScreen()) {
|
||||
Serial.println("Starting discovery scan...");
|
||||
the_mesh.startDiscovery();
|
||||
ui_task.gotoDiscoveryScreen();
|
||||
} else if (ui_task.isOnDiscoveryScreen()) {
|
||||
@@ -3501,6 +3680,24 @@ void handleKeyboardInput() {
|
||||
ui_task.gotoContactsScreen();
|
||||
break;
|
||||
}
|
||||
// Alarm screen: Q/backspace routing depends on sub-mode
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
if (ui_task.isOnAlarmScreen()) {
|
||||
AlarmScreen* alarmScr = (AlarmScreen*)ui_task.getAlarmScreen();
|
||||
if (alarmScr && alarmScr->isRinging()) {
|
||||
alarmScr->dismiss();
|
||||
ui_task.gotoHomeScreen();
|
||||
} else if (alarmScr && alarmScr->getMode() != AlarmScreen::ALARM_LIST) {
|
||||
// In edit/picker/digit mode — pass to screen (Q = back to list, backspace = delete)
|
||||
ui_task.injectKey(key);
|
||||
} else {
|
||||
// On alarm list — go home
|
||||
Serial.println("Nav: Alarm -> Home");
|
||||
ui_task.gotoHomeScreen();
|
||||
}
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
// Last Heard: Q goes back to home
|
||||
if (ui_task.isOnLastHeardScreen()) {
|
||||
Serial.println("Nav: Last Heard -> Home");
|
||||
@@ -3543,6 +3740,13 @@ void handleKeyboardInput() {
|
||||
ui_task.injectKey(key);
|
||||
break;
|
||||
}
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
// Pass unhandled keys to alarm screen (digits for time entry, o for toggle)
|
||||
if (ui_task.isOnAlarmScreen()) {
|
||||
ui_task.injectKey(key);
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
Serial.printf("Unhandled key in normal mode: '%c' (0x%02X)\n", key, key);
|
||||
break;
|
||||
}
|
||||
|
||||
1084
examples/companion_radio/ui-new/Alarmscreen.h
Normal file
1084
examples/companion_radio/ui-new/Alarmscreen.h
Normal file
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,8 @@
|
||||
// JPEG decoder for cover art — JPEGDEC by bitbank2
|
||||
#include <JPEGDEC.h>
|
||||
|
||||
#include "../NodePrefs.h"
|
||||
|
||||
// Forward declarations
|
||||
class UITask;
|
||||
|
||||
@@ -151,6 +153,7 @@ public:
|
||||
|
||||
private:
|
||||
UITask* _task;
|
||||
NodePrefs* _prefs;
|
||||
Audio* _audio;
|
||||
Mode _mode;
|
||||
bool _sdReady;
|
||||
@@ -1193,10 +1196,10 @@ private:
|
||||
}
|
||||
|
||||
// Switch to tiny font for file list (6x8 built-in)
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs ? _prefs->smallTextSize() : 0);
|
||||
|
||||
// Calculate visible items — tiny font uses ~8 virtual units per line
|
||||
int itemHeight = 8;
|
||||
// Calculate visible items
|
||||
int itemHeight = (_prefs ? _prefs->smallLineH() : 9) - 1;
|
||||
int listTop = 13;
|
||||
int listBottom = display.height() - 14; // Reserve footer space
|
||||
int visibleItems = (listBottom - listTop) / itemHeight;
|
||||
@@ -1208,7 +1211,7 @@ private:
|
||||
_scrollOffset = _selectedFile - visibleItems + 1;
|
||||
}
|
||||
|
||||
// Approx chars that fit in tiny font (~36 on 128 virtual width)
|
||||
// Approx chars for suffix/type tag sizing (still needed for type tag assembly)
|
||||
const int charsPerLine = 36;
|
||||
|
||||
// Draw file list
|
||||
@@ -1218,9 +1221,7 @@ private:
|
||||
|
||||
if (fileIdx == _selectedFile) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
// setCursor adds +5 to y internally, but fillRect does not.
|
||||
// Offset fillRect by +5 to align highlight bar with text.
|
||||
display.fillRect(0, y + 5, display.width(), itemHeight - 1);
|
||||
display.fillRect(0, y + (_prefs ? _prefs->smallHighlightOff() : 5), display.width(), itemHeight - 1);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -1231,29 +1232,15 @@ private:
|
||||
char fullLine[96];
|
||||
|
||||
if (fe.isDir) {
|
||||
// Directory entry: show as "/ FolderName" or just ".."
|
||||
if (fe.name == "..") {
|
||||
snprintf(fullLine, sizeof(fullLine), ".. (up)");
|
||||
} else {
|
||||
snprintf(fullLine, sizeof(fullLine), "/%s", fe.name.c_str());
|
||||
// Truncate if needed
|
||||
if ((int)strlen(fullLine) > charsPerLine - 1) {
|
||||
fullLine[charsPerLine - 4] = '.';
|
||||
fullLine[charsPerLine - 3] = '.';
|
||||
fullLine[charsPerLine - 2] = '.';
|
||||
fullLine[charsPerLine - 1] = '\0';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Audio file: "Title - Author [TYPE]"
|
||||
char lineBuf[80];
|
||||
|
||||
// Reserve space for type tag and bookmark indicator
|
||||
int suffixLen = fe.fileType.length() + 3; // " [M4B]" or " [MP3]"
|
||||
int bmkLen = fe.hasBookmark ? 2 : 0; // " >"
|
||||
int availChars = charsPerLine - suffixLen - bmkLen;
|
||||
if (availChars < 10) availChars = 10;
|
||||
|
||||
if (fe.displayAuthor.length() > 0) {
|
||||
snprintf(lineBuf, sizeof(lineBuf), "%s - %s",
|
||||
fe.displayTitle.c_str(), fe.displayAuthor.c_str());
|
||||
@@ -1261,24 +1248,13 @@ private:
|
||||
snprintf(lineBuf, sizeof(lineBuf), "%s", fe.displayTitle.c_str());
|
||||
}
|
||||
|
||||
// Truncate with ellipsis if needed
|
||||
if ((int)strlen(lineBuf) > availChars) {
|
||||
if (availChars > 3) {
|
||||
lineBuf[availChars - 3] = '.';
|
||||
lineBuf[availChars - 2] = '.';
|
||||
lineBuf[availChars - 1] = '.';
|
||||
lineBuf[availChars] = '\0';
|
||||
} else {
|
||||
lineBuf[availChars] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
// Append file type tag
|
||||
snprintf(fullLine, sizeof(fullLine), "%s [%s]", lineBuf, fe.fileType.c_str());
|
||||
}
|
||||
|
||||
display.setCursor(2, y);
|
||||
display.print(fullLine);
|
||||
// Pixel-aware ellipsis — reserve space for bookmark indicator
|
||||
int reserveRight = (!fe.isDir && fe.hasBookmark) ? 10 : 2;
|
||||
display.drawTextEllipsized(2, y, display.width() - reserveRight, fullLine);
|
||||
|
||||
// Bookmark indicator (right-aligned, files only)
|
||||
if (!fe.isDir && fe.hasBookmark) {
|
||||
@@ -1464,8 +1440,8 @@ private:
|
||||
}
|
||||
|
||||
public:
|
||||
AudiobookPlayerScreen(UITask* task, Audio* audio)
|
||||
: _task(task), _audio(audio), _mode(FILE_LIST),
|
||||
AudiobookPlayerScreen(UITask* task, Audio* audio, NodePrefs* prefs = nullptr)
|
||||
: _task(task), _prefs(prefs), _audio(audio), _mode(FILE_LIST),
|
||||
_sdReady(false), _i2sInitialized(false), _dacPowered(false),
|
||||
_displayRef(nullptr),
|
||||
_selectedFile(0), _scrollOffset(0),
|
||||
|
||||
@@ -637,8 +637,8 @@ public:
|
||||
}
|
||||
|
||||
// Render inbox list
|
||||
display.setTextSize(0);
|
||||
int lineH = 9;
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
|
||||
int lineH = the_mesh.getNodePrefs()->smallLineH();
|
||||
int headerH = 14;
|
||||
int footerH = 14;
|
||||
int maxY = display.height() - footerH;
|
||||
@@ -672,7 +672,7 @@ public:
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), lineH);
|
||||
#else
|
||||
display.fillRect(0, y + 5, display.width(), lineH);
|
||||
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), display.width(), lineH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
@@ -745,8 +745,8 @@ public:
|
||||
|
||||
// --- Path detail overlay ---
|
||||
if (_showPathOverlay) {
|
||||
display.setTextSize(0);
|
||||
int lineH = 9;
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
|
||||
int lineH = the_mesh.getNodePrefs()->smallLineH();
|
||||
int y = 14;
|
||||
|
||||
ChannelMessage* msg = getNewestReceivedMsg();
|
||||
@@ -942,7 +942,7 @@ public:
|
||||
}
|
||||
|
||||
if (channelMsgCount == 0) {
|
||||
display.setTextSize(0); // Tiny font for body text
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize()); // Tiny font for body text
|
||||
display.setCursor(0, 20);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
if (_viewChannelIdx == 0xFF) {
|
||||
@@ -975,8 +975,8 @@ public:
|
||||
// =================================================================
|
||||
// DM Inbox: list of contacts/rooms you have DM history with
|
||||
// =================================================================
|
||||
display.setTextSize(0);
|
||||
int lineHeight = 9;
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
|
||||
int lineHeight = the_mesh.getNodePrefs()->smallLineH();
|
||||
int headerHeight = 14;
|
||||
int footerHeight = 14;
|
||||
int maxY = display.height() - footerHeight;
|
||||
@@ -1056,7 +1056,7 @@ public:
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), lineHeight);
|
||||
#else
|
||||
display.fillRect(0, y + 5, display.width(), lineHeight);
|
||||
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), display.width(), lineHeight);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
@@ -1094,8 +1094,8 @@ public:
|
||||
}
|
||||
display.setTextSize(1);
|
||||
} else {
|
||||
display.setTextSize(0); // Tiny font for message body
|
||||
int lineHeight = 9; // 8px font + 1px spacing
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize()); // Tiny font for message body
|
||||
int lineHeight = the_mesh.getNodePrefs()->smallLineH(); // 8px font + 1px spacing
|
||||
int headerHeight = 14;
|
||||
int footerHeight = 14;
|
||||
int scrollBarW = 4; // Width of scroll indicator on right edge
|
||||
@@ -1163,7 +1163,7 @@ public:
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, contentW, maxFillH);
|
||||
#else
|
||||
display.fillRect(0, y + 5, contentW, maxFillH);
|
||||
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), contentW, maxFillH);
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -1324,7 +1324,7 @@ public:
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, contentW, maxFillH - usedH);
|
||||
#else
|
||||
display.fillRect(0, y + 5, contentW, maxFillH - usedH);
|
||||
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), contentW, maxFillH - usedH);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -1646,7 +1646,26 @@ public:
|
||||
}
|
||||
}
|
||||
} else if (_viewChannelIdx > 0) {
|
||||
_viewChannelIdx--;
|
||||
// Skip backwards over any empty/gap slots
|
||||
uint8_t prev = _viewChannelIdx - 1;
|
||||
bool found = false;
|
||||
while (true) {
|
||||
ChannelDetails ch;
|
||||
if (the_mesh.getChannel(prev, ch) && ch.name[0] != '\0') {
|
||||
_viewChannelIdx = prev;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
if (prev == 0) break;
|
||||
prev--;
|
||||
}
|
||||
if (!found) {
|
||||
// No valid channel below → wrap to DM tab
|
||||
_viewChannelIdx = 0xFF;
|
||||
_dmInboxMode = true;
|
||||
_dmInboxScroll = 0;
|
||||
_dmFilterName[0] = '\0';
|
||||
}
|
||||
} else {
|
||||
// Channel 0 → wrap to DM tab
|
||||
_viewChannelIdx = 0xFF;
|
||||
@@ -1667,11 +1686,17 @@ public:
|
||||
// DM tab → wrap to channel 0
|
||||
_viewChannelIdx = 0;
|
||||
} else {
|
||||
ChannelDetails ch;
|
||||
uint8_t nextIdx = _viewChannelIdx + 1;
|
||||
if (the_mesh.getChannel(nextIdx, ch) && ch.name[0] != '\0') {
|
||||
_viewChannelIdx = nextIdx;
|
||||
} else {
|
||||
// Skip forward over any empty/gap slots
|
||||
bool found = false;
|
||||
for (uint8_t next = _viewChannelIdx + 1; next < MAX_GROUP_CHANNELS; next++) {
|
||||
ChannelDetails ch;
|
||||
if (the_mesh.getChannel(next, ch) && ch.name[0] != '\0') {
|
||||
_viewChannelIdx = next;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
// Past last channel → go to DM tab
|
||||
_viewChannelIdx = 0xFF;
|
||||
_dmInboxMode = true;
|
||||
|
||||
@@ -162,11 +162,11 @@ public:
|
||||
// Returns: 0=miss, 1=moved, 2=tapped current row.
|
||||
int selectRowAtVY(int vy) {
|
||||
if (_filteredCount == 0) return 0;
|
||||
const int headerH = 14, footerH = 14, lineH = 9;
|
||||
const int headerH = 14, footerH = 14, lineH = the_mesh.getNodePrefs()->smallLineH();
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
const int bodyTop = headerH;
|
||||
#else
|
||||
const int bodyTop = headerH + 5;
|
||||
const int bodyTop = headerH + the_mesh.getNodePrefs()->smallHighlightOff();
|
||||
#endif
|
||||
if (vy < bodyTop || vy >= 128 - footerH) return 0;
|
||||
|
||||
@@ -235,8 +235,8 @@ public:
|
||||
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
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize()); // tiny font for compact rows
|
||||
int lineHeight = the_mesh.getNodePrefs()->smallLineH(); // 8px font + 1px gap
|
||||
int headerHeight = 14;
|
||||
int footerHeight = 14;
|
||||
int maxY = display.height() - footerHeight;
|
||||
@@ -275,7 +275,7 @@ public:
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), lineHeight);
|
||||
#else
|
||||
display.fillRect(0, y + 5, display.width(), lineHeight);
|
||||
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), display.width(), lineHeight);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
|
||||
@@ -49,11 +49,11 @@ public:
|
||||
int selectRowAtVY(int vy) {
|
||||
int count = the_mesh.getDiscoveredCount();
|
||||
if (count == 0) return 0;
|
||||
const int headerH = 14, footerH = 14, lineH = 9;
|
||||
const int headerH = 14, footerH = 14, lineH = the_mesh.getNodePrefs()->smallLineH();
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
const int bodyTop = headerH;
|
||||
#else
|
||||
const int bodyTop = headerH + 5;
|
||||
const int bodyTop = headerH + the_mesh.getNodePrefs()->smallHighlightOff();
|
||||
#endif
|
||||
if (vy < bodyTop || vy >= 128 - footerH) return 0;
|
||||
|
||||
@@ -91,8 +91,8 @@ public:
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
// === Body — discovered node rows ===
|
||||
display.setTextSize(0); // tiny font for compact rows
|
||||
int lineHeight = 9;
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize()); // tiny font for compact rows
|
||||
int lineHeight = the_mesh.getNodePrefs()->smallLineH();
|
||||
int headerHeight = 14;
|
||||
int footerHeight = 14;
|
||||
int maxY = display.height() - footerHeight;
|
||||
@@ -129,7 +129,7 @@ public:
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), lineHeight);
|
||||
#else
|
||||
display.fillRect(0, y + 5, display.width(), lineHeight);
|
||||
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), display.width(), lineHeight);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
|
||||
@@ -68,11 +68,11 @@ public:
|
||||
// Returns: 0=miss, 1=moved, 2=tapped current row.
|
||||
int selectRowAtVY(int vy) {
|
||||
if (_count == 0) return 0;
|
||||
const int headerH = 14, footerH = 14, lineH = 9;
|
||||
const int headerH = 14, footerH = 14, lineH = the_mesh.getNodePrefs()->smallLineH();
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
const int bodyTop = headerH;
|
||||
#else
|
||||
const int bodyTop = headerH + 5;
|
||||
const int bodyTop = headerH + the_mesh.getNodePrefs()->smallHighlightOff();
|
||||
#endif
|
||||
if (vy < bodyTop || vy >= 128 - footerH) return 0;
|
||||
|
||||
@@ -117,8 +117,8 @@ public:
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
// === Body — node rows ===
|
||||
display.setTextSize(0);
|
||||
int lineHeight = 9;
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
|
||||
int lineHeight = the_mesh.getNodePrefs()->smallLineH();
|
||||
int headerHeight = 14;
|
||||
int footerHeight = 14;
|
||||
int maxY = display.height() - footerHeight;
|
||||
@@ -147,7 +147,7 @@ public:
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), lineHeight);
|
||||
#else
|
||||
display.fillRect(0, y + 5, display.width(), lineHeight);
|
||||
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), display.width(), lineHeight);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <SD.h>
|
||||
#include <vector>
|
||||
#include "Utf8CP437.h"
|
||||
#include "../NodePrefs.h"
|
||||
|
||||
// Forward declarations
|
||||
class UITask;
|
||||
@@ -52,9 +53,11 @@ public:
|
||||
|
||||
private:
|
||||
UITask* _task;
|
||||
NodePrefs* _prefs;
|
||||
Mode _mode;
|
||||
bool _sdReady;
|
||||
bool _initialized;
|
||||
uint8_t _lastFontPref;
|
||||
DisplayDriver* _display;
|
||||
|
||||
// Display layout (calculated once from display metrics)
|
||||
@@ -518,8 +521,8 @@ private:
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
// File list with "+ New Note" at index 0
|
||||
display.setTextSize(0);
|
||||
int listLineH = 9; // Match contacts/discovery for consistent selection highlight
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
int listLineH = _prefs->smallLineH();
|
||||
int startY = 14;
|
||||
int totalItems = 1 + (int)_fileList.size();
|
||||
int maxVisible = (display.height() - startY - _footerHeight) / listLineH;
|
||||
@@ -539,27 +542,21 @@ private:
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), listLineH);
|
||||
#else
|
||||
display.fillRect(0, y + 5, display.width(), listLineH);
|
||||
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width(), listLineH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
|
||||
display.setCursor(0, y);
|
||||
|
||||
if (i == 0) {
|
||||
display.setColor(selected ? DisplayDriver::DARK : DisplayDriver::GREEN);
|
||||
display.print(selected ? "> + New Note" : " + New Note");
|
||||
display.drawTextEllipsized(0, y, display.width() - 4,
|
||||
selected ? "> + New Note" : " + New Note");
|
||||
} else {
|
||||
String line = selected ? "> " : " ";
|
||||
String name = _fileList[i - 1];
|
||||
int maxLen = _charsPerLine - 4;
|
||||
if ((int)name.length() > maxLen) {
|
||||
name = name.substring(0, maxLen - 3) + "...";
|
||||
}
|
||||
line += name;
|
||||
display.print(line.c_str());
|
||||
line += _fileList[i - 1];
|
||||
display.drawTextEllipsized(0, y, display.width() - 4, line.c_str());
|
||||
}
|
||||
y += listLineH;
|
||||
}
|
||||
@@ -605,7 +602,7 @@ private:
|
||||
}
|
||||
|
||||
// Render current page using tiny font
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
|
||||
int pageStart = _pageOffsets[_currentPage];
|
||||
@@ -722,7 +719,7 @@ private:
|
||||
int textAreaTop = 14;
|
||||
int textAreaBottom = display.height() - 16;
|
||||
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
|
||||
// Find cursor line
|
||||
int cursorLine = lineForPos(_cursorPos);
|
||||
@@ -771,7 +768,7 @@ private:
|
||||
|
||||
// If buffer is empty, show cursor at top
|
||||
if (_bufLen == 0) {
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setCursor(0, textAreaTop);
|
||||
display.print("|");
|
||||
@@ -829,7 +826,7 @@ private:
|
||||
display.setCursor(0, 20);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.print("From: ");
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
String origDisplay = _renameOriginal;
|
||||
if (origDisplay.length() > 30) origDisplay = origDisplay.substring(0, 27) + "...";
|
||||
display.print(origDisplay.c_str());
|
||||
@@ -840,7 +837,7 @@ private:
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.print("To: ");
|
||||
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
char displayName[NOTES_RENAME_MAX + 2];
|
||||
snprintf(displayName, sizeof(displayName), "%s|", _renameBuf);
|
||||
@@ -880,7 +877,7 @@ private:
|
||||
display.setCursor(0, 25);
|
||||
display.print("File:");
|
||||
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setCursor(0, 38);
|
||||
String nameDisplay = _deleteTarget;
|
||||
if (nameDisplay.length() > 35) nameDisplay = nameDisplay.substring(0, 32) + "...";
|
||||
@@ -1096,9 +1093,9 @@ private:
|
||||
}
|
||||
|
||||
public:
|
||||
NotesScreen(UITask* task)
|
||||
: _task(task), _mode(FILE_LIST),
|
||||
_sdReady(false), _initialized(false), _display(nullptr),
|
||||
NotesScreen(UITask* task, NodePrefs* prefs = nullptr)
|
||||
: _task(task), _prefs(prefs), _mode(FILE_LIST),
|
||||
_sdReady(false), _initialized(false), _lastFontPref(0), _display(nullptr),
|
||||
_charsPerLine(38), _linesPerPage(22), _lineHeight(5), _footerHeight(14),
|
||||
_editCharsPerLine(20), _editLineHeight(12), _editMaxLines(8),
|
||||
_selectedFile(0), _buf(nullptr), _bufLen(0), _cursorPos(0),
|
||||
@@ -1133,15 +1130,31 @@ public:
|
||||
// ---- Layout Init ----
|
||||
|
||||
void initLayout(DisplayDriver& display) {
|
||||
// Re-init if font preference changed since last layout
|
||||
uint8_t curFont = _prefs ? _prefs->large_font : 0;
|
||||
if (_initialized && curFont != _lastFontPref) {
|
||||
_initialized = false;
|
||||
Serial.println("Notes: font changed, recalculating layout");
|
||||
}
|
||||
if (_initialized) return;
|
||||
_lastFontPref = curFont;
|
||||
_display = &display;
|
||||
|
||||
// Tiny font metrics (for read mode)
|
||||
display.setTextSize(0);
|
||||
// Font metrics (for read mode)
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
uint16_t tenCharsW = display.getTextWidth("MMMMMMMMMM");
|
||||
if (tenCharsW > 0) {
|
||||
_charsPerLine = (display.width() * 10) / tenCharsW;
|
||||
}
|
||||
// Proportional font: use average-width measurement instead of M-width
|
||||
if (_prefs && _prefs->large_font) {
|
||||
const char* sample = "the quick brown fox jumps over lazy dog";
|
||||
uint16_t sampleW = display.getTextWidth(sample);
|
||||
int sampleLen = strlen(sample);
|
||||
if (sampleW > 0 && sampleLen > 0) {
|
||||
_charsPerLine = (display.width() * sampleLen * 70) / ((int)sampleW * 100);
|
||||
}
|
||||
}
|
||||
if (_charsPerLine < 15) _charsPerLine = 15;
|
||||
if (_charsPerLine > 60) _charsPerLine = 60;
|
||||
|
||||
@@ -1151,6 +1164,10 @@ public:
|
||||
} else {
|
||||
_lineHeight = 5;
|
||||
}
|
||||
// Large font: formula above assumes built-in 6x8 ratio — too small for 9pt
|
||||
if (_prefs && _prefs->large_font) {
|
||||
_lineHeight = _prefs->smallLineH();
|
||||
}
|
||||
|
||||
_footerHeight = 14;
|
||||
int textAreaHeight = display.height() - _footerHeight;
|
||||
|
||||
@@ -777,8 +777,8 @@ private:
|
||||
// =====================================================================
|
||||
|
||||
void renderCategoryMenu(DisplayDriver& display, int y, int bodyHeight) {
|
||||
display.setTextSize(0);
|
||||
int lineHeight = 9;
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
|
||||
int lineHeight = the_mesh.getNodePrefs()->smallLineH();
|
||||
|
||||
// Clock drift info line
|
||||
if (_serverTime > 0) {
|
||||
@@ -862,8 +862,8 @@ private:
|
||||
// =====================================================================
|
||||
|
||||
void renderCommandMenu(DisplayDriver& display, int y, int bodyHeight) {
|
||||
display.setTextSize(0);
|
||||
int lineHeight = 9;
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
|
||||
int lineHeight = the_mesh.getNodePrefs()->smallLineH();
|
||||
const AdminCategoryDef& cat = CATEGORIES[_catSel];
|
||||
|
||||
// Category title
|
||||
@@ -1025,7 +1025,7 @@ private:
|
||||
if (_pendingCmd) display.print(_pendingCmd->label);
|
||||
|
||||
y += 14;
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
|
||||
display.setCursor(0, y);
|
||||
|
||||
// Show the param value if one was collected
|
||||
@@ -1033,7 +1033,7 @@ private:
|
||||
char preview[80];
|
||||
snprintf(preview, sizeof(preview), "Value: %s", _paramBuf);
|
||||
display.print(preview);
|
||||
y += 10;
|
||||
y += the_mesh.getNodePrefs()->smallLineH() + 1;
|
||||
display.setCursor(0, y);
|
||||
}
|
||||
|
||||
@@ -1071,8 +1071,8 @@ private:
|
||||
// =====================================================================
|
||||
|
||||
void renderResponse(DisplayDriver& display, int y, int bodyHeight) {
|
||||
display.setTextSize(0);
|
||||
int lineHeight = 9;
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
|
||||
int lineHeight = the_mesh.getNodePrefs()->smallLineH();
|
||||
|
||||
display.setColor((_state == STATE_ERROR) ? DisplayDriver::YELLOW : DisplayDriver::LIGHT);
|
||||
|
||||
@@ -1166,7 +1166,7 @@ private:
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), lineHeight);
|
||||
#else
|
||||
display.fillRect(0, y + 5, display.width(), lineHeight);
|
||||
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), display.width(), lineHeight);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else if (warn) {
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
#include "ModemManager.h"
|
||||
#include "SMSStore.h"
|
||||
#include "SMSContacts.h"
|
||||
#include "../NodePrefs.h"
|
||||
|
||||
// Limits
|
||||
#define SMS_INBOX_PAGE_SIZE 4
|
||||
@@ -51,6 +52,7 @@ public:
|
||||
|
||||
private:
|
||||
UITask* _task;
|
||||
NodePrefs* _prefs;
|
||||
SubView _view;
|
||||
|
||||
// App menu state
|
||||
@@ -117,8 +119,8 @@ private:
|
||||
}
|
||||
|
||||
public:
|
||||
SMSScreen(UITask* task)
|
||||
: _task(task), _view(APP_MENU)
|
||||
SMSScreen(UITask* task, NodePrefs* prefs = nullptr)
|
||||
: _task(task), _prefs(prefs), _view(APP_MENU)
|
||||
, _menuCursor(0)
|
||||
, _convCount(0), _inboxCursor(0), _inboxScrollTop(0)
|
||||
, _msgCount(0), _msgScrollPos(0)
|
||||
@@ -276,7 +278,7 @@ public:
|
||||
|
||||
// Show modem state text if not ready
|
||||
if (ms != ModemState::READY && ms != ModemState::SENDING_SMS) {
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
const char* label = ModemManager::stateToString(ms);
|
||||
uint16_t labelW = display.getTextWidth(label);
|
||||
@@ -356,7 +358,7 @@ public:
|
||||
|
||||
// Modem status indicator
|
||||
ModemState ms = modemManager.getState();
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setCursor(4, y + lineHeight + 8);
|
||||
if (ms == ModemState::OFF || ms == ModemState::POWERING_ON ||
|
||||
ms == ModemState::INITIALIZING) {
|
||||
@@ -483,7 +485,7 @@ public:
|
||||
bool isAction = (row == 4); // Bottom row has action buttons
|
||||
|
||||
if (isAction) {
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
if (col == 2 && _phoneInputPos > 0) {
|
||||
display.setColor(DisplayDriver::GREEN); // CALL
|
||||
} else if (col == 1) {
|
||||
@@ -544,7 +546,7 @@ public:
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
if (_convCount == 0) {
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(0, 20);
|
||||
display.print("No conversations");
|
||||
@@ -560,8 +562,8 @@ public:
|
||||
}
|
||||
display.setTextSize(1);
|
||||
} else {
|
||||
display.setTextSize(0);
|
||||
int lineHeight = 10;
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
int lineHeight = _prefs->smallLineH() + 1;
|
||||
int y = 14;
|
||||
|
||||
int visibleCount = (display.height() - 14 - 14) / (lineHeight * 2 + 2);
|
||||
@@ -643,14 +645,14 @@ public:
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
if (_msgCount == 0) {
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(0, 25);
|
||||
display.print("No messages");
|
||||
display.setTextSize(1);
|
||||
} else {
|
||||
display.setTextSize(0);
|
||||
int lineHeight = 10;
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
int lineHeight = _prefs->smallLineH() + 1;
|
||||
int headerHeight = 14;
|
||||
int footerHeight = 14;
|
||||
|
||||
@@ -764,12 +766,13 @@ public:
|
||||
// Message body
|
||||
display.setCursor(0, 14);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
|
||||
uint16_t testWidth = display.getTextWidth("MMMMMMMMMM");
|
||||
int charsPerLine = (testWidth > 0) ? (display.width() * 10) / testWidth : 20;
|
||||
if (charsPerLine < 12) charsPerLine = 12;
|
||||
|
||||
int composeLH = _prefs->smallLineH() + 1;
|
||||
int y = 14;
|
||||
int x = 0;
|
||||
char cs[2] = {0, 0};
|
||||
@@ -780,7 +783,7 @@ public:
|
||||
x++;
|
||||
if (x >= charsPerLine) {
|
||||
x = 0;
|
||||
y += 10;
|
||||
y += composeLH;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -827,7 +830,7 @@ public:
|
||||
int cnt = smsContacts.count();
|
||||
|
||||
if (cnt == 0) {
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(0, 25);
|
||||
display.print("No contacts saved");
|
||||
@@ -837,8 +840,8 @@ public:
|
||||
display.print("and press A to add");
|
||||
display.setTextSize(1);
|
||||
} else {
|
||||
display.setTextSize(0);
|
||||
int lineHeight = 10;
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
int lineHeight = _prefs->smallLineH() + 1;
|
||||
int y = 14;
|
||||
|
||||
int visibleCount = (display.height() - 14 - 14) / (lineHeight * 2 + 2);
|
||||
@@ -900,7 +903,7 @@ public:
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
// Phone number (read-only)
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(0, 16);
|
||||
display.print("Phone: ");
|
||||
@@ -956,7 +959,7 @@ public:
|
||||
display.print(dispName);
|
||||
|
||||
// Phone number below name (smaller, dimmer)
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(4, 36);
|
||||
display.print(_callPhone);
|
||||
@@ -1011,7 +1014,7 @@ public:
|
||||
display.print(dispName);
|
||||
|
||||
// Phone number below name (smaller, dimmer)
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(4, 36);
|
||||
display.print(_callPhone);
|
||||
@@ -1070,7 +1073,7 @@ public:
|
||||
display.print(dispName);
|
||||
|
||||
// Phone number below name (smaller, dimmer)
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(4, 36);
|
||||
display.print(_callPhone);
|
||||
@@ -1090,7 +1093,7 @@ public:
|
||||
display.print(timeBuf);
|
||||
|
||||
// Volume (left-aligned)
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
char volLabel[12];
|
||||
snprintf(volLabel, sizeof(volLabel), "Vol: %d/5", _callVolume);
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
#include <SD.h>
|
||||
#endif
|
||||
#include <WebServer.h>
|
||||
#include <DNSServer.h>
|
||||
#include <Update.h>
|
||||
#include <esp_ota_ops.h>
|
||||
#endif
|
||||
@@ -112,6 +113,7 @@ enum SettingsRowType : uint8_t {
|
||||
ROW_UTC_OFFSET, // UTC offset (-12 to +14)
|
||||
ROW_MSG_NOTIFY, // Keyboard flash on new msg toggle
|
||||
ROW_DARK_MODE, // Dark mode toggle (inverted display)
|
||||
ROW_LARGE_FONT, // Font size toggle: 0=tiny (default), 1=larger
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
ROW_PORTRAIT_MODE, // Portrait orientation toggle
|
||||
#endif
|
||||
@@ -142,7 +144,9 @@ enum SettingsRowType : uint8_t {
|
||||
ROW_ADD_CHANNEL, // "+ Add Hashtag Channel"
|
||||
ROW_INFO_HEADER, // "--- Info ---" separator
|
||||
#ifdef MECK_OTA_UPDATE
|
||||
ROW_OTA_TOOLS_SUBMENU, // Folder row → enters OTA Tools sub-screen
|
||||
ROW_FW_UPDATE, // "Firmware Update" — WiFi upload + flash
|
||||
ROW_SD_FILE_MGR, // "SD File Manager" — WiFi file browser
|
||||
#endif
|
||||
ROW_PUB_KEY, // Public key display
|
||||
ROW_FIRMWARE, // Firmware version
|
||||
@@ -167,6 +171,7 @@ enum EditMode : uint8_t {
|
||||
#endif
|
||||
#ifdef MECK_OTA_UPDATE
|
||||
EDIT_OTA, // OTA firmware update flow (multi-phase overlay)
|
||||
EDIT_FILEMGR, // SD file manager flow (WiFi file browser)
|
||||
#endif
|
||||
};
|
||||
|
||||
@@ -177,6 +182,9 @@ enum SubScreen : uint8_t {
|
||||
SUB_NONE, // Top-level settings list
|
||||
SUB_CONTACTS, // Contacts settings sub-screen
|
||||
SUB_CHANNELS, // Channels management sub-screen
|
||||
#ifdef MECK_OTA_UPDATE
|
||||
SUB_OTA_TOOLS, // OTA Tools sub-screen (FW update + File Manager)
|
||||
#endif
|
||||
};
|
||||
|
||||
#ifdef MECK_OTA_UPDATE
|
||||
@@ -191,6 +199,13 @@ enum OtaPhase : uint8_t {
|
||||
OTA_PHASE_DONE, // Success, rebooting
|
||||
OTA_PHASE_ERROR, // Error with message
|
||||
};
|
||||
|
||||
// File manager phases
|
||||
enum FmPhase : uint8_t {
|
||||
FM_PHASE_CONFIRM, // "Start SD file manager? Enter:Yes Q:No"
|
||||
FM_PHASE_WAITING, // AP up, file browser active
|
||||
FM_PHASE_ERROR, // Error with message
|
||||
};
|
||||
#endif
|
||||
|
||||
// Max rows in the settings list (increased for contact sub-toggles + WiFi)
|
||||
@@ -242,6 +257,9 @@ private:
|
||||
// Dirty flag for radio params  prompt to apply
|
||||
bool _radioChanged;
|
||||
|
||||
// T5S3: signal UITask to open VKB when entering text edit mode
|
||||
bool _needsTextVKB;
|
||||
|
||||
// 4G modem state (runtime cache of config)
|
||||
#ifdef HAS_4G_MODEM
|
||||
bool _modemEnabled;
|
||||
@@ -277,6 +295,10 @@ private:
|
||||
bool _otaUploadOk;
|
||||
char _otaApName[24];
|
||||
const char* _otaError;
|
||||
// File manager state
|
||||
FmPhase _fmPhase;
|
||||
const char* _fmError;
|
||||
DNSServer* _dnsServer;
|
||||
#endif
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -349,15 +371,21 @@ private:
|
||||
}
|
||||
} else if (_subScreen == SUB_CHANNELS) {
|
||||
// --- Channels sub-screen: only channel-related rows ---
|
||||
// Scan ALL slots — companion app may write non-contiguously, and
|
||||
// gaps can appear after channel deletion if compaction is incomplete.
|
||||
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;
|
||||
}
|
||||
}
|
||||
addRow(ROW_ADD_CHANNEL);
|
||||
#ifdef MECK_OTA_UPDATE
|
||||
} else if (_subScreen == SUB_OTA_TOOLS) {
|
||||
// --- OTA Tools sub-screen ---
|
||||
addRow(ROW_FW_UPDATE);
|
||||
addRow(ROW_SD_FILE_MGR);
|
||||
#endif
|
||||
} else {
|
||||
// --- Top-level settings list ---
|
||||
addRow(ROW_NAME);
|
||||
@@ -372,6 +400,7 @@ private:
|
||||
addRow(ROW_GPS_BAUD);
|
||||
addRow(ROW_PATH_HASH_SIZE);
|
||||
addRow(ROW_DARK_MODE);
|
||||
addRow(ROW_LARGE_FONT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
addRow(ROW_PORTRAIT_MODE);
|
||||
#endif
|
||||
@@ -389,12 +418,12 @@ private:
|
||||
// Folder rows for sub-screens
|
||||
addRow(ROW_CONTACTS_SUBMENU);
|
||||
addRow(ROW_CHANNELS_SUBMENU);
|
||||
#ifdef MECK_OTA_UPDATE
|
||||
addRow(ROW_OTA_TOOLS_SUBMENU);
|
||||
#endif
|
||||
|
||||
// Info section (stays at top level)
|
||||
addRow(ROW_INFO_HEADER);
|
||||
#ifdef MECK_OTA_UPDATE
|
||||
addRow(ROW_FW_UPDATE);
|
||||
#endif
|
||||
addRow(ROW_PUB_KEY);
|
||||
addRow(ROW_FIRMWARE);
|
||||
|
||||
@@ -501,14 +530,12 @@ private:
|
||||
ChannelDetails empty;
|
||||
memset(&empty, 0, sizeof(empty));
|
||||
|
||||
// Find total channel count
|
||||
// Find highest used channel slot (scan all — gaps may exist)
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -545,7 +572,7 @@ public:
|
||||
_editMode(EDIT_NONE), _editPos(0), _editPickerIdx(0),
|
||||
_editFloat(0), _editInt(0), _confirmAction(0),
|
||||
_onboarding(false), _subScreen(SUB_NONE), _savedTopCursor(0),
|
||||
_radioChanged(false) {
|
||||
_radioChanged(false), _needsTextVKB(false) {
|
||||
memset(_editBuf, 0, sizeof(_editBuf));
|
||||
#ifdef MECK_OTA_UPDATE
|
||||
_otaServer = nullptr;
|
||||
@@ -553,6 +580,9 @@ public:
|
||||
_otaBytesReceived = 0;
|
||||
_otaUploadOk = false;
|
||||
_otaError = nullptr;
|
||||
_fmPhase = FM_PHASE_CONFIRM;
|
||||
_fmError = nullptr;
|
||||
_dnsServer = nullptr;
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -603,13 +633,13 @@ public:
|
||||
// and move cursor there. Returns: 0=miss, 1=moved to new row, 2=tapped current row.
|
||||
int selectRowAtVY(int vy) {
|
||||
if (_editMode != EDIT_NONE) return 0; // Don't change cursor while editing
|
||||
const int headerH = 14, footerH = 14, lineH = 9;
|
||||
// T-Deck Pro render offsets fillRect by +5 (GxEPD baseline compensation),
|
||||
// so visual rows start 5 units below headerH. T5S3 renders at y directly.
|
||||
const int headerH = 14, footerH = 14, lineH = _prefs->smallLineH();
|
||||
// bodyTop must match where the visual rows start (highlight bar position).
|
||||
// T5S3 renders highlight at y directly. T-Deck Pro offsets by smallHighlightOff().
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
const int bodyTop = headerH;
|
||||
#else
|
||||
const int bodyTop = headerH + 5;
|
||||
const int bodyTop = headerH + _prefs->smallHighlightOff();
|
||||
#endif
|
||||
if (vy < bodyTop || vy >= 128 - footerH) return 0; // Outside body area
|
||||
|
||||
@@ -740,6 +770,19 @@ public:
|
||||
|
||||
#endif
|
||||
|
||||
// T5S3 VKB integration for text editing (channel name, device name, freq, APN)
|
||||
bool needsTextVKB() const { return _needsTextVKB; }
|
||||
void clearTextNeedsVKB() { _needsTextVKB = false; }
|
||||
const char* getEditBuf() const { return _editBuf; }
|
||||
SettingsRowType getCurrentRowType() const { return _rows[_cursor].type; }
|
||||
void submitEditText(const char* text) {
|
||||
strncpy(_editBuf, text, SETTINGS_TEXT_BUF - 1);
|
||||
_editBuf[SETTINGS_TEXT_BUF - 1] = '\0';
|
||||
_editPos = strlen(_editBuf);
|
||||
// Simulate Enter to confirm the edit through the normal path
|
||||
handleInput('\r');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OTA firmware update
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -963,7 +1006,7 @@ public:
|
||||
display.fillRect(2, 14, display.width() - 4, display.height() - 28);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(2, 14, display.width() - 4, display.height() - 28);
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.drawTextCentered(display.width() / 2, 22, "Flashing Firmware");
|
||||
snprintf(tmp, sizeof(tmp), "%d / %d KB", (int)(totalWritten / 1024), (int)(fileSize / 1024));
|
||||
display.drawTextCentered(display.width() / 2, 42, tmp);
|
||||
@@ -987,10 +1030,18 @@ public:
|
||||
return true;
|
||||
}
|
||||
|
||||
// Called from render loop AND main loop to poll the web server
|
||||
// Called from render loop AND main loop to poll the web server.
|
||||
// Handles both OTA firmware upload and SD file manager modes.
|
||||
void pollOTAServer() {
|
||||
if (_otaServer && (_otaPhase == OTA_PHASE_WAITING || _otaPhase == OTA_PHASE_RECEIVING)) {
|
||||
_otaServer->handleClient();
|
||||
if (_otaServer) {
|
||||
if ((_editMode == EDIT_OTA && (_otaPhase == OTA_PHASE_WAITING || _otaPhase == OTA_PHASE_RECEIVING)) ||
|
||||
(_editMode == EDIT_FILEMGR && _fmPhase == FM_PHASE_WAITING)) {
|
||||
_otaServer->handleClient();
|
||||
}
|
||||
}
|
||||
// Process DNS for captive portal redirect (file manager only)
|
||||
if (_dnsServer && _editMode == EDIT_FILEMGR && _fmPhase == FM_PHASE_WAITING) {
|
||||
_dnsServer->processNextRequest();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1037,7 +1088,7 @@ public:
|
||||
display.fillRect(2, 14, display.width() - 4, display.height() - 28);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(2, 14, display.width() - 4, display.height() - 28);
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.drawTextCentered(display.width() / 2, 30, "Update Complete!");
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
@@ -1057,6 +1108,443 @@ public:
|
||||
ESP.restart();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SD File Manager — WiFi file browser, upload, download, delete
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void startFileMgr() {
|
||||
_editMode = EDIT_FILEMGR;
|
||||
_fmPhase = FM_PHASE_CONFIRM;
|
||||
_fmError = nullptr;
|
||||
}
|
||||
|
||||
void startFileMgrServer() {
|
||||
// Build AP name with last 4 of MAC for uniqueness
|
||||
uint8_t mac[6];
|
||||
WiFi.macAddress(mac);
|
||||
snprintf(_otaApName, sizeof(_otaApName), "Meck-Files-%02X%02X", mac[4], mac[5]);
|
||||
|
||||
// Pause LoRa radio — SD and LoRa share the same SPI bus on both
|
||||
// platforms. Incoming packets during SD writes cause bus contention.
|
||||
extern void otaPauseRadio();
|
||||
otaPauseRadio();
|
||||
|
||||
// Clean WiFi init from any state
|
||||
WiFi.disconnect(true);
|
||||
WiFi.mode(WIFI_OFF);
|
||||
delay(200);
|
||||
WiFi.mode(WIFI_AP);
|
||||
WiFi.softAP(_otaApName);
|
||||
delay(500);
|
||||
Serial.printf("FM: AP '%s' started, IP: %s\n",
|
||||
_otaApName, WiFi.softAPIP().toString().c_str());
|
||||
|
||||
// Start DNS server — redirect ALL DNS lookups to our AP IP.
|
||||
// This triggers captive portal detection on phones, which opens the
|
||||
// page in a real browser instead of the restricted captive webview.
|
||||
if (_dnsServer) { delete _dnsServer; }
|
||||
_dnsServer = new DNSServer();
|
||||
_dnsServer->start(53, "*", WiFi.softAPIP());
|
||||
Serial.println("FM: DNS captive portal started");
|
||||
|
||||
// Start web server
|
||||
if (_otaServer) { _otaServer->stop(); delete _otaServer; }
|
||||
_otaServer = new WebServer(80);
|
||||
|
||||
// --- Captive portal detection handlers ---
|
||||
// Phones/OS probe these URLs to detect captive portals. Redirecting
|
||||
// them to our page causes the OS to open a real browser.
|
||||
// iOS / macOS
|
||||
_otaServer->on("/hotspot-detect.html", HTTP_GET, [this]() {
|
||||
Serial.println("FM: captive probe (Apple)");
|
||||
_otaServer->sendHeader("Location", "http://192.168.4.1/");
|
||||
_otaServer->send(302, "text/plain", "");
|
||||
});
|
||||
// Android
|
||||
_otaServer->on("/generate_204", HTTP_GET, [this]() {
|
||||
Serial.println("FM: captive probe (Android)");
|
||||
_otaServer->sendHeader("Location", "http://192.168.4.1/");
|
||||
_otaServer->send(302, "text/plain", "");
|
||||
});
|
||||
_otaServer->on("/gen_204", HTTP_GET, [this]() {
|
||||
_otaServer->sendHeader("Location", "http://192.168.4.1/");
|
||||
_otaServer->send(302, "text/plain", "");
|
||||
});
|
||||
// Windows
|
||||
_otaServer->on("/connecttest.txt", HTTP_GET, [this]() {
|
||||
_otaServer->sendHeader("Location", "http://192.168.4.1/");
|
||||
_otaServer->send(302, "text/plain", "");
|
||||
});
|
||||
_otaServer->on("/redirect", HTTP_GET, [this]() {
|
||||
_otaServer->sendHeader("Location", "http://192.168.4.1/");
|
||||
_otaServer->send(302, "text/plain", "");
|
||||
});
|
||||
// Firefox
|
||||
_otaServer->on("/canonical.html", HTTP_GET, [this]() {
|
||||
_otaServer->sendHeader("Location", "http://192.168.4.1/");
|
||||
_otaServer->send(302, "text/plain", "");
|
||||
});
|
||||
_otaServer->on("/success.txt", HTTP_GET, [this]() {
|
||||
_otaServer->send(200, "text/plain", "success");
|
||||
});
|
||||
|
||||
// --- Main page: server-rendered directory listing (no JS needed) ---
|
||||
_otaServer->on("/", HTTP_GET, [this]() {
|
||||
String path = _otaServer->arg("path");
|
||||
if (path.isEmpty()) path = "/";
|
||||
String msg = _otaServer->arg("msg");
|
||||
Serial.printf("FM: page request path='%s'\n", path.c_str());
|
||||
String html = fmBuildPage(path, msg);
|
||||
_otaServer->send(200, "text/html", html);
|
||||
});
|
||||
|
||||
// --- File download: GET /dl?path=/file.txt ---
|
||||
_otaServer->on("/dl", HTTP_GET, [this]() {
|
||||
String path = _otaServer->arg("path");
|
||||
File f = SD.open(path, FILE_READ);
|
||||
if (!f || f.isDirectory()) {
|
||||
if (f) f.close();
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
_otaServer->send(404, "text/plain", "Not found");
|
||||
return;
|
||||
}
|
||||
String name = path;
|
||||
int lastSlash = name.lastIndexOf('/');
|
||||
if (lastSlash >= 0) name = name.substring(lastSlash + 1);
|
||||
_otaServer->sendHeader("Content-Disposition",
|
||||
"attachment; filename=\"" + name + "\"");
|
||||
size_t fileSize = f.size();
|
||||
_otaServer->setContentLength(fileSize);
|
||||
_otaServer->send(200, "application/octet-stream", "");
|
||||
uint8_t* buf = (uint8_t*)ps_malloc(4096);
|
||||
if (!buf) buf = (uint8_t*)malloc(4096);
|
||||
if (buf) {
|
||||
while (f.available()) {
|
||||
int n = f.read(buf, 4096);
|
||||
if (n > 0) _otaServer->sendContent((const char*)buf, n);
|
||||
}
|
||||
free(buf);
|
||||
}
|
||||
f.close();
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
});
|
||||
|
||||
// --- File upload: POST /upload?dir=/ → redirect back to listing ---
|
||||
_otaServer->on("/upload", HTTP_POST,
|
||||
[this]() {
|
||||
String dir = _otaServer->arg("dir");
|
||||
if (dir.isEmpty()) dir = "/";
|
||||
_otaServer->sendHeader("Location", "/?path=" + dir + "&msg=Upload+complete");
|
||||
_otaServer->send(303, "text/plain", "Redirecting...");
|
||||
},
|
||||
[this]() {
|
||||
HTTPUpload& upload = _otaServer->upload();
|
||||
static File fmUploadFile;
|
||||
|
||||
if (upload.status == UPLOAD_FILE_START) {
|
||||
String dir = _otaServer->arg("dir");
|
||||
if (dir.isEmpty()) dir = "/";
|
||||
if (!dir.endsWith("/")) dir += "/";
|
||||
String fullPath = dir + upload.filename;
|
||||
Serial.printf("FM: Upload start: %s\n", fullPath.c_str());
|
||||
fmUploadFile = SD.open(fullPath, FILE_WRITE);
|
||||
if (!fmUploadFile) Serial.println("FM: Failed to open file for write");
|
||||
|
||||
} else if (upload.status == UPLOAD_FILE_WRITE) {
|
||||
if (fmUploadFile) fmUploadFile.write(upload.buf, upload.currentSize);
|
||||
|
||||
} else if (upload.status == UPLOAD_FILE_END) {
|
||||
if (fmUploadFile) {
|
||||
fmUploadFile.close();
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
Serial.printf("FM: Upload done: %s (%d bytes)\n",
|
||||
upload.filename.c_str(), upload.totalSize);
|
||||
}
|
||||
|
||||
} else if (upload.status == UPLOAD_FILE_ABORTED) {
|
||||
if (fmUploadFile) fmUploadFile.close();
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
Serial.println("FM: Upload aborted");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// --- Create directory: GET /mkdir?name=xxx&dir=/path ---
|
||||
_otaServer->on("/mkdir", HTTP_GET, [this]() {
|
||||
String dir = _otaServer->arg("dir");
|
||||
String name = _otaServer->arg("name");
|
||||
if (dir.isEmpty()) dir = "/";
|
||||
if (name.isEmpty()) {
|
||||
_otaServer->sendHeader("Location", "/?path=" + dir + "&msg=No+name");
|
||||
_otaServer->send(303);
|
||||
return;
|
||||
}
|
||||
String full = dir + (dir.endsWith("/") ? "" : "/") + name;
|
||||
bool ok = SD.mkdir(full);
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
Serial.printf("FM: mkdir '%s' %s\n", full.c_str(), ok ? "OK" : "FAIL");
|
||||
_otaServer->sendHeader("Location",
|
||||
"/?path=" + dir + "&msg=" + (ok ? "Folder+created" : "mkdir+failed"));
|
||||
_otaServer->send(303);
|
||||
});
|
||||
|
||||
// --- Delete file/folder: GET /rm?path=/file&ret=/parent ---
|
||||
_otaServer->on("/rm", HTTP_GET, [this]() {
|
||||
String path = _otaServer->arg("path");
|
||||
String ret = _otaServer->arg("ret");
|
||||
if (ret.isEmpty()) ret = "/";
|
||||
if (path.isEmpty() || path == "/") {
|
||||
_otaServer->sendHeader("Location", "/?path=" + ret + "&msg=Bad+path");
|
||||
_otaServer->send(303);
|
||||
return;
|
||||
}
|
||||
File f = SD.open(path);
|
||||
bool ok = false;
|
||||
if (f) {
|
||||
bool isDir = f.isDirectory();
|
||||
f.close();
|
||||
ok = isDir ? SD.rmdir(path) : SD.remove(path);
|
||||
}
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
Serial.printf("FM: rm '%s' %s\n", path.c_str(), ok ? "OK" : "FAIL");
|
||||
_otaServer->sendHeader("Location",
|
||||
"/?path=" + ret + "&msg=" + (ok ? "Deleted" : "Delete+failed"));
|
||||
_otaServer->send(303);
|
||||
});
|
||||
|
||||
// --- Confirm delete page: GET /confirm-rm?path=/file&ret=/parent ---
|
||||
_otaServer->on("/confirm-rm", HTTP_GET, [this]() {
|
||||
String path = _otaServer->arg("path");
|
||||
String ret = _otaServer->arg("ret");
|
||||
if (ret.isEmpty()) ret = "/";
|
||||
String name = path;
|
||||
int sl = name.lastIndexOf('/');
|
||||
if (sl >= 0) name = name.substring(sl + 1);
|
||||
String html = "<!DOCTYPE html><html><head>"
|
||||
"<meta charset='UTF-8'>"
|
||||
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
||||
"<title>Confirm Delete</title>"
|
||||
"<style>"
|
||||
"body{font-family:-apple-system,sans-serif;max-width:480px;margin:40px auto;"
|
||||
"padding:0 20px;background:#1a1a2e;color:#e0e0e0;text-align:center}"
|
||||
".b{display:inline-block;padding:10px 24px;border-radius:6px;text-decoration:none;"
|
||||
"font-weight:bold;margin:8px;font-size:1em}"
|
||||
".br{background:#e74c3c;color:#fff}.bg{background:#4ecca3;color:#1a1a2e}"
|
||||
"</style></head><body>"
|
||||
"<h2 style='color:#e74c3c'>Delete?</h2>"
|
||||
"<p style='font-size:1.1em'>" + fmHtmlEscape(name) + "</p>"
|
||||
"<a class='b br' href='/rm?path=" + fmUrlEncode(path) + "&ret=" + fmUrlEncode(ret) + "'>Delete</a>"
|
||||
"<a class='b bg' href='/?path=" + fmUrlEncode(ret) + "'>Cancel</a>"
|
||||
"</body></html>";
|
||||
_otaServer->send(200, "text/html", html);
|
||||
});
|
||||
|
||||
// Catch-all: redirect unknown URLs to file manager (catches captive portal probes)
|
||||
_otaServer->onNotFound([this]() {
|
||||
Serial.printf("FM: redirect %s -> /\n", _otaServer->uri().c_str());
|
||||
_otaServer->sendHeader("Location", "http://192.168.4.1/");
|
||||
_otaServer->send(302, "text/plain", "");
|
||||
});
|
||||
|
||||
_otaServer->begin();
|
||||
Serial.println("FM: Web server started on port 80");
|
||||
_fmPhase = FM_PHASE_WAITING;
|
||||
}
|
||||
|
||||
void stopFileMgr() {
|
||||
if (_otaServer) { _otaServer->stop(); delete _otaServer; _otaServer = nullptr; }
|
||||
if (_dnsServer) { _dnsServer->stop(); delete _dnsServer; _dnsServer = nullptr; }
|
||||
WiFi.softAPdisconnect(true);
|
||||
WiFi.mode(WIFI_OFF);
|
||||
delay(100);
|
||||
_editMode = EDIT_NONE;
|
||||
extern void otaResumeRadio();
|
||||
otaResumeRadio();
|
||||
#ifdef MECK_WIFI_COMPANION
|
||||
WiFi.mode(WIFI_STA);
|
||||
wifiReconnectSaved();
|
||||
#endif
|
||||
Serial.println("FM: Stopped, AP down, radio resumed");
|
||||
}
|
||||
|
||||
// --- Helpers for server-rendered HTML ---
|
||||
|
||||
static String fmHtmlEscape(const String& s) {
|
||||
String r;
|
||||
r.reserve(s.length());
|
||||
for (unsigned int i = 0; i < s.length(); i++) {
|
||||
char c = s[i];
|
||||
if (c == '&') r += "&";
|
||||
else if (c == '<') r += "<";
|
||||
else if (c == '>') r += ">";
|
||||
else if (c == '"') r += """;
|
||||
else r += c;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
static String fmUrlEncode(const String& s) {
|
||||
String r;
|
||||
for (unsigned int i = 0; i < s.length(); i++) {
|
||||
char c = s[i];
|
||||
if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '/' || c == '~') {
|
||||
r += c;
|
||||
} else {
|
||||
char hex[4];
|
||||
snprintf(hex, sizeof(hex), "%%%02X", (uint8_t)c);
|
||||
r += hex;
|
||||
}
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
static String fmFormatSize(size_t bytes) {
|
||||
if (bytes < 1024) return String(bytes) + " B";
|
||||
if (bytes < 1048576) return String(bytes / 1024) + " KB";
|
||||
return String(bytes / 1048576) + "." + String((bytes % 1048576) * 10 / 1048576) + " MB";
|
||||
}
|
||||
|
||||
// Build the complete HTML page with inline directory listing
|
||||
String fmBuildPage(const String& path, const String& msg) {
|
||||
String html;
|
||||
html.reserve(4096);
|
||||
|
||||
// --- Head + CSS ---
|
||||
html += "<!DOCTYPE html><html><head>"
|
||||
"<meta charset='UTF-8'>"
|
||||
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
||||
"<title>Meck SD Files</title>"
|
||||
"<style>"
|
||||
"body{font-family:-apple-system,sans-serif;max-width:600px;margin:20px auto;"
|
||||
"padding:0 16px;background:#1a1a2e;color:#e0e0e0}"
|
||||
"h1{color:#4ecca3;font-size:1.3em;margin:8px 0}"
|
||||
".pa{background:#16213e;padding:8px 12px;border-radius:6px;margin:8px 0;"
|
||||
"font-family:monospace;font-size:0.9em;word-break:break-all}"
|
||||
".tb{display:flex;gap:6px;margin:8px 0;flex-wrap:wrap}"
|
||||
".b{background:#4ecca3;color:#1a1a2e;border:none;padding:7px 14px;"
|
||||
"border-radius:5px;font-size:0.85em;font-weight:bold;cursor:pointer;"
|
||||
"text-decoration:none;display:inline-block}"
|
||||
".b:active{background:#3ba88f}"
|
||||
".br{background:#e74c3c;color:#fff;padding:3px 8px;font-size:0.75em}.br:active{background:#c0392b}"
|
||||
".it{display:flex;align-items:center;padding:8px 4px;border-bottom:1px solid #16213e;gap:6px}"
|
||||
".ic{font-size:1.1em;width:22px;text-align:center}"
|
||||
".nm{flex:1;word-break:break-all;color:#e0e0e0;text-decoration:none}"
|
||||
".nm:hover{color:#4ecca3}"
|
||||
".sz{color:#888;font-size:0.8em;min-width:54px;text-align:right;margin-right:4px}"
|
||||
".up{background:#16213e;border:2px dashed #4ecca3;border-radius:8px;"
|
||||
"padding:14px;margin:10px 0;text-align:center}"
|
||||
".em{color:#888;text-align:center;padding:20px}"
|
||||
".ms{background:#16213e;padding:8px 12px;border-radius:6px;margin:8px 0;"
|
||||
"border-left:3px solid #4ecca3;font-size:0.9em}"
|
||||
"</style></head><body>";
|
||||
|
||||
// --- Title + path ---
|
||||
html += "<h1>Meck SD File Manager</h1>";
|
||||
html += "<div class='pa'>" + fmHtmlEscape(path) + "</div>";
|
||||
|
||||
// --- Status message (from redirects) ---
|
||||
if (msg.length() > 0) {
|
||||
html += "<div class='ms'>" + fmHtmlEscape(msg) + "</div>";
|
||||
}
|
||||
|
||||
// --- Navigation buttons ---
|
||||
html += "<div class='tb'>";
|
||||
if (path != "/") {
|
||||
// Compute parent
|
||||
String parent = path;
|
||||
if (parent.endsWith("/")) parent = parent.substring(0, parent.length() - 1);
|
||||
int sl = parent.lastIndexOf('/');
|
||||
parent = (sl <= 0) ? "/" : parent.substring(0, sl);
|
||||
html += "<a class='b' href='/?path=" + fmUrlEncode(parent) + "'>.. Up</a>";
|
||||
}
|
||||
html += "<a class='b' href='/?path=" + fmUrlEncode(path) + "'>Refresh</a>";
|
||||
html += "</div>";
|
||||
|
||||
// --- Directory listing (server-rendered) ---
|
||||
File dir = SD.open(path, FILE_READ);
|
||||
if (!dir || !dir.isDirectory()) {
|
||||
if (dir) dir.close();
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
html += "<div class='em'>Cannot open directory</div>";
|
||||
} else {
|
||||
// Collect entries into arrays for sorting (dirs first, then alpha)
|
||||
struct FmEntry { String name; size_t size; bool isDir; };
|
||||
FmEntry entries[128]; // max entries to display
|
||||
int count = 0;
|
||||
File entry = dir.openNextFile();
|
||||
while (entry && count < 128) {
|
||||
const char* fullName = entry.name();
|
||||
const char* baseName = strrchr(fullName, '/');
|
||||
baseName = baseName ? baseName + 1 : fullName;
|
||||
entries[count].name = baseName;
|
||||
entries[count].size = entry.size();
|
||||
entries[count].isDir = entry.isDirectory();
|
||||
count++;
|
||||
entry.close();
|
||||
entry = dir.openNextFile();
|
||||
}
|
||||
dir.close();
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
|
||||
Serial.printf("FM: listing %d entries for '%s'\n", count, path.c_str());
|
||||
|
||||
// Sort: dirs first, then alphabetical
|
||||
for (int i = 0; i < count - 1; i++) {
|
||||
for (int j = i + 1; j < count; j++) {
|
||||
bool swap = false;
|
||||
if (entries[i].isDir != entries[j].isDir) {
|
||||
swap = !entries[i].isDir && entries[j].isDir;
|
||||
} else {
|
||||
swap = entries[i].name.compareTo(entries[j].name) > 0;
|
||||
}
|
||||
if (swap) {
|
||||
FmEntry tmp = entries[i];
|
||||
entries[i] = entries[j];
|
||||
entries[j] = tmp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (count == 0) {
|
||||
html += "<div class='em'>Empty folder</div>";
|
||||
} else {
|
||||
for (int i = 0; i < count; i++) {
|
||||
String fp = path + (path.endsWith("/") ? "" : "/") + entries[i].name;
|
||||
html += "<div class='it'>";
|
||||
html += "<span class='ic'>" + String(entries[i].isDir ? "\xF0\x9F\x93\x81" : "\xF0\x9F\x93\x84") + "</span>";
|
||||
if (entries[i].isDir) {
|
||||
html += "<a class='nm' href='/?path=" + fmUrlEncode(fp) + "'>" + fmHtmlEscape(entries[i].name) + "</a>";
|
||||
} else {
|
||||
html += "<a class='nm' href='/dl?path=" + fmUrlEncode(fp) + "'>" + fmHtmlEscape(entries[i].name) + "</a>";
|
||||
html += "<span class='sz'>" + fmFormatSize(entries[i].size) + "</span>";
|
||||
}
|
||||
html += "<a class='b br' href='/confirm-rm?path=" + fmUrlEncode(fp) + "&ret=" + fmUrlEncode(path) + "'>Del</a>";
|
||||
html += "</div>";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Upload form (standard HTML form, no JS needed) ---
|
||||
html += "<div class='up'>"
|
||||
"<form method='POST' action='/upload?dir=" + fmUrlEncode(path) + "' enctype='multipart/form-data'>"
|
||||
"<p>Select files to upload</p>"
|
||||
"<input type='file' name='file' multiple><br><br>"
|
||||
"<button class='b' type='submit'>Upload</button>"
|
||||
"</form></div>";
|
||||
|
||||
// --- New folder (tiny inline form) ---
|
||||
html += "<form action='/mkdir' method='GET' style='margin:8px 0;display:flex;gap:6px'>"
|
||||
"<input type='hidden' name='dir' value='" + fmHtmlEscape(path) + "'>"
|
||||
"<input type='text' name='name' placeholder='New folder name' "
|
||||
"style='flex:1;padding:7px;border-radius:5px;border:1px solid #4ecca3;"
|
||||
"background:#16213e;color:#e0e0e0'>"
|
||||
"<button class='b' type='submit'>Create</button>"
|
||||
"</form>";
|
||||
|
||||
html += "</body></html>";
|
||||
return html;
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1068,6 +1556,9 @@ public:
|
||||
strncpy(_editBuf, initial, SETTINGS_TEXT_BUF - 1);
|
||||
_editBuf[SETTINGS_TEXT_BUF - 1] = '\0';
|
||||
_editPos = strlen(_editBuf);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
_needsTextVKB = true; // Signal UITask to open virtual keyboard
|
||||
#endif
|
||||
}
|
||||
|
||||
void startEditPicker(int initialIdx) {
|
||||
@@ -1102,6 +1593,10 @@ public:
|
||||
display.print("Settings > Contacts");
|
||||
} else if (_subScreen == SUB_CHANNELS) {
|
||||
display.print("Settings > Channels");
|
||||
#ifdef MECK_OTA_UPDATE
|
||||
} else if (_subScreen == SUB_OTA_TOOLS) {
|
||||
display.print("Settings > OTA Tools");
|
||||
#endif
|
||||
} else {
|
||||
display.print("Settings");
|
||||
}
|
||||
@@ -1114,8 +1609,8 @@ public:
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
// === Body ===
|
||||
display.setTextSize(0); // tiny font
|
||||
int lineHeight = 9;
|
||||
display.setTextSize(_prefs->smallTextSize()); // tiny font
|
||||
int lineHeight = _prefs->smallLineH();
|
||||
int headerH = 14;
|
||||
int footerH = 14;
|
||||
int maxY = display.height() - footerH;
|
||||
@@ -1140,7 +1635,7 @@ public:
|
||||
// Highlight needs to start above the baseline to cover ascenders.
|
||||
display.fillRect(0, y, display.width(), lineHeight);
|
||||
#else
|
||||
display.fillRect(0, y + 5, display.width(), lineHeight);
|
||||
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width(), lineHeight);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
@@ -1233,7 +1728,7 @@ public:
|
||||
break;
|
||||
|
||||
case ROW_MSG_NOTIFY:
|
||||
snprintf(tmp, sizeof(tmp), "Msg Rcvd LED Light Pulse: %s",
|
||||
snprintf(tmp, sizeof(tmp), "Msg LED Flash: %s",
|
||||
_prefs->kb_flash_notify ? "ON" : "OFF");
|
||||
display.print(tmp);
|
||||
break;
|
||||
@@ -1266,6 +1761,12 @@ public:
|
||||
display.print(tmp);
|
||||
break;
|
||||
|
||||
case ROW_LARGE_FONT:
|
||||
snprintf(tmp, sizeof(tmp), "Font Size: %s",
|
||||
_prefs->large_font ? "LARGER" : "TINY");
|
||||
display.print(tmp);
|
||||
break;
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
case ROW_PORTRAIT_MODE:
|
||||
snprintf(tmp, sizeof(tmp), "Portrait Mode: %s",
|
||||
@@ -1421,9 +1922,18 @@ public:
|
||||
break;
|
||||
|
||||
#ifdef MECK_OTA_UPDATE
|
||||
case ROW_OTA_TOOLS_SUBMENU:
|
||||
display.setColor(selected ? DisplayDriver::DARK : DisplayDriver::GREEN);
|
||||
display.print("OTA Tools >>");
|
||||
break;
|
||||
|
||||
case ROW_FW_UPDATE:
|
||||
display.print("Firmware Update");
|
||||
break;
|
||||
|
||||
case ROW_SD_FILE_MGR:
|
||||
display.print("SD File Manager");
|
||||
break;
|
||||
#endif
|
||||
|
||||
case ROW_INFO_HEADER:
|
||||
@@ -1506,7 +2016,7 @@ public:
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(bx, by, bw, bh);
|
||||
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
if (_confirmAction == 1) {
|
||||
uint8_t chIdx = _rows[_cursor].param;
|
||||
ChannelDetails ch;
|
||||
@@ -1534,7 +2044,7 @@ public:
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(bx, by, bw, bh);
|
||||
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
int wy = by + 4;
|
||||
|
||||
if (_wifiPhase == WIFI_PHASE_SCANNING) {
|
||||
@@ -1620,7 +2130,7 @@ public:
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(bx, by, bw, bh);
|
||||
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
int oy = by + 4;
|
||||
|
||||
if (_otaPhase == OTA_PHASE_CONFIRM) {
|
||||
@@ -1700,6 +2210,75 @@ public:
|
||||
|
||||
display.setTextSize(1);
|
||||
}
|
||||
|
||||
// === File Manager overlay ===
|
||||
if (_editMode == EDIT_FILEMGR) {
|
||||
int bx = 2, by = 14, bw = display.width() - 4;
|
||||
int bh = display.height() - 28;
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
display.fillRect(bx, by, bw, bh);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(bx, by, bw, bh);
|
||||
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
int oy = by + 4;
|
||||
|
||||
if (_fmPhase == FM_PHASE_CONFIRM) {
|
||||
display.drawTextCentered(display.width() / 2, oy, "SD File Manager");
|
||||
oy += 14;
|
||||
display.setCursor(bx + 4, oy);
|
||||
display.print("Start WiFi file server?");
|
||||
oy += 10;
|
||||
display.setCursor(bx + 4, oy);
|
||||
display.print("Upload and download files");
|
||||
oy += 8;
|
||||
display.setCursor(bx + 4, oy);
|
||||
display.print("on SD card via browser.");
|
||||
oy += 10;
|
||||
display.setCursor(bx + 4, oy);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.print("LoRa paused while active.");
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
|
||||
} else if (_fmPhase == FM_PHASE_WAITING) {
|
||||
display.drawTextCentered(display.width() / 2, oy, "SD File Manager");
|
||||
oy += 14;
|
||||
display.setCursor(bx + 4, oy);
|
||||
display.print("Connect to WiFi network:");
|
||||
oy += 10;
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setCursor(bx + 4, oy);
|
||||
display.print(_otaApName);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
oy += 12;
|
||||
display.setCursor(bx + 4, oy);
|
||||
display.print("Then open browser:");
|
||||
oy += 10;
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setCursor(bx + 4, oy);
|
||||
char ipBuf[32];
|
||||
snprintf(ipBuf, sizeof(ipBuf), "http://%s", WiFi.softAPIP().toString().c_str());
|
||||
display.print(ipBuf);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
oy += 12;
|
||||
display.setCursor(bx + 4, oy);
|
||||
display.print("File server active...");
|
||||
|
||||
pollOTAServer();
|
||||
|
||||
} else if (_fmPhase == FM_PHASE_ERROR) {
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.drawTextCentered(display.width() / 2, oy, "File Manager Error");
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
oy += 14;
|
||||
if (_fmError) {
|
||||
display.setCursor(bx + 4, oy);
|
||||
display.print(_fmError);
|
||||
}
|
||||
}
|
||||
|
||||
display.setTextSize(1);
|
||||
}
|
||||
#endif
|
||||
|
||||
// === Footer ===
|
||||
@@ -1712,7 +2291,12 @@ public:
|
||||
if (_editMode == EDIT_NONE) {
|
||||
if (_subScreen != SUB_NONE) {
|
||||
display.print("Boot:Back");
|
||||
const char* r = (_subScreen == SUB_CHANNELS) ? "Tap:Select Hold:Del" : "Tap:Toggle Hold:Edit";
|
||||
const char* r;
|
||||
if (_subScreen == SUB_CHANNELS) r = "Tap:Select Hold:Del";
|
||||
#ifdef MECK_OTA_UPDATE
|
||||
else if (_subScreen == SUB_OTA_TOOLS) r = "Tap:Select";
|
||||
#endif
|
||||
else r = "Tap:Toggle Hold:Edit";
|
||||
display.setCursor(display.width() - display.getTextWidth(r) - 2, footerY);
|
||||
display.print(r);
|
||||
} else {
|
||||
@@ -1761,6 +2345,19 @@ public:
|
||||
} else {
|
||||
display.print("Please wait...");
|
||||
}
|
||||
} else if (_editMode == EDIT_FILEMGR) {
|
||||
if (_fmPhase == FM_PHASE_CONFIRM) {
|
||||
display.print("Boot:Cancel");
|
||||
const char* r = "Tap:Start";
|
||||
display.setCursor(display.width() - display.getTextWidth(r) - 2, footerY);
|
||||
display.print(r);
|
||||
} else if (_fmPhase == FM_PHASE_WAITING) {
|
||||
display.print("Boot:Stop");
|
||||
} else if (_fmPhase == FM_PHASE_ERROR) {
|
||||
display.print("Boot:Back");
|
||||
} else {
|
||||
display.print("Please wait...");
|
||||
}
|
||||
#endif
|
||||
} else if (_editMode == EDIT_TEXT) {
|
||||
display.print("Hold:Type");
|
||||
@@ -1798,6 +2395,16 @@ public:
|
||||
} else {
|
||||
display.print("Please wait...");
|
||||
}
|
||||
} else if (_editMode == EDIT_FILEMGR) {
|
||||
if (_fmPhase == FM_PHASE_CONFIRM) {
|
||||
display.print("Enter:Start Q:Cancel");
|
||||
} else if (_fmPhase == FM_PHASE_WAITING) {
|
||||
display.print("Q:Stop");
|
||||
} else if (_fmPhase == FM_PHASE_ERROR) {
|
||||
display.print("Q:Back");
|
||||
} else {
|
||||
display.print("Please wait...");
|
||||
}
|
||||
#endif
|
||||
} else if (_editMode == EDIT_PICKER) {
|
||||
display.print("A/D:Choose Enter:Ok");
|
||||
@@ -1818,9 +2425,10 @@ public:
|
||||
#endif
|
||||
|
||||
#ifdef MECK_OTA_UPDATE
|
||||
// Poll web server frequently during OTA waiting/receiving phases
|
||||
if (_editMode == EDIT_OTA &&
|
||||
(_otaPhase == OTA_PHASE_WAITING || _otaPhase == OTA_PHASE_RECEIVING)) {
|
||||
// Poll web server frequently during OTA waiting/receiving or file manager phases
|
||||
if ((_editMode == EDIT_OTA &&
|
||||
(_otaPhase == OTA_PHASE_WAITING || _otaPhase == OTA_PHASE_RECEIVING)) ||
|
||||
(_editMode == EDIT_FILEMGR && _fmPhase == FM_PHASE_WAITING)) {
|
||||
return 200; // 200ms — fast enough for web server responsiveness
|
||||
}
|
||||
#endif
|
||||
@@ -1887,6 +2495,32 @@ public:
|
||||
// Consume all keys during OTA
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- File Manager flow ---
|
||||
if (_editMode == EDIT_FILEMGR) {
|
||||
if (_fmPhase == FM_PHASE_CONFIRM) {
|
||||
if (c == '\r' || c == 13) {
|
||||
startFileMgrServer();
|
||||
return true;
|
||||
}
|
||||
if (c == 'q' || c == 'Q') {
|
||||
_editMode = EDIT_NONE;
|
||||
return true;
|
||||
}
|
||||
} else if (_fmPhase == FM_PHASE_WAITING) {
|
||||
if (c == 'q' || c == 'Q') {
|
||||
stopFileMgr();
|
||||
return true;
|
||||
}
|
||||
} else if (_fmPhase == FM_PHASE_ERROR) {
|
||||
if (c == 'q' || c == 'Q') {
|
||||
stopFileMgr();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Consume all keys during file manager
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef MECK_WIFI_COMPANION
|
||||
@@ -2311,6 +2945,12 @@ public:
|
||||
Serial.printf("Settings: Dark mode = %s\n",
|
||||
_prefs->dark_mode ? "ON" : "OFF");
|
||||
break;
|
||||
case ROW_LARGE_FONT:
|
||||
_prefs->large_font = _prefs->large_font ? 0 : 1;
|
||||
the_mesh.savePrefs();
|
||||
Serial.printf("Settings: Font size = %s\n",
|
||||
_prefs->large_font ? "LARGER" : "TINY");
|
||||
break;
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
case ROW_PORTRAIT_MODE:
|
||||
_prefs->portrait_mode = _prefs->portrait_mode ? 0 : 1;
|
||||
@@ -2453,9 +3093,20 @@ public:
|
||||
startEditText("");
|
||||
break;
|
||||
#ifdef MECK_OTA_UPDATE
|
||||
case ROW_OTA_TOOLS_SUBMENU:
|
||||
_savedTopCursor = _cursor;
|
||||
_subScreen = SUB_OTA_TOOLS;
|
||||
_cursor = 0;
|
||||
_scrollTop = 0;
|
||||
rebuildRows();
|
||||
Serial.println("Settings: entered OTA Tools sub-screen");
|
||||
break;
|
||||
case ROW_FW_UPDATE:
|
||||
startOTA();
|
||||
break;
|
||||
case ROW_SD_FILE_MGR:
|
||||
startFileMgr();
|
||||
break;
|
||||
#endif
|
||||
case ROW_CHANNEL:
|
||||
case ROW_PUB_KEY:
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <vector>
|
||||
#include "Utf8CP437.h"
|
||||
#include "EpubProcessor.h"
|
||||
#include "../NodePrefs.h"
|
||||
|
||||
// Forward declarations
|
||||
class UITask;
|
||||
@@ -327,12 +328,13 @@ inline int indexPagesWordWrap(File& file, long startPos,
|
||||
inline int indexPagesWordWrapPixel(File& file, long startPos,
|
||||
std::vector<long>& pagePositions,
|
||||
int linesPerPage, int maxChars,
|
||||
DisplayDriver* display, int maxPages) {
|
||||
DisplayDriver* display, int maxPages,
|
||||
NodePrefs* prefs = nullptr) {
|
||||
const int BUF_SIZE = READER_BUF_SIZE; // Match page buffer to avoid chunk boundary wrap mismatches
|
||||
char buffer[BUF_SIZE];
|
||||
|
||||
// Ensure body font is active for pixel measurement
|
||||
display->setTextSize(0);
|
||||
display->setTextSize(prefs ? prefs->smallTextSize() : 0);
|
||||
|
||||
file.seek(startPos);
|
||||
int pagesAdded = 0;
|
||||
@@ -396,9 +398,11 @@ public:
|
||||
|
||||
private:
|
||||
UITask* _task;
|
||||
NodePrefs* _prefs;
|
||||
Mode _mode;
|
||||
bool _sdReady;
|
||||
bool _initialized; // Layout metrics calculated
|
||||
uint8_t _lastFontPref; // Font preference at last layout init (detect changes)
|
||||
bool _bootIndexed; // Boot-time pre-indexing done
|
||||
DisplayDriver* _display; // Stored reference for splash screens
|
||||
|
||||
@@ -1084,8 +1088,8 @@ private:
|
||||
display.setCursor(0, 42);
|
||||
display.print("/books/ on SD card");
|
||||
} else {
|
||||
display.setTextSize(0); // Tiny font for file list
|
||||
int listLineH = 8; // Approximate tiny font line height in virtual coords
|
||||
display.setTextSize(_prefs->smallTextSize()); // Tiny font for file list
|
||||
int listLineH = _prefs->smallLineH();
|
||||
int startY = 14;
|
||||
int maxVisible = (display.height() - startY - _footerHeight) / listLineH;
|
||||
if (maxVisible < 3) maxVisible = 3;
|
||||
@@ -1106,7 +1110,7 @@ private:
|
||||
#else
|
||||
// setCursor adds +5 to y internally, but fillRect does not.
|
||||
// Offset fillRect by +5 to align highlight bar with text.
|
||||
display.fillRect(0, y + 5, display.width(), listLineH);
|
||||
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width(), listLineH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
@@ -1114,8 +1118,6 @@ private:
|
||||
}
|
||||
|
||||
// Set cursor AFTER fillRect so text draws on top of highlight
|
||||
display.setCursor(0, y);
|
||||
|
||||
int type = itemTypeAt(i);
|
||||
String line = selected ? "> " : " ";
|
||||
|
||||
@@ -1125,10 +1127,6 @@ private:
|
||||
} else if (type == 1) {
|
||||
// Subdirectory
|
||||
line += "/" + dirNameAt(i);
|
||||
// Truncate if needed
|
||||
if ((int)line.length() > _charsPerLine) {
|
||||
line = line.substring(0, _charsPerLine - 3) + "...";
|
||||
}
|
||||
} else {
|
||||
// File
|
||||
int fi = fileIndexAt(i);
|
||||
@@ -1141,16 +1139,11 @@ private:
|
||||
suffix = " *";
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate if needed
|
||||
int maxLen = _charsPerLine - 4 - suffix.length();
|
||||
if ((int)name.length() > maxLen) {
|
||||
name = name.substring(0, maxLen - 3) + "...";
|
||||
}
|
||||
line += name + suffix;
|
||||
}
|
||||
|
||||
display.print(line.c_str());
|
||||
// Pixel-aware ellipsis — small margin prevents GxEPD edge wrapping
|
||||
display.drawTextEllipsized(0, y, display.width() - 4, line.c_str());
|
||||
y += listLineH;
|
||||
}
|
||||
display.setTextSize(1); // Restore
|
||||
@@ -1163,7 +1156,7 @@ private:
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.drawTextCentered(display.width() / 2, footerY, "Swipe: Scroll Tap: Open Boot: home");
|
||||
#else
|
||||
display.setCursor(0, footerY);
|
||||
@@ -1177,7 +1170,7 @@ private:
|
||||
|
||||
void renderPage(DisplayDriver& display) {
|
||||
// Use tiny font for maximum text density
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
|
||||
int y = 0;
|
||||
@@ -1270,7 +1263,7 @@ private:
|
||||
}
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setCursor(0, footerY);
|
||||
display.print(status);
|
||||
const char* right = "Swipe:Page Tap:GoTo Hold:Close";
|
||||
@@ -1287,8 +1280,8 @@ private:
|
||||
}
|
||||
|
||||
public:
|
||||
TextReaderScreen(UITask* task)
|
||||
: _task(task), _mode(FILE_LIST), _sdReady(false), _initialized(false),
|
||||
TextReaderScreen(UITask* task, NodePrefs* prefs = nullptr)
|
||||
: _task(task), _prefs(prefs), _mode(FILE_LIST), _sdReady(false), _initialized(false), _lastFontPref(0),
|
||||
_bootIndexed(false), _display(nullptr),
|
||||
_charsPerLine(38), _linesPerPage(22), _lineHeight(5),
|
||||
_textAreaHeight(100), _headerHeight(14), _footerHeight(14),
|
||||
@@ -1313,16 +1306,24 @@ public:
|
||||
|
||||
// Call once after display is available to calculate layout metrics
|
||||
void initLayout(DisplayDriver& display) {
|
||||
// Re-init if font preference changed since last layout
|
||||
uint8_t curFont = _prefs ? _prefs->large_font : 0;
|
||||
if (_initialized && curFont != _lastFontPref) {
|
||||
_initialized = false;
|
||||
Serial.println("TextReader: font changed, recalculating layout");
|
||||
}
|
||||
if (_initialized) return;
|
||||
_lastFontPref = curFont;
|
||||
|
||||
// Store display reference for splash screens during openBook
|
||||
_display = &display;
|
||||
|
||||
// Measure tiny font metrics using the display driver
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
|
||||
// Measure character width: use 10 M's for monospace (T-Deck Pro).
|
||||
// T5S3 overrides this below with average-width measurement.
|
||||
// Measure character width: use 10 M's for monospace (T-Deck Pro tiny font).
|
||||
// Proportional fonts (T5S3 and T-Deck Pro large_font) override below with
|
||||
// average-width measurement since M is the widest glyph (~40% wider than average).
|
||||
uint16_t tenCharsW = display.getTextWidth("MMMMMMMMMM");
|
||||
if (tenCharsW > 0) {
|
||||
_charsPerLine = (display.width() * 10) / tenCharsW;
|
||||
@@ -1343,6 +1344,15 @@ public:
|
||||
if (_charsPerLine < 15) _charsPerLine = 15;
|
||||
if (_charsPerLine > 80) _charsPerLine = 80;
|
||||
#else
|
||||
// T-Deck Pro: large_font uses FreeSans9pt (proportional) — same fix
|
||||
if (_prefs && _prefs->large_font) {
|
||||
const char* sample = "the quick brown fox jumps over lazy dog";
|
||||
uint16_t sampleW = display.getTextWidth(sample);
|
||||
int sampleLen = strlen(sample);
|
||||
if (sampleW > 0 && sampleLen > 0) {
|
||||
_charsPerLine = (display.width() * sampleLen * 70) / ((int)sampleW * 100);
|
||||
}
|
||||
}
|
||||
if (_charsPerLine < 15) _charsPerLine = 15;
|
||||
if (_charsPerLine > 60) _charsPerLine = 60;
|
||||
#endif
|
||||
@@ -1362,13 +1372,17 @@ public:
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
// T5S3 uses FreeSans12pt/FreeSerif12pt for size 0 (yAdvance=29px).
|
||||
// Line height in virtual coords depends on orientation:
|
||||
// Landscape: 29px / scale_y(4.22) ≈ 7 + 1 spacing = 8
|
||||
// Portrait: 29px / scale_y(7.50) ≈ 4 + 1 spacing = 5
|
||||
{
|
||||
extern DISPLAY_CLASS display;
|
||||
_lineHeight = display.isPortraitMode() ? 5 : 8;
|
||||
}
|
||||
#else
|
||||
// T-Deck Pro large_font uses FreeSans9pt (yAdvance=22px at scale 1.5625×).
|
||||
// The 6x8 formula above gives ~5-7 which is way too small — lines overlap.
|
||||
// Use smallLineH() which is already tuned for this font.
|
||||
if (_prefs && _prefs->large_font) {
|
||||
_lineHeight = _prefs->smallLineH();
|
||||
}
|
||||
#endif
|
||||
|
||||
_headerHeight = 0; // No header in reading mode (maximize text area)
|
||||
@@ -1389,6 +1403,107 @@ public:
|
||||
// Called from setup() after SD card init. Scans files, pre-indexes first
|
||||
// 100 pages of each, and shows progress on the e-ink display.
|
||||
|
||||
// Pre-index files inside one level of subdirectories so navigating
|
||||
// into them later is instant (idx files already on SD).
|
||||
void bootIndexSubfolders() {
|
||||
// Work from the root-level _dirList that scanFiles() already populated.
|
||||
// Copy it -- scanFiles() will overwrite _dirList when we scan each subfolder.
|
||||
std::vector<String> subDirs = _dirList;
|
||||
if (subDirs.empty()) return;
|
||||
|
||||
Serial.printf("TextReader: Pre-indexing %d subfolders\n", (int)subDirs.size());
|
||||
|
||||
int totalSubFiles = 0;
|
||||
int cachedSubFiles = 0;
|
||||
int indexedSubFiles = 0;
|
||||
|
||||
for (int d = 0; d < (int)subDirs.size(); d++) {
|
||||
String subPath = String(BOOKS_FOLDER) + "/" + subDirs[d];
|
||||
_currentPath = subPath;
|
||||
scanFiles(); // populates _fileList for this subfolder
|
||||
|
||||
// Also pick up previously converted EPUB cache files for this subfolder
|
||||
String epubCachePath = subPath + "/.epub_cache";
|
||||
if (SD.exists(epubCachePath.c_str())) {
|
||||
File cacheDir = SD.open(epubCachePath.c_str());
|
||||
if (cacheDir && cacheDir.isDirectory()) {
|
||||
File cf = cacheDir.openNextFile();
|
||||
while (cf && _fileList.size() < READER_MAX_FILES) {
|
||||
if (!cf.isDirectory()) {
|
||||
String cname = String(cf.name());
|
||||
int cslash = cname.lastIndexOf('/');
|
||||
if (cslash >= 0) cname = cname.substring(cslash + 1);
|
||||
if (cname.endsWith(".txt") || cname.endsWith(".TXT")) {
|
||||
bool dup = false;
|
||||
for (int k = 0; k < (int)_fileList.size(); k++) {
|
||||
if (_fileList[k] == cname) { dup = true; break; }
|
||||
}
|
||||
if (!dup) _fileList.push_back(cname);
|
||||
}
|
||||
}
|
||||
cf = cacheDir.openNextFile();
|
||||
}
|
||||
cacheDir.close();
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < (int)_fileList.size(); i++) {
|
||||
totalSubFiles++;
|
||||
|
||||
// Try loading existing .idx cache -- if hit, skip
|
||||
FileCache tempCache;
|
||||
if (loadIndex(_fileList[i], tempCache)) {
|
||||
cachedSubFiles++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip .epub files (converted on first open)
|
||||
if (_fileList[i].endsWith(".epub") || _fileList[i].endsWith(".EPUB")) continue;
|
||||
|
||||
// Index this .txt file
|
||||
String fullPath = _currentPath + "/" + _fileList[i];
|
||||
File file = SD.open(fullPath.c_str(), FILE_READ);
|
||||
if (!file) {
|
||||
// Try epub cache fallback
|
||||
String cacheFallback = epubCachePath + "/" + _fileList[i];
|
||||
file = SD.open(cacheFallback.c_str(), FILE_READ);
|
||||
}
|
||||
if (!file) continue;
|
||||
|
||||
indexedSubFiles++;
|
||||
String displayName = subDirs[d] + "/" + _fileList[i];
|
||||
drawBootSplash(indexedSubFiles, 0, displayName);
|
||||
|
||||
FileCache cache;
|
||||
cache.filename = _fileList[i];
|
||||
cache.fileSize = file.size();
|
||||
cache.fullyIndexed = false;
|
||||
cache.lastReadPage = 0;
|
||||
cache.pagePositions.clear();
|
||||
cache.pagePositions.push_back(0);
|
||||
|
||||
indexPagesWordWrap(file, 0, cache.pagePositions,
|
||||
_linesPerPage, _charsPerLine,
|
||||
PREINDEX_PAGES - 1,
|
||||
_textAreaHeight, _lineHeight);
|
||||
cache.fullyIndexed = !file.available();
|
||||
file.close();
|
||||
|
||||
saveIndex(cache.filename, cache.pagePositions, cache.fileSize,
|
||||
cache.fullyIndexed, 0);
|
||||
|
||||
Serial.printf("TextReader: %s/%s - indexed %d pages%s\n",
|
||||
subDirs[d].c_str(), _fileList[i].c_str(),
|
||||
(int)cache.pagePositions.size(),
|
||||
cache.fullyIndexed ? " (complete)" : "");
|
||||
yield(); // Feed WDT between files
|
||||
}
|
||||
}
|
||||
|
||||
Serial.printf("TextReader: Subfolder pre-index: %d files (%d cached, %d newly indexed)\n",
|
||||
totalSubFiles, cachedSubFiles, indexedSubFiles);
|
||||
}
|
||||
|
||||
void bootIndex(DisplayDriver& display) {
|
||||
if (!_sdReady) return;
|
||||
|
||||
@@ -1430,20 +1545,24 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
if (_fileList.size() == 0) {
|
||||
Serial.println("TextReader: No files to index");
|
||||
if (_fileList.size() == 0 && _dirList.size() == 0) {
|
||||
Serial.println("TextReader: No files or folders to index");
|
||||
_bootIndexed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
int cachedCount = 0;
|
||||
int needsIndexCount = 0;
|
||||
|
||||
// --- Pass 1 & 2: Index root-level files ---
|
||||
if (_fileList.size() > 0) {
|
||||
|
||||
// --- Pass 1: Fast cache load (no per-file splash screens) ---
|
||||
// Try to load existing .idx files from SD for every file.
|
||||
// This is just SD reads — no indexing, no e-ink refreshes.
|
||||
_fileCache.clear();
|
||||
_fileCache.resize(_fileList.size()); // Pre-allocate slots to maintain alignment with _fileList
|
||||
|
||||
int cachedCount = 0;
|
||||
int needsIndexCount = 0;
|
||||
|
||||
for (int i = 0; i < (int)_fileList.size(); i++) {
|
||||
if (loadIndex(_fileList[i], _fileCache[i])) {
|
||||
@@ -1509,6 +1628,26 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
} // end if (_fileList.size() > 0)
|
||||
|
||||
// --- Pass 3: Pre-index files inside subfolders (one level deep) ---
|
||||
// Save root state -- bootIndexSubfolders() will overwrite _fileList/_dirList
|
||||
// via scanFiles() as it iterates each subdirectory.
|
||||
if (_dirList.size() > 0) {
|
||||
std::vector<String> savedFileList = _fileList;
|
||||
std::vector<String> savedDirList = _dirList;
|
||||
std::vector<FileCache> savedFileCache = _fileCache;
|
||||
|
||||
bootIndexSubfolders();
|
||||
|
||||
// Restore root state
|
||||
_currentPath = String(BOOKS_FOLDER);
|
||||
_fileList = savedFileList;
|
||||
_dirList = savedDirList;
|
||||
_fileCache = savedFileCache;
|
||||
}
|
||||
|
||||
|
||||
// Deselect SD to free SPI bus
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
|
||||
@@ -1574,11 +1713,12 @@ public:
|
||||
// Returns: 0=miss, 1=moved, 2=tapped current row.
|
||||
int selectRowAtVY(int vy) {
|
||||
if (_mode != FILE_LIST) return 0;
|
||||
const int startY = 14, footerH = 14, listLineH = 8;
|
||||
const int startY = 14, footerH = 14;
|
||||
const int listLineH = _prefs ? _prefs->smallLineH() : 9;
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
const int bodyTop = startY;
|
||||
#else
|
||||
const int bodyTop = startY + 5; // GxEPD baseline offset
|
||||
const int bodyTop = startY + (_prefs ? _prefs->smallHighlightOff() : 5);
|
||||
#endif
|
||||
if (vy < bodyTop || vy >= 128 - footerH) return 0;
|
||||
|
||||
|
||||
@@ -12,12 +12,15 @@
|
||||
#include "MapScreen.h"
|
||||
#endif
|
||||
#include "target.h"
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(MECK_AUDIO_VARIANT)
|
||||
#include "HomeIcons.h"
|
||||
#endif
|
||||
#if defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
|
||||
#include <WiFi.h>
|
||||
#endif
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) && !defined(BLE_PIN_CODE) && !defined(MECK_WIFI_COMPANION)
|
||||
#include "esp_sleep.h"
|
||||
#endif
|
||||
|
||||
#ifndef AUTO_OFF_MILLIS
|
||||
#define AUTO_OFF_MILLIS 15000 // 15 seconds
|
||||
@@ -156,7 +159,7 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts,
|
||||
}
|
||||
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_node_prefs->smallTextSize());
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
// T5S3: text-only battery indicator — "Batt 99% 4.1v"
|
||||
@@ -170,7 +173,7 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts,
|
||||
display.print(battStr);
|
||||
display.setTextSize(1); // restore default text size
|
||||
#else
|
||||
// T-Deck Pro: icon + percentage text
|
||||
// T-Deck Pro: icon + percentage text (icon hidden in large font)
|
||||
int iconWidth = 16;
|
||||
int iconHeight = 6;
|
||||
int iconY = 0;
|
||||
@@ -181,26 +184,35 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts,
|
||||
sprintf(pctStr, "%d%%", batteryPercentage);
|
||||
uint16_t textWidth = display.getTextWidth(pctStr);
|
||||
|
||||
// layout: [icon][cap 2px][gap 2px][text][margin 2px]
|
||||
int totalWidth = iconWidth + 2 + 2 + textWidth + 2;
|
||||
int iconX = display.width() - totalWidth;
|
||||
if (_node_prefs->large_font) {
|
||||
// Large font: text only — no room for icon in header
|
||||
int textX = display.width() - textWidth - 2;
|
||||
if (outIconX) *outIconX = textX;
|
||||
display.setCursor(textX, textY);
|
||||
display.print(pctStr);
|
||||
} else {
|
||||
// Tiny font: icon + text
|
||||
// layout: [icon][cap 2px][gap 2px][text][margin 2px]
|
||||
int totalWidth = iconWidth + 2 + 2 + textWidth + 2;
|
||||
int iconX = display.width() - totalWidth;
|
||||
|
||||
if (outIconX) *outIconX = iconX;
|
||||
if (outIconX) *outIconX = iconX;
|
||||
|
||||
// battery outline
|
||||
display.drawRect(iconX, iconY, iconWidth, iconHeight);
|
||||
// battery outline
|
||||
display.drawRect(iconX, iconY, iconWidth, iconHeight);
|
||||
|
||||
// battery "cap"
|
||||
display.fillRect(iconX + iconWidth, iconY + (iconHeight / 4), 2, iconHeight / 2);
|
||||
// battery "cap"
|
||||
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);
|
||||
// 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
|
||||
int textX = iconX + iconWidth + 2 + 2; // after cap + gap
|
||||
display.setCursor(textX, textY);
|
||||
display.print(pctStr);
|
||||
// draw percentage text after the battery cap
|
||||
int textX = iconX + iconWidth + 2 + 2; // after cap + gap
|
||||
display.setCursor(textX, textY);
|
||||
display.print(pctStr);
|
||||
}
|
||||
display.setTextSize(1); // restore default text size
|
||||
#endif
|
||||
}
|
||||
@@ -215,12 +227,31 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts,
|
||||
if (!_task->isAudioPlayingInBackground()) return;
|
||||
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(0); // tiny font (same as clock & battery %)
|
||||
display.setTextSize(_node_prefs->smallTextSize()); // tiny font (same as clock & battery %)
|
||||
int x = batteryLeftX - display.getTextWidth(">>") - 2;
|
||||
display.setCursor(x, -3); // align vertically with battery text
|
||||
display.print(">>");
|
||||
display.setTextSize(1); // restore
|
||||
}
|
||||
|
||||
// ---- Alarm enabled indicator ----
|
||||
// Shows a small bell icon to the left of the audio indicator
|
||||
// (or battery icon if no audio playing) when any alarm is enabled.
|
||||
void renderAlarmIndicator(DisplayDriver& display, int batteryLeftX) {
|
||||
AlarmScreen* alarmScr = (AlarmScreen*)_task->getAlarmScreen();
|
||||
if (!alarmScr || alarmScr->enabledCount() == 0) return;
|
||||
|
||||
// Calculate X: shift left past audio indicator if it's showing
|
||||
int rightEdge = batteryLeftX;
|
||||
if (_task->isAudioPlayingInBackground()) {
|
||||
display.setTextSize(_node_prefs->smallTextSize());
|
||||
rightEdge = rightEdge - display.getTextWidth(">>") - 2;
|
||||
}
|
||||
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
int x = rightEdge - BELL_ICON_W - 2;
|
||||
display.drawXbm(x, 1, icon_bell_small, BELL_ICON_W, BELL_ICON_H);
|
||||
}
|
||||
#endif
|
||||
|
||||
CayenneLPP sensors_lpp;
|
||||
@@ -276,7 +307,7 @@ public:
|
||||
_task->setHomeShowingTiles(false); // Reset — only set true on FIRST page
|
||||
#endif
|
||||
// node name (tinyfont to avoid overlapping clock)
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_node_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
char filtered_name[sizeof(_node_prefs->node_name)];
|
||||
display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name));
|
||||
@@ -290,18 +321,21 @@ public:
|
||||
display.setCursor(0, HOME_HDR_Y);
|
||||
display.print(filtered_name);
|
||||
|
||||
// battery voltage
|
||||
// battery voltage + status icons
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
int battLeftX = display.width(); // default if battery doesn't render
|
||||
renderBatteryIndicator(display, _task->getBattMilliVolts(), &battLeftX);
|
||||
|
||||
// audio background playback indicator (>> icon next to battery)
|
||||
renderAudioIndicator(display, battLeftX);
|
||||
|
||||
// alarm enabled indicator (AL icon, left of audio or battery)
|
||||
renderAlarmIndicator(display, battLeftX);
|
||||
#else
|
||||
renderBatteryIndicator(display, _task->getBattMilliVolts());
|
||||
#endif
|
||||
|
||||
// centered clock (tinyfont) - only show when time is valid
|
||||
// centered clock — only show when time is valid
|
||||
{
|
||||
uint32_t now = _rtc->getCurrentTime();
|
||||
if (now > 1700000000) { // valid timestamp (after ~Nov 2023)
|
||||
@@ -315,11 +349,14 @@ public:
|
||||
char timeBuf[6];
|
||||
sprintf(timeBuf, "%02d:%02d", hrs, mins);
|
||||
|
||||
display.setTextSize(0); // tinyfont
|
||||
display.setTextSize(_node_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
uint16_t tw = display.getTextWidth(timeBuf);
|
||||
int clockX = (display.width() - tw) / 2;
|
||||
display.setCursor(clockX, HOME_HDR_Y); // align with node name Y
|
||||
// Ensure clock doesn't overlap the node name
|
||||
int nameRight = display.getTextWidth(filtered_name) + 4;
|
||||
if (clockX < nameRight) clockX = nameRight;
|
||||
display.setCursor(clockX, HOME_HDR_Y);
|
||||
display.print(timeBuf);
|
||||
display.setTextSize(1); // restore
|
||||
}
|
||||
@@ -362,17 +399,17 @@ public:
|
||||
IPAddress ip = WiFi.localIP();
|
||||
if (ip != IPAddress(0,0,0,0)) {
|
||||
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d:%d", ip[0], ip[1], ip[2], ip[3], TCP_PORT);
|
||||
display.setTextSize(0); // Tiny font for IP
|
||||
display.setTextSize(_node_prefs->smallTextSize()); // Tiny font for IP
|
||||
display.drawTextCentered(display.width() / 2, y, tmp);
|
||||
y += 8;
|
||||
y += _node_prefs->smallLineH() - 1;
|
||||
}
|
||||
#endif
|
||||
#if defined(BLE_PIN_CODE) || defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION)
|
||||
if (_task->hasConnection()) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(0); // Tiny font for Connected
|
||||
display.setTextSize(_node_prefs->smallTextSize()); // Tiny font for Connected
|
||||
display.drawTextCentered(display.width() / 2, y, "< Connected >");
|
||||
y += 8; // Reduced from 12
|
||||
y += _node_prefs->smallLineH() - 1;
|
||||
#ifdef BLE_PIN_CODE
|
||||
} else if (_task->isSerialEnabled() && the_mesh.getBLEPin() != 0) {
|
||||
display.setColor(DisplayDriver::RED);
|
||||
@@ -423,7 +460,7 @@ public:
|
||||
display.drawXbm(iconX, iconY, tiles[row][col].icon, HOME_ICON_W, HOME_ICON_H);
|
||||
|
||||
// Label centered below icon
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_node_prefs->smallTextSize());
|
||||
display.drawTextCentered(tx + tileW / 2, ty + 18, tiles[row][col].label);
|
||||
}
|
||||
}
|
||||
@@ -431,47 +468,99 @@ public:
|
||||
// Nav hint below grid
|
||||
y = gridY + 2 * tileH + gapY + 2;
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_node_prefs->smallTextSize());
|
||||
display.drawTextCentered(display.width() / 2, y, "Tap tile to open");
|
||||
}
|
||||
display.setTextSize(1);
|
||||
|
||||
#else
|
||||
// ----- T-Deck Pro: Keyboard shortcut text menu -----
|
||||
// Menu shortcuts - tinyfont monospaced grid
|
||||
y += 6;
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setTextSize(0); // tinyfont 6x8 monospaced
|
||||
display.drawTextCentered(display.width() / 2, y, "Press:");
|
||||
y += 12;
|
||||
display.drawTextCentered(display.width() / 2, y, "[M] Messages [C] Contacts ");
|
||||
y += 10;
|
||||
display.drawTextCentered(display.width() / 2, y, "[N] Notes [S] Settings ");
|
||||
y += 10;
|
||||
#if HAS_GPS
|
||||
display.drawTextCentered(display.width() / 2, y, "[E] Reader [G] Maps ");
|
||||
#else
|
||||
display.drawTextCentered(display.width() / 2, y, "[E] Reader ");
|
||||
#endif
|
||||
y += 10;
|
||||
#if defined(HAS_4G_MODEM) && defined(MECK_WEB_READER)
|
||||
display.drawTextCentered(display.width() / 2, y, "[T] Phone [B] Browser ");
|
||||
#elif defined(HAS_4G_MODEM)
|
||||
display.drawTextCentered(display.width() / 2, y, "[T] Phone ");
|
||||
#elif defined(MECK_AUDIO_VARIANT) && defined(MECK_WEB_READER)
|
||||
display.drawTextCentered(display.width() / 2, y, "[P] Audiobooks [B] Browser ");
|
||||
#elif defined(MECK_AUDIO_VARIANT)
|
||||
display.drawTextCentered(display.width() / 2, y, "[P] Audiobooks ");
|
||||
#elif defined(MECK_WEB_READER)
|
||||
display.drawTextCentered(display.width() / 2, y, "[B] Browser ");
|
||||
#else
|
||||
y -= 10; // reclaim the row for standalone
|
||||
#endif
|
||||
y += 14;
|
||||
display.setTextSize(_node_prefs->smallTextSize());
|
||||
int menuLH = _node_prefs->smallLineH();
|
||||
|
||||
// Nav hint
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.drawTextCentered(display.width() / 2, y, "Press A/D to cycle home views");
|
||||
if (_node_prefs->large_font) {
|
||||
// Proportional font: two-column layout with fixed X positions
|
||||
y += 2;
|
||||
int col1 = 2;
|
||||
int col2 = display.width() / 2;
|
||||
|
||||
display.setCursor(col1, y); display.print("[M] Messages");
|
||||
display.setCursor(col2, y); display.print("[C] Contacts");
|
||||
y += menuLH;
|
||||
display.setCursor(col1, y); display.print("[N] Notes");
|
||||
display.setCursor(col2, y); display.print("[S] Settings");
|
||||
y += menuLH;
|
||||
#if HAS_GPS
|
||||
display.setCursor(col1, y); display.print("[E] Reader");
|
||||
display.setCursor(col2, y); display.print("[G] Maps");
|
||||
#else
|
||||
display.setCursor(col1, y); display.print("[E] Reader");
|
||||
#endif
|
||||
y += menuLH;
|
||||
#if defined(HAS_4G_MODEM) && defined(MECK_WEB_READER)
|
||||
display.setCursor(col1, y); display.print("[T] Phone");
|
||||
display.setCursor(col2, y); display.print("[B] Browser");
|
||||
#elif defined(HAS_4G_MODEM)
|
||||
display.setCursor(col1, y); display.print("[T] Phone");
|
||||
display.setCursor(col2, y); display.print("[F] Discover");
|
||||
#elif defined(MECK_AUDIO_VARIANT)
|
||||
display.setCursor(col1, y); display.print("[P] Audio");
|
||||
display.setCursor(col2, y); display.print("[K] Alarm");
|
||||
y += menuLH;
|
||||
#ifdef MECK_WEB_READER
|
||||
display.setCursor(col1, y); display.print("[B] Browser");
|
||||
display.setCursor(col2, y); display.print("[F] Discover");
|
||||
#else
|
||||
display.setCursor(col1, y); display.print("[F] Discover");
|
||||
#endif
|
||||
#elif defined(MECK_WEB_READER)
|
||||
display.setCursor(col1, y); display.print("[B] Browser");
|
||||
#else
|
||||
display.setCursor(col1, y); display.print("[F] Discover");
|
||||
#endif
|
||||
y += menuLH + 2;
|
||||
} else {
|
||||
// Monospaced built-in font: centered space-padded strings
|
||||
y += 6;
|
||||
display.drawTextCentered(display.width() / 2, y, "Press:");
|
||||
y += 12;
|
||||
display.drawTextCentered(display.width() / 2, y, "[M] Messages [C] Contacts ");
|
||||
y += 10;
|
||||
display.drawTextCentered(display.width() / 2, y, "[N] Notes [S] Settings ");
|
||||
y += 10;
|
||||
#if HAS_GPS
|
||||
display.drawTextCentered(display.width() / 2, y, "[E] Reader [G] Maps ");
|
||||
#else
|
||||
display.drawTextCentered(display.width() / 2, y, "[E] Reader ");
|
||||
#endif
|
||||
y += 10;
|
||||
#if defined(HAS_4G_MODEM) && defined(MECK_WEB_READER)
|
||||
display.drawTextCentered(display.width() / 2, y, "[T] Phone [B] Browser ");
|
||||
#elif defined(HAS_4G_MODEM)
|
||||
display.drawTextCentered(display.width() / 2, y, "[T] Phone [F] Discover ");
|
||||
#elif defined(MECK_AUDIO_VARIANT)
|
||||
display.drawTextCentered(display.width() / 2, y, "[P] Audiobooks [K] Alarm ");
|
||||
y += 10;
|
||||
#ifdef MECK_WEB_READER
|
||||
display.drawTextCentered(display.width() / 2, y, "[B] Browser [F] Discover ");
|
||||
#else
|
||||
display.drawTextCentered(display.width() / 2, y, "[F] Discover ");
|
||||
#endif
|
||||
#elif defined(MECK_WEB_READER)
|
||||
display.drawTextCentered(display.width() / 2, y, "[B] Browser ");
|
||||
#else
|
||||
display.drawTextCentered(display.width() / 2, y, "[F] Discover ");
|
||||
#endif
|
||||
y += 14;
|
||||
}
|
||||
|
||||
// Nav hint (only if room)
|
||||
if (y < display.height() - 14) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.drawTextCentered(display.width() / 2, y,
|
||||
_node_prefs->large_font ? "A/D: cycle views" : "Press A/D to cycle home views");
|
||||
}
|
||||
display.setTextSize(1); // restore
|
||||
#endif
|
||||
} else if (_page == HomePage::RECENT) {
|
||||
@@ -501,7 +590,7 @@ public:
|
||||
}
|
||||
// Hint for full Last Heard screen
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_node_prefs->smallTextSize());
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.drawTextCentered(display.width() / 2, display.height() - 24,
|
||||
"Tap here for full Last Heard list");
|
||||
@@ -571,19 +660,20 @@ public:
|
||||
display.drawTextCentered(display.width() / 2, 18, "WiFi Companion");
|
||||
|
||||
int wy = 36;
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_node_prefs->smallTextSize());
|
||||
int wLH = _node_prefs->smallLineH() + 1;
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
snprintf(tmp, sizeof(tmp), "SSID: %s", WiFi.SSID().c_str());
|
||||
display.drawTextCentered(display.width() / 2, wy, tmp);
|
||||
wy += 10;
|
||||
wy += wLH;
|
||||
IPAddress ip = WiFi.localIP();
|
||||
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
|
||||
display.drawTextCentered(display.width() / 2, wy, tmp);
|
||||
wy += 10;
|
||||
wy += wLH;
|
||||
snprintf(tmp, sizeof(tmp), "Port: %d", TCP_PORT);
|
||||
display.drawTextCentered(display.width() / 2, wy, tmp);
|
||||
wy += 12;
|
||||
wy += wLH + 2;
|
||||
if (_task->hasConnection()) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(1);
|
||||
@@ -596,7 +686,7 @@ public:
|
||||
} else {
|
||||
display.setColor(DisplayDriver::RED);
|
||||
display.drawTextCentered(display.width() / 2, wy, "Not connected");
|
||||
wy += 12;
|
||||
wy += wLH + 2;
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawTextCentered(display.width() / 2, wy, "Configure in Settings");
|
||||
}
|
||||
@@ -697,7 +787,7 @@ public:
|
||||
display.drawTextCentered(display.width() / 2, by + 4, buf);
|
||||
|
||||
// Show controls hint
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_node_prefs->smallTextSize());
|
||||
display.drawTextCentered(display.width() / 2, by + bh - 10, "W/S:adj Enter:ok Q:cancel");
|
||||
display.setTextSize(1);
|
||||
}
|
||||
@@ -1107,12 +1197,10 @@ public:
|
||||
}
|
||||
|
||||
// ---- Unlock hint ----
|
||||
display.setTextSize(0);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.setTextSize(_node_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawTextCentered(display.width() / 2, 120, "Hold button to unlock");
|
||||
#else
|
||||
display.drawTextCentered(display.width() / 2, 120, "Dbl-press to unlock");
|
||||
#endif
|
||||
|
||||
return 30000;
|
||||
@@ -1198,8 +1286,8 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
|
||||
((ChannelScreen*)channel_screen)->setDMUnreadPtr(_dmUnread);
|
||||
contacts_screen = new ContactsScreen(this, &rtc_clock);
|
||||
((ContactsScreen*)contacts_screen)->setDMUnreadPtr(_dmUnread);
|
||||
text_reader = new TextReaderScreen(this);
|
||||
notes_screen = new NotesScreen(this);
|
||||
text_reader = new TextReaderScreen(this, node_prefs);
|
||||
notes_screen = new NotesScreen(this, node_prefs);
|
||||
settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs);
|
||||
repeater_admin = nullptr; // Lazy-initialized on first use to preserve heap for audio
|
||||
discovery_screen = new DiscoveryScreen(this, &rtc_clock);
|
||||
@@ -1208,8 +1296,11 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
|
||||
lock_screen = new LockScreen(this, &rtc_clock, node_prefs);
|
||||
#endif
|
||||
audiobook_screen = nullptr; // Created and assigned from main.cpp if audio hardware present
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
alarm_screen = nullptr; // Created and assigned from main.cpp if audio hardware present
|
||||
#endif
|
||||
#ifdef HAS_4G_MODEM
|
||||
sms_screen = new SMSScreen(this);
|
||||
sms_screen = new SMSScreen(this, node_prefs);
|
||||
#endif
|
||||
#if HAS_GPS
|
||||
map_screen = new MapScreen(this);
|
||||
@@ -1238,6 +1329,34 @@ void UITask::showAlert(const char* text, int duration_millis) {
|
||||
_next_refresh = millis() + 100; // trigger re-render to show updated text
|
||||
}
|
||||
|
||||
void UITask::showBootHint(bool immediate) {
|
||||
if (immediate) {
|
||||
// Activate now — used when hint should overlay the current screen (e.g. onboarding)
|
||||
_hintActive = true;
|
||||
_hintExpiry = millis() + 8000; // 8 seconds auto-dismiss
|
||||
_pendingBootHint = false;
|
||||
_next_refresh = millis() + 100;
|
||||
Serial.println("[UI] Boot hint activated (immediate)");
|
||||
} else {
|
||||
// Defer until after splash screen — actual activation happens in gotoHomeScreen()
|
||||
_pendingBootHint = true;
|
||||
Serial.println("[UI] Boot hint pending (will show after splash)");
|
||||
}
|
||||
}
|
||||
|
||||
void UITask::dismissBootHint() {
|
||||
if (!_hintActive) return;
|
||||
_hintActive = false;
|
||||
_hintExpiry = 0;
|
||||
// Persist so hint never shows again
|
||||
if (_node_prefs) {
|
||||
_node_prefs->hint_shown = 1;
|
||||
the_mesh.savePrefs();
|
||||
}
|
||||
_next_refresh = millis() + 100;
|
||||
Serial.println("[UI] Boot hint dismissed");
|
||||
}
|
||||
|
||||
void UITask::notify(UIEventType t) {
|
||||
#if defined(PIN_BUZZER)
|
||||
switch(t){
|
||||
@@ -1426,6 +1545,7 @@ void UITask::setCurrScreen(UIScreen* c) {
|
||||
curr = c;
|
||||
_alert_expiry = 0; // Dismiss any active toast — prevents stale overlay from
|
||||
// triggering extra 644ms e-ink refreshes on the new screen
|
||||
if (_hintActive) dismissBootHint(); // Dismiss hint when navigating away
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
@@ -1600,6 +1720,14 @@ void UITask::loop() {
|
||||
}
|
||||
#endif
|
||||
|
||||
if (c != 0 && curr) {
|
||||
// Dismiss boot hint on any button input (boot button on T5S3)
|
||||
if (_hintActive) {
|
||||
dismissBootHint();
|
||||
c = 0; // Consume the press
|
||||
}
|
||||
}
|
||||
|
||||
if (c != 0 && curr) {
|
||||
curr->handleInput(c);
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer
|
||||
@@ -1721,7 +1849,56 @@ if (curr) curr->poll();
|
||||
}
|
||||
#endif
|
||||
|
||||
if (millis() < _alert_expiry) {
|
||||
// Check if settings screen needs VKB for text editing (channel name, freq, APN)
|
||||
if (isOnSettingsScreen() && !_vkbActive) {
|
||||
SettingsScreen* ss = (SettingsScreen*)settings_screen;
|
||||
if (ss->needsTextVKB()) {
|
||||
ss->clearTextNeedsVKB();
|
||||
// Pick a context-appropriate label
|
||||
const char* label = "Edit";
|
||||
SettingsRowType rt = ss->getCurrentRowType();
|
||||
if (rt == ROW_NAME) label = "Node Name";
|
||||
else if (rt == ROW_ADD_CHANNEL) label = "Channel Name";
|
||||
else if (rt == ROW_FREQ) label = "Frequency";
|
||||
showVirtualKeyboard(VKB_SETTINGS_TEXT, label, ss->getEditBuf(), 31);
|
||||
}
|
||||
}
|
||||
|
||||
if (_hintActive && millis() < _hintExpiry) {
|
||||
// Boot navigation hint overlay — multi-line, larger box
|
||||
_display->setTextSize(1);
|
||||
int w = _display->width();
|
||||
int h = _display->height();
|
||||
int boxX = w / 8;
|
||||
int boxY = h / 5;
|
||||
int boxW = w - boxX * 2;
|
||||
int boxH = h * 3 / 5;
|
||||
_display->setColor(DisplayDriver::DARK);
|
||||
_display->fillRect(boxX, boxY, boxW, boxH);
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->drawRect(boxX, boxY, boxW, boxH);
|
||||
int cx = w / 2;
|
||||
int lineH = 11;
|
||||
int startY = boxY + 6;
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
_display->drawTextCentered(cx, startY, "Swipe: Navigate");
|
||||
_display->drawTextCentered(cx, startY + lineH, "Tap: Select");
|
||||
_display->drawTextCentered(cx, startY + lineH * 2, "Long Press: Action");
|
||||
_display->drawTextCentered(cx, startY + lineH * 3, "Boot Btn: Home");
|
||||
_display->drawTextCentered(cx, startY + lineH * 4 + 4, "[Tap to dismiss hint]");
|
||||
#else
|
||||
_display->drawTextCentered(cx, startY, "M:Msgs C:Contacts");
|
||||
_display->drawTextCentered(cx, startY + lineH, "S:Settings E:Reader");
|
||||
_display->drawTextCentered(cx, startY + lineH * 2, "N:Notes W/S:Scroll");
|
||||
_display->drawTextCentered(cx, startY + lineH * 3, "A/D:Cycle Left/Right");
|
||||
_display->drawTextCentered(cx, startY + lineH * 4 + 4, "[X to dismiss hint]");
|
||||
#endif
|
||||
_next_refresh = _hintExpiry;
|
||||
} else if (_hintActive) {
|
||||
// Hint expired — auto-dismiss
|
||||
dismissBootHint();
|
||||
_next_refresh = millis() + 200;
|
||||
} else if (millis() < _alert_expiry) {
|
||||
_display->setTextSize(1);
|
||||
int y = _display->height() / 3;
|
||||
int p = _display->height() / 32;
|
||||
@@ -1737,7 +1914,33 @@ if (curr) curr->poll();
|
||||
}
|
||||
#else
|
||||
int delay_millis = curr->render(*_display);
|
||||
if (millis() < _alert_expiry) { // render alert popup
|
||||
if (_hintActive && millis() < _hintExpiry) {
|
||||
// Boot navigation hint overlay — multi-line, larger box
|
||||
_display->setTextSize(1);
|
||||
int w = _display->width();
|
||||
int h = _display->height();
|
||||
int boxX = w / 8;
|
||||
int boxY = h / 5;
|
||||
int boxW = w - boxX * 2;
|
||||
int boxH = h * 3 / 5;
|
||||
_display->setColor(DisplayDriver::DARK);
|
||||
_display->fillRect(boxX, boxY, boxW, boxH);
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->drawRect(boxX, boxY, boxW, boxH);
|
||||
int cx = w / 2;
|
||||
int lineH = 11;
|
||||
int startY = boxY + 6;
|
||||
_display->drawTextCentered(cx, startY, "M:Msgs C:Contacts");
|
||||
_display->drawTextCentered(cx, startY + lineH, "S:Settings E:Reader");
|
||||
_display->drawTextCentered(cx, startY + lineH * 2, "N:Notes W/S:Scroll");
|
||||
_display->drawTextCentered(cx, startY + lineH * 3, "A/D:Cycle Left/Right");
|
||||
_display->drawTextCentered(cx, startY + lineH * 4 + 4, "[X to dismiss]");
|
||||
_next_refresh = _hintExpiry;
|
||||
} else if (_hintActive) {
|
||||
// Hint expired — auto-dismiss
|
||||
dismissBootHint();
|
||||
_next_refresh = millis() + 200;
|
||||
} else if (millis() < _alert_expiry) { // render alert popup
|
||||
_display->setTextSize(1);
|
||||
int y = _display->height() / 3;
|
||||
int p = _display->height() / 32;
|
||||
@@ -1796,6 +1999,42 @@ if (curr) curr->poll();
|
||||
}
|
||||
#endif
|
||||
|
||||
// ── T5S3 standalone powersaving ──────────────────────────────────────────
|
||||
// When locked with display off, enter ESP32 light sleep (~8 mA total).
|
||||
// Radio stays in continuous RX — DIO1 going HIGH wakes the CPU instantly.
|
||||
// Boot button (GPIO0 LOW) and a 30-min safety timer also wake.
|
||||
// First sleep starts 60s after lock; subsequent cycles wake for 5s to let
|
||||
// the mesh stack process/relay any received packet, then sleep again.
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) && !defined(BLE_PIN_CODE) && !defined(MECK_WIFI_COMPANION)
|
||||
if (_locked && _display != NULL && !_display->isOn()) {
|
||||
unsigned long now = millis();
|
||||
if (now - _psLastActive >= _psNextSleepSecs * 1000UL) {
|
||||
Serial.println("[POWERSAVE] Entering light sleep (locked+idle)");
|
||||
board.sleep(1800); // Light sleep up to 30 min
|
||||
// ── CPU resumes here on wake ──
|
||||
unsigned long wakeAt = millis();
|
||||
_psLastActive = wakeAt;
|
||||
_psNextSleepSecs = 5; // Stay awake 5s for mesh processing
|
||||
|
||||
esp_sleep_wakeup_cause_t cause = esp_sleep_get_wakeup_cause();
|
||||
if (cause == ESP_SLEEP_WAKEUP_GPIO) {
|
||||
// Boot button pressed — unlock and return to normal use
|
||||
Serial.println("[POWERSAVE] Woke by button — unlocking");
|
||||
unlockScreen();
|
||||
_psNextSleepSecs = 60; // Reset to long delay after user interaction
|
||||
} else if (cause == ESP_SLEEP_WAKEUP_EXT1) {
|
||||
Serial.println("[POWERSAVE] Woke by LoRa packet");
|
||||
} else if (cause == ESP_SLEEP_WAKEUP_TIMER) {
|
||||
Serial.println("[POWERSAVE] Woke by timer");
|
||||
}
|
||||
}
|
||||
} else if (!_locked) {
|
||||
// Not locked — keep powersaving timer reset so first sleep is 60s after lock
|
||||
_psLastActive = millis();
|
||||
_psNextSleepSecs = 60;
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef PIN_VIBRATION
|
||||
vibration.loop();
|
||||
#endif
|
||||
@@ -1922,6 +2161,10 @@ void UITask::lockScreen() {
|
||||
_next_refresh = 0; // Draw lock screen immediately
|
||||
_auto_off = millis() + 60000; // 60s before display off while locked
|
||||
_lastLockRefresh = millis(); // Start 15-min clock refresh cycle
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) && !defined(BLE_PIN_CODE) && !defined(MECK_WIFI_COMPANION)
|
||||
_psLastActive = millis(); // Start powersaving countdown (60s to first sleep)
|
||||
_psNextSleepSecs = 60;
|
||||
#endif
|
||||
Serial.println("[UI] Screen locked — entering low-power mode");
|
||||
}
|
||||
|
||||
@@ -2044,6 +2287,19 @@ void UITask::onVKBSubmit() {
|
||||
if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB);
|
||||
break;
|
||||
}
|
||||
case VKB_SETTINGS_TEXT: {
|
||||
// Generic settings text edit — copy text back to settings edit buffer
|
||||
// and confirm via the normal Enter path (handles name/freq/channel/APN)
|
||||
SettingsScreen* ss = (SettingsScreen*)settings_screen;
|
||||
if (strlen(text) > 0) {
|
||||
ss->submitEditText(text);
|
||||
} else {
|
||||
// Empty submission — cancel the edit
|
||||
ss->handleInput('q');
|
||||
}
|
||||
if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB);
|
||||
break;
|
||||
}
|
||||
case VKB_NOTES: {
|
||||
NotesScreen* notes = (NotesScreen*)getNotesScreen();
|
||||
if (notes && strlen(text) > 0) {
|
||||
@@ -2248,6 +2504,15 @@ void UITask::gotoHomeScreen() {
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
|
||||
// Activate deferred boot hint now that home screen is visible
|
||||
if (_pendingBootHint) {
|
||||
_pendingBootHint = false;
|
||||
_hintActive = true;
|
||||
_hintExpiry = millis() + 8000; // 8 seconds auto-dismiss
|
||||
_next_refresh = millis() + 100;
|
||||
Serial.println("[UI] Boot hint activated");
|
||||
}
|
||||
}
|
||||
|
||||
bool UITask::isEditingHomeScreen() const {
|
||||
@@ -2375,6 +2640,22 @@ void UITask::gotoAudiobookPlayer() {
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
void UITask::gotoAlarmScreen() {
|
||||
if (alarm_screen == nullptr) return;
|
||||
AlarmScreen* alarmScr = (AlarmScreen*)alarm_screen;
|
||||
if (_display != NULL) {
|
||||
alarmScr->enter(*_display);
|
||||
}
|
||||
setCurrScreen(alarm_screen);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef HAS_4G_MODEM
|
||||
void UITask::gotoSMSScreen() {
|
||||
SMSScreen* smsScr = (SMSScreen*)sms_screen;
|
||||
@@ -2505,7 +2786,7 @@ void UITask::gotoWebReader() {
|
||||
if (web_reader == nullptr) {
|
||||
Serial.printf("WebReader: lazy init - free heap: %d, largest block: %d\n",
|
||||
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||||
web_reader = new WebReaderScreen(this);
|
||||
web_reader = new WebReaderScreen(this, _node_prefs);
|
||||
Serial.printf("WebReader: init complete - free heap: %d\n", ESP.getFreeHeap());
|
||||
}
|
||||
WebReaderScreen* wr = (WebReaderScreen*)web_reader;
|
||||
|
||||
@@ -30,6 +30,10 @@
|
||||
#include "WebReaderScreen.h"
|
||||
#endif
|
||||
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
#include "AlarmScreen.h"
|
||||
#endif
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
#include "VirtualKeyboard.h"
|
||||
#endif
|
||||
@@ -56,6 +60,9 @@ class UITask : public AbstractUITask {
|
||||
NodePrefs* _node_prefs;
|
||||
char _alert[80];
|
||||
unsigned long _alert_expiry;
|
||||
bool _hintActive = false; // Boot navigation hint overlay
|
||||
unsigned long _hintExpiry = 0; // Auto-dismiss time for hint
|
||||
bool _pendingBootHint = false; // Deferred hint — show after splash screen
|
||||
int _msgcount;
|
||||
unsigned long ui_started_at, next_batt_chck;
|
||||
uint8_t _low_batt_count = 0; // Consecutive low-voltage readings for debounce
|
||||
@@ -79,6 +86,9 @@ class UITask : public AbstractUITask {
|
||||
UIScreen* notes_screen; // Notes editor screen
|
||||
UIScreen* settings_screen; // Settings/onboarding screen
|
||||
UIScreen* audiobook_screen; // Audiobook player screen (null if not available)
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
UIScreen* alarm_screen; // Alarm clock screen (audio variant only)
|
||||
#endif
|
||||
#ifdef HAS_4G_MODEM
|
||||
UIScreen* sms_screen; // SMS messaging screen (4G variant only)
|
||||
#endif
|
||||
@@ -103,6 +113,13 @@ class UITask : public AbstractUITask {
|
||||
bool _vkbActive = false;
|
||||
UIScreen* _screenBeforeVKB = nullptr;
|
||||
unsigned long _vkbOpenedAt = 0;
|
||||
|
||||
// Powersaving: light sleep when locked + idle (standalone only — no BLE/WiFi)
|
||||
// Wakes on LoRa packet (DIO1), boot button (GPIO0), or 30-min timer
|
||||
#if !defined(BLE_PIN_CODE) && !defined(MECK_WIFI_COMPANION)
|
||||
unsigned long _psLastActive = 0; // millis() at last wake or lock entry
|
||||
unsigned long _psNextSleepSecs = 60; // Seconds before first sleep (60s), then 5s cycles
|
||||
#endif
|
||||
#ifdef MECK_CARDKB
|
||||
bool _cardkbDetected = false;
|
||||
#endif
|
||||
@@ -169,6 +186,9 @@ public:
|
||||
void gotoSettingsScreen(); // Navigate to settings
|
||||
void gotoOnboarding(); // Navigate to settings in onboarding mode
|
||||
void gotoAudiobookPlayer(); // Navigate to audiobook player
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
void gotoAlarmScreen(); // Navigate to alarm clock
|
||||
#endif
|
||||
void gotoRepeaterAdmin(int contactIdx); // Navigate to repeater admin
|
||||
void gotoRepeaterAdminDirect(int contactIdx); // Auto-login admin (L key from conversation)
|
||||
void gotoDiscoveryScreen(); // Navigate to node discovery scan
|
||||
@@ -186,6 +206,9 @@ public:
|
||||
#endif
|
||||
void showAlert(const char* text, int duration_millis) override;
|
||||
void forceRefresh() override { _next_refresh = 100; }
|
||||
void showBootHint(bool immediate = false); // Show navigation hint overlay on first boot
|
||||
void dismissBootHint(); // Dismiss hint and save preference
|
||||
bool isHintActive() const { return _hintActive; }
|
||||
// Wake display and extend auto-off timer. Call this when handling keys
|
||||
// outside of injectKey() to prevent display auto-off during direct input.
|
||||
void keepAlive() {
|
||||
@@ -215,6 +238,9 @@ public:
|
||||
bool isOnNotesScreen() const { return curr == notes_screen; }
|
||||
bool isOnSettingsScreen() const { return curr == settings_screen; }
|
||||
bool isOnAudiobookPlayer() const { return curr == audiobook_screen; }
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
bool isOnAlarmScreen() const { return curr == alarm_screen; }
|
||||
#endif
|
||||
bool isOnRepeaterAdmin() const { return curr == repeater_admin; }
|
||||
bool isOnDiscoveryScreen() const { return curr == discovery_screen; }
|
||||
bool isOnLastHeardScreen() const { return curr == last_heard_screen; }
|
||||
@@ -280,8 +306,13 @@ public:
|
||||
UIScreen* getContactsScreen() const { return contacts_screen; }
|
||||
UIScreen* getChannelScreen() const { return channel_screen; }
|
||||
UIScreen* getSettingsScreen() const { return settings_screen; }
|
||||
NodePrefs* getNodePrefs() const { return _node_prefs; }
|
||||
UIScreen* getAudiobookScreen() const { return audiobook_screen; }
|
||||
void setAudiobookScreen(UIScreen* s) { audiobook_screen = s; }
|
||||
#ifdef MECK_AUDIO_VARIANT
|
||||
UIScreen* getAlarmScreen() const { return alarm_screen; }
|
||||
void setAlarmScreen(UIScreen* s) { alarm_screen = s; }
|
||||
#endif
|
||||
UIScreen* getRepeaterAdminScreen() const { return repeater_admin; }
|
||||
UIScreen* getDiscoveryScreen() const { return discovery_screen; }
|
||||
UIScreen* getLastHeardScreen() const { return last_heard_screen; }
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
#include "ModemManager.h"
|
||||
#endif
|
||||
#include "Utf8CP437.h"
|
||||
#include "../NodePrefs.h"
|
||||
|
||||
// Forward declarations
|
||||
class UITask;
|
||||
@@ -1030,8 +1031,10 @@ public:
|
||||
|
||||
private:
|
||||
UITask* _task;
|
||||
NodePrefs* _prefs;
|
||||
Mode _mode;
|
||||
bool _initialized;
|
||||
uint8_t _lastFontPref;
|
||||
DisplayDriver* _display;
|
||||
|
||||
// Display layout (calculated once)
|
||||
@@ -1424,7 +1427,7 @@ private:
|
||||
_display->print("WiFi Setup");
|
||||
_display->drawRect(0, 11, _display->width(), 1);
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->setTextSize(0);
|
||||
_display->setTextSize(_prefs->smallTextSize());
|
||||
_display->setCursor(0, 18);
|
||||
_display->print("Scanning for networks...");
|
||||
_display->endFrame();
|
||||
@@ -1524,7 +1527,7 @@ private:
|
||||
_display->print("Web Reader");
|
||||
_display->drawRect(0, 11, _display->width(), 1);
|
||||
|
||||
_display->setTextSize(0);
|
||||
_display->setTextSize(_prefs->smallTextSize());
|
||||
_display->setCursor(0, 18);
|
||||
_display->print("Connected!");
|
||||
_display->setCursor(0, 30);
|
||||
@@ -2306,7 +2309,7 @@ private:
|
||||
_display->print("Web Reader");
|
||||
_display->drawRect(0, 11, _display->width(), 1);
|
||||
_display->setColor(DisplayDriver::YELLOW);
|
||||
_display->setTextSize(0);
|
||||
_display->setTextSize(_prefs->smallTextSize());
|
||||
_display->setCursor(0, 18);
|
||||
_display->print("Fetch failed:");
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
@@ -2442,7 +2445,7 @@ private:
|
||||
_display->setTextSize(2);
|
||||
_display->setCursor(10, 20);
|
||||
_display->print("Logging in...");
|
||||
_display->setTextSize(0);
|
||||
_display->setTextSize(_prefs->smallTextSize());
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->setCursor(10, 45);
|
||||
_display->print("Refreshing session...");
|
||||
@@ -2656,14 +2659,14 @@ private:
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
|
||||
if (_wifiState == WIFI_SCANNING) {
|
||||
display.setCursor(0, 18);
|
||||
display.print("Scanning for networks...");
|
||||
} else if (_wifiState == WIFI_SCAN_DONE) {
|
||||
int y = 14;
|
||||
int listLineH = 8;
|
||||
int listLineH = _prefs ? _prefs->smallLineH() : 9;
|
||||
for (int i = 0; i < _ssidCount && y < display.height() - 24; i++) {
|
||||
bool selected = (i == _selectedSSID);
|
||||
if (selected) {
|
||||
@@ -2671,7 +2674,7 @@ private:
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), listLineH);
|
||||
#else
|
||||
display.fillRect(0, y + 5, display.width(), listLineH);
|
||||
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width(), listLineH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
@@ -2695,7 +2698,7 @@ private:
|
||||
y += 12;
|
||||
display.setCursor(0, y);
|
||||
display.print("Password:");
|
||||
y += 10;
|
||||
y += _prefs->smallLineH() + 1;
|
||||
display.setCursor(0, y);
|
||||
// Show masked password with brief reveal of last char
|
||||
char passBuf[WEB_WIFI_PASS_LEN + 2];
|
||||
@@ -2771,7 +2774,7 @@ private:
|
||||
|
||||
if (isNetworkAvailable()) {
|
||||
display.print("Web Reader");
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
if (isWiFiConnected()) {
|
||||
IPAddress ip = WiFi.localIP();
|
||||
@@ -2797,7 +2800,7 @@ private:
|
||||
const int footerY = display.height() - 12;
|
||||
const int viewportH = display.height() - headerY - footerH;
|
||||
const int scrollbarW = 4;
|
||||
const int listLineH = 8;
|
||||
const int listLineH = _prefs ? _prefs->smallLineH() : 9;
|
||||
const int sepH = 8; // Separator between IRC and web sections
|
||||
const int sectionH = listLineH; // Section header height
|
||||
int maxChars = _charsPerLine - 2; // Account for "> " prefix
|
||||
@@ -2875,7 +2878,7 @@ private:
|
||||
if (totalContentH <= viewportH) _homeScrollY = 0;
|
||||
|
||||
// ---- Render pass (with scroll offset) ----
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
int y = headerY - _homeScrollY; // Start Y in screen coords
|
||||
itemIdx = 0;
|
||||
bool needsScroll = (totalContentH > viewportH);
|
||||
@@ -2895,7 +2898,7 @@ private:
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
|
||||
#else
|
||||
display.fillRect(0, y + 5, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
|
||||
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
@@ -2934,7 +2937,7 @@ private:
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
|
||||
#else
|
||||
display.fillRect(0, y + 5, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
|
||||
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
@@ -2971,7 +2974,7 @@ private:
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
|
||||
#else
|
||||
display.fillRect(0, y + 5, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
|
||||
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
@@ -3024,7 +3027,7 @@ private:
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, contentW, itemH);
|
||||
#else
|
||||
display.fillRect(0, y + 5, contentW, itemH);
|
||||
display.fillRect(0, y + _prefs->smallHighlightOff(), contentW, itemH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
@@ -3076,7 +3079,7 @@ private:
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, contentW, itemH);
|
||||
#else
|
||||
display.fillRect(0, y + 5, contentW, itemH);
|
||||
display.fillRect(0, y + _prefs->smallHighlightOff(), contentW, itemH);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
@@ -3198,7 +3201,7 @@ private:
|
||||
display.setCursor(10, 20);
|
||||
display.print("Loading...");
|
||||
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
|
||||
// Word-wrap the URL across multiple lines
|
||||
@@ -3243,7 +3246,7 @@ private:
|
||||
display.print("Download Complete");
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(0, 16);
|
||||
display.print("Saved to /books/:");
|
||||
@@ -3277,7 +3280,7 @@ private:
|
||||
display.print("Download Failed");
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(0, 18);
|
||||
display.print(_fetchError.c_str());
|
||||
@@ -3314,7 +3317,7 @@ private:
|
||||
return;
|
||||
}
|
||||
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
|
||||
// Determine page bounds
|
||||
@@ -3476,9 +3479,16 @@ private:
|
||||
// ---- Layout Initialization ----
|
||||
|
||||
void initLayout(DisplayDriver& display) {
|
||||
// Re-init if font preference changed since last layout
|
||||
uint8_t curFont = _prefs ? _prefs->large_font : 0;
|
||||
if (_initialized && curFont != _lastFontPref) {
|
||||
_initialized = false;
|
||||
Serial.println("WebReader: font changed, recalculating layout");
|
||||
}
|
||||
if (_initialized) return;
|
||||
_lastFontPref = curFont;
|
||||
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
uint16_t mWidth = display.getTextWidth("M");
|
||||
if (mWidth > 0) {
|
||||
_charsPerLine = display.width() / mWidth;
|
||||
@@ -3487,6 +3497,19 @@ private:
|
||||
_charsPerLine = 40;
|
||||
_lineHeight = 5;
|
||||
}
|
||||
// Proportional font: use average-width measurement instead of M-width
|
||||
if (_prefs && _prefs->large_font && mWidth > 0) {
|
||||
const char* sample = "the quick brown fox jumps over lazy dog";
|
||||
uint16_t sampleW = display.getTextWidth(sample);
|
||||
int sampleLen = strlen(sample);
|
||||
if (sampleW > 0 && sampleLen > 0) {
|
||||
_charsPerLine = (display.width() * sampleLen * 70) / ((int)sampleW * 100);
|
||||
}
|
||||
}
|
||||
// Large font: formula above assumes built-in 6x8 ratio — too small for 9pt
|
||||
if (_prefs && _prefs->large_font) {
|
||||
_lineHeight = _prefs->smallLineH();
|
||||
}
|
||||
|
||||
_footerHeight = 14;
|
||||
int textAreaHeight = display.height() - _footerHeight;
|
||||
@@ -3931,7 +3954,7 @@ private:
|
||||
if (_activeForm < 0 || _activeForm >= _formCount) return;
|
||||
WebForm& form = _forms[_activeForm];
|
||||
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
|
||||
// Header
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
@@ -3954,7 +3977,7 @@ private:
|
||||
display.drawRect(0, 9, display.width(), 1);
|
||||
|
||||
int y = 12;
|
||||
int lineH = 10; // Taller lines for form fields
|
||||
int lineH = _prefs->smallLineH() + 1; // Taller lines for form fields
|
||||
int visCount = getVisibleFieldCount(form);
|
||||
|
||||
// Render each visible field
|
||||
@@ -4662,9 +4685,9 @@ private:
|
||||
display.print("IRC Setup");
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
int y = 16;
|
||||
int lineH = 10;
|
||||
int lineH = _prefs->smallLineH() + 1;
|
||||
|
||||
const char* labels[] = {"Server:", "Port:", "Nick:", "Channel:", "[ Connect ]"};
|
||||
const char* chanDisp = (_ircChannel[0] != '\0') ? _ircChannel : "(none)";
|
||||
@@ -4822,7 +4845,7 @@ private:
|
||||
display.print(header);
|
||||
|
||||
// Connection indicator on right
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
if (!_ircConnected) {
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setCursor(display.width() - 42, -3);
|
||||
@@ -4848,7 +4871,7 @@ private:
|
||||
|
||||
if (_ircComposing) {
|
||||
// Compose text just above separator (tiny font to match messages)
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(0, footerY - 12);
|
||||
char compDisp[IRC_COMPOSE_MAX + 4];
|
||||
@@ -4878,10 +4901,10 @@ private:
|
||||
}
|
||||
|
||||
// Message area
|
||||
display.setTextSize(0);
|
||||
display.setTextSize(_prefs->smallTextSize());
|
||||
int msgAreaTop = 14;
|
||||
int msgAreaBottom = _ircComposing ? footerY - 16 : footerY - 4;
|
||||
int lineH = 8;
|
||||
int lineH = _prefs->smallLineH() - 1;
|
||||
int scrollBarW = 4;
|
||||
int lineW = _charsPerLine - 1; // Reserve space for scroll bar
|
||||
_ircLinesPerPage = (msgAreaBottom - msgAreaTop) / lineH;
|
||||
@@ -5065,8 +5088,8 @@ private:
|
||||
}
|
||||
|
||||
public:
|
||||
WebReaderScreen(UITask* task)
|
||||
: _task(task), _mode(HOME), _initialized(false), _display(nullptr),
|
||||
WebReaderScreen(UITask* task, NodePrefs* prefs = nullptr)
|
||||
: _task(task), _prefs(prefs), _mode(HOME), _initialized(false), _lastFontPref(0), _display(nullptr),
|
||||
_charsPerLine(40), _linesPerPage(15), _lineHeight(5), _footerHeight(14),
|
||||
_wifiState(WIFI_IDLE), _ssidCount(0), _selectedSSID(0), _wifiPassLen(0),
|
||||
_urlLen(0), _urlCursor(0),
|
||||
@@ -5150,7 +5173,7 @@ public:
|
||||
_display->print("Web Reader");
|
||||
_display->drawRect(0, 11, _display->width(), 1);
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->setTextSize(0);
|
||||
_display->setTextSize(_prefs->smallTextSize());
|
||||
_display->setCursor(0, 18);
|
||||
_display->print("Connecting to WiFi...");
|
||||
_display->endFrame();
|
||||
|
||||
@@ -46,4 +46,18 @@ static const uint8_t icon_notepad[] PROGMEM = {
|
||||
static const uint8_t icon_search[] PROGMEM = {
|
||||
0x3C,0x00, 0x42,0x00, 0x81,0x00, 0x81,0x00, 0x81,0x00, 0x42,0x00,
|
||||
0x3C,0x00, 0x03,0x00, 0x01,0x80, 0x00,0xC0, 0x00,0x40, 0x00,0x00,
|
||||
};
|
||||
|
||||
// ⏰ Alarm Clock (AlarmScreen) — 12x12 home tile icon
|
||||
static const uint8_t icon_alarm[] PROGMEM = {
|
||||
0x40,0x40, 0x9E,0x20, 0x20,0x80, 0x44,0x40, 0x44,0x40, 0x46,0x40,
|
||||
0x40,0x40, 0x20,0x80, 0x1F,0x00, 0x00,0x00, 0x20,0x40, 0x40,0x20,
|
||||
};
|
||||
|
||||
// 🔔 Bell — 7x8 status bar indicator (alarm enabled)
|
||||
// MSB-first, 1 byte per row
|
||||
#define BELL_ICON_W 7
|
||||
#define BELL_ICON_H 8
|
||||
static const uint8_t icon_bell_small[] PROGMEM = {
|
||||
0x10, 0x38, 0x7C, 0x7C, 0x7C, 0xFE, 0x00, 0x10,
|
||||
};
|
||||
@@ -1,7 +1,10 @@
|
||||
"""
|
||||
PlatformIO post-build script: merge bootloader + partitions + firmware
|
||||
PlatformIO post-build script: merge bootloader + partitions + firmware + SPIFFS
|
||||
into a single flashable binary.
|
||||
|
||||
Includes a pre-formatted empty SPIFFS image so first-boot doesn't need to
|
||||
format the partition (which takes 1-2 minutes on 16MB flash).
|
||||
|
||||
Output: .pio/build/<env>/firmware_merged.bin
|
||||
Flash: esptool.py --chip esp32s3 write_flash 0x0 firmware_merged.bin
|
||||
|
||||
@@ -12,6 +15,87 @@ Add to each environment (or the base section):
|
||||
|
||||
Import("env")
|
||||
|
||||
def find_spiffs_partition(partitions_bin):
|
||||
"""Parse compiled partitions.bin to find SPIFFS partition offset and size.
|
||||
|
||||
ESP32 partition entry format (32 bytes each):
|
||||
0xAA50 magic, type, subtype, offset(u32le), size(u32le), label(16), flags(u32le)
|
||||
SPIFFS: type=0x01(data), subtype=0x82(spiffs)
|
||||
"""
|
||||
import struct
|
||||
|
||||
with open(partitions_bin, "rb") as f:
|
||||
data = f.read()
|
||||
|
||||
for i in range(0, len(data) - 32, 32):
|
||||
magic = struct.unpack_from("<H", data, i)[0]
|
||||
if magic != 0xAA50:
|
||||
continue
|
||||
ptype = data[i + 2]
|
||||
subtype = data[i + 3]
|
||||
offset = struct.unpack_from("<I", data, i + 4)[0]
|
||||
size = struct.unpack_from("<I", data, i + 8)[0]
|
||||
label = data[i + 12:i + 28].split(b'\x00')[0].decode("ascii", errors="ignore")
|
||||
if ptype == 0x01 and subtype == 0x82: # data/spiffs
|
||||
return offset, size, label
|
||||
return None, None, None
|
||||
|
||||
|
||||
def build_spiffs_image(env, size):
|
||||
"""Generate an empty formatted SPIFFS image using mkspiffs."""
|
||||
import subprocess, os, tempfile, glob
|
||||
|
||||
build_dir = env.subst("$BUILD_DIR")
|
||||
spiffs_bin = os.path.join(build_dir, "spiffs_empty.bin")
|
||||
|
||||
# If already generated for this build, reuse it
|
||||
if os.path.isfile(spiffs_bin) and os.path.getsize(spiffs_bin) == size:
|
||||
return spiffs_bin
|
||||
|
||||
# Find mkspiffs in PlatformIO packages
|
||||
pio_home = os.path.expanduser("~/.platformio")
|
||||
mkspiffs_paths = glob.glob(os.path.join(pio_home, "packages", "tool-mkspiffs*", "mkspiffs*"))
|
||||
if not mkspiffs_paths:
|
||||
# Also check platform-specific tool paths
|
||||
mkspiffs_paths = glob.glob(os.path.join(pio_home, "packages", "tool-mklittlefs*", "mkspiffs*"))
|
||||
|
||||
mkspiffs = None
|
||||
for p in mkspiffs_paths:
|
||||
if os.path.isfile(p) and os.access(p, os.X_OK):
|
||||
mkspiffs = p
|
||||
break
|
||||
|
||||
if not mkspiffs:
|
||||
print("[merge] WARNING: mkspiffs not found, skipping SPIFFS image")
|
||||
return None
|
||||
|
||||
# Create empty data directory for mkspiffs
|
||||
data_dir = os.path.join(build_dir, "_empty_spiffs_data")
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
|
||||
# SPIFFS block/page sizes — ESP32 Arduino defaults
|
||||
block_size = 4096
|
||||
page_size = 256
|
||||
|
||||
cmd = [
|
||||
mkspiffs,
|
||||
"-c", data_dir,
|
||||
"-b", str(block_size),
|
||||
"-p", str(page_size),
|
||||
"-s", str(size),
|
||||
spiffs_bin,
|
||||
]
|
||||
|
||||
print(f"[merge] Generating empty SPIFFS image ({size // 1024} KB)...")
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode == 0 and os.path.isfile(spiffs_bin):
|
||||
print(f"[merge] SPIFFS image OK: {spiffs_bin}")
|
||||
return spiffs_bin
|
||||
else:
|
||||
print(f"[merge] mkspiffs failed: {result.stderr}")
|
||||
return None
|
||||
|
||||
|
||||
def merge_bin(source, target, env):
|
||||
import subprocess, os
|
||||
|
||||
@@ -52,8 +136,18 @@ def merge_bin(source, target, env):
|
||||
"0x10000", firmware,
|
||||
]
|
||||
|
||||
# Try to include a pre-formatted SPIFFS image (eliminates 1-2 min first-boot format)
|
||||
spiffs_offset, spiffs_size, spiffs_label = find_spiffs_partition(partitions)
|
||||
if spiffs_offset and spiffs_size:
|
||||
spiffs_bin = build_spiffs_image(env, spiffs_size)
|
||||
if spiffs_bin:
|
||||
cmd.extend([f"0x{spiffs_offset:x}", spiffs_bin])
|
||||
print(f"[merge] Including SPIFFS image at 0x{spiffs_offset:x} ({spiffs_size // 1024} KB)")
|
||||
else:
|
||||
print("[merge] No SPIFFS partition found in partition table, skipping SPIFFS image")
|
||||
|
||||
print(f"\n[merge] Creating merged firmware for {env_name}...")
|
||||
print(f"[merge] {' '.join(cmd[-6:])}")
|
||||
print(f"[merge] {' '.join(cmd[-8:])}")
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
|
||||
@@ -36,7 +36,7 @@ uint32_t Dispatcher::getCADFailRetryDelay() const {
|
||||
return 200;
|
||||
}
|
||||
uint32_t Dispatcher::getCADFailMaxDuration() const {
|
||||
return 4000; // 4 seconds
|
||||
return 6000; // 6 seconds
|
||||
}
|
||||
|
||||
void Dispatcher::loop() {
|
||||
@@ -52,10 +52,28 @@ void Dispatcher::loop() {
|
||||
prev_isrecv_mode = is_recv;
|
||||
if (!is_recv) {
|
||||
radio_nonrx_start = _ms->getMillis();
|
||||
} else {
|
||||
rx_stuck_count = 0; // radio recovered — reset counter
|
||||
}
|
||||
}
|
||||
if (!is_recv && _ms->getMillis() - radio_nonrx_start > 8000) { // radio has not been in Rx mode for 8 seconds!
|
||||
_err_flags |= ERR_EVENT_STARTRX_TIMEOUT;
|
||||
|
||||
rx_stuck_count++;
|
||||
MESH_DEBUG_PRINTLN("%s Dispatcher::loop(): RX stuck (attempt %d), calling onRxStuck()", getLogDateTime(), rx_stuck_count);
|
||||
onRxStuck();
|
||||
|
||||
uint8_t reboot_threshold = getRxFailRebootThreshold();
|
||||
if (reboot_threshold > 0 && rx_stuck_count >= reboot_threshold) {
|
||||
MESH_DEBUG_PRINTLN("%s Dispatcher::loop(): RX unrecoverable after %d attempts", getLogDateTime(), rx_stuck_count);
|
||||
onRxUnrecoverable();
|
||||
}
|
||||
|
||||
// Reset state to give recovery the full 8s window before re-triggering
|
||||
radio_nonrx_start = _ms->getMillis();
|
||||
prev_isrecv_mode = true;
|
||||
cad_busy_start = 0;
|
||||
next_agc_reset_time = futureMillis(getAGCResetInterval());
|
||||
}
|
||||
|
||||
if (outbound) { // waiting for outbound send to be completed
|
||||
@@ -273,14 +291,31 @@ void Dispatcher::checkSend() {
|
||||
outbound_start = _ms->getMillis();
|
||||
bool success = _radio->startSendRaw(raw, len);
|
||||
if (!success) {
|
||||
MESH_DEBUG_PRINTLN("%s Dispatcher::loop(): ERROR: send start failed!", getLogDateTime());
|
||||
MESH_DEBUG_PRINTLN("%s Dispatcher::checkSend(): ERROR: send start failed!", getLogDateTime());
|
||||
|
||||
logTxFail(outbound, outbound->getRawLength());
|
||||
|
||||
releasePacket(outbound); // return to pool
|
||||
|
||||
// re-queue packet for retry instead of dropping it
|
||||
int retry_delay = getCADFailRetryDelay();
|
||||
unsigned long retry_time = futureMillis(retry_delay);
|
||||
_mgr->queueOutbound(outbound, 0, retry_time);
|
||||
outbound = NULL;
|
||||
next_tx_time = retry_time;
|
||||
|
||||
// count consecutive failures and reset radio if stuck
|
||||
uint8_t threshold = getTxFailResetThreshold();
|
||||
if (threshold > 0) {
|
||||
tx_fail_count++;
|
||||
if (tx_fail_count >= threshold) {
|
||||
MESH_DEBUG_PRINTLN("%s Dispatcher::checkSend(): TX stuck (%d failures), resetting radio", getLogDateTime(), tx_fail_count);
|
||||
onTxStuck();
|
||||
tx_fail_count = 0;
|
||||
next_tx_time = futureMillis(2000);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
tx_fail_count = 0; // clear counter on successful TX start
|
||||
outbound_expiry = futureMillis(max_airtime);
|
||||
|
||||
#if MESH_PACKET_LOGGING
|
||||
|
||||
@@ -122,6 +122,8 @@ class Dispatcher {
|
||||
bool prev_isrecv_mode;
|
||||
uint32_t n_sent_flood, n_sent_direct;
|
||||
uint32_t n_recv_flood, n_recv_direct;
|
||||
uint8_t tx_fail_count;
|
||||
uint8_t rx_stuck_count;
|
||||
|
||||
void processRecvPacket(Packet* pkt);
|
||||
|
||||
@@ -142,6 +144,8 @@ protected:
|
||||
_err_flags = 0;
|
||||
radio_nonrx_start = 0;
|
||||
prev_isrecv_mode = true;
|
||||
tx_fail_count = 0;
|
||||
rx_stuck_count = 0;
|
||||
}
|
||||
|
||||
virtual DispatcherAction onRecvPacket(Packet* pkt) = 0;
|
||||
@@ -159,6 +163,11 @@ protected:
|
||||
virtual uint32_t getCADFailMaxDuration() const;
|
||||
virtual int getInterferenceThreshold() const { return 0; } // disabled by default
|
||||
virtual int getAGCResetInterval() const { return 0; } // disabled by default
|
||||
virtual uint8_t getTxFailResetThreshold() const { return 3; } // reset radio after N consecutive TX failures; 0=disabled
|
||||
virtual void onTxStuck() { _radio->resetAGC(); } // override to use doFullRadioReset() when available
|
||||
virtual uint8_t getRxFailRebootThreshold() const { return 3; } // reboot after N failed RX recovery attempts; 0=disabled
|
||||
virtual void onRxStuck() { _radio->resetAGC(); } // called each time RX stuck for 8s; override for deeper reset
|
||||
virtual void onRxUnrecoverable() { } // called when reboot threshold exceeded; override to call _board->reboot()
|
||||
|
||||
public:
|
||||
void begin();
|
||||
@@ -188,4 +197,4 @@ private:
|
||||
void checkSend();
|
||||
};
|
||||
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,10 @@
|
||||
#endif
|
||||
|
||||
void BaseChatMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis) {
|
||||
sendFlood(pkt, delay_millis);
|
||||
sendFlood(pkt, delay_millis, getPathHashSize());
|
||||
}
|
||||
void BaseChatMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis) {
|
||||
sendFlood(pkt, delay_millis);
|
||||
sendFlood(pkt, delay_millis, getPathHashSize());
|
||||
}
|
||||
|
||||
mesh::Packet* BaseChatMesh::createSelfAdvert(const char* name) {
|
||||
|
||||
@@ -130,6 +130,7 @@ protected:
|
||||
virtual void onContactResponse(const ContactInfo& contact, const uint8_t* data, uint8_t len) = 0;
|
||||
virtual void handleReturnPathRetry(const ContactInfo& contact, const uint8_t* path, uint8_t path_len);
|
||||
|
||||
virtual uint8_t getPathHashSize() const = 0;
|
||||
virtual void sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis=0);
|
||||
virtual void sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis=0);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <Wire.h>
|
||||
#include "esp_wifi.h"
|
||||
#include "driver/rtc_io.h"
|
||||
#include "driver/gpio.h"
|
||||
|
||||
class ESP32Board : public mesh::MainBoard {
|
||||
protected:
|
||||
@@ -60,13 +61,20 @@ public:
|
||||
#if defined(CONFIG_IDF_TARGET_ESP32S3) && defined(P_LORA_DIO_1) // Supported ESP32 variants
|
||||
if (rtc_gpio_is_valid_gpio((gpio_num_t)P_LORA_DIO_1)) { // Only enter sleep mode if P_LORA_DIO_1 is RTC pin
|
||||
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON);
|
||||
esp_sleep_enable_ext1_wakeup((1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // To wake up when receiving a LoRa packet
|
||||
esp_sleep_enable_ext1_wakeup((1ULL << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // Wake on LoRa packet
|
||||
|
||||
// T5S3: Also wake on boot button press (GPIO0, active LOW).
|
||||
// gpio_wakeup uses level trigger — works for light sleep only.
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) && defined(PIN_USER_BTN)
|
||||
gpio_wakeup_enable((gpio_num_t)PIN_USER_BTN, GPIO_INTR_LOW_LEVEL);
|
||||
esp_sleep_enable_gpio_wakeup();
|
||||
#endif
|
||||
|
||||
if (secs > 0) {
|
||||
esp_sleep_enable_timer_wakeup(secs * 1000000); // To wake up every hour to do periodically jobs
|
||||
esp_sleep_enable_timer_wakeup(secs * 1000000ULL); // Timer wake (microseconds)
|
||||
}
|
||||
|
||||
esp_light_sleep_start(); // CPU enters light sleep
|
||||
esp_light_sleep_start(); // CPU halts here, resumes on wake
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -154,4 +162,4 @@ public:
|
||||
}
|
||||
};
|
||||
|
||||
#endif
|
||||
#endif
|
||||
@@ -185,7 +185,7 @@ size_t SerialBLEInterface::writeFrame(const uint8_t src[], size_t len) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
#define BLE_WRITE_MIN_INTERVAL 60
|
||||
#define BLE_WRITE_MIN_INTERVAL 30
|
||||
|
||||
bool SerialBLEInterface::isWriteBusy() const {
|
||||
return millis() < _last_write + BLE_WRITE_MIN_INTERVAL; // still too soon to start another write?
|
||||
|
||||
@@ -23,7 +23,7 @@ class SerialBLEInterface : public BaseSerialInterface, BLESecurityCallbacks, BLE
|
||||
uint8_t buf[MAX_FRAME_SIZE];
|
||||
};
|
||||
|
||||
#define FRAME_QUEUE_SIZE 4
|
||||
#define FRAME_QUEUE_SIZE 8
|
||||
int recv_queue_len;
|
||||
Frame recv_queue[FRAME_QUEUE_SIZE];
|
||||
int send_queue_len;
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
#endif
|
||||
|
||||
#ifndef CPU_FREQ_LOW_POWER
|
||||
#define CPU_FREQ_LOW_POWER 40 // MHz — lock screen / idle standby
|
||||
#define CPU_FREQ_LOW_POWER 80 // MHz — lock screen / idle standby (40 MHz breaks I2C)
|
||||
#endif
|
||||
|
||||
#ifndef CPU_BOOST_TIMEOUT_MS
|
||||
|
||||
@@ -188,9 +188,15 @@ int16_t T5S3Board::getBattTemperature() {
|
||||
}
|
||||
|
||||
// ---- BQ27220 Design Capacity configuration ----
|
||||
// Identical procedure to TDeckBoard — sets 1500 mAh for T5S3's larger cell.
|
||||
// The BQ27220 ships with 3000 mAh default. This writes once on first boot
|
||||
// and persists in battery-backed RAM.
|
||||
// The BQ27220 ships with a 3000 mAh default. T5S3 uses a 1500 mAh cell.
|
||||
// This function checks on boot and writes the correct value via the
|
||||
// MAC Data Memory interface if needed. The value persists in battery-backed
|
||||
// RAM, so this typically only writes once (or after a full battery disconnect).
|
||||
//
|
||||
// When DC and DE are already correct but FCC is stuck (common after initial
|
||||
// flash), the root cause is Qmax Cell 0 (0x9106) and stored FCC (0x929D)
|
||||
// retaining factory 3000 mAh defaults. This function detects and fixes all
|
||||
// three layers: DC/DE, Qmax, and stored FCC.
|
||||
|
||||
bool T5S3Board::configureFuelGauge(uint16_t designCapacity_mAh) {
|
||||
#if HAS_BQ27220
|
||||
@@ -198,23 +204,169 @@ bool T5S3Board::configureFuelGauge(uint16_t designCapacity_mAh) {
|
||||
Serial.printf("BQ27220: Design Capacity = %d mAh (target %d)\n", currentDC, designCapacity_mAh);
|
||||
|
||||
if (currentDC == designCapacity_mAh) {
|
||||
// Design Capacity correct, but check if Full Charge Capacity is sane.
|
||||
uint16_t fcc = bq27220_read16(BQ27220_REG_FULL_CAP);
|
||||
Serial.printf("BQ27220: Design Capacity correct, FCC=%d mAh\n", fcc);
|
||||
if (fcc < designCapacity_mAh * 3 / 2) {
|
||||
return true; // FCC is sane, nothing to do
|
||||
Serial.printf("BQ27220: Design Capacity already correct, FCC=%d mAh\n", fcc);
|
||||
if (fcc >= designCapacity_mAh * 3 / 2) {
|
||||
// FCC is >=150% of design — stale from factory defaults (typically 3000 mAh).
|
||||
uint16_t designEnergy = (uint16_t)((uint32_t)designCapacity_mAh * 37 / 10);
|
||||
Serial.printf("BQ27220: FCC %d >> DC %d, checking Design Energy (target %d mWh)\n",
|
||||
fcc, designCapacity_mAh, designEnergy);
|
||||
|
||||
// Unseal to read data memory and issue RESET
|
||||
bq27220_writeControl(0x0414); delay(2);
|
||||
bq27220_writeControl(0x3672); delay(2);
|
||||
// Full Access
|
||||
bq27220_writeControl(0xFFFF); delay(2);
|
||||
bq27220_writeControl(0xFFFF); delay(2);
|
||||
|
||||
// Enter CFG_UPDATE to access data memory
|
||||
bq27220_writeControl(0x0090);
|
||||
bool ready = false;
|
||||
for (int i = 0; i < 50; i++) {
|
||||
delay(20);
|
||||
uint16_t opSt = bq27220_read16(BQ27220_REG_OP_STATUS);
|
||||
if (opSt & 0x0400) { ready = true; break; }
|
||||
}
|
||||
if (ready) {
|
||||
// Read Design Energy at data memory address 0x92A1
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E); Wire.write(0xA1); Wire.write(0x92);
|
||||
Wire.endTransmission();
|
||||
delay(10);
|
||||
uint8_t oldMSB = bq27220_read8(0x40);
|
||||
uint8_t oldLSB = bq27220_read8(0x41);
|
||||
uint16_t currentDE = (oldMSB << 8) | oldLSB;
|
||||
|
||||
if (currentDE != designEnergy) {
|
||||
// Design Energy actually needs updating — write it
|
||||
uint8_t oldChk = bq27220_read8(0x60);
|
||||
uint8_t dLen = bq27220_read8(0x61);
|
||||
uint8_t newMSB = (designEnergy >> 8) & 0xFF;
|
||||
uint8_t newLSB = designEnergy & 0xFF;
|
||||
uint8_t temp = (255 - oldChk - oldMSB - oldLSB);
|
||||
uint8_t newChk = 255 - ((temp + newMSB + newLSB) & 0xFF);
|
||||
|
||||
Serial.printf("BQ27220: DE old=%d new=%d mWh, writing\n", currentDE, designEnergy);
|
||||
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E); Wire.write(0xA1); Wire.write(0x92);
|
||||
Wire.write(newMSB); Wire.write(newLSB);
|
||||
Wire.endTransmission();
|
||||
delay(5);
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x60); Wire.write(newChk); Wire.write(dLen);
|
||||
Wire.endTransmission();
|
||||
delay(10);
|
||||
|
||||
// Exit with reinit since we actually changed data
|
||||
bq27220_writeControl(0x0091); // EXIT_CFG_UPDATE_REINIT
|
||||
delay(200);
|
||||
Serial.println("BQ27220: Design Energy written, exited CFG_UPDATE");
|
||||
} else {
|
||||
// DC and DE are both correct, but FCC is stuck.
|
||||
// Root cause: Qmax Cell 0 (0x9106) and stored FCC (0x929D) retain
|
||||
// factory 3000 mAh defaults. Overwrite both with designCapacity_mAh.
|
||||
Serial.printf("BQ27220: DE correct (%d mWh) — fixing Qmax + stored FCC\n", currentDE);
|
||||
|
||||
// --- Helper lambda for MAC data memory 2-byte write ---
|
||||
// Reads old value + checksum, computes differential checksum, writes new value.
|
||||
auto writeDM16 = [](uint16_t addr, uint16_t newVal) -> bool {
|
||||
// Select address
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E);
|
||||
Wire.write(addr & 0xFF);
|
||||
Wire.write((addr >> 8) & 0xFF);
|
||||
Wire.endTransmission();
|
||||
delay(10);
|
||||
|
||||
uint8_t oldMSB = bq27220_read8(0x40);
|
||||
uint8_t oldLSB = bq27220_read8(0x41);
|
||||
uint8_t oldChk = bq27220_read8(0x60);
|
||||
uint8_t dLen = bq27220_read8(0x61);
|
||||
uint16_t oldVal = (oldMSB << 8) | oldLSB;
|
||||
|
||||
if (oldVal == newVal) {
|
||||
Serial.printf("BQ27220: [0x%04X] already %d, skip\n", addr, newVal);
|
||||
return true; // already correct
|
||||
}
|
||||
|
||||
uint8_t newMSB = (newVal >> 8) & 0xFF;
|
||||
uint8_t newLSB = newVal & 0xFF;
|
||||
uint8_t temp = (255 - oldChk - oldMSB - oldLSB);
|
||||
uint8_t newChk = 255 - ((temp + newMSB + newLSB) & 0xFF);
|
||||
|
||||
Serial.printf("BQ27220: [0x%04X] %d -> %d\n", addr, oldVal, newVal);
|
||||
|
||||
// Write new value
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E);
|
||||
Wire.write(addr & 0xFF);
|
||||
Wire.write((addr >> 8) & 0xFF);
|
||||
Wire.write(newMSB);
|
||||
Wire.write(newLSB);
|
||||
Wire.endTransmission();
|
||||
delay(5);
|
||||
|
||||
// Write checksum
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x60);
|
||||
Wire.write(newChk);
|
||||
Wire.write(dLen);
|
||||
Wire.endTransmission();
|
||||
delay(10);
|
||||
return true;
|
||||
};
|
||||
|
||||
// Overwrite Qmax Cell 0 (IT Cfg) — this is what FCC is derived from
|
||||
writeDM16(0x9106, designCapacity_mAh);
|
||||
|
||||
// Overwrite stored FCC reference (Gas Gauging, 2 bytes before DC)
|
||||
writeDM16(0x929D, designCapacity_mAh);
|
||||
|
||||
// Exit with reinit to apply the new values
|
||||
bq27220_writeControl(0x0091); // EXIT_CFG_UPDATE_REINIT
|
||||
delay(200);
|
||||
Serial.println("BQ27220: Qmax + stored FCC updated, exited CFG_UPDATE");
|
||||
}
|
||||
} else {
|
||||
Serial.println("BQ27220: Failed to enter CFG_UPDATE for DE check");
|
||||
}
|
||||
|
||||
// Seal first, then issue RESET.
|
||||
// RESET forces the gauge to fully reinitialize its Impedance Track
|
||||
// algorithm and recalculate FCC from the current DC/DE values.
|
||||
bq27220_writeControl(0x0030); // SEAL
|
||||
delay(5);
|
||||
Serial.println("BQ27220: Issuing RESET to force FCC recalculation...");
|
||||
bq27220_writeControl(0x0041); // RESET
|
||||
delay(2000); // Full reset needs generous settle time
|
||||
|
||||
fcc = bq27220_read16(BQ27220_REG_FULL_CAP);
|
||||
Serial.printf("BQ27220: FCC after RESET: %d mAh (target <= %d)\n", fcc, designCapacity_mAh);
|
||||
|
||||
if (fcc > designCapacity_mAh * 3 / 2) {
|
||||
// RESET didn't fix FCC — the gauge IT algorithm is stubbornly
|
||||
// retaining its learned value. This typically resolves after one
|
||||
// full charge/discharge cycle. Software clamp in
|
||||
// getFullChargeCapacity() ensures correct display regardless.
|
||||
Serial.printf("BQ27220: FCC still stale at %d — software clamp active\n", fcc);
|
||||
}
|
||||
}
|
||||
// FCC is stale from factory — fall through to reconfigure
|
||||
Serial.printf("BQ27220: FCC %d >> DC %d, reconfiguring\n", fcc, designCapacity_mAh);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Unseal
|
||||
Serial.printf("BQ27220: Updating Design Capacity from %d to %d mAh\n", currentDC, designCapacity_mAh);
|
||||
|
||||
// Step 1: Unseal (default unseal keys)
|
||||
bq27220_writeControl(0x0414); delay(2);
|
||||
bq27220_writeControl(0x3672); delay(2);
|
||||
// Full Access
|
||||
|
||||
// Step 2: Full Access
|
||||
bq27220_writeControl(0xFFFF); delay(2);
|
||||
bq27220_writeControl(0xFFFF); delay(2);
|
||||
|
||||
// Enter CFG_UPDATE
|
||||
// Step 3: Enter CFG_UPDATE
|
||||
bq27220_writeControl(0x0090);
|
||||
bool cfgReady = false;
|
||||
for (int i = 0; i < 50; i++) {
|
||||
@@ -229,7 +381,7 @@ bool T5S3Board::configureFuelGauge(uint16_t designCapacity_mAh) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Write Design Capacity at 0x929F
|
||||
// Step 4: Write Design Capacity at 0x929F
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E); Wire.write(0x9F); Wire.write(0x92);
|
||||
Wire.endTransmission();
|
||||
@@ -255,7 +407,7 @@ bool T5S3Board::configureFuelGauge(uint16_t designCapacity_mAh) {
|
||||
Wire.endTransmission();
|
||||
delay(10);
|
||||
|
||||
// Write Design Energy at 0x92A1
|
||||
// Step 4a: Write Design Energy at 0x92A1
|
||||
{
|
||||
uint16_t designEnergy = (uint16_t)((uint32_t)designCapacity_mAh * 37 / 10);
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
@@ -271,6 +423,9 @@ bool T5S3Board::configureFuelGauge(uint16_t designCapacity_mAh) {
|
||||
uint8_t deTemp = (255 - deOldChk - deOldMSB - deOldLSB);
|
||||
uint8_t deNewChk = 255 - ((deTemp + deNewMSB + deNewLSB) & 0xFF);
|
||||
|
||||
Serial.printf("BQ27220: Design Energy: old=%d new=%d mWh\n",
|
||||
(deOldMSB << 8) | deOldLSB, designEnergy);
|
||||
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E); Wire.write(0xA1); Wire.write(0x92);
|
||||
Wire.write(deNewMSB); Wire.write(deNewLSB);
|
||||
@@ -282,16 +437,17 @@ bool T5S3Board::configureFuelGauge(uint16_t designCapacity_mAh) {
|
||||
delay(10);
|
||||
}
|
||||
|
||||
// Exit CFG_UPDATE with reinit
|
||||
// Step 5: Exit CFG_UPDATE with reinit
|
||||
bq27220_writeControl(0x0091);
|
||||
Serial.println("BQ27220: Sent EXIT_CFG_UPDATE_REINIT, waiting...");
|
||||
delay(200);
|
||||
|
||||
// Seal
|
||||
// Step 6: Seal
|
||||
bq27220_writeControl(0x0030);
|
||||
delay(5);
|
||||
|
||||
// Force RESET to reinitialize FCC
|
||||
bq27220_writeControl(0x0041);
|
||||
// Step 7: Force RESET to reinitialize FCC from new DC/DE
|
||||
bq27220_writeControl(0x0041); // RESET
|
||||
delay(1000);
|
||||
|
||||
uint16_t verifyDC = bq27220_read16(BQ27220_REG_DESIGN_CAP);
|
||||
@@ -302,4 +458,4 @@ bool T5S3Board::configureFuelGauge(uint16_t designCapacity_mAh) {
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,7 @@ build_src_filter = ${esp32_base.build_src_filter}
|
||||
lib_deps =
|
||||
${esp32_base.lib_deps}
|
||||
WebServer
|
||||
DNSServer
|
||||
Update
|
||||
|
||||
; ---------------------------------------------------------------------------
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
#endif
|
||||
|
||||
#ifndef CPU_FREQ_LOW_POWER
|
||||
#define CPU_FREQ_LOW_POWER 40 // MHz — lock screen / idle standby
|
||||
#define CPU_FREQ_LOW_POWER 80 // MHz — lock screen / idle standby (40 MHz breaks I2C)
|
||||
#endif
|
||||
|
||||
#ifndef CPU_BOOST_TIMEOUT_MS
|
||||
|
||||
@@ -161,24 +161,47 @@ public:
|
||||
return false;
|
||||
}
|
||||
|
||||
// Configure keyboard matrix (8 rows x 10 cols)
|
||||
// --- Warm-reboot safe init sequence ---
|
||||
// The TCA8418 stays powered across ESP32 resets (no dedicated RST pin),
|
||||
// so the scanner may still be active from the previous session.
|
||||
// We must disable it before reconfiguring the matrix.
|
||||
|
||||
// 1. Disable scanner — stop all scanning before touching config
|
||||
writeReg(TCA8418_REG_CFG, 0x00);
|
||||
|
||||
// 2. Drain any stale events from the previous session
|
||||
for (int i = 0; i < 16; i++) {
|
||||
if ((readReg(TCA8418_REG_KEY_LCK_EC) & 0x0F) == 0) break;
|
||||
readReg(TCA8418_REG_KEY_EVENT_A);
|
||||
}
|
||||
writeReg(TCA8418_REG_INT_STAT, 0x1F); // Clear all interrupt flags
|
||||
|
||||
// 3. Explicitly clear GPI event masks (prevent phantom GPI events)
|
||||
writeReg(TCA8418_REG_GPI_EM1, 0x00);
|
||||
writeReg(TCA8418_REG_GPI_EM2, 0x00);
|
||||
writeReg(TCA8418_REG_GPI_EM3, 0x00);
|
||||
|
||||
// 4. 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
|
||||
// 5. Set debounce
|
||||
writeReg(TCA8418_REG_DEBOUNCE, 0x03);
|
||||
|
||||
// Clear any pending interrupts
|
||||
// 6. Final pre-enable cleanup
|
||||
writeReg(TCA8418_REG_INT_STAT, 0x1F);
|
||||
|
||||
// Flush the FIFO
|
||||
while (readReg(TCA8418_REG_KEY_LCK_EC) & 0x0F) {
|
||||
// 7. Enable scanner — matrix config is stable, safe to start scanning
|
||||
writeReg(TCA8418_REG_CFG, 0x11); // KE_IEN + INT_CFG
|
||||
|
||||
// 8. Let scanner stabilise, then flush any spurious first-scan events
|
||||
delay(5);
|
||||
for (int i = 0; i < 16; i++) {
|
||||
if ((readReg(TCA8418_REG_KEY_LCK_EC) & 0x0F) == 0) break;
|
||||
readReg(TCA8418_REG_KEY_EVENT_A);
|
||||
}
|
||||
writeReg(TCA8418_REG_INT_STAT, 0x1F);
|
||||
|
||||
_initialized = true;
|
||||
Serial.println("TCA8418: Keyboard initialized OK");
|
||||
|
||||
@@ -97,6 +97,7 @@ lib_deps =
|
||||
adafruit/Adafruit GFX Library@^1.11.0
|
||||
bitbank2/PNGdec@^1.0.1
|
||||
WebServer
|
||||
DNSServer
|
||||
Update
|
||||
|
||||
; ---------------------------------------------------------------------------
|
||||
@@ -150,7 +151,7 @@ build_flags =
|
||||
-D MECK_AUDIO_VARIANT
|
||||
-D MECK_WEB_READER=1
|
||||
-D MECK_OTA_UPDATE=1
|
||||
-D FIRMWARE_VERSION='"Meck v1.3.WiFi"'
|
||||
-D FIRMWARE_VERSION='"Meck v1.5.WiFi"'
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
@@ -203,7 +204,7 @@ build_flags =
|
||||
-D HAS_4G_MODEM=1
|
||||
-D MECK_WEB_READER=1
|
||||
-D MECK_OTA_UPDATE=1
|
||||
-D FIRMWARE_VERSION='"Meck v1.3.4G"'
|
||||
-D FIRMWARE_VERSION='"Meck v1.5.4G"'
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
@@ -234,7 +235,7 @@ build_flags =
|
||||
-D HAS_4G_MODEM=1
|
||||
-D MECK_WEB_READER=1
|
||||
-D MECK_OTA_UPDATE=1
|
||||
-D FIRMWARE_VERSION='"Meck v1.3.4G.WiFi"'
|
||||
-D FIRMWARE_VERSION='"Meck v1.5.4G.WiFi"'
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
@@ -261,7 +262,7 @@ build_flags =
|
||||
-D HAS_4G_MODEM=1
|
||||
-D MECK_WEB_READER=1
|
||||
-D MECK_OTA_UPDATE=1
|
||||
-D FIRMWARE_VERSION='"Meck v1.3.4G.SA"'
|
||||
-D FIRMWARE_VERSION='"Meck v1.5.4G.SA"'
|
||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
|
||||
Reference in New Issue
Block a user