diff --git a/deploy/site/index.html b/deploy/site/index.html index 50438d4..584c33e 100644 --- a/deploy/site/index.html +++ b/deploy/site/index.html @@ -391,6 +391,11 @@ Download .bin

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 {