A Better ESPHOME Bluetooth Proxy and BLE Scanner

ILI9341-based BLE Proxy Monitor

I won’t go through the entire workings of my latest BLE Scanner and bluetooth proxy here. It is all detailed in the previous blogs – this is a major functional development of my previous effort using a better processor with more RAM. Suffice it to say that marvellous as the TTGO BT proxy and scanner is, even compared to the tiny Abrobot scanner I made earlier this week… ULTIMATELY the neat TTGO board with 2 in-built buttons is built on an ESP32 that is severely limiting – I had to juggle MQTT, web updates, WebUI and more and could not do the lot.

Then I had an epiphany – I’ve some esp32-s3 “super mini” boards lying around, they came from Aliexpress. I also have the odd ILI94341 320*240 display – same source. Dirty cheap, just a few euros/dollars/pounds for both.

I put the two together – no extra components and the ESP32-S3 “Super-Mini” (see photo below) has a USB-C connection. To test this combo I had the ESP plugged into a USB connection on my PC so no special power requirements… controls map back to Home Assistant if needed as does the BLE device info.

And here is the result… a WiFi-roaming, portable, multi-page BLE Proxy and BT Scanner with search. I’m VERY pleased. To see what this is about, read the two previous entries on the subject.

When looking at the webUI below – note that this looks slightly different to earlier ESPHOME WebUIs – it’s the latest version with small visual changes.. (you can select light or dark as before) and also that most of my BT devices do not send out a name, just a MAC. Name isn’t a requirement..

Importantly, it is possible with a USB-C to USB-C lead, to power the unit from a phone and also view the WebUI on the same. See below…

BLE ProxyMonitor WebUI - Peter Scargill - August 2026

This scanner also has a neat SEARCH facility and it can highlight a particular MAC or range of MACs as they fly past. MAC addresses in the search box can be partial, case insensitive and can include the colons or not – all for ease of use.

See WebUI view on my Android phone..

BLEProxy Monitor WebUI seen on a phone
BLEProxy Monitor WebUI seen on a phone

So a neat board with a tiny ESP32-S3 on the back – just needs some kind of simple case… and here is the ESPHOME YAML code to make it all work. You’ll need your WiFi and password secrets file in ESPHOME (my ESPHOME sits inside HOME ASSISTANT and the whole lot is August 2026 updated at the time or writing)… the wiring is obvious from the code (MISO is not used and the ILI9341 CS pin is tied to 3v3 along with reset – I used the 5v pin on the ESP32 for the ILI9341 VCC).

Really – READ the TTGO project – all explained in there and the previous Abrobot project – this is simply a WAY better implementation. I’m nowhere NEAR out of resources on this one so who knows what it will include in future…. only a short while ago I hated BT because of it’s range limits but now I know how simple ble proxies can be (an ESP32 tiny board with nothing else attached and a little ESPHOME code – even the example that comes with ESPHOME will do) I’ve had a complete change of heart.

ILI9341 wiring to mini-ESP32

if anyone’s thinking of putting this together – here’s MY wiring – 7 wires – looking at the back of the display… but check your board for compatibility.

Here with my new toy I can walk around and check BT connectivity – AND GUESS WHAT – IMPORTANT – I only just found out that ESPHome can now be configured with multiple WiFi access points and roam between them, letting it select an appropriate available network while I’m walking all over the house/office with this device – MAGIC. I use the same password on each access point but of course you don’t have to. I’m retrofitting all my ESPHOME projects. The actual SSIDs and passwords are in my ESPHOME secrets file – good idea.

# ========================================================
# S3 + ILI9341 BLE PROXY and Scanner 3.1.1 with search
# August 30, 2026 Peter Scargill
# ========================================================
substitutions:
  product_version: "3.1.1"
  timezone: "Europe/Madrid"
  
esphome:
  name: s3-ili9341-ble
  friendly_name: S3 ILI9341 BLE Proxy Monitor

esp32:
  board: esp32-s3-devkitc-1
  framework:
    type: arduino

logger:

web_server:
  port: 80
  version: 3
  local: true

  sorting_groups:
    - id: control
      name: "Control"
      sorting_weight: 10

    - id: sensors
      name: "Sensors"
      sorting_weight: 20

    - id: diagnostic
      name: "Diagnostic"
      sorting_weight: 30

