TTGO T-Display – ESPHome BLE Scanner and Bluetooth Proxy

TTTGO BLE Proxy with Scanner

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?

Agrobot BLE 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:

RSSIColour
−55 or betterGreen
−56 to −65Yellow
−66 to −79Orange
below −79Red

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).

TTGO BLE Proxy

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.

Two of my BLE scanning boards

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.

Leave a Reply

Your email address will not be published. Required fields are marked *

Leave the field below empty!

Are you human? Please solve:Captcha


The maximum upload file size: 512 MB. You can upload: image, audio, video, document, spreadsheet, interactive, text, archive, code, other. Links to YouTube, Facebook, Twitter and other services inserted in the comment text will be automatically embedded. Drop file here