ESPHome BLE Proxy Monitor 3.1.1

ESPHome BLE Proxy Monitor 3.1.1

This is the latest and hopefully final ever version of my ST7789v-based ESPHome-based BLE Proxy now turned general useful BT/IP tool – is there no stopping me?

Well, I’m writing this for two reasons:

  1. I’ve improved the code immensely.
  2. This is based on the ST7789V display board commonly available on Aliexpress, coupled with a common ESP32-S3 miniature board. I’ve also added a BME280 temperature sensor, but the BME280 is entirely optional. It’s just an I2C add-on for the webUI because I had one handy.

But there’s far more to the story than this: Firstly, let me tell you – I built a little Abrobot-based BLE proxy which I blogged about earlier on, and wasted days trying to get to the bottom of why sometimes it wouldn’t connect to my Comfast router in my office, but would connect to other routers – I figured a code problem or board hardware issue – but no, It turns out that the Comfast – I’ll give you the product number — CF-WR631AXV2 — was adding a second DHCP server, if you like, to my system and confusing the little board, so I moved on to this ST7789V version and had exactly the same problem. I wasted a LOT of time because of a duff router. Anyway, I replaced the router with a small mini access point – cheap €10 job from Aliexpress and that solved the problem.

Then as if I’d not had enough issues, I went back to ESPHome to recompile the code with a little addition and all of a sudden, part of the ESPHome service that provides JavaScript was being blocked, would you believe, by the Spanish government. I’ve since written to the government in a fury, and to Comfast, and to ESPHome, to see if we can get the government to change whatever it is they’re doing to prevent illegal streaming that is interfering with legitimate use of ESPHome. Easily bypassed by VPN’ing to another country but VPNs bring their own pain.

At the end of all of that, I was so elated that I’d solved the problems that I decided this little Bluetooth monitor and proxy was worth adding a couple of last features, which I think you’ll love.

it’s just a USB-C powered ESP32-S3 board with added ST7789v display and powered by ESPHOME code, nothing more – but I’d like to think I can get a case made to carry it around… no doubt many others could do the same. I have to tell you, I’m more than excited with how this has worked out. The previous blog entry for the ST7789v version of the unit tells you ALL about what it can do. Features include keeping a history of SSIDs as I move around the house. The back of the board is shown further down.

Here’s the WebUI display (PC or phone as it displays in a browser), followed by the board encased in how I envisage a 3d-printed box..

BLE Proxy Monitor and more - Peter Scargill
BLE Proxy Monitor and more - Peter Scargill

Included in this article is the full code in YAML for ESPHome. You simply need an ST7789V 240×320 TFT SPI display and an ESP32-S3 board with the (entirely) optional BME2820. All the relevant pins are detailed in the code.

If you copy this code straight out of the blog, remember to use the raw option on your screen to get the raw code without any “extras” added by WordPress. This should paste straight into a new SPHOME project – initially flashed serially and subsequently by WiFi.

# ========================================================
# S3 + st7789v BLE PROXY and Scanner with search
# August 30, 2026 Peter Scargill
# ========================================================
substitutions:
  product_version: "3.1.1"
  timezone: "Europe/Madrid"
  
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
  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 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: 30s

    temperature:
      name: "BME280 Temperature"
      id: bme280_temperature
      accuracy_decimals: 1
      web_server:
        sorting_group_id: sensors
        sorting_weight: 10

    humidity:
      name: "BME280 Humidity"
      id: bme280_humidity
      accuracy_decimals: 1
      web_server:
        sorting_group_id: sensors
        sorting_weight: 20

    pressure:
      name: "BME280 Pressure"
      id: bme280_pressure
      accuracy_decimals: 1
      web_server:
        sorting_group_id: sensors
        sorting_weight: 30

  - platform: wifi_signal
    name: "WiFi Signal"
    update_interval: 30s
    web_server:
      sorting_group_id: sensors
      sorting_weight: 40
      
# =========================================================
# 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

# =========================================================
# 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: mipi_spi
    model: st7789v
    id: ble_display

    dc_pin: GPIO5
    reset_pin: GPIO8
    cs_pin: GPIO7

    invert_colors: false
    data_rate: 10MHz
    buffer_size: 25%

    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,
            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
      // =================================================

      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 devices",
        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}

At this point you may be wondreing if I’m going to give more detail – coming up…

But before delving into this version complete with ALL CODE ABOVE… a QUICK backtrack for anyone not up to speed on BLE Proxies. Go straight to The Projects if you’re already up to speed on BLE, ESPHOME etc. But first.. there is a later and improved version of the st7789v code in a later article you likely dont want to miss.

What is BLE?

BLE

BLE (Bluetooth Low Energy) is a low-power version of Bluetooth designed for small devices such as sensors, watches, beacons and trackers. Devices can periodically broadcast small amounts of information without maintaining a continuous Bluetooth connection.

What is a BLE proxy?