api:

ota:
  - platform: esphome
    password: ttgo_ota_password

  - platform: web_server

wifi:
  networks:
    - ssid: !secret wifi_ssid
      password: !secret wifi_password

    - ssid: !secret wifi_ssid2
      password: !secret wifi_password

    - ssid: !secret wifi_ssid3
      password: !secret wifi_password

    - ssid: !secret wifi_ssid4
      password: !secret wifi_password
      
    - ssid: !secret wifi_ssid5
      password: !secret wifi_password

  power_save_mode: none
  post_connect_roaming: true

# =========================================================
# SPI
# =========================================================

spi:
  clk_pin: GPIO4
  mosi_pin: GPIO6

# =========================================================
# Sensors
# =========================================================

sensor:
  - platform: wifi_signal
    name: "WiFi Signal"
    update_interval: 30s
    web_server:
      sorting_group_id: sensors
      sorting_weight: 40

  - platform: template
    name: "BLE Devices"
    id: ble_devices_web
    unit_of_measurement: "devices"
    accuracy_decimals: 0
    update_interval: 1s
    lambda: |-
      return id(ble_device_count);
    web_server:
      sorting_group_id: sensors
      sorting_weight: 10      
# =========================================================
# WIFI INFORMATION
# =========================================================

text_sensor:

  - platform: wifi_info
    ip_address:
      name: "IP Address"
      id: ip_address
      entity_category: ""
      web_server:
        sorting_group_id: diagnostic
        sorting_weight: 10

        


        
    ssid:
      name: "WiFi SSID"
      id: wifi_ssid
      entity_category: ""
      on_value:
        then:
          - lambda: |-
              if (x.empty())
                return;

              if (id(tracked_ssid).empty()) {
                id(tracked_ssid) = x;
                return;
              }

              if (x != id(tracked_ssid)) {
                id(previous_previous_ssid) = id(previous_ssid);
                id(previous_ssid) = id(tracked_ssid);
                id(tracked_ssid) = x;
              }
      web_server:
        sorting_group_id: diagnostic
        sorting_weight: 40
    bssid:
      name: "WiFi BSSID"
      entity_category: ""
      web_server:
        sorting_group_id: diagnostic
        sorting_weight: 20
    mac_address:
      name: "WiFi MAC"
      entity_category: ""
      web_server:
        sorting_group_id: diagnostic
        sorting_weight: 30

# =========================================================
# CURRENT PAGE - WEB UI
# =========================================================
  - platform: template
    name: "Current Page"
    id: current_page_display
    update_interval: 1s
    lambda: |-
      const int MAX_DEVICES = 40;
      const int ROWS_PER_PAGE = 20;
      const uint32_t DEVICE_TIMEOUT = 120000;

      int count = 0;
      for (int i = 0; i < MAX_DEVICES; i++) {
        if (!id(ble_macs)[i].empty()) {
          uint32_t age = millis() - id(ble_last_seen)[i];
          if (age < DEVICE_TIMEOUT) count++;
        }
      }

      int pages = (count + ROWS_PER_PAGE - 1) / ROWS_PER_PAGE;
      if (pages < 1) pages = 1;

      int current = id(ble_page);
      if (current >= pages) current = pages - 1;

      char page_text[32];
      snprintf(page_text, sizeof(page_text), "P%d/%d", current + 1, pages);
      return {page_text};

    web_server:
      sorting_group_id: sensors
      sorting_weight: 5

  - platform: template
    name: "Version"
    id: version
    lambda: |-
      return {"${product_version}"};
    update_interval: 60s
    web_server:
      sorting_group_id: sensors
      sorting_weight: 5
      
# =========================================================
# GLOBALS
# =========================================================

