ESPHome BLE Proxy Monitor 3.1.1 - Scargill's Tech Blog

This is the latest and hopefully final ever version of my ESPHome-based BLE Proxy now turned general useful BT/IP tool – is there no stopping me? Well, I’m writing this for two reasons: I’ve improved the code immensely.This is based on the ST7789V display board commonly available on Aliexpress and an ESP32-S3 miniature board.I’ve also stuck on there a BME280 temperature sensor, but that’s actually not even necessary.

It’s just an I2C add-on because I had one handy.But there’s far more to the story than this: Firstly, let me tell you – I built a little Acrobat-based BLE proxy which is blogged about earlier on, and wasted days trying to get to the bottom of why sometimes it wouldn’t connect to my Comfast router in my office, but would connect to other routers.It turns out that the Comfast – I’ll give you the product number — CF-WR631AXV2 — was adding a second DHCP server, if you like, to my system and confusing the little board, so I moved on to the ST7789V version and exactly the same problem.

This is wasted because of a duff router.Anyway, I replaced the router with a small mini access point – cheap €10 job from Aliexpress and that solved the problem.Then I went back to ESPHome to recompile the code with a little addition and part of the ESPHome service that provides JavaScript was being blocked, would you believe, by the Spanish government.

I’ve since written to the government in a fury, and to Comfast, and to ESPHome, to see if we can get the government to change whatever it is they’re doing to prevent illegal streaming that is interfering with ESPHome’s legitimate use.At the end of all of that, I was so elated that I’d solved the problem that I decided this little Bluetooth monitor and proxy was worth a couple of last features, which I think you’ll love.it’s just a USB-C powered board, nothing more- but I’d like to think I can get a case made to carry it around… no doubt others could do the same.

I have to tell you, I’m more than excited with how this has worked out.The previous blog entry for the ST7789v version of the unit tells you ALL about what it can do.Additions not covered in the previous version include keeping a history of SSIDs as I move around the house.

The back of the board is also shown in the earlier entry but use the code in here… Here’s the WebUI followed by the board in how I envisage a case..Included in this article is the full code in YAML for ESPHome.You simply need an ST7789V 240×320 TFT SPI display and an ESP32-S3 board with the optional BME2820.

