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 simply the 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 USB-C for power and programming, 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. This is the 2.8″ screen version.
I bought the board as I fancied building up a general purpose scanner for WiFi, BLE and BT. At first I looked at Wireless Wizard on YouTube – but then, on loading it – erm no.
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.
The project seems to have been updated in the last few months and credit to the guy for putting it together – but read on – lots of big errors. 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 resolve. Far more relevant issues came to light when I tried scanning a BT device as against BLE…. ie my phone set to be visible to other devices. It could not find the phone. Several hacks later we realised the original code did not actually have the Bluetooth set up. Combine that with missing “scanned devices” no list reset before the next scan…and many more issues… anyway – all sorted in the included code below..
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 nearly 2 day’s graft later, working with my pal ChatGPT and we finally tonight got the whole thing running on my board and much improved.
If the project interests you, there are two files in the original author’s code folder and I suggest setting up the original project. You need Visual Studio code withe plaatformio included – at least that’s what I used. If you have my board the two files are incldued below – and in the platformio.ini file you’ll have to change the serial port to match your setup (device manager – ports).
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 triple-scanner.
And if the novelty wears off, it’s a simple matter to USB-FLASH manic miner onto the board (no pressing reset or “flash” buttons) – it then takes a minute to put the scanner code back in over serial (no on-board button-pressing). Even with that ridiculous tax this board is 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.
For anyone who tried this out on October 5, 2026 – on October 7 I added/modified code again – clearing WiFi and BLE results before subsequent scans. So now all 3 types of scan clear before use.
// 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. Oh and let's not forget the many folk who
// developed the libraries this code relies on.
//
#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>
#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;
};
BluetoothSerial SerialBT;
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 2000
#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 clearWiFiResults()
{
wifiCount = 0;
memset(wifiTable, 0, sizeof(wifiTable));
}
void startWifi()
{
clearWiFiResults();
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 clearBLEResults()
{
bleCount = 0;
memset(bleTable, 0, sizeof(bleTable));
}
void startBLE()
{
clearBLEResults();
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)
{
// Already in this mode — do nothing.
if (newMode == radioMode)
return;
RadioMode oldMode = radioMode;
radioMode = newMode;
updateModeLED();
appState = STATE_SCAN;
topCount = 0;
listPage = 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 ? "WiFi" :
radioMode == RADIO_BLE ? "BLE" : "Classic BT";
char countText[12];
snprintf(countText, sizeof(countText), "(%d)", activeCount());
int titleWidth = tft.textWidth(title, 2);
int countWidth = tft.textWidth(countText, 1);
int gap = 4;
int totalWidth = titleWidth + gap + countWidth;
int startX = (SCREEN_W - totalWidth) / 2;
// Main title
tft.drawString(title, startX, 4, 2);
// Smaller count, vertically centred
tft.drawString(countText, startX + titleWidth + gap, 10, 1);
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);
}
}
}
// ─── 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(0, 0, 80, 53, cMode);
tft.drawRect(80, 0, 80, 53, cMode);
tft.drawRect(160, 0, 80, 53, 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, 57, 80, 26, cBack);
tft.setTextColor(cBack, TFT_BLACK);
tft.fillRect(2, 59, 30, 10, TFT_BLACK);
tft.drawString("BACK", 2, 59, 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 ──────────────────────────────────────────────────────────
// Three equal touch zones across the full header area.
// X: 0-79 = WiFi, 80-159 = BLE, 160-239 = Classic.
// Y: 0-52 = entire top area.
if (sy >= 0 && sy <= 52)
{
if (sx < 80)
{
switchToMode(RADIO_WIFI);
if (debugOverlay)
drawDebugOverlay();
return;
}
if (sx < 160)
{
switchToMode(RADIO_BLE);
if (debugOverlay)
drawDebugOverlay();
return;
}
// 160-239
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 = n;
trackerPeak = -100;
bellCenter = 120.0f;
bellVelocity = 0.0f;
if (radioMode == RADIO_WIFI)
{
uint8_t idx = topList[trackerIdx];
currentChannel = activeTable()[idx].channel - 1;
esp_wifi_set_channel(currentChannel + 1, WIFI_SECOND_CHAN_NONE);
}
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 >= 53 && sy <= 95 &&
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 (radioMode == RADIO_WIFI &&
appState != STATE_TRACKER &&
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 platformio.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


