I won’t go through the entire workings of my latest BLE Scanner and bluetooth proxy here. It is all detailed in the previous blogs – this is a major functional development of my previous effort using a better processor with more RAM. Suffice it to say that marvellous as the TTGO BT proxy and scanner is, even compared to the tiny Abrobot scanner I made earlier this week… ULTIMATELY the neat TTGO board with 2 in-built buttons is built on an ESP32 that is severely limiting – I had to juggle MQTT, web updates, WebUI and more and could not do the lot.
Then I had an epiphany – I’ve some esp32-s3 “super mini” boards lying around, they came from Aliexpress. I also have the odd ILI94341 320*240 display – same source. Dirty cheap, just a few euros/dollars/pounds for both.
I put the two together – no extra components and the ESP32-S3 “Super-Mini” (see photo below) has a USB-C connection. To test this combo I had the ESP plugged into a USB connection on my PC so no special power requirements… controls map back to Home Assistant if needed as does the BLE device info.
And here is the result… a WiFi-roaming, portable, multi-page BLE Proxy and BT Scanner with search. I’m VERY pleased. To see what this is about, read the two previous entries on the subject.
When looking at the webUI below – note that this looks slightly different to earlier ESPHOME WebUIs – it’s the latest version with small visual changes.. (you can select light or dark as before) and also that most of my BT devices do not send out a name, just a MAC. Name isn’t a requirement..
Importantly, it is possible with a USB-C to USB-C lead, to power the unit from a phone and also view the WebUI on the same. See below…

This scanner also has a neat SEARCH facility and it can highlight a particular MAC or range of MACs as they fly past. MAC addresses in the search box can be partial, case insensitive and can include the colons or not – all for ease of use.
See WebUI view on my Android phone..
So a neat board with a tiny ESP32-S3 on the back – just needs some kind of simple case… and here is the ESPHOME YAML code to make it all work. You’ll need your WiFi and password secrets file in ESPHOME (my ESPHOME sits inside HOME ASSISTANT and the whole lot is August 2026 updated at the time or writing)… the wiring is obvious from the code (MISO is not used and the ILI9341 CS pin is tied to 3v3 along with reset – I used the 5v pin on the ESP32 for the ILI9341 VCC).
Really – READ the TTGO project – all explained in there and the previous Abrobot project – this is simply a WAY better implementation. I’m nowhere NEAR out of resources on this one so who knows what it will include in future…. only a short while ago I hated BT because of it’s range limits but now I know how simple ble proxies can be (an ESP32 tiny board with nothing else attached and a little ESPHOME code – even the example that comes with ESPHOME will do) I’ve had a complete change of heart.
if anyone’s thinking of putting this together – here’s MY wiring – 7 wires – looking at the back of the display… but check your board for compatibility.
Here with my new toy I can walk around and check BT connectivity – AND GUESS WHAT – IMPORTANT – I only just found out that ESPHome can now be configured with multiple WiFi access points and roam between them, letting it select an appropriate available network while I’m walking all over the house/office with this device – MAGIC. I use the same password on each access point but of course you don’t have to. I’m retrofitting all my ESPHOME projects. The actual SSIDs and passwords are in my ESPHOME secrets file – good idea.
# ========================================================
# S3 + ILI9341 BLE PROXY and Scanner 3.1.1 with search
# August 30, 2026 Peter Scargill
# ========================================================
substitutions:
product_version: "3.1.1"
timezone: "Europe/Madrid"
esphome:
name: s3-ili9341-ble
friendly_name: S3 ILI9341 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
# =========================================================
spi:
clk_pin: GPIO4
mosi_pin: GPIO6
# =========================================================
# Sensors
# =========================================================
sensor:
- platform: wifi_signal
name: "WiFi Signal"
update_interval: 30s
web_server:
sorting_group_id: sensors
sorting_weight: 40
- platform: template
name: "BLE Devices"
id: ble_devices_web
unit_of_measurement: "devices"
accuracy_decimals: 0
update_interval: 1s
lambda: |-
return id(ble_device_count);
web_server:
sorting_group_id: sensors
sorting_weight: 10
# =========================================================
# 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
- platform: template
name: "Version"
id: version
lambda: |-
return {"${product_version}"};
update_interval: 60s
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: ili9xxx
model: ili9341
id: ble_display
dc_pin: GPIO5
reset_pin: GPIO8
invert_colors: false
color_palette: 8BIT
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 = 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,
102,
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 units",
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}
Note the timezone at the end of the code and my choice and number of WiFi access points near the top. If using the code be VERY careful to maintain correct indenting.
I hope someone finds this to be a useful tool. Changes to use an ST7789v display are fairly trivial and that’s the subject of another blog entry.



