diff --git a/.copier-answers.yml b/.copier-answers.yml new file mode 100644 index 0000000..f2b0133 --- /dev/null +++ b/.copier-answers.yml @@ -0,0 +1,11 @@ +# Changes here will be overwritten by Copier +_commit: v0.5.0 +_src_path: git@github.com:finitelabs/control4-driver-template.git +project_name: control4-esphome +project_description: Integrate ESPHome-based devices into Control4 +github_org: finitelabs +distributions: drivercentral oss +readme_driver_slug: esphome +readme_build: oss +primary_color: "#17BCF2" +vendor_modules: json deferred drivers-common-public xml bitn bthome noiseprotocol protobuf \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..0c35390 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,101 @@ +name: Build + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22' + + - name: Setup Lua + uses: leafo/gh-actions-lua@v12 + with: + luaVersion: 'luajit-2.1' + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: '3.x' + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libxml2-dev \ + libxslt-dev \ + libssl-dev \ + swig \ + pandoc \ + xmlstarlet \ + xvfb \ + libnss3 \ + libatk1.0-0 \ + libatk-bridge2.0-0 \ + libcups2 \ + libdrm2 \ + libxkbcommon0 \ + libxcomposite1 \ + libxdamage1 \ + libxrandr2 \ + libgbm1 \ + libpango-1.0-0 \ + libcairo2 \ + libasound2t64 + + - name: Initialize project + run: make init + + - name: Build + run: xvfb-run make build + env: + ELECTRON_DISABLE_SANDBOX: '1' + + - name: Verify PDFs were generated + run: | + for build in drivercentral oss; do + pdf_count=$(find "dist/$build" -name '*.pdf' 2>/dev/null | wc -l) + if [ "$pdf_count" -eq 0 ]; then + echo "::error::No PDF files found in dist/$build" + ls -la "dist/$build/" || true + exit 1 + fi + echo "Found $pdf_count PDF(s) in dist/$build" + done + + - name: Check for dirty tree + run: | + git diff --exit-code || { echo "::error::Uncommitted changes after build"; exit 1; } + UNTRACKED=$(git ls-files --others --exclude-standard) + if [ -n "$UNTRACKED" ]; then + echo "::error::Untracked files after build:" + echo "$UNTRACKED" + exit 1 + fi + + - name: Upload drivercentral artifacts + if: always() + uses: actions/upload-artifact@v7 + with: + name: drivercentral + path: dist/drivercentral/* + if-no-files-found: error + + - name: Upload oss artifacts + if: always() + uses: actions/upload-artifact@v7 + with: + name: oss + path: dist/oss/* + if-no-files-found: error \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0c5d97b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,57 @@ +name: Release + +on: + push: + tags: ['v*'] + +permissions: + contents: write + actions: read + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - name: Wait for build and download oss artifacts + run: | + echo "Waiting for Build workflow to complete for $GITHUB_SHA..." + while true; do + run_id=$(gh run list \ + --repo "$GITHUB_REPOSITORY" \ + --workflow build.yml \ + --commit "$GITHUB_SHA" \ + --json databaseId,status,conclusion \ + --jq '.[] | select(.status == "completed") | .databaseId' \ + | head -1) + if [ -n "$run_id" ]; then + echo "Build run $run_id completed" + break + fi + echo "Build not finished yet, waiting 30s..." + sleep 30 + done + + conclusion=$(gh run view "$run_id" \ + --repo "$GITHUB_REPOSITORY" \ + --json conclusion --jq '.conclusion') + if [ "$conclusion" != "success" ]; then + echo "::error::Build run $run_id finished with: $conclusion" + exit 1 + fi + + gh run download "$run_id" \ + --repo "$GITHUB_REPOSITORY" \ + --name oss \ + --dir dist/oss + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + files: | + dist/oss/*.c4z + dist/oss/*.pdf + dist/oss/*.zip diff --git a/.gitignore b/.gitignore index 632585d..b2359a9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ /dist /build /.venv +/.lua /node_modules +**/http-client.private.env.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 014acb0..636c3a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog + [//]: # "## v[Version] - YYY-MM-DD" [//]: # "### Added" [//]: # "- Added" @@ -9,6 +10,128 @@ [//]: # "- Changed" [//]: # "### Removed" [//]: # "- Removed" + + +## Unreleased + +### Added + +- Added Event entity support: stateless triggers (button presses, gestures, + doorbell rings) now create Control4 events for programming and track the last + event type in a variable +- Added Date, Time, and Datetime entity support: configurable date/time values + on the device are exposed as writable string variables (YYYY-MM-DD, HH:MM:SS, + YYYY-MM-DD HH:MM:SS) +- Added Update entity support: firmware update tracking with current/latest + version variables, update available flag, in-progress indicator, and automatic + device update option + +## v20260328 - 2026-03-28 + +### Added + +- Added Select entity support: STRING variable with current option, writable via + programming or variable writes +- Added "Set Select" programming command with dynamic Select and Option + dropdowns + +## v20260326 - 2026-03-26 + + + +### Fixed + +- Fixed automatic driver updates not working when the leader instance is removed + from the project + + + +## v20260325 - 2026-03-25 + +### Fixed + +- Fixed cover contact sensors sending duplicate notifications during open/close + operations +- Fixed Yale DoorSense contact sensor sending duplicate "Closed" notifications + on every poll cycle by tracking the last known door status and only reporting + on actual state changes + +## v20260319 - 2026-03-19 + +### Fixed + +- Fixed an issue where entities were no longer being detected reliably on + connection + +## v20260318 - 2026-03-18 + +### Fixed + +- Fixed Bluetooth Coordinator failing to connect to active BLE devices + (SwitchBot, Yale locks) through proxies + +## v20260314 - 2026-03-14 + +### Added + +- Added fan support with on/off, speed control (1-6 speed variants), direction, + and oscillation +- Added ESPHome Yale sub-driver for Yale/August BLE smart locks with lock/unlock + control, door sense, and battery monitoring + +## v20260217 - 2026-02-17 + +### Added + +- Added Bluetooth proxy support with scanner infrastructure, advertisement + parsing, and GATT connection management +- Added ESPHome Bluetooth Coordinator driver for multi-proxy aggregation with + RSSI-based routing and connection failover +- Added room presence tracking with RSSI-based detection, anti-flapping, and + contact sensor bindings +- Added ESPHome BTHome sub-driver for Shelly BLU and BTHome v1/v2 sensors +- Added ESPHome Govee sub-driver for temperature, humidity, and meat thermometer + sensors +- Added ESPHome SwitchBot sub-driver for Bot, Plug Mini, Meter, Motion, and + Contact devices +- Added device log forwarding to the ESPHome driver + +## v20251031 - 2025-10-31 + +### Fixed + +- Fixed compatibility with ESPHome 2025.10.0 for devices configured without + passwords +- Improved password authentication failure detection and error reporting + +## v20251022 - 2025-10-22 + +### Fixed + +- Fixed an issue with parsing unknown fields in protobuf messages + +## v20251019 - 2025-10-19 + +### Added + +- Added support for OpenSSL with "Encryption Key" authentication mode across all + applicable algorithms + +### Fixed + +- Fixed a bug with the authentication flow in the latest 2025.10.0 firmware + +## v20250811 - 2025-08-11 + +### Fixed + +- Fixed switch entities not responding to bound relay proxies + +## v20250715 - 2025-07-14 + +### Fixed + +- Fixed bug causing entities to not be discovered on connect ## v20250714 - 2025-07-14 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..730783d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,154 @@ +# Contributing to control4-esphome + +## Project Structure + +This project uses [Copier](https://copier.readthedocs.io/) to manage shared +infrastructure across multiple Control4 driver repositories. Shared code lives +in a [template repo](https://github.com/finitelabs/control4-driver-template) and +is synced into each driver project. + +### What's Managed by the Template + +The following files are maintained via the template and **should not be edited +directly** in this repo. Changes to these files should be made in the +[template repo](https://github.com/finitelabs/control4-driver-template) and +synced with `copier update`. + +**Build tooling:** + +- `Makefile` — build, format, docs, package, and clean targets +- `package.json` — npm dependency declarations only (no scripts) + +**Common libraries (`src/lib/`):** + +- `bindings.lua` — binding management +- `conditionals.lua` — conditional/programming UI management +- `events.lua` — event firing and management +- `http.lua` — HTTP client wrapper +- `logging.lua` — structured logging with configurable levels +- `lru.lua` — LRU cache utility +- `persist.lua` — persistent storage abstraction +- `utils.lua` — general utilities (XML, device queries, table helpers, type + coercion) +- `values.lua` — value parsing, coercion, and formatting +- `github-updater.lua` — GitHub Releases self-updater (non-DriverCentral builds) + +**Vendor libraries (`vendor/`):** + +- `JSON.lua` — JSON encoder/decoder +- `deferred.lua` — promises/deferred implementation +- `cloud-client-byte.lua` — DriverCentral cloud licensing +- `version.lua` — semver comparison (used by github-updater) +- `drivers-common-public/` — Control4's official shared libraries +- `xml/` — XML parser (xml2lua) +- `bitn.lua` — bit manipulation library +- `bthome.lua` — BTHome protocol support +- `noiseprotocol.lua` — Noise Protocol encryption +- `protobuf.lua` — Protocol Buffers + +**Tools (`tools/`):** + +- `preprocess` — C-style `#ifdef`/`#ifndef` preprocessor for Lua, XML, and + Markdown +- `gen-squishy.lua` — auto-generates squishy files from driver.c4zproj +- `pandoc-remove-style.lua` — Pandoc filter for README generation + +**Other:** + +- `.gitignore`, `LICENSE`, `CONTRIBUTING.md` +- `test/c4_shim.lua`, `test/run_test.sh` + +### What's Driver-Specific (Yours to Edit) + +- `src/constants.lua` — driver-specific constants +- `drivers/*/driver.lua` — main driver logic +- `drivers/*/driver.xml` — driver XML configuration +- `drivers/*/driver.c4zproj` — driver packaging manifest +- `drivers/*/www/` — documentation and icons +- `CHANGELOG.md`, `README.md` +- Any additional `src/` modules specific to this driver +- Any additional `vendor/` libraries specific to this driver + +## Updating Shared Code + +When the template is updated, sync changes into this repo: + +```bash +copier update --trust +``` + +Copier will show diffs for any files that changed and let you resolve conflicts. +It tracks which template version you're on via the `.copier-answers.yml` file +(committed to the repo). + +To update shared code for **all** driver repos, run `copier update` in each one. + +## Build System + +This project uses `make` for build orchestration and `npm` only for JavaScript +dependency management. + +### Prerequisites + +- Python 3.9+ (for preprocess and driverpackager) +- Node.js / npm (for formatter and doc dependencies) +- [xmlstarlet](https://xmlstarlet.sourceforge.net/) (`brew install xmlstarlet`) +- [LuaJIT](https://luajit.org/) (`brew install luajit`) — for squish and tests +- [Pandoc](https://pandoc.org/) (`brew install pandoc`) — for README generation + +### Common Commands + +```bash +make init # One-time setup: install all dependencies +make build # Full build: format, preprocess, docs, package, zip +make build-nodocs # Build without generating docs +make fmt # Format all code (Lua, Python, Markdown) +make clean # Remove build artifacts +make clean-all # Remove everything (build artifacts, deps, venv) +``` + +### Build Pipeline + +1. **Format** — stylua (Lua), black (Python), prettier (Markdown) +2. **Preprocess** — resolve `#ifdef`/`#ifndef` directives per distribution +3. **Generate squishy** — create squish manifests from .c4zproj files +4. **Update driver.xml** — stamp version date and modified timestamp +5. **Generate docs** — Markdown → HTML → PDF, plus README +6. **Package** — run driverpackager to create .c4z files +7. **Zip** — bundle .c4z and .pdf files per distribution + +### Distributions + +Builds are configured for these distributions: `drivercentral oss` + +Each distribution produces its own set of .c4z driver files with +distribution-specific code paths controlled by `#ifdef` directives (e.g., +`#ifdef DRIVERCENTRAL` vs `#ifdef OSS`). + +## Preprocessor Directives + +The `tools/preprocess` script supports C-style conditional compilation in Lua, +XML, and Markdown: + +```lua +--#ifdef DRIVERCENTRAL +DC_PID = 1234 +DC_FILENAME = "driver.c4z" +--#else +DRIVER_GITHUB_REPO = "finitelabs/control4-esphome" +--#endif +``` + +```xml + + + + + +``` + +### Variant Expansion + +Drivers can define variants via a `variants.json` file. The preprocessor expands +these into multiple driver directories with substituted values, generating one +.c4z per variant combination. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6aa0c71 --- /dev/null +++ b/Makefile @@ -0,0 +1,189 @@ +# Control4 Driver Build System +# Run `make help` for available targets. + +DISTRIBUTIONS := drivercentral oss +README_DRIVER := esphome +README_BUILD := oss + +# Paths +VENV := .venv +VENV_PY := $(VENV)/bin/python3 +VENV_BLACK := $(VENV)/bin/black +PACKAGER := dist/driverpackager/dp3/driverpackager.py + +# OpenSSL detection (cross-platform) +OPENSSL_PREFIX := $(or \ + $(shell pkg-config --variable=prefix openssl 2>/dev/null), \ + $(shell brew --prefix openssl 2>/dev/null)) + +# Only set paths if we found OpenSSL outside standard locations +ifneq ($(OPENSSL_PREFIX),) + export LDFLAGS := -L$(OPENSSL_PREFIX)/lib + export CFLAGS := -I$(OPENSSL_PREFIX)/include -DPRAGMA_IGNORE_UNUSED_LABEL= -DPRAGMA_WARN_STRICT_PROTOTYPES= + export SWIG_FEATURES := -cpperraswarn -I$(OPENSSL_PREFIX)/include +else + export CFLAGS := -DPRAGMA_IGNORE_UNUSED_LABEL= -DPRAGMA_WARN_STRICT_PROTOTYPES= +endif + +# ─── Help ───────────────────────────────────────────────────────────────────── + +.PHONY: help +help: ## Show this help + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' + +# ─── Init ───────────────────────────────────────────────────────────────────── + +.PHONY: init +init: node_modules $(VENV) $(PACKAGER) ## One-time setup: install all dependencies + +node_modules: package.json + npm install + @touch $@ + +$(VENV): + python3 -m venv $(VENV) + $(VENV_PY) -m pip install --upgrade pip setuptools wheel M2Crypto lxml black copier + +$(PACKAGER): + rm -rf dist/driverpackager + git clone https://github.com/finitelabs/drivers-driverpackager.git dist/driverpackager + +# ─── Format ─────────────────────────────────────────────────────────────────── + +.PHONY: fmt fmt-lua fmt-py fmt-md +fmt: fmt-lua fmt-py fmt-md ## Format all code + +fmt-lua: node_modules + npx stylua \ + --indent-type Spaces --column-width 120 --line-endings Unix \ + --indent-width 2 --quote-style AutoPreferDouble \ + -g '*.lua' -v ./drivers ./src ./test ./tools ./vendor + +fmt-py: + $(VENV_BLACK) tools/preprocess + +fmt-md: node_modules + npx prettier --prose-wrap always --write ./drivers/**/www/**/*.md *.md + +# ─── Preprocess ─────────────────────────────────────────────────────────────── + +.PHONY: preprocess +preprocess: ## Run preprocessor for all distributions + @for build in $(DISTRIBUTIONS); do \ + ./tools/preprocess --$$build || exit 1; \ + done + +# ─── Squishy ────────────────────────────────────────────────────────────────── + +.PHONY: gen-squishy +gen-squishy: ## Auto-generate squishy files from .c4zproj + @for build in $(DISTRIBUTIONS); do \ + for driver_dir in build/$$build/drivers/*/; do \ + (cd "$$driver_dir" && lua ../../../../tools/gen-squishy.lua) || exit 1; \ + done; \ + done + +# ─── Driver XML ─────────────────────────────────────────────────────────────── + +.PHONY: update-xml update-xml-version update-xml-modified +update-xml: update-xml-version update-xml-modified ## Stamp version + modified in driver.xml + +update-xml-version: + @for build in $(DISTRIBUTIONS); do \ + for driver_dir in build/$$build/drivers/*/; do \ + xmlstarlet edit --inplace --omit-decl \ + --update '/devicedata/version' --value "$$(date +'%Y%m%d')" \ + "$${driver_dir}driver.xml"; \ + done; \ + done + +update-xml-modified: + @for build in $(DISTRIBUTIONS); do \ + for driver_dir in build/$$build/drivers/*/; do \ + xmlstarlet edit --inplace --omit-decl \ + --update '/devicedata/modified' --value "$$(date +'%m/%d/%Y %I:%M %p')" \ + "$${driver_dir}driver.xml"; \ + done; \ + done + +# ─── Docs ───────────────────────────────────────────────────────────────────── + +.PHONY: docs docs-html docs-pdf docs-readme +docs: docs-readme docs-html docs-pdf ## Generate all documentation + + +docs-readme: + rm -rf ./images + @if [ -d drivers/$(README_DRIVER)/www/documentation/images ]; then cp -r drivers/$(README_DRIVER)/www/documentation/images .; fi + pandoc build/$(README_BUILD)/drivers/$(README_DRIVER)/www/documentation/index.md \ + -f gfm -t gfm --lua-filter=tools/pandoc-remove-style.lua -o README.md + + +docs-html: node_modules + @for build in $(DISTRIBUTIONS); do \ + for driver_dir in build/$$build/drivers/*/; do \ + npx generate-md --layout github \ + --input "$${driver_dir}www/documentation/index.md" \ + --output "$${driver_dir}www/documentation"; \ + done; \ + done + +docs-pdf: node_modules + @for build in $(DISTRIBUTIONS); do \ + mkdir -p "dist/$$build"; \ + for driver_dir in build/$$build/drivers/*/; do \ + if [ -f "$${driver_dir}.variant_pdf" ]; then \ + driver_display_name=$$(cat "$${driver_dir}.variant_pdf"); \ + else \ + driver_display_name=$$(xmlstarlet sel -t -v '/devicedata/name' "$${driver_dir}driver.xml"); \ + fi; \ + pdf_output="dist/$$build/$$driver_display_name Documentation.pdf"; \ + if [ -f "$$pdf_output" ]; then continue; fi; \ + npx electron-pdf --marginsType 0 \ + --input "$$(pwd)/$${driver_dir}www/documentation/index.html" \ + --output "$$pdf_output" || exit 1; \ + done; \ + done + +# ─── Package ────────────────────────────────────────────────────────────────── + +.PHONY: package +package: $(PACKAGER) ## Create .c4z driver packages + @for build in $(DISTRIBUTIONS); do \ + for driver_dir in build/$$build/drivers/*/; do \ + dir=$$(basename "$$driver_dir"); \ + pwd_saved="$$(pwd)"; \ + cd "build/$$build/drivers/$$dir" && \ + "$$pwd_saved/$(VENV_PY)" "$$pwd_saved/$(PACKAGER)" . "$$pwd_saved/dist/$$build" driver.c4zproj && \ + cd "$$pwd_saved"; \ + done; \ + done + +.PHONY: zip +zip: ## Zip .c4z and .pdf files per distribution + @for build in $(DISTRIBUTIONS); do \ + cd "dist/$$build" && \ + zip "$$(basename "$$(realpath "$$(pwd)/../../")").zip" *.c4z *.pdf && \ + cd ../../; \ + done + +# ─── Build ──────────────────────────────────────────────────────────────────── + +.PHONY: build build-nodocs +build: clean-build preprocess gen-squishy update-xml docs fmt package zip ## Full build + +build-nodocs: clean-build preprocess gen-squishy update-xml fmt package ## Build without docs + +# ─── Clean ──────────────────────────────────────────────────────────────────── + +.PHONY: clean-build clean clean-all +clean-build: ## Remove build artifacts + rm -rf build + @for build in $(DISTRIBUTIONS); do rm -rf "dist/$$build"; done + +clean: clean-build ## Remove build artifacts and dist + rm -rf dist + +clean-all: clean ## Remove everything (build, dist, deps, venv) + rm -rf node_modules $(VENV) diff --git a/README.md b/README.md index eee59ad..1a38261 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,18 @@ ESPHome ------------------------------------------------------------------------- +--- # Overview -> DISCLAIMER: This software is neither affiliated with nor endorsed by -> either Control4 or ESPHome. +> DISCLAIMER: This software is neither affiliated with nor endorsed by either +> Control4 or ESPHome. -Integrate ESPHome-based devices into Control4. ESPHome is an open-source -system that transforms common microcontrollers, like ESP8266 and ESP32, -into smart home devices through simple YAML configuration. ESPHome -devices can be set up, monitored, and controlled using a web browser, -Home Assistant, or other compatible platforms. This driver enables -seamless monitoring and control of ESPHome devices directly from your -Control4 system. +Integrate [ESPHome-based devices](https://devices.esphome.io) into Control4. +ESPHome is an open-source system that transforms common microcontrollers, like +ESP8266 and ESP32, into smart home devices through simple YAML configuration. +ESPHome devices can be set up, monitored, and controlled using a web browser, +Home Assistant, or other compatible platforms. This driver enables seamless +monitoring and control of ESPHome devices directly from your Control4 system. # Index @@ -31,10 +30,13 @@ Control4 system. - [Cloud Settings](#cloud-settings) - [Driver Settings](#driver-settings) - [Device Settings](#device-settings) + - [Bluetooth Proxy Settings](#bluetooth-proxy-settings) - [Device Info](#device-info) - [Driver Actions](#driver-actions) + - [Programming Reference](#programming-reference) - [Configuration Guides](#configuration-guides) - [ratgdo Configuration Guide](#ratgdo-configuration-guide) + - [Bluetooth Proxy Configuration Guide](#bluetooth-proxy-configuration-guide) - [Support](#support) - [Changelog](#changelog) @@ -42,15 +44,15 @@ Control4 system.
-# System requirements +# System Requirements - Control4 OS 3.3+ # Features - Local network communication requiring no cloud services -- Real-time updates from all [supported - entities](#supported-esphome-entities) exposed by the device +- Real-time updates from all [supported entities](#supported-esphome-entities) + exposed by the device - Supports encrypted connections using the device encryption key - Variable Programming Support @@ -58,14 +60,27 @@ Control4 system. ## Verified Devices -This driver will generically work with any ESPHome device, but we have -tested extensively with the following devices: +This driver will generically work with any ESPHome device, but we have tested +extensively with the following devices: -- [ratgdo](https://ratcloud.llc) - [Configuration - Guide](#ratgdo-configuration-guide) +- [ratgdo](https://ratcloud.llc) - + [Configuration Guide](#ratgdo-configuration-guide) -If you try this driver on a product listed above, and it works, let us -know! +If you try this driver on a product listed above, and it works, let us know! + +## Supported Bluetooth Devices + +When used as a Bluetooth proxy, this driver supports the following BLE device +types through sub-drivers: + +| Protocol | Sub-Driver | Example Devices | +| ----------- | ----------------- | ---------------------------------------------------- | +| SwitchBot | ESPHome SwitchBot | Bot, Plug Mini, Relay Switch, Meter, Motion, Contact | +| BTHome | ESPHome BTHome | Shelly BLU Button/Door/Motion/H&T, DIY sensors | +| Govee | ESPHome Govee | Temperature/humidity monitors, meat thermometers | +| Yale/August | ESPHome Yale | Yale and August smart locks | + +See the individual sub-driver documentation for device-specific details.
@@ -73,83 +88,89 @@ know!
-| Entity Type | Supported | -|---------------------|-----------| -| Alarm Control Panel | ❌ | -| API Noise | ❌ | -| Binary Sensor | ✅ | -| Bluetooth Proxy | ❌ | -| Button | ✅ | -| Climate | ❌ | -| Cover | ✅ | -| Datetime | ❌ | -| Date | ❌ | -| Time | ❌ | -| Camera | ❌ | -| Event | ❌ | -| Fan | ❌ | -| Light | ✅ | -| Lock | ✅ | -| Media Player | ❌ | -| Number | ✅ | -| Select | ❌ | -| Sensor | ✅ | -| Siren | ❌ | -| Switch | ✅ | -| Text | ✅ | -| Text Sensor | ✅ | -| Update | ❌ | -| Valve | ❌ | -| Voice Assistant | ❌ | +| Entity Type | Supported | +| ------------------- | ----------------------------- | +| Alarm Control Panel | ❌ | +| API Noise | ✅ | +| Binary Sensor | ✅ | +| Bluetooth Proxy | ✅ | +| Button | ✅ | +| Camera | ❌ | +| Climate | ❌ | +| Cover | ✅ | +| Date | ✅ | +| Datetime | ✅ | +| Event | ✅ | +| Fan | ✅ | +| Light | ✅ | +| Lock | ✅ | +| Media Player | ❌ | +| Number | ✅ | +| Select | ✅ | +| Sensor | ✅ | +| Siren | ❌ | +| Switch | ✅ | +| Text | ✅ | +| Text Sensor | ✅ | +| Time | ✅ | +| Update | ✅ | +| Valve | ❌ | +| Voice Assistant | ❌[\*](#voice-assistant-note) |
+ + +> \* Voice Assistant requires a speech-to-text and intent processing pipeline +> (e.g. Home Assistant Assist). Control4 does not natively provide voice intent +> handling, so this entity type is not supported. +
# Installer Setup -> ⚠️ Only a ***single*** driver instance is required per ESPHome device. -> Multiple instance of this driver connected to the same device will -> have unexpected behavior. However, you can have multiple instances of -> this driver connected to ***different*** ESPHome devices. +> ⚠️ Only a **_single_** driver instance is required per ESPHome device. +> Multiple instance of this driver connected to the same device will have +> unexpected behavior. However, you can have multiple instances of this driver +> connected to **_different_** ESPHome devices. ## Driver Installation -Driver installation and setup are similar to most other ip-based -drivers. Below is an outline of the basic steps for your convenience. +Driver installation and setup are similar to most other ip-based drivers. Below +is an outline of the basic steps for your convenience. 1. Download the latest `control4-esphome.zip` from [Github](https://github.com/finitelabs/control4-esphome/releases/latest). 2. Extract and - [install]((https://www.control4.com/help/c4/software/cpro/dealer-composer-help/content/composerpro_userguide/adding_drivers_manually.htm)) - the `esphome.c4z`, `esphome_light.c4z`, and `esphome_lock.c4z` - drivers. + [install](https://www.control4.com/help/c4/software/cpro/dealer-composer-help/content/composerpro_userguide/adding_drivers_manually.htm) + all `.c4z` files. 3. Use the "Search" tab to find the "ESPHome" driver and add it to your project. - > ⚠️ A ***single*** driver instance is required per ESPHome device. + > ⚠️ A **_single_** driver instance is required per ESPHome device. ![Search Drivers](images/search-drivers.png) -4. Configure the [Device Settings](#device-settings) with the - connection information. +4. Configure the [Device Settings](#device-settings) with the connection + information. -5. After a few moments the [`Driver Status`](#driver-status-read-only) - will display `Connected`. If the driver fails to connect, set the - [`Log Mode`](#log-mode--off--print--log--print-and-log-) property to - `Print` and re-set the [`IP Adress`](#ip-address) field to - reconnect. Then check the lua output window for more information. +5. After a few moments the [`Driver Status`](#driver-status-read-only) will + display `Connected`. If the driver fails to connect, set the + [`Log Mode`](#log-mode--off--print--log--print-and-log-) property to `Print` + and re-set the [`IP Adress`](#ip-address) field to reconnect. Then check the + lua output window for more information. 6. Once connected, the driver will automatically create variables and - connections for each supported entity type. + connection bindings for each supported entity type. -7. To control lights and/or locks, use the "Search" tab to find the - "ESPHome Light" and/or "ESPHome Lock" driver. Add one driver - instance for each exposed light or lock entity in your project. In - the "Connections" tab, select the "ESPHome" driver and bind the - light or lock entities to the newly added drivers. +7. To control lights, fans, and/or locks, use the "Search" tab to find the + "ESPHome Light", "ESPHome Fan", and/or "ESPHome Lock" driver. For fans, + choose the speed variant that matches your fan (e.g., "ESPHome Fan (3 + Speed)"). Add one driver instance for each exposed entity in your project. + In the "Connections" tab, select the "ESPHome" driver and bind the entities + to the newly added drivers. ## Driver Setup @@ -157,14 +178,14 @@ drivers. Below is an outline of the basic steps for your convenience. #### Cloud Settings -##### Automatic Updates +##### Automatic Updates \[ Off \| **_On_** \] -Turns on/off the GitHub cloud automatic updates. +Enables or disables automatic driver updates from GitHub releases. -##### Update Channel +##### Update Channel \[ **_Production_** \| Prerelease \] -Sets the update channel for which releases are considered during an -automatic update from the GitHub repo releases. +Sets the update channel for which releases are considered during automatic +updates from GitHub releases. #### Driver Settings @@ -176,53 +197,182 @@ Displays the current status of the driver. Displays the current version of the driver. -##### Log Level \[ Fatal \| Error \| Warning \| ***Info*** \| Debug \| Trace \| Ultra \] +##### Log Level \[ 0 - Fatal \| 1 - Error \| 2 - Warning \| **_3 - Info_** \| 4 - Debug \| 5 - Trace \| 6 - Ultra \] -Sets the logging level. Default is `Info`. +Sets the logging level. Default is `3 - Info`. -##### Log Mode \[ ***Off*** \| Print \| Log \| Print and Log \] +##### Log Mode \[ **_Off_** \| Print \| Log \| Print and Log \] Sets the logging mode. Default is `Off`. +##### Device Log Forwarding \[ **_Off_** \| On \] + +Forward ESPHome device logs to the driver's Lua output at the current Log Level. +Changing Log Level or disabling Log Mode will reconnect to apply the new +settings. + #### Device Settings ##### IP Address -Sets the device IP address (e.g. `192.168.1.30`). Domain names are -allowed as long as they can be resolved to an accessible IP address by -the controller. HTTPS is not supported. +Sets the device IP address (e.g. `192.168.1.30`). Domain names are allowed as +long as they can be resolved to an accessible IP address by the controller. +HTTPS is not supported. -> ⚠️ If you are using an IP address, you should ensure it will not -> change by assigning a static IP or creating a DHCP reservation. +> ⚠️ If you are using an IP address, you should ensure it will not change by +> assigning a static IP or creating a DHCP reservation. -##### Port +##### Port \[ 1 - 65535, default: **_6053_** \] Sets the device port. The default port for ESPHome devices is `6053`. -##### Authentication Mode \[ ***None*** \| Password \| Encryption Key \] +##### Authentication Mode \[ **_None_** \| Password \| Encryption Key \] Selects the authentication method for connecting to the ESPHome device. - **None**: No authentication required. - **Password**: Use a password for authentication (see below). -- **Encryption Key**: Use an encryption key for secure communication - (see below). +- **Encryption Key**: Use an encryption key for secure communication (see + below). + +> **Tip:** For ESPHome devices used primarily as Bluetooth proxies, consider +> configuring the firmware without API encryption. In busy BLE environments, the +> volume of proxy traffic combined with the controller's limited cryptographic +> performance can cause significant CPU load. BLE traffic is inherently +> over-the-air, and sensitive protocols (such as Yale/August lock commands) use +> their own end-to-end encryption between the driver and the device, making it +> safe to relay through an unencrypted proxy. To disable encryption, omit the +> `encryption` block from the ESPHome `api:` configuration and set +> Authentication Mode to `None`. ##### Password -Shown only if [Authentication -Mode](#authentication-mode--none--password--encryption-key-) is set to -`Password`. -Sets the device password. This must match the password configured on the -ESPHome device. +Shown only if +[Authentication Mode](#authentication-mode--none--password--encryption-key-) is +set to `Password`. Sets the device password. This must match the password +configured on the ESPHome device. ##### Encryption Key -Shown only if [Authentication -Mode](#authentication-mode--none--password--encryption-key-) is set to -`Encryption Key`. -Sets the device encryption key for secure communication. This must match -the encryption key configured on the ESPHome device. +Shown only if +[Authentication Mode](#authentication-mode--none--password--encryption-key-) is +set to `Encryption Key`. Sets the device encryption key for secure +communication. This must match the encryption key configured on the ESPHome +device. + +##### Use OpenSSL \[ **_Yes_** \| No \] + +Use OpenSSL for encryption. This should typically be left at the default value +of `Yes` for better performance and compatibility. + +#### Bluetooth Proxy Settings + +> The Bluetooth Proxy feature requires an ESP32 device with the +> `bluetooth_proxy` component configured in ESPHome. See the +> [ESPHome Bluetooth Proxy documentation](https://esphome.io/components/bluetooth_proxy.html) +> for firmware configuration. + +##### Bluetooth Proxy Status (read-only) + +Shows the current state of the Bluetooth proxy. The format is a pipe-separated +list of status components: + +**Standalone Mode:** `Standalone Mode | Scanning (Passive) | 1/4 Active` + +**Coordinator Mode:** +`Coordinator Mode | Scanning (Passive) | 0/3 Active | MAC Filter: 5` + +Components explained: + +- **Mode** - "Standalone Mode" or "Coordinator Mode" depending on whether the + driver is connected to a Bluetooth Coordinator +- **Scanner State** - Current state (Idle, Starting, Running, Stopping, Stopped, + Failed) and mode (Passive or Active) +- **Connection Slots** - Shows "used/total Active" slots (e.g., "1/4 Active" + means 1 slot in use out of 4 available). If you select more active devices + than available slots, "(Oversubscribed)" is appended (see + [Oversubscription](#oversubscription)) +- **MAC Filter** - (Coordinator mode only) Shows how many device MACs are being + filtered for, or "none" if forwarding all advertisements + +##### Bluetooth Proxy Capabilities (read-only) + +Displays a comma-separated list of capabilities supported by this ESPHome +Bluetooth proxy: + +- **Scan** - Can receive BLE advertisements (passive scanning) +- **Connect** - Can establish GATT connections to devices (active connections) +- **Cache** - Caches GATT service data remotely +- **Pair** - Can pair with devices requiring authentication +- **Raw** - Can receive raw advertisement data + +##### Select Bluetooth Devices + +> Only visible in **Standalone Mode** (hidden when connected to a Bluetooth +> Coordinator, as device selection is done there instead). + +A dropdown list showing discovered BLE devices. Select "Refresh List" to start a +new scan. + +**During scanning:** + +- The dropdown displays "-- Scanning..." while the scan is in progress +- Select "-- Stop Scan" to stop early and keep newly discovered devices +- Select "-- Abort Scan" to stop early and discard newly discovered devices +- When complete, the dropdown repopulates with discovered devices + +**Device list shows:** + +- MAC Address +- Device Name (if available) +- Device Type (BTHome, SwitchBot, Govee, etc.) +- Connection Type (Active/Passive) + +After selecting a device, a connection binding is automatically created for the +appropriate sub-driver. + +> **Tip:** Some BLE devices (buttons, sensors with long advertisement intervals) +> may be in sleep mode. Wake them by pressing buttons, triggering motion +> sensors, or opening/closing contact sensors during the scan to ensure they +> appear in the device list. + +##### Bluetooth Scan Duration \[ 5 - 60, default: **_30_** \] + +> Only visible in **Standalone Mode**. + +Sets how long (in seconds) to scan for BLE devices when refreshing the device +list. Longer scans may discover more devices with long advertisement intervals. + +##### Bluetooth Proxy Room + +> Only visible in **Coordinator Mode** (when connected to a Bluetooth +> Coordinator). + +Sets the room where this Bluetooth proxy is physically located. This is used for +presence tracking to determine which room a device is in based on signal +strength. + +##### Minimum Room RSSI Override (dBm) \[ -100 - -40, default: **_-100_** \] + +> Only visible in **Coordinator Mode** (when connected to a Bluetooth +> Coordinator). + +Overrides the coordinator's global "Minimum Room RSSI" setting for this proxy's +room. Use this when a room has different size or characteristics than others. + +- **-100 (default)** - Use the coordinator's global setting +- **-85** - More permissive; allow detection from further away (large rooms) +- **-60** - More restrictive; require closer proximity (small rooms) + +**Examples:** + +- Large living room: Set to `-85` to allow detection from further away +- Small bathroom: Set to `-60` to require closer proximity +- Leave at `-100` to use the coordinator's global setting + +> **Note:** Only affects room assignment for this proxy. The value is sent to +> the Bluetooth Coordinator when this proxy connects or when the setting +> changes. #### Device Info @@ -246,21 +396,98 @@ Displays the MAC address of the connected ESPHome device. Displays the firmware version of the connected ESPHome device. -#### Driver Actions +### Driver Actions -##### Update Drivers +#### Update Drivers -Trigger the driver to update from the latest release on GitHub, -regardless of the current version. +Trigger the driver to update from the latest release on GitHub, regardless of +the current version. -##### Reset Connections and Variables +#### Reset Driver > ⚠️ This will reset all connection bindings and delete any programming > associated with the variables. -Reset the driver connections and variables. This is useful if you change -the connected ESPHome device or there are stale connections or -variables. +Resets the driver to its initial state, removing all dynamically created +connections and variables. This is useful if you change the connected ESPHome +device or there are stale connections or variables. + +**Parameters:** + +- **Are You Sure?** \[ **_No_** \| Yes \] - Confirmation to reset the driver. + +## Programming Reference + +Once connected, the driver automatically creates variables and bindings for each +supported ESPHome entity. Use this reference for Control4 programming. + +### Variables by Entity Type + +| Entity Type | Variable Name | Type | Notes | +| ------------- | --------------------------- | ------ | ------------------------------------------ | +| Binary Sensor | `{name} State` | BOOL | "1" = triggered, "0" = clear | +| Button | (none) | \- | Use "Press Button" command (see below) | +| Cover | `{name} State` | STRING | "open", "closed", "opening", "closing" | +| Date | `{name}` | STRING | Writable, formatted as YYYY-MM-DD | +| Datetime | `{name}` | STRING | Writable, formatted as YYYY-MM-DD HH:MM:SS | +| Update | `{name} Current Version` | STRING | Current firmware version | +| Update | `{name} Latest Version` | STRING | Latest available firmware version | +| Update | `{name} Update Available` | BOOL | True when an update is available | +| Update | `{name} Update In Progress` | BOOL | True while an update is being installed | +| Event | `{name} Last Event` | STRING | Last event type (e.g., "single_press") | +| Fan | (none) | \- | State via Fan proxy | +| Light | (none) | \- | State via Light proxy | +| Lock | (none) | \- | State via Lock proxy | +| Number | `{name}` | NUMBER | Writable, 1 decimal precision | +| Select | `{name}` | STRING | Writable, current option | +| Sensor | `{name}` | NUMBER | Read-only, 1 decimal precision | +| Switch | `{name} State` | BOOL | "1" = on, "0" = off (writable) | +| Text | `{name}` | STRING | Writable | +| Text Sensor | `{name}` | STRING | Read-only | +| Time | `{name}` | STRING | Writable, formatted as HH:MM:SS | + +> **Note:** `{name}` is replaced with the entity's display name from ESPHome +> (e.g., a sensor named "Temperature" creates a variable called "Temperature"). + +### Bindings by Entity Type + +| Entity Type | Binding Class | Purpose | +| ------------- | ------------------------------- | -------------------------------------- | +| Binary Sensor | `CONTACT_SENSOR` | Integrates with Contact Sensor proxy | +| Switch | `RELAY` | Control via Relay proxy | +| Cover | `CONTACT_SENSOR` | Open/closed state contacts | +| Cover | `RELAY` | Open/close/stop control relays | +| Button | `BUTTON_LINK` | Allows other devices to trigger button | +| Fan | `ESPHOME_FAN_N_SPEED[_REVERSE]` | Bind to ESPHome Fan sub-driver | +| Light | `ESPHOME_LIGHT` | Bind to ESPHome Light sub-driver | +| Lock | `ESPHOME_LOCK` | Bind to ESPHome Lock sub-driver | + +> **Note:** Sensor, Number, Select, Text, Text Sensor, Date, Time, Datetime, and +> Event entities do not create bindings. They expose data only through variables +> and events. + +### Events by Entity Type + +| Entity Type | Event Name | Description | +| ----------- | ---------------------- | ------------------------------------------- | +| Event | `{name}: {event_type}` | One C4 event per event type for programming | + +> **Note:** Event entities are stateless triggers (button presses, gestures, +> doorbell rings). Each discovered event type creates a Control4 event that can +> be used in programming. The `{name} Last Event` variable tracks the most +> recent event type. + +### Commands + +| Command | Parameters | Description | +| ------------- | -------------- | ------------------------------------------------- | +| Press Button | Button | Triggers an ESPHome button entity by name | +| Set Select | Select, Option | Sets a select entity to the specified option | +| Update Device | Update | Installs a firmware update on the selected entity | + +> **Note:** The Button parameter is a dynamic list populated with discovered +> ESPHome button entities. The Select and Option parameters are dynamic lists +> populated with discovered ESPHome select entities and their available options.
@@ -268,41 +495,39 @@ variables. # ratgdo Configuration Guide -This guide provides instructions for configuring the ESPHome driver to -work with ratgdo devices for garage door control via relays in Control4 -Composer Pro. +This guide provides instructions for configuring the ESPHome driver to work with +ratgdo devices for garage door control via relays in Control4 Composer Pro. ## Add Relay Controller Driver -Add the desired relay controller driver to your Control4 project in -Composer Pro. +Add the desired relay controller driver to your Control4 project in Composer +Pro. Relay Controller Drivers ## Relay Controller Properties -The ratgdo device exposes a "Cover" entity in ESPHome, which maps to the -relay controller functionality in Control4. +The ratgdo device exposes a "Cover" entity in ESPHome, which maps to the relay +controller functionality in Control4. ### Number of Relays -The ratgdo device uses a multi-relay configuration to control the garage -door. In Composer Pro, you should configure the relay settings as -follows: +The ratgdo device uses a multi-relay configuration to control the garage door. +In Composer Pro, you should configure the relay settings as follows: - Set to **2 Relays** (Open/Close) or **3 Relays** (Open/Close/Stop) - - The ratgdo device uses separate commands for opening and closing the - garage door - - If your ratgdo firmware supports the "stop" command, configure for 3 - relays to enable the stop functionality. If you are not sure, you - can look at the ratgdo connections in Composer Pro to see if the - "Stop Door" relay is available. + - The ratgdo device uses separate commands for opening and closing the garage + door + - If your ratgdo firmware supports the "stop" command, configure for 3 relays + to enable the stop functionality. If you are not sure, you can look at the + ratgdo connections in Composer Pro to see if the "Stop Door" relay is + available. ### Relay Configuration - Set to **Pulse** - - ratgdo uses momentary pulses to trigger the garage door opener, - similar to a wall button press + - ratgdo uses momentary pulses to trigger the garage door opener, similar to a + wall button press ### Pulse Time @@ -324,8 +549,8 @@ follows: ### Example Properties -For reference, here is an example of the relay controller properties in -Composer Pro: +For reference, here is an example of the relay controller properties in Composer +Pro: Relay Controller Properties @@ -344,8 +569,8 @@ Composer Pro: ### Example Connections -For reference, here is an example of how the connections should look in -Composer Pro: +For reference, here is an example of how the connections should look in Composer +Pro: Relay Controller Connections @@ -362,17 +587,15 @@ You can create programming in Control4 to: Using the "Still Open Time" property from the relay controller driver: -1. Set the "Still Open Time" to your desired duration (e.g., 10 - minutes) -2. Create a programming rule that triggers when the "Still Open" event - fires +1. Set the "Still Open Time" to your desired duration (e.g., 10 minutes) +2. Create a programming rule that triggers when the "Still Open" event fires 3. Add actions to send notifications or perform other tasks ## Additional Entities -Depending on your ratgdo device, firmware, and its capabilities, there -may be additional entities exposed by the ESPHome driver. These can come -as additional connections or driver variables. +Depending on your ratgdo device, firmware, and its capabilities, there may be +additional entities exposed by the ESPHome driver. These can come as additional +connections or driver variables. Please refer to ratgdo's documentation for more information on specific entities: @@ -381,10 +604,222 @@ entities:
+# Bluetooth Proxy Configuration Guide + +This guide explains how to use the ESPHome Bluetooth Proxy feature to integrate +BLE devices into Control4. + +## Prerequisites + +- ESP32 device with ESPHome firmware and `bluetooth_proxy` component enabled +- BLE devices within range of the ESP32 + +**Recommended Hardware:** + +The following POE-powered Bluetooth proxies are excellent choices with 4 active +connection slots: + +- [Seeed Studio XIAO ESP32C6](https://www.seeedstudio.com/Seeed-Studio-XIAO-ESP32C6-p-5884.html) + with POE expansion board +- [Olimex ESP32-POE](https://www.olimex.com/Products/IoT/ESP32/ESP32-POE/open-source-hardware) + or + [ESP32-POE-ISO](https://www.olimex.com/Products/IoT/ESP32/ESP32-POE-ISO/open-source-hardware) + +**Firmware Installation:** + +- **Quick Start:** Use [web.esphome.io](https://web.esphome.io) for one-click + firmware installation directly in your browser - no YAML configuration needed. +- **Advanced:** For more control over settings, create a custom YAML + configuration. See the + [ESPHome Bluetooth Proxy documentation](https://esphome.io/components/bluetooth_proxy.html) + for configuration options. + +## Understanding Connection Types + +BLE devices use one of two connection modes: + +### Passive Mode (No Slot Required) + +Passive devices broadcast their data in advertisements. The proxy listens +without establishing a connection. These devices include: + +- **Shelly BLU devices** - Button, Door/Window, Motion, H&T sensors (native + BTHome support) +- **BTHome sensors** - Temperature, humidity, motion, door sensors +- **Govee sensors** - Temperature/humidity monitors, meat thermometers +- **SwitchBot sensors** - Meters, motion sensors, contact sensors, water leak + +**Advantage:** Unlimited passive devices can be monitored simultaneously. + +### Active Mode (Uses Connection Slot) + +Active devices require a GATT connection to send commands. The ESP32 has limited +connection slots (typically 3-4). These devices include: + +- **SwitchBot Bot** - Requires connection to send press/on/off commands +- **SwitchBot Switch** - Plug Mini, Relay switches (encrypted commands) +- **Yale/August Locks** - Requires connection for encrypted lock/unlock commands + +### Oversubscription + +You can select more active devices than available connection slots. This is +called **oversubscription** and works well when devices only need brief +connections to exchange data (e.g., sending a command to a SwitchBot Bot). The +device connects, sends the command, and immediately frees the slot. + +Oversubscription becomes problematic when multiple devices need simultaneous +connections. If all slots are in use, commands to additional devices will queue +and retry until a slot becomes available. + +> **Tip:** If you see "Allocation failed" errors in the logs, you have too many +> concurrent active connections. Consider reducing the number of active devices +> or adding another proxy. + +## Step-by-Step Setup + +1. **Add the ESPHome driver** and configure it to connect to your ESP32 device. + +2. **Verify Bluetooth Proxy Status** shows "Ready" with available slots. + +3. **Scan for devices:** + - Set "Bluetooth Scan Duration" (30 seconds recommended) + - Select "Refresh List" from the "Select Bluetooth Devices" dropdown + - Wait for the scan to complete + +4. **Select a discovered device** from the dropdown. A connection will be + automatically created. + +5. **Add the appropriate sub-driver:** + - Search for the driver matching your device type (e.g., "ESPHome BTHome") + - Add it to your project + +6. **Bind the sub-driver** to the connection created in step 4. + +7. **Configure the sub-driver** properties as needed. + +## Supported Device Types + +| Device Protocol | Sub-Driver | Connection | +| --------------- | ----------------- | -------------- | +| BTHome | ESPHome BTHome | Passive | +| Govee | ESPHome Govee | Passive | +| SwitchBot | ESPHome SwitchBot | Active/Passive | +| Yale/August | ESPHome Yale | Active | + +## Performance Considerations + +### Device Limits + +The ESP32 Bluetooth proxy has practical limits on device capacity: + +| Connection Type | Recommended Limit | Notes | +| ------------------ | ----------------- | ----------------------------------- | +| Passive devices | 20-30 | Sensors broadcasting advertisements | +| Active connections | 3-5 | Devices requiring GATT connections | + +Exceeding these limits may cause: + +- Missed advertisements from passive devices +- "Allocation failed" errors for active connections +- Delayed or failed commands to active devices + +> **Warning:** If you see "Too many BLE events to process" in the logs, the +> proxy is overwhelmed. Reduce the number of tracked devices or add another +> proxy. + +### Busy BLE Environments + +In environments with many BLE devices (smart home hubs, fitness equipment, +wireless speakers, neighbors' devices), performance may degrade: + +- **2.4 GHz congestion** - BLE and WiFi share spectrum; heavy WiFi traffic + affects BLE reception +- **Advertisement flooding** - Many devices broadcasting simultaneously can + overwhelm the scanner +- **Interference sources** - Microwaves, USB 3.0 devices, and poorly shielded + electronics cause interference + +**Mitigation strategies:** + +1. Use Ethernet-connected ESP32 boards (Olimex ESP32-POE) to avoid WiFi/BLE + contention +2. Position proxies away from WiFi routers (at least 2-3 meters) +3. Use the Bluetooth Coordinator to distribute load across multiple proxies +4. Reduce scan duration if not actively discovering new devices + +### Proxy Placement Tips + +For optimal BLE reception: + +- **Height matters** - Place at chest height or higher, not on the floor +- **Avoid metal** - Keep away from refrigerators, filing cabinets, and metal + shelving (metal causes reflections and unstable signals) +- **Avoid enclosed spaces** - Don't place inside cabinets or behind furniture +- **Distance from electronics** - Keep 2-3 meters from routers, switches, and + other network equipment + +**Coverage expectations:** + +| Environment | Typical Range | +| -------------------- | ----------------------- | +| Open indoor space | 10-15 meters (30-50 ft) | +| Through 1-2 walls | 5-10 meters (15-30 ft) | +| Concrete/brick walls | 3-5 meters (10-15 ft) | + +
+ +## Bluetooth Coordinator Setup + +For advanced setups with **multiple ESPHome Bluetooth proxies**, use the +**ESPHome Bluetooth Coordinator** driver to aggregate them. The coordinator +provides: + +- **RSSI-based routing** - Commands are routed to the proxy with the best signal + strength for each BLE device +- **Automatic failover** - If a connection fails, the coordinator retries + through alternate proxies +- **Room-level presence tracking** - Track which room a BLE device (like a + phone) is in based on signal strength from each proxy + +> **Note:** Apple devices (iPhone, iPad, Apple Watch) and Android devices with +> MAC randomization enabled cannot be tracked for presence. Use dedicated BLE +> beacons or devices with static MAC addresses instead. + +### When to Use the Coordinator + +| Setup | Recommendation | +| ------------------------ | --------------------------- | +| Single ESP32 proxy | Use ESPHome driver directly | +| Multiple proxies | Use Bluetooth Coordinator | +| Want presence tracking | Use Bluetooth Coordinator | +| Want failover/redundancy | Use Bluetooth Coordinator | + +### Coordinator Setup Steps + +1. **Add the Bluetooth Coordinator driver** to your project +2. **Connect your ESPHome drivers** to the coordinator's proxy bindings + (Connections tab) +3. **Set the "Bluetooth Proxy Room" property** on each ESPHome driver to + indicate where that proxy is physically located +4. **Select devices** via the coordinator's "Select Bluetooth Devices" property +5. **For presence tracking**, select devices via "Select Presence Devices" + +When an ESPHome driver is connected to the coordinator: + +- The "Select Bluetooth Devices" property is hidden (selection is done in the + coordinator) +- The "Bluetooth Proxy Room" property becomes visible for presence tracking + configuration + +See the **ESPHome Bluetooth Coordinator** driver documentation for full details +on presence tracking settings and events. + +
+ # Support -If you have any questions or issues integrating this driver with -Control4, you can file an issue on GitHub: +If you have any questions or issues integrating this driver with Control4, you +can file an issue on GitHub: @@ -394,12 +829,128 @@ Control4, you can file an issue on GitHub: # Changelog +## Unreleased + +### Added + +- Added Event entity support: stateless triggers (button presses, gestures, + doorbell rings) now create Control4 events for programming and track the last + event type in a variable +- Added Date, Time, and Datetime entity support: configurable date/time values + on the device are exposed as writable string variables (YYYY-MM-DD, HH:MM:SS, + YYYY-MM-DD HH:MM:SS) +- Added Update entity support: firmware update tracking with current/latest + version variables, update available flag, in-progress indicator, and automatic + device update option + +## v20260328 - 2026-03-28 + +### Added + +- Added Select entity support: STRING variable with current option, writable via + programming or variable writes +- Added "Set Select" programming command with dynamic Select and Option + dropdowns + +## v20260326 - 2026-03-26 + +### Fixed + +- Fixed automatic driver updates not working when the leader instance is removed + from the project + +## v20260325 - 2026-03-25 + +### Fixed + +- Fixed cover contact sensors sending duplicate notifications during open/close + operations +- Fixed Yale DoorSense contact sensor sending duplicate "Closed" notifications + on every poll cycle by tracking the last known door status and only reporting + on actual state changes + +## v20260319 - 2026-03-19 + +### Fixed + +- Fixed an issue where entities were no longer being detected reliably on + connection + +## v20260318 - 2026-03-18 + +### Fixed + +- Fixed Bluetooth Coordinator failing to connect to active BLE devices + (SwitchBot, Yale locks) through proxies + +## v20260314 - 2026-03-14 + +### Added + +- Added fan support with on/off, speed control (1-6 speed variants), direction, + and oscillation +- Added ESPHome Yale sub-driver for Yale/August BLE smart locks with lock/unlock + control, door sense, and battery monitoring + +## v20260217 - 2026-02-17 + +### Added + +- Added Bluetooth proxy support with scanner infrastructure, advertisement + parsing, and GATT connection management +- Added ESPHome Bluetooth Coordinator driver for multi-proxy aggregation with + RSSI-based routing and connection failover +- Added room presence tracking with RSSI-based detection, anti-flapping, and + contact sensor bindings +- Added ESPHome BTHome sub-driver for Shelly BLU and BTHome v1/v2 sensors +- Added ESPHome Govee sub-driver for temperature, humidity, and meat thermometer + sensors +- Added ESPHome SwitchBot sub-driver for Bot, Plug Mini, Meter, Motion, and + Contact devices +- Added device log forwarding to the ESPHome driver + +## v20251031 - 2025-10-31 + +### Fixed + +- Fixed compatibility with ESPHome 2025.10.0 for devices configured without + passwords +- Improved password authentication failure detection and error reporting + +## v20251022 - 2025-10-22 + +### Fixed + +- Fixed an issue with parsing unknown fields in protobuf messages + +## v20251019 - 2025-10-19 + +### Added + +- Added support for OpenSSL with "Encryption Key" authentication mode across all + applicable algorithms + +### Fixed + +- Fixed a bug with the authentication flow in the latest 2025.10.0 firmware + +## v20250811 - 2025-08-11 + +### Fixed + +- Fixed switch entities not responding to bound relay proxies + +## v20250715 - 2025-07-14 + +### Fixed + +- Fixed bug causing entities to not be discovered on connect + ## v20250714 - 2025-07-14 ### Added -- Added support for encrypted connections using the device encryption - key +- Added support for encrypted connections using the device encryption key ## v20250619 - 2025-06-19 diff --git a/drivers/esphome/driver.lua b/drivers/esphome/driver.lua index a2a6656..509c1bf 100644 --- a/drivers/esphome/driver.lua +++ b/drivers/esphome/driver.lua @@ -1,59 +1,123 @@ +--#ifdef DRIVERCENTRAL +DC_PID = 819 +DC_X = nil +DC_FILENAME = "esphome.c4z" +--#else DRIVER_GITHUB_REPO = "finitelabs/control4-esphome" DRIVER_FILENAMES = { "esphome.c4z", + "esphome_bluetooth_coordinator.c4z", + "esphome_govee.c4z", + "esphome_bthome.c4z", + -- #variant-filenames esphome_fan "esphome_light.c4z", "esphome_lock.c4z", + "esphome_switchbot.c4z", + "esphome_yale.c4z", } --- ---#ifdef DRIVERCENTRAL -DC_PID = 819 -DC_X = nil -DC_FILENAME = "esphome.c4z" --#endif + require("lib.utils") -require("vendor.drivers-common-public.global.handlers") -require("vendor.drivers-common-public.global.lib") -require("vendor.drivers-common-public.global.timer") -require("vendor.drivers-common-public.global.url") +require("drivers-common-public.global.handlers") +require("drivers-common-public.global.lib") +require("drivers-common-public.global.timer") +require("drivers-common-public.global.url") local log = require("lib.logging") local bindings = require("lib.bindings") +--#ifndef DRIVERCENTRAL local githubUpdater = require("lib.github-updater") +--#endif local values = require("lib.values") local ESPHomeClient = require("esphome.client") +local ESPHomeProtoSchema = require("esphome.proto_schema") +local LocalScannerNode = require("esphome.ble.local_scanner_node") + +local bleScanner = require("esphome.ble.scanner") +local bleScannerProperties = require("esphome.ble.scanner_properties") + +local BluetoothProxyCapability = require("esphome.capabilities.bluetooth_proxy") + local BinarySensorEntity = require("esphome.entities.binary_sensor") local ButtonEntity = require("esphome.entities.button") local CoverEntity = require("esphome.entities.cover") +local DateEntity = require("esphome.entities.date") +local DateTimeEntity = require("esphome.entities.datetime") +local EventEntity = require("esphome.entities.event") +local FanEntity = require("esphome.entities.fan") local LightEntity = require("esphome.entities.light") local LockEntity = require("esphome.entities.lock") local NumberEntity = require("esphome.entities.number") local SensorEntity = require("esphome.entities.sensor") local SwitchEntity = require("esphome.entities.switch") +local SelectEntity = require("esphome.entities.select") local TextEntity = require("esphome.entities.text") local TextSensorEntity = require("esphome.entities.text_sensor") +local TimeEntity = require("esphome.entities.time") +local UpdateEntity = require("esphome.entities.update") local constants = require("constants") local esphome = ESPHomeClient:new() +local localScannerNode = LocalScannerNode:new(esphome) + +bleScanner:addNode(localScannerNode) + +local bluetoothProxyCapability = BluetoothProxyCapability:new(esphome) --- @type table local Entities = { [BinarySensorEntity.TYPE] = BinarySensorEntity:new(esphome), [ButtonEntity.TYPE] = ButtonEntity:new(esphome), [CoverEntity.TYPE] = CoverEntity:new(esphome), + [DateEntity.TYPE] = DateEntity:new(esphome), + [DateTimeEntity.TYPE] = DateTimeEntity:new(esphome), + [EventEntity.TYPE] = EventEntity:new(esphome), + [FanEntity.TYPE] = FanEntity:new(esphome), [LightEntity.TYPE] = LightEntity:new(esphome), [LockEntity.TYPE] = LockEntity:new(esphome), [NumberEntity.TYPE] = NumberEntity:new(esphome), + [SelectEntity.TYPE] = SelectEntity:new(esphome), [SensorEntity.TYPE] = SensorEntity:new(esphome), [SwitchEntity.TYPE] = SwitchEntity:new(esphome), [TextEntity.TYPE] = TextEntity:new(esphome), [TextSensorEntity.TYPE] = TextSensorEntity:new(esphome), + [TimeEntity.TYPE] = TimeEntity:new(esphome), + [UpdateEntity.TYPE] = UpdateEntity:new(esphome), } +--- Get all ESPHome driver instances sorted by device ID +--- @return integer[] deviceIds Sorted list of device IDs +local function getESPHomeDriverIds() + local drivers = C4:GetDevicesByC4iName(C4:GetDriverFileName()) or {} + --- @type integer[] + local ids = {} + for id, _ in pairs(drivers) do + table.insert(ids, tointeger(id)) + end + table.sort(ids) + return ids +end + +--- Sync a property value to all other ESPHome driver instances +--- Only syncs if the other instance has a different value (avoids infinite loops) +--- @param propertyName string The property name to sync +--- @param propertyValue string The property value to sync +local function syncPropertyToOtherInstances(propertyName, propertyValue) + local ids = getESPHomeDriverIds() + local myId = C4:GetDeviceID() + for _, deviceId in ipairs(ids) do + if deviceId ~= myId then + log:info("Syncing property '%s' = '%s' to device %d", propertyName, propertyValue, deviceId) + SetDeviceProperties(deviceId, { [propertyName] = propertyValue }, true) + end + end +end + function OnDriverInit() --#ifdef DRIVERCENTRAL - require("vendor.cloud-client-byte") + require("cloud-client-byte") C4:AllowExecute(false) --#else C4:AllowExecute(true) @@ -70,10 +134,15 @@ function OnDriverLateInit() if not CheckMinimumVersion("Driver Status") then return end - - -- Firmaware version is usually an entity and will be picked up by state updates + -- Firmware version is usually an entity and will be picked up by state updates C4:SetPropertyAttribs("Firmware Version", constants.HIDE_PROPERTY) + -- Hide Bluetooth Proxy properties until we detect support + bluetoothProxyCapability:setPropertiesAttribs(constants.HIDE_PROPERTY) + + -- Hide Automatic Device Updates until we detect an UpdateEntity + C4:SetPropertyAttribs("Automatic Device Updates", constants.HIDE_PROPERTY) + C4:FileSetDir("c29tZXNwZWNpYWxrZXk=++11") bindings:restoreBindings() values:restoreValues() @@ -82,14 +151,42 @@ function OnDriverLateInit() -- global sets, they'll change if Property is changed. for p, _ in pairs(Properties) do local status, err = pcall(OnPropertyChanged, p) - if not status and err ~= nil then - log:error(err) + if not status and err then + log:error("Error in OnPropertyChanged for property '%s': %s", p, err or "unknown error") end end gInitialized = true Connect() end +function OPC.Automatic_Updates(propertyValue) + log:trace("OPC.Automatic_Updates('%s')", propertyValue) + --#ifndef DRIVERCENTRAL + if not gInitialized then + return + end + syncPropertyToOtherInstances("Automatic Updates", propertyValue) + --#endif +end + +function OPC.Automatic_Device_Updates(propertyValue) + log:trace("OPC.Automatic_Device_Updates('%s')", propertyValue) + if not gInitialized then + return + end + -- Device updates are device-specific, do NOT sync to other instances +end + +--#ifndef DRIVERCENTRAL +function OPC.Update_Channel(propertyValue) + log:trace("OPC.Update_Channel('%s')", propertyValue) + if not gInitialized then + return + end + syncPropertyToOtherInstances("Update Channel", propertyValue) +end +--#endif + function OPC.Driver_Version(propertyValue) log:trace("OPC.Driver_Version('%s')", propertyValue) C4:UpdateProperty("Driver Version", C4:GetDriverConfigInfo("version")) @@ -100,6 +197,11 @@ function OPC.Log_Mode(propertyValue) log:setLogMode(propertyValue) CancelTimer("LogMode") if not log:isEnabled() then + -- If log mode is disabled and we're subscribed to logs, disconnect to stop logs + if esphome:isLogsSubscribed() then + esphome:disconnect() + end + UpdateProperty("Log Level", "3 - Info", true) return end log:warn("Log mode '%s' will expire in 3 hours", propertyValue) @@ -107,6 +209,7 @@ function OPC.Log_Mode(propertyValue) log:warn("Setting log mode to 'Off' (timer expired)") UpdateProperty("Log Mode", "Off", true) end) + OnPropertyChanged("Log Level") end function OPC.Log_Level(propertyValue) @@ -117,21 +220,33 @@ function OPC.Log_Level(propertyValue) DEBUG_TIMER = true DEBUG_RFN = true DEBUG_URL = true + DEBUG_WEBSOCKET = true else DEBUGPRINT = false DEBUG_TIMER = false DEBUG_RFN = false DEBUG_URL = false + DEBUG_WEBSOCKET = false + end + -- If subscribed to logs, disconnect to resubscribe at new level + if esphome:isLogsSubscribed() then + esphome:disconnect() end end function OPC.IP_Address(propertyValue) log:trace("OPC.IP_Address('%s')", propertyValue) + if not gInitialized then + return + end Connect() end function OPC.Port(propertyValue) - log:trace("OPC.IP_Address('%s')", propertyValue) + log:trace("OPC.Port('%s')", propertyValue) + if not gInitialized then + return + end Connect() end @@ -142,34 +257,163 @@ function OPC.Authentication_Mode(propertyValue) UpdateProperty("Encryption Key", "") C4:SetPropertyAttribs("Password", constants.HIDE_PROPERTY) C4:SetPropertyAttribs("Encryption Key", constants.HIDE_PROPERTY) + C4:SetPropertyAttribs("Use OpenSSL", constants.HIDE_PROPERTY) end if propertyValue == "Password" then UpdateProperty("Encryption Key", "") C4:SetPropertyAttribs("Password", constants.SHOW_PROPERTY) C4:SetPropertyAttribs("Encryption Key", constants.HIDE_PROPERTY) + C4:SetPropertyAttribs("Use OpenSSL", constants.HIDE_PROPERTY) end if propertyValue == "Encryption Key" then UpdateProperty("Password", "") C4:SetPropertyAttribs("Password", constants.HIDE_PROPERTY) C4:SetPropertyAttribs("Encryption Key", constants.SHOW_PROPERTY) + C4:SetPropertyAttribs("Use OpenSSL", constants.SHOW_PROPERTY) + end + if not gInitialized then + return end Connect() end function OPC.Password(propertyValue) log:trace("OPC.Password('%s')", not IsEmpty(propertyValue) and "****" or "") + if not gInitialized then + return + end Connect() end function OPC.Encryption_Key(propertyValue) log:trace("OPC.Encryption_Key('%s')", not IsEmpty(propertyValue) and "****" or "") + if not gInitialized then + return + end + Connect() +end + +function OPC.Use_OpenSSL(propertyValue) + log:trace("OPC.Use_OpenSSL('%s')", propertyValue) + if not gInitialized then + return + end Connect() end +--- Map ESPHome log levels to driver log methods +--- @type table +local ESPHOME_LOG_LEVEL_MAP = { + [ESPHomeProtoSchema.Enum.LogLevel.LOG_LEVEL_ERROR] = log.error, + [ESPHomeProtoSchema.Enum.LogLevel.LOG_LEVEL_WARN] = log.warn, + [ESPHomeProtoSchema.Enum.LogLevel.LOG_LEVEL_INFO] = log.info, + [ESPHomeProtoSchema.Enum.LogLevel.LOG_LEVEL_CONFIG] = log.info, + [ESPHomeProtoSchema.Enum.LogLevel.LOG_LEVEL_DEBUG] = log.debug, + [ESPHomeProtoSchema.Enum.LogLevel.LOG_LEVEL_VERBOSE] = log.trace, + [ESPHomeProtoSchema.Enum.LogLevel.LOG_LEVEL_VERY_VERBOSE] = log.ultra, +} + +--- Map driver log level (0-6) to ESPHome log level for subscription. +--- Driver levels: 0-Fatal, 1-Error, 2-Warning, 3-Info, 4-Debug, 5-Trace, 6-Ultra +--- ESPHome levels: 0-NONE, 1-ERROR, 2-WARN, 3-INFO, 4-CONFIG, 5-DEBUG, 6-VERBOSE, 7-VERY_VERBOSE +--- @type table +local DRIVER_TO_ESPHOME_LOG_LEVEL = { + [0] = ESPHomeProtoSchema.Enum.LogLevel.LOG_LEVEL_ERROR, -- Fatal -> ERROR + [1] = ESPHomeProtoSchema.Enum.LogLevel.LOG_LEVEL_ERROR, -- Error -> ERROR + [2] = ESPHomeProtoSchema.Enum.LogLevel.LOG_LEVEL_WARN, -- Warning -> WARN + [3] = ESPHomeProtoSchema.Enum.LogLevel.LOG_LEVEL_INFO, -- Info -> INFO + [4] = ESPHomeProtoSchema.Enum.LogLevel.LOG_LEVEL_DEBUG, -- Debug -> DEBUG + [5] = ESPHomeProtoSchema.Enum.LogLevel.LOG_LEVEL_VERBOSE, -- Trace -> VERBOSE + [6] = ESPHomeProtoSchema.Enum.LogLevel.LOG_LEVEL_VERY_VERBOSE, -- Ultra -> VERY_VERBOSE +} + +--- Strip ANSI escape codes from a string. +--- @param str string The string to strip +--- @return string stripped The string without ANSI codes +local function stripAnsiCodes(str) + -- Match ANSI escape sequences: ESC [ ... m (where ... is digits/semicolons) + return (str:gsub("\027%[[0-9;]*m", "")) +end + +--- Subscribe to ESPHome device logs and forward them to the driver log. +--- Uses the current driver log level to determine ESPHome subscription level. +local function subscribeToDeviceLogs() + if not esphome:isConnected() then + log:debug("Cannot subscribe to device logs: not connected") + return + end + + -- Map driver log level to ESPHome log level + local driverLevel = log:getLogLevel() + local esphomeLevel = DRIVER_TO_ESPHOME_LOG_LEVEL[driverLevel] or ESPHomeProtoSchema.Enum.LogLevel.LOG_LEVEL_DEBUG + + esphome + :subscribeLogs(function(level, message) + local logMethod = level and ESPHOME_LOG_LEVEL_MAP[level] or log.debug + logMethod(log, "[ESPHome] %s", stripAnsiCodes(message or "")) + end, esphomeLevel) + :next(function() + log:info("Subscribed to ESPHome device logs at level %d", esphomeLevel) + end, function(err) + log:error("Failed to subscribe to device logs: %s", err) + end) +end + +function OPC.Device_Log_Forwarding(propertyValue) + log:trace("OPC.Device_Log_Forwarding('%s')", propertyValue) + if not gInitialized then + return + end + + if toboolean(propertyValue) and log:isEnabled() then + subscribeToDeviceLogs() + elseif esphome:isLogsSubscribed() then + -- Disconnect to stop the log stream (no API to unsubscribe) + -- Heartbeat timer will automatically reconnect without log subscription + esphome:disconnect() + end +end + +function OPC.Select_Bluetooth_Devices(propertyValue) + log:trace("OPC.Select_Bluetooth_Devices('%s')", propertyValue) + if not gInitialized then + return + end + + bleScannerProperties:handleSelection(BluetoothProxyCapability.PROPERTY_NAME, propertyValue) +end + +function OPC.Bluetooth_Scan_Duration(propertyValue) + log:trace("OPC.Bluetooth_Scan_Duration('%s')", propertyValue) + bleScanner:setScanDuration(propertyValue) +end + +function OPC.Bluetooth_Proxy_Room(propertyValue) + log:trace("OPC.Bluetooth_Proxy_Room('%s')", propertyValue) + if not gInitialized then + return + end + -- Notify coordinator of room change + bluetoothProxyCapability:onRoomChanged() +end + +function OPC.Minimum_Room_RSSI_Override_dBm(propertyValue) + log:trace("OPC.Minimum_Room_RSSI_Override_dBm('%s')", propertyValue) + if not gInitialized then + return + end + -- Notify coordinator of minRssiOverride change + bluetoothProxyCapability:onMinRssiOverrideChanged() +end + local function updateStatus(status) + log:trace("updateStatus(%s)", status) UpdateProperty("Driver Status", not IsEmpty(status) and status or "Unknown") end +--- Backoff period in seconds after a fatal connection error before retrying. +local FATAL_ERROR_BACKOFF = 120 + function Connect() log:trace("Connect()") if not gInitialized then @@ -177,9 +421,17 @@ function Connect() return end - esphome:setConfig(Properties["IP Address"], Properties["Port"], Properties["Password"], Properties["Encryption Key"]) + esphome:setConfig( + Properties["IP Address"], + Properties["Port"], + Properties["Password"], + Properties["Encryption Key"], + Properties["Use OpenSSL"] == "Yes" + ) local lastUpdateTime = os.time() -- Don't check for updates on the first cycle + local lastDeviceUpdateCheckTime = os.time() -- Don't check for device updates on the first cycle + local lastFatalErrorTime = 0 -- Track when the last fatal error occurred local heartbeat = function() --#ifdef DRIVERCENTRAL @@ -198,19 +450,53 @@ function Connect() local now = os.time() local secondsSinceLastUpdate = now - lastUpdateTime - if toboolean(Properties["Automatic Updates"]) and secondsSinceLastUpdate > (30 * 60) then - log:info("Checking for driver update (timer expired)") + -- Recompute leader each cycle in case the previous leader was removed + local isLeaderInstance = Select(getESPHomeDriverIds(), 1) == C4:GetDeviceID() + -- Only the leader instance (lowest device ID) performs update checks + if isLeaderInstance and toboolean(Properties["Automatic Updates"]) and secondsSinceLastUpdate > (30 * 60) then + log:info("Checking for driver update (leader instance)") lastUpdateTime = now UpdateDrivers() elseif not esphome:isConnected() then + -- Check for fatal error and apply backoff + local fatalError = esphome:getFatalError() + if fatalError then + if lastFatalErrorTime == 0 then + -- First time seeing this error, record the time + lastFatalErrorTime = now + end + local secondsSinceFailure = now - lastFatalErrorTime + if secondsSinceFailure < FATAL_ERROR_BACKOFF then + local remaining = FATAL_ERROR_BACKOFF - secondsSinceFailure + updateStatus(fatalError .. " (retry in " .. remaining .. "s)") + return + end + -- Backoff period elapsed, reset and try again + lastFatalErrorTime = 0 + end + updateStatus("Connecting") esphome:connect():next(function() - updateStatus("Connected") + lastFatalErrorTime = 0 -- Clear on successful connection + -- If using password authentication, show "waiting for authentication" status + -- until first successful operation confirms auth succeeded + if Properties["Authentication Mode"] == "Password" and not IsEmpty(Properties["Password"]) then + updateStatus("Connection established, waiting for authentication") + else + updateStatus("Connected") + end RefreshStatus() end, function(reason) updateStatus("Connection failed: " .. reason) end) else + -- Check for device updates on the same interval as driver updates (when connected) + local secondsSinceDeviceCheck = now - lastDeviceUpdateCheckTime + if Entities[UpdateEntity.TYPE]:hasEntities() and secondsSinceDeviceCheck > (30 * 60) then + lastDeviceUpdateCheckTime = now + log:info("Checking for device updates") + Entities[UpdateEntity.TYPE]:checkAll() + end updateStatus("Connected") end end @@ -227,10 +513,14 @@ function RefreshStatus() :getDeviceInfo() :next(function(deviceInfo) log:debug("Device Info: %s", deviceInfo) + -- First successful operation confirms authentication succeeded + updateStatus("Connected") values:update("Name", Select(deviceInfo, "friendly_name") or Select(deviceInfo, "name") or "N/A", "STRING") values:update("Model", Select(deviceInfo, "model") or "N/A", "STRING") values:update("Manufacturer", Select(deviceInfo, "manufacturer") or "N/A", "STRING") values:update("MAC Address", Select(deviceInfo, "mac_address") or "N/A", "STRING") + + bluetoothProxyCapability:discovered(deviceInfo) end) :next(function() return esphome:listEntities() @@ -253,6 +543,15 @@ function RefreshStatus() else log:debug("No Entities['%s']:discovered() handler", entity.entity_type) end + + -- Detect restart button for scanner recovery + if entity.entity_type == "button" then + local objectId = entity.object_id or "" + if objectId:lower():find("restart") then + log:info("Found restart button entity: %s (key=%s)", objectId, entity.key) + bluetoothProxyCapability:setRestartButtonKey(entity.key) + end + end end return entities @@ -297,6 +596,12 @@ function RefreshStatus() end end) end) + :next(function() + -- Subscribe to device logs if forwarding is enabled and log mode is on + if toboolean(Properties["Device Log Forwarding"]) and log:isEnabled() then + subscribeToDeviceLogs() + end + end) :next(function() log:info("Successfully refreshed device status") end, function(error) @@ -304,35 +609,102 @@ function RefreshStatus() error = "unknown error" end log:error("An error occurred refreshing device status; %s", error) + updateStatus("Refresh failed: " .. error) esphome:disconnect() end) end) end -function EC.ResetConnectionsAndVariables(params) - log:trace("EC.ResetConnectionsAndVariables(%s)", params) +--- Property values for reset. +--- @type table +local RESET_PROPERTY_VALUES = { + ["Driver Status"] = "Reset - Reconnecting...", + ["Driver Version"] = "", + ["Log Level"] = "3 - Info", + ["Log Mode"] = "Off", + ["Automatic Updates"] = "On", + ["Update Channel"] = "Production", + ["Device Log Forwarding"] = "Off", + ["Use OpenSSL"] = "Yes", + ["Bluetooth Proxy Status"] = "", + ["Bluetooth Scan Duration"] = "30", + ["Name"] = "N/A", + ["Model"] = "N/A", + ["Manufacturer"] = "N/A", + ["MAC Address"] = "N/A", + ["Firmware Version"] = "N/A", +} + +function EC.Reset_Driver(params) + log:trace("EC.Reset_Driver(%s)", params) if Select(params, "Are You Sure?") ~= "Yes" then return end - log:print("Resetting connections and variables") + log:print("Resetting driver to initial state") - for ns, nsBindings in pairs(bindings:getBindings()) do - for bindingKey, binding in pairs(nsBindings) do - log:info("Deleting connection '%s'", binding.displayName) - bindings:deleteBinding(ns, bindingKey) - end - end + -- Reset all dynamic bindings + bindings:reset() + + -- Reset all values (variables and properties) + values:reset() + + -- Reset BLE scanner state + bleScanner:abortScan() + bleScanner:reset() - for name, _ in pairs(Variables or {}) do - log:info("Deleting variable '%s'", name) - values:delete(name) + -- Reset scanner properties (clears device selections) + bleScannerProperties:reset() + + -- Reset properties to default values + for propName, defaultValue in pairs(RESET_PROPERTY_VALUES) do + UpdateProperty(propName, defaultValue, true) end + -- Hide Bluetooth Proxy properties until capability is re-detected + bluetoothProxyCapability:setPropertiesAttribs(constants.HIDE_PROPERTY) + + -- Hide Firmware Version property (usually comes from entity) + C4:SetPropertyAttribs("Firmware Version", constants.HIDE_PROPERTY) + + -- Trigger Authentication Mode handler to set correct visibility for + -- Password, Encryption Key, and Use OpenSSL based on preserved setting + OnPropertyChanged("Authentication Mode") + RefreshStatus() end -function EC.UpdateDrivers() - log:trace("EC.UpdateDrivers()") +-- GATT command handlers for Bluetooth Coordinator +-- These route ExecuteCommand calls to the bluetooth_proxy capability + +function EC.GATT_CONNECT(tParams) + log:trace("EC.GATT_CONNECT(%s)", tParams) + bluetoothProxyCapability:handleCoordinatorCommand("GATT_CONNECT", tParams) +end + +function EC.GATT_DISCONNECT(tParams) + log:trace("EC.GATT_DISCONNECT(%s)", tParams) + bluetoothProxyCapability:handleCoordinatorCommand("GATT_DISCONNECT", tParams) +end + +function EC.GATT_WRITE(tParams) + log:trace("EC.GATT_WRITE(%s)", tParams) + bluetoothProxyCapability:handleCoordinatorCommand("GATT_WRITE", tParams) +end + +function EC.GATT_READ(tParams) + log:trace("EC.GATT_READ(%s)", tParams) + bluetoothProxyCapability:handleCoordinatorCommand("GATT_READ", tParams) +end + +function EC.GATT_NOTIFY(tParams) + log:trace("EC.GATT_NOTIFY(%s)", tParams) + bluetoothProxyCapability:handleCoordinatorCommand("GATT_NOTIFY", tParams) +end + +--#ifndef DRIVERCENTRAL +-- Action: Update Drivers +function EC.Update_Drivers() + log:trace("EC.Update_Drivers()") log:print("Updating drivers") UpdateDrivers(true) end @@ -353,3 +725,4 @@ function UpdateDrivers(forceUpdate) log:error("An error occurred updating drivers: %s", error) end) end +--#endif diff --git a/drivers/esphome/driver.xml b/drivers/esphome/driver.xml index cdb87de..ccef36d 100644 --- a/drivers/esphome/driver.xml +++ b/drivers/esphome/driver.xml @@ -9,7 +9,7 @@ lua_gen ip DriverWorks - Copyright 2025 Finite Labs, LLC. All rights reserved. + Copyright 2026 Finite Labs, LLC. All rights reserved. 06/06/2025 12:00:00 PM true @@ -43,6 +43,15 @@ On + + Automatic Device Updates + LIST + + Off + On + + Off + Update Channel @@ -96,6 +105,16 @@ Print and Log + + Device Log Forwarding + LIST + Off + + Off + On + + Forward ESPHome device logs to the driver's Lua output at the current Log Level. Changing Log Level or disabling Log Mode will reconnect to apply the new settings. + Device Settings LABEL @@ -135,6 +154,65 @@ true + + Use OpenSSL + LIST + Yes + + Yes + No + + + + Bluetooth Proxy Settings + LABEL + Bluetooth Proxy Settings + + + Bluetooth Proxy Status + STRING + + true + Shows scanner state and active BLE connection slots in use. You may select more devices than available slots if they don't require continuous connections - brief-connection devices free up their slot after exchanging data. Passive devices (like BTHome sensors) don't use slots at all. + + + Bluetooth Proxy Capabilities + STRING + + true + Capabilities supported by this ESPHome Bluetooth proxy. + + + Select Bluetooth Devices + DYNAMIC_LIST + Select "Refresh List" to scan. Wake sleepy devices (e.g., buttons and sensors) by interacting with them during the scan. + + + Bluetooth Scan Duration + RANGED_INTEGER + 5 + 60 + 30 + Seconds to scan for BLE devices when refreshing the device list + + + Bluetooth Proxy Room + DEVICE_SELECTOR + + roomdevice.c4i + + false + + Room where this Bluetooth proxy is located (for presence tracking with Bluetooth Coordinator) + + + Minimum Room RSSI Override (dBm) + RANGED_INTEGER + -100 + -40 + -100 + Override the coordinator's global Minimum Room RSSI for this proxy's room. -100 means use coordinator default. + Device Info LABEL @@ -175,12 +253,16 @@ Update Drivers - UpdateDrivers + Update_Drivers - Reset Connections and Variables - ResetConnectionsAndVariables + Update Device + Update_Device + + + Reset Driver + Reset_Driver Are You Sure? @@ -193,5 +275,41 @@ + + + Press Button + Press NAME button PARAM1 + + + Button + DYNAMIC_LIST + + + + + Set Select + Set NAME select PARAM1 to PARAM2 + + + Select + DYNAMIC_LIST + + + Option + DYNAMIC_LIST + + + + + Update Device + Update device firmware on NAME PARAM1 + + + Update + DYNAMIC_LIST + + + + diff --git a/drivers/esphome/squishy b/drivers/esphome/squishy deleted file mode 100644 index 2e127e4..0000000 --- a/drivers/esphome/squishy +++ /dev/null @@ -1,53 +0,0 @@ -Main "driver.lua" - -#ifdef DRIVERCENTRAL -Module "vendor.cloud-client-byte" "../../../../src/vendor/cloud-client-byte.lua" -#endif -Module "vendor.deferred" "../../../../src/vendor/deferred.lua" -Module "vendor.drivers-common-public.global.handlers" "../../../../src/vendor/drivers-common-public/global/handlers.lua" -Module "vendor.drivers-common-public.global.lib" "../../../../src/vendor/drivers-common-public/global/lib.lua" -Module "vendor.drivers-common-public.global.timer" "../../../../src/vendor/drivers-common-public/global/timer.lua" -Module "vendor.drivers-common-public.global.url" "../../../../src/vendor/drivers-common-public/global/url.lua" -Module "vendor.JSON" "../../../../src/vendor/JSON.lua" -Module "vendor.noiseprotocol" "../../../../src/vendor/noiseprotocol.lua" -Module "vendor.version" "../../../../src/vendor/version.lua" -Module "vendor.xml.xml2lua" "../../../../src/vendor/xml/xml2lua.lua" -Module "vendor.xml.xmlhandler.dom" "../../../../src/vendor/xml/xmlhandler/dom.lua" -Module "vendor.xml.xmlhandler.print" "../../../../src/vendor/xml/xmlhandler/print.lua" -Module "vendor.xml.xmlhandler.tree" "../../../../src/vendor/xml/xmlhandler/tree.lua" -Module "vendor.xml.XmlParser" "../../../../src/vendor/xml/XmlParser.lua" - -Module "constants" "../../../../src/constants.lua" -Module "esphome.client" "../../../../src/esphome/client.lua" -Module "esphome.proto-schema" "../../../../src/esphome/proto-schema.lua" -Module "esphome.entities.binary_sensor" "../../../../src/esphome/entities/binary_sensor.lua" -Module "esphome.entities.button" "../../../../src/esphome/entities/button.lua" -Module "esphome.entities.cover" "../../../../src/esphome/entities/cover.lua" -Module "esphome.entities.light" "../../../../src/esphome/entities/light.lua" -Module "esphome.entities.lock" "../../../../src/esphome/entities/lock.lua" -Module "esphome.entities.number" "../../../../src/esphome/entities/number.lua" -Module "esphome.entities.sensor" "../../../../src/esphome/entities/sensor.lua" -Module "esphome.entities.switch" "../../../../src/esphome/entities/switch.lua" -Module "esphome.entities.text" "../../../../src/esphome/entities/text.lua" -Module "esphome.entities.text_sensor" "../../../../src/esphome/entities/text_sensor.lua" - -Module "lib.bindings" "../../../../src/lib/bindings.lua" -Module "lib.bit16" "../../../../src/lib/bit16.lua" -Module "lib.events" "../../../../src/lib/events.lua" -Module "lib.github-updater" "../../../../src/lib/github-updater.lua" -Module "lib.http" "../../../../src/lib/http.lua" -Module "lib.logging" "../../../../src/lib/logging.lua" -Module "lib.protobuf" "../../../../src/lib/protobuf.lua" -Module "lib.persist" "../../../../src/lib/persist.lua" -Module "lib.utils" "../../../../src/lib/utils.lua" -Module "lib.values" "../../../../src/lib/values.lua" - -#ifdef DRIVERCENTRAL -Output "../../../../dist/drivercentral/esphome.lua" -#else -Output "../../../../dist/oss/esphome.lua" -#endif -Option "minify" "true" -Option "minify_level" "none" -Option "minify_comments" "true" -Option "minify_emptylines" "true" diff --git a/drivers/esphome/www/documentation/index.md b/drivers/esphome/www/documentation/index.md index 9112cd5..b589df8 100644 --- a/drivers/esphome/www/documentation/index.md +++ b/drivers/esphome/www/documentation/index.md @@ -1,4 +1,4 @@ -[copyright]: # "Copyright 2025 Finite Labs, LLC. All rights reserved." +[copyright]: # "Copyright 2026 Finite Labs, LLC. All rights reserved." + +ESPHome Bluetooth Coordinator + +--- + +# Overview + + + +> DISCLAIMER: This software is neither affiliated with nor endorsed by either +> Control4 or ESPHome. + + + +The ESPHome Bluetooth Coordinator aggregates multiple ESPHome Bluetooth proxies +to provide intelligent BLE device management across your home. This enables: + +- **RSSI-based routing** - Commands are automatically routed to the proxy with + the strongest signal for each device +- **Automatic failover** - Failed connections retry through alternate proxies +- **Room-level presence tracking** - Track which room BLE devices are in, + similar to ESPresense or Room Assistant + +## Architecture Overview + +The following diagram shows how the drivers work together: + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ BLE Proxy 1 │ │ BLE Proxy 2 │ │ BLE Proxy 3 │ +│ (Living Room) │ │ (Kitchen) │ │ (Bedroom) │ +└────────┬────────┘ └────────┬────────┘ └────────┬────────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ ESPHome Driver │ │ ESPHome Driver │ │ ESPHome Driver │ +│ (Instance 1) │ │ (Instance 2) │ │ (Instance 3) │ +└────────┬────────┘ └────────┬────────┘ └────────┬────────┘ + │ │ │ + └───────────────────────┼───────────────────────┘ + │ + ▼ + ┌──────────────────────────┐ + │ Bluetooth Coordinator │ + │ (RSSI routing, presence │ + │ tracking, failover) │ + └────────────┬─────────────┘ + │ + ┌───────────────────┼───────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌────────────────┐ ┌─────────────────┐ ┌────────────────┐ + │ ESPHome BTHome │ │ESPHome SwitchBot│ │ ESPHome Govee │ + │ Sub-driver │ │ Sub-driver │ │ Sub-driver │ + └────────────────┘ └─────────────────┘ └────────────────┘ +``` + +Each Bluetooth proxy connects to its own ESPHome driver instance. The Bluetooth +Coordinator aggregates all proxies and routes commands to the optimal proxy +based on signal strength. Sub-drivers handle protocol-specific communication +with BLE devices. + +# Index + +
+ +- [System Requirements](#system-requirements) +- [Features](#features) +- [Installer Setup](#installer-setup) + + - [DriverCentral Cloud Setup](#drivercentral-cloud-setup) + + - [Driver Installation](#driver-installation) + - [Coordinator Setup](#coordinator-setup) +- [Driver Properties](#driver-properties) + + - [Cloud Settings](#cloud-settings) + + - [Driver Settings](#driver-settings) + - [Coordinator Status](#coordinator-status) + - [Device Settings](#device-settings) + - [Presence Settings](#presence-settings) +- [Connections](#connections) +- [Driver Actions](#driver-actions) + - [Reset Driver](#reset-driver) +- [Presence Tracking](#presence-tracking) + - [How It Works](#how-it-works) + - [Anti-Flapping Algorithm](#anti-flapping-algorithm) + - [Unsupported Devices](#unsupported-devices) + - [Events](#events) + - [Variables](#variables) + - [Contact Sensor Bindings](#contact-sensor-bindings) +- [Best Practices](#best-practices) + - [Proxy Placement](#proxy-placement-for-presence-tracking) + - [Tuning Anti-Flapping Settings](#understanding-the-anti-flapping-settings) + - [Performance Considerations](#performance-considerations) +- [Troubleshooting](#troubleshooting) + +- [Developer Information](#developer-information) + +- [Support](#support) +- [Changelog](#changelog) + +
+ +
+ +# System Requirements + +- Control4 OS 3.3+ +- One or more ESPHome devices with `bluetooth_proxy` component enabled +- ESPHome driver installed and connected for each proxy + +# Features + +- **Multi-proxy aggregation** - Combine BLE coverage from multiple ESP32 devices +- **Intelligent routing** - Automatically select the best proxy based on signal + strength +- **Connection failover** - Retry failed operations through alternate proxies +- **Room presence detection** - Determine which room devices are in +- **Occupancy tracking** - Create automations based on room occupancy +- **Home/Away detection** - Track when devices arrive or leave + +# Installer Setup + + + +## DriverCentral Cloud Setup + +> If you already have the +> [DriverCentral Cloud driver](https://drivercentral.io/platforms/control4-drivers/utility/drivercentral-cloud-driver/) +> installed in your project you can continue to +> [Driver Installation](#driver-installation). + +This driver relies on the DriverCentral Cloud driver to manage licensing and +automatic updates. If you are new to using DriverCentral you can refer to their +[Cloud Driver](https://help.drivercentral.io/407519-Cloud-Driver) documentation +for setting it up. + + + +## Driver Installation + + + +1. Download the latest `control4-esphome.zip` from + [DriverCentral](https://drivercentral.io/platforms/control4-drivers/utility/esphome). +2. Extract and install the `esphome_bluetooth_coordinator.c4z` driver. +3. Use the "Search" tab to find "ESPHome Bluetooth Coordinator" and add it to + your project. + + + +1. Download the latest `control4-esphome.zip` from + [Github](https://github.com/finitelabs/control4-esphome/releases/latest). +2. Extract and install the `esphome_bluetooth_coordinator.c4z` driver. +3. Use the "Search" tab to find "ESPHome Bluetooth Coordinator" and add it to + your project. + + + +## Coordinator Setup + +> **Important:** Complete all ESPHome driver setup (steps 1-2) before adding the +> Bluetooth Coordinator. Each ESPHome driver must show "Connected" status before +> proceeding. + +### Step 1: Set Up ESPHome Drivers + +For each Bluetooth proxy in your home: + +1. Use the "Search" tab to find "ESPHome" and add it to your project +2. Place the driver in the room where the physical proxy is located (this sets + the default room for presence tracking) +3. Configure the driver properties: + - Set the **IP Address** of the device + - Set **Authentication Mode** and credentials if required +4. Wait for **Driver Status** to show "Connected" +5. Verify **Bluetooth Proxy Status** appears + +Repeat the above steps for each proxy. You should have one ESPHome driver +instance per physical device. + +### Step 2: Add the Bluetooth Coordinator + +1. Use the "Search" tab to find "ESPHome Bluetooth Coordinator" and add it to + your project +2. You only need **one** Coordinator instance regardless of how many proxies you + have + +### Step 3: Connect ESPHome Drivers to the Coordinator + +1. Go to the **Connections** tab in Composer Pro +2. Select the **Bluetooth Coordinator** driver +3. For each ESPHome driver: + - Find the ESPHome driver's "Bluetooth Coordinator" connection (under the + ESPHome driver) + - Bind it to the Coordinator's "Bluetooth Proxies" connection + + + +### Step 4: Configure Room Assignments (Optional) + +By default, each proxy uses the Control4 room where its ESPHome driver is placed +in the project. If you need to override this: + +1. Select the **ESPHome driver** +2. Set the **Bluetooth Proxy Room** property to a different room name + +> **Note:** The "Bluetooth Proxy Room" property only appears after the ESPHome +> driver is connected to the Coordinator. + +### Step 5: Select BLE Devices (Optional) + +1. Select the **Bluetooth Coordinator** driver +2. In the **Select Bluetooth Devices** dropdown, select "Refresh List" to scan + for devices +3. Wait for scanning to complete (the dropdown shows "-- Scanning..." during the + scan) +4. Select each BLE device you want to connect to from the dropdown +5. A connection binding is automatically created for each selected device + +### Step 6: Add and Bind Sub-Drivers (Optional) + +For each BLE device you selected: + +1. Use the "Search" tab to find the appropriate sub-driver: + - **ESPHome SwitchBot** - for SwitchBot devices + - **ESPHome BTHome** - for Shelly BLU, BTHome sensors + - **ESPHome Govee** - for Govee sensors +2. Add the sub-driver to your project +3. Go to the **Connections** tab and bind the sub-driver to the device + connection created in Step 5 + +### Step 7: Configure Presence Tracking (Optional) + +If you want room-level presence tracking: + +1. Select the **Bluetooth Coordinator** driver +2. In the **Select Presence Devices** dropdown, select devices to track +3. Adjust presence settings as needed (see + [Presence Settings](#presence-settings)) + +
+ +# Driver Properties + + + +## Cloud Settings + +#### Cloud Status (read-only) + +Displays the current DriverCentral cloud connection and license status. + +#### Automatic Updates [ Off | **_On_** ] + +When enabled, the driver will automatically update to the latest version when +available. Default is `On`. + + + +## Driver Settings + +#### Driver Status (read-only) + +Displays the current status of the coordinator. + +#### Driver Version (read-only) + +Displays the current version of the driver. + +#### Log Level [ 0 - Fatal | 1 - Error | 2 - Warning | **_3 - Info_** | 4 - Debug | 5 - Trace | 6 - Ultra ] + +Sets the logging level. Default is `3 - Info`. + +#### Log Mode [ **_Off_** | Print | Log | Print and Log ] + +Sets the logging mode. Default is `Off`. + +## Coordinator Status + +#### Connected Proxies (read-only) + +Shows the number of ESPHome Bluetooth proxies currently connected. + +#### Selected Devices (read-only) + +Shows the number of BLE devices selected for tracking via the "Select Bluetooth +Devices" property. + +## Device Settings + +#### Select Bluetooth Devices + +A dropdown list showing BLE devices discovered across all connected proxies. +Selecting a device: + +- Creates a dynamic binding for the appropriate sub-driver (BTHome, SwitchBot, + etc.) +- Enables RSSI-based routing for that device +- Tracks the device across all proxies + +#### Scan Duration (seconds) [ 5 - 60, default: **_30_** ] + +Sets the duration in seconds to scan for BLE devices when refreshing the device +list. + +#### RSSI Freshness (seconds) [ 10 - 300, default: **_60_** ] + +Sets how long RSSI readings remain valid for proxy selection. After this time, +stale readings are discarded. + +## Presence Settings + +#### Select Presence Devices + +A dropdown list for selecting devices to track for presence/location. Any BLE +device can be tracked, including: + +- Phones (if they broadcast BLE advertisements) +- Smartwatches +- Fitness trackers +- BLE beacons +- Any other device with a consistent MAC address + +#### RSSI Smoothing Factor [ 0.1 - 0.5, default: **_0.2_** ] + +Controls how quickly the RSSI tracking responds to signal changes. + +- **Lower values (0.1)** - Smoother, slower response; better for stable tracking +- **Higher values (0.5)** - Faster response; may cause more room "flapping" + +#### Room Change Hysteresis (dBm) [ 3 - 15, default: **_6_** ] + +The signal improvement (in dBm) required before changing rooms. This prevents +bouncing between rooms when a device is near a boundary. + +- **Higher values** - More stable, slower transitions +- **Lower values** - Faster transitions, may cause flapping + +#### Room Change Dwell Time (seconds) [ 2 - 30, default: **_5_** ] + +How long a new room must have the best signal before committing to the change. + +- **Higher values** - More stable, ignores brief signal spikes +- **Lower values** - Faster room changes + +#### Away Timeout (seconds) [ 30 - 600, default: **_120_** ] + +How long without any signal before marking a device as "away" from home. + +#### Minimum Room RSSI (dBm) [ -100 - -40, default: **_-100_** ] + +Sets the global minimum signal strength (in dBm) required to assign a device to +a room. Devices with weaker signals will be considered "home" but not in any +specific room. + +- **-100 (default)** - Disabled; any signal assigns a room +- **-75 (recommended)** - Medium threshold; device must be within ~6 meters of a + proxy to be assigned to that room +- **-60** - Strict threshold; device must be within ~3 meters + +**Use cases:** + +- **Sparse proxy coverage**: When you only have proxies in a few rooms, set to + `-75` so devices in unmonitored areas aren't incorrectly assigned to the + nearest (but distant) proxy +- **Large open areas**: Prevent false room assignment when a device is far from + any proxy but still detectable + +**RSSI Reference:** + +| RSSI | Signal | Typical Distance | +| ----------- | --------- | ---------------- | +| -40 to -60 | Strong | < 3 meters | +| -60 to -75 | Medium | 3-6 meters | +| -75 to -85 | Weak | 6-10 meters | +| -85 to -100 | Very Weak | 10+ meters | + +> **Note:** This only affects room assignment. Home/away status uses any signal +> regardless of strength. Individual proxies can override this value (see +> ESPHome driver "Minimum Room RSSI Override" property). + +
+ +# Connections + +#### Bluetooth Proxies (provider) + +The provider binding that all ESPHome Bluetooth proxy drivers connect to. Each +ESPHome driver instance with Bluetooth proxy capability binds to this connection +as a consumer, enabling the coordinator to aggregate signals and route commands +across all proxies. + +#### Dynamic Device Bindings (provider) + +When BLE devices are selected via "Select Bluetooth Devices", the coordinator +dynamically creates provider bindings for each device. These bindings allow +sub-drivers (BTHome, SwitchBot, Govee, etc.) to connect and communicate with BLE +devices through the coordinator's RSSI-based routing. + +#### Dynamic Contact Sensor Bindings (provider) + +The coordinator dynamically creates CONTACT_SENSOR bindings for presence +tracking integration. See [Contact Sensor Bindings](#contact-sensor-bindings) +for details. + +
+ +# Driver Actions + +#### Reset Driver + +> ⚠️ This will clear all device selections, presence tracking configuration, and +> dynamic bindings. + +Resets the coordinator to its initial state. Use this if you need to start fresh +or are experiencing issues. + +**Parameters:** + +- **Are You Sure?** [ **_No_** | Yes ] - Confirmation to reset the driver. + +
+ +# Presence Tracking + +## How It Works + +1. **Signal Collection** - Each proxy reports the RSSI (signal strength) when it + sees a tracked device's BLE advertisement +2. **Signal Smoothing** - RSSI values are smoothed using an exponential moving + average to filter noise +3. **Room Determination** - The device is considered to be in the room of the + proxy with the strongest smoothed signal +4. **Anti-Flapping** - Multiple safeguards prevent rapid room changes when + devices are near room boundaries + +## Anti-Flapping Algorithm + +The presence tracker uses a multi-layer approach to prevent false room changes: + +| Layer | Purpose | How It Works | +| -------------- | -------------------------- | --------------------------------------- | +| RSSI Smoothing | Filter signal noise | Exponential moving average on raw RSSI | +| Hysteresis | Prevent boundary flapping | New room must be significantly stronger | +| Dwell Time | Confirm sustained presence | New room must be "best" for N seconds | +| Away Timeout | Graceful departure | No signal for N seconds = away | + +**Example scenario:** Device is in Kitchen (RSSI -55), walks toward Living Room: + +1. Living Room proxy sees device at -58 → No change (not 6dB better than -55) +2. Device moves further, Living Room at -50 → Pending transition starts +3. 3 seconds later, still -50 → Still dwelling +4. 5 seconds later, still consistently better → **Transition to Living Room** + +## Unsupported Devices + +The following devices **cannot currently** be tracked for presence: + +- **Apple devices** (iPhone, iPad, Apple Watch, AirPods) - These devices use + randomized MAC addresses for privacy, making them unidentifiable via standard + BLE scanning +- **Android devices with MAC randomization enabled** - Some newer Android + devices also randomize their MAC address + +> **Note:** Apple device support via IRK (Identity Resolving Key) enrollment is +> planned for a future release. + +**Recommended alternatives for presence tracking:** + +- Dedicated BLE beacons (iBeacon, Eddystone) +- Tile or similar Bluetooth trackers +- Fitness bands/smartwatches that don't randomize MAC +- Any BLE device with a consistent, static MAC address + +## Events + +The coordinator creates dynamic events for Control4 programming. Per-device and +per-room events are created automatically when devices are tracked and rooms are +discovered. Display names include a unique suffix (MAC address for devices, room +ID for rooms) to avoid conflicts. + +### Per-Device Events + +| Event | Description | +| --------------------------- | ---------------------------------------------------- | +| [Device] [MAC] Home | Fired when device arrives home (first advertisement) | +| [Device] [MAC] Away | Fired when device leaves home (away timeout expired) | +| [Device] [MAC] Entered Room | Fired when device enters a room | +| [Device] [MAC] Left Room | Fired when device leaves a room | + +### Per-Room Events + +| Event | Description | +| ------------------------ | ---------------------------------------------- | +| [Room] [RoomID] Occupied | Fired when room goes from empty to occupied | +| [Room] [RoomID] Empty | Fired when last tracked device leaves the room | + +### Generic Events + +| Event | Description | +| ----------------------- | --------------------------------------------- | +| Any Device Entered Room | Fired when any tracked device enters any room | +| Any Device Left Room | Fired when any tracked device leaves any room | + +> **Tip:** Use the "Last Presence" variables with generic events to determine +> which device and room triggered the event. + +## Variables + +Variable names for per-device and per-room variables include a unique suffix +(MAC address for devices, room ID for rooms) to avoid conflicts. + +### Per-Device Variables + +| Variable | Type | Description | +| -------------------------------- | ------ | -------------------------------------------------------------------- | +| Presence [Device] [MAC] Room | STRING | Current room name, "Home" (below RSSI threshold), or "Away" | +| Presence [Device] [MAC] Distance | NUMBER | Estimated distance in meters | +| Presence [Device] [MAC] RSSI | NUMBER | Current signal strength in dBm (useful for tuning Minimum Room RSSI) | + +### Per-Room Variables + +| Variable | Type | Description | +| ------------------------------ | ------ | ------------------------------------ | +| [Room] [RoomID] Occupied | STRING | "true" or "false" | +| [Room] [RoomID] Occupant Count | NUMBER | Number of tracked devices in room | +| [Room] [RoomID] Occupants | STRING | Comma-separated list of device names | + +### Last Event Context Variables + +These are updated before generic events fire, allowing programming to identify +which device/room triggered the event: + +| Variable | Type | Description | +| --------------------------- | ------ | ---------------------------- | +| Last Presence Device MAC | STRING | MAC address of device | +| Last Presence Device Name | STRING | Display name of device | +| Last Presence Room | STRING | Room name | +| Last Presence Previous Room | STRING | Previous room (or "Away") | +| Last Presence Distance | NUMBER | Estimated distance in meters | + +## Contact Sensor Bindings + +The coordinator creates dynamic CONTACT_SENSOR bindings for integration with +Control4's occupancy features. Binding names include a unique suffix (MAC +address for devices, room ID for rooms) to avoid conflicts. + +### Room Occupancy Bindings + +- **[Room] [RoomID] Occupied** - CLOSED when room has occupants, OPENED when + empty + +### Device Presence Bindings + +- **[Device] [MAC] Present** - CLOSED when device is home, OPENED when away + +
+ +# Best Practices + +## Proxy Placement for Presence Tracking + +Effective presence tracking requires thoughtful proxy placement. Unlike simple +device control, presence detection relies on comparing signal strength across +multiple proxies to determine location. + +### Placement Guidelines + +| Guideline | Reason | +| ------------------------------ | ------------------------------------------------------ | +| **One proxy per tracked room** | Each room you want presence detection in needs a proxy | +| **Central placement** | Maximizes signal strength from anywhere in the room | +| **Chest height or higher** | Reduces signal blockage from furniture | +| **Away from metal objects** | Metal causes reflections and unstable RSSI | + +### What to Avoid + +- **Proxies too close together** - If two proxies are in the same room or very + close, they'll report similar RSSI values, making room detection unreliable +- **Behind large metal objects** - Refrigerators, filing cabinets, and metal + shelving block and reflect signals unpredictably +- **Inside cabinets or enclosures** - Blocks signal and reduces range +- **Near WiFi routers** - RF interference degrades BLE reception + +### Recommended Proxy Density + +| Home Size | Recommended Proxies | +| --------------------------- | ------------------- | +| Small apartment | 2-3 | +| Typical 2-story home | 4-6 | +| Large home / concrete walls | 6+ | + +> **Tip:** More proxies generally improve accuracy, but proxies placed too close +> together (same room) can actually hurt presence detection by providing +> redundant, similar readings. + +### Sparse Coverage Scenarios + +If you only have proxies in some rooms (not every room), consider using the +**Minimum Room RSSI** setting to prevent false room assignments: + +- Without this setting, a device in an unmonitored room (e.g., hallway, + bathroom) will be assigned to whichever proxy has the strongest signal, even + if that proxy is far away +- Set **Minimum Room RSSI** to `-75` so devices must be within reasonable range + of a proxy to be assigned to that room +- Devices with weak signals will show as "Home" but not in any specific room + +**Example:** You have proxies in Kitchen and Living Room, but not in the hallway +between them. Without a minimum RSSI threshold, a person standing in the hallway +might constantly flip between Kitchen and Living Room. With threshold set to +`-75`, they'd show as "Home" without a room assignment until they actually enter +a monitored room. + +## Understanding the Anti-Flapping Settings + +The presence tracking settings work together to prevent false room changes. +Here's how to tune them for your environment: + +### RSSI Smoothing Factor (0.1 - 0.5) + +Controls how quickly the system responds to signal changes. + +- **Lower values (0.1-0.2)** - Smoother, more stable; ignores brief signal + spikes; better for most homes +- **Higher values (0.3-0.5)** - Faster response; may cause flapping in + environments with signal reflections + +**When to increase:** If room changes feel sluggish or delayed. + +**When to decrease:** If presence flaps between rooms when you're stationary. + +### Room Change Hysteresis (3 - 15 dBm) + +The signal improvement required before changing rooms. For example, if +hysteresis is 6 dBm and the current room shows -60 dBm, the new room must show +at least -54 dBm before a transition is considered. + +- **Lower values (3-5)** - More sensitive; faster room transitions +- **Higher values (8-15)** - More stable; requires definitive signal difference + +**When to increase:** Flapping between adjacent rooms, especially near doorways. + +**When to decrease:** Room changes don't register even when moving +significantly. + +### Room Change Dwell Time (2 - 30 seconds) + +How long the new room must have the best signal before committing to the change. + +- **Lower values (2-5)** - Faster room detection; may cause brief incorrect + states +- **Higher values (10-30)** - Very stable; won't register quick pass-throughs + +**When to increase:** Brief signal spikes cause incorrect room changes. + +**When to decrease:** Entering a room takes too long to register. + +### Recommended Starting Points + +| Environment | Smoothing | Hysteresis | Dwell Time | +| -------------------- | --------- | ---------- | ---------- | +| Open floor plan | 0.2 | 8 dBm | 5 sec | +| Many small rooms | 0.15 | 6 dBm | 3 sec | +| Concrete/brick walls | 0.2 | 5 dBm | 4 sec | +| Flapping issues | 0.1 | 10 dBm | 8 sec | + +## Performance Considerations + +### Scaling Limits + +Adding more proxies and presence devices increases processing load: + +| Component | Recommended Limit | Impact When Exceeded | +| ------------------- | ----------------- | ----------------------------------------- | +| Proxies | 8-10 | Increased network traffic, slower updates | +| Presence devices | 10-15 | Higher CPU usage, potential delays | +| BLE devices (total) | 30-50 | Advertisement processing bottleneck | + +### Network Traffic + +Each proxy forwards BLE advertisements to the coordinator. In busy environments: + +- **Duplicate advertisements** - Multiple proxies seeing the same device each + send updates +- **High-frequency advertisers** - Some devices advertise multiple times per + second +- **Presence calculations** - Each advertisement triggers RSSI processing + +**Mitigation:** The coordinator filters advertisements to only process devices +you've explicitly selected. Unselected devices are ignored. + +
+ +# Troubleshooting + +## Presence Flapping Between Rooms + +**Symptoms:** Device rapidly switches between two or more rooms, or constantly +shows the wrong room. + +**Common Causes:** + +1. **Proxies too close together** - Two proxies reporting similar RSSI values +2. **Device near room boundary** - Signal strength is similar to multiple + proxies +3. **Signal reflections** - Metal objects causing unpredictable RSSI +4. **Smoothing too aggressive** - System responding to noise + +**Solutions:** + +1. Increase **Room Change Hysteresis** to 8-12 dBm +2. Increase **Dwell Time** to 8-10 seconds +3. Decrease **RSSI Smoothing Factor** to 0.1 +4. Relocate proxies further apart or reposition away from metal +5. Check that each room has a dedicated proxy + +## Device Shows Wrong Room + +**Symptoms:** Device consistently shows in the wrong room. + +**Common Causes:** + +1. **Proxy misconfigured** - Wrong room assignment in ESPHome driver +2. **Antenna differences** - One proxy has stronger/weaker antenna +3. **Environmental factors** - Walls, furniture affecting signal path + +**Solutions:** + +1. Verify "Bluetooth Proxy Room" is set correctly on each ESPHome driver +2. If one proxy consistently "wins," it may have a better antenna; consider + relocating other proxies closer to their rooms +3. Add a proxy to the room where the device should be detected + +## Device Shows "Away" When Home + +**Symptoms:** Device intermittently or constantly shows as away despite being +home. + +**Common Causes:** + +1. **Device not advertising** - Bluetooth disabled or device in deep sleep +2. **Out of range** - No proxy close enough to receive signal +3. **Away Timeout too short** - Gaps in advertisements trigger away state + +**Solutions:** + +1. Verify device has Bluetooth enabled and is advertising +2. Add a proxy closer to where the device usually is +3. Increase **Away Timeout** to 180-300 seconds +4. Check that the device has a static MAC address (see Unsupported Devices) + +## Slow Room Transitions + +**Symptoms:** Moving between rooms takes too long to register. + +**Solutions:** + +1. Decrease **Dwell Time** to 2-3 seconds +2. Decrease **Room Change Hysteresis** to 4-5 dBm +3. Increase **RSSI Smoothing Factor** to 0.25-0.3 + +## High CPU or Network Usage + +**Symptoms:** Control4 system slowdown, network congestion. + +**Solutions:** + +1. Reduce the number of tracked presence devices +2. Remove devices from "Select Bluetooth Devices" that don't need tracking +3. Consider using fewer proxies if you have more than 6-8 + + + +# Developer Information + +

+Finite Labs +

+ +Copyright © 2026 Finite Labs LLC + +All information contained herein is, and remains the property of Finite Labs LLC +and its suppliers, if any. The intellectual and technical concepts contained +herein are proprietary to Finite Labs LLC and its suppliers and may be covered +by U.S. and Foreign Patents, patents in process, and are protected by trade +secret or copyright law. Dissemination of this information or reproduction of +this material is strictly forbidden unless prior written permission is obtained +from Finite Labs LLC. For the latest information, please visit +https://drivercentral.io/platforms/control4-drivers/utility/esphome + + + +# Support + + + +If you have any questions or issues integrating this driver with Control4 or +ESPHome, you can contact us at +[driver-support@finitelabs.com](mailto:driver-support@finitelabs.com) or +call/text us at [+1 (949) 371-5805](tel:+19493715805). + + + +If you have any questions or issues integrating this driver with Control4, you +can file an issue on GitHub: + +https://github.com/finitelabs/control4-esphome/issues/new + +Buy Me A Coffee + + + +
+ + diff --git a/drivers/esphome_bluetooth_coordinator/www/icons/device_lg.png b/drivers/esphome_bluetooth_coordinator/www/icons/device_lg.png new file mode 100644 index 0000000..ab1aee7 Binary files /dev/null and b/drivers/esphome_bluetooth_coordinator/www/icons/device_lg.png differ diff --git a/drivers/esphome_bluetooth_coordinator/www/icons/device_sm.png b/drivers/esphome_bluetooth_coordinator/www/icons/device_sm.png new file mode 100644 index 0000000..28937f3 Binary files /dev/null and b/drivers/esphome_bluetooth_coordinator/www/icons/device_sm.png differ diff --git a/drivers/esphome_bluetooth_coordinator/www/icons/experience_100.png b/drivers/esphome_bluetooth_coordinator/www/icons/experience_100.png new file mode 100644 index 0000000..bae48c3 Binary files /dev/null and b/drivers/esphome_bluetooth_coordinator/www/icons/experience_100.png differ diff --git a/drivers/esphome_bluetooth_coordinator/www/icons/experience_1024.png b/drivers/esphome_bluetooth_coordinator/www/icons/experience_1024.png new file mode 100644 index 0000000..ae5cc29 Binary files /dev/null and b/drivers/esphome_bluetooth_coordinator/www/icons/experience_1024.png differ diff --git a/drivers/esphome_bluetooth_coordinator/www/icons/experience_110.png b/drivers/esphome_bluetooth_coordinator/www/icons/experience_110.png new file mode 100644 index 0000000..d86e97f Binary files /dev/null and b/drivers/esphome_bluetooth_coordinator/www/icons/experience_110.png differ diff --git a/drivers/esphome_bluetooth_coordinator/www/icons/experience_120.png b/drivers/esphome_bluetooth_coordinator/www/icons/experience_120.png new file mode 100644 index 0000000..c5af34e Binary files /dev/null and b/drivers/esphome_bluetooth_coordinator/www/icons/experience_120.png differ diff --git a/drivers/esphome_bluetooth_coordinator/www/icons/experience_130.png b/drivers/esphome_bluetooth_coordinator/www/icons/experience_130.png new file mode 100644 index 0000000..1ec4a3e Binary files /dev/null and b/drivers/esphome_bluetooth_coordinator/www/icons/experience_130.png differ diff --git a/drivers/esphome_bluetooth_coordinator/www/icons/experience_140.png b/drivers/esphome_bluetooth_coordinator/www/icons/experience_140.png new file mode 100644 index 0000000..0ea950c Binary files /dev/null and b/drivers/esphome_bluetooth_coordinator/www/icons/experience_140.png differ diff --git a/drivers/esphome_bluetooth_coordinator/www/icons/experience_20.png b/drivers/esphome_bluetooth_coordinator/www/icons/experience_20.png new file mode 100644 index 0000000..c2a668b Binary files /dev/null and b/drivers/esphome_bluetooth_coordinator/www/icons/experience_20.png differ diff --git a/drivers/esphome_bluetooth_coordinator/www/icons/experience_30.png b/drivers/esphome_bluetooth_coordinator/www/icons/experience_30.png new file mode 100644 index 0000000..a444b0f Binary files /dev/null and b/drivers/esphome_bluetooth_coordinator/www/icons/experience_30.png differ diff --git a/drivers/esphome_bluetooth_coordinator/www/icons/experience_300.png b/drivers/esphome_bluetooth_coordinator/www/icons/experience_300.png new file mode 100644 index 0000000..dc799f0 Binary files /dev/null and b/drivers/esphome_bluetooth_coordinator/www/icons/experience_300.png differ diff --git a/drivers/esphome_bluetooth_coordinator/www/icons/experience_40.png b/drivers/esphome_bluetooth_coordinator/www/icons/experience_40.png new file mode 100644 index 0000000..de70d6d Binary files /dev/null and b/drivers/esphome_bluetooth_coordinator/www/icons/experience_40.png differ diff --git a/drivers/esphome_bluetooth_coordinator/www/icons/experience_50.png b/drivers/esphome_bluetooth_coordinator/www/icons/experience_50.png new file mode 100644 index 0000000..59e374b Binary files /dev/null and b/drivers/esphome_bluetooth_coordinator/www/icons/experience_50.png differ diff --git a/drivers/esphome_bluetooth_coordinator/www/icons/experience_512.png b/drivers/esphome_bluetooth_coordinator/www/icons/experience_512.png new file mode 100644 index 0000000..9138b4b Binary files /dev/null and b/drivers/esphome_bluetooth_coordinator/www/icons/experience_512.png differ diff --git a/drivers/esphome_bluetooth_coordinator/www/icons/experience_60.png b/drivers/esphome_bluetooth_coordinator/www/icons/experience_60.png new file mode 100644 index 0000000..bb53857 Binary files /dev/null and b/drivers/esphome_bluetooth_coordinator/www/icons/experience_60.png differ diff --git a/drivers/esphome_bluetooth_coordinator/www/icons/experience_70.png b/drivers/esphome_bluetooth_coordinator/www/icons/experience_70.png new file mode 100644 index 0000000..8f298df Binary files /dev/null and b/drivers/esphome_bluetooth_coordinator/www/icons/experience_70.png differ diff --git a/drivers/esphome_bluetooth_coordinator/www/icons/experience_80.png b/drivers/esphome_bluetooth_coordinator/www/icons/experience_80.png new file mode 100644 index 0000000..1998bdc Binary files /dev/null and b/drivers/esphome_bluetooth_coordinator/www/icons/experience_80.png differ diff --git a/drivers/esphome_bluetooth_coordinator/www/icons/experience_90.png b/drivers/esphome_bluetooth_coordinator/www/icons/experience_90.png new file mode 100644 index 0000000..3b767d2 Binary files /dev/null and b/drivers/esphome_bluetooth_coordinator/www/icons/experience_90.png differ diff --git a/drivers/esphome_bthome/driver.c4zproj b/drivers/esphome_bthome/driver.c4zproj new file mode 100644 index 0000000..f511a61 --- /dev/null +++ b/drivers/esphome_bthome/driver.c4zproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/drivers/esphome_bthome/driver.lua b/drivers/esphome_bthome/driver.lua new file mode 100644 index 0000000..87dfa60 --- /dev/null +++ b/drivers/esphome_bthome/driver.lua @@ -0,0 +1,1044 @@ +--- ESPHome BTHome Driver +--#ifdef DRIVERCENTRAL +DC_PID = 819 +DC_X = nil +DC_FILENAME = "esphome_bthome.c4z" +--#endif +require("lib.utils") +require("drivers-common-public.global.handlers") +require("drivers-common-public.global.lib") +require("drivers-common-public.global.timer") + +JSON = require("JSON") + +local log = require("lib.logging") +local values = require("lib.values") +local events = require("lib.events") +local bindings = require("lib.bindings") +local constants = require("constants") +local BTHome = require("bthome") +local UUID = require("esphome.ble.uuid") + +-------------------------------------------------------------------------------- +-- Constants +-------------------------------------------------------------------------------- + +--- Binding IDs +local ESPHOME_BINDING = 5001 -- Inbound binding from parent ESPHome driver + +--- Bindings namespace for sensor bindings +local BINDINGS_NAMESPACE = "BTHome" + +--- Event namespace for BTHome events +local EVENT_NAMESPACE = "BTHome" + +--- @class EventDef +--- @field key string Unique key for the event +--- @field name string Human-readable event name +--- @field description string Event description for programming UI + +--- BTHome button event definitions +--- Maps BTHome event names (from vendor/bthome.lua event.BUTTON_NAMES) to C4 event definitions +--- @type table +local BUTTON_EVENT_DEFS = { + press = { key = "single_press", name = "Single Press", description = "pressed once" }, + double_press = { key = "double_press", name = "Double Press", description = "pressed twice" }, + triple_press = { key = "triple_press", name = "Triple Press", description = "pressed three times" }, + long_press = { key = "long_press", name = "Long Press", description = "held for ~2 seconds" }, + long_double_press = { key = "long_double_press", name = "Long Double Press", description = "held then pressed twice" }, + long_triple_press = { + key = "long_triple_press", + name = "Long Triple Press", + description = "held then pressed three times", + }, + hold_press = { key = "hold_press", name = "Hold Press", description = "is being held" }, +} + +--- Dimmer event definitions +--- Maps BTHome dimmer event names (from vendor/bthome.lua event.DIMMER_NAMES) to C4 event definitions +--- @type table +local DIMMER_EVENT_DEFS = { + rotate_left = { key = "dimmer_left", name = "Rotate Left", description = "rotated counter-clockwise" }, + rotate_right = { key = "dimmer_right", name = "Rotate Right", description = "rotated clockwise" }, +} + +--- @class SensorBindingConfig +--- @field bindingClass string The binding class for the sensor (e.g., "TEMPERATURE_VALUE") +--- @field scale string? Optional scale for the sensor value (e.g., "PERCENT", "CELSIUS") + +--- Sensor binding configurations +--- Maps BTHome sensor names to C4 binding classes +--- @type table +local SENSOR_BINDINGS = { + temperature = { + bindingClass = "TEMPERATURE_VALUE", + scale = "CELSIUS", + }, + humidity = { + bindingClass = "HUMIDITY_VALUE", + scale = "PERCENT", + }, +} + +--- @class ContactBindingConfig +--- @field openEvent string C4 event to send when BTHome value is true (1) +--- @field closedEvent string C4 event to send when BTHome value is false (0) + +--- Binary sensor binding configurations (create CONTACT_SENSOR bindings) +--- Maps BTHome binary sensor names to contact sensor config +--- For "normally closed" sensors (active = closed), swap the events +--- All binary sensors from vendor/bthome.lua OBJECT_IDS are included +--- @type table +local CONTACT_BINDINGS = { + -- Physical open/closed sensors: True (1) = Open, False (0) = Closed + door = { openEvent = "OPENED", closedEvent = "CLOSED" }, + window = { openEvent = "OPENED", closedEvent = "CLOSED" }, + opening = { openEvent = "OPENED", closedEvent = "CLOSED" }, + garage_door = { openEvent = "OPENED", closedEvent = "CLOSED" }, + lock_unlocked = { openEvent = "OPENED", closedEvent = "CLOSED" }, + + -- Detection sensors: True (1) = Detected (non-steady), False (0) = Clear (steady) + generic_boolean = { openEvent = "OPENED", closedEvent = "CLOSED" }, + motion = { openEvent = "OPENED", closedEvent = "CLOSED" }, + moving = { openEvent = "OPENED", closedEvent = "CLOSED" }, + occupancy = { openEvent = "OPENED", closedEvent = "CLOSED" }, + presence = { openEvent = "OPENED", closedEvent = "CLOSED" }, + vibration_detected = { openEvent = "OPENED", closedEvent = "CLOSED" }, + sound_detected = { openEvent = "OPENED", closedEvent = "CLOSED" }, + light_detected = { openEvent = "OPENED", closedEvent = "CLOSED" }, + + -- State sensors: True (1) = Active (non-steady), False (0) = Inactive (steady) + power_on = { openEvent = "OPENED", closedEvent = "CLOSED" }, + plug = { openEvent = "OPENED", closedEvent = "CLOSED" }, + running = { openEvent = "OPENED", closedEvent = "CLOSED" }, + connectivity = { openEvent = "OPENED", closedEvent = "CLOSED" }, + battery_charging = { openEvent = "OPENED", closedEvent = "CLOSED" }, + + -- Alert sensors: True (1) = Alert (non-steady), False (0) = Normal (steady) + battery_low = { openEvent = "OPENED", closedEvent = "CLOSED" }, + carbon_monoxide_detected = { openEvent = "OPENED", closedEvent = "CLOSED" }, + smoke_detected = { openEvent = "OPENED", closedEvent = "CLOSED" }, + gas_detected = { openEvent = "OPENED", closedEvent = "CLOSED" }, + moisture_detected = { openEvent = "OPENED", closedEvent = "CLOSED" }, + tamper = { openEvent = "OPENED", closedEvent = "CLOSED" }, + cold = { openEvent = "OPENED", closedEvent = "CLOSED" }, + heat = { openEvent = "OPENED", closedEvent = "CLOSED" }, + problem = { openEvent = "OPENED", closedEvent = "CLOSED" }, + + -- Safety is inverted: True (1) = Safe (steady), False (0) = Unsafe (alert) + safety = { openEvent = "CLOSED", closedEvent = "OPENED" }, +} + +--- @class ObjectVariableDef +--- @field name string User-friendly display name +--- @field type "NUMBER"|"STRING"|"BOOL" Control4 variable type +--- @field hidden boolean? If true, don't create variable or show in summary + +--- BTHome object name to variable name mapping +--- Maps BTHome object names to user-friendly Control4 variable/property names +--- Names must match the "name" field in vendor/bthome.lua OBJECT_IDS +--- @type table +local OBJECT_VARIABLE_MAP = { + -- Primary sensors + battery = { name = "Battery", type = "NUMBER" }, + temperature = { name = "Temperature C", type = "NUMBER" }, + humidity = { name = "Humidity", type = "NUMBER" }, + illuminance = { name = "Illuminance", type = "NUMBER" }, + pressure = { name = "Pressure", type = "NUMBER" }, + dewpoint = { name = "Dew Point", type = "NUMBER" }, + moisture = { name = "Moisture", type = "NUMBER" }, + + -- Binary sensors - names must match vendor/bthome.lua OBJECT_IDS + light_detected = { name = "Light Detected", type = "BOOL" }, + motion = { name = "Motion", type = "BOOL" }, + door = { name = "Door", type = "BOOL" }, + window = { name = "Window", type = "BOOL" }, + opening = { name = "Opening", type = "BOOL" }, + occupancy = { name = "Occupancy", type = "BOOL" }, + presence = { name = "Presence", type = "BOOL" }, + vibration_detected = { name = "Vibration Detected", type = "BOOL" }, + smoke_detected = { name = "Smoke Detected", type = "BOOL" }, + gas_detected = { name = "Gas Detected", type = "BOOL" }, + moisture_detected = { name = "Moisture Detected", type = "BOOL" }, + tamper = { name = "Tamper", type = "BOOL" }, + moving = { name = "Moving", type = "BOOL" }, + lock_unlocked = { name = "Lock Unlocked", type = "BOOL" }, + garage_door = { name = "Garage Door", type = "BOOL" }, + cold = { name = "Cold", type = "BOOL" }, + heat = { name = "Heat", type = "BOOL" }, + running = { name = "Running", type = "BOOL" }, + safety = { name = "Safety", type = "BOOL" }, + problem = { name = "Problem", type = "BOOL" }, + sound_detected = { name = "Sound Detected", type = "BOOL" }, + plug = { name = "Plug", type = "BOOL" }, + power_on = { name = "Power On", type = "BOOL" }, + generic_boolean = { name = "Generic Boolean", type = "BOOL" }, + battery_low = { name = "Battery Low", type = "BOOL" }, + battery_charging = { name = "Battery Charging", type = "BOOL" }, + connectivity = { name = "Connectivity", type = "BOOL" }, + carbon_monoxide_detected = { name = "Carbon Monoxide Detected", type = "BOOL" }, + + -- Events + button = { name = "Button", type = "NUMBER" }, + dimmer = { name = "Dimmer", type = "NUMBER" }, + + -- Power/energy sensors + voltage = { name = "Voltage", type = "NUMBER" }, + current = { name = "Current", type = "NUMBER" }, + power = { name = "Power", type = "NUMBER" }, + energy = { name = "Energy", type = "NUMBER" }, + + -- Air quality sensors + co2 = { name = "CO2", type = "NUMBER" }, + tvoc = { name = "TVOC", type = "NUMBER" }, + pm2_5 = { name = "PM2.5", type = "NUMBER" }, + pm10 = { name = "PM10", type = "NUMBER" }, + + -- Distance/volume sensors + distance_mm = { name = "Distance", type = "NUMBER" }, + distance_m = { name = "Distance", type = "NUMBER" }, + volume = { name = "Volume", type = "NUMBER" }, + volume_ml = { name = "Volume", type = "NUMBER" }, + volume_storage = { name = "Volume Storage", type = "NUMBER" }, + volume_flow_rate = { name = "Volume Flow Rate", type = "NUMBER" }, + water = { name = "Water", type = "NUMBER" }, + gas = { name = "Gas", type = "NUMBER" }, + + -- Motion/orientation sensors + acceleration = { name = "Acceleration", type = "NUMBER" }, + acceleration_signed = { name = "Acceleration", type = "NUMBER" }, + gyroscope = { name = "Gyroscope", type = "NUMBER" }, + speed = { name = "Speed", type = "NUMBER" }, + speed_signed = { name = "Speed", type = "NUMBER" }, + rotational_speed = { name = "Rotational Speed", type = "NUMBER" }, + direction = { name = "Direction", type = "NUMBER" }, + rotation = { name = "Rotation", type = "NUMBER" }, + + -- Misc sensors + count = { name = "Count", type = "NUMBER" }, + duration = { name = "Duration", type = "NUMBER" }, + uv_index = { name = "UV Index", type = "NUMBER" }, + mass_kg = { name = "Mass", type = "NUMBER" }, + mass_lb = { name = "Mass", type = "NUMBER" }, + conductivity = { name = "Conductivity", type = "NUMBER" }, + timestamp = { name = "Timestamp", type = "NUMBER" }, + precipitation = { name = "Precipitation", type = "NUMBER" }, + channel = { name = "Channel", type = "NUMBER", hidden = true }, + text = { name = "Text", type = "STRING" }, + raw = { name = "Raw", type = "STRING", hidden = true }, + + -- Device metadata + device_type_id = { name = "Device Type ID", type = "NUMBER" }, + firmware_version = { name = "Firmware Version", type = "STRING" }, + + -- Hidden internal fields + packet_id = { name = "Packet ID", type = "NUMBER", hidden = true }, +} + +--- Optional properties that should be hidden unless we have data +--- These are generated dynamically from sensor readings +local OPTIONAL_PROPERTIES = { + -- Device Info + "Name", + "Device Type", + "Device Type ID", + "Firmware Version", + + -- Primary sensors + "Battery", + "Temperature C", + "Temperature F", + "Humidity", + "Illuminance", + "Pressure", + "Dew Point", + "Moisture", + + -- Binary sensors (names must match OBJECT_VARIABLE_MAP[].name) + "Light Detected", + "Motion", + "Door", + "Window", + "Opening", + "Occupancy", + "Presence", + "Vibration Detected", + "Smoke Detected", + "Gas Detected", + "Moisture Detected", + "Tamper", + "Moving", + "Lock Unlocked", + "Garage Door", + "Cold", + "Heat", + "Running", + "Safety", + "Problem", + "Sound Detected", + "Plug", + "Power On", + "Generic Boolean", + "Battery Low", + "Battery Charging", + "Connectivity", + "Carbon Monoxide Detected", + + -- Power/energy + "Voltage", + "Current", + "Power", + "Energy", + + -- Air quality + "CO2", + "TVOC", + "PM2.5", + "PM10", + + -- Distance/volume + "Distance", + "Volume", + "Volume Storage", + "Volume Flow Rate", + "Water", + "Gas", + + -- Motion/orientation + "Acceleration", + "Gyroscope", + "Speed", + "Rotational Speed", + "Direction", + "Rotation", + + -- Misc + "Count", + "Duration", + "UV Index", + "Mass", + "Conductivity", + "Timestamp", + "Precipitation", + "Text", + "RSSI", +} + +-------------------------------------------------------------------------------- +-- Global State +-------------------------------------------------------------------------------- + +--- Track known objects to detect device capability changes +local knownObjects = {} + +--- Cached bind key bytes (16 bytes) for encrypted BTHome devices +--- @type string|nil +local cachedBindKey = nil + +--- Cached MAC address bytes (6 bytes) for encrypted BTHome devices +--- @type string|nil +local cachedMacBytes = nil + +-------------------------------------------------------------------------------- +-- Property Management +-------------------------------------------------------------------------------- + +--- Hide all optional properties +local function hideOptionalProperties() + log:trace("hideOptionalProperties()") + for _, propName in ipairs(OPTIONAL_PROPERTIES) do + C4:SetPropertyAttribs(propName, constants.HIDE_PROPERTY) + end +end + +-------------------------------------------------------------------------------- +-- Value Helpers +-------------------------------------------------------------------------------- + +--- Update the "Last Seen" timestamp +local function updateLastSeen() + values:update("Last Seen", tostring(os.date("%Y-%m-%d %H:%M:%S"))) +end + +--- Update RSSI value +local function updateRSSI(rssi) + local rssiNum = tonumber(rssi) or -999 + if rssiNum > -999 then + values:update("RSSI", rssiNum, nil, nil, " dBm") + end +end + +--- Get display name for an entity. +--- @param reading BTHomeReading The BTHome reading with name and index fields +--- @return string displayName Human-readable name +local function getEntityDisplayName(reading) + local objectDef = BTHome.const.get_object(reading.id) + assert(objectDef, "Unknown BTHome object ID: " .. tostring(reading.id)) + + local displayName = objectDef.display_name + if type(reading.instance) == "number" and reading.instance > 1 then + displayName = displayName .. " (" .. reading.instance .. ")" + end + return displayName +end + +-------------------------------------------------------------------------------- +-- Dynamic Event Creation +-------------------------------------------------------------------------------- + +--- Get or create a dynamic event for a button/dimmer event. +--- @param reading BTHomeReading The BTHome reading with name and index fields +--- @return Event|nil event The event object or nil if creation failed +local function getOrCreateEntityEvent(reading) + if reading.name ~= "button" and reading.name ~= "dimmer" then + log:warn("Cannot create event for non-button/dimmer entity: %s", reading.name) + return nil + end + --- @type string|nil + local eventName = Select(reading.event, "event_name") + if not eventName then + log:warn("No event name in reading event for entity: %s", reading.name) + return nil + end + if eventName == "none" then + return nil + end + --- @type EventDef|nil + local eventDef + if reading.name == "button" then + eventDef = BUTTON_EVENT_DEFS[eventName] + else + eventDef = DIMMER_EVENT_DEFS[eventName] + end + if not eventDef then + log:warn("No event definition for %s event: %s", reading.name, eventName) + return nil + end + + local displayName = getEntityDisplayName(reading) + + -- Create unique event key that includes entity index + local eventKey = reading.name .. "_" .. reading.instance .. eventDef.key + local eventDisplayName = displayName .. " " .. eventDef.name + local eventDescription = displayName .. " " .. eventDef.description + return events:getOrAddEvent(EVENT_NAMESPACE, eventKey, eventDisplayName, eventDescription) +end + +--- Fire a dynamic event for an entity. +--- @param reading BTHomeReading The BTHome reading with name and index fields +local function fireEntityEvent(reading) + local event = getOrCreateEntityEvent(reading) + if not event then + return + end + if type(event.eventId) ~= "number" then + log:warn("Cannot fire event - no ID for event: %s", event.name) + return + end + C4:FireEventByID(event.eventId) +end + +-------------------------------------------------------------------------------- +-- Dynamic Binding Creation (Sensor) +-------------------------------------------------------------------------------- + +--- Get or create a sensor binding. +--- @param reading BTHomeReading The BTHome reading with name and index fields +--- @param sensorConfig SensorBindingConfig Sensor configuration with bindingClass, scale +--- @return Binding|nil binding The binding or nil if creation failed +local function getOrCreateSensorBinding(reading, sensorConfig) + local bindingKey = reading.name .. "_" .. reading.instance + local displayName = getEntityDisplayName(reading) + local binding = bindings:getOrAddDynamicBinding( + BINDINGS_NAMESPACE, + bindingKey, + "CONTROL", + true, -- provider + displayName, + sensorConfig.bindingClass + ) + + if binding then + log:info("Created %s binding for '%s' (id=%s)", sensorConfig.bindingClass, displayName, binding.bindingId) + + -- Register RFP handler for value requests + RFP[binding.bindingId] = function(idBinding, strCommand, _tParams, _args) + log:trace("RFP[%s](%s, %s, %s, %s)", binding.bindingId, idBinding, strCommand, _tParams, _args) + if strCommand == "GET_VALUE" then + -- Send cached value + local cachedValue = values:getValue(displayName) + if cachedValue and cachedValue.value then + local params = { + VALUE = cachedValue.value, + SCALE = sensorConfig.scale, + } + SendToProxy(idBinding, "VALUE_CHANGED", params) + end + end + end + + -- Register OBC handler for binding changes + OBC[binding.bindingId] = function(idBinding, _strClass, bIsBound, _otherDeviceId, _otherBindingId) + log:trace( + "OBC[%s](%s, %s, %s, %s, %s)", + binding.bindingId, + idBinding, + _strClass, + bIsBound, + _otherDeviceId, + _otherBindingId + ) + if bIsBound then + -- Send current value when bound + local cachedValue = values:getValue(displayName) + if cachedValue and cachedValue.value then + local params = { + VALUE = cachedValue.value, + SCALE = sensorConfig.scale, + } + SendToProxy(idBinding, "VALUE_CHANGED", params) + end + end + end + end + + return binding +end + +--- Send sensor value to bound consumers. +--- @param reading BTHomeReading The BTHome reading with value field +--- @param sensorConfig SensorBindingConfig Sensor configuration with bindingClass, scale +local function sendSensorValue(reading, sensorConfig) + local binding = getOrCreateSensorBinding(reading, sensorConfig) + if not binding then + return + end + + log:debug("Sending %s value %s to binding %s", reading.name, reading.value, binding.bindingId) + SendToProxy(binding.bindingId, "VALUE_CHANGED", { + VALUE = reading.value, + SCALE = sensorConfig.scale, + }) +end + +--- Get or create a contact sensor binding. +--- @param reading BTHomeReading The BTHome reading with name and index fields +--- @return Binding|nil binding The binding or nil if creation failed +local function getOrCreateContactBinding(reading) + local bindingKey = "contact_" .. reading.name .. "_" .. reading.instance + local displayName = getEntityDisplayName(reading) + local binding = bindings:getOrAddDynamicBinding( + BINDINGS_NAMESPACE, + bindingKey, + "PROXY", + true, -- provider + displayName, + "CONTACT_SENSOR" + ) + + if binding then + log:info("Created CONTACT_SENSOR binding for '%s' (id=%s)", displayName, binding.bindingId) + end + + return binding +end + +--- Send contact sensor state to bound consumers. +--- @param reading BTHomeReading The BTHome reading with value field +--- @param contactConfig ContactBindingConfig Contact binding configuration +local function sendContactState(reading, contactConfig) + local binding = getOrCreateContactBinding(reading) + if not binding then + return + end + + local event = toboolean(reading.value) and contactConfig.openEvent or contactConfig.closedEvent + log:debug("Sending %s to contact binding %s", event, binding.bindingId) + SendToProxy(binding.bindingId, event, {}, "NOTIFY") +end + +--- Get or create a button link binding for a specific event type. +--- Each event type (single, double, long, etc.) gets its own BUTTON_LINK binding. +--- @param reading BTHomeReading The BTHome reading with name and index fields +--- @return Binding|nil binding The binding or nil if creation failed +local function getOrCreateButtonBinding(reading) + if reading.name ~= "button" then + log:warn("Cannot create button link binding for non-button entity: %s", reading.name) + return nil + end + --- @type string|nil + local eventName = Select(reading.event, "event_name") + if not eventName then + log:warn("No event name in reading event for entity: %s", reading.name) + return nil + end + if eventName == "none" then + return nil + end + --- @type EventDef|nil + local eventDef = BUTTON_EVENT_DEFS[eventName] + if not eventDef then + log:warn("No event definition for button event: %s", eventName) + return nil + end + + local bindingKey = "button_" .. reading.name .. "_" .. reading.instance .. ":" .. eventName + local displayName = getEntityDisplayName(reading) .. " " .. eventDef.name + local binding = bindings:getOrAddDynamicBinding( + BINDINGS_NAMESPACE, + bindingKey, + "CONTROL", + false, -- consumer (initiates connection to provider, sends events) + displayName, + "BUTTON_LINK" + ) + + if binding then + log:info("Created BUTTON_LINK binding for '%s' (id=%s)", displayName, binding.bindingId) + end + + return binding +end + +--- Send button event to bound consumers. +--- Sends DO_PUSH followed by DO_CLICK to the event-specific binding. +--- @param reading BTHomeReading The BTHome reading with name and index fields +local function sendButtonEvent(reading) + -- Get or create the binding for this specific event type + local binding = getOrCreateButtonBinding(reading) + if not binding then + return + end + + log:debug("Sending DO_CLICK and DO_PUSH/DO_RELEASE from binding %s", binding.bindingId) + SendToProxy(binding.bindingId, "DO_CLICK", {}, "NOTIFY") + SendToProxy(binding.bindingId, "DO_PUSH", {}, "NOTIFY") + SendToProxy(binding.bindingId, "DO_RELEASE", {}, "NOTIFY") +end + +-------------------------------------------------------------------------------- +-- Data Processing +-------------------------------------------------------------------------------- + +--- Process a BTHome object and update the corresponding variable/property +--- @param reading BTHomeReading The BTHome object with value, unit, event fields +--- @param summaryParts string[] Table to append summary parts to +local function processBTHomeReading(reading, summaryParts) + local displayName = getEntityDisplayName(reading) + + -- Handle button events - create and fire dynamic events for each button + if reading.name == "button" and reading.event then + fireEntityEvent(reading) + sendButtonEvent(reading) + local eventName = reading.event.event_name or "" + local eventDef = BUTTON_EVENT_DEFS[eventName] + if eventDef then + table.insert(summaryParts, displayName .. " " .. eventDef.name) + end + return + end + + -- Handle dimmer events - create and fire dynamic events for each dimmer + if reading.name == "dimmer" and reading.event then + fireEntityEvent(reading) + local eventName = reading.event.event_name or "" + local steps = reading.event.steps or 0 + local eventDef = DIMMER_EVENT_DEFS[eventName] + if eventDef then + table.insert(summaryParts, displayName .. " " .. eventDef.name .. " (" .. steps .. " steps)") + end + return + end + + -- Look up variable definition by base name + local varDef = OBJECT_VARIABLE_MAP[reading.name] + if not varDef then + log:warn("Unknown BTHome object: %s (ignoring)", reading.name) + return + end + + -- Skip hidden objects + if varDef.hidden then + return + end + + -- Track new objects + if not knownObjects[reading.name] then + knownObjects[reading.name] = true + log:info("Discovered BTHome object: %s", displayName) + + -- Create sensor binding if applicable + local sensorConfig = SENSOR_BINDINGS[reading.name] + if sensorConfig then + getOrCreateSensorBinding(reading, sensorConfig) + end + + -- Create contact sensor binding if applicable + local contactConfig = CONTACT_BINDINGS[reading.name] + if contactConfig then + getOrCreateContactBinding(reading) + end + end + + -- Format the value + local value = reading.value + local displayValue = value + if type(value) == "number" then + -- Round to 2 decimal places for display + displayValue = round(value, 2) + end + + -- Build variable name with index if needed + local varName = varDef.name + if type(reading.instance) == "number" and reading.instance > 1 then + varName = varDef.name .. " (" .. reading.instance .. ")" + end + + -- Update the variable (and property if applicable via suffix) + local changed = values:update(varName, displayValue, varDef.type, nil, reading.unit and (" " .. reading.unit) or nil) + + -- Add to summary + local unit = reading.unit and (" " .. reading.unit) or "" + table.insert(summaryParts, displayName .. ": " .. tostring(displayValue) .. unit) + + -- FIXME: Hack + if varName == "Temperature C" and type(value) == "number" then + values:update("Temperature F", c2f(value), varDef.type, nil, " °F") + end + + -- Only send to bindings if value changed + if not changed then + return + end + + -- Send to sensor binding if applicable + local sensorConfig = SENSOR_BINDINGS[reading.name] + if sensorConfig and type(value) == "number" then + sendSensorValue(reading, sensorConfig) + end + + -- Send to contact binding if applicable + local contactConfig = CONTACT_BINDINGS[reading.name] + if contactConfig then + sendContactState(reading, contactConfig) + end +end + +--- Process incoming BTHome data from the parent driver +--- @param readings BTHomeReading[] Array of BTHome readings from bthome +--- @param rssi string|nil RSSI value as string +local function processBTHomeReadings(readings, rssi) + log:trace("processBTHomeReadings()") + + -- Update timestamps + updateLastSeen() + + -- Update RSSI + if rssi then + updateRSSI(rssi) + end + + -- Process each reading (summary built inline) + local summaryParts = {} + for _, reading in ipairs(readings) do + processBTHomeReading(reading, summaryParts) + end + + -- Update "Last Received" property + UpdateProperty("Last Received", #summaryParts > 0 and table.concat(summaryParts, ", ") or "No data") +end + +-------------------------------------------------------------------------------- +-- Initialization +-------------------------------------------------------------------------------- + +function OnDriverInit() + --#ifdef DRIVERCENTRAL + require("cloud-client-byte") + C4:AllowExecute(false) + --#else + C4:AllowExecute(true) + --#endif + gInitialized = false + log:setLogName(C4:GetDeviceData(C4:GetDeviceID(), "name")) + log:setLogLevel(Properties["Log Level"]) + log:setLogMode(Properties["Log Mode"]) + log:trace("OnDriverInit()") + + -- Restore all persisted values, events, and bindings + values:restoreValues() + events:restoreEvents() + bindings:restoreBindings() +end + +function OnDriverLateInit() + log:trace("OnDriverLateInit()") + if not CheckMinimumVersion("Driver Status") then + return + end + + -- Hide all optional properties initially + hideOptionalProperties() + + -- Fire OnPropertyChanged to set the initial Headers and other Property + -- global sets, they'll change if Property is changed. + for p, _ in pairs(Properties) do + local status, err = pcall(OnPropertyChanged, p) + if not status and err then + log:error("Error in OnPropertyChanged for property '%s': %s", p, err or "unknown error") + end + end + + gInitialized = true + UpdateProperty("Driver Status", "Waiting for data") + + -- Request refresh from parent driver + SendToProxy(ESPHOME_BINDING, "REFRESH_STATE", {}, "NOTIFY") +end + +-------------------------------------------------------------------------------- +-- OPC Handlers +-------------------------------------------------------------------------------- + +function OPC.Driver_Status(propertyValue) + log:trace("OPC.Driver_Status('%s')", propertyValue) + if not gInitialized then + UpdateProperty("Driver Status", "Initializing", false) + return + end +end + +function OPC.Driver_Version(propertyValue) + log:trace("OPC.Driver_Version('%s')", propertyValue) + C4:UpdateProperty("Driver Version", C4:GetDriverConfigInfo("version")) +end + +function OPC.Log_Mode(propertyValue) + log:trace("OPC.Log_Mode('%s')", propertyValue) + log:setLogMode(propertyValue) + CancelTimer("LogMode") + if not log:isEnabled() then + UpdateProperty("Log Level", "3 - Info", true) + return + end + log:warn("Log mode '%s' will expire in 3 hours", propertyValue) + SetTimer("LogMode", 3 * ONE_HOUR, function() + log:warn("Setting log mode to 'Off' (timer expired)") + UpdateProperty("Log Mode", "Off", true) + end) + OnPropertyChanged("Log Level") +end + +function OPC.Log_Level(propertyValue) + log:trace("OPC.Log_Level('%s')", propertyValue) + log:setLogLevel(propertyValue) + if log:getLogLevel() >= 6 and log:isPrintEnabled() then + DEBUGPRINT = true + DEBUG_TIMER = true + DEBUG_RFN = true + DEBUG_URL = true + DEBUG_WEBSOCKET = true + else + DEBUGPRINT = false + DEBUG_TIMER = false + DEBUG_RFN = false + DEBUG_URL = false + DEBUG_WEBSOCKET = false + end +end + +function OPC.Bind_Key(propertyValue) + log:trace("OPC.Bind_Key('%s')", propertyValue and string.rep("*", #propertyValue) or "nil") + if not propertyValue or propertyValue == "" then + cachedBindKey = nil + return + end + + -- Ignore error messages (they get cleared by delay) + if propertyValue:match("^Error:") then + return + end + + -- Validate hex string (32 chars = 16 bytes) + if #propertyValue ~= 32 or not propertyValue:match("^[0-9A-Fa-f]+$") then + log:warn("Bind key must be 32 hex characters (16 bytes)") + cachedBindKey = nil + -- Show error in property field, then clear after delay + UpdateProperty("Bind Key", "Error: Must be 32 hex chars") + delay(2 * ONE_SECOND):next(function() + UpdateProperty("Bind Key", "") + end) + return + end + + -- Convert hex to bytes + local bytes = {} + for i = 1, 32, 2 do + bytes[#bytes + 1] = string.char(tonumber(propertyValue:sub(i, i + 1), 16) or 0) + end + cachedBindKey = table.concat(bytes) + log:info("Bind key configured (%d bytes)", #cachedBindKey) + + UpdateProperty("Driver Status", "Waiting for data") +end + +-------------------------------------------------------------------------------- +-- RFP Handlers +-------------------------------------------------------------------------------- + +--- Handle passive connect notification from parent driver +--- BTHome devices use advertisement-based data, no GATT connection +function RFP.CONNECTED_PASSIVE(idBinding, strCommand, tParams, args) + log:trace("RFP.CONNECTED_PASSIVE(%s, %s, %s, %s)", idBinding, strCommand, tParams, args) + if idBinding ~= ESPHOME_BINDING then + return + end + + local name = Select(tParams, "name") + local mac = Select(tParams, "mac") or "Unknown" + local deviceType = Select(tParams, "deviceType") or "Unknown" + + log:debug("BTHome device in passive mode: %s (%s)", mac, deviceType) + + -- Update device info properties + if not IsEmpty(name) then + values:update("Name", name, "STRING") + end + values:update("Device Type", deviceType, "STRING") + values:update("MAC Address", mac, "STRING") + + -- Cache MAC bytes for encrypted BTHome decryption + if mac and mac ~= "Unknown" then + local bytes = {} + for octet in mac:gmatch("[0-9A-Fa-f]+") do + bytes[#bytes + 1] = string.char(tonumber(octet, 16) or 0) + end + if #bytes == 6 then + cachedMacBytes = table.concat(bytes) + end + end + + UpdateProperty("Driver Status", "Listening") +end + +--- Handle incoming BLE advertisement from parent driver +function RFP.BLE_ADVERTISEMENT(idBinding, strCommand, tParams, args) + log:trace("RFP.BLE_ADVERTISEMENT(%s, %s, %s, %s)", idBinding, strCommand, tParams, args) + + -- Call the passive connection to make sure mac and device type are set + RFP.CONNECTED_PASSIVE(idBinding, strCommand, tParams, args) + + if idBinding ~= ESPHOME_BINDING then + return + end + + -- Deserialize the BLEAdvertisement + local advStr = Select(tParams, "advertisement") + if not advStr or advStr == "" then + return + end + + local advertisement = DeserializeSafe(advStr) + if not advertisement then + return + end + --- @cast advertisement BLEAdvertisement + + -- Extract BTHome service data + local serviceData, uuid = + UUID.findData(advertisement.serviceData, BTHome.UUID_V2, BTHome.UUID_V1_UNENCRYPTED, BTHome.UUID_V1_ENCRYPTED) + if not serviceData or not uuid then + return + end + + -- Parse BTHome data (pass cached bind key and MAC for encrypted devices) + local result, err = BTHome.parse(uuid, serviceData, cachedBindKey, cachedMacBytes) + if not result then + UpdateProperty("Driver Status", "Error: " .. (err or "unknown")) + return + end + + -- Device type + --local deviceType = Select(tParams, "deviceType") + local version = tointeger(Select(result.device_info, "version")) + if version ~= nil then + local deviceType = "BTHome V" .. version + if toboolean(Select(result.device_info, "encrypted")) then + deviceType = deviceType .. " (Encrypted)" + end + values:update("Device Type", deviceType, "STRING") + end + + -- Update status + UpdateProperty("Driver Status", "Listening") + + -- Process the data + processBTHomeReadings(result.readings, advertisement.rssi) +end + +--- Handle disconnection notification from main driver +function RFP.DISCONNECTED(idBinding, strCommand, tParams, args) + log:trace("RFP.DISCONNECTED(%s, %s, %s, %s)", idBinding, strCommand, tParams, args) + if idBinding ~= ESPHOME_BINDING then + return + end + + local reason = Select(tParams, "reason") or "unknown" + log:info("BTHome device disconnected: %s", reason) + UpdateProperty("Driver Status", "Waiting for data") +end + +-------------------------------------------------------------------------------- +-- OBC Handlers +-------------------------------------------------------------------------------- + +--- Handle binding changes +OBC[ESPHOME_BINDING] = function(idBinding, strClass, bIsBound, otherDeviceId) + log:trace("OBC[%s](%s, %s, %s, %s)", ESPHOME_BINDING, idBinding, strClass, bIsBound, otherDeviceId) + -- Reset state when binding changes + knownObjects = {} + + if bIsBound then + UpdateProperty("Driver Status", "Waiting for data") + else + UpdateProperty("Driver Status", "Disconnected") + end +end + +-------------------------------------------------------------------------------- +-- EC Handlers +-------------------------------------------------------------------------------- + +--- Reset driver to initial state +function EC.Reset_Driver(params) + log:trace("EC.Reset_Driver(%s)", params) + if Select(params, "Are You Sure?") ~= "Yes" then + return + end + log:print("Resetting driver to initial state") + + -- Reset all dynamic bindings using library method + bindings:reset() + + -- Reset all values/variables using library method + values:reset() + + -- Reset all dynamic events using library method + events:reset() + + -- Reset local state + knownObjects = {} + cachedMacBytes = nil + + -- Reset properties to defaults (excludes user-entered credentials) + local resetValues = GetPropertyResetValues({ "Bind Key" }) + for propName, defaultValue in pairs(resetValues) do + UpdateProperty(propName, defaultValue, true) + end + + -- Hide optional properties + hideOptionalProperties() + + -- Request refresh from parent driver + SendToProxy(ESPHOME_BINDING, "REFRESH_STATE", {}, "NOTIFY") +end diff --git a/drivers/esphome_bthome/driver.xml b/drivers/esphome_bthome/driver.xml new file mode 100644 index 0000000..d81188a --- /dev/null +++ b/drivers/esphome_bthome/driver.xml @@ -0,0 +1,590 @@ + + ESPHome BTHome + + Finite Labs + ESPHome BTHome Sensor + Derek Miller + icons/device_sm.png + icons/device_lg.png + lua_gen + DriverWorks + Copyright 2026 Finite Labs, LLC. All rights reserved. + 01/12/2026 12:00:00 PM + + true + 3.3.0 + + Sensors + + + + +