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



