When I get on a roll, I sometimes find it hard to stop, especially when things are working.And so it was, I made a tiny ESP32-based BLE Proxy for my Home Assistant setup with a tiny display to show how many devices the unit could see (I did this a few days ago).Then I got carried away and pulled out a couple of TTGO Display boards using the old WROOM ESP32 chips.
This went together yesterday.And THEN I started to see the limitations of these older ESP32s… it didn’t stop me writing about the projects which work JUST FINE… but I wanted MORE.The next step would be an ILI9341-based board using an ESP32-S3 which has LOADS more RAM.
That’s all documented in the blog – but THEN I found an st7789v display – and took the code, only VERY slightly modified (but adding improvements all the way including a totally un-necessary BME280 temperature, humidity and pressure chip).The result – the board you see here, which will work stand-alone as a BLE proxy AND pocket scanner – and optionally plug into a USB-C-equipped phone so I can wander around the house checking my bluetooth connectivity using the display for output and my phone (using the webUI of the project in a browser)to monitor the bluetooth signals – filter specific signals and also as an aside check temperature, humidity and pressure… and… well, by the time you read this I’ll likely have added more….I’m having great fun.
You have photos of the front and back here… and below – the YAML code for ESPHOME – only modify the WiFi access point info (near the start) and time location (at the end) CAREFULLY.The usual scenario here is to assemble and check the board + ESP32-S3 + BME280 if you want that in – then plug the board into a PC USB port, make a new ESPHOME project using this code….save, flash using USB – then subsequently any mods you can flash over WiFi.
The WebUI here… Note the MAC filter with case-insensitivity, partial-MAC ability and lax attitude toward colons.and finally befoer the code – what this looks like in Home Assistant… # ======================================================== # S3 + st7789v BLE PROXY and Scanner 3.0 with search # August 21, 2026 Peter Scargill # ======================================================== esphome: name: s3-st7789v-ble friendly_name: S3 st7789v BLE Proxy Monitor esp32: board: esp32-s3-devkitc-1 framework: type: arduino logger: web_server: port: 80 version: 3 sorting_groups: - id: sensor_control name: "Sensor and Control" sorting_weight: 10 api: ota: - platform: esphome password: ttgo_ota_password - platform: web_server wifi: networks: - ssid: !secret wifi_ssid password: !secret wifi_password - ssid: !secret wifi_ssid2 password: !secret wifi_password - ssid: !secret wifi_ssid3 password: !secret wifi_password power_save_mode: none post_connect_roaming: true # ========================================================= # SPI and I2C # ========================================================= spi: clk_pin: GPIO4 mosi_pin: GPIO6 i2c: sda: GPIO1 scl: GPIO2 scan: true # ========================================================= # Sensors # ========================================================= sensor: - platform: bme280_i2c address: 0x77 update_interval: 10s temperature: name: "BME280 Temperature" id: bme280_temperature accuracy_decimals: 1 humidity: name: "BME280 Humidity" id: bme280_humidity accuracy_decimals: 1 pressure: name: "BME280 Pressure" id: bme280_pressure accuracy_decimals: 1 # ========================================================= # GLOBALS # ========================================================= globals: - id: ble_page type: int restore_value: no initial_value: "0" - id: ble_macs type: std::string[40] restore_value: no - id: ble_names type: std::string[40] restore_value: no - id: ble_rssi type: int[40] restore_value: no - id: ble_last_seen type: uint32_t[40] restore_value: no - id: ble_device_count type: int restore_value: no initial_value: "0" # true = NAME + MAC # false = MAC only - id: display_name_mode type: bool restore_value: yes initial_value: "true" # Normalised MAC search string.Empty = no highlighting.
- id: ble_mac_search type: std::string restore_value: no # ========================================================= # WEB UI CONTROLS # ========================================================= text: - platform: template name: "Search MAC" id: ble_mac_search_text mode: text optimistic: true restore_value: no web_server: sorting_group_id: sensor_control sorting_weight: 10 button: # ------------------------------------------------------- # CONFIRM SEARCH # ------------------------------------------------------- - platform: template name: "Confirm Search" icon: "mdi:magnify" web_server: sorting_group_id: sensor_control sorting_weight: 20 on_press: - lambda: |- std::string s = id(ble_mac_search_text).state; std::string cleaned; for (char c : s) { if (c != ':' && c != ' ' && c != '-') { cleaned += (char)tolower((unsigned char)c); } } id(ble_mac_search) = cleaned; id(ble_page) = 0; - component.update: ble_display # ------------------------------------------------------- # HOME PAGE # ------------------------------------------------------- - platform: template web_server: sorting_group_id: sensor_control sorting_weight: 30 name: "Home Page" icon: "mdi:home" on_press: - lambda: |- id(ble_page) = 0; - component.update: ble_display # ------------------------------------------------------- # PREVIOUS PAGE # ------------------------------------------------------- - platform: template web_server: sorting_group_id: sensor_control sorting_weight: 50 name: "Previous Page" icon: "mdi:chevron-left" on_press: - lambda: |- const int MAX_DEVICES = 40; const int ROWS_PER_PAGE = 24; const uint32_t DEVICE_TIMEOUT = 120000; int count = 0; for (int i = 0; i < MAX_DEVICES; i++) { if (!id(ble_macs)[i].empty()) { uint32_t age = millis() - id(ble_last_seen)[i]; if (age < DEVICE_TIMEOUT) { count++; } } } int pages = (count + ROWS_PER_PAGE - 1) / ROWS_PER_PAGE; if (pages < 1) { pages = 1; } id(ble_page)--; if (id(ble_page) < 0) { id(ble_page) = pages - 1; } - component.update: ble_display # ------------------------------------------------------- # NEXT PAGE # ------------------------------------------------------- - platform: template web_server: sorting_group_id: sensor_control sorting_weight: 40 name: "Next Page" icon: "mdi:chevron-right" on_press: - lambda: |- const int MAX_DEVICES = 40; const int ROWS_PER_PAGE = 24; const uint32_t DEVICE_TIMEOUT = 120000; int count = 0; for (int i = 0; i < MAX_DEVICES; i++) { if (!id(ble_macs)[i].empty()) { uint32_t age = millis() - id(ble_last_seen)[i]; if (age < DEVICE_TIMEOUT) { count++; } } } int pages = (count + ROWS_PER_PAGE - 1) / ROWS_PER_PAGE; if (pages < 1) { pages = 1; } id(ble_page)++; if (id(ble_page) >= pages) { id(ble_page) = 0; } - component.update: ble_display # ========================================================= # DISPLAY PAUSE SWITCH # ON = PAUSED # OFF = RUNNING # ========================================================= switch: - platform: template web_server: sorting_group_id: sensor_control sorting_weight: 5 name: "Pause Display" id: display_pause_switch icon: "mdi:pause-circle" optimistic: true restore_mode: ALWAYS_OFF turn_on_action: - component.suspend: ble_display turn_off_action: - component.resume: ble_display - component.update: ble_display # ========================================================= # BLE TRACKER # ========================================================= esp32_ble_tracker: scan_parameters: interval: 1100ms window: 1100ms active: true on_ble_advertise: then: - lambda: |- const int MAX_DEVICES = 40; const uint32_t DEVICE_TIMEOUT = 120000; char mac_buffer[18]; x.address_str_to(mac_buffer); ESP_LOGI("BLE", "Found: %s %s RSSI: %d", mac_buffer, x.get_name().c_str(), x.get_rssi()); std::string mac = mac_buffer; std::string name = x.get_name(); int rssi = x.get_rssi(); uint32_t now = millis(); // ================================================= // FIND EXISTING DEVICE // ================================================= int found = -1; for (int i = 0; i < MAX_DEVICES; i++) { if (id(ble_macs)[i] == mac) { found = i; break; } } // ================================================= // FIND EMPTY SLOT // ================================================= if (found < 0) { for (int i = 0; i < MAX_DEVICES; i++) { if (id(ble_macs)[i].empty()) { found = i; break; } } } // ================================================= // IF FULL, REPLACE OLDEST INACTIVE DEVICE // ================================================= if (found < 0) { uint32_t oldest_time = 0xFFFFFFFFUL; int oldest_slot = -1; for (int i = 0; i < MAX_DEVICES; i++) { uint32_t age = now - id(ble_last_seen)[i]; if (age >= DEVICE_TIMEOUT && id(ble_last_seen)[i] < oldest_time) { oldest_time = id(ble_last_seen)[i]; oldest_slot = i; } } if (oldest_slot >= 0) { found = oldest_slot; } } // ================================================= // STORE DEVICE // ================================================= if (found >= 0) { id(ble_macs)[found] = mac; id(ble_names)[found] = name; id(ble_rssi)[found] = rssi; id(ble_last_seen)[found] = now; } // ================================================= // COUNT ACTIVE DEVICES // ================================================= int count = 0; for (int i = 0; i < MAX_DEVICES; i++) { if (!id(ble_macs)[i].empty()) { uint32_t age = now - id(ble_last_seen)[i]; if (age < DEVICE_TIMEOUT) { count++; } } } id(ble_device_count) = count; # ========================================================= # FONTS # ========================================================= font: - file: "gfonts://Inconsolata" id: font_row size: 12 - file: "gfonts://Roboto" id: font_title size: 18 - file: "gfonts://Roboto" id: font_small size: 11 # ========================================================= # DISPLAY # # ILI9341 # Physical resolution: 240 x 320 # Portrait # # X = 0 ...239 # Y = 0 ...319 # ========================================================= display: - platform: mipi_spi model: st7789v id: ble_display dc_pin: GPIO5 reset_pin: GPIO8 cs_pin: GPIO7 invert_colors: false data_rate: 10MHz rotation: 0 update_interval: 1s lambda: |- // ================================================= // DISPLAY DIMENSIONS // ================================================= const int SCREEN_WIDTH = 240; const int SCREEN_HEIGHT = 320; const int RIGHT_EDGE = SCREEN_WIDTH - 1; // ================================================= // LAYOUT // ================================================= const int HEADER_Y = 5; const int HEADER_LINE_Y = 27; const int FIRST_Y = 32; const int LINE_HEIGHT = 13; const int FOOTER_LINE_Y = 306; const int FOOTER_Y = 309; const int ROWS_PER_PAGE = 20; // ================================================= // COLOURS // ================================================= auto black = Color(0, 0, 0); auto white = Color(255, 255, 255); auto cyan = Color(0, 255, 255); auto blue = Color(0, 120, 255); auto green = Color(0, 255, 0); auto yellow = Color(255, 255, 0); auto orange = Color(255, 165, 0); auto red = Color(255, 30, 30); auto purple = Color(200, 150, 255); auto dark_red = Color(220, 0, 0); it.fill(black); // ================================================= // HEADER // ================================================= it.print( 5, HEADER_Y, id(font_title), blue, "BLE Proxy Monitor" ); it.strftime( RIGHT_EDGE - 4, HEADER_Y, id(font_title), cyan, TextAlign::TOP_RIGHT, "%H:%M", id(sntp_time).now() ); it.line( 0, HEADER_LINE_Y, RIGHT_EDGE, HEADER_LINE_Y, blue ); // ================================================= // BUILD ACTIVE DEVICE LIST // ================================================= const int MAX_DEVICES = 40; int order[MAX_DEVICES]; int count = 0; for (int i = 0; i < MAX_DEVICES; i++) { if (!id(ble_macs)[i].empty()) { uint32_t age = millis() - id(ble_last_seen)[i]; if (age < 120000) { order[count++] = i; } } } // ================================================= // SORT STRONGEST RSSI FIRST // ================================================= for (int i = 0; i < count - 1; i++) { for (int j = i + 1; j < count; j++) { if (id(ble_rssi)[order[j]] > id(ble_rssi)[order[i]]) { int temp = order[i]; order[i] = order[j]; order[j] = temp; } } } // ================================================= // PAGE CALCULATION // ================================================= int pages = (count + ROWS_PER_PAGE - 1) / ROWS_PER_PAGE; if (pages < 1) { pages = 1; } // No physical page buttons on this S3 yet.
// Display page 1 for now.int current_page = id(ble_page); int start = current_page * ROWS_PER_PAGE; int end = start + ROWS_PER_PAGE; if (end > count) { end = count; } // ================================================= // DEVICE ROWS // ================================================= int y = FIRST_Y; for (int n = start; n < end; n++) { int i = order[n]; // ------------------------------------------------- // MAC // ------------------------------------------------- std::string mac = id(ble_macs)[i]; // ------------------------------------------------- // MAC SEARCH / HIGHLIGHT // Case-insensitive, ignores :, spaces and - // Partial matches are supported.// ------------------------------------------------- std::string mac_compare; for (char c : mac) { if (c != ':' && c != ' ' && c != '-') { mac_compare += (char)tolower((unsigned char)c); } } bool mac_match = !id(ble_mac_search).empty() && mac_compare.find(id(ble_mac_search)) != std::string::npos; if (mac_match) { // Red background behind the MAC, white text.
it.filled_rectangle( 1, y + 2, 105, 11, dark_red ); } it.print( 2, y, id(font_row), mac_match ? white : white, mac.c_str() ); // ------------------------------------------------- // NAME // ------------------------------------------------- std::string name = id(ble_names)[i]; if (id(display_name_mode) && !name.empty()) { // Keep the name clear of RSSI.if (name.length() > 17) { name = name.substr(0, 17); } it.print( 110, y, id(font_row), purple, name.c_str() ); } // ------------------------------------------------- // RSSI // ------------------------------------------------- Color rssi_color; if (id(ble_rssi)[i] >= -55) { rssi_color = green; } else if (id(ble_rssi)[i] >= -65) { rssi_color = yellow; } else if (id(ble_rssi)[i] >= -79) { rssi_color = orange; } else { rssi_color = red; } char rssi_text[8]; snprintf( rssi_text, sizeof(rssi_text), "%d", id(ble_rssi)[i] ); it.print( RIGHT_EDGE - 2, y, id(font_row), rssi_color, TextAlign::TOP_RIGHT, rssi_text ); y += LINE_HEIGHT; } // ================================================= // FOOTER LINE // ================================================= it.line( 0, FOOTER_LINE_Y, RIGHT_EDGE, FOOTER_LINE_Y, blue ); // ================================================= // FOOTER - DEVICE COUNT // Fixed left position // ================================================= char count_text[32]; snprintf( count_text, sizeof(count_text), "%d BLE devices", count ); it.print( 5, FOOTER_Y, id(font_small), cyan, count_text ); // ================================================= // FOOTER - PAGE // Fixed right position // ================================================= char page_text[32]; snprintf( page_text, sizeof(page_text), "PAGE %d/%d", current_page + 1, pages ); it.print( RIGHT_EDGE - 4, FOOTER_Y, id(font_small), cyan, TextAlign::TOP_RIGHT, page_text ); # ========================================================= # TIME # ========================================================= time: - platform: sntp id: sntp_time timezone: Europe/Madrid
Read More