globals:
  
  - id: ble_page
    type: int
    restore_value: no
    initial_value: "0"
    
  - id: ble_macs
    type: std::string[40]
    restore_value: no

  - id: ble_names
    type: std::string[40]
    restore_value: no

  - id: ble_rssi
    type: int[40]
    restore_value: no

  - id: ble_last_seen
    type: uint32_t[40]
    restore_value: no

  - id: ble_device_count
    type: int
    restore_value: no
    initial_value: "0"

  # true = NAME + MAC
  # false = MAC only
  - id: display_name_mode
    type: bool
    restore_value: yes
    initial_value: "true"

  # Normalised MAC search string. Empty = no highlighting.
  - id: ble_mac_search
    type: std::string
    restore_value: no

  - id: previous_ssid
    type: std::string
    restore_value: no
    initial_value: '""'

  - id: previous_previous_ssid
    type: std::string
    restore_value: no
    initial_value: '""'

  - id: tracked_ssid
    type: std::string
    restore_value: no
    initial_value: '""'
    
# =========================================================
# WEB UI CONTROLS
# =========================================================

text:
  - platform: template
    name: "Search MAC"
    id: ble_mac_search_text
    mode: text
    optimistic: true
    restore_value: no

    web_server:
      sorting_group_id: control
      sorting_weight: 10


button:

  # -------------------------------------------------------
  # CONFIRM SEARCH
  # -------------------------------------------------------

  - platform: template
    name: "Confirm Search"
    icon: "mdi:magnify"

    web_server:
      sorting_group_id: control
      sorting_weight: 20

    on_press:
      - lambda: |-
          std::string s = id(ble_mac_search_text).state;
          std::string cleaned;

          for (char c : s) {
            if (c != ':' && c != ' ' && c != '-') {
              cleaned += (char)tolower((unsigned char)c);
            }
          }

          id(ble_mac_search) = cleaned;
          id(ble_page) = 0;

      - component.update: ble_display


  # -------------------------------------------------------
  # HOME PAGE
  # -------------------------------------------------------

  - platform: template
    web_server:
      sorting_group_id: control
      sorting_weight: 30

    name: "Home Page"
    icon: "mdi:home"

    on_press:
      - lambda: |-
          id(ble_page) = 0;

      - component.update: ble_display
      - component.update: current_page_display


  # -------------------------------------------------------
  # PREVIOUS PAGE
  # -------------------------------------------------------

  - platform: template
    web_server:
      sorting_group_id: control
      sorting_weight: 40

    name: "Previous Page"
    icon: "mdi:chevron-left"

    on_press:
      - lambda: |-

          const int MAX_DEVICES = 40;
          const int ROWS_PER_PAGE = 20;
          const uint32_t DEVICE_TIMEOUT = 120000;

          int count = 0;

          for (int i = 0; i < MAX_DEVICES; i++) {

            if (!id(ble_macs)[i].empty()) {

              uint32_t age =
                millis() - id(ble_last_seen)[i];

              if (age < DEVICE_TIMEOUT) {
                count++;
              }
            }
          }

          int pages =
            (count + ROWS_PER_PAGE - 1) /
            ROWS_PER_PAGE;

          if (pages < 1) {
            pages = 1;
          }

          id(ble_page)--;

          if (id(ble_page) < 0) {
            id(ble_page) = pages - 1;
          }

      - component.update: ble_display
      - component.update: current_page_display


  # -------------------------------------------------------
  # NEXT PAGE
  # -------------------------------------------------------

  - platform: template
    web_server:
      sorting_group_id: control
      sorting_weight: 60

    name: "Next Page"
    icon: "mdi:chevron-right"

    on_press:
      - lambda: |-

          const int MAX_DEVICES = 40;
          const int ROWS_PER_PAGE = 20;
          const uint32_t DEVICE_TIMEOUT = 120000;

          int count = 0;

          for (int i = 0; i < MAX_DEVICES; i++) {

            if (!id(ble_macs)[i].empty()) {

              uint32_t age =
                millis() - id(ble_last_seen)[i];

              if (age < DEVICE_TIMEOUT) {
                count++;
              }
            }
          }

          int pages =
            (count + ROWS_PER_PAGE - 1) /
            ROWS_PER_PAGE;

          if (pages < 1) {
            pages = 1;
          }

          id(ble_page)++;

          if (id(ble_page) >= pages) {
            id(ble_page) = 0;
          }

      - component.update: ble_display
      - component.update: current_page_display

# =========================================================
# DISPLAY PAUSE SWITCH
# ON  = PAUSED
# OFF = RUNNING
# =========================================================

