WiFi + BLE + BT Scanner using a CYD - Scargill's Tech Blog

By now you should maybe be aware of the CYD – or actually CYDs as there are several variations.Cheap Yellow Display.They range in size and capability… I only have one and it is down at the lower end… You can see the device in the image above….

yes, that price in the lead image is correct – just over 10 Euros inc post + the great new EU rip-off tax.Here’s the link – https://es.aliexpress.com/item/1005007286593472.html And here’s a photo of the board..well, the underside anyway.

On the other side is a 320*240 touch-display.The package comes complete with USB-A to USB-C lead, little adaptors and an ALLEN bolt.My particular board has an RGB (not ws2812) LED in view above – a full size ESP32, a micro-SD slot which I’ve not used, a microUSB slot which I’ve not used… and a couple of buttons I’ve not touched.

I bought the board as I fancied building up a general purpose scanner for WiFi, BLE and BT.I thought I might just sneak in the odd game of Manic Miner while I was there – but as it happens the scanner took a little more work than I’d planned.Here’s the link to the project I picked – https://github.com/deamonmist/WhereFiCYD The project doesnt seem to be very old and credit to the guy for putting it together – but read on.

You’ll see reference in the link above (and the code header) to PYTHON which is a bit odd as 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 – the idea being that this would be a 5 minute drop-in project.Sadly things didn’t work out that way.Firstly the code was based around a 480px wide version of the CYD – so that took a little modification.

Then it seems it was also based on a display with inverted colours – that took far too long to realise.The next hurdle came when I tried scanning a BT device as against BLE….ie my phone set to be visible to other devices.

It could not find it.Several hacks later – the original code did not actually have the Bluetooth set up.Also there’s a nice RGB LED on my board which was not used at all – ok, cosmetic but I like to make use of an onboard RGB if it’s there.

Suffice it to say that a day’s graft later, working with my pal ChatGPT and we finally tonight got the whole thing running on my board.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 flaw in the original code came into view when for example the device spotted more than 5 BLE devices – clicking on a device produced/produces a pretty graph showing signal and relative direction – but there seemed to be no swipe ability to scroll down below the first 5 entries – we added that in.

So here it is – you can touch one of 3 on-screen buttons at the top of the display (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 appears to have no special battery facilities so it’s USB-powered at least for now.

After much work I now own a nice little scanner.And if the novelty wears off its a simple matter to USB-FLASH manic miner onto the board (no pressing reset or “flash” buttons).Even with that ridiculous tax it’s still good value – most of my boards of this kind have no touch capability which is a shame – and no doubt a reason that this and similar boards with the same CYD name are so popular – DO be aware that there are lots of variations however.

// 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 <Arduino.h> #include <math.h> #include <string.h> #include <TFT_eSPI.h> #include <XPT2046_Touchscreen.h> #include <BLEDevice.h> #include <BLEUtils.h> #include <BLEScan.h> #include <BLEAdvertisedDevice.h> #include <BluetoothSerial.h> 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 = &param->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’s the .ini file only slightly updated to handle the 320*240 display and pin definitions on the CYD and to use the COM port available 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 https://github.com/PaulStoffregen/XPT2046_Touchscreen 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

Read More
Related Posts