By now you should probably be aware of the CYD – or actually CYDs as there are several variations. Cheap yellow display. They vary in size and capacity… I only have one and it’s on the lower end… you can see the device in the picture above…. yes, that price in the lead picture is correct – just over 10 euros inc post + the big new EU rip tax.
Here is the link –
And here’s a picture of the board.. well, the end anyway.
On the other side is a 320*240 touch display. The package comes complete with USB-A to USB-C lead, small adapter and an ALLEN bolt. My particular board has an RGB (not ws2812) LED in the top view – a full size ESP32, a Micro-SD slot that I didn’t use, a MicroUSB slot that I didn’t use… and some buttons that I didn’t touch.
I bought the board as I thought to build a general purpose scanner for WiFi, BLE and BT. I thought I’d just sneak in the odd game of Manic Miner while I was there – but as it turns out the scanner was a bit more work than I’d planned. Here is the link to the project I chose –
The project doesn’t seem to be very old and credit to the guy for putting it together – but read on. You will see reference in the link above (in the code header) to PYTHON which is a bit strange since the code is written in C++
I put this together in Visual Studio Code on my Windows 11 PC, using a Windows PC serial port to flash the code to – the idea being that this would be a 5 minute drop-in project. Unfortunately, things didn’t work out that way. First, the code was based on a 480px wide version of CYD – so it took some modification. Then it also seems to be based on a display with inverted colors – that took far too long to realize. The next hurdle came when I tried to scan a BT device as against BLE…. ie set my phone to be visible to other devices. It could not find it. A few hacks later – the original code didn’t set up bluetooth.
Also, there’s a nice RGB LED on my board that hasn’t been used at all – ok, cosmetic but I like to use an onboard RGB when it’s there. Suffice it to say that a day later my friend ChatGPT was working and we finally got the whole thing running on my board this evening.
If the project interests you, there are two files in the original author’s code folder and I suggest setting up the original project and replacing those two files with mine if you have my board.
A final error in the original code came into view, such as the device seeing more than 5 BLE devices – clicking on a device produces/produces a nice graph showing signal and relative direction – but there doesn’t seem to be a swipe ability to scroll down below the first 5 entries – we added that.

