My LAST EVER BLE Proxy and Monitor – I PROMISE

Latest BLE Proxy and Monitor

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…

BLE Proxy webUI

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…

BLE Scanner in HA
# ========================================================
# 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

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