├── .gitignore ├── config ├── .gitignore └── base.yaml ├── .github ├── dependabot.yaml └── workflows │ └── tests.yaml ├── .devcontainer ├── Dockerfile └── devcontainer.json ├── LICENSE ├── CONTRIBUTING.md ├── components └── ble_adv_proxy │ ├── ble_adv_proxy.h │ ├── __init__.py │ └── ble_adv_proxy.cpp ├── tools └── esphome └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | # Python cache 2 | __pycache__ 3 | 4 | # build files 5 | .cache 6 | .vscode 7 | -------------------------------------------------------------------------------- /config/.gitignore: -------------------------------------------------------------------------------- 1 | # Gitignore settings for ESPHome 2 | # This is an example and may include too much for your use-case. 3 | # You can modify this file to suit your needs. 4 | /secrets.yaml 5 | -------------------------------------------------------------------------------- /config/base.yaml: -------------------------------------------------------------------------------- 1 | logger: 2 | level: INFO 3 | 4 | esp32: 5 | board: esp32dev 6 | framework: 7 | type: esp-idf 8 | 9 | esphome: 10 | name: esp32-idf 11 | 12 | network: # needed by api, itself needed by ble_adv_proxy 13 | 14 | ble_adv_proxy: 15 | 16 | external_components: 17 | - source: 18 | type: local 19 | path: ../components 20 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 2 | version: 2 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "monthly" 8 | 9 | - package-ecosystem: "docker" 10 | directory: "/.devcontainer" 11 | schedule: 12 | interval: "daily" -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/esphome/esphome:2025.12.0 2 | ARG USERNAME=esphome 3 | ARG USER_UID=1000 4 | ARG USER_GID=$USER_UID 5 | 6 | RUN groupadd --gid $USER_GID $USERNAME \ 7 | && useradd --uid $USER_UID --gid $USER_GID -ms /bin/bash $USERNAME 8 | 9 | ADD https://raw.githubusercontent.com/esphome/esphome/refs/heads/dev/.clang-format /esphome/.clang-format 10 | RUN chmod +r /esphome/.clang-format 11 | 12 | USER $USERNAME 13 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Compile tests 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | branches: 7 | - "main" 8 | 9 | jobs: 10 | get_image_version: 11 | runs-on: ubuntu-latest 12 | outputs: 13 | esphome_version: ${{ steps.version.outputs.esphome_version }} 14 | steps: 15 | - uses: actions/checkout@v6 16 | with: 17 | fetch-depth: 0 18 | - id: version 19 | name: Set the Esphome image version 20 | run: echo "esphome_version=$(grep FROM .devcontainer/Dockerfile | cut -d ':' -f 2)" >> $GITHUB_OUTPUT 21 | 22 | tests: 23 | needs: get_image_version 24 | runs-on: ubuntu-latest 25 | container: 26 | image: docker://esphome/esphome:${{needs.get_image_version.outputs.esphome_version}} 27 | steps: 28 | - uses: actions/checkout@v6 29 | with: 30 | fetch-depth: 0 31 | - name: Test with base config 32 | run: | 33 | esphome compile config/base.yaml 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 NicoIIT 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "NicoIIT/esphome-ble_adv_proxy", 3 | "build": { 4 | "dockerfile": "Dockerfile" 5 | }, 6 | "runArgs": [ 7 | //"--device=/dev/ttyUSBO", // USB Device access, to be added / uncommented / changed IF you have a USB Device connected 8 | //"--device=/dev/ttyACM0", // USB Device access, to be added / uncommented / changed IF you have a USB Device connected 9 | "--group-add=20" // dialout group, needed to access USB Devices 10 | ], 11 | "mounts": [ 12 | // External configs, as you can also use esphome directly to handle your configs 13 | "source=/data/esphome/config,target=/config,type=bind,consistency=cached" 14 | ], 15 | "remoteUser": "esphome", 16 | "containerEnv": { 17 | "ESPHOME_DASHBOARD_USE_PING": "1", 18 | "PLATFORMIO_CORE_DIR": "${containerWorkspaceFolder}/.cache/.pio", 19 | "ESPHOME_DATA_DIR": "${containerWorkspaceFolder}/.cache/data", 20 | "GIT_EDITOR": "code --wait" 21 | }, 22 | "customizations": { 23 | "vscode": { 24 | "extensions": [ 25 | "charliermarsh.ruff", 26 | "github.vscode-pull-request-github", 27 | "ms-python.python", 28 | "ms-python.vscode-pylance", 29 | "ryanluker.vscode-coverage-gutters", 30 | "ms-vscode.cpptools" 31 | ], 32 | "settings": { 33 | "files.eol": "\n", 34 | "editor.tabSize": 4, 35 | "editor.formatOnPaste": true, 36 | "editor.formatOnSave": true, 37 | "editor.formatOnType": false, 38 | "files.trimTrailingWhitespace": true, 39 | "python.analysis.typeCheckingMode": "basic", 40 | "python.analysis.autoImportCompletions": true, 41 | "python.defaultInterpreterPath": "/usr/local/bin/python3", 42 | "python.analysis.diagnosticSeverityOverrides": { 43 | "reportArgumentType": "none" 44 | }, 45 | "python.analysis.extraPaths": [ 46 | "/esphome" 47 | ], 48 | "[python]": { 49 | "editor.defaultFormatter": "charliermarsh.ruff" 50 | }, 51 | "ruff.configuration": "/esphome/pyproject.toml", 52 | //"explorer.excludeGitIgnore": true, 53 | "C_Cpp.clang_format_style": "file:/esphome/.clang-format" 54 | } 55 | } 56 | } 57 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guidelines 2 | 3 | Contributing to this project should be as easy and transparent as possible, whether it's: 4 | 5 | - Reporting a bug 6 | - Discussing the current state of the code 7 | - Submitting a fix 8 | - Proposing new features 9 | 10 | ## Github and Visual Studio Code devcontainer as a basis 11 | 12 | Github is used to host code, to track issues and feature requests, as well as accept pull requests. 13 | Visual Studio Code devcontainer is used to create an immediatly up and running workspace with everything you need to work. 14 | 15 | Should you want to propose changes please follow: 16 | 17 | 1. Fork the repo or update your fork from original repo 18 | 2. create your working branch from `main`. We will use `dev` as working branch name in what follows. As a general rule, do NEVER work on the `main` branch of a Fork, it is the one to be kept in sync with the `main` branch of the original repo and should not contain local developments. 19 | 3. Use Visual Studio Code [devcontainer feature from githup repo](https://code.visualstudio.com/docs/devcontainers/containers#_quick-start-open-a-git-repository-or-github-pr-in-an-isolated-container-volume) to create your Visual Studio Code workspace, including ready to use command line 'esphome', or its patched version 'tools/esphome' that will: 20 | * automatically setup the 'components' folder as local external_components 21 | * setup the C++ IntelliSense for vscode once a yaml config is compiled 22 | 4. Perform your changes, add relevant tests and documentation. 23 | 5. Ensure tests run OK. 24 | 6. Commit your changes and push them to your `dev` branch. 25 | 7. Open a Pull Request from your `dev` branch to the `main` branch of the original repo. 26 | 8. Check that the Actions on github run OK on your PR, correct if needed. 27 | 9. Wait for review, approval and merge. 28 | 10. Delete your working branch when the PR is merged 29 | 30 | 31 | ## Any contributions you make will be under the MIT Software License 32 | 33 | In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. 34 | 35 | ## Report bugs using Github's [issues](../../issues) 36 | 37 | GitHub issues are used to track public bugs. 38 | Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! 39 | 40 | ## Write bug reports with detail, background, and sample code 41 | 42 | **Great Bug Reports** tend to have: 43 | 44 | - A quick summary and/or background 45 | - Steps to reproduce 46 | - Be specific! 47 | - Give sample code if you can. 48 | - What you expected would happen 49 | - What actually happens 50 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 51 | 52 | People *love* thorough bug reports. I'm not even kidding. 53 | People *hate* one line bug reports. If they have to ask you for details, it is more likely that your bug report will never be considered. 54 | 55 | ## License 56 | 57 | By contributing, you agree that your contributions will be licensed under its MIT License. 58 | -------------------------------------------------------------------------------- /components/ble_adv_proxy/ble_adv_proxy.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "esphome/core/component.h" 4 | #include "esphome/components/esp32_ble/ble.h" 5 | #include "esphome/components/api/custom_api_device.h" 6 | #include "esphome/components/text_sensor/text_sensor.h" 7 | 8 | #include 9 | #include 10 | #include 11 | #include 12 | 13 | namespace esphome { 14 | 15 | namespace ble_adv_proxy { 16 | 17 | static constexpr size_t MAX_PACKET_LEN = 31; 18 | 19 | class BleAdvParam { 20 | public: 21 | BleAdvParam(const std::string &hex_string, uint32_t duration); 22 | BleAdvParam(const uint8_t *buf, size_t len, const esp_bd_addr_t &orig, uint32_t duration); 23 | BleAdvParam(BleAdvParam &&) = default; 24 | BleAdvParam &operator=(BleAdvParam &&) = default; 25 | 26 | uint32_t duration_{100}; 27 | uint8_t buf_[MAX_PACKET_LEN]{0}; 28 | size_t len_{0}; 29 | esp_bd_addr_t orig_{0}; 30 | }; 31 | 32 | /** 33 | BleAdvProxy: 34 | */ 35 | class BleAdvProxy : public Component, 36 | public esp32_ble::GAPScanEventHandler, 37 | public Parented, 38 | public api::CustomAPIDevice { 39 | public: 40 | // component handling 41 | void setup() override; 42 | void loop() override; 43 | void dump_config() override; 44 | 45 | void set_use_max_tx_power(bool use_max_tx_power) { this->use_max_tx_power_ = use_max_tx_power; } 46 | void set_sensor_name(text_sensor::TextSensor *sens, const std::string &adapter_name) { 47 | this->sensor_name_ = sens; 48 | this->sensor_name_->state = adapter_name; 49 | } 50 | void on_setup_v0(float ign_duration, std::vector ignored_cids, std::vector ignored_macs); 51 | void on_advertise_v0(std::string raw, float duration); 52 | void on_advertise_v1(std::string raw, float duration, float repeat, std::vector ignored_advs, 53 | float ign_duration); 54 | void on_raw_recv(const BleAdvParam ¶m, const std::string &str_mac); 55 | bool check_add_dupe_packet(BleAdvParam &&packet); 56 | 57 | protected: 58 | /** 59 | Performing RAW ADV 60 | */ 61 | std::list send_packets_; 62 | uint32_t adv_stop_time_ = 0; 63 | 64 | esp_ble_adv_params_t adv_params_ = { 65 | .adv_int_min = 0x20, 66 | .adv_int_max = 0x20, 67 | .adv_type = ADV_TYPE_IND, 68 | .own_addr_type = BLE_ADDR_TYPE_PUBLIC, 69 | .peer_addr = {0x00}, 70 | .peer_addr_type = BLE_ADDR_TYPE_PUBLIC, 71 | .channel_map = ADV_CHNL_ALL, 72 | .adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY, 73 | }; 74 | 75 | bool use_max_tx_power_ = false; 76 | bool max_tx_power_setup_done_ = false; 77 | void setup_max_tx_power(); 78 | 79 | /** 80 | Listening to ADV 81 | */ 82 | void gap_scan_event_handler(const esp32_ble::BLEScanResult &scan_result) override; 83 | SemaphoreHandle_t scan_result_lock_; 84 | uint32_t dupe_ignore_duration_ = 20000; 85 | std::list recv_packets_; 86 | std::list dupe_packets_; 87 | std::vector ign_macs_; 88 | std::vector ign_cids_; 89 | 90 | /* 91 | API Discovery 92 | */ 93 | bool setup_done_ = false; 94 | text_sensor::TextSensor *sensor_name_ = nullptr; 95 | }; 96 | 97 | } // namespace ble_adv_proxy 98 | } // namespace esphome 99 | -------------------------------------------------------------------------------- /components/ble_adv_proxy/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import esphome.codegen as cg 4 | from esphome.components.esp32_ble import CONF_BLE_ID, ESP32BLE 5 | from esphome.components.text_sensor import new_text_sensor, text_sensor_schema 6 | import esphome.config_validation as cv 7 | from esphome.const import ( 8 | CONF_ENTITY_CATEGORY, 9 | CONF_ID, 10 | CONF_NAME, 11 | ENTITY_CATEGORY_DIAGNOSTIC, 12 | ) 13 | 14 | # Support function esp32_ble.register_gap_scan_event_handler introduced in ESPHome 2025.11 15 | # as well as in previous version with fallback to previous implementation 16 | try: 17 | from esphome.components.esp32_ble import register_gap_scan_event_handler 18 | except ImportError: 19 | 20 | def register_gap_scan_event_handler(parent_var, handler_var) -> None: 21 | cg.add(parent_var.register_gap_scan_event_handler(handler_var)) 22 | 23 | 24 | # A wonderful HACK to avoid the need for users to define the 'api' option 'custom_services' to True: 25 | # we patch the CONFIG_SCHEMA of the 'api' component to setup the default value of 'custom_services' to True 26 | # This can break anytime, but anyway ESPHome changes regularly break all our users, so not less risky... 27 | try: 28 | from esphome.components.api import ( 29 | CONF_CUSTOM_SERVICES as API_CONF_CUSTOM_SERVICES, 30 | CONF_HOMEASSISTANT_SERVICES as API_CONF_HOMEASSISTANT_SERVICES, 31 | CONFIG_SCHEMA as API_CONFIG_SCHEMA, 32 | ) 33 | 34 | vals = list(API_CONFIG_SCHEMA.validators) 35 | vals[0] = vals[0].extend( 36 | { 37 | cv.Optional(API_CONF_CUSTOM_SERVICES, default=True): cv.boolean, 38 | cv.Optional(API_CONF_HOMEASSISTANT_SERVICES, default=True): cv.boolean, 39 | } 40 | ) 41 | API_CONFIG_SCHEMA.validators = tuple(vals) 42 | except BaseException: 43 | logging.warning( 44 | "Unable to define api custom_services to True, please refer to the doc to do it manually." 45 | ) 46 | # End of workaround 47 | 48 | 49 | AUTO_LOAD = ["esp32_ble", "esp32_ble_tracker", "api", "text_sensor"] 50 | DEPENDENCIES = ["esp32", "api"] 51 | MULTI_CONF = False 52 | 53 | bleadvproxy_ns = cg.esphome_ns.namespace("ble_adv_proxy") 54 | BleAdvProxy = bleadvproxy_ns.class_( 55 | "BleAdvProxy", cg.Component, cg.Parented.template(ESP32BLE) 56 | ) 57 | 58 | CONF_BLE_ADV_USE_MAX_TX_POWER = "use_max_tx_power" 59 | CONF_NAME_SENSOR = "name_sensor" 60 | CONF_VAL_NAME_SENSOR = "ble_adv_proxy_name" 61 | CONF_ADAPTER_NAME = "adapter_name" 62 | 63 | CONFIG_SCHEMA = cv.All( 64 | cv.Schema( 65 | { 66 | cv.GenerateID(): cv.declare_id(BleAdvProxy), 67 | cv.GenerateID(CONF_BLE_ID): cv.use_id(ESP32BLE), 68 | cv.Optional(CONF_BLE_ADV_USE_MAX_TX_POWER, default=False): cv.boolean, 69 | cv.Optional(CONF_ADAPTER_NAME): cv.valid_name, 70 | cv.Optional( 71 | CONF_NAME_SENSOR, 72 | default={ 73 | CONF_ID: CONF_VAL_NAME_SENSOR, 74 | CONF_NAME: CONF_VAL_NAME_SENSOR, 75 | CONF_ENTITY_CATEGORY: ENTITY_CATEGORY_DIAGNOSTIC, 76 | }, 77 | ): text_sensor_schema(), 78 | } 79 | ), 80 | # Creation of esp32_ble::GAPScanEventHandler: 2025.6.2 81 | # API_CONF_HOMEASSISTANT_SERVICES: 2025.8.0 82 | cv.require_esphome_version(2025, 8, 0), 83 | ) 84 | 85 | 86 | async def to_code(config): 87 | var = cg.new_Pvariable(config[CONF_ID]) 88 | await cg.register_component(var, config) 89 | cg.add(var.set_use_max_tx_power(config[CONF_BLE_ADV_USE_MAX_TX_POWER])) 90 | parent = await cg.get_variable(config[CONF_BLE_ID]) 91 | register_gap_scan_event_handler(parent, var) 92 | cg.add(var.set_parent(parent)) 93 | sens = await new_text_sensor(config[CONF_NAME_SENSOR]) 94 | cg.add(var.set_sensor_name(sens, config.get(CONF_ADAPTER_NAME, ""))) 95 | -------------------------------------------------------------------------------- /tools/esphome: -------------------------------------------------------------------------------- 1 | #!/usr/local/bin/python 2 | 3 | """Override ESPHome CLI tool located in /usr/local/bin/esphome. 4 | 5 | Feature 1: Overload the 'external_components' to specify the only source as local 'components' workspace folder 6 | Feature 2: use the platformio idedata computed while compiling to feed the '.vscode/c_cpp_properties.json' to use C++ intellisense 7 | """ 8 | 9 | import json 10 | import os 11 | import sys 12 | from typing import Any 13 | 14 | from esphome import platformio_api, yaml_util 15 | import esphome.__main__ as main_esphome 16 | from esphome.core import CORE, TimePeriod 17 | from esphome.git import run_git_command 18 | 19 | 20 | def get_wsk_folder(folder_name: str): 21 | work_dir = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) 22 | return os.path.join(work_dir, folder_name) 23 | 24 | 25 | ######################### 26 | ## Override yaml load 27 | ######################### 28 | def load_yaml_override(fname: str, clear_secrets: bool = True) -> Any: 29 | conf = prev_load_override(fname, clear_secrets) 30 | comp_folder = get_wsk_folder("components") 31 | wsk = get_wsk_folder(".") 32 | gs = run_git_command(["git", "config", "--get", "remote.origin.url"], wsk) 33 | main_esphome._LOGGER.info(gs) 34 | for comp in conf["external_components"]: 35 | src = comp["source"] 36 | if (isinstance(src, str) and src == f"github://{gs[19:]}") or ( 37 | isinstance(src, dict) and src["type"] == "git" and src["url"] == gs 38 | ): 39 | comp["source"] = {"path": comp_folder, "type": "local"} 40 | comp["refresh"] = TimePeriod(seconds=1) 41 | main_esphome._LOGGER.info( 42 | f"Override Feature 1: external component '{gs}' patched to local '{comp_folder}'" 43 | ) 44 | return conf 45 | 46 | 47 | prev_load_override = yaml_util.load_yaml 48 | yaml_util.load_yaml = load_yaml_override 49 | 50 | 51 | ############################################# 52 | ## Override compile action 53 | ############################################# 54 | def compile_program_override(args, conf): 55 | main_esphome._LOGGER.info("Compiling app...") 56 | rc = platformio_api.run_compile(conf, CORE.verbose) 57 | if rc != 0: 58 | return rc 59 | idedata = platformio_api.get_idedata(conf) 60 | if idedata is not None: 61 | vscode_folder = get_wsk_folder(".vscode") 62 | os.makedirs(vscode_folder, exist_ok=True) 63 | with open( 64 | os.path.join(vscode_folder, "c_cpp_properties.json"), "w" 65 | ) as output_json: 66 | json.dump(convert_pio_to_vscode(idedata.raw), output_json, indent=4) 67 | main_esphome._LOGGER.info( 68 | f"Override Feature 2: vscode C++ Configuration '{idedata.raw['env_name']}' refreshed." 69 | ) 70 | return 0 71 | return 1 72 | 73 | 74 | main_esphome.compile_program = compile_program_override 75 | 76 | 77 | def convert_pio_to_vscode(input_data): 78 | all_include = [ 79 | *input_data["includes"]["build"], 80 | *input_data["includes"]["compatlib"], 81 | *input_data["includes"]["toolchain"], 82 | ] 83 | return { 84 | "configurations": [ 85 | { 86 | "name": input_data["env_name"], 87 | "includePath": all_include, 88 | "browse": { 89 | "limitSymbolsToIncludedHeaders": True, 90 | "path": all_include, 91 | }, 92 | "defines": input_data["defines"], 93 | "compilerPath": input_data["cxx_path"], 94 | "compilerArgs": input_data["cxx_flags"], 95 | } 96 | ], 97 | "version": 4, 98 | } 99 | 100 | 101 | ######################## 102 | ## MAIN 103 | ######################## 104 | if __name__ == "__main__": 105 | sys.exit(main_esphome.main()) 106 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ESPHome custom component ble_adv_proxy 2 | 3 | This component transforms your ESP32 into a bluetooth adv proxy that can be used with [ha-ble-adv component](https://github.com/NicoIIT/ha-ble-adv). 4 | 5 | Contrarily to the existing bluetooth_proxy component, this component is handling Raw Advertising exchanges in both directions which is mandatory in order to control devices using BLE Advertising. 6 | 7 | ## Compatibility 8 | The compatibility of `ha-ble-adv` and `ble_adv_proxy` is ensured if using latest version of both components. Moreover the components are backward compatible for one month: any version of `ha-ble-adv` is compatible with a build of `ble_adv_proxy` done up to one month before or after the release of the version. 9 | 10 | => Before opening an issue occuring on a rebuild of an ESP32 including this component, please ensure you have the latest version of `ha-ble-adv` installed in your Home Assistant instance. 11 | 12 | ## Install 13 | The easiest solution to build an ESPHome based binary including this component is to add this feature to an existing [standard Bluetooth ESPHome Proxy](https://esphome.io/components/bluetooth_proxy.html): 14 | 1. Create your standard Bluetooth ESPHome proxy following the standard guide, and have it available in HA. **No help will be provided on this part, you can find tons of help and tutorials on the net** 15 | 2. Add this to the yaml config: 16 | 17 | ```yaml 18 | ble_adv_proxy: 19 | use_max_tx_power: true # see below, remove in case of build issues 20 | 21 | # The 'adapter_name' option should be added ONLY if you want the adapter to have 22 | # a different name from the device (esphome.name) 23 | # If you change the adapter_name, your ble_adv integrations using it will stop working 24 | # and will require you to update the reconfiguring -> Technical Parameters (see ha-ble-adv doc) 25 | # The name MUST be unique for HA: if you have several proxies linked to a HA instance, 26 | # they MUST all have different names 27 | # This option requires ha-ble-adv v1.2.1 minimum 28 | # adapter_name: esp-proxy 29 | 30 | external_components: 31 | source: github://NicoIIT/esphome-ble_adv_proxy 32 | ``` 33 | 34 | 3. Since ESPHome 2025.7.0, you need to define the `custom_services: true` and `homeassistant_services: true` at api level in the yaml config as this component needs it: 35 | ```yaml 36 | api: 37 | custom_services: true 38 | homeassistant_services: true 39 | ``` 40 | 41 | It can also be used with an existing config including ble_adv_manager / ble_adv_remote / ble_adv_controller, this will ease migrations from those components to the HA integration. 42 | 43 | ## Variables 44 | The following variables are available: 45 | - **use_max_tx_power** (Optional, Default: False): Try to use the max TX Power for the Advertising stack, as defined in Espressif [doc](https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/bluetooth/controller_vhci.html#_CPPv417esp_power_level_t). Setup to 'true' if your ESP32 is far from your device and have difficulties to communicate with it. 46 | - **adapter_name** (Optional, Default: the node name): The name of the adapter, defaulting to the name of the node if not provided. 47 | 48 | ## FAQ 49 | ### I have a lot of warnings from esp32_ble_tracker, but I did not even add this component! How can I get ride of them ? 50 | ``` 51 | [23:09:41][W][esp32_ble_tracker:125]: Too many BLE events to process. Some devices may not show up. 52 | [23:09:41][W][esp32_ble_tracker:125]: Too many BLE events to process. Some devices may not show up. 53 | ``` 54 | The `esp32_ble_tracker` is forced included by this component in order to handle the scanning part, it is also included in default Bluetooth Proxy config. 55 | Still the default scan parameters defined in this component may not be accurate to handle the needs of our devices that publish a LOT of Advertising messages in a short amount of time. You can decrease them in order to get ride of the warnings by defining the esp32_ble_tracker and its scan parameters: 56 | 57 | ```yaml 58 | esp32_ble_tracker: 59 | scan_parameters: 60 | window: 20ms 61 | interval: 20ms 62 | ``` 63 | Please be aware that the more you decrease those parameters, the more CPU will be used and the more issue you may face with Wifi / BLE co-existence. 64 | -------------------------------------------------------------------------------- /components/ble_adv_proxy/ble_adv_proxy.cpp: -------------------------------------------------------------------------------- 1 | #include "ble_adv_proxy.h" 2 | #include "esphome/core/log.h" 3 | #include "esphome/core/application.h" 4 | #include 5 | #include 6 | #include 7 | 8 | #ifdef ESP_PWR_LVL_P20 9 | #define MAX_TX_POWER ESP_PWR_LVL_P20 10 | #elif ESP_PWR_LVL_P18 11 | #define MAX_TX_POWER ESP_PWR_LVL_P18 12 | #elif ESP_PWR_LVL_P15 13 | #define MAX_TX_POWER ESP_PWR_LVL_P15 14 | #elif ESP_PWR_LVL_P12 15 | #define MAX_TX_POWER ESP_PWR_LVL_P12 16 | #else 17 | #define MAX_TX_POWER ESP_PWR_LVL_P9 18 | #endif 19 | 20 | namespace esphome { 21 | namespace ble_adv_proxy { 22 | 23 | static constexpr const char *TAG = "ble_adv_proxy"; 24 | static constexpr const char *ADV_RECV_EVENT = "esphome.ble_adv.raw_adv"; 25 | static constexpr const char *SETUP_SVC_V0 = "setup_svc_v0"; 26 | static constexpr const char *CONF_IGN_ADVS = "ignored_advs"; 27 | static constexpr const char *CONF_IGN_CIDS = "ignored_cids"; 28 | static constexpr const char *CONF_IGN_MACS = "ignored_macs"; 29 | static constexpr const char *CONF_IGN_DURATION = "ignored_duration"; 30 | static constexpr const char *ADV_SVC_V0 = "adv_svc"; // legacy name / service 31 | static constexpr const char *ADV_SVC_V1 = "adv_svc_v1"; 32 | static constexpr const char *CONF_RAW = "raw"; 33 | static constexpr const char *CONF_ORIGIN = "orig"; 34 | static constexpr const char *CONF_DURATION = "duration"; 35 | static constexpr const char *CONF_REPEAT = "repeat"; 36 | 37 | static constexpr const uint8_t REPEAT_NB = 3; 38 | static constexpr const uint8_t MIN_ADV = 0x20; 39 | static constexpr const uint8_t MIN_VIABLE_PACKET_LEN = 5; 40 | 41 | BleAdvParam::BleAdvParam(const std::string &hex_string, uint32_t duration) 42 | : duration_(duration), len_(std::min(MAX_PACKET_LEN, hex_string.size() / 2)) { 43 | esphome::parse_hex(hex_string, this->buf_, this->len_); 44 | } 45 | 46 | BleAdvParam::BleAdvParam(const uint8_t *buf, size_t len, const esp_bd_addr_t &orig, uint32_t duration) 47 | : duration_(duration), len_(std::min(MAX_PACKET_LEN, len)) { 48 | std::copy(buf, buf + this->len_, this->buf_); 49 | std::copy(orig, orig + ESP_BD_ADDR_LEN, this->orig_); 50 | } 51 | 52 | void BleAdvProxy::setup() { 53 | this->register_service(&BleAdvProxy::on_setup_v0, SETUP_SVC_V0, {CONF_IGN_DURATION, CONF_IGN_CIDS, CONF_IGN_MACS}); 54 | this->register_service(&BleAdvProxy::on_advertise_v0, ADV_SVC_V0, {CONF_RAW, CONF_DURATION}); 55 | this->register_service(&BleAdvProxy::on_advertise_v1, ADV_SVC_V1, 56 | {CONF_RAW, CONF_DURATION, CONF_REPEAT, CONF_IGN_ADVS, CONF_IGN_DURATION}); 57 | this->scan_result_lock_ = xSemaphoreCreateMutex(); 58 | if (this->sensor_name_->state.empty()) { 59 | this->sensor_name_->state = App.get_name(); 60 | } 61 | this->sensor_name_->publish_state(this->sensor_name_->state); 62 | } 63 | 64 | void BleAdvProxy::dump_config() { 65 | ESP_LOGCONFIG(TAG, "BleAdvProxy '%s'", this->sensor_name_->state.c_str()); 66 | ESP_LOGCONFIG(TAG, " Use Max TxPower: %s", this->use_max_tx_power_ ? "True" : "False"); 67 | } 68 | 69 | void BleAdvProxy::on_setup_v0(float ign_duration, std::vector ignored_cids, 70 | std::vector ignored_macs) { 71 | this->dupe_ignore_duration_ = ign_duration; 72 | this->dupe_packets_.clear(); 73 | this->ign_cids_.clear(); 74 | for (auto &ign_cid : ignored_cids) { 75 | this->ign_cids_.emplace_back(uint16_t(ign_cid)); 76 | } 77 | ESP_LOGI(TAG, "SETUP - %d Company IDs Permanently ignored.", this->ign_cids_.size()); 78 | this->ign_macs_.clear(); 79 | std::swap(ignored_macs, this->ign_macs_); 80 | ESP_LOGI(TAG, "SETUP - %d MACs Permanently ignored.", this->ign_macs_.size()); 81 | this->setup_done_ = true; 82 | } 83 | 84 | void BleAdvProxy::on_advertise_v0(std::string raw, float duration) { 85 | this->on_advertise_v1(raw, duration / REPEAT_NB, REPEAT_NB, {raw}, this->dupe_ignore_duration_); 86 | } 87 | 88 | void BleAdvProxy::on_advertise_v1(std::string raw, float duration, float repeat, std::vector ignored_advs, 89 | float ign_duration) { 90 | this->setup_done_ = true; // Flag setup done as best effort 91 | uint8_t int_repeat = uint8_t(repeat); 92 | uint32_t int_duration = uint32_t(duration); 93 | uint32_t int_ign_duration = uint32_t(ign_duration); 94 | ESP_LOGD(TAG, "send adv - %s, duration %dms, repeat: %d", raw.c_str(), int_duration, int_repeat); 95 | for (uint8_t i = 0; i < int_repeat; ++i) { 96 | this->send_packets_.emplace_back(raw, int_duration); 97 | } 98 | // Prevent ignored packets from being re sent to HA host in case received 99 | for (auto &ignored_adv : ignored_advs) { 100 | // ESP_LOGD(TAG, "Ignoring ADV for %ds: %s", int_ign_duration / 1000, ignored_adv.c_str()); 101 | this->check_add_dupe_packet(BleAdvParam(ignored_adv, millis() + int_ign_duration)); 102 | } 103 | } 104 | 105 | bool BleAdvProxy::check_add_dupe_packet(BleAdvParam &&packet) { 106 | // Check the recently received advs 107 | auto idx = std::find_if(this->dupe_packets_.begin(), this->dupe_packets_.end(), [&](BleAdvParam &p) { 108 | return (p.len_ <= packet.len_) && std::equal(p.buf_, p.buf_ + p.len_, packet.buf_); 109 | }); 110 | if (idx != this->dupe_packets_.end()) { 111 | if (idx->duration_ > 0) { 112 | idx->duration_ = packet.duration_; // Existing Packet with specified deletion time: Update the deletion time 113 | } 114 | return false; 115 | } 116 | this->dupe_packets_.emplace_back(std::move(packet)); 117 | return true; 118 | } 119 | 120 | std::string get_str_mac(const uint8_t *mac) { 121 | return str_snprintf("%02X:%02X:%02X:%02X:%02X:%02X", 17, mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); 122 | } 123 | 124 | void BleAdvProxy::on_raw_recv(const BleAdvParam ¶m, const std::string &str_mac) { 125 | std::string raw = esphome::format_hex(param.buf_, param.len_); 126 | ESP_LOGD(TAG, "[%s] recv raw - %s", str_mac.c_str(), raw.c_str()); 127 | if (!this->is_connected() || !this->setup_done_) { 128 | ESP_LOGD(TAG, "Connection to HA not ready, received adv ignored."); 129 | return; 130 | } 131 | this->fire_homeassistant_event(ADV_RECV_EVENT, {{CONF_RAW, std::move(raw)}, {CONF_ORIGIN, std::move(str_mac)}}); 132 | } 133 | 134 | void BleAdvProxy::setup_max_tx_power() { 135 | if (this->max_tx_power_setup_done_ || !this->use_max_tx_power_) { 136 | return; 137 | } 138 | 139 | esp_power_level_t lev_init = esp_ble_tx_power_get(ESP_BLE_PWR_TYPE_ADV); 140 | ESP_LOGD(TAG, "Advertising TX Power enum value (NOT dBm) before max setup: %d", lev_init); 141 | 142 | if (lev_init != MAX_TX_POWER) { 143 | ESP_LOGI(TAG, "Advertising TX Power setup attempt to: %d", MAX_TX_POWER); 144 | esp_ble_tx_power_set(ESP_BLE_PWR_TYPE_ADV, MAX_TX_POWER); 145 | esp_power_level_t lev_final = esp_ble_tx_power_get(ESP_BLE_PWR_TYPE_ADV); 146 | ESP_LOGI(TAG, "Advertising TX Power enum value (NOT dBm) after max setup: %d", lev_final); 147 | } else { 148 | ESP_LOGI(TAG, "Advertising TX Power already at max: %d", MAX_TX_POWER); 149 | } 150 | 151 | this->max_tx_power_setup_done_ = true; 152 | } 153 | 154 | void BleAdvProxy::loop() { 155 | if (!this->get_parent()->is_active()) { 156 | // esp32_ble::ESP32BLE not ready: do not process any action 157 | return; 158 | } 159 | 160 | if (!this->is_connected() && this->setup_done_) { 161 | ESP_LOGI(TAG, "HA Connection lost."); 162 | this->setup_done_ = false; 163 | } 164 | 165 | // Cleanup expired packets 166 | this->dupe_packets_.remove_if([&](BleAdvParam &p) { return p.duration_ > 0 && p.duration_ < millis(); }); 167 | 168 | // swap packet list to further process it outside of the lock 169 | std::list new_packets; 170 | if (xSemaphoreTake(this->scan_result_lock_, 5L / portTICK_PERIOD_MS)) { 171 | std::swap(this->recv_packets_, new_packets); 172 | xSemaphoreGive(this->scan_result_lock_); 173 | } else { 174 | ESP_LOGW(TAG, "loop - failed to take lock"); 175 | } 176 | 177 | // handle new packets, exclude if one of the following is true: 178 | // - len is too small 179 | // - company ID is part of ignored company ids 180 | // - mac is part of ignored macs 181 | // - is dupe of previously received 182 | for (auto &sr : new_packets) { 183 | uint16_t cid = (sr.ble_adv[3] << 8) + sr.ble_adv[2]; 184 | std::string str_mac = get_str_mac(sr.bda); 185 | if (std::find(this->ign_cids_.begin(), this->ign_cids_.end(), cid) == this->ign_cids_.end() && 186 | std::find(this->ign_macs_.begin(), this->ign_macs_.end(), str_mac) == this->ign_macs_.end() && 187 | this->check_add_dupe_packet( 188 | BleAdvParam(sr.ble_adv, sr.adv_data_len, sr.bda, millis() + this->dupe_ignore_duration_))) { 189 | this->on_raw_recv(this->dupe_packets_.back(), str_mac); 190 | } 191 | } 192 | 193 | // Process advertising 194 | if (this->adv_stop_time_ == 0) { 195 | // No packet is being advertised, advertise the front one 196 | if (!this->send_packets_.empty()) { 197 | BleAdvParam &packet = this->send_packets_.front(); 198 | this->setup_max_tx_power(); 199 | ESP_ERROR_CHECK_WITHOUT_ABORT(esp_ble_gap_config_adv_data_raw(packet.buf_, packet.len_)); 200 | uint8_t adv_time = std::max(MIN_ADV, uint8_t(1.6 * packet.duration_)); 201 | this->adv_params_.adv_int_min = adv_time; 202 | this->adv_params_.adv_int_max = adv_time; 203 | ESP_ERROR_CHECK_WITHOUT_ABORT(esp_ble_gap_start_advertising(&(this->adv_params_))); 204 | this->adv_stop_time_ = millis() + packet.duration_; 205 | } 206 | } else { 207 | // Packet is being advertised, stop advertising and remove packet 208 | if (millis() > this->adv_stop_time_) { 209 | ESP_ERROR_CHECK_WITHOUT_ABORT(esp_ble_gap_stop_advertising()); 210 | this->adv_stop_time_ = 0; 211 | this->send_packets_.pop_front(); 212 | } 213 | } 214 | } 215 | 216 | // We let the configuration of the scanning to esp32_ble_tracker, towards with stop / start 217 | // We only gather directly the raw events 218 | void BleAdvProxy::gap_scan_event_handler(const esp32_ble::BLEScanResult &sr) { 219 | if (sr.adv_data_len <= MAX_PACKET_LEN && sr.adv_data_len >= MIN_VIABLE_PACKET_LEN) { 220 | if (xSemaphoreTake(this->scan_result_lock_, 5L / portTICK_PERIOD_MS)) { 221 | this->recv_packets_.emplace_back(sr); 222 | xSemaphoreGive(this->scan_result_lock_); 223 | } else { 224 | ESP_LOGW(TAG, "evt - failed to take lock"); 225 | } 226 | } 227 | } 228 | 229 | } // namespace ble_adv_proxy 230 | } // namespace esphome 231 | --------------------------------------------------------------------------------