A BLE proxy is a network-connected device, such as an ESP32, that listens for nearby BLE devices and passes what it hears over Wi-Fi to Home Assistant.

This is SO useful because Home Assistant (HA) doesn’t need to be physically close to the Bluetooth device. You can put several inexpensive ESP32 BLE proxies around the house or office, and together they extend HA’s Bluetooth coverage.

In this project, the ESP32-S3 is both the BLE listener and the display: it scans for nearby BLE devices, shows what it finds on the TFT display, and simultaneously makes those devices available to HA over Wi-Fi. A simple ESP32 with no display does the job of a basic BLE proxy as do some ESP32-based smart devices

ESPHOME is a free framework for developing code for ESP8266 and ESP32-based projects. Historically I preferred Tasmota and have made and used countless devices with this free software at their base. At first, ESPHOME was clumsy and difficult to get your head around. Today is it well developed, can optionally work within HA and has drivers for many, many devices.

The basic BLE Proxy

The starting point is the ESPHome Bluetooth proxy. This is remarkably simple: the ESP32-S3’s built-in Bluetooth Low Energy radio listens for nearby BLE devices and passes the information back to HA over the network. ESPHome handles the Bluetooth communication, networking and Home Assistant connection for us, so there is no Bluetooth programming required.

esp32_ble_tracker:

bluetooth_proxy:

That’s essentially the heart of the BLE monitor. Once those two components are enabled, the ESP32 can discover nearby BLE devices and act as a Bluetooth proxy for Home Assistant. It is worth remembering that the ESP32 isn’t doing anything particularly clever with the BLE data itself — ESPHome does the hard work.

Adding the display

The next part is the TFT display. ESPHome already knows how to drive a large range of display controllers, so rather than writing SPI routines ourselves, we simply specify the controller, the pins and the orientation.

For the ST7789V display used here, the relevant section tells ESPHome which GPIOs are connected to SPI clock, data, chip select, reset and data/command. ESPHome then provides a display object which we can draw on from a simple C++ lambda.

That means displaying text, lines and colours becomes almost trivial compared with writing a display driver from scratch.

Displaying the BLE information

The BLE results can then be sorted and formatted before being drawn onto the TFT. The code takes the discovered MAC addresses, checks for names where we have them, adds the RSSI value and displays the resulting list a page at a time.

The important point is that we aren’t having to write the BLE discovery code. ESPHome supplies the BLE device information; our code is really just concerned with deciding how we want that information presented.

The Entirely Optional BME280

Adding the environmental sensor (just for the sake of it) is similarly painless. The BME280 is a very cheap, accurate and widely available temperature, humidity and pressure sensor and is connected using the ESP32’s I²C bus, in this case with GPIO1 as SDA and GPIO2 as SCL. the ESP32 will let you use any general purpose IO pins for this purpose. I generally ignore all pins except for vcc, gnd, SDA and SCL.

i2c:
  sda: GPIO1
  scl: GPIO2

ESPHome takes care of the I²C communication and turns the BME280’s measurements into Home Assistant sensors. Temperature, humidity and atmospheric pressure therefore appear automatically in both Home Assistant and the project’s ESPHome web interface.

The web interface

Finally, adding the ESPHome web server gives the device its own browser-based control panel:

web_server:
  port: 80

This is particularly useful here because it means the monitor can be controlled without adding physical buttons. The web interface can provide controls for changing pages, pausing and resuming the display, and other functions we have added to the monitor.

It also means the device can be controlled from a phone or tablet without HA being involved at all.

And that’s really the attraction of ESPHome

What would traditionally have required a considerable amount of embedded programming — BLE scanning, Wi-Fi networking, a web server, an SPI TFT driver, I²C sensor communication and Home Assistant integration — becomes a collection of relatively small ESPHome configuration sections.

The interesting part of the project is therefore not writing the low-level hardware drivers. ESPHome has already done that. We’re simply joining those building blocks together and adding the small amount of code needed to turn the raw information into something useful on the display.

And now…..

The Projects

I was more than happy at first with the progress of my Abrobot-based BLE proxy device… which works JUST FINE… but I wanted MORE. The next step would be to use a pair of TTGO boards I had lying around… all did their job fine but THEN I started to see the limitations of these older ESP32s used in the TTGO boards – lack of RAM prevented me from enjoying these projects to the full – with the display taking up lots of code, I seemed to be able to have WebUI OR MQTT – and as for WiFi OTA…. I started to hit barriers..

The next and essentially final major 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 I had a couple of st7789v displays lying around – 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 just for fun (a few minutes work to add) check temperature, humidity and pressure… and… well, by the time you read this I’ll likely have added more…. I’m having great fun.

ST7789v

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 ESPHOME WebUI here (no port settings, just go in a browser to the IP address of the project)…

S3 ST7789v webUI

Note the MAC filter search box above – with case-insensitivity, partial-MAC ability and lax attitude toward colons.

and finally – what this looks like in HA (Home Assistant) if you’re using that…

Home Assistant

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