Files
wadamesh/variants/tdisplay_p4/Xl9535.cpp
T
Kaj SchittecatandClaude Opus 4.8 3bf56cdd37 p4: #167 resolved -- SD writes flash the AMOLED; hot store moved to internal LittleFS
Root cause, established by on-device elimination over one long session: SD-card
WRITE bursts electrically disturb the T-Display P4's AMOLED -- the whole-screen
blue flash of #167. The display stack itself measured clean at every layer while
flashes were visible: framebuffer content (flush tracer: every band black), DSI
bridge underrun interrupt (never fired), DSI host protocol errors incl. the host
payload-buffer underflow (never latched), DCS/brightness traffic and XL9535
expander writes (silent), disp on/off (never). The flash survived every digital
derate -- 4->1-bit SD bus, 20->10 MHz SD clock, 1000->750 Mbps DSI lane rate --
on every card tried, at any brightness (including near-zero, i.e. the panel is
being upset at the hardware level, not showing content), yet vanished the moment
the card was removed or left unwritten. The card's rail is switched off the same
3V3 domain as the panel (XL9535 SD_EN), so its program-current spikes reach the
panel and no firmware setting can filter them. Streaming tile writes never
flashed; rapid open-write-close store bursts always did. Meck-P4 not flashing
proves nothing about the board: it never writes the SD.

Fix: the hot store (contacts/channels/identity/blobs/prefs-file/chat history)
lives on the INTERNAL 'storage' partition; the SD keeps bulk, gentle writers
(map tiles, backups). The internal partition is mounted as LittleFS, not FAT:
the P4's FAT layer has documented broken metadata and the v3 migration proved
its writes are no better (a 102 KB contacts3 "copied" onto it read back as 0 B;
on LittleFS the same copy reads back byte-exact and loads all 689 contacts).
esp_littlefs looks partitions up by label with SUBTYPE_ANY, so the ex-FAT
partition converts in place on first boot (formatOnFail) -- no partition-table
change, OTA-safe.

One-time v4 migration (lessons v1-v3 encoded): substance-gated (only from a
card whose contacts3 beats the internal one -- a fresh test card once donated a
blank store), whitelist-only copy (v2 filled the partition with junk before
contacts3 landed), both historical card layouts probed (root vs /meshcomod),
marker written only after the copy, card never written (it remains the
rollback).

Also in this change, kept as opt-in diagnostics (dormant without their env
gates): WADA_P4_POKE_TRACE (DCS/expander/disp-on-off tracing + a DSI host
error-register poller), WADA_SD_KHZ / WADA_SD_1BIT (SD primary-rung derates),
WADA_DSI_MBPS (lane-rate override; default back at the vendor 1000 -- the 750
experiment changed nothing and is documented in the driver).

Verified on device: no flash on the contact-churn trigger with the store
internal (the previously 100%-reproducing trigger), 689/689 contacts after
migration, tiles still on SD.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 14:50:37 +02:00