switch:

  - platform: template
    web_server:
      sorting_group_id: control
      sorting_weight: 5

    name: "Pause Display"
    id: display_pause_switch
    icon: "mdi:pause-circle"

    optimistic: true
    restore_mode: ALWAYS_OFF

    turn_on_action:
      - component.suspend: ble_display

    turn_off_action:
      - component.resume: ble_display
      - component.update: ble_display
        
# =========================================================
# BLE TRACKER
# =========================================================

esp32_ble_tracker:

  scan_parameters:
    interval: 1100ms
    window: 1100ms
    active: true

  on_ble_advertise:
    then:
      - lambda: |-

          const int MAX_DEVICES = 40;
          const uint32_t DEVICE_TIMEOUT = 120000;

          char mac_buffer[18];
          x.address_str_to(mac_buffer);

          ESP_LOGI("BLE", "Found: %s  %s  RSSI: %d",
                   mac_buffer,
                   x.get_name().c_str(),
                   x.get_rssi());

          std::string mac = mac_buffer;
          std::string name = x.get_name();
          int rssi = x.get_rssi();
          uint32_t now = millis();

          // =================================================
          // FIND EXISTING DEVICE
          // =================================================

          int found = -1;

          for (int i = 0; i < MAX_DEVICES; i++) {

            if (id(ble_macs)[i] == mac) {
              found = i;
              break;
            }
          }


          // =================================================
          // FIND EMPTY SLOT
          // =================================================

          if (found < 0) {

            for (int i = 0; i < MAX_DEVICES; i++) {

              if (id(ble_macs)[i].empty()) {
                found = i;
                break;
              }
            }
          }

          // =================================================
          // IF FULL, REPLACE OLDEST INACTIVE DEVICE
          // =================================================

          if (found < 0) {

            uint32_t oldest_time = 0xFFFFFFFFUL;
            int oldest_slot = -1;

            for (int i = 0; i < MAX_DEVICES; i++) {

              uint32_t age =
                now - id(ble_last_seen)[i];

              if (age >= DEVICE_TIMEOUT &&
                  id(ble_last_seen)[i] < oldest_time) {

                oldest_time = id(ble_last_seen)[i];
                oldest_slot = i;
              }
            }

            if (oldest_slot >= 0) {
              found = oldest_slot;
            }
          }

          // =================================================
          // STORE DEVICE
          // =================================================

          if (found >= 0) {

            id(ble_macs)[found] = mac;
            id(ble_names)[found] = name;
            id(ble_rssi)[found] = rssi;
            id(ble_last_seen)[found] = now;
          }

          // =================================================
          // COUNT ACTIVE DEVICES
          // =================================================

          int count = 0;

          for (int i = 0; i < MAX_DEVICES; i++) {

            if (!id(ble_macs)[i].empty()) {

              uint32_t age =
                now - id(ble_last_seen)[i];

              if (age < DEVICE_TIMEOUT) {
                count++;
              }
            }
          }

          id(ble_device_count) = count;

# =========================================================
# FONTS
# =========================================================

font:

  - file: "gfonts://Inconsolata"
    id: font_row
    size: 12

  - file: "gfonts://Roboto"
    id: font_title
    size: 18

  - file: "gfonts://Roboto"
    id: font_small
    size: 11

# =========================================================
# DISPLAY
#
# ILI9341
# Physical resolution: 240 x 320
# Portrait
#
# X = 0 ... 239
# Y = 0 ... 319
# =========================================================

