New board, not yet hardware-tested — beta only. Please flash with care and report back.
+
Attaky Core (experimental)
ESP32-S3 · 16 MB · 8 MB PSRAM.
+ Partially supportedPartially supportedThe core works: LoRa mesh communication, chat, contacts, channels and the phone-app link. This community-contributed board (PR #158) isn't hardware-verified by us yet — flash with care and report back.
+
+ Download .bin
+
New board, not yet hardware-tested — beta only. Please flash with care and report back.
Tanmatsu
ESP32-P4 keyboard handheld.
Partially supportedPartially supportedThe core works: LoRa mesh communication, chat, contacts, channels and the phone-app link over Wi-Fi/USB. Some features are still settling on this board (Bluetooth pairing has known gaps).
Already have one? No USB flashing needed — install WADAMESH straight from the Tanmatsu app store on the device.
diff --git a/release-notes/beta_47.txt b/release-notes/beta_47.txt
new file mode 100644
index 0000000..62f37dd
--- /dev/null
+++ b/release-notes/beta_47.txt
@@ -0,0 +1,17 @@
+# beta_47 — Discover app (find + map nearby nodes, wardriving), a new Attaky board, pager + P4 additions
+# One user-facing note per non-blank, non-# line; # lines are section comments.
+
+# New
+New Discover app: actively sweep for nearby nodes and list everything that answers — repeaters, companions and room servers — ranked by signal, with live RSSI/SNR, a "direct" badge for anything in RF range, and a last-heard age. It's in the app drawer (the ⟳ tile, next to Map).
+Tap any node in Discover to add it straight to your contacts — it keeps the right type (repeater, companion or room).
+Wardriving: with a GPS fix, Discover logs every node it hears against your position to a CSV on the SD card as you move, and plots your RF coverage on the map as a signal-coloured track (green = strong, red = weak) — a standalone, on-device coverage mapper, no phone needed.
+
+# New boards
+Attaky Core (ESP32-S3, 16 MB flash / 8 MB PSRAM): a new board, contributed by @attakygit (#158). Experimental — on the beta channel while it finishes hardware verification.
+LilyGo T-Display P4 (TFT-LCD screen variant): early support for the LCD screen model (the AMOLED variant was already supported). Experimental — if you have the LCD screen, grab the wadamesh-tdisplay-p4-lcd build from the release assets.
+
+# Fixed
+LilyGo T-LoRa Pager: the rotary encoder now keeps its focus inside the chat message list, and the composer navigation keys are remapped so moving around and typing a message no longer fight each other. (community contribution by @codemonkeybr, #162)
+
+# Boards
+T-Deck · Heltec V4 TFT · Heltec V4-R8 · ThinkNode M9 · RAK TAP v2 · T-LoRa Pager (LR1121 / SX1262) · Attaky Core · T-Display P4.
diff --git a/scripts/build/gen-flasher-meta.py b/scripts/build/gen-flasher-meta.py
index b954cd1..a780c29 100644
--- a/scripts/build/gen-flasher-meta.py
+++ b/scripts/build/gen-flasher-meta.py
@@ -42,6 +42,7 @@ BOARDS = {
"manifest-heltec-v4-r8-tft.json": ("wadamesh — Heltec V4-R8 (experimental)", "wadamesh-heltec-v4-r8-tft-merged.bin", "ESP32-S3"),
"manifest-tlora-pager-lr1121.json": ("wadamesh — LilyGo T-LoRa Pager (LR1121)", "wadamesh-tlora-pager-lr1121-merged.bin", "ESP32-S3"),
"manifest-tlora-pager-sx1262.json": ("wadamesh — LilyGo T-LoRa Pager (SX1262)", "wadamesh-tlora-pager-sx1262-merged.bin", "ESP32-S3"),
+ "manifest-attaky.json": ("wadamesh — Attaky Core (experimental)", "wadamesh-attaky-merged.bin", "ESP32-S3"),
"manifest-tdisplay-p4.json": ("wadamesh — LilyGo T-Display P4", "wadamesh-tdisplay-p4-merged.bin", "ESP32-P4"),
}
for fn, (name, binf, chip) in BOARDS.items():
diff --git a/scripts/release.sh b/scripts/release.sh
index 84aa72c..7e87dd4 100755
--- a/scripts/release.sh
+++ b/scripts/release.sh
@@ -29,7 +29,7 @@ DEST="${WADAMESH_VPS:-}"; DEST_PATH="${WADAMESH_VPS_PATH:-/srv/wadamesh/firmware
# env:binname pairs — plain string form (works on macOS's bash 3.2; no associative arrays).
# All S3/PIO boards. The T-Display P4 is an ESP32-P4 IDF build (tdisplay_p4/) handled OUT of band
# — see the release skill / the P4 build+merge step — because this loop is PlatformIO-only.
-ENVS="heltec_v4_tft_companion_radio_usb_tcp_touch:wadamesh-heltec-v4-tft LilyGo_TDeck_companion_radio_touch:wadamesh-tdeck ThinkNode_M9_companion_radio_touch:wadamesh-thinknode-m9 rak_tap_v2_companion_radio_touch:wadamesh-rak-tap-v2 heltec_v4_r8_tft_companion_radio_usb_tcp_touch:wadamesh-heltec-v4-r8-tft tlora_pager_lr1121_companion_radio_touch:wadamesh-tlora-pager-lr1121 tlora_pager_sx1262_companion_radio_touch:wadamesh-tlora-pager-sx1262"
+ENVS="heltec_v4_tft_companion_radio_usb_tcp_touch:wadamesh-heltec-v4-tft LilyGo_TDeck_companion_radio_touch:wadamesh-tdeck ThinkNode_M9_companion_radio_touch:wadamesh-thinknode-m9 rak_tap_v2_companion_radio_touch:wadamesh-rak-tap-v2 heltec_v4_r8_tft_companion_radio_usb_tcp_touch:wadamesh-heltec-v4-r8-tft tlora_pager_lr1121_companion_radio_touch:wadamesh-tlora-pager-lr1121 tlora_pager_sx1262_companion_radio_touch:wadamesh-tlora-pager-sx1262 attaky_mesh_series_companion_radio_touch:wadamesh-attaky"
# Per-channel destination paths.
if [ "$MODE" = "stable" ]; then
diff --git a/src/MyMesh.cpp b/src/MyMesh.cpp
index 718f770..fe32a3c 100644
--- a/src/MyMesh.cpp
+++ b/src/MyMesh.cpp
@@ -2401,6 +2401,22 @@ void MyMesh::onControlDataRecv(mesh::Packet *packet) {
_ui_sig_ms = millis();
}
}
+ // Discover scan (Discover app): collect EVERY NODE_DISCOVER_RESP matching the active discover
+ // sweep tag into _discover[], keyed by responder pubkey. Additive to the single-scalar probe
+ // capture above (they use different tags). RESP payload (MeshCore docs/payloads.md):
+ // [0]=0x9<<4|node_type [1]=their SNR*4 (reverse link) [2..5]=tag [6..]=pubkey (8 or 32 bytes).
+ if (packet->payload_len >= 6 + 8 && (packet->payload[0] & 0xF0) == CTL_TYPE_NODE_DISCOVER_RESP
+ && _discover_tag != 0) {
+ uint32_t rtag; memcpy(&rtag, &packet->payload[2], 4);
+ if (rtag == _discover_tag) {
+ uint8_t node_type = packet->payload[0] & 0x0F;
+ int8_t their_snr_q4 = (int8_t)packet->payload[1];
+ uint8_t pklen = (packet->payload_len >= 6 + 32) ? 32 : 8;
+ int8_t our_snr_q4 = (int8_t)(packet->getSNR() * 4.0f);
+ int8_t our_rssi = (int8_t)_radio->getLastRSSI();
+ discoverUpsert(&packet->payload[6], pklen, node_type, our_snr_q4, our_rssi, their_snr_q4, packet->path_len);
+ }
+ }
if (packet->payload_len + 4 > sizeof(out_frame)) {
MESH_DEBUG_PRINTLN("onControlDataRecv(), payload_len too long: %d", packet->payload_len);
return;
@@ -2420,6 +2436,38 @@ void MyMesh::onControlDataRecv(mesh::Packet *packet) {
}
}
+// Upsert a discover responder into _discover[] (keyed by the 8-byte pubkey prefix). New nodes
+// append; a full table evicts the least-recently-heard. Both link directions + hop count are
+// refreshed on every reply. See DiscoverHit / uiStartDiscoverScan in MyMesh.h.
+void MyMesh::discoverUpsert(const uint8_t* pk, uint8_t pklen, uint8_t node_type,
+ int8_t our_snr_q4, int8_t our_rssi, int8_t their_snr_q4, uint8_t path_len) {
+ uint32_t now = millis();
+ int slot = -1;
+ for (uint8_t i = 0; i < _discover_cnt; i++) {
+ if (memcmp(_discover[i].pubkey, pk, 8) == 0) { slot = i; break; }
+ }
+ if (slot < 0) {
+ if (_discover_cnt < DISCOVER_MAX) {
+ slot = _discover_cnt++;
+ } else { // table full -> evict the least-recently-heard
+ uint32_t oldest = 0xFFFFFFFFu; slot = 0;
+ for (uint8_t i = 0; i < _discover_cnt; i++)
+ if (_discover[i].last_ms < oldest) { oldest = _discover[i].last_ms; slot = i; }
+ }
+ memset(&_discover[slot], 0, sizeof(DiscoverHit));
+ memcpy(_discover[slot].pubkey, pk, pklen > 32 ? 32 : pklen);
+ _discover[slot].first_ms = now;
+ }
+ DiscoverHit& h = _discover[slot];
+ h.node_type = node_type;
+ h.our_snr_q4 = our_snr_q4;
+ h.our_rssi = our_rssi;
+ h.their_snr_q4 = their_snr_q4;
+ h.path_len = path_len;
+ h.last_ms = now;
+ if (h.heard < 0xFFFF) h.heard++;
+}
+
void MyMesh::onRawDataRecv(mesh::Packet *packet) {
if (packet->payload_len + 4 > sizeof(out_frame)) {
MESH_DEBUG_PRINTLN("onRawDataRecv(), payload_len too long: %d", packet->payload_len);
diff --git a/src/MyMesh.h b/src/MyMesh.h
index 3626e56..8d43522 100644
--- a/src/MyMesh.h
+++ b/src/MyMesh.h
@@ -481,6 +481,36 @@ public:
int8_t uiSignalRssi() const { return _ui_sig_rssi; }
uint32_t uiSignalMs() const { return _ui_sig_ms; }
+ // ---- Discover scan (the Discover app: active node-discovery, ALL node types) ----
+ // uiStartDiscoverScan() broadcasts a zero-hop NODE_DISCOVER_REQ with a type filter + a fresh
+ // random tag; EVERY neighbour that answers with a NODE_DISCOVER_RESP is upserted here (keyed by
+ // pubkey prefix) from onControlDataRecv. Unlike the single-scalar signal probe, every responder
+ // is kept, with BOTH link directions (our RX of them + their RX of us, from RESP payload[1]).
+ struct DiscoverHit {
+ uint8_t pubkey[32]; // responder identity (full key — the REQ sets prefix_only=0)
+ uint8_t node_type; // ADV_TYPE_* (RESP payload[0] low nibble): repeater/chat/room/sensor
+ int8_t our_snr_q4; // our RX SNR*4 of their reply (forward link)
+ int8_t our_rssi; // our RX RSSI dBm of their reply
+ int8_t their_snr_q4; // their RX SNR*4 of our request (reverse link — RESP payload[1])
+ uint8_t path_len; // hops the reply travelled (0 = heard directly, i.e. in RF range)
+ uint32_t first_ms; // millis() first heard this session
+ uint32_t last_ms; // millis() last heard
+ uint16_t heard; // reply count
+ };
+ static const int DISCOVER_MAX = 64;
+ DiscoverHit _discover[DISCOVER_MAX];
+ uint8_t _discover_cnt = 0;
+ uint32_t _discover_tag = 0; // active scan tag (matches RESPs; 0 = no scan yet)
+ uint32_t _discover_scan_ms = 0; // millis() the current scan sweep was fired
+ uint8_t discoverCount() const { return _discover_cnt; }
+ bool discoverGet(uint8_t i, DiscoverHit& out) const {
+ if (i >= _discover_cnt) return false; out = _discover[i]; return true;
+ }
+ void discoverClear() { _discover_cnt = 0; }
+ uint32_t discoverScanMs() const { return _discover_scan_ms; }
+ void discoverUpsert(const uint8_t* pk, uint8_t pklen, uint8_t node_type,
+ int8_t our_snr_q4, int8_t our_rssi, int8_t their_snr_q4, uint8_t path_len);
+
// ---- Recent-RX ring (RF Monitor app) ----
// One record per received frame, captured in logRxRaw(): payload type / route
// / hop count / length + signal, so the Monitor page can show a live "what am
@@ -711,6 +741,29 @@ public:
return _ui_sig_probe_tag;
}
+ /** DISCOVER SCAN (Discover app): like uiSendSignalProbe, but asks ALL node types and KEEPS
+ * every responder (see _discover[] + discoverUpsert, populated in onControlDataRecv). Broadcasts
+ * one zero-hop NODE_DISCOVER_REQ; neighbours reply DIRECTLY (never floods). type_filter = OR of
+ * (1< full 32-byte pubkeys
+ data[1] = type_filter ? type_filter
+ : (uint8_t)((1 << ADV_TYPE_CHAT) | (1 << ADV_TYPE_REPEATER) |
+ (1 << ADV_TYPE_ROOM) | (1 << ADV_TYPE_SENSOR)); // all types
+ getRNG()->random(&data[2], 4); // fresh random tag to match this sweep's replies
+ memcpy(&_discover_tag, &data[2], 4);
+ if (_discover_tag == 0) { _discover_tag = 1; memcpy(&data[2], &_discover_tag, 4); }
+ uint32_t since = 0; // 0 = answer regardless of freshness
+ memcpy(&data[6], &since, 4);
+ mesh::Packet* pkt = createControlData(data, sizeof(data));
+ if (!pkt) { _discover_tag = 0; return 0; }
+ sendZeroHop(pkt);
+ _discover_scan_ms = millis();
+ return _discover_tag;
+ }
+
/** Request CayenneLPP telemetry from a remote contact. Reply is delivered
* via AbstractUITask::onTelemetryReply with the raw LPP payload after the
* 4-byte timestamp header. Falls back to onPingReply if the UI didn't
@@ -831,6 +884,27 @@ public:
return true;
}
+ /** Add a node found by the Discover app (active NODE_DISCOVER sweep) to contacts, from its full
+ * 32-byte pubkey + node TYPE + a display name. Like uiAddManualContact but preserves the type
+ * (repeater/room/sensor/chat) so the contact lands with the right role/icon — the discovery
+ * response carries the type + full pubkey but no advert, so name is a placeholder until one
+ * arrives. Returns false if it's already a contact, the name is empty, or the table is full. */
+ bool uiAddDiscoveredContact(const uint8_t pub_key[32], uint8_t type, const char* name) {
+ if (!name || !name[0]) return false;
+ if (lookupContactByPubKey(pub_key, PUB_KEY_SIZE) != nullptr) return false;
+ ContactInfo ci{};
+ memcpy(ci.id.pub_key, pub_key, PUB_KEY_SIZE);
+ ci.type = type;
+ ci.out_path_len = OUT_PATH_UNKNOWN;
+ ci.last_advert_timestamp = 0; // unknown — we only heard a discovery reply
+ ci.lastmod = getRTCClock()->getCurrentTime();
+ StrHelper::strncpy(ci.name, name, sizeof(ci.name));
+ if (!addContact(ci)) return false;
+ saveContacts();
+ if (_ui) _ui->onThreadsChanged();
+ return true;
+ }
+
/** Persist the in-RAM contact table to flash (/contacts3). Public wrapper so
* UI paths that insert via the base addContact() — e.g. the Discovered-list
* "Add to contacts" button — can persist; otherwise that contact is RAM-only
diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp
index 9a9f2a4..8b5a64c 100644
--- a/src/ui-touch/UITask.cpp
+++ b/src/ui-touch/UITask.cpp
@@ -134,7 +134,11 @@
#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
+ #if defined(HAS_TDP4_LCD)
+ #include // HI8561 TFT-LCD (LCD SKU) on the T-Display P4
+ #else
+ #include // RM69A10 MIPI-DSI AMOLED (default SKU) on the T-Display P4
+ #endif
#else
#include
#endif
@@ -182,7 +186,7 @@
#elif defined(HAS_RAK_TAP_V2) || defined(HELTEC_LORA_V4_R8)
extern LGFXDisplay display;
#elif defined(HAS_TDISPLAY_P4)
- extern RM69A10Display display;
+ extern DISPLAY_CLASS display; // RM69A10Display (AMOLED) or HI8561Display (LCD) — set in CMakeLists
#else
extern ST7789LCDDisplay display;
#endif
@@ -20353,6 +20357,316 @@ static void openReaderPage() {
}
#endif // Reader
+// ===== Discover app (active node-discovery + live signal-ranked list) =====================
+// Fires a zero-hop NODE_DISCOVER_REQ sweep (ALL node types) every few seconds while open and lists
+// every node that answers, ranked by signal. The engine (MyMesh::_discover[] + uiStartDiscoverScan
+// + discoverUpsert) collects the replies; this page renders them + drives the cadence, airtime-gated
+// so a repeating scan respects the duty-cycle budget. Rendered as ONE recolored label (like the RF
+// Monitor feed) — light + scroll-safe, no per-row object churn. Map + wardriving build on top later.
+static lv_obj_t* s_discover_root = nullptr;
+static lv_obj_t* s_discover_feed = nullptr; // single recolored multi-line label
+static lv_obj_t* s_discover_status = nullptr; // header: "Scanning… · N nearby"
+static lv_obj_t* s_discover_btn_lbl = nullptr; // Scan/Stop button label
+static lv_timer_t* s_discover_timer = nullptr;
+static bool s_discover_scanning = true;
+static uint32_t s_discover_last_scan_ms = 0;
+
+static void closeDiscoverPage();
+static void discoverJumpToMapHere(); // "Show on map" — defined with the map code (uses map statics)
+
+// node_type -> row colour / short label (mirrors the map marker colours).
+static uint32_t discTypeColor(uint8_t t) {
+ switch (t) {
+ case ADV_TYPE_REPEATER: return 0x4DA8FF; // blue
+ case ADV_TYPE_CHAT: return 0x53C06B; // green
+ case ADV_TYPE_ROOM: return 0xF0A020; // amber
+ case ADV_TYPE_SENSOR: return 0x35C9C9; // cyan
+ default: return 0x9AA0A6; // grey
+ }
+}
+static const char* discTypeName(uint8_t t) {
+ switch (t) {
+ case ADV_TYPE_REPEATER: return "repeater";
+ case ADV_TYPE_CHAT: return "companion";
+ case ADV_TYPE_ROOM: return "room";
+ case ADV_TYPE_SENSOR: return "sensor";
+ default: return "node";
+ }
+}
+
+// ---- Wardriving: log GPS-tagged sightings as we scan (a standalone, on-device MeshMapper) ----
+// While the Discover app scans, sample our GPS position; at each sample, log every currently-heard
+// node (our position + its type + signal) to an SD CSV, and keep an in-RAM coverage ring for the map
+// overlay. A 0-hop hit means "reachable from HERE", so the track of samples IS a personal RF-coverage
+// map. All UI-side (needs GPS, which the engine doesn't have). Map rendering reads s_disc_track[].
+struct DiscTrackPt { int32_t lat_e6, lon_e6; int8_t best_rssi; uint8_t node_count; };
+static const int k_disc_track_max = 160;
+static DiscTrackPt* s_disc_track = (DiscTrackPt*)psAlloc(sizeof(DiscTrackPt) * k_disc_track_max); // PSRAM (~1KB off .bss)
+static int s_disc_track_n = 0; // total samples ever (ring slot = n % max)
+static int32_t s_disc_last_lat = 0, s_disc_last_lon = 0;
+static uint32_t s_disc_last_sample_ms = 0;
+static uint32_t s_disc_log_count = 0; // sightings written to SD this session
+static lv_obj_t* s_disc_footer = nullptr; // bottom status line: GPS / wardrive state
+
+static void discoverLogSighting(int32_t lat_e6, int32_t lon_e6, const MyMesh::DiscoverHit& h) {
+#if CAP_SD
+ if (SD.cardType() == CARD_NONE) return;
+ markSdIo();
+ SD.mkdir("/meshcomod");
+ SD.mkdir("/meshcomod/discover");
+ File f = SD.open("/meshcomod/discover/wardrive.csv", FILE_APPEND);
+ if (!f) return;
+ if (f.size() == 0) f.print("epoch,lat,lon,type,pubkey,rssi,snr,hops\n");
+ uint32_t epoch = (uint32_t)rtc_clock.getCurrentTime();
+ f.printf("%lu,%.6f,%.6f,%s,%02X%02X%02X%02X,%d,%.1f,%u\n",
+ (unsigned long)epoch, (double)lat_e6 / 1e6, (double)lon_e6 / 1e6, discTypeName(h.node_type),
+ h.pubkey[0], h.pubkey[1], h.pubkey[2], h.pubkey[3],
+ (int)h.our_rssi, (double)h.our_snr_q4 / 4.0, (unsigned)h.path_len);
+ f.close();
+ s_disc_log_count++;
+#else
+ (void)lat_e6; (void)lon_e6; (void)h;
+#endif
+}
+
+// Called every scan tick: refresh the footer + (once we've moved ~15 m) log a coverage sample.
+static void discoverWardriveTick() {
+ UITask* task = g_lv.task;
+ const bool fix = task && task->getGpsFix();
+ const double latd = fix ? task->getNodeLat() : 0.0, lond = fix ? task->getNodeLon() : 0.0;
+ if (fix && !(latd == 0.0 && lond == 0.0)) {
+ const int32_t lat_e6 = (int32_t)lround(latd * 1e6), lon_e6 = (int32_t)lround(lond * 1e6);
+ const uint32_t now = millis();
+ int32_t dlat = lat_e6 - s_disc_last_lat; if (dlat < 0) dlat = -dlat;
+ int32_t dlon = lon_e6 - s_disc_last_lon; if (dlon < 0) dlon = -dlon;
+ if (s_disc_last_sample_ms == 0 || dlat > 150 || dlon > 150 || (now - s_disc_last_sample_ms) > 20000) {
+ int nodes = 0; int8_t best = -128;
+ uint8_t n = the_mesh.discoverCount();
+ for (uint8_t i = 0; i < n; i++) {
+ MyMesh::DiscoverHit h;
+ if (!the_mesh.discoverGet(i, h) || (now - h.last_ms) > 8000) continue; // only currently-heard
+ nodes++; if (h.our_rssi > best) best = h.our_rssi;
+ discoverLogSighting(lat_e6, lon_e6, h);
+ }
+ if (nodes > 0) {
+ DiscTrackPt& p = s_disc_track[s_disc_track_n % k_disc_track_max];
+ p.lat_e6 = lat_e6; p.lon_e6 = lon_e6; p.best_rssi = best;
+ p.node_count = (uint8_t)(nodes > 255 ? 255 : nodes);
+ s_disc_track_n++;
+ }
+ s_disc_last_lat = lat_e6; s_disc_last_lon = lon_e6; s_disc_last_sample_ms = now;
+ }
+ }
+ if (s_disc_footer) {
+ if (!fix) lv_label_set_text(s_disc_footer, "#7A7F87 Wardrive: waiting for GPS fix\xE2\x80\xA6#");
+ else lv_label_set_text_fmt(s_disc_footer,
+ "#7A7F87 Wardrive: %d coverage pts \xC2\xB7 %lu logged to SD#",
+ s_disc_track_n > k_disc_track_max ? k_disc_track_max : s_disc_track_n,
+ (unsigned long)s_disc_log_count);
+ }
+}
+
+// Per-row snapshot for tap-to-add (populated by discoverBuildFeed in the displayed sort order):
+// full pubkey + type + name, so a tap on the feed resolves to the exact node under it.
+static const int DISC_MAXROWS = 40;
+static uint8_t s_disc_row_key[DISC_MAXROWS][32];
+static uint8_t s_disc_row_type[DISC_MAXROWS];
+static char s_disc_row_name[DISC_MAXROWS][26];
+static int s_disc_row_n = 0;
+
+// Rebuild the feed label from the engine's _discover[] table, strongest-signal first.
+static void discoverBuildFeed() {
+ if (!s_discover_feed) return;
+ const uint32_t now = millis();
+ static MyMesh::DiscoverHit hits[MyMesh::DISCOVER_MAX];
+ static uint8_t idx[MyMesh::DISCOVER_MAX];
+ uint8_t m = 0;
+ uint8_t n = the_mesh.discoverCount();
+ for (uint8_t i = 0; i < n && m < MyMesh::DISCOVER_MAX; i++) {
+ if (the_mesh.discoverGet(i, hits[m])) { idx[m] = m; m++; }
+ }
+ for (uint8_t a = 0; a < m; a++) // insertion sort by our_rssi desc
+ for (uint8_t b = (uint8_t)(a + 1); b < m; b++)
+ if (hits[idx[b]].our_rssi > hits[idx[a]].our_rssi) { uint8_t t = idx[a]; idx[a] = idx[b]; idx[b] = t; }
+
+ static char buf[3200];
+ int q = 0, rpt = 0, comp = 0;
+ const int MAXROWS = DISC_MAXROWS;
+ s_disc_row_n = 0;
+ for (uint8_t k = 0; k < m && k < MAXROWS && q < (int)sizeof(buf) - 112; k++) {
+ const MyMesh::DiscoverHit& h = hits[idx[k]];
+ if (h.node_type == ADV_TYPE_REPEATER) rpt++;
+ else if (h.node_type == ADV_TYPE_CHAT) comp++;
+ char name[26];
+ ContactInfo* c = the_mesh.lookupContactByPubKey((uint8_t*)h.pubkey, 6);
+ if (c && c->name[0]) snprintf(name, sizeof name, "%s", c->name);
+ else snprintf(name, sizeof name, "Node \xC2\xB7%02X%02X", h.pubkey[0], h.pubkey[1]);
+ memcpy(s_disc_row_key[k], h.pubkey, 32); // snapshot this row for tap-to-add
+ s_disc_row_type[k] = h.node_type;
+ snprintf(s_disc_row_name[k], sizeof s_disc_row_name[k], "%s", name);
+ s_disc_row_n = k + 1;
+ uint32_t age = (now - h.last_ms) / 1000;
+ char ago[10];
+ if (age < 60) snprintf(ago, sizeof ago, "%us", (unsigned)age);
+ else snprintf(ago, sizeof ago, "%um", (unsigned)(age / 60));
+ const char* direct = (h.path_len == 0) ? " #53C06B direct#" : "";
+ q += snprintf(buf + q, sizeof(buf) - q,
+ "#%06X %s# %s %ddBm %.1f%s \xC2\xB7 %s\n",
+ (unsigned)discTypeColor(h.node_type), name, discTypeName(h.node_type),
+ (int)h.our_rssi, (double)h.our_snr_q4 / 4.0, direct, ago);
+ }
+ if (q == 0) snprintf(buf, sizeof buf, "#7A7F87 Scanning\xE2\x80\xA6 nothing has answered yet#");
+ else if (buf[q - 1] == '\n') buf[q - 1] = '\0';
+ lv_label_set_text(s_discover_feed, buf);
+ if (s_discover_status)
+ lv_label_set_text_fmt(s_discover_status, "%s \xC2\xB7 %d nearby (%d rpt, %d comp)",
+ s_discover_scanning ? "Scanning\xE2\x80\xA6" : "Paused", (int)m, rpt, comp);
+}
+
+static void discoverTimerCb(lv_timer_t* t) {
+ (void)t;
+ if (!s_discover_root) return;
+ const uint32_t now = millis();
+ if (s_discover_scanning && (s_discover_last_scan_ms == 0 || (now - s_discover_last_scan_ms) >= 4000)) {
+ if (the_mesh.getRemainingTxBudget() >= 300) { // airtime gate: keep a duty-cycle cushion
+ the_mesh.uiStartDiscoverScan(0); // 0 = all node types
+ s_discover_last_scan_ms = now;
+ }
+ }
+ discoverBuildFeed();
+ discoverWardriveTick();
+}
+
+static void discoverScanToggleCb(lv_event_t* e) {
+ if (lv_event_get_code(e) != LV_EVENT_CLICKED) return;
+ s_discover_scanning = !s_discover_scanning;
+ if (s_discover_scanning) s_discover_last_scan_ms = 0; // resume -> sweep immediately
+ if (s_discover_btn_lbl) lv_label_set_text(s_discover_btn_lbl, s_discover_scanning ? "Stop" : "Scan");
+ discoverBuildFeed();
+}
+
+static void discoverShowMapCb(lv_event_t* e) {
+ if (lv_event_get_code(e) != LV_EVENT_CLICKED) return;
+ discoverJumpToMapHere(); // centre the map on us + open the Map tab (coverage overlay draws there)
+}
+
+// Tap a row in the feed -> add that node to contacts (using the full pubkey the discovery reply
+// carried). Resolves the row from the tap's Y (uniform one-line rows: LONG_CLIP + a fixed line_space).
+static void discoverFeedTapCb(lv_event_t* e) {
+ if (lv_event_get_code(e) != LV_EVENT_CLICKED) return;
+ if (!s_discover_feed || s_disc_row_n == 0) return;
+ lv_indev_t* indev = lv_indev_get_act();
+ if (!indev) return;
+ lv_point_t p; lv_indev_get_point(indev, &p);
+ lv_area_t a; lv_obj_get_coords(s_discover_feed, &a);
+ const int local_y = (int)p.y - (int)a.y1;
+ if (local_y < 0) return;
+ const int row_h = (int)lv_font_get_line_height(&g_font_12) + 3; // == the label's line_space
+ const int row = local_y / row_h;
+ if (row < 0 || row >= s_disc_row_n) return; // tapped empty space below the list
+ const uint8_t* pk = s_disc_row_key[row];
+ if (the_mesh.lookupContactByPubKey((uint8_t*)pk, PUB_KEY_SIZE) != nullptr) {
+ if (g_lv.task) g_lv.task->showAlert(TR("Already in contacts"), 1100);
+ return;
+ }
+ if (the_mesh.uiAddDiscoveredContact(pk, s_disc_row_type[row], s_disc_row_name[row])) {
+ if (g_lv.task) { char msg[44]; snprintf(msg, sizeof msg, "%s %s", TR("Added"), s_disc_row_name[row]); g_lv.task->showAlert(msg, 1400); }
+ } else if (g_lv.task) {
+ g_lv.task->showAlert(TR("Could not add"), 1100);
+ }
+}
+
+static void closeDiscoverPage() {
+ if (s_discover_timer) { lv_timer_del(s_discover_timer); s_discover_timer = nullptr; }
+ if (s_discover_root) { popupClose(&s_discover_root); }
+ s_discover_feed = s_discover_status = s_discover_btn_lbl = s_disc_footer = nullptr;
+ s_disc_row_n = 0;
+ s_discover_scanning = true;
+ if (s_apppage_close == closeDiscoverPage) {
+ s_apppage_title = nullptr; s_apppage_close = nullptr;
+ statusBarSetTall(false); updateGlobalStatusBar();
+ }
+}
+
+static void openDiscoverPage() {
+ closeDiscoverPage();
+ const lv_coord_t sw = lv_disp_get_hor_res(nullptr);
+ const lv_coord_t sh = lv_disp_get_ver_res(nullptr);
+ const int H = sh - STATUSBAR_H;
+ s_discover_root = lv_obj_create(lv_layer_top());
+ lv_obj_remove_style_all(s_discover_root);
+ lv_obj_set_size(s_discover_root, sw, H);
+ lv_obj_set_pos(s_discover_root, 0, STATUSBAR_H);
+ lv_obj_set_style_bg_color(s_discover_root, lv_color_hex(COLOR_BG), LV_PART_MAIN);
+ lv_obj_set_style_bg_opa(s_discover_root, LV_OPA_COVER, LV_PART_MAIN);
+ lv_obj_clear_flag(s_discover_root, LV_OBJ_FLAG_SCROLLABLE);
+
+ s_apppage_title = "Discover";
+ s_apppage_close = closeDiscoverPage;
+ statusBarSetTall(true);
+ updateGlobalStatusBar();
+ const int top = STATUSBAR_H + 8;
+
+ s_discover_status = lv_label_create(s_discover_root);
+ lv_label_set_text(s_discover_status, "Scanning\xE2\x80\xA6");
+ lv_obj_set_style_text_font(s_discover_status, &g_font_12, LV_PART_MAIN);
+ lv_obj_set_style_text_color(s_discover_status, lv_color_hex(COLOR_SUB), LV_PART_MAIN);
+ lv_obj_set_pos(s_discover_status, 10, top);
+ lv_obj_set_width(s_discover_status, sw - 130); // keep clear of the two header buttons
+ lv_label_set_long_mode(s_discover_status, LV_LABEL_LONG_DOT);
+
+ lv_obj_t* btn = lv_btn_create(s_discover_root); // Stop / Scan
+ lv_obj_set_size(btn, 56, 30);
+ lv_obj_set_pos(btn, sw - 62, top - 4);
+ styleButton(btn);
+ lv_obj_add_event_cb(btn, discoverScanToggleCb, LV_EVENT_CLICKED, nullptr);
+ s_discover_btn_lbl = lv_label_create(btn);
+ lv_label_set_text(s_discover_btn_lbl, "Stop");
+ lv_obj_center(s_discover_btn_lbl);
+
+ lv_obj_t* mbtn = lv_btn_create(s_discover_root); // Show on map
+ lv_obj_set_size(mbtn, 56, 30);
+ lv_obj_set_pos(mbtn, sw - 122, top - 4);
+ styleButton(mbtn);
+ lv_obj_add_event_cb(mbtn, discoverShowMapCb, LV_EVENT_CLICKED, nullptr);
+ lv_obj_t* mlbl = lv_label_create(mbtn);
+ lv_label_set_text(mlbl, "Map");
+ lv_obj_center(mlbl);
+
+ lv_obj_t* sc = lv_obj_create(s_discover_root);
+ lv_obj_remove_style_all(sc);
+ lv_obj_set_size(sc, sw, H - (top - STATUSBAR_H) - 34 - 20); // leave a row for the wardrive footer
+ lv_obj_set_pos(sc, 0, top + 30);
+ lv_obj_set_style_bg_opa(sc, LV_OPA_TRANSP, LV_PART_MAIN);
+ lv_obj_set_style_pad_all(sc, 8, LV_PART_MAIN);
+ lv_obj_set_scroll_dir(sc, LV_DIR_VER);
+ s_discover_feed = lv_label_create(sc);
+ lv_label_set_recolor(s_discover_feed, true);
+ lv_obj_set_width(s_discover_feed, sw - 20);
+ lv_label_set_long_mode(s_discover_feed, LV_LABEL_LONG_CLIP); // one line per row -> tap-to-add maps Y->row
+ lv_obj_set_style_text_line_space(s_discover_feed, 3, LV_PART_MAIN); // fixed row pitch (font line-height + 3)
+ lv_obj_set_style_text_font(s_discover_feed, &g_font_12, LV_PART_MAIN);
+ lv_obj_set_style_text_color(s_discover_feed, lv_color_hex(0xC8CDD2), LV_PART_MAIN);
+ lv_obj_add_flag(s_discover_feed, LV_OBJ_FLAG_CLICKABLE); // tap a node row -> add to contacts
+ lv_obj_add_event_cb(s_discover_feed, discoverFeedTapCb, LV_EVENT_CLICKED, nullptr);
+ lv_label_set_text(s_discover_feed, "");
+
+ // wardriving footer (GPS + logged-count status), pinned at the bottom
+ s_disc_footer = lv_label_create(s_discover_root);
+ lv_label_set_recolor(s_disc_footer, true);
+ lv_obj_set_style_text_font(s_disc_footer, &g_font_12, LV_PART_MAIN);
+ lv_obj_set_pos(s_disc_footer, 10, STATUSBAR_H + H - 18);
+ lv_label_set_text(s_disc_footer, "#7A7F87 Wardrive: \xE2\x80\xA6#");
+
+ the_mesh.discoverClear();
+ s_discover_scanning = true;
+ s_discover_last_scan_ms = 0; // fire the first sweep on the first tick
+ discoverBuildFeed();
+ s_discover_timer = lv_timer_create(discoverTimerCb, 1000, nullptr);
+
+ if (g_statusbar.root) lv_obj_move_foreground(g_statusbar.root);
+}
+
static void openMonitorPage() {
closeMonitorPage();
const lv_coord_t sw = lv_disp_get_hor_res(nullptr);
@@ -24632,6 +24946,56 @@ static void routeHudUpdate();
static void showRouteHud();
static void hideRouteHud();
+// ---- Discover wardriving coverage overlay (drawn on the map pan layer) ----
+// Plots the Discover app's logged coverage samples (s_disc_track[], populated in discoverWardriveTick)
+// as signal-coloured dots — a personal RF-coverage map: a 0-hop hit means "reachable from that point",
+// so green = strong contact, red = weak. Object pool freed alongside the other map markers.
+static lv_obj_t* s_disc_map_objs[k_disc_track_max] = {nullptr};
+static int s_disc_map_obj_n = 0;
+static void discoverFreeMapObjs() {
+ for (int i = 0; i < s_disc_map_obj_n; ++i)
+ if (s_disc_map_objs[i]) { lv_obj_del(s_disc_map_objs[i]); s_disc_map_objs[i] = nullptr; }
+ s_disc_map_obj_n = 0;
+}
+static void discoverDrawCoverage(lv_obj_t* parent, double cwx, double cwy) {
+ const int cap = (int)(sizeof(s_disc_map_objs) / sizeof(s_disc_map_objs[0]));
+ const int total = s_disc_track_n < k_disc_track_max ? s_disc_track_n : k_disc_track_max;
+ for (int k = 0; k < total && s_disc_map_obj_n < cap; ++k) {
+ const DiscTrackPt& p = s_disc_track[k];
+ double mwx, mwy;
+ latLonToWorldPx((double)p.lat_e6 / 1e6, (double)p.lon_e6 / 1e6, s_map_zoom, &mwx, &mwy);
+ const int sx = (int)(mwx - cwx + k_map_canvas_w / 2);
+ const int sy = (int)(mwy - cwy + k_map_canvas_h / 2);
+ if (sx < -4 || sx >= k_map_canvas_w + 4 || sy < -4 || sy >= k_map_canvas_h + 4) continue;
+ int r = p.best_rssi; if (r > -50) r = -50; if (r < -110) r = -110; // clamp to the colour range
+ int g = (r + 110) * 255 / 60; // 0 (weak) .. 255 (strong)
+ uint32_t col = ((uint32_t)(255 - g) << 16) | ((uint32_t)g << 8); // red -> green
+ lv_obj_t* d = lv_obj_create(parent);
+ lv_obj_remove_style_all(d);
+ lv_obj_set_size(d, 8, 8);
+ lv_obj_set_pos(d, sx - 4, sy - 4);
+ lv_obj_set_style_bg_color(d, lv_color_hex(col), LV_PART_MAIN);
+ lv_obj_set_style_bg_opa(d, LV_OPA_70, LV_PART_MAIN);
+ lv_obj_set_style_border_color(d, lv_color_hex(0x101010), LV_PART_MAIN);
+ lv_obj_set_style_border_width(d, 1, LV_PART_MAIN);
+ lv_obj_set_style_radius(d, 4, LV_PART_MAIN);
+ lv_obj_clear_flag(d, LV_OBJ_FLAG_CLICKABLE);
+ s_disc_map_objs[s_disc_map_obj_n++] = d;
+ }
+}
+// Discover page's "Show on map": centre the map on our current position + open the Map tab, where
+// the coverage overlay draws. Keeps the centre (s_map_view_inited) so onMapTabActivated won't snap back.
+static void discoverJumpToMapHere() {
+ UITask* task = g_lv.task;
+ if (task && task->getGpsFix()) {
+ s_map_center_lat = task->getNodeLat();
+ s_map_center_lon = task->getNodeLon();
+ s_map_view_inited = true;
+ }
+ closeDiscoverPage();
+ goToTab(MAP_TAB_INDEX);
+}
+
static void freeMapMarkers() {
for (auto& m : s_map_markers) {
if (m.obj) { lv_obj_del(m.obj); m.obj = nullptr; }
@@ -24644,6 +25008,7 @@ static void freeMapMarkers() {
if (s_route_objs[i]) { lv_obj_del(s_route_objs[i]); s_route_objs[i] = nullptr; }
}
s_route_obj_n = 0;
+ discoverFreeMapObjs();
}
static void openMarkerPopupForContact(int mesh_idx);
@@ -24783,6 +25148,9 @@ static void renderMapMarkers() {
++slot;
}
+ // Discover wardriving coverage dots (my logged signal samples), under the route overlay.
+ if (s_disc_track_n > 0) discoverDrawCoverage(parent, cwx, cwy);
+
// Route-replay overlay — drawn last so the path + nodes sit above tiles,
// link lines and contact markers.
if (s_route_active) drawRouteOverlay(parent, cwx, cwy);
@@ -35111,6 +35479,7 @@ enum AppDrawerAction {
APPACT_CHATS, APPACT_CONTACTS, APPACT_MAP, APPACT_SETTINGS,
APPACT_ADVERT, APPACT_POWER, APPACT_MENTIONS, APPACT_CMDCENTER, APPACT_SIGNAL,
APPACT_TERMINAL, APPACT_FILES, APPACT_MONITOR, APPACT_SPECTRUM, APPACT_SNAKE, APPACT_VNC, APPACT_REMOTE, APPACT_READER,
+ APPACT_DISCOVER,
};
static void closeAppDrawer() {
@@ -35355,6 +35724,7 @@ static void appTileCb(lv_event_t* e) {
case APPACT_SIGNAL: openSignalInfoPopup(); return; // signal/traffic + auto-discover settings
case APPACT_MONITOR: openMonitorPage(); return; // RF activity graph + repeater-style radio stats
case APPACT_SPECTRUM: openSpectrumPage(); return; // swept RF spectrum analyzer (borrows the radio)
+ case APPACT_DISCOVER: openDiscoverPage(); return; // active node-discovery sweep + nearby list
#if !defined(HAS_TANMATSU)
case APPACT_VNC: openVncPage(); return; // screen mirror + remote control from a browser
case APPACT_REMOTE: openRemotePage(); return; // reboot into the web-resolution headless UI
@@ -35702,7 +36072,7 @@ static void openAppDrawer() {
{ LV_SYMBOL_ENVELOPE, "Chats", APPACT_CHATS, unread, 0x4F9DF7 }, // messaging blue
{ TOUCH_SYM_PERSON, "Contacts", APPACT_CONTACTS, 0, 0xA784E0 }, // people violet
{ LV_SYMBOL_GPS, "Map", APPACT_MAP, 0, 0x53C06B }, // location green
- { "@", "Mentions", APPACT_MENTIONS, mentions, 0xF2A33C }, // mention amber
+ { LV_SYMBOL_REFRESH, "Discover", APPACT_DISCOVER, 0, 0x9B59FF }, // active node-discovery sweep (purple)
{ LV_SYMBOL_UPLOAD, "Advertise", APPACT_ADVERT, 0, 0xE072B0 }, // broadcast magenta
#if !defined(HAS_TANMATSU)
{ LV_SYMBOL_IMAGE, "VNC", APPACT_VNC, 0, 0x6C7CF0 }, // browser screen-mirror indigo
@@ -35714,6 +36084,7 @@ static void openAppDrawer() {
{ nullptr, "Signal", APPACT_SIGNAL, 0, COLOR_ACCENT }, // theme (drawn signal bars)
{ TOUCH_SYM_ANTENNA, "Monitor", APPACT_MONITOR, 0, 0x35C9C9 }, // RF monitor cyan
{ TOUCH_SYM_ANTENNA, "Spectrum", APPACT_SPECTRUM, 0, 0xE8A33D }, // RF spectrum analyzer amber
+ { "@", "Mentions", APPACT_MENTIONS, mentions, 0xF2A33C }, // mention amber
{ LV_SYMBOL_SETTINGS, "Settings", APPACT_SETTINGS, 0, 0x9AA3AD }, // neutral gear grey
#if defined(HAS_TOUCH_UI)
{ ">_", "Terminal", APPACT_TERMINAL, 0, 0x3DD27A }, // console green
@@ -41794,8 +42165,13 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
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.
+ // P4: LVGL renders at half res (upscaled 2x on flush) — full-width band in the abundant 32MB PSRAM.
+ // AMOLED (RM69A10) = 284-wide; TFT-LCD (HI8561) = 270-wide.
+ #if defined(HAS_TDP4_LCD)
+ g_draw_buf_px = 270 * LV_DRAW_BUF_LINES;
+ #else
g_draw_buf_px = 284 * LV_DRAW_BUF_LINES;
+ #endif
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);
@@ -41936,11 +42312,17 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
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.
+ // Render at HALF the native panel; the P4 DisplayDriver upscales 2x on flush, so the whole UI is
+ // uniformly 2x bigger on the high-DPI panel (simpler than per-element scaling). The touch driver
+ // reports in this same logical space. Two SKUs (build-time): AMOLED 568x1232 -> 284x616 (GT9895
+ // touch); TFT-LCD 540x1168 -> 270x584 (HI8561 integrated touch).
+ #if defined(HAS_TDP4_LCD)
+ g_lv.disp_drv.hor_res = 270;
+ g_lv.disp_drv.ver_res = 584;
+ #else
g_lv.disp_drv.hor_res = 284;
g_lv.disp_drv.ver_res = 616;
+ #endif
#elif defined(TLORA_PAGER)
// Fixed landscape via hardware MADCTL rotation (like T-Deck/Heltec below),
// just a different native panel size — no ui_landscape ternary needed since
@@ -44312,6 +44694,7 @@ static const PopupEnt k_popup_registry[] = {
{ P_OPEN(s_urlmenu_root), []{ closeUrlMenu(); }, PF_COUNT }, // chat URL -> action menu
{ P_OPEN(s_meminfo_root), []{ closeMemInfo(); }, PF_COUNT },
{ P_OPEN(s_monitor_root), []{ closeMonitorPage(); }, PF_COUNT },
+ { P_OPEN(s_discover_root), []{ closeDiscoverPage(); }, PF_COUNT },
{ P_OPEN(s_spec_root), []{ closeSpectrumPage(); }, PF_COUNT },
{ P_OPEN(s_advert_root), []{ closeAdvertPage(); }, PF_COUNT }, // was dismissable but never counted
#if defined(HAS_EXPANSION_KIT)
diff --git a/tdisplay_p4/components/wadamesh_app/CMakeLists.txt b/tdisplay_p4/components/wadamesh_app/CMakeLists.txt
index 4b3cbdd..31eeb32 100644
--- a/tdisplay_p4/components/wadamesh_app/CMakeLists.txt
+++ b/tdisplay_p4/components/wadamesh_app/CMakeLists.txt
@@ -6,7 +6,10 @@
# 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")
+# Also glob variants/tdisplay_p4/*.c — the vendored esp_lcd_hi8561.c (Espressif Apache-2.0, verbatim)
+# is kept as C so it compiles without the C++ strictness patches esp_lcd_rm69a10.cpp needed; its header
+# has extern "C", so HI8561Display.cpp (C++, LCD SKU) links against it fine.
+file(GLOB_RECURSE APP_SRCS "${APP}/*.cpp" "${APP}/*.c" "${REPO}/variants/tdisplay_p4/*.cpp" "${REPO}/variants/tdisplay_p4/*.c")
list(FILTER APP_SRCS EXCLUDE REGEX "/main\\.cpp$")
list(FILTER APP_SRCS EXCLUDE REGEX "/helpers/input/(HeltecV4CapTouch|TDeckKeyboard|TDeckTouch|TDeckTrackball|RakTapV2Touch)\\.cpp$")
diff --git a/tdisplay_p4/main/CMakeLists.txt b/tdisplay_p4/main/CMakeLists.txt
index c680550..ae823f0 100644
--- a/tdisplay_p4/main/CMakeLists.txt
+++ b/tdisplay_p4/main/CMakeLists.txt
@@ -52,9 +52,8 @@ set(WADA_DEFS
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
+ # --- Display: panel SKU (DISPLAY_CLASS / SCREEN_WIDTH / SCREEN_HEIGHT) is selected in the
+ # WADA_P4_LCD block below — the RESET line (XL9535 IO2) is shared by both SKUs. ---
XL9535_SCREEN_RST_IO=2
# --- Touch: HI8561 (I2C_1); RST/INT on the expander ---
@@ -81,6 +80,22 @@ set(WADA_DEFS
foreach(_d ${WADA_DEFS})
idf_build_set_property(COMPILE_OPTIONS "-D${_d}" APPEND)
endforeach()
+
+# --- Panel SKU: the LilyGo T-Display P4 ships as AMOLED (RM69A10, self-emissive, default) or
+# TFT-LCD (HI8561 TDDI, 540x1168 + integrated touch + DCS-0x51 backlight). LilyGo themselves branch
+# per-panel ([rm69a10] vs [hi8561] firmware), so we build a SEPARATE bin per SKU — the proven AMOLED
+# build stays byte-for-byte untouched. Select the LCD with `WADA_P4_LCD=1 ./build.sh build`
+# (compiles HI8561Display + the HI8561 integrated-touch path in Hi8561Touch.cpp). ---
+if(DEFINED ENV{WADA_P4_LCD})
+ set(P4_DISPLAY_DEFS DISPLAY_CLASS=HI8561Display SCREEN_WIDTH=540 SCREEN_HEIGHT=1168 HAS_TDP4_LCD=1)
+ message(STATUS "wadamesh: T-Display P4 panel = HI8561 TFT-LCD (540x1168)")
+else()
+ set(P4_DISPLAY_DEFS DISPLAY_CLASS=RM69A10Display SCREEN_WIDTH=568 SCREEN_HEIGHT=1232)
+ message(STATUS "wadamesh: T-Display P4 panel = RM69A10 AMOLED (568x1232)")
+endif()
+foreach(_d ${P4_DISPLAY_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).
diff --git a/variants/tdisplay_p4/HI8561Display.cpp b/variants/tdisplay_p4/HI8561Display.cpp
new file mode 100644
index 0000000..b37f970
--- /dev/null
+++ b/variants/tdisplay_p4/HI8561Display.cpp
@@ -0,0 +1,192 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// HI8561 TFT-LCD DisplayDriver for the LilyGo T-Display P4 (LCD SKU). Mirrors RM69A10Display.cpp
+// almost line-for-line — the ESP32-P4 MIPI-DSI bring-up is identical; only the vendor panel driver,
+// the 540x1168 resolution and the DPI timing differ. The vendor panel driver lives in the vendored
+// esp_lcd_hi8561.{h,cpp} (Espressif's Apache-2.0 esp_lcd_hi8561, from Waveshare-ESP32-components,
+// unmodified except the .c->.cpp rename for our variants/ glob). The 540x1168 @ 48 MHz DPI timing is
+// the vendor's HI8561_540_1168_PANEL_60HZ_DPI_CONFIG macro (hand-inlined here so num_fbs / pixel
+// format stay explicit and this file diffs cleanly against RM69A10Display.cpp). The DSI-PHY LDO is
+// kept at the tested-on-the-board 1.83 V (a deprecated LilyGo P4 bin notes "changed MIPI voltage
+// domain to 1.8V" — same rail for both panels), NOT the 2.5 V a generic HI8561 eval board uses.
+#if defined(HAS_TDISPLAY_P4)
+#include "HI8561Display.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_hi8561.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. This binary semaphore makes each flush
+// synchronous: draw_bitmap -> wait for done. (Same contract as RM69A10Display.)
+static SemaphoreHandle_t s_flush_sem = nullptr;
+static bool IRAM_ATTR hi8561TransDone(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;
+}
+
+// HI8561 TFT-LCD, 540x1168 portrait. DPI timing = vendor HI8561_540_1168_PANEL_60HZ_DPI_CONFIG.
+#define HI_W 540
+#define HI_H 1168
+// UI scale: the panel is high-DPI, so LVGL renders at 1/HI_UI_SCALE and writePixelsRGB565
+// nearest-neighbour-upscales each flush to fill the native panel — whole UI HI_UI_SCALE x bigger,
+// uniformly. UITask's HAS_TDP4_LCD LVGL res + the HI8561-touch scale must match (HI_W/HI_UI_SCALE).
+#ifndef HI_UI_SCALE
+#define HI_UI_SCALE 2
+#endif
+#define HI_DPI_MHZ 48 // vendor HI8561_540_1168 DPI clock
+#define HI_LANES 2
+#define HI_BITRATE 1000 // Mbps per lane (vendor HI8561_PANEL_BUS_DSI_2CH_CONFIG)
+#define HI_HSYNC 20 // hsync_pulse_width
+#define HI_HBP 40 // hsync_back_porch
+#define HI_HFP 20 // hsync_front_porch
+#define HI_VSYNC 2 // vsync_pulse_width
+#define HI_VBP 12 // vsync_back_porch
+#define HI_VFP 200 // vsync_front_porch
+// ESP32-P4 MIPI-DSI PHY internal LDO: channel 3 @ 1.83V — the tested LilyGo T-Display P4 value
+// (same rail the RM69A10 SKU uses; the board's MIPI voltage domain is 1.8V, per LilyGo).
+#define DSI_LDO_CHAN 3
+#define DSI_LDO_MV 1830
+
+static esp_ldo_channel_handle_t s_ldo = nullptr;
+
+bool HI8561Display::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("[HI8561] 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). Same
+ // HIGH -> LOW -> HIGH / 200 ms order as the RM69A10 SKU, done AFTER the LDO and BEFORE the DSI bus.
+ 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 = HI_LANES,
+ .phy_clk_src = MIPI_DSI_PHY_CLK_SRC_DEFAULT, .lane_bit_rate_mbps = HI_BITRATE,
+ };
+ if (esp_lcd_new_dsi_bus(&bus_cfg, &dsi_bus) != ESP_OK) { Serial.println("[HI8561] 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("[HI8561] dbi_io fail"); return false; }
+
+ // 4. DPI (video) config — vendor HI8561_540_1168_PANEL_60HZ_DPI_CONFIG values, inlined.
+ esp_lcd_dpi_panel_config_t dpi_cfg = {
+ .virtual_channel = 0,
+ .dpi_clk_src = MIPI_DSI_DPI_CLK_SRC_DEFAULT,
+ .dpi_clock_freq_mhz = HI_DPI_MHZ,
+ .pixel_format = LCD_COLOR_PIXEL_FORMAT_RGB565,
+ .num_fbs = 1,
+ .video_timing = {
+ .h_size = HI_W, .v_size = HI_H,
+ .hsync_pulse_width = HI_HSYNC, .hsync_back_porch = HI_HBP, .hsync_front_porch = HI_HFP,
+ .vsync_pulse_width = HI_VSYNC, .vsync_back_porch = HI_VBP, .vsync_front_porch = HI_VFP,
+ },
+ .flags = { .use_dma2d = true },
+ };
+
+ // 5. HI8561 vendor panel (reset via the XL9535 -> reset_gpio_num=-1; use the driver's default init).
+ hi8561_vendor_config_t vendor = {
+ .init_cmds = nullptr, .init_cmds_size = 0,
+ .mipi_config = { .dsi_bus = dsi_bus, .dpi_config = &dpi_cfg },
+ };
+ 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_hi8561(dbi_io, &dev, &_panel) != ESP_OK) { Serial.println("[HI8561] new_panel fail"); return false; }
+
+ esp_lcd_panel_reset(_panel);
+ if (esp_lcd_panel_init(_panel) != ESP_OK) { Serial.println("[HI8561] panel_init fail"); return false; }
+ esp_lcd_panel_disp_on_off(_panel, true);
+
+ // Brightness: the HI8561's default init does SLPOUT/DISPON but leaves brightness unset; drive it at
+ // runtime via DCS 0x51 (the TDDI's integrated backlight — no GPIO). Keep the DBI IO handle so
+ // setBrightness() can drive it live (CC slider); start at max so the LCD actually lights.
+ _dbi_io = dbi_io;
+ setBrightness(255);
+ _on = true;
+
+ // Signal each draw_bitmap's frame-buffer copy completion so writePixelsRGB565 can wait for it.
+ s_flush_sem = xSemaphoreCreateBinary();
+ esp_lcd_dpi_panel_event_callbacks_t cbs = {};
+ cbs.on_color_trans_done = hi8561TransDone;
+ 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)HI_W * SH, 2, MALLOC_CAP_SPIRAM);
+ if (strip) {
+ for (int y0 = 0; y0 < HI_H; y0 += SH) {
+ int hh = (y0 + SH <= HI_H) ? SH : (HI_H - y0);
+ esp_lcd_panel_draw_bitmap(_panel, 0, y0, HI_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("[HI8561] up %dx%d\n", HI_W, HI_H);
+ return true;
+}
+
+void HI8561Display::writePixelsRGB565(int x, int y, int w, int h, const uint16_t* pixels) {
+ if (!_panel || !pixels || w <= 0 || h <= 0) return;
+#if HI_UI_SCALE > 1
+ // Nearest-neighbour upscale the (half-res) LVGL band to the native panel: each source pixel becomes
+ // an HI_UI_SCALE x HI_UI_SCALE block. One expanded band -> one draw_bitmap (exclusive end coords).
+ const int S = HI_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 HI8561Display::setBrightness(uint8_t b) {
+ if (!_dbi_io) return;
+ esp_lcd_panel_io_tx_param(_dbi_io, 0x51, &b, 1); // DCS SET_DISPLAY_BRIGHTNESS
+}
+
+void HI8561Display::turnOn() { if (_panel) esp_lcd_panel_disp_on_off(_panel, true); _on = true; }
+void HI8561Display::turnOff() { if (_panel) esp_lcd_panel_disp_on_off(_panel, false); _on = false; }
+
+#endif // HAS_TDISPLAY_P4
diff --git a/variants/tdisplay_p4/HI8561Display.h b/variants/tdisplay_p4/HI8561Display.h
new file mode 100644
index 0000000..e867a9b
--- /dev/null
+++ b/variants/tdisplay_p4/HI8561Display.h
@@ -0,0 +1,48 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+#pragma once
+// HI8561 TFT-LCD (MIPI-DSI, 540x1168 portrait) — wadamesh DisplayDriver for the LilyGo T-Display P4
+// *LCD* SKU. The T-Display P4 ships in two panel variants (LilyGo's own firmware branches
+// [hi8561] vs [rm69a10]): the AMOLED SKU uses the RM69A10 (see RM69A10Display), the TFT-LCD SKU
+// uses the HI8561 TDDI (integrated display+touch driver). This class is the LCD-SKU display; it is
+// a near line-for-line mirror of RM69A10Display — the P4 DSI bring-up is identical (internal LDO ->
+// DSI bus -> DBI IO -> DPI panel -> vendor init -> reset/init/on) — only the vendor panel driver
+// (esp_lcd_new_panel_hi8561, in the vendored Apache-2.0 esp_lcd_hi8561.*), the resolution
+// (540x1168), and the DPI timing (48 MHz, from the vendor's HI8561_540_1168 macro) differ. The
+// panel RESET line is on the XL9535 (released in RM/HI begin), so the vendor reset_gpio_num = -1.
+// Backlight is HI8561-internal (no GPIO): brightness is DCS 0x51, like the RM69A10.
+// Selected at build time by HAS_TDP4_LCD (DISPLAY_CLASS=HI8561Display) — see tdisplay_p4/main/CMakeLists.txt.
+#include
+#include
+#include "esp_lcd_types.h"
+
+class HI8561Display : 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:
+ HI8561Display() : DisplayDriver(540, 1168) {}
+
+ bool begin(); // full DSI bring-up; returns false on failure (logs the stage)
+
+ // LVGL flush hot path — exclusive end coords (see RM69A10Display 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); // HI8561 cmd 0x51 (integrated backlight)
+ 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/Hi8561Touch.cpp b/variants/tdisplay_p4/Hi8561Touch.cpp
index 63b2a19..08eb732 100644
--- a/variants/tdisplay_p4/Hi8561Touch.cpp
+++ b/variants/tdisplay_p4/Hi8561Touch.cpp
@@ -43,24 +43,49 @@
// 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.
+// Logical (LVGL) space = native panel / UI-scale(2). The LilyGo T-Display P4 has TWO panel SKUs,
+// each with its OWN touch controller (a build-time choice — HAS_TDP4_LCD):
+// AMOLED (RM69A10, 568x1232) -> 284x616 logical, touch = Goodix GT9895 (0x5D).
+// TFT-LCD (HI8561, 540x1168) -> 270x584 logical, touch = HI8561 integrated TDDI touch (0x68).
+#if defined(HAS_TDP4_LCD)
+static const int SCR_W = 270;
+static const int SCR_H = 584;
+static const int RAW_W = 270;
+static const int RAW_H = 584;
+
+// The HI8561 is a TDDI (touch+display in one). Its integrated touch is at I2C 0x68 and reads via an
+// indirect ERAM-pointer protocol (ported from LilyGo cpp_bus_driver hi8561_touch.cpp, GPL-3.0):
+// once at init, read a touch-info start-address out of the ERAM section table (InitAddressInfo);
+// then each poll writes {0xF3, addr(BE32), 0x03} and reads the point back (x/y big-endian, 0xFFFF =
+// no finger). It reports in panel-native 540x1168; we scale /2 (SCR_W/HI8561_NATIVE_W) to logical.
+// TODO(device): confirm the native grid + axis orientation against on-screen taps (the touch-debug
+// overlay shows raw coords, via heltecV4CapTouchGetRaw).
+static const uint8_t HI8561_ADDR = 0x68;
+static const uint32_t HI8561_ERAM_BASE = 0x20011000;
+static const uint16_t HI8561_ERAM_SIZE = 4096;
+// kEsramSectionInfoStartAddress = base + 4 + 25*8 + 4 = 0x200110D0 (see cpp_bus_driver hi8561_touch.h).
+static const uint32_t HI8561_ESRAM_SECTION_INFO = HI8561_ERAM_BASE + 4 + 25 * 8 + 4;
+static const uint8_t HI8561_POINT_OFFSET = 3; // kTouchPointAddressOffset
+static const int HI8561_NATIVE_W = 540;
+static const int HI8561_NATIVE_H = 1168;
+static uint32_t s_hi8561_info_addr = 0; // touch_info_start_address_ (learned at init)
+#else
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
+// NOTE: despite this file's name, the LilyGo T-Display P4 AMOLED (RM69A10) SKU actually ships a
+// Goodix GT9895 touch controller at 0x5D — NOT the HI8561 (that's the LCD SKU, gated above). An I2C
+// bus scan on the real board found 0x5D. This path 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.
+// 32-bit address big-endian (4 bytes), then read the report.
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).
+// The GT9895 reports in its native 1060x2400 digitiser grid; scale to the 284x616 logical space.
static const int GT9895_NATIVE_W = 1060;
static const int GT9895_NATIVE_H = 2400;
+#endif
static bool s_init_ok = false;
static bool s_given_up = false;
@@ -97,9 +122,21 @@ static uint8_t s_point_rotation = 0;
#define P4_TOUCH_LONG_MS 1000
#endif
-// ---- GT9895 low-level access ----
+// ---- Touch-controller low-level access ----
-// Write an N-byte command (repeated start), then read M bytes.
+// Write an N-byte command (repeated start, no stop), then read M bytes. One helper per SKU (only
+// the compiled one is used); the transaction shape is identical, just the I2C address differs.
+#if defined(HAS_TDP4_LCD)
+static bool hi8561WriteRead(const uint8_t* cmd, uint8_t clen, uint8_t* out, uint8_t olen) {
+ Wire.beginTransmission(HI8561_ADDR);
+ Wire.write(cmd, clen);
+ if (Wire.endTransmission(false) != 0) return false; // repeated start, no stop
+ uint8_t got = Wire.requestFrom((int)HI8561_ADDR, (int)olen);
+ if (got != olen) return false;
+ for (uint8_t i = 0; i < olen; i++) out[i] = Wire.read();
+ return true;
+}
+#else
static bool gt9895WriteRead(const uint8_t* cmd, uint8_t clen, uint8_t* out, uint8_t olen) {
Wire.beginTransmission(GT9895_ADDR);
Wire.write(cmd, clen);
@@ -109,6 +146,7 @@ static bool gt9895WriteRead(const uint8_t* cmd, uint8_t clen, uint8_t* out, uint
for (uint8_t i = 0; i < olen; i++) out[i] = Wire.read();
return true;
}
+#endif
// ---- Init (called by UITask loop every tick until success or give-up) ----
@@ -140,6 +178,41 @@ bool heltecV4CapTouchBegin() {
Serial.println(sc);
}
+#if defined(HAS_TDP4_LCD)
+ // HI8561 integrated TDDI touch at 0x68 — probe, then learn the ERAM touch-info pointer once.
+ Wire.beginTransmission(HI8561_ADDR);
+ if (Wire.endTransmission() != 0) {
+ snprintf(s_scan_str, sizeof s_scan_str, "i2c7/8: no ACK at 0x%02X", HI8561_ADDR);
+ Serial.printf("[TOUCH] HI8561 not found (retry %d/3)\n", s_retries);
+ return false;
+ }
+ // InitAddressInfo: read touch_info_start_address_ out of the ERAM section table (offset 8).
+ {
+ const uint32_t sa = HI8561_ESRAM_SECTION_INFO;
+ uint8_t cmd[6] = { 0xF3, (uint8_t)(sa >> 24), (uint8_t)(sa >> 16),
+ (uint8_t)(sa >> 8), (uint8_t)sa, 0x03 };
+ uint8_t buf[48] = {0};
+ if (!hi8561WriteRead(cmd, sizeof cmd, buf, sizeof buf)) {
+ snprintf(s_scan_str, sizeof s_scan_str, "0x%02X hi8561 info read fail", HI8561_ADDR);
+ Serial.printf("[TOUCH] %s (retry %d/3)\n", s_scan_str, s_retries);
+ return false;
+ }
+ uint32_t info = (uint32_t)buf[8] | ((uint32_t)buf[9] << 8) |
+ ((uint32_t)buf[10] << 16) | ((uint32_t)buf[11] << 24);
+ if (info < HI8561_ERAM_BASE || info >= (HI8561_ERAM_BASE + HI8561_ERAM_SIZE)) {
+ snprintf(s_scan_str, sizeof s_scan_str, "0x%02X hi8561 info bad 0x%08lX",
+ HI8561_ADDR, (unsigned long)info);
+ Serial.printf("[TOUCH] %s (retry %d/3)\n", s_scan_str, s_retries);
+ return false;
+ }
+ s_hi8561_info_addr = info;
+ }
+ s_init_ok = true;
+ snprintf(s_scan_str, sizeof s_scan_str, "0x%02X hi8561 OK (info 0x%08lX)",
+ HI8561_ADDR, (unsigned long)s_hi8561_info_addr);
+ Serial.printf("[TOUCH] %s\n", s_scan_str);
+ return true;
+#else
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);
@@ -151,6 +224,7 @@ bool heltecV4CapTouchBegin() {
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;
+#endif
}
// ---- Coordinate mapping (identity in portrait; rotation kept for parity) ----
@@ -169,12 +243,32 @@ static void mapRaw(uint16_t rx, uint16_t ry, uint16_t* ox, uint16_t* oy) {
}
// ---- Physical report read ------------------------------------------------
-// Returns 1 = finger down (rx/ry set to raw panel coords), 0 = no touch, -1 = I2C error.
-//
+// Returns 1 = finger down (rx/ry set to logical coords), 0 = no touch, -1 = I2C error.
+// TODO(device): verify X/Y axis orientation + origin against on-screen taps (may need flip/swap in mapRaw).
+#if defined(HAS_TDP4_LCD)
+// HI8561 integrated touch: read finger 1's point at (info_addr + kTouchPointAddressOffset). The
+// report is 5 bytes: [x_hi][x_lo][y_hi][y_lo][pressure] (x/y big-endian); 0xFFFF/0xFFFF = no touch.
+static int hi8561ReadPoint(uint16_t* rx, uint16_t* ry) {
+ if (!s_hi8561_info_addr) return -1;
+ const uint32_t addr = s_hi8561_info_addr + HI8561_POINT_OFFSET; // finger 1 (stride handled at init)
+ uint8_t cmd[6] = { 0xF3, (uint8_t)(addr >> 24), (uint8_t)(addr >> 16),
+ (uint8_t)(addr >> 8), (uint8_t)addr, 0x03 };
+ uint8_t buf[5] = {0};
+ if (!hi8561WriteRead(cmd, sizeof cmd, buf, sizeof buf)) return -1;
+ uint16_t x = ((uint16_t)buf[0] << 8) | buf[1]; // big-endian
+ uint16_t y = ((uint16_t)buf[2] << 8) | buf[3];
+ if (x == 0xFFFF && y == 0xFFFF) return 0; // no finger down
+ if (x >= HI8561_NATIVE_W) x = HI8561_NATIVE_W - 1; // clamp a stray out-of-range report
+ if (y >= HI8561_NATIVE_H) y = HI8561_NATIVE_H - 1;
+ // Scale the native 540x1168 grid down to the 270x584 logical space.
+ *rx = (uint16_t)((uint32_t)x * SCR_W / HI8561_NATIVE_W);
+ *ry = (uint16_t)((uint32_t)y * SCR_H / HI8561_NATIVE_H);
+ return 1;
+}
+#else
// 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).
+// (x/y little-endian).
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;
@@ -182,11 +276,12 @@ static int hi8561ReadPoint(uint16_t* rx, uint16_t* ry) {
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.
+ // Scale the native 1060x2400 grid down to the 284x616 logical space.
*rx = (uint16_t)(x * (uint32_t)SCR_W / GT9895_NATIVE_W);
*ry = (uint16_t)(y * (uint32_t)SCR_H / GT9895_NATIVE_H);
return 1;
}
+#endif
static void hi8561Poll() {
uint16_t rx = 0, ry = 0;
diff --git a/variants/tdisplay_p4/TDISPLAY_P4_PORT.md b/variants/tdisplay_p4/TDISPLAY_P4_PORT.md
index d9fa52d..2a2891c 100644
--- a/variants/tdisplay_p4/TDISPLAY_P4_PORT.md
+++ b/variants/tdisplay_p4/TDISPLAY_P4_PORT.md
@@ -10,8 +10,10 @@ flashes over the right-side USB (USB-Serial-JTAG). LoRa region = EU 868.
- **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
+- **Display** two SKUs (LilyGo branches [rm69a10]/[hi8561]): RM69A10 AMOLED MIPI-DSI 568×1232, or
+ HI8561 TFT-LCD MIPI-DSI 540×1168 — both portrait, reset via XL9535 IO2. Build-time select (below).
+- **Touch** per-SKU on I2C_1 (7/8), RST/INT via XL9535: AMOLED = Goodix GT9895 (0x5D); LCD = HI8561
+ integrated TDDI touch (0x68)
- **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
@@ -110,3 +112,24 @@ rm69a10_driver.cpp,t_display_p4_driver.cpp}`.
## Build
`cd tdisplay_p4 && ./build.sh build` · flash: `./build.sh flash -p /dev/cu.usbmodem`
+
+## TFT-LCD SKU (HI8561) — second panel variant (DONE, needs on-device verify)
+The T-Display P4 ships in two panels; LilyGo branches its own firmware `[rm69a10]` (AMOLED) vs
+`[hi8561]` (TFT-LCD). We build a **separate bin per SKU** (vendor-style) so the proven AMOLED build
+is never touched. **Select the LCD at build time:** `WADA_P4_LCD=1 ./build.sh build`.
+- **Display** `HI8561Display.{h,cpp}` — mirrors `RM69A10Display` line-for-line; only the vendor panel
+ driver (`esp_lcd_new_panel_hi8561`), the resolution (**540×1168**) and the DPI timing (**48 MHz**,
+ `hbp40/hpw20/hfp20 · vbp12/vpw2/vfp200`) differ. Same P4 DSI bring-up, same **1.83 V** DSI-PHY LDO
+ (a deprecated LilyGo P4 bin notes "changed MIPI voltage domain to 1.8V"), same XL9535 IO2 reset.
+ Backlight is HI8561-internal (no GPIO) — brightness = DCS **0x51**, like the AMOLED.
+- **Touch** the HI8561 is a TDDI (touch+display in one): its **integrated touch at I2C 0x68** (NOT the
+ AMOLED's GT9895) — added as the `HAS_TDP4_LCD` path in `Hi8561Touch.cpp`. Indirect ERAM-pointer
+ protocol ported from LilyGo `cpp_bus_driver/hi8561_touch.cpp` (GPL-3.0): read the touch-info start
+ address out of ERAM once, then poll finger 1 (`{0xF3,addr(BE32),0x03}` → x/y big-endian).
+- **Vendored** `esp_lcd_hi8561.{h,cpp}` = Espressif's Apache-2.0 driver (from Waveshare-ESP32-components),
+ copied verbatim (`.c`→`.cpp` for our `variants/*.cpp` glob) — same provenance/pattern as `esp_lcd_rm69a10`.
+- **Wiring** `main/CMakeLists.txt` `WADA_P4_LCD` block sets `DISPLAY_CLASS=HI8561Display`,
+ `SCREEN_WIDTH/HEIGHT=540/1168`, `HAS_TDP4_LCD=1`; UITask picks the include/extern + LVGL res (270×584).
+- **On-device verify (no LCD device in-house):** (1) screen lights + renders; (2) touch registers and
+ is aligned — if offset, the HI8561 native touch grid ≠ 540×1168; read raw coords via the Map "Tile
+ debug"/touch-debug overlay (`heltecV4CapTouchGetRaw`) and adjust `HI8561_NATIVE_W/H` + the /2 scale.
diff --git a/variants/tdisplay_p4/esp_lcd_hi8561.c b/variants/tdisplay_p4/esp_lcd_hi8561.c
new file mode 100644
index 0000000..a45408b
--- /dev/null
+++ b/variants/tdisplay_p4/esp_lcd_hi8561.c
@@ -0,0 +1,336 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Espressif Systems (Shanghai) CO LTD
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+#include "soc/soc_caps.h"
+
+#if SOC_MIPI_DSI_SUPPORTED
+#include "esp_check.h"
+#include "esp_log.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 "freertos/FreeRTOS.h"
+#include "freertos/task.h"
+#include "driver/gpio.h"
+#include "esp_lcd_hi8561.h"
+
+#define HI8561_CMD_SHLR_BIT (1 << 0)
+#define HI8561_CMD_UPDN_BIT (1 << 1)
+
+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
+ uint8_t colmod_val; // save surrent value of LCD_CMD_COLMOD register
+ const hi8561_lcd_init_cmd_t *init_cmds;
+ uint16_t init_cmds_size;
+ 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);
+} hi8561_panel_t;
+
+static const char *TAG = "hi8561";
+
+static esp_err_t panel_hi8561_del(esp_lcd_panel_t *panel);
+static esp_err_t panel_hi8561_init(esp_lcd_panel_t *panel);
+static esp_err_t panel_hi8561_reset(esp_lcd_panel_t *panel);
+static esp_err_t panel_hi8561_invert_color(esp_lcd_panel_t *panel, bool invert_color_data);
+static esp_err_t panel_hi8561_mirror(esp_lcd_panel_t *panel, bool mirror_x, bool mirror_y);
+static esp_err_t panel_hi8561_disp_on_off(esp_lcd_panel_t *panel, bool on_off);
+
+esp_err_t esp_lcd_new_panel_hi8561(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)
+{
+ // wadamesh: version-log commented out — the ESP_LCD_HI8561_VER_* macros come from the component's
+ // cmake_utilities dependency, which we don't vendor (same as esp_lcd_rm69a10.cpp). Sole edit to this
+ // otherwise-verbatim Espressif Apache-2.0 driver.
+ // ESP_LOGI(TAG, "version: %d.%d.%d", ESP_LCD_HI8561_VER_MAJOR, ESP_LCD_HI8561_VER_MINOR,
+ // ESP_LCD_HI8561_VER_PATCH);
+ ESP_RETURN_ON_FALSE(io && panel_dev_config && ret_panel, ESP_ERR_INVALID_ARG, TAG, "invalid arguments");
+ hi8561_vendor_config_t *vendor_config = (hi8561_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;
+ hi8561_panel_t *hi8561 = (hi8561_panel_t *)calloc(1, sizeof(hi8561_panel_t));
+ ESP_RETURN_ON_FALSE(hi8561, ESP_ERR_NO_MEM, TAG, "no mem for hi8561 panel");
+
+ if (panel_dev_config->reset_gpio_num >= 0) {
+ gpio_config_t io_conf = {
+ .mode = GPIO_MODE_OUTPUT,
+ .pin_bit_mask = 1ULL << panel_dev_config->reset_gpio_num,
+ };
+ ESP_GOTO_ON_ERROR(gpio_config(&io_conf), err, TAG, "configure GPIO for RST line failed");
+ }
+
+ switch (panel_dev_config->rgb_ele_order) {
+ case LCD_RGB_ELEMENT_ORDER_RGB:
+ hi8561->madctl_val = 0;
+ break;
+ case LCD_RGB_ELEMENT_ORDER_BGR:
+ hi8561->madctl_val |= LCD_CMD_BGR_BIT;
+ break;
+ default:
+ ESP_GOTO_ON_FALSE(false, ESP_ERR_NOT_SUPPORTED, err, TAG, "unsupported color space");
+ break;
+ }
+
+ hi8561->io = io;
+ hi8561->init_cmds = vendor_config->init_cmds;
+ hi8561->init_cmds_size = vendor_config->init_cmds_size;
+ hi8561->reset_gpio_num = panel_dev_config->reset_gpio_num;
+ hi8561->flags.reset_level = panel_dev_config->flags.reset_active_high;
+
+ // Create MIPI DPI panel
+ esp_lcd_panel_handle_t panel_handle = NULL;
+ ESP_GOTO_ON_ERROR(esp_lcd_new_panel_dpi(vendor_config->mipi_config.dsi_bus, vendor_config->mipi_config.dpi_config, &panel_handle), err, TAG,
+ "create MIPI DPI panel failed");
+ ESP_LOGD(TAG, "new MIPI DPI panel @%p", panel_handle);
+
+ // Save the original functions of MIPI DPI panel
+ hi8561->del = panel_handle->del;
+ hi8561->init = panel_handle->init;
+ // Overwrite the functions of MIPI DPI panel
+ panel_handle->del = panel_hi8561_del;
+ panel_handle->init = panel_hi8561_init;
+ panel_handle->reset = panel_hi8561_reset;
+ panel_handle->mirror = panel_hi8561_mirror;
+ panel_handle->invert_color = panel_hi8561_invert_color;
+ panel_handle->disp_on_off = panel_hi8561_disp_on_off;
+ panel_handle->user_data = hi8561;
+ *ret_panel = panel_handle;
+ ESP_LOGD(TAG, "new hi8561 panel @%p", hi8561);
+
+ return ESP_OK;
+
+err:
+ if (hi8561) {
+ if (panel_dev_config->reset_gpio_num >= 0) {
+ gpio_reset_pin(panel_dev_config->reset_gpio_num);
+ }
+ free(hi8561);
+ }
+ return ret;
+}
+
+static const hi8561_lcd_init_cmd_t vendor_specific_init_default[] = {
+ // {cmd, { data }, data_size, delay_ms}
+ /**** CMD_Page 3 ****/
+ {0xDF, (uint8_t[]){0x90, 0x69, 0xF9}, 3, 0},
+ {0xDE, (uint8_t[]){0x00}, 1, 0},
+ {0xBB, (uint8_t[]){0x0F, 0x10, 0x43, 0x50, 0x32, 0x44, 0x44}, 7, 0},
+ {0xBF, (uint8_t[]){0x46, 0x32}, 2, 0},
+ {0xC0, (uint8_t[]){0x01, 0xAD, 0x01, 0xAD}, 4, 0},
+ {0xBD, (uint8_t[]){0x00, 0xB4}, 2, 0},
+ {0xC6, (uint8_t[]){0x00, 0x7D, 0x00, 0xC8, 0x00, 0x17, 0x1A, 0x82, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x01}, 23, 0},
+ {0xC8, (uint8_t[]){0x23, 0x48, 0x87}, 3, 0},
+ // {0xCC, (uint8_t[]){0x33}, 1, 0},//4lane
+ {0xCC, (uint8_t[]){0x31}, 1, 0}, // 2lane
+ // {0xCC, (uint8_t[]){0x30}, 1, 0}, // 1lane
+ {0xBC, (uint8_t[]){0x2E, 0x80, 0x84}, 3, 0},
+ {0xC3, (uint8_t[]){0x3B, 0x01, 0x02, 0x05, 0x0C, 0x0C, 0x75, 0x0A, 0x79, 0x0A, 0x79, 0x02, 0x6E, 0x02, 0x6E, 0x02, 0x6E, 0x0A, 0x0D, 0x0A, 0x0F, 0x0A, 0x0F, 0x0A, 0x0F}, 25, 0},
+ {0xC4, (uint8_t[]){0x01, 0x02, 0x05, 0x0C, 0x0C, 0x75, 0x0A, 0x79, 0x0A, 0x79, 0x02, 0x6E, 0x02, 0x6E, 0x02, 0x6E, 0x0A, 0x0D, 0x0A, 0x0F, 0x0A, 0x0F, 0x0A, 0x0F}, 24, 0},
+ {0xC5, (uint8_t[]){0x03, 0x05, 0x0C, 0x0C, 0x75, 0x0A, 0x79, 0x0A, 0x79, 0x02, 0x6E, 0x02, 0x6E, 0x02, 0x6E, 0x0A, 0x0D, 0x0A, 0x0F, 0x0A, 0x0F, 0x0A, 0x0F}, 23, 0},
+ {0xD7, (uint8_t[]){0x00, 0x0A, 0x63, 0x0A, 0x63, 0x0A, 0x63, 0x0A, 0x63, 0x0A, 0x63, 0x0A, 0x63, 0x0A, 0x63, 0x0A, 0x63}, 17, 0},
+ {0xCB, (uint8_t[]){0x7F, 0x78, 0x71, 0x64, 0x5A, 0x58, 0x4B, 0x51, 0x3A, 0x53, 0x51, 0x4F, 0x6A, 0x54, 0x57, 0x46, 0x3F, 0x2F, 0x1B, 0x0F, 0x08, 0x7F, 0x78, 0x71, 0x64, 0x5A, 0x58, 0x4B, 0x51, 0x3A, 0x53, 0x51, 0x4F, 0x6A, 0x54, 0x57, 0x46, 0x3F, 0x2F, 0x1B, 0x0F, 0x08, 0x00}, 43, 0},
+ {0xCE, (uint8_t[]){0x00, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C}, 23, 0},
+ {0xCF, (uint8_t[]){0x00, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, 45, 0},
+ {0xD0, (uint8_t[]){0x00, 0x1F, 0x1F, 0x11, 0x1E, 0x1F, 0x0F, 0x0F, 0x0D, 0x0D, 0x0B, 0x0B, 0x09, 0x09, 0x07, 0x07, 0x05, 0x05, 0x01, 0x1F, 0x1F, 0x1F, 0x1F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, 29, 0},
+ {0xD1, (uint8_t[]){0x00, 0x1F, 0x1F, 0x10, 0x1E, 0x1F, 0x0E, 0x0E, 0x0C, 0x0C, 0x0A, 0x0A, 0x08, 0x08, 0x06, 0x06, 0x04, 0x04, 0x00, 0x1F, 0x1F, 0x1F, 0x1F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, 29, 0},
+ {0xD2, (uint8_t[]){0x00, 0x5F, 0x1F, 0x10, 0x1F, 0x1E, 0x08, 0x08, 0x4A, 0x0A, 0x0C, 0x0C, 0x0E, 0x0E, 0x04, 0x04, 0x06, 0x06, 0x00, 0x1F, 0x1F, 0x1F, 0x1F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, 29, 0},
+ {0xD3, (uint8_t[]){0x00, 0x1F, 0x1F, 0x11, 0x1F, 0x1E, 0x09, 0x09, 0x0B, 0x0B, 0x0D, 0x0D, 0x0F, 0x0F, 0x05, 0x05, 0x07, 0x07, 0x01, 0x1F, 0x1F, 0x1F, 0x1F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, 29, 0},
+ {0xD4, (uint8_t[]){0x00, 0x20, 0x0B, 0x00, 0x0D, 0x00, 0x0F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x03, 0x03, 0x00, 0x81, 0x04, 0xAE, 0x04, 0xB0, 0x04, 0xB2, 0x04, 0xB4, 0x04, 0xB6, 0x04, 0xB8, 0x00, 0x00, 0x00, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x00, 0x06, 0x44, 0x06, 0x46, 0x03, 0x03, 0x00, 0x00, 0x07, 0x00, 0x06, 0x04, 0xA7, 0x04, 0xA8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x01, 0x00, 0x00, 0x20, 0x00}, 87, 0},
+ {0xD5, (uint8_t[]){0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xE0, 0x00, 0x00, 0x00, 0x07, 0x32, 0x5A, 0x00, 0x00, 0x3C, 0x00, 0x1E, 0x00, 0x1E, 0xB3, 0x00, 0x0F, 0x06, 0x0C, 0x00, 0x71, 0x20, 0x04, 0x10, 0x04, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x1F, 0xFF, 0x00, 0x00, 0x00, 0x1F, 0xFF, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}, 61, 0},
+ {0xCD, (uint8_t[]){0x00, 0x00}, 2, 0},
+
+ {0xDE, (uint8_t[]){0x01}, 1, 0},
+ {0xB9, (uint8_t[]){0x00, 0xFF, 0xFF, 0x04}, 4, 0},
+ {0xC7, (uint8_t[]){0x1F, 0x14, 0x0E}, 3, 0},
+
+ {0xDE, (uint8_t[]){0x02}, 1, 0},
+ {0xE5, (uint8_t[]){0x00, 0x60, 0x60, 0x02, 0x18, 0x60, 0x18, 0x60, 0x09, 0x04, 0x00, 0xC5, 0x01, 0x2C, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x04}, 24, 0},
+ {0xE6, (uint8_t[]){0x10, 0x10, 0x82}, 3, 0},
+ {0xC4, (uint8_t[]){0x00, 0x11, 0x07, 0x00, 0x11, 0x01, 0x08}, 7, 0},
+ {0xC3, (uint8_t[]){0x20, 0xFF}, 2, 0},
+ {0xBD, (uint8_t[]){0x1B}, 1, 0},
+ {0xC6, (uint8_t[]){0x4A, 0x00}, 2, 0},
+ {0xCD, (uint8_t[]){0x14, 0x64, 0x11, 0x40}, 4, 0},
+ {0xC1, (uint8_t[]){0x00, 0x40, 0x00, 0x02, 0x02, 0x02, 0x02, 0x7F, 0x00, 0x00}, 10, 0},
+ {0xB3, (uint8_t[]){0x00, 0xA8}, 2, 0},
+ {0xBB, (uint8_t[]){0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x41, 0x40, 0x43, 0x04}, 11, 0},
+ {0xC2, (uint8_t[]){0x02, 0x42, 0x50, 0x00, 0x02, 0xE4, 0x61, 0x73, 0xF9, 0x08}, 10, 0},
+ {0xEC, (uint8_t[]){0x07, 0x07, 0x40, 0x00, 0x22, 0x02, 0x00, 0xFF, 0x08, 0x7C, 0x00, 0x00, 0x00, 0x00}, 14, 0},
+
+ {0xDE, (uint8_t[]){0x03}, 1, 0},
+ {0xD1, (uint8_t[]){0x00, 0x00, 0x21, 0xFF, 0x00}, 5, 0},
+
+ {0xDE, (uint8_t[]){0x00}, 1, 0},
+ {0x35, (uint8_t[]){0x00}, 0, 30},
+
+ {0x11, (uint8_t[]){0x00}, 0, 120},
+
+ {0x29, (uint8_t[]){0x00}, 0, 50},
+};
+
+static esp_err_t panel_hi8561_del(esp_lcd_panel_t *panel)
+{
+ hi8561_panel_t *hi8561 = (hi8561_panel_t *)panel->user_data;
+
+ // Delete MIPI DPI panel
+ ESP_RETURN_ON_ERROR(hi8561->del(panel), TAG, "del hi8561 panel failed");
+ if (hi8561->reset_gpio_num >= 0) {
+ gpio_reset_pin(hi8561->reset_gpio_num);
+ }
+ ESP_LOGD(TAG, "del hi8561 panel @%p", hi8561);
+ free(hi8561);
+
+ return ESP_OK;
+}
+
+static esp_err_t panel_hi8561_init(esp_lcd_panel_t *panel)
+{
+ hi8561_panel_t *hi8561 = (hi8561_panel_t *)panel->user_data;
+ esp_lcd_panel_io_handle_t io = hi8561->io;
+ const hi8561_lcd_init_cmd_t *init_cmds = NULL;
+ uint16_t init_cmds_size = 0;
+ bool is_cmd_overwritten = false;
+
+ uint8_t ID[3];
+ ESP_RETURN_ON_ERROR(esp_lcd_panel_io_rx_param(io, 0x04, ID, 3), TAG, "read ID failed");
+ ESP_LOGI(TAG, "LCD ID: %02X %02X %02X", ID[0], ID[1], ID[2]);
+
+ ESP_RETURN_ON_ERROR(esp_lcd_panel_io_tx_param(io, LCD_CMD_MADCTL, (uint8_t[]) {
+ hi8561->madctl_val,
+ }, 1), TAG, "send command failed");
+
+ // vendor specific initialization, it can be different between manufacturers
+ // should consult the LCD supplier for initialization sequence code
+ if (hi8561->init_cmds) {
+ init_cmds = hi8561->init_cmds;
+ init_cmds_size = hi8561->init_cmds_size;
+ } else {
+ init_cmds = vendor_specific_init_default;
+ init_cmds_size = sizeof(vendor_specific_init_default) / sizeof(hi8561_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;
+ hi8561->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));
+ }
+ ESP_LOGD(TAG, "send init commands success");
+
+ ESP_RETURN_ON_ERROR(hi8561->init(panel), TAG, "init MIPI DPI panel failed");
+
+ return ESP_OK;
+}
+
+static esp_err_t panel_hi8561_reset(esp_lcd_panel_t *panel)
+{
+ hi8561_panel_t *hi8561 = (hi8561_panel_t *)panel->user_data;
+ esp_lcd_panel_io_handle_t io = hi8561->io;
+
+ // Perform hardware reset
+ if (hi8561->reset_gpio_num >= 0) {
+ gpio_set_level(hi8561->reset_gpio_num, !hi8561->flags.reset_level);
+ vTaskDelay(pdMS_TO_TICKS(5));
+ gpio_set_level(hi8561->reset_gpio_num, hi8561->flags.reset_level);
+ vTaskDelay(pdMS_TO_TICKS(10));
+ gpio_set_level(hi8561->reset_gpio_num, !hi8561->flags.reset_level);
+ vTaskDelay(pdMS_TO_TICKS(120));
+ } 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(120));
+ }
+
+ return ESP_OK;
+}
+
+static esp_err_t panel_hi8561_invert_color(esp_lcd_panel_t *panel, bool invert_color_data)
+{
+ hi8561_panel_t *hi8561 = (hi8561_panel_t *)panel->user_data;
+ esp_lcd_panel_io_handle_t io = hi8561->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;
+}
+
+static esp_err_t panel_hi8561_mirror(esp_lcd_panel_t *panel, bool mirror_x, bool mirror_y)
+{
+ hi8561_panel_t *hi8561 = (hi8561_panel_t *)panel->user_data;
+ esp_lcd_panel_io_handle_t io = hi8561->io;
+ uint8_t madctl_val = hi8561->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 |= HI8561_CMD_SHLR_BIT;
+ } else {
+ madctl_val &= ~HI8561_CMD_SHLR_BIT;
+ }
+ if (mirror_y) {
+ madctl_val |= HI8561_CMD_UPDN_BIT;
+ } else {
+ madctl_val &= ~HI8561_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");
+ hi8561->madctl_val = madctl_val;
+
+ return ESP_OK;
+}
+
+static esp_err_t panel_hi8561_disp_on_off(esp_lcd_panel_t *panel, bool on_off)
+{
+ hi8561_panel_t *hi8561 = (hi8561_panel_t *)panel->user_data;
+ esp_lcd_panel_io_handle_t io = hi8561->io;
+ int command = 0;
+
+ if (on_off) {
+ command = LCD_CMD_DISPON;
+ } else {
+ command = LCD_CMD_DISPOFF;
+ }
+ ESP_RETURN_ON_ERROR(esp_lcd_panel_io_tx_param(io, command, NULL, 0), TAG, "send command failed");
+ return ESP_OK;
+}
+#endif
diff --git a/variants/tdisplay_p4/esp_lcd_hi8561.h b/variants/tdisplay_p4/esp_lcd_hi8561.h
new file mode 100644
index 0000000..9c561be
--- /dev/null
+++ b/variants/tdisplay_p4/esp_lcd_hi8561.h
@@ -0,0 +1,168 @@
+/*
+ * SPDX-FileCopyrightText: 2024-2025 Espressif Systems (Shanghai) CO LTD
+ *
+ * SPDX-License-Identifier: Apache-2.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"
+#include "esp_idf_version.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/**
+ * @brief LCD panel initialization commands.
+ *
+ */
+typedef struct {
+ int cmd; /*= ESP_IDF_VERSION_VAL(6, 0, 0)
+#ifndef LCD_COLOR_PIXEL_FORMAT_RGB565
+#define LCD_COLOR_PIXEL_FORMAT_RGB565 LCD_COLOR_FMT_RGB565
+#endif
+#ifndef LCD_COLOR_PIXEL_FORMAT_RGB666
+#define LCD_COLOR_PIXEL_FORMAT_RGB666 LCD_COLOR_FMT_RGB888
+#endif
+#ifndef LCD_COLOR_PIXEL_FORMAT_RGB888
+#define LCD_COLOR_PIXEL_FORMAT_RGB888 LCD_COLOR_FMT_RGB888
+#endif
+#define HI8561_540_1168_PANEL_60HZ_DPI_CONFIG(color_format) HI8561_540_1168_PANEL_60HZ_DPI_CONFIG_CF(color_format)
+#endif
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/variants/tdisplay_p4/target.h b/variants/tdisplay_p4/target.h
index 7d6a7c4..c7b6850 100644
--- a/variants/tdisplay_p4/target.h
+++ b/variants/tdisplay_p4/target.h
@@ -15,7 +15,11 @@ extern "C" void set_boot_phase(int phase);
#include "../../src/helpers/ClockFloorRTC.h" // monotonic send-timestamp floor (issue #89)
#include
#include
-#include "RM69A10Display.h"
+#if defined(HAS_TDP4_LCD)
+#include "HI8561Display.h" // HI8561 TFT-LCD (LCD SKU) — DISPLAY_CLASS=HI8561Display
+#else
+#include "RM69A10Display.h" // RM69A10 AMOLED (default SKU) — DISPLAY_CLASS=RM69A10Display
+#endif
#include "Xl9535.h"
class TDisplayP4Board : public ESP32Board {