I’ve had a couple of these little TTGO T-Display boards sitting around for quite some time. They are cheap ESP32 boards with a surprisingly good little colour display, two buttons and USB-C.
I originally found that one of mine was already running some very old firmware. It was displaying the time and date and, interestingly, still connected to my WiFi.
That got me thinking…
I have recently been doing quite a bit with Bluetooth Low Energy, particularly using ESPHome Bluetooth proxies around the house. The T-Display has a screen which is large enough to actually show useful information, so why not turn one into a BLE scanner which also works as a Bluetooth proxy?
Firstly I made THIS (image on the left) using an Agrobot board… handy for showing how many BLE devices the board can see in a rolling 120 second window – and the coding is simple enough in ESPHome….
but then I found my TTGO boards and got carried away..
As it turned out, the TTGO board makes a very superior and rather nice little device.
The finished result
The T-Display now runs ESPHome and does several jobs simultaneously:
- BLE scanning
- Bluetooth proxy for Home Assistant
- Displays discovered BLE devices
- Shows MAC addresses and advertised names
- Sorts devices by signal strength
- Colour-codes the RSSI
- Shows the current time
- Supports multiple pages
- Has a pause function
- Has a controllable display backlight
- Provides a simple ESPHome Web UI
The display is rotated into portrait mode, which turns out to be much more useful than landscape for this particular application.
With the final font size I can comfortably display 18 devices per page.
The Display
The T-Display uses a 135 × 240 colour TFT.
The original idea was to use the display in landscape mode, but once I started putting BLE addresses on it, it became obvious that portrait was much better.
There simply isn’t enough vertical space in landscape. Turning it through 90 degrees gives us a useful list:
The font is a 10-pixel Roboto Mono, which turned out to be a good compromise between readability and the number of devices we can display.
RSSI Colours
I wanted the signal strength to be immediately obvious without having to read every number.
The RSSI value is therefore coloured:
| RSSI | Colour |
|---|---|
| −55 or better | Green |
| −56 to −65 | Yellow |
| −66 to −79 | Orange |
| below −79 | Red |
I deliberately didn’t make −79 “bad”. A signal around there can still be perfectly usable, particularly with BLE devices, so red is reserved for genuinely weak signals.
This makes it very easy to walk around the house with the display and see which devices are being heard strongly.
BLE Scanner
The ESPHome BLE scanner runs continuously:
esp32_ble_tracker:
scan_parameters:
interval: 1100ms
window: 1100ms
active: true
Whenever an advertisement is received, the device’s:
- MAC address
- advertised name
- RSSI
- last-seen time
are recorded.
Devices which haven’t been heard for 120 seconds are removed. I allowed storage for up to 40 devices, although only 18 are displayed on a page.
The list is sorted by RSSI, strongest first. That makes the display surprisingly useful as a portable BLE diagnostic tool.
Bluetooth Proxy
The important part is that the BLE scanner isn’t replacing the Bluetooth proxy.
The proxy remains active:
bluetooth_proxy: active: true connection_slots: 3
So the TTGO is doing both jobs.
It can be sitting somewhere in the house providing Bluetooth coverage to Home Assistant while simultaneously showing me what it can actually hear.
That was really the point of the project.
I don’t just want another Bluetooth proxy hidden somewhere doing its job invisibly — I wanted to be able to see what it was doing.
The Buttons
There are two physical buttons on the T-Display.
A short press changes page:
- Left button → previous page
- Right button → next page
The pages wrap around, so pressing Next on the last page takes you back to the first.
There is also a useful long-press function.
Hold either button for about a second and the display freezes. Release the button and it immediately resumes. Importantly, this only freezes the display.
The BLE scanner and Bluetooth proxy continue operating in the background.
That means I can stop the screen on a particular set of devices and examine it without stopping the actual Bluetooth processing.
The Backlight
There was one thing I hadn’t initially considered.
The TFT is bright — but leaving it illuminated 24 hours a day isn’t particularly sensible.
So I added a proper ESPHome switch for the display backlight.
Here it is in the WEBUI (ip address of the device).
The T-Display uses GPIO4 for the backlight, and the final configuration gives control of that pin to an ESPHome GPIO switch.
The important part is:
switch:
- platform: gpio
id: display_backlight
name: "Display Backlight"
icon: "mdi:monitor"
pin:
number: GPIO4
restore_mode: ALWAYS_ON
The display driver itself is told not to claim the backlight pin:
backlight_pin: no
This gives me an ordinary ESPHome switch which can be controlled from Home Assistant or the device’s Web UI – so I can simply turn the display off when I don’t need it while leaving the BLE proxy running.
ESPHome Web UI
I also added the ESPHome Web Server:
web_server: port: 80 log: false
I don’t really need a live log on this particular device. The useful thing is having a simple Web UI containing the Display Backlight control.
Opening the device in a browser gives me an On/Off control for the display (apart from power usage, these displays don’t last forever so leaving the display on 24/7 is probably not a good idea).
That means the TTGO can be installed somewhere permanently and the screen can be controlled without having to physically touch it.
Time Display
The time comes from ESPHome’s SNTP component:
time:
- platform: sntp
id: local_time
timezone: Europe/Madrid
The time is displayed in cyan at the top right.
The device initially needs to obtain the time over the network, but once synchronised it updates normally.
Two of Them
Once I had the first one working, I had another identical T-Display sitting there.
Why not make that one a second proxy?
The second board is essentially an exact copy of the first. The only things which need to differ are the ESPHome device name and friendly name so the two boards can coexist on the network.
The result is two little portable BLE monitors/proxies.
Note – the friendly name appears in the webUI – the name HAS to be unique for each one. I can put one in one room and another elsewhere and immediately see what each one is hearing (of course they need to be within range of a particular SSID – i.e. router access point).
Seems to me that this is going to be rather useful when investigating Bluetooth range and coverage.
The Complete ESPHome Configuration
This is the complete configuration:
# ========================================================
# Version1 of my ble proxy with scrolling display
# August 2026 Peter Scargill and credit to ChatGPT
# ========================================================
esphome:
name: ttgo-ble-proxy
friendly_name: TTGO BLE Proxy
esp32:
board: esp32dev
framework:
type: arduino
logger:
api:
ota:
- platform: esphome
# =========================================================
# WEB UI
# =========================================================
web_server:
port: 80
log: false
# =========================================================
# WIFI
# =========================================================
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
# =========================================================
# TIME
# =========================================================
time:
- platform: sntp
id: local_time
timezone: Europe/Madrid
# =========================================================
# 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;
std::string mac = x.address_str();
std::string name = x.get_name();
int rssi = x.get_rssi();
uint32_t now = millis();
int found = -1;
// Find existing device
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 device
if (found < 0) {
uint32_t oldest = 0;
found = 0;
for (int i = 0; i < MAX_DEVICES; i++) {
if (id(ble_last_seen)[i] < oldest || i == 0) {
oldest = id(ble_last_seen)[i];
found = i;
}
}
}
// Store/update device
id(ble_macs)[found] = mac;
id(ble_names)[found] = name;
id(ble_rssi)[found] = rssi;
id(ble_last_seen)[found] = now;
// Remove devices not heard for 120 seconds
for (int i = 0; i < MAX_DEVICES; i++) {
if (!id(ble_macs)[i].empty()) {
if ((uint32_t)(now - id(ble_last_seen)[i]) >= DEVICE_TIMEOUT) {
id(ble_macs)[i].clear();
id(ble_names)[i].clear();
id(ble_rssi)[i] = 0;
id(ble_last_seen)[i] = 0;
}
}
}
// Count active devices
int count = 0;
for (int i = 0; i < MAX_DEVICES; i++) {
if (!id(ble_macs)[i].empty()) {
count++;
}
}
id(ble_device_count) = count;
# =========================================================
# BLUETOOTH PROXY
# =========================================================
bluetooth_proxy:
active: true
connection_slots: 3
# =========================================================
# GLOBALS
# =========================================================
globals:
- 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"
- id: ble_page
type: int
restore_value: no
initial_value: "0"
- id: ble_paused
type: bool
restore_value: no
initial_value: "false"
# =========================================================
# DISPLAY BACKLIGHT
#
# TTGO T-Display backlight = GPIO4
#
# ON = illuminated
# OFF = backlight off
# =========================================================
switch:
- platform: gpio
id: display_backlight
name: "Display Backlight"
icon: "mdi:monitor"
pin: GPIO4
restore_mode: RESTORE_DEFAULT_ON
# =========================================================
# BUTTONS
#
# LEFT GPIO0 = previous page
# RIGHT GPIO35 = next page
#
# Short press:
# page change
#
# Hold for 1 second:
# freeze display
#
# Release:
# resume display
#
# BLE scanning and proxy continue throughout.
# =========================================================
binary_sensor:
# -------------------------------------------------------
# LEFT BUTTON - GPIO0
# -------------------------------------------------------
- platform: gpio
id: button_left
internal: true
pin:
number: GPIO0
inverted: true
mode:
input: true
pullup: true
# Short press = previous page
on_click:
- min_length: 50ms
max_length: 700ms
then:
- lambda: |-
const int MAX_DEVICES = 40;
const int ROWS = 18;
int count = 0;
for (int i = 0; i < MAX_DEVICES; i++) {
if (!id(ble_macs)[i].empty()) {
count++;
}
}
int pages = (count + ROWS - 1) / ROWS;
if (pages < 1)
pages = 1;
id(ble_page)--;
if (id(ble_page) < 0)
id(ble_page) = pages - 1;
# Long press = pause while held
on_press:
then:
- delay: 1000ms
- if:
condition:
binary_sensor.is_on: button_left
then:
- lambda: |-
id(ble_paused) = true;
- component.update: ble_display
- delay: 100ms
- component.suspend: ble_display
# Release = resume
on_release:
then:
- if:
condition:
lambda: 'return id(ble_paused);'
then:
- lambda: |-
id(ble_paused) = false;
- component.resume: ble_display
- component.update: ble_display
# -------------------------------------------------------
# RIGHT BUTTON - GPIO35
# -------------------------------------------------------
- platform: gpio
id: button_right
internal: true
pin:
number: GPIO35
inverted: true
# Short press = next page
on_click:
- min_length: 50ms
max_length: 700ms
then:
- lambda: |-
const int MAX_DEVICES = 40;
const int ROWS = 18;
int count = 0;
for (int i = 0; i < MAX_DEVICES; i++) {
if (!id(ble_macs)[i].empty()) {
count++;
}
}
int pages = (count + ROWS - 1) / ROWS;
if (pages < 1)
pages = 1;
id(ble_page)++;
if (id(ble_page) >= pages)
id(ble_page) = 0;
# Long press = pause while held
on_press:
then:
- delay: 1000ms
- if:
condition:
binary_sensor.is_on: button_right
then:
- lambda: |-
id(ble_paused) = true;
- component.update: ble_display
- delay: 100ms
- component.suspend: ble_display
# Release = resume
on_release:
then:
- if:
condition:
lambda: 'return id(ble_paused);'
then:
- lambda: |-
id(ble_paused) = false;
- component.resume: ble_display
- component.update: ble_display
# =========================================================
# FONTS
# =========================================================
font:
- file: "gfonts://Roboto Mono"
id: font_row
size: 10
- file: "gfonts://Roboto"
id: font_title
size: 12
- file: "gfonts://Roboto"
id: font_small
size: 9
# =========================================================
# SPI
# =========================================================
spi:
clk_pin: GPIO18
mosi_pin: GPIO19
# =========================================================
# DISPLAY
# =========================================================
display:
- platform: st7789v
id: ble_display
model: TTGO TDisplay 135x240
backlight_pin: no
rotation: 0
update_interval: 1s
lambda: |-
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, 0, 0);
auto grey = Color(150, 150, 150);
it.fill(black);
// ---------------------------------------------------
// HEADER
// ---------------------------------------------------
if (id(ble_paused)) {
it.print(2, 2,
id(font_title),
cyan,
"PAUSED");
} else {
it.print(2, 2,
id(font_title),
blue,
"BLE PROXY");
}
it.strftime(133, 2,
id(font_title),
cyan,
TextAlign::TOP_RIGHT,
"%H:%M",
id(local_time).now());
it.line(0, 16, 134, 16, blue);
// ---------------------------------------------------
// SORT DEVICES BY RSSI
// ---------------------------------------------------
const int MAX_DEVICES = 40;
const int ROWS = 18;
int order[MAX_DEVICES];
int count = 0;
for (int i = 0; i < MAX_DEVICES; i++) {
if (!id(ble_macs)[i].empty()) {
order[count++] = i;
}
}
// Strongest signal 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;
}
}
}
// ---------------------------------------------------
// PAGING
// ---------------------------------------------------
int pages = (count + ROWS - 1) / ROWS;
if (pages < 1)
pages = 1;
if (id(ble_page) >= pages)
id(ble_page) = pages - 1;
if (id(ble_page) < 0)
id(ble_page) = 0;
int start = id(ble_page) * ROWS;
int end = start + ROWS;
if (end > count)
end = count;
// ---------------------------------------------------
// DEVICE ROWS
// ---------------------------------------------------
int y = 20;
for (int n = start; n < end; n++) {
int i = order[n];
std::string display_name = id(ble_names)[i];
// No advertised name? Use MAC
if (display_name.empty()) {
display_name = id(ble_macs)[i];
}
// Leave room for RSSI
if (display_name.length() > 17) {
display_name = display_name.substr(0, 17);
}
it.print(1, y,
id(font_row),
white,
display_name.c_str());
// -------------------------------------------------
// RSSI COLOUR
//
// >= -55 GREEN
// -56 to -65 YELLOW
// -66 to -79 ORANGE
// < -79 RED
// -------------------------------------------------
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(133, y,
id(font_row),
rssi_color,
TextAlign::TOP_RIGHT,
rssi_text);
y += 11;
}
// ---------------------------------------------------
// FOOTER
// ---------------------------------------------------
it.line(0, 226, 134, 226, blue);
char footer[48];
snprintf(footer,
sizeof(footer),
"%d BLE devices %d/%d",
count,
id(ble_page) + 1,
pages);
it.print(2, 229,
id(font_small),
cyan,
footer);
COMPLETE YAML HERE…..
Final Thoughts
What started as an old TTGO board sitting in a drawer has turned into something considerably more useful than I expected.
It is simultaneously:
a BLE scanner, a Bluetooth proxy, a signal-strength monitor and a little portable diagnostic display.
And because the display is showing the actual BLE traffic being detected, it gives me something I don’t normally get from a conventional Bluetooth proxy: a visual indication of what is happening.
For example, walking into another room and suddenly seeing a device move from: -52 to: -74 tells me considerably more than simply knowing that the Bluetooth proxy exists.
I think these little TTGO displays are going to be rather useful for future Bluetooth experiments.




