diff --git a/include/lv_conf.h b/include/lv_conf.h index 12e5287..4c330f7 100644 --- a/include/lv_conf.h +++ b/include/lv_conf.h @@ -52,10 +52,11 @@ #define LV_FONT_MONTSERRAT_12 1 #define LV_FONT_MONTSERRAT_14 1 #define LV_FONT_MONTSERRAT_16 1 -/* Larger Montserrat sizes for the Tanmatsu's crisp "UI size" (Large/Huge) — render the UI bigger - * at native resolution instead of upscaling a low-res frame. Gated to the Tanmatsu so the - * flash-tighter S3 touch builds don't pay for fonts they never use. */ -#if defined(HAS_TANMATSU) +/* Larger Montserrat sizes for the large-screen boards' crisp "UI size" (Large/Huge) — render the + * UI bigger at native resolution instead of upscaling a low-res frame. Gated to the CAP_LARGE_SCREEN + * P4 boards (Tanmatsu + T-Display P4) so the flash-tighter S3 touch builds don't pay for fonts they + * never use. (device_caps.h isn't included here, so match on the board macros directly.) */ +#if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) #define LV_FONT_MONTSERRAT_18 1 #define LV_FONT_MONTSERRAT_20 1 #define LV_FONT_MONTSERRAT_24 1 diff --git a/platformio.ini b/platformio.ini index 14d255a..07c6d00 100644 --- a/platformio.ini +++ b/platformio.ini @@ -262,7 +262,12 @@ build_flags = ; touch (CHSC6x) + board I2C unified on GPIO17/18; micro-SD chip-select -D PIN_TOUCH_SDA=17 -D PIN_TOUCH_SCL=18 - -D PIN_TOUCH_RST=21 + ; RST=-1 ON PURPOSE (tester repro fix): the Expansion V2 wires the CHSC6x reset to GPIO21 — the + ; SAME line as the ST7789 reset. The chsc6x driver hard-pulses its RST at init (+3 retry sites), + ; which RESET the LCD mid-boot and wiped its init: logo OK -> screen black -> white/garbled rows + ; on any redraw. The LGFX display init already resets both chips via pin 21; the touch driver must + ; never drive it (-1 = every pulse in chsc6x_platform no-ops). + -D PIN_TOUCH_RST=-1 -D PIN_TOUCH_INT=43 -D PIN_BOARD_SDA=17 -D PIN_BOARD_SCL=18 diff --git a/src/DataStore.cpp b/src/DataStore.cpp index c898020..cfe244c 100644 --- a/src/DataStore.cpp +++ b/src/DataStore.cpp @@ -5,7 +5,7 @@ #include #include "helpers/esp32/WdtHeavyGuard.h" // suspend core-0 idle WDT during the (SPIFFS-GC-prone) contact write #endif -#if defined(HAS_TANMATSU) +#if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) #include #endif @@ -835,8 +835,8 @@ bool DataStore::useSdStorage() { } #endif -#if defined(HAS_TANMATSU) -// Tanmatsu: same full-store adoption as useSdStorage(), but on the SD_MMC slot. +#if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) +// P4 boards: same full-store adoption as useSdStorage(), but on the SD_MMC slot. // The internal FFat 'locfd' has a broken FAT metadata layer (f_stat/exists lie — // see the tile-cache notes), and the exists()-gated identity + prefs loads that // "worked" at -Og read different garbage at -Os and came up empty: fresh node diff --git a/src/DataStore.h b/src/DataStore.h index 0b5ee52..478c096 100644 --- a/src/DataStore.h +++ b/src/DataStore.h @@ -43,9 +43,9 @@ public: // if the dir can't be created. No-op on non-ESP32. bool useSdStorage(); #endif -#if defined(HAS_TANMATSU) - // Full-store adoption of the SD_MMC card (Tanmatsu): identity + prefs move off - // the broken-metadata internal FFat (its exists()/f_stat lie, which made the +#if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) + // Full-store adoption of the SD_MMC card (the P4 boards): identity + prefs move + // off the broken-metadata internal FFat (its exists()/f_stat lie, which made the // gated loads come up empty). Caller migrates FFat-resident files first. bool useSdMmcStorage(); #endif diff --git a/src/MyMesh.cpp b/src/MyMesh.cpp index c336483..e0fd1ee 100644 --- a/src/MyMesh.cpp +++ b/src/MyMesh.cpp @@ -8,8 +8,8 @@ #include // gmtime_r for the "clock" CLI command #ifdef ESP32 #include // esp_restart for the "bootloader" CLI command -#if !defined(HAS_TANMATSU) -#include // RTC_CNTL_OPTION1_REG / FORCE_DOWNLOAD_BOOT (S3-only; P4 lacks it) +#if !defined(HAS_TANMATSU) && !defined(HAS_TDISPLAY_P4) +#include // RTC_CNTL_OPTION1_REG / FORCE_DOWNLOAD_BOOT (S3-only; both P4 boards lack it) #endif #endif #include @@ -586,7 +586,7 @@ bool MyMesh::handleMeshcomodCommand(const char* text, int text_len) { // rest of RTC_CNTL_OPTION1 and wedges the RTC so esp_restart() hangs instead // of resetting (that was the earlier "freeze"). board.reboot() == esp_restart, // the proven reset path on this board; the RTC bit survives it. -#if !defined(HAS_TANMATSU) +#if !defined(HAS_TANMATSU) && !defined(HAS_TDISPLAY_P4) uint32_t opt1 = REG_READ(RTC_CNTL_OPTION1_REG); REG_WRITE(RTC_CNTL_OPTION1_REG, opt1 | RTC_CNTL_FORCE_DOWNLOAD_BOOT); #endif diff --git a/src/helpers/ClockFloorRTC.h b/src/helpers/ClockFloorRTC.h index 09c6173..7163786 100644 --- a/src/helpers/ClockFloorRTC.h +++ b/src/helpers/ClockFloorRTC.h @@ -35,21 +35,40 @@ public: static constexpr uint32_t MIN_VALID_EPOCH = 1715770351UL; // 15 May 2024 — the core's own unset-clock seed static constexpr uint32_t TRUSTED_BACK_CAP = 10UL * 60UL; + // High-side twin of MIN_VALID_EPOCH: an external RTC chip with corrupted/unset date + // registers can read as a *future* date (seen live: a T-Display P4's PCF8563 came back + // from a reboot asserting 2043). Future garbage is worse than past garbage — it passes + // the MIN check, latches the ratchet, and every timestamp sticks years ahead until a + // >10 min backward set arrives. Anything past ~(build year + 3) cannot be a real clock. + static constexpr uint32_t BUILD_YEAR = + (uint32_t)(__DATE__[7]-'0')*1000u + (uint32_t)(__DATE__[8]-'0')*100u + + (uint32_t)(__DATE__[9]-'0')*10u + (uint32_t)(__DATE__[10]-'0'); + static constexpr uint32_t MAX_PLAUSIBLE_EPOCH = (BUILD_YEAR + 3u - 1970u) * 31557600u; + uint32_t getCurrentTime() override { uint32_t t = AutoDiscoverRTCClock::getCurrentTime(); + if (t > MAX_PLAUSIBLE_EPOCH) { + // Hardware clock is asserting garbage-future: ignore the read entirely and hold at + // the last trusted point (or the core's unset seed) until a real source sets us. + return (_floor >= MIN_VALID_EPOCH) ? _floor : MIN_VALID_EPOCH; + } if (t < _floor) return _floor; _floor = t; return t; } void setCurrentTime(uint32_t time) override { - if (time < MIN_VALID_EPOCH) return; // garbage set — ignore, whatever the source + if (time < MIN_VALID_EPOCH || time > MAX_PLAUSIBLE_EPOCH) return; // garbage set — ignore, whatever the source AutoDiscoverRTCClock::setCurrentTime(time); if (_floor > time + TRUSTED_BACK_CAP) _floor = time; } // Boot-time seed from the persisted floor (only ever raises), and the getter // the persister reads back. Benign u32 races: both cores may touch _floor. - void seedFloor(uint32_t persisted) { if (persisted > _floor) _floor = persisted; } + // A poisoned persisted value (saved while the hw clock asserted garbage) is refused. + void seedFloor(uint32_t persisted) { + if (persisted > MAX_PLAUSIBLE_EPOCH) return; + if (persisted > _floor) _floor = persisted; + } uint32_t getFloor() const { return _floor; } }; diff --git a/src/helpers/esp32/MqttBridge.cpp b/src/helpers/esp32/MqttBridge.cpp index bf8b55e..7bd7661 100644 --- a/src/helpers/esp32/MqttBridge.cpp +++ b/src/helpers/esp32/MqttBridge.cpp @@ -5,7 +5,7 @@ // makes MqttBridge a no-op stub on the Tanmatsu, and the real bridge below on all other boards. MqttBridge mqtt_bridge; -#if !defined(HAS_TANMATSU) // ---- real PubSubClient/WiFiClient implementation; NOT built on the P4 ---- +#if !defined(HAS_TANMATSU) && !defined(HAS_TDISPLAY_P4) // ---- real PubSubClient/WiFiClient implementation; NOT built on the P4 boards ---- #include #include #include diff --git a/src/helpers/esp32/MqttBridge.h b/src/helpers/esp32/MqttBridge.h index 620a102..751eed7 100644 --- a/src/helpers/esp32/MqttBridge.h +++ b/src/helpers/esp32/MqttBridge.h @@ -3,12 +3,12 @@ #include -#if defined(HAS_TANMATSU) -// ---- MQTT DISABLED on the Tanmatsu (ESP32-P4) ---- +#if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) +// ---- MQTT DISABLED on the ESP32-P4 boards (Tanmatsu + T-Display P4) ---- // The P4's Wi-Fi runs through the esp-hosted C6 co-processor; the Arduino WiFiClient / PubSubClient // path this bridge uses is not wired for that and crashed at boot. Provide a no-op bridge with the // SAME public API so the shared companion + settings code links without pulling PubSubClient into -// the P4 build. Re-enable once esp-hosted TCP is proven on the Tanmatsu. +// the P4 build. Re-enable once esp-hosted TCP is proven on the P4. class MqttBridge { public: void begin(const char* /*nodeHex*/) {} diff --git a/src/helpers/esp32/MultiTransportCompanionInterface.cpp b/src/helpers/esp32/MultiTransportCompanionInterface.cpp index bb8bfb7..d65404e 100644 --- a/src/helpers/esp32/MultiTransportCompanionInterface.cpp +++ b/src/helpers/esp32/MultiTransportCompanionInterface.cpp @@ -33,6 +33,19 @@ void MultiTransportCompanionInterface::begin(Stream& usb_serial, uint16_t tcp_po } void MultiTransportCompanionInterface::startTcpServer(bool wifi_connected) { +#if defined(HAS_TDISPLAY_P4) + // ESP-AT allows ONE listening port, so the router server (_p4_srv) owns it and dispatches + // each inbound connection by first byte — see p4RouteClients(). _tcp/_ws never listen + // themselves here; started flags only mark them willing to adopt routed clients. + if (_tcp_enabled && !_tcp_started && _tcp_port != 0) { + _p4_srv.begin(_tcp_port); + _tcp_started = true; + } + if (_tcp_enabled && !_ws_started && _ws_port != 0 && wifi_connected) { + _ws_started = true; // enables tickHandshake + the mirror stream task; _ws itself stays listen-less + printf("[WS] P4 router: web UI shares TCP:%u (first-byte dispatch)\n", (unsigned)_tcp_port); + } +#else if (_tcp_enabled && !_tcp_started && _tcp_port != 0) { _tcp.begin(_tcp_port); _tcp_started = true; @@ -42,8 +55,58 @@ void MultiTransportCompanionInterface::startTcpServer(bool wifi_connected) { _ws.begin(_ws_port); _ws_started = true; } +#endif } +#if defined(HAS_TDISPLAY_P4) +// One AT listener serves BOTH protocols. Accept every inbound connection here, peek its first +// byte, and hand the socket to the right server: HTTP requests start with an ASCII verb +// (GET/HEAD/POST/PUT/OPTIONS/DELETE...) and cover the web viewer page, the mirror/terminal +// WebSockets and the WS companion; companion frames from the phone app always start with '<'. +// A peer that stays silent gets the legacy benefit of the doubt after 3 s: some companion +// clients connect and only listen for pushes without ever sending. +void MultiTransportCompanionInterface::p4RouteClients() { + if (!_tcp_started && !_ws_started) return; + const int NPEND = (int)(sizeof(_p4_pend) / sizeof(_p4_pend[0])); + while (_p4_srv.hasClient()) { + int slot = -1; + for (int i = 0; i < NPEND; i++) { + if (!_p4_pend[i].used) { + slot = i; + break; + } + } + if (slot < 0) break; // all pending slots mid-peek; the accept FIFO holds the rest + WiFiClient incoming = _p4_srv.accept(); + if (!incoming) continue; + _p4_pend[slot].c = incoming; + _p4_pend[slot].ms = millis(); + _p4_pend[slot].used = true; + } + for (int i = 0; i < NPEND; i++) { + if (!_p4_pend[i].used) continue; + WiFiClient& c = _p4_pend[i].c; + if (!c.connected()) { + c.stop(); + _p4_pend[i].used = false; + continue; + } + int b = c.peek(); + bool is_http = (b == 'G' || b == 'H' || b == 'P' || b == 'O' || b == 'D'); + if (b < 0 && millis() - _p4_pend[i].ms < 3000) continue; // no first byte yet + if (is_http && _ws_started) { + _ws.adoptClient(c); + } else if (!is_http && _tcp_started) { + _tcp.adoptClient(c); + } else { + c.stop(); // the matching server is suspended (OTA) — refuse politely + } + _p4_pend[i].used = false; + _p4_pend[i].c = WiFiClient(); // drop our shared link ref; the adopting server holds its own + } +} +#endif + // Web-mirror streaming runs on CORE 0 (parallel to the UI on core 1) so the browser's // socket stays fed at full link speed even while core 1 is busy with a heavy LVGL render. // The WS client array is guarded by WebSocketCompanionServer's recursive mutex, so this @@ -58,6 +121,9 @@ static void wsMirrorStreamTask(void* arg) { } void MultiTransportCompanionInterface::tickWebSocketHandshake() { +#if defined(HAS_TDISPLAY_P4) + p4RouteClients(); // dispatch newly accepted AT-listener connections to _tcp / _ws +#endif if (_ws_started) { _ws.tickHandshake(); // accept + WS handshake stay on core 1 (the main loop) if (!_mirror_task) { // spawn the core-0 streamer once, on first tick after the WS server is up @@ -76,6 +142,16 @@ void MultiTransportCompanionInterface::stopTcpServer() { _tcp.stop(); _tcp_started = false; } +#if defined(HAS_TDISPLAY_P4) + _p4_srv.stop(); // the router owns the AT listener (see startTcpServer) + for (size_t i = 0; i < sizeof(_p4_pend) / sizeof(_p4_pend[0]); i++) { + if (_p4_pend[i].used) { + _p4_pend[i].c.stop(); + _p4_pend[i].c = WiFiClient(); + _p4_pend[i].used = false; + } + } +#endif _tcp_enabled = false; } @@ -251,14 +327,22 @@ void MultiTransportCompanionInterface::restoreAfterHttpOta() { } #endif if (_ota_tcp_suspended) { +#if defined(HAS_TDISPLAY_P4) + _tcp_started = true; // the router still owns the AT listener; just resume adopting +#else _tcp.begin(_tcp_port); _tcp_started = true; +#endif _ota_tcp_suspended = false; meshcoreRepeaterTcpOtaEmitLine("OTA: restored companion TCP server"); } if (_ota_ws_suspended) { +#if defined(HAS_TDISPLAY_P4) + _ws_started = true; // ditto — never begin a second AT listener on _ws_port +#else _ws.begin(_ws_port); _ws_started = true; +#endif _ota_ws_suspended = false; meshcoreRepeaterTcpOtaEmitLine("OTA: restored companion WebSocket server"); } diff --git a/src/helpers/esp32/MultiTransportCompanionInterface.h b/src/helpers/esp32/MultiTransportCompanionInterface.h index e223ab3..aabb5e4 100644 --- a/src/helpers/esp32/MultiTransportCompanionInterface.h +++ b/src/helpers/esp32/MultiTransportCompanionInterface.h @@ -39,7 +39,16 @@ public: void enableBle() override; void disableBle() override; bool isBleEnabled() const override { return _ble_enabled; } +#if defined(HAS_TDISPLAY_P4) + // T-Display P4: the factory C6 ESP-AT firmware has its BLE advertising commands stubbed + // (BLEADVDATA/ADVSTART all ERROR — Meck-P4 hit the same wall and ships Wi-Fi companion), and + // reflashing the C6 is ruled out for users. Phone pairing on this board = Wi-Fi (TCP:5000) or + // USB; the UI hides its Bluetooth toggles off this. The MeshCore GATT table stays pre-provisioned + // on the C6 (c6_at) so a future LilyGo AT firmware with working advertising lights up cheaply. + bool hasBleCapability() const override { return false; } +#else bool hasBleCapability() const override { return true; } +#endif bool getBlePeerAddress(char* buf, size_t len) const override; #endif @@ -47,7 +56,13 @@ public: void disableTcp() override; bool isTcpEnabled() const override { return _tcp_enabled; } bool isWsStarted() const override { return _ws_started; } +#if defined(HAS_TDISPLAY_P4) + // The web UI shares the companion TCP port (single AT listener + first-byte router), + // so that's the port to show/open — _ws_port is never listened on here. + uint16_t getWsPort() const override { return _tcp_port; } +#else uint16_t getWsPort() const override { return _ws_port; } +#endif int getWsConnectedCount() const override { return _ws.connectedCount(); } /** Accept WebSocket clients / handshakes; call from main loop. */ void tickWebSocketHandshake() override; @@ -95,6 +110,18 @@ private: uint16_t _ws_port; bool _tcp_started; bool _ws_started; +#if defined(HAS_TDISPLAY_P4) + // ESP-AT allows ONE listening port, so this router server owns it: every inbound + // connection is accepted here, held briefly, and dispatched on its first byte — + // an HTTP verb ('G','H','P','O','D') goes to _ws (web mirror/VNC/remote/terminal + + // WS companion), everything else ('<' companion frames) to _tcp (phone app). + // _tcp/_ws never begin() their own listeners on this board (their C6Server stays + // inert); they receive clients via adoptClient(). + WiFiServer _p4_srv; + struct P4PendingClient { WiFiClient c; uint32_t ms = 0; bool used = false; }; + P4PendingClient _p4_pend[3]; + void p4RouteClients(); +#endif void* _mirror_task = nullptr; // TaskHandle_t for the core-0 web-mirror stream task (created once) bool _tcp_enabled; // if false, startTcpServer() no-ops until enableTcp() bool _isEnabled; diff --git a/src/helpers/esp32/TCPCompanionServer.cpp b/src/helpers/esp32/TCPCompanionServer.cpp index 29696e6..ff0479c 100644 --- a/src/helpers/esp32/TCPCompanionServer.cpp +++ b/src/helpers/esp32/TCPCompanionServer.cpp @@ -17,6 +17,12 @@ // pocket keeps its socket "connected" but unwritable, and every mesh-RX push then // stalls the loop thread (and the UI) for seconds. static bool socketWritableNow(WiFiClient& client) { +#if defined(HAS_TDISPLAY_P4) + // C6/AT transport: no lwIP fd to select() on (fd() is -1 by design). Writes go through the AT + // worker whose CIPSEND exchange is itself bounded (the C6 buffers TX), so liveness is the only + // cheap pre-check here. + return client.connected(); +#else int fd = client.fd(); if (fd < 0) return false; fd_set wset; @@ -26,6 +32,7 @@ static bool socketWritableNow(WiFiClient& client) { tv.tv_sec = 0; tv.tv_usec = 0; return select(fd + 1, NULL, &wset, NULL, &tv) > 0 && FD_ISSET(fd, &wset); +#endif } static bool writeAllBytes(WiFiClient& client, const uint8_t* buf, size_t len, uint32_t timeout_ms) { @@ -69,26 +76,32 @@ void TCPCompanionServer::stop() { } } +void TCPCompanionServer::adoptClient(WiFiClient& incoming) { + // Slot-insert an already-accepted connection. Used by our own accept loop below AND by the + // T-Display P4's first-byte router (one shared AT listener dispatches companion vs web). + int slot = -1; + for (int i = 0; i < TCP_COMPANION_MAX_CLIENTS; i++) { + if (!_clients[i].in_use) { + slot = i; + break; + } + } + if (slot >= 0) { + _clients[slot].client = incoming; + _clients[slot].in_use = true; + _clients[slot].state = RECV_STATE_IDLE; + _clients[slot].rx_len = 0; + _clients[slot].stall_ms = 0; + } else { + incoming.stop(); + } +} + void TCPCompanionServer::acceptNewClients() { while (_server.hasClient()) { WiFiClient incoming = _server.accept(); if (!incoming) continue; - int slot = -1; - for (int i = 0; i < TCP_COMPANION_MAX_CLIENTS; i++) { - if (!_clients[i].in_use) { - slot = i; - break; - } - } - if (slot >= 0) { - _clients[slot].client = incoming; - _clients[slot].in_use = true; - _clients[slot].state = RECV_STATE_IDLE; - _clients[slot].rx_len = 0; - _clients[slot].stall_ms = 0; - } else { - incoming.stop(); - } + adoptClient(incoming); } } diff --git a/src/helpers/esp32/TCPCompanionServer.h b/src/helpers/esp32/TCPCompanionServer.h index b508db5..5269d5c 100644 --- a/src/helpers/esp32/TCPCompanionServer.h +++ b/src/helpers/esp32/TCPCompanionServer.h @@ -3,6 +3,16 @@ #include #include +#if defined(HAS_TDISPLAY_P4) + // T-Display P4: TCP/IP lives on the C6 (ESP-AT over SDIO) — rebind the server + client types to + // the c6_at facades BEFORE the class definitions below so every TU sees one consistent layout. + // Inbound listening = AT+CIPSERVER (single port; it's this companion server's). + #include + #include + #define WiFiClient C6Client + #define WiFiServer C6Server +#endif + #ifndef TCP_COMPANION_MAX_CLIENTS #define TCP_COMPANION_MAX_CLIENTS 3 #endif @@ -43,6 +53,10 @@ public: int connectedCount() const; void disconnectClient(int client_index); + // Slot-insert an already-accepted connection (stops it if no slot is free). Lets an + // external router (T-Display P4: one shared AT listener) hand us companion clients. + void adoptClient(WiFiClient& incoming); + private: WiFiServer _server; mutable TCPClientState _clients[TCP_COMPANION_MAX_CLIENTS]; diff --git a/src/helpers/esp32/WebSocketCompanionServer.cpp b/src/helpers/esp32/WebSocketCompanionServer.cpp index bacedb5..841892b 100644 --- a/src/helpers/esp32/WebSocketCompanionServer.cpp +++ b/src/helpers/esp32/WebSocketCompanionServer.cpp @@ -435,6 +435,12 @@ static void base64Encode20(const uint8_t* in, char* out) { // in TCPCompanionServer.cpp. Without it, WiFiClient::write() against a peer that // stopped ACKing (half-open socket) blocks in 1 s select() waits on the loop thread. static bool socketWritableNow(WiFiClient& client) { +#if defined(HAS_TDISPLAY_P4) + // C6/AT transport: no lwIP fd to select() on (fd() is -1 by design). Writes go through the AT + // worker whose CIPSEND exchange is itself bounded (the C6 buffers TX), so liveness is the only + // cheap pre-check here. + return client.connected(); +#else int fd = client.fd(); if (fd < 0) return false; fd_set wset; @@ -444,6 +450,7 @@ static bool socketWritableNow(WiFiClient& client) { tv.tv_sec = 0; tv.tv_usec = 0; return select(fd + 1, NULL, &wset, NULL, &tv) > 0 && FD_ISSET(fd, &wset); +#endif } static bool writeAllBytes(WiFiClient& client, const uint8_t* buf, size_t len, uint32_t timeout_ms) { @@ -529,48 +536,55 @@ void WebSocketCompanionServer::resumeListen() { Serial.printf("[WS] listen resumed port=%u (plain)\n", (unsigned)_port); } +void WebSocketCompanionServer::adoptClient(WiFiClient& incoming) { + // Slot-insert an already-accepted connection. Used by our own accept loop below AND by the + // T-Display P4's first-byte router (one shared AT listener dispatches companion vs web). + WsClientsLock _lk(_client_mtx); + int slot = -1; + for (int i = 0; i < WS_COMPANION_MAX_CLIENTS; i++) { + if (!_clients[i].in_use) { + slot = i; + break; + } + } + // No free slot: evict the oldest existing client so a fresh connect can + // recover from stuck slots (half-closed sockets where connected() still + // reports true). Without this, every new connect is accept()ed then + // immediately stop()ed, producing SYN/SYN+ACK/ACK/FIN-ACK with zero data. + if (slot < 0) { + uint32_t now = millis(); + uint32_t oldest_age = 0; + for (int i = 0; i < WS_COMPANION_MAX_CLIENTS; i++) { + uint32_t age = now - _clients[i].accept_ms; + if (slot < 0 || age > oldest_age) { + slot = i; + oldest_age = age; + } + } + _clients[slot].client.stop(); + _clients[slot].in_use = false; + } + _clients[slot].client = incoming; + _clients[slot].in_use = true; + _clients[slot].accept_ms = millis(); + _clients[slot].handshake_done = false; + _clients[slot].handshake_len = 0; + _clients[slot].ws_state = WS_STATE_HEADER_0; + _clients[slot].comp_state = COMP_STATE_IDLE; + _clients[slot].stall_ms = 0; + _clients[slot].is_mirror = false; + _clients[slot].is_term = false; + _clients[slot].meta_sent = false; + _clients[slot].tx_len = 0; // drop any stale pending frame from the previous occupant (tx_buf is reused) + _clients[slot].tx_sent = 0; +} + void WebSocketCompanionServer::acceptNewClients() { if (!_listening) return; while (_server.hasClient()) { WiFiClient incoming = _server.accept(); if (!incoming) continue; - int slot = -1; - for (int i = 0; i < WS_COMPANION_MAX_CLIENTS; i++) { - if (!_clients[i].in_use) { - slot = i; - break; - } - } - // No free slot: evict the oldest existing client so a fresh connect can - // recover from stuck slots (half-closed sockets where connected() still - // reports true). Without this, every new connect is accept()ed then - // immediately stop()ed, producing SYN/SYN+ACK/ACK/FIN-ACK with zero data. - if (slot < 0) { - uint32_t now = millis(); - uint32_t oldest_age = 0; - for (int i = 0; i < WS_COMPANION_MAX_CLIENTS; i++) { - uint32_t age = now - _clients[i].accept_ms; - if (slot < 0 || age > oldest_age) { - slot = i; - oldest_age = age; - } - } - _clients[slot].client.stop(); - _clients[slot].in_use = false; - } - _clients[slot].client = incoming; - _clients[slot].in_use = true; - _clients[slot].accept_ms = millis(); - _clients[slot].handshake_done = false; - _clients[slot].handshake_len = 0; - _clients[slot].ws_state = WS_STATE_HEADER_0; - _clients[slot].comp_state = COMP_STATE_IDLE; - _clients[slot].stall_ms = 0; - _clients[slot].is_mirror = false; - _clients[slot].is_term = false; - _clients[slot].meta_sent = false; - _clients[slot].tx_len = 0; // drop any stale pending frame from the previous occupant (tx_buf is reused) - _clients[slot].tx_sent = 0; + adoptClient(incoming); } } @@ -911,6 +925,22 @@ void WebSocketCompanionServer::drainClientTx(int idx) { WSClientState* c = &_clients[idx]; if (!c->in_use || c->tx_len == 0) return; if (!c->client.connected()) { c->tx_len = c->tx_sent = 0; return; } +#if defined(HAS_TDISPLAY_P4) + // AT transport: write() is a BLOCKING worker round-trip (there is no lwIP buffer to + // fill non-blockingly), and we run with _client_mtx held — draining a whole band here + // starved the main loop (tickHandshake / pollRecvFrame / mesh) and froze the device. + // Send ONE CIPSEND-sized chunk per call; tx_sent resumes next pass. serviceMirror's + // all-idle check then also self-limits to one in-flight band. A failed chunk means the + // worker already waited out its SEND-OK window (seconds) — retrying a sick browser tab + // would stall the whole AT link every pass, so drop the client immediately (the viewer + // page auto-reconnects). + size_t chunk = c->tx_len - c->tx_sent; + if (chunk > 1436) chunk = 1436; // == c6_at SOCK_PULL_MAX: exactly one AT round-trip + size_t n = c->client.write(c->tx_buf + c->tx_sent, chunk); + if (n == 0) { disconnectClient(idx); return; } + c->tx_sent += (uint16_t)n; + if (c->tx_sent >= c->tx_len) { c->tx_len = 0; c->tx_sent = 0; c->stall_ms = 0; } +#else while (c->tx_sent < c->tx_len && socketWritableNow(c->client)) { size_t n = c->client.write(c->tx_buf + c->tx_sent, c->tx_len - c->tx_sent); if (n == 0) break; @@ -921,6 +951,7 @@ void WebSocketCompanionServer::drainClientTx(int idx) { // stays unwritable for the full drop window is treated as wedged. if (c->stall_ms == 0) c->stall_ms = millis() | 1; else if (millis() - c->stall_ms >= WS_WEDGED_DROP_MS) disconnectClient(idx); +#endif } // Read masked WebSocket binary frames from a mirror client and dispatch pointer diff --git a/src/helpers/esp32/WebSocketCompanionServer.h b/src/helpers/esp32/WebSocketCompanionServer.h index 0a143d4..efc624f 100644 --- a/src/helpers/esp32/WebSocketCompanionServer.h +++ b/src/helpers/esp32/WebSocketCompanionServer.h @@ -5,6 +5,17 @@ #include #include +#if defined(HAS_TDISPLAY_P4) + // Same type rebind as TCPCompanionServer.h (every TU must see one consistent class layout). + // NB: begin() is never called on the P4 — ESP-AT has a single listening port, so a first-byte + // router in MultiTransportCompanionInterface accepts everything on the companion port and + // hands HTTP/WS connections here via adoptClient() (_port stays 0; pause/resumeListen no-op). + #include + #include + #define WiFiClient C6Client + #define WiFiServer C6Server +#endif + class WebMirror; // web-UI mirror bridge (WebMirror.h); included in the .cpp #ifndef WS_COMPANION_MAX_CLIENTS @@ -68,6 +79,10 @@ public: size_t pollRecvFrame(uint8_t dest[], int* client_index_out); /** Accept new clients and prune disconnects; call from main loop for timely handshakes. */ void tickHandshake(); + /** Slot-insert an already-accepted connection (evicts the oldest handshaking client if + * full). Lets an external router (T-Display P4: one shared AT listener) hand us + * HTTP/WS clients that were accepted on the companion port. */ + void adoptClient(WiFiClient& incoming); size_t writeToClient(int client_index, const uint8_t src[], size_t len); size_t writeToAllClients(const uint8_t src[], size_t len); diff --git a/src/helpers/esp32/WifiRuntimeStore.cpp b/src/helpers/esp32/WifiRuntimeStore.cpp index 29a4b15..9ea9377 100644 --- a/src/helpers/esp32/WifiRuntimeStore.cpp +++ b/src/helpers/esp32/WifiRuntimeStore.cpp @@ -5,6 +5,11 @@ #include "SdNvsPrefs.h" // NVS, or SD /meshcomod fallback when NVS is unusable (Launcher) #include #include +#if defined(HAS_TDISPLAY_P4) + // T-Display P4: rebind WiFi.* to the c6_at AT-over-SDIO facade — Arduino's real WiFi.mode()/begin() + // re-init esp_hosted (the C6 runs ESP-AT, not an esp-hosted slave) and panic the P4. P4-only header. + #include +#endif static const char *WIFI_CONFIG_NAMESPACE = "meshcomod"; static const char *WIFI_CONFIG_SSID_KEY = "wifi_ssid"; diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 5a654dc..e421cc9 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -5,6 +5,15 @@ #include "device_caps.h" // CAP_* capability flags (replaces device-name #ifs) +// Port the browser-facing web UI (VNC mirror / remote / terminal viewer page) listens on. +// The T-Display P4's ESP-AT stack allows ONE listening port, so the web UI shares the +// companion TCP port behind a first-byte router (MultiTransportCompanionInterface). +#if defined(HAS_TDISPLAY_P4) + #define WEB_UI_PORT_STR "5000" +#else + #define WEB_UI_PORT_STR "8765" +#endif + #include #include #include @@ -77,10 +86,10 @@ #ifndef PIN_I2S_DOUT #define PIN_I2S_DOUT 6 #endif -#elif defined(HAS_TANMATSU) - #include // the file browser + DataStore use the internal 'locfd' FAT partition - #include // microSD on the P4's SDMMC slot 0 (IOMUX 43/44/39-42); slot 1 = C6 radio - extern bool g_fs_ok; // set in main.cpp once FFat(locfd) is mounted +#elif defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) + #include // internal FAT partition (Tanmatsu 'locfd' / P4 'storage') + #include // microSD on the P4-class boards' SDMMC slot 0; slot 1 = C6 radio + extern bool g_fs_ok; // set in main.cpp once the internal FAT is mounted #endif #include #include // PSRAM-preferred alloc helpers for the map tile cache @@ -105,6 +114,8 @@ #include // badge-bsp-backed DisplayDriver (P4) #elif defined(HAS_RAK_TAP_V2) #include // LovyanGFX FSPI on RAK Tap V2 + #elif defined(HAS_TDISPLAY_P4) + #include // RM69A10 MIPI-DSI on the T-Display P4 #else #include #endif @@ -131,12 +142,26 @@ #endif #include "../helpers/esp32/WifiRuntimeStore.h" // QUOTED: this tree's copy (wifiScan*Active), not the lib's stale one #include "helpers/esp32/MqttBridge.h" + #if defined(HAS_TDISPLAY_P4) + // T-Display P4: the C6 runs ESP-AT, so Arduino's real WiFi object must NEVER be driven (its + // mode()/begin() re-init esp_hosted and panic). These facades rebind every WiFi.* below to the + // c6_at AT-over-SDIO driver — scans/joins become real, status reads come from a cache — and + // every WiFiClient/WiFiClientSecure to AT+CIP sockets (HTTPClient dispatches virtually, so + // tiles/version-check/OTA/reader fetch through the C6; TLS runs ON the C6). MUST stay the + // LAST includes of this block. P4-only: the headers only exist in the P4 build. + #include + #include + #define WiFiClient C6Client + #define WiFiClientSecure C6ClientSecure + #endif #endif #endif #if defined(HAS_TANMATSU) extern TanmatsuDisplay display; #elif defined(HAS_RAK_TAP_V2) || defined(HELTEC_LORA_V4_R8) extern LGFXDisplay display; + #elif defined(HAS_TDISPLAY_P4) + extern RM69A10Display display; #else extern ST7789LCDDisplay display; #endif @@ -443,11 +468,23 @@ static inline lv_coord_t SC(int px) { return (lv_coord_t)((px * s_ui_fscale + 50 // Popup-card dimension scaler: the 800×480 Tanmatsu panel dwarfs dialogs sized for the // 320px T-Deck/V4 — scale their fixed card/menu W/H up so they don't look lost in the middle. // No-op (plain SC) on the smaller boards, so their popups stay byte-for-byte unchanged. -#if CAP_LARGE_SCREEN -static inline lv_coord_t PSC(int px) { return (lv_coord_t)((SC(px) * 17) / 10); } // ~1.7× +// EXCLUDES the round P4: it's CAP_LARGE_SCREEN (for font scaling) but only 284 px wide, so +// the 1.7× would blow fixed-width cards (e.g. PSC(232)=394) far past the screen and clip the +// controls off-side. Plain SC keeps those cards ≤232 px — a comfortable fit on the 284 px panel. +#if CAP_LARGE_SCREEN && !CAP_ROUND_CORNERS +static inline lv_coord_t PSC(int px) { return (lv_coord_t)((SC(px) * 17) / 10); } // ~1.7× — wide Tanmatsu only #else static inline lv_coord_t PSC(int px) { return SC(px); } #endif +// Popup CARD WIDTH — PSC-scaled but clamped so a fixed-width dialog can never exceed the screen. +// On the narrow round P4 (284 px logical) a Large/Huge font scale pushes PSC(232) to ~325-394 px, +// which clipped settings cards off BOTH edges. The clamp keeps a small margin either side; it's a +// no-op on the wide boards (their PSC widths already fit). Height is unaffected (tall screen). +static inline lv_coord_t PCW(int px) { + lv_coord_t w = PSC(px); + lv_coord_t cap = lv_disp_get_hor_res(nullptr) - SC(12); + return (cap > 0 && w > cap) ? cap : w; +} #if CAP_LARGE_SCREEN static lv_font_t g_font_tab; // fixed 16 px tab-bar icon font (montserrat_16 + person glyph) #endif @@ -763,11 +800,50 @@ static void tdeckPreviewWavFile(const char* prefpath) { } #endif // HAS_TDECK_GT911 -// ---- Unified UI notification sound (T-Deck I2S speaker OR Heltec V4, Elecrow M9 piezo) ---- -#if defined(HAS_TDECK_GT911) || defined(HELTEC_V4_BUZZER_PIN) || defined(THINKNODE_M9_BUZZER_PIN) +// ---- Unified UI notification sound (T-Deck I2S speaker, Heltec V4 / Elecrow M9 piezo, +// T-Display P4 ES8311 codec) ---- +#if defined(HAS_TDECK_GT911) || defined(HELTEC_V4_BUZZER_PIN) || defined(THINKNODE_M9_BUZZER_PIN) || defined(HAS_TDISPLAY_P4) #define HAS_UI_SOUND 1 #endif +#if defined(HAS_TDISPLAY_P4) +// T-Display P4: ES8311 codec + NS4150B speaker (P4Audio.cpp in the variant). Same three +// chime patterns as the T-Deck, rendered on a throwaway task so the ~300 ms of blocking +// I2S writes never stalls the UI/mesh loop. Loudness = amplitude from the volume pref +// (the codec DAC volume stays fixed). +#include +static volatile bool s_p4_snd_playing = false; +static volatile int s_p4_snd_slot = TOUCH_SND_MSG; +static volatile int s_p4_snd_vol = 9000; +static void p4NotifyTaskFn(void*) { + const int v = s_p4_snd_vol; + if (s_p4_snd_slot == TOUCH_SND_MEN) { // @-mention: bright 3-note rising arpeggio + p4AudioTone(1318, 70, v); // E6 + p4AudioTone(1760, 70, v); // A6 + p4AudioTone(2349, 130, v); // D7 + } else if (s_p4_snd_slot == TOUCH_SND_DM) { // direct message: distinct rising fifth + p4AudioTone(1047, 90, v); // C6 + p4AudioTone(1568, 120, v); // G6 + } else { // message: the original two-note chime + p4AudioTone(880, 90, v); // A5 + p4AudioTone(1318, 110, v); // E6 + } + s_p4_snd_playing = false; + vTaskDelete(nullptr); +} +static void p4PlayNotifySlot(int slot) { + if (s_p4_snd_playing) return; // already chiming — don't stack tasks + const int pct = (int)touchPrefsGetSoundVolume(); + if (pct <= 0) return; + s_p4_snd_slot = slot; + s_p4_snd_vol = pct * 130; // 0..100 -> 0..13000 amplitude + s_p4_snd_playing = true; + if (xTaskCreate(p4NotifyTaskFn, "notify", 4096, nullptr, 3, nullptr) != pdPASS) { + s_p4_snd_playing = false; // couldn't spawn — skip the chime + } +} +#endif + #if defined(HELTEC_V4_BUZZER_PIN) || defined(THINKNODE_M9_BUZZER_PIN) #if defined(HELTEC_V4_BUZZER_PIN) #define UI_BUZZER_PIN HELTEC_V4_BUZZER_PIN @@ -811,6 +887,8 @@ static void tanBeep(); // I2S notification tick; defined far below (with the C static inline void uiPlaySlot(int slot) { #if defined(HAS_TDECK_GT911) tdeckPlayNotifySlot(slot); +#elif defined(HAS_TDISPLAY_P4) + p4PlayNotifySlot(slot); // ES8311 codec chimes (same patterns as the T-Deck) #elif defined(HELTEC_V4_BUZZER_PIN) || defined(THINKNODE_M9_BUZZER_PIN) v4BuzzerBeep(slot == TOUCH_SND_MEN); // mention = higher trill; msg/DM = the lower chime #elif defined(HAS_TANMATSU) @@ -830,6 +908,10 @@ static void tanBeep(); // declared so the Sound settings page (buildDeviceSettings, above it) can apply the // volume live as the user adjusts it. static void applyVolume(uint8_t pct); +#elif defined(HAS_TDISPLAY_P4) +// Same forward-declaration for the P4 (defined in its apply block far below): the +// Sound settings volume buttons keep the CC slider state in sync through it. +static void applyVolume(uint8_t pct); #endif // ---- misc UI constants ---- @@ -846,6 +928,20 @@ constexpr int SWIPE_SCROLL_STEP = 90; // Runtime (not constexpr) so the UI-scale can grow it to fit bigger status-bar text — set to SC(22) // once at boot in begin(), before the UI is built. Stays 22 on the non-scaled boards. static lv_coord_t STATUSBAR_H = 22; +#if CAP_ROUND_CORNERS +// Two-row status bar for the round-cornered phone panel: row 1 (top) holds the app name + +// clock, row 2 (bottom) holds the wifi/ble/sd/signal/battery cluster. A top pad clears the +// corner arc; a horizontal inset keeps the end glyphs out of the left/right arcs. STATUSBAR_H +// is set to (top pad + two rows) at boot so every page reserves the taller bar, and the bar +// stays this fixed height in ALL states (no *2 doubling — the tall personalities reuse the +// two rows). Text tops (ROW1_Y / ROW2_Y) are tuned so the small g_font glyphs sit centred. +static constexpr lv_coord_t SB_ROW = 22; // one status-bar row +static constexpr lv_coord_t SB_TOP_PAD = 8; // clear the top corner arc +static constexpr lv_coord_t SB_INSET_X = 16; // left/right corner safe-area +static constexpr lv_coord_t SB_ROW1_Y = SB_TOP_PAD + 2; // ~10 text top, row 1 (clock) +static constexpr lv_coord_t SB_ROW2_Y = SB_TOP_PAD + SB_ROW + 3; // ~33 text top, row 2 (name + status) +static void statusBarLayoutTwoRow(int slide); // defined just after buildGlobalStatusBar +#endif // True while the Reader/Web page is collapsed and showing its URL in the status bar's // title zone — updateGlobalStatusBar then hides the clock to make room for the URL. static bool s_reader_bar_url = false; @@ -890,7 +986,13 @@ static bool s_statusbar_tall = false; // centred in the bar; a tap on the bar calls s_apppage_close (see statusBarTapCb). static const char* s_apppage_title = nullptr; static void (*s_apppage_close)() = nullptr; +#if CAP_ROUND_CORNERS +// The round-panel bar is already two rows tall in every state — the tall personalities +// (settings title, inbox actions, open chat) reuse the two rows rather than doubling it. +static inline lv_coord_t statusBarCurH() { return STATUSBAR_H; } +#else static inline lv_coord_t statusBarCurH() { return s_statusbar_tall ? (lv_coord_t)(STATUSBAR_H * 2) : STATUSBAR_H; } +#endif static void statusBarSetTall(bool tall) { s_statusbar_tall = tall; if (g_statusbar.root) lv_obj_set_height(g_statusbar.root, statusBarCurH()); @@ -1016,7 +1118,11 @@ static inline lv_coord_t chatScreenW() { return lv_disp_get_hor_res(nullptr); // bar's lower row is a translucent glass strip (see updateGlobalStatusBar) that the // bubbles scroll UNDER — p.msgs carries a top inset == that row so the newest content // still rests below the bar. +#if CAP_ROUND_CORNERS +static inline lv_coord_t chatBarH() { return STATUSBAR_H; } // fixed two-row bar, no doubling +#else static inline lv_coord_t chatBarH() { return (lv_coord_t)(STATUSBAR_H * 2); } +#endif static inline lv_coord_t chatScreenH() { return lv_disp_get_ver_res(nullptr) - STATUSBAR_H; } static inline lv_coord_t chatKbH() { return chatLandscape() ? (lv_disp_get_ver_res(nullptr) / 2) : CHAT_KB_H; } // The message list spans the FULL height under the header down to the screen bottom @@ -2161,7 +2267,7 @@ static void drawRemotePlaceholder(bool exit_armed = false) { display.setTextSize(1); if (up) { char u[48]; - snprintf(u, sizeof u, "http://%s:8765", WiFi.localIP().toString().c_str()); + snprintf(u, sizeof u, "http://%s:" WEB_UI_PORT_STR, WiFi.localIP().toString().c_str()); display.setColor(DisplayDriver::LIGHT); display.drawTextCentered(W / 2, y, "Open this address in a browser"); display.setColor(DisplayDriver::GREEN); @@ -4692,6 +4798,14 @@ static void kbApplyLayoutForRotation(uint8_t rot) { static void kbApplyRotation(uint8_t rot) { lv_disp_t* disp = lv_disp_get_default(); if (!disp) return; + // This often runs MID-PRESS: the composer focuses on touch-DOWN, and rotating the display + // transforms the live pointer's coordinates — LVGL sees the still-held finger "teleport" by + // hundreds of px and treats it as a huge drag, scroll-throwing lv_scr_act() (the chat overlay + // slides out of the viewport and the tab list slides in; on the P4's 284x616 the jump is a + // full screen — hideKb's scroll_to(0,0) was this same artifact's band-aid). Swallow the rest + // of the gesture BEFORE rotating so the jump is never seen as movement. + lv_indev_t* act = lv_indev_get_act(); + if (act) lv_indev_wait_release(act); // In hardware-landscape the panel is already rotated; applying LVGL software // rotation on top would double-rotate. Software rotation is used ONLY for the // transient keyboard-landscape trick when the base orientation is portrait. @@ -4704,6 +4818,9 @@ static void kbApplyRotation(uint8_t rot) { // orientation so horizontal swipes are detected correctly in landscape. heltecV4CapTouchSetRotation(rot); kbApplyLayoutForRotation(rot); + // Pin the root scroll: any scroll the pre-rotation gesture already banked (or a + // scroll-throw in flight) would leave the viewport parked off the chat page. + lv_obj_scroll_to(lv_scr_act(), 0, 0, LV_ANIM_OFF); // Force a full redraw — without this the previous frame's contents linger // briefly at the new orientation's coordinates. lv_obj_invalidate(lv_scr_act()); @@ -5931,7 +6048,7 @@ static void openThreadActionSheet(int thread_idx, const char* name, bool is_chan // Same metrics as the contact action sheet — the two sheets are siblings and // should read identically. (PSC is a no-op on the smaller boards.) #if CAP_LARGE_SCREEN - const int card_w = PSC(232); + const int card_w = PCW(232); const int btn_h = PSC(30); const int btn_gap = PSC(6); const int title_h = PSC(28); @@ -6605,7 +6722,7 @@ static void openQuickReplyPicker(LvChatPanel* p) { // Bigger on the 800-px Tanmatsu panel; unchanged on the smaller boards. #if CAP_LARGE_SCREEN - const int card_w = PSC(210); + const int card_w = PCW(210); const int btn_h = SC(32); // SC not PSC: the 1.7x PSC boost made this 6-row card taller than the screen const int pad = SC(8); const int title_h = SC(26); @@ -6898,7 +7015,7 @@ static void toggleMentionSoundCb(lv_event_t* e) { if (on) uiPlayMention(); #endif } -#if defined(HAS_TDECK_GT911) || defined(HAS_TANMATSU) +#if defined(HAS_TDECK_GT911) || defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) // Volume +/- step buttons (user_data = step, e.g. +10 / -10). Clamps 0..100, // updates the readout, previews. Simpler + more reliable than a drag slider. static lv_obj_t* s_vol_val_lbl = nullptr; @@ -6911,6 +7028,9 @@ static void volumeStepCb(lv_event_t* e) { #if defined(HAS_TANMATSU) applyVolume((uint8_t)v); // push the new level to the codec live, then preview it tanBeep(); +#elif defined(HAS_TDISPLAY_P4) + applyVolume((uint8_t)v); // keep the CC slider position in sync, then preview + uiPlayNotify(); #else uiPlayNotify(); // preview at the new volume #endif @@ -8042,7 +8162,7 @@ static void openDiscoveredSettingsSheetCb(lv_event_t* e) { lv_obj_add_event_cb(s_disc_settings_root, discSettingsDismissCb, LV_EVENT_CLICKED, nullptr); #if defined(HAS_TANMATSU) - const int card_w = PSC(220); + const int card_w = PCW(220); #else const int card_w = 220; #endif @@ -10425,8 +10545,8 @@ static void buildDeviceSettings(int sec) { } } #endif -#if defined(HAS_TDECK_GT911) || defined(HAS_TANMATSU) - // Volume with - / + step buttons (T-Deck I2S + Tanmatsu codec; the V4 piezo can't vary volume). +#if defined(HAS_TDECK_GT911) || defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) + // Volume with - / + step buttons (T-Deck I2S + Tanmatsu/P4 codec; the V4 piezo can't vary volume). { lv_obj_t* vl = lv_label_create(body); lv_label_set_text(vl, TR("Volume")); @@ -10520,7 +10640,10 @@ static void buildDeviceSettings(int sec) { y += SC(44); } /* Message LED: flash the envelope-icon LED on a new message and softly breathe it while there - are unread messages. Turn off to keep that LED dark. (Tanmatsu only — the others have no such LED.) */ + are unread messages. Turn off to keep that LED dark. (Tanmatsu only — the others have no such + LED. It lives under CAP_LARGE_SCREEN, so it must be gated to HAS_TANMATSU explicitly now that + the T-Display P4 is also a large-screen board.) */ +#if defined(HAS_TANMATSU) { int h = settingsRowLabel(body, y, 6, TR("Message LED"), COLOR_SUB, nullptr, 56); lv_obj_t* sw = lv_switch_create(body); @@ -10529,6 +10652,7 @@ static void buildDeviceSettings(int sec) { lv_obj_add_event_cb(sw, msgLedToggleCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(40, h + 12); } +#endif #endif /* Distance units: OFF = km (default), ON = miles. Applies immediately. */ @@ -11389,7 +11513,7 @@ static void showConfirm(const char* msg, const char* ok_label, SimpleCb on_confi // Card lv_obj_t* card = lv_obj_create(s_confirm_modal); lv_obj_remove_style_all(card); - lv_obj_set_size(card, PSC(210), PSC(160)); + lv_obj_set_size(card, PCW(210), PSC(160)); lv_obj_align(card, LV_ALIGN_CENTER, 0, 0); styleSurface(card, COLOR_PANEL, 12); lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); @@ -11482,6 +11606,23 @@ static void buildBluetoothSettings() { lv_obj_t* body = createSettingsModal("Bluetooth", SettingsModalKind::Bluetooth); int y = 0; + if (!g_lv.task || !g_lv.task->hasBleCapability()) { + // No BLE on this board (T-Display P4: the C6 radio's factory firmware can't do BLE companion). + // An explanation beats a hidden row — users coming from other MeshCore devices WILL look here. + lv_obj_t* why = lv_label_create(body); + lv_label_set_text(why, TR("Bluetooth pairing isn't available on this device.\n\n" + "The radio chip's factory firmware doesn't support it.\n\n" + "Pair the phone app over Wi-Fi instead: connect this device to your " + "network, then add it in the app by its IP address (TCP, port 5000) — " + "shown under Settings > Network. USB works too.")); + lv_label_set_long_mode(why, LV_LABEL_LONG_WRAP); + lv_obj_set_width(why, lv_pct(100)); + lv_obj_set_style_text_color(why, lv_color_hex(COLOR_SUB), LV_PART_MAIN); + lv_obj_set_style_text_font(why, &g_font_12, LV_PART_MAIN); + lv_obj_set_pos(why, 2, y); + return; + } + // ---- Mode line ---- (Wi-Fi + BLE coexist now; show both radios' state) const bool ble_active = g_lv.task && g_lv.task->hasBleCapability() && g_lv.task->isBleEnabled(); lv_obj_t* mode = lv_label_create(body); @@ -12735,7 +12876,7 @@ static void openContactsSearchSheetCb(lv_event_t* e) { lv_obj_add_event_cb(s_contacts_search_sheet, contactsSearchSheetCloseCb, LV_EVENT_CLICKED, nullptr); #if CAP_LARGE_SCREEN - const int card_w = PSC(220); + const int card_w = PCW(220); const int card_h = PSC(130); #else const int card_w = 220; @@ -13470,7 +13611,7 @@ static void openAdminLoginPrompt(const ContactInfo& c) { }, LV_EVENT_CLICKED, nullptr); #if CAP_LARGE_SCREEN - const int card_w = PSC(220); + const int card_w = PCW(220); const int card_h = PSC(180); #else const int card_w = 220; @@ -13896,7 +14037,7 @@ static void losDrawPlot() { // the chart fills the 800×480 panel instead of looking lost. Width, graph // height and every stacked Y offset below all go through PSC() so the plot // stays aligned with its axis labels / sliders / verdict at any board+scale. - const int card_w = PSC(230); + const int card_w = PCW(230); const double self_lat = s_los_self_lat, self_lon = s_los_self_lon; const double peer_lat = s_los_peer_lat, peer_lon = s_los_peer_lon; @@ -14068,7 +14209,7 @@ static void losPeerPlusCb(lv_event_t* e) { static void losRenderResult() { if (!s_los_card) return; lv_obj_t* card = s_los_card; - const int card_w = PSC(230); // matches losDrawPlot()/builder — see PSC() note there + const int card_w = PCW(230); // matches losDrawPlot()/builder — see PSC() note there const int gw = card_w - 16; if (s_los_got < k_los_samples) { @@ -14189,7 +14330,7 @@ static void openLosModal(uint32_t mesh_idx) { // Cap the card to the usable height so it fits the shorter landscape screen // (the graph + controls shrink to match — see chatLandscape() in losDrawPlot // / losRenderResult). - const int card_w = PSC(230); // matches losDrawPlot()/losRenderResult() — see PSC() note in losDrawPlot() + const int card_w = PCW(230); // matches losDrawPlot()/losRenderResult() — see PSC() note in losDrawPlot() int card_h = PSC(248); if (card_h > modalAvailH()) card_h = modalAvailH(); lv_obj_t* card = lv_obj_create(s_los_root); @@ -14306,7 +14447,7 @@ static void openContactActionSheet(uint32_t mesh_idx, bool is_repeater, const ch // widen the card and grow the rows/gaps/title. The non-Tanmatsu values are the // historical integers, untouched (PSC is a no-op there). #if CAP_LARGE_SCREEN - const int card_w = PSC(232); + const int card_w = PCW(232); int btn_h = PSC(30); // vertical metrics are mutable: the shrink-to-fit below trims them int btn_gap = PSC(6); // when the big-screen 1.7x scale would push the sheet off-screen int title_h = PSC(28); @@ -15065,7 +15206,7 @@ static void openAddChannelSheet() { if (hdr_h + rows * (btn_h + row_gap) + pad > modalAvailH()) btn_h = ((modalAvailH() - hdr_h - pad) / rows) - row_gap; if (btn_h < PSC(26)) btn_h = PSC(26); - int card_w = PSC(220); + int card_w = PCW(220); if (card_w > modalAvailW()) card_w = modalAvailW(); const int card_h = hdr_h + rows * (btn_h + row_gap) + pad; lv_obj_t* card = lv_obj_create(s_addch_sheet); @@ -15208,7 +15349,7 @@ static void openShareMyContactPopup() { // the operator wants to dictate it over voice and there's no camera). // Clamp to the usable area and scale the QR to fit so the popup never runs // off the shorter landscape screen. - int card_w = PSC(220); + int card_w = PCW(220); if (card_w > modalAvailW()) card_w = modalAvailW(); int card_h = PSC(270); if (card_h > modalAvailH()) card_h = modalAvailH(); @@ -15975,15 +16116,15 @@ static char s_fm_path[160] = {0}; // current dir within s_fm_fs (e.g. // fmRefresh, so they blip the LED too. #if defined(HAS_TDECK_GT911) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) static inline bool fmIsSd(fs::FS* fs) { return fs == &SD; } // Arduino SD (T-Deck/M9 LoRa bus, V4-R8 TFT bus) -#elif defined(HAS_TANMATSU) +#elif defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) static inline bool fmIsSd(fs::FS* fs) { return fs == &SD_MMC; } // microSD on SDMMC slot 0 #else static inline bool fmIsSd(fs::FS*) { return false; } // Heltec: no Arduino SD global in this FM path #endif static inline void fmMarkSdIo() { if (fmIsSd(s_fm_fs)) markSdIo(); } -#if defined(HAS_TANMATSU) -// Arduino loop-task WDT shim: the Tanmatsu's IDF build doesn't expose feedLoopWDT (the long -// file-copy loop calls it). The IDF task WDT isn't armed on our LVGL loop, so a no-op is safe. +#if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) +// Arduino loop-task WDT shim: the P4 IDF builds (Tanmatsu + T-Display P4) don't expose feedLoopWDT +// (the long file-copy loop calls it). The IDF task WDT isn't armed on our LVGL loop, so a no-op is safe. static inline void feedLoopWDT() {} #endif static char s_fm_store[12] = {0}; // storage label ("Internal" / "SD") @@ -17083,8 +17224,8 @@ static void fmOpenStorage(fs::FS* fs, const char* store, const char* path) { } static void fmInternalClickCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; -#if defined(HAS_TANMATSU) - fmOpenStorage(&FFat, "Internal", "/"); // the internal 'locfd' FAT data partition +#if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) + fmOpenStorage(&FFat, "Internal", "/"); // the internal FAT data partition (locfd / 'storage') #else fmOpenStorage(&SPIFFS, "Internal", "/"); #endif @@ -18373,20 +18514,26 @@ static void fmRefresh() { } // Roots screen: list the available storages. -#if defined(HAS_TANMATSU) -// ---- Tanmatsu microSD (SDMMC slot 0) ------------------------------------------------------------- +#if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) +// ---- Tanmatsu / T-Display P4 microSD (SDMMC slot 0) ---------------------------------------------- // The card sits on the P4's SDMMC *slot 0* (IOMUX pins CLK43/CMD44/D0-3 39-42, selected by the -// BOARD_SDMMC_SLOT=0 build define); slot 1 is the C6 radio (esp-hosted SDIO), which we never touch — -// so mounting here can't disturb Wi-Fi/BLE. External-powered IO (no on-chip LDO), 4-bit, mounted -// LAZILY on first browse so the radio is already up. SD_MMC is the fs::FS the file manager browses, -// exactly like the T-Deck's &SD. +// BOARD_SDMMC_SLOT=0 build define); slot 1 is the C6 radio (esp-hosted / ESP-AT SDIO), which we never +// touch — so mounting here can't disturb Wi-Fi/BLE. 4-bit, mounted LAZILY on first browse (a no-op +// on the T-Display P4, whose main.cpp already mounted the card at boot — SD_MMC.begin early-returns +// when mounted). SD_MMC is the fs::FS the file manager browses, exactly like the T-Deck's &SD. static bool s_tan_sd_mounted = false; static uint64_t s_tan_sd_size = 0; static uint32_t s_tan_sd_retry_after = 0; // backoff so an absent/cold card isn't re-probed every render static bool tanSdTryMount() { if (s_tan_sd_mounted) return true; if (millis() < s_tan_sd_retry_after) return false; +#if defined(HAS_TDISPLAY_P4) + // Hot-insert path (no-op when main.cpp already mounted at boot): 20 MHz like the boot ladder, + // not Arduino's 40 MHz HIGHSPEED default. + if (SD_MMC.begin("/sdcard", false /*4-bit*/, false, SDMMC_FREQ_DEFAULT) && SD_MMC.cardType() != CARD_NONE) { +#else if (SD_MMC.begin("/sdcard", false /*4-bit*/) && SD_MMC.cardType() != CARD_NONE) { +#endif s_tan_sd_mounted = true; s_tan_sd_size = SD_MMC.cardSize(); return true; @@ -18413,8 +18560,8 @@ static void fmShowRoots() { if (s_fm_path_lbl) lv_label_set_text(s_fm_path_lbl, TR("Storage")); char sub[48], us[16], ts[16]; -#if defined(HAS_TANMATSU) - fmFmtSize(FFat.usedBytes(), us, sizeof us); // Tanmatsu "Internal" = the locfd FAT partition +#if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) + fmFmtSize(FFat.usedBytes(), us, sizeof us); // "Internal" = the FAT data partition (locfd / 'storage') fmFmtSize(FFat.totalBytes(), ts, sizeof ts); #else fmFmtSize(SPIFFS.usedBytes(), us, sizeof us); @@ -18443,7 +18590,7 @@ static void fmShowRoots() { lv_obj_add_event_cb(sd, fmSdMountOrFormatCb, LV_EVENT_CLICKED, nullptr); } #endif -#if defined(HAS_TANMATSU) +#if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) // microSD (SDMMC slot 0). Probe outside the mount-backoff window so an absent card doesn't grind // the bus on every render; tapping the row forces a fresh mount attempt (tanSdClickCb clears it). if ((s_tan_sd_mounted || millis() >= s_tan_sd_retry_after) && tanSdTryMount()) { @@ -19235,7 +19382,8 @@ static void readerFetchTaskFn(void*) { else { began = httpc.begin(pcli, url); } if (began) { httpc.addHeader("Accept-Encoding", "identity"); code = httpc.GET(); } if (code == HTTP_CODE_OK) { - WiFiClient* st = httpc.getStreamPtr(); unsigned long t0 = millis(); + auto* st = httpc.getStreamPtr(); // NetworkClient* on arduino 3.x (P4: C6Client behind it) + unsigned long t0 = millis(); while (st && total < rawcap - 1 && (millis() - t0) < 20000) { int a = st->available(); if (a <= 0) { if (!httpc.connected()) break; vTaskDelay(pdMS_TO_TICKS(8)); continue; } @@ -19963,7 +20111,7 @@ static void vncRefresh() { if (s_vnc_url_lbl) { if (wifi) { char u[64]; - snprintf(u, sizeof u, "http://%s:8765/", WiFi.localIP().toString().c_str()); + snprintf(u, sizeof u, "http://%s:" WEB_UI_PORT_STR "/", WiFi.localIP().toString().c_str()); lv_label_set_text(s_vnc_url_lbl, u); lv_obj_set_style_text_color(s_vnc_url_lbl, lv_color_hex(on ? COLOR_ACCENT : COLOR_SUB), LV_PART_MAIN); } else { @@ -20153,7 +20301,7 @@ static void webTerminalToggleCb(lv_event_t* e) { if (on) { char msg[96]; if (WiFi.status() == WL_CONNECTED) - snprintf(msg, sizeof msg, "Terminal on: http://%s:8765/", WiFi.localIP().toString().c_str()); + snprintf(msg, sizeof msg, "Terminal on: http://%s:" WEB_UI_PORT_STR "/", WiFi.localIP().toString().c_str()); else snprintf(msg, sizeof msg, "%s", "Terminal on (connect Wi-Fi to reach it)"); g_lv.task->showAlert(msg, 3200); @@ -20221,7 +20369,7 @@ static void openRemotePage() { lv_obj_set_width(ipval, cw - 20); lv_obj_set_style_text_font(ipval, &g_font_16, LV_PART_MAIN); if (WiFi.status() == WL_CONNECTED && (uint32_t)WiFi.localIP() != 0) { - char u[48]; snprintf(u, sizeof u, "http://%s:8765", WiFi.localIP().toString().c_str()); + char u[48]; snprintf(u, sizeof u, "http://%s:" WEB_UI_PORT_STR, WiFi.localIP().toString().c_str()); lv_label_set_text(ipval, u); lv_obj_set_style_text_color(ipval, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); } else { @@ -20737,8 +20885,28 @@ static void makeHome(lv_obj_t* tab) { auto tanBtnY = [&](int slot) { return slot * (tan_btn_h + tan_btn_gap); }; // At Large/Huge there isn't room for both the TX/RX chart AND the 8-row info panel — drop the // chart (TX/RX totals still show in the legend above) so the info panel gets the freed height. +#if defined(HAS_TDISPLAY_P4) + // P4: the tall portrait home has room for the chart at EVERY UI scale — and the fscale>100 skip + // was leaving an 84-px hole (chart_h stayed reserved below while the chart was never created; + // this unit inherited fscale>100 from the adopted T-Deck prefs, so home showed blank space). + const bool home_scaled = false; +#else const bool home_scaled = (s_ui_fscale > 100); +#endif if (home_scaled) chart_h = 0; +#if defined(HAS_TDISPLAY_P4) + // T-Display P4: LARGE but PORTRAIT (284x616) — the Tanmatsu right-strip layout doesn't apply. + // Column flow instead: chart (taller, full width) → a 2-col launcher grid (Advert/Apps, + // Terminal/Files, Control full-width) → the info card fills the rest, full width. The old code + // put the half-width Advert row at chart-bottom AND the (needlessly RSTRIP-narrowed) info card + // 4 px below it — they overlapped, and portrait boards never got the launcher buttons at all. + chart_h = 84; // was 110: with TX/RX idle the chart is near-invisible, and the freed height + // lets the info card below fit all 8 rows (Battery/Uptime were clipping off) + const int p4_btn_h = 40; + const int p4_btn_gap = 8; + const int p4_grid_y = chart_y + 16 + chart_h + 10; + const int p4_grid_bottom = p4_grid_y + 3 * p4_btn_h + 2 * p4_btn_gap; +#endif #else const bool home_scaled = false; // Non-Tanmatsu landscape (T-Deck) right-hand column: Advert + launchers spread @@ -20815,11 +20983,36 @@ static void makeHome(lv_obj_t* tab) { // Commander info panel — fills the space below the chart on the big screen. Two columns: // static keys (left) + live values (right, s_home_info; refreshed in refreshStatusLabels). { +#if defined(HAS_TDISPLAY_P4) + const int info_y = p4_grid_bottom + 12; // below the portrait launcher grid + const int info_w = cw; // full width — no right button strip in portrait +#else const int info_y = chart_y + 16 + chart_h + 12; const int info_w = cw - RSTRIP; // left/centre column, clear of the right button strip +#endif lv_obj_t* card = lv_obj_create(tab); lv_obj_remove_style_all(card); +#if defined(HAS_TDISPLAY_P4) + // Content-fit height with tight spacing, CLAMPED to the space left above the tab bar — at + // Large/Huge UI scale the rows are taller and a fixed-spacing card ran past the bottom + // (Uptime clipped). Spacing adapts down (3 → 0) before the card ever exceeds the screen; + // if even 0-spacing overflows (extreme scale) the card's AUTO scrollbar takes over. + const int p4_info_fh = lv_font_get_line_height(&g_font_12); + // Clamp against the tab's MEASURED content height, not the derived home_avail — the + // round-corner status-bar pad stack makes the real space smaller than the formula + // (Uptime kept clipping under the tab bar at Large scale). + lv_obj_update_layout(tab); + const int p4_tab_h = lv_obj_get_content_height(tab); + const int p4_max_h = (p4_tab_h > 0 ? p4_tab_h : home_avail) - info_y - 4; + int p4_info_ls = (p4_max_h - 16 - 8 * p4_info_fh) / 7; + if (p4_info_ls > 3) p4_info_ls = 3; // tight, not airy + if (p4_info_ls < 0) p4_info_ls = 0; + int p4_card_h = 16 + 8 * p4_info_fh + 7 * p4_info_ls; + if (p4_card_h > p4_max_h) p4_card_h = p4_max_h; + lv_obj_set_size(card, info_w, p4_card_h); +#else lv_obj_set_size(card, info_w, home_avail - info_y - SC(8)); // small bottom margin so the last row (Uptime) clears +#endif lv_obj_align(card, LV_ALIGN_TOP_LEFT, 0, info_y); lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); lv_obj_set_style_bg_opa(card, LV_OPA_COVER, LV_PART_MAIN); @@ -20835,11 +21028,15 @@ static void makeHome(lv_obj_t* tab) { // (Battery/Uptime) were clipping off the bottom at Huge, and a no-touch device // can't reach the AUTO scrollbar. Spread the leftover over the 7 inter-row gaps. const lv_font_t* info_font = &g_font_12; +#if defined(HAS_TDISPLAY_P4) + const int info_ls = p4_info_ls; // fixed tight spacing (card is content-fit) +#else const int info_card_h = home_avail - info_y - SC(8); const int info_fh = lv_font_get_line_height(info_font); int info_ls = (info_card_h - 16 - 8 * info_fh) / 7; // pad_all=8 → -16 if (info_ls < 1) info_ls = 1; if (info_ls > 8) info_ls = 8; +#endif lv_obj_t* keys = lv_label_create(card); lv_label_set_text(keys, TR("Node\nRegion\nRadio\nSignal\nContacts\nChannels\nBattery\nUptime")); lv_obj_set_style_text_color(keys, lv_color_hex(COLOR_SUB), LV_PART_MAIN); @@ -20852,6 +21049,10 @@ static void makeHome(lv_obj_t* tab) { lv_obj_set_style_text_color(s_home_info, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_obj_set_style_text_font(s_home_info, info_font, LV_PART_MAIN); lv_obj_set_style_text_line_space(s_home_info, info_ls, LV_PART_MAIN); + // Constrain + clip the values column at the card's padding edge — a long node name or + // radio string otherwise ran to the card border and was cut mid-glyph against it. + lv_obj_set_width(s_home_info, info_w - 16 - SC(100)); + lv_label_set_long_mode(s_home_info, LV_LABEL_LONG_CLIP); lv_obj_align(s_home_info, LV_ALIGN_TOP_LEFT, SC(100), 0); // values column clears the keys } #endif @@ -20860,6 +21061,9 @@ static void makeHome(lv_obj_t* tab) { // a compact button parked in the top-right strip (next to the status text), // which is why the chart above was allowed to run full-height. const int adv_y = chart_y + 16 + chart_h + 8; +#if defined(HAS_TDISPLAY_P4) && CAP_LARGE_SCREEN + (void)adv_y; // P4 portrait-large uses the grid Ys (p4_grid_y); adv_y feeds the other layouts +#endif lv_obj_t* adv = lv_btn_create(tab); #if defined(HAS_EXPANSION_KIT) s_home_adv_btn = adv; @@ -20877,10 +21081,16 @@ static void makeHome(lv_obj_t* tab) { lv_obj_align(adv, LV_ALIGN_TOP_LEFT, cw - BTNW, tdBtnY(0)); #endif } else { +#if defined(HAS_TDISPLAY_P4) && CAP_LARGE_SCREEN + // Portrait-large (T-Display P4): Advert = grid slot row 0 left (grid laid out above). + lv_obj_set_size(adv, (cw - p4_btn_gap) / 2, p4_btn_h); + lv_obj_align(adv, LV_ALIGN_TOP_LEFT, 0, p4_grid_y); +#else // Portrait (V4): share the row with an "Apps" button — Advert on the left half, // Apps on the right half. (Landscape boards get Apps in the launcher column below.) lv_obj_set_size(adv, (cw - 8) / 2, 36); lv_obj_align(adv, LV_ALIGN_TOP_LEFT, 0, adv_y); +#endif } lv_obj_add_event_cb(adv, openAdvertModalCb, LV_EVENT_CLICKED, nullptr); lv_obj_t* adv_l = lv_label_create(adv); @@ -20893,11 +21103,21 @@ static void makeHome(lv_obj_t* tab) { // Portrait "Apps" launcher (right half of the advert row). Landscape boards build // Apps in the right-hand launcher column instead, so this is portrait-only. if (!home_land) { - const int half = (cw - 8) / 2; +#if defined(HAS_TDISPLAY_P4) && CAP_LARGE_SCREEN + const int half = (cw - p4_btn_gap) / 2; + const int btn_h = p4_btn_h; + const int apps_x = half + p4_btn_gap; + const int apps_y = p4_grid_y; +#else + const int half = (cw - 8) / 2; + const int btn_h = 36; + const int apps_x = half + 8; + const int apps_y = adv_y; +#endif lv_obj_t* apb = lv_btn_create(tab); styleButton(apb); - lv_obj_set_size(apb, half, 36); - lv_obj_align(apb, LV_ALIGN_TOP_LEFT, half + 8, adv_y); + lv_obj_set_size(apb, half, btn_h); + lv_obj_align(apb, LV_ALIGN_TOP_LEFT, apps_x, apps_y); const uint32_t inv_accent = 0xFFFFFFu ^ (COLOR_ACCENT & 0xFFFFFFu); lv_obj_set_style_bg_color(apb, lv_color_hex(inv_accent), LV_PART_MAIN); lv_obj_set_style_bg_opa(apb, LV_OPA_COVER, LV_PART_MAIN); @@ -20908,6 +21128,29 @@ static void makeHome(lv_obj_t* tab) { lv_obj_set_style_text_font(apl, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(apl, lv_color_hex(0xFFFFFF), LV_PART_MAIN); lv_obj_center(apl); +#if defined(HAS_TDISPLAY_P4) && CAP_LARGE_SCREEN + // Rows 1–2 of the portrait-large launcher grid: Terminal | Files, then Control full-width. + // (The tall screen has the room the other portrait board lacks; Files is live now that the + // file manager browses SD_MMC/FFat on this board.) + auto p4_launcher = [&](const char* label, int x, int y, int w, lv_event_cb_t cb) -> lv_obj_t* { + lv_obj_t* b = lv_btn_create(tab); + styleButton(b); + lv_obj_set_size(b, w, p4_btn_h); + lv_obj_align(b, LV_ALIGN_TOP_LEFT, x, y); + lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, nullptr); + lv_obj_t* l = lv_label_create(b); + lv_label_set_text(l, TR(label)); + lv_obj_set_style_text_font(l, &g_font_14, LV_PART_MAIN); + lv_obj_set_style_text_color(l, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); + lv_obj_center(l); + return b; + }; + const int row1 = p4_grid_y + p4_btn_h + p4_btn_gap; + const int row2 = row1 + p4_btn_h + p4_btn_gap; + p4_launcher(">_ Terminal", 0, row1, half, homeTerminalCb); + p4_launcher(LV_SYMBOL_DIRECTORY " Files", apps_x, row1, half, homeFilesCb); + p4_launcher(LV_SYMBOL_BARS " Control", 0, row2, cw, homeControlPanelCb); +#endif } #endif @@ -21111,7 +21354,7 @@ static void openContactsOverflowSheetCb(lv_event_t* e) { // Bigger on the 800-px Tanmatsu panel; unchanged on the smaller boards (PSC no-op). // Compact rows so all five buttons + title fit the T-Deck's short 240px landscape // screen (the 5th "Blocked list" item pushed the old 36px rows off-screen). #72. - const int card_w = PSC(200); + const int card_w = PCW(200); const int btn_h = SC(30), btn_gap = SC(4), title_h = SC(22), padding = SC(8); // SC not PSC: the 1.7x boost clipped the lower rows (Auto-add / Blocked) off-screen at Large scale const int rows = 5; int card_h = 2 * padding + title_h + rows * btn_h + (rows - 1) * btn_gap; // reserve BOTH pad_all paddings (top+bottom) or the last row clips @@ -21632,6 +21875,10 @@ static void makeContactsTab(lv_obj_t* tab) { lv_obj_set_style_text_font(l, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(l, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); lv_label_set_long_mode(l, LV_LABEL_LONG_DOT); + // Reserve the right ~quarter of the chip for the count badge: LONG_DOT only ellipsizes a + // FIXED-width label, and the content-sized label ran under the badge on narrow chips + // ("Disc[48]er" — the pill painted mid-word on the 284-px P4). + lv_obj_set_width(l, lv_pct(76)); lv_obj_align(l, LV_ALIGN_LEFT_MID, 0, 0); s_ct_disc_badge = lv_label_create(b); // red count pill, right side (updated by the loop) lv_obj_set_size(s_ct_disc_badge, LV_SIZE_CONTENT, 16); @@ -22360,7 +22607,7 @@ static int verchkFetchLatest(WiFiClient& client, HTTPClient& http) { } const int code = http.GET(); if (code != 200) { http.end(); return -1; } - WiFiClient* st = http.getStreamPtr(); + auto* st = http.getStreamPtr(); // NetworkClient* on arduino 3.x (P4: C6Client behind it) static const char PAT[] = "beta_"; const int patlen = (int)sizeof(PAT) - 1; int mp = 0, best = -1, curnum = -1; @@ -22481,7 +22728,7 @@ static void otaWorkerRun(WiFiClient& client, HTTPClient& http) { Update.onProgress([](size_t done, size_t total) { s_ota_pct = total ? (int)((uint64_t)done * 100 / total) : 0; }); - WiFiClient* stream = http.getStreamPtr(); + auto* stream = http.getStreamPtr(); // NetworkClient* on arduino 3.x (P4: C6Client behind it) const size_t written = stream ? Update.writeStream(*stream) : 0; http.end(); if (written != (size_t)len) { @@ -22530,7 +22777,7 @@ static void sdFwWorkerRun(WiFiClient& client, HTTPClient& http) { uint8_t* buf = (uint8_t*)heap_caps_malloc(bufsz, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if (!buf) { bufsz = 2048; buf = (uint8_t*)malloc(bufsz); } if (!buf) { f.close(); http.end(); snprintf(s_sdfw_msg, sizeof s_sdfw_msg, "out of memory"); s_sdfw_state = 3; return; } - WiFiClient* st = http.getStreamPtr(); + auto* st = http.getStreamPtr(); // NetworkClient* on arduino 3.x (P4: C6Client behind it) int received = 0; unsigned long last_data = millis(); bool ok = true; @@ -22844,7 +23091,7 @@ static void tileFetchTaskFn(void* arg) { if (content_len > 0 && content_len <= 100 * 1024) { File f = tileCacheOpen(path_jpg, "w"); if (f) { - WiFiClient* stream = http.getStreamPtr(); + auto* stream = http.getStreamPtr(); // NetworkClient* on arduino 3.x (P4: C6Client behind it) uint8_t buf[1024]; int remaining = content_len; size_t disk_written = 0; // bytes actually committed to the FS/card @@ -24676,7 +24923,7 @@ static void openMapPicker(const int* idxs, int n) { lv_obj_add_event_cb(s_map_picker_root, mapPickerBackdropCb, LV_EVENT_CLICKED, nullptr); // Bigger on the 800-px Tanmatsu panel; unchanged on the smaller boards (PSC no-op). - const int card_w = PSC(220); + const int card_w = PCW(220); const int btn_h = PSC(34); const int gap = PSC(4); const int hdr_h = PSC(30); @@ -26330,8 +26577,14 @@ static bool crashDumpExport(char* out_path, size_t out_cap) { const char* path = "/wadamesh-crash.elf"; #if CAP_SD if (fmSdTryMount()) { dst = &SD; used_sd = true; } +#elif defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) + if (tanSdTryMount()) { dst = &SD_MMC; used_sd = true; } // microSD on SDMMC slot 0 #endif +#if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) + if (!dst) dst = &FFat; // no SPIFFS on these boards; internal = the FAT data partition +#else if (!dst) { SPIFFS.begin(false); dst = &SPIFFS; } +#endif File f = dst->open(path, "w"); if (!f) return false; @@ -26958,7 +27211,7 @@ static void openTraceResultPopup(const char* title, const char* body) { lv_obj_add_event_cb(s_trace_result_root, traceResultBackdropCb, LV_EVENT_CLICKED, nullptr); #if CAP_LARGE_SCREEN - const int card_w = PSC(220); + const int card_w = PCW(220); const int card_h = PSC(236); #else const int card_w = 220; @@ -27199,7 +27452,7 @@ static void openMessageActionMenu(int msg_idx) { // per row — with Delete the menu carries up to 7 actions, and a single column // no longer fit the 240-px screens without scrolling. #if CAP_LARGE_SCREEN - const int card_w = PSC(220); + const int card_w = PCW(220); const int btn_h = PSC(30); const int gap = PSC(4); const int pad = PSC(10); @@ -27339,7 +27592,7 @@ static void openMessageInfoPopup(int msg_idx) { const int route_pts = buildRouteFromMessage(m); const bool show_route = (route_pts >= 2); #if CAP_LARGE_SCREEN - const int card_w = PSC(220); + const int card_w = PCW(220); int card_h = (show_trace || show_route) ? PSC(290) : PSC(250); #else const int card_w = 220; @@ -28666,7 +28919,7 @@ static void openUrlQrPopup(const char* url) { lv_obj_set_style_bg_opa(s_urlqr_root, LV_OPA_60, LV_PART_MAIN); lv_obj_clear_flag(s_urlqr_root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(s_urlqr_root, urlQrBackdropCb, LV_EVENT_CLICKED, nullptr); - int card_w = PSC(230); if (card_w > modalAvailW()) card_w = modalAvailW(); + int card_w = PCW(230); if (card_w > modalAvailW()) card_w = modalAvailW(); int card_h = PSC(250); if (card_h > modalAvailH()) card_h = modalAvailH(); int qr_size = card_h - 34 - 34 - 20; if (qr_size > PSC(160)) qr_size = PSC(160); if (qr_size > card_w - 20) qr_size = card_w - 20; if (qr_size < 96) qr_size = 96; @@ -28753,7 +29006,7 @@ static void openUrlMenu(const char* url) { lv_obj_set_style_bg_opa(s_urlmenu_root, LV_OPA_60, LV_PART_MAIN); lv_obj_clear_flag(s_urlmenu_root, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(s_urlmenu_root, urlMenuBackdropCb, LV_EVENT_CLICKED, nullptr); - const int card_w = PSC(230), btn_h = PSC(34), pad = PSC(12), gap = PSC(8), url_h = PSC(18); + const int card_w = PCW(230), btn_h = PSC(34), pad = PSC(12), gap = PSC(8), url_h = PSC(18); #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) const int nbtn = 2; #else @@ -30127,7 +30380,6 @@ static void refreshContactsList() { // chain that bootlooped pre-alpha when siblings were added inside it. const int row_w = tabContentW(); const bool sel_md = s_ct_select_mode; - const int ROW_H = 34; const int star_x = row_w - 18; // ★ favorite marker (reserved on every row) // Heard (age) + Location (distance) columns. On the narrow 240/320 boards the // compact 32/46-px widths just fit; on the wide Tanmatsu (≥400 px) the values @@ -30135,13 +30387,21 @@ static void refreshContactsList() { // both columns and add a gap there. Labels below also get an explicit width so // the text right-aligns within its column instead of bleeding into the next. const bool wide_cols = (row_w >= 400); - const int loc_w = wide_cols ? 80 : 46; - const int hrd_w = wide_cols ? 64 : 32; - const int col_gap = wide_cols ? 10 : 2; // gap between the two columns - const int loc_x = star_x - (wide_cols ? 8 : 4) - loc_w; // Location column (pushed right) + const bool mid_cols = (!wide_cols && row_w >= 280); // T-Display P4 (284): two-line rows (below) + const int loc_w = wide_cols ? 80 : (mid_cols ? 70 : 46); + const int hrd_w = wide_cols ? 64 : (mid_cols ? 48 : 32); + const int col_gap = wide_cols ? 10 : (mid_cols ? 8 : 2); // narrow 2-px gap read as one glued value ("46m33km") + const int loc_x = star_x - (wide_cols ? 8 : (mid_cols ? 6 : 4)) - loc_w; // Location column (pushed right) const int heard_x = loc_x - col_gap - hrd_w; // Heard column (left of Location) const int icon_x = sel_md ? 34 : 8; - const int name_x = icon_x + 20; + const int name_x = icon_x + (mid_cols ? 24 : 20); // the font-16 type glyph grazed the name's first letter + // mid_cols (P4 284px): a single 34-px line can't hold name + age + distance without either + // scrolling the name (rejected) or gluing the value columns. TWO-LINE rows instead: + // line 1: the full name (one line, dot-ellipsized — never scrolls) + // line 2: heard-age and distance, small + dim under the name + const int ROW_H = mid_cols ? 46 : 34; + const int name_line_h_row = lv_font_get_line_height(&g_font_14); + const int row2_y = 5 + name_line_h_row + 2; // line 2 top (below the name line) int name_w = heard_x - name_x - 6; if (name_w < 50) name_w = 50; for (int k = 0; k < n_entries; ++k) { @@ -30213,42 +30473,55 @@ static void refreshContactsList() { // Name: wrap up to 2 lines; if the name would need a 3rd line (which // overflows the fixed-height row), scroll it horizontally instead. + // (mid_cols/P4: exactly ONE line, full row width, dot-ellipsized — no scrolling.) char san[40]; copyUtf8ReplacingMissingGlyphs(&g_font_14, san, sizeof(san), e.name); lv_obj_t* nm = lv_label_create(rb); - lv_point_t nsz; - lv_txt_get_size(&nsz, san, &g_font_14, 0, 0, name_w, LV_TEXT_FLAG_NONE); - const int name_line_h = lv_font_get_line_height(&g_font_14); - lv_label_set_long_mode(nm, (nsz.y > 2 * name_line_h) ? LV_LABEL_LONG_SCROLL_CIRCULAR - : LV_LABEL_LONG_DOT); - lv_obj_set_width(nm, name_w); + if (mid_cols) { + lv_label_set_long_mode(nm, LV_LABEL_LONG_DOT); + // BOTH dimensions constrained: with free height LONG_DOT doesn't ellipsize — the name + // wrapped to a 2nd line and collided with the age/distance line below it. + lv_obj_set_size(nm, star_x - 6 - name_x, name_line_h_row); + } else { + lv_point_t nsz; + lv_txt_get_size(&nsz, san, &g_font_14, 0, 0, name_w, LV_TEXT_FLAG_NONE); + const int name_line_h = lv_font_get_line_height(&g_font_14); + lv_label_set_long_mode(nm, (nsz.y > 2 * name_line_h) ? LV_LABEL_LONG_SCROLL_CIRCULAR + : LV_LABEL_LONG_DOT); + lv_obj_set_width(nm, name_w); + } lv_label_set_text(nm, san); lv_obj_set_style_text_font(nm, &g_font_14, LV_PART_MAIN); lv_obj_set_style_text_color(nm, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); - lv_obj_align(nm, LV_ALIGN_LEFT_MID, name_x, 0); + if (mid_cols) lv_obj_align(nm, LV_ALIGN_TOP_LEFT, name_x, 5); + else lv_obj_align(nm, LV_ALIGN_LEFT_MID, name_x, 0); // Heard column (12 px — smaller than the 14 px name — and pushed right). // Explicit column width so a longer age never bleeds into the Location // column; left-aligned within it like the rest of the row. + // mid_cols: line 2, left-aligned under the name instead of a right column. lv_obj_t* hl = lv_label_create(rb); lv_label_set_text(hl, age_buf); lv_obj_set_style_text_font(hl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(hl, lv_color_hex(COLOR_SUB), LV_PART_MAIN); lv_obj_set_width(hl, hrd_w); lv_label_set_long_mode(hl, LV_LABEL_LONG_CLIP); - lv_obj_align(hl, LV_ALIGN_LEFT_MID, heard_x, 0); + if (mid_cols) lv_obj_align(hl, LV_ALIGN_TOP_LEFT, name_x, row2_y); + else lv_obj_align(hl, LV_ALIGN_LEFT_MID, heard_x, 0); s_contacts_ctx[k].age_lbl = hl; // in-place 60s age updates (#82) s_contacts_rows_built = k + 1; // Location column — explicit width so "1.4km" / "390ft" fit and don't // overrun the favourite-star slot on the wide layout. + // mid_cols: line 2, right after the age. lv_obj_t* ll = lv_label_create(rb); lv_label_set_text(ll, loc_buf); lv_obj_set_style_text_font(ll, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(ll, lv_color_hex(e.has_gps ? COLOR_SUB : 0x4A4E54), LV_PART_MAIN); lv_obj_set_width(ll, loc_w); lv_label_set_long_mode(ll, LV_LABEL_LONG_CLIP); - lv_obj_align(ll, LV_ALIGN_LEFT_MID, loc_x, 0); + if (mid_cols) lv_obj_align(ll, LV_ALIGN_TOP_LEFT, name_x + hrd_w + col_gap, row2_y); + else lv_obj_align(ll, LV_ALIGN_LEFT_MID, loc_x, 0); // Favorite star if (e.is_fav) { @@ -31229,8 +31502,8 @@ static void backupAddPath(const char* stored, const char* disp) { // would fail on an already-mounted volume), plain SPIFFS on the T-Deck / V4. Mirrors the file // manager's "Internal" root so a backup lands where the user can actually see + restore it. static fs::FS* backupInternalFs() { -#if defined(HAS_TANMATSU) - return &FFat; +#if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) + return &FFat; // FAT data partition (locfd / 'storage'), mounted at boot; no SPIFFS here #else SPIFFS.begin(false); return &SPIFFS; @@ -31484,7 +31757,7 @@ static void doDeleteBackup() { if (!s_backup_del_path[0]) return; const char* path = s_backup_del_path; bool ok = false; - if (!strncmp(path, "int:", 4)) { SPIFFS.begin(false); ok = SPIFFS.remove(path + 4); } + if (!strncmp(path, "int:", 4)) { ok = backupInternalFs()->remove(path + 4); } // FFat on Tanmatsu/P4, SPIFFS elsewhere #if CAP_SD else if (!strncmp(path, "sd:", 3)) { if (fmSdTryMount()) ok = SD.remove(path + 3); } #endif @@ -32648,7 +32921,7 @@ static void powerOffCb(lv_event_t* e) { } #if defined(ESP32) -#if !defined(HAS_TANMATSU) +#if !defined(HAS_TANMATSU) && !defined(HAS_TDISPLAY_P4) #include "soc/rtc_cntl_reg.h" // RTC_CNTL_FORCE_DOWNLOAD_BOOT (header-guarded) #endif // Force the ROM serial bootloader (USB download / flash mode) on the next reset, @@ -32657,7 +32930,7 @@ static void powerOffCb(lv_event_t* e) { // esptool's post-flash reset — or a power cycle — clears it back to a normal boot. // (Tanmatsu/P4 has no RTC_CNTL_OPTION1_REG and the launcher manages flashing — plain restart.) static void rebootToDownloadMode() { -#if !defined(HAS_TANMATSU) +#if !defined(HAS_TANMATSU) && !defined(HAS_TDISPLAY_P4) REG_SET_BIT(RTC_CNTL_OPTION1_REG, RTC_CNTL_FORCE_DOWNLOAD_BOOT); #endif ESP.restart(); @@ -32893,6 +33166,26 @@ static void tanBeep() { size_t wr = 0; i2s_channel_write(h, buf, (size_t)n * 2 * sizeof(int16_t), &wr, 200 / portTICK_PERIOD_MS); } +#elif defined(HAS_TDISPLAY_P4) +// T-Display P4: the RM69A10 AMOLED has no backlight — brightness is the panel's own DCS +// 0x51 register (RM69A10Display::setBrightness). Volume is loudness only: the notify +// chimes scale their amplitude from the pref (the ES8311 DAC volume stays fixed), so +// applyVolume just tracks the slider for the CC. +#define HAS_CC_BRIGHTNESS 1 +#define HAS_CC_VOLUME 1 +static uint8_t s_brightness_pct = 100; +static void applyBrightness(uint8_t pct) { + if (pct < 5) pct = 5; + if (pct > 100) pct = 100; + s_brightness_pct = pct; + // Linear map onto the panel's usable band: DCS 8 is still clearly lit, 255 = max. + display.setBrightness((uint8_t)(8 + (uint32_t)(pct - 5) * 247u / 95u)); +} +static uint8_t s_volume_pct = 70; +static void applyVolume(uint8_t pct) { + if (pct > 100) pct = 100; + s_volume_pct = pct; // play paths read the persisted pref; this keeps the slider live +} #elif defined(HAS_THINKNODE_M9) // M9: BL_EN (GPIO17) is a PNP transistor gate. LEDC PWM confirmed working on hardware // (Specter bring-up), but the duty is INVERTED — lower duty on the base = MORE conduction @@ -32937,7 +33230,7 @@ static void ccKbdBlReleaseCb(lv_event_t* e) { static void ccVolumeCb(lv_event_t* e) { applyVolume((uint8_t)lv_slider_get_value(lv_event_get_target(e))); } // live codec volume static void ccVolumeReleaseCb(lv_event_t* e) { touchPrefsSetSoundVolume((uint8_t)lv_slider_get_value(lv_event_get_target(e))); // persist - tanBeep(); // audible feedback at the chosen level + uiSoundPreview(); // audible feedback at the chosen level } #endif @@ -33056,6 +33349,9 @@ static void openControlCenter() { #if defined(HAS_TANMATSU) const int bl_y = row_y; // three stacked sliders: Screen / Keys / Sound const int gps_y = row_y + SC(100); // GPS line sits below all three (scaled so it clears taller sliders) +#elif defined(HAS_TDISPLAY_P4) + const int bl_y = row_y; // two stacked sliders: Screen / Sound + const int gps_y = row_y + SC(66); // GPS line clears both rows #elif defined(HAS_CC_BRIGHTNESS) const int bl_y = row_y; const int gps_y = row_y + 12; @@ -33115,6 +33411,33 @@ static void openControlCenter() { lv_obj_add_event_cb(s, sl[i].done, LV_EVENT_RELEASED, nullptr); } } +#elif defined(HAS_TDISPLAY_P4) + // ---- Two stacked sliders: Screen brightness (AMOLED DCS 0x51) + Sound volume. + // Same anchored-label pattern as the Tanmatsu block, minus its Keys row. + { + struct { const char* name; int val; int lo; lv_event_cb_t live; lv_event_cb_t done; } sl[2] = { + { "Screen", s_brightness_pct, 5, ccBrightnessCb, ccBrightnessReleaseCb }, + { "Sound", s_volume_pct, 0, ccVolumeCb, ccVolumeReleaseCb }, + }; + for (int i = 0; i < 2; i++) { + const int y = bl_y + i * SC(30); + lv_obj_t* lab = lv_label_create(card); + lv_label_set_text(lab, TR(sl[i].name)); + lv_obj_set_style_text_font(lab, &g_font_12, LV_PART_MAIN); + lv_obj_set_style_text_color(lab, lv_color_hex(COLOR_SUB), LV_PART_MAIN); + lv_obj_align(lab, LV_ALIGN_TOP_LEFT, 0, y - 1); + lv_obj_t* s = lv_slider_create(card); + lv_obj_set_size(s, card_w - 20 - SC(70), 10); + lv_obj_align(s, LV_ALIGN_TOP_RIGHT, 0, y); + lv_slider_set_range(s, sl[i].lo, 100); + lv_slider_set_value(s, sl[i].val, LV_ANIM_OFF); + lv_obj_set_style_bg_color(s, lv_color_hex(COLOR_ACCENT), LV_PART_INDICATOR); + lv_obj_set_style_bg_color(s, lv_color_hex(COLOR_ACCENT), LV_PART_KNOB); + lv_obj_set_style_pad_all(s, 4, LV_PART_KNOB); + lv_obj_add_event_cb(s, sl[i].live, LV_EVENT_VALUE_CHANGED, nullptr); + lv_obj_add_event_cb(s, sl[i].done, LV_EVENT_RELEASED, nullptr); + } + } #elif defined(HAS_CC_BRIGHTNESS) // ---- Brightness slider (thin; a sun/gear glyph anchors it on the left so it // doesn't cost a separate label row). bl_y is computed above so it sits @@ -34290,12 +34613,19 @@ static void buildGlobalStatusBar() { { // LEFT-aligned group. ~70% of the double-bar height so there's breathing room // above/below; soft rounded corners + a dimmer edge so they don't pop. +#if CAP_ROUND_CORNERS + // Round panel: the bar is a fixed two rows (no doubling), so size these to sit on + // ROW 2's left, inside the corner inset — the status cluster occupies row-2 right. + const lv_coord_t BH = 18, BW = 30, GAP = 6, BX0 = SB_INSET_X; + const lv_coord_t BY = SB_TOP_PAD + SB_ROW + (SB_ROW - BH) / 2; +#else const lv_coord_t BH = (lv_coord_t)((STATUSBAR_H * 2 - 4) * 7 / 10); - const lv_coord_t BW = 34, GAP = 4, BY = (lv_coord_t)(STATUSBAR_H * 2 - BH) / 2; + const lv_coord_t BW = 34, GAP = 4, BX0 = 6, BY = (lv_coord_t)(STATUSBAR_H * 2 - BH) / 2; +#endif auto mk = [&](int slot_from_left) -> lv_obj_t* { lv_obj_t* b = lv_btn_create(g_statusbar.root); lv_obj_set_size(b, BW, BH); - lv_obj_set_pos(b, 6 + slot_from_left * (BW + GAP), BY); + lv_obj_set_pos(b, BX0 + slot_from_left * (BW + GAP), BY); styleButton(b); lv_obj_set_style_radius(b, 9, LV_PART_MAIN); // softer corners lv_obj_set_style_border_opa(b, LV_OPA_20, LV_PART_MAIN); // dim the edge so it blends @@ -34508,8 +34838,45 @@ static void buildGlobalStatusBar() { lv_obj_clear_flag(g_statusbar.dim, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(g_statusbar.dim, LV_OBJ_FLAG_CLICKABLE); // swallow bar taps behind a modal lv_obj_add_flag(g_statusbar.dim, LV_OBJ_FLAG_HIDDEN); + +#if CAP_ROUND_CORNERS + statusBarLayoutTwoRow(0); // place the clock (row 1) + the status cluster (row 2) +#endif } +#if CAP_ROUND_CORNERS +// Two-row status-bar layout for the round-cornered phone panel. Row 1 (top): the clock +// at the right inset (+ keyboard-layout tag to its left, + chat back/cog on the left). +// Row 2 (bottom): the battery pinned at the right inset, with the signal/wifi/sd/ble +// cluster to its left — that cluster slides right by `slide` px when the %-column is +// hidden while charging (mirrors the square-panel slide). The app-name / page-title / +// chat thread-name (left_label) is placed by updateGlobalStatusBar. Per-item y nudges +// centre the different glyph heights within their row. Base X offsets mirror the +// single-row builder so the horizontal spacing is unchanged; +SB_INSET_X keeps the end +// glyphs clear of the corner arcs. +static void statusBarLayoutTwoRow(int slide) { + const int ins = SB_INSET_X; + // Row 2 — status cluster, right→left, evenly spaced. Battery pinned at the inset; + // the sub-battery cluster (signal/sd/wifi/ble) sits to its left and slides right by + // `slide` when the %-column hides while charging. y nudges centre each glyph in the + // row (montserrat_14 battery sits a touch higher than the 12px glyphs; sig/sd dots + // drop a few px). The scrolling profile name shares this row on the left (placed by + // updateGlobalStatusBar). + if (g_statusbar.batt_icon) lv_obj_align(g_statusbar.batt_icon, LV_ALIGN_TOP_RIGHT, -(2 + ins), SB_ROW2_Y - 2); + if (g_statusbar.batt_pct) lv_obj_align(g_statusbar.batt_pct, LV_ALIGN_TOP_RIGHT, -(26 + ins), SB_ROW2_Y); + if (g_statusbar.sig_box) lv_obj_align(g_statusbar.sig_box, LV_ALIGN_TOP_RIGHT, -(64 + ins - slide), SB_ROW2_Y + 2); + if (g_statusbar.sd_icon) lv_obj_align(g_statusbar.sd_icon, LV_ALIGN_TOP_RIGHT, -(86 + ins - slide), SB_ROW2_Y + 4); + if (g_statusbar.conn_icon) lv_obj_align(g_statusbar.conn_icon, LV_ALIGN_TOP_RIGHT, -(102 + ins - slide), SB_ROW2_Y); + if (g_statusbar.ble_icon) lv_obj_align(g_statusbar.ble_icon, LV_ALIGN_TOP_RIGHT, -(126 + ins - slide), SB_ROW2_Y); + // Row 1 — clock at the right inset (+ keyboard-layout tag to its left when typing). + if (g_statusbar.clock) lv_obj_align(g_statusbar.clock, LV_ALIGN_TOP_RIGHT, -ins, SB_ROW1_Y); + if (g_statusbar.layout_label) lv_obj_align(g_statusbar.layout_label, LV_ALIGN_TOP_RIGHT, -(48 + ins), SB_ROW1_Y); + // Row 1 — chat back chevron + channel-settings cog on the left (shown only in a chat). + if (g_statusbar.chat_back) lv_obj_align(g_statusbar.chat_back, LV_ALIGN_TOP_LEFT, ins, SB_ROW1_Y); + if (g_statusbar.chan_gear) lv_obj_align(g_statusbar.chan_gear, LV_ALIGN_TOP_LEFT, ins + 24, SB_ROW1_Y); +} +#endif + static void updateGlobalStatusBar() { if (!g_statusbar.root || !g_lv.task) return; @@ -34580,7 +34947,9 @@ static void updateGlobalStatusBar() { // built state — opaque root + hidden fade — already matches the inactive case). // EXCEPT while the channel-settings / blocked-users sheets are open: those are a new // page over the chat, so the bar goes SOLID (no glass revealing the chat behind it). - const bool fade_active = want_tall && !chanScopeIsOpen() && !blockedModalIsOpen() && !s_reader_page_open; + // The round panel keeps a SOLID two-row bar (no glass fade — there's no doubled lower + // row to reveal content through), so fade never activates there. + const bool fade_active = want_tall && !chanScopeIsOpen() && !blockedModalIsOpen() && !s_reader_page_open && !CAP_ROUND_CORNERS; static bool s_fade_active = false; if (fade_active != s_fade_active) { s_fade_active = fade_active; @@ -34601,7 +34970,9 @@ static void updateGlobalStatusBar() { // Per-element vertical placement in the tall bar. Default = TOP row (system content, // same place as the regular bar). Exceptions: the settings back+title stays CENTRED; // an open chat's thread name drops to the LOWER row and its cog centres across both. - const lv_coord_t up = s_statusbar_tall ? -(lv_coord_t)(STATUSBAR_H / 2) : 0; + // On the round panel the two rows come from each child's BASE align (set by + // statusBarLayoutTwoRow), not from a per-tick shift — so never translate there. + const lv_coord_t up = (s_statusbar_tall && !CAP_ROUND_CORNERS) ? -(lv_coord_t)(STATUSBAR_H / 2) : 0; const uint32_t nch = lv_obj_get_child_cnt(g_statusbar.root); for (uint32_t i = 0; i < nch; ++i) { lv_obj_t* c = lv_obj_get_child(g_statusbar.root, i); @@ -34642,11 +35013,26 @@ static void updateGlobalStatusBar() { } // In a chat the thread name (+ unread prefix) is CENTRED on the lower row (the back + // cog sit at the far left); otherwise it's left-aligned per the usual layout. +#if CAP_ROUND_CORNERS + // Round panel placement of the left zone: + // - open chat: thread name centred on ROW 1 (back/cog on row-1 left, clock row-1 right). + // - settings/tool page: "‹ Title" on ROW 1 left. + // - home / other tabs: the scrolling profile name (or ✉ unread badge) sits on ROW 2, + // the SAME line as the battery/status cluster (per request), left-aligned. + if (in_chan_chat) { + lv_obj_align(g_statusbar.left_label, LV_ALIGN_TOP_MID, 0, SB_ROW1_Y); + } else if (s_settings_open_cat >= 0 || s_apppage_title) { + lv_obj_align(g_statusbar.left_label, LV_ALIGN_TOP_LEFT, SB_INSET_X, SB_ROW1_Y); + } else { + lv_obj_align(g_statusbar.left_label, LV_ALIGN_TOP_LEFT, SB_INSET_X, SB_ROW2_Y); + } +#else if (in_chan_chat) { lv_obj_align(g_statusbar.left_label, LV_ALIGN_CENTER, 0, 0); } else { lv_obj_align(g_statusbar.left_label, LV_ALIGN_LEFT_MID, 6, 0); } +#endif // The home tab shows the user's profile name in a fixed ~11-char window that // marquee-scrolls when it's longer. These track that config so we (a) reset it @@ -34774,8 +35160,15 @@ static void updateGlobalStatusBar() { const lv_coord_t sb_w = lv_disp_get_hor_res(nullptr); // Reserve = clock anchor SC(126) + its text width + margin, so the name window // ends just before the (now SC-scaled) right-side icon cluster at any UI scale. +#if CAP_ROUND_CORNERS + // Round panel: the name sits on ROW 2 next to the status cluster, so the window + // runs from the left inset up to just before the leftmost icon (Bluetooth, whose + // right anchor is 126+inset from the right edge — reserve its glyph + a margin). + lv_obj_set_width(g_statusbar.left_label, sb_w - (SB_INSET_X + 126 + 44)); +#else lv_obj_set_width(g_statusbar.left_label, (sb_w >= 600) ? (sb_w - SC(190)) : (sb_w >= 300) ? 100 : 66); // #47a: narrower window on the 240px V4 bar so a long name can't run into the clock +#endif lv_label_set_long_mode(g_statusbar.left_label, LV_LABEL_LONG_SCROLL_CIRCULAR); lv_obj_set_style_anim_speed(g_statusbar.left_label, 14, LV_PART_MAIN); // slow, readable marquee lv_label_set_text(g_statusbar.left_label, nm); @@ -34877,7 +35270,11 @@ static void updateGlobalStatusBar() { // The % column disappears while charging (bolt only), so slide everything // left of the battery rightward to keep it snug against the bolt — else a // %-wide gap opens between the signal bars and the lightning glyph. -#if CAP_LARGE_SCREEN +#if CAP_ROUND_CORNERS + // Round panel: re-apply the two-row layout, sliding the row-2 sub-battery cluster + // right by the hidden %-column width so it stays snug against the bolt. + statusBarLayoutTwoRow(charging ? 32 : 0); +#elif CAP_LARGE_SCREEN // Tanmatsu: the cluster is built with SC() UI-scaling, so this slide MUST scale too — the raw // offsets below marched the scaled glyphs (incl. the BLE icon) into each other at Large/Huge the // instant charging toggled. Bases match the SC() builder; the clock is re-placed (SC-scaled) by @@ -34952,6 +35349,22 @@ static void updateGlobalStatusBar() { // cluster, so it never collides with e.g. the "Files" header. Otherwise it's // top-right; charging slides it +32 to hug the bolt once the % column hides. // Re-aligned only on a state change so it isn't laid out every tick. +#if CAP_ROUND_CORNERS + // Round P4 two-row bar: centre the clock on ROW 1 for the home / normal screens — row-1 + // centre is free there (the node name lives on row 2). In a chat the row-1 centre is the + // thread title, so the clock moves to row-1 right instead. Re-placed only when the chat + // state flips, so it isn't laid out every tick. + { + static int8_t s_clk_chat = -1; + if ((int8_t)chat_open != s_clk_chat) { + s_clk_chat = (int8_t)chat_open; + if (chat_open) lv_obj_align(g_statusbar.clock, LV_ALIGN_TOP_RIGHT, -SB_INSET_X, SB_ROW1_Y); + else lv_obj_align(g_statusbar.clock, LV_ALIGN_TOP_MID, 0, SB_ROW1_Y); + if (g_statusbar.async_icon) + lv_obj_align_to(g_statusbar.async_icon, g_statusbar.clock, LV_ALIGN_OUT_LEFT_MID, -4, 0); + } + } +#else { static int8_t s_clk_center = -1; // -1 = unset -> forces the first align static bool s_clk_chg = false; @@ -34982,6 +35395,7 @@ static void updateGlobalStatusBar() { lv_obj_align_to(g_statusbar.async_icon, g_statusbar.clock, LV_ALIGN_OUT_LEFT_MID, -4, 0); } } +#endif // ---- Layout indicator ---- // Only while a chat/channel conversation is open (s_chat_title set): that is @@ -36859,6 +37273,13 @@ static void buildUiTree() { lv_obj_set_style_text_color(tab_btns, lv_color_hex(COLOR_ACCENT), LV_PART_ITEMS | LV_STATE_CHECKED); lv_obj_set_style_text_font(tab_btns, &g_font_14, LV_PART_MAIN); +#if CAP_ROUND_CORNERS + // Round panel: inset the bottom tab row from the two bottom corner arcs (left/right) + // and lift the icons off the very bottom edge, so no tab glyph sits under a corner. + lv_obj_set_style_pad_left(tab_btns, SB_INSET_X, LV_PART_MAIN); + lv_obj_set_style_pad_right(tab_btns, SB_INSET_X, LV_PART_MAIN); + lv_obj_set_style_pad_bottom(tab_btns, SB_TOP_PAD, LV_PART_MAIN); +#endif lv_obj_add_event_cb(tab_btns, homeTabClickedCb, LV_EVENT_CLICKED, nullptr); // Home re-tap toggles the drawer lv_obj_add_event_cb(tab_btns, tabBarGestureCb, LV_EVENT_GESTURE, nullptr); // swipe up from the bar opens the drawer #if CAP_TRACKBALL @@ -38199,11 +38620,13 @@ static char s_ui_data_root[16] = ""; static bool s_ui_data_resolved = false; static bool uiDataFsReady() { if (s_ui_data_fs != nullptr) return true; // cache SUCCESS only — a failed resolve MUST stay retryable -#if defined(HAS_TANMATSU) - // Tanmatsu: prefer the microSD card. The internal FFat 'locfd' loses frequently-rewritten data on - // this P4 (broken FAT metadata; see the tile-cache notes), so chat history vanished on reboot. SD is - // reliable FAT — mirror the T-Deck's /meshcomod root. Fall back to FFat only when no card is present. - // Both are mounted at boot in main.cpp (g_sd_ok / g_fs_ok). +#if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) + // Tanmatsu + T-Display P4: prefer the microSD card. On the Tanmatsu the internal FFat 'locfd' + // loses frequently-rewritten data (broken FAT metadata; see the tile-cache notes); on the + // T-Display P4 SD-first keeps history on the same medium the DataStore adopts. Mirror the + // T-Deck's /meshcomod root; fall back to FFat only when no card is present. Both are mounted at + // boot in main.cpp (g_sd_ok / g_fs_ok). (The P4 used to fall into the #else SPIFFS branch below — + // no SPIFFS partition exists there, so history was never persisted and vanished on every reboot.) extern bool g_sd_ok; if (g_sd_ok) { SD_MMC.mkdir("/meshcomod"); @@ -38244,7 +38667,7 @@ static bool uiDataFsReady() { // message ring (MAX_UI_MESSAGES_SD) since the card has room for the bigger file. static bool uiDataFsIsSdCard() { if (!uiDataFsReady()) return false; -#if defined(HAS_TANMATSU) +#if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) return s_ui_data_fs == &SD_MMC; #elif defined(HAS_TDECK_GT911) return s_ui_data_fs == &SD; @@ -39484,9 +39907,34 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no WIRE_DBG("[TILE] Tanmatsu: no SD card -> caching Wi-Fi tiles on FFat /tiles (best-effort)"); } #endif +#if defined(HAS_TDISPLAY_P4) + // T-Display P4: no dedicated "tiles" partition in its table. Prefer the microSD (SD_MMC slot 0, + // mounted by main.cpp at boot — XL9535 SD_EN must be LOW, it gates the slot's power); else the + // internal 8 MB FFat 'storage' partition — a standard FFat mount of OUR OWN partition, so none of + // the Tanmatsu locfd metadata quirks apply. Either way the map gets a working Wi-Fi tile cache. + else if (SD_MMC.cardType() != CARD_NONE) { + s_tile_fs = &SD_MMC; + s_tile_root[0] = '\0'; + s_tiles_fs_ready = true; + printf("[TILE] T-Display P4: caching Wi-Fi tiles on microSD /tiles (SD_MMC)\n"); + } + else { + if (g_fs_ok) { + s_tile_fs = &FFat; + s_tile_root[0] = '\0'; + s_tiles_fs_ready = true; + printf("[TILE] T-Display P4: no SD card -> caching Wi-Fi tiles on FFat /tiles\n"); + } else { + s_tile_fs = nullptr; + printf("[TILE] T-Display P4: NO tile backend (no SD, FFat down)\n"); + } + } +#endif +#if !defined(HAS_TDISPLAY_P4) else { s_tile_fs = nullptr; // no cache backend at all -> map shows the storage notice } +#endif // Remember the non-microSD backend, then (microSD-tile mode) re-point the cache at // the SD card ROOT so Wi-Fi-fetched tiles land in /tiles///.jpg right next @@ -39682,7 +40130,11 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no if (!g_lv.ready) { lv_init(); initTouchFontFallbacks(); +#if CAP_ROUND_CORNERS + STATUSBAR_H = SB_TOP_PAD + SB_ROW * 2; // top safe-area + two rows (round phone panel) +#else STATUSBAR_H = SC(22); // grow the status bar to fit bigger text at Large/Huge (no-op at 100%) +#endif // Allocate the draw buffer in PSRAM so the ~12 KB it costs comes out of // the 8 MB external RAM instead of the 320 KB internal DRAM that WiFi // DMA buffers also need. Falls back to DRAM if PSRAM allocation fails. @@ -39695,6 +40147,12 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no const size_t buf_bytes = sizeof(lv_color_t) * g_draw_buf_px; g_draw_buffer = (lv_color_t*)heap_caps_malloc(buf_bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if (!g_draw_buffer) g_draw_buffer = (lv_color_t*)malloc(buf_bytes); +#elif defined(HAS_TDISPLAY_P4) + // RM69A10: LVGL renders at 284-wide (half res, upscaled 2x on flush) — full-width band in the abundant 32MB PSRAM. + g_draw_buf_px = 284 * LV_DRAW_BUF_LINES; + const size_t buf_bytes = sizeof(lv_color_t) * g_draw_buf_px; + g_draw_buffer = (lv_color_t*)heap_caps_malloc(buf_bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!g_draw_buffer) g_draw_buffer = (lv_color_t*)malloc(buf_bytes); #else #if defined(HAS_RAK_TAP_V2) const int draw_band_w = 320; @@ -39821,6 +40279,12 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no // stays == the panel width, so it's never entered). g_lv.disp_drv.hor_res = TAN_PANEL_PW; // 480 g_lv.disp_drv.ver_res = TAN_PANEL_PH; // 800 +#elif defined(HAS_TDISPLAY_P4) + // Render at HALF the 568x1232 panel; RM69A10Display upscales RM_UI_SCALE(2)x on flush, so the whole + // UI is uniformly 2x bigger on the high-DPI AMOLED (simpler than per-element scaling). The GT9895 + // touch reports in this same 284x616 logical space. + g_lv.disp_drv.hor_res = 284; + g_lv.disp_drv.ver_res = 616; #else // Landscape rotates the panel in HARDWARE (smooth — no per-pixel software // rotation each flush), so tell LVGL the already-rotated resolution and let @@ -40737,11 +41201,17 @@ static inline void touchScreenBacklight(bool on) { // Restored to 240 MHz on wake so tile decode / rendering stays fast. Only // switches when the target changes (the call is a hard DFS switch). static void setCpuForScreen(bool screen_on) { +#if defined(HAS_TDISPLAY_P4) + // The P4's Arduino HAL only accepts 360 MHz (240/80 log a "could not be set" error every screen + // toggle) — no DFS downclock available through this API on that chip, so skip entirely. + (void)screen_on; +#else static int s_cur_mhz = 240; const int want = screen_on ? 240 : 80; if (want == s_cur_mhz) return; setCpuFrequencyMhz(want); s_cur_mhz = want; +#endif } #else static inline void setCpuForScreen(bool) {} diff --git a/src/ui-touch/device_caps.h b/src/ui-touch/device_caps.h index f25e586..a3a0b64 100644 --- a/src/ui-touch/device_caps.h +++ b/src/ui-touch/device_caps.h @@ -83,6 +83,23 @@ #define CAP_OTA 1 // native dual-OTA slot #define CAP_LOCK_SCREEN 0 +#elif defined(HAS_TDISPLAY_P4) // ===== LilyGo T-Display P4 (ESP32-P4 + C6) ===== + // Phone-class AMOLED handheld: RM69A10 MIPI-DSI 568x1232 portrait, HI8561 cap touch, SX1262, + // C6 Wi-Fi/BLE (esp-hosted), SD_MMC. 32 MB PSRAM — web browser fits easily. + #define CAP_TOUCH 1 + #define CAP_ROTATABLE 0 // fixed portrait panel (SW-rotate later if wanted) + #define CAP_LARGE_SCREEN 1 // 568x1232 -> UI upscaling like the Tanmatsu + // CAP_SD gates the *Arduino SD* (shared-SPI) path used by the T-Deck/M9/R8. The P4's + // card is SD_MMC (slot 0), mounted in main.cpp and used as the DataStore backend — + // exposed to the UI via CAP_FILESYSTEM, exactly like the Tanmatsu's FFat. So CAP_SD=0 + // here keeps the Arduino-`SD` UI blocks (battery-log-on-SD, WAV sounds, fm SD mount) + // compiled out; SD_MMC file-manager browsing can be wired via the filesystem path later. + #define CAP_SD 0 + #define CAP_FILESYSTEM 1 // SD_MMC + internal FFat 'storage' + #define CAP_GPS 1 // L76K + #define CAP_OTA 1 // standalone dual-OTA app + #define CAP_LOCK_SCREEN 1 + #else // ===== Heltec V4 TFT (default) ===== #define CAP_TOUCH 1 // capacitive touch panel #define CAP_ROTATABLE 1 // user can flip portrait/landscape @@ -115,6 +132,17 @@ #define CAP_KEYPAD_NAV 0 #endif +// Round-cornered, tall/narrow phone-class panel (LilyGo T-Display P4, 568x1232). The +// AMOLED corners are arcs, so content is inset from all four corners, and the very tall +// aspect lets the status bar wrap to TWO rows (row 1 = name + clock, row 2 = the wifi/ +// ble/sd/signal/battery cluster) instead of cramming everything onto one narrow line. +// Square-cornered panels leave this 0 and keep the single-row bar with no insets. +#if defined(HAS_TDISPLAY_P4) + #define CAP_ROUND_CORNERS 1 +#else + #define CAP_ROUND_CORNERS 0 +#endif + // ---- Capabilities aliased to existing device-neutral macros ----------------- // (behaviour-identical; lets new code use CAP_* while old sites migrate lazily) #if defined(HAS_TDECK_TRACKBALL) diff --git a/tdisplay_p4/.gitignore b/tdisplay_p4/.gitignore new file mode 100644 index 0000000..e916341 --- /dev/null +++ b/tdisplay_p4/.gitignore @@ -0,0 +1,22 @@ +# project-local ESP-IDF + tools (bootstrapped like the Tanmatsu's; see build.sh) +esp-idf/ +esp-idf-tools/ +# build output + component manager cache +build/ +managed_components/ +sdkconfig +sdkconfig.old +dependencies.lock + +# local C6 recovery images (device-specific factory NVS backup + LilyGo's esp-hosted +# slave bin — kept on-disk for emergencies, never published) +*.bin + +# vendored dependency payloads (regenerate with fetch-deps.sh) +components/ardlib_*/ +components/meshcore/core +components/ardlibs/upstream/ +components/lvgl/upstream/ +components/RadioLib/src/ +components/RadioLib/library.properties +components/RadioLib/license.txt diff --git a/tdisplay_p4/CMakeLists.txt b/tdisplay_p4/CMakeLists.txt new file mode 100644 index 0000000..37ccc29 --- /dev/null +++ b/tdisplay_p4/CMakeLists.txt @@ -0,0 +1,3 @@ +cmake_minimum_required(VERSION 3.10) +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(application) diff --git a/tdisplay_p4/build.sh b/tdisplay_p4/build.sh new file mode 100755 index 0000000..44d1d83 --- /dev/null +++ b/tdisplay_p4/build.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# wadamesh / LilyGo T-Display P4 (AMOLED) build wrapper. Standalone ESP-IDF app +# (NOT a launcher/AppFS app like the Tanmatsu) — flashes directly over USB. +# Reuses the Tanmatsu's project-local ESP-IDF 5.5.1 (esp32p4) via the ../tanmatsu +# symlinks, and the board-neutral components (meshcore/ardlibs/lvgl/esp_hosted). +# ./build.sh build # compile +# ./build.sh flash -p /dev/cu.usbmodemXXXX +# ./build.sh menuconfig +set -e +cd "$(dirname "$0")" +export IDF_TOOLS_PATH="$PWD/esp-idf-tools" +# shellcheck disable=SC1091 +source esp-idf/export.sh >/dev/null 2>&1 + +# --- Build-time patch: SD_MMC internal pull-ups (T-Display P4) ------------------------------------ +# The board has no external pull-ups on the SD data lines; the IDF sdmmc host explicitly FLOATS the +# pads unless SDMMC_SLOT_FLAG_INTERNAL_PULLUP is set (so pre-begin gpio_pullup_en gets undone). +# Meck-P4's working SD init sets the flag; Arduino's SD_MMC never does and has no API for it. Patch +# the P4's own managed copy. Idempotent. +SDMMC_C="managed_components/espressif__arduino-esp32/libraries/SD_MMC/src/SD_MMC.cpp" +if [ -f "$SDMMC_C" ] && ! grep -q 'wadamesh P4: internal pullups' "$SDMMC_C"; then + sed -i '' 's|sdmmc_slot_config_t slot_config = SDMMC_SLOT_CONFIG_DEFAULT();|sdmmc_slot_config_t slot_config = SDMMC_SLOT_CONFIG_DEFAULT();\n slot_config.flags \|= SDMMC_SLOT_FLAG_INTERNAL_PULLUP; // wadamesh P4: internal pullups (no external ones on the SD lines)|' "$SDMMC_C" + echo "[build.sh] patched arduino SD_MMC.cpp (internal pull-ups on the SD slot)" +fi +# ⚠️ The P4/slot-0 branch REBUILDS slot_config from scratch with `.flags = 0`, silently discarding +# the flag added above (that's the branch that actually runs with BOARD_SDMMC_SLOT=0) — so patch that +# struct literal too. Without the flag the IDF host explicitly FLOATS the pads -> deterministic 0x109 +# CRC at the first data read regardless of card/frequency. +if [ -f "$SDMMC_C" ] && ! grep -q 'wadamesh P4: slot0 pullups' "$SDMMC_C"; then + sed -i '' 's|^ .flags = 0,$| .flags = SDMMC_SLOT_FLAG_INTERNAL_PULLUP, // wadamesh P4: slot0 pullups (struct rebuilt here; the earlier flag is discarded)|' "$SDMMC_C" + echo "[build.sh] patched arduino SD_MMC.cpp (slot-0 struct pull-up flag)" +fi + +# --- Build-time patch: arduino-esp32 must NEVER start esp-hosted on the P4 ------------------------ +# The T-Display P4's C6 runs ESP-AT (driven by the c6_at component over SDIO). If ANY code path +# reaches Arduino's real WiFi (i.e. not rebound to the C6WifiShim facade), its hostedInit() would run +# esp_hosted_init() and grab the C6 SDIO out from under c6_at -> panic ("connect Wi-Fi blue screen"). +# Hard-stub hostedInit() to fail fast instead. Patches only the P4's own managed copy. Idempotent. +HOSTED_C="managed_components/espressif__arduino-esp32/cores/esp32/esp32-hal-hosted.c" +if [ -f "$HOSTED_C" ] && ! grep -q 'wadamesh P4: never start esp_hosted' "$HOSTED_C"; then + sed -i '' 's|^static bool hostedInit() {|static bool hostedInit() { return false; // wadamesh P4: never start esp_hosted (C6 runs ESP-AT; see components/c6_at)|' "$HOSTED_C" + echo "[build.sh] hard-stubbed arduino hostedInit() (P4 uses c6_at, never esp-hosted)" +fi +BLEDEV_CPP="managed_components/espressif__arduino-esp32/libraries/BLE/src/BLEDevice.cpp" +if [ -f "$BLEDEV_CPP" ] && grep -q 'int rc = ble_gap_read_local_irk(irk);' "$BLEDEV_CPP"; then + sed -i '' 's|int rc = ble_gap_read_local_irk(irk);|int rc = 0; (void)irk; // wadamesh: arduino BLE unused; symbol renamed in IDF 5.5 NimBLE|' "$BLEDEV_CPP" + echo "[build.sh] patched arduino BLEDevice.cpp (stub renamed ble_gap_read_local_irk)" +fi + +# --- Build-time patch: neutralize esp-hosted's auto-init constructor (P4-ONLY) -------------------- +# The T-Display P4's on-board C6 runs ESP-AT firmware, which we drive over SDIO via the c6_at +# component (ESSL). esp-hosted (pulled in transitively by arduino-esp32) force-initialises itself in +# an unconditional __attribute__((constructor)) before app_main and grabs the same SDIO — its rx task +# then steals the interrupts/tokens our ESSL send needs, so essl_send_packet fails. There is no +# Kconfig to disable it, so stub the constructor's init call. This patches ONLY the P4's own copy of +# the managed component (tdisplay_p4/managed_components); the Tanmatsu's copy is untouched, so its +# genuine esp-hosted C6 still inits normally. +# Two copies get pulled in (espressif__esp_hosted via arduino-esp32, and the nicolaielectronics__ +# esp-hosted-tanmatsu fork via tanmatsu-wifi/badge-bsp). The build actually links the fork, so patch +# BOTH so whichever is linked never grabs the SDIO. +for HDIR in espressif__esp_hosted nicolaielectronics__esp-hosted-tanmatsu; do + HINIT_C="managed_components/$HDIR/host/port/esp/freertos/src/port_esp_hosted_host_init.c" + if [ -f "$HINIT_C" ] && grep -q 'ESP_ERROR_CHECK(esp_hosted_init());' "$HINIT_C"; then + sed -i '' 's|ESP_ERROR_CHECK(esp_hosted_init());|/* wadamesh P4: C6 runs ESP-AT (driven by c6_at over SDIO) — skip esp-hosted auto-init */|' "$HINIT_C" + echo "[build.sh] neutralized $HDIR auto-init constructor (P4 uses AT, not esp-hosted)" + fi +done + +WADA_FW_TAG="${WADA_FW_TAG:-$(git describe --tags --match 'beta_*' --always 2>/dev/null || echo dev)}" +WADA_FW_DATE="$(date '+%-d %b %Y')" + +exec idf.py -B build/tdisplay_p4 \ + -DDEVICE=tdisplay_p4 \ + -DSDKCONFIG_DEFAULTS="sdkconfigs/general;sdkconfigs/tdisplay_p4;sdkconfigs/wadamesh" \ + -DWADA_FW_TAG="$WADA_FW_TAG" -DWADA_FW_DATE="$WADA_FW_DATE" \ + -DIDF_TARGET=esp32p4 "$@" diff --git a/tdisplay_p4/components/RadioLib/CMakeLists.txt b/tdisplay_p4/components/RadioLib/CMakeLists.txt new file mode 100644 index 0000000..d5c2576 --- /dev/null +++ b/tdisplay_p4/components/RadioLib/CMakeLists.txt @@ -0,0 +1,38 @@ +# RadioLib 7.7.1 as an ESP-IDF component for the T-Display P4 (raw SX1262 path). +# +# The Tanmatsu never needed RadioLib (it uses the LoRa bridge), so this component +# is P4-local — it is intentionally NOT added to the shared `ardlibs` so the +# Tanmatsu build stays RadioLib-free. +# +# Unlike RadioLib's stock IDF CMake (which excludes ALL src/hal/*.cpp and expects +# the caller to supply a RadioLibHal), we build with the *Arduino* HAL: our +# target.cpp constructs `new Module(NSS, DIO1, RST, BUSY, SPIClass&)`, whose +# default hal is ArduinoHal. So we KEEP src/hal/Arduino/ and drop only the other +# platforms' duino HALs (RPi/RPiPico/Stm32duino). +# +# RadioLib auto-selects the Arduino path via `#if defined(ARDUINO)` in BuildOpt.h, +# but arduino-esp32 exports -DARDUINO only PRIVATE-ly, so RadioLib's own TUs (and +# our consumers) wouldn't see it. We therefore force RADIOLIB_BUILD_ARDUINO as a +# PUBLIC define: it reaches both RadioLib's sources AND wadamesh_app (which +# includes ), keeping the Module class layout identical on both sides. + +file(GLOB_RECURSE RADIOLIB_SOURCES "src/*.cpp") +list(FILTER RADIOLIB_SOURCES EXCLUDE REGEX "src/hal/(RPi|RPiPico|Stm32duino)/") + +idf_component_register( + SRCS ${RADIOLIB_SOURCES} + INCLUDE_DIRS "." "src" + REQUIRES espressif__arduino-esp32 +) + +# PUBLIC so consumers that include agree on the Arduino build path. +# - RADIOLIB_BUILD_ARDUINO : use the Arduino HAL (ArduinoHal.cpp). +# - RADIOLIB_GODMODE : expose SX126x private members — MeshCore's core +# CustomSX1262Wrapper/SX126xReset reach into mod/ +# spreadingFactor/freqMHz (matches the S3 PlatformIO +# build flags: -DRADIOLIB_GODMODE=1 -DRADIOLIB_STATIC_ONLY=1). +# - RADIOLIB_STATIC_ONLY : static buffers (must match on both sides — layout). +target_compile_definitions(${COMPONENT_LIB} PUBLIC + RADIOLIB_BUILD_ARDUINO RADIOLIB_GODMODE=1 RADIOLIB_STATIC_ONLY=1) +# Third-party code we don't own — never -Werror-fail on it. +target_compile_options(${COMPONENT_LIB} PRIVATE -w) diff --git a/tdisplay_p4/components/ardlibs b/tdisplay_p4/components/ardlibs new file mode 120000 index 0000000..9d989e7 --- /dev/null +++ b/tdisplay_p4/components/ardlibs @@ -0,0 +1 @@ +../../tanmatsu/components/ardlibs \ No newline at end of file diff --git a/tdisplay_p4/components/c6_at/CMakeLists.txt b/tdisplay_p4/components/c6_at/CMakeLists.txt new file mode 100644 index 0000000..c2c9f2a --- /dev/null +++ b/tdisplay_p4/components/c6_at/CMakeLists.txt @@ -0,0 +1,9 @@ +# P4-ONLY component: AT-over-SDIO Wi-Fi/BLE driver for the on-board ESP32-C6 (which ships running +# Espressif ESP-AT firmware, reachable over SDIO slot 1). This component exists ONLY in the +# tdisplay_p4 build tree, so no other board (Tanmatsu, T-Deck, V4, ...) can even see it. Clean-room: +# uses only Espressif's public SDMMC + esp_serial_slave_link (ESSL) APIs — no third-party driver code. +idf_component_register( + SRCS "c6_at.c" + INCLUDE_DIRS "include" + REQUIRES sdmmc esp_serial_slave_link driver esp_timer esp_mm +) diff --git a/tdisplay_p4/components/c6_at/c6_at.c b/tdisplay_p4/components/c6_at/c6_at.c new file mode 100644 index 0000000..c0e6e64 --- /dev/null +++ b/tdisplay_p4/components/c6_at/c6_at.c @@ -0,0 +1,927 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// c6_at — AT-over-SDIO client for the T-Display P4's ESP32-C6 (ESP-AT firmware). See c6_at.h. +// Clean-room: SDIO transport via Espressif's public SDMMC + esp_serial_slave_link (ESSL); the AT +// framing / worker / Wi-Fi logic on top is our own. +#include "c6_at.h" +#include +#include +#include +#include "esp_log.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/queue.h" +#include "driver/sdmmc_host.h" +#include "driver/sdmmc_default_configs.h" +#include "sdmmc_cmd.h" +#include "esp_serial_slave_link/essl.h" +#include "esp_serial_slave_link/essl_sdio.h" +#include "esp_heap_caps.h" + +static const char *TAG = "c6_at"; + +// ---- C6 SDIO wiring (T-Display P4, "SDIO_2" — slot 1) ---- +#define C6_SDIO_SLOT 1 +#define C6_SDIO_CLK 18 +#define C6_SDIO_CMD 19 +#define C6_SDIO_D0 14 +#define C6_SDIO_D1 15 +#define C6_SDIO_D2 16 +#define C6_SDIO_D3 17 +// ESP-AT SDIO uses a 2048-byte transfer buffer on the slave; the host recv size must match. +#define C6_RECV_BUF 2048 +#define C6_TX_CAP 2048 // AT lines AND raw socket payload chunks go out through s_tx +// SDIO clock: conservative for reliable bring-up; ESP-AT SDIO is happy at 20 MHz. +#define C6_SDIO_KHZ SDMMC_FREQ_DEFAULT // 20 MHz + +static sdmmc_card_t *s_card = NULL; +static essl_handle_t s_essl = NULL; +static volatile bool s_up = false; +static volatile bool s_dead = false; +// ESSL SDIO transfers DMA straight from/into these buffers: they must be DMA-capable internal RAM, +// and on the P4 (cached internal RAM) both the ADDRESS and the transfer SIZE must be cache-line +// aligned — hence heap_caps_aligned_alloc(64) + padding sends up to a 64-multiple. +static uint8_t *s_rx = NULL; +static uint8_t *s_tx = NULL; +static size_t s_align = 64; // ESP32-P4 L1 cache line + +// ---- lock-free status cache (single writer = worker; readers are the UI/facade) ----------------- +#define SCAN_MAX 24 +typedef struct { char ssid[33]; int8_t rssi; uint8_t enc; uint8_t chan; } scan_item_t; +static scan_item_t s_scan[SCAN_MAX]; +static volatile int s_scan_n = 0; +static volatile int s_scan_state = -2; // -2 idle/failed, -1 running, >=0 count +static volatile bool s_connected = false; +static volatile uint32_t s_ip4 = 0; +static char s_cur_ssid[33] = {0}; +static volatile int s_cur_rssi = 0; +static volatile int s_cur_chan = 0; +static volatile bool s_sta_en = false; // facade WiFi.mode() bookkeeping only + +// ---- sockets (AT+CIP*, CIPMUX=1 + CIPRECVMODE=1 passive receive) --------------------------------- +// Passive mode: TCP data buffers ON the C6; "+IPD,," arrives as a bare notification line and +// the worker PULLS payload with AT+CIPRECVDATA into a per-link PSRAM ring. That keeps every byte of +// binary payload inside a bounded command exchange — no async binary demux in the RX stream. +#define SOCK_MAX 5 +#define SOCK_RING 16384 // per-link rx ring (PSRAM) +#define SOCK_PULL_MAX 1436 // per-CIPRECVDATA pull (fits the 2 KB collectors with header room) +typedef struct { + volatile bool used; // link id allocated by a client + volatile bool open; // TCP established (cleared by ",CLOSED" or close) + volatile bool rx_notified; // C6 says data is waiting (+IPD seen) + volatile bool pull_inflight; // a REQ_PULL for this link is queued/running + uint8_t *ring; // SOCK_RING bytes, PSRAM, lazy + volatile uint32_t r, w; // ring indexes (w-r = fill); single producer (worker), single consumer +} sock_t; +static sock_t s_sock[SOCK_MAX]; + +// Inbound server (AT+CIPSERVER, single port). Want-flag design: c6at_server_start just records +// the wish; the worker's idle housekeeping brings the listener up once the AT link exists (and +// re-arms it if the caller asked before the worker was even spawned — the TCP companion latches +// its own "started" flag, so a lost first request would otherwise never retry). Accept FIFO: +// worker produces (urc_scan sees ",CONNECT" on a link no outbound owner marked used), the +// app loop consumes via c6at_server_accept(). Free-running u8 indices; fill = w - r. +static volatile uint16_t s_srv_port = 0; +static volatile bool s_srv_want = false; +static volatile bool s_srv_up = false; +static volatile int8_t s_acc_q[8]; +static volatile uint8_t s_acc_r = 0, s_acc_w = 0; + +// SNTP over the C6: worker configures it after a join and polls AT+CIPSNTPTIME? until synced. +static volatile uint32_t s_sntp_epoch = 0; // UTC epoch at s_sntp_at_ms +static volatile uint32_t s_sntp_at_ms = 0; // esp_timer ms when it was sampled +static bool s_sntp_cfged = false; + +// ---- worker request queue ----------------------------------------------------------------------- +typedef enum { REQ_SCAN = 1, REQ_JOIN, REQ_DISCONNECT, + REQ_SOCK_CONNECT, REQ_SOCK_SEND, REQ_SOCK_PULL, REQ_SOCK_CLOSE, + REQ_SERVER_START, REQ_SERVER_STOP } req_type_t; +// Blocking bridge: the caller stack-allocates `status` (REQ_PENDING) and polls it; the worker writes +// the outcome. Callers block in their own tasks (tile fetcher / reader / version check), never LVGL. +#define REQ_PENDING 0 +#define REQ_OK 1 +#define REQ_FAIL 2 +typedef struct { + req_type_t type; + char ssid[33]; char pass[65]; // JOIN + char host[96]; uint16_t port; bool ssl; // SOCK_CONNECT + int id; // SOCK_* + const uint8_t *data; size_t len; // SOCK_SEND (caller's buffer; caller blocks until done) + volatile int *status; // REQ_* result (may be NULL for fire-and-forget) +} c6_req_t; +static QueueHandle_t s_reqq = NULL; +static TaskHandle_t s_worker = NULL; + +// ---- transport --------------------------------------------------------------------------------- + +static bool sdio_bring_up(void) { + sdmmc_host_t host = SDMMC_HOST_DEFAULT(); + host.slot = C6_SDIO_SLOT; + // ALLOC_ALIGNED_BUF: generic ESSL reads its 4-byte status registers into unaligned stack vars — + // this flag makes the SDMMC layer auto-bounce those through an aligned buffer instead of + // returning INVALID_ARG on the cache-aligned P4. (Our own AT buffers skip the bounce.) + host.flags = SDMMC_HOST_FLAG_4BIT | SDMMC_HOST_FLAG_ALLOC_ALIGNED_BUF; + host.max_freq_khz = C6_SDIO_KHZ; + + sdmmc_slot_config_t slot = { + .clk = C6_SDIO_CLK, .cmd = C6_SDIO_CMD, + .d0 = C6_SDIO_D0, .d1 = C6_SDIO_D1, .d2 = C6_SDIO_D2, .d3 = C6_SDIO_D3, + .cd = SDMMC_SLOT_NO_CD, .wp = SDMMC_SLOT_NO_WP, + .width = 4, .flags = 0, + }; + + esp_err_t err = sdmmc_host_init(); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { // INVALID_STATE = already inited (SD_MMC slot 0) + ESP_LOGE(TAG, "sdmmc_host_init: %s", esp_err_to_name(err)); return false; + } + err = sdmmc_host_init_slot(C6_SDIO_SLOT, &slot); + if (err != ESP_OK) { ESP_LOGE(TAG, "sdmmc_host_init_slot: %s", esp_err_to_name(err)); return false; } + + s_card = (sdmmc_card_t *)calloc(1, sizeof(sdmmc_card_t)); + if (!s_card) return false; + err = sdmmc_card_init(&host, s_card); // probes the C6 as an SDIO (IO) card + if (err != ESP_OK) { ESP_LOGE(TAG, "sdmmc_card_init (C6 not on SDIO?): %s", esp_err_to_name(err)); return false; } + ESP_LOGI(TAG, "SDIO card up"); + + essl_sdio_config_t cfg = { .card = s_card, .recv_buffer_size = C6_RECV_BUF }; + err = essl_sdio_init_dev(&s_essl, &cfg); + if (err != ESP_OK) { ESP_LOGE(TAG, "essl_sdio_init_dev: %s", esp_err_to_name(err)); return false; } + err = essl_init(s_essl, 1000); + if (err != ESP_OK) { ESP_LOGE(TAG, "essl_init: %s", esp_err_to_name(err)); return false; } + (void)essl_wait_for_ready(s_essl, 2000); + return true; +} + +// Drain pending RX packets from the C6 into `out` (up to sz-1), NUL-terminate. Returns bytes read. +// Polls essl_get_packet (the C6 AT slave doesn't reliably raise the SDIO interrupt for us); the echo +// and the "OK" arrive as separate packets, so keep draining until 2 empty polls after data. +static size_t drain_rx(char *out, size_t sz, uint32_t deadline_ms) { + size_t n = 0; + int empty = 0; + while ((uint32_t)(esp_timer_get_time() / 1000) < deadline_ms) { + size_t got = 0; + essl_get_packet(s_essl, s_rx, C6_RECV_BUF, &got, 50); + if (got > 0) { + empty = 0; + if (out) { + size_t cp = (n + got <= sz - 1) ? got : (sz - 1 - n); + memcpy(out + n, s_rx, cp); + n += cp; + } else { + n += got; + } + } else { + if (n > 0 && ++empty >= 2) break; // saw data, now quiet -> response complete + vTaskDelay(pdMS_TO_TICKS(5)); + } + } + if (out) out[(n < sz) ? n : sz - 1] = '\0'; + return n; +} + +// ---- AT client (worker-thread only once the worker runs) ---------------------------------------- + +static void urc_scan(const char *buf, size_t n); // spot +IPD / ,CLOSED lines in any response + +bool c6at_command(const char *cmd, char *resp, size_t resp_sz, uint32_t timeout_ms) { + if (!s_essl || !s_tx) return false; + int len = snprintf((char *)s_tx, C6_TX_CAP, "%s\r\n", cmd); + if (len <= 0 || len >= C6_TX_CAP) return false; + // The SDIO DMA transfer size must be a multiple of the cache line: pad with '\n' (ESP-AT + // ignores empty lines). + size_t plen = ((size_t)len + s_align - 1) / s_align * s_align; + if (plen > C6_TX_CAP) plen = C6_TX_CAP; + for (size_t i = (size_t)len; i < plen; i++) s_tx[i] = '\n'; + + esp_err_t serr = essl_send_packet(s_essl, s_tx, plen, 1000); + if (serr != ESP_OK) { ESP_LOGW(TAG, "send '%s' failed: %s", cmd, esp_err_to_name(serr)); return false; } + + // Collect the reply until a terminating status line appears. Static: only the worker calls this. + static char buf[3584]; size_t n = 0; buf[0] = '\0'; + uint32_t deadline = (uint32_t)(esp_timer_get_time() / 1000) + timeout_ms; + bool ok = false, done = false; + while (!done && (uint32_t)(esp_timer_get_time() / 1000) < deadline) { + static char chunk[C6_RECV_BUF]; + size_t got = drain_rx(chunk, sizeof(chunk), deadline); + if (got) { + size_t cp = (n + got < sizeof(buf) - 1) ? got : (sizeof(buf) - 1 - n); + memcpy(buf + n, chunk, cp); n += cp; buf[n] = '\0'; + } + if (strstr(buf, "\r\nOK\r\n") || (n >= 4 && !strncmp(buf, "OK\r\n", 4))) { ok = true; done = true; } + else if (strstr(buf, "\r\nERROR\r\n") || strstr(buf, "\r\nFAIL\r\n")) { ok = false; done = true; } + } + urc_scan(buf, n); // "+IPD"/",CLOSED" lines interleave with command output — never miss them + if (resp) { strncpy(resp, buf, resp_sz - 1); resp[resp_sz - 1] = '\0'; } + ESP_LOGI(TAG, "AT '%s' -> %s (%u bytes)", cmd, ok ? "OK" : (done ? "ERR" : "TIMEOUT"), (unsigned)n); + return ok; +} + +// ---- TEMP diagnostic: scan the C6's mfg_nvs partition for the baked-in GATT service table -------- +// ESP-AT v4 keeps its factory data (and, we hope, the BLE services blob) in the 'mfg_nvs' partition, +// which AT+SYSFLASH exposes read/write over PLAIN AT — i.e. a possible cable-free path to replacing +// the demo GATT table with MeshCore's NUS UUIDs. This scanner chunk-reads the partition and reports +// GATT signatures (0x2800 primary-svc / 0x2803 char-decl / 0x2902 CCCD / 0xA002+0xC300 demo UUIDs) +// and ASCII NVS key names. Diagnostic only — compiled out once the layout is known. + +// ---- TEMP diagnostic: replace the C6's GATT table with MeshCore's NUS over AT+SYSMFG ------------- +// The factory table (stock esp-at demo, cfg0..cfg30 in mfg_nvs "ble_data") can't serve the MeshCore +// app's 128-bit Nordic-UART UUIDs. AT+SYSMFG edits those rows key-by-key (firmware does the NVS +// writes; full factory image backed up in tdisplay_p4/c6_mfg_nvs_factory_backup.bin). 128-bit hex +// byte order is undocumented — this writes LITTLE-ENDIAN (reversed) and reads back BLEGATTSCHAR? +// to check; if the echo is reversed we flip. Row format: index,uuid_len,uuid,perm,max,cur,value. +#define C6AT_DIAG_SYSMFG 1 +#if C6AT_DIAG_SYSMFG +static bool collect_until(const char *token, char *acc, size_t acc_sz, uint32_t timeout_ms); +static bool sysmfg_write_row(int idx, const char *row) { + static char acc[512]; + int clen = snprintf((char *)s_tx, C6_TX_CAP, "AT+SYSMFG=2,\"ble_data\",\"cfg%d\",7,%u\r\n", + idx, (unsigned)strlen(row)); + size_t plen = ((size_t)clen + s_align - 1) / s_align * s_align; + for (size_t i = (size_t)clen; i < plen; i++) s_tx[i] = '\n'; + if (essl_send_packet(s_essl, s_tx, plen, 1000) != ESP_OK) return false; + if (!collect_until(">", acc, sizeof(acc), 3000)) { printf("[nus] cfg%d no prompt: %s\n", idx, acc); return false; } + // Data phase: EXACT length first — pad bytes after the counted data desync the SYSMFG reader + // (unlike CIPSEND, which tolerates them). ALLOC_ALIGNED_BUF bounces small unaligned DMA sends; + // fall back to '\n' padding only if the raw send is rejected by the host driver. + int dl = snprintf((char *)s_tx, C6_TX_CAP, "%s", row); + esp_err_t serr = essl_send_packet(s_essl, s_tx, (size_t)dl, 1000); + if (serr != ESP_OK) { + size_t dpad = ((size_t)dl + s_align - 1) / s_align * s_align; + for (size_t i = (size_t)dl; i < dpad; i++) s_tx[i] = '\n'; + if (essl_send_packet(s_essl, s_tx, dpad, 1000) != ESP_OK) return false; + printf("[nus] cfg%d exact-len send rejected (0x%x), padded fallback used\n", idx, serr); + } + if (!collect_until("OK", acc, sizeof(acc), 3000)) { printf("[nus] cfg%d write FAIL: %s\n", idx, acc); return false; } + printf("[nus] cfg%d <- %s\n", idx, row); + // Self-sync: ping until the parser answers OK again (absorbs any queued stray responses). + for (int t = 0; t < 6; t++) { + if (c6at_command("AT", NULL, 0, 400)) return true; + vTaskDelay(pdMS_TO_TICKS(250)); + } + printf("[nus] cfg%d: parser never re-synced\n", idx); + return false; +} +// One-time GATT-table provisioning + BLE bring-up. Idempotent: if cfg0 already holds the MeshCore +// service row, the table writes are skipped and only init+advertising run. The factory demo table +// is preserved in tdisplay_p4/c6_mfg_nvs_factory_backup.bin (restore = write the 31 original rows +// back through the same SYSMFG mechanism). +#define NUS_SVC_LE "9ECADC240EE5A9E093F3A3B50100406E" // 6E400001-B5A3-F393-E0A9-E50E24DCCA9E, LE +static void c6at_ble_provision(void) { + static char r[1024]; + if (!c6at_command("AT+SYSMFG=1,\"ble_data\",\"cfg0\"", r, sizeof(r), 2000)) { + printf("[nus] SYSMFG absent/unreadable — BLE unavailable on this AT build\n"); + return; + } + if (!strstr(r, NUS_SVC_LE)) { + printf("[nus] provisioning MeshCore GATT table (one-time)\n"); + c6at_command("AT+BLEINIT=0", NULL, 0, 2000); // ble_data may only change while BLE is down + // MeshCore NUS (SerialBLEInterface): svc 6E400001-..., RX 6E400002 (write), + // TX 6E400003 (read+notify) + CCCD. 128-bit hex = REVERSED (little-endian, NimBLE order). + static const char *rows[] = { + "0,16,0x2800,0x01,16,16," NUS_SVC_LE, + "1,16,0x2803,0x01,1,1,08", // props: WRITE + "2,128,0x9ECADC240EE5A9E093F3A3B50200406E,0x10,512,512,", // RX value (perm WRITE) + "3,16,0x2803,0x01,1,1,12", // props: READ|NOTIFY + "4,128,0x9ECADC240EE5A9E093F3A3B50300406E,0x01,512,512,", // TX value (perm READ) + "5,16,0x2902,0x11,2,2,0000", // CCCD rw + }; + for (int i = 0; i < 6; i++) { + c6at_command("AT", NULL, 0, 400); // absorb strays; keep the parser in sync + vTaskDelay(pdMS_TO_TICKS(80)); + if (!sysmfg_write_row(i, rows[i])) { + vTaskDelay(pdMS_TO_TICKS(300)); + c6at_command("AT", NULL, 0, 400); + if (!sysmfg_write_row(i, rows[i])) return; // next boot retries the full table + } + } + for (int i = 6; i <= 30; i++) { // drop the demo rows beyond our table + char cmd[48]; + snprintf(cmd, sizeof(cmd), "AT+SYSMFG=0,\"ble_data\",\"cfg%d\"", i); + c6at_command(cmd, NULL, 0, 2000); + vTaskDelay(pdMS_TO_TICKS(25)); + } + printf("[nus] GATT table provisioned\n"); + } + // BLE stays OFF: this dev AT build can't set adv data / start advertising (BLEADVDATA / + // BLEADVDATAEX / BLESCANRSPDATA / BLEADVSTART all ERROR; BLEINIT=2 only fires an unnamed, + // uncontrollable auto-advert). Leaving it down keeps the airwaves clean; the provisioned + // MeshCore table means a future LilyGo AT firmware with working advertising lights up + // instantly. UI-side, MultiTransport::hasBleCapability() is false on this board. + c6at_command("AT+BLEINIT=0", NULL, 0, 2000); +} +#endif + +bool c6at_begin(void) { + if (s_up) return true; + ESP_LOGI(TAG, "bringing up C6 AT over SDIO slot %d ...", C6_SDIO_SLOT); + if (!s_rx) s_rx = heap_caps_aligned_alloc(s_align, C6_RECV_BUF, MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL); + if (!s_tx) s_tx = heap_caps_aligned_alloc(s_align, C6_TX_CAP, MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL); + if (!s_rx || !s_tx) { ESP_LOGE(TAG, "DMA buffer alloc failed"); return false; } + if (!sdio_bring_up()) return false; + + for (int i = 0; i < 10; i++) { // the C6 may still be booting ESP-AT + char r[64]; + if (c6at_command("AT", r, sizeof(r), 500)) { s_up = true; break; } + vTaskDelay(pdMS_TO_TICKS(200)); + } + if (!s_up) { ESP_LOGE(TAG, "C6 never answered AT"); return false; } + + c6at_command("ATE0", NULL, 0, 500); // echo off + c6at_command("AT+CWMODE=1", NULL, 0, 1000); // station mode + c6at_command("AT+CIPMUX=1", NULL, 0, 1000); // multi-connection (link ids 0..4) + c6at_command("AT+CIPRECVMODE=1", NULL, 0, 1000); // passive rx: data buffers on the C6, host pulls + { // Log the slave firmware version once per boot (cheap, invaluable in bug reports). + // BLE survey result (2026-07-15, factory LilyGo ESP-AT v4.1.0.0-dev): BLE commands PRESENT, + // but the GATT table is the BAKED-IN stock demo (16-bit 0xC30x/0xC40x chars) — ESP-AT can't + // create runtime services, so the MeshCore app's 128-bit UUIDs can never exist on this build. + // No self-OTA either (AT+CIUPDATE/AT+USEROTA both absent) — a C6 firmware change is a WIRED + // flash via the C6 UART0/IO9 pads. BLE on this board therefore needs the C6 reflashed + // (esp-hosted slave = the Tanmatsu path, or a custom AT build) — a product decision, not code. + char r[512]; + if (c6at_command("AT+GMR", r, sizeof(r), 1000)) printf("[c6at] GMR:\n%s\n", r); + } +#if C6AT_DIAG_SYSMFG + c6at_ble_provision(); +#endif + ESP_LOGI(TAG, "C6 ESP-AT is up"); + return true; +} + +bool c6at_is_up(void) { return s_up; } +bool c6at_is_dead(void) { return s_dead; } + +// ---- worker internals ---------------------------------------------------------------------------- + +// Spot unsolicited result codes in any text the C6 sends. Passive mode means +IPD carries NO payload +// (just "data waiting"), so plain string scanning is safe. +static void urc_scan(const char *buf, size_t n) { + (void)n; + const char *p = buf; + while ((p = strstr(p, "+IPD,")) != NULL) { + int id = atoi(p + 5); + if (id >= 0 && id < SOCK_MAX) s_sock[id].rx_notified = true; + p += 5; + } + for (int id = 0; id < SOCK_MAX; id++) { + char pat[12]; + snprintf(pat, sizeof(pat), "%d,CLOSED", id); + if (strstr(buf, pat)) { s_sock[id].open = false; } + } + // Inbound server connections: ",CONNECT" on a link NO outbound owner has marked used + // (c6at_sock_connect sets used=true before the worker ever sends CIPSTART, so an outbound + // link's own CONNECT line can't be mistaken for an inbound one). Runs in worker/bring-up + // context only — the PSRAM ring malloc is safe here. + if (s_srv_up) { + for (int id = 0; id < SOCK_MAX; id++) { + if (s_sock[id].used) continue; + char pat[14]; + snprintf(pat, sizeof(pat), "%d,CONNECT", id); + if (!strstr(buf, pat)) continue; + sock_t *sk = &s_sock[id]; + if (!sk->ring) sk->ring = heap_caps_malloc(SOCK_RING, MALLOC_CAP_SPIRAM); + if (!sk->ring) continue; // no RAM: ignore; the peer will time out + sk->r = sk->w = 0; sk->rx_notified = false; sk->pull_inflight = false; + sk->open = true; sk->used = true; // used LAST: publishes the slot + if ((uint8_t)(s_acc_w - s_acc_r) < 8) { s_acc_q[s_acc_w % 8] = (int8_t)id; s_acc_w++; } + ESP_LOGI(TAG, "server: inbound connection on link %d", id); + } + } +} + +static inline uint32_t ring_fill(sock_t *s) { return s->w - s->r; } +static inline uint32_t ring_free(sock_t *s) { return SOCK_RING - ring_fill(s); } + +static void ring_push(sock_t *s, const uint8_t *d, uint32_t len) { + for (uint32_t i = 0; i < len; i++) s->ring[(s->w + i) % SOCK_RING] = d[i]; + s->w += len; // publish after the bytes are in place (single producer) +} + +// Bespoke collector: drain the RX stream into `acc` until `token` appears (or deadline). Used for the +// CIPSEND '>' prompt and the SEND OK trailer, where c6at_command's OK/ERROR framing doesn't apply. +static bool collect_until(const char *token, char *acc, size_t acc_sz, uint32_t timeout_ms) { + size_t n = 0; acc[0] = '\0'; + uint32_t deadline = (uint32_t)(esp_timer_get_time() / 1000) + timeout_ms; + while ((uint32_t)(esp_timer_get_time() / 1000) < deadline) { + size_t got = 0; + essl_get_packet(s_essl, s_rx, C6_RECV_BUF, &got, 40); + if (got) { + size_t cp = (n + got < acc_sz - 1) ? got : (acc_sz - 1 - n); + memcpy(acc + n, s_rx, cp); n += cp; acc[n] = '\0'; + if (strstr(acc, token)) { urc_scan(acc, n); return true; } + if (strstr(acc, "ERROR")) { urc_scan(acc, n); return false; } + } else { + vTaskDelay(pdMS_TO_TICKS(4)); + } + } + urc_scan(acc, n); + return false; +} + +static void worker_sock_connect(c6_req_t *rq) { + sock_t *s = &s_sock[rq->id]; + if (!s->ring) s->ring = heap_caps_malloc(SOCK_RING, MALLOC_CAP_SPIRAM); + if (!s->ring) { if (rq->status) *rq->status = REQ_FAIL; return; } + s->r = s->w = 0; s->rx_notified = false; s->open = false; + char cmd[160]; + snprintf(cmd, sizeof(cmd), "AT+CIPSTART=%d,\"%s\",\"%s\",%u", + rq->id, rq->ssl ? "SSL" : "TCP", rq->host, (unsigned)rq->port); + bool ok = c6at_command(cmd, NULL, 0, 15000); + s->open = ok; + if (rq->status) *rq->status = ok ? REQ_OK : REQ_FAIL; +} + +static void worker_sock_send(c6_req_t *rq) { + sock_t *s = &s_sock[rq->id]; + const uint8_t *p = rq->data; + size_t left = rq->len; + bool ok = s->open; + static char acc[512]; + while (ok && left > 0) { + size_t chunk = left > SOCK_PULL_MAX ? SOCK_PULL_MAX : left; + char cmd[40]; + snprintf(cmd, sizeof(cmd), "AT+CIPSEND=%d,%u", rq->id, (unsigned)chunk); + int clen = snprintf((char *)s_tx, C6_TX_CAP, "%s\r\n", cmd); + size_t plen = ((size_t)clen + s_align - 1) / s_align * s_align; + for (size_t i = (size_t)clen; i < plen; i++) s_tx[i] = '\n'; + if (essl_send_packet(s_essl, s_tx, plen, 1000) != ESP_OK) { ok = false; break; } + if (!collect_until(">", acc, sizeof(acc), 3000)) { ok = false; break; } + // Raw payload: exactly `chunk` bytes count; the '\n' padding lands in the AT parser as + // ignorable empty lines. + memcpy(s_tx, p, chunk); + size_t dlen = (chunk + s_align - 1) / s_align * s_align; + for (size_t i = chunk; i < dlen; i++) s_tx[i] = '\n'; + if (essl_send_packet(s_essl, s_tx, dlen, 2000) != ESP_OK) { ok = false; break; } + // 4 s, not more: SEND OK normally lands in ms; it only drags when the peer stopped + // ACKing and the C6's socket buffer is full. Every second spent here stalls the whole + // AT worker (companion + HTTP + mirror), so fail fast and let the caller's reaper + // drop the sick client. + if (!collect_until("SEND OK", acc, sizeof(acc), 4000)) { ok = false; break; } + p += chunk; left -= chunk; + } + // Success at debug only — the web-mirror stream sends many frames/s and a per-send + // INFO line would both flood the console and throttle the stream. + if (ok) ESP_LOGD(TAG, "sock%d send %u -> OK", rq->id, (unsigned)rq->len); + else ESP_LOGI(TAG, "sock%d send %u -> FAIL", rq->id, (unsigned)rq->len); + if (rq->status) *rq->status = ok ? REQ_OK : REQ_FAIL; +} + +// Pull buffered TCP payload off the C6 into the link's ring. Binary-safe bespoke parse: the payload +// may contain "OK\r\n", so we parse "+CIPRECVDATA:," structurally and count bytes. +static void worker_sock_pull(int id) { + sock_t *s = &s_sock[id]; + s->pull_inflight = false; // re-kickable as soon as we've run + if (!s->ring) return; + uint32_t space = ring_free(s); + if (space < 128) return; // consumer hasn't caught up — retry on the next kick + uint32_t want = space - 1; if (want > SOCK_PULL_MAX) want = SOCK_PULL_MAX; + + int clen = snprintf((char *)s_tx, C6_TX_CAP, "AT+CIPRECVDATA=%d,%u\r\n", id, (unsigned)want); + size_t plen = ((size_t)clen + s_align - 1) / s_align * s_align; + for (size_t i = (size_t)clen; i < plen; i++) s_tx[i] = '\n'; + if (essl_send_packet(s_essl, s_tx, plen, 1000) != ESP_OK) return; + + static uint8_t acc[SOCK_PULL_MAX + 512]; + size_t n = 0; + uint32_t deadline = (uint32_t)(esp_timer_get_time() / 1000) + 3000; + int hdr_at = -1, data_len = -1; size_t data_off = 0; + while ((uint32_t)(esp_timer_get_time() / 1000) < deadline) { + size_t got = 0; + essl_get_packet(s_essl, s_rx, C6_RECV_BUF, &got, 40); + if (got) { + size_t cp = (n + got <= sizeof(acc)) ? got : (sizeof(acc) - n); + memcpy(acc + n, s_rx, cp); n += cp; + } else { vTaskDelay(pdMS_TO_TICKS(3)); } + if (hdr_at < 0 && n > 16) { + acc[n < sizeof(acc) ? n : sizeof(acc) - 1] = 0; + char *h = strstr((char *)acc, "+CIPRECVDATA:"); + if (h) { + data_len = atoi(h + 13); + char *c = strchr(h + 13, ','); + if (c) { hdr_at = (int)((uint8_t *)c - acc); data_off = (size_t)hdr_at + 1; } + else if (data_len == 0) { break; } + } else if (strstr((char *)acc, "ERROR")) { break; } + } + if (hdr_at >= 0 && data_len >= 0 && n >= data_off + (size_t)data_len) break; // payload complete + } + if (hdr_at >= 0 && data_len > 0 && n >= data_off + (size_t)data_len) { + ring_push(s, acc + data_off, (uint32_t)data_len); + if ((uint32_t)data_len >= want) s->rx_notified = true; // more likely waiting — keep pulling + else s->rx_notified = false; + ESP_LOGI(TAG, "sock%d pull %d bytes (ring %u)", id, data_len, (unsigned)ring_fill(s)); + } else { + s->rx_notified = false; // nothing there (or error) — wait for the next +IPD + } + urc_scan((char *)acc, n); +} + +static void worker_sntp_service(bool just_joined) { + if (just_joined && !s_sntp_cfged) { + s_sntp_cfged = c6at_command("AT+CIPSNTPCFG=1,0,\"pool.ntp.org\",\"time.google.com\"", NULL, 0, 2000); + } + if (!s_sntp_cfged || s_sntp_epoch) return; + static char resp[192]; + if (!c6at_command("AT+CIPSNTPTIME?", resp, sizeof(resp), 3000)) return; + // +CIPSNTPTIME:Thu Jul 14 21:03:15 2026 (UTC because tz=0) + char *p = strstr(resp, "+CIPSNTPTIME:"); + if (!p) return; + p += 13; + char mon[4] = {0}; int day, hh, mm, ss, year; + if (sscanf(p, "%*3s %3s %d %d:%d:%d %d", mon, &day, &hh, &mm, &ss, &year) != 6) return; + if (year < 2024) return; // not synced yet (epoch/1970 placeholder) + static const char *M = "JanFebMarAprMayJunJulAugSepOctNovDec"; + const char *mp = strstr(M, mon); + if (!mp) return; + int m = (int)(mp - M) / 3 + 1; + // days-from-civil (Howard Hinnant) -> UTC epoch + int y = year - (m <= 2); + int era = y / 400; + int yoe = y - era * 400; + int doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + day - 1; + int doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + long days = (long)era * 146097 + doe - 719468; + s_sntp_epoch = (uint32_t)(days * 86400L + hh * 3600 + mm * 60 + ss); + s_sntp_at_ms = (uint32_t)(esp_timer_get_time() / 1000); + ESP_LOGI(TAG, "SNTP synced: %04d-%02d-%02d %02d:%02d:%02d UTC (epoch %lu)", + year, m, day, hh, mm, ss, (unsigned long)s_sntp_epoch); +} + +static void worker_refresh_status(void) { + // Current AP: +CWJAP:"ssid","mac",chan,rssi,... ERROR/No AP when disconnected. + // A single failed/garbled poll must NOT flip s_connected: the response buffer can be + // polluted by +IPD/CONNECT URC noise from active links, and the app reacts to one + // false "disconnected" with WiFi.disconnect+begin — which silently kills the C6's + // CIPSERVER. Only a "No AP" answer (genuinely not associated) clears immediately; + // anything ambiguous needs 2 misses in a row. + static uint8_t miss_streak = 0; + static char resp[512]; + bool cmd_ok = c6at_command("AT+CWJAP?", resp, sizeof(resp), 3000); + char *p = cmd_ok ? strstr(resp, "+CWJAP:\"") : NULL; + if (!p) { + bool no_ap = cmd_ok && strstr(resp, "No AP"); + if (no_ap || ++miss_streak >= 2) { + s_connected = false; s_ip4 = 0; + if (no_ap) s_cur_ssid[0] = '\0'; + miss_streak = 0; + } + return; + } + miss_streak = 0; + p += 8; + char *e = strchr(p, '"'); + if (e) { + size_t l = (size_t)(e - p); if (l > 32) l = 32; + memcpy(s_cur_ssid, p, l); s_cur_ssid[l] = '\0'; + // fields: "ssid","mac",chan,rssi — chan = after the 2nd comma, rssi = after the 3rd + char *c = e; + for (int skip = 0; c && skip < 2; skip++) c = strchr(c + 1, ','); + if (c) { s_cur_chan = atoi(c + 1); c = strchr(c + 1, ','); } + if (c) s_cur_rssi = atoi(c + 1); + } + // IP: +CIPSTA:ip:"a.b.c.d" + if (c6at_command("AT+CIPSTA?", resp, sizeof(resp), 2000)) { + char *ip = strstr(resp, "+CIPSTA:ip:\""); + if (ip) { + unsigned a, b, c2, d; + if (sscanf(ip + 12, "%u.%u.%u.%u", &a, &b, &c2, &d) == 4) { + uint32_t v = (a << 24) | (b << 16) | (c2 << 8) | d; + s_ip4 = (v == 0) ? 0 : v; + s_connected = (v != 0); + return; + } + } + } + s_connected = true; // associated (CWJAP answered) but no IP parsed yet +} + +static void worker_do_scan(void) { + s_scan_state = -1; + static char resp[3072]; + if (!c6at_command("AT+CWLAP", resp, sizeof(resp), 10000)) { s_scan_n = 0; s_scan_state = 0; return; } + // Lines: +CWLAP:(ecn,"ssid",rssi,"mac",channel,...) + int count = 0; + char *p = resp; + while (count < SCAN_MAX && (p = strstr(p, "+CWLAP:(")) != NULL) { + p += 8; + int ecn = atoi(p); + char *q = strchr(p, '"'); if (!q) break; + q++; + char *e = strchr(q, '"'); if (!e) break; + size_t sl = (size_t)(e - q); if (sl > 32) sl = 32; + memcpy(s_scan[count].ssid, q, sl); s_scan[count].ssid[sl] = '\0'; + char *r = strchr(e, ','); + s_scan[count].rssi = r ? (int8_t)atoi(r + 1) : 0; + s_scan[count].enc = (uint8_t)ecn; + // channel = 5th field: (ecn,"ssid",rssi,"mac",channel — two commas past the rssi one + s_scan[count].chan = 0; + if (r) { char *c = strchr(r + 1, ','); if (c) c = strchr(c + 1, ','); if (c) s_scan[count].chan = (uint8_t)atoi(c + 1); } + if (s_scan[count].ssid[0]) count++; + p = e + 1; + } + s_scan_n = count; + s_scan_state = count; + ESP_LOGI(TAG, "scan: %d APs", count); +} + +static void worker_do_join(const char *ssid, const char *pass) { + char cmd[192]; + // NB: no escaping of " , \ in credentials yet (rare in practice; TODO if it bites). + snprintf(cmd, sizeof(cmd), "AT+CWJAP=\"%s\",\"%s\"", ssid, pass ? pass : ""); + static char resp[512]; + bool ok = c6at_command(cmd, resp, sizeof(resp), 20000); + if (ok || strstr(resp, "WIFI GOT IP")) { + strncpy(s_cur_ssid, ssid, sizeof(s_cur_ssid) - 1); + worker_refresh_status(); + ESP_LOGI(TAG, "join '%s': %s ip=%08lx", ssid, s_connected ? "CONNECTED" : "assoc,no-ip", (unsigned long)s_ip4); + } else { + s_connected = false; s_ip4 = 0; + ESP_LOGW(TAG, "join '%s' FAILED", ssid); + } +} + +static void c6WorkerTask(void *arg) { + (void)arg; + // Bring the link up ourselves (the C6 may still be booting ESP-AT after the C6_EN pulse). + vTaskDelay(pdMS_TO_TICKS(1500)); + bool up = false; + for (int i = 0; i < 10 && !up; i++) { + up = c6at_begin(); + if (!up) vTaskDelay(pdMS_TO_TICKS(1000)); + } + if (!up) { + s_dead = true; + ESP_LOGE(TAG, "worker: C6 AT link never came up — Wi-Fi unavailable"); + // Keep servicing the queue as no-ops so requesters never wedge. + } + uint32_t last_refresh = 0; + for (;;) { + c6_req_t req; + if (xQueueReceive(s_reqq, &req, pdMS_TO_TICKS(150)) == pdTRUE) { + if (!s_up) { // link dead: fail requests fast + if (req.type == REQ_SCAN) { s_scan_n = 0; s_scan_state = 0; } + if (req.status) *req.status = REQ_FAIL; + continue; + } + switch (req.type) { + case REQ_SCAN: worker_do_scan(); break; + case REQ_JOIN: worker_do_join(req.ssid, req.pass); + if (s_connected) worker_sntp_service(true); + // A (re)association drops any running CIPSERVER on the + // C6 — re-arm the want-flag so the idle loop restarts it. + if (s_srv_want) s_srv_up = false; + break; + case REQ_DISCONNECT: c6at_command("AT+CWQAP", NULL, 0, 3000); s_connected = false; s_ip4 = 0; break; + case REQ_SOCK_CONNECT: worker_sock_connect(&req); break; + case REQ_SOCK_SEND: worker_sock_send(&req); break; + case REQ_SOCK_PULL: worker_sock_pull(req.id); break; + case REQ_SOCK_CLOSE: { + if (s_sock[req.id].open) { // peer-closed links (",CLOSED" seen) error on CIPCLOSE + char cmd[24]; + snprintf(cmd, sizeof(cmd), "AT+CIPCLOSE=%d", req.id); + c6at_command(cmd, NULL, 0, 3000); + s_sock[req.id].open = false; + } + if (req.status) *req.status = REQ_OK; + break; + } + case REQ_SERVER_START: // legacy kick — the want-flag path below does the work + if (req.status) *req.status = REQ_OK; + break; + case REQ_SERVER_STOP: + c6at_command("AT+CIPSERVER=0", NULL, 0, 5000); + s_srv_up = false; + if (req.status) *req.status = REQ_OK; + break; + } + last_refresh = (uint32_t)(esp_timer_get_time() / 1000); + } else if (s_up) { + // Idle housekeeping: + // 1. service links with data waiting on the C6 (+IPD seen) — pull into their rings. + bool pulled = false; + for (int id = 0; id < SOCK_MAX; id++) { + sock_t *sk = &s_sock[id]; + if (sk->used && sk->rx_notified && sk->ring && ring_free(sk) > 128) { + worker_sock_pull(id); + pulled = true; + break; // one per idle slice keeps the loop responsive to new requests + } + } + // 2. drain any stray unsolicited lines so +IPD/CLOSED aren't stuck in the C6's queue. + if (!pulled) { + size_t got = 0; + essl_get_packet(s_essl, s_rx, C6_RECV_BUF, &got, 20); + if (got) { s_rx[got < C6_RECV_BUF ? got : C6_RECV_BUF - 1] = 0; urc_scan((char *)s_rx, got); } + } + // 3. bring the inbound listener up when asked (want-flag; also self-heals a request + // made before this worker existed — the TCP companion latches its own started flag). + static uint32_t srv_next_try = 0; + if (s_srv_want && !s_srv_up && s_srv_port && + (uint32_t)(esp_timer_get_time() / 1000) >= srv_next_try) { + c6at_command("AT+CIPSERVERMAXCONN=3", NULL, 0, 2000); // phone + web viewer + one transient page GET; the rest stay outbound (tiles/HTTP) + char cmd[32]; + snprintf(cmd, sizeof(cmd), "AT+CIPSERVER=1,%u", (unsigned)s_srv_port); + bool ok = c6at_command(cmd, NULL, 0, 5000); + s_srv_up = ok; + ESP_LOGI(TAG, "CIPSERVER port %u -> %s", (unsigned)s_srv_port, ok ? "LISTENING" : "FAIL (retry in 12s)"); + if (!ok) srv_next_try = (uint32_t)(esp_timer_get_time() / 1000) + 12000; + } + // 4. refresh association/IP every ~12 s so the UI stays truthful, + SNTP until synced. + uint32_t now = (uint32_t)(esp_timer_get_time() / 1000); + if (now - last_refresh > 12000) { + worker_refresh_status(); + // Self-heal the inbound listener: the C6 drops CIPSERVER on re-association + // (or an app-side rejoin) without any URC, leaving s_srv_up stale-true and + // the port refusing connections until reboot. Ask, and re-arm on mismatch. + if (s_srv_want && s_srv_up && s_connected) { + static char sresp[96]; + if (c6at_command("AT+CIPSERVER?", sresp, sizeof(sresp), 2000)) { + char *sp = strstr(sresp, "+CIPSERVER:"); + if (sp && sp[11] == '0') { + s_srv_up = false; + ESP_LOGW(TAG, "CIPSERVER dropped by C6 — re-arming"); + } + } + } + if (s_connected) worker_sntp_service(!s_sntp_cfged); + last_refresh = now; + } + } + } +} + +// ---- public async API ----------------------------------------------------------------------------- + +void c6at_worker_start(void) { + if (s_worker) return; + if (!s_reqq) s_reqq = xQueueCreate(6, sizeof(c6_req_t)); + xTaskCreatePinnedToCore(c6WorkerTask, "c6at", 16384, NULL, 3, &s_worker, 1); +} + +void c6at_req_scan(void) { + if (s_dead) { s_scan_n = 0; s_scan_state = 0; return; } + s_scan_state = -1; // running (set now so a poll right after sees RUNNING) + c6_req_t r = { .type = REQ_SCAN }; + if (s_reqq) xQueueSend(s_reqq, &r, 0); +} + +void c6at_req_join(const char *ssid, const char *pass) { + if (s_dead || !ssid) return; + c6_req_t r = { .type = REQ_JOIN }; + strncpy(r.ssid, ssid, sizeof(r.ssid) - 1); + if (pass) strncpy(r.pass, pass, sizeof(r.pass) - 1); + if (s_reqq) xQueueSend(s_reqq, &r, 0); +} + +void c6at_req_disconnect(void) { + if (s_dead) return; + c6_req_t r = { .type = REQ_DISCONNECT }; + if (s_reqq) xQueueSend(s_reqq, &r, 0); +} + +bool c6at_connected(void) { return s_connected; } +uint32_t c6at_ip4(void) { return s_ip4; } +int c6at_current_rssi(void) { return s_cur_rssi; } +int c6at_current_channel(void) { return s_cur_chan; } +int c6at_scan_state(void) { return s_scan_state; } +int c6at_scan_channel(int idx) { return (idx < 0 || idx >= s_scan_n) ? 0 : s_scan[idx].chan; } + +bool c6at_current_ssid(char *out, size_t sz) { + if (!out || sz == 0) return false; + strncpy(out, s_cur_ssid, sz - 1); out[sz - 1] = '\0'; + return out[0] != '\0'; +} + +bool c6at_scan_get(int idx, char *ssid, size_t sz, int8_t *rssi, uint8_t *enc) { + if (idx < 0 || idx >= s_scan_n) return false; + if (ssid) { strncpy(ssid, s_scan[idx].ssid, sz - 1); ssid[sz - 1] = '\0'; } + if (rssi) *rssi = s_scan[idx].rssi; + if (enc) *enc = s_scan[idx].enc; + return true; +} + +void c6at_scan_clear(void) { s_scan_n = 0; s_scan_state = -2; } +void c6at_set_sta_enabled(bool en) { s_sta_en = en; } +bool c6at_sta_enabled(void) { return s_sta_en; } + +// ---- public socket API ----------------------------------------------------------------------------- +// Blocking bridge: the caller polls a stack-resident status until the worker writes it. NO caller-side +// timeout bail — the worker's own AT timeouts bound every op, and bailing early would leave the worker +// holding a dangling pointer onto our stack. (Dead-link requests are failed fast by the worker.) + +static uint32_t s_last_kick[SOCK_MAX]; + +static void kick_pull(int id) { + sock_t *s = &s_sock[id]; + if (s->pull_inflight || !s_reqq) return; + s->pull_inflight = true; + c6_req_t r = { .type = REQ_SOCK_PULL, .id = id }; + if (xQueueSend(s_reqq, &r, 0) != pdTRUE) s->pull_inflight = false; + else s_last_kick[id] = (uint32_t)(esp_timer_get_time() / 1000); +} + +int c6at_sock_connect(const char *host, uint16_t port, bool ssl, uint32_t timeout_ms) { + (void)timeout_ms; // worker-side CIPSTART timeout (15 s) governs + if (s_dead || !s_up || !host || !s_reqq) return -1; + int id = -1; + // Top-down: ESP-AT hands INBOUND server connections the lowest free id, so outbound stays + // high to avoid racing an arriving phone-app connection for the same link number. + for (int i = SOCK_MAX - 1; i >= 0; i--) if (!s_sock[i].used) { s_sock[i].used = true; id = i; break; } + if (id < 0) return -1; + c6_req_t r = { .type = REQ_SOCK_CONNECT, .port = port, .ssl = ssl, .id = id }; + strncpy(r.host, host, sizeof(r.host) - 1); + volatile int st = REQ_PENDING; r.status = &st; + if (xQueueSend(s_reqq, &r, pdMS_TO_TICKS(250)) != pdTRUE) { s_sock[id].used = false; return -1; } + while (st == REQ_PENDING) vTaskDelay(pdMS_TO_TICKS(5)); + if (st != REQ_OK) { s_sock[id].used = false; return -1; } + return id; +} + +bool c6at_sock_send(int id, const uint8_t *data, size_t len, uint32_t timeout_ms) { + (void)timeout_ms; + if (id < 0 || id >= SOCK_MAX || !data || !len || !s_reqq) return false; + if (!s_sock[id].used || !s_sock[id].open) return false; + c6_req_t r = { .type = REQ_SOCK_SEND, .id = id, .data = data, .len = len }; + volatile int st = REQ_PENDING; r.status = &st; + if (xQueueSend(s_reqq, &r, pdMS_TO_TICKS(250)) != pdTRUE) return false; + while (st == REQ_PENDING) vTaskDelay(pdMS_TO_TICKS(5)); + return st == REQ_OK; +} + +int c6at_sock_available(int id) { + if (id < 0 || id >= SOCK_MAX) return 0; + sock_t *s = &s_sock[id]; + uint32_t f = ring_fill(s); + if (f == 0) { + uint32_t now = (uint32_t)(esp_timer_get_time() / 1000); + // Pull on notification immediately; while open, also probe every 200 ms (an +IPD line can be + // missed if it split across drained chunks — the probe is a cheap 0-or-data CIPRECVDATA). + if (s->rx_notified || (s->open && now - s_last_kick[id] > 200)) kick_pull(id); + } + return (int)f; +} + +int c6at_sock_read(int id, uint8_t *dst, size_t maxlen) { + if (id < 0 || id >= SOCK_MAX || !dst || !maxlen) return -1; + sock_t *s = &s_sock[id]; + uint32_t f = ring_fill(s); + if (f == 0) { (void)c6at_sock_available(id); return 0; } + uint32_t n = f < maxlen ? f : (uint32_t)maxlen; + for (uint32_t i = 0; i < n; i++) dst[i] = s->ring[(s->r + i) % SOCK_RING]; + s->r += n; + if (s->rx_notified) kick_pull(id); // room freed — keep the pipeline moving + return (int)n; +} + +int c6at_sock_peek(int id) { + if (id < 0 || id >= SOCK_MAX) return -1; + sock_t *s = &s_sock[id]; + if (ring_fill(s) == 0) { (void)c6at_sock_available(id); return -1; } + return (int)s->ring[s->r % SOCK_RING]; +} + +bool c6at_sock_connected(int id) { + if (id < 0 || id >= SOCK_MAX || !s_sock[id].used) return false; + sock_t *s = &s_sock[id]; + return s->open || s->rx_notified || ring_fill(s) > 0; // closed-with-buffered-data still reads +} + +void c6at_sock_close(int id) { + if (id < 0 || id >= SOCK_MAX || !s_sock[id].used) return; + if (s_reqq && !s_dead) { + c6_req_t r = { .type = REQ_SOCK_CLOSE, .id = id }; + volatile int st = REQ_PENDING; r.status = &st; + if (xQueueSend(s_reqq, &r, pdMS_TO_TICKS(250)) == pdTRUE) + while (st == REQ_PENDING) vTaskDelay(pdMS_TO_TICKS(5)); + } + s_sock[id].open = false; s_sock[id].rx_notified = false; + s_sock[id].r = s_sock[id].w = 0; + s_sock[id].used = false; +} + +bool c6at_server_start(uint16_t port) { + if (!port) return false; + s_srv_port = port; + s_srv_want = true; // the worker's idle housekeeping starts the listener (works even when + // called before c6at_worker_start — the wish survives until it can run) + return !s_dead; +} + +void c6at_server_stop(void) { + s_srv_want = false; + if (!s_reqq || s_dead || !s_srv_up) return; + c6_req_t r = { .type = REQ_SERVER_STOP }; + xQueueSend(s_reqq, &r, 0); +} + +int c6at_server_pending(void) { return (int)(uint8_t)(s_acc_w - s_acc_r); } + +int c6at_server_accept(void) { + if (s_acc_r == s_acc_w) return -1; + int id = s_acc_q[s_acc_r % 8]; + s_acc_r++; + return id; +} + +uint32_t c6at_sntp_epoch(void) { + if (!s_sntp_epoch) return 0; + return s_sntp_epoch + ((uint32_t)(esp_timer_get_time() / 1000) - s_sntp_at_ms) / 1000; +} diff --git a/tdisplay_p4/components/c6_at/include/C6Socket.h b/tdisplay_p4/components/c6_at/include/C6Socket.h new file mode 100644 index 0000000..0f2cafb --- /dev/null +++ b/tdisplay_p4/components/c6_at/include/C6Socket.h @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// C6Socket — P4-only Arduino-client facades over the c6_at AT+CIP socket layer. +// +// C6Client subclasses NetworkClient and overrides the Client virtuals (connect/read/write/available/ +// peek/stop/connected...), so HTTPClient works UNCHANGED through its NetworkClient& — virtual dispatch +// lands here instead of lwIP (which has no netif on this board: the C6 owns TCP/IP). C6ClientSecure +// maps to AT+CIPSTART "SSL": the C6 does the TLS handshake ON-CHIP, which makes on-device HTTPS work +// on this board (the S3 boards never had the heap for it). C6Server fronts AT+CIPSERVER for the +// inbound companion TCP port (ESP-AT: ONE listening port). +// +// The link is SHARED between copies (WiFiClient semantics): TCPCompanionServer does +// `WiFiClient incoming = _server.accept(); _clients[i].client = incoming;` — with plain +// close-on-destroy the dying temporary would kill the freshly accepted link. A shared ref closes +// the link only when the LAST copy goes away; stop() hard-closes for all copies at once. +// +// Include AFTER in a P4-gated block, then rebind the types: +// #define WiFiClient C6Client +// #define WiFiClientSecure C6ClientSecure +// #define WiFiServer C6Server +// (Done by the includer, so shared code paths pick the facade only on the P4.) +#pragma once +#include // NetworkClient / IPAddress / String +#include +#include "c6_at.h" + +struct C6LinkRef { // owns one AT link id; last holder closes it + int id; + explicit C6LinkRef(int i) : id(i) {} + ~C6LinkRef() { if (id >= 0) c6at_sock_close(id); } +}; + +class C6Client : public NetworkClient { +protected: + std::shared_ptr _ref; + bool _ssl = false; + int link() const { return _ref ? _ref->id : -1; } + +public: + C6Client() {} + explicit C6Client(int accepted_link) { // wrap a server-accepted link id + if (accepted_link >= 0) _ref = std::make_shared(accepted_link); + } + + int connect(IPAddress ip, uint16_t port) { return connect(ip, port, 15000); } + int connect(IPAddress ip, uint16_t port, int32_t timeout_ms) { + char h[20]; + snprintf(h, sizeof(h), "%u.%u.%u.%u", ip[0], ip[1], ip[2], ip[3]); + return connect((const char *)h, port, timeout_ms); + } + int connect(const char *host, uint16_t port) { return connect(host, port, 15000); } + int connect(const char *host, uint16_t port, int32_t timeout_ms) { + stop(); + int id = c6at_sock_connect(host, port, _ssl, (uint32_t)timeout_ms); + if (id >= 0) _ref = std::make_shared(id); + return id >= 0 ? 1 : 0; + } + + size_t write(uint8_t b) { return write(&b, 1); } + size_t write(const uint8_t *buf, size_t size) { + int id = link(); + if (id < 0 || !buf || !size) return 0; + return c6at_sock_send(id, buf, size, 30000) ? size : 0; + } + + int available() { int id = link(); return id < 0 ? 0 : c6at_sock_available(id); } + int read() { + uint8_t b; + return (read(&b, 1) == 1) ? (int)b : -1; + } + int read(uint8_t *buf, size_t size) { + int id = link(); + if (id < 0) return -1; + return c6at_sock_read(id, buf, size); // 0 = nothing yet (caller's timeout loop retries) + } + int peek() { int id = link(); return id < 0 ? -1 : c6at_sock_peek(id); } + void flush() {} // writes are synchronous through the worker — nothing buffered host-side + + void stop() { // hard close for ALL copies sharing this link (WiFiClient::stop semantics) + if (_ref) { + int id = _ref->id; + _ref->id = -1; // other copies see a dead link; C6LinkRef won't double-close + if (id >= 0) c6at_sock_close(id); + } + _ref.reset(); + } + uint8_t connected() { int id = link(); return (id >= 0 && c6at_sock_connected(id)) ? 1 : 0; } + operator bool() { return connected(); } + + void setNoDelay(bool) {} // AT transport: no Nagle knob; sends are already framed + virtual int fd() const { return -1; } // no lwIP socket behind this client +}; + +class C6ClientSecure : public C6Client { +public: + C6ClientSecure() { _ssl = true; } + // TLS runs on the C6 with its own bundle; the app always used setInsecure() anyway. + void setInsecure() {} + void setCACert(const char *) {} + void setCertificate(const char *) {} + void setPrivateKey(const char *) {} +}; + +// Inbound listener facade (WiFiServer surface used by TCP/WS companion servers). +// c6_at has ONE global listener + accept FIFO, but multiple C6Server instances exist +// (TCP + WS companion servers each embed one; on the P4 only the router's instance ever +// begins). Gate everything on _begun so a never-begun instance is fully inert — matching +// lwIP WiFiServer semantics — instead of stealing accepts from / stopping the live one. +class C6Server { + uint16_t _port = 0; + bool _begun = false; + +public: + C6Server() {} + explicit C6Server(uint16_t port) : _port(port) {} + void begin(uint16_t port = 0) { + if (port) _port = port; + if (_port) { + c6at_server_start(_port); // async; the AT worker logs LISTENING/FAIL + _begun = true; + } + } + bool hasClient() { return _begun && c6at_server_pending() > 0; } + C6Client accept() { return C6Client(_begun ? c6at_server_accept() : -1); } + void stop() { + if (_begun) { + c6at_server_stop(); + _begun = false; + } + } + void end() { stop(); } + void close() { stop(); } + operator bool() { return _begun; } +}; diff --git a/tdisplay_p4/components/c6_at/include/C6WifiShim.h b/tdisplay_p4/components/c6_at/include/C6WifiShim.h new file mode 100644 index 0000000..80fca9d --- /dev/null +++ b/tdisplay_p4/components/c6_at/include/C6WifiShim.h @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// C6WifiShim — P4-only drop-in facade for the Arduino `WiFi` object, backed by the c6_at driver. +// +// WHY: on the T-Display P4 the C6 runs ESP-AT (not an esp-hosted slave). Arduino's real WiFi class +// calls esp_hosted_init() at WiFi.mode()/begin() time, which grabs the C6's SDIO bus out from under +// the c6_at driver and panics the P4 (the "connect Wi-Fi -> blue screen" crash). This facade keeps +// the exact WiFi.* call surface the app already uses, but routes it to the c6_at worker: scans and +// joins become REAL (over AT), status/IP reads come from the worker's lock-free cache, and nothing +// can ever touch esp_hosted. +// +// USAGE (per translation unit, AFTER all other includes): #include +// The trailing `#define WiFi WadaC6WiFi` rebinds every later `WiFi.` in the TU to the facade. +// Header-only; the single instance is a C++17 inline variable. Only the methods the app actually +// calls are implemented — adding a new WiFi.* call elsewhere will fail to compile HERE (good: it +// forces a conscious decision instead of silently hitting esp_hosted). +#pragma once +#include // real header first (types: String, IPAddress, wl_status_t, wifi_mode_t) +#include "c6_at.h" + +class C6WiFiFacade { +public: + // ---- station control ---- + wl_status_t begin(const char *ssid, const char *passphrase = nullptr) { + c6at_worker_start(); + c6at_set_sta_enabled(true); + c6at_req_join(ssid, passphrase); + return status(); + } + bool disconnect(bool wifioff = false, bool eraseap = false) { + (void)eraseap; + c6at_req_disconnect(); + if (wifioff) c6at_set_sta_enabled(false); + return true; + } + bool mode(wifi_mode_t m) { + if (m == WIFI_MODE_NULL) { c6at_req_disconnect(); c6at_set_sta_enabled(false); } + else { c6at_worker_start(); c6at_set_sta_enabled(true); } + return true; + } + wifi_mode_t getMode() { return c6at_sta_enabled() ? WIFI_MODE_STA : WIFI_MODE_NULL; } + + // ---- status (lock-free cache reads; safe on the UI thread) ---- + wl_status_t status() { return c6at_connected() ? WL_CONNECTED : WL_DISCONNECTED; } + IPAddress localIP() { + uint32_t v = c6at_ip4(); + return IPAddress((v >> 24) & 0xFF, (v >> 16) & 0xFF, (v >> 8) & 0xFF, v & 0xFF); + } + String SSID() { + char s[33]; + return c6at_current_ssid(s, sizeof(s)) ? String(s) : String(); + } + int8_t RSSI() { return (int8_t)c6at_current_rssi(); } + int32_t channel() { return (int32_t)c6at_current_channel(); } // current AP channel (0 = unknown) + + // ---- scanning (async via the worker; Arduino return-code semantics) ---- + int16_t scanNetworks(bool async = false, bool show_hidden = false, bool passive = false, + uint32_t max_ms_per_chan = 300, uint8_t channel = 0, + const char *ssid = nullptr, const uint8_t *bssid = nullptr) { + (void)async; (void)show_hidden; (void)passive; (void)max_ms_per_chan; (void)channel; (void)ssid; (void)bssid; + c6at_worker_start(); + c6at_set_sta_enabled(true); + c6at_req_scan(); + return WIFI_SCAN_RUNNING; + } + int16_t scanComplete() { return (int16_t)c6at_scan_state(); } + void scanDelete() { c6at_scan_clear(); } + String SSID(uint8_t i) { + char s[33]; + return c6at_scan_get(i, s, sizeof(s), nullptr, nullptr) ? String(s) : String(); + } + int32_t channel(uint8_t i) { return (int32_t)c6at_scan_channel(i); } // scan-result channel + + // ---- accepted no-ops (ESP-AT handles reconnect + power policy on the C6 itself) ---- + bool setAutoReconnect(bool en) { (void)en; return true; } + bool setSleep(bool en) { (void)en; return true; } +}; + +inline C6WiFiFacade WadaC6WiFi; + +// Rebind all later `WiFi.` uses in this TU to the facade. (String literals are unaffected; any +// re-include of after this point is inert thanks to its include guard.) +#define WiFi WadaC6WiFi diff --git a/tdisplay_p4/components/c6_at/include/c6_at.h b/tdisplay_p4/components/c6_at/include/c6_at.h new file mode 100644 index 0000000..8da77fb --- /dev/null +++ b/tdisplay_p4/components/c6_at/include/c6_at.h @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// c6_at — AT-over-SDIO client for the LilyGo T-Display P4's on-board ESP32-C6. +// +// The C6 ships running Espressif ESP-AT firmware, reachable over SDIO slot 1 (CLK18/CMD19/D0-3= +// 14..17). This is a clean-room driver: the SDIO transport is Espressif's public esp_serial_slave_link +// (ESSL) + SDMMC host API, and the AT command / Wi-Fi / socket layers on top are our own. It exists +// ONLY in the tdisplay_p4 build — no other board can reference it. +// +// Layering: +// 1. transport : ESSL send/recv of raw byte packets over SDIO +// 2. AT client : send an AT line, collect the response (c6at_command) +// 3. worker : the ONLY task doing AT I/O; async requests + a lock-free status cache that the +// UI (via the C6WifiShim facade) reads without ever touching the bus +// 4. Wi-Fi : scan / join / IP over the worker (c6at_req_*, c6at_scan_*) +// 5. sockets : AT+CIP* TCP/UDP (later phase) +#pragma once +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// ---- worker lifecycle --------------------------------------------------------------------------- +// Spawn the AT worker task (idempotent). It brings the SDIO link up itself (with retries, so the C6 +// can still be booting), then services requests. NOTHING here blocks the caller. +void c6at_worker_start(void); +bool c6at_is_up(void); // transport up + ESP-AT answered (set by the worker) +bool c6at_is_dead(void); // worker gave up bringing the link up (AT probe exhausted) + +// ---- async requests (enqueue only; the worker executes) ----------------------------------------- +void c6at_req_scan(void); +void c6at_req_join(const char *ssid, const char *pass); // copies the strings +void c6at_req_disconnect(void); + +// ---- lock-free status cache (safe from any task) ------------------------------------------------- +bool c6at_connected(void); +uint32_t c6at_ip4(void); // IPv4 as host-order a.b.c.d packed (a<<24)... 0 = none +bool c6at_current_ssid(char *out, size_t sz); +int c6at_current_rssi(void); +int c6at_current_channel(void); // current AP channel (0 = unknown) +// Scan cache. State mirrors Arduino semantics: -2 idle/failed, -1 running, >=0 result count. +int c6at_scan_state(void); +bool c6at_scan_get(int idx, char *ssid, size_t sz, int8_t *rssi, uint8_t *enc); +int c6at_scan_channel(int idx); // scan result channel (0 = unknown) +void c6at_scan_clear(void); +// Facade "radio mode" bookkeeping (no AT traffic — just remembers STA-enabled for WiFi.getMode()). +void c6at_set_sta_enabled(bool en); +bool c6at_sta_enabled(void); + +// ---- TCP/SSL sockets (AT+CIP*, passive receive) --------------------------------------------------- +// Blocking calls (poll a per-request status the worker completes) — call them from WORKER-FRIENDLY +// tasks (the tile fetcher, reader, version check), never the LVGL thread. Up to 5 links (CIPMUX=1). +// The C6 does TLS on-chip for ssl=true links (fixes on-device HTTPS: the S3 boards never could). +int c6at_sock_connect(const char *host, uint16_t port, bool ssl, uint32_t timeout_ms); // link id or -1 +bool c6at_sock_send(int id, const uint8_t *data, size_t len, uint32_t timeout_ms); +int c6at_sock_read(int id, uint8_t *dst, size_t maxlen); // non-blocking pop from the rx ring +int c6at_sock_peek(int id); // next byte or -1 +int c6at_sock_available(int id); // ring bytes (kicks a pull when empty) +bool c6at_sock_connected(int id); // open, or closed with data still buffered +void c6at_sock_close(int id); + +// ---- inbound TCP server (AT+CIPSERVER) ------------------------------------------------------------ +// ESP-AT allows exactly ONE listening port — on this board it's the phone-app companion TCP server. +// The C6 assigns inbound connections the LOWEST free link id (outbound allocation is top-down to +// stay out of the way); accepted links reuse the same per-link rings / pull / send machinery. +bool c6at_server_start(uint16_t port); // async (worker executes); idempotent per port +void c6at_server_stop(void); +int c6at_server_pending(void); // inbound connections waiting to be accept()ed +int c6at_server_accept(void); // pop one inbound link id, or -1 + +// SNTP over the C6 (AT+CIPSNTP*): 0 until synced, then the current UTC epoch (self-advancing). +uint32_t c6at_sntp_epoch(void); + +// ---- low-level (worker-internal + bring-up probes; do NOT call from other tasks once the worker +// runs — all AT I/O must stay single-threaded) ---------------------------------------------------- +bool c6at_begin(void); +bool c6at_command(const char *cmd, char *resp, size_t resp_sz, uint32_t timeout_ms); + +#ifdef __cplusplus +} +#endif diff --git a/tdisplay_p4/components/esp_hosted b/tdisplay_p4/components/esp_hosted new file mode 120000 index 0000000..816835a --- /dev/null +++ b/tdisplay_p4/components/esp_hosted @@ -0,0 +1 @@ +../../tanmatsu/components/esp_hosted \ No newline at end of file diff --git a/tdisplay_p4/components/lvgl b/tdisplay_p4/components/lvgl new file mode 120000 index 0000000..2ddbce2 --- /dev/null +++ b/tdisplay_p4/components/lvgl @@ -0,0 +1 @@ +../../tanmatsu/components/lvgl \ No newline at end of file diff --git a/tdisplay_p4/components/meshcore/CMakeLists.txt b/tdisplay_p4/components/meshcore/CMakeLists.txt new file mode 100644 index 0000000..59c2339 --- /dev/null +++ b/tdisplay_p4/components/meshcore/CMakeLists.txt @@ -0,0 +1,35 @@ +# MeshCore core as an IDF component for the T-Display P4. Shares the SAME vendored +# core source as the Tanmatsu (core/ is a symlink to ../../tanmatsu/components/meshcore/core), +# but with P4-specific build rules: unlike the Tanmatsu (which reaches LoRa through the +# TanmatsuLoraRadio bridge and drops RadioLib entirely), the T-Display P4 has a REAL SX1262, +# so it compiles the core's helpers/radiolib/ RadioLib wrappers (RadioLibWrappers.cpp — the +# CustomSX1262Wrapper + buffered-RX drain/acquire methods MyMesh calls) and REQUIREs RadioLib. +# +# Everything else mirrors the Tanmatsu selection (see its CMakeLists): drop nRF52/TBeam boards, +# the S3 panel drivers, sensors, other-platform helpers, ESP-NOW, and the MC_VENDORED_TOUCH_APP +# files our own src/ ships. +set(CORE "core/src") +file(GLOB_RECURSE CORE_SRCS "${CMAKE_CURRENT_LIST_DIR}/${CORE}/*.cpp") +# NOTE: helpers/radiolib/ is NOT excluded here (unlike the Tanmatsu) — the P4 needs it. +# helpers/sensors/ is NOT fully excluded either: the P4 has a real GPS (L76K), so it compiles +# EnvironmentSensorManager.cpp (its heavy telemetry drivers are all #if ENV_INCLUDE_* — off here, +# leaving just the GPS/NMEA path). Only the RAK soil-moisture sensor (a separate I2C lib) is dropped. +list(FILTER CORE_SRCS EXCLUDE REGEX "/helpers/sensors/RAK12035_SoilMoisture\\.cpp$") +list(FILTER CORE_SRCS EXCLUDE REGEX "(NRF52Board|TBeamBoard)\\.cpp$") # keep ESP32Board (base TDisplayP4Board extends); drop nRF52 + TBeam +list(FILTER CORE_SRCS EXCLUDE REGEX "/helpers/ui/.*(Display|OLED|TFT|Epaper|EInk|GxEPD|U8g2|SH1106|SSD1306|ST77|ILI9|Adafruit).*\\.cpp$") +list(FILTER CORE_SRCS EXCLUDE REGEX "(LvglPsramAlloc|TouchDiagTrace)\\.cpp$") +list(FILTER CORE_SRCS EXCLUDE REGEX "/helpers/esp32/(MultiTransportCompanionInterface|SdNvsPrefs|TCPCompanionServer|TouchPrefsStore|WifiRuntimeStore|WebSocketCompanionServer)\\.cpp$") +list(FILTER CORE_SRCS EXCLUDE REGEX "/helpers/(nrf52|stm32|rp2040)/") +list(FILTER CORE_SRCS EXCLUDE REGEX "/ESPNOWRadio\\.cpp$") # ESP-NOW radio — the P4 uses the SX1262 + +# Core's bundled ed25519 C implementation (Identity.cpp does #include ). +file(GLOB ED25519_SRCS "${CMAKE_CURRENT_LIST_DIR}/core/lib/ed25519/*.c") + +idf_component_register( + SRCS ${CORE_SRCS} ${ED25519_SRCS} + INCLUDE_DIRS "${CORE}" "core/lib/ed25519" + REQUIRES espressif__arduino-esp32 ardlibs RadioLib nvs_flash efuse h2zero__esp-nimble-cpp bt +) +# Vendored core — don't let IDF's strict -Werror fail the build on code we consume as a library. +target_compile_options(${COMPONENT_LIB} PRIVATE -Wno-error + -include "${CMAKE_CURRENT_LIST_DIR}/../../../variants/tdisplay_p4/tdisplay_p4_compat.h") # adcAttachPin() shim diff --git a/tdisplay_p4/components/wadamesh_app/CMakeLists.txt b/tdisplay_p4/components/wadamesh_app/CMakeLists.txt new file mode 100644 index 0000000..4b3cbdd --- /dev/null +++ b/tdisplay_p4/components/wadamesh_app/CMakeLists.txt @@ -0,0 +1,25 @@ +# The wadamesh app layer (MyMesh + DataStore + helpers + transports + the LVGL UITask), +# compiled directly from the repo's src/ — our own in-tree code (NOT vendored). Mirrors +# tanmatsu/components/wadamesh_app but builds variants/tdisplay_p4/ instead of variants/tanmatsu/, +# and — unlike the badge-bsp Tanmatsu — keeps the RAW-pin radio path (the T-Display P4 has a real +# SX1262 on P4 GPIOs). Excludes main.cpp (our app_main is in tdisplay_p4/main/) and the S3 input +# drivers (the P4's touch is HI8561, in variants/tdisplay_p4/). +set(REPO "${CMAKE_CURRENT_LIST_DIR}/../../..") +set(APP "${REPO}/src") +file(GLOB_RECURSE APP_SRCS "${APP}/*.cpp" "${APP}/*.c" "${REPO}/variants/tdisplay_p4/*.cpp") +list(FILTER APP_SRCS EXCLUDE REGEX "/main\\.cpp$") +list(FILTER APP_SRCS EXCLUDE REGEX "/helpers/input/(HeltecV4CapTouch|TDeckKeyboard|TDeckTouch|TDeckTrackball|RakTapV2Touch)\\.cpp$") + +idf_component_register( + SRCS ${APP_SRCS} + INCLUDE_DIRS "${APP}" "${APP}/ui-touch" "${REPO}/include" "${REPO}/variants/tdisplay_p4" + REQUIRES espressif__arduino-esp32 meshcore lvgl ardlibs RadioLib nvs_flash esp_lcd fatfs + esp_driver_i2c esp_driver_gpio esp_hosted h2zero__esp-nimble-cpp bt + soc esp_hw_support app_update driver esp_timer esp_partition spi_flash esp_adc esp_pm espcoredump + c6_at # C6WifiShim facade (WiFi.* -> AT-over-SDIO; UITask + WifiRuntimeStore include it) + # See the Tanmatsu note: whole-archive so the strong ssl_client stub defs are always present. + WHOLE_ARCHIVE +) +# Our own code, but huge + LVGL-heavy — keep the first port building; don't -Werror on warnings. +target_compile_options(${COMPONENT_LIB} PRIVATE -w + -include "${REPO}/variants/tdisplay_p4/tdisplay_p4_compat.h") # adcAttachPin() shim (arduino 3.x dropped it) diff --git a/tdisplay_p4/esp-idf b/tdisplay_p4/esp-idf new file mode 120000 index 0000000..3ab3a91 --- /dev/null +++ b/tdisplay_p4/esp-idf @@ -0,0 +1 @@ +../tanmatsu/esp-idf \ No newline at end of file diff --git a/tdisplay_p4/esp-idf-tools b/tdisplay_p4/esp-idf-tools new file mode 120000 index 0000000..c628e21 --- /dev/null +++ b/tdisplay_p4/esp-idf-tools @@ -0,0 +1 @@ +../tanmatsu/esp-idf-tools \ No newline at end of file diff --git a/tdisplay_p4/fetch-deps.sh b/tdisplay_p4/fetch-deps.sh new file mode 100755 index 0000000..5a34fba --- /dev/null +++ b/tdisplay_p4/fetch-deps.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Populate tdisplay_p4/components/ with the vendored payloads a fresh clone needs, sourced +# from PlatformIO's already-resolved libdeps (same convention as tanmatsu/fetch-deps.sh — +# payloads are gitignored, the committed component wrappers are tracked). Run an S3 build +# first to populate libdeps: +# /Users/kaj/Library/Python/3.9/bin/pio run -e LilyGo_TDeck_companion_radio_touch +set -euo pipefail +cd "$(dirname "$0")" +LIBDEPS="../.pio/libdeps/LilyGo_TDeck_companion_radio_touch" +[ -d "$LIBDEPS/MeshCore" ] || { echo "!! $LIBDEPS/MeshCore missing — run an S3 'pio run' first"; exit 1; } + +mkdir -p components + +# --- MeshCore core: shared with the Tanmatsu — one vendored copy, symlinked here. +# Run tanmatsu/fetch-deps.sh first (or let it be run by this one) to materialize it. +[ -d ../tanmatsu/components/meshcore/core/src ] || ../tanmatsu/fetch-deps.sh +mkdir -p components/meshcore +[ -e components/meshcore/core ] || ln -s ../../../tanmatsu/components/meshcore/core components/meshcore/core +echo "linked: meshcore core -> tanmatsu's vendored copy" + +# --- LVGL 8.4 (own component; big; uses the repo's include/lv_conf.h) --- +rm -rf components/lvgl/upstream +mkdir -p components/lvgl +cp -R "$LIBDEPS/lvgl" components/lvgl/upstream +echo "vendored: lvgl ($(find components/lvgl/upstream/src -name '*.c' | wc -l | tr -d ' ') c)" + +# --- RadioLib: the P4 drives a RAW SX1262 through the core's RadioLib path (unlike the +# Tanmatsu's C6 LoRa bridge), so the resolved RadioLib is vendored as its own component. --- +rm -rf components/RadioLib/src components/RadioLib/library.properties components/RadioLib/license.txt +cp -R "$LIBDEPS/RadioLib/src" components/RadioLib/src +cp "$LIBDEPS/RadioLib/library.properties" components/RadioLib/library.properties +cp "$LIBDEPS/RadioLib/LICENSE" components/RadioLib/license.txt 2>/dev/null || \ + cp "$LIBDEPS/RadioLib/license.txt" components/RadioLib/license.txt 2>/dev/null || true +echo "vendored: RadioLib $(grep -m1 '^version=' components/RadioLib/library.properties | cut -d= -f2)" + +# --- Arduino libraries: ALL into ONE 'ardlibs' component (inter-library #includes resolve +# without per-lib REQUIRES). Same list as the Tanmatsu. --- +rm -rf components/ardlibs/upstream +mkdir -p components/ardlibs/upstream +vend() { + local src="$1"; local dst="${src// /_}" + [ -d "$LIBDEPS/$src" ] || { echo " skip ($src not in libdeps)"; return; } + cp -R "$LIBDEPS/$src" "components/ardlibs/upstream/$dst" + echo " + $src -> $dst" +} +vend "Crypto" +vend "RTClib" +vend "Adafruit BusIO" +vend "Adafruit Unified Sensor" +vend "Melopero RV3028" +vend "CayenneLPP" +vend "ArduinoJson" +vend "base64" +vend "MicroNMEA" +vend "AsyncTCP" +vend "ESPAsyncWebServer" +vend "AsyncElegantOTA" + +touch components/meshcore/CMakeLists.txt components/ardlibs/CMakeLists.txt \ + components/RadioLib/CMakeLists.txt components/wadamesh_app/CMakeLists.txt 2>/dev/null || true +echo "done." diff --git a/tdisplay_p4/main/CMakeLists.txt b/tdisplay_p4/main/CMakeLists.txt new file mode 100644 index 0000000..c680550 --- /dev/null +++ b/tdisplay_p4/main/CMakeLists.txt @@ -0,0 +1,101 @@ +idf_build_get_property(idf_target IDF_TARGET) + +idf_component_register( + SRCS + "main.cpp" + PRIV_REQUIRES + esp_lcd # MIPI-DSI (RM69A10 AMOLED) + esp_driver_i2c # XL9535 expander + HI8561 touch + RTC/gauge + esp_driver_gpio + fatfs # SD_MMC storage + nvs_flash + bt # NimBLE companion (BLE over the C6) + espressif__arduino-esp32 + esp_hosted # ESP32-C6 Wi-Fi/BLE over SDIO_2 + meshcore + lvgl + RadioLib # target.h (included by main.cpp) pulls + wadamesh_app + c6_at # AT-over-SDIO Wi-Fi/BLE driver for the on-board ESP32-C6 (P4-only) + INCLUDE_DIRS + "." +) + +# The vendored MeshCore core + Arduino libs trip IDF's blanket -Werror=all. A bare +# -Wno-error does not cancel it, so strip it outright (warnings stay visible, non-fatal). +idf_build_set_property(COMPILE_OPTIONS "-Wno-error=unused-variable" APPEND) +idf_build_set_property(COMPILE_OPTIONS "-Wno-error=unused-const-variable" APPEND) +idf_build_get_property(_co COMPILE_OPTIONS) +list(REMOVE_ITEM _co "-Werror=all") +idf_build_set_property(COMPILE_OPTIONS "${_co}") + +# ---- LilyGo T-Display P4 (AMOLED) app define set. ------------------------------------------------- +# Unlike the Tanmatsu (badge-bsp + a LoRa bridge), the T-Display P4 has a RAW SX1262 on P4 GPIOs, so +# it uses the core's RadioLib path like the S3 boards — except SX1262 RESET + DIO1 sit on the XL9535 +# I2C GPIO expander (the variant's target.cpp drives them; the raw P_LORA_RESET/DIO_1 are -1 = "none"). +# Pins from LilyGo's components/private_library/t_display_p4_config.h (see [[tdisplay-p4-port]]). +set(WADA_DEFS + HAS_TDISPLAY_P4=1 ESP32_PLATFORM MC_VENDORED_TOUCH_APP UI_LVGL=1 HAS_TOUCH_UI=1 + MULTI_TRANSPORT_COMPANION TCP_PORT=5000 WS_PORT=8765 BLE_PIN_CODE=123456 + LORA_FREQ=869.618 LORA_BW=62.5 LORA_SF=8 # EU 868 (same as the V4) + LORA_TX_POWER=22 MAX_LORA_TX_POWER=22 # SX1262 PA max (dBm); user-tunable in Settings + MAX_CONTACTS=2000 MAX_GROUP_CHANNELS=40 # P4 + 32MB PSRAM: ample + ENABLE_PRIVATE_KEY_IMPORT=1 ENABLE_PRIVATE_KEY_EXPORT=1 + + # --- LoRa SX1262 (SPI_1) — CS/BUSY/SPI on raw P4 GPIO; RST+DIO1 on the XL9535 expander --- + USE_SX1262 RADIO_CLASS=CustomSX1262 WRAPPER_CLASS=CustomSX1262Wrapper + P_LORA_NSS=24 P_LORA_BUSY=6 P_LORA_SCLK=2 P_LORA_MISO=4 P_LORA_MOSI=3 + P_LORA_RESET=-1 P_LORA_DIO_1=-1 # on XL9535 IO16/IO17 — variant drives these + MESH_RADIO_DIO1_POLLED=1 # DIO1 behind the expander → poll RxDone/TxDone over SPI (no host IRQ) + P_LORA_TX_LED=-1 P_LORA_TX_NEOPIXEL_LED=-1 + SX126X_RX_BOOSTED_GAIN=1 + XL9535_SX1262_RST_IO=16 XL9535_SX1262_DIO1_IO=17 # SX1262 reset + IRQ behind the expander + XL9535_RF_SWITCH_IO=1 # SKY13453 VCTL (TX/RX path) + + # --- Display: RM69A10 AMOLED, MIPI-DSI, 568x1232 portrait (self-emissive; no backlight pin) --- + DISPLAY_CLASS=RM69A10Display + SCREEN_WIDTH=568 SCREEN_HEIGHT=1232 + XL9535_SCREEN_RST_IO=2 + + # --- Touch: HI8561 (I2C_1); RST/INT on the expander --- + HAS_HI8561_TOUCH=1 PIN_TOUCH_SDA=7 PIN_TOUCH_SCL=8 + XL9535_TOUCH_RST_IO=3 XL9535_TOUCH_INT_IO=4 + + # --- Board I2C_1 (XL9535 + touch + PCF8563 RTC + BQ27220 gauge) --- + PIN_BOARD_SDA=7 PIN_BOARD_SCL=8 + XL9535_SDA=7 XL9535_SCL=8 XL9535_INT_PIN=5 + + # --- GPS L76K (UART): module TX -> GPIO22 (ESP receives), module RX -> GPIO23 (ESP transmits). + # Core convention (initBasicGPS does Serial1.setPins(PIN_GPS_TX, PIN_GPS_RX) = (rx, tx)): + # PIN_GPS_TX = the GPIO wired to the MODULE'S TX pin; PIN_GPS_RX = wired to the module's RX. + # Matches LilyGo's t_display_p4_config.h "GPS_TX 22 / GPS_RX 23" naming. The first cut had them + # swapped (silent: UART opens fine, just never hears NMEA). Wake: XL9535 GPS_WAKE must be HIGH + # (powerOnSequence) or the L76K sleeps. Baud: L76K power-on default 9600 = the core default. --- + ENV_INCLUDE_GPS=1 PIN_GPS_TX=22 PIN_GPS_RX=23 + + # --- microSD on SDMMC slot 0 (CLK43/CMD44/D0-3=39-42); C6 esp-hosted is slot 1 --- + BOARD_HAS_SDMMC=1 BOARD_SDMMC_SLOT=0 + + # battery via BQ27220 I2C gauge (not ADC); no raw VBAT pin + PIN_VBAT_READ=-1) +foreach(_d ${WADA_DEFS}) + idf_build_set_property(COMPILE_OPTIONS "-D${_d}" APPEND) +endforeach() +# One-off C6-reflash helper: build with `C6_FLASH_HELPER=1 ./build.sh build` to boot a P4 image that +# never touches the C6 (no C6_EN reset pulse, no AT worker), so the C6 can be held in ROM download mode +# and reflashed over its own USB. Harmless when the env var is unset (normal builds are unaffected). +if(DEFINED ENV{C6_FLASH_HELPER}) + idf_build_set_property(COMPILE_OPTIONS "-DTDP4_C6_FLASH_HELPER=1" APPEND) + message(STATUS "wadamesh: BUILDING C6-FLASH HELPER (P4 leaves the C6 free)") +endif() + +# Firmware version/tag (build.sh passes WADA_FW_TAG/-DATE from git describe + date). +if(NOT WADA_FW_TAG) + set(WADA_FW_TAG "dev") +endif() +if(NOT WADA_FW_DATE) + set(WADA_FW_DATE "") +endif() +idf_build_set_property(COMPILE_OPTIONS "-DFIRMWARE_RELEASE_TAG=\"${WADA_FW_TAG}\"" APPEND) +idf_build_set_property(COMPILE_OPTIONS "-DFIRMWARE_BUILD_DATE=\"${WADA_FW_DATE}\"" APPEND) +idf_build_set_property(COMPILE_OPTIONS "-DFIRMWARE_VERSION=\"wadamesh ${WADA_FW_TAG}\"" APPEND) diff --git a/tdisplay_p4/main/idf_component.yml b/tdisplay_p4/main/idf_component.yml new file mode 100644 index 0000000..8e8515f --- /dev/null +++ b/tdisplay_p4/main/idf_component.yml @@ -0,0 +1,24 @@ +dependencies: + idf: ">=5.5.1" + # ── Wi-Fi/BLE over the C6 is NOT supported on this board (2026-07-14 investigation) ───────────── + # The T-Display P4's FACTORY C6 runs ESP-AT firmware (SDIO), NOT an esp-hosted slave — exactly what + # Meck-P4's real firmware talks to (Cpp_Bus_Driver::Esp_At, not esp_hosted_connect_to_slave). Our + # whole Wi-Fi/BLE stack is Arduino WiFi over esp-hosted/esp_wifi_remote, so the C6's SDIO stream is + # misframed ("len>max ... Drop") and the P4 reset-loops — this happens at BOTH esp_hosted 2.12.9 and + # 2.0.17 (verified), because it's a firmware-TYPE mismatch, not a version one. Making Wi-Fi work would + # need either (a) re-flashing the C6 with an esp-hosted slave image (the C6 is SDIO-only — no user + # flash path, unshippable) or (b) re-architecting Wi-Fi around the AT interface (large). So the C6 is + # left OFF and TDP4_C6_READY=0; the P4 ships LoRa + UI only. Keep arduino-esp32 at the standard 3.3.10. + badgeteam/badge-bsp: "^0.9.9" + nicolaielectronics/tanmatsu-wifi: "^1.1.2" + nicolaielectronics/tanmatsu-lora: "^0.3.0" # SX1262 LoRa over the C6 — TanmatsuLoraRadio bridge + # NB: tanmatsu-wifi drags in the nicolaielectronics/esp-hosted-tanmatsu FORK (also pulled via + # badge-bsp), whose auto-init constructor grabs the C6 SDIO bus before app_main and blocks our AT + # driver. We can't just remove the dep (it's transitive), so build.sh neutralizes that fork's + # constructor too (alongside espressif/esp_hosted's). The P4's Wi-Fi is AT-over-SDIO via c6_at. + # arduino-esp32 as an IDF component so the shared wadamesh src/ (Arduino API) compiles. + espressif/arduino-esp32: "3.3.10" + # BLE companion over the C6: esp-nimble-cpp is the IDF-host NimBLE C++ wrapper (works with esp-hosted's + # remote BLE controller, unlike NimBLE-Arduino's bundled host). The Tanmatsu's vendored SerialBLEInterface + # copy is ported to this 2.x API. C6 controller comes up via hostedInitBLE(). + h2zero/esp-nimble-cpp: "^2.3.0" diff --git a/tdisplay_p4/main/main.cpp b/tdisplay_p4/main/main.cpp new file mode 100644 index 0000000..eb1e27f --- /dev/null +++ b/tdisplay_p4/main/main.cpp @@ -0,0 +1,390 @@ +// wadamesh on the LilyGo T-Display P4 (AMOLED) — the app entry point. +// +// Standalone ESP-IDF app (NOT an AppFS/launcher app like the Tanmatsu): it owns the USB console, +// drives the XL9535 expander to power the board, brings up the RM69A10 AMOLED + the C6 (esp-hosted) +// + the raw SX1262, then runs the shared UITask. The board/radio/display globals + radio_init() live +// in variants/tdisplay_p4/target.cpp (via target.h). Modeled on tanmatsu/main/main.cpp; the WiFi + +// companion loop is ported faithfully. Bring-up TODOs (touch, brightness, DSI/power tuning) in +// variants/tdisplay_p4/TDISPLAY_P4_PORT.md. +#include // adcAttachPin() shim — BEFORE target.h pulls ESP32Board.h +#include +#include +#include "MyMesh.h" +#include // placement-new for the PSRAM-resident the_mesh +#include "esp_heap_caps.h" +#include "UITask.h" +#include "target.h" // board, radio_driver, rtc_clock, display, sensors, radio_init() +#include // internal 'storage' FAT partition (no-card fallback) +#include // microSD on SDMMC slot 0 (primary store) +#include "esp_partition.h" +#include +#include +#include // wifiConfig* (runtime Wi-Fi state the loop drives) +#include // touchPrefsBuildLocalTz + WIFI_CONFIG_* sizes +#include "esp_hosted.h" +#include "esp_netif.h" +#include "esp_event.h" +#include "lvgl.h" +#include "c6_at.h" // AT-over-SDIO Wi-Fi/BLE driver for the on-board ESP32-C6 (P4-only) +#include // C6Client — used directly by httpDateProbe() below +#include "esp_vfs_fat.h" // native SD probe (esp_vfs_fat_sdmmc_mount) +#include "driver/sdmmc_host.h" // SDMMC_HOST_DEFAULT / sdmmc_slot_config_t for the probe +#include "sdmmc_cmd.h" +#include "sd_pwr_ctrl_by_on_chip_ldo.h" // SD IO rail = P4 on-chip LDO channel 4 +// LAST include: rebind every WiFi.* below to the c6_at facade. Arduino's real WiFi object re-inits +// esp_hosted at mode()/begin() time and panics the P4 (the C6 runs ESP-AT, not an esp-hosted slave). +// With the facade, this file's Wi-Fi state machine drives REAL AT scans/joins instead. +#include + +#ifndef TCP_PORT +#define TCP_PORT 5000 +#endif +#ifndef WS_PORT +#define WS_PORT 8765 +#endif + +// --- C6 / esp-hosted bring-up gate ----------------------------------------------------------------- +// The ESP32-C6 (Wi-Fi/BLE co-processor over SDIO) doesn't come up yet: the SDIO bus inits, but the +// C6's esp-hosted slave never signals ready, so esp_hosted_connect_to_slave() times out (~12 s) and +// esp-hosted resets the P4 — a boot loop that never reaches ui_task.begin(), leaving the AMOLED dark. +// Until the C6 reset/power/slave-firmware path is proven (needs a reset callback via the XL9535 — see +// TDISPLAY_P4_PORT.md), gate ALL C6-dependent bring-up OFF so the display + touch + LoRa come up. +// LoRa is a raw SX1262 on P4 GPIOs, independent of the C6, so the mesh still works over USB/LoRa. +// Flip to 1 once the C6 link is solid to restore Wi-Fi + BLE. +#ifndef TDP4_C6_READY +// C6 reset+enabled via the Meck-P4 C6_EN pulse (Xl9535::powerOnSequence) + host esp-hosted pinned to +// 2.0.17 to match the factory C6 slave protocol (see idf_component.yml). SDIO card inits AND the RPC +// channel should now sync, so connect to the C6 for Wi-Fi + BLE. (If the link ever misframes again the +// P4 reset-loops — set this back to 0 and re-flash the safe build.) +#define TDP4_C6_READY 0 // C6 connect OFF: factory C6 firmware misframes esp-hosted even at host 2.0.17 (see notes) +#endif +// BLE over the C6 uses arduino-esp32's hostedInitBLE(), which only exists in arduino-esp32 >=3.3.10. +// We pin 3.3.0 (so esp_hosted can be the C6-compatible 2.0.17), which predates hostedInitBLE — so BLE +// needs its own bring-up. Keep BLE gated OFF until that's provided; Wi-Fi (esp_wifi_remote) works now. +#ifndef TDP4_BLE_READY +#define TDP4_BLE_READY 0 +#endif + +extern "C" bool hostedInitBLE(); // arduino-esp32 BLE controller bring-up over esp-hosted + +// Boot-trace hooks the shared code defines on the S3 boards (src/main.cpp, excluded here). +volatile int g_boot_phase = 0; +extern "C" void set_boot_phase(int phase) { g_boot_phase = phase; } + +// App globals — src/main.cpp declares these; we own them here (that file is excluded from the build). +DataStore store(FFat, rtc_clock); +bool g_fs_ok = false; // FFat('storage') mounted (extern — UITask file browser) +bool g_sd_ok = false; // microSD mounted (extern) +MultiTransportCompanionInterface serial_interface; +StdRNG fast_rng; +SimpleMeshTables tables; +UITask ui_task(&board, &serial_interface); +// the_mesh (~42 KB, MAX_CONTACTS-dominated) lives in the 32 MB PSRAM, not scarce internal DRAM. +static MyMesh& makeTheMesh() { + void* mem = heap_caps_malloc(sizeof(MyMesh), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!mem) mem = malloc(sizeof(MyMesh)); + return *new (mem) MyMesh(radio_driver, fast_rng, rtc_clock, tables, store, &ui_task); +} +MyMesh& the_mesh = makeTheMesh(); + +static void hostedConnectC6() { + esp_hosted_init(); // no-op if the constructor already ran + int rc = -1; + for (int i = 0; i < 4 && rc != 0; i++) { + rc = esp_hosted_connect_to_slave(); + if (rc != 0) { printf("[BOOT] C6 connect try %d -> %d\n", i, rc); delay(250); } + } + printf("[BOOT] C6 hosted link: %s\n", rc == 0 ? "UP" : "FAILED"); +} + +// Recursive FS→FS copy for the one-time SD adoption migration (FFat store → SD /meshcomod). +// Skips files that already exist at the destination, so a partial earlier run just completes. +static void migrateFsCopyFile(fs::FS &src, const char *sp, fs::FS &dst, const char *dp) { + File probe = dst.open(dp, FILE_READ); + if (probe) { probe.close(); return; } + File in = src.open(sp, FILE_READ); + if (!in) return; + File out = dst.open(dp, FILE_WRITE); + if (!out) { in.close(); return; } + static uint8_t buf[1024]; + int n; + while ((n = in.read(buf, sizeof buf)) > 0) out.write(buf, (size_t)n); + out.close(); in.close(); + printf("[storage] migrated %s\n", sp); +} +static void migrateFsDir(fs::FS &src, const char *sdir, fs::FS &dst, const char *ddir) { + File d = src.open(sdir); + if (!d || !d.isDirectory()) { if (d) d.close(); return; } + dst.mkdir(ddir); + File f; + while ((f = d.openNextFile())) { + const char *name = f.name(); + const char *leaf = strrchr(name, '/'); + leaf = leaf ? leaf + 1 : name; + char sp[128], dp[128]; + snprintf(sp, sizeof sp, "%s/%s", strcmp(sdir, "/") == 0 ? "" : sdir, leaf); + snprintf(dp, sizeof dp, "%s/%s", ddir, leaf); + bool is_dir = f.isDirectory(); + f.close(); + if (is_dir) migrateFsDir(src, sp, dst, dp); + else migrateFsCopyFile(src, sp, dst, dp); + } + d.close(); +} + +static void wadameshSetup() { + Serial.begin(115200); + delay(150); + printf("[BOOT] wadamesh / T-Display P4\n"); + + // 1. XL9535 expander FIRST — it powers the rails and enables the C6, SD, screen + touch. + if (!xl9535.begin(7, 8)) printf("[BOOT] XL9535 init FAILED (board will not power up)\n"); + xl9535.powerOnSequence(); + + // 2. AMOLED (RM69A10 MIPI-DSI) — up before LVGL flushes to it. + if (!display.begin()) printf("[BOOT] RM69A10 display init FAILED\n"); + + // 3. lwIP/tcpip + default event loop before any WiFi/netif call (esp_wifi_remote needs it). + esp_netif_init(); + esp_event_loop_create_default(); + + // 4. C6: runs the FACTORY ESP-AT firmware (never esp-hosted, never reflashed — Kaj's product + // decision 2026-07-15). Wi-Fi comes up via the c6_at AT-over-SDIO worker spawned at the end of + // setup; BLE companion is unavailable on this AT build (advertising commands stubbed) so the + // phone pairs over Wi-Fi (TCP:5000) or USB. +#if TDP4_C6_READY + hostedConnectC6(); // never taken (TDP4_C6_READY=0): kept only as a reference to the hosted path +#else + printf("[BOOT] C6 = factory ESP-AT (Wi-Fi via c6_at worker; BLE companion unavailable on this build)\n"); +#endif + + // 5. Radio (raw SX1262; reset via the XL9535 inside radio_init). + if (!radio_init()) printf("[BOOT] radio_init FAILED\n"); + + DisplayDriver* disp = &display; + + // 6. Storage — microSD (SDMMC slot 0) primary, internal FFat 'storage' as the no-card fallback. + g_fs_ok = FFat.begin(true, "/ffat", 10, "storage"); + printf("[storage] FFat(storage) = %s\n", g_fs_ok ? "OK" : "FAILED"); + if (g_fs_ok) { FFat.mkdir("/identity"); FFat.mkdir("/bl"); } + // The slot's VDD is gated by the XL9535's SD_EN (IO15), ACTIVE-LOW — powerOnSequence() drives it + // low. (Root-caused 2026-07-15 with a GPIO pad sweep: SD_EN high = all six SD pads clamped LOW + // through the unpowered card's ESD diodes -> 0x107/0x109 on every mount; SD_EN low = pads high, + // card powered. Our Xl9535::begin() had parked it output-HIGH.) Card supply also wants the P4's + // on-chip LDO4 attached like Meck does — Arduino's SD_MMC defaults to exactly that on this chip. + // Pins are the board's SDMMC slot 0 IOMUX set (CLK43/CMD44/D0-3=39..42), 4-bit like Meck. + SD_MMC.setPins(43, 44, 39, 40, 41, 42); + // Meck's working init also sets SDMMC_SLOT_FLAG_INTERNAL_PULLUP (build.sh patches Arduino's slot-0 + // struct literal to add it). The powered board turns out to have external pull-ups on all six + // lines too — belt and braces. + for (int p : {44, 39, 40, 41, 42}) gpio_pullup_en((gpio_num_t)p); + // Mount ladder: 4-bit@20 MHz normally succeeds first try now that the slot is powered; the + // fallbacks stay for marginal cards. (1-bit skips D1..D3; 5 MHz derates signal margin.) + { + struct { bool onebit; int khz; const char *tag; } tries[] = { + { false, SDMMC_FREQ_DEFAULT, "4-bit 20MHz" }, + { false, 5000, "4-bit 5MHz" }, + { true, SDMMC_FREQ_DEFAULT, "1-bit 20MHz" }, + { true, 5000, "1-bit 5MHz" }, + }; + for (auto &t : tries) { + g_sd_ok = SD_MMC.begin("/sdcard", t.onebit, false, t.khz) && SD_MMC.cardType() != CARD_NONE; + printf("[storage] SD_MMC try %s -> %s\n", t.tag, g_sd_ok ? "OK" : "fail"); + if (g_sd_ok) break; + SD_MMC.end(); + delay(120); + } + } + printf("[storage] SD_MMC = %s\n", g_sd_ok ? "OK" : "no card"); + if (g_sd_ok) { + SD_MMC.mkdir("/meshcomod"); SD_MMC.mkdir("/meshcomod/identity"); SD_MMC.mkdir("/meshcomod/bl"); + // One-time ADOPTION MIGRATION (the beta_36 lesson: never flip a storage root without migrating). + // This device has been running with the store on FFat while the SD init was broken — if the card + // has no identity yet but FFat does, copy the whole FFat store across BEFORE switching the root, + // or the node would boot with a fresh identity and "lose" contacts/prefs/history. + File sp = SD_MMC.open("/meshcomod/identity/_main.id", FILE_READ); + bool sd_has_id = (bool)sp; if (sp) sp.close(); + File fp = FFat.open("/identity/_main.id", FILE_READ); + bool ff_has_id = (bool)fp; if (fp) fp.close(); + if (!sd_has_id && ff_has_id) { + printf("[storage] first SD adoption -> migrating FFat store to SD /meshcomod\n"); + migrateFsDir(FFat, "/", SD_MMC, "/meshcomod"); + } + store.useSdMmcStorage(); + } + store.begin(); + + the_mesh.begin(disp != NULL); + + serial_interface.begin(Serial, TCP_PORT, WS_PORT); + serial_interface.setBroadcastResponses(true); + the_mesh.startInterface(serial_interface); + +#if defined(BLE_PIN_CODE) && TDP4_C6_READY && TDP4_BLE_READY + if (hostedInitBLE()) { + char* nm = the_mesh.getNodePrefs()->node_name; + serial_interface.prepareBle("wadamesh-", nm, the_mesh.getBLEPin()); + if (wifiConfigGetBleEnabled()) + serial_interface.beginBle("wadamesh-", nm, the_mesh.getBLEPin()); + } else { + printf("[BOOT] hostedInitBLE FAILED\n"); + } +#endif + + // GPS UART resilience (same fix as the S3 boards): the core opens Serial1 with Arduino's + // 256-byte RX ring; one long LVGL frame overflows it and corrupts NMEA, stretching TTFF from + // ~1 min to many. Must precede sensors.begin() (a no-op once the UART runs). Unconditional + // here — the P4 has RAM to spare, and GPS detection itself needs an intact first second. + Serial1.setRxBufferSize(4096); + sensors.begin(); + { // GPS detect visibility (the "gps" setting is only registered when NMEA was heard) + bool has_gps = false; + for (int i = 0; i < sensors.getNumSettings(); i++) + if (strcmp(sensors.getSettingName(i), "gps") == 0) has_gps = true; + printf("[GPS] L76K %s\n", has_gps ? "DETECTED (NMEA on GPIO22)" : "NOT detected (no NMEA in the 1s window)"); + } + ui_task.begin(disp, &sensors, the_mesh.getNodePrefs()); + board.onBootComplete(); + printf("[BOOT] setup done\n"); + +#ifdef TDP4_C6_FLASH_HELPER + // C6-FLASH HELPER BUILD: the P4 must NOT touch the C6 so it can be held in ROM download mode and + // reflashed over its own USB. powerOnSequence() already skips the C6_EN reset pulse under this flag + // (leaving C6_EN HIGH = powered), and here we skip the AT worker entirely. Boots the normal UI so + // the board is alive, but the C6 is left completely free. Temporary — remove after the C6 reflash. + printf("[BOOT] C6-FLASH HELPER: AT worker NOT started, C6 left free for download-mode reflash\n"); + board.onBootComplete(); + printf("[BOOT] setup done (helper)\n"); + return; +#endif + // C6 AT/SDIO Wi-Fi: spawn the c6_at worker (non-blocking — it brings the AT link up itself on + // core 1 with retries). The Wi-Fi state machine in app_main's loop then drives scans/joins through + // the C6WifiShim facade exactly like the other boards drive Arduino WiFi. + c6at_worker_start(); +} + +// Time-sync fallback: pull UTC from the Date header of a plain-HTTP HEAD to the firmware +// host. C6-SNTP needs UDP/123 egress, which some networks (guest/IoT VLANs) block — while +// port-80 HTTP is proven open on this device (tiles + version checks ride it). Returns a +// UTC epoch, or 0. Runs on the loop thread only while the clock is still unsynced. +static uint32_t httpDateProbe(void) { + C6Client c; + if (!c.connect("firmware.wadamesh.com", 80, 8000)) return 0; + static const char req[] = + "HEAD / HTTP/1.1\r\nHost: firmware.wadamesh.com\r\nConnection: close\r\n\r\n"; + if (c.write((const uint8_t*)req, sizeof(req) - 1) != sizeof(req) - 1) { c.stop(); return 0; } + char buf[600]; + size_t got = 0; + uint32_t t0 = millis(); + while (got < sizeof(buf) - 1 && millis() - t0 < 6000) { + int n = c.read((uint8_t*)buf + got, sizeof(buf) - 1 - got); + if (n > 0) { got += (size_t)n; buf[got] = '\0'; if (strstr(buf, "\r\n\r\n")) break; } + else if (!c.connected()) break; + else vTaskDelay(pdMS_TO_TICKS(20)); + } + c.stop(); + buf[got] = '\0'; + const char* p = strstr(buf, "\r\nDate: "); // "Date: Tue, 15 Jul 2026 16:33:47 GMT" + if (!p) return 0; + char mon[4] = {0}; + int day, year, hh, mm, ss; + if (sscanf(p + 8, "%*3s, %d %3s %d %d:%d:%d", &day, mon, &year, &hh, &mm, &ss) != 6) return 0; + if (year < 2024) return 0; + static const char* M = "JanFebMarAprMayJunJulAugSepOctNovDec"; + const char* mp = strstr(M, mon); + if (!mp) return 0; + int m = (int)(mp - M) / 3 + 1; + int y = year - (m <= 2); // days-from-civil (Howard Hinnant) + int era = y / 400, yoe = y - era * 400; + int doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + day - 1; + int doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + long days = (long)era * 146097 + doe - 719468; + return (uint32_t)(days * 86400L + hh * 3600 + mm * 60 + ss); +} + +extern "C" void app_main(void) { + initArduino(); + wadameshSetup(); + + // WiFi state machine + SNTP + TCP/WS companion server — ported from src/main.cpp's loop() + // (excluded from this build), identical to the Tanmatsu. + bool wifi_started = false, wifi_radio_prev = true, wifi_radio_inited = false; + bool sntp_kicked = false, sntp_pushed = false, modem_sleep_set = false; + uint32_t last_wifi_retry_ms = 0, sntp_kick_ms = 0; + const uint32_t WIFI_RETRY_INTERVAL_MS = 10000; + + for (;;) { + ui_task.loop(); + + // WiFi.* here is the C6WifiShim facade (AT-over-SDIO), so this state machine drives REAL joins: + // start once the c6_at worker has the AT link up + the user wants Wi-Fi. The 10 s retry branch + // doubles as auto-reconnect. (TDP4_C6_READY still gates the unused esp-hosted path elsewhere.) + bool wifi_radio_en = c6at_is_up() && wifiConfigWantsWifi(); + if (!wifi_radio_inited) { wifi_radio_inited = true; wifi_radio_prev = wifi_radio_en; } + else if (wifi_radio_en != wifi_radio_prev) { + wifi_radio_prev = wifi_radio_en; + if (!wifi_radio_en) { WiFi.disconnect(true); delay(50); WiFi.mode(WIFI_OFF); } + wifi_started = false; + } + if (wifiConfigConsumeApplyRequest()) { + if (wifi_started) { + if (!wifi_radio_en) { WiFi.disconnect(true); delay(50); WiFi.mode(WIFI_OFF); } + else { WiFi.disconnect(false, false); delay(50); } + } + wifi_started = false; last_wifi_retry_ms = 0; + } + if (wifi_radio_en) { + if (!wifi_started) { + wifi_started = true; + WiFi.mode(WIFI_STA); + if (wifiConfigHasRuntime()) { + char ssid[WIFI_CONFIG_SSID_MAX], pwd[WIFI_CONFIG_PWD_MAX]; + wifiConfigGetSsid(ssid, sizeof(ssid)); wifiConfigGetPwd(pwd, sizeof(pwd)); + if (strlen(ssid) > 0) { WiFi.begin(ssid, pwd[0] ? pwd : nullptr); last_wifi_retry_ms = millis(); } + } + } + if (wifiConfigHasRuntime() && WiFi.status() != WL_CONNECTED) { + uint32_t now = millis(); + if ((uint32_t)(now - last_wifi_retry_ms) >= WIFI_RETRY_INTERVAL_MS) { + last_wifi_retry_ms = now; + char ssid[WIFI_CONFIG_SSID_MAX], pwd[WIFI_CONFIG_PWD_MAX]; + wifiConfigGetSsid(ssid, sizeof(ssid)); wifiConfigGetPwd(pwd, sizeof(pwd)); + if (strlen(ssid) > 0) { WiFi.disconnect(false, true); WiFi.begin(ssid, pwd[0] ? pwd : nullptr); } + } + } + if (WiFi.status() == WL_CONNECTED) { + if (!modem_sleep_set) { WiFi.setSleep(true); modem_sleep_set = true; } + serial_interface.startTcpServer(true); + // NTP runs ON the C6 (AT+CIPSNTP*, configured by the c6_at worker after a join) — lwIP has no + // netif on this board, so esp_sntp/configTzTime can't sync. Pull the UTC epoch when ready. + if (!sntp_pushed) { + if (sntp_kick_ms == 0) sntp_kick_ms = millis(); // connected-since stamp for the fallback below + uint32_t e = c6at_sntp_epoch(); + if (e > 1700000000) { rtc_clock.setCurrentTime(e); sntp_pushed = true; printf("[C6-AT] SNTP -> rtc %lu\n", (unsigned long)e); } + // Bench-verified 2026-07-15: on some networks SNTP never completes (UDP/123 + // blocked) — the clock then stays wrong forever, since BLE is parked and the + // phone app only sets time forward. After 45 s connected without a sync, fall + // back to HTTP Date (sets the RTC through the same guarded ClockFloorRTC path, + // so it also *repairs* a garbage-future hardware clock via the backward hatch). + else if (millis() - sntp_kick_ms > 45000) { + static uint32_t http_date_next = 0; + if ((int32_t)(millis() - http_date_next) >= 0) { + http_date_next = millis() + 120000; + uint32_t he = httpDateProbe(); + if (he > 1700000000) { + rtc_clock.setCurrentTime(he); + sntp_pushed = true; + printf("[TIME] HTTP-Date -> rtc %lu\n", (unsigned long)he); + } + } + } + } + (void)sntp_kicked; + } + } + serial_interface.tickWebSocketHandshake(); + + the_mesh.loop(); + vTaskDelay(1); + } +} diff --git a/tdisplay_p4/partition_tables/tdisplay_p4_16M.csv b/tdisplay_p4/partition_tables/tdisplay_p4_16M.csv new file mode 100644 index 0000000..50b2936 --- /dev/null +++ b/tdisplay_p4/partition_tables/tdisplay_p4_16M.csv @@ -0,0 +1,12 @@ +# LilyGo T-Display P4 (AMOLED) — standalone 16 MB partition table. +# NOT an AppFS/launcher app (that's the Tanmatsu) — this flashes bootloader+app directly. +# Dual OTA app slots (4 MB each; the P4 wadamesh image is ~2.5-3 MB) + a big FAT for +# identity/prefs/contacts/chat history + map tiles. +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 24K, +otadata, data, ota, 0xF000, 8K, +phy_init, data, phy, 0x11000, 4K, +ota_0, app, ota_0, 0x20000, 4M, +ota_1, app, ota_1, 0x420000, 4M, +storage, data, fat, 0x820000, 8000K, +coredump, data, coredump, 0xFF0000, 64K, diff --git a/tdisplay_p4/sdkconfigs/general b/tdisplay_p4/sdkconfigs/general new file mode 120000 index 0000000..4f2d3dc --- /dev/null +++ b/tdisplay_p4/sdkconfigs/general @@ -0,0 +1 @@ +../../tanmatsu/sdkconfigs/general \ No newline at end of file diff --git a/tdisplay_p4/sdkconfigs/tdisplay_p4 b/tdisplay_p4/sdkconfigs/tdisplay_p4 new file mode 100644 index 0000000..70d14bc --- /dev/null +++ b/tdisplay_p4/sdkconfigs/tdisplay_p4 @@ -0,0 +1,49 @@ +# LilyGo T-Display P4 (AMOLED) board sdkconfig fragment (first pass — validate at first build). +# Modeled on sdkconfigs/tanmatsu but: NO badge-bsp, esp-hosted C6 on the T-Display P4's SDIO_2 pins, +# our standalone partition table. See variants/tdisplay_p4/TDISPLAY_P4_PORT.md. +CONFIG_IDF_TARGET="esp32p4" + +# 32 MB HEX (16-line) PSRAM at 200 MHz. On the ESP32-P4, HEX-PSRAM @ 200 MHz is gated behind +# CONFIG_IDF_EXPERIMENTAL_FEATURES — without it Kconfig silently drops SPEED_200M to 20 MHz, which +# starves the MIPI-DSI DPI (the panel underruns fetching the framebuffer). Matches the Tanmatsu. +CONFIG_SPIRAM=y +CONFIG_IDF_EXPERIMENTAL_FEATURES=y +CONFIG_SPIRAM_SPEED_200M=y +CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=65536 + +# 16 MB flash + our standalone partition table +CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partition_tables/tdisplay_p4_16M.csv" + +# MIPI-DSI (RM69A10 AMOLED) +CONFIG_LCD_DSI_ISR_IRAM_SAFE=y + +# ESP32-C6 companion via esp-hosted over SDIO_2 (CLK18 / CMD19 / D0-3 = 14/15/16/17). +# (Tanmatsu slot-1 used 16-21; the T-Display P4 wires the C6 to these instead.) +CONFIG_SLAVE_IDF_TARGET_ESP32C6=y +CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_CLK_SLOT_1=18 +CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_CMD_SLOT_1=19 +CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D0_SLOT_1=14 +CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D1_4BIT_BUS_SLOT_1=15 +CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D2_4BIT_BUS_SLOT_1=16 +CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D3_4BIT_BUS_SLOT_1=17 +# C6 EN/reset is on the XL9535 expander (IO14): we reset+enable the C6 OURSELVES with a C6_EN +# HIGH->LOW->HIGH pulse in Xl9535::powerOnSequence() (exactly like the working Meck-P4 app_main), +# so esp-hosted must NOT try its own reset. Match Meck's setup: stock esp_hosted defaults (no reset +# callback) + reset-only-if-necessary, so the host just connects to the already-booted C6 slave. +CONFIG_ESP_HOSTED_SLAVE_RESET_ONLY_IF_NECESSARY=y +CONFIG_ESP_HOSTED_ENABLE_BT_NIMBLE=y +# Do NOT reboot the P4 when the C6 doesn't answer (default esp-hosted behaviour). During bring-up the +# C6 SDIO/power path isn't proven yet; without this the host reboots ~10 s after a failed C6 init, +# preventing the UI (and LoRa) from ever coming up. Keep the device alive; Wi-Fi/BLE just stay down. +CONFIG_ESP_HOSTED_HOST_RESTART_NO_COMMUNICATION_WITH_SLAVE=n + +# Panic coredump -> the dedicated 64K 'coredump' partition (see tdisplay_p4_16M.csv). Unlike the +# Tanmatsu (whose table has no coredump partition, so UITask stubs the API), the P4 has one, so +# enable the real esp_core_dump_image_check/_get/_erase the crash-report export calls. ELF format +# so the coredump can be decoded against firmware.elf (see the tdeck-coredump-decode workflow). +CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH=y +CONFIG_ESP_COREDUMP_DATA_FORMAT_ELF=y +CONFIG_ESP_COREDUMP_CHECKSUM_CRC32=y +CONFIG_ESP_COREDUMP_MAX_TASKS_NUM=64 diff --git a/tdisplay_p4/sdkconfigs/wadamesh b/tdisplay_p4/sdkconfigs/wadamesh new file mode 120000 index 0000000..5339545 --- /dev/null +++ b/tdisplay_p4/sdkconfigs/wadamesh @@ -0,0 +1 @@ +../../tanmatsu/sdkconfigs/wadamesh \ No newline at end of file diff --git a/variants/heltec_v4/LGFXDisplay.cpp b/variants/heltec_v4/LGFXDisplay.cpp index 9eecac6..4db4742 100644 --- a/variants/heltec_v4/LGFXDisplay.cpp +++ b/variants/heltec_v4/LGFXDisplay.cpp @@ -74,13 +74,17 @@ bool LGFXDisplay::begin() { _lcd.init(); _lcd.setSwapBytes(true); // LVGL pixels are LE; ST7789 needs BE - _lcd.setRotation(1); // landscape 320×240 + // PORTRAIT as-inited (240x320) — the V4-shared UI contract: portrait never calls + // setDisplayRotation() ("portrait leaves the panel as inited"), landscape calls it with 1/3. + // The old hardcoded setRotation(1) here put the panel in landscape under portrait frames — + // the tester's "boot logo in the wrong orientation". + _lcd.setRotation(0); _lcd.setBrightness(255); _lcd.fillScreen(0x0000); setLogicalSize(_lcd.width(), _lcd.height()); _isOn = true; - Serial.printf("[TFT] LGFXDisplay %dx%d rotation=1\n", _lcd.width(), _lcd.height()); + Serial.printf("[TFT] LGFXDisplay %dx%d rotation=0 (portrait)\n", _lcd.width(), _lcd.height()); return true; } @@ -132,8 +136,10 @@ void LGFXDisplay::writePixelsRGB565(int x, int y, int w, int h, const uint16_t* _lcd.endWrite(); } -void LGFXDisplay::setDisplayRotation(uint8_t) { - _lcd.setRotation(1); +// UI contract (see UITask applyRotation): called with 1 for ROT_90, 3 for ROT_270; portrait +// stays as-inited (rotation 0). Honor the argument — the old hardcoded 1 ignored it. +void LGFXDisplay::setDisplayRotation(uint8_t r) { + _lcd.setRotation(r & 3); setLogicalSize(_lcd.width(), _lcd.height()); } diff --git a/variants/tdisplay_p4/Hi8561Touch.cpp b/variants/tdisplay_p4/Hi8561Touch.cpp new file mode 100644 index 0000000..63b2a19 --- /dev/null +++ b/variants/tdisplay_p4/Hi8561Touch.cpp @@ -0,0 +1,320 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// LilyGo T-Display P4 (AMOLED) touch input — implements the shared touch-input +// API (HeltecV4CapTouch.h) for the board's HI8561 capacitive controller. +// +// The HI8561 shares I2C_1 (Wire, SDA=7 / SCL=8) with the XL9535 expander, the +// PCF8563 RTC and the BQ27220 gauge. Wire is already begun by the XL9535 driver +// (xl9535.begin(7,8) in main.cpp) and the controller's RST/INT lines are driven +// by the expander — powerOnSequence() releases TOUCH_RST (IO3) before we probe. +// So, like RakTapV2Touch, this driver uses the shared Wire and must NOT re-init +// or reset the bus. +// +// STATE MACHINE: the tap/swipe/long-press gesture logic below is the proven one +// shared with the CHSC6x (Heltec V4) and FT5x06 (RAK Tap) drivers — only the +// physical report read (hi8561ReadPoint) and the panel geometry differ. +// +// ⚠️ FIRST-BOOT STATUS (display-first): hi8561ReadPoint() currently reports NO +// touch — the panel boots and renders, but taps aren't delivered yet. Enabling +// real touch is a single localised change: fill in the HI8561 report parse (addr +// 0x48; see the TODO in hi8561ReadPoint) and verify the coordinate mapping on the +// device. A wrong report layout produces phantom presses that lock the UI, so it +// stays disabled until it can be validated against hardware. Everything else +// (probe, async poll task, gesture machine, rotation) is complete. + +#if defined(HAS_HI8561_TOUCH) && defined(ESP32) + +#include // shared touch-API contract (this file lives in variants/, not next to it) +#include +#include +#include +#include +#include + +#ifndef PIN_TOUCH_SDA + #define PIN_TOUCH_SDA 7 +#endif +#ifndef PIN_TOUCH_SCL + #define PIN_TOUCH_SCL 8 +#endif + +// RM69A10 AMOLED is a fixed 568×1232 portrait panel (no display rotation on this +// board — CAP_ROTATABLE=0). The controller reports in the same portrait space, +// so raw == screen and the default (identity) mapping applies. +// LVGL renders at 284x616 (half the 568x1232 panel; RM69A10Display upscales 2x on flush), so touch +// coordinates must be reported in that SAME 284x616 logical space — the GT9895's native 1060x2400 +// grid is scaled straight to 284x616 here. +static const int SCR_W = 284; +static const int SCR_H = 616; +static const int RAW_W = 284; +static const int RAW_H = 616; + +// NOTE: despite this file's name, the LilyGo T-Display P4 (RM69A10 variant) actually ships a Goodix +// GT9895 touch controller at 0x5D — NOT the HI8561 (that's a different display variant). An I2C bus +// scan on the real board found 0x5D. This driver talks to the GT9895. Ported from LilyGo +// cpp_bus_driver gt9895.cpp: the touch report is at register 0x00010308; each read = write that +// 32-bit address big-endian (4 bytes), then read the report. Scale factors default to 1.0 (the +// controller reports directly in panel coordinates), so no rescale is needed. +static const uint8_t GT9895_ADDR = 0x5D; +static const uint8_t GT9895_TOUCH_CMD[4] = { 0x00, 0x01, 0x03, 0x08 }; // register 0x00010308, big-endian +// The GT9895 reports in its native 1060x2400 digitiser grid; scale to the 568x1232 panel (LilyGo's +// GT9895_MAX_X_SIZE/MAX_Y_SIZE + RM69A10_SCREEN_W/H scale factors). Without this the raw coords +// over-run the panel (touch clamps past ~54% X / ~51% Y). +static const int GT9895_NATIVE_W = 1060; +static const int GT9895_NATIVE_H = 2400; + +static bool s_init_ok = false; +static bool s_given_up = false; +static int s_retries = 0; +static char s_scan_str[128] = "scan: (not run)"; + +static volatile uint16_t s_dbg_rawx = 0, s_dbg_rawy = 0; + +static bool s_have_touch = false; +static uint16_t s_cur_x = 0, s_cur_y = 0; +static bool s_down = false; +static unsigned long s_down_at = 0; +static uint16_t s_start_x = 0, s_start_y = 0; +static uint16_t s_last_x = 0, s_last_y = 0; +static bool s_live = false; +static uint16_t s_live_x = 0, s_live_y = 0; +static bool s_tap_pending = false; +static uint16_t s_tap_x = 0, s_tap_y = 0; +static bool s_swiping_now = false; +static bool s_swipe_pending = false; +static int8_t s_swipe_x = 0, s_swipe_y = 0; + +static uint8_t s_ui_rotation = 0; +static uint8_t s_point_rotation = 0; + +// Larger panel -> larger swipe threshold than the 240×320 boards. +#ifndef P4_TOUCH_SWIPE_MIN + #define P4_TOUCH_SWIPE_MIN 60 +#endif +#ifndef P4_TOUCH_TAP_MOVE_MAX + #define P4_TOUCH_TAP_MOVE_MAX 24 +#endif +#ifndef P4_TOUCH_LONG_MS + #define P4_TOUCH_LONG_MS 1000 +#endif + +// ---- GT9895 low-level access ---- + +// Write an N-byte command (repeated start), then read M bytes. +static bool gt9895WriteRead(const uint8_t* cmd, uint8_t clen, uint8_t* out, uint8_t olen) { + Wire.beginTransmission(GT9895_ADDR); + Wire.write(cmd, clen); + if (Wire.endTransmission(false) != 0) return false; // repeated start, no stop + uint8_t got = Wire.requestFrom((int)GT9895_ADDR, (int)olen); + if (got != olen) return false; + for (uint8_t i = 0; i < olen; i++) out[i] = Wire.read(); + return true; +} + +// ---- Init (called by UITask loop every tick until success or give-up) ---- + +bool heltecV4CapTouchBegin() { + if (s_init_ok) return true; + if (s_given_up) return false; + + ++s_retries; + if (s_retries > 3) { + s_given_up = true; + Serial.println("[TOUCH] HI8561 giving up after 3 attempts — booting without touch"); + return false; + } + + // Wire is up on 7/8 (xl9535.begin). Bump to 400 kHz + short timeout so a stuck + // controller can't stall the UI loop. + Wire.setClock(400000); + Wire.setTimeOut(20); + delay(10); + + // Bring-up: scan the I2C bus once so we can see exactly what touch controller is present + // (HI8561=0x68, GT9895=0x5D/0x14, others) — the RM69A10 variant may pair with GT9895, not HI8561. + if (s_retries == 1) { + char sc[96]; int n = 0; n += snprintf(sc, sizeof sc, "[TOUCH] i2c7/8 scan:"); + for (uint8_t a = 0x08; a < 0x78 && n < (int)sizeof sc - 6; a++) { + Wire.beginTransmission(a); + if (Wire.endTransmission() == 0) n += snprintf(sc + n, sizeof sc - n, " %02X", a); + } + Serial.println(sc); + } + + Wire.beginTransmission(GT9895_ADDR); + if (Wire.endTransmission() != 0) { + snprintf(s_scan_str, sizeof s_scan_str, "i2c7/8: no ACK at 0x%02X", GT9895_ADDR); + Serial.printf("[TOUCH] GT9895 not found (retry %d/3)\n", s_retries); + return false; + } + + s_init_ok = true; + snprintf(s_scan_str, sizeof s_scan_str, "0x%02X gt9895 OK (retry %d)", GT9895_ADDR, s_retries - 1); + Serial.printf("[TOUCH] %s\n", s_scan_str); + return true; +} + +// ---- Coordinate mapping (identity in portrait; rotation kept for parity) ---- + +static void mapRaw(uint16_t rx, uint16_t ry, uint16_t* ox, uint16_t* oy) { + int sx, sy; + switch (s_point_rotation) { + case 1: sx = (RAW_H - 1) - (int)ry; sy = (int)rx; break; // ROT_90 + case 3: sx = (int)ry; sy = (RAW_W - 1) - (int)rx; break; // ROT_270 + default: sx = (int)rx; sy = (int)ry; break; // portrait / 180 + } + if (sx < 0) sx = 0; if (sx >= SCR_W) sx = SCR_W - 1; + if (sy < 0) sy = 0; if (sy >= SCR_H) sy = SCR_H - 1; + *ox = (uint16_t)sx; + *oy = (uint16_t)sy; +} + +// ---- Physical report read ------------------------------------------------ +// Returns 1 = finger down (rx/ry set to raw panel coords), 0 = no touch, -1 = I2C error. +// +// GT9895 single-touch read: 16 bytes = TOUCH_POINT_ADDRESS_OFFSET(8) + 1*SINGLE_TOUCH_POINT_DATA_SIZE(8). +// buf[2] = finger count; point data at offset 8: [id/status][?][x_lo][x_hi][y_lo][y_hi][pressure][?] +// (x/y little-endian; scale factor 1.0 => already panel coords). +// TODO(device): verify X/Y axis orientation + origin against on-screen taps (may need flip/swap in mapRaw). +static int hi8561ReadPoint(uint16_t* rx, uint16_t* ry) { + uint8_t buf[16] = {0}; + if (!gt9895WriteRead(GT9895_TOUCH_CMD, 4, buf, sizeof buf)) return -1; + uint8_t fingers = buf[2]; + if (fingers < 1 || fingers > 10) return 0; // no finger down + uint32_t x = (uint32_t)buf[10] | ((uint32_t)buf[11] << 8); + uint32_t y = (uint32_t)buf[12] | ((uint32_t)buf[13] << 8); + // Scale the native 1060x2400 grid down to the 568x1232 panel. + *rx = (uint16_t)(x * (uint32_t)SCR_W / GT9895_NATIVE_W); + *ry = (uint16_t)(y * (uint32_t)SCR_H / GT9895_NATIVE_H); + return 1; +} + +static void hi8561Poll() { + uint16_t rx = 0, ry = 0; + if (hi8561ReadPoint(&rx, &ry) == 1) { + s_dbg_rawx = rx; + s_dbg_rawy = ry; + mapRaw(rx, ry, &s_cur_x, &s_cur_y); + s_have_touch = true; + } else { + s_have_touch = false; + } +} + +void heltecV4CapTouchGetRaw(uint16_t* rx, uint16_t* ry) { + if (rx) *rx = s_dbg_rawx; + if (ry) *ry = s_dbg_rawy; +} + +// ---- Gesture state machine (shared logic; mirrors RakTapV2Touch) ---- + +int heltecV4CapTouchCheck() { + if (!s_init_ok) return BUTTON_EVENT_NONE; + hi8561Poll(); + + if (s_have_touch) { + s_live = true; + s_live_x = s_cur_x; + s_live_y = s_cur_y; + if (!s_down) { + s_down = true; + s_down_at = millis(); + s_start_x = s_cur_x; + s_start_y = s_cur_y; + s_swiping_now = false; + } + s_last_x = s_cur_x; + s_last_y = s_cur_y; + if (!s_swiping_now) { + int adx = abs((int)s_last_x - (int)s_start_x); + int ady = abs((int)s_last_y - (int)s_start_y); + if (adx >= P4_TOUCH_SWIPE_MIN && adx > ady) s_swiping_now = true; + } + return BUTTON_EVENT_NONE; + } + + if (s_down) { + s_down = false; + s_live = false; + s_swiping_now = false; + unsigned long dur = millis() - s_down_at; + int adx = abs((int)s_last_x - (int)s_start_x); + int ady = abs((int)s_last_y - (int)s_start_y); + if (adx >= P4_TOUCH_SWIPE_MIN && adx > (ady + 8)) { + bool left = ((int)s_last_x - (int)s_start_x) < 0; + s_swipe_x = left ? -1 : 1; + s_swipe_y = 0; + s_swipe_pending = true; + return left ? BUTTON_EVENT_DOUBLE_CLICK : BUTTON_EVENT_TRIPLE_CLICK; + } + if (ady >= P4_TOUCH_SWIPE_MIN && ady > (adx + 8)) { + s_swipe_x = 0; + s_swipe_y = ((int)s_last_y - (int)s_start_y) < 0 ? -1 : 1; + s_swipe_pending = true; + return BUTTON_EVENT_NONE; + } + if (dur >= 12 && dur < (unsigned long)P4_TOUCH_LONG_MS && + adx <= P4_TOUCH_TAP_MOVE_MAX && ady <= P4_TOUCH_TAP_MOVE_MAX) { + s_tap_x = s_last_x; + s_tap_y = s_last_y; + s_tap_pending = true; + return BUTTON_EVENT_CLICK; + } + if (dur >= (unsigned long)P4_TOUCH_LONG_MS) { + return BUTTON_EVENT_LONG_PRESS; + } + } else { + s_live = false; + } + return BUTTON_EVENT_NONE; +} + +bool heltecV4CapTouchGetLive(uint16_t* x, uint16_t* y) { + if (!s_live) return false; + if (x) *x = s_live_x; + if (y) *y = s_live_y; + return true; +} +bool heltecV4CapTouchPopTap(uint16_t* x, uint16_t* y) { + if (!s_tap_pending) return false; + s_tap_pending = false; + if (x) *x = s_tap_x; + if (y) *y = s_tap_y; + return true; +} +bool heltecV4CapTouchPopSwipe(int8_t* xd, int8_t* yd) { + if (!s_swipe_pending) return false; + s_swipe_pending = false; + if (xd) *xd = s_swipe_x; + if (yd) *yd = s_swipe_y; + return true; +} + +// ---- Background poll (core 0) ---- + +static TaskHandle_t s_poll_task = nullptr; +static volatile bool s_async = false; +static uint32_t s_period_ms = 8; + +static void touchPollTask(void* arg) { + (void)arg; + for (;;) { + heltecV4CapTouchCheck(); + vTaskDelay(pdMS_TO_TICKS(s_period_ms)); + } +} + +bool heltecV4CapTouchStartBackgroundPoll(uint32_t period_ms) { + if (s_async || !s_init_ok) return false; + s_period_ms = period_ms < 4 ? 4 : (period_ms > 100 ? 100 : period_ms); + BaseType_t ok = xTaskCreatePinnedToCore(touchPollTask, "hi8561_touch", 3072, + nullptr, 2, &s_poll_task, 0); + if (ok == pdPASS) { s_async = true; return true; } + return false; +} +bool heltecV4CapTouchIsAsyncPolling() { return s_async; } +bool heltecV4CapTouchIsSwiping() { return s_swiping_now; } +void heltecV4CapTouchSetRotation(uint8_t r) { s_ui_rotation = r & 3; } +void heltecV4CapTouchSetPointRotation(uint8_t r) { s_point_rotation = r & 3; } +const char* heltecV4CapTouchDebug() { return s_scan_str; } + +#endif // HAS_HI8561_TOUCH && ESP32 diff --git a/variants/tdisplay_p4/P4Audio.cpp b/variants/tdisplay_p4/P4Audio.cpp new file mode 100644 index 0000000..c0a5a9e --- /dev/null +++ b/variants/tdisplay_p4/P4Audio.cpp @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#if defined(HAS_TDISPLAY_P4) +// ES8311 register code adapted from Espressif's esp-bsp components/es8311 (Apache-2.0, +// SPDX-FileCopyrightText: 2015-2026 Espressif Systems) — trimmed to the playback path this +// board needs and re-based onto Arduino Wire1, because the original talks the LEGACY +// driver/i2c API which aborts at boot when linked next to arduino-esp32 3.x's new-driver +// Wire. Clocking is fixed at MCLK = 256×fs from the MCLK pin, so one static divider row +// ({4096000, 16000}: pre_div 1, mult 1x, adc/dac_div 1, bclk_div 4, osr 0x10) covers us. +#include "P4Audio.h" +#include +#include +#include +#include "driver/i2s_std.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" + +// t_display_p4_config.h (LilyGo): ES8311 on IIC_2, I2S pins below. +#define P4A_I2C_SDA 20 +#define P4A_I2C_SCL 21 +#define P4A_ADDR 0x18 +#define P4A_MCLK 13 +#define P4A_BCLK 12 +#define P4A_WS 9 +#define P4A_DOUT 10 + +#define P4A_RATE 16000 +#define P4A_MCLK_HZ (P4A_RATE * 256) + +// ---- minimal ES8311 register layer (names == hex addresses in the datasheet map) ---- +static bool es8311Write(uint8_t reg, uint8_t val) { + Wire1.beginTransmission(P4A_ADDR); + Wire1.write(reg); + Wire1.write(val); + return Wire1.endTransmission() == 0; +} +static bool es8311Read(uint8_t reg, uint8_t* val) { + Wire1.beginTransmission(P4A_ADDR); + Wire1.write(reg); + if (Wire1.endTransmission(false) != 0) return false; + if (Wire1.requestFrom((uint8_t)P4A_ADDR, (uint8_t)1) != 1) return false; + *val = Wire1.read(); + return true; +} + +// es8311_init() for clk{mclk_from_mclk_pin, 4.096 MHz, 16 kHz} + 16-bit I2S slave + the +// "NOT default" analog power-ups, then DAC volume + unmute. Register values follow the +// esp-bsp driver verbatim. +static bool es8311Init() { + uint8_t v; + if (!es8311Write(0x00, 0x1F)) return false; // reset + vTaskDelay(pdMS_TO_TICKS(20)); + es8311Write(0x00, 0x00); + es8311Write(0x00, 0x80); // power-on, CSM on + + es8311Write(0x01, 0x3F); // all clocks on, MCLK from MCLK pin + if (es8311Read(0x06, &v)) es8311Write(0x06, v & ~0x20); // SCLK not inverted + + // divider row {mclk=4096000, rate=16000}: pre_div=1 mult=1x adc/dac_div=1 fs_mode=0 + // lrck 0x00/0xFF bclk_div=4 osr 0x10/0x10 (es8311_sample_frequency_config) + if (es8311Read(0x02, &v)) es8311Write(0x02, (uint8_t)((v & 0x07) | ((1 - 1) << 5) | (0 << 3))); + es8311Write(0x03, (uint8_t)((0 << 6) | 0x10)); // fs_mode | adc_osr + es8311Write(0x04, 0x10); // dac_osr + es8311Write(0x05, (uint8_t)(((1 - 1) << 4) | (1 - 1))); // adc_div | dac_div + if (es8311Read(0x06, &v)) es8311Write(0x06, (uint8_t)((v & 0xE0) | (4 - 1))); // bclk_div + if (es8311Read(0x07, &v)) es8311Write(0x07, (uint8_t)((v & 0xC0) | 0x00)); // lrck_h + es8311Write(0x08, 0xFF); // lrck_l + + if (es8311Read(0x00, &v)) es8311Write(0x00, v & 0xBF); // slave serial port + es8311Write(0x09, (uint8_t)(3 << 2)); // SDP-in 16-bit + es8311Write(0x0A, (uint8_t)(3 << 2)); // SDP-out 16-bit + + es8311Write(0x0D, 0x01); // power up analog circuitry + es8311Write(0x0E, 0x02); // enable analog PGA / ADC modulator + es8311Write(0x12, 0x00); // power up DAC + es8311Write(0x13, 0x10); // enable output to HP drive + es8311Write(0x1C, 0x6A); // ADC EQ bypass, cancel DC offset + es8311Write(0x37, 0x08); // bypass DAC equalizer + + es8311Write(0x32, (uint8_t)(85 * 256 / 100 - 1)); // DAC volume ~85% (loudness = amplitude) + if (es8311Read(0x31, &v)) es8311Write(0x31, v & ~0x60); // unmute + return true; +} + +static i2s_chan_handle_t s_tx = nullptr; +static SemaphoreHandle_t s_mtx = nullptr; // one player at a time +static bool s_ready = false, s_failed = false; + +bool p4AudioReady() { + if (s_ready) return true; + if (s_failed) return false; + if (!s_mtx) s_mtx = xSemaphoreCreateMutex(); + xSemaphoreTake(s_mtx, portMAX_DELAY); + if (s_ready || s_failed) { xSemaphoreGive(s_mtx); return s_ready; } + + Wire1.begin(P4A_I2C_SDA, P4A_I2C_SCL, 400000); + Wire1.beginTransmission(P4A_ADDR); + bool present = (Wire1.endTransmission() == 0); + if (!present || !es8311Init()) { + printf("[P4AUDIO] ES8311 %s\n", present ? "init failed" : "not found @0x18"); + s_failed = true; + xSemaphoreGive(s_mtx); + return false; + } + + i2s_chan_config_t ch = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER); + ch.auto_clear = true; // underrun sends silence, not a looping last buffer + if (i2s_new_channel(&ch, &s_tx, nullptr) != ESP_OK) { + s_failed = true; + xSemaphoreGive(s_mtx); + return false; + } + i2s_std_config_t std_cfg = { + .clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(P4A_RATE), + .slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_STEREO), + .gpio_cfg = { + .mclk = (gpio_num_t)P4A_MCLK, + .bclk = (gpio_num_t)P4A_BCLK, + .ws = (gpio_num_t)P4A_WS, + .dout = (gpio_num_t)P4A_DOUT, + .din = GPIO_NUM_NC, + .invert_flags = { .mclk_inv = false, .bclk_inv = false, .ws_inv = false }, + }, + }; + std_cfg.clk_cfg.mclk_multiple = I2S_MCLK_MULTIPLE_256; // codec dividers assume 256×fs + if (i2s_channel_init_std_mode(s_tx, &std_cfg) != ESP_OK || + i2s_channel_enable(s_tx) != ESP_OK) { + printf("[P4AUDIO] I2S init failed\n"); + s_failed = true; + xSemaphoreGive(s_mtx); + return false; + } + printf("[P4AUDIO] ES8311 up (16 kHz, MCLK 256x)\n"); + s_ready = true; + xSemaphoreGive(s_mtx); + return true; +} + +void p4AudioTone(int freq_hz, int duration_ms, int amplitude) { + if (amplitude <= 0 || freq_hz <= 0 || duration_ms <= 0) return; + if (!p4AudioReady()) return; + if (amplitude > 30000) amplitude = 30000; + xSemaphoreTake(s_mtx, portMAX_DELAY); + const int total = P4A_RATE * duration_ms / 1000; + const int fade = P4A_RATE * 4 / 1000; // 4 ms in/out fade kills the click + static int16_t buf[256 * 2]; // stereo frames, chunked + int done = 0; + float phase = 0.0f; + const float step = 2.0f * (float)M_PI * (float)freq_hz / (float)P4A_RATE; + while (done < total) { + int n = 0; + for (; n < 256 && done < total; ++n, ++done) { + float env = 1.0f; + if (done < fade) env = (float)done / fade; + else if (done > total - fade) env = (float)(total - done) / fade; + int16_t s = (int16_t)((float)amplitude * env * sinf(phase)); + phase += step; + if (phase > 2.0f * (float)M_PI) phase -= 2.0f * (float)M_PI; + buf[2 * n] = buf[2 * n + 1] = s; + } + size_t wr = 0; + i2s_channel_write(s_tx, buf, (size_t)n * 2 * sizeof(int16_t), &wr, pdMS_TO_TICKS(300)); + } + xSemaphoreGive(s_mtx); +} + +#endif // HAS_TDISPLAY_P4 diff --git a/variants/tdisplay_p4/P4Audio.h b/variants/tdisplay_p4/P4Audio.h new file mode 100644 index 0000000..46d9681 --- /dev/null +++ b/variants/tdisplay_p4/P4Audio.h @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +// T-Display P4 speaker path: ES8311 codec (I²C_2, 0x18) + NS4150B class-D amp (always +// enabled in hardware — LilyGo routes no control pin) fed by I2S MCLK=13 BCLK=12 WS=9 +// DOUT=10. Fixed 16 kHz / 16-bit stereo — plenty for notification chimes, tiny buffers. +// All calls are safe from any task; init is lazy on first use (~ms, I²C + I2S bring-up). +#include + +// Bring the codec + I2S up (idempotent). Returns false if the codec never answered. +bool p4AudioReady(); + +// Blocking sine tone: `amplitude` is the 16-bit peak (0..~30000; the UI uses pct*130). +// No-op (fast) when amplitude <= 0 or the codec is absent. +void p4AudioTone(int freq_hz, int duration_ms, int amplitude); diff --git a/variants/tdisplay_p4/RM69A10Display.cpp b/variants/tdisplay_p4/RM69A10Display.cpp new file mode 100644 index 0000000..4deece1 --- /dev/null +++ b/variants/tdisplay_p4/RM69A10Display.cpp @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#if defined(HAS_TDISPLAY_P4) +#include "RM69A10Display.h" +#include +#include "esp_heap_caps.h" +#include "esp_ldo_regulator.h" +#include "Xl9535.h" // SCREEN_RST lives on the expander (reset_gpio_num = -1) +#include "esp_lcd_mipi_dsi.h" +#include "esp_lcd_panel_io.h" // esp_lcd_panel_io_tx_param (runtime brightness 0x51) +#include "esp_lcd_panel_ops.h" +#include "esp_lcd_rm69a10.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" + +// The DPI panel copies each draw_bitmap band into the internal frame buffer ASYNCHRONOUSLY +// (DMA2D) and fires on_color_trans_done when the copy is complete. writePixelsRGB565 reuses a +// single upscale scratch buffer (and LVGL reuses its draw buffer) on the very next flush, so +// without waiting for that copy the next band overwrites the source mid-transfer — corrupting +// the frame buffer as horizontal black/garbage bands, worst during transitions (many rapid +// flushes). This binary semaphore makes each flush synchronous: draw_bitmap → wait for done. +static SemaphoreHandle_t s_flush_sem = nullptr; +static bool IRAM_ATTR rm69a10TransDone(esp_lcd_panel_handle_t, esp_lcd_dpi_panel_event_data_t*, void*) { + BaseType_t hpw = pdFALSE; + if (s_flush_sem) xSemaphoreGiveFromISR(s_flush_sem, &hpw); + return hpw == pdTRUE; +} + +// RM69A10 DSI timing — LilyGo rm69a10_driver.h (values confirmed via t_display_p4_config.h). +#define RM_W 568 +#define RM_H 1232 +// UI scale: the 568x1232 AMOLED is very high-DPI, so LVGL renders at 1/RM_UI_SCALE and +// writePixelsRGB565 nearest-neighbour-upscales each flush to fill the native panel — the whole UI is +// RM_UI_SCALE x bigger, uniformly, with no per-element tuning. UITask's HAS_TDISPLAY_P4 LVGL res + +// the GT9895 touch scale must match (568/RM_UI_SCALE). +#ifndef RM_UI_SCALE +#define RM_UI_SCALE 2 +#endif +#define RM_DPI_MHZ 60 +#define RM_LANES 2 +#define RM_BITRATE 1000 // Mbps per lane — TODO(device): confirm vs LilyGo RM69A10 define +#define RM_HSYNC 50 +#define RM_HBP 150 +#define RM_HFP 50 +#define RM_VSYNC 40 +#define RM_VBP 120 +#define RM_VFP 80 +// ESP32-P4 MIPI-DSI PHY internal LDO: channel 3 @ 1.83V. This is the tested LilyGo/Meck-P4 value — +// the "standard" 2500 mV left the RM69A10 dark (the panel's DSI rail wants 1.83 V here). +#define DSI_LDO_CHAN 3 +#define DSI_LDO_MV 1830 + +static esp_ldo_channel_handle_t s_ldo = nullptr; + +bool RM69A10Display::begin() { + // 1. Power the DSI PHY via the P4 internal LDO (BEFORE creating the DSI bus). + esp_ldo_channel_config_t ldo_cfg = { .chan_id = DSI_LDO_CHAN, .voltage_mv = DSI_LDO_MV }; + if (esp_ldo_acquire_channel(&ldo_cfg, &s_ldo) != ESP_OK) { + Serial.println("[RM69A10] LDO acquire fail"); return false; + } + delay(100); + + // 1b. Panel hardware reset via the XL9535 (reset_gpio_num = -1 so esp_lcd won't do it). The + // tested Meck-P4 order is HIGH -> LOW -> HIGH with 200 ms settling, done AFTER the LDO and BEFORE + // the DSI bus. (An earlier attempt reset after the bus / with short delays kept the panel dark.) + xl9535.write(Xl9535::IO_SCREEN_RST, true); delay(200); + xl9535.write(Xl9535::IO_SCREEN_RST, false); delay(200); + xl9535.write(Xl9535::IO_SCREEN_RST, true); delay(200); + + // 2. DSI bus (initialises the DSI PHY). + esp_lcd_dsi_bus_handle_t dsi_bus = nullptr; + esp_lcd_dsi_bus_config_t bus_cfg = { + .bus_id = 0, .num_data_lanes = RM_LANES, + .phy_clk_src = MIPI_DSI_PHY_CLK_SRC_DEFAULT, .lane_bit_rate_mbps = RM_BITRATE, + }; + if (esp_lcd_new_dsi_bus(&bus_cfg, &dsi_bus) != ESP_OK) { Serial.println("[RM69A10] dsi_bus fail"); return false; } + + // 3. DBI IO (LCD command channel). + esp_lcd_panel_io_handle_t dbi_io = nullptr; + esp_lcd_dbi_io_config_t dbi_cfg = { .virtual_channel = 0, .lcd_cmd_bits = 8, .lcd_param_bits = 8 }; + if (esp_lcd_new_panel_io_dbi(dsi_bus, &dbi_cfg, &dbi_io) != ESP_OK) { Serial.println("[RM69A10] dbi_io fail"); return false; } + + // 4. DPI (video) config. + esp_lcd_dpi_panel_config_t dpi_cfg = { + .virtual_channel = 0, + .dpi_clk_src = MIPI_DSI_DPI_CLK_SRC_DEFAULT, + .dpi_clock_freq_mhz = RM_DPI_MHZ, + .pixel_format = LCD_COLOR_PIXEL_FORMAT_RGB565, + .num_fbs = 1, + .video_timing = { + .h_size = RM_W, .v_size = RM_H, + .hsync_pulse_width = RM_HSYNC, .hsync_back_porch = RM_HBP, .hsync_front_porch = RM_HFP, + .vsync_pulse_width = RM_VSYNC, .vsync_back_porch = RM_VBP, .vsync_front_porch = RM_VFP, + }, + .flags = { .use_dma2d = true }, + }; + + // 5. RM69A10 vendor panel (reset via the XL9535, done in powerOnSequence -> reset_gpio_num=-1). + rm69a10_vendor_config_t vendor = { + .init_cmds = nullptr, .init_cmds_size = 0, // use the driver's vendor_specific_init_default + .mipi_config = { .dsi_bus = dsi_bus, .dpi_config = &dpi_cfg, .lane_num = RM_LANES }, + }; + esp_lcd_panel_dev_config_t dev = { + .reset_gpio_num = -1, + .rgb_ele_order = LCD_RGB_ELEMENT_ORDER_RGB, + .bits_per_pixel = 16, + .vendor_config = &vendor, + }; + if (esp_lcd_new_panel_rm69a10(dbi_io, &dev, &_panel) != ESP_OK) { Serial.println("[RM69A10] new_panel fail"); return false; } + + esp_lcd_panel_reset(_panel); + if (esp_lcd_panel_init(_panel) != ESP_OK) { Serial.println("[RM69A10] panel_init fail"); return false; } + esp_lcd_panel_disp_on_off(_panel, true); + + // Brightness: the init array ships 0x51=0 (placeholder), and RM69A10 brightness is driven at + // runtime — Meck-P4 ramps set_rm69a10_brightness() after display-on. Keep the DBI IO handle so + // setBrightness() can drive DCS 0x51 live (CC slider); start at max so the AMOLED actually emits. + _dbi_io = dbi_io; + setBrightness(255); + _on = true; + + // Signal each draw_bitmap's frame-buffer copy completion so writePixelsRGB565 can wait for it + // (single scratch/draw buffer reuse — see the note at s_flush_sem). + s_flush_sem = xSemaphoreCreateBinary(); + esp_lcd_dpi_panel_event_callbacks_t cbs = {}; + cbs.on_color_trans_done = rm69a10TransDone; + esp_lcd_dpi_panel_register_event_callbacks(_panel, &cbs, nullptr); + + // Clear the panel framebuffer to black once, so nothing stale shows before LVGL's first flush. + { + const int SH = 32; + uint16_t* strip = (uint16_t*)heap_caps_calloc((size_t)RM_W * SH, 2, MALLOC_CAP_SPIRAM); + if (strip) { + for (int y0 = 0; y0 < RM_H; y0 += SH) { + int hh = (y0 + SH <= RM_H) ? SH : (RM_H - y0); + esp_lcd_panel_draw_bitmap(_panel, 0, y0, RM_W, y0 + hh, strip); + if (s_flush_sem) xSemaphoreTake(s_flush_sem, pdMS_TO_TICKS(100)); // let the copy finish before reusing/freeing strip + } + free(strip); + } + } + + Serial.printf("[RM69A10] up %dx%d\n", RM_W, RM_H); + return true; +} + +void RM69A10Display::writePixelsRGB565(int x, int y, int w, int h, const uint16_t* pixels) { + if (!_panel || !pixels || w <= 0 || h <= 0) return; +#if RM_UI_SCALE > 1 + // Nearest-neighbour upscale the (half-res) LVGL band to the native panel: each source pixel becomes + // an RM_UI_SCALE x RM_UI_SCALE block. One expanded band -> one draw_bitmap (exclusive end coords). + const int S = RM_UI_SCALE, dw = w * S, dh = h * S; + static uint16_t* s_up = nullptr; static size_t s_up_px = 0; + size_t need = (size_t)dw * dh; + if (need > s_up_px) { + if (s_up) heap_caps_free(s_up); + s_up = (uint16_t*)heap_caps_malloc(need * sizeof(uint16_t), MALLOC_CAP_SPIRAM); + s_up_px = s_up ? need : 0; + } + if (s_up) { + for (int row = 0; row < h; row++) { + const uint16_t* src = pixels + (size_t)row * w; + uint16_t* d0 = s_up + (size_t)(row * S) * dw; // first of S output rows + for (int i = 0; i < w; i++) { uint16_t c = src[i]; uint16_t* p = d0 + i * S; for (int sx = 0; sx < S; sx++) p[sx] = c; } + for (int sy = 1; sy < S; sy++) memcpy(d0 + (size_t)sy * dw, d0, (size_t)dw * sizeof(uint16_t)); + } + esp_lcd_panel_draw_bitmap(_panel, x * S, y * S, x * S + dw, y * S + dh, s_up); + if (s_flush_sem) xSemaphoreTake(s_flush_sem, pdMS_TO_TICKS(100)); // wait for the FB copy before the next flush reuses s_up + return; + } + // fall through to an unscaled draw if the scratch alloc failed (tiny UI, but visible) +#endif + // exclusive end coords (esp_lcd_panel_draw_bitmap contract). + esp_lcd_panel_draw_bitmap(_panel, x, y, x + w, y + h, pixels); + if (s_flush_sem) xSemaphoreTake(s_flush_sem, pdMS_TO_TICKS(100)); // wait for the FB copy before LVGL reuses this buffer +} + +void RM69A10Display::setBrightness(uint8_t b) { + if (!_dbi_io) return; + esp_lcd_panel_io_tx_param(_dbi_io, 0x51, &b, 1); // DCS SET_DISPLAY_BRIGHTNESS +} + +void RM69A10Display::turnOn() { if (_panel) esp_lcd_panel_disp_on_off(_panel, true); _on = true; } +void RM69A10Display::turnOff() { if (_panel) esp_lcd_panel_disp_on_off(_panel, false); _on = false; } + +#endif // HAS_TDISPLAY_P4 diff --git a/variants/tdisplay_p4/RM69A10Display.h b/variants/tdisplay_p4/RM69A10Display.h new file mode 100644 index 0000000..69140db --- /dev/null +++ b/variants/tdisplay_p4/RM69A10Display.h @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +// RM69A10 AMOLED (MIPI-DSI, 568x1232 portrait) — wadamesh DisplayDriver for the LilyGo T-Display P4. +// begin() runs the full P4 DSI bring-up: internal LDO (DSI PHY power) -> DSI bus -> DBI IO -> DPI +// panel -> RM69A10 vendor init (esp_lcd_rm69a10.*, ported from LilyGo) -> reset/init/on. LVGL's +// lvglFlush() -> writePixelsRGB565() blits the dirty area via esp_lcd_panel_draw_bitmap. Mirrors +// TanmatsuDisplay's contract. The panel RESET line is on the XL9535 (released in powerOnSequence), +// so the vendor driver's reset_gpio_num = -1. Timing from LilyGo rm69a10_driver.h (TDISPLAY_P4_PORT.md). +#include +#include +#include "esp_lcd_types.h" + +class RM69A10Display : public DisplayDriver { + bool _on = true; + esp_lcd_panel_handle_t _panel = nullptr; + esp_lcd_panel_io_handle_t _dbi_io = nullptr; // DCS command channel (runtime brightness 0x51) +public: + RM69A10Display() : DisplayDriver(568, 1232) {} + + bool begin(); // full DSI bring-up; returns false on failure (logs the stage) + + // LVGL flush hot path — exclusive end coords (see TanmatsuDisplay note). + void writePixelsRGB565(int x, int y, int w, int h, const uint16_t* pixels); + void setDisplayRotation(int rot) { (void)rot; } // TODO(device): panel rotation / SW-rotate + void startFrame() {} + void endFrame() {} + + // --- DisplayDriver contract (minimal; LVGL does the real drawing) --- + bool isOn() override { return _on; } + void turnOn() override; + void turnOff() override; + void setBrightness(uint8_t b); // RM69A10 cmd 0x51 + void clear() override {} + void startFrame(Color) override {} + void setTextSize(int) override {} + void setColor(Color) override {} + void setCursor(int, int) override {} + void print(const char*) override {} + void fillRect(int, int, int, int) override {} + void drawRect(int, int, int, int) override {} + void drawXbm(int, int, const uint8_t*, int, int) override {} + uint16_t getTextWidth(const char*) override { return 0; } +}; diff --git a/variants/tdisplay_p4/TDISPLAY_P4_PORT.md b/variants/tdisplay_p4/TDISPLAY_P4_PORT.md new file mode 100644 index 0000000..d9fa52d --- /dev/null +++ b/variants/tdisplay_p4/TDISPLAY_P4_PORT.md @@ -0,0 +1,112 @@ +# LilyGo T-Display P4 (AMOLED) — wadamesh port tracker + +A phone-class ESP32-P4 + ESP32-C6 handheld with a real SX1262. Essentially a **touchscreen +Tanmatsu**, so we reuse the Tanmatsu P4/IDF/esp-hosted foundation (approach A: standalone IDF app +in `tdisplay_p4/`, sharing the Tanmatsu's ESP-IDF 5.5.1 via symlink). Full pinout + rationale: +memory `tdisplay-p4-port`. Device confirmed: ESP32-P4, 16 MB flash, MAC 30:ed:a0:e1:c2:a7, +flashes over the right-side USB (USB-Serial-JTAG). LoRa region = EU 868. + +## Pinout (from LilyGo `components/private_library/t_display_p4_config.h`) +- **SX1262** SPI SCLK=2/MOSI=3/MISO=4, CS=24, BUSY=6 (raw GPIO); **RST=XL9535-IO16, DIO1=XL9535-IO17** +- **XL9535 I2C expander** on I2C_1 (SDA=7/SCL=8, INT=GPIO5): power rails, C6-EN(IO14), SD-EN(IO15), + SCREEN_RST(IO2), TOUCH_RST(IO3)/INT(IO4), SX1262 RST(IO16)/DIO1(IO17), RF-switch VCTL(IO1), GPS-WAKE +- **Display** RM69A10 AMOLED MIPI-DSI 568×1232 (portrait); reset via XL9535 +- **Touch** HI8561 (or GT9895) on I2C_1 (7/8); RST/INT via XL9535 +- **C6** esp-hosted over SDIO_2 (CLK18/CMD19/D0-3=14-17); **SD** SD_MMC on SDIO_1 slot 0 (43/44/39-42) +- **GPS** L76K UART: ESP RX=22 / TX=23. **RTC** PCF8563, **gauge** BQ27220 on I2C_1 + +## Scaffold state (DONE) +- `tdisplay_p4/` created; `esp-idf`,`esp-idf-tools` + components `meshcore/ardlibs/lvgl/esp_hosted` + + `sdkconfigs/{general,wadamesh}` symlinked to `../tanmatsu/` +- `build.sh` (DEVICE=tdisplay_p4, target esp32p4, standalone), `CMakeLists.txt` +- `main/CMakeLists.txt` — the full board WADA_DEFS (raw SX1262 pins, RM69A10, HI8561, XL9535 IOs) +- `components/wadamesh_app/CMakeLists.txt` — builds `src/` + `variants/tdisplay_p4/`, no badge-bsp +- `sdkconfigs/tdisplay_p4` (P4/PSRAM/DSI/esp-hosted-SDIO_2), `partition_tables/tdisplay_p4_16M.csv` + +## Remaining Phase 1 — bring-up (empirical, needs the device; do in this order) +1. ✅ DONE `variants/tdisplay_p4/Xl9535.{h,cpp}` — I2C GPIO-expander driver + `powerOnSequence()` (rails → + C6/SD enable → release screen/touch resets). Uses Arduino Wire on 7/8. Power-seq delays TUNE on-device. +2. `RM69A10Display.{h,cpp}` — MIPI-DSI panel. Recipe (from LilyGo t_display_p4_driver.cpp + config): + - `esp_ldo_acquire_channel(chan_id=3, voltage_mv=2500)` powers the P4 DSI PHY (do BEFORE the DSI bus). + - `esp_lcd_new_dsi_bus` (bus_id 0, **lanes=2**, lane_bit_rate ~**1000 Mbps** [confirm]) → `esp_lcd_new_panel_io_dbi` + (8/8 cmd/param) → `esp_lcd_dpi_panel_config_t`: **dpi_clk=60MHz, RGB565(16bpp), 568×1232**, + **HSYNC=50 HBP=150 HFP=50 / VSYNC=40 VBP=120 VFP=80**, num_fbs=1, use_dma2d=true. + - RM69A10 vendor init: port LilyGo `rm69a10_driver.cpp` as an `esp_lcd_new_panel_rm69a10(io,&dev,&panel)` + vendor driver (reset_gpio=-1 — reset is via XL9535 IO2, done in powerOnSequence). Then reset/init/disp_on. + - Subclass `DisplayDriver(568,1232)`; `writePixelsRGB565(x,y,w,h,px)` → `esp_lcd_panel_draw_bitmap(panel, + x,y,x+w,y+h,px)` (EXCLUSIVE end coords — see TanmatsuDisplay). This is UITask's lvglFlush target. +2b. ✅ DONE `esp_lcd_rm69a10.{cpp,h}` (LilyGo's vendor panel driver, ported verbatim) + + `RM69A10Display.{h,cpp}` (the DSI bring-up recipe above → DisplayDriver + writePixelsRGB565). +3. ✅ DONE `target.{h,cpp}` — `TDisplayP4Board : ESP32Board` (RF switch via XL9535 in onBefore/AfterTransmit), + raw SX1262 Module (RST/DIO1=RADIOLIB_NC), `radio_init()` = `xl9535.sx1262Reset()` + `radio.std_init(&spi)`, + RM69A10 `display`, PCF8563 RTC on Wire. `tdisplay_p4_compat.h` (adcAttachPin shim) copied. +4. ✅ DONE `src/ui-touch/device_caps.h` — `HAS_TDISPLAY_P4` block (CAP_TOUCH/SD/GPS/FILESYSTEM/LARGE=1; + web browser via the 32MB/#else path). + +### Build integration DONE (bring-up commit — decisions worth remembering) +- **RadioLib is a P4-local IDF component** (`tdisplay_p4/components/RadioLib/`, v7.7.1 copied from the + S3 libdep). Its CMake keeps the Arduino HAL and forces `RADIOLIB_BUILD_ARDUINO` + `RADIOLIB_GODMODE=1` + + `RADIOLIB_STATIC_ONLY=1` **PUBLIC** (matches the S3 platformio flags; GODMODE is what lets the core's + CustomSX1262Wrapper/SX126xReset reach SX126x private members). NOT added to the shared `ardlibs` — the + Tanmatsu stays RadioLib-free. +- **P4 has its OWN meshcore component** (no longer a symlink to the Tanmatsu's): `core/` is symlinked to + share the vendored source, but the P4 CMakeLists does NOT exclude `helpers/radiolib/` (compiles + `RadioLibWrappers.cpp` — the buffered-RX `radioAcquire/rxQueueEnable/rxQueueSuspend` that MyMesh calls + unconditionally) and `REQUIRES RadioLib`. The Tanmatsu's vendored core was pre-beta_34 and missing those; + synced `RadioLibWrappers.{h,cpp}` from the S3 core (only 2 files differed; safe — Tanmatsu uses the bridge). +- **`CAP_SD=0`** for the P4 (device_caps.h). CAP_SD means "Arduino `SD` on shared SPI" (T-Deck/M9/R8); the + P4's SD_MMC is the DataStore backend + exposed via CAP_FILESYSTEM like the Tanmatsu's FFat. This compiles + out the whole Arduino-`SD`/`CARD_NONE`/`fmSdTryMount` UI cluster. SD_MMC file-browser = later, via the FS path. +- **P4 folded into the Tanmatsu's P4/IDF special-cases**: MQTT no-op bridge (no PubSubClient over esp-hosted), + `soc/rtc_cntl_reg.h` + RTC force-download guarded off (S3-only reg), `feedLoopWDT` no-op shim. Msg-LED + settings row re-gated to HAS_TANMATSU (it lived under CAP_LARGE_SCREEN; P4 is the 1st other large board). +- **HI8561 touch (`Hi8561Touch.cpp`) = display-first**: full gesture state machine (shared w/ CHSC6x/FT5x06) + but `hi8561ReadPoint()` returns no-touch until the report format is verified on-device (documented skeleton + inside). Boots + renders; taps come after pixels are confirmed (avoids phantom-press UI lockups). + +### 🎉 FIRST BOOT — 2026-07-14 (image built + flashed; app runs) +Build links (app 2.9 MB, 27% free in the 4 MB slot) and flashes over the right-side USB +(`./build.sh flash -p `; MAC 30:ed:a0:e1:c2:a7 confirms the board vs the Tanmatsu). `app_main` +runs: 32 MB PSRAM found, ESP-IDF 5.5.1. Two HW-integration issues seen on the serial + their fixes: +- **MIPI-DSI underrun** (`lcd.dsi.dpi: can't fetch data from external memory fast enough`): PSRAM was + running at **20 MHz** — HEX-PSRAM @ 200 MHz on the P4 needs `CONFIG_IDF_EXPERIMENTAL_FEATURES=y` + alongside `SPIRAM_SPEED_200M` (without it Kconfig silently drops to 20 MHz, starving the DPI). FIXED + in the sdkconfig fragment (matches the Tanmatsu). ⟶ re-flash to confirm pixels. +- **esp-hosted C6 reboot loop** (`transport: Init event not received within timeout, Resetting myself` + → full reboot ~10 s): the C6 isn't answering over SDIO yet. Added + `CONFIG_ESP_HOSTED_HOST_RESTART_NO_COMMUNICATION_WITH_SLAVE=n` so the host stays up (UI + LoRa work; + Wi-Fi/BLE stay down) instead of rebooting. The C6 SDIO pins (CLK18/CMD19/D0-3=14-17) + XL9535 power/ + enable/reset timing still need on-device verification — that's the next hardware task after pixels. + +### 🖥️ DISPLAY WORKING — 2026-07-14 (the RM69A10 AMOLED emits + the wadamesh UI renders) +The panel came up only after matching the **pelgraine/Meck-P4** `screen_lvgl` reference EXACTLY (my +config matched LilyGo's config.h — pin map, 568×1232, DSI timing, init array — but the *bring-up +sequence* was wrong). The non-obvious, must-keep recipe (all in Xl9535.cpp + RM69A10Display.cpp): +- **DSI-PHY LDO = channel 3 @ 1830 mV** (NOT the "standard" 2500 mV — 2500 left it dark). +- **Power rails are power-CYCLED, not just set high**: VCCA→LOW; 5V HIGH/LOW/HIGH; 3V3 LOW/HIGH/LOW; + 200 ms between each; final states VCCA=LOW, 5V=HIGH, 3V3=LOW. +- **SCREEN_RST = HIGH→LOW→HIGH, 200 ms each, AFTER the LDO but BEFORE the DSI bus** (reset_gpio_num=-1; + driven via the XL9535 inside RM69A10Display::begin, not powerOnSequence). +- **Brightness is a runtime write**: the init array's `0x51` is a placeholder (=0); send DCS `0x51=0xFF` + via `esp_lcd_panel_io_tx_param` after `disp_on` (Meck ramps set_rm69a10_brightness()). +- LVGL flush → writePixelsRGB565 → esp_lcd_panel_draw_bitmap works (UI text renders clean). +Reference (working): github.com/pelgraine/Meck-P4 `main/examples/screen_lvgl/main.cpp`. LilyGo source: +github.com/Xinyuan-LilyGO/T-Display-P4 `components/private_library/{t_display_p4_config.h, +rm69a10_driver.cpp,t_display_p4_driver.cpp}`. +- ⚠️ OPEN: the tall 568×1232 portrait UI needs layout work — the wizard (designed for other aspect + ratios) fills only the top; the rest showed the stale framebuffer. Also re-verify LoRa still works + after the rail-state change (3V3 ends LOW). + +### STILL TODO (needs the device — write→build→flash→iterate) +5. `Hi8561Touch.{h,cpp}` — I2C touch → the touch-UI indev (mirror RakTapV2Touch); RST/INT via XL9535. + Can stub first (display-only boot), add after pixels work. Pull LilyGo `hi8561_touch.cpp` for the read proto. +6. `tdisplay_p4/main/main.cpp` — standalone `app_main` (model on tanmatsu/main/main.cpp, strip AppFS/badge-bsp): + Serial.begin → `xl9535.begin()` + `powerOnSequence()` → `display.begin()` → esp-hosted C6 connect → + `radio_init()` → placement-new the_mesh in PSRAM → storage (SD_MMC/FFat) → `ui_task` + companion → LVGL loop. +7. UITask.cpp threading: the display extern (line ~139: add `#elif defined(HAS_TDISPLAY_P4) extern RM69A10Display display;`) + + the SD/`fmSdTryMount`/touch guards get `|| defined(HAS_TDISPLAY_P4)` (mirror the R8 pass); touch indev wiring. +8. `./build.sh build` → fix (P4/IDF-specific) → flash → **first-boot-on-screen**. Then P2 radio, P3 Wi-Fi/SD/GPS, + P4 the 568×1232 portrait UI layout. + +## Build +`cd tdisplay_p4 && ./build.sh build` · flash: `./build.sh flash -p /dev/cu.usbmodem` diff --git a/variants/tdisplay_p4/Xl9535.cpp b/variants/tdisplay_p4/Xl9535.cpp new file mode 100644 index 0000000..b7bbc39 --- /dev/null +++ b/variants/tdisplay_p4/Xl9535.cpp @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#if defined(HAS_TDISPLAY_P4) +#include "Xl9535.h" +#include +#include + +// 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); + 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 + write(IO_RF_SWITCH, false); // RX path + 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 diff --git a/variants/tdisplay_p4/Xl9535.h b/variants/tdisplay_p4/Xl9535.h new file mode 100644 index 0000000..2ee13c3 --- /dev/null +++ b/variants/tdisplay_p4/Xl9535.h @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +// XL9535 — 16-bit I2C GPIO expander (TCA9535/PCA9535-class) on the LilyGo T-Display P4. +// It is the board's linchpin: it gates the power rails and the enable/reset lines for the +// ESP32-C6, the SD card, the AMOLED, the touch panel, the SX1262 (RESET + DIO1) and the +// RF switch. NOTHING comes up until begin() runs, so app_main must call it first. +// +// LilyGo names the pins IO0..IO7 (port 0) and IO10..IO17 (port 1); we keep that numbering. +// Register map (standard 9535): 0=in0 1=in1 2=out0 3=out1 6=cfg0 7=cfg1 (cfg bit: 1=input). +// +// Runs on the Arduino `Wire` bus already used for the board I2C (SDA=7/SCL=8), so the RTC, +// gauge and touch share it. Pins/roles from LilyGo t_display_p4_config.h (see TDISPLAY_P4_PORT.md). + +#include + +class Xl9535 { +public: + // LilyGo IO indices (0-7 = port0 bit0-7, 10-17 = port1 bit0-7). + enum Io : uint8_t { + // port 0 + IO_3V3_EN = 0, // 3.3V rail enable + IO_RF_SWITCH = 1, // SKY13453 VCTL (LoRa TX/RX path) + IO_SCREEN_RST = 2, + IO_TOUCH_RST = 3, + IO_TOUCH_INT = 4, // input + IO_ETHERNET_RST = 5, + IO_5V_EN = 6, + IO_EXT_SENSOR_INT= 7, // input + // port 1 + IO_VCCA_EN = 10, + IO_GPS_WAKE = 11, + IO_RTC_INT = 12, // input + IO_C6_WAKE = 13, + IO_C6_EN = 14, + IO_SD_EN = 15, + IO_SX1262_RST = 16, + IO_SX1262_DIO1 = 17, // input (the LoRa IRQ) + }; + + // addr defaults to 0x20 (A0-A2 low). sda/scl default to the board I2C_1 pins. + bool begin(uint8_t sda = 7, uint8_t scl = 8, uint8_t addr = 0x20); + bool ok() const { return _ok; } + + // Per-pin control (io = an Io value). dir: true=input. + void setDir(uint8_t io, bool input); + void write(uint8_t io, bool high); + bool read(uint8_t io); + + // Board power-on sequence: enable rails, bring up the C6 + SD, release the screen/touch + // resets, park the SX1262 RESET high. Conservative delays — TUNE against LilyGo on-device. + void powerOnSequence(); + + // Convenience for the SX1262 glue (RESET + DIO1 live here). + void sx1262Reset(); // active-low pulse on IO_SX1262_RST + bool sx1262Dio1() { return read(IO_SX1262_DIO1); } + void rfSwitchTx(bool tx) { write(IO_RF_SWITCH, tx); } // polarity TBD on-device + +private: + uint8_t _addr = 0x20; + bool _ok = false; + uint8_t _out[2] = {0xFF, 0xFF}; // shadow of output regs (port0, port1) + uint8_t _cfg[2] = {0xFF, 0xFF}; // shadow of config regs (1=input) + void writeReg(uint8_t reg, uint8_t val); + uint8_t readReg(uint8_t reg); + static void split(uint8_t io, uint8_t& port, uint8_t& bit) { port = io < 8 ? 0 : 1; bit = io < 8 ? io : io - 10; } +}; + +extern Xl9535 xl9535; // the board-global instance (defined in target.cpp) diff --git a/variants/tdisplay_p4/esp_lcd_rm69a10.cpp b/variants/tdisplay_p4/esp_lcd_rm69a10.cpp new file mode 100644 index 0000000..666234a --- /dev/null +++ b/variants/tdisplay_p4/esp_lcd_rm69a10.cpp @@ -0,0 +1,377 @@ +/* + * SPDX-FileCopyrightText: 2023-2024 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "soc/soc_caps.h" + +#if SOC_MIPI_DSI_SUPPORTED +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "driver/gpio.h" +#include "esp_check.h" +#include "esp_lcd_panel_commands.h" +#include "esp_lcd_panel_interface.h" +#include "esp_lcd_panel_io.h" +#include "esp_lcd_mipi_dsi.h" +#include "esp_lcd_panel_vendor.h" +#include "esp_log.h" +#include "esp_lcd_rm69a10.h" + +#define RM69A10_PAD_CONTROL (0xB2) +#define RM69A10_DSI_2_LANE (0x10) +#define RM69A10_DSI_4_LANE (0x00) + +#define RM69A10_CMD_SHLR_BIT (1ULL << 0) +#define RM69A10_CMD_UPDN_BIT (1ULL << 1) +#define RM69A10_MDCTL_VALUE_DEFAULT (0x01) + +static const rm69a10_lcd_init_cmd_t vendor_specific_init_default[] = { + // {cmd, { data }, data_size, delay_ms} + /**** CMD_Page 3 ****/ + {0xFE, (uint8_t[]){0xFD}, 1, 0}, + {0x80, (uint8_t[]){0xFC}, 1, 0}, + {0xFE, (uint8_t[]){0x00}, 1, 0}, + {0x2A, (uint8_t[]){0x00, 0x00, 0x02, 0x37}, 4, 0}, + {0x2B, (uint8_t[]){0x00, 0x00, 0x04, 0xCF}, 4, 0}, + {0x31, (uint8_t[]){0x00, 0x03, 0x02, 0x34}, 4, 0}, + {0x30, (uint8_t[]){0x00, 0x00, 0x04, 0xCF}, 4, 0}, + {0x12, (uint8_t[]){0x00}, 1, 0}, + {0x35, (uint8_t[]){0x00}, 1, 0}, + // wadamesh: hardcode 16bit/pixel (0x75) — the RM69A10Display DPI is RGB565. LilyGo gated this behind + // CONFIG_SCREEN_PIXEL_FORMAT_RGB565/RGB888, neither of which is defined in our build, so the original + // sent NO pixel-format command and the panel fell back to its default format. + {0x3A, (uint8_t[]){0x75}, 1, 0}, // interface pixel format 16bit/pixel (RGB565) + // wadamesh: LilyGo's reference shipped brightness 0x00 (设置屏幕亮度为0 = "set brightness to 0") — a BLACK + // AMOLED even with display-on. Set it to max so the UI is actually visible. + {0x51, (uint8_t[]){0xFF}, 1, 0}, // display brightness = max (WRITE_DISPLAY_BRIGHTNESS) + {0x11, (uint8_t[]){0x00}, 0, 120}, + {0x29, (uint8_t[]){0x00}, 0, 0}, + //============ Gamma END=========== +}; + +typedef struct +{ + esp_lcd_panel_io_handle_t io; + int reset_gpio_num; + uint8_t madctl_val; // save current value of LCD_CMD_MADCTL register + const rm69a10_lcd_init_cmd_t *init_cmds; + uint16_t init_cmds_size; + uint8_t lane_num; + struct + { + unsigned int reset_level : 1; + } flags; + // To save the original functions of MIPI DPI panel + esp_err_t (*del)(esp_lcd_panel_t *panel); + esp_err_t (*init)(esp_lcd_panel_t *panel); +} rm69a10_panel_t; + +static const char *TAG = "rm69a10"; + +static esp_err_t panel_rm69a10_send_init_cmds(rm69a10_panel_t *rm69a10); + +static esp_err_t panel_rm69a10_del(esp_lcd_panel_t *panel); +static esp_err_t panel_rm69a10_init(esp_lcd_panel_t *panel); +static esp_err_t panel_rm69a10_reset(esp_lcd_panel_t *panel); +static esp_err_t panel_rm69a10_mirror(esp_lcd_panel_t *panel, bool mirror_x, bool mirror_y); +static esp_err_t panel_rm69a10_invert_color(esp_lcd_panel_t *panel, bool invert_color_data); +static esp_err_t panel_rm69a10_sleep(esp_lcd_panel_t *panel, bool sleep); +static esp_err_t panel_rm69a10_on_off(esp_lcd_panel_t *panel, bool on_off); + +esp_err_t esp_lcd_new_panel_rm69a10(const esp_lcd_panel_io_handle_t io, const esp_lcd_panel_dev_config_t *panel_dev_config, + esp_lcd_panel_handle_t *ret_panel) +{ + // ESP_LOGI(TAG, "version: %d.%d.%d", ESP_LCD_RM69A10_VER_MAJOR, ESP_LCD_RM69A10_VER_MINOR, + // ESP_LCD_RM69A10_VER_PATCH); + ESP_RETURN_ON_FALSE(io && panel_dev_config && ret_panel, ESP_ERR_INVALID_ARG, TAG, "invalid arguments"); + rm69a10_vendor_config_t *vendor_config = (rm69a10_vendor_config_t *)panel_dev_config->vendor_config; + ESP_RETURN_ON_FALSE(vendor_config && vendor_config->mipi_config.dpi_config && vendor_config->mipi_config.dsi_bus, ESP_ERR_INVALID_ARG, TAG, + "invalid vendor config"); + + esp_err_t ret = ESP_OK; + rm69a10_panel_t *rm69a10 = (rm69a10_panel_t *)calloc(1, sizeof(rm69a10_panel_t)); + ESP_RETURN_ON_FALSE(rm69a10, ESP_ERR_NO_MEM, TAG, "no mem for rm69a10 panel"); + + if (panel_dev_config->reset_gpio_num >= 0) + { + gpio_config_t io_conf = { + .pin_bit_mask = 1ULL << panel_dev_config->reset_gpio_num, + .mode = GPIO_MODE_OUTPUT, + }; + ESP_GOTO_ON_ERROR(gpio_config(&io_conf), err, TAG, "configure GPIO for RST line failed"); + } + + rm69a10->io = io; + rm69a10->init_cmds = vendor_config->init_cmds; + rm69a10->init_cmds_size = vendor_config->init_cmds_size; + rm69a10->lane_num = vendor_config->mipi_config.lane_num; + rm69a10->reset_gpio_num = panel_dev_config->reset_gpio_num; + rm69a10->flags.reset_level = panel_dev_config->flags.reset_active_high; + rm69a10->madctl_val = RM69A10_MDCTL_VALUE_DEFAULT; + + // Create MIPI DPI panel + ESP_GOTO_ON_ERROR(esp_lcd_new_panel_dpi(vendor_config->mipi_config.dsi_bus, vendor_config->mipi_config.dpi_config, ret_panel), err, TAG, + "create MIPI DPI panel failed"); + ESP_LOGD(TAG, "new MIPI DPI panel @%p", *ret_panel); + + // Save the original functions of MIPI DPI panel + rm69a10->del = (*ret_panel)->del; + rm69a10->init = (*ret_panel)->init; + // Overwrite the functions of MIPI DPI panel + (*ret_panel)->del = panel_rm69a10_del; + (*ret_panel)->init = panel_rm69a10_init; + (*ret_panel)->reset = panel_rm69a10_reset; + (*ret_panel)->mirror = panel_rm69a10_mirror; + (*ret_panel)->invert_color = panel_rm69a10_invert_color; + (*ret_panel)->disp_sleep = panel_rm69a10_sleep; + (*ret_panel)->disp_on_off = panel_rm69a10_on_off; + (*ret_panel)->user_data = rm69a10; + ESP_LOGD(TAG, "new rm69a10 panel @%p", rm69a10); + + return ESP_OK; + +err: + if (rm69a10) + { + if (panel_dev_config->reset_gpio_num >= 0) + { + gpio_reset_pin((gpio_num_t)panel_dev_config->reset_gpio_num); + } + free(rm69a10); + } + return ret; +} + +static esp_err_t panel_rm69a10_send_init_cmds(rm69a10_panel_t *rm69a10) +{ + esp_lcd_panel_io_handle_t io = rm69a10->io; + const rm69a10_lcd_init_cmd_t *init_cmds = NULL; + uint16_t init_cmds_size = 0; + // uint8_t lane_command = RM69A10_DSI_2_LANE; + // bool is_cmd_overwritten = false; + + // switch (rm69a10->lane_num) + // { + // case 0: + // case 2: + // lane_command = RM69A10_DSI_2_LANE; + // break; + // case 4: + // lane_command = RM69A10_DSI_4_LANE; + // break; + // default: + // ESP_LOGE(TAG, "Invalid lane number %d", rm69a10->lane_num); + // return ESP_ERR_INVALID_ARG; + // } + // ESP_RETURN_ON_ERROR(esp_lcd_panel_io_tx_param(io, RM69A10_PAD_CONTROL, (uint8_t[]){ + // lane_command, + // }, + // 1), + // TAG, "send command failed"); + + // vendor specific initialization, it can be different between manufacturers + // should consult the LCD supplier for initialization sequence code + // if (rm69a10->init_cmds) + // { + // init_cmds = rm69a10->init_cmds; + // init_cmds_size = rm69a10->init_cmds_size; + // } + // else + // { + // init_cmds = vendor_specific_init_default; + // init_cmds_size = sizeof(vendor_specific_init_default) / sizeof(rm69a10_lcd_init_cmd_t); + // } + + init_cmds = vendor_specific_init_default; + init_cmds_size = sizeof(vendor_specific_init_default) / sizeof(rm69a10_lcd_init_cmd_t); + + for (int i = 0; i < init_cmds_size; i++) + { + // // Check if the command has been used or conflicts with the internal + // if (init_cmds[i].data_bytes > 0) + // { + // switch (init_cmds[i].cmd) + // { + // case LCD_CMD_MADCTL: + // is_cmd_overwritten = true; + // rm69a10->madctl_val = ((uint8_t *)init_cmds[i].data)[0]; + // break; + // default: + // is_cmd_overwritten = false; + // break; + // } + + // if (is_cmd_overwritten) + // { + // is_cmd_overwritten = false; + // ESP_LOGW(TAG, "The %02Xh command has been used and will be overwritten by external initialization sequence", + // init_cmds[i].cmd); + // } + // } + + // Send command + ESP_RETURN_ON_ERROR(esp_lcd_panel_io_tx_param(io, init_cmds[i].cmd, init_cmds[i].data, init_cmds[i].data_bytes), TAG, "send command failed"); + vTaskDelay(pdMS_TO_TICKS(init_cmds[i].delay_ms)); + // printf("Ciallo\n"); + } + + ESP_LOGD(TAG, "send init commands success"); + + return ESP_OK; +} + +static esp_err_t panel_rm69a10_del(esp_lcd_panel_t *panel) +{ + rm69a10_panel_t *rm69a10 = (rm69a10_panel_t *)panel->user_data; + + if (rm69a10->reset_gpio_num >= 0) + { + gpio_reset_pin((gpio_num_t)rm69a10->reset_gpio_num); + } + // Delete MIPI DPI panel + rm69a10->del(panel); + ESP_LOGD(TAG, "del rm69a10 panel @%p", rm69a10); + free(rm69a10); + + return ESP_OK; +} + +static esp_err_t panel_rm69a10_init(esp_lcd_panel_t *panel) +{ + rm69a10_panel_t *rm69a10 = (rm69a10_panel_t *)panel->user_data; + + ESP_RETURN_ON_ERROR(panel_rm69a10_send_init_cmds(rm69a10), TAG, "send init commands failed"); + ESP_RETURN_ON_ERROR(rm69a10->init(panel), TAG, "init MIPI DPI panel failed"); + + return ESP_OK; +} + +static esp_err_t panel_rm69a10_reset(esp_lcd_panel_t *panel) +{ + rm69a10_panel_t *rm69a10 = (rm69a10_panel_t *)panel->user_data; + esp_lcd_panel_io_handle_t io = rm69a10->io; + + // Perform hardware reset + if (rm69a10->reset_gpio_num >= 0) + { + gpio_set_level((gpio_num_t)rm69a10->reset_gpio_num, rm69a10->flags.reset_level); + vTaskDelay(pdMS_TO_TICKS(10)); + gpio_set_level((gpio_num_t)rm69a10->reset_gpio_num, !rm69a10->flags.reset_level); + vTaskDelay(pdMS_TO_TICKS(20)); + } + else if (io) + { // Perform software reset + ESP_RETURN_ON_ERROR(esp_lcd_panel_io_tx_param(io, LCD_CMD_SWRESET, NULL, 0), TAG, "send command failed"); + vTaskDelay(pdMS_TO_TICKS(20)); + } + + return ESP_OK; +} + +static esp_err_t panel_rm69a10_sleep(esp_lcd_panel_t *panel, bool sleep) +{ + rm69a10_panel_t *rm69a10 = (rm69a10_panel_t *)panel->user_data; + esp_lcd_panel_io_handle_t io = rm69a10->io; + + if (sleep == true) + { + ESP_RETURN_ON_ERROR(esp_lcd_panel_io_tx_param(io, 0x10, 0x00, 0), TAG, "esp_lcd_panel_io_tx_param fail"); + ESP_LOGI(TAG, "panel_rm69a10 sleep on"); + } + else + { + ESP_RETURN_ON_ERROR(esp_lcd_panel_io_tx_param(io, 0x11, 0x00, 0), TAG, "esp_lcd_panel_io_tx_param fail"); + ESP_LOGI(TAG, "panel_rm69a10 sleep off"); + } + + vTaskDelay(pdMS_TO_TICKS(120)); + + return ESP_OK; +} + +static esp_err_t panel_rm69a10_on_off(esp_lcd_panel_t *panel, bool on_off) +{ + rm69a10_panel_t *rm69a10 = (rm69a10_panel_t *)panel->user_data; + esp_lcd_panel_io_handle_t io = rm69a10->io; + + if (on_off == true) + { + ESP_RETURN_ON_ERROR(esp_lcd_panel_io_tx_param(io, 0x29, 0x00, 0), TAG, "esp_lcd_panel_io_tx_param fail"); + ESP_LOGI(TAG, "panel_rm69a10 display on"); + } + else + { + ESP_RETURN_ON_ERROR(esp_lcd_panel_io_tx_param(io, 0x28, 0x00, 0), TAG, "esp_lcd_panel_io_tx_param fail"); + ESP_LOGI(TAG, "panel_rm69a10 display off"); + } + + vTaskDelay(pdMS_TO_TICKS(120)); + + return ESP_OK; +} + +static esp_err_t panel_rm69a10_mirror(esp_lcd_panel_t *panel, bool mirror_x, bool mirror_y) +{ + rm69a10_panel_t *rm69a10 = (rm69a10_panel_t *)panel->user_data; + esp_lcd_panel_io_handle_t io = rm69a10->io; + uint8_t madctl_val = rm69a10->madctl_val; + + ESP_RETURN_ON_FALSE(io, ESP_ERR_INVALID_STATE, TAG, "invalid panel IO"); + + // Control mirror through LCD command + if (mirror_x) + { + madctl_val |= RM69A10_CMD_SHLR_BIT; + } + else + { + madctl_val &= ~RM69A10_CMD_SHLR_BIT; + } + if (mirror_y) + { + madctl_val |= RM69A10_CMD_UPDN_BIT; + } + else + { + madctl_val &= ~RM69A10_CMD_UPDN_BIT; + } + + ESP_RETURN_ON_ERROR(esp_lcd_panel_io_tx_param(io, LCD_CMD_MADCTL, (uint8_t[]){madctl_val}, 1), TAG, "send command failed"); + rm69a10->madctl_val = madctl_val; + + return ESP_OK; +} + +static esp_err_t panel_rm69a10_invert_color(esp_lcd_panel_t *panel, bool invert_color_data) +{ + rm69a10_panel_t *rm69a10 = (rm69a10_panel_t *)panel->user_data; + esp_lcd_panel_io_handle_t io = rm69a10->io; + uint8_t command = 0; + + ESP_RETURN_ON_FALSE(io, ESP_ERR_INVALID_STATE, TAG, "invalid panel IO"); + + if (invert_color_data) + { + command = LCD_CMD_INVON; + } + else + { + command = LCD_CMD_INVOFF; + } + ESP_RETURN_ON_ERROR(esp_lcd_panel_io_tx_param(io, command, NULL, 0), TAG, "send command failed"); + + return ESP_OK; +} + +esp_err_t set_rm69a10_brightness(esp_lcd_panel_t *panel, uint8_t brightness) +{ + rm69a10_panel_t *rm69a10 = (rm69a10_panel_t *)panel->user_data; + esp_lcd_panel_io_handle_t io = rm69a10->io; + + ESP_RETURN_ON_ERROR(esp_lcd_panel_io_tx_param(io, 0x51, &brightness, 1), TAG, "esp_lcd_panel_io_tx_param fail"); + + return ESP_OK; +} + +#endif diff --git a/variants/tdisplay_p4/esp_lcd_rm69a10.h b/variants/tdisplay_p4/esp_lcd_rm69a10.h new file mode 100644 index 0000000..b3f0c35 --- /dev/null +++ b/variants/tdisplay_p4/esp_lcd_rm69a10.h @@ -0,0 +1,68 @@ +/* + * @Description: rm69a10_driver + * @Author: LILYGO_L + * @Date: 2025-07-07 14:23:16 + * @LastEditTime: 2025-10-09 10:30:10 + * @License: GPL 3.0 + */ +#pragma once + +#include +#include "soc/soc_caps.h" + +#if SOC_MIPI_DSI_SUPPORTED +#include "esp_lcd_panel_vendor.h" +#include "esp_lcd_mipi_dsi.h" + +/** + * @brief LCD panel initialization commands. + * + */ +typedef struct +{ + int cmd; /* +struct sslclient_context; +#define WK + +WK int start_ssl_client(sslclient_context*, const IPAddress&, unsigned long, const char*, int, + const char*, bool, const char*, const char*, const char*, const char*, + bool, const char**) { return -1; } +WK void stop_ssl_socket(sslclient_context*) {} +WK int data_to_read(sslclient_context*) { return 0; } +WK int send_net_data(sslclient_context*, const unsigned char*, unsigned int) { return -1; } +WK int get_net_receive(sslclient_context*, unsigned char*, int) { return -1; } +WK int peek_net_receive(sslclient_context*, int) { return -1; } +WK int send_ssl_data(sslclient_context*, const unsigned char*, unsigned int) { return -1; } +WK int get_ssl_receive(sslclient_context*, unsigned char*, int) { return -1; } +WK void ssl_init(sslclient_context*) {} +WK int ssl_starttls_handshake(sslclient_context*) { return -1; } +#endif diff --git a/variants/tdisplay_p4/target.cpp b/variants/tdisplay_p4/target.cpp new file mode 100644 index 0000000..094f03b --- /dev/null +++ b/variants/tdisplay_p4/target.cpp @@ -0,0 +1,79 @@ +#include +#include // BQ27220 fuel gauge (getBattMilliVolts) +#include "target.h" +#include // RadioNoiseListener / StdRNG + +TDisplayP4Board board; +Xl9535 xl9535; // board-global expander (declared extern in Xl9535.h) + +// SX1262 on the P4 SPI (SCLK=2/MOSI=3/MISO=4, CS=24, BUSY=6). RESET + DIO1 are on the XL9535 +// (P_LORA_RESET/DIO_1 == -1 = RADIOLIB_NC): reset is pulsed via the expander in radio_init(); with +// DIO1 == NC RadioLib polls for RX/TX-done instead of taking a hardware IRQ. +// TODO(device): if polling RX is unreliable, route DIO1 through the XL9535 INT (GPIO5). +static SPIClass spi(FSPI); +RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi); +WRAPPER_CLASS radio_driver(radio, board); + +DISPLAY_CLASS display; // RM69A10 MIPI-DSI + +ESP32RTCClock fallback_clock; +ClockFloorRTC rtc_clock(fallback_clock); + +#if ENV_INCLUDE_GPS + #include + MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); + EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); +#else + EnvironmentSensorManager sensors; +#endif + +void TDisplayP4Board::begin() { + ESP32Board::begin(); +} + +// BQ27220 fuel gauge (I2C_1 @0x55, same shared Wire bus as the XL9535/RTC/touch — TwoWire's +// internal lock makes each transaction atomic across tasks). Standard command reads: 16-bit +// little-endian at Voltage()=0x08 (mV). Reads are rate-limited and sanity-windowed; on any I2C +// hiccup the last good value is kept so the battery UI never flickers to nonsense. +static uint16_t bq27220ReadU16(uint8_t cmd) { + Wire.beginTransmission(0x55); + Wire.write(cmd); + if (Wire.endTransmission(false) != 0) return 0; + if (Wire.requestFrom(0x55, 2) != 2) return 0; + uint16_t lo = Wire.read(), hi = Wire.read(); + return (uint16_t)(lo | (hi << 8)); +} + +uint16_t TDisplayP4Board::getBattMilliVolts() { + static uint32_t last_ms = 0; + static uint16_t last_mv = 3800; // pre-first-read placeholder + static bool logged = false; + const uint32_t now = millis(); + if (last_ms == 0 || now - last_ms >= 5000) { + last_ms = now; + const uint16_t mv = bq27220ReadU16(0x08); // Voltage(), mV + if (mv >= 2500 && mv <= 4600) { + last_mv = mv; + if (!logged) { printf("[BATT] BQ27220 alive: %u mV\n", mv); logged = true; } + } else if (!logged) { + printf("[BATT] BQ27220 read invalid (%u) — placeholder in use\n", mv); + logged = true; + } + } + return last_mv; +} + +bool radio_init() { + fallback_clock.begin(); + rtc_clock.begin(Wire); // PCF8563 RTC is on I2C_1 (shared Wire, SDA=7/SCL=8) + + // SX1262 RESET is on the XL9535 — pulse it before RadioLib init. + xl9535.sx1262Reset(); + spi.begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI, P_LORA_NSS); + return radio.std_init(&spi); +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); // fresh random identity from SX1262 RSSI noise +} diff --git a/variants/tdisplay_p4/target.h b/variants/tdisplay_p4/target.h new file mode 100644 index 0000000..7d6a7c4 --- /dev/null +++ b/variants/tdisplay_p4/target.h @@ -0,0 +1,39 @@ +#pragma once +#ifdef __cplusplus +extern "C" void set_boot_phase(int phase); +#endif + +// LilyGo T-Display P4 target wiring. Unlike the Tanmatsu (coprocessor LoRa bridge), this board has +// a RAW SX1262 on the P4's SPI, so it uses the core's RadioLib path like the S3 boards — except the +// SX1262 RESET + DIO1 and the RF-path switch (SKY13453) live on the XL9535 expander. +#define RADIOLIB_STATIC_ONLY 1 +#include +#include +#include +#include +#include +#include "../../src/helpers/ClockFloorRTC.h" // monotonic send-timestamp floor (issue #89) +#include +#include +#include "RM69A10Display.h" +#include "Xl9535.h" + +class TDisplayP4Board : public ESP32Board { +public: + void begin(); + // LoRa TX/RX antenna path is the SKY13453, driven via the XL9535 (polarity TBD on-device). + void onBeforeTransmit(void) override { xl9535.rfSwitchTx(true); } + void onAfterTransmit(void) override { xl9535.rfSwitchTx(false); } + uint16_t getBattMilliVolts() override; // BQ27220 gauge on I2C_1 — TODO(device) + const char* getManufacturerName() const override { return "LilyGo T-Display P4"; } +}; + +extern TDisplayP4Board board; +extern WRAPPER_CLASS radio_driver; +extern RADIO_CLASS radio; // raw SX1262 (also driven directly by the Spectrum sweep) +extern ClockFloorRTC rtc_clock; +extern EnvironmentSensorManager sensors; +extern DISPLAY_CLASS display; // RM69A10 MIPI-DSI, LVGL flush target + +bool radio_init(); +mesh::LocalIdentity radio_new_identity(); diff --git a/variants/tdisplay_p4/tdisplay_p4_compat.h b/variants/tdisplay_p4/tdisplay_p4_compat.h new file mode 100644 index 0000000..bbaaada --- /dev/null +++ b/variants/tdisplay_p4/tdisplay_p4_compat.h @@ -0,0 +1,8 @@ +#pragma once +// Compatibility shims for Arduino-ESP32 3.x / ESP32-P4 API gaps that the vendored MeshCore core +// still expects. Force-included on the Tanmatsu build (see tanmatsu/main/CMakeLists.txt). +#include + +// adcAttachPin() was removed in Arduino-ESP32 3.x. The core's ESP32Board calls it for the VBAT +// pin; on the Tanmatsu that pin is -1/none (battery comes from badge-bsp/power), so a no-op is fine. +static inline void adcAttachPin(uint8_t) {}