So here it is – you can touch one of 3 on-screen buttons at the top of the screen (I use fingers even though a pen came with the board) to switch between WiFi, BLE and Classic (ie Bluetooth).
These boards often come with neat cases or you can make your own if you can 3D print. My board doesn’t seem to have any special battery facilities so it’s USB powered at least for now.
After a lot of work I now have a nice little scanner.
And when the novelty wears its a simple thing to USB-FLASH manic miner on the board (no press reset or “Flash” buttons). Even with that ridiculous tax it’s still good value – most of my boards of this type don’t have touch capability which is a shame – and no doubt a reason this and similar boards with the same CYD name are so popular – BECAUSE there are lots of variations though.
// main.py //<=(oo)=> It's the main one y'all. It's axiomatic. It's reflexive. It's...magic. //+++++++++++++++++++++++++++++//written_by_deamonmist//+++++++++++++++++++++++++++++++++ // The title above is from the original author - I owe it to him to leave it there despite // the work we've done to get this code working on my AliExpress 320*240 BYD // and no it's not a .py file it's a C++ file. Thanks to the entire chain of people who made projects like // this possible. #include#include #include #include #include #include #include #include #include #include BluetoothSerial SerialBT; #include "esp_wifi.h" #include "esp_event.h" #include "nvs_flash.h" #include "esp_bt.h" #include "esp_bt_main.h" #include "esp_gap_bt_api.h" // ─── Pins ───────────────────────────────────────────────────────────────────── #define BACKLIGHT_PIN 21 #define TOUCH_CS_PIN 33 XPT2046_Touchscreen touch(TOUCH_CS_PIN, 255); SPIClass touchSPI(HSPI); // ─── Layout ─────────────────────────────────────────────────────────────────── #define SCREEN_W 240 #define SCREEN_H 320 #define NUM_CH 13 // ─── Radio / App state ──────────────────────────────────────────────────────── enum RadioMode { RADIO_WIFI, RADIO_BLE, RADIO_CLASSIC }; enum AppState { STATE_SCAN, STATE_LIST, STATE_TRACKER }; RadioMode radioMode = RADIO_WIFI; AppState appState = STATE_SCAN; TFT_eSPI tft; // ─── WiFi channel state ─────────────────────────────────────────────────────── volatile uint8_t currentChannel = 0; uint32_t dwellStart = 0; uint32_t dwellMs = 80; // ─── Security types ─────────────────────────────────────────────────────────── enum SecType { SEC_OPEN, SEC_WEP, SEC_WPA, SEC_WPA2, SEC_WPA3, SEC_UNKNOWN }; const char *secLabel(SecType s) { switch (s) { case SEC_OPEN: return "OPEN"; case SEC_WEP: return "WEP"; case SEC_WPA: return "WPA"; case SEC_WPA2: return "WPA2"; case SEC_WPA3: return "WPA3"; default: return "?"; } } uint16_t secColor(SecType s) { switch (s) { case SEC_OPEN: return TFT_RED; case SEC_WEP: return TFT_ORANGE; case SEC_WPA: return TFT_YELLOW; case SEC_WPA2: return TFT_GREEN; case SEC_WPA3: return tft.color565(0, 255, 200); default: return TFT_DARKGREY; } } uint8_t listPage = 0; // ─── Generic device table (shared by all three modes) ───────────────────────── #define MAX_TRACKED 20 #define TOP_N 5 struct DeviceEntry { char name[33]; // SSID or BT device name char detail[33]; // MAC for WiFi/BLE, class for Classic int8_t rssi; uint8_t channel; // WiFi channel, or BLE adv type, or BT class uint8_t mac[6]; SecType security; // WiFi only bool active; // BLE extras char manufacturer[32]; bool connectable; }; DeviceEntry wifiTable[MAX_TRACKED]; uint8_t wifiCount = 0; DeviceEntry bleTable[MAX_TRACKED]; uint8_t bleCount = 0; DeviceEntry classicTable[MAX_TRACKED]; uint8_t classicCount = 0; uint8_t topList[TOP_N]; uint8_t topCount = 0; // ─── Scan timing ────────────────────────────────────────────────────────────── #define WIFI_SCAN_MS 10000 #define BLE_SCAN_MS 8000 #define CLASSIC_SCAN_MS 15000 uint32_t scanStart = 0; // ─── Tracker state ──────────────────────────────────────────────────────────── uint8_t trackerIdx = 0; int8_t trackerPeak = -100; float bellCenter = 120.0f; float bellVelocity = 0.0f; #define RSSI_HISTORY 60 int8_t rssiHistory[RSSI_HISTORY]; uint8_t rssiHistHead = 0; bool rssiHistFull = false; // ─── Touch ──────────────────────────────────────────────────────────────────── uint32_t lastTouchMs = 0; uint32_t touchDownMs = 0; bool touchWasDown = false; bool debugOverlay = false; #define TOUCH_DEBOUNCE 400 #define LONG_PRESS_MS 1200 int touchStartX = 0; int touchStartY = 0; bool touchSwipeHandled = false; bool touchMoved = false; bool touchReleasePending = false; uint32_t touchReleaseMs = 0; // ─── BLE scanner ────────────────────────────────────────────────────────────── BLEScan *pBLEScan = nullptr; bool bleScanRunning = false; // ─── Classic BT ─────────────────────────────────────────────────────────────── bool classicScanRunning = false; // ─── Helpers ────────────────────────────────────────────────────────────────── uint16_t heatColor(uint8_t v) { if (v < 64) { uint8_t t = v * 4; return tft.color565(0, 0, t); } if (v < 128) { uint8_t t = (v - 64) * 4; return tft.color565(0, t, 255 - t); } if (v < 192) { uint8_t t = (v - 128) * 4; return tft.color565(t, 255, 0); } uint8_t t = (v - 192) * 4; return tft.color565(255, 255 - t, 0); } uint8_t rssiToQuality(int8_t rssi) { if (rssi <= -100) return 0; if (rssi >= -30) return 100; return (uint8_t)((rssi + 100) * (100.0f / 70.0f)); } uint8_t getBatteryPct() { uint32_t sum = 0; for (uint8_t i = 0; i < 10; i++) sum += analogRead(34); float v = (sum / 10.0f) * (3.3f / 4095.0f) * 2.0f; if (v >= 4.2f) return 100; if (v <= 3.0f) return 0; return (uint8_t)((v - 3.0f) / 1.2f * 100.0f); } // ─── Active table accessors ─────────────────────────────────────────────────── DeviceEntry *activeTable() { if (radioMode == RADIO_WIFI) return wifiTable; if (radioMode == RADIO_BLE) return bleTable; return classicTable; } uint8_t &activeCount() { if (radioMode == RADIO_WIFI) return wifiCount; if (radioMode == RADIO_BLE) return bleCount; return classicCount; } uint32_t scanDuration() { if (radioMode == RADIO_WIFI) return WIFI_SCAN_MS; if (radioMode == RADIO_BLE) return BLE_SCAN_MS; return CLASSIC_SCAN_MS; } // ─── Security parser ────────────────────────────────────────────────────────── SecType parseSecurity(const uint8_t *payload, uint16_t len) { if (len < 36) return SEC_UNKNOWN; bool hasPrivacy = (payload[34] & 0x10) != 0; bool hasRSN = false, hasWPA = false, hasWPA3 = false; uint16_t pos = 36; while (pos + 2 <= len) { uint8_t id = payload[pos], eln = payload[pos + 1]; if (pos + 2 + eln > len) break; if (id == 48 && eln >= 4) { hasRSN = true; if (eln >= 12) { uint16_t pairCount = payload[pos + 2 + 4] | (payload[pos + 2 + 5] << 8); uint16_t akmStart = pos + 2 + 4 + 2 + pairCount * 4 + 2; if (akmStart + 4 <= len) { uint8_t akmType = payload[akmStart + 3]; if (akmType == 8 || akmType == 9) hasWPA3 = true; } } } if (id == 221 && eln >= 4) { if (payload[pos + 2] == 0x00 && payload[pos + 3] == 0x50 && payload[pos + 4] == 0xF2 && payload[pos + 5] == 0x01) hasWPA = true; } pos += 2 + eln; } if (hasWPA3) return SEC_WPA3; if (hasRSN) return SEC_WPA2; if (hasWPA) return SEC_WPA; if (hasPrivacy) return SEC_WEP; return SEC_OPEN; } // ─── Device upsert ──────────────────────────────────────────────────────────── void upsertDevice(DeviceEntry *table, uint8_t &count, const char *name, int8_t rssi, uint8_t chan, const uint8_t *mac, SecType sec, const char *manufacturer = "", bool connectable = false) { // Match by MAC for (uint8_t i = 0; i < count; i++) { if (memcmp(table[i].mac, mac, 6) == 0) { // In scan mode keep peak RSSI, in tracker always use latest if (appState == STATE_TRACKER || rssi > table[i].rssi) { table[i].rssi = rssi; table[i].channel = chan; } return; } } if (count >= MAX_TRACKED) return; strncpy(table[count].name, name[0] ? name : "Unknown", 32); table[count].name[32] = '\0'; table[count].rssi = rssi; table[count].channel = chan; table[count].security = sec; table[count].active = true; table[count].connectable = connectable; memcpy(table[count].mac, mac, 6); snprintf(table[count].detail, sizeof(table[count].detail), "%02X:%02X:%02X:%02X:%02X:%02X", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); strncpy(table[count].manufacturer, manufacturer, 31); table[count].manufacturer[31] = '\0'; count++; } void updateModeLED(); void buildTopList() { DeviceEntry *table = activeTable(); uint8_t cnt = activeCount(); topCount = 0; if (cnt == 0) return; bool used[MAX_TRACKED] = {false}; // Number of devices to skip for the current page uint8_t startRank = listPage * TOP_N; // Sort devices by RSSI strength and select the // five belonging to the current page. for (uint8_t rank = 0; rank < cnt; rank++) { int8_t best = -127; uint8_t bestIdx = 0; bool found = false; for (uint8_t i = 0; i < cnt; i++) { if (!used[i] && table[i].rssi > best) { best = table[i].rssi; bestIdx = i; found = true; } } if (!found) break; used[bestIdx] = true; // Skip devices on earlier pages if (rank < startRank) continue; // Add device to the current page if (topCount < TOP_N) { topList[topCount++] = bestIdx; } // We have filled this page if (topCount >= TOP_N) break; } } // ─── WiFi sniffer ───────────────────────────────────────────────────────────── void IRAM_ATTR wifiSniffCb(void *buf, wifi_promiscuous_pkt_type_t type) { if (type != WIFI_PKT_MGMT) return; const wifi_promiscuous_pkt_t *pkt = (wifi_promiscuous_pkt_t *)buf; const uint8_t *p = pkt->payload; uint16_t len = pkt->rx_ctrl.sig_len; int8_t rssi = pkt->rx_ctrl.rssi; if (len < 38 || p[0] != 0x80) return; uint8_t ssidLen = p[37]; if (ssidLen == 0 || ssidLen > 32 || (38u + ssidLen) >= len) return; char ssid[33]; memcpy(ssid, &p[38], ssidLen); ssid[ssidLen] = '\0'; const uint8_t *mac = &p[16]; SecType sec = parseSecurity(p, len); if (appState == STATE_SCAN) upsertDevice(wifiTable, wifiCount, ssid, rssi, currentChannel + 1, mac, sec); else if (appState == STATE_TRACKER) { for (uint8_t i = 0; i < wifiCount; i++) { if (memcmp(wifiTable[i].mac, mac, 6) == 0) { wifiTable[i].rssi = rssi; break; } } } } void drawScanScreen(); // forward declaration void advanceChannel() { currentChannel = (currentChannel + 1) % NUM_CH; esp_wifi_set_channel(currentChannel + 1, WIFI_SECOND_CHAN_NONE); dwellStart = millis(); } // ─── BLE callback ───────────────────────────────────────────────────────────── class BLECallback : public BLEAdvertisedDeviceCallbacks { void onResult(BLEAdvertisedDevice dev) { uint8_t mac[6]; uint64_t addr = *(uint64_t *)dev.getAddress().getNative(); for (int i = 0; i < 6; i++) mac[i] = (addr >> (i * 8)) & 0xFF; const char *name = dev.haveName() ? dev.getName().c_str() : ""; const char *mfr = dev.haveManufacturerData() ? "MFR" : ""; upsertDevice(bleTable, bleCount, name, dev.getRSSI(), 0, mac, SEC_UNKNOWN, mfr, false); } }; BLECallback bleCb; // ─── Classic BT callback ────────────────────────────────────────────────────── void classicBtCb(esp_bt_gap_cb_event_t event, esp_bt_gap_cb_param_t *param) { if (event == ESP_BT_GAP_DISC_RES_EVT) { uint8_t *mac = param->disc_res.bda; char name[33] = "Unknown"; int8_t rssi = -100; uint32_t cod = 0; for (int i = 0; i < param->disc_res.num_prop; i++) { esp_bt_gap_dev_prop_t *p = ¶m->disc_res.prop[i]; if (p->type == ESP_BT_GAP_DEV_PROP_BDNAME && p->len > 0) { uint8_t l = min((uint8_t)p->len, (uint8_t)32); memcpy(name, p->val, l); name[l] = '\0'; } if (p->type == ESP_BT_GAP_DEV_PROP_RSSI) rssi = *(int8_t *)p->val; if (p->type == ESP_BT_GAP_DEV_PROP_COD) cod = *(uint32_t *)p->val; } upsertDevice(classicTable, classicCount, name, rssi, 0, mac, SEC_UNKNOWN); } } // ─── Radio init/deinit ──────────────────────────────────────────────────────── void stopWifi() { esp_wifi_set_promiscuous(false); esp_wifi_stop(); esp_wifi_deinit(); esp_event_loop_delete_default(); } void startWifi() { esp_event_loop_create_default(); wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); esp_wifi_init(&cfg); esp_wifi_set_storage(WIFI_STORAGE_RAM); esp_wifi_set_mode(WIFI_MODE_NULL); esp_wifi_start(); esp_wifi_set_promiscuous(true); esp_wifi_set_promiscuous_rx_cb(wifiSniffCb); esp_wifi_set_channel(1, WIFI_SECOND_CHAN_NONE); currentChannel = 0; dwellStart = millis(); } void stopBLE() { if (pBLEScan) { pBLEScan->stop(); bleScanRunning = false; } BLEDevice::deinit(false); pBLEScan = nullptr; } void startBLE() { BLEDevice::init(""); pBLEScan = BLEDevice::getScan(); pBLEScan->setAdvertisedDeviceCallbacks(&bleCb, true); pBLEScan->setActiveScan(true); pBLEScan->setInterval(100); pBLEScan->setWindow(99); pBLEScan->start(BLE_SCAN_MS / 1000, true); // async, non-blocking bleScanRunning = true; } void stopClassic() { esp_bt_gap_cancel_discovery(); esp_bluedroid_disable(); esp_bluedroid_deinit(); esp_bt_controller_disable(); classicScanRunning = false; } void startClassic() { // Start Bluetooth Classic in master mode so we can scan for devices. if (!SerialBT.begin("CYD-Scanner", true)) { classicScanRunning = false; return; } // Start asynchronous Classic Bluetooth discovery. if ( SerialBT.discoverAsync([](BTAdvertisedDevice *pDevice) { std::string name = pDevice->getName(); if (name.empty()) { name = "(unnamed)"; } int8_t rssi = pDevice->getRSSI(); BTAddress address = pDevice->getAddress(); const uint8_t *mac = *address.getNative(); upsertDevice( classicTable, classicCount, name.c_str(), rssi, 0, mac, SEC_UNKNOWN); })) { classicScanRunning = true; } else { classicScanRunning = false; } } // ─── Mode switching ─────────────────────────────────────────────────────────── void switchToMode(RadioMode newMode) { RadioMode oldMode = radioMode; radioMode = newMode; updateModeLED(); appState = STATE_SCAN; topCount = 0; if (newMode == RADIO_CLASSIC) classicCount = 0; // Draw scan screen immediately so user sees it while radio switches drawScanScreen(); // Stop current radio if (oldMode == RADIO_WIFI) stopWifi(); if (oldMode == RADIO_BLE) stopBLE(); if (oldMode == RADIO_CLASSIC) stopClassic(); // Start new radio — reset scanStart AFTER so timer is accurate if (radioMode == RADIO_WIFI) startWifi(); if (radioMode == RADIO_BLE) startBLE(); if (radioMode == RADIO_CLASSIC) startClassic(); scanStart = millis(); } // ─── UI shared helpers ──────────────────────────────────────────────────────── void drawBatteryIcon(int x, int y, uint8_t pct) { (void)pct; tft.setTextColor(TFT_GREEN, tft.color565(0, 20, 42)); tft.drawString("USB", x - 2, y + 2, 1); } void drawModeButtons(uint16_t headerBg) { const int y = 22; const int h = 26; const int w = 76; const int x1 = 2, x2 = 82, x3 = 162; uint16_t wBg = radioMode == RADIO_WIFI ? tft.color565(0, 70, 120) : tft.color565(15, 15, 35); tft.fillRoundRect(x1, y, w, h, 4, wBg); if (radioMode == RADIO_WIFI) tft.drawRoundRect(x1, y, w, h, 4, TFT_CYAN); tft.setTextColor(radioMode == RADIO_WIFI ? TFT_CYAN : tft.color565(80, 80, 100), wBg); tft.drawCentreString("WiFi", x1 + w / 2, y + 7, 2); uint16_t bBg = radioMode == RADIO_BLE ? tft.color565(60, 0, 100) : tft.color565(15, 15, 35); tft.fillRoundRect(x2, y, w, h, 4, bBg); if (radioMode == RADIO_BLE) tft.drawRoundRect(x2, y, w, h, 4, tft.color565(180, 100, 255)); tft.setTextColor(radioMode == RADIO_BLE ? tft.color565(180, 100, 255) : tft.color565(80, 80, 100), bBg); tft.drawCentreString("BLE", x2 + w / 2, y + 7, 2); uint16_t cBg = radioMode == RADIO_CLASSIC ? tft.color565(80, 40, 0) : tft.color565(15, 15, 35); tft.fillRoundRect(x3, y, w, h, 4, cBg); if (radioMode == RADIO_CLASSIC) tft.drawRoundRect(x3, y, w, h, 4, TFT_ORANGE); tft.setTextColor(radioMode == RADIO_CLASSIC ? TFT_ORANGE : tft.color565(80, 80, 100), cBg); // tft.drawCentreString("Classic", x3+w/2, y+7, 1); tft.drawCentreString("Classic", x3 + w / 2, y + 7, 2); } void drawSignalBar(int x, int y, int w, int h, uint8_t pct, uint16_t col) { tft.drawRect(x, y, w, h, tft.color565(40, 40, 40)); uint16_t fw = (uint16_t)(pct * (w - 2) / 100); tft.fillRect(x + 1, y + 1, w - 2, h - 2, tft.color565(20, 20, 20)); if (fw > 0) tft.fillRect(x + 1, y + 1, fw, h - 2, col); } // ─── SCAN screen ────────────────────────────────────────────────────────────── void drawScanScreen() { tft.fillScreen(tft.color565(6, 6, 14)); tft.fillRect(0, 0, SCREEN_W, 54, tft.color565(0, 20, 42)); tft.drawFastHLine(0, 54, SCREEN_W, TFT_CYAN); // Mode label at top tft.setTextColor(TFT_CYAN, tft.color565(0, 20, 42)); const char *modeStr = radioMode == RADIO_WIFI ? "WiFi Scanner" : radioMode == RADIO_BLE ? "BLE Scanner" : "Classic BT Scanner"; tft.drawCentreString(modeStr, SCREEN_W / 2, 4, 2); drawModeButtons(tft.color565(0, 20, 42)); drawBatteryIcon(SCREEN_W - 36, 4, getBatteryPct()); // Scan animation area tft.setTextColor(TFT_DARKGREY, tft.color565(6, 6, 14)); tft.drawCentreString("Scanning...", SCREEN_W / 2, 105, 4); tft.drawRoundRect(20, 155, 200, 24, 4, tft.color565(40, 40, 40)); } void updateScanProgress(uint32_t elapsed) { uint32_t dur = scanDuration(); uint16_t w = min((uint16_t)(elapsed * (SCREEN_W - 44) / dur), (uint16_t)(SCREEN_W - 44)); tft.fillRoundRect(22, 157, w, 20, 3, radioMode == RADIO_WIFI ? TFT_CYAN : radioMode == RADIO_BLE ? tft.color565(180, 100, 255) : TFT_ORANGE); char buf[32]; snprintf(buf, sizeof(buf), "%d devices found", activeCount()); tft.setTextColor(TFT_WHITE, tft.color565(6, 6, 14)); tft.drawCentreString(buf, SCREEN_W / 2, 200, 2); if (radioMode == RADIO_WIFI) { tft.fillRect(30, 265, 180, 20, tft.color565(6, 6, 14)); for (uint8_t i = 0; i < NUM_CH; i++) { uint16_t col = (i == currentChannel) ? TFT_CYAN : tft.color565(30, 30, 30); int x = 20 + (i * (SCREEN_W - 40)) / NUM_CH; int barW = max(4, ((SCREEN_W - 40) / NUM_CH) - 2); tft.fillRect(x, 294, barW, 10, col); } } } // ─── LIST screen ────────────────────────────────────────────────────────────── #define LIST_ITEM_H 53 #define LIST_TOP 54 void drawListScreen() { DeviceEntry *table = activeTable(); tft.fillScreen(tft.color565(8, 8, 18)); // Header uint16_t hdrBg = tft.color565(0, 20, 42); tft.fillRect(0, 0, SCREEN_W, LIST_TOP, hdrBg); tft.drawFastHLine(0, LIST_TOP, SCREEN_W, TFT_CYAN); // Title at top tft.setTextColor(TFT_CYAN, hdrBg); const char *title = radioMode == RADIO_WIFI ? "Top WiFi" : radioMode == RADIO_BLE ? "Top BLE" : "Top Classic BT"; tft.drawCentreString(title, SCREEN_W / 2, 4, 2); drawModeButtons(hdrBg); drawBatteryIcon(SCREEN_W - 36, 4, getBatteryPct()); if (topCount == 0) { tft.setTextColor(TFT_RED, tft.color565(8, 8, 18)); tft.drawCentreString("No devices found", SCREEN_W / 2, 240, 2); return; } for (uint8_t n = 0; n < topCount; n++) { uint8_t idx = topList[n]; int y = LIST_TOP + 1 + n * LIST_ITEM_H; uint16_t bg = (n % 2 == 0) ? tft.color565(12, 12, 28) : tft.color565(8, 8, 20); tft.fillRect(0, y, SCREEN_W, LIST_ITEM_H - 1, bg); tft.drawFastHLine(0, y + LIST_ITEM_H - 1, SCREEN_W, tft.color565(30, 30, 50)); // Rank badge uint16_t badgeBg = radioMode == RADIO_WIFI ? tft.color565(0, 40, 80) : radioMode == RADIO_BLE ? tft.color565(40, 0, 80) : tft.color565(60, 30, 0); tft.fillRoundRect(5, y + 5, 20, 20, 3, badgeBg); tft.setTextColor(TFT_WHITE, badgeBg); char rank[4]; snprintf(rank, sizeof(rank), "%d", (listPage * TOP_N) + n + 1); tft.drawCentreString(rank, 15, y + 8, 2); // Name tft.setTextColor(TFT_WHITE, bg); char nm[24]; strncpy(nm, table[idx].name, 23); nm[23] = '\0'; tft.drawString(nm, 32, y + 5, 2); // MAC / detail tft.setTextColor(tft.color565(100, 100, 140), bg); tft.drawString(table[idx].detail, 32, y + 22, 1); // Third line: channel for WiFi, connectable for BLE, blank for Classic tft.setTextColor(tft.color565(120, 120, 160), bg); if (radioMode == RADIO_WIFI) { char ch[8]; snprintf(ch, sizeof(ch), "CH %d", table[idx].channel); tft.drawString(ch, 32, y + 34, 1); } else if (radioMode == RADIO_BLE) { tft.drawString(table[idx].connectable ? "Connectable" : "Broadcast only", 32, y + 34, 1); } else { tft.drawString(table[idx].manufacturer[0] ? table[idx].manufacturer : "Classic BT", 32, y + 34, 1); } // Right side: security badge (WiFi only) or mode badge (BLE/Classic) if (radioMode == RADIO_WIFI) { SecType sec = table[idx].security; uint16_t secBg = tft.color565( sec == SEC_OPEN ? 80 : 0, sec == SEC_WPA2 || sec == SEC_WPA3 ? 60 : 20, sec == SEC_WPA3 ? 80 : 20); tft.fillRoundRect(SCREEN_W - 58, y + 5, 52, 17, 3, secBg); tft.setTextColor(secColor(sec), secBg); tft.drawCentreString(secLabel(sec), SCREEN_W - 32, y + 8, 1); } else { uint16_t modeBg = radioMode == RADIO_BLE ? tft.color565(40, 0, 80) : tft.color565(60, 30, 0); uint16_t modeCol = radioMode == RADIO_BLE ? tft.color565(180, 100, 255) : TFT_ORANGE; tft.fillRoundRect(SCREEN_W - 58, y + 5, 52, 17, 3, modeBg); tft.setTextColor(modeCol, modeBg); tft.drawCentreString(radioMode == RADIO_BLE ? "BLE" : "BT", SCREEN_W - 32, y + 8, 1); } // RSSI + signal bar int8_t rssi = table[idx].rssi; uint16_t rssiCol = rssi > -60 ? TFT_GREEN : rssi > -75 ? TFT_YELLOW : TFT_RED; tft.setTextColor(rssiCol, bg); char rs[8]; snprintf(rs, sizeof(rs), "%ddBm", rssi); tft.drawString(rs, SCREEN_W - 58, y + 34, 1); drawSignalBar(SCREEN_W - 58, y + 24, 52, 7, rssiToQuality(rssi), rssiCol); } } // ─── TRACKER screen ─────────────────────────────────────────────────────────── #define HEADER_H 86 #define BELL_TOP HEADER_H #define BELL_H (SCREEN_H - HEADER_H - 80) #define FOOTER_TOP (SCREEN_H - 55) #define BELL_COLS 48 #define GLOW_LAYERS 4 int16_t bellHeights[BELL_COLS]; int16_t prevBellHeights[BELL_COLS]; float gaussianH(float x, float center, float sigma, float peakH) { float d = x - center; return peakH * expf(-(d * d) / (2.0f * sigma * sigma)); } void computeBell(float center, float peakH) { for (uint8_t i = 0; i < BELL_COLS; i++) { float bx = (i + 0.5f) * (SCREEN_W / (float)BELL_COLS); bellHeights[i] = (int16_t)gaussianH(bx, center, 42.0f, peakH); } } void drawTrackerScreen(bool full) { DeviceEntry *table = activeTable(); uint8_t idx = topList[trackerIdx]; int8_t rssi = table[idx].rssi; if (rssi > trackerPeak) trackerPeak = rssi; static uint32_t lastHistUpdate = 0; if (millis() - lastHistUpdate > 500) { rssiHistory[rssiHistHead] = rssi; rssiHistHead = (rssiHistHead + 1) % RSSI_HISTORY; if (!rssiHistFull && rssiHistHead == 0) rssiHistFull = true; lastHistUpdate = millis(); } float delta = (float)(trackerPeak - rssi); if (delta > 3.0f) { bellVelocity += (random(-8, 8) * 0.4f); bellVelocity *= 0.82f; } else { bellVelocity += (120.0f - bellCenter) * 0.08f; bellVelocity *= 0.65f; } bellCenter += bellVelocity; bellCenter = constrain(bellCenter, 30.0f, 210.0f); float peakH = (float)rssiToQuality(rssi) / 100.0f * (BELL_H - 8); uint8_t qual = rssiToQuality(rssi); // Mode accent color uint16_t accentCol = radioMode == RADIO_WIFI ? TFT_CYAN : radioMode == RADIO_BLE ? tft.color565(180, 100, 255) : TFT_ORANGE; if (full) { tft.fillScreen(tft.color565(6, 6, 14)); tft.fillRect(0, 0, SCREEN_W, HEADER_H, tft.color565(0, 20, 42)); tft.drawFastHLine(0, HEADER_H, SCREEN_W, accentCol); // Row 1 (y=2-20): Device name centered, battery top-right tft.setTextColor(TFT_WHITE, tft.color565(0, 20, 42)); // Truncate name to leave room for battery char nm[20]; strncpy(nm, table[idx].name, 19); nm[19] = '\0'; tft.drawCentreString(nm, SCREEN_W / 2, 3, 2); drawBatteryIcon(SCREEN_W - 38, 3, getBatteryPct()); // Row 2 (y=22-48): Mode buttons (full width) drawModeButtons(tft.color565(0, 20, 42)); // Row 3 (y=52-62): MAC address tft.setTextColor(tft.color565(100, 100, 160), tft.color565(0, 20, 42)); tft.drawCentreString(table[idx].detail, SCREEN_W / 2, 50, 1); // Row 4 (y=64-74): Security/type + Back button if (radioMode == RADIO_WIFI) { char info[28]; snprintf(info, sizeof(info), "%s | CH %d", secLabel(table[idx].security), table[idx].channel); tft.setTextColor(secColor(table[idx].security), tft.color565(0, 20, 42)); tft.drawCentreString(info, SCREEN_W / 2 + 25, 65, 1); } else if (radioMode == RADIO_BLE) { tft.setTextColor(accentCol, tft.color565(0, 20, 42)); tft.drawCentreString(table[idx].connectable ? "Connectable" : "Broadcast", SCREEN_W / 2 + 25, 65, 1); } else { tft.setTextColor(accentCol, tft.color565(0, 20, 42)); tft.drawCentreString("Classic BT", SCREEN_W / 2 + 25, 65, 1); } // Back button sits in row 4, left side tft.fillRoundRect(4, 61, 68, 24, 4, tft.color565(50, 0, 0)); tft.setTextColor(tft.color565(255, 80, 80), tft.color565(50, 0, 0)); tft.drawCentreString("< BACK", 38, 66, 2); // Footer bg tft.fillRect(0, FOOTER_TOP, SCREEN_W, 55, tft.color565(0, 20, 42)); tft.drawFastHLine(0, FOOTER_TOP, SCREEN_W, accentCol); // Center tick marks for (uint8_t t = 0; t < 5; t++) tft.drawFastHLine(156, BELL_TOP + t * (BELL_H / 4), 8, tft.color565(30, 30, 50)); memset(prevBellHeights, 0, sizeof(prevBellHeights)); } // ── Bell ────────────────────────────────────────────────────────────────── uint16_t colW = SCREEN_W / BELL_COLS; computeBell(bellCenter, peakH); for (uint8_t i = 0; i < BELL_COLS; i++) { int16_t h = bellHeights[i]; int16_t prev = prevBellHeights[i]; int bx = i * colW; if (h == prev && !full) continue; tft.fillRect(bx, BELL_TOP, colW, BELL_H, tft.color565(6, 6, 14)); if (h > 0) { int by = BELL_TOP + BELL_H - h; for (uint8_t g = GLOW_LAYERS; g > 0; g--) { float glowH = gaussianH(bx + colW / 2.0f, bellCenter, 42.0f + g * 12.0f, peakH); uint8_t a = 40 / g; int gy = BELL_TOP + BELL_H - (int16_t)glowH; if ((int16_t)glowH > h) tft.fillRect(bx, gy, colW, (int16_t)glowH - h, tft.color565(0, a, a * 2)); } uint8_t inten = (uint8_t)(h * 255 / BELL_H); tft.fillRect(bx + 1, by, colW - 2, h, heatColor(inten)); tft.drawFastHLine(bx + 1, by, colW - 2, TFT_WHITE); } prevBellHeights[i] = h; } tft.drawFastVLine(120, BELL_TOP, BELL_H, tft.color565(40, 40, 60)); // ── Footer ──────────────────────────────────────────────────────────────── tft.fillRect(0, FOOTER_TOP + 1, SCREEN_W, 54, tft.color565(0, 20, 42)); uint16_t qCol = qual > 66 ? TFT_GREEN : qual > 33 ? TFT_YELLOW : TFT_RED; char qStr[8]; snprintf(qStr, sizeof(qStr), "%d%%", qual); tft.setTextColor(qCol, tft.color565(0, 20, 42)); tft.drawCentreString(qStr, 45, FOOTER_TOP + 3, 4); tft.setTextColor(tft.color565(160, 160, 200), tft.color565(0, 20, 42)); char rssiStr[10]; snprintf(rssiStr, sizeof(rssiStr), "%d dBm", rssi); tft.drawCentreString(rssiStr, 45, FOOTER_TOP + 31, 2); tft.setTextColor(tft.color565(80, 80, 120), tft.color565(0, 20, 42)); char pkStr[16]; snprintf(pkStr, sizeof(pkStr), "peak %d", trackerPeak); tft.drawCentreString(pkStr, 45, FOOTER_TOP + 47, 1); static uint32_t lastBatUpdate = 0; if (!full && millis() - lastBatUpdate > 30000) { lastBatUpdate = millis(); drawBatteryIcon(SCREEN_W - 38, 3, getBatteryPct()); } if (full) lastBatUpdate = millis(); // Mini RSSI graph uint8_t histCount = rssiHistFull ? RSSI_HISTORY : rssiHistHead; if (histCount > 1) { int gx = 88, gy = FOOTER_TOP + 4, gw = 148, gh = 46; tft.drawRect(gx, gy, gw, gh, tft.color565(30, 30, 60)); for (uint8_t i = 1; i < histCount; i++) { uint8_t a = (rssiHistHead + RSSI_HISTORY - histCount + i - 1) % RSSI_HISTORY; uint8_t b = (rssiHistHead + RSSI_HISTORY - histCount + i) % RSSI_HISTORY; int8_t ra = rssiHistory[a], rb = rssiHistory[b]; int x0 = gx + 1 + (i - 1) * (gw - 2) / (histCount - 1); int x1 = gx + 1 + i * (gw - 2) / (histCount - 1); int y0 = constrain(gy + gh - 2 - (int)((ra + 100) * (gh - 4) / 70), gy + 1, gy + gh - 2); int y1 = constrain(gy + gh - 2 - (int)((rb + 100) * (gh - 4) / 70), gy + 1, gy + gh - 2); uint16_t lc = rssiToQuality(rb) > 66 ? TFT_GREEN : rssiToQuality(rb) > 33 ? TFT_YELLOW : TFT_RED; tft.drawLine(x0, y0, x1, y1, lc); } } // Centered label tft.fillRect(SCREEN_W / 2 - 45, BELL_TOP + 4, 90, 16, tft.color565(6, 6, 14)); if (fabsf(bellCenter - 120.0f) < 20.0f && delta < 4.0f) { tft.setTextColor(TFT_GREEN, tft.color565(6, 6, 14)); tft.drawCentreString("[ CENTERED ]", SCREEN_W / 2, BELL_TOP + 4, 2); } } // ─── Debug overlay ──────────────────────────────────────────────────────────── void drawDebugOverlay() { uint16_t cMode = tft.color565(255, 255, 0); // yellow — mode buttons uint16_t cBack = tft.color565(255, 80, 80); // red — back button uint16_t cList = tft.color565(0, 200, 255); // cyan — list items uint16_t cNone = tft.color565(120, 120, 120); // grey — info // Mode button zones (y 7-37) tft.drawRect(10, 28, 105, 27, cMode); tft.drawRect(82, 22, 76, 26, cMode); tft.drawRect(221, 28, 97, 27, cMode); tft.setTextColor(cMode, TFT_BLACK); tft.fillRect(12, 30, 28, 10, TFT_BLACK); tft.fillRect(84, 24, 22, 10, TFT_BLACK); tft.fillRect(164, 24, 44, 10, TFT_BLACK); tft.drawString("WiFi", 4, 24, 1); tft.drawString("BLE", 84, 24, 1); tft.drawString("Classic", 164, 24, 1); if (appState == STATE_TRACKER) { // Back button zone (y 47-73, x 0-80) tft.drawRect(0, 47, 80, 26, cBack); tft.setTextColor(cBack, TFT_BLACK); tft.fillRect(2, 49, 30, 10, TFT_BLACK); tft.drawString("BACK", 2, 49, 1); } if (appState == STATE_LIST) { for (uint8_t n = 0; n < topCount; n++) { int itemY = LIST_TOP + 1 + n * LIST_ITEM_H; tft.drawRect(0, itemY, SCREEN_W, LIST_ITEM_H - 1, cList); tft.setTextColor(cList, TFT_BLACK); char lbl[8]; snprintf(lbl, sizeof(lbl), "Item%d", n + 1); tft.fillRect(2, itemY + 2, 34, 10, TFT_BLACK); tft.drawString(lbl, 2, itemY + 2, 1); } } // Footer info bar tft.fillRect(0, SCREEN_H - 16, SCREEN_W, 16, TFT_BLACK); tft.setTextColor(cNone, TFT_BLACK); tft.drawString("LONG PRESS: hide overlay", 4, SCREEN_H - 14, 1); char st[24]; snprintf(st, sizeof(st), "S%d M%d", (int)appState, (int)radioMode); tft.drawString(st, 190, SCREEN_H - 14, 1); } // ─── Touch ──────────────────────────────────────────────────────────────────── void handleTouch() { bool isDown = touch.touched(); uint32_t now = millis(); // ── New touch ──────────────────────────────────────────────────────────────── if (isDown && !touchWasDown) { touchDownMs = now; touchSwipeHandled = false; touchMoved = false; touchReleasePending = false; TS_Point p = touch.getPoint(); touchStartX = constrain( map(p.x, 3860, 280, SCREEN_W - 1, 0), 0, SCREEN_W - 1); touchStartY = constrain( map(p.y, 340, 3860, 0, SCREEN_H), 0, SCREEN_H - 1); touchWasDown = true; return; } // ── Touch has come back after a possible false release ───────────────────── if (isDown && touchReleasePending) { // It wasn't really released. touchReleasePending = false; } // ── Finger still down ─────────────────────────────────────────────────────── if (isDown && touchWasDown) { TS_Point p = touch.getPoint(); int sx = constrain( map(p.x, 3860, 280, SCREEN_W - 1, 0), 0, SCREEN_W - 1); int sy = constrain( map(p.y, 340, 3860, 0, SCREEN_H), 0, SCREEN_H - 1); int dx = sx - touchStartX; int dy = sy - touchStartY; // Remember that the finger has moved significantly if (abs(dx) >= 15 || abs(dy) >= 15) { touchMoved = true; } // ── Long press ──────────────────────────────────────────────────────────── if (!touchSwipeHandled && now - touchDownMs >= LONG_PRESS_MS) { if (now - lastTouchMs >= TOUCH_DEBOUNCE) { lastTouchMs = now; debugOverlay = !debugOverlay; if (debugOverlay) { drawDebugOverlay(); } else { if (appState == STATE_SCAN) drawScanScreen(); else if (appState == STATE_LIST) drawListScreen(); else drawTrackerScreen(true); } touchSwipeHandled = true; } return; } // ── Swipe detection ─────────────────────────────────────────────────────── if (!touchSwipeHandled && appState == STATE_LIST && topCount > 0) { if (abs(dy) >= 40 && abs(dy) > abs(dx) * 1.3) { DeviceEntry *table = activeTable(); uint8_t cnt = activeCount(); uint8_t maxPage = (cnt > 0) ? (uint8_t)((cnt - 1) / TOP_N) : 0; // Swipe UP if (dy < 0) { if (listPage < maxPage) { listPage++; buildTopList(); drawListScreen(); } } // Swipe DOWN else { if (listPage > 0) { listPage--; buildTopList(); drawListScreen(); } } // Do not allow this gesture to become a tap. touchSwipeHandled = true; return; } } return; } // ── Possible release ──────────────────────────────────────────────────────── if (!isDown && touchWasDown && !touchReleasePending) { // Don't immediately assume the finger has really been lifted. touchReleasePending = true; touchReleaseMs = now; return; } // ── Confirmed release ─────────────────────────────────────────────────────── if (!isDown && touchWasDown && touchReleasePending && now - touchReleaseMs >= 80) { touchWasDown = false; touchReleasePending = false; // Swipe or long press — nothing else to do. if (touchSwipeHandled || touchMoved) { touchSwipeHandled = false; touchMoved = false; return; } // ── Genuine tap ────────────────────────────────────────────────────────── if (now - lastTouchMs < TOUCH_DEBOUNCE) { return; } lastTouchMs = now; int sx = touchStartX; int sy = touchStartY; // ── Mode buttons ────────────────────────────────────────────────────────── if (sy >= 22 && sy <= 52) { // if (sx >= 2 && sx <= 78 && // radioMode != RADIO_WIFI) { if (sx >= 2 && sx <= 78) { switchToMode(RADIO_WIFI); if (debugOverlay) drawDebugOverlay(); return; } // if (sx >= 82 && sx <= 158 && // radioMode != RADIO_BLE) { if (sx >= 82 && sx <= 158) { switchToMode(RADIO_BLE); if (debugOverlay) drawDebugOverlay(); return; } // if (sx >= 162 && sx <= 238 && // radioMode != RADIO_CLASSIC) { if (sx >= 162 && sx <= 238) { switchToMode(RADIO_CLASSIC); if (debugOverlay) drawDebugOverlay(); return; } } // ── Device tap ──────────────────────────────────────────────────────────── if (appState == STATE_LIST && topCount > 0) { for (uint8_t n = 0; n < topCount; n++) { int itemY = LIST_TOP + 1 + n * LIST_ITEM_H; if (sy >= itemY && sy < itemY + LIST_ITEM_H - 1) { trackerIdx = topList[n]; trackerPeak = -100; bellCenter = 120.0f; bellVelocity = 0.0f; rssiHistHead = 0; rssiHistFull = false; memset(rssiHistory, 0, sizeof(rssiHistory)); appState = STATE_TRACKER; bleScanRunning = false; classicScanRunning = false; drawTrackerScreen(true); if (debugOverlay) drawDebugOverlay(); return; } } } // ── Tracker back button ────────────────────────────────────────────────── if (appState == STATE_TRACKER && sy >= 61 && sy <= 88 && sx < 80) { appState = STATE_LIST; drawListScreen(); if (debugOverlay) drawDebugOverlay(); return; } } } // ─── RGB LED PWM test ───────────────────────────────────────────────────────── #define LED_R 4 #define LED_G 16 #define LED_B 17 void rgbSet(uint8_t redValue, uint8_t greenValue, uint8_t blueValue) { // RGB LED is active LOW, so invert brightness analogWrite(LED_R, 255 - redValue); analogWrite(LED_G, 255 - greenValue); analogWrite(LED_B, 255 - blueValue); } void updateModeLED() { if (radioMode == RADIO_WIFI) { // WiFi = yellow rgbSet(255, 40, 0); } else if (radioMode == RADIO_BLE) { // BLE = magenta rgbSet(255, 0, 255); } else if (radioMode == RADIO_CLASSIC) { // Classic = cyan rgbSet(0, 40, 255); } } // ─── Setup ──────────────────────────────────────────────────────────────────── void setup() { pinMode(LED_R, OUTPUT); pinMode(LED_G, OUTPUT); pinMode(LED_B, OUTPUT); // Start with everything OFF rgbSet(0, 0, 0); updateModeLED(); Serial.begin(115200); delay(200); randomSeed(analogRead(0)); pinMode(BACKLIGHT_PIN, OUTPUT); digitalWrite(BACKLIGHT_PIN, HIGH); tft.init(); tft.invertDisplay(false); tft.setRotation(0); tft.fillScreen(TFT_BLACK); touchSPI.begin(25, 39, 32, TOUCH_CS_PIN); touch.begin(touchSPI); touch.setRotation(0); memset(wifiTable, 0, sizeof(wifiTable)); memset(bleTable, 0, sizeof(bleTable)); memset(classicTable, 0, sizeof(classicTable)); memset(bellHeights, 0, sizeof(bellHeights)); memset(prevBellHeights, 0, sizeof(prevBellHeights)); memset(rssiHistory, -100, sizeof(rssiHistory)); nvs_flash_init(); // Start in WiFi mode radioMode = RADIO_WIFI; startWifi(); appState = STATE_SCAN; scanStart = millis(); drawScanScreen(); } // ─── Loop ───────────────────────────────────────────────────────────────────── void loop() { handleTouch(); // WiFi channel hop if (radioMode == RADIO_WIFI && millis() - dwellStart >= dwellMs) advanceChannel(); if (appState == STATE_SCAN) { uint32_t elapsed = millis() - scanStart; updateScanProgress(elapsed); if (elapsed >= scanDuration()) { // BLE scan auto-stops via its own timer, just collect results if (radioMode == RADIO_BLE && pBLEScan) pBLEScan->stop(); buildTopList(); appState = STATE_LIST; drawListScreen(); } } else if (appState == STATE_TRACKER) { // Keep BLE scanning continuously in tracker mode if (radioMode == RADIO_BLE && pBLEScan) { static uint32_t bleScanTimer = 0; if (!bleScanRunning || millis() - bleScanTimer > 1200) { if (bleScanRunning) pBLEScan->stop(); pBLEScan->clearResults(); pBLEScan->start(1, true); // 1s non-blocking scan bleScanRunning = true; bleScanTimer = millis(); } } // Keep Classic BT discovering continuously in tracker mode if (radioMode == RADIO_CLASSIC) { static uint32_t classicScanTimer = 0; if (!classicScanRunning || millis() - classicScanTimer > 12000) { if (classicScanRunning) esp_bt_gap_cancel_discovery(); esp_bt_gap_start_discovery(ESP_BT_INQ_MODE_GENERAL_INQUIRY, 5, 0); classicScanRunning = true; classicScanTimer = millis(); } } static uint32_t lastUpdate = 0; if (millis() - lastUpdate > 150) { lastUpdate = millis(); drawTrackerScreen(false); } } }
And here the .ini file is only slightly updated to handle the 320*240 display and pin definitions on the CYD and use the COM port on my PC.
[env:esp32dev] platform = espressif32 board = esp32dev framework = arduino upload_port = COM16 monitor_speed = 115200 lib_deps = bodmer/TFT_eSPI @ ^2.5.43 build_flags = -DUSER_SETUP_LOADED=1 -DST7789_DRIVER=1 -DTFT_WIDTH=240 -DTFT_HEIGHT=320 -DTFT_MISO=12 -DTFT_MOSI=13 -DTFT_SCLK=14 -DTFT_CS=15 -DTFT_DC=2 -DTFT_RST=-1 -DTFT_BL=21 -DTFT_BACKLIGHT_ON=HIGH -DTOUCH_CS=33 -DSPI_FREQUENCY=27000000 -DSPI_TOUCH_FREQUENCY=2500000 -DLOAD_GLCD=1 -DLOAD_FONT2=1 -DLOAD_FONT4=1 board_build.partitions = partitions.csv
