GPS — fixed (the "off" toggle and boot path now actually cut the XL9555 rail). Kept in main.cpp + UITask.cpp.
BLE controller — the ~13 mA between the BLE build (BLE off) and standalone is the controller staying initialised; the boot-gate in SerialBLEInterface.cpp/.h defers BLEDevice::init() until first enable, reclaiming it. That's the meaningful idle win.
CPU, gyro rail, ES8311, frontlight — all measured and ruled out. The residual ~12 mA Max-vs-Pro is distributed always-on hardware with no software switch — hardware overhead, not a bug.
Defer ESP32 BLE controller bring-up to first user enable
MyMesh::startInterface() called serial.enable() unconditionally at boot. On the ESP32 BLE build this ran the deferred-init SerialBLEInterface's _realBegin()/BLEDevice::init() and powered the BT controller before main.cpp's boot-time disable(), which only stops advertising and cannot power the controller back down -- so the controller stayed up while "off", drawing ~13 mA at idle. Guard the enable() so ESP32 BLE builds skip it at boot; the controller now comes up lazily on the first enable() when the user turns Bluetooth on from the Bluetooth page. WiFi builds are unaffected and still enable at boot.
The fix (six sites: 948, 978, 1519, 1651, 1719, 1906): the indexer now chooses pixel-vs-char wrapping from _display->getFontStyle(), the same basis the renderer (1200), the layout sizing (1336), and the cache key already use. With all four agreeing, the indexer's recorded page boundary lands on the exact byte where the renderer's height-based stop lands — no more swallowed run between pages.
Version bump (INDEX_VERSION 13 → 14): your existing .idx caches were built by the old indexer and would otherwise be reloaded as-is (same font key), so the fix would appear to do nothing. Bumping the version makes the loader discard every v13 cache and rebuild it.
All six edits are in and braces balance (267/267). Here's what changed in Audiobookplayerscreen.h:
PNGdec include (47) and cover size 40→30 (67) — 30 virtual units ≈ 55px.
PNG draw callback coverDrawCallbackPNG (147) — nearest-neighbour downscales the 256px source line-by-line and dithers to 1-bit using the same Bayer matrix as the JPEG path. The decoder is heap-allocated per-decode (new PNG()), mirroring how the JPEG path uses new JPEGDEC(), so there's no permanent BSS cost.
decodeFolderCoverPNG() (419) + tryLoadFolderCover() (505) — reads cover.png from _currentPath into PSRAM and decodes it.
Hooks in both openBook (1099) and advanceTrack (1198), so the cover loads on open and persists as tracks auto-advance within an album. It only loads when there's no embedded cover, so M4B/ID3 embedded art still wins; music (no embedded art) gets the folder PNG.
Layout (1531, 1556) — cover now draws below the artist line, above the status/Paused-Vol line, exactly where you wanted it.
target.h — declares meck_audio_route_amp() and meck_audio_codec_init() in the bridge (54-55), guarded by HAS_ES8311_AUDIO, ready for the alarm/voice paths to reuse later.
target.cpp — includes ES8311.h (6) and defines both helpers where board and the codec driver are visible: route+amp (100-102), and the once-only es8311_init_44100_16bit() (108-110).
Audiobookplayerscreen.h — forward-declares both (54-55); ensureI2SInit() now does route+amp and the 5-arg setPinout with MCLK on MAX (268-270); and meck_audio_codec_init() runs right after connecttoFS (1142). The Pro path is untouched.
UITask shutdown (already occurs/unchanged):
1. BLE disabled (_serial->disable()).
2. WiFi disconnected + WIFI_OFF.
3. 4G modem shutdown().
4. GPS power cut (PIN_GPS_EN LOW).
5. LoRa radio powerOff() (standby mode).
6. Display turnOff().
TDeckBoard::powerOff() (new):
7. btStop() -- BLE controller stop.
8. Peripheral power OFF (PIN_PERF_POWERON LOW) -- keyboard, BQ27220, sensors.
9. LoRa module power OFF (P_LORA_EN LOW) -- cuts power entirely.
10. Hold LoRa NSS high (prevents SX1262 drawing current from floating CS).
11. esp_deep_sleep_start() -- CPU halts, ~10-40uA.
Wake (reset button or USB power-on):
12. ESP32-S3 cold boots.
13. TDeckBoard::begin() runs: peripheral power ON, I2C init, LoRa power ON, NSS hold released.
14. App starts fresh -- prefs/contacts/messages load from flash.
No LoRa wake during hibernate -- the device is truly off. Only a hardware
reset (reset button) or USB power-on wakes the device.
Incorporates hardware-specific learnings from caveman99's Meshtastic
T-Echo Card support PR (meshtastic/firmware#10267), cross-referenced
against LilyGo's official t_echo_card_config.h pinmap.
Battery (critical):
- Add BATTERY_MEASUREMENT_CONTROL pin P0.31 — gates the resistive
voltage divider feeding AIN0. Without toggling this pin, battery
ADC reads are invalid (floating divider input).
- TechoCardBoard::getBatteryVoltage() now drives P0.31 HIGH before
SAADC read and LOW after to avoid parasitic drain.
Display (critical):
- Add OLED_DISPLAY_OFFSET = 24 for the SSD1315 72×40 panel. The
physical display is mapped at GDDRAM pages 3–7 (rows 24–63);
SETDISPLAYOFFSET and SETMULTIPLEX commands are sent after
display.begin() in target.cpp to shift the visible window.
Power rail (high priority):
- RT9080 3V3 LDO now gets a clean HIGH→LOW→HIGH reset cycle with
100ms dwell in board.begin(), preventing brown-out when LoRa TX
fires at +22 dBm after a soft reset.
TDPro - Update firmware build date
Contactsscreen.h — five changes:
- EPOCH_2026 = 1735689600UL constant added (Jan 1 2026 UTC), used in sort
and formatAge.
- typeChar replaced by typeStr returning const char*, with "RS" for room
servers (previously "S", easily confused with sensors). prefix buffer
bumped to [5], all three snprintf calls updated to %s.
- Hop display: out_path_len == 0xFF branch now performs a live lookup
against the 12 most recently heard advert paths (via
getRecentlyHeard). Matches on first 7 bytes of pub_key, extracts hop
count with a bph-aware sanity cap (64/bph max) to reject impossible
values. Shows "~D" for direct flood neighbours, "~N" for N-hop flood
path, "?" if not in the recent-heard cache. Resets to "?" on reboot
until each contact re-advertises — intentional, ensures hop count is
always fresh.
- Sort: _filteredTs now stores contact.lastmod (our local receive time)
instead of contact.last_advert_timestamp (sender's claimed time).
lastmod values below EPOCH_2026 are stored as 0 so stale repeaters
with unsynced clocks and contacts received before our own timesync
sink to the bottom of the list.
- formatAge rewritten: rejects timestamp == 0, timestamp < EPOCH_2026,
and now < timestamp (all show "--" instead of wrapping or displaying
garbage). Arithmetic changed from int to uint32_t, eliminating the
signed overflow path that produced negative hour values. Age display
call site switched from last_advert_timestamp to lastmod, so display
self-corrects after a GPS or 4G timesync.