All the relevant pins are detailed in the code.If you copy this code straight out of the blog, remember to use the raw option on your screen to get the raw code without any extras added by WordPress.# ======================================================== # S3 + st7789v BLE PROXY and Scanner 3.1.1 with search # August 21, 2026 Peter Scargill # ======================================================== substitutions: product_version: "3.1.1" timezone: "Europe/Madrid" 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 local: true sorting_groups: - id: control name: "Control" sorting_weight: 10 - id: sensors name: "Sensors" sorting_weight: 20 - id: diagnostic name: "Diagnostic" sorting_weight: 30 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 - ssid: !secret wifi_ssid4 password: !secret wifi_password - ssid: !secret wifi_ssid5 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 web_server: sorting_group_id: sensors sorting_weight: 10 humidity: name: "BME280 Humidity" id: bme280_humidity accuracy_decimals: 1 web_server: sorting_group_id: sensors sorting_weight: 20 pressure: name: "BME280 Pressure" id: bme280_pressure accuracy_decimals: 1 web_server: sorting_group_id: sensors sorting_weight: 30 - platform: wifi_signal name: "WiFi Signal" update_interval: 30s web_server: sorting_group_id: sensors sorting_weight: 40 # ========================================================= # WIFI INFORMATION # ========================================================= text_sensor: - platform: wifi_info ip_address: name: "IP Address" id: ip_address entity_category: "" web_server: sorting_group_id: diagnostic sorting_weight: 10 ssid: name: "WiFi SSID" id: wifi_ssid entity_category: "" on_value: then: - lambda: |- if (x.empty()) return; if (id(tracked_ssid).empty()) { id(tracked_ssid) = x; return; } if (x != id(tracked_ssid)) { id(previous_previous_ssid) = id(previous_ssid); id(previous_ssid) = id(tracked_ssid); id(tracked_ssid) = x; } web_server: sorting_group_id: diagnostic sorting_weight: 40 bssid: name: "WiFi BSSID" entity_category: "" web_server: sorting_group_id: diagnostic sorting_weight: 20 mac_address: name: "WiFi MAC" entity_category: "" web_server: sorting_group_id: diagnostic sorting_weight: 30 # ========================================================= # CURRENT PAGE - WEB UI # ========================================================= - platform: template name: "Current Page" id: current_page_display update_interval: 1s lambda: |- const int MAX_DEVICES = 40; const int ROWS_PER_PAGE = 20; 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; int current = id(ble_page); if (current >= pages) current = pages - 1; char page_text[32]; snprintf(page_text, sizeof(page_text), "P%d/%d", current + 1, pages); return {page_text}; web_server: sorting_group_id: sensors sorting_weight: 5 # ========================================================= # 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 - id: previous_ssid type: std::string restore_value: no initial_value: '""' - id: previous_previous_ssid type: std::string restore_value: no initial_value: '""' - id: tracked_ssid type: std::string restore_value: no initial_value: '""' # ========================================================= # 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: control sorting_weight: 10 button: # ------------------------------------------------------- # CONFIRM SEARCH # ------------------------------------------------------- - platform: template name: "Confirm Search" icon: "mdi:magnify" web_server: sorting_group_id: 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: control sorting_weight: 30 name: "Home Page" icon: "mdi:home" on_press: - lambda: |- id(ble_page) = 0; - component.update: ble_display - component.update: current_page_display # ------------------------------------------------------- # PREVIOUS PAGE # ------------------------------------------------------- - platform: template web_server: sorting_group_id: control sorting_weight: 40 name: "Previous Page" icon: "mdi:chevron-left" on_press: - lambda: |- const int MAX_DEVICES = 40; const int ROWS_PER_PAGE = 20; 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 - component.update: current_page_display # ------------------------------------------------------- # NEXT PAGE # ------------------------------------------------------- - platform: template web_server: sorting_group_id: control sorting_weight: 60 name: "Next Page" icon: "mdi:chevron-right" on_press: - lambda: |- const int MAX_DEVICES = 40; const int ROWS_PER_PAGE = 20; 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 - component.update: current_page_display # ========================================================= # DISPLAY PAUSE SWITCH # ON = PAUSED # OFF = RUNNING # ========================================================= switch: - platform: template web_server: sorting_group_id: 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 buffer_size: 25% 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 = 12; const int FOOTER_LINE_Y = 274; const int FOOTER_Y = 278; const int SSID_FOOTER_Y = 293; 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); auto grey = Color(150, 150, 150); 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 // ================================================= it.line( 0, FOOTER_LINE_Y, RIGHT_EDGE, FOOTER_LINE_Y, blue ); // ------------------------------------------------- // TOP STATUS LINE // BLE count | current IP | page // ------------------------------------------------- char count_text[32]; snprintf( count_text, sizeof(count_text), "%d BLE devices", count ); it.print( 2, FOOTER_Y, id(font_small), cyan, count_text ); std::string current_ip = id(ip_address).state; if (current_ip.empty() || current_ip == "0.0.0.0") current_ip = "----"; std::string ip_display = "IP: " + current_ip; it.print( 120, FOOTER_Y, id(font_small), cyan, TextAlign::TOP_CENTER, ip_display.c_str() ); char page_text[32]; snprintf( page_text, sizeof(page_text), "Page %d/%d", current_page + 1, pages ); it.print( RIGHT_EDGE - 2, FOOTER_Y, id(font_small), cyan, TextAlign::TOP_RIGHT, page_text ); // ------------------------------------------------- // SSID HISTORY LINE // ------------------------------------------------- std::string ssid_oldest = id(previous_previous_ssid); std::string ssid_previous = id(previous_ssid); std::string ssid_current = id(tracked_ssid); // Only current SSID known // Current = GREEN, LEFT if (ssid_oldest.empty() && ssid_previous.empty()) { if (!ssid_current.empty()) { it.print( 2, SSID_FOOTER_Y, id(font_small), green, ssid_current.c_str() ); } } // Two SSIDs known // Current = GREEN, LEFT // Previous = YELLOW, CENTRE else if (ssid_oldest.empty()) { if (!ssid_current.empty()) { it.print( 2, SSID_FOOTER_Y, id(font_small), green, ssid_current.c_str() ); } if (!ssid_previous.empty()) { it.print( 120, SSID_FOOTER_Y, id(font_small), grey, TextAlign::TOP_CENTER, ssid_previous.c_str() ); } } // Three SSIDs known // Current = GREEN, LEFT // Previous = YELLOW, CENTRE // Oldest = YELLOW, RIGHT else { if (!ssid_current.empty()) { it.print( 2, SSID_FOOTER_Y, id(font_small), green, ssid_current.c_str() ); } it.print( 120, SSID_FOOTER_Y, id(font_small), grey, TextAlign::TOP_CENTER, ssid_previous.c_str() ); it.print( RIGHT_EDGE - 2, SSID_FOOTER_Y, id(font_small), grey, TextAlign::TOP_RIGHT, ssid_oldest.c_str() ); } # ========================================================= # TIME # ========================================================= time: - platform: sntp id: sntp_time timezone: ${timezone}

Read More
Related Posts