132 lines
5.9 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
#if defined(HAS_TDISPLAY_P4)
#include "Xl9535.h"
#include <Arduino.h>
#include <Wire.h>
// 9535 register addresses
static constexpr uint8_t REG_OUT0 = 0x02; // out1 = 0x03
static constexpr uint8_t REG_CFG0 = 0x06; // cfg1 = 0x07 (bit: 1=input, 0=output)
void Xl9535::writeReg(uint8_t reg, uint8_t val) {
Wire.beginTransmission(_addr);
Wire.write(reg);
Wire.write(val);
Wire.endTransmission();
}
uint8_t Xl9535::readReg(uint8_t reg) {
Wire.beginTransmission(_addr);
Wire.write(reg);
if (Wire.endTransmission(false) != 0) return 0xFF;
Wire.requestFrom((int)_addr, 1);
return Wire.available() ? Wire.read() : 0xFF;
}
bool Xl9535::begin(uint8_t sda, uint8_t scl, uint8_t addr) {
_addr = addr;
Wire.begin(sda, scl);
Wire.setClock(400000);
// probe
Wire.beginTransmission(_addr);
_ok = (Wire.endTransmission() == 0);
if (!_ok) { Serial.printf("[XL9535] not found at 0x%02X (SDA=%d SCL=%d)\n", _addr, sda, scl); return false; }
// start with outputs high, everything as output (we'll flip the few inputs in powerOnSequence)
_out[0] = _out[1] = 0xFF;
_cfg[0] = _cfg[1] = 0x00; // all outputs by default
writeReg(REG_OUT0, _out[0]); writeReg(REG_OUT0 + 1, _out[1]);
writeReg(REG_CFG0, _cfg[0]); writeReg(REG_CFG0 + 1, _cfg[1]);
Serial.println("[XL9535] up");
return true;
}
void Xl9535::setDir(uint8_t io, bool input) {
uint8_t port, bit; split(io, port, bit);
if (input) _cfg[port] |= (1 << bit);
else _cfg[port] &= ~(1 << bit);
writeReg(REG_CFG0 + port, _cfg[port]);
}
void Xl9535::write(uint8_t io, bool high) {
uint8_t port, bit; split(io, port, bit);
if (high) _out[port] |= (1 << bit);
else _out[port] &= ~(1 << bit);
#if defined(TDP4_POKE_TRACE)
// #167 hunt: any expander write while the panel streams is a glitch suspect (this is an
// UNLOCKED shadow RMW -- a cross-task race can transiently drop SCREEN_RST or a rail bit).
printf("[XLW] %lu io=%u v=%d out=%02X%02X\n", (unsigned long)millis(), io, (int)high, _out[1], _out[0]);
#endif
writeReg(REG_OUT0 + port, _out[port]);
}
bool Xl9535::read(uint8_t io) {
uint8_t port, bit; split(io, port, bit);
return (readReg(port /*in0=0x00,in1=0x01*/) >> bit) & 1;
}
void Xl9535::sx1262Reset() {
write(IO_SX1262_RST, false); delay(2);
write(IO_SX1262_RST, true); delay(5);
}
void Xl9535::powerOnSequence() {
if (!_ok) return;
// inputs: touch INT, ext-sensor INT, RTC INT, SX1262 DIO1
setDir(IO_TOUCH_INT, true);
setDir(IO_EXT_SENSOR_INT, true);
setDir(IO_RTC_INT, true);
setDir(IO_SX1262_DIO1, true);
// Power-rail bring-up — replicated EXACTLY from the tested-working LilyGo/Meck-P4 screen_lvgl
// sequence. The rails are power-CYCLED with 200 ms settling and end in specific states (VCCA LOW,
// 5V HIGH, 3V3 LOW); "just set them all high" leaves the AMOLED dark. Do not reorder / drop delays.
setDir(IO_VCCA_EN, false); setDir(IO_5V_EN, false); setDir(IO_3V3_EN, false);
setDir(IO_C6_EN, false); // C6 enable is an output — reset-pulsed at the end of this sequence
// GPS wake: HIGH = L76K awake (LilyGo's l76k example writes it HIGH, commented "turn off sleep").
// We used to park it LOW = module in sleep -> UART silent -> "no GPS detected" (SD_EN's twin).
write(IO_GPS_WAKE, true);
write(IO_VCCA_EN, false); // VCCA -> LOW (final)
write(IO_5V_EN, true); delay(200); // 5V power-cycle: HIGH -> LOW -> HIGH
write(IO_5V_EN, false); delay(200);
write(IO_5V_EN, true);
write(IO_3V3_EN, false); delay(200); // 3V3 power-cycle: LOW -> HIGH -> LOW
write(IO_3V3_EN, true); delay(200);
write(IO_3V3_EN, false);
delay(200);
// Board peripherals we still need (Meck's display-only example omits these): the SX1262.
// SD_EN (IO15) is an ACTIVE-LOW load-switch enable for the SD slot's VDD (pad-sweep proven
// 2026-07-15: SD_EN high -> all six SD pads clamp LOW through the unpowered card's ESD diodes,
// even against the P4's internal pull-ups; SD_EN low or Hi-Z -> all pads high, card powered).
// Meck never touches it because the XL9535 powers up all-inputs (Hi-Z) and the board strap
// defaults the switch ON — but OUR begin() parks every pin output-HIGH, which turned the slot
// OFF and made every mount fail (0x107 OCR timeout / 0x109 CRC). Drive it LOW, always.
write(IO_SD_EN, false); // SD slot power ON (active-low)
write(IO_SX1262_RST, true); // park high; radio_init pulses it
// Antenna select: park on the on-board antenna (RF1) before the radio exists, so the first
// transmit after any boot cannot key the PA into a possibly-empty external socket. LilyGo's own
// XL9535 init preloads this same level as its safe state. Choosing the external antenna is a
// deliberate, confirmed, session-only action from Settings -> Radio.
write(IO_RF_SWITCH, INTERNAL_LEVEL);
write(IO_TOUCH_RST, true); // release touch (HI8561 driver probes it)
// NB: SCREEN_RST is intentionally NOT touched here — RM69A10Display::begin() sequences it with
// the DSI-PHY LDO (HIGH->LOW->HIGH, 200 ms each), exactly as the working Meck-P4 app_main does.
// ESP32-C6 reset + enable: pulse C6_EN (IO14) HIGH->LOW->HIGH (100 ms each) + ~1 s so the C6 boots
// its ESP-AT firmware, which the c6_at component then drives over SDIO (AT-based Wi-Fi/BLE). C6_EN IS
// the reset (no separate line). Doing it here means the C6 is booting while the rest of the app inits.
#ifdef TDP4_C6_FLASH_HELPER
// C6-FLASH HELPER: do NOT reset-pulse the C6. C6_EN stays HIGH (powered) from begin()'s default, so
// the C6 has power but is never yanked out of a user-held ROM download mode by a P4 (re)boot.
Serial.println("[XL9535] power-on done — C6 reset SKIPPED (flash-helper build; C6 left free)");
#else
write(IO_C6_EN, true); delay(100);
write(IO_C6_EN, false); delay(100);
write(IO_C6_EN, true); delay(100);
Serial.println("[XL9535] power-on sequence done (C6 enabled for ESP-AT)");
#endif
}
#endif // HAS_TDISPLAY_P4