display:

  - platform: ili9xxx
    model: ili9341
    id: ble_display

    dc_pin: GPIO5
    reset_pin: GPIO8

    invert_colors: false
    color_palette: 8BIT
    data_rate: 10MHz

    rotation: 0

    update_interval: 1s

    lambda: |-

      // =================================================
      // DISPLAY DIMENSIONS
      // =================================================

      const int SCREEN_WIDTH  = 240;
      const int SCREEN_HEIGHT = 320;

      const int RIGHT_EDGE = SCREEN_WIDTH - 1;

      // =================================================
      // LAYOUT
      // =================================================

      const int HEADER_Y      = 5;
      const int HEADER_LINE_Y = 27;

      const int FIRST_Y       = 32;
      const int LINE_HEIGHT   = 12;

      const int FOOTER_LINE_Y = 274;
      const int FOOTER_Y      = 278;
      const int SSID_FOOTER_Y = 293;

      const int ROWS_PER_PAGE = 20;

      // =================================================
      // COLOURS
      // =================================================

      auto black  = Color(0, 0, 0);
      auto white  = Color(255, 255, 255);
      auto cyan   = Color(0, 255, 255);
      auto blue   = Color(0, 120, 255);
      auto green  = Color(0, 255, 0);
      auto yellow = Color(255, 255, 0);
      auto orange = Color(255, 165, 0);
      auto red    = Color(255, 30, 30);
      auto purple = Color(200, 150, 255);
      auto dark_red = Color(220, 0, 0);
      auto grey   = Color(150, 150, 150);

      it.fill(black);

      // =================================================
      // HEADER
      // =================================================

      it.print(
        5,
        HEADER_Y,
        id(font_title),
        blue,
        "BLE Proxy Monitor"
      );

      it.strftime(
        RIGHT_EDGE - 4,
        HEADER_Y,
        id(font_title),
        cyan,
        TextAlign::TOP_RIGHT,
        "%H:%M",
        id(sntp_time).now()
      );

      it.line(
        0,
        HEADER_LINE_Y,
        RIGHT_EDGE,
        HEADER_LINE_Y,
        blue
      );

      // =================================================
      // BUILD ACTIVE DEVICE LIST
      // =================================================

      const int MAX_DEVICES = 40;

      int order[MAX_DEVICES];
      int count = 0;

      for (int i = 0; i < MAX_DEVICES; i++) {

        if (!id(ble_macs)[i].empty()) {

          uint32_t age =
            millis() - id(ble_last_seen)[i];

          if (age < 120000) {
            order[count++] = i;
          }
        }
      }

      // =================================================
      // SORT STRONGEST RSSI FIRST
      // =================================================

      for (int i = 0; i < count - 1; i++) {

        for (int j = i + 1; j < count; j++) {

          if (id(ble_rssi)[order[j]] >
              id(ble_rssi)[order[i]]) {

            int temp = order[i];
            order[i] = order[j];
            order[j] = temp;
          }
        }
      }

      // =================================================
      // PAGE CALCULATION
      // =================================================

      int pages =
        (count + ROWS_PER_PAGE - 1) /
        ROWS_PER_PAGE;

      if (pages < 1) {
        pages = 1;
      }

      // No physical page buttons on this S3 yet.
      // Display page 1 for now.

      int current_page = id(ble_page);

      int start =
        current_page * ROWS_PER_PAGE;

      int end =
        start + ROWS_PER_PAGE;

      if (end > count) {
        end = count;
      }

      // =================================================
      // DEVICE ROWS
      // =================================================

      int y = FIRST_Y;

      for (int n = start; n < end; n++) {

        int i = order[n];

        // -------------------------------------------------
        // MAC
        // -------------------------------------------------

        std::string mac =
          id(ble_macs)[i];

        // -------------------------------------------------
        // MAC SEARCH / HIGHLIGHT
        // Case-insensitive, ignores :, spaces and -
        // Partial matches are supported.
        // -------------------------------------------------

        std::string mac_compare;

        for (char c : mac) {
          if (c != ':' && c != ' ' && c != '-') {
            mac_compare += (char)tolower((unsigned char)c);
          }
        }

        bool mac_match =
          !id(ble_mac_search).empty() &&
          mac_compare.find(id(ble_mac_search)) != std::string::npos;

        if (mac_match) {
          // Red background behind the MAC, white text.
          it.filled_rectangle(
            1,
            y + 2,
            102,
            11,
            dark_red
          );
        }

        it.print(
          2,
          y,
          id(font_row),
          mac_match ? white : white,
          mac.c_str()
        );

        // -------------------------------------------------
        // NAME
        // -------------------------------------------------

        std::string name =
          id(ble_names)[i];

        if (id(display_name_mode) &&
            !name.empty()) {

          // Keep the name clear of RSSI.
          if (name.length() > 17) {
            name = name.substr(0, 17);
          }

          it.print(
            110,
            y,
            id(font_row),
            purple,
            name.c_str()
          );
        }

        // -------------------------------------------------
        // RSSI
        // -------------------------------------------------

        Color rssi_color;

        if (id(ble_rssi)[i] >= -55) {

          rssi_color = green;

        } else if (id(ble_rssi)[i] >= -65) {

          rssi_color = yellow;

        } else if (id(ble_rssi)[i] >= -79) {

          rssi_color = orange;

        } else {

          rssi_color = red;
        }

        char rssi_text[8];

        snprintf(
          rssi_text,
          sizeof(rssi_text),
          "%d",
          id(ble_rssi)[i]
        );

        it.print(
          RIGHT_EDGE - 2,
          y,
          id(font_row),
          rssi_color,
          TextAlign::TOP_RIGHT,
          rssi_text
        );

        y += LINE_HEIGHT;
      }

      // =================================================
      // FOOTER
      // =================================================

      it.line(
        0,
        FOOTER_LINE_Y,
        RIGHT_EDGE,
        FOOTER_LINE_Y,
        blue
      );

      // -------------------------------------------------
      // TOP STATUS LINE
      // BLE count | current IP | page
      // -------------------------------------------------

      char count_text[32];
      snprintf(
        count_text,
        sizeof(count_text),
        "%d BLE units",
        count
      );

      it.print(
        2,
        FOOTER_Y,
        id(font_small),
        cyan,
        count_text
      );

      std::string current_ip = id(ip_address).state;
      if (current_ip.empty() || current_ip == "0.0.0.0")
        current_ip = "----";

      std::string ip_display = "IP: " + current_ip;

      it.print(
        120,
        FOOTER_Y,
        id(font_small),
        cyan,
        TextAlign::TOP_CENTER,
        ip_display.c_str()
      );

      char page_text[32];
      snprintf(
        page_text,
        sizeof(page_text),
        "Page %d/%d",
        current_page + 1,
        pages
      );

      it.print(
        RIGHT_EDGE - 2,
        FOOTER_Y,
        id(font_small),
        cyan,
        TextAlign::TOP_RIGHT,
        page_text
      );

      // -------------------------------------------------
      // SSID HISTORY LINE
      // -------------------------------------------------
      
      std::string ssid_oldest   = id(previous_previous_ssid);
      std::string ssid_previous = id(previous_ssid);
      std::string ssid_current  = id(tracked_ssid);
      
      
      // Only current SSID known
      // Current = GREEN, LEFT
      
      if (ssid_oldest.empty() && ssid_previous.empty()) {
      
        if (!ssid_current.empty()) {
          it.print(
            2,
            SSID_FOOTER_Y,
            id(font_small),
            green,
            ssid_current.c_str()
          );
        }
      
      }
      
      
      // Two SSIDs known
      // Current  = GREEN, LEFT
      // Previous = YELLOW, CENTRE
      
      else if (ssid_oldest.empty()) {
      
        if (!ssid_current.empty()) {
          it.print(
            2,
            SSID_FOOTER_Y,
            id(font_small),
            green,
            ssid_current.c_str()
          );
        }
      
        if (!ssid_previous.empty()) {
          it.print(
            120,
            SSID_FOOTER_Y,
            id(font_small),
            grey,
            TextAlign::TOP_CENTER,
            ssid_previous.c_str()
          );
        }
      
      }
      
      
      // Three SSIDs known
      // Current  = GREEN, LEFT
      // Previous = YELLOW, CENTRE
      // Oldest   = YELLOW, RIGHT
      
      else {
      
        if (!ssid_current.empty()) {
          it.print(
            2,
            SSID_FOOTER_Y,
            id(font_small),
            green,
            ssid_current.c_str()
          );
        }
      
        it.print(
          120,
          SSID_FOOTER_Y,
          id(font_small),
          grey,
          TextAlign::TOP_CENTER,
          ssid_previous.c_str()
        );
      
        it.print(
          RIGHT_EDGE - 2,
          SSID_FOOTER_Y,
          id(font_small),
          grey,
          TextAlign::TOP_RIGHT,
          ssid_oldest.c_str()
        );
      }
# =========================================================
# TIME
# =========================================================

time:

  - platform: sntp
    id: sntp_time
    timezone: ${timezone}

Note the timezone at the end of the code and my choice and number of WiFi access points near the top. If using the code be VERY careful to maintain correct indenting.

I hope someone finds this to be a useful tool. Changes to use an ST7789v display are fairly trivial and that’s the subject of another blog entry.

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