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 @@
-------------------------------------------------------------------------
+---
# 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.

-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 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:
@@ -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:
@@ -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."
+
+
+
+---
+
+# 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
+
+
+
+
+
+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
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+
+
+ Cloud Settings
+ LABEL
+ Cloud Settings
+
+
+ Cloud Status
+
+ STRING
+ true
+
+
+ Automatic Updates
+ LIST
+
+ - Off
+ - On
+
+ On
+
+
+
+ Driver Settings
+ LABEL
+ Driver Settings
+
+
+ Driver Status
+ STRING
+
+ true
+
+
+ Driver Version
+ STRING
+
+ true
+
+
+ Log Level
+ LIST
+ 3 - Info
+
+ - 0 - Fatal
+ - 1 - Error
+ - 2 - Warning
+ - 3 - Info
+ - 4 - Debug
+ - 5 - Trace
+ - 6 - Ultra
+
+
+
+ Log Mode
+ LIST
+ Off
+
+ - Off
+ - Print
+ - Log
+ - Print and Log
+
+
+
+
+ Device Settings
+ LABEL
+ Device Settings
+
+
+ Bind Key
+ STRING
+
+ false
+ Optional: 32-character hex key for encrypted BTHome devices
+
+
+ Device Info
+ LABEL
+ Device Info
+
+
+ Name
+ STRING
+
+ true
+
+
+ Device Type
+ STRING
+
+ true
+
+
+ Device Type ID
+ STRING
+
+ true
+
+
+ Firmware Version
+ STRING
+
+ true
+
+
+ MAC Address
+ STRING
+ Unknown
+ true
+
+
+ Last Seen
+ STRING
+ Never
+ true
+
+
+ Last Received
+ STRING
+
+ true
+
+
+
+ Device Data
+ LABEL
+ Device Data
+
+
+
+ Battery
+ STRING
+
+ true
+
+
+ Temperature C
+ STRING
+
+ true
+
+
+ Temperature F
+ STRING
+
+ true
+
+
+ Humidity
+ STRING
+
+ true
+
+
+ Illuminance
+ STRING
+
+ true
+
+
+ Pressure
+ STRING
+
+ true
+
+
+ Dew Point
+ STRING
+
+ true
+
+
+ Moisture
+ STRING
+
+ true
+
+
+
+ Light Detected
+ STRING
+
+ true
+
+
+ Motion
+ STRING
+
+ true
+
+
+ Door
+ STRING
+
+ true
+
+
+ Window
+ STRING
+
+ true
+
+
+ Opening
+ STRING
+
+ true
+
+
+ Occupancy
+ STRING
+
+ true
+
+
+ Presence
+ STRING
+
+ true
+
+
+ Vibration Detected
+ STRING
+
+ true
+
+
+ Smoke Detected
+ STRING
+
+ true
+
+
+ Gas Detected
+ STRING
+
+ true
+
+
+ Carbon Monoxide Detected
+ STRING
+
+ true
+
+
+ Moisture Detected
+ STRING
+
+ true
+
+
+ Tamper
+ STRING
+
+ true
+
+
+ Moving
+ STRING
+
+ true
+
+
+ Lock Unlocked
+ STRING
+
+ true
+
+
+ Garage Door
+ STRING
+
+ true
+
+
+ Cold
+ STRING
+
+ true
+
+
+ Heat
+ STRING
+
+ true
+
+
+ Running
+ STRING
+
+ true
+
+
+ Safety
+ STRING
+
+ true
+
+
+ Problem
+ STRING
+
+ true
+
+
+ Sound Detected
+ STRING
+
+ true
+
+
+ Plug
+ STRING
+
+ true
+
+
+ Power On
+ STRING
+
+ true
+
+
+ Generic Boolean
+ STRING
+
+ true
+
+
+ Battery Low
+ STRING
+
+ true
+
+
+ Battery Charging
+ STRING
+
+ true
+
+
+ Connectivity
+ STRING
+
+ true
+
+
+
+ Voltage
+ STRING
+
+ true
+
+
+ Current
+ STRING
+
+ true
+
+
+ Power
+ STRING
+
+ true
+
+
+ Energy
+ STRING
+
+ true
+
+
+
+ CO2
+ STRING
+
+ true
+
+
+ TVOC
+ STRING
+
+ true
+
+
+ PM2.5
+ STRING
+
+ true
+
+
+ PM10
+ STRING
+
+ true
+
+
+
+ Distance
+ STRING
+
+ true
+
+
+ Volume
+ STRING
+
+ true
+
+
+ Volume Storage
+ STRING
+
+ true
+
+
+ Volume Flow Rate
+ STRING
+
+ true
+
+
+ Water
+ STRING
+
+ true
+
+
+ Gas
+ STRING
+
+ true
+
+
+
+ Acceleration
+ STRING
+
+ true
+
+
+ Gyroscope
+ STRING
+
+ true
+
+
+ Speed
+ STRING
+
+ true
+
+
+ Rotational Speed
+ STRING
+
+ true
+
+
+ Direction
+ STRING
+
+ true
+
+
+ Rotation
+ STRING
+
+ true
+
+
+
+ Count
+ STRING
+
+ true
+
+
+ Duration
+ STRING
+
+ true
+
+
+ UV Index
+ STRING
+
+ true
+
+
+ Mass
+ STRING
+
+ true
+
+
+ Conductivity
+ STRING
+
+ true
+
+
+ Timestamp
+ STRING
+
+ true
+
+
+ Precipitation
+ STRING
+
+ true
+
+
+ Text
+ STRING
+
+ true
+
+
+ RSSI
+ STRING
+
+ true
+
+
+
+
+
+ Reset Driver
+ Reset_Driver
+
+
+ Are You Sure?
+ LIST
+
+ - No
+ - Yes
+
+
+
+
+
+
+
+
+
+ 5001
+ 6
+ ESPHome BTHome
+ 2
+ True
+ False
+ False
+ False
+
+
+ ESPHOME_BTHOME
+
+
+ False
+
+
+
diff --git a/drivers/esphome_bthome/www/documentation/images/finite-labs-logo.png b/drivers/esphome_bthome/www/documentation/images/finite-labs-logo.png
new file mode 100644
index 0000000..97f7147
Binary files /dev/null and b/drivers/esphome_bthome/www/documentation/images/finite-labs-logo.png differ
diff --git a/drivers/esphome_bthome/www/documentation/images/header.png b/drivers/esphome_bthome/www/documentation/images/header.png
new file mode 100644
index 0000000..90674e2
Binary files /dev/null and b/drivers/esphome_bthome/www/documentation/images/header.png differ
diff --git a/drivers/esphome_bthome/www/documentation/index.md b/drivers/esphome_bthome/www/documentation/index.md
new file mode 100644
index 0000000..6496823
--- /dev/null
+++ b/drivers/esphome_bthome/www/documentation/index.md
@@ -0,0 +1,590 @@
+[copyright]: # "Copyright 2026 Finite Labs, LLC. All rights reserved."
+
+
+
+
+
+---
+
+# Overview
+
+
+
+> DISCLAIMER: This software is neither affiliated with nor endorsed by either
+> Control4, ESPHome, or BTHome.
+
+
+
+Integrate BTHome-compatible BLE devices into Control4 through an ESPHome
+Bluetooth Proxy. BTHome is an open standard for BLE sensor data, supported by
+many manufacturers and DIY devices. This driver receives data from BTHome
+devices via the ESPHome Bluetooth Proxy and exposes sensor values and events to
+Control4.
+
+# Index
+
+
+
+- [System Requirements](#system-requirements)
+- [Features](#features)
+- [Compatibility](#compatibility)
+ - [Supported Devices](#supported-devices)
+- [Installer Setup](#installer-setup)
+
+ - [DriverCentral Cloud Setup](#drivercentral-cloud-setup)
+
+ - [Adding the Driver](#adding-the-driver)
+ - [Binding to ESPHome Proxy](#binding-to-esphome-proxy)
+ - [Driver Properties](#driver-properties)
+
+ - [Cloud Settings](#cloud-settings)
+
+ - [Driver Settings](#driver-settings)
+ - [Device Settings](#device-settings)
+ - [Device Info](#device-info)
+ - [Device Data](#device-data)
+ - [Driver Actions](#driver-actions)
+- [Programming](#programming)
+ - [Events](#events)
+ - [Variables](#variables)
+ - [Connections](#connections)
+
+- [Developer Information](#developer-information)
+
+- [Support](#support)
+- [Changelog](#changelog)
+
+
+
+
+
+# System Requirements
+
+- Control4 OS 3.3+
+- ESPHome driver configured with Bluetooth Proxy enabled
+- ESP32 device with `bluetooth_proxy` component
+
+# Features
+
+- Dynamic property creation based on received sensor data
+- Real-time updates from BTHome advertisements
+- Support for BTHome v2 sensor types
+- Variable programming support for all sensor values
+- Event-based programming for binary sensors
+
+> **Important:** BTHome devices do not have a discovery mechanism for their
+> supported entities. The driver learns what a device supports only when it
+> receives a broadcast advertisement containing that data. This means
+> properties, variables, events, and connections are created dynamically as data
+> is observed, they will **not** appear until the device has broadcast at least
+> once with that data. For example, a button's press events won't show up in
+> programming until the button has been pressed at least once. Most sensors
+> broadcast periodically on their own, but event-based devices like buttons only
+> broadcast when activated.
+
+# Compatibility
+
+## Supported Devices
+
+This driver supports any device compatible with the BTHome protocol. See
+[https://bthome.io](https://bthome.io) for the full compatibility list.
+
+Common BTHome devices include:
+
+| Manufacturer | Device Types |
+| ------------ | ---------------------------------------------------- |
+| Shelly | BLU Button, BLU Door/Window, BLU Motion, BLU H&T |
+| Xiaomi | Sensors with BTHome firmware (custom flash required) |
+| b-parasite | Open-source soil moisture sensor |
+| DIY | ESP32-based sensors with BTHome firmware |
+
+
+
+# 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
+> [Adding the Driver](#adding-the-driver).
+
+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.
+
+
+
+## Adding the Driver
+
+
+
+1. Download the latest `control4-esphome.zip` from
+ [DriverCentral](https://drivercentral.io/platforms/control4-drivers/utility/esphome).
+2. Extract and install the `esphome_bthome.c4z` driver.
+3. Use the "Search" tab to find "ESPHome BTHome" 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_bthome.c4z` driver.
+3. Use the "Search" tab to find "ESPHome BTHome" and add it to your project.
+
+
+
+## Binding to ESPHome Proxy
+
+1. Ensure the main ESPHome driver is connected and Bluetooth Proxy is ready.
+2. In the main ESPHome driver properties, select "Refresh List" from the "Select
+ Bluetooth Devices" dropdown.
+3. Select your BTHome device from the list. A connection binding will be
+ automatically created.
+4. Go to the "Connections" tab and bind the ESPHome BTHome driver to the newly
+ created BTHome connection.
+
+## Driver Properties
+
+
+
+### Cloud Settings
+
+#### Cloud Status (read-only)
+
+Displays the DriverCentral cloud license status.
+
+#### Automatic Updates [ Off | **_On_** ]
+
+Enables or disables automatic driver updates via DriverCentral.
+
+
+
+### Driver Settings
+
+#### Driver Status (read-only)
+
+Displays the current status of the driver.
+
+#### 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`.
+
+### Device Settings
+
+#### Bind Key
+
+Optional 32-character hex key for encrypted BTHome devices. Enter the bind key
+if your device uses BTHome encryption.
+
+### Device Info
+
+#### Name (read-only)
+
+Displays the name of the bound BTHome device.
+
+#### Device Type (read-only)
+
+Displays the detected BTHome device type.
+
+#### Device Type ID (read-only)
+
+Displays the BTHome device type identifier.
+
+#### Firmware Version (read-only)
+
+Displays the device firmware version (if available).
+
+#### MAC Address (read-only)
+
+Displays the MAC address of the bound BTHome device.
+
+#### Last Seen (read-only)
+
+Displays the timestamp of the last received advertisement.
+
+#### Last Received (read-only)
+
+Displays the parsed data from the last received advertisement.
+
+### Device Data
+
+Properties are created dynamically based on the data received from the device.
+Only properties relevant to the connected device will be shown.
+
+> **Note:** These properties will only appear after the device has broadcast
+> data containing the corresponding sensor values. If you don't see an expected
+> property, trigger the device (e.g., press a button, open a door) or wait for
+> its next periodic broadcast.
+
+#### Primary Sensors
+
+| Property | Unit | Description |
+| ------------- | ---- | ------------------------ |
+| Battery | % | Battery level |
+| Temperature C | °C | Temperature (Celsius) |
+| Temperature F | °F | Temperature (Fahrenheit) |
+| Humidity | % | Relative humidity |
+| Illuminance | lux | Light level |
+| Pressure | hPa | Barometric pressure |
+| Dew Point | °C | Dew point temperature |
+| Moisture | % | Soil moisture |
+
+#### Binary Sensors
+
+| Property | Description |
+| ------------------------ | ------------------------- |
+| Light Detected | Light detected |
+| Motion | Motion detected |
+| Door | Door open/closed |
+| Window | Window open/closed |
+| Opening | Opening detected |
+| Occupancy | Occupancy detected |
+| Presence | Presence detected |
+| Vibration Detected | Vibration detected |
+| Smoke Detected | Smoke detected |
+| Gas Detected | Gas detected |
+| Carbon Monoxide Detected | CO detected |
+| Moisture Detected | Water/moisture detected |
+| Tamper | Tamper detected |
+| Moving | Device is moving |
+| Lock Unlocked | Lock is unlocked |
+| Garage Door | Garage door open/closed |
+| Cold | Cold temperature detected |
+| Heat | High temperature detected |
+| Running | Device is running |
+| Safety | Safety issue detected |
+| Problem | Problem detected |
+| Sound Detected | Sound detected |
+| Plug | Plug connected |
+| Power On | Power is on |
+| Generic Boolean | Generic boolean value |
+| Battery Low | Low battery warning |
+| Battery Charging | Battery is charging |
+| Connectivity | Connection status |
+
+#### Power/Energy
+
+| Property | Unit | Description |
+| -------- | ---- | ------------------ |
+| Voltage | V | Voltage reading |
+| Current | A | Current reading |
+| Power | W | Power consumption |
+| Energy | kWh | Energy consumption |
+
+#### Air Quality
+
+| Property | Unit | Description |
+| -------- | ----- | -------------------------------- |
+| CO2 | ppm | Carbon dioxide level |
+| TVOC | µg/m³ | Total volatile organic compounds |
+| PM2.5 | µg/m³ | Particulate matter 2.5 |
+| PM10 | µg/m³ | Particulate matter 10 |
+
+#### Distance/Volume
+
+| Property | Unit | Description |
+| ---------------- | ---- | ---------------- |
+| Distance | m | Distance reading |
+| Volume | L | Volume |
+| Volume Storage | L | Storage volume |
+| Volume Flow Rate | L/s | Flow rate |
+| Water | L | Water volume |
+| Gas | m³ | Gas volume |
+
+#### Motion/Orientation
+
+| Property | Unit | Description |
+| ---------------- | ---- | ----------------- |
+| Acceleration | m/s² | Acceleration |
+| Gyroscope | °/s | Angular velocity |
+| Speed | m/s | Speed |
+| Rotational Speed | RPM | Rotational speed |
+| Direction | ° | Direction/heading |
+| Rotation | ° | Rotation angle |
+
+#### Miscellaneous
+
+| Property | Unit | Description |
+| ------------- | ---- | ----------------------- |
+| Count | - | Event counter |
+| Duration | s | Duration |
+| UV Index | - | UV index |
+| Mass | kg | Mass/weight |
+| Conductivity | µS | Electrical conductivity |
+| Timestamp | - | Timestamp value |
+| Precipitation | mm | Precipitation amount |
+| Text | - | Text value |
+| RSSI | dBm | Signal strength |
+
+## Driver Actions
+
+### Reset Driver
+
+Resets the driver state and clears cached sensor values.
+
+**Parameters:**
+
+- **Are You Sure?** [ **_No_** | Yes ] - Confirmation to reset the driver.
+
+
+
+# Programming
+
+## Events
+
+> **Note:** Events are created dynamically and will only appear in Composer Pro
+> programming after the device has broadcast the corresponding event at least
+> once. For example, you must press a button before its press events become
+> available. Once an event has been observed, it remains available for
+> programming even if the device has not broadcast recently.
+
+### Button Events
+
+For BTHome button devices (e.g., Shelly BLU Button), the following events are
+created dynamically:
+
+| Event | Description |
+| ----------------- | -------------------------------- |
+| Single Press | Button pressed once |
+| Double Press | Button pressed twice |
+| Triple Press | Button pressed three times |
+| Long Press | Button held for ~2 seconds |
+| Long Double Press | Button held then pressed twice |
+| Long Triple Press | Button held then pressed 3 times |
+| Hold Press | Button is being held |
+
+### Dimmer Events
+
+For BTHome dimmer/rotary devices:
+
+| Event | Description |
+| ------------ | ------------------------- |
+| Rotate Left | Rotated counter-clockwise |
+| Rotate Right | Rotated clockwise |
+
+### Binary Sensors
+
+Binary sensors (motion, door, window, occupancy, etc.) create **CONTACT_SENSOR
+bindings** instead of events. These bindings send OPENED/CLOSED states to
+Control4, allowing integration with the Contact Sensor proxy.
+
+> **Note:** Events are created dynamically based on the device's capabilities.
+> Multi-button devices will have separate events for each button (e.g., "Button
+> 1 Single Press", "Button 2 Single Press").
+
+## Variables
+
+All sensor values are exposed as variables for programming. Variables are
+created dynamically based on the data received from the device. Variable names
+match the property names shown in Composer Pro.
+
+> **Note:** Variables will only appear after the device has broadcast data
+> containing the corresponding values. If an expected variable is missing,
+> trigger the device or wait for its next periodic broadcast.
+
+### Common Variables
+
+| Variable | Type | Description |
+| ----------- | ------ | ------------------------------- |
+| Last Seen | STRING | Timestamp of last advertisement |
+| RSSI | NUMBER | Signal strength (dBm) |
+| Name | STRING | Device name |
+| Device Type | STRING | Detected BTHome device type |
+| MAC Address | STRING | Device MAC address |
+
+### Primary Sensor Variables
+
+| Variable | Type | Description |
+| ------------- | ------ | ------------------------- |
+| Battery | NUMBER | Battery level (%) |
+| Temperature C | NUMBER | Temperature (Celsius) |
+| Temperature F | NUMBER | Temperature (Fahrenheit) |
+| Humidity | NUMBER | Relative humidity (%) |
+| Illuminance | NUMBER | Light level (lux) |
+| Pressure | NUMBER | Barometric pressure (hPa) |
+| Dew Point | NUMBER | Dew point temperature |
+| Moisture | NUMBER | Soil moisture (%) |
+
+### Binary Sensor Variables
+
+| Variable | Type | Description |
+| ------------------------ | ---- | ------------------------- |
+| Light Detected | BOOL | Light detected |
+| Motion | BOOL | Motion detected |
+| Door | BOOL | Door open/closed |
+| Window | BOOL | Window open/closed |
+| Opening | BOOL | Opening detected |
+| Occupancy | BOOL | Occupancy detected |
+| Presence | BOOL | Presence detected |
+| Vibration Detected | BOOL | Vibration detected |
+| Smoke Detected | BOOL | Smoke detected |
+| Gas Detected | BOOL | Gas detected |
+| Carbon Monoxide Detected | BOOL | CO detected |
+| Moisture Detected | BOOL | Water/moisture detected |
+| Tamper | BOOL | Tamper detected |
+| Moving | BOOL | Device is moving |
+| Lock Unlocked | BOOL | Lock is unlocked |
+| Garage Door | BOOL | Garage door open/closed |
+| Cold | BOOL | Cold temperature detected |
+| Heat | BOOL | High temperature detected |
+| Running | BOOL | Device is running |
+| Safety | BOOL | Safety status |
+| Problem | BOOL | Problem detected |
+| Sound Detected | BOOL | Sound detected |
+| Plug | BOOL | Plug connected |
+| Power On | BOOL | Power is on |
+| Generic Boolean | BOOL | Generic boolean value |
+| Battery Low | BOOL | Low battery warning |
+| Battery Charging | BOOL | Battery is charging |
+| Connectivity | BOOL | Connection status |
+
+### Power/Energy Variables
+
+| Variable | Type | Description |
+| -------- | ------ | ------------ |
+| Voltage | NUMBER | Voltage (V) |
+| Current | NUMBER | Current (A) |
+| Power | NUMBER | Power (W) |
+| Energy | NUMBER | Energy (kWh) |
+
+### Air Quality Variables
+
+| Variable | Type | Description |
+| -------- | ------ | -------------------------------- |
+| CO2 | NUMBER | Carbon dioxide (ppm) |
+| TVOC | NUMBER | Total volatile organic compounds |
+| PM2.5 | NUMBER | Particulate matter 2.5 (µg/m³) |
+| PM10 | NUMBER | Particulate matter 10 (µg/m³) |
+
+### Distance/Volume Variables
+
+| Variable | Type | Description |
+| ---------------- | ------ | -------------- |
+| Distance | NUMBER | Distance |
+| Volume | NUMBER | Volume |
+| Volume Storage | NUMBER | Storage volume |
+| Volume Flow Rate | NUMBER | Flow rate |
+| Water | NUMBER | Water volume |
+| Gas | NUMBER | Gas volume |
+
+### Motion/Orientation Variables
+
+| Variable | Type | Description |
+| ---------------- | ------ | ----------------- |
+| Acceleration | NUMBER | Acceleration |
+| Gyroscope | NUMBER | Angular velocity |
+| Speed | NUMBER | Speed |
+| Rotational Speed | NUMBER | Rotational speed |
+| Direction | NUMBER | Direction/heading |
+| Rotation | NUMBER | Rotation angle |
+
+### Miscellaneous Variables
+
+| Variable | Type | Description |
+| ---------------- | ------ | ----------------------- |
+| Count | NUMBER | Event counter |
+| Duration | NUMBER | Duration |
+| UV Index | NUMBER | UV index |
+| Mass | NUMBER | Mass/weight |
+| Conductivity | NUMBER | Electrical conductivity |
+| Timestamp | NUMBER | Timestamp value |
+| Precipitation | NUMBER | Precipitation amount |
+| Text | STRING | Text value |
+| Device Type ID | NUMBER | BTHome device type ID |
+| Firmware Version | STRING | Device firmware version |
+
+## Connections
+
+### ESPHome BTHome (consumer)
+
+Bind this connection to the BTHome device binding exposed by the main ESPHome
+driver after selecting the device from "Select Bluetooth Devices".
+
+### Dynamic Bindings (provider)
+
+The driver creates bindings dynamically based on received sensor data. Bindings
+will only appear after the device has broadcast data of the corresponding type.
+
+> **Tip:** If you don't see the expected bindings after adding the driver,
+> trigger the device (e.g., press a button, open a door sensor) to generate a
+> broadcast. Sensor devices that report periodically (temperature, humidity,
+> etc.) will create their bindings automatically after the next broadcast cycle.
+
+| Data Type | Binding Class | Description |
+| -------------- | ----------------- | ------------------------------- |
+| Temperature | TEMPERATURE_VALUE | Temperature readings in Celsius |
+| Humidity | HUMIDITY_VALUE | Humidity percentage |
+| Binary Sensors | CONTACT_SENSOR | Motion, door, window, occupancy |
+| Button | BUTTON_LINK | Button press events |
+
+Binary sensor bindings (CONTACT_SENSOR) integrate with Control4's Contact Sensor
+proxy for programming and automation.
+
+
+
+
+
+# Developer Information
+
+
+
+
+
+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
+BTHome devices, 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
+
+
+
+
+
+
+
+
diff --git a/drivers/esphome_bthome/www/icons/device_lg.png b/drivers/esphome_bthome/www/icons/device_lg.png
new file mode 100644
index 0000000..ab1aee7
Binary files /dev/null and b/drivers/esphome_bthome/www/icons/device_lg.png differ
diff --git a/drivers/esphome_bthome/www/icons/device_sm.png b/drivers/esphome_bthome/www/icons/device_sm.png
new file mode 100644
index 0000000..28937f3
Binary files /dev/null and b/drivers/esphome_bthome/www/icons/device_sm.png differ
diff --git a/drivers/esphome_bthome/www/icons/experience_100.png b/drivers/esphome_bthome/www/icons/experience_100.png
new file mode 100644
index 0000000..bae48c3
Binary files /dev/null and b/drivers/esphome_bthome/www/icons/experience_100.png differ
diff --git a/drivers/esphome_bthome/www/icons/experience_1024.png b/drivers/esphome_bthome/www/icons/experience_1024.png
new file mode 100644
index 0000000..ae5cc29
Binary files /dev/null and b/drivers/esphome_bthome/www/icons/experience_1024.png differ
diff --git a/drivers/esphome_bthome/www/icons/experience_110.png b/drivers/esphome_bthome/www/icons/experience_110.png
new file mode 100644
index 0000000..d86e97f
Binary files /dev/null and b/drivers/esphome_bthome/www/icons/experience_110.png differ
diff --git a/drivers/esphome_bthome/www/icons/experience_120.png b/drivers/esphome_bthome/www/icons/experience_120.png
new file mode 100644
index 0000000..c5af34e
Binary files /dev/null and b/drivers/esphome_bthome/www/icons/experience_120.png differ
diff --git a/drivers/esphome_bthome/www/icons/experience_130.png b/drivers/esphome_bthome/www/icons/experience_130.png
new file mode 100644
index 0000000..1ec4a3e
Binary files /dev/null and b/drivers/esphome_bthome/www/icons/experience_130.png differ
diff --git a/drivers/esphome_bthome/www/icons/experience_140.png b/drivers/esphome_bthome/www/icons/experience_140.png
new file mode 100644
index 0000000..0ea950c
Binary files /dev/null and b/drivers/esphome_bthome/www/icons/experience_140.png differ
diff --git a/drivers/esphome_bthome/www/icons/experience_20.png b/drivers/esphome_bthome/www/icons/experience_20.png
new file mode 100644
index 0000000..c2a668b
Binary files /dev/null and b/drivers/esphome_bthome/www/icons/experience_20.png differ
diff --git a/drivers/esphome_bthome/www/icons/experience_30.png b/drivers/esphome_bthome/www/icons/experience_30.png
new file mode 100644
index 0000000..a444b0f
Binary files /dev/null and b/drivers/esphome_bthome/www/icons/experience_30.png differ
diff --git a/drivers/esphome_bthome/www/icons/experience_300.png b/drivers/esphome_bthome/www/icons/experience_300.png
new file mode 100644
index 0000000..dc799f0
Binary files /dev/null and b/drivers/esphome_bthome/www/icons/experience_300.png differ
diff --git a/drivers/esphome_bthome/www/icons/experience_40.png b/drivers/esphome_bthome/www/icons/experience_40.png
new file mode 100644
index 0000000..de70d6d
Binary files /dev/null and b/drivers/esphome_bthome/www/icons/experience_40.png differ
diff --git a/drivers/esphome_bthome/www/icons/experience_50.png b/drivers/esphome_bthome/www/icons/experience_50.png
new file mode 100644
index 0000000..59e374b
Binary files /dev/null and b/drivers/esphome_bthome/www/icons/experience_50.png differ
diff --git a/drivers/esphome_bthome/www/icons/experience_512.png b/drivers/esphome_bthome/www/icons/experience_512.png
new file mode 100644
index 0000000..9138b4b
Binary files /dev/null and b/drivers/esphome_bthome/www/icons/experience_512.png differ
diff --git a/drivers/esphome_bthome/www/icons/experience_60.png b/drivers/esphome_bthome/www/icons/experience_60.png
new file mode 100644
index 0000000..bb53857
Binary files /dev/null and b/drivers/esphome_bthome/www/icons/experience_60.png differ
diff --git a/drivers/esphome_bthome/www/icons/experience_70.png b/drivers/esphome_bthome/www/icons/experience_70.png
new file mode 100644
index 0000000..8f298df
Binary files /dev/null and b/drivers/esphome_bthome/www/icons/experience_70.png differ
diff --git a/drivers/esphome_bthome/www/icons/experience_80.png b/drivers/esphome_bthome/www/icons/experience_80.png
new file mode 100644
index 0000000..1998bdc
Binary files /dev/null and b/drivers/esphome_bthome/www/icons/experience_80.png differ
diff --git a/drivers/esphome_bthome/www/icons/experience_90.png b/drivers/esphome_bthome/www/icons/experience_90.png
new file mode 100644
index 0000000..3b767d2
Binary files /dev/null and b/drivers/esphome_bthome/www/icons/experience_90.png differ
diff --git a/drivers/esphome_fan/driver.c4zproj b/drivers/esphome_fan/driver.c4zproj
new file mode 100644
index 0000000..db0ad0f
--- /dev/null
+++ b/drivers/esphome_fan/driver.c4zproj
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/drivers/esphome_fan/driver.lua b/drivers/esphome_fan/driver.lua
new file mode 100644
index 0000000..c257c4d
--- /dev/null
+++ b/drivers/esphome_fan/driver.lua
@@ -0,0 +1,414 @@
+--#ifdef DRIVERCENTRAL
+DC_PID = 819
+DC_X = nil
+--#ifdef FAN_CAN_REVERSE
+DC_FILENAME = "esphome_fan___FAN_SPEED_COUNT___speed_reverse.c4z"
+--#else
+DC_FILENAME = "esphome_fan___FAN_SPEED_COUNT___speed.c4z"
+--#endif
+--#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 constants = require("constants")
+
+local ON_BINDING = 300
+local OFF_BINDING = 301
+local TOGGLE_BINDING = 302
+local SPEED_UP_BINDING = 303
+local SPEED_DOWN_BINDING = 304
+local TOGGLE_DIRECTION_BINDING = 305
+local PROXY_BINDING = 5001
+local ESPHOME_BINDING = 5002
+
+---@diagnostic disable-next-line: undefined-global
+local DISCRETE_LEVELS = __FAN_SPEED_COUNT__
+
+local ENTITY
+local STATE
+local PRESET_SPEED
+
+--- Get the current speed level from the ESPHome state.
+--- @return integer speed Current speed (1..DISCRETE_LEVELS), or 0 if off/unknown
+local function getCurrentSpeed()
+ if STATE == nil then
+ return 0
+ end
+ local speed_level = tointeger(Select(STATE, "speed_level"))
+ if speed_level == nil or speed_level <= 0 then
+ return 0
+ end
+ return math.max(1, math.min(DISCRETE_LEVELS, speed_level))
+end
+
+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()")
+end
+
+function OnDriverLateInit()
+ log:trace("OnDriverLateInit()")
+ if not CheckMinimumVersion("Driver Status") then
+ return
+ end
+
+ -- 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", "Disconnected")
+ SendToProxy(PROXY_BINDING, "ONLINE_CHANGED", { STATE = "false" }, "NOTIFY")
+ SendToProxy(ESPHOME_BINDING, "REFRESH_STATE", {}, "NOTIFY")
+end
+
+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
+
+local function on()
+ log:trace("on()")
+ SendToProxy(ESPHOME_BINDING, "ENTITY_COMMAND", {
+ body = SerializeSafe({
+ has_state = true,
+ state = true,
+ }),
+ })
+end
+
+local function off()
+ log:trace("off()")
+ SendToProxy(ESPHOME_BINDING, "ENTITY_COMMAND", {
+ body = SerializeSafe({
+ has_state = true,
+ state = false,
+ }),
+ })
+end
+
+local function toggle()
+ log:trace("toggle()")
+ local state = toboolean(Select(STATE, "state"))
+ if state then
+ off()
+ else
+ on()
+ end
+end
+
+local function setSpeed(speed)
+ log:trace("setSpeed(%s)", speed)
+ SendToProxy(ESPHOME_BINDING, "ENTITY_COMMAND", {
+ body = SerializeSafe({
+ has_state = true,
+ state = true,
+ has_speed_level = true,
+ speed_level = speed,
+ }),
+ })
+end
+
+local function cycleSpeedUp()
+ log:trace("cycleSpeedUp()")
+ local current = getCurrentSpeed()
+ local next_speed = math.min(DISCRETE_LEVELS, current + 1)
+ setSpeed(next_speed)
+end
+
+local function cycleSpeedDown()
+ log:trace("cycleSpeedDown()")
+ local current = getCurrentSpeed()
+ if current <= 1 then
+ off()
+ return
+ end
+ setSpeed(current - 1)
+end
+
+local function toggleDirection()
+ log:trace("toggleDirection()")
+ local current_direction = tointeger(Select(STATE, "direction")) or 0
+ local new_direction = current_direction == 0 and 1 or 0
+ SendToProxy(ESPHOME_BINDING, "ENTITY_COMMAND", {
+ body = SerializeSafe({
+ has_direction = true,
+ direction = new_direction,
+ }),
+ })
+end
+
+function RFP.ON(idBinding, strCommand)
+ log:trace("RFP.ON(%s, %s)", idBinding, strCommand)
+ if idBinding ~= PROXY_BINDING then
+ return
+ end
+ on()
+end
+
+function RFP.OFF(idBinding, strCommand)
+ log:trace("RFP.OFF(%s, %s)", idBinding, strCommand)
+ if idBinding ~= PROXY_BINDING then
+ return
+ end
+ off()
+end
+
+function RFP.TOGGLE(idBinding, strCommand)
+ log:trace("RFP.TOGGLE(%s, %s)", idBinding, strCommand)
+ if idBinding ~= PROXY_BINDING then
+ return
+ end
+ toggle()
+end
+
+function RFP.SET_SPEED(idBinding, strCommand, tParams)
+ log:trace("RFP.SET_SPEED(%s, %s, %s)", idBinding, strCommand, tParams)
+ if idBinding ~= PROXY_BINDING then
+ return
+ end
+ local speed = tointeger(Select(tParams, "SPEED"))
+ if speed == nil or speed <= 0 then
+ off()
+ return
+ end
+ setSpeed(math.min(DISCRETE_LEVELS, speed))
+end
+
+function RFP.CYCLE_SPEED_UP(idBinding, strCommand)
+ log:trace("RFP.CYCLE_SPEED_UP(%s, %s)", idBinding, strCommand)
+ if idBinding ~= PROXY_BINDING then
+ return
+ end
+ cycleSpeedUp()
+end
+
+function RFP.CYCLE_SPEED_DOWN(idBinding, strCommand)
+ log:trace("RFP.CYCLE_SPEED_DOWN(%s, %s)", idBinding, strCommand)
+ if idBinding ~= PROXY_BINDING then
+ return
+ end
+ cycleSpeedDown()
+end
+
+function RFP.SET_DIRECTION(idBinding, strCommand, tParams)
+ log:trace("RFP.SET_DIRECTION(%s, %s, %s)", idBinding, strCommand, tParams)
+ if idBinding ~= PROXY_BINDING then
+ return
+ end
+ local esphome_direction
+ if Select(tParams, "FORWARD") or tostring(Select(tParams, "DIRECTION")):lower() == "forward" then
+ esphome_direction = 0 -- FAN_DIRECTION_FORWARD
+ elseif Select(tParams, "REVERSE") or tostring(Select(tParams, "DIRECTION")):lower() == "reverse" then
+ esphome_direction = 1 -- FAN_DIRECTION_REVERSE
+ else
+ log:warn("SET_DIRECTION called with unknown params: %s", tParams)
+ return
+ end
+ SendToProxy(ESPHOME_BINDING, "ENTITY_COMMAND", {
+ body = SerializeSafe({
+ has_direction = true,
+ direction = esphome_direction,
+ }),
+ })
+end
+
+function RFP.TOGGLE_DIRECTION(idBinding, strCommand)
+ log:trace("RFP.TOGGLE_DIRECTION(%s, %s)", idBinding, strCommand)
+ if idBinding ~= PROXY_BINDING then
+ return
+ end
+ toggleDirection()
+end
+
+function RFP.DESIGNATE_PRESET(idBinding, strCommand, tParams)
+ log:trace("RFP.DESIGNATE_PRESET(%s, %s, %s)", idBinding, strCommand, tParams)
+ if idBinding ~= PROXY_BINDING then
+ return
+ end
+ PRESET_SPEED = tointeger(Select(tParams, "SPEED"))
+ log:debug("Preset speed set to %s", PRESET_SPEED)
+end
+
+function RFP.GET_CURRENT_STATE(idBinding, strCommand)
+ log:trace("RFP.GET_CURRENT_STATE(%s, %s)", idBinding, strCommand)
+ if idBinding ~= PROXY_BINDING then
+ return
+ end
+ if STATE == nil then
+ return
+ end
+
+ local is_on = toboolean(Select(STATE, "state"))
+ local speed = getCurrentSpeed()
+ local direction = tointeger(Select(STATE, "direction")) or 0
+
+ SendToProxy(PROXY_BINDING, "ONLINE_CHANGED", { STATE = "true" }, "NOTIFY")
+ SendToProxy(PROXY_BINDING, is_on and "ON" or "OFF", {}, "NOTIFY")
+ if is_on and speed > 0 then
+ SendToProxy(PROXY_BINDING, "CURRENT_SPEED", { SPEED = tostring(speed) }, "NOTIFY")
+ end
+ SendToProxy(PROXY_BINDING, "DIRECTION", { DIRECTION = direction == 0 and "forward" or "reverse" }, "NOTIFY")
+end
+
+function RFP.UPDATE_STATE(idBinding, strCommand, tParams, args)
+ log:trace("RFP.UPDATE_STATE(%s, %s, %s, %s)", idBinding, strCommand, tParams, args)
+ if idBinding ~= ESPHOME_BINDING then
+ log:error("RFP.UPDATE_STATE called with idBinding %s, expected %s", idBinding, ESPHOME_BINDING)
+ return
+ end
+
+ local entity = DeserializeSafe(Select(tParams, "entity"))
+ local state = DeserializeSafe(Select(tParams, "state"))
+ if IsEmpty(entity) or IsEmpty(state) then
+ log:error("RFP.UPDATE_STATE called with invalid parameters: %s", tParams)
+ return
+ end
+
+ log:trace("Entity: %s", entity)
+ log:trace("State: %s", state)
+
+ local oldIsOn = nil
+ if STATE ~= nil then
+ oldIsOn = toboolean(Select(STATE, "state"))
+ end
+ local newIsOn = toboolean(Select(state, "state"))
+
+ ENTITY = entity
+ STATE = state
+
+ -- Always update connection status
+ UpdateProperty("Driver Status", "Connected")
+ SendToProxy(PROXY_BINDING, "ONLINE_CHANGED", { STATE = "true" }, "NOTIFY")
+
+ -- Send ON/OFF notification
+ if oldIsOn ~= newIsOn then
+ log:debug("State changed from %s -> %s", oldIsOn, newIsOn)
+ SendToProxy(PROXY_BINDING, newIsOn and "ON" or "OFF", {}, "NOTIFY")
+ end
+
+ -- Send speed notification
+ local speed = getCurrentSpeed()
+ if newIsOn and speed > 0 then
+ SendToProxy(PROXY_BINDING, "CURRENT_SPEED", { SPEED = tostring(speed) }, "NOTIFY")
+ end
+
+ -- Send direction notification
+ local direction = tointeger(Select(state, "direction")) or 0
+ SendToProxy(PROXY_BINDING, "DIRECTION", { DIRECTION = direction == 0 and "forward" or "reverse" }, "NOTIFY")
+end
+
+function EC.Oscillate(tParams)
+ log:trace("EC.Oscillate(%s)", tParams)
+ local oscillating = Select(tParams, "Oscillation") == "True"
+ SendToProxy(ESPHOME_BINDING, "ENTITY_COMMAND", {
+ body = SerializeSafe({
+ has_oscillating = true,
+ oscillating = oscillating,
+ }),
+ })
+end
+
+function RFP.DO_CLICK(idBinding, strCommand, tParams, args)
+ log:trace("RFP.DO_CLICK(%s, %s, %s, %s)", idBinding, strCommand, tParams, args)
+ if idBinding == ON_BINDING then
+ on()
+ elseif idBinding == OFF_BINDING then
+ off()
+ elseif idBinding == TOGGLE_BINDING then
+ toggle()
+ elseif idBinding == SPEED_UP_BINDING then
+ cycleSpeedUp()
+ elseif idBinding == SPEED_DOWN_BINDING then
+ cycleSpeedDown()
+ elseif idBinding == TOGGLE_DIRECTION_BINDING then
+ toggleDirection()
+ end
+end
+
+function RFP.BUTTON_ACTION(idBinding, strCommand, tParams, args)
+ log:trace("RFP.BUTTON_ACTION(%s, %s, %s, %s)", idBinding, strCommand, tParams, args)
+ local buttonId = tointeger(Select(tParams, "BUTTON_ID"))
+ local action = tointeger(Select(tParams, "ACTION"))
+
+ if action ~= constants.ButtonActions.PRESS then
+ return
+ end
+ if buttonId == constants.ButtonIds.TOP then
+ on()
+ elseif buttonId == constants.ButtonIds.BOTTOM then
+ off()
+ elseif buttonId == constants.ButtonIds.TOGGLE then
+ toggle()
+ else
+ log:error("RFP.BUTTON_ACTION called with invalid BUTTON_ID %s", buttonId)
+ end
+end
+
+OBC[ESPHOME_BINDING] = function()
+ -- When the binding is changed, reset globals to allow for a refresh of the driver state.
+ ENTITY = nil
+ STATE = nil
+end
diff --git a/drivers/esphome_fan/driver.xml b/drivers/esphome_fan/driver.xml
new file mode 100644
index 0000000..f47afba
--- /dev/null
+++ b/drivers/esphome_fan/driver.xml
@@ -0,0 +1,262 @@
+
+
+ ESPHome Fan (__FAN_SPEED_COUNT__ Speed, Reversible)
+
+ ESPHome Fan (__FAN_SPEED_COUNT__ Speed)
+
+
+ Finite Labs
+
+ ESPHome Fan (__FAN_SPEED_COUNT__ Speed, Reversible)
+
+ ESPHome Fan (__FAN_SPEED_COUNT__ Speed)
+
+ Derek Miller
+ lua_gen
+ DriverWorks
+ Copyright 2026 Finite Labs, LLC. All rights reserved.
+ 02/23/2026 12:00:00 PM
+
+ 3.3.0
+
+ Comfort
+
+
+
+
+
+
+
+
+
+ Cloud Settings
+ LABEL
+ Cloud Settings
+
+
+ Cloud Status
+
+ STRING
+ true
+
+
+ Automatic Updates
+ LIST
+
+ - Off
+ - On
+
+ On
+
+
+
+ Driver Settings
+ LABEL
+ Driver Settings
+
+
+ Driver Status
+ STRING
+
+ true
+
+
+ Driver Version
+ STRING
+
+ true
+
+
+ Log Level
+ LIST
+ 3 - Info
+
+ - 0 - Fatal
+ - 1 - Error
+ - 2 - Warning
+ - 3 - Info
+ - 4 - Debug
+ - 5 - Trace
+ - 6 - Ultra
+
+
+
+ Log Mode
+ LIST
+ Off
+
+ - Off
+ - Print
+ - Log
+ - Print and Log
+
+
+
+
+
+ Oscillate
+ Set Fan oscillation for NAME to PARAM1
+
+
+ Oscillation
+ LIST
+
+ - True
+ - False
+
+ True
+
+
+
+
+
+
+
+ True
+
+ True
+
+ False
+
+ True
+ __FAN_SPEED_COUNT__
+ __FAN_SPEED_NAMES__
+ True
+
+
+
+ fan
+
+ fan
+
+
+
+
+ 300
+ 6
+ On Button Link
+ 1
+ False
+ False
+ False
+ True
+
+
+ BUTTON_LINK
+
+
+
+
+ 301
+ 6
+ Off Button Link
+ 1
+ False
+ False
+ False
+ True
+
+
+ BUTTON_LINK
+
+
+
+
+ 302
+ 6
+ Toggle Button Link
+ 1
+ False
+ False
+ False
+ True
+
+
+ BUTTON_LINK
+
+
+
+
+ 303
+ 6
+ Speed Up Button Link
+ 1
+ False
+ False
+ False
+ True
+
+
+ BUTTON_LINK
+
+
+
+
+ 304
+ 6
+ Speed Down Button Link
+ 1
+ False
+ False
+ False
+ True
+
+
+ BUTTON_LINK
+
+
+
+
+
+ 305
+ 6
+ Toggle Direction Button Link
+ 1
+ False
+ False
+ False
+ True
+
+
+ BUTTON_LINK
+
+
+
+
+
+ 5001
+ 6
+ Fan
+ 2
+ False
+ False
+ False
+ False
+
+
+ FAN
+
+
+
+
+ 5002
+ 6
+ ESPHome Fan
+ 2
+ True
+ False
+ False
+ False
+
+
+
+ ESPHOME_FAN___FAN_SPEED_COUNT___SPEED_REVERSE
+
+ ESPHOME_FAN___FAN_SPEED_COUNT___SPEED
+
+
+
+ False
+
+
+
diff --git a/drivers/esphome_fan/variants.json b/drivers/esphome_fan/variants.json
new file mode 100644
index 0000000..8b3885b
--- /dev/null
+++ b/drivers/esphome_fan/variants.json
@@ -0,0 +1,48 @@
+{
+ "pdf": "ESPHome Fan",
+ "dimensions": [
+ [
+ {
+ "suffix": "1_speed",
+ "FAN_SPEED_COUNT": "1",
+ "FAN_SPEED_NAMES": "On"
+ },
+ {
+ "suffix": "2_speed",
+ "FAN_SPEED_COUNT": "2",
+ "FAN_SPEED_NAMES": "Low,High"
+ },
+ {
+ "suffix": "3_speed",
+ "FAN_SPEED_COUNT": "3",
+ "FAN_SPEED_NAMES": "Low,Medium,High"
+ },
+ {
+ "suffix": "4_speed",
+ "FAN_SPEED_COUNT": "4",
+ "FAN_SPEED_NAMES": "Low,Medium Low,Medium High,High"
+ },
+ {
+ "suffix": "5_speed",
+ "FAN_SPEED_COUNT": "5",
+ "FAN_SPEED_NAMES": "Low,Low Medium,Medium,Medium High,High"
+ },
+ {
+ "suffix": "6_speed",
+ "FAN_SPEED_COUNT": "6",
+ "FAN_SPEED_NAMES": "1,2,3,4,5,6"
+ }
+ ],
+ [
+ {
+ "suffix": ""
+ },
+ {
+ "suffix": "_reverse",
+ "conditions": [
+ "FAN_CAN_REVERSE"
+ ]
+ }
+ ]
+ ]
+}
diff --git a/drivers/esphome_fan/www/documentation/images/finite-labs-logo.png b/drivers/esphome_fan/www/documentation/images/finite-labs-logo.png
new file mode 100644
index 0000000..97f7147
Binary files /dev/null and b/drivers/esphome_fan/www/documentation/images/finite-labs-logo.png differ
diff --git a/drivers/esphome_fan/www/documentation/images/header.png b/drivers/esphome_fan/www/documentation/images/header.png
new file mode 100644
index 0000000..90674e2
Binary files /dev/null and b/drivers/esphome_fan/www/documentation/images/header.png differ
diff --git a/drivers/esphome_fan/www/documentation/index.md b/drivers/esphome_fan/www/documentation/index.md
new file mode 100644
index 0000000..0e012e0
--- /dev/null
+++ b/drivers/esphome_fan/www/documentation/index.md
@@ -0,0 +1,217 @@
+[copyright]: # "Copyright 2026 Finite Labs, LLC. All rights reserved."
+
+
+
+
+
+---
+
+# Overview
+
+
+
+> DISCLAIMER: This software is neither affiliated with nor endorsed by either
+> Control4 or ESPHome.
+
+
+
+This driver provides specialized support for ESPHome devices with fan entities,
+allowing them to be controlled through the Control4 fan proxy with configurable
+discrete speed levels.
+
+# Index
+
+
+
+- [System Requirements](#system-requirements)
+- [Features](#features)
+- [Installer Setup](#installer-setup)
+ - [Choosing the Right Variant](#choosing-the-right-variant)
+ - [Driver Properties](#driver-properties)
+
+ - [Cloud Settings](#cloud-settings)
+
+ - [Driver Settings](#driver-settings)
+ - [Connections](#connections)
+ - [Commands](#commands)
+
+- [Developer Information](#developer-information)
+
+- [Support](#support)
+- [Changelog](#changelog)
+
+
+
+
+
+# System Requirements
+
+- Control4 OS 3.3+
+- ESPHome driver configured and connected to an ESPHome device with fan entities
+
+# Features
+
+- Control4 Fan Proxy integration for native Control4 fan control
+- On/Off, speed control with %%FAN_SPEED_COUNT%% discrete speed levels
+- Direction control (forward/reverse) in Reversible variants
+- Oscillation control via custom command
+- Button Link connections for programming integration
+- Real-time state synchronization with ESPHome device
+
+# Installer Setup
+
+Refer to the main ESPHome driver documentation for setup instructions. Once the
+main driver is configured and connected to your ESPHome device, bind the ESPHome
+Fan driver to the fan entity exposed by the main driver.
+
+## Choosing the Right Variant
+
+The ESPHome Fan driver is available in multiple variants, each supporting a
+different number of discrete speed levels. Choose the variant that matches your
+fan's `speed_count` in its ESPHome configuration:
+
+| Variant | Speed Levels | Reversible | Speed Names |
+| ------------------- | ------------ | ---------- | ------------------------------------------ |
+| 1 Speed | 1 | No | On (on/off only) |
+| 2 Speed | 2 | No | Low, High |
+| 3 Speed | 3 | No | Low, Medium, High |
+| 4 Speed | 4 | No | Low, Medium Low, Medium High, High |
+| 5 Speed | 5 | No | Low, Low Medium, Medium, Medium High, High |
+| 6 Speed | 6 | No | 1, 2, 3, 4, 5, 6 |
+| 1 Speed, Reversible | 1 | Yes | On (on/off only) |
+| 2 Speed, Reversible | 2 | Yes | Low, High |
+| 3 Speed, Reversible | 3 | Yes | Low, Medium, High |
+| 4 Speed, Reversible | 4 | Yes | Low, Medium Low, Medium High, High |
+| 5 Speed, Reversible | 5 | Yes | Low, Low Medium, Medium, Medium High, High |
+| 6 Speed, Reversible | 6 | Yes | 1, 2, 3, 4, 5, 6 |
+
+The main ESPHome driver automatically creates a binding based on the fan's
+reported speed count and direction support, ensuring only the matching variant
+can be bound.
+
+## Driver Properties
+
+
+
+### Cloud Settings
+
+#### Cloud Status (read-only)
+
+Displays the DriverCentral cloud license status.
+
+#### Automatic Updates [ Off | **_On_** ]
+
+Enables or disables automatic driver updates via DriverCentral.
+
+
+
+### Driver Settings
+
+#### Driver Status (read-only)
+
+Displays the current status of the driver.
+
+#### 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`.
+
+## Connections
+
+### Fan (provider)
+
+The Control4 Fan proxy connection. This is automatically managed by the driver
+and provides the fan functionality to Control4.
+
+### ESPHome Fan (consumer)
+
+Bind this connection to the fan entity exposed by the main ESPHome driver.
+
+### Button Links
+
+The driver provides button link connections for programming integration:
+
+| Connection | Description |
+| ---------------------------- | --------------------------------------- |
+| On Button Link | Turns the fan on when triggered |
+| Off Button Link | Turns the fan off when triggered |
+| Toggle Button Link | Toggles the fan when triggered |
+| Speed Up Button Link | Increases fan speed when triggered |
+| Speed Down Button Link | Decreases fan speed when triggered |
+| Toggle Direction Button Link | Toggles direction (Reversible variants) |
+
+## Commands
+
+### Oscillate
+
+Sets the fan oscillation on or off. This command is accessible from Composer Pro
+programming.
+
+| Parameter | Type | Values | Default |
+| ----------- | ---- | ----------- | ------- |
+| Oscillation | LIST | True, False | True |
+
+
+
+
+
+# Developer Information
+
+
+
+
+
+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
+
+
+
+
+
+
+
+
diff --git a/drivers/esphome_govee/driver.c4zproj b/drivers/esphome_govee/driver.c4zproj
new file mode 100644
index 0000000..ca0e8c8
--- /dev/null
+++ b/drivers/esphome_govee/driver.c4zproj
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/drivers/esphome_govee/driver.lua b/drivers/esphome_govee/driver.lua
new file mode 100644
index 0000000..15cedef
--- /dev/null
+++ b/drivers/esphome_govee/driver.lua
@@ -0,0 +1,719 @@
+--- ESPHome Govee Driver
+--#ifdef DRIVERCENTRAL
+DC_PID = 819
+DC_X = nil
+DC_FILENAME = "esphome_govee.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 bindings = require("lib.bindings")
+local values = require("lib.values")
+local events = require("lib.events")
+local persist = require("lib.persist")
+local constants = require("constants")
+local Govee = require("esphome.ble.parsers.govee")
+
+--------------------------------------------------------------------------------
+-- Constants
+--------------------------------------------------------------------------------
+
+--- Binding IDs
+local ESPHOME_BINDING = 5001
+
+--- Namespaces for dynamic bindings
+local BINDINGS_NAMESPACE = "Govee"
+
+--- Event namespace for Govee events
+local EVENT_NAMESPACE = "Govee"
+
+--- Sensor type constants (for temperature/humidity bindings)
+--- @enum GoveeSensorType
+local SENSOR_TYPE = {
+ TEMPERATURE = "temperature",
+ HUMIDITY = "humidity",
+}
+
+--- Event definitions for probe alarms and errors
+--- @type table
+local EVENT_DEFS = {
+ probe1_alarm_active = {
+ key = "probe1_alarm_active",
+ name = "Probe 1 Alarm Active",
+ description = "Probe 1 reached alarm temperature",
+ },
+ probe1_alarm_cleared = {
+ key = "probe1_alarm_cleared",
+ name = "Probe 1 Alarm Cleared",
+ description = "Probe 1 below alarm temperature",
+ },
+ probe2_alarm_active = {
+ key = "probe2_alarm_active",
+ name = "Probe 2 Alarm Active",
+ description = "Probe 2 reached alarm temperature",
+ },
+ probe2_alarm_cleared = {
+ key = "probe2_alarm_cleared",
+ name = "Probe 2 Alarm Cleared",
+ description = "Probe 2 below alarm temperature",
+ },
+ error_detected = { key = "error_detected", name = "Error Detected", description = "Sensor reported an error" },
+ error_cleared = { key = "error_cleared", name = "Error Cleared", description = "Sensor error cleared" },
+}
+
+--- Map device types to their supported event keys
+--- @type table
+local DEVICE_EVENTS = {
+ -- Meat thermometers with probe alarms
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5181]] = {
+ "probe1_alarm_active",
+ "probe1_alarm_cleared",
+ "error_detected",
+ "error_cleared",
+ },
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5182]] = {
+ "probe1_alarm_active",
+ "probe1_alarm_cleared",
+ "probe2_alarm_active",
+ "probe2_alarm_cleared",
+ "error_detected",
+ "error_cleared",
+ },
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5184]] = {
+ "probe1_alarm_active",
+ "probe1_alarm_cleared",
+ "probe2_alarm_active",
+ "probe2_alarm_cleared",
+ "error_detected",
+ "error_cleared",
+ },
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5185]] = {
+ "probe1_alarm_active",
+ "probe1_alarm_cleared",
+ "error_detected",
+ "error_cleared",
+ },
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5191]] = {
+ "probe1_alarm_active",
+ "probe1_alarm_cleared",
+ "error_detected",
+ "error_cleared",
+ },
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5198]] = {
+ "probe1_alarm_active",
+ "probe1_alarm_cleared",
+ "probe2_alarm_active",
+ "probe2_alarm_cleared",
+ "error_detected",
+ "error_cleared",
+ },
+ -- Dual sensors with error flag
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5178]] = { "error_detected", "error_cleared" },
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5112]] = { "error_detected", "error_cleared" },
+ -- Standard sensors with error flag (3-byte format includes error bit)
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5074]] = { "error_detected", "error_cleared" },
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5075]] = { "error_detected", "error_cleared" },
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5100]] = { "error_detected", "error_cleared" },
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5101]] = { "error_detected", "error_cleared" },
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5102]] = { "error_detected", "error_cleared" },
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5104]] = { "error_detected", "error_cleared" },
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5108]] = { "error_detected", "error_cleared" },
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5177]] = { "error_detected", "error_cleared" },
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5179]] = { "error_detected", "error_cleared" },
+}
+
+--- Sensor binding configurations
+--- @type table
+local SENSOR_BINDINGS = {
+ [SENSOR_TYPE.TEMPERATURE] = { bindingClass = "TEMPERATURE_VALUE", scale = "CELSIUS", displayName = "Temperature" },
+ [SENSOR_TYPE.HUMIDITY] = { bindingClass = "HUMIDITY_VALUE", scale = "PERCENT", displayName = "Humidity" },
+}
+
+--- Optional properties that should be hidden unless we have data
+--- @type string[]
+local OPTIONAL_PROPERTIES = {
+ "Name",
+ "Battery",
+ "Humidity",
+ "PM2.5",
+ "RSSI",
+ "Temperature C",
+ "Temperature F",
+ -- Dual sensor properties
+ "Sensor ID",
+ "Error",
+ -- Meat thermometer properties
+ "Probe 1 C",
+ "Probe 1 F",
+ "Probe 1 Alarm",
+ "Probe 2 C",
+ "Probe 2 F",
+ "Probe 2 Alarm",
+ "Probe 3 C",
+ "Probe 3 F",
+ "Probe 4 C",
+ "Probe 4 F",
+ "Ambient C",
+ "Ambient F",
+}
+
+--------------------------------------------------------------------------------
+-- 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
+--- @param rssi string|number|nil RSSI value
+local function updateRSSI(rssi)
+ local rssiNum = tonumber(rssi) or -999
+ if rssiNum > -999 then
+ values:update("RSSI", rssiNum, nil, nil, " dBm")
+ C4:SetPropertyAttribs("RSSI", constants.SHOW_PROPERTY)
+ end
+end
+
+--- Check if probe temperature has crossed alarm threshold
+--- @param probeTemp number|nil Current probe temperature
+--- @param alarmTemp number|nil Alarm threshold temperature
+--- @return boolean|nil isActive True if at/above alarm, false if below, nil if can't determine
+local function isProbeAlarmActive(probeTemp, alarmTemp)
+ if not probeTemp or not alarmTemp then
+ return nil
+ end
+ return probeTemp >= alarmTemp
+end
+
+--------------------------------------------------------------------------------
+-- Device Initialization
+--------------------------------------------------------------------------------
+
+--- Create events for a device based on its type
+--- @param deviceType string The device type string (e.g., "Govee H5181")
+local function createEventsForDevice(deviceType)
+ if not deviceType then
+ log:debug("No device type provided for event creation")
+ return
+ end
+
+ local eventKeys = DEVICE_EVENTS[deviceType]
+ if IsEmpty(eventKeys) then
+ log:debug("No events defined for device type: %s", deviceType)
+ return
+ end
+ --- @cast eventKeys -nil
+
+ log:info("Creating events for %s: %s", deviceType, table.concat(eventKeys, ", "))
+ for _, eventKey in ipairs(eventKeys) do
+ local eventDef = EVENT_DEFS[eventKey]
+ if eventDef then
+ events:getOrAddEvent(EVENT_NAMESPACE, eventDef.key, eventDef.name, eventDef.description)
+ end
+ end
+end
+
+--------------------------------------------------------------------------------
+-- Dynamic Binding Creation (Sensor)
+--------------------------------------------------------------------------------
+
+--- Get or create a sensor binding (temperature/humidity)
+--- @param sensorType GoveeSensorType
+--- @return Binding|nil binding
+local function getOrCreateSensorBinding(sensorType)
+ log:trace("getOrCreateSensorBinding(%s)", sensorType)
+ local config = SENSOR_BINDINGS[sensorType]
+ if not config then
+ return nil
+ end
+
+ local binding = bindings:getOrAddDynamicBinding(
+ BINDINGS_NAMESPACE,
+ sensorType,
+ "CONTROL",
+ true,
+ config.displayName,
+ config.bindingClass
+ )
+
+ if binding then
+ log:info("Created %s binding for %s (id=%s)", config.bindingClass, config.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
+ local cachedValue = values:getValue(config.displayName)
+ if cachedValue and cachedValue.value then
+ SendToProxy(idBinding, "VALUE_CHANGED", { VALUE = cachedValue.value, SCALE = config.scale })
+ 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
+ local cachedValue = values:getValue(config.displayName)
+ if cachedValue and cachedValue.value then
+ SendToProxy(idBinding, "VALUE_CHANGED", { VALUE = cachedValue.value, SCALE = config.scale })
+ end
+ end
+ end
+ end
+
+ return binding
+end
+
+--- Send sensor value to bound consumers
+--- @param sensorType GoveeSensorType
+--- @param value number
+local function sendSensorValue(sensorType, value)
+ log:trace("sendSensorValue(%s, %s)", sensorType, value)
+ local config = SENSOR_BINDINGS[sensorType]
+ if not config then
+ return
+ end
+
+ local binding = getOrCreateSensorBinding(sensorType)
+ if not binding then
+ return
+ end
+
+ SendToProxy(binding.bindingId, "VALUE_CHANGED", { VALUE = value, SCALE = config.scale })
+end
+
+--------------------------------------------------------------------------------
+-- Event Firing (Sensor)
+--------------------------------------------------------------------------------
+
+--- Fire an event if state changed
+--- @param stateKey string State key for tracking
+--- @param currentState boolean
+--- @param trueEvent string Event key when state becomes true
+--- @param falseEvent string Event key when state becomes false
+local function fireStateChangeEvent(stateKey, currentState, trueEvent, falseEvent)
+ log:trace("fireStateChangeEvent(%s, %s, %s, %s)", stateKey, currentState, trueEvent, falseEvent)
+ local prevState = persist:get("previousState", {})
+ local prev = prevState[stateKey]
+ if prev == nil then
+ -- Don't fire event on initial state load
+ prevState[stateKey] = currentState
+ persist:set("previousState", prevState)
+ return
+ end
+
+ if currentState == prev then
+ return
+ end
+
+ if currentState and not prev then
+ events:fire(EVENT_NAMESPACE, trueEvent)
+ elseif not currentState and prev then
+ events:fire(EVENT_NAMESPACE, falseEvent)
+ end
+
+ prevState[stateKey] = currentState
+ persist:set("previousState", prevState)
+end
+
+--------------------------------------------------------------------------------
+-- Data Processing
+--------------------------------------------------------------------------------
+
+--- Process incoming Govee data from the parent driver
+--- @param data GoveeParsedData Parsed Govee data
+--- @param rssi number|nil RSSI value
+local function processGoveeData(data, rssi)
+ log:trace("processGoveeData()")
+
+ updateLastSeen()
+ if rssi then
+ updateRSSI(rssi)
+ end
+
+ -- Summary parts for "Last Received" property
+ local summaryParts = {}
+
+ -- Device type
+ if not IsEmpty(data.deviceType) then
+ values:update("Device Type", data.deviceType, "STRING")
+ end
+
+ -- Battery
+ if type(data.battery) == "number" then
+ values:update("Battery", data.battery, "NUMBER", nil, " %")
+ table.insert(summaryParts, "Battery: " .. data.battery .. "%")
+ end
+
+ -- Error flag
+ if data.hasError ~= nil then
+ values:update("Error", data.hasError and "Yes" or "No", "STRING")
+ C4:SetPropertyAttribs("Error", constants.SHOW_PROPERTY)
+ fireStateChangeEvent("hasError", data.hasError, "error_detected", "error_cleared")
+ if data.hasError then
+ table.insert(summaryParts, "Error: Yes")
+ end
+ end
+
+ -- Sensor ID (H5178 dual sensor, H5112 dual probe)
+ if data.sensorId ~= nil then
+ values:update("Sensor ID", tostring(data.sensorId), "STRING")
+ C4:SetPropertyAttribs("Sensor ID", constants.SHOW_PROPERTY)
+ end
+
+ -- Temperature (standard temp/humidity sensors)
+ if type(data.temperature) == "number" then
+ values:update("Temperature C", data.temperature, "NUMBER", nil, " °C")
+ values:update("Temperature F", c2f(data.temperature), "NUMBER", nil, " °F")
+ table.insert(summaryParts, "Temp: " .. round(data.temperature, 1) .. "°C")
+
+ sendSensorValue(SENSOR_TYPE.TEMPERATURE, data.temperature)
+ end
+
+ -- Humidity
+ if type(data.humidity) == "number" then
+ values:update("Humidity", data.humidity, "NUMBER", nil, " %")
+ table.insert(summaryParts, "Humidity: " .. round(data.humidity, 0) .. "%")
+
+ sendSensorValue(SENSOR_TYPE.HUMIDITY, data.humidity)
+ end
+
+ -- PM2.5 (H5106 air quality sensor)
+ if type(data.pm25) == "number" then
+ values:update("PM2.5", data.pm25, "NUMBER", nil, " µg/m³")
+ table.insert(summaryParts, "PM2.5: " .. data.pm25 .. " µg/m³")
+ end
+
+ -- Probe 1 temperature (meat thermometers)
+ if type(data.probe1Temp) == "number" then
+ values:update("Probe 1 C", data.probe1Temp, "NUMBER", nil, " °C")
+ values:update("Probe 1 F", c2f(data.probe1Temp), "NUMBER", nil, " °F")
+ C4:SetPropertyAttribs("Probe 1 C", constants.SHOW_PROPERTY)
+ C4:SetPropertyAttribs("Probe 1 F", constants.SHOW_PROPERTY)
+ table.insert(summaryParts, "P1: " .. round(data.probe1Temp, 1) .. "°C")
+ end
+
+ -- Probe 1 alarm
+ if type(data.probe1Alarm) == "number" then
+ values:update("Probe 1 Alarm", data.probe1Alarm, "NUMBER", nil, " °C")
+ C4:SetPropertyAttribs("Probe 1 Alarm", constants.SHOW_PROPERTY)
+
+ -- Check if alarm is active
+ local alarmActive = isProbeAlarmActive(data.probe1Temp, data.probe1Alarm)
+ if alarmActive ~= nil then
+ fireStateChangeEvent("probe1Alarm", alarmActive, "probe1_alarm_active", "probe1_alarm_cleared")
+ if alarmActive then
+ table.insert(summaryParts, "P1 Alarm!")
+ end
+ end
+ end
+
+ -- Probe 2 temperature
+ if type(data.probe2Temp) == "number" then
+ values:update("Probe 2 C", data.probe2Temp, "NUMBER", nil, " °C")
+ values:update("Probe 2 F", c2f(data.probe2Temp), "NUMBER", nil, " °F")
+ C4:SetPropertyAttribs("Probe 2 C", constants.SHOW_PROPERTY)
+ C4:SetPropertyAttribs("Probe 2 F", constants.SHOW_PROPERTY)
+ table.insert(summaryParts, "P2: " .. round(data.probe2Temp, 1) .. "°C")
+ end
+
+ -- Probe 2 alarm
+ if type(data.probe2Alarm) == "number" then
+ values:update("Probe 2 Alarm", data.probe2Alarm, "NUMBER", nil, " °C")
+ C4:SetPropertyAttribs("Probe 2 Alarm", constants.SHOW_PROPERTY)
+
+ -- Check if alarm is active
+ local alarmActive = isProbeAlarmActive(data.probe2Temp, data.probe2Alarm)
+ if alarmActive ~= nil then
+ fireStateChangeEvent("probe2Alarm", alarmActive, "probe2_alarm_active", "probe2_alarm_cleared")
+ if alarmActive then
+ table.insert(summaryParts, "P2 Alarm!")
+ end
+ end
+ end
+
+ -- Probe 3 temperature (H5184 4-probe thermometer)
+ if type(data.probe3Temp) == "number" then
+ values:update("Probe 3 C", data.probe3Temp, "NUMBER", nil, " °C")
+ values:update("Probe 3 F", c2f(data.probe3Temp), "NUMBER", nil, " °F")
+ C4:SetPropertyAttribs("Probe 3 C", constants.SHOW_PROPERTY)
+ C4:SetPropertyAttribs("Probe 3 F", constants.SHOW_PROPERTY)
+ table.insert(summaryParts, "P3: " .. round(data.probe3Temp, 1) .. "°C")
+ end
+
+ -- Probe 4 temperature (H5184 4-probe thermometer)
+ if type(data.probe4Temp) == "number" then
+ values:update("Probe 4 C", data.probe4Temp, "NUMBER", nil, " °C")
+ values:update("Probe 4 F", c2f(data.probe4Temp), "NUMBER", nil, " °F")
+ C4:SetPropertyAttribs("Probe 4 C", constants.SHOW_PROPERTY)
+ C4:SetPropertyAttribs("Probe 4 F", constants.SHOW_PROPERTY)
+ table.insert(summaryParts, "P4: " .. round(data.probe4Temp, 1) .. "°C")
+ end
+
+ -- Ambient temperature (H5191)
+ if type(data.ambientTemp) == "number" then
+ values:update("Ambient C", data.ambientTemp, "NUMBER", nil, " °C")
+ values:update("Ambient F", c2f(data.ambientTemp), "NUMBER", nil, " °F")
+ C4:SetPropertyAttribs("Ambient C", constants.SHOW_PROPERTY)
+ C4:SetPropertyAttribs("Ambient F", constants.SHOW_PROPERTY)
+ table.insert(summaryParts, "Ambient: " .. round(data.ambientTemp, 1) .. "°C")
+ end
+
+ UpdateProperty("Last Received", #summaryParts > 0 and table.concat(summaryParts, ", ") or "No data")
+ UpdateProperty("Driver Status", "Listening")
+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 persisted state
+ 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()
+
+ -- Restore device type and recreate events
+ local storedDeviceType = Select(values:getValue("Device Type"), "value")
+ if storedDeviceType then
+ createEventsForDevice(storedDeviceType)
+ end
+
+ -- Fire OnPropertyChanged for all properties
+ 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
+
+--------------------------------------------------------------------------------
+-- RFP Handlers
+--------------------------------------------------------------------------------
+
+--- Handle passive connect notification from parent driver
+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:info("Govee device in passive mode: %s (%s)", mac, deviceType)
+
+ if not IsEmpty(name) then
+ values:update("Name", name, "STRING")
+ end
+ values:update("Device Type", deviceType, "STRING")
+ values:update("MAC Address", mac, "STRING")
+
+ -- Create events based on device model (only creates if not already present)
+ createEventsForDevice(deviceType)
+
+ 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
+
+ -- Use cached device type from CONNECTED_PASSIVE (contains model like "Govee H5074")
+ local deviceType = Select(values:getValue("Device Type"), "value")
+
+ -- Parse Govee data from manufacturer data
+ local data = Govee.parse(advertisement.manufacturerData, advertisement.serviceData, deviceType)
+ if not data then
+ return
+ end
+
+ processGoveeData(data, advertisement.rssi)
+end
+
+--- Handle disconnection notification
+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
+
+ log:info("Govee device disconnected")
+ UpdateProperty("Driver Status", "Waiting for data")
+end
+
+--------------------------------------------------------------------------------
+-- OBC Handlers
+--------------------------------------------------------------------------------
+
+OBC[ESPHOME_BINDING] = function(idBinding, strClass, bIsBound, otherDeviceId)
+ log:trace("OBC[%s](%s, %s, %s, %s)", ESPHOME_BINDING, idBinding, strClass, bIsBound, otherDeviceId)
+ persist:set("previousState", {})
+
+ 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 state
+ bindings:reset()
+ values:reset()
+ events:reset()
+
+ -- Reset sensor state tracking
+ persist:set("previousState", {})
+
+ -- Hide optional properties
+ hideOptionalProperties()
+
+ -- Reset properties to defaults
+ local resetValues = GetPropertyResetValues({})
+ for propName, defaultValue in pairs(resetValues) do
+ UpdateProperty(propName, defaultValue, true)
+ end
+
+ -- Request refresh from parent
+ SendToProxy(ESPHOME_BINDING, "REFRESH_STATE", {}, "NOTIFY")
+end
diff --git a/drivers/esphome_govee/driver.xml b/drivers/esphome_govee/driver.xml
new file mode 100644
index 0000000..eca3535
--- /dev/null
+++ b/drivers/esphome_govee/driver.xml
@@ -0,0 +1,290 @@
+
+ ESPHome Govee
+
+ Finite Labs
+ ESPHome Govee
+ Derek Miller
+ icons/device_sm.png
+ icons/device_lg.png
+ lua_gen
+ DriverWorks
+ Copyright 2026 Finite Labs, LLC. All rights reserved.
+ 01/17/2026 12:00:00 PM
+
+ true
+ 3.3.0
+
+ Sensors
+
+
+
+
+
+
+
+
+
+ Cloud Settings
+ LABEL
+ Cloud Settings
+
+
+ Cloud Status
+
+ STRING
+ true
+
+
+ Automatic Updates
+ LIST
+
+ - Off
+ - On
+
+ On
+
+
+
+ Driver Settings
+ LABEL
+ Driver Settings
+
+
+ Driver Status
+ STRING
+
+ true
+
+
+ Driver Version
+ STRING
+
+ true
+
+
+ Log Level
+ LIST
+ 3 - Info
+
+ - 0 - Fatal
+ - 1 - Error
+ - 2 - Warning
+ - 3 - Info
+ - 4 - Debug
+ - 5 - Trace
+ - 6 - Ultra
+
+
+
+ Log Mode
+ LIST
+ Off
+
+ - Off
+ - Print
+ - Log
+ - Print and Log
+
+
+
+ Device Info
+ LABEL
+ Device Info
+
+
+ Name
+ STRING
+
+ true
+
+
+ Device Type
+ STRING
+
+ true
+
+
+ MAC Address
+ STRING
+ Unknown
+ true
+
+
+ Last Seen
+ STRING
+ Never
+ true
+
+
+ RSSI
+ STRING
+
+ true
+
+
+ Last Received
+ STRING
+
+ true
+
+
+
+ Device Data
+ LABEL
+ Device Data
+
+
+ Battery
+ STRING
+
+ true
+
+
+ Temperature C
+ STRING
+
+ true
+
+
+ Temperature F
+ STRING
+
+ true
+
+
+ Humidity
+ STRING
+
+ true
+
+
+ PM2.5
+ STRING
+
+ true
+
+
+
+ Sensor ID
+ STRING
+
+ true
+
+
+ Error
+ STRING
+
+ true
+
+
+
+ Probe 1 C
+ STRING
+
+ true
+
+
+ Probe 1 F
+ STRING
+
+ true
+
+
+ Probe 1 Alarm
+ STRING
+
+ true
+
+
+ Probe 2 C
+ STRING
+
+ true
+
+
+ Probe 2 F
+ STRING
+
+ true
+
+
+ Probe 2 Alarm
+ STRING
+
+ true
+
+
+ Probe 3 C
+ STRING
+
+ true
+
+
+ Probe 3 F
+ STRING
+
+ true
+
+
+ Probe 4 C
+ STRING
+
+ true
+
+
+ Probe 4 F
+ STRING
+
+ true
+
+
+ Ambient C
+ STRING
+
+ true
+
+
+ Ambient F
+ STRING
+
+ true
+
+
+
+
+ Reset Driver
+ Reset_Driver
+
+
+ Are You Sure?
+ LIST
+
+ - No
+ - Yes
+
+
+
+
+
+
+
+
+
+
+ 5001
+ 6
+ ESPHome Govee
+ 2
+ True
+ False
+ False
+ False
+
+
+ ESPHOME_GOVEE
+
+
+ False
+
+
+
diff --git a/drivers/esphome_govee/www/documentation/images/finite-labs-logo.png b/drivers/esphome_govee/www/documentation/images/finite-labs-logo.png
new file mode 100644
index 0000000..97f7147
Binary files /dev/null and b/drivers/esphome_govee/www/documentation/images/finite-labs-logo.png differ
diff --git a/drivers/esphome_govee/www/documentation/images/header.png b/drivers/esphome_govee/www/documentation/images/header.png
new file mode 100644
index 0000000..90674e2
Binary files /dev/null and b/drivers/esphome_govee/www/documentation/images/header.png differ
diff --git a/drivers/esphome_govee/www/documentation/index.md b/drivers/esphome_govee/www/documentation/index.md
new file mode 100644
index 0000000..8ae0a0d
--- /dev/null
+++ b/drivers/esphome_govee/www/documentation/index.md
@@ -0,0 +1,434 @@
+[copyright]: # "Copyright 2026 Finite Labs, LLC. All rights reserved."
+
+
+
+
+
+---
+
+# Overview
+
+
+
+> DISCLAIMER: This software is neither affiliated with nor endorsed by either
+> Control4, ESPHome, or Govee.
+
+
+
+Integrate Govee BLE devices into Control4 through an ESPHome Bluetooth Proxy.
+This driver receives data from Govee temperature/humidity sensors and meat
+thermometers via BLE advertisements, exposing sensor values and alarm events to
+Control4.
+
+# Index
+
+
+
+- [System Requirements](#system-requirements)
+- [Features](#features)
+- [Compatibility](#compatibility)
+ - [Supported Devices](#supported-devices)
+- [Installer Setup](#installer-setup)
+
+ - [DriverCentral Cloud Setup](#drivercentral-cloud-setup)
+
+ - [Adding the Driver](#adding-the-driver)
+ - [Binding to ESPHome Proxy](#binding-to-esphome-proxy)
+ - [Driver Properties](#driver-properties)
+
+ - [Cloud Settings](#cloud-settings)
+
+ - [Driver Settings](#driver-settings)
+ - [Device Info](#device-info)
+ - [Device Data](#device-data)
+ - [Driver Actions](#driver-actions)
+- [Programming](#programming)
+ - [Events](#events)
+ - [Variables](#variables)
+ - [Connections](#connections)
+
+- [Developer Information](#developer-information)
+
+- [Support](#support)
+- [Changelog](#changelog)
+
+
+
+
+
+# System Requirements
+
+- Control4 OS 3.3+
+- ESPHome driver configured with Bluetooth Proxy enabled
+- ESP32 device with `bluetooth_proxy` component
+
+# Features
+
+- Real-time temperature and humidity monitoring
+- Support for multi-probe meat thermometers
+- Alarm event notifications for threshold monitoring
+- Battery level monitoring
+- Variable programming support for all sensor values
+
+# Compatibility
+
+## Supported Devices
+
+### Temperature/Humidity Sensors
+
+| Model | Features |
+| ----- | -------------------------------------------- |
+| H5051 | Temperature, Humidity, Battery |
+| H5052 | Temperature, Humidity, Battery |
+| H5071 | Temperature, Humidity, Battery |
+| H5072 | Temperature, Humidity, Battery |
+| H5074 | Temperature, Humidity, Battery |
+| H5075 | Temperature, Humidity, Battery |
+| H5100 | Temperature, Humidity, Battery |
+| H5101 | Temperature, Humidity, Battery |
+| H5102 | Temperature, Humidity, Battery |
+| H5103 | Temperature, Humidity, Battery |
+| H5104 | Temperature, Humidity, Battery |
+| H5105 | Temperature, Humidity, Battery |
+| H5106 | Temperature, Humidity, Battery, PM2.5 |
+| H5108 | Temperature, Humidity, Battery |
+| H5110 | Temperature, Humidity, Battery |
+| H5112 | Temperature, Humidity, Battery (Dual Probe) |
+| H5174 | Temperature, Humidity, Battery |
+| H5177 | Temperature, Humidity, Battery |
+| H5178 | Temperature, Humidity, Battery (Dual Sensor) |
+| H5179 | Temperature, Humidity, Battery |
+
+### Meat Thermometers
+
+| Model | Probes | Alarm Events | Error Events |
+| ----- | ------ | ------------ | ------------ |
+| H5055 | Multi | ❌ | ❌ |
+| H5181 | 1 | Probe 1 | ✅ |
+| H5182 | 2 | Probe 1, 2 | ✅ |
+| H5183 | Multi | ❌ | ❌ |
+| H5184 | 4 | Probe 1, 2 | ✅ |
+| H5185 | 1 | Probe 1 | ✅ |
+| H5191 | 1 | Probe 1 | ✅ |
+| H5198 | 2 | Probe 1, 2 | ✅ |
+
+
+
+# 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
+> [Adding the Driver](#adding-the-driver).
+
+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.
+
+
+
+## Adding the Driver
+
+
+
+1. Download the latest `control4-esphome.zip` from
+ [DriverCentral](https://drivercentral.io/platforms/control4-drivers/utility/esphome).
+2. Extract and install the `esphome_govee.c4z` driver.
+3. Use the "Search" tab to find "ESPHome Govee" 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_govee.c4z` driver.
+3. Use the "Search" tab to find "ESPHome Govee" and add it to your project.
+
+
+
+## Binding to ESPHome Proxy
+
+1. Ensure the main ESPHome driver is connected and Bluetooth Proxy is ready.
+2. In the main ESPHome driver properties, select "Refresh List" from the "Select
+ Bluetooth Devices" dropdown.
+3. Select your Govee device from the list. A connection binding will be
+ automatically created.
+4. Go to the "Connections" tab and bind the ESPHome Govee driver to the newly
+ created Govee connection.
+
+## Driver Properties
+
+
+
+### Cloud Settings
+
+#### Cloud Status (read-only)
+
+Displays the DriverCentral cloud license status.
+
+#### Automatic Updates [ Off | **_On_** ]
+
+Enables or disables automatic driver updates via DriverCentral.
+
+
+
+### Driver Settings
+
+#### Driver Status (read-only)
+
+Displays the current status of the driver.
+
+#### 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`.
+
+### Device Info
+
+#### Name (read-only)
+
+Displays the name of the bound Govee device.
+
+#### Device Type (read-only)
+
+Displays the detected Govee device type.
+
+#### MAC Address (read-only)
+
+Displays the MAC address of the bound Govee device.
+
+#### Last Seen (read-only)
+
+Displays the timestamp of the last received advertisement.
+
+#### RSSI (read-only)
+
+Displays the signal strength of the last received advertisement.
+
+#### Last Received (read-only)
+
+Displays a summary of the most recently received sensor data.
+
+### Device Data
+
+Properties shown depend on the connected device type:
+
+#### Temperature/Humidity Sensors
+
+| Property | Unit | Description |
+| ------------- | ----- | -------------------------------- |
+| Battery | % | Battery level |
+| Temperature C | °C | Current temperature (Celsius) |
+| Temperature F | °F | Current temperature (Fahrenheit) |
+| Humidity | % | Relative humidity |
+| PM2.5 | µg/m³ | Particulate matter (H5106 only) |
+
+#### Dual Probe/Sensor Models (H5178, H5112)
+
+| Property | Unit | Description |
+| ------------- | ---- | ------------------------------------------- |
+| Sensor ID | - | Identifies which sensor (primary/secondary) |
+| Temperature C | °C | Sensor temperature (Celsius) |
+| Temperature F | °F | Sensor temperature (Fahrenheit) |
+| Humidity | % | Relative humidity |
+| Error | - | Sensor error status |
+
+#### Meat Thermometers
+
+| Property | Unit | Description |
+| ------------- | ---- | ------------------------------------ |
+| Probe 1 C | °C | Probe 1 temperature (Celsius) |
+| Probe 1 F | °F | Probe 1 temperature (Fahrenheit) |
+| Probe 1 Alarm | °C | Probe 1 target temperature threshold |
+| Probe 2 C | °C | Probe 2 temperature (Celsius) |
+| Probe 2 F | °F | Probe 2 temperature (Fahrenheit) |
+| Probe 2 Alarm | °C | Probe 2 target temperature threshold |
+| Probe 3 C | °C | Probe 3 temperature (Celsius) |
+| Probe 3 F | °F | Probe 3 temperature (Fahrenheit) |
+| Probe 4 C | °C | Probe 4 temperature (Celsius) |
+| Probe 4 F | °F | Probe 4 temperature (Fahrenheit) |
+| Ambient C | °C | Ambient temperature (Celsius) |
+| Ambient F | °F | Ambient temperature (Fahrenheit) |
+
+> **Note:** Alarm events (`probe1_alarm_active`, etc.) fire when the probe
+> temperature reaches or exceeds the alarm threshold temperature.
+
+## Driver Actions
+
+### Reset Driver
+
+Resets the driver state and clears cached sensor values.
+
+**Parameters:**
+
+- **Are You Sure?** [ **_No_** | Yes ] - Confirmation to reset the driver.
+
+
+
+# Programming
+
+## Events
+
+The following events are available for meat thermometer models:
+
+| Event | Description |
+| --------------------- | ---------------------------------- |
+| Probe 1 Alarm Active | Probe 1 reached target temperature |
+| Probe 1 Alarm Cleared | Probe 1 below target temperature |
+| Probe 2 Alarm Active | Probe 2 reached target temperature |
+| Probe 2 Alarm Cleared | Probe 2 below target temperature |
+
+> **Note:** Only Probe 1 and Probe 2 support alarm events. Probes 3 and 4
+> provide temperature readings only.
+
+The following events are available for sensors with error reporting:
+
+| Event | Description |
+| -------------- | --------------------- |
+| Error Detected | Sensor error detected |
+| Error Cleared | Sensor error cleared |
+
+**Models with error event support:**
+
+- Temperature/Humidity: H5074, H5075, H5100, H5101, H5102, H5104, H5108, H5112,
+ H5177, H5178, H5179
+- Meat Thermometers: H5181, H5182, H5184, H5185, H5191, H5198
+
+> **Note:** Models H5051, H5052, H5071, H5072, H5103, H5105, H5106, H5110,
+> H5174, H5055, and H5183 do not support error events.
+
+## Variables
+
+All sensor values are exposed as variables for programming:
+
+### Common Variables
+
+| Variable | Type | Description |
+| ----------- | ------ | ------------------------------- |
+| Device Type | STRING | Detected device model |
+| Last Seen | STRING | Timestamp of last advertisement |
+| MAC Address | STRING | Device MAC address |
+| Name | STRING | Device name |
+| RSSI | NUMBER | Signal strength (dBm) |
+
+### Temperature/Humidity Sensor Variables
+
+| Variable | Type | Description |
+| ------------- | ------ | -------------------------------- |
+| Battery | NUMBER | Battery percentage (0-100) |
+| Temperature C | NUMBER | Temperature in Celsius |
+| Temperature F | NUMBER | Temperature in Fahrenheit |
+| Humidity | NUMBER | Relative humidity percentage |
+| PM2.5 | NUMBER | Particulate matter µg/m³ (H5106) |
+| Sensor ID | STRING | Sensor identifier (H5178, H5112) |
+| Error | STRING | Error status ("Yes" or "No") |
+
+### Meat Thermometer Variables
+
+| Variable | Type | Description |
+| ------------- | ------ | -------------------------------- |
+| Probe 1 C | NUMBER | Probe 1 temperature (Celsius) |
+| Probe 1 F | NUMBER | Probe 1 temperature (Fahrenheit) |
+| Probe 1 Alarm | NUMBER | Probe 1 target threshold (°C) |
+| Probe 2 C | NUMBER | Probe 2 temperature (Celsius) |
+| Probe 2 F | NUMBER | Probe 2 temperature (Fahrenheit) |
+| Probe 2 Alarm | NUMBER | Probe 2 target threshold (°C) |
+| Probe 3 C | NUMBER | Probe 3 temperature (Celsius) |
+| Probe 3 F | NUMBER | Probe 3 temperature (Fahrenheit) |
+| Probe 4 C | NUMBER | Probe 4 temperature (Celsius) |
+| Probe 4 F | NUMBER | Probe 4 temperature (Fahrenheit) |
+| Ambient C | NUMBER | Ambient temperature (Celsius) |
+| Ambient F | NUMBER | Ambient temperature (Fahrenheit) |
+
+> **Note:** Variables are only created when the corresponding sensor data is
+> received. Not all variables will be present for all device types.
+
+## Connections
+
+### ESPHome Govee (consumer)
+
+Bind this connection to the Govee device binding exposed by the main ESPHome
+driver after selecting the Govee device from the "Select Bluetooth Devices"
+dropdown.
+
+### Dynamic Sensor Bindings (provider)
+
+The driver dynamically creates sensor bindings when temperature/humidity data is
+received:
+
+| Binding | Class | Description |
+| ----------- | ----------------- | ---------------------- |
+| Temperature | TEMPERATURE_VALUE | Temperature in Celsius |
+| Humidity | HUMIDITY_VALUE | Humidity percentage |
+
+These bindings can be connected to other Control4 devices that consume
+temperature or humidity values (e.g., climate displays, thermostats).
+
+
+
+
+
+# Developer Information
+
+
+
+
+
+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
+Govee devices, 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
+
+
+
+
+
+
+
+
diff --git a/drivers/esphome_govee/www/icons/device_lg.png b/drivers/esphome_govee/www/icons/device_lg.png
new file mode 100644
index 0000000..ab1aee7
Binary files /dev/null and b/drivers/esphome_govee/www/icons/device_lg.png differ
diff --git a/drivers/esphome_govee/www/icons/device_sm.png b/drivers/esphome_govee/www/icons/device_sm.png
new file mode 100644
index 0000000..28937f3
Binary files /dev/null and b/drivers/esphome_govee/www/icons/device_sm.png differ
diff --git a/drivers/esphome_govee/www/icons/experience_100.png b/drivers/esphome_govee/www/icons/experience_100.png
new file mode 100644
index 0000000..bae48c3
Binary files /dev/null and b/drivers/esphome_govee/www/icons/experience_100.png differ
diff --git a/drivers/esphome_govee/www/icons/experience_1024.png b/drivers/esphome_govee/www/icons/experience_1024.png
new file mode 100644
index 0000000..ae5cc29
Binary files /dev/null and b/drivers/esphome_govee/www/icons/experience_1024.png differ
diff --git a/drivers/esphome_govee/www/icons/experience_110.png b/drivers/esphome_govee/www/icons/experience_110.png
new file mode 100644
index 0000000..d86e97f
Binary files /dev/null and b/drivers/esphome_govee/www/icons/experience_110.png differ
diff --git a/drivers/esphome_govee/www/icons/experience_120.png b/drivers/esphome_govee/www/icons/experience_120.png
new file mode 100644
index 0000000..c5af34e
Binary files /dev/null and b/drivers/esphome_govee/www/icons/experience_120.png differ
diff --git a/drivers/esphome_govee/www/icons/experience_130.png b/drivers/esphome_govee/www/icons/experience_130.png
new file mode 100644
index 0000000..1ec4a3e
Binary files /dev/null and b/drivers/esphome_govee/www/icons/experience_130.png differ
diff --git a/drivers/esphome_govee/www/icons/experience_140.png b/drivers/esphome_govee/www/icons/experience_140.png
new file mode 100644
index 0000000..0ea950c
Binary files /dev/null and b/drivers/esphome_govee/www/icons/experience_140.png differ
diff --git a/drivers/esphome_govee/www/icons/experience_20.png b/drivers/esphome_govee/www/icons/experience_20.png
new file mode 100644
index 0000000..c2a668b
Binary files /dev/null and b/drivers/esphome_govee/www/icons/experience_20.png differ
diff --git a/drivers/esphome_govee/www/icons/experience_30.png b/drivers/esphome_govee/www/icons/experience_30.png
new file mode 100644
index 0000000..a444b0f
Binary files /dev/null and b/drivers/esphome_govee/www/icons/experience_30.png differ
diff --git a/drivers/esphome_govee/www/icons/experience_300.png b/drivers/esphome_govee/www/icons/experience_300.png
new file mode 100644
index 0000000..dc799f0
Binary files /dev/null and b/drivers/esphome_govee/www/icons/experience_300.png differ
diff --git a/drivers/esphome_govee/www/icons/experience_40.png b/drivers/esphome_govee/www/icons/experience_40.png
new file mode 100644
index 0000000..de70d6d
Binary files /dev/null and b/drivers/esphome_govee/www/icons/experience_40.png differ
diff --git a/drivers/esphome_govee/www/icons/experience_50.png b/drivers/esphome_govee/www/icons/experience_50.png
new file mode 100644
index 0000000..59e374b
Binary files /dev/null and b/drivers/esphome_govee/www/icons/experience_50.png differ
diff --git a/drivers/esphome_govee/www/icons/experience_512.png b/drivers/esphome_govee/www/icons/experience_512.png
new file mode 100644
index 0000000..9138b4b
Binary files /dev/null and b/drivers/esphome_govee/www/icons/experience_512.png differ
diff --git a/drivers/esphome_govee/www/icons/experience_60.png b/drivers/esphome_govee/www/icons/experience_60.png
new file mode 100644
index 0000000..bb53857
Binary files /dev/null and b/drivers/esphome_govee/www/icons/experience_60.png differ
diff --git a/drivers/esphome_govee/www/icons/experience_70.png b/drivers/esphome_govee/www/icons/experience_70.png
new file mode 100644
index 0000000..8f298df
Binary files /dev/null and b/drivers/esphome_govee/www/icons/experience_70.png differ
diff --git a/drivers/esphome_govee/www/icons/experience_80.png b/drivers/esphome_govee/www/icons/experience_80.png
new file mode 100644
index 0000000..1998bdc
Binary files /dev/null and b/drivers/esphome_govee/www/icons/experience_80.png differ
diff --git a/drivers/esphome_govee/www/icons/experience_90.png b/drivers/esphome_govee/www/icons/experience_90.png
new file mode 100644
index 0000000..3b767d2
Binary files /dev/null and b/drivers/esphome_govee/www/icons/experience_90.png differ
diff --git a/drivers/esphome_light/driver.lua b/drivers/esphome_light/driver.lua
index 465f2eb..bedcabf 100644
--- a/drivers/esphome_light/driver.lua
+++ b/drivers/esphome_light/driver.lua
@@ -4,11 +4,11 @@ DC_X = nil
DC_FILENAME = "esphome_light.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("drivers-common-public.global.handlers")
+require("drivers-common-public.global.lib")
+require("drivers-common-public.global.timer")
-JSON = require("vendor.JSON")
+JSON = require("JSON")
local log = require("lib.logging")
@@ -25,7 +25,7 @@ local STATE -- leaving this explicitly nil so we can distinguish driver init fro
function OnDriverInit()
--#ifdef DRIVERCENTRAL
- require("vendor.cloud-client-byte")
+ require("cloud-client-byte")
C4:AllowExecute(false)
--#else
C4:AllowExecute(true)
@@ -47,10 +47,11 @@ 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
UpdateProperty("Driver Status", "Disconnected")
SendToProxy(PROXY_BINDING, "ONLINE_CHANGED", { STATE = "false" }, "NOTIFY")
@@ -75,6 +76,7 @@ function OPC.Log_Mode(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)
@@ -82,6 +84,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)
@@ -92,18 +95,20 @@ 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
end
local function on()
log:trace("on()")
SendToProxy(ESPHOME_BINDING, "ENTITY_COMMAND", {
- body = Serialize({
+ body = SerializeSafe({
has_state = true,
state = true,
}),
@@ -113,7 +118,7 @@ end
local function off()
log:trace("off()")
SendToProxy(ESPHOME_BINDING, "ENTITY_COMMAND", {
- body = Serialize({
+ body = SerializeSafe({
has_state = true,
state = false,
}),
@@ -178,8 +183,8 @@ function RFP.UPDATE_STATE(idBinding, strCommand, tParams, args)
return
end
- local entity = Deserialize(Select(tParams, "entity"))
- local state = Deserialize(Select(tParams, "state"))
+ local entity = DeserializeSafe(Select(tParams, "entity"))
+ local state = DeserializeSafe(Select(tParams, "state"))
if IsEmpty(entity) or IsEmpty(state) then
log:error("RFP.UPDATE_STATE called with invalid parameters: %s", tParams)
return
diff --git a/drivers/esphome_light/driver.xml b/drivers/esphome_light/driver.xml
index 98fffcd..de9dbbb 100644
--- a/drivers/esphome_light/driver.xml
+++ b/drivers/esphome_light/driver.xml
@@ -6,7 +6,7 @@
Derek Miller
lua_gen
DriverWorks
- Copyright 2025 Finite Labs, LLC. All rights reserved.
+ Copyright 2026 Finite Labs, LLC. All rights reserved.
06/06/2025 12:00:00 PM
3.3.0
diff --git a/drivers/esphome_light/squishy b/drivers/esphome_light/squishy
deleted file mode 100644
index 2e2754f..0000000
--- a/drivers/esphome_light/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_light.lua"
-#else
-Output "../../../../dist/oss/esphome_light.lua"
-#endif
-Option "minify" "true"
-Option "minify_level" "none"
-Option "minify_comments" "true"
-Option "minify_emptylines" "true"
diff --git a/drivers/esphome_light/www/documentation/index.md b/drivers/esphome_light/www/documentation/index.md
index be39c4d..169afb5 100644
--- a/drivers/esphome_light/www/documentation/index.md
+++ b/drivers/esphome_light/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."
-
+
---
@@ -29,6 +29,43 @@
This driver provides specialized support for ESPHome devices with light
entities, allowing them to be controlled through the Control4 light proxy.
+# Index
+
+
+
+- [System Requirements](#system-requirements)
+- [Features](#features)
+- [Compatibility](#compatibility)
+ - [Supported Color Modes](#supported-color-modes)
+- [Installer Setup](#installer-setup)
+ - [Driver Properties](#driver-properties)
+
+ - [Cloud Settings](#cloud-settings)
+
+ - [Driver Settings](#driver-settings)
+ - [Connections](#connections)
+
+- [Developer Information](#developer-information)
+
+- [Support](#support)
+- [Changelog](#changelog)
+
+
+
+
+
+# System Requirements
+
+- Control4 OS 3.3+
+- ESPHome driver configured and connected to an ESPHome device with light
+ entities
+
+# Features
+
+- Control4 Light Proxy integration for native Control4 lighting control
+- Button Link connections for programming integration
+- Real-time state synchronization with ESPHome device
+
# Compatibility
## Supported Color Modes
@@ -49,48 +86,71 @@ entities, allowing them to be controlled through the Control4 light proxy.
+
+
# Installer Setup
Refer to the main ESPHome driver documentation for setup instructions. Once the
main driver is configured and connected to your ESPHome device, bind the ESPHome
Light driver to the light entity exposed by the main driver.
-## Driver Setup
-
-### Driver Properties
+## Driver Properties
-#### Cloud Settings
+### Cloud Settings
-##### Cloud Status
+#### Cloud Status (read-only)
Displays the DriverCentral cloud license status.
-##### Automatic Updates
+#### Automatic Updates [ Off | **_On_** ]
-Turns on/off the DriverCentral cloud automatic updates.
+Enables or disables automatic driver updates via DriverCentral.
-#### Driver Settings
+### Driver Settings
-##### Driver Status (read-only)
+#### Driver Status (read-only)
Displays the current status of the driver.
-##### Driver Version (read-only)
+#### Driver Version (read-only)
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`.
+## Connections
+
+### Light (provider)
+
+The Control4 Light proxy connection. This is automatically managed by the driver
+and provides the light functionality to Control4.
+
+### ESPHome Light (consumer)
+
+Bind this connection to the light entity exposed by the main ESPHome driver.
+
+### Button Links
+
+The driver provides three button link connections for programming integration:
+
+| Connection | Description |
+| ------------------ | ---------------------------------- |
+| On Button Link | Turns the light on when triggered |
+| Toggle Button Link | Toggles the light when triggered |
+| Off Button Link | Turns the light off when triggered |
+
+
+
# Developer Information
@@ -99,7 +159,7 @@ Sets the logging mode. Default is `Off`.
-Copyright © 2025 Finite Labs LLC
+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
diff --git a/drivers/esphome_lock/driver.lua b/drivers/esphome_lock/driver.lua
index 98d148d..ef1f522 100644
--- a/drivers/esphome_lock/driver.lua
+++ b/drivers/esphome_lock/driver.lua
@@ -4,12 +4,12 @@ DC_X = nil
DC_FILENAME = "esphome_lock.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("drivers-common-public.global.handlers")
+require("drivers-common-public.global.lib")
+require("drivers-common-public.global.timer")
-JSON = require("vendor.JSON")
-local ESPHomeProtoSchema = require("esphome.proto-schema")
+JSON = require("JSON")
+local ESPHomeProtoSchema = require("esphome.proto_schema")
local log = require("lib.logging")
@@ -21,7 +21,7 @@ local STATE -- leaving this explicitly nil so we can distinguish driver init fro
function OnDriverInit()
--#ifdef DRIVERCENTRAL
- require("vendor.cloud-client-byte")
+ require("cloud-client-byte")
C4:AllowExecute(false)
--#else
C4:AllowExecute(true)
@@ -43,8 +43,8 @@ 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
@@ -71,6 +71,7 @@ function OPC.Log_Mode(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)
@@ -78,6 +79,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)
@@ -88,11 +90,13 @@ 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
end
@@ -104,7 +108,7 @@ local function unlock()
log:trace("unlock()")
local code = not IsEmpty(Properties["Lock Code"]) and Properties["Lock Code"] or nil
SendToProxy(ESPHOME_BINDING, "ENTITY_COMMAND", {
- body = Serialize({
+ body = SerializeSafe({
command = ESPHomeProtoSchema.Enum.LockCommand.LOCK_UNLOCK,
has_code = code ~= nil,
code = code,
@@ -116,7 +120,7 @@ local function lock()
log:trace("lock()")
local code = not IsEmpty(Properties["Lock Code"]) and Properties["Lock Code"] or nil
SendToProxy(ESPHOME_BINDING, "ENTITY_COMMAND", {
- body = Serialize({
+ body = SerializeSafe({
command = ESPHomeProtoSchema.Enum.LockCommand.LOCK_LOCK,
has_code = code ~= nil,
code = code,
@@ -185,8 +189,8 @@ function RFP.UPDATE_STATE(idBinding, strCommand, tParams, args)
return
end
- local entity = Deserialize(Select(tParams, "entity"))
- local state = Deserialize(Select(tParams, "state"))
+ local entity = DeserializeSafe(Select(tParams, "entity"))
+ local state = DeserializeSafe(Select(tParams, "state"))
if IsEmpty(entity) or IsEmpty(state) then
log:error("RFP.UPDATE_STATE called with invalid parameters: %s", tParams)
return
diff --git a/drivers/esphome_lock/driver.xml b/drivers/esphome_lock/driver.xml
index c558e52..bf3829d 100644
--- a/drivers/esphome_lock/driver.xml
+++ b/drivers/esphome_lock/driver.xml
@@ -6,7 +6,7 @@
Derek Miller
lua_gen
DriverWorks
- Copyright 2025 Finite Labs, LLC. All rights reserved.
+ Copyright 2026 Finite Labs, LLC. All rights reserved.
06/06/2025 12:00:00 PM
3.3.0
diff --git a/drivers/esphome_lock/squishy b/drivers/esphome_lock/squishy
deleted file mode 100644
index c76f86c..0000000
--- a/drivers/esphome_lock/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_lock.lua"
-#else
-Output "../../../../dist/oss/esphome_lock.lua"
-#endif
-Option "minify" "true"
-Option "minify_level" "none"
-Option "minify_comments" "true"
-Option "minify_emptylines" "true"
diff --git a/drivers/esphome_lock/www/documentation/index.md b/drivers/esphome_lock/www/documentation/index.md
index 9ef1aff..612b81c 100644
--- a/drivers/esphome_lock/www/documentation/index.md
+++ b/drivers/esphome_lock/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."
-
+
---
@@ -29,55 +29,102 @@
This driver provides specialized support for ESPHome devices with lock entities,
allowing them to be controlled through the Control4 lock proxy.
+# Index
+
+
+
+- [System Requirements](#system-requirements)
+- [Features](#features)
+- [Installer Setup](#installer-setup)
+ - [Driver Properties](#driver-properties)
+
+ - [Cloud Settings](#cloud-settings)
+
+ - [Driver Settings](#driver-settings)
+ - [Device Settings](#device-settings)
+ - [Connections](#connections)
+
+- [Developer Information](#developer-information)
+
+- [Support](#support)
+- [Changelog](#changelog)
+
+
+
+
+
+# System Requirements
+
+- Control4 OS 3.3+
+- ESPHome driver configured and connected to an ESPHome device with lock
+ entities
+
+# Features
+
+- Control4 Lock Proxy integration for native Control4 lock control
+- Lock/unlock commands with optional lock code support
+- Real-time state synchronization with ESPHome device
+
# Installer Setup
Refer to the main ESPHome driver documentation for setup instructions. Once the
main driver is configured and connected to your ESPHome device, bind the ESPHome
-lock driver to the lock entity exposed by the main driver.
+Lock driver to the lock entity exposed by the main driver.
-## Driver Setup
-
-### Driver Properties
+## Driver Properties
-#### Cloud Settings
+### Cloud Settings
-##### Cloud Status
+#### Cloud Status (read-only)
Displays the DriverCentral cloud license status.
-##### Automatic Updates
+#### Automatic Updates [ Off | **_On_** ]
-Turns on/off the DriverCentral cloud automatic updates.
+Enables or disables automatic driver updates via DriverCentral.
-#### Driver Settings
+### Driver Settings
-##### Driver Status (read-only)
+#### Driver Status (read-only)
Displays the current status of the driver.
-##### Driver Version (read-only)
+#### Driver Version (read-only)
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 Settings
+### Device Settings
-##### Lock Code
+#### Lock Code
Sets the lock code to be used for locking and unlocking the device. If the
device does not require a code to lock/unlock, leave this field blank.
+## Connections
+
+### Lock (provider)
+
+The Control4 Lock proxy connection. This is automatically managed by the driver
+and provides the lock functionality to Control4.
+
+### ESPHome Lock (consumer)
+
+Bind this connection to the lock entity exposed by the main ESPHome driver.
+
+
+
# Developer Information
@@ -86,7 +133,7 @@ device does not require a code to lock/unlock, leave this field blank.
-Copyright © 2025 Finite Labs LLC
+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
diff --git a/drivers/esphome_switchbot/driver.c4zproj b/drivers/esphome_switchbot/driver.c4zproj
new file mode 100644
index 0000000..cf7e5c7
--- /dev/null
+++ b/drivers/esphome_switchbot/driver.c4zproj
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/drivers/esphome_switchbot/driver.lua b/drivers/esphome_switchbot/driver.lua
new file mode 100644
index 0000000..3e03630
--- /dev/null
+++ b/drivers/esphome_switchbot/driver.lua
@@ -0,0 +1,2684 @@
+--- ESPHome SwitchBot Driver
+--#ifdef DRIVERCENTRAL
+DC_PID = 819
+DC_X = nil
+DC_FILENAME = "esphome_switchbot.c4z"
+--#endif
+require("lib.utils")
+require("drivers-common-public.global.handlers")
+require("drivers-common-public.global.lib")
+require("drivers-common-public.global.timer")
+require("drivers-common-public.global.url")
+
+JSON = require("JSON")
+
+local log = require("lib.logging")
+local bindings = require("lib.bindings")
+local values = require("lib.values")
+local events = require("lib.events")
+local persist = require("lib.persist")
+local constants = require("constants")
+local UUID = require("esphome.ble.uuid")
+local SwitchBot = require("esphome.ble.parsers.switchbot")
+local http = require("lib.http")
+
+--------------------------------------------------------------------------------
+-- Constants
+--------------------------------------------------------------------------------
+
+--- Binding IDs
+local ESPHOME_BINDING = 5001
+
+--- Namespaces for dynamic bindings
+local BINDINGS_NAMESPACE = "SwitchBot"
+
+--- Event namespace for SwitchBot events
+local EVENT_NAMESPACE = "SwitchBot"
+
+--- SwitchBot BLE UUIDs
+--- See: https://github.com/OpenWonderLabs/SwitchBotAPI-BLE/blob/latest/devicetypes/bot.md
+--- @enum SwitchBotUUID
+local SWITCHBOT_UUID = {
+ -- Communication Service (all SwitchBot devices)
+ SERVICE = "CBA20D00-224D-11E6-9FB8-0002A5D5C51B",
+ -- TX Characteristic - Write commands to device
+ TX = "CBA20002-224D-11E6-9FB8-0002A5D5C51B",
+ -- RX Characteristic - Read status / receive notifications from device
+ RX = "CBA20003-224D-11E6-9FB8-0002A5D5C51B",
+}
+
+--- Device category constants
+--- @enum DeviceCategory
+local DEVICE_CATEGORY = {
+ BOT = "bot",
+ SWITCH = "switch", -- Plug Mini, Relay switches
+ SENSOR = "sensor", -- Meters, Motion, Contact, Leak
+}
+
+--- Switch type constants (for SWITCH category devices)
+--- @enum SwitchType
+local SWITCH_TYPE = {
+ PLUG = "plug",
+ RELAY = "relay",
+ RELAY_1PM = "relay_1pm",
+ RELAY_2PM = "relay_2pm",
+}
+
+--- Contact sensor type constants
+--- @enum ContactType
+local CONTACT_TYPE = {
+ MOTION = "motion",
+ CONTACT = "contact",
+ LEAK = "leak",
+ TAMPER = "tamper",
+}
+
+--- Sensor type constants (for temperature/humidity bindings)
+--- @enum SwitchBotSensorType
+local SENSOR_TYPE = {
+ TEMPERATURE = "temperature",
+ HUMIDITY = "humidity",
+}
+
+--- Devices requiring encryption
+--- @type table
+local ENCRYPTED_DEVICES = {
+ [SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.RELAY_1]] = true,
+ [SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.RELAY_1PM]] = true,
+ [SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.RELAY_2PM]] = true,
+}
+
+--- Dual channel devices
+--- @type table
+local DUAL_CHANNEL_DEVICES = {
+ [SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.RELAY_2PM]] = true,
+}
+
+--- SwitchBot Bot command bytes (for Bot devices)
+--- @enum SwitchBotCommand
+local SWITCHBOT_CMD = {
+ HEADER = 0x57,
+ CMD_ACTION = 0x01,
+ GET_BASIC_SETTING = 0x02,
+ SET_MODE = 0x03,
+ ACTION_PRESS = 0x00,
+ ACTION_ON = 0x01,
+ ACTION_OFF = 0x02,
+ ACTION_PUSH_STOP = 0x03,
+ ACTION_BACK = 0x04,
+}
+
+-- Pre-built Bot commands
+local CMD_BOT_PRESS = string.char(SWITCHBOT_CMD.HEADER, SWITCHBOT_CMD.CMD_ACTION, SWITCHBOT_CMD.ACTION_PRESS)
+local CMD_BOT_ON = string.char(SWITCHBOT_CMD.HEADER, SWITCHBOT_CMD.CMD_ACTION, SWITCHBOT_CMD.ACTION_ON)
+local CMD_BOT_OFF = string.char(SWITCHBOT_CMD.HEADER, SWITCHBOT_CMD.CMD_ACTION, SWITCHBOT_CMD.ACTION_OFF)
+local CMD_BOT_GET_SETTINGS = string.char(SWITCHBOT_CMD.HEADER, SWITCHBOT_CMD.GET_BASIC_SETTING)
+
+--- IV Request command prefix (for encrypted devices)
+local IV_REQUEST_PREFIX = "\x57\x00\x00\x00\x0f\x21\x03"
+
+--- Event definitions by key
+--- @type table
+local EVENT_DEFS = {
+ motion_detected = { key = "motion_detected", name = "Motion Detected", description = "NAME motion was detected" },
+ motion_cleared = { key = "motion_cleared", name = "Motion Cleared", description = "NAME motion cleared" },
+ contact_opened = { key = "contact_opened", name = "Contact Opened", description = "NAME contact was opened" },
+ contact_closed = { key = "contact_closed", name = "Contact Closed", description = "NAME contact was closed" },
+ leak_detected = { key = "leak_detected", name = "Leak Detected", description = "NAME water leak detected" },
+ leak_cleared = { key = "leak_cleared", name = "Leak Cleared", description = "NAME water leak cleared" },
+ tamper_detected = { key = "tamper_detected", name = "Tamper Detected", description = "NAME tamper detected" },
+ tamper_cleared = { key = "tamper_cleared", name = "Tamper Cleared", description = "NAME tamper cleared" },
+ button_pressed = { key = "button_pressed", name = "Button Pressed", description = "NAME button was pressed" },
+ low_battery = { key = "low_battery", name = "Low Battery", description = "NAME battery is low" },
+ battery_ok = { key = "battery_ok", name = "Battery OK", description = "NAME battery is ok" },
+}
+
+--- Map device types to their supported event keys
+--- @type table
+local DEVICE_EVENTS = {
+ [SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.MOTION]] = { "motion_detected", "motion_cleared" },
+ [SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.PRESENCE]] = { "motion_detected", "motion_cleared" },
+ [SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.CONTACT]] = { "contact_opened", "contact_closed", "button_pressed" },
+ [SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.WATER_LEAK]] = {
+ "leak_detected",
+ "leak_cleared",
+ "tamper_detected",
+ "tamper_cleared",
+ "low_battery",
+ "battery_ok",
+ },
+}
+
+--- Sensor binding configurations
+--- @type table
+local SENSOR_BINDINGS = {
+ [SENSOR_TYPE.TEMPERATURE] = { bindingClass = "TEMPERATURE_VALUE", scale = "CELSIUS", displayName = "Temperature" },
+ [SENSOR_TYPE.HUMIDITY] = { bindingClass = "HUMIDITY_VALUE", scale = "PERCENT", displayName = "Humidity" },
+}
+
+--- Contact sensor binding configurations
+--- @type table
+local CONTACT_BINDINGS = {
+ [CONTACT_TYPE.MOTION] = { openEvent = "OPENED", closedEvent = "CLOSED", displayName = "Motion" },
+ [CONTACT_TYPE.CONTACT] = { openEvent = "OPENED", closedEvent = "CLOSED", displayName = "Contact" },
+ [CONTACT_TYPE.LEAK] = { openEvent = "CLOSED", closedEvent = "OPENED", displayName = "Leak" },
+ [CONTACT_TYPE.TAMPER] = { openEvent = "CLOSED", closedEvent = "OPENED", displayName = "Tamper" },
+}
+
+--- SwitchBot Cloud API configuration
+local SWITCHBOT_API = {
+ CLIENT_ID = "5nnwmhmsa9xxskm14hd85lm9bm",
+ BASE_URL = "api.switchbot.net",
+ LOGIN_PATH = "/account/api/v1/user/login",
+ USERINFO_PATH = "/account/api/v1/user/userinfo",
+ KEYS_PATH = "/wonder/keys/v1/communicate",
+}
+
+--- Optional properties that should be hidden unless we have data
+--- @type string[]
+local OPTIONAL_PROPERTIES = {
+ "Device Data", -- Label
+ "Battery Low",
+ "Battery",
+ "Channel 1 Power",
+ "Channel 1 State",
+ "Channel 1",
+ "Channel 2 Power",
+ "Channel 2 State",
+ "Channel 2",
+ "CO2",
+ "Contact",
+ "Humidity",
+ "Leak Detected",
+ "Light Level",
+ "Mode",
+ "Motion",
+ "Name",
+ "RSSI",
+ "State",
+ "Tamper",
+ "Temperature C",
+ "Temperature F",
+}
+
+--- Bot-only properties
+local BOT_PROPERTIES = {
+ "Device Data", -- Label
+ "Mode",
+ "State",
+}
+
+--- Encryption-related properties (hidden for devices that don't need encryption)
+--- @type string[]
+local ENCRYPTION_PROPERTIES = {
+ "Encryption Key",
+ "Encryption Settings", -- Label
+ "Encryption Status",
+ "Key ID",
+ "SwitchBot Password",
+ "SwitchBot Username",
+}
+
+--- Switch channel properties
+--- @type string[]
+local CHANNEL_1_PROPERTIES = {
+ "Device Data", -- Label
+ "Channel 1 Power",
+ "Channel 1 State",
+ "Channel 1",
+}
+
+--- Channel 2 properties (2PM only)
+--- @type string[]
+local CHANNEL_2_PROPERTIES = {
+ "Device Data", -- Label
+ "Channel 2 Power",
+ "Channel 2 State",
+ "Channel 2",
+}
+
+--- Sensor properties
+--- @type string[]
+local SENSOR_PROPERTIES = {
+ "Device Data", -- Label
+ "Battery Low",
+ "CO2",
+ "Contact",
+ "Humidity",
+ "Leak Detected",
+ "Light Level",
+ "Motion",
+ "Tamper",
+ "Temperature C",
+ "Temperature F",
+}
+
+--------------------------------------------------------------------------------
+-- Global State
+--------------------------------------------------------------------------------
+
+-- Device identification
+--- @type string?
+local deviceType = nil -- Full device type string (e.g., "SwitchBot Bot")
+--- @type DeviceCategory?
+local deviceCategory = nil
+--- @type SwitchType?
+local switchType = nil
+--- @type boolean
+local isPassive = false -- True for sensor devices
+--- @type boolean
+local requiresEncryption = false
+--- @type boolean
+local isDualChannel = false
+
+-- Connection state (runtime only, not persisted)
+--- @type { cmd: string, channel: 1|2|nil, requiresEncryption: boolean, isOn: boolean }|nil
+local pendingCommand = nil
+--- @type boolean
+local awaitingCommandResponse = false
+--- @type boolean
+local notificationsSubscribed = false
+--- @type boolean
+local notificationsSubscribing = false
+--- @type integer
+local DISCONNECT_DELAY_MS = 4000
+--- @type integer
+local PRESS_REVERT_DELAY_MS = 5000
+
+-- Encryption state (runtime only)
+--- @type string|nil
+local encryptionIV = nil
+--- @type boolean
+local pendingIVRequest = false
+
+--------------------------------------------------------------------------------
+-- 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
+
+--- Hide encryption properties (for devices that don't need encryption)
+local function hideEncryptionProperties()
+ log:trace("hideEncryptionProperties()")
+ for _, propName in ipairs(ENCRYPTION_PROPERTIES) do
+ C4:SetPropertyAttribs(propName, constants.HIDE_PROPERTY)
+ end
+end
+
+--- Show encryption properties (for Relay switches which need encryption)
+local function showEncryptionProperties()
+ log:trace("showEncryptionProperties()")
+ for _, propName in ipairs(ENCRYPTION_PROPERTIES) do
+ C4:SetPropertyAttribs(propName, constants.SHOW_PROPERTY)
+ end
+end
+
+--- Hide switch properties
+local function hideSwitchProperties()
+ log:trace("hideSwitchProperties()")
+ for _, propName in ipairs(CHANNEL_1_PROPERTIES) do
+ C4:SetPropertyAttribs(propName, constants.HIDE_PROPERTY)
+ end
+ for _, propName in ipairs(CHANNEL_2_PROPERTIES) do
+ C4:SetPropertyAttribs(propName, constants.HIDE_PROPERTY)
+ end
+end
+
+--- Show channel 1 properties
+local function showChannel1Properties()
+ log:trace("showChannel1Properties()")
+ for _, propName in ipairs(CHANNEL_1_PROPERTIES) do
+ C4:SetPropertyAttribs(propName, constants.SHOW_PROPERTY)
+ end
+end
+
+--- Show channel 2 properties (for 2PM only)
+local function showChannel2Properties()
+ log:trace("showChannel2Properties()")
+ for _, propName in ipairs(CHANNEL_2_PROPERTIES) do
+ C4:SetPropertyAttribs(propName, constants.SHOW_PROPERTY)
+ end
+end
+
+--- Show bot properties
+local function showBotProperties()
+ log:trace("showBotProperties()")
+ for _, propName in ipairs(BOT_PROPERTIES) do
+ C4:SetPropertyAttribs(propName, constants.SHOW_PROPERTY)
+ end
+end
+
+--- Hide bot properties
+local function hideBotProperties()
+ log:trace("hideBotProperties()")
+ for _, propName in ipairs(BOT_PROPERTIES) do
+ C4:SetPropertyAttribs(propName, constants.HIDE_PROPERTY)
+ end
+end
+
+--- Show sensor properties
+local function showSensorProperties()
+ log:trace("showSensorProperties()")
+ -- Sensor properties are dynamically shown/hidden based on data received, but we need to show the label
+ C4:SetPropertyAttribs("Device Data", constants.SHOW_PROPERTY)
+end
+
+--- Hide sensor properties
+local function hideSensorProperties()
+ log:trace("hideSensorProperties()")
+ for _, propName in ipairs(SENSOR_PROPERTIES) do
+ C4:SetPropertyAttribs(propName, constants.HIDE_PROPERTY)
+ end
+end
+
+--------------------------------------------------------------------------------
+-- Handle Management
+--------------------------------------------------------------------------------
+
+--- Get BLE handles from persist
+--- @return integer|nil txHandle TX characteristic handle
+--- @return integer|nil rxHandle RX characteristic handle
+local function getHandles()
+ log:trace("getHandles()")
+ return tointeger(persist:get("TX_HANDLE")), tointeger(persist:get("RX_HANDLE"))
+end
+
+--- Save BLE handles to persist
+--- @param txHandle integer|nil TX characteristic handle
+--- @param rxHandle integer|nil RX characteristic handle
+local function saveHandles(txHandle, rxHandle)
+ log:trace("saveHandles(%s, %s)", txHandle, rxHandle)
+ persist:set("TX_HANDLE", txHandle)
+ persist:set("RX_HANDLE", rxHandle)
+end
+
+--- Reset connection state
+--- @param clearHandles boolean|nil If true, also clear persisted BLE handles
+local function resetConnectionState(clearHandles)
+ log:trace("resetConnectionState(%s)", clearHandles)
+ -- Connection state
+ pendingCommand = nil
+ awaitingCommandResponse = false
+ notificationsSubscribed = false
+ notificationsSubscribing = false
+
+ -- Encryption state
+ encryptionIV = nil
+ pendingIVRequest = false
+
+ if clearHandles then
+ saveHandles(nil, nil)
+ end
+end
+
+--------------------------------------------------------------------------------
+-- Connection Management
+--------------------------------------------------------------------------------
+
+--- Request GATT connection from parent driver
+local function requestConnection()
+ log:trace("requestConnection()")
+ SendToProxy(ESPHOME_BINDING, "CONNECT", {}, "NOTIFY")
+end
+
+--- Actually perform the GATT disconnection
+local function doDisconnect()
+ log:trace("doDisconnect()")
+ resetConnectionState()
+ SendToProxy(ESPHOME_BINDING, "DISCONNECT", {}, "NOTIFY")
+end
+
+--- Request GATT disconnection from parent driver (debounced)
+local function requestDisconnect()
+ log:trace("requestDisconnect()")
+ SetTimer("DisconnectDelay", DISCONNECT_DELAY_MS, doDisconnect)
+end
+
+--- Cancel any pending disconnect (called when starting a new command)
+local function cancelPendingDisconnect()
+ log:trace("cancelPendingDisconnect()")
+ CancelTimer("DisconnectDelay")
+end
+
+--- Subscribe to GATT notifications on RX handle
+local function subscribeNotifications()
+ log:trace("subscribeNotifications()")
+ local _, rxHandle = getHandles()
+ if not rxHandle then
+ log:warn("Cannot subscribe to notifications: RX handle not discovered")
+ return
+ end
+
+ if notificationsSubscribed then
+ log:debug("Notifications already subscribed")
+ return
+ end
+
+ if notificationsSubscribing then
+ log:debug("Notifications subscription already in progress")
+ return
+ end
+
+ notificationsSubscribing = true
+ log:debug("Subscribing to GATT notifications on handle %s", rxHandle)
+ SendToProxy(ESPHOME_BINDING, "GATT_NOTIFY", {
+ handle = tostring(rxHandle),
+ enable = "true",
+ }, "NOTIFY")
+end
+
+--------------------------------------------------------------------------------
+-- Value Helpers
+--------------------------------------------------------------------------------
+
+--- Update the "Last Seen" timestamp
+local function updateLastSeen()
+ log:trace("updateLastSeen()")
+ values:update("Last Seen", tostring(os.date("%Y-%m-%d %H:%M:%S")))
+end
+
+--- Update RSSI value
+--- @param rssi string|number RSSI value
+local function updateRSSI(rssi)
+ log:trace("updateRSSI(%s)", rssi)
+ local rssiNum = tonumber(rssi) or -999
+ if rssiNum > -999 then
+ values:update("RSSI", rssiNum, nil, nil, " dBm")
+ end
+end
+
+--- Set battery level
+--- @param level number Battery percentage
+local function setBatteryLevel(level)
+ log:trace("setBatteryLevel(%s)", level)
+ local battery = math.max(0, math.min(tointeger(level) or 0, 100))
+ values:update("Battery", battery, "NUMBER", nil, " %")
+end
+
+--- Get the current Bot state
+--- @return boolean isOn True if Bot is on
+local function getBotState()
+ log:trace("getBotState()")
+ return Select(values:getValue("State"), "value") == "On"
+end
+
+--- Check if Bot is in switch mode (vs press mode)
+--- @return boolean isSwitch True if Bot is in switch mode
+local function isBotSwitchMode()
+ log:trace("isBotSwitchMode()")
+ return Select(values:getValue("Mode"), "value") == "Switch"
+end
+
+--- Set the Bot state and notify bound consumers
+--- @param isOn boolean Whether the Bot is on
+local function setBotState(isOn)
+ log:trace("setBotState(%s)", isOn)
+ local changed = values:update("State", isOn and "On" or "Off", "STRING")
+ if changed then
+ local binding = bindings:getDynamicBinding(BINDINGS_NAMESPACE, "botRelay")
+ if binding then
+ SendToProxy(binding.bindingId, isOn and "CLOSED" or "OPENED", {}, "NOTIFY")
+ end
+ end
+
+ -- Press mode safety: auto-revert to OFF after a timeout in case the normal
+ -- revert path (GATT_WRITE_RESPONSE) is missed due to disconnection or error.
+ if isOn and not isBotSwitchMode() then
+ SetTimer("PressRevert", PRESS_REVERT_DELAY_MS, function()
+ log:debug("Press mode safety revert: setting Bot state to OFF")
+ setBotState(false)
+ end)
+ else
+ CancelTimer("PressRevert")
+ end
+end
+
+--- Set the Bot mode and return whether it changed
+--- @param isSwitch boolean Whether the device is in switch mode
+--- @return boolean changed True if mode changed
+local function setBotMode(isSwitch)
+ log:trace("setBotMode(%s)", isSwitch)
+ local mode = isSwitch and "Switch" or "Press"
+ local changed = values:update("Mode", mode, "STRING")
+ if changed then
+ log:info("Bot mode changed to %s", mode)
+ end
+ return changed
+end
+
+--- Get the current Channel 1 state
+--- @return boolean isOn True if Channel 1 is on
+local function getChannel1State()
+ log:trace("getChannel1State()")
+ return Select(values:getValue("Channel 1 State"), "value") == "On"
+end
+
+--- Set the Channel 1 state and notify bound consumers
+--- @param isOn boolean Whether Channel 1 is on
+local function setChannel1State(isOn)
+ log:trace("setChannel1State(%s)", isOn)
+ local changed = values:update("Channel 1 State", isOn and "On" or "Off", "STRING")
+ if changed then
+ local binding = bindings:getDynamicBinding(BINDINGS_NAMESPACE, "channel1")
+ if binding then
+ SendToProxy(binding.bindingId, isOn and "CLOSED" or "OPENED", {}, "NOTIFY")
+ end
+ end
+end
+
+--- Get the current Channel 2 state
+--- @return boolean isOn True if Channel 2 is on
+local function getChannel2State()
+ log:trace("getChannel2State()")
+ return Select(values:getValue("Channel 2 State"), "value") == "On"
+end
+
+--- Set the Channel 2 state and notify bound consumers
+--- @param isOn boolean Whether Channel 2 is on
+local function setChannel2State(isOn)
+ log:trace("setChannel2State(%s)", isOn)
+ if not isDualChannel then
+ return
+ end
+ local changed = values:update("Channel 2 State", isOn and "On" or "Off", "STRING")
+ if changed then
+ local binding = bindings:getDynamicBinding(BINDINGS_NAMESPACE, "channel2")
+ if binding then
+ SendToProxy(binding.bindingId, isOn and "CLOSED" or "OPENED", {}, "NOTIFY")
+ end
+ end
+end
+
+--- Set the Channel 1 power reading
+--- @param power number|nil Power in watts
+local function setChannel1Power(power)
+ log:trace("setChannel1Power(%s)", power)
+ if type(power) == "number" then
+ values:update("Channel 1 Power", round(power, 2), "NUMBER", nil, " W")
+ end
+end
+
+--- Set the Channel 2 power reading
+--- @param power number|nil Power in watts
+local function setChannel2Power(power)
+ log:trace("setChannel2Power(%s)", power)
+ if type(power) == "number" then
+ values:update("Channel 2 Power", round(power, 2), "NUMBER", nil, " W")
+ end
+end
+
+--------------------------------------------------------------------------------
+-- Device Type Detection
+--------------------------------------------------------------------------------
+
+--- Detect device category from device type string
+--- @param deviceTypeStr string|nil Device type name
+--- @return DeviceCategory?
+local function detectDeviceCategory(deviceTypeStr)
+ log:trace("detectDeviceCategory(%s)", deviceTypeStr)
+ if not deviceTypeStr then
+ return nil
+ end
+
+ -- Check for Bot
+ if SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.BOT] == deviceTypeStr then
+ return DEVICE_CATEGORY.BOT
+ end
+
+ -- Check for sensor devices (Meter, Motion, Presence, Contact, Leak)
+ if
+ SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.METER] == deviceTypeStr
+ or SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.METER_PLUS] == deviceTypeStr
+ or SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.METER_PRO] == deviceTypeStr
+ or SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.METER_PRO_CO2] == deviceTypeStr
+ or SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.INDOOR_OUTDOOR_METER] == deviceTypeStr
+ or SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.CONTACT] == deviceTypeStr
+ or SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.MOTION] == deviceTypeStr
+ or SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.PRESENCE] == deviceTypeStr
+ or SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.WATER_LEAK] == deviceTypeStr
+ or SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.REMOTE] == deviceTypeStr
+ then
+ return DEVICE_CATEGORY.SENSOR
+ end
+
+ -- Check for switch devices (Plug Mini, Relay switches)
+ if
+ SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.PLUG_MINI] == deviceTypeStr
+ or SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.RELAY_1] == deviceTypeStr
+ or SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.RELAY_1PM] == deviceTypeStr
+ or SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.RELAY_2PM] == deviceTypeStr
+ then
+ return DEVICE_CATEGORY.SWITCH
+ end
+
+ return nil
+end
+
+--- Determine switch subtype from device type string
+--- @param deviceTypeStr string Device type name
+--- @return SwitchType|nil
+local function detectSwitchType(deviceTypeStr)
+ log:trace("detectSwitchType(%s)", deviceTypeStr)
+ if not deviceTypeStr then
+ return nil
+ end
+
+ if deviceTypeStr:match("Plug") then
+ return SWITCH_TYPE.PLUG
+ elseif deviceTypeStr:match("2PM") then
+ return SWITCH_TYPE.RELAY_2PM
+ elseif deviceTypeStr:match("1PM") then
+ return SWITCH_TYPE.RELAY_1PM
+ elseif deviceTypeStr:match("Relay") then
+ return SWITCH_TYPE.RELAY
+ end
+
+ return nil
+end
+
+--------------------------------------------------------------------------------
+-- Dynamic Binding Creation (Relay)
+--------------------------------------------------------------------------------
+
+--- Get or create channel 1 relay binding (for Switch devices)
+--- @return Binding|nil binding
+local function getOrCreateChannel1Binding()
+ log:trace("getOrCreateChannel1Binding()")
+ local binding =
+ bindings:getOrAddDynamicBinding(BINDINGS_NAMESPACE, "channel1", "CONTROL", true, "Channel 1 Relay", "RELAY")
+
+ if binding then
+ log:info("Created RELAY binding for channel 1 (id=%s)", binding.bindingId)
+
+ -- Register OBC handler
+ 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
+ SendToProxy(binding.bindingId, getChannel1State() and "STATE_CLOSED" or "STATE_OPENED", {}, "NOTIFY")
+ end
+ end
+ end
+
+ return binding
+end
+
+--- Get or create channel 2 relay binding
+--- @return Binding|nil binding
+local function getOrCreateChannel2Binding()
+ log:trace("getOrCreateChannel2Binding()")
+ local binding =
+ bindings:getOrAddDynamicBinding(BINDINGS_NAMESPACE, "channel2", "CONTROL", true, "Channel 2 Relay", "RELAY")
+
+ if binding then
+ log:info("Created RELAY binding for channel 2 (id=%s)", binding.bindingId)
+
+ -- Register OBC handler
+ 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
+ SendToProxy(binding.bindingId, getChannel2State() and "STATE_CLOSED" or "STATE_OPENED", {}, "NOTIFY")
+ end
+ end
+ end
+
+ return binding
+end
+
+--- Get or create Bot relay binding
+--- @return Binding|nil binding
+local function getOrCreateBotRelayBinding()
+ log:trace("getOrCreateBotRelayBinding()")
+ local binding = bindings:getOrAddDynamicBinding(BINDINGS_NAMESPACE, "botRelay", "CONTROL", true, "Relay", "RELAY")
+
+ if binding then
+ log:info("Created Bot RELAY binding (id=%s)", binding.bindingId)
+
+ -- Register OBC handler
+ 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
+ SendToProxy(binding.bindingId, getBotState() and "STATE_CLOSED" or "STATE_OPENED", {}, "NOTIFY")
+ end
+ end
+ end
+
+ return binding
+end
+
+--------------------------------------------------------------------------------
+-- Dynamic Binding Creation (Sensor)
+--------------------------------------------------------------------------------
+
+--- Get or create a sensor binding (temperature/humidity)
+--- @param sensorType SwitchBotSensorType
+--- @return Binding|nil binding
+local function getOrCreateSensorBinding(sensorType)
+ log:trace("getOrCreateSensorBinding(%s)", sensorType)
+ local config = SENSOR_BINDINGS[sensorType]
+ if not config then
+ return nil
+ end
+
+ local binding = bindings:getOrAddDynamicBinding(
+ BINDINGS_NAMESPACE,
+ sensorType,
+ "CONTROL",
+ true,
+ config.displayName,
+ config.bindingClass
+ )
+
+ if binding then
+ log:info("Created %s binding for %s (id=%s)", config.bindingClass, config.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
+ local cachedValue = values:getValue(config.displayName)
+ if cachedValue and cachedValue.value then
+ SendToProxy(idBinding, "VALUE_CHANGED", { VALUE = cachedValue.value, SCALE = config.scale })
+ 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
+ local cachedValue = values:getValue(config.displayName)
+ if cachedValue and cachedValue.value then
+ SendToProxy(idBinding, "VALUE_CHANGED", { VALUE = cachedValue.value, SCALE = config.scale })
+ end
+ end
+ end
+ end
+
+ return binding
+end
+
+--- Send sensor value to bound consumers
+--- @param sensorType SwitchBotSensorType
+--- @param value number
+local function sendSensorValue(sensorType, value)
+ log:trace("sendSensorValue(%s, %s)", sensorType, value)
+ local config = SENSOR_BINDINGS[sensorType]
+ if not config then
+ return
+ end
+
+ local binding = getOrCreateSensorBinding(sensorType)
+ if not binding then
+ return
+ end
+
+ SendToProxy(binding.bindingId, "VALUE_CHANGED", { VALUE = value, SCALE = config.scale })
+end
+
+--- Get or create a contact sensor binding
+--- @param contactType ContactType
+--- @return Binding|nil binding
+local function getOrCreateContactBinding(contactType)
+ log:trace("getOrCreateContactBinding(%s)", contactType)
+ local config = CONTACT_BINDINGS[contactType]
+ if not config then
+ return nil
+ end
+
+ local binding = bindings:getOrAddDynamicBinding(
+ BINDINGS_NAMESPACE,
+ "contact_" .. contactType,
+ "PROXY",
+ true,
+ config.displayName,
+ "CONTACT_SENSOR"
+ )
+
+ if binding then
+ log:info("Created CONTACT_SENSOR binding for %s (id=%s)", config.displayName, binding.bindingId)
+ end
+
+ return binding
+end
+
+--- Send contact sensor state (only on state change)
+--- @param contactType ContactType
+--- @param isActive boolean
+local function sendContactState(contactType, isActive)
+ log:trace("sendContactState(%s, %s, %s)", contactType, isActive)
+ local binding = getOrCreateContactBinding(contactType)
+ if not binding then
+ return
+ end
+
+ local config = CONTACT_BINDINGS[contactType]
+ if not config then
+ return
+ end
+
+ -- Check if state has changed from last known value
+ local stateKey = "contact_" .. contactType
+ local prevState = persist:get("previousState", {})
+ local lastState = prevState[stateKey]
+ if lastState == isActive then
+ return
+ end
+
+ -- Update persisted state
+ prevState[stateKey] = isActive
+ persist:set("previousState", prevState)
+
+ local event = isActive and config.closedEvent or config.openEvent
+ log:debug("Sending %s to contact binding %s (state changed)", event, binding.bindingId)
+ SendToProxy(binding.bindingId, event, {}, "NOTIFY")
+end
+
+--- Get or create a button binding
+--- @param key string The binding key (e.g., "contact_button")
+--- @param displayName string The display name for the binding
+--- @return Binding|nil binding
+local function getOrCreateButtonBinding(key, displayName)
+ log:trace("getOrCreateButtonBinding(%s, %s)", key, displayName)
+ local binding = bindings:getOrAddDynamicBinding(BINDINGS_NAMESPACE, key, "CONTROL", false, 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 press event to bound consumers
+--- @param key string The binding key (e.g., "contact_button")
+--- @param displayName string The display name for the binding
+local function sendButtonEvent(key, displayName)
+ log:trace("sendButtonEvent(%s, %s)", key, displayName)
+ local binding = getOrCreateButtonBinding(key, displayName)
+ 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
+
+--------------------------------------------------------------------------------
+-- Event Firing (Sensor)
+--------------------------------------------------------------------------------
+
+--- Fire an event if state changed
+--- @param stateKey string State key for tracking
+--- @param currentState boolean
+--- @param trueEvent string Event key when state becomes true
+--- @param falseEvent string Event key when state becomes false
+local function fireStateChangeEvent(stateKey, currentState, trueEvent, falseEvent)
+ log:trace("fireStateChangeEvent(%s, %s, %s, %s)", stateKey, currentState, trueEvent, falseEvent)
+ local prevState = persist:get("previousState", {})
+ local prev = prevState[stateKey]
+ if prev == nil then
+ -- Don't fire event on initial state load
+ prevState[stateKey] = currentState
+ persist:set("previousState", prevState)
+ return
+ end
+
+ if currentState == prev then
+ return
+ end
+
+ if currentState and not prev then
+ events:fire(EVENT_NAMESPACE, trueEvent)
+ elseif not currentState and prev then
+ events:fire(EVENT_NAMESPACE, falseEvent)
+ end
+
+ prevState[stateKey] = currentState
+ persist:set("previousState", prevState)
+end
+
+--------------------------------------------------------------------------------
+-- Encryption
+--------------------------------------------------------------------------------
+
+--- Check if encryption is configured (key and key_id provided)
+--- @return boolean
+local function isEncryptionConfigured()
+ log:trace("isEncryptionConfigured()")
+ local keyId = Properties["Key ID"]
+ local key = Properties["Encryption Key"]
+ return not IsEmpty(keyId) and not IsEmpty(key)
+end
+
+--- Check if encryption is ready (configured and IV retrieved)
+--- @return boolean
+local function isEncryptionReady()
+ log:trace("isEncryptionReady()")
+ return isEncryptionConfigured() and encryptionIV ~= nil
+end
+
+--- Request IV from device via GATT write
+local function requestIV()
+ log:trace("requestIV()")
+ local txHandle, _rxHandle = getHandles()
+ if not txHandle then
+ log:error("Cannot request IV: TX handle not discovered")
+ return
+ end
+
+ if not isEncryptionConfigured() then
+ log:debug("Encryption not configured, skipping IV request")
+ return
+ end
+
+ if pendingIVRequest then
+ log:debug("IV request already pending")
+ return
+ end
+
+ log:info("Requesting IV from device")
+ pendingIVRequest = true
+ UpdateProperty("Encryption Status", "Requesting IV...")
+
+ local keyId = Properties["Key ID"]
+ local keyIdByte = tonumber(keyId, 16)
+ if not keyIdByte or keyIdByte < 0 or keyIdByte > 255 then
+ log:error("Invalid key ID: %s", keyId or "nil")
+ pendingIVRequest = false
+ UpdateProperty("Encryption Status", "Error: Invalid Key ID")
+ return
+ end
+
+ local cmd = IV_REQUEST_PREFIX .. string.char(keyIdByte)
+ log:debug("IV request command: %s (%d bytes)", C4:Encode(cmd, "HEX"), #cmd)
+
+ SendToProxy(ESPHOME_BINDING, "GATT_WRITE", {
+ handle = tostring(txHandle),
+ data = C4:Base64Encode(cmd),
+ response = "true",
+ }, "NOTIFY")
+end
+
+--- Process IV response from device
+--- @param data string Binary data from GATT notification
+--- @return boolean success True if IV was successfully extracted
+local function processIVResponse(data)
+ log:trace("processIVResponse(%s)", data)
+
+ if not data or #data < 5 then
+ log:error("IV response too short: %d bytes (need at least 5)", data and #data or 0)
+ return false
+ end
+
+ local status = string.byte(data, 1)
+ if status ~= 1 then
+ log:error("IV request failed: status=%d (expected 1)", status)
+ return false
+ end
+
+ local ivPart = data:sub(5)
+ if #ivPart == 0 then
+ log:error("IV response has no IV data after status bytes")
+ return false
+ end
+
+ local fullIV
+ if #ivPart >= 16 then
+ fullIV = ivPart:sub(1, 16)
+ else
+ fullIV = ivPart .. string.rep("\x00", 16 - #ivPart)
+ end
+
+ encryptionIV = fullIV
+ pendingIVRequest = false
+
+ log:info("IV retrieved successfully (%d bytes)", #ivPart)
+ UpdateProperty("Encryption Status", "Ready")
+
+ return true
+end
+
+--- Encrypt a command for sending to device
+--- @param cmd string Command bytes to encrypt
+--- @return string|nil encrypted Encrypted command ready to send
+local function encryptCommand(cmd)
+ log:trace("encryptCommand(%s)", cmd)
+ if not isEncryptionReady() then
+ log:error("Cannot encrypt: encryption not ready")
+ return nil
+ end
+ --- @cast Properties["Encryption Key"] -nil
+ --- @cast Properties["Key ID"] -nil
+ --- @cast encryptionIV -nil
+
+ if #cmd < 2 then
+ log:error("Command too short to encrypt: %d bytes", #cmd)
+ return nil
+ end
+
+ local cmdHeader = cmd:sub(1, 1)
+ local cmdPayload = cmd:sub(2)
+
+ --- @type string
+ local keyHex = Properties["Encryption Key"]
+ local keyBytes = C4:Decode(keyHex, "HEX")
+ local encryptedPayload = C4:Encrypt("AES-128-CTR", keyBytes, encryptionIV, cmdPayload, { padding = false })
+
+ --- @type string
+ local keyId = Properties["Key ID"]
+ local keyIdByte = tonumber(keyId, 16)
+ if not keyIdByte then
+ log:error("Invalid key ID for encryption: %s", keyId or "nil")
+ return nil
+ end
+
+ local ivPrefix = encryptionIV:sub(1, 2)
+ local packet = cmdHeader .. string.char(keyIdByte) .. ivPrefix .. encryptedPayload
+
+ log:debug("Encrypted command: %s -> %s", C4:Encode(cmd, "HEX"), C4:Encode(packet, "HEX"))
+
+ return packet
+end
+
+--- Validate encryption key format
+--- @param keyHex string 32-character hex string
+--- @return boolean valid
+local function validateEncryptionKey(keyHex)
+ log:trace("validateEncryptionKey(%s)", keyHex)
+ if not keyHex or keyHex == "" then
+ return false
+ end
+
+ if #keyHex ~= 32 then
+ log:error("Encryption key must be 32 hex characters (got %d)", #keyHex)
+ return false
+ end
+
+ if not keyHex:match("^[0-9a-fA-F]+$") then
+ log:error("Encryption key must be hex characters only")
+ return false
+ end
+
+ local keyBytes = C4:Decode(keyHex, "HEX")
+ if #keyBytes ~= 16 then
+ log:error("Failed to convert encryption key")
+ return false
+ end
+
+ log:info("Encryption key validated successfully")
+ encryptionIV = nil
+
+ return true
+end
+
+--- Validate key ID format
+--- @param keyId string 2-character hex key ID
+--- @return boolean valid
+local function validateKeyId(keyId)
+ log:trace("validateKeyId(%s)", keyId)
+ if not keyId or keyId == "" then
+ return false
+ end
+
+ if #keyId ~= 2 then
+ log:error("Key ID must be 2 hex characters (got %d)", #keyId)
+ return false
+ end
+
+ if not keyId:match("^[0-9a-fA-F]+$") then
+ log:error("Key ID must be hex characters only")
+ return false
+ end
+
+ local keyIdByte = tonumber(keyId, 16)
+ if not keyIdByte then
+ log:error("Failed to convert key ID to number")
+ return false
+ end
+
+ log:info("Key ID validated: %s", keyId)
+ encryptionIV = nil
+
+ return true
+end
+
+--- Update encryption status property based on current state
+local function updateEncryptionStatus()
+ log:trace("updateEncryptionStatus()")
+ if not requiresEncryption then
+ UpdateProperty("Encryption Status", "Not Required")
+ return
+ end
+
+ if not isEncryptionConfigured() then
+ UpdateProperty("Encryption Status", "Required - Not Configured")
+ elseif isEncryptionReady() then
+ UpdateProperty("Encryption Status", "Ready")
+ elseif pendingIVRequest then
+ UpdateProperty("Encryption Status", "Requesting IV...")
+ else
+ UpdateProperty("Encryption Status", "Configured (IV needed)")
+ end
+end
+
+--------------------------------------------------------------------------------
+-- Command Sending
+--------------------------------------------------------------------------------
+
+--- Get the appropriate command for the device type and action
+--- @param action string "on", "off", or "press"
+--- @param channel 1|2|nil Channel number (1 or 2)
+--- @return string|nil cmd Command bytes
+local function getCommand(action, channel)
+ log:trace("getCommand(%s, %s)", action, channel)
+ if deviceCategory == DEVICE_CATEGORY.BOT then
+ -- Bot commands
+ if action == "press" or (action == "on" and not isBotSwitchMode()) then
+ return CMD_BOT_PRESS
+ elseif action == "on" then
+ return CMD_BOT_ON
+ elseif action == "off" then
+ -- In press mode, turn off is a no-op
+ if not isBotSwitchMode() then
+ return nil
+ end
+ return CMD_BOT_OFF
+ end
+ elseif deviceCategory == DEVICE_CATEGORY.SWITCH then
+ -- Switch commands (Plug Mini, Relay)
+ if switchType == SWITCH_TYPE.PLUG then
+ if action == "on" then
+ return SwitchBot.Commands.PLUG_ON
+ else
+ return SwitchBot.Commands.PLUG_OFF
+ end
+ elseif switchType == SWITCH_TYPE.RELAY_2PM then
+ if channel == 2 then
+ if action == "on" then
+ return SwitchBot.Commands.RELAY_2PM_CH2_ON
+ else
+ return SwitchBot.Commands.RELAY_2PM_CH2_OFF
+ end
+ else
+ if action == "on" then
+ return SwitchBot.Commands.RELAY_2PM_CH1_ON
+ else
+ return SwitchBot.Commands.RELAY_2PM_CH1_OFF
+ end
+ end
+ else
+ -- Relay 1 and 1PM
+ if action == "on" then
+ return SwitchBot.Commands.RELAY_ON
+ else
+ return SwitchBot.Commands.RELAY_OFF
+ end
+ end
+ end
+
+ log:error("Unknown device category or action: %s/%s", deviceCategory or "nil", action)
+ return nil
+end
+
+--- Send command to device via GATT write
+--- @param cmd string Command bytes to send
+--- @param needsEncryption boolean If true, encrypt the command
+--- @return boolean success True if command was sent
+local function sendCommand(cmd, needsEncryption)
+ log:trace("sendCommand(%s, %s)", cmd, needsEncryption)
+ local txHandle, _rxHandle = getHandles()
+ if not txHandle then
+ log:error("Cannot send command: TX handle not discovered")
+ return false
+ end
+
+ local dataToSend
+ if needsEncryption then
+ if not isEncryptionConfigured() then
+ log:error("Encryption required but keys not configured")
+ UpdateProperty("Encryption Status", "Error: Keys not configured")
+ return false
+ end
+ if not isEncryptionReady() then
+ log:error("Encryption required but IV not retrieved")
+ UpdateProperty("Encryption Status", "Error: IV not retrieved")
+ return false
+ end
+ dataToSend = encryptCommand(cmd)
+ if not dataToSend then
+ UpdateProperty("Encryption Status", "Error: Command encryption failed")
+ return false
+ end
+ else
+ dataToSend = cmd
+ end
+
+ awaitingCommandResponse = true
+
+ log:debug("Sending %s command: %s", needsEncryption and "encrypted" or "plain", C4:Encode(dataToSend, "HEX"))
+
+ SendToProxy(ESPHOME_BINDING, "GATT_WRITE", {
+ handle = tostring(txHandle),
+ data = C4:Base64Encode(dataToSend),
+ response = "true",
+ }, "NOTIFY")
+
+ return true
+end
+
+--- Execute pending command if one exists and we're ready
+local function executePendingCommand()
+ log:trace("executePendingCommand()")
+ if not pendingCommand then
+ return
+ end
+
+ local cmd = pendingCommand
+ log:debug("Executing pending command")
+
+ local needsEncryption = cmd.requiresEncryption
+ if needsEncryption and not isEncryptionReady() then
+ log:debug("Pending command waiting for encryption to be ready")
+ return
+ end
+
+ if sendCommand(cmd.cmd, needsEncryption) then
+ -- Optimistically update state
+ if deviceCategory == DEVICE_CATEGORY.BOT then
+ setBotState(cmd.isOn)
+ elseif deviceCategory == DEVICE_CATEGORY.SWITCH then
+ if cmd.channel == 2 then
+ setChannel2State(cmd.isOn)
+ else
+ setChannel1State(cmd.isOn)
+ end
+ end
+ else
+ log:error("Failed to execute pending command")
+ pendingCommand = nil
+ requestDisconnect()
+ end
+
+ pendingCommand = nil
+end
+
+--- Check if we're ready to send a command
+--- @return boolean ready True if we can send commands immediately
+local function isReadyToSend()
+ log:trace("isReadyToSend()")
+ local txHandle, _rxHandle = getHandles()
+ if not txHandle then
+ return false
+ end
+ if requiresEncryption and not isEncryptionReady() then
+ return false
+ end
+ return true
+end
+
+--- Initiate a command - either send immediately or queue and connect
+--- @param cmd string The command bytes
+--- @param channel 1|2|nil The channel (1 or 2)
+--- @param isOn boolean Whether this is an ON command
+local function initiateCommand(cmd, channel, isOn)
+ log:trace("initiateCommand(%s, %s, %s)", cmd, channel, isOn)
+ cancelPendingDisconnect()
+
+ if isReadyToSend() then
+ log:debug("Ready to send, executing command immediately")
+ if sendCommand(cmd, requiresEncryption) then
+ if deviceCategory == DEVICE_CATEGORY.BOT then
+ setBotState(isOn)
+ elseif deviceCategory == DEVICE_CATEGORY.SWITCH then
+ if channel == 2 then
+ setChannel2State(isOn)
+ else
+ setChannel1State(isOn)
+ end
+ end
+ end
+ return
+ end
+
+ log:debug("Not ready, queuing command and connecting")
+ pendingCommand = {
+ cmd = cmd,
+ channel = channel,
+ requiresEncryption = requiresEncryption,
+ isOn = isOn,
+ }
+
+ UpdateProperty("Driver Status", "Busy")
+ requestConnection()
+end
+
+--- Turn on (channel 1 or specified channel)
+--- @param channel 1|2|nil Channel number (default 1)
+local function turnOn(channel)
+ log:trace("turnOn(%s)", channel)
+ local cmd = getCommand("on", channel)
+ if cmd then
+ initiateCommand(cmd, channel, true)
+ end
+end
+
+--- Turn off (channel 1 or specified channel)
+--- @param channel 1|2|nil Channel number (default 1)
+local function turnOff(channel)
+ log:trace("turnOff(%s)", channel)
+ local cmd = getCommand("off", channel)
+ if cmd then
+ initiateCommand(cmd, channel, false)
+ end
+end
+
+--- Toggle channel
+--- @param channel 1|2|nil Channel number (default 1)
+local function toggle(channel)
+ log:trace("toggle(%s)", channel)
+
+ local currentState
+ if deviceCategory == DEVICE_CATEGORY.BOT then
+ currentState = getBotState()
+ elseif channel == 2 then
+ currentState = getChannel2State()
+ else
+ currentState = getChannel1State()
+ end
+
+ if currentState then
+ turnOff(channel)
+ else
+ turnOn(channel)
+ end
+end
+
+--------------------------------------------------------------------------------
+-- Dynamic Binding Creation (Bot)
+--------------------------------------------------------------------------------
+
+--- Register RFP handlers for a BUTTON_LINK binding (Bot only).
+--- @param binding Binding|nil The binding to register handlers for
+--- @param action string The action to perform: "on", "off", "toggle", or "press"
+local function registerBotButtonLinkHandler(binding, action)
+ log:trace("registerBotButtonLinkHandler(%s, %s)", binding, action)
+ if not binding then
+ return
+ end
+
+ RFP[binding.bindingId] = function(idBinding, strCommand, _tParams, _args)
+ log:trace("RFP[%s](%s, %s, %s, %s) action=%s", binding.bindingId, idBinding, strCommand, _tParams, _args, action)
+ if strCommand ~= "DO_CLICK" and strCommand ~= "DO_PUSH" then
+ return
+ end
+
+ log:info("Button action %s received on '%s' binding", action, binding.displayName)
+ if action == "on" or action == "press" then
+ turnOn()
+ elseif action == "off" then
+ turnOff()
+ elseif action == "toggle" then
+ toggle()
+ end
+ end
+end
+
+--- Get or create BUTTON_LINK bindings based on Bot mode.
+--- Press mode: single "Press" binding
+--- Switch mode: "On", "Off", "Toggle" bindings
+--- @param isSwitch boolean Whether the device is in switch mode
+local function getOrCreateBotModeBindings(isSwitch)
+ log:trace("getOrCreateBotModeBindings(%s)", isSwitch)
+
+ if isSwitch then
+ -- Switch mode: On, Off, Toggle bindings - remove press binding if exists
+ bindings:deleteBinding(BINDINGS_NAMESPACE, "press")
+
+ local onBinding =
+ bindings:getOrAddDynamicBinding(BINDINGS_NAMESPACE, "on", "CONTROL", true, "Turn On", "BUTTON_LINK")
+ local offBinding =
+ bindings:getOrAddDynamicBinding(BINDINGS_NAMESPACE, "off", "CONTROL", true, "Turn Off", "BUTTON_LINK")
+ local toggleBinding =
+ bindings:getOrAddDynamicBinding(BINDINGS_NAMESPACE, "toggle", "CONTROL", true, "Toggle", "BUTTON_LINK")
+
+ registerBotButtonLinkHandler(onBinding, "on")
+ registerBotButtonLinkHandler(offBinding, "off")
+ registerBotButtonLinkHandler(toggleBinding, "toggle")
+ else
+ -- Press mode: single Press binding - remove switch mode bindings if exist
+ bindings:deleteBinding(BINDINGS_NAMESPACE, "on")
+ bindings:deleteBinding(BINDINGS_NAMESPACE, "off")
+ bindings:deleteBinding(BINDINGS_NAMESPACE, "toggle")
+
+ local pressBinding =
+ bindings:getOrAddDynamicBinding(BINDINGS_NAMESPACE, "press", "CONTROL", true, "Press", "BUTTON_LINK")
+ registerBotButtonLinkHandler(pressBinding, "press")
+ end
+end
+
+--------------------------------------------------------------------------------
+-- Device Initialization
+--------------------------------------------------------------------------------
+
+--- Create events for a device based on its type
+local function createEventsForDevice()
+ log:trace("createEventsForDevice()")
+ if not deviceType then
+ return
+ end
+
+ local eventKeys = DEVICE_EVENTS[deviceType]
+ if IsEmpty(eventKeys) then
+ return
+ end
+ --- @cast eventKeys -nil
+
+ log:info("Creating events for %s: %s", deviceType, table.concat(eventKeys, ", "))
+ for _, eventKey in ipairs(eventKeys) do
+ local eventDef = EVENT_DEFS[eventKey]
+ if eventDef then
+ events:getOrAddEvent(EVENT_NAMESPACE, eventDef.key, eventDef.name, eventDef.description)
+ end
+ end
+end
+
+--- Initialize driver for a specific device type
+--- @param deviceTypeStr string Device type name
+local function initializeForDeviceType(deviceTypeStr)
+ log:trace("initializeForDeviceType(%s)", deviceTypeStr)
+ if not deviceTypeStr then
+ return
+ end
+
+ deviceType = deviceTypeStr
+ deviceCategory = detectDeviceCategory(deviceTypeStr)
+ if ENCRYPTED_DEVICES[deviceTypeStr] == true then
+ requiresEncryption = true
+ else
+ requiresEncryption = false
+ end
+ if DUAL_CHANNEL_DEVICES[deviceTypeStr] == true then
+ isDualChannel = true
+ else
+ isDualChannel = false
+ end
+
+ if deviceCategory == DEVICE_CATEGORY.BOT then
+ -- Bot device
+ hideSwitchProperties()
+ hideSensorProperties()
+ hideEncryptionProperties()
+ showBotProperties()
+ -- Create relay binding for Bot
+ getOrCreateBotRelayBinding()
+ -- Create mode bindings based on stored mode
+ local mode = Select(values:getValue("Mode"), "value")
+ getOrCreateBotModeBindings(mode == "Switch")
+ elseif deviceCategory == DEVICE_CATEGORY.SWITCH then
+ -- Switch device (Plug Mini, Relay)
+ switchType = detectSwitchType(deviceTypeStr)
+ hideBotProperties()
+ hideSensorProperties()
+ showChannel1Properties()
+
+ if requiresEncryption then
+ showEncryptionProperties()
+ else
+ hideEncryptionProperties()
+ end
+
+ -- Create relay binding for channel 1
+ getOrCreateChannel1Binding()
+
+ if isDualChannel then
+ showChannel2Properties()
+ -- Create relay binding for channel 2
+ getOrCreateChannel2Binding()
+ end
+ elseif deviceCategory == DEVICE_CATEGORY.SENSOR then
+ -- Sensor device (passive)
+ hideBotProperties()
+ hideSwitchProperties()
+ hideEncryptionProperties()
+ showSensorProperties()
+ else
+ -- Unknown device type
+ log:warn("Unknown device category for: %s", deviceTypeStr)
+ UpdateProperty("Driver Status", "Unknown device type: " .. (deviceTypeStr or "nil"))
+ hideBotProperties()
+ hideSwitchProperties()
+ hideSensorProperties()
+ hideEncryptionProperties()
+ end
+
+ -- Create events for this device type
+ createEventsForDevice()
+
+ log:debug(
+ "Device category: %s, passive: %s, encryption: %s, dual: %s",
+ deviceCategory or "unknown",
+ isPassive,
+ requiresEncryption,
+ isDualChannel
+ )
+end
+
+--------------------------------------------------------------------------------
+-- Data Processing
+--------------------------------------------------------------------------------
+
+----- Parse Bot status response from notification
+----- @param data string Binary data from notification
+--local function parseBotStatusResponse(data)
+-- log:trace("parseBotStatusResponse()")
+--
+-- --- @type integer?
+-- local battery = string.byte(data, 2)
+-- if battery then
+-- log:info("Battery level: %d%%", battery)
+-- setBatteryLevel(battery)
+-- end
+--
+-- if #data >= 10 then
+-- --- @type integer?
+-- local modeFlags = string.byte(data, 10)
+-- if modeFlags then
+-- local isSwitch = bit32.band(modeFlags, 0x10) ~= 0
+-- local modeChanged = setBotMode(isSwitch)
+-- if modeChanged then
+-- getOrCreateBotModeBindings(isSwitch)
+-- end
+-- if isSwitch then
+-- local isOn = bit32.band(modeFlags, 0x40) ~= 0
+-- setBotState(isOn)
+-- end
+-- end
+-- end
+--
+-- updateLastSeen()
+--end
+
+--- Process incoming SwitchBot data from advertisement or GATT
+--- @param data SwitchBotParsedData Parsed SwitchBot data
+--- @param rssi string|nil RSSI value
+local function processSwitchBotData(data, rssi)
+ log:trace("processSwitchBotData()")
+
+ updateLastSeen()
+ if rssi then
+ updateRSSI(rssi)
+ end
+
+ -- Summary parts for "Last Received" property
+ local summaryParts = {}
+
+ -- Device type
+ if not IsEmpty(data.deviceType) then
+ --- @cast data.deviceType -nil
+ local deviceTypeChanged = values:update("Device Type", data.deviceType, "STRING")
+ if deviceTypeChanged then
+ initializeForDeviceType(data.deviceType)
+ end
+ end
+
+ -- Battery
+ if type(data.battery) == "number" then
+ setBatteryLevel(data.battery)
+ table.insert(summaryParts, "Battery: " .. data.battery .. "%")
+ end
+
+ -- Bot-specific data
+ if deviceCategory == DEVICE_CATEGORY.BOT then
+ if data.isSwitchMode ~= nil then
+ local modeChanged = setBotMode(data.isSwitchMode)
+ if modeChanged then
+ getOrCreateBotModeBindings(data.isSwitchMode)
+ end
+ table.insert(summaryParts, "Mode: " .. (data.isSwitchMode and "Switch" or "Press"))
+ if data.isOn ~= nil then
+ setBotState(data.isOn)
+ table.insert(summaryParts, "State: " .. (data.isOn and "On" or "Off"))
+ end
+ end
+ UpdateProperty("Last Received", #summaryParts > 0 and table.concat(summaryParts, ", ") or "No data")
+ UpdateProperty("Driver Status", "Listening")
+ return
+ end
+
+ -- Switch-specific data (Plug Mini, Relay)
+ if deviceCategory == DEVICE_CATEGORY.SWITCH then
+ if data.channel1On ~= nil then
+ setChannel1State(data.channel1On)
+ table.insert(summaryParts, "Ch1: " .. (data.channel1On and "On" or "Off"))
+ elseif data.isOn ~= nil then
+ setChannel1State(data.isOn)
+ table.insert(summaryParts, "State: " .. (data.isOn and "On" or "Off"))
+ end
+
+ if data.channel1Power ~= nil then
+ setChannel1Power(data.channel1Power)
+ table.insert(summaryParts, "Ch1 Power: " .. round(data.channel1Power, 1) .. "W")
+ elseif data.power ~= nil then
+ setChannel1Power(data.power)
+ table.insert(summaryParts, "Power: " .. round(data.power, 1) .. "W")
+ end
+
+ if data.channel2On ~= nil then
+ setChannel2State(data.channel2On)
+ table.insert(summaryParts, "Ch2: " .. (data.channel2On and "On" or "Off"))
+ end
+
+ if data.channel2Power ~= nil then
+ setChannel2Power(data.channel2Power)
+ table.insert(summaryParts, "Ch2 Power: " .. round(data.channel2Power, 1) .. "W")
+ end
+
+ UpdateProperty("Last Received", #summaryParts > 0 and table.concat(summaryParts, ", ") or "No data")
+ UpdateProperty("Driver Status", "Listening")
+ return
+ end
+
+ -- Sensor-specific data
+ if deviceCategory == DEVICE_CATEGORY.SENSOR then
+ -- Temperature (meters)
+ if type(data.temperature) == "number" then
+ values:update("Temperature C", data.temperature, "NUMBER", nil, " °C")
+ values:update("Temperature F", c2f(data.temperature), "NUMBER", nil, " °F")
+ table.insert(summaryParts, "Temp: " .. round(data.temperature, 1) .. "°C")
+
+ sendSensorValue(SENSOR_TYPE.TEMPERATURE, data.temperature)
+ end
+
+ -- Humidity (meters)
+ if type(data.humidity) == "number" then
+ values:update("Humidity", data.humidity, "NUMBER", nil, " %")
+ table.insert(summaryParts, "Humidity: " .. round(data.humidity, 0) .. "%")
+
+ sendSensorValue(SENSOR_TYPE.HUMIDITY, data.humidity)
+ end
+
+ -- CO2 (Meter Pro CO2)
+ if type(data.co2) == "number" then
+ values:update("CO2", data.co2, "NUMBER", nil, " ppm")
+ table.insert(summaryParts, "CO2: " .. data.co2 .. " ppm")
+ end
+
+ -- Motion (motion/presence sensor)
+ if data.motionDetected ~= nil then
+ local motionStr = data.motionDetected and "Detected" or "Clear"
+ values:update("Motion", motionStr, "STRING")
+ table.insert(summaryParts, "Motion: " .. motionStr)
+
+ sendContactState(CONTACT_TYPE.MOTION, data.motionDetected)
+ fireStateChangeEvent("motion", data.motionDetected, "motion_detected", "motion_cleared")
+ end
+
+ -- Light level
+ if type(data.lightLevel) == "number" then
+ --- Normalize the light level for presence sensor to the 0-3 scale
+ local normalizedLevel
+ if data.deviceType == SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.PRESENCE] then
+ if data.lightLevel <= 2 then
+ normalizedLevel = 0
+ elseif data.lightLevel <= 6 then
+ normalizedLevel = 1
+ elseif data.lightLevel <= 17 then
+ normalizedLevel = 2
+ else
+ normalizedLevel = 3
+ end
+ else
+ normalizedLevel = data.lightLevel
+ end
+ local lightNames = { [0] = "Dark", [1] = "Dim", [2] = "Bright", [3] = "Very Bright" }
+ local lightStr = lightNames[normalizedLevel] or "Unknown"
+ values:update("Light Level", lightStr, "STRING")
+ table.insert(summaryParts, "Light: " .. lightStr)
+ end
+
+ -- Contact (contact sensor)
+ if data.contactOpen ~= nil then
+ local contactStr = data.contactOpen and "Open" or "Closed"
+ values:update("Contact", contactStr, "STRING")
+ table.insert(summaryParts, "Contact: " .. contactStr)
+
+ sendContactState(CONTACT_TYPE.CONTACT, data.contactOpen)
+ fireStateChangeEvent("contact", data.contactOpen, "contact_opened", "contact_closed")
+
+ -- At this point we know the device supports contact button
+ getOrCreateButtonBinding("contact_button", "Contact Button")
+ end
+
+ -- Leak detected (water leak detector)
+ if data.leakDetected ~= nil then
+ local leakStr = data.leakDetected and "LEAK DETECTED" or "No Leak"
+ values:update("Leak Detected", leakStr, "STRING")
+ table.insert(summaryParts, "Leak: " .. (data.leakDetected and "DETECTED" or "No"))
+
+ sendContactState(CONTACT_TYPE.LEAK, data.leakDetected)
+ fireStateChangeEvent("leak", data.leakDetected, "leak_detected", "leak_cleared")
+ end
+
+ -- Tamper (water leak detector)
+ if data.tampered ~= nil then
+ local tamperStr = data.tampered and "Yes" or "No"
+ values:update("Tamper", tamperStr, "STRING")
+ table.insert(summaryParts, "Tamper: " .. tamperStr)
+
+ sendContactState(CONTACT_TYPE.TAMPER, data.tampered)
+ fireStateChangeEvent("tamper", data.tampered, "tamper_detected", "tamper_cleared")
+ end
+
+ -- Low battery event
+ if data.lowBattery ~= nil then
+ values:update("Battery Low", data.lowBattery and "Yes" or "No", "STRING")
+
+ fireStateChangeEvent("low_battery", data.lowBattery, "low_battery", "battery_ok")
+ end
+
+ -- Button count (contact sensor)
+ if data.contactButtonCount ~= nil then
+ local prevState = persist:get("previousState", {})
+ local prevCount = prevState.contactButtonCount
+ if prevCount ~= nil and prevCount ~= data.contactButtonCount then
+ log:info("Contact button pressed (count changed: %d -> %d)", prevCount, data.contactButtonCount)
+ events:fire(EVENT_NAMESPACE, "button_pressed")
+ sendButtonEvent("contact_button", "Contact Button")
+ table.insert(summaryParts, "Button Pressed")
+ end
+ prevState.contactButtonCount = data.contactButtonCount
+ persist:set("previousState", prevState)
+ end
+
+ UpdateProperty("Last Received", #summaryParts > 0 and table.concat(summaryParts, ", ") or "No data")
+ UpdateProperty("Driver Status", "Listening")
+ end
+end
+
+--- Check command result from device response
+--- @param data string Binary response data
+--- @return boolean success True if command succeeded
+local function checkCommandResult(data)
+ log:trace("checkCommandResult(%s)", data)
+ if not data or #data < 1 then
+ log:error("Command result: empty response")
+ return false
+ end
+
+ local status = string.byte(data, 1)
+ log:debug("Command result: status=%d (0x%02X)", status, status)
+
+ if status == 1 then
+ log:info("Command executed successfully")
+ return true
+ elseif status == 0x07 then
+ log:error("Command failed: password/encryption required")
+ UpdateProperty("Encryption Status", "Error: Encryption required by device")
+ return false
+ elseif status == 0x09 then
+ log:error("Command failed: incorrect encryption key")
+ UpdateProperty("Encryption Status", "Error: Incorrect encryption key")
+ return false
+ else
+ log:warn("Command result: unexpected status %d (0x%02X)", status, status)
+ return false
+ end
+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 persisted state
+ 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()
+ hideEncryptionProperties()
+ hideSwitchProperties()
+ hideBotProperties()
+ hideSensorProperties()
+
+ -- Reset notification state on boot
+ resetConnectionState()
+
+ -- Restore device type and category from stored value
+ local storedDeviceType = Select(values:getValue("Device Type"), "value")
+ if storedDeviceType then
+ initializeForDeviceType(storedDeviceType)
+ end
+
+ -- Fire OnPropertyChanged for all properties
+ 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.Key_ID(propertyValue)
+ log:trace("OPC.Key_ID('%s')", propertyValue)
+ if IsEmpty(propertyValue) then
+ -- Cleared - just update status
+ encryptionIV = nil
+ updateEncryptionStatus()
+ elseif validateKeyId(propertyValue) then
+ updateEncryptionStatus()
+ if gInitialized and isEncryptionConfigured() then
+ subscribeNotifications()
+ end
+ else
+ UpdateProperty("Encryption Status", "Error: Invalid Key ID")
+ UpdateProperty("Key ID", "")
+ encryptionIV = nil
+ end
+end
+
+function OPC.Encryption_Key(propertyValue)
+ log:trace("OPC.Encryption_Key('%s')", propertyValue)
+ if IsEmpty(propertyValue) then
+ -- Cleared - just update status
+ encryptionIV = nil
+ updateEncryptionStatus()
+ elseif validateEncryptionKey(propertyValue) then
+ updateEncryptionStatus()
+ if gInitialized and isEncryptionConfigured() then
+ subscribeNotifications()
+ end
+ else
+ UpdateProperty("Encryption Status", "Error: Invalid Encryption Key")
+ UpdateProperty("Encryption Key", "")
+ encryptionIV = nil
+ end
+end
+
+--- Fetch encryption keys from SwitchBot cloud
+local function fetchEncryptionKeys()
+ log:trace("fetchEncryptionKeys()")
+
+ local username = Properties["SwitchBot Username"]
+ local password = Properties["SwitchBot Password"]
+ local mac = Properties["MAC Address"]
+
+ if not username or username == "" then
+ log:error("Cannot fetch keys: SwitchBot Username not set")
+ UpdateProperty("Encryption Status", "Error: Username required")
+ return
+ end
+
+ if not password or password == "" then
+ log:error("Cannot fetch keys: SwitchBot Password not set")
+ UpdateProperty("Encryption Status", "Error: Password required")
+ return
+ end
+
+ if not mac or mac == "" or mac == "Unknown" then
+ log:error("Cannot fetch keys: MAC Address not known (connect device first)")
+ UpdateProperty("Encryption Status", "Error: Connect device first")
+ return
+ end
+
+ UpdateProperty("Encryption Status", "Logging in...")
+
+ local loginUrl = "https://account." .. SWITCHBOT_API.BASE_URL .. SWITCHBOT_API.LOGIN_PATH
+ local loginData = JSON:encode({
+ clientId = SWITCHBOT_API.CLIENT_ID,
+ username = username,
+ password = password,
+ grantType = "password",
+ verifyCode = "",
+ })
+ local loginHeaders = { ["Content-Type"] = "application/json" }
+
+ log:debug("SwitchBot API: Logging in as %s", username)
+
+ http
+ :post(loginUrl, loginData, loginHeaders)
+ :next(function(response)
+ --- @cast response HTTPResponse
+ --- @type integer
+ local statusCode = tointeger(Select(response.body, "statusCode")) or -1
+ if statusCode ~= 100 then
+ --- @type string?
+ local message = Select(response.body, "message")
+ error(message or ("Login status " .. statusCode))
+ end
+ --- @type string?
+ local accessToken = Select(response.body, "body", "access_token")
+ if IsEmpty(accessToken) then
+ error("No access token in response")
+ end
+ --- @cast accessToken -nil
+ log:info("SwitchBot login successful")
+ return accessToken
+ end)
+ :next(function(accessToken)
+ UpdateProperty("Encryption Status", "Getting region...")
+ log:debug("SwitchBot API: Getting user info")
+
+ local userInfoUrl = "https://account." .. SWITCHBOT_API.BASE_URL .. SWITCHBOT_API.USERINFO_PATH
+ local userInfoHeaders = {
+ ["Content-Type"] = "application/json",
+ ["Authorization"] = accessToken,
+ }
+
+ return http:post(userInfoUrl, "{}", userInfoHeaders):next(function(response)
+ --- @cast response HTTPResponse
+ --- @type integer
+ local statusCode = tointeger(Select(response.body, "statusCode")) or -1
+ if statusCode ~= 100 then
+ local message = Select(response.body, "message")
+ error(message or ("User info status " .. statusCode))
+ end
+ --- @type string?
+ local region = Select(response.body, "body", "botRegion")
+ if IsEmpty(region) then
+ error("No region in response")
+ end
+ --- @cast region -nil
+ log:info("SwitchBot user region: %s", region)
+ return { accessToken = accessToken, region = region }
+ end)
+ end)
+ :next(function(ctx)
+ --- @cast ctx { accessToken: string, region: string }
+ UpdateProperty("Encryption Status", "Fetching keys...")
+ log:debug("SwitchBot API: Getting keys for %s", mac)
+
+ local keysUrl = "https://wonderlabs." .. ctx.region .. "." .. SWITCHBOT_API.BASE_URL .. SWITCHBOT_API.KEYS_PATH
+ local keysData = {
+ device_mac = mac:upper():gsub(":", ""),
+ keyType = "user",
+ }
+ local keysHeaders = {
+ ["Content-Type"] = "application/json",
+ ["Authorization"] = ctx.accessToken,
+ }
+
+ return http:post(keysUrl, keysData, keysHeaders)
+ end)
+ :next(function(response)
+ --- @cast response HTTPResponse
+ --- @type integer
+ local statusCode = tointeger(Select(response.body, "statusCode")) or -1
+ if statusCode ~= 100 then
+ local message = Select(response.body, "message")
+ error(message or ("Get keys status " .. statusCode))
+ end
+ --- @type string?
+ local keyId = Select(response.body, "body", "communicationKey", "keyId")
+ --- @type string?
+ local encKey = Select(response.body, "body", "communicationKey", "key")
+ if IsEmpty(keyId) or IsEmpty(encKey) then
+ error("Missing keys in response")
+ end
+ --- @cast keyId -nil
+ --- @cast encKey -nil
+ log:info("SwitchBot keys retrieved: keyId=%s", keyId)
+
+ -- Clear existing keys
+ UpdateProperty("Key ID", "")
+ UpdateProperty("Encryption Key", "")
+
+ UpdateProperty("Key ID", keyId, true)
+ UpdateProperty("Encryption Key", encKey, true)
+ end)
+ :next(nil, function(err)
+ local errorMsg = type(err) == "table" and (err.error or err.message) or tostring(err)
+ log:error("SwitchBot API error: %s", errorMsg)
+ UpdateProperty("Encryption Status", "Error: " .. errorMsg)
+ end)
+end
+
+--- Check if we can auto-fetch encryption keys
+--- @param force boolean If true, fetch keys even if already configured
+local function maybeAutoFetchKeys(force)
+ log:trace("maybeAutoFetchKeys(%s)", force)
+ local username = Properties["SwitchBot Username"]
+ local password = Properties["SwitchBot Password"]
+ local mac = Properties["MAC Address"]
+
+ if IsEmpty(username) or IsEmpty(password) then
+ return
+ end
+ if IsEmpty(mac) or mac == "Unknown" then
+ log:debug("Cannot auto-fetch keys: MAC address not known yet")
+ return
+ end
+
+ if not requiresEncryption then
+ log:debug("Skipping auto-fetch: device doesn't need encryption")
+ return
+ end
+
+ if not force and isEncryptionConfigured() then
+ log:debug("Skipping auto-fetch: encryption already configured")
+ return
+ end
+
+ log:info("Auto-fetching encryption keys (credentials provided)")
+ fetchEncryptionKeys()
+end
+
+function OPC.SwitchBot_Username(propertyValue)
+ log:trace("OPC.SwitchBot_Username('%s')", propertyValue and "***" or "nil")
+ maybeAutoFetchKeys(gInitialized)
+end
+
+function OPC.SwitchBot_Password(propertyValue)
+ log:trace("OPC.SwitchBot_Password('%s')", propertyValue and "***" or "nil")
+ maybeAutoFetchKeys(gInitialized)
+end
+
+--------------------------------------------------------------------------------
+-- RFP Handlers - Relay Commands
+--------------------------------------------------------------------------------
+
+--- Get channel number from binding ID
+--- @param idBinding number The binding ID
+--- @return 1|2|nil channel 1, 2, or nil if not a relay binding
+local function getChannelForBinding(idBinding)
+ log:trace("getChannelForBinding(%s)", idBinding)
+ local existingBindings = bindings:getDynamicBindings(BINDINGS_NAMESPACE)
+ if not existingBindings then
+ return nil
+ end
+
+ if idBinding == Select(existingBindings, "botRelay", "bindingId") then
+ return 1
+ elseif idBinding == Select(existingBindings, "channel1", "bindingId") then
+ return 1
+ elseif idBinding == Select(existingBindings, "channel2", "bindingId") then
+ return 2
+ end
+ return nil
+end
+
+function RFP.ON(idBinding, strCommand, _tParams, _args)
+ log:trace("RFP.ON(%s, %s)", idBinding, strCommand)
+ local channel = getChannelForBinding(idBinding)
+ if not channel then
+ return
+ end
+ turnOn(channel)
+end
+
+function RFP.OFF(idBinding, strCommand, _tParams, _args)
+ log:trace("RFP.OFF(%s, %s)", idBinding, strCommand)
+ local channel = getChannelForBinding(idBinding)
+ if not channel then
+ return
+ end
+ turnOff(channel)
+end
+
+function RFP.CLOSE(idBinding, strCommand, _tParams, _args)
+ log:trace("RFP.CLOSE(%s, %s)", idBinding, strCommand)
+ local channel = getChannelForBinding(idBinding)
+ if not channel then
+ return
+ end
+ turnOn(channel)
+end
+
+function RFP.OPEN(idBinding, strCommand, _tParams, _args)
+ log:trace("RFP.OPEN(%s, %s)", idBinding, strCommand)
+ local channel = getChannelForBinding(idBinding)
+ if not channel then
+ return
+ end
+ turnOff(channel)
+end
+
+function RFP.TOGGLE(idBinding, strCommand, _tParams, _args)
+ log:trace("RFP.TOGGLE(%s, %s)", idBinding, strCommand)
+ local channel = getChannelForBinding(idBinding)
+ if not channel then
+ return
+ end
+ toggle(channel)
+end
+
+--------------------------------------------------------------------------------
+-- RFP Handlers - Connection
+--------------------------------------------------------------------------------
+
+--- Handle connection notification from main driver (active GATT connection)
+function RFP.CONNECTED(idBinding, strCommand, tParams, args)
+ log:trace("RFP.CONNECTED(%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")
+ local devType = Select(tParams, "deviceType")
+ local services = DeserializeSafe(Select(tParams, "services"))
+
+ log:info("Connected to %s device: %s", devType or "unknown", mac or "unknown")
+ CancelTimer("ConnectionTimeout")
+
+ -- Active GATT connection means this is not a passive device
+ isPassive = false
+
+ if not IsEmpty(name) then
+ values:update("Name", name, "STRING")
+ end
+ if mac then
+ values:update("MAC Address", mac, "STRING")
+ end
+ if devType then
+ values:update("Device Type", devType, "STRING")
+ if not deviceCategory then
+ initializeForDeviceType(devType)
+ end
+ end
+
+ if services then
+ local txHandle = UUID.findCharacteristicHandle(services, SWITCHBOT_UUID.SERVICE, SWITCHBOT_UUID.TX)
+ local rxHandle = UUID.findCharacteristicHandle(services, SWITCHBOT_UUID.SERVICE, SWITCHBOT_UUID.RX)
+
+ if txHandle and rxHandle then
+ log:info("Found SwitchBot TX: %d, RX: %d", txHandle, rxHandle)
+ saveHandles(txHandle, rxHandle)
+
+ notificationsSubscribed = false
+ notificationsSubscribing = false
+
+ if deviceCategory == DEVICE_CATEGORY.BOT then
+ -- Bot: execute command immediately or request status
+ if pendingCommand then
+ executePendingCommand()
+ else
+ log:debug("No pending command, requesting device status")
+ SendToProxy(ESPHOME_BINDING, "GATT_NOTIFY", {
+ handle = tostring(rxHandle),
+ enable = "true",
+ }, "NOTIFY")
+ end
+ elseif deviceCategory == DEVICE_CATEGORY.SWITCH then
+ -- Switch: check if encryption is needed
+ if requiresEncryption then
+ if isEncryptionConfigured() then
+ subscribeNotifications()
+ else
+ log:warn("Encryption not configured - control commands will fail")
+ UpdateProperty("Encryption Status", "Required - Not Configured")
+ pendingCommand = nil
+ requestDisconnect()
+ end
+ else
+ -- Non-encrypted device (Plug Mini)
+ log:info("Device does not require encryption, executing pending command")
+ updateEncryptionStatus()
+ executePendingCommand()
+ end
+ end
+ else
+ log:error("Could not find SwitchBot characteristics")
+ -- Try cached handles
+ local cachedTx, cachedRx = getHandles()
+ if cachedTx and cachedRx then
+ log:warn("Using cached handles as fallback: TX=%d, RX=%d", cachedTx, cachedRx)
+ notificationsSubscribed = false
+ notificationsSubscribing = false
+ if requiresEncryption and isEncryptionConfigured() then
+ subscribeNotifications()
+ elseif not requiresEncryption then
+ updateEncryptionStatus()
+ executePendingCommand()
+ end
+ else
+ UpdateProperty("Driver Status", "Error: Missing characteristics")
+ end
+ end
+ else
+ log:error("No services provided in CONNECTED message")
+ UpdateProperty("Driver Status", "Error: Missing services")
+ end
+end
+
+--- Handle passive connect notification from parent driver (sensor devices)
+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 devType = Select(tParams, "deviceType") or "Unknown"
+
+ log:info("SwitchBot device in passive mode: %s [%s]", devType, mac)
+
+ -- Passive connection means this is a sensor/passive device
+ isPassive = true
+
+ if not IsEmpty(name) then
+ values:update("Name", name, "STRING")
+ end
+ values:update("Device Type", devType, "STRING")
+ values:update("MAC Address", mac, "STRING")
+
+ if not deviceCategory then
+ initializeForDeviceType(devType)
+ 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)
+ if idBinding ~= ESPHOME_BINDING then
+ return
+ end
+
+ -- For passive devices, call CONNECTED_PASSIVE to set up device info
+ if isPassive or not deviceCategory then
+ RFP.CONNECTED_PASSIVE(idBinding, strCommand, tParams, args)
+ end
+
+ -- Update device info from tParams
+ local mac = Select(tParams, "mac")
+ local devType = Select(tParams, "deviceType")
+ if mac then
+ values:update("MAC Address", mac, "STRING")
+ end
+ if devType then
+ values:update("Device Type", devType, "STRING")
+ if not deviceCategory then
+ initializeForDeviceType(devType)
+ end
+ 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
+
+ -- Parse SwitchBot data from service and manufacturer data
+ local data = SwitchBot.parse(advertisement.serviceData, advertisement.manufacturerData)
+ if not data then
+ return
+ end
+
+ processSwitchBotData(data, 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("Disconnected from device: %s", reason)
+
+ resetConnectionState()
+
+ -- If we just received a response acknowledging our disconnect, then we are still listening
+ UpdateProperty("Driver Status", "Listening")
+end
+
+--- Handle connection failure notification from main driver
+function RFP.CONNECTION_FAILED(idBinding, strCommand, tParams, args)
+ log:trace("RFP.CONNECTION_FAILED(%s, %s, %s, %s)", idBinding, strCommand, tParams, args)
+ if idBinding ~= ESPHOME_BINDING then
+ return
+ end
+
+ local error = Select(tParams, "error") or "unknown"
+ log:error("Connection failed: %s", error)
+
+ resetConnectionState()
+ UpdateProperty("Driver Status", "Connection Failed: " .. error)
+end
+
+--------------------------------------------------------------------------------
+-- RFP Handlers - GATT
+--------------------------------------------------------------------------------
+
+--- Handle GATT write response from main driver
+function RFP.GATT_WRITE_RESPONSE(idBinding, strCommand, tParams, args)
+ log:trace("RFP.GATT_WRITE_RESPONSE(%s, %s, %s, %s)", idBinding, strCommand, tParams, args)
+ if idBinding ~= ESPHOME_BINDING then
+ return
+ end
+
+ local success = Select(tParams, "success") == "true"
+ local errorCode = Select(tParams, "error")
+
+ if success then
+ log:debug("Write command successful")
+ UpdateProperty("Driver Status", "Busy")
+ updateLastSeen()
+
+ -- Bot press mode: revert to OPENED
+ if deviceCategory == DEVICE_CATEGORY.BOT and not isBotSwitchMode() then
+ setBotState(false)
+ end
+ else
+ log:error("Write command failed: error=%s", errorCode)
+ -- Bot press mode: revert optimistic state on failure
+ if deviceCategory == DEVICE_CATEGORY.BOT and not isBotSwitchMode() then
+ setBotState(false)
+ end
+ if pendingIVRequest then
+ pendingIVRequest = false
+ UpdateProperty("Encryption Status", "Error: IV request failed")
+ end
+ if awaitingCommandResponse then
+ awaitingCommandResponse = false
+ requestDisconnect()
+ end
+ end
+
+ -- Bot: disconnect after command
+ if deviceCategory == DEVICE_CATEGORY.BOT and awaitingCommandResponse then
+ awaitingCommandResponse = false
+ requestDisconnect()
+ end
+end
+
+--- Handle GATT notification subscription confirmation
+function RFP.GATT_NOTIFY_SUBSCRIBED(idBinding, strCommand, tParams, args)
+ log:trace("RFP.GATT_NOTIFY_SUBSCRIBED(%s, %s, %s, %s)", idBinding, strCommand, tParams, args)
+ if idBinding ~= ESPHOME_BINDING then
+ return
+ end
+
+ local handle = tointeger(Select(tParams, "handle"))
+ local success = Select(tParams, "success") == "true"
+ local error = Select(tParams, "error")
+
+ notificationsSubscribing = false
+
+ if success then
+ log:info("GATT notifications subscribed on handle %d", handle or 0)
+ notificationsSubscribed = true
+
+ if deviceCategory == DEVICE_CATEGORY.BOT then
+ -- Bot: request device status
+ local cmd = CMD_BOT_GET_SETTINGS
+ local txHandle, _rxHandle = getHandles()
+ SendToProxy(ESPHOME_BINDING, "GATT_WRITE", {
+ handle = tostring(txHandle),
+ data = C4:Base64Encode(cmd),
+ response = "false",
+ }, "NOTIFY")
+ elseif deviceCategory == DEVICE_CATEGORY.SWITCH then
+ if requiresEncryption then
+ if isEncryptionConfigured() and not isEncryptionReady() then
+ log:debug("Requesting IV for encrypted device")
+ requestIV()
+ elseif isEncryptionReady() then
+ log:debug("Encryption already ready, executing pending command")
+ executePendingCommand()
+ else
+ log:error("Encryption required but not configured")
+ UpdateProperty("Encryption Status", "Error: Not configured")
+ pendingCommand = nil
+ requestDisconnect()
+ end
+ else
+ log:debug("Non-encrypted device, executing pending command")
+ executePendingCommand()
+ end
+ end
+ else
+ log:error("GATT notification subscription failed: %s", error or "unknown")
+ notificationsSubscribed = false
+ UpdateProperty("Encryption Status", "Error: Notification subscription failed")
+ end
+end
+
+--- Handle GATT notification data from main driver
+function RFP.GATT_NOTIFY_DATA(idBinding, strCommand, tParams, args)
+ log:trace("RFP.GATT_NOTIFY_DATA(%s, %s, %s, %s)", idBinding, strCommand, tParams, args)
+ if idBinding ~= ESPHOME_BINDING then
+ return
+ end
+
+ local handle = tointeger(Select(tParams, "handle"))
+ local data = Select(tParams, "data")
+
+ if not data then
+ log:debug("GATT_NOTIFY with no data")
+ return
+ end
+
+ local binaryData = C4:Base64Decode(data)
+ if not binaryData or #binaryData == 0 then
+ log:debug("GATT_NOTIFY with empty data after decode")
+ return
+ end
+
+ log:debug("GATT_NOTIFY_DATA: handle=%s, %d bytes", handle or "nil", #binaryData)
+
+ -- Check if this is an IV response
+ if pendingIVRequest then
+ if processIVResponse(binaryData) then
+ log:info("IV retrieved, encryption ready")
+ executePendingCommand()
+ else
+ log:error("Failed to process IV response")
+ pendingIVRequest = false
+ UpdateProperty("Encryption Status", "Error: Invalid IV response")
+ pendingCommand = nil
+ requestDisconnect()
+ end
+ return
+ end
+
+ -- Check if this is a command response
+ if awaitingCommandResponse then
+ awaitingCommandResponse = false
+ checkCommandResult(binaryData)
+ requestDisconnect()
+ return
+ end
+
+ ---- Bot status response
+ --local _, rxHandle = getHandles()
+ --if deviceCategory == DEVICE_CATEGORY.BOT and handle == rxHandle then
+ -- if #binaryData >= 2 then
+ -- parseBotStatusResponse(binaryData)
+ -- requestDisconnect()
+ -- return
+ -- end
+ --end
+
+ log:debug("Unexpected notification data")
+end
+
+--------------------------------------------------------------------------------
+-- OBC Handlers
+--------------------------------------------------------------------------------
+
+OBC[ESPHOME_BINDING] = function(idBinding, strClass, bIsBound, otherDeviceId)
+ log:trace("OBC[%s](%s, %s, %s, %s)", ESPHOME_BINDING, idBinding, strClass, bIsBound, otherDeviceId)
+ resetConnectionState(true)
+ persist:set("previousState", {})
+
+ 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 state
+ bindings:reset()
+ values:reset()
+ events:reset()
+
+ -- Reset sensor state tracking
+ persist:set("previousState", {})
+
+ -- Reset device identification
+ deviceType = nil
+ deviceCategory = nil
+ switchType = nil
+ isPassive = false
+ requiresEncryption = false
+ isDualChannel = false
+
+ -- Clear handles and connection state
+ resetConnectionState(true)
+
+ -- Hide optional properties
+ hideOptionalProperties()
+ hideEncryptionProperties()
+ hideSwitchProperties()
+ hideBotProperties()
+ hideSensorProperties()
+
+ -- Reset properties to defaults (excludes user-entered credentials)
+ local resetValues = GetPropertyResetValues({ "SwitchBot Username", "SwitchBot Password" })
+ for propName, defaultValue in pairs(resetValues) do
+ UpdateProperty(propName, defaultValue, true)
+ end
+
+ -- Request refresh from parent
+ SendToProxy(ESPHOME_BINDING, "REFRESH_STATE", {}, "NOTIFY")
+end
diff --git a/drivers/esphome_switchbot/driver.xml b/drivers/esphome_switchbot/driver.xml
new file mode 100644
index 0000000..4fe04d8
--- /dev/null
+++ b/drivers/esphome_switchbot/driver.xml
@@ -0,0 +1,328 @@
+
+ ESPHome SwitchBot
+
+ Finite Labs
+ ESPHome SwitchBot
+ Derek Miller
+ icons/device_sm.png
+ icons/device_lg.png
+ lua_gen
+ DriverWorks
+ Copyright 2026 Finite Labs, LLC. All rights reserved.
+ 01/30/2026 12:00:00 PM
+
+ true
+ 3.3.0
+
+ Motorization
+
+
+
+
+
+
+
+
+
+ Cloud Settings
+ LABEL
+ Cloud Settings
+
+
+ Cloud Status
+
+ STRING
+ true
+
+
+ Automatic Updates
+ LIST
+
+ - Off
+ - On
+
+ On
+
+
+
+ Driver Settings
+ LABEL
+ Driver Settings
+
+
+ Driver Status
+ STRING
+
+ true
+
+
+ Driver Version
+ STRING
+
+ true
+
+
+ Log Level
+ LIST
+ 3 - Info
+
+ - 0 - Fatal
+ - 1 - Error
+ - 2 - Warning
+ - 3 - Info
+ - 4 - Debug
+ - 5 - Trace
+ - 6 - Ultra
+
+
+
+ Log Mode
+ LIST
+ Off
+
+ - Off
+ - Print
+ - Log
+ - Print and Log
+
+
+
+
+ Encryption Settings
+ LABEL
+ Encryption Settings
+
+
+ Encryption Status
+ STRING
+ Not Configured
+ true
+
+
+ SwitchBot Username
+ STRING
+
+ Your SwitchBot account email address
+
+
+ SwitchBot Password
+ STRING
+
+ true
+ Your SwitchBot account password
+
+
+ Key ID
+ STRING
+
+ true
+
+
+ Encryption Key
+ STRING
+
+ true
+
+
+
+ Device Info
+ LABEL
+ Device Info
+
+
+ Name
+ STRING
+
+ true
+
+
+ Device Type
+ STRING
+
+ true
+
+
+ MAC Address
+ STRING
+ Unknown
+ true
+
+
+ Last Seen
+ STRING
+ Never
+ true
+
+
+ RSSI
+ STRING
+
+ true
+
+
+ Last Received
+ STRING
+
+ true
+
+
+
+ Device Data
+ LABEL
+ Device Data
+
+
+
+ State
+ STRING
+ Unknown
+ true
+
+
+ Battery
+ STRING
+
+ true
+
+
+ Battery Low
+ STRING
+
+ true
+
+
+ Mode
+ STRING
+ Unknown
+ true
+
+
+
+ Channel 1
+ LABEL
+ Channel 1
+
+
+ Channel 1 State
+ STRING
+ Unknown
+ true
+
+
+ Channel 1 Power
+ STRING
+
+ true
+
+
+
+ Channel 2
+ LABEL
+ Channel 2
+
+
+ Channel 2 State
+ STRING
+ Unknown
+ true
+
+
+ Channel 2 Power
+ STRING
+
+ true
+
+
+
+ Temperature C
+ STRING
+
+ true
+
+
+ Temperature F
+ STRING
+
+ true
+
+
+ Humidity
+ STRING
+
+ true
+
+
+ CO2
+ STRING
+
+ true
+
+
+ Motion
+ STRING
+
+ true
+
+
+ Light Level
+ STRING
+
+ true
+
+
+ Contact
+ STRING
+
+ true
+
+
+ Leak Detected
+ STRING
+
+ true
+
+
+ Tamper
+ STRING
+
+ true
+
+
+
+
+ Reset Driver
+ Reset_Driver
+
+
+ Are You Sure?
+ LIST
+
+ - No
+ - Yes
+
+
+
+
+
+
+
+
+
+
+ 5001
+ 6
+ ESPHome SwitchBot
+ 2
+ True
+ False
+ False
+ False
+
+
+ ESPHOME_SWITCHBOT
+
+
+ False
+
+
+
diff --git a/drivers/esphome_switchbot/www/documentation/images/finite-labs-logo.png b/drivers/esphome_switchbot/www/documentation/images/finite-labs-logo.png
new file mode 100644
index 0000000..97f7147
Binary files /dev/null and b/drivers/esphome_switchbot/www/documentation/images/finite-labs-logo.png differ
diff --git a/drivers/esphome_switchbot/www/documentation/images/header.png b/drivers/esphome_switchbot/www/documentation/images/header.png
new file mode 100644
index 0000000..90674e2
Binary files /dev/null and b/drivers/esphome_switchbot/www/documentation/images/header.png differ
diff --git a/drivers/esphome_switchbot/www/documentation/index.md b/drivers/esphome_switchbot/www/documentation/index.md
new file mode 100644
index 0000000..a2ec906
--- /dev/null
+++ b/drivers/esphome_switchbot/www/documentation/index.md
@@ -0,0 +1,604 @@
+[copyright]: # "Copyright 2026 Finite Labs, LLC. All rights reserved."
+
+
+
+
+
+---
+
+# Overview
+
+
+
+> DISCLAIMER: This software is neither affiliated with nor endorsed by either
+> Control4, ESPHome, or SwitchBot.
+
+
+
+Control all SwitchBot devices from Control4 through an ESPHome Bluetooth Proxy.
+This unified driver supports Bot, Plug Mini, Relay Switches, Meters, Motion
+Sensors, Contact Sensors, and Water Leak Detectors.
+
+# Index
+
+
+
+- [System Requirements](#system-requirements)
+- [Features](#features)
+- [Compatibility](#compatibility)
+ - [Supported Devices](#supported-devices)
+- [Installer Setup](#installer-setup)
+
+ - [DriverCentral Cloud Setup](#drivercentral-cloud-setup)
+
+ - [Adding the Driver](#adding-the-driver)
+ - [Binding to ESPHome Proxy](#binding-to-esphome-proxy)
+ - [Driver Properties](#driver-properties)
+
+ - [Cloud Settings](#cloud-settings)
+
+ - [Driver Settings](#driver-settings)
+ - [Encryption Settings](#encryption-settings)
+ - [Device Info](#device-info)
+ - [Device Data](#device-data)
+ - [Driver Actions](#driver-actions)
+- [Device Types](#device-types)
+ - [Bot](#bot)
+ - [Plug Mini](#plug-mini)
+ - [Relay Switches](#relay-switches)
+ - [Meters](#meters)
+ - [Motion and Presence Sensors](#motion-and-presence-sensors)
+ - [Contact Sensor](#contact-sensor)
+ - [Water Leak Detector](#water-leak-detector)
+- [Programming](#programming)
+ - [Events](#events)
+ - [Variables](#variables)
+ - [Connections](#connections)
+- [Troubleshooting](#troubleshooting)
+
+- [Developer Information](#developer-information)
+
+- [Support](#support)
+- [Changelog](#changelog)
+
+
+
+
+
+# System Requirements
+
+- Control4 OS 3.3+
+- ESPHome driver configured with Bluetooth Proxy enabled
+- ESP32 device with `bluetooth_proxy` component
+
+# Features
+
+- **Unified driver** for all SwitchBot device types
+- **Active and passive** BLE connection modes
+- **Encryption support** for Relay Switch devices
+- **Automatic key fetching** from SwitchBot cloud
+- **Dynamic bindings** created based on device type
+- **Event programming** for sensors and contact devices
+- **Control4 proxy integration** (Relay, Contact Sensor, Temperature, Humidity)
+
+> **Connection Types:** Active devices (Bot, Plug, Relay) use one of the ESP32's
+> limited connection slots (typically 3 available). Passive devices (Meters,
+> Sensors) use advertisement monitoring and don't consume slots.
+
+# Compatibility
+
+## Supported Devices
+
+| Device | Mode | Bindings | Encryption |
+| -------------------- | ------- | ------------------------------------- | ---------- |
+| Bot | Active | RELAY + BUTTON_LINK (mode-dependent) | No |
+| Plug Mini | Active | RELAY | No |
+| Relay Switch 1 | Active | RELAY | Yes |
+| Relay Switch 1PM | Active | RELAY | Yes |
+| Relay Switch 2PM | Active | RELAY x2 | Yes |
+| Meter / Meter Plus | Passive | TEMPERATURE_VALUE, HUMIDITY_VALUE | No |
+| Meter Pro / CO2 | Passive | TEMPERATURE_VALUE, HUMIDITY_VALUE | No |
+| Indoor/Outdoor Meter | Passive | TEMPERATURE_VALUE, HUMIDITY_VALUE | No |
+| Motion Sensor | Passive | CONTACT_SENSOR + Events | No |
+| Presence Sensor | Passive | CONTACT_SENSOR + Events | No |
+| Contact Sensor | Passive | CONTACT_SENSOR + BUTTON_LINK + Events | No |
+| Water Leak Detector | Passive | CONTACT_SENSOR + Events | No |
+| Remote | Passive | (advertisement parsing only) | No |
+
+
+
+# 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
+> [Adding the Driver](#adding-the-driver).
+
+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.
+
+
+
+## Adding the Driver
+
+
+
+1. Download the latest `control4-esphome.zip` from
+ [DriverCentral](https://drivercentral.io/platforms/control4-drivers/utility/esphome).
+2. Extract and install the `esphome_switchbot.c4z` driver.
+3. Use the "Search" tab to find "ESPHome SwitchBot" 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_switchbot.c4z` driver.
+3. Use the "Search" tab to find "ESPHome SwitchBot" and add it to your project.
+
+
+
+## Binding to ESPHome Proxy
+
+1. Ensure the main ESPHome driver is connected and Bluetooth Proxy shows
+ available connection slots (for active devices).
+2. In the main ESPHome driver properties, select "Refresh List" from the "Select
+ Bluetooth Devices" dropdown.
+3. Select your SwitchBot device from the list. A connection binding will be
+ automatically created.
+4. Go to the "Connections" tab and bind the ESPHome SwitchBot driver to the
+ newly created SwitchBot connection.
+
+## Driver Properties
+
+
+
+### Cloud Settings
+
+#### Cloud Status (read-only)
+
+Displays the DriverCentral cloud license status.
+
+#### Automatic Updates [ Off | **_On_** ]
+
+Enables or disables automatic driver updates via DriverCentral.
+
+
+
+### Driver Settings
+
+#### Driver Status (read-only)
+
+Displays the current status of the driver.
+
+#### 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`.
+
+### Encryption Settings
+
+> These properties are only shown for Relay Switch devices that require
+> encryption.
+
+#### Encryption Status (read-only)
+
+Displays the current encryption status.
+
+#### SwitchBot Username
+
+Your SwitchBot account email address. Used to automatically fetch encryption
+keys from SwitchBot cloud.
+
+#### SwitchBot Password
+
+Your SwitchBot account password.
+
+#### Key ID (read-only)
+
+The encryption key ID retrieved from SwitchBot cloud.
+
+#### Encryption Key (read-only)
+
+The encryption key retrieved from SwitchBot cloud.
+
+### Device Info
+
+#### Name (read-only)
+
+Displays the name of the bound SwitchBot device.
+
+#### Device Type (read-only)
+
+Displays the detected SwitchBot device type.
+
+#### MAC Address (read-only)
+
+Displays the MAC address of the bound SwitchBot device.
+
+#### Last Seen (read-only)
+
+Displays the timestamp of the last communication with the device.
+
+#### RSSI (read-only)
+
+Displays the signal strength of the Bluetooth connection.
+
+#### Last Received (read-only)
+
+Displays a summary of the most recently received device data.
+
+### Device Data
+
+Properties shown here depend on the detected device type. Only properties
+relevant to the connected device will be shown.
+
+#### Bot
+
+| Property | Description |
+| -------- | ----------------------------- |
+| State | Current state (On/Off) |
+| Battery | Battery level (%) |
+| Mode | Operating mode (Press/Switch) |
+
+#### Plug Mini / Relay Switches
+
+| Property | Description |
+| --------------- | ------------------------------------ |
+| Channel 1 State | Channel 1 relay state (On/Off) |
+| Channel 1 Power | Channel 1 power consumption (W) |
+| Channel 2 State | Channel 2 relay state (On/Off, 2PM) |
+| Channel 2 Power | Channel 2 power consumption (W, 2PM) |
+
+#### Meters
+
+| Property | Unit | Description |
+| ------------- | ---- | -------------------------------- |
+| Temperature C | °C | Current temperature (Celsius) |
+| Temperature F | °F | Current temperature (Fahrenheit) |
+| Humidity | % | Relative humidity |
+| CO2 | ppm | Carbon dioxide level (Pro CO2) |
+| Battery | % | Battery level |
+
+#### Motion / Presence Sensors
+
+| Property | Description |
+| ----------- | ----------------------------- |
+| Motion | Motion state (Detected/Clear) |
+| Light Level | Ambient light level |
+| Battery | Battery level (%) |
+
+#### Contact Sensor
+
+| Property | Description |
+| -------- | --------------------------- |
+| Contact | Contact state (Open/Closed) |
+| Battery | Battery level (%) |
+
+#### Water Leak Detector
+
+| Property | Description |
+| ------------- | ------------------ |
+| Leak Detected | Leak state |
+| Tamper | Tamper state |
+| Battery | Battery level (%) |
+| Battery Low | Low battery status |
+
+## Driver Actions
+
+### Reset Driver
+
+Resets the driver state to defaults.
+
+**Parameters:**
+
+- **Are You Sure?** [ **_No_** | Yes ] - Confirmation to reset the driver.
+
+
+
+# Device Types
+
+## Bot
+
+The SwitchBot Bot is a physical button pusher that operates in two modes:
+
+### Press Mode
+
+- Arm extends and immediately returns
+- Ideal for doorbells, elevator buttons, momentary switches
+- Single "Press" BUTTON_LINK binding created
+
+### Switch Mode
+
+- Arm moves to and stays in on/off position
+- Ideal for light switches, toggle switches
+- "Turn On", "Turn Off", "Toggle" BUTTON_LINK bindings created
+- State tracking (On/Off)
+
+> **Note:** Mode can only be changed in the SwitchBot app. The driver
+> automatically detects the current mode.
+
+**Properties shown:** State, Battery, Mode
+
+## Plug Mini
+
+Smart plug with power monitoring. Does not require encryption.
+
+**Properties shown:** Channel 1 State, Channel 1 Power
+
+**Bindings:** RELAY
+
+## Relay Switches
+
+The Relay Switch family provides in-wall relay control. All Relay Switches
+require encryption keys from SwitchBot cloud.
+
+| Model | Channels | Power Monitoring |
+| ---------------- | -------- | ---------------- |
+| Relay Switch 1 | 1 | No |
+| Relay Switch 1PM | 1 | Yes |
+| Relay Switch 2PM | 2 | Yes |
+
+**Properties shown:** Channel 1/2 State, Channel 1/2 Power (1PM/2PM only)
+
+**Bindings:** RELAY (Channel 2 creates dynamic binding for 2PM)
+
+### Encryption Setup
+
+1. Enter your SwitchBot account email in "SwitchBot Username"
+2. Enter your SwitchBot account password in "SwitchBot Password"
+3. Keys are automatically fetched when both credentials are provided
+4. Check "Encryption Status" to confirm keys were retrieved
+
+## Meters
+
+Passive temperature and humidity sensors. The driver automatically creates
+TEMPERATURE_VALUE and HUMIDITY_VALUE bindings.
+
+| Model | Features |
+| -------------------- | ------------------------------------ |
+| Meter | Temperature, Humidity |
+| Meter Plus | Temperature, Humidity |
+| Meter Pro | Temperature, Humidity |
+| Meter Pro CO2 | Temperature, Humidity, CO2 |
+| Indoor/Outdoor Meter | Temperature, Humidity (dual sensors) |
+
+**Properties shown:** Temperature C, Temperature F, Humidity, CO2, Battery
+
+## Motion and Presence Sensors
+
+Passive motion detection sensors with light level sensing.
+
+**Properties shown:** Motion, Light Level, Battery
+
+**Bindings:** CONTACT_SENSOR (motion = closed)
+
+**Events:**
+
+- Motion Detected - Motion was detected
+- Motion Cleared - Motion cleared
+
+## Contact Sensor
+
+Door/window contact sensor with physical button.
+
+**Properties shown:** Contact, Battery
+
+**Bindings:** CONTACT_SENSOR, BUTTON_LINK (for physical button)
+
+**Events:**
+
+- Contact Opened - Contact was opened
+- Contact Closed - Contact was closed
+- Button Pressed - Physical button was pressed
+
+## Water Leak Detector
+
+Water leak detection sensor with tamper detection.
+
+**Properties shown:** Leak Detected, Tamper, Battery, Battery Low
+
+**Bindings:** CONTACT_SENSOR (leak = closed)
+
+**Events:**
+
+- Leak Detected - Water leak detected
+- Leak Cleared - Water leak cleared
+- Tamper Detected - Tamper detected
+- Tamper Cleared - Tamper cleared
+- Low Battery - Battery is low
+- Battery OK - Battery restored to normal
+
+
+
+# Programming
+
+## Events
+
+Events are created dynamically based on device type:
+
+| Device Type | Events |
+| --------------- | ------------------------------------------------------------------------------------- |
+| Motion/Presence | Motion Detected, Motion Cleared |
+| Contact | Contact Opened, Contact Closed, Button Pressed |
+| Water Leak | Leak Detected, Leak Cleared, Tamper Detected, Tamper Cleared, Low Battery, Battery OK |
+
+## Variables
+
+The following variables are available for programming (varies by device type):
+
+### Common Variables
+
+| Variable | Type | Devices | Description |
+| ----------- | ------ | ------------ | -------------------- |
+| Battery | NUMBER | Most devices | Battery level (%) |
+| Battery Low | STRING | Water Leak | Low battery status |
+| Device Type | STRING | All | Detected device type |
+| MAC Address | STRING | All | Device MAC address |
+| Name | STRING | All | Device name |
+
+### Bot Variables
+
+| Variable | Type | Description |
+| -------- | ------ | ----------------------------- |
+| State | STRING | Current state (On/Off) |
+| Mode | STRING | Operating mode (Press/Switch) |
+
+### Switch/Relay Variables
+
+| Variable | Type | Devices | Description |
+| --------------- | ------ | ---------------- | ---------------------- |
+| Channel 1 State | STRING | All switches | Relay 1 state (On/Off) |
+| Channel 1 Power | NUMBER | Plug, 1PM, 2PM | Channel 1 power (W) |
+| Channel 2 State | STRING | Relay Switch 2PM | Relay 2 state (On/Off) |
+| Channel 2 Power | NUMBER | Relay Switch 2PM | Channel 2 power (W) |
+
+### Meter Variables
+
+| Variable | Type | Devices | Description |
+| ------------- | ------ | ------------- | ------------------------- |
+| Temperature C | NUMBER | All meters | Temperature in Celsius |
+| Temperature F | NUMBER | All meters | Temperature in Fahrenheit |
+| Humidity | NUMBER | All meters | Relative humidity (%) |
+| CO2 | NUMBER | Meter Pro CO2 | CO2 level (ppm) |
+
+### Sensor Variables
+
+| Variable | Type | Devices | Description |
+| ------------- | ------ | --------------- | ----------------------------- |
+| Motion | STRING | Motion/Presence | Motion state (Detected/Clear) |
+| Light Level | STRING | Motion/Presence | Ambient light level |
+| Contact | STRING | Contact Sensor | Contact state (Open/Closed) |
+| Leak Detected | STRING | Water Leak | Leak state (Yes/No) |
+| Tamper | STRING | Water Leak | Tamper state |
+
+## Connections
+
+### ESPHome SwitchBot (consumer)
+
+Bind this connection to the SwitchBot device binding exposed by the main ESPHome
+driver after selecting the SwitchBot device from the "Select Bluetooth Devices"
+dropdown.
+
+### Dynamic Bindings (provider)
+
+The driver dynamically creates bindings based on the detected device type:
+
+| Binding Class | Devices | Description |
+| ----------------- | ------------------------------- | ------------------------ |
+| RELAY | Bot, Plug Mini, Relay Switches | Relay on/off control |
+| BUTTON_LINK | Bot (mode-dependent), Contact | Button press events |
+| CONTACT_SENSOR | Motion, Presence, Contact, Leak | Open/closed sensor state |
+| TEMPERATURE_VALUE | Meters | Temperature in Celsius |
+| HUMIDITY_VALUE | Meters | Humidity percentage |
+
+
+
+# Troubleshooting
+
+## Device Not Responding
+
+If the device doesn't respond to commands:
+
+1. Check that the Bluetooth Proxy has available connection slots (active
+ devices)
+2. Verify the device is within BLE range of the ESP32
+3. Check the battery level - low battery can cause connection issues
+4. For Relay Switches, ensure encryption keys are configured
+
+## Encryption Key Errors
+
+If you see encryption errors for Relay Switches:
+
+1. Verify your SwitchBot account credentials are correct
+2. Clear and re-enter the username and password to trigger key fetch
+3. Ensure the device is registered to your SwitchBot account
+4. Check that the MAC address is correctly detected
+5. Check "Encryption Status" property for detailed error messages
+
+## Sensor Not Updating
+
+If passive sensors aren't showing data:
+
+1. Passive devices rely on BLE advertisements
+2. Ensure the device is within range of the ESP32
+3. Check that the device has battery remaining
+4. Some devices only advertise periodically (every few seconds)
+
+## Wrong Device Type Detected
+
+If the driver detects the wrong device type:
+
+1. Use "Reset Driver" action to clear state
+2. Unbind and rebind the device connection
+3. Ensure you're binding to the correct device
+
+
+
+
+
+# Developer Information
+
+
+
+
+
+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
+SwitchBot devices, 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
+
+
+
+
+
+
+
+
diff --git a/drivers/esphome_switchbot/www/icons/device_lg.png b/drivers/esphome_switchbot/www/icons/device_lg.png
new file mode 100644
index 0000000..ab1aee7
Binary files /dev/null and b/drivers/esphome_switchbot/www/icons/device_lg.png differ
diff --git a/drivers/esphome_switchbot/www/icons/device_sm.png b/drivers/esphome_switchbot/www/icons/device_sm.png
new file mode 100644
index 0000000..28937f3
Binary files /dev/null and b/drivers/esphome_switchbot/www/icons/device_sm.png differ
diff --git a/drivers/esphome_switchbot/www/icons/experience_100.png b/drivers/esphome_switchbot/www/icons/experience_100.png
new file mode 100644
index 0000000..bae48c3
Binary files /dev/null and b/drivers/esphome_switchbot/www/icons/experience_100.png differ
diff --git a/drivers/esphome_switchbot/www/icons/experience_1024.png b/drivers/esphome_switchbot/www/icons/experience_1024.png
new file mode 100644
index 0000000..ae5cc29
Binary files /dev/null and b/drivers/esphome_switchbot/www/icons/experience_1024.png differ
diff --git a/drivers/esphome_switchbot/www/icons/experience_110.png b/drivers/esphome_switchbot/www/icons/experience_110.png
new file mode 100644
index 0000000..d86e97f
Binary files /dev/null and b/drivers/esphome_switchbot/www/icons/experience_110.png differ
diff --git a/drivers/esphome_switchbot/www/icons/experience_120.png b/drivers/esphome_switchbot/www/icons/experience_120.png
new file mode 100644
index 0000000..c5af34e
Binary files /dev/null and b/drivers/esphome_switchbot/www/icons/experience_120.png differ
diff --git a/drivers/esphome_switchbot/www/icons/experience_130.png b/drivers/esphome_switchbot/www/icons/experience_130.png
new file mode 100644
index 0000000..1ec4a3e
Binary files /dev/null and b/drivers/esphome_switchbot/www/icons/experience_130.png differ
diff --git a/drivers/esphome_switchbot/www/icons/experience_140.png b/drivers/esphome_switchbot/www/icons/experience_140.png
new file mode 100644
index 0000000..0ea950c
Binary files /dev/null and b/drivers/esphome_switchbot/www/icons/experience_140.png differ
diff --git a/drivers/esphome_switchbot/www/icons/experience_20.png b/drivers/esphome_switchbot/www/icons/experience_20.png
new file mode 100644
index 0000000..c2a668b
Binary files /dev/null and b/drivers/esphome_switchbot/www/icons/experience_20.png differ
diff --git a/drivers/esphome_switchbot/www/icons/experience_30.png b/drivers/esphome_switchbot/www/icons/experience_30.png
new file mode 100644
index 0000000..a444b0f
Binary files /dev/null and b/drivers/esphome_switchbot/www/icons/experience_30.png differ
diff --git a/drivers/esphome_switchbot/www/icons/experience_300.png b/drivers/esphome_switchbot/www/icons/experience_300.png
new file mode 100644
index 0000000..dc799f0
Binary files /dev/null and b/drivers/esphome_switchbot/www/icons/experience_300.png differ
diff --git a/drivers/esphome_switchbot/www/icons/experience_40.png b/drivers/esphome_switchbot/www/icons/experience_40.png
new file mode 100644
index 0000000..de70d6d
Binary files /dev/null and b/drivers/esphome_switchbot/www/icons/experience_40.png differ
diff --git a/drivers/esphome_switchbot/www/icons/experience_50.png b/drivers/esphome_switchbot/www/icons/experience_50.png
new file mode 100644
index 0000000..59e374b
Binary files /dev/null and b/drivers/esphome_switchbot/www/icons/experience_50.png differ
diff --git a/drivers/esphome_switchbot/www/icons/experience_512.png b/drivers/esphome_switchbot/www/icons/experience_512.png
new file mode 100644
index 0000000..9138b4b
Binary files /dev/null and b/drivers/esphome_switchbot/www/icons/experience_512.png differ
diff --git a/drivers/esphome_switchbot/www/icons/experience_60.png b/drivers/esphome_switchbot/www/icons/experience_60.png
new file mode 100644
index 0000000..bb53857
Binary files /dev/null and b/drivers/esphome_switchbot/www/icons/experience_60.png differ
diff --git a/drivers/esphome_switchbot/www/icons/experience_70.png b/drivers/esphome_switchbot/www/icons/experience_70.png
new file mode 100644
index 0000000..8f298df
Binary files /dev/null and b/drivers/esphome_switchbot/www/icons/experience_70.png differ
diff --git a/drivers/esphome_switchbot/www/icons/experience_80.png b/drivers/esphome_switchbot/www/icons/experience_80.png
new file mode 100644
index 0000000..1998bdc
Binary files /dev/null and b/drivers/esphome_switchbot/www/icons/experience_80.png differ
diff --git a/drivers/esphome_switchbot/www/icons/experience_90.png b/drivers/esphome_switchbot/www/icons/experience_90.png
new file mode 100644
index 0000000..3b767d2
Binary files /dev/null and b/drivers/esphome_switchbot/www/icons/experience_90.png differ
diff --git a/drivers/esphome_yale/driver.c4zproj b/drivers/esphome_yale/driver.c4zproj
new file mode 100644
index 0000000..90c5837
--- /dev/null
+++ b/drivers/esphome_yale/driver.c4zproj
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/drivers/esphome_yale/driver.lua b/drivers/esphome_yale/driver.lua
new file mode 100644
index 0000000..7807df2
--- /dev/null
+++ b/drivers/esphome_yale/driver.lua
@@ -0,0 +1,1753 @@
+--- ESPHome Yale/August BLE Lock Driver
+--- Hybrid of esphome_lock (lock proxy) and esphome_switchbot (BLE GATT).
+--#ifdef DRIVERCENTRAL
+DC_PID = 819
+DC_X = nil
+DC_FILENAME = "esphome_yale.c4z"
+--#endif
+require("lib.utils")
+require("drivers-common-public.global.handlers")
+require("drivers-common-public.global.lib")
+require("drivers-common-public.global.timer")
+require("drivers-common-public.global.url")
+
+JSON = require("JSON")
+
+local deferred = require("deferred")
+local log = require("lib.logging")
+local bindings = require("lib.bindings")
+local values = require("lib.values")
+local persist = require("lib.persist")
+local constants = require("constants")
+local UUID = require("esphome.ble.uuid")
+local yale_protocol = require("esphome.ble.yale_protocol")
+local http = require("lib.http")
+
+--------------------------------------------------------------------------------
+-- Constants
+--------------------------------------------------------------------------------
+
+--- Binding IDs
+local PROXY_BINDING = 5001
+local ESPHOME_BINDING = 5002
+
+--- Namespace for dynamic bindings
+local BINDINGS_NAMESPACE = "Yale"
+
+--- Disconnect delay after command completion (ms)
+local DISCONNECT_DELAY_MS = 5000
+
+--- Keepalive interval for persistent connection (ms) - poll status to keep BLE alive
+local KEEPALIVE_INTERVAL_MS = 20000 -- 20 seconds
+
+--- Status poll delay after lock/unlock command (ms)
+local STATUS_POLL_DELAY_MS = 1500
+
+--- Post-operation jam detection delay (ms) - re-query lock status to catch late jams
+local JAM_CHECK_DELAY_MS = 10000
+
+--- Handshake timeout (ms) - abort and retry if lock doesn't respond
+local HANDSHAKE_TIMEOUT_MS = 10000
+
+--- Minimum delay between GATT writes (ms) per Yale BLE protocol
+local GATT_WRITE_COOLDOWN_MS = 250
+
+--- August Cloud API
+local AUGUST_API = {
+ BASE_URL = "https://api-production.august.com",
+ API_KEY = "7cab4bbd-2693-4fc1-b99b-dec0fb20f9d4",
+ HEADERS = {
+ ["Content-Type"] = "application/json",
+ ["Accept-Version"] = "0.0.1",
+ ["User-Agent"] = "August/Luna-3.2.2 (Android; SDK 31; Pixel 5)",
+ },
+}
+
+--------------------------------------------------------------------------------
+-- Handshake State
+--------------------------------------------------------------------------------
+
+--- @enum HandshakeState
+local HANDSHAKE_STATE = {
+ IDLE = 0,
+ AWAITING_KEY_EXCHANGE_RESPONSE = 1,
+ AWAITING_INIT_RESPONSE = 2,
+ COMPLETE = 3,
+}
+
+--------------------------------------------------------------------------------
+-- Connection Mode
+--------------------------------------------------------------------------------
+
+--- @enum ConnectionMode
+local CONNECTION_MODE = {
+ PERSISTENT = "Persistent",
+ POLL = "Poll",
+}
+
+--- Current connection mode
+--- @type string
+local connectionMode = CONNECTION_MODE.POLL
+
+--------------------------------------------------------------------------------
+-- Global State
+--------------------------------------------------------------------------------
+
+--- Connection state
+--- @type HandshakeState
+local handshakeState = HANDSHAKE_STATE.IDLE
+--- @type string|nil 16 random bytes for handshake
+local handshakeKeys = nil
+--- @type YaleSecureSession|nil
+local secureSession = nil
+--- @type YaleSession|nil
+local session = nil
+
+--- GATT notification subscription tracking
+--- @type boolean
+local secureReadSubscribed = false
+--- @type boolean
+local regularReadSubscribed = false
+
+--- Pending command to execute after handshake
+--- @type string|nil "lock", "unlock", "status"
+local pendingCommand = nil
+
+--- What command response we're awaiting (nil = none)
+--- @type string|nil "lock", "unlock", "status_lock", "status_door", "status_battery"
+local awaitingResponse = nil
+
+--- GATT write queue for 250ms cooldown enforcement
+--- @type table[]
+local gattWriteQueue = {}
+
+--- Current lock status for toggle logic
+--- @type string|nil "locked", "unlocked", etc.
+local currentLockStatus = nil
+
+--- Whether DoorSense is detected as configured on the lock
+--- @type boolean|nil nil = unknown, true = configured, false = not configured
+local doorSenseConfigured = nil
+
+--- Reconnect backoff tracking (Persistent mode)
+--- @type integer
+local reconnectAttempts = 0
+local MAX_RECONNECT_ATTEMPTS = 5
+local BASE_RECONNECT_MS = 5000
+
+--- Whether initial status query has been triggered (for Poll mode first-advertisement)
+--- @type boolean
+local initialStatusTriggered = false
+
+--- August cloud session token (for key fetching)
+--- @type string|nil
+local augustSessionToken = nil
+
+--------------------------------------------------------------------------------
+-- Handle Management
+--------------------------------------------------------------------------------
+
+--- Get BLE handles from persist
+--- @return integer|nil writeHandle
+--- @return integer|nil readHandle
+--- @return integer|nil secureWriteHandle
+--- @return integer|nil secureReadHandle
+local function getHandles()
+ return tointeger(persist:get("WRITE_HANDLE")),
+ tointeger(persist:get("READ_HANDLE")),
+ tointeger(persist:get("SECURE_WRITE_HANDLE")),
+ tointeger(persist:get("SECURE_READ_HANDLE"))
+end
+
+--- Save BLE handles to persist
+local function saveHandles(writeHandle, readHandle, secureWriteHandle, secureReadHandle)
+ persist:set("WRITE_HANDLE", writeHandle)
+ persist:set("READ_HANDLE", readHandle)
+ persist:set("SECURE_WRITE_HANDLE", secureWriteHandle)
+ persist:set("SECURE_READ_HANDLE", secureReadHandle)
+end
+
+--- Reset connection state
+--- @param clearHandles boolean|nil If true, also clear persisted BLE handles
+local function resetConnectionState(clearHandles)
+ log:trace("resetConnectionState(%s)", clearHandles)
+ handshakeState = HANDSHAKE_STATE.IDLE
+ handshakeKeys = nil
+ secureSession = nil
+ session = nil
+ secureReadSubscribed = false
+ regularReadSubscribed = false
+ pendingCommand = nil
+ awaitingResponse = nil
+ gattWriteQueue = {}
+ CancelTimer("GattWriteDrain")
+ CancelTimer("HandshakeTimeout")
+ CancelTimer("Keepalive")
+ CancelTimer("Reconnect")
+ CancelTimer("PollCycle")
+ CancelTimer("JamCheck")
+ CancelTimer("CleanDisconnect")
+ CancelTimer("DisconnectDelay")
+ CancelTimer("StatusPoll")
+ CancelTimer("DoorPoll")
+ CancelTimer("BatteryPoll")
+
+ if clearHandles then
+ saveHandles(nil, nil, nil, nil)
+ end
+end
+
+local findCharacteristicHandle = UUID.findCharacteristicHandle
+
+--------------------------------------------------------------------------------
+-- GATT Write Helpers
+--------------------------------------------------------------------------------
+
+--- Send the next queued GATT write and schedule the next drain
+local function drainGattWriteQueue()
+ if #gattWriteQueue == 0 then
+ return
+ end
+
+ local entry = table.remove(gattWriteQueue, 1)
+ log:info(
+ "GATT write: handle=%d, %d bytes, hex=%s (%d queued)",
+ entry.handle,
+ #entry.data,
+ C4:Encode(entry.data, "HEX"),
+ #gattWriteQueue
+ )
+ SendToProxy(ESPHOME_BINDING, "GATT_WRITE", {
+ handle = tostring(entry.handle),
+ data = C4:Base64Encode(entry.data),
+ response = "true",
+ }, "NOTIFY")
+
+ if #gattWriteQueue > 0 then
+ SetTimer("GattWriteDrain", GATT_WRITE_COOLDOWN_MS, drainGattWriteQueue)
+ end
+end
+
+--- Queue a GATT write, draining at 250ms intervals
+--- @param handle integer GATT handle
+--- @param data string Binary data to write
+local function gattWrite(handle, data)
+ gattWriteQueue[#gattWriteQueue + 1] = { handle = handle, data = data }
+
+ if #gattWriteQueue == 1 then
+ drainGattWriteQueue()
+ end
+end
+
+--------------------------------------------------------------------------------
+-- Connection Management
+--------------------------------------------------------------------------------
+
+--- Request GATT connection from parent driver
+local function requestConnection()
+ log:trace("requestConnection()")
+ SendToProxy(ESPHOME_BINDING, "CONNECT", {}, "NOTIFY")
+end
+
+--- Actually perform the GATT disconnection
+local function doDisconnect()
+ log:trace("doDisconnect()")
+ resetConnectionState()
+ SendToProxy(ESPHOME_BINDING, "DISCONNECT", {}, "NOTIFY")
+end
+
+--- Send a clean disconnect command via secure session before GATT disconnect
+local function sendCleanDisconnect()
+ log:trace("sendCleanDisconnect()")
+
+ if not secureSession or handshakeState ~= HANDSHAKE_STATE.COMPLETE then
+ doDisconnect()
+ return
+ end
+
+ local _, _, secureWriteHandle = getHandles()
+ if not secureWriteHandle then
+ doDisconnect()
+ return
+ end
+
+ -- Build disconnect command (opcode 0x05, byte 0x11 = 0x00)
+ local cmd = yale_protocol.buildSecureCommand(yale_protocol.Opcode.SEC_DISCONNECT, 0x00)
+
+ -- Recompute security checksum
+ local cmdBytes = {}
+ for i = 1, 18 do
+ cmdBytes[i] = string.byte(cmd, i)
+ end
+ yale_protocol.writeSecurityChecksum(cmdBytes)
+
+ local chars = {}
+ for i = 1, 18 do
+ chars[i] = string.char(cmdBytes[i])
+ end
+ local packet = table.concat(chars)
+
+ local encrypted = secureSession:encrypt(packet)
+ gattWrite(secureWriteHandle, encrypted)
+
+ -- Disconnect after a short delay regardless of response
+ SetTimer("CleanDisconnect", 500, doDisconnect)
+end
+
+--- Request GATT disconnection (debounced)
+local function requestDisconnect()
+ log:debug("requestDisconnect() - will disconnect in %dms", DISCONNECT_DELAY_MS)
+ CancelTimer("Keepalive")
+ SetTimer("DisconnectDelay", DISCONNECT_DELAY_MS, function()
+ log:debug("DisconnectDelay timer fired - sending clean disconnect")
+ sendCleanDisconnect()
+ end)
+end
+
+--- Cancel any pending disconnect
+local function cancelPendingDisconnect()
+ log:trace("cancelPendingDisconnect()")
+ CancelTimer("DisconnectDelay")
+end
+
+--- Start keepalive timer for persistent connection.
+--- Periodically polls lock status to keep the BLE connection alive
+--- and detect state changes from unsolicited notifications.
+local function startKeepalive()
+ log:info("Persistent connection established - starting keepalive")
+ UpdateProperty("Driver Status", "Connected")
+ CancelTimer("DisconnectDelay")
+ SetTimer("Keepalive", KEEPALIVE_INTERVAL_MS, function()
+ if handshakeState == HANDSHAKE_STATE.COMPLETE and session and session:isReady() then
+ log:debug("Keepalive: polling lock status")
+ pendingCommand = "status"
+ executePendingCommand()
+ else
+ log:warn("Keepalive: session not ready, connection may have dropped")
+ end
+ end, true) -- repeat=true
+end
+
+--- Schedule next poll cycle (Poll mode only).
+--- Sets a one-shot timer that connects, queries status, then disconnects.
+local function schedulePoll()
+ local interval = tointeger(Properties["Polling Interval"]) or 60
+ log:info("Scheduling next poll in %d seconds", interval)
+ UpdateProperty("Driver Status", string.format("Listening (next poll in %ds)", interval))
+ CancelTimer("PollCycle")
+ SetTimer("PollCycle", interval * 1000, function()
+ log:info("Poll timer fired - querying status")
+ initiateCommand("status")
+ end)
+end
+
+--- Called when the status chain completes (lock → door → battery done).
+--- Replaces direct startKeepalive() calls — branches on connection mode.
+local function onStatusChainComplete()
+ log:debug("onStatusChainComplete() mode=%s", connectionMode)
+ if connectionMode == CONNECTION_MODE.PERSISTENT then
+ startKeepalive()
+ else
+ -- Poll mode: disconnect immediately after query
+ sendCleanDisconnect()
+ end
+end
+
+--- Transition to a new connection mode. Cancels all timers, disconnects if connected,
+--- resets toggle tracking, and sets the new mode.
+--- @param newMode string One of CONNECTION_MODE values
+local function setConnectionMode(newMode)
+ log:info("Setting connection mode: %s -> %s", connectionMode, newMode)
+
+ -- Cancel all mode-related timers
+ CancelTimer("Keepalive")
+ CancelTimer("Reconnect")
+ CancelTimer("PollCycle")
+ CancelTimer("DisconnectDelay")
+
+ -- Disconnect if connected
+ if handshakeState ~= HANDSHAKE_STATE.IDLE then
+ doDisconnect()
+ end
+
+ -- Reset state
+ reconnectAttempts = 0
+ initialStatusTriggered = false
+
+ -- Set new mode
+ connectionMode = newMode
+
+ -- Show/hide Polling Interval property
+ if newMode == CONNECTION_MODE.POLL then
+ C4:SetPropertyAttribs("Polling Interval", constants.SHOW_PROPERTY)
+ else
+ C4:SetPropertyAttribs("Polling Interval", constants.HIDE_PROPERTY)
+ end
+
+ UpdateProperty("Driver Status", "Listening")
+end
+
+--- Subscribe to GATT notifications on a handle
+--- @param handle integer GATT characteristic handle
+local function subscribeNotifications(handle)
+ log:debug("Subscribing to GATT notifications on handle %s", handle)
+ SendToProxy(ESPHOME_BINDING, "GATT_NOTIFY", {
+ handle = tostring(handle),
+ enable = "true",
+ }, "NOTIFY")
+end
+
+--------------------------------------------------------------------------------
+-- Value Helpers
+--------------------------------------------------------------------------------
+
+local function updateLastSeen()
+ values:update("Last Seen", tostring(os.date("%Y-%m-%d %H:%M:%S")))
+end
+
+--- @param rssi string|number 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
+
+--------------------------------------------------------------------------------
+-- Offline Key Validation
+--------------------------------------------------------------------------------
+
+--- Get the offline key as a 16-byte binary string
+--- @return string|nil key 16-byte key or nil if not configured
+local function getOfflineKey()
+ local hex = Properties["Offline Key"]
+ if type(hex) ~= "string" or #hex ~= 32 then
+ return nil
+ end
+ -- Validate hex characters
+ if not hex:match("^%x+$") then
+ return nil
+ end
+ return (C4:Decode(hex, "HEX"))
+end
+
+--- Get the key slot index
+--- @return integer keySlot Key slot (default 1)
+local function getKeySlot()
+ return tointeger(Properties["Key Slot"]) or 1
+end
+
+--- Check if authentication is configured
+--- @return boolean configured True if offline key is set
+local function isAuthConfigured()
+ return getOfflineKey() ~= nil
+end
+
+--- Schedule reconnect or poll after a connection/handshake failure.
+--- Centralizes recovery logic used by DISCONNECTED, CONNECTION_FAILED, and handshake errors.
+--- @param status string Driver status message to display
+--- @param savedCommand string|nil Command to retry on reconnect (e.g. "lock", "unlock", "status")
+local function scheduleRecovery(status, savedCommand)
+ if connectionMode == CONNECTION_MODE.PERSISTENT then
+ if not isAuthConfigured() then
+ UpdateProperty("Driver Status", "Listening")
+ return
+ end
+ reconnectAttempts = reconnectAttempts + 1
+ if reconnectAttempts > MAX_RECONNECT_ATTEMPTS then
+ log:warn("Max reconnect attempts (%d) reached: %s", MAX_RECONNECT_ATTEMPTS, status)
+ UpdateProperty("Driver Status", "Listening (reconnect failed)")
+ reconnectAttempts = 0
+ return
+ end
+ local delay = BASE_RECONNECT_MS * (2 ^ (reconnectAttempts - 1))
+ log:info("Retrying in %ds (attempt %d/%d): %s", delay / 1000, reconnectAttempts, MAX_RECONNECT_ATTEMPTS, status)
+ UpdateProperty("Driver Status", string.format("Reconnecting (%d/%d)", reconnectAttempts, MAX_RECONNECT_ATTEMPTS))
+ local retryCommand = savedCommand or "status"
+ SetTimer("Reconnect", delay, function()
+ initiateCommand(retryCommand)
+ end)
+ elseif connectionMode == CONNECTION_MODE.POLL then
+ UpdateProperty("Driver Status", status)
+ schedulePoll()
+ else
+ UpdateProperty("Driver Status", status)
+ end
+end
+
+--------------------------------------------------------------------------------
+-- Lock Status Updates
+--------------------------------------------------------------------------------
+
+--- Update the lock status and notify the proxy
+--- @param status string C4 lock status ("locked", "unlocked", "fault")
+local function updateLockStatus(status)
+ log:info("Lock status: %s", status)
+ currentLockStatus = status
+ UpdateProperty("Lock Status", status)
+ SendToProxy(PROXY_BINDING, "LOCK_STATUS_CHANGED", { LOCK_STATUS = status }, "NOTIFY")
+end
+
+--- Enable or disable DoorSense contact sensor based on detection.
+--- When DoorSense is configured on the lock, creates a dynamic CONTACT_SENSOR binding
+--- and shows the Door Status property. When not configured, removes the binding and hides it.
+--- @param configured boolean Whether DoorSense is configured
+local function setDoorSenseConfigured(configured)
+ if doorSenseConfigured == configured then
+ return
+ end
+ doorSenseConfigured = configured
+
+ if configured then
+ log:info("DoorSense detected - creating contact sensor binding")
+ bindings:getOrAddDynamicBinding(BINDINGS_NAMESPACE, "door", "PROXY", true, "Door", "CONTACT_SENSOR")
+ C4:SetPropertyAttribs("Door Status", constants.SHOW_PROPERTY)
+ else
+ log:info("DoorSense not configured - removing contact sensor binding")
+ bindings:deleteBinding(BINDINGS_NAMESPACE, "door")
+ UpdateProperty("Door Status", "")
+ C4:SetPropertyAttribs("Door Status", constants.HIDE_PROPERTY)
+ end
+end
+
+--- Update the door status and notify dynamic contact sensor binding.
+--- Persists state via the values lib so it survives reboots/driver updates.
+--- Deduplicates: only sends a proxy notification when the state actually changes.
+--- @param doorStatus string "CLOSED" or "OPENED"
+local function updateDoorStatus(doorStatus)
+ setDoorSenseConfigured(true)
+
+ if not values:update("Door Status", doorStatus) then
+ return
+ end
+
+ log:info("Door status: %s", doorStatus)
+
+ local binding = bindings:getDynamicBinding(BINDINGS_NAMESPACE, "door")
+ if binding then
+ SendToProxy(binding.bindingId, doorStatus, {}, "NOTIFY")
+ end
+end
+
+--- Update battery percentage
+--- @param percentage integer Battery percentage (0-100)
+local function updateBattery(percentage)
+ log:info("Battery: %d%%", percentage)
+ values:update("Battery", percentage, "NUMBER", nil, " %")
+end
+
+--------------------------------------------------------------------------------
+-- Handshake State Machine
+--------------------------------------------------------------------------------
+
+--- Generate 16 random bytes
+--- @return string randomBytes 16 random bytes
+local function generateRandomBytes()
+ local bytes = {}
+ for i = 1, 16 do
+ bytes[i] = string.char(math.random(0, 255))
+ end
+ return table.concat(bytes)
+end
+
+--- Start the Yale BLE handshake
+local function startHandshake()
+ log:info("Starting Yale BLE handshake")
+
+ local offlineKey = getOfflineKey()
+ if not offlineKey then
+ log:error("Cannot start handshake: offline key not configured")
+ UpdateProperty("Driver Status", "Error: Offline key required")
+ requestDisconnect()
+ return
+ end
+
+ -- Initialize sessions
+ secureSession = yale_protocol.SecureSession:new(offlineKey)
+ session = yale_protocol.Session:new()
+
+ -- Step 1: Generate 16 random bytes
+ handshakeKeys = generateRandomBytes()
+
+ -- Step 2: Build SEC_LOCK_TO_MOBILE_KEY_EXCHANGE command
+ local cmd = yale_protocol.buildSecureCommand(yale_protocol.Opcode.SEC_LOCK_TO_MOBILE_KEY_EXCHANGE, getKeySlot())
+
+ -- Copy first 8 bytes of handshakeKeys to offset 0x04 (bytes 5-12)
+ local cmdBytes = {}
+ for i = 1, 18 do
+ cmdBytes[i] = string.byte(cmd, i)
+ end
+ for i = 1, 8 do
+ cmdBytes[4 + i] = string.byte(handshakeKeys, i)
+ end
+
+ -- Recompute security checksum (LE u32 sum method)
+ yale_protocol.writeSecurityChecksum(cmdBytes)
+
+ local chars = {}
+ for i = 1, 18 do
+ chars[i] = string.char(cmdBytes[i])
+ end
+ local packet = table.concat(chars)
+
+ log:info("Handshake plaintext: %s", C4:Encode(packet, "HEX"))
+
+ -- Encrypt with secure session (ECB)
+ local encrypted = secureSession:encrypt(packet)
+
+ log:info("Handshake encrypted: %s", C4:Encode(encrypted, "HEX"))
+
+ -- Send on secure write characteristic
+ local _, _, secureWriteHandle = getHandles()
+ if not secureWriteHandle then
+ log:error("Secure write handle not available")
+ requestDisconnect()
+ return
+ end
+
+ log:info("Sending KEY_EXCHANGE to handle %d (%d bytes)", secureWriteHandle, #encrypted)
+ handshakeState = HANDSHAKE_STATE.AWAITING_KEY_EXCHANGE_RESPONSE
+ gattWrite(secureWriteHandle, encrypted)
+
+ -- Timeout if lock doesn't respond to handshake
+ SetTimer("HandshakeTimeout", HANDSHAKE_TIMEOUT_MS, function()
+ if handshakeState ~= HANDSHAKE_STATE.IDLE and handshakeState ~= HANDSHAKE_STATE.COMPLETE then
+ log:warn("Handshake timeout - no response after %dms", HANDSHAKE_TIMEOUT_MS)
+ local savedCommand = pendingCommand
+ doDisconnect()
+ scheduleRecovery("Error: Handshake timeout", savedCommand)
+ end
+ end)
+end
+
+--- Handle handshake key exchange response
+--- @param data string 18-byte response from secure read
+local function handleKeyExchangeResponse(data)
+ log:info("Received key exchange response")
+
+ if not secureSession or not handshakeKeys then
+ log:error("Handshake state lost")
+ requestDisconnect()
+ return
+ end
+
+ -- Decrypt the response
+ local decrypted = secureSession:decrypt(data)
+ local opcode = string.byte(decrypted, 1)
+
+ if opcode ~= yale_protocol.Opcode.SEC_MOBILE_TO_LOCK_KEY_EXCHANGE_RESP then
+ log:error(
+ "Unexpected handshake response opcode: 0x%02X (expected 0x%02X)",
+ opcode,
+ yale_protocol.Opcode.SEC_MOBILE_TO_LOCK_KEY_EXCHANGE_RESP
+ )
+ log:warn("Offline key may have been rotated - re-fetch keys from Yale Cloud")
+ UpdateProperty("Yale Cloud Status", "Key mismatch - re-fetch keys")
+ requestDisconnect()
+ return
+ end
+
+ -- Extract lock's 8 bytes from offset 0x04 (bytes 5-12)
+ local lockBytes = decrypted:sub(5, 12)
+
+ -- Derive session key: handshakeKeys[1:8] || lockBytes[1:8]
+ local sessionKey = handshakeKeys:sub(1, 8) .. lockBytes:sub(1, 8)
+
+ -- Re-key both sessions
+ secureSession:setKey(sessionKey)
+ session:setKey(sessionKey)
+
+ -- Step 6: Send SEC_INITIALIZATION_COMMAND
+ local cmd = yale_protocol.buildSecureCommand(yale_protocol.Opcode.SEC_INITIALIZATION_COMMAND, getKeySlot())
+
+ -- Copy handshakeKeys[9:16] to offset 0x04 (bytes 5-12)
+ local cmdBytes = {}
+ for i = 1, 18 do
+ cmdBytes[i] = string.byte(cmd, i)
+ end
+ for i = 1, 8 do
+ cmdBytes[4 + i] = string.byte(handshakeKeys, 8 + i)
+ end
+
+ -- Recompute security checksum (LE u32 sum method)
+ yale_protocol.writeSecurityChecksum(cmdBytes)
+
+ local chars = {}
+ for i = 1, 18 do
+ chars[i] = string.char(cmdBytes[i])
+ end
+ local packet = table.concat(chars)
+
+ -- Encrypt with secure session (now using session key)
+ local encrypted = secureSession:encrypt(packet)
+
+ local _, _, secureWriteHandle = getHandles()
+ if not secureWriteHandle then
+ log:error("Secure write handle not available")
+ requestDisconnect()
+ return
+ end
+
+ handshakeState = HANDSHAKE_STATE.AWAITING_INIT_RESPONSE
+ gattWrite(secureWriteHandle, encrypted)
+end
+
+--- Handle handshake initialization response
+--- @param data string 18-byte response from secure read
+local function handleInitResponse(data)
+ log:info("Received initialization response")
+
+ if not secureSession then
+ log:error("Handshake state lost")
+ requestDisconnect()
+ return
+ end
+
+ local decrypted = secureSession:decrypt(data)
+ local opcode = string.byte(decrypted, 1)
+
+ if opcode ~= yale_protocol.Opcode.SEC_INITIALIZATION_RESP then
+ log:error(
+ "Unexpected init response opcode: 0x%02X (expected 0x%02X)",
+ opcode,
+ yale_protocol.Opcode.SEC_INITIALIZATION_RESP
+ )
+ log:warn("Offline key may have been rotated - re-fetch keys from Yale Cloud")
+ UpdateProperty("Yale Cloud Status", "Key mismatch - re-fetch keys")
+ requestDisconnect()
+ return
+ end
+
+ log:info("Handshake complete - session established")
+ CancelTimer("HandshakeTimeout")
+ handshakeState = HANDSHAKE_STATE.COMPLETE
+ reconnectAttempts = 0
+ UpdateProperty("Driver Status", "Connected")
+ SendToProxy(PROXY_BINDING, "ONLINE_CHANGED", { STATE = "true" }, "NOTIFY")
+
+ -- Per yalexs-ble: subscribe regular read AFTER handshake completes
+ local _, readHandle = getHandles()
+ if readHandle and not regularReadSubscribed then
+ subscribeNotifications(readHandle)
+ else
+ -- Already subscribed (shouldn't happen in normal flow)
+ executePendingCommand()
+ end
+end
+
+--------------------------------------------------------------------------------
+-- Command Execution
+--------------------------------------------------------------------------------
+
+--- Execute the pending command (called after handshake completes)
+function executePendingCommand()
+ log:trace("executePendingCommand()")
+ if not pendingCommand then
+ log:debug("No pending command, requesting status")
+ pendingCommand = "status"
+ end
+
+ if handshakeState ~= HANDSHAKE_STATE.COMPLETE then
+ log:debug("Handshake not complete, deferring command")
+ return
+ end
+
+ if not session or not session:isReady() then
+ log:error("Session not ready for command execution")
+ requestDisconnect()
+ return
+ end
+
+ local writeHandle = getHandles()
+ if not writeHandle then
+ log:error("Write handle not available")
+ requestDisconnect()
+ return
+ end
+
+ local cmd = pendingCommand
+ pendingCommand = nil
+
+ if cmd == "lock" then
+ log:info("Sending LOCK command")
+ local packet = yale_protocol.buildCommand(yale_protocol.Opcode.LOCK)
+ local encrypted = session:encrypt(packet)
+ awaitingResponse = "lock"
+ gattWrite(writeHandle, encrypted)
+ elseif cmd == "unlock" then
+ log:info("Sending UNLOCK command")
+ local packet = yale_protocol.buildCommand(yale_protocol.Opcode.UNLOCK)
+ local encrypted = session:encrypt(packet)
+ awaitingResponse = "unlock"
+ gattWrite(writeHandle, encrypted)
+ elseif cmd == "status" then
+ log:info("Sending GET_STATUS command (lock only)")
+ local packet =
+ yale_protocol.buildOperationCommand(yale_protocol.Opcode.GET_STATUS, yale_protocol.StatusType.LOCK_ONLY)
+ local encrypted = session:encrypt(packet)
+ awaitingResponse = "status_lock"
+ gattWrite(writeHandle, encrypted)
+ elseif cmd == "door" then
+ log:info("Sending GET_STATUS command (door only)")
+ local packet =
+ yale_protocol.buildOperationCommand(yale_protocol.Opcode.GET_STATUS, yale_protocol.StatusType.DOOR_ONLY)
+ local encrypted = session:encrypt(packet)
+ awaitingResponse = "status_door"
+ gattWrite(writeHandle, encrypted)
+ elseif cmd == "battery" then
+ log:info("Sending battery status command")
+ local packet =
+ yale_protocol.buildOperationCommand(yale_protocol.Opcode.GET_STATUS, yale_protocol.StatusType.BATTERY)
+ local encrypted = session:encrypt(packet)
+ awaitingResponse = "status_battery"
+ gattWrite(writeHandle, encrypted)
+ end
+end
+
+--- Initiate a lock/unlock command - connect if needed, then execute
+--- @param command string "lock", "unlock", or "status"
+function initiateCommand(command)
+ log:info("Initiating command: %s", command)
+ cancelPendingDisconnect()
+ CancelTimer("PollCycle")
+ pendingCommand = command
+
+ if handshakeState == HANDSHAKE_STATE.COMPLETE and session and session:isReady() then
+ -- Already connected and authenticated, execute immediately
+ executePendingCommand()
+ elseif handshakeState ~= HANDSHAKE_STATE.IDLE then
+ -- Handshake already in progress - pendingCommand is set above,
+ -- it will execute when the current handshake completes
+ log:info("Handshake in progress (state=%d), command queued", handshakeState)
+ else
+ -- Not connected, need to connect first
+ requestConnection()
+ end
+end
+
+--- Queue a status poll after lock/unlock command
+local function queueStatusPoll()
+ SetTimer("StatusPoll", STATUS_POLL_DELAY_MS, function()
+ if handshakeState == HANDSHAKE_STATE.COMPLETE then
+ pendingCommand = "status"
+ executePendingCommand()
+ end
+ end)
+end
+
+--- Queue a door status poll after lock status
+local function queueDoorPoll()
+ SetTimer("DoorPoll", STATUS_POLL_DELAY_MS, function()
+ if handshakeState == HANDSHAKE_STATE.COMPLETE then
+ pendingCommand = "door"
+ executePendingCommand()
+ end
+ end)
+end
+
+--- Queue a battery poll after door status
+local function queueBatteryPoll()
+ SetTimer("BatteryPoll", STATUS_POLL_DELAY_MS, function()
+ if handshakeState == HANDSHAKE_STATE.COMPLETE then
+ pendingCommand = "battery"
+ executePendingCommand()
+ end
+ end)
+end
+
+--------------------------------------------------------------------------------
+-- Response Handling
+--------------------------------------------------------------------------------
+
+--- Handle a command response from the regular read characteristic.
+--- This is called for ALL notifications on the regular read handle (not just
+--- solicited ones) because we must always decrypt to keep the CBC IV chain in sync.
+--- @param data string 18-byte encrypted response
+local function handleCommandResponse(data)
+ if not session or not session:isReady() then
+ log:error("Session not ready for response decryption")
+ return
+ end
+
+ -- Always decrypt to keep CBC IV chain in sync
+ local decrypted = session:decrypt(data)
+ log:info("Decrypted response hex: %s", C4:Encode(decrypted, "HEX"))
+
+ -- Validate simple checksum on decrypted response
+ local expectedChecksum = yale_protocol.simpleChecksum(decrypted)
+ local actualChecksum = string.byte(decrypted, 4)
+ if expectedChecksum ~= actualChecksum then
+ log:warn("Response checksum mismatch: expected=0x%02X, got=0x%02X", expectedChecksum, actualChecksum)
+ end
+
+ local response = yale_protocol.parseResponse(decrypted)
+
+ if not response then
+ log:warn("Failed to parse response (decryption kept IV in sync)")
+ return
+ end
+
+ log:debug("Response: flag=0x%02X, opcode=0x%02X", response.flag, response.opcode)
+
+ -- Determine if this response matches what we're awaiting.
+ -- Only consume awaitingResponse when the response type matches,
+ -- so unsolicited notifications don't steal the solicited flag.
+ local wasSolicited = false
+ if awaitingResponse then
+ if response.flag == 0xAA and (awaitingResponse == "lock" or awaitingResponse == "unlock") then
+ wasSolicited = true
+ awaitingResponse = nil
+ elseif response.flag == 0xBB and response.opcode == yale_protocol.Opcode.GET_STATUS then
+ if awaitingResponse == "status_lock" and response.statusType == yale_protocol.StatusType.LOCK_ONLY then
+ wasSolicited = true
+ awaitingResponse = nil
+ elseif awaitingResponse == "status_door" and response.statusType == yale_protocol.StatusType.DOOR_ONLY then
+ wasSolicited = true
+ awaitingResponse = nil
+ elseif awaitingResponse == "status_battery" and response.statusType == yale_protocol.StatusType.BATTERY then
+ wasSolicited = true
+ awaitingResponse = nil
+ end
+ end
+ end
+
+ log:debug("Response is %s", wasSolicited and "solicited" or "unsolicited")
+
+ -- Status response
+ if response.flag == 0xBB then
+ if response.lockStatus then
+ local c4Status = yale_protocol.toC4LockStatus(response.lockStatus)
+ local statusStr = yale_protocol.parseLockStatus(response.lockStatus)
+ log:info("Lock status: %s (0x%02X) -> C4: %s", statusStr, response.lockStatus, c4Status)
+ updateLockStatus(c4Status)
+ end
+
+ if response.doorStatus then
+ local doorStr = yale_protocol.parseDoorStatus(response.doorStatus)
+ if doorStr ~= "UNKNOWN" then
+ updateDoorStatus(doorStr)
+ else
+ setDoorSenseConfigured(false)
+ end
+ end
+
+ if response.batteryMillivolts then
+ local battery = yale_protocol.parseBattery(response.batteryMillivolts)
+ if battery > 0 then
+ updateBattery(battery)
+ end
+ end
+
+ updateLastSeen()
+
+ -- Only drive the command state machine for solicited responses
+ if wasSolicited then
+ if response.opcode == yale_protocol.Opcode.GET_STATUS then
+ if response.statusType == yale_protocol.StatusType.LOCK_ONLY then
+ queueDoorPoll()
+ elseif response.statusType == yale_protocol.StatusType.DOOR_ONLY then
+ queueBatteryPoll()
+ else
+ -- Status chain complete (battery done)
+ onStatusChainComplete()
+ end
+ else
+ onStatusChainComplete()
+ end
+ end
+ return
+ end
+
+ -- Acknowledgment response (lock/unlock completed)
+ if response.flag == 0xAA then
+ -- ACK means command succeeded: infer status from opcode
+ if response.opcode == yale_protocol.Opcode.LOCK then
+ updateLockStatus("locked")
+ elseif response.opcode == yale_protocol.Opcode.UNLOCK then
+ updateLockStatus("unlocked")
+ end
+ updateLastSeen()
+
+ -- Poll for full status after command, then schedule jam check
+ if wasSolicited then
+ queueStatusPoll()
+ -- Re-query lock status after 10s to catch late-reported jams
+ SetTimer("JamCheck", JAM_CHECK_DELAY_MS, function()
+ if handshakeState == HANDSHAKE_STATE.COMPLETE then
+ log:debug("Jam check: re-querying lock status")
+ pendingCommand = "status"
+ executePendingCommand()
+ elseif connectionMode == CONNECTION_MODE.POLL then
+ log:debug("Jam check: connecting to query status")
+ initiateCommand("status")
+ end
+ end)
+ end
+ return
+ end
+
+ -- Unknown response — just log it, don't disconnect
+ log:debug("Unknown response flag: 0x%02X (CBC IV chain kept in sync)", response.flag)
+end
+
+--------------------------------------------------------------------------------
+-- Yale Cloud API Key Fetching
+--------------------------------------------------------------------------------
+
+--- Request a verification code from the August API.
+--- @return Deferred deferred Resolves on success, rejects with error string on failure
+local function requestVerificationCode()
+ log:trace("requestVerificationCode()")
+ local d = deferred.new()
+
+ local email = Properties["Yale Email"]
+ local password = Properties["Yale Password"]
+
+ if IsEmpty(email) then
+ return d:reject("Email required")
+ end
+ if IsEmpty(password) then
+ return d:reject("Password required")
+ end
+
+ UpdateProperty("Yale Cloud Status", "Creating session...")
+
+ local sessionUrl = AUGUST_API.BASE_URL .. "/session"
+ local headers = {}
+ for k, v in pairs(AUGUST_API.HEADERS) do
+ headers[k] = v
+ end
+ headers["x-august-api-key"] = AUGUST_API.API_KEY
+
+ local sessionData = JSON:encode({
+ identifier = "email:" .. email,
+ installId = "C4-" .. tostring(C4:GetDeviceID()),
+ password = password,
+ })
+
+ http
+ :post(sessionUrl, sessionData, headers)
+ :next(function(response)
+ local token = Select(response.headers, "x-august-access-token")
+ or Select(response.headers, "X-August-Access-Token")
+ if IsEmpty(token) then
+ return d:reject("No access token in session response")
+ end
+ augustSessionToken = token
+ log:info("August session created")
+
+ -- Send verification code
+ UpdateProperty("Yale Cloud Status", "Sending verification code...")
+ local validateUrl = AUGUST_API.BASE_URL .. "/validation/email"
+ local validateHeaders = {}
+ for k, v in pairs(AUGUST_API.HEADERS) do
+ validateHeaders[k] = v
+ end
+ validateHeaders["x-august-api-key"] = AUGUST_API.API_KEY
+ validateHeaders["x-august-access-token"] = token
+
+ local validateData = JSON:encode({ value = email })
+ return http:post(validateUrl, validateData, validateHeaders)
+ end)
+ :next(function()
+ log:info("Verification code sent to %s", Properties["Yale Email"])
+ d:resolve(true)
+ end, function(err)
+ return d:reject(err)
+ end)
+
+ return d
+end
+
+--- Verify the code and fetch offline keys.
+--- @param code string The verification code from the action param
+--- @return Deferred deferred Resolves with { offlineKey, keySlot } on success, rejects with error string on failure
+local function verifyAndFetchKeys(code)
+ log:trace("verifyAndFetchKeys()")
+ local d = deferred.new()
+
+ local email = Properties["Yale Email"]
+
+ if IsEmpty(augustSessionToken) then
+ return d:reject("Request verification code first")
+ end
+ if IsEmpty(code) then
+ return d:reject("Verification code required")
+ end
+
+ UpdateProperty("Yale Cloud Status", "Verifying code...")
+
+ local headers = {}
+ for k, v in pairs(AUGUST_API.HEADERS) do
+ headers[k] = v
+ end
+ headers["x-august-api-key"] = AUGUST_API.API_KEY
+ headers["x-august-access-token"] = augustSessionToken
+
+ -- Validate the code
+ local validateUrl = AUGUST_API.BASE_URL .. "/validate/email"
+ local validateData = JSON:encode({ code = code, email = email })
+
+ http
+ :post(validateUrl, validateData, headers)
+ :next(function(response)
+ -- Check for updated token in response
+ local newToken = Select(response.headers, "x-august-access-token")
+ or Select(response.headers, "X-August-Access-Token")
+ if not IsEmpty(newToken) then
+ augustSessionToken = newToken
+ headers["x-august-access-token"] = newToken
+ end
+
+ UpdateProperty("Yale Cloud Status", "Fetching locks...")
+ local locksUrl = AUGUST_API.BASE_URL .. "/users/locks/mine"
+ return http:get(locksUrl, headers)
+ end)
+ :next(function(response)
+ local locks = response.body
+ if type(locks) == "string" then
+ locks = JSON:decode(locks)
+ end
+ if type(locks) ~= "table" then
+ return d:reject("Invalid locks response")
+ end
+
+ -- Find the first lock (or match by MAC if we have one)
+ local mac = Properties["MAC Address"]
+ local targetLockId = nil
+
+ for lockId, lockInfo in pairs(locks) do
+ if type(lockInfo) == "table" then
+ -- Check if MAC matches (if we have one)
+ if not IsEmpty(mac) and mac ~= "Unknown" then
+ local lockMac = Select(lockInfo, "macAddress") or ""
+ lockMac = lockMac:upper():gsub("[:-]", "")
+ local ourMac = mac:upper():gsub("[:-]", "")
+ if lockMac == ourMac then
+ targetLockId = lockId
+ break
+ end
+ end
+ -- Otherwise use first lock found
+ if not targetLockId then
+ targetLockId = lockId
+ end
+ end
+ end
+
+ if not targetLockId then
+ return d:reject("No locks found in account")
+ end
+
+ UpdateProperty("Yale Cloud Status", "Fetching key for lock " .. targetLockId .. "...")
+ local lockUrl = AUGUST_API.BASE_URL .. "/locks/" .. targetLockId
+ return http:get(lockUrl, headers)
+ end)
+ :next(function(response)
+ local lockInfo = response.body
+ log:debug("Lock info response type: %s", type(lockInfo))
+ if type(lockInfo) == "string" then
+ lockInfo = JSON:decode(lockInfo)
+ end
+ if type(lockInfo) ~= "table" then
+ return d:reject("Invalid lock info response (type: " .. type(lockInfo) .. ")")
+ end
+
+ -- Extract offline key from OfflineKeys.loaded
+ log:debug("Lock info keys: %s", lockInfo)
+ local offlineKeys = Select(lockInfo, "OfflineKeys") or {}
+ local loaded = offlineKeys.loaded
+ local offlineKey, keySlot
+ if loaded and #loaded > 0 then
+ offlineKey = loaded[1].key
+ keySlot = tostring(loaded[1].slot or 1)
+ end
+
+ if IsEmpty(offlineKey) then
+ -- Check if key exists but hasn't been loaded onto the lock yet
+ local created = offlineKeys.created
+ if created and #created > 0 then
+ return d:reject(
+ "Offline key is provisioned but not yet loaded on the lock. Operate the lock once from the Yale app, then retry."
+ )
+ end
+ return d:reject("No offline key found for this lock")
+ end
+
+ d:resolve({ offlineKey = offlineKey, keySlot = keySlot })
+ end, function(err)
+ return d:reject(err)
+ end)
+
+ return d
+end
+
+--------------------------------------------------------------------------------
+-- Initialization
+--------------------------------------------------------------------------------
+
+function OnDriverInit()
+ --#ifdef DRIVERCENTRAL
+ require("cloud-client-byte")
+ C4:AllowExecute(false)
+ --#else
+ C4:AllowExecute(true)
+ --#endif
+ gInitialized = false
+ math.randomseed(os.time() + C4:GetDeviceID())
+ log:setLogName(C4:GetDeviceData(C4:GetDeviceID(), "name"))
+ log:setLogLevel(Properties["Log Level"])
+ log:setLogMode(Properties["Log Mode"])
+ log:trace("OnDriverInit()")
+end
+
+function OnDriverLateInit()
+ log:trace("OnDriverLateInit()")
+ if not CheckMinimumVersion("Driver Status") then
+ return
+ end
+
+ bindings:restoreBindings()
+ values:restoreValues()
+
+ 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", "Disconnected")
+ SendToProxy(PROXY_BINDING, "ONLINE_CHANGED", { STATE = "false" }, "NOTIFY")
+
+ -- Restore lock status from persisted property
+ local savedStatus = Properties["Lock Status"]
+ if not IsEmpty(savedStatus) and savedStatus ~= "Unknown" then
+ currentLockStatus = savedStatus
+ end
+
+ -- Set initial lock status so the proxy doesn't show "?"
+ local initialStatus = currentLockStatus or "unknown"
+ SendToProxy(PROXY_BINDING, "LOCK_STATUS_CHANGED", { LOCK_STATUS = initialStatus }, "NOTIFY")
+
+ -- Restore DoorSense state from persisted bindings
+ local doorBinding = bindings:getDynamicBinding(BINDINGS_NAMESPACE, "door")
+ if doorBinding then
+ doorSenseConfigured = true
+ C4:SetPropertyAttribs("Door Status", constants.SHOW_PROPERTY)
+ else
+ doorSenseConfigured = nil
+ C4:SetPropertyAttribs("Door Status", constants.HIDE_PROPERTY)
+ end
+end
+
+--------------------------------------------------------------------------------
+-- Property Changed Handlers
+--------------------------------------------------------------------------------
+
+function OPC.Driver_Status(propertyValue)
+ log:trace("OPC.Driver_Status('%s')", propertyValue)
+ if not gInitialized then
+ UpdateProperty("Driver Status", "Initializing", false)
+ 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.Connection_Mode(propertyValue)
+ log:trace("OPC.Connection_Mode('%s')", propertyValue)
+ if not gInitialized then
+ -- On init, just set the variable without triggering transitions
+ connectionMode = propertyValue or CONNECTION_MODE.POLL
+ if connectionMode == CONNECTION_MODE.POLL then
+ C4:SetPropertyAttribs("Polling Interval", constants.SHOW_PROPERTY)
+ else
+ C4:SetPropertyAttribs("Polling Interval", constants.HIDE_PROPERTY)
+ end
+ return
+ end
+ setConnectionMode(propertyValue or CONNECTION_MODE.POLL)
+end
+
+function OPC.Polling_Interval(propertyValue)
+ log:trace("OPC.Polling_Interval('%s')", propertyValue)
+ if not gInitialized then
+ return
+ end
+ -- Reschedule poll timer if currently in Poll mode and idle
+ if connectionMode == CONNECTION_MODE.POLL and handshakeState == HANDSHAKE_STATE.IDLE then
+ schedulePoll()
+ end
+end
+
+function OPC.Offline_Key(propertyValue)
+ log:trace("OPC.Offline_Key('%s')", not IsEmpty(propertyValue) and "****" or "")
+end
+
+function OPC.Key_Slot(propertyValue)
+ log:trace("OPC.Key_Slot('%s')", propertyValue)
+end
+
+function OPC.Yale_Email(propertyValue)
+ log:trace("OPC.Yale_Email('%s')", propertyValue and "***" or "nil")
+end
+
+function OPC.Yale_Password(propertyValue)
+ log:trace("OPC.Yale_Password('%s')", propertyValue and "***" or "nil")
+end
+
+--------------------------------------------------------------------------------
+-- RFP Handlers - Lock Proxy (binding 5001)
+--------------------------------------------------------------------------------
+
+function RFP.LOCK(idBinding, strCommand)
+ log:trace("RFP.LOCK(%s, %s)", idBinding, strCommand)
+ if idBinding ~= PROXY_BINDING then
+ return
+ end
+ initiateCommand("lock")
+end
+
+function RFP.UNLOCK(idBinding, strCommand)
+ log:trace("RFP.UNLOCK(%s, %s)", idBinding, strCommand)
+ if idBinding ~= PROXY_BINDING then
+ return
+ end
+ initiateCommand("unlock")
+end
+
+function RFP.TOGGLE(idBinding, strCommand)
+ log:trace("RFP.TOGGLE(%s, %s)", idBinding, strCommand)
+ if idBinding ~= PROXY_BINDING then
+ return
+ end
+ if currentLockStatus == "locked" then
+ initiateCommand("unlock")
+ else
+ initiateCommand("lock")
+ end
+end
+
+--------------------------------------------------------------------------------
+-- RFP Handlers - BLE Connection (binding 5002)
+--------------------------------------------------------------------------------
+
+--- Handle active GATT connection from parent driver
+function RFP.CONNECTED(idBinding, strCommand, tParams, args)
+ log:trace("RFP.CONNECTED(%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")
+ local services = DeserializeSafe(Select(tParams, "services"))
+
+ log:info("Connected to Yale lock: %s", mac or "unknown")
+ CancelTimer("ConnectionTimeout")
+
+ if not IsEmpty(name) then
+ values:update("Name", name, "STRING")
+ end
+ if mac then
+ values:update("MAC Address", mac, "STRING")
+ end
+
+ if services then
+ local writeHandle = findCharacteristicHandle(services, yale_protocol.UUID.SERVICE, yale_protocol.UUID.WRITE)
+ local readHandle = findCharacteristicHandle(services, yale_protocol.UUID.SERVICE, yale_protocol.UUID.READ)
+ local secureWriteHandle =
+ findCharacteristicHandle(services, yale_protocol.UUID.SERVICE, yale_protocol.UUID.SECURE_WRITE)
+ local secureReadHandle =
+ findCharacteristicHandle(services, yale_protocol.UUID.SERVICE, yale_protocol.UUID.SECURE_READ)
+
+ if writeHandle and readHandle and secureWriteHandle and secureReadHandle then
+ log:info(
+ "Found Yale handles: W=%d, R=%d, SW=%d, SR=%d",
+ writeHandle,
+ readHandle,
+ secureWriteHandle,
+ secureReadHandle
+ )
+ saveHandles(writeHandle, readHandle, secureWriteHandle, secureReadHandle)
+
+ secureReadSubscribed = false
+ regularReadSubscribed = false
+
+ -- Subscribe to notifications on secure read first, then regular read
+ subscribeNotifications(secureReadHandle)
+ else
+ log:error("Could not find all Yale GATT characteristics")
+ -- Try cached handles
+ local cW, cR, cSW, cSR = getHandles()
+ if cW and cR and cSW and cSR then
+ log:warn("Using cached handles as fallback")
+ secureReadSubscribed = false
+ regularReadSubscribed = false
+ subscribeNotifications(cSR)
+ else
+ UpdateProperty("Driver Status", "Error: Missing characteristics")
+ end
+ end
+ else
+ log:error("No services provided in CONNECTED message")
+ UpdateProperty("Driver Status", "Error: Missing services")
+ end
+end
+
+--- Timestamp of last advertisement processing (for throttling)
+--- @type number
+local lastAdvProcessedAt = 0
+
+--- Handle incoming BLE advertisement
+function RFP.BLE_ADVERTISEMENT(idBinding, strCommand, tParams, args)
+ if idBinding ~= ESPHOME_BINDING then
+ return
+ end
+
+ -- Go online on first advertisement if still disconnected
+ local driverStatus = Properties["Driver Status"]
+ if driverStatus == "Disconnected" then
+ UpdateProperty("Driver Status", "Listening")
+ SendToProxy(PROXY_BINDING, "ONLINE_CHANGED", { STATE = "true" }, "NOTIFY")
+ if currentLockStatus then
+ SendToProxy(PROXY_BINDING, "LOCK_STATUS_CHANGED", { LOCK_STATUS = currentLockStatus }, "NOTIFY")
+ end
+ end
+
+ -- Mode-specific connection logic
+ if connectionMode == CONNECTION_MODE.PERSISTENT then
+ -- Auto-connect when idle and auth is configured
+ if
+ (driverStatus == "Disconnected" or driverStatus == "Listening")
+ and handshakeState == HANDSHAKE_STATE.IDLE
+ and isAuthConfigured()
+ then
+ log:info("Auto-connecting to Yale lock (Persistent mode)")
+ initiateCommand("status")
+ return
+ end
+ elseif connectionMode == CONNECTION_MODE.POLL then
+ -- On first advertisement with auth configured, trigger initial status query
+ -- which starts the poll scheduling cycle via the disconnect handler
+ if
+ not initialStatusTriggered
+ and (driverStatus == "Disconnected" or driverStatus == "Listening")
+ and handshakeState == HANDSHAKE_STATE.IDLE
+ and isAuthConfigured()
+ then
+ log:info("Initial status query (Poll mode)")
+ initialStatusTriggered = true
+ initiateCommand("status")
+ return
+ end
+ end
+
+ -- Throttle RSSI/Last Seen updates
+ local throttleSeconds = 30
+ local now = os.time()
+ if now - lastAdvProcessedAt < throttleSeconds then
+ return
+ end
+ lastAdvProcessedAt = now
+
+ -- Deserialize advertisement (if not already done above)
+ local advStr = Select(tParams, "advertisement")
+ if not advStr or advStr == "" then
+ return
+ end
+
+ local advertisement = DeserializeSafe(advStr)
+ if not advertisement then
+ return
+ end
+
+ -- Update device info
+ local mac = Select(tParams, "mac")
+ if mac then
+ values:update("MAC Address", mac, "STRING")
+ end
+
+ -- Update RSSI
+ if advertisement.rssi then
+ updateRSSI(advertisement.rssi)
+ end
+
+ updateLastSeen()
+end
+
+--- Handle disconnection
+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("Disconnected from Yale lock: %s", reason)
+
+ -- Preserve user-initiated commands (lock/unlock) so they can be retried
+ local savedCommand = pendingCommand
+ if savedCommand ~= "lock" and savedCommand ~= "unlock" then
+ savedCommand = nil
+ end
+
+ resetConnectionState()
+ scheduleRecovery("Disconnected: " .. reason, savedCommand)
+end
+
+--- Handle connection failure
+function RFP.CONNECTION_FAILED(idBinding, strCommand, tParams, args)
+ log:trace("RFP.CONNECTION_FAILED(%s, %s, %s, %s)", idBinding, strCommand, tParams, args)
+ if idBinding ~= ESPHOME_BINDING then
+ return
+ end
+
+ local errMsg = Select(tParams, "error") or "unknown"
+ log:error("Connection failed: %s", errMsg)
+ resetConnectionState()
+ scheduleRecovery("Connection failed: " .. errMsg)
+end
+
+--------------------------------------------------------------------------------
+-- RFP Handlers - GATT
+--------------------------------------------------------------------------------
+
+--- Handle GATT write response
+function RFP.GATT_WRITE_RESPONSE(idBinding, strCommand, tParams, args)
+ log:trace("RFP.GATT_WRITE_RESPONSE(%s, %s, %s, %s)", idBinding, strCommand, tParams, args)
+ if idBinding ~= ESPHOME_BINDING then
+ return
+ end
+
+ local success = Select(tParams, "success") == "true"
+ local errorCode = Select(tParams, "error")
+
+ if success then
+ log:debug("Write command successful")
+ updateLastSeen()
+ else
+ log:error("Write command failed: error=%s", errorCode)
+ if handshakeState ~= HANDSHAKE_STATE.COMPLETE then
+ log:error("Handshake write failed, disconnecting")
+ doDisconnect()
+ scheduleRecovery("Error: Handshake failed")
+ end
+ end
+end
+
+--- Handle GATT notification subscription confirmation
+function RFP.GATT_NOTIFY_SUBSCRIBED(idBinding, strCommand, tParams, args)
+ log:trace("RFP.GATT_NOTIFY_SUBSCRIBED(%s, %s, %s, %s)", idBinding, strCommand, tParams, args)
+ if idBinding ~= ESPHOME_BINDING then
+ return
+ end
+
+ local handle = tointeger(Select(tParams, "handle"))
+ local success = Select(tParams, "success") == "true"
+
+ if not success then
+ log:error("GATT notification subscription failed on handle %s", handle)
+ return
+ end
+
+ local _, readHandle, _, secureReadHandle = getHandles()
+
+ if handle == secureReadHandle then
+ log:info("Secure read notifications subscribed")
+ secureReadSubscribed = true
+ -- Per yalexs-ble: subscribe secure read, then start handshake immediately
+ -- Regular read is subscribed AFTER handshake completes
+ if isAuthConfigured() then
+ startHandshake()
+ else
+ log:warn("Authentication not configured")
+ UpdateProperty("Driver Status", "Error: Offline key required")
+ requestDisconnect()
+ end
+ elseif handle == readHandle then
+ log:info("Regular read notifications subscribed")
+ regularReadSubscribed = true
+ -- Regular read is ready after handshake — execute pending command
+ executePendingCommand()
+ end
+end
+
+--- Handle GATT notification data
+function RFP.GATT_NOTIFY_DATA(idBinding, strCommand, tParams, args)
+ log:trace("RFP.GATT_NOTIFY_DATA(%s, %s, %s, %s)", idBinding, strCommand, tParams, args)
+ if idBinding ~= ESPHOME_BINDING then
+ return
+ end
+
+ local handle = tointeger(Select(tParams, "handle"))
+ local data = Select(tParams, "data")
+
+ if not data then
+ log:debug("GATT_NOTIFY with no data")
+ return
+ end
+
+ local binaryData = C4:Base64Decode(data)
+ if not binaryData or #binaryData == 0 then
+ log:debug("GATT_NOTIFY with empty data after decode")
+ return
+ end
+
+ log:debug("GATT_NOTIFY_DATA: handle=%s, %d bytes", handle or "nil", #binaryData)
+
+ local _, readHandle, _, secureReadHandle = getHandles()
+
+ -- Route based on handle and handshake state
+ if handle == secureReadHandle then
+ -- Secure read notifications are for handshake
+ if handshakeState == HANDSHAKE_STATE.AWAITING_KEY_EXCHANGE_RESPONSE then
+ handleKeyExchangeResponse(binaryData)
+ return
+ elseif handshakeState == HANDSHAKE_STATE.AWAITING_INIT_RESPONSE then
+ handleInitResponse(binaryData)
+ return
+ end
+ elseif handle == readHandle then
+ -- Always decrypt regular read notifications to keep CBC IV chain in sync.
+ -- Per yalexs-ble: the lock's CBC encryptor advances on every notification,
+ -- so we must decrypt every one, even if we're not awaiting a response.
+ if session and session:isReady() then
+ handleCommandResponse(binaryData)
+ else
+ log:debug("Regular read notification but session not ready, ignoring")
+ end
+ return
+ end
+
+ log:debug("Unexpected notification data on handle %s (handshake=%d)", handle or "nil", handshakeState)
+end
+
+--------------------------------------------------------------------------------
+-- OBC Handlers
+--------------------------------------------------------------------------------
+
+OBC[ESPHOME_BINDING] = function(idBinding, strClass, bIsBound, otherDeviceId)
+ log:trace("OBC[%s](%s, %s, %s, %s)", ESPHOME_BINDING, idBinding, strClass, bIsBound, otherDeviceId)
+ resetConnectionState(true)
+
+ if bIsBound then
+ UpdateProperty("Driver Status", "Waiting for data")
+ else
+ UpdateProperty("Driver Status", "Disconnected")
+ end
+end
+
+--------------------------------------------------------------------------------
+-- EC Handlers (Actions)
+--------------------------------------------------------------------------------
+
+function EC.Request_Verification_Code()
+ log:trace("EC.Request_Verification_Code()")
+ requestVerificationCode():next(function()
+ UpdateProperty("Yale Cloud Status", "Verification code sent - run 'Verify and Fetch Keys' and enter the code")
+ end, function(err)
+ log:error("Failed to request verification code: %s", err)
+ UpdateProperty("Yale Cloud Status", "Error: " .. tostring(err))
+ end)
+end
+
+function EC.Verify_And_Fetch_Keys(params)
+ log:trace("EC.Verify_And_Fetch_Keys(%s)", params)
+ verifyAndFetchKeys(Select(params, "Verification Code")):next(function(result)
+ UpdateProperty("Offline Key", result.offlineKey)
+ UpdateProperty("Key Slot", tostring(result.keySlot))
+ UpdateProperty("Yale Cloud Status", "Keys fetched successfully")
+ log:info("Successfully fetched offline key (slot %s)", result.keySlot)
+ end, function(err)
+ log:error("Failed to fetch keys: %s", err)
+ UpdateProperty("Yale Cloud Status", "Error: " .. tostring(err))
+ end)
+end
+
+function EC.Request_Status()
+ log:info("Status refresh requested via programming")
+ initiateCommand("status")
+end
+
+function EC.Set_Connection_Mode(params)
+ local mode = Select(params, "Mode")
+ log:info("Connection mode change requested via programming: %s", mode)
+ if mode then
+ UpdateProperty("Connection Mode", mode, true)
+ end
+end
+
+function EC.Set_Polling_Interval(params)
+ local interval = tointeger(Select(params, "Interval"))
+ log:info("Polling interval change requested via programming: %s", interval)
+ if interval then
+ UpdateProperty("Polling Interval", tostring(interval), true)
+ end
+end
+
+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")
+
+ bindings:reset()
+ values:reset()
+ resetConnectionState(true)
+ initialStatusTriggered = false
+ doorSenseConfigured = nil
+
+ -- Restore connection mode from property
+ connectionMode = Properties["Connection Mode"] or CONNECTION_MODE.POLL
+ if connectionMode ~= CONNECTION_MODE.POLL then
+ C4:SetPropertyAttribs("Polling Interval", constants.HIDE_PROPERTY)
+ else
+ C4:SetPropertyAttribs("Polling Interval", constants.SHOW_PROPERTY)
+ end
+
+ UpdateProperty("Driver Status", "Disconnected")
+ UpdateProperty("Lock Status", "Unknown")
+ UpdateProperty("Door Status", "")
+ C4:SetPropertyAttribs("Door Status", constants.HIDE_PROPERTY)
+ UpdateProperty("Battery", "")
+ UpdateProperty("Yale Cloud Status", "")
+end
diff --git a/drivers/esphome_yale/driver.xml b/drivers/esphome_yale/driver.xml
new file mode 100644
index 0000000..bbe2e9d
--- /dev/null
+++ b/drivers/esphome_yale/driver.xml
@@ -0,0 +1,320 @@
+
+ ESPHome Yale
+
+ Finite Labs
+ ESPHome Yale
+ Derek Miller
+ lua_gen
+ DriverWorks
+ Copyright 2026 Finite Labs, LLC. All rights reserved.
+ 02/19/2026 12:00:00 PM
+
+ 3.3.0
+
+ Lock
+
+
+
+
+
+
+
+
+
+ Cloud Settings
+ LABEL
+ Cloud Settings
+
+
+ Cloud Status
+
+ STRING
+ true
+
+
+ Automatic Updates
+ LIST
+
+ - Off
+ - On
+
+ On
+
+
+
+ Driver Settings
+ LABEL
+ Driver Settings
+
+
+ Driver Status
+ STRING
+
+ true
+
+
+ Driver Version
+ STRING
+
+ true
+
+
+ Log Level
+ LIST
+ 3 - Info
+
+ - 0 - Fatal
+ - 1 - Error
+ - 2 - Warning
+ - 3 - Info
+ - 4 - Debug
+ - 5 - Trace
+ - 6 - Ultra
+
+
+
+ Log Mode
+ LIST
+ Off
+
+ - Off
+ - Print
+ - Log
+ - Print and Log
+
+
+
+ Connection Mode
+ LIST
+ Poll
+
+ - Persistent
+ - Poll
+
+
+
+ Polling Interval
+ RANGED_INTEGER
+ 15
+ 300
+ 60
+
+
+
+ Authentication Settings
+ LABEL
+ Authentication Settings
+
+
+ Offline Key
+ STRING
+
+ 32-character hex string (offline key from Yale/August cloud)
+
+
+ Key Slot
+ STRING
+ 1
+ Key slot index (usually 1)
+
+
+
+ Yale Cloud Settings
+ LABEL
+ Yale Cloud Settings
+
+
+ Yale Email
+ STRING
+
+
+
+ Yale Password
+ STRING
+
+ true
+
+
+ Yale Cloud Status
+ STRING
+
+ true
+
+
+
+ Device Info
+ LABEL
+ Device Info
+
+
+ Name
+ STRING
+
+ true
+
+
+ MAC Address
+ STRING
+ Unknown
+ true
+
+
+ RSSI
+ STRING
+
+ true
+
+
+ Last Seen
+ STRING
+ Never
+ true
+
+
+ Battery
+ STRING
+
+ true
+
+
+ Lock Status
+ STRING
+ Unknown
+ true
+
+
+ Door Status
+ STRING
+
+ true
+
+
+
+
+ Request Verification Code
+ Request_Verification_Code
+
+
+ Verify and Fetch Keys
+ Verify_And_Fetch_Keys
+
+
+ Verification Code
+ STRING
+
+
+
+
+ Reset Driver
+ Reset_Driver
+
+
+ Are You Sure?
+ LIST
+
+ - No
+ - Yes
+
+
+
+
+
+ Request Status
+ Request_Status
+
+
+
+
+ Request Status
+ Request an immediate status update from NAME
+
+
+ Set Connection Mode
+ Set the connection mode for NAME to PARAM1
+
+
+ Mode
+ LIST
+
+ - Persistent
+ - Poll
+
+
+
+
+
+ Set Polling Interval
+ Set the polling interval for NAME to PARAM1 seconds
+
+
+ Interval
+ RANGED_INTEGER
+ 15
+ 300
+
+
+
+
+
+
+ False
+ False
+ False
+ False
+ False
+ False
+ False
+ False
+ False
+ False
+ False
+ False
+ False
+ False
+ False
+ False
+ False
+
+
+ lock
+
+
+ normal
+ English
+ false
+
+
+
+ 5001
+ 6
+ Lock
+ 2
+ False
+ False
+ False
+ False
+
+
+ LOCK
+
+
+ True
+
+
+ 5002
+ 6
+ ESPHome Yale
+ 2
+ True
+ False
+ False
+ False
+
+
+ ESPHOME_YALE
+
+
+ False
+
+
+
diff --git a/drivers/esphome_yale/www/documentation/images/finite-labs-logo.png b/drivers/esphome_yale/www/documentation/images/finite-labs-logo.png
new file mode 100644
index 0000000..97f7147
Binary files /dev/null and b/drivers/esphome_yale/www/documentation/images/finite-labs-logo.png differ
diff --git a/drivers/esphome_yale/www/documentation/images/header.png b/drivers/esphome_yale/www/documentation/images/header.png
new file mode 100644
index 0000000..90674e2
Binary files /dev/null and b/drivers/esphome_yale/www/documentation/images/header.png differ
diff --git a/drivers/esphome_yale/www/documentation/index.md b/drivers/esphome_yale/www/documentation/index.md
new file mode 100644
index 0000000..c3cfc55
--- /dev/null
+++ b/drivers/esphome_yale/www/documentation/index.md
@@ -0,0 +1,525 @@
+[copyright]: # "Copyright 2026 Finite Labs, LLC. All rights reserved."
+
+
+
+
+
+---
+
+# Overview
+
+
+
+> DISCLAIMER: This software is neither affiliated with nor endorsed by either
+> Control4, ESPHome, or Yale.
+
+
+
+This driver provides native Control4 lock proxy integration for Yale and August
+smart locks via BLE through an ESPHome Bluetooth proxy. It enables lock/unlock
+control, status monitoring, battery reporting, and door sense.
+
+No cloud connection is required for day-to-day operation - the Yale/August cloud
+is only used during initial setup to retrieve the offline key.
+
+# Index
+
+
+
+- [System Requirements](#system-requirements)
+- [Features](#features)
+- [Compatibility](#compatibility)
+ - [Supported Locks](#supported-locks)
+- [How It Works](#how-it-works)
+ - [Connection Modes](#connection-modes)
+ - [Door Sense](#door-sense)
+ - [Jam Detection](#jam-detection)
+- [Installer Setup](#installer-setup)
+
+ - [DriverCentral Cloud Setup](#drivercentral-cloud-setup)
+
+ - [Adding the Driver](#adding-the-driver)
+ - [Key Setup](#key-setup)
+ - [Driver Properties](#driver-properties)
+
+ - [Cloud Settings](#cloud-settings)
+
+ - [Driver Settings](#driver-settings)
+ - [Authentication Settings](#authentication-settings)
+ - [Yale Cloud Settings](#yale-cloud-settings)
+ - [Device Info](#device-info)
+ - [Driver Actions](#driver-actions)
+ - [Programming Commands](#programming-commands)
+ - [Connections](#connections)
+- [Troubleshooting](#troubleshooting)
+
+- [Developer Information](#developer-information)
+
+- [Support](#support)
+- [Changelog](#changelog)
+
+
+
+
+
+# System Requirements
+
+- Control4 OS 3.3+
+- ESPHome driver configured with Bluetooth proxy capability
+- Yale or August smart lock within BLE range of the ESPHome device
+- Offline key from the Yale/August cloud account (obtained automatically via the
+ driver or manually)
+
+# Features
+
+- Control4 Lock Proxy integration for native lock/unlock/toggle control
+- Two connection modes: Persistent (always connected) or Poll
+ (connect-query-disconnect)
+- Battery level reporting
+- Door sense (contact sensor) for models with DoorSense hardware - auto-detected
+ and dynamically added
+- Automatic offline key retrieval from the Yale/August cloud API
+- Jam detection after lock/unlock operations
+
+# Compatibility
+
+This driver uses the same BLE protocol as the
+[yalexs-ble](https://github.com/bdraco/yalexs-ble) library and should work with
+any Yale or August smart lock that supports offline key authentication over
+Bluetooth.
+
+## Supported Locks
+
+| Brand | Model | Name |
+| ------ | ------ | ---------------------------------------------- |
+| Yale | YRD216 | Assure Lock Keypad with Physical Key |
+| Yale | YRL216 | Assure Door Lever Lock with Push Button Keypad |
+| Yale | YRD226 | Assure Lock Touchscreen Deadbolt |
+| Yale | YRL226 | Assure Door Lever Lock Keypad |
+| Yale | YRD256 | Assure Lock Keypad |
+| Yale | YRD420 | Assure Lock 2 |
+| Yale | YRD450 | Assure Lock 2 Key Free |
+| August | ASL-05 | WiFi Smart Lock (Gen 4) |
+| August | ASL-03 | Smart Lock Pro (Gen 3) |
+| August | ASL-02 | Smart Lock Pro (Gen 2) |
+
+> **Note:** Other Yale/August locks using the same BLE protocol may also work.
+> Yale Conexis (L1/L2) and Yale Smart Cabinet Lock have limited protocol support
+> (lock/unlock only, no status updates).
+
+
+
+# How It Works
+
+## Connection Modes
+
+The driver supports two connection modes, configured via the **Connection Mode**
+property:
+
+### Poll (default)
+
+The driver connects to the lock on-demand, queries the full status chain (lock
+state, door state, battery), then cleanly disconnects. After disconnecting, it
+schedules the next poll based on the **Polling Interval** property.
+
+The first poll is triggered automatically when the driver receives a BLE
+advertisement from the lock. Subsequent polls follow the configured interval.
+Lock/unlock commands also trigger an immediate connect-query-disconnect cycle.
+
+Poll mode is recommended for most installations. It conserves lock battery and
+does not hold the BLE connection, leaving it available for other clients (e.g.,
+the Yale app or HomeKit).
+
+### Persistent
+
+The driver maintains a continuous BLE connection to the lock with a 20-second
+keepalive poll. This provides the lowest latency for status updates but consumes
+more battery and monopolizes the lock's single BLE connection slot.
+
+If the connection drops, the driver automatically reconnects with exponential
+backoff (5s, 10s, 20s, 40s, 80s) for up to 5 attempts. After 5 failures, it
+falls back to listening and waits for the next BLE advertisement to retry.
+
+> **Note:** Yale/August locks only support one active BLE connection at a time.
+> In Persistent mode, the Yale app and HomeKit will be unable to connect to the
+> lock while the driver holds the connection.
+
+## Door Sense
+
+If the lock has DoorSense hardware configured (a magnetic contact sensor that
+detects whether the door is open or closed), the driver automatically detects
+this from status responses and creates a dynamic Contact Sensor binding. The
+**Door Status** property is shown only when DoorSense is detected.
+
+If DoorSense is not configured on the lock, no contact sensor binding is created
+and the Door Status property is hidden.
+
+## Jam Detection
+
+After a lock or unlock command, the driver re-queries lock status to confirm the
+operation succeeded. If the lock reports a jam, the status is updated to `fault`
+on the Control4 lock proxy.
+
+
+
+# Installer Setup
+
+
+
+## DriverCentral Cloud Setup
+
+After adding the driver, enter your DriverCentral credentials in the Cloud
+Settings section to activate the license and enable automatic updates.
+
+
+
+## Adding the Driver
+
+1. Add the ESPHome driver and configure it with Bluetooth proxy enabled
+2. The ESPHome driver will scan for nearby BLE devices - Yale/August locks
+ appear as "Yale Lock" or similar
+3. Add the ESPHome Yale driver from the Lock category
+4. Bind the ESPHome Yale connection to the Yale Lock device exposed by the
+ ESPHome driver
+
+## Key Setup
+
+The driver requires an **offline key** to authenticate with the lock. There are
+two ways to obtain it:
+
+### Automatic (recommended)
+
+1. Enter your Yale/August email and password in the **Yale Cloud Settings**
+ section
+2. Run the **Request Verification Code** action - a code is sent to your email
+3. Run the **Verify and Fetch Keys** action and enter the verification code
+4. The driver automatically populates the **Offline Key** and **Key Slot**
+ fields
+
+### Manual
+
+If you already have the offline key (e.g., from another integration):
+
+1. Enter the 32-character hex string in the **Offline Key** field
+2. Set the **Key Slot** (usually `1`)
+
+Once the key is configured, the driver will connect to the lock on the next BLE
+advertisement.
+
+> **Note:** If the Yale cloud API reports that the key is "provisioned but not
+> yet loaded," you need to operate the lock once from the Yale app to load the
+> key onto the lock hardware, then retry key fetching.
+
+
+
+## Driver Properties
+
+
+
+### Cloud Settings
+
+#### Cloud Status (read-only)
+
+Displays the DriverCentral cloud license status.
+
+#### Automatic Updates [ Off | **_On_** ]
+
+Enables or disables automatic driver updates via DriverCentral.
+
+
+
+### Driver Settings
+
+#### Driver Status (read-only)
+
+Displays the current driver state. Common values:
+
+- `Disconnected` - Not receiving BLE advertisements
+- `Listening` - Receiving advertisements, not connected
+- `Listening (next poll in Ns)` - Poll mode, waiting for next cycle
+- `Connected` - Active BLE session (Persistent mode or mid-query)
+- `Reconnecting (N/5)` - Persistent mode auto-reconnect with attempt count
+- `Listening (reconnect failed)` - Persistent mode max retries exhausted
+- `Error: ...` - Configuration or connection error
+
+#### 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. Logging automatically turns off after 3 hours to prevent
+excessive log output. Default is `Off`.
+
+#### Connection Mode [ Persistent | **_Poll_** ]
+
+Controls how the driver connects to the lock. See
+[Connection Modes](#connection-modes) for details.
+
+- **Poll** (default) - Connect on-demand, query status, disconnect. Best battery
+ life. The lock's BLE connection remains available for other clients.
+- **Persistent** - Maintain a continuous BLE connection with 20s keepalive.
+ Lowest latency but higher battery drain and monopolizes the lock's BLE slot.
+
+#### Polling Interval [ 15 - 300, default: **_60_** ]
+
+How often (in seconds) to connect and query lock status in Poll mode. Only
+visible when Connection Mode is set to `Poll`.
+
+### Authentication Settings
+
+#### Offline Key
+
+The 32-character hex string offline key for your Yale/August lock. This is
+populated automatically by the **Verify and Fetch Keys** action, or can be
+entered manually.
+
+#### Key Slot
+
+The key slot index used during the BLE handshake. Default is `1`. This is
+populated automatically by the **Verify and Fetch Keys** action.
+
+### Yale Cloud Settings
+
+These settings enable automatic retrieval of the offline key from the
+Yale/August cloud API. They are only needed during initial setup - the driver
+does not contact the cloud during normal operation.
+
+#### Yale Email
+
+Your Yale/August account email address.
+
+#### Yale Password
+
+Your Yale/August account password.
+
+#### Yale Cloud Status (read-only)
+
+Displays the status of the cloud key retrieval process. Shows progress through
+session creation, verification code sending, lock enumeration, and key
+extraction.
+
+### Device Info
+
+#### Name (read-only)
+
+The BLE advertisement name of the lock.
+
+#### MAC Address (read-only)
+
+The Bluetooth MAC address of the lock.
+
+#### RSSI (read-only)
+
+The signal strength of the last BLE advertisement, in dBm.
+
+#### Last Seen (read-only)
+
+The timestamp of the last communication with the lock.
+
+#### Battery (read-only)
+
+The battery percentage of the lock. Updated each time the driver queries the
+lock's status chain.
+
+#### Lock Status (read-only)
+
+The current lock status as reported by the lock (`locked`, `unlocked`, `fault`,
+`unknown`). A `fault` status indicates the lock is jammed.
+
+#### Door Status (read-only)
+
+The current door status (`CLOSED`, `OPENED`) for models with DoorSense. This
+property is only visible when DoorSense is detected on the lock.
+
+## Driver Actions
+
+### Request Verification Code
+
+Initiates the Yale/August cloud authentication flow. Creates a session with the
+cloud API and sends a verification code to the email address configured in
+**Yale Email**. Enter your email and password before running this action.
+
+After running this action, check your email for the verification code, then run
+**Verify and Fetch Keys**.
+
+### Verify and Fetch Keys
+
+Validates the verification code and fetches the offline key from the Yale/August
+cloud API.
+
+**Parameters:**
+
+- **Verification Code** - The code received via email after running **Request
+ Verification Code**.
+
+On success, the **Offline Key** and **Key Slot** properties are automatically
+populated. The driver will connect to the lock on the next BLE advertisement.
+
+### Request Status
+
+Triggers an immediate status refresh from the lock. Queries lock state, door
+sensor, and battery level. Useful as a Composer Pro action for quick manual
+checks.
+
+In both connection modes, this is also available as a programming command (see
+[Programming Commands](#programming-commands) below).
+
+### Reset Driver
+
+Resets the driver state to defaults. Clears all persisted data, cached BLE
+handles, and dynamic bindings. The offline key and other properties are
+preserved.
+
+**Parameters:**
+
+- **Are You Sure?** [ **_No_** | Yes ] - Confirmation to reset the driver.
+
+## Programming Commands
+
+These commands are available in Control4 programming under the device's command
+list.
+
+### Request Status
+
+Triggers an immediate status refresh from the lock. Queries lock state, door
+sensor (if equipped), and battery level in a single status chain.
+
+- **Persistent mode:** Executes immediately on the active connection instead of
+ waiting for the next 20-second keepalive poll.
+- **Poll mode:** Connects to the lock, runs the full status chain, then
+ disconnects.
+
+**Example use case:** Create a programming event "When front door contact sensor
+opens, execute Request Status on Yale lock." This gives near-instant lock state
+updates when the door is opened.
+
+### Set Connection Mode
+
+Changes the connection mode at runtime.
+
+**Parameters:**
+
+- **Mode** [ Persistent | Poll ] - The connection mode to switch to.
+
+### Set Polling Interval
+
+Changes the polling interval at runtime.
+
+**Parameters:**
+
+- **Interval** [ 15 - 300 ] - The polling interval in seconds.
+
+## Connections
+
+### Lock (provider)
+
+The Control4 Lock proxy connection (binding 5001). This is automatically managed
+by the driver and provides lock/unlock/toggle functionality to Control4.
+
+### ESPHome Yale (consumer)
+
+The BLE connection to the lock via the ESPHome driver (binding 5002). Bind this
+to the Yale Lock device exposed by the main ESPHome driver after scanning for
+Bluetooth devices.
+
+
+
+# Troubleshooting
+
+**"Error: Offline key required"** The offline key has not been configured. Use
+the Yale Cloud Settings to fetch it automatically or enter it manually in
+Authentication Settings.
+
+**"Key mismatch - re-fetch keys"** The offline key has been rotated (e.g., by
+the Yale app or a firmware update). Use the Yale Cloud actions to fetch the new
+key.
+
+**"Offline key is provisioned but not yet loaded on the lock"** The key exists
+in the Yale cloud but has not been loaded onto the lock hardware. Open the Yale
+app and operate the lock once (lock or unlock), then retry the **Verify and
+Fetch Keys** action.
+
+**Lock shows as offline / Driver Status stuck on "Disconnected"** The ESPHome
+Bluetooth proxy is not detecting the lock. Verify the lock is within BLE range
+of the ESP32 device and that the ESPHome driver has Bluetooth proxy enabled.
+
+**Persistent mode keeps reconnecting** Yale locks only support one BLE
+connection. If the Yale app, HomeKit, or another client is connected, the driver
+cannot connect. Switch to Poll mode or ensure no other clients are connected.
+
+**Lock/unlock commands are slow** In Poll mode, commands require a full
+connection cycle (connect, handshake, command, status, disconnect), which takes
+several seconds. For faster response, switch to Persistent mode.
+
+**Door Status not showing** The Door Status property and contact sensor binding
+are only created when the lock has DoorSense hardware configured. Not all
+Yale/August lock models include DoorSense. If your lock has DoorSense hardware
+but Door Status is not appearing, verify that DoorSense is calibrated in the
+Yale Access app first.
+
+**Key stops working after using the Yale app** The Yale/August cloud may rotate
+the offline key when the lock is operated from a mobile device. If this happens,
+re-run the **Verify and Fetch Keys** action to retrieve the updated key.
+
+
+
+
+
+# Developer Information
+
+
+
+
+
+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
+Yale/August locks, 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
+
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
index 8eb1d2b..c971d9a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5,16 +5,17 @@
"packages": {
"": {
"dependencies": {
- "@johnnymorganz/stylua-bin": "^2.1.0",
- "electron-pdf": "^25.0.0",
+ "@johnnymorganz/stylua-bin": "^2.3.1",
+ "electron-pdf": "^40.1.0",
"markdown-styles": "^3.2.0",
- "prettier": "^3.5.3"
+ "prettier": "^3.8.1"
}
},
"node_modules/@electron/get": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz",
"integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==",
+ "license": "MIT",
"dependencies": {
"debug": "^4.1.1",
"env-paths": "^2.2.0",
@@ -32,11 +33,12 @@
}
},
"node_modules/@electron/get/node_modules/debug": {
- "version": "4.3.4",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
- "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
"dependencies": {
- "ms": "2.1.2"
+ "ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
@@ -48,14 +50,15 @@
}
},
"node_modules/@electron/get/node_modules/ms": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
- "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
},
"node_modules/@johnnymorganz/stylua-bin": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/@johnnymorganz/stylua-bin/-/stylua-bin-2.1.0.tgz",
- "integrity": "sha512-ztGW8b7uoPEzqaUftCkY27tcLUmFDCRyGIJrf7WKyAd0hpNbF+Q43rmGM71RYD6oQ9JYGUQXXC+r/WZqeTd/Ew==",
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/@johnnymorganz/stylua-bin/-/stylua-bin-2.3.1.tgz",
+ "integrity": "sha512-JKoNYakvnkYMDBpl8sDMeY37mH8R2/iYSXc7gHGN9M0RURK0G5DBo0Do2HtPdbugwVUwgTnoSv5QCXCSaFicjg==",
"hasInstallScript": true,
"license": "MPL-2.0",
"dependencies": {
@@ -217,6 +220,7 @@
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
"integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==",
+ "license": "MIT",
"engines": {
"node": ">=10"
},
@@ -228,6 +232,7 @@
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
"integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==",
+ "license": "MIT",
"dependencies": {
"defer-to-connect": "^2.0.0"
},
@@ -245,6 +250,7 @@
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
"integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==",
+ "license": "MIT",
"dependencies": {
"@types/http-cache-semantics": "*",
"@types/keyv": "^3.1.4",
@@ -253,35 +259,43 @@
}
},
"node_modules/@types/http-cache-semantics": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.3.tgz",
- "integrity": "sha512-V46MYLFp08Wf2mmaBhvgjStM3tPa+2GAdy/iqoX+noX1//zje2x4XmrIU0cAwyClATsTmahbtoQ2EwP7I5WSiA=="
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
+ "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==",
+ "license": "MIT"
},
"node_modules/@types/keyv": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz",
"integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==",
+ "license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/node": {
- "version": "18.18.6",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-18.18.6.tgz",
- "integrity": "sha512-wf3Vz+jCmOQ2HV1YUJuCWdL64adYxumkrxtc+H1VUQlnQI04+5HtH+qZCOE21lBE7gIrt+CwX2Wv8Acrw5Ak6w=="
+ "version": "24.10.13",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz",
+ "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.16.0"
+ }
},
"node_modules/@types/responselike": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.2.tgz",
- "integrity": "sha512-/4YQT5Kp6HxUDb4yhRkm0bJ7TbjvTddqX7PZ5hz6qV3pxSo72f/6YPRo+Mu2DU307tm9IioO69l7uAwn5XNcFA==",
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz",
+ "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==",
+ "license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/yauzl": {
- "version": "2.10.2",
- "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.2.tgz",
- "integrity": "sha512-Km7XAtUIduROw7QPgvcft0lIupeG8a8rdKL8RiSyKvlE7dYY31fEn41HVuQsRFDuROA8tA4K2UVL+WdfFmErBA==",
+ "version": "2.10.3",
+ "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
+ "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
+ "license": "MIT",
"optional": true,
"dependencies": {
"@types/node": "*"
@@ -492,6 +506,8 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
"integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
+ "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
+ "license": "MIT",
"optional": true
},
"node_modules/brace-expansion": {
@@ -507,6 +523,7 @@
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
+ "license": "MIT",
"engines": {
"node": "*"
}
@@ -531,6 +548,7 @@
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz",
"integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==",
+ "license": "MIT",
"engines": {
"node": ">=10.6.0"
}
@@ -539,6 +557,7 @@
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz",
"integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==",
+ "license": "MIT",
"dependencies": {
"clone-response": "^1.0.2",
"get-stream": "^5.1.0",
@@ -611,6 +630,7 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz",
"integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==",
+ "license": "MIT",
"dependencies": {
"mimic-response": "^1.0.0"
},
@@ -683,6 +703,7 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+ "license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
@@ -697,6 +718,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+ "license": "MIT",
"engines": {
"node": ">=10"
},
@@ -708,6 +730,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
"integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==",
+ "license": "MIT",
"engines": {
"node": ">=10"
}
@@ -767,6 +790,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
+ "license": "MIT",
"optional": true
},
"node_modules/dunder-proto": {
@@ -792,13 +816,14 @@
}
},
"node_modules/electron": {
- "version": "25.9.2",
- "resolved": "https://registry.npmjs.org/electron/-/electron-25.9.2.tgz",
- "integrity": "sha512-hVBN5rsrL99BKNHvzMeYy2PkAmewuIobu4U3o3EzVz4MDoLmMfW4yTH5GZ4RbJrpokoEky5IzGtRR/ggPzL6Fw==",
+ "version": "40.1.0",
+ "resolved": "https://registry.npmjs.org/electron/-/electron-40.1.0.tgz",
+ "integrity": "sha512-2j/kvw7uF0H1PnzYBzw2k2Q6q16J8ToKrtQzZfsAoXbbMY0l5gQi2DLOauIZLzwp4O01n8Wt/74JhSRwG0yj9A==",
"hasInstallScript": true,
+ "license": "MIT",
"dependencies": {
"@electron/get": "^2.0.0",
- "@types/node": "^18.11.18",
+ "@types/node": "^24.9.0",
"extract-zip": "^2.0.1"
},
"bin": {
@@ -820,14 +845,15 @@
}
},
"node_modules/electron-pdf": {
- "version": "25.0.0",
- "resolved": "https://registry.npmjs.org/electron-pdf/-/electron-pdf-25.0.0.tgz",
- "integrity": "sha512-g5CD4z/rGPjIvEkix0FQKwn3l+5tZBy9AQeHDKTWsr8SAA9cCfPB/oDXZOqcZYf7S1+UoBBk+JJNscAK0UYQAA==",
+ "version": "40.1.0",
+ "resolved": "https://registry.npmjs.org/electron-pdf/-/electron-pdf-40.1.0.tgz",
+ "integrity": "sha512-ASW2VVwQksbdlNYW9h1WTqZQLbLceVwK99x0bDPezSbmLeseNHY8HbN1kusfTJ4UkcWraUQoHv4FWFc+y8cDbg==",
+ "license": "MIT",
"dependencies": {
"@sentry/electron": "^1.5.2",
"async": "^2.0.1",
"debug": "^2.3.2",
- "electron": "^25.4.0",
+ "electron": "40.1.0",
"eventemitter2": "^2.1.3",
"github-markdown-css": "^2.0.9",
"highlight.js": "^9.0.0",
@@ -855,9 +881,10 @@
}
},
"node_modules/end-of-stream": {
- "version": "1.4.4",
- "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
- "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
@@ -866,6 +893,7 @@
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
"integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
+ "license": "MIT",
"engines": {
"node": ">=6"
}
@@ -992,6 +1020,7 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
"integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
+ "license": "MIT",
"optional": true
},
"node_modules/escalade": {
@@ -1006,6 +1035,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "license": "MIT",
"optional": true,
"engines": {
"node": ">=10"
@@ -1075,6 +1105,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
"integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
+ "license": "BSD-2-Clause",
"dependencies": {
"debug": "^4.1.1",
"get-stream": "^5.1.0",
@@ -1091,11 +1122,12 @@
}
},
"node_modules/extract-zip/node_modules/debug": {
- "version": "4.3.4",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
- "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
"dependencies": {
- "ms": "2.1.2"
+ "ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
@@ -1107,14 +1139,16 @@
}
},
"node_modules/extract-zip/node_modules/ms": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
- "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
},
"node_modules/fd-slicer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
+ "license": "MIT",
"dependencies": {
"pend": "~1.2.0"
}
@@ -1199,6 +1233,7 @@
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
+ "license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^4.0.0",
@@ -1320,6 +1355,7 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
"integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
+ "license": "MIT",
"dependencies": {
"pump": "^3.0.0"
},
@@ -1427,6 +1463,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
"integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
+ "license": "BSD-3-Clause",
"optional": true,
"dependencies": {
"boolean": "^3.0.1",
@@ -1441,13 +1478,11 @@
}
},
"node_modules/global-agent/node_modules/semver": {
- "version": "7.5.4",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
- "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "license": "ISC",
"optional": true,
- "dependencies": {
- "lru-cache": "^6.0.0"
- },
"bin": {
"semver": "bin/semver.js"
},
@@ -1485,6 +1520,7 @@
"version": "11.8.6",
"resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz",
"integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==",
+ "license": "MIT",
"dependencies": {
"@sindresorhus/is": "^4.0.0",
"@szmarczak/http-timer": "^4.0.5",
@@ -1610,9 +1646,10 @@
}
},
"node_modules/http-cache-semantics": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
- "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ=="
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
+ "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
+ "license": "BSD-2-Clause"
},
"node_modules/http-proxy-agent": {
"version": "7.0.2",
@@ -1663,6 +1700,7 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz",
"integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==",
+ "license": "MIT",
"dependencies": {
"quick-lru": "^5.1.1",
"resolve-alpn": "^1.0.0"
@@ -1967,18 +2005,21 @@
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
- "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "license": "MIT"
},
"node_modules/json-stringify-safe": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
+ "license": "ISC",
"optional": true
},
"node_modules/jsonfile": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
+ "license": "MIT",
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
@@ -1987,6 +2028,7 @@
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "license": "MIT",
"dependencies": {
"json-buffer": "3.0.1"
}
@@ -2005,6 +2047,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
"integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==",
+ "license": "MIT",
"engines": {
"node": ">=8"
}
@@ -2014,18 +2057,6 @@
"resolved": "https://registry.npmjs.org/lru_map/-/lru_map-0.3.3.tgz",
"integrity": "sha512-Pn9cox5CsMYngeDbmChANltQl+5pi6XmTrraMSzhPmMBbmgcxmqWry0U3PGapCU1yB4/LqCcom7qhHZiF/jGfQ=="
},
- "node_modules/lru-cache": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
- "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
- "optional": true,
- "dependencies": {
- "yallist": "^4.0.0"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/markdown-stream-utils": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/markdown-stream-utils/-/markdown-stream-utils-1.6.0.tgz",
@@ -2089,6 +2120,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
"integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
+ "license": "MIT",
"optional": true,
"dependencies": {
"escape-string-regexp": "^4.0.0"
@@ -2142,6 +2174,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
"integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==",
+ "license": "MIT",
"engines": {
"node": ">=4"
}
@@ -2248,6 +2281,7 @@
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
"integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==",
+ "license": "MIT",
"engines": {
"node": ">=10"
},
@@ -2331,6 +2365,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
"integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==",
+ "license": "MIT",
"engines": {
"node": ">=8"
}
@@ -2428,7 +2463,8 @@
"node_modules/pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
- "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="
+ "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
+ "license": "MIT"
},
"node_modules/pipe-iterators": {
"version": "1.3.0",
@@ -2485,9 +2521,9 @@
}
},
"node_modules/prettier": {
- "version": "3.5.3",
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz",
- "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
+ "version": "3.8.1",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
+ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
@@ -2508,6 +2544,7 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
+ "license": "MIT",
"engines": {
"node": ">=0.4.0"
}
@@ -2592,9 +2629,10 @@
"license": "MIT"
},
"node_modules/pump": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
- "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==",
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
+ "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
+ "license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
@@ -2604,6 +2642,7 @@
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
"integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
+ "license": "MIT",
"engines": {
"node": ">=10"
},
@@ -2667,12 +2706,14 @@
"node_modules/resolve-alpn": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
- "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="
+ "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==",
+ "license": "MIT"
},
"node_modules/responselike": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz",
"integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==",
+ "license": "MIT",
"dependencies": {
"lowercase-keys": "^2.0.0"
},
@@ -2698,6 +2739,7 @@
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
"integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
+ "license": "BSD-3-Clause",
"optional": true,
"dependencies": {
"boolean": "^3.0.1",
@@ -2774,6 +2816,7 @@
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
@@ -2782,12 +2825,14 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
"integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
+ "license": "MIT",
"optional": true
},
"node_modules/serialize-error": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
"integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
+ "license": "MIT",
"optional": true,
"dependencies": {
"type-fest": "^0.13.1"
@@ -3002,6 +3047,7 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
"integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==",
+ "license": "Apache-2.0",
"dependencies": {
"debug": "^4.1.0"
},
@@ -3010,11 +3056,12 @@
}
},
"node_modules/sumchecker/node_modules/debug": {
- "version": "4.3.4",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
- "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
"dependencies": {
- "ms": "2.1.2"
+ "ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
@@ -3026,9 +3073,10 @@
}
},
"node_modules/sumchecker/node_modules/ms": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
- "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
@@ -3087,6 +3135,7 @@
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
"integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
+ "license": "(MIT OR CC0-1.0)",
"optional": true,
"engines": {
"node": ">=10"
@@ -3182,10 +3231,17 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/undici-types": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
+ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
+ "license": "MIT"
+ },
"node_modules/universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
+ "license": "MIT",
"engines": {
"node": ">= 4.0.0"
}
@@ -3396,12 +3452,6 @@
"node": ">=10"
}
},
- "node_modules/yallist": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
- "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
- "optional": true
- },
"node_modules/yargs": {
"version": "16.2.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
@@ -3431,6 +3481,7 @@
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
+ "license": "MIT",
"dependencies": {
"buffer-crc32": "~0.2.3",
"fd-slicer": "~1.1.0"
diff --git a/package.json b/package.json
index 93790b6..4590b5c 100644
--- a/package.json
+++ b/package.json
@@ -1,38 +1,10 @@
{
- "config": {
- "distributions": "drivercentral oss",
- "drivers": "esphome esphome_light esphome_lock",
- "driver_names": {
- "esphome": "ESPHome",
- "esphome_light": "ESPHome Light",
- "esphome_lock": "ESPHome Lock"
- }
- },
- "scripts": {
- "init": "python -m venv .venv && . .venv/bin/activate && python -m pip install pip setuptools wheel protobuf types-protobuf black && env LDFLAGS=\"-L$(brew --prefix openssl)/lib\" CFLAGS=\"-I$(brew --prefix openssl)/include\" SWIG_FEATURES=\"-cpperraswarn -includeall -I$(brew --prefix openssl)/include\" pip install M2Crypto lxml && rm -rf dist/driverpackager && git clone git@github.com:snap-one/drivers-driverpackager.git dist/driverpackager",
- "fmt:lua": "stylua --indent-type Spaces --column-width 120 --line-endings Unix --indent-width 2 --quote-style AutoPreferDouble -g '*.lua' -v ./{drivers,src}",
- "fmt:py": ".venv/bin/black tools/*",
- "fmt:md": "prettier --prose-wrap always --write ./drivers/**/www/**/*.md",
- "fmt": "npm run fmt:lua && npm run fmt:md",
- "preprocess": "for build in $npm_package_config_distributions; do ./tools/preprocess --$build || exit 1; done",
- "docs:html": "for build in $npm_package_config_distributions; do for dir in $npm_package_config_drivers; do generate-md --layout github --input \"build/$build/drivers/$dir/www/documentation/index.md\" --output \"build/$build/drivers/$dir/www/documentation\"; done; done",
- "docs:pdf": "for build in $npm_package_config_distributions; do for dir in $npm_package_config_drivers; do driver_name_var=npm_package_config_driver_names_$dir; electron-pdf --marginsType 0 --input \"$(pwd)/build/$build/drivers/$dir/www/documentation/index.html\" --output \"dist/$build/${!driver_name_var} Documentation.pdf\"; done; done",
- "docs:readme": "rm -rf ./images && cp -r drivers/esphome/www/documentation/images . && pandoc build/oss/drivers/esphome/www/documentation/index.md -f gfm -t gfm --lua-filter=tools/pandoc-remove-style.lua -o README.md",
- "docs": "npm run docs:readme && npm run docs:html && npm run docs:pdf",
- "update-driver.xml:version": "for build in $npm_package_config_distributions; do for dir in $npm_package_config_drivers; do xmlstarlet edit --inplace --omit-decl --update '/devicedata/version' --value \"`date +'%Y%m%d'`\" ./build/$build/drivers/$dir/driver.xml; done; done",
- "update-driver.xml:modified": "for build in $npm_package_config_distributions; do for dir in $npm_package_config_drivers; do xmlstarlet edit --inplace --omit-decl --update '/devicedata/modified' --value \"`date +'%m/%d/%Y %I:%M %p'`\" ./build/$build/drivers/$dir/driver.xml; done; done",
- "update-driver.xml": "npm run update-driver.xml:version && npm run update-driver.xml:modified",
- "gen-lua-proto-schema": ".venv/bin/python3 tools/gen_lua_proto_schema src/esphome/proto-schema.lua https://raw.githubusercontent.com/esphome/esphome/refs/heads/dev/esphome/components/api/api.proto https://raw.githubusercontent.com/esphome/esphome/refs/heads/dev/esphome/components/api/api_options.proto",
- "package": "for build in $npm_package_config_distributions; do for dir in $npm_package_config_drivers; do export pwd=\"$(pwd)\" && cd \"build/$build/drivers/$dir\" && \"$pwd/.venv/bin/python3\" \"$pwd/dist/driverpackager/dp3/driverpackager.py\" . \"$pwd/dist/$build\" driver.c4zproj && cd \"$pwd\"; done; done",
- "zip": "for build in $npm_package_config_distributions; do cd \"dist/$build\" && zip $(basename \"$(realpath \"$(pwd)/../../\")\").zip *.{c4z,pdf} && cd ../../; done",
- "build": "npm run fmt && npm run preprocess && npm run update-driver.xml && npm run docs && npm run package && npm run zip",
- "clean:build": "for build in $npm_package_config_distributions; do rm -rfv \"dist/$build\";done && rm -rfv build",
- "clean": "npm run clean:build && rm -rfv dist node_modules .venv"
- },
+ "private": true,
+ "description": "Integrate ESPHome-based devices into Control4 — npm dependencies only; see Makefile for build targets.",
"dependencies": {
- "@johnnymorganz/stylua-bin": "^2.1.0",
- "electron-pdf": "^25.0.0",
+ "@johnnymorganz/stylua-bin": "^2.3.1",
+ "electron-pdf": "^40.1.0",
"markdown-styles": "^3.2.0",
- "prettier": "^3.5.3"
+ "prettier": "^3.8.1"
}
}
diff --git a/src/constants.lua b/src/constants.lua
index 21d4939..2ce1407 100644
--- a/src/constants.lua
+++ b/src/constants.lua
@@ -1,4 +1,3 @@
---- @module "constants"
--- Constants used throughout the ESPHome driver.
return {
@@ -10,6 +9,26 @@ return {
--- @type number
HIDE_PROPERTY = 1,
+ --- Default option for dynamic list properties.
+ --- @type string
+ SELECT_OPTION = "(Select)",
+
+ --- Refresh list option for dynamic list properties.
+ --- @type string
+ REFRESH_LIST_OPTION = " -- Refresh List",
+
+ --- Scanning indicator for dynamic list properties.
+ --- @type string
+ SCANNING_OPTION = " -- Scanning...",
+
+ --- Stop scan option for dynamic list properties (keeps discovered devices).
+ --- @type string
+ STOP_SCAN_OPTION = " -- Stop Scan",
+
+ --- Abort scan option for dynamic list properties (discards discovered devices).
+ --- @type string
+ ABORT_SCAN_OPTION = " -- Abort Scan",
+
--- Constant for button action IDs.
--- @type table
ButtonIds = {
diff --git a/src/esphome/ble/address.lua b/src/esphome/ble/address.lua
new file mode 100644
index 0000000..453e8fd
--- /dev/null
+++ b/src/esphome/ble/address.lua
@@ -0,0 +1,79 @@
+--- BLE MAC address conversion utilities.
+--- Provides consistent handling of 48-bit Bluetooth MAC addresses.
+
+local log = require("lib.logging")
+local bit64 = require("bitn").bit64
+
+--- @class BLEAddress
+local BLEAddress = {}
+
+--- BLE Address Type per Bluetooth Core Specification.
+--- See: https://www.bluetooth.com/wp-content/uploads/Files/Specification/HTML/Core-54/out/en/host-controller-interface/host-controller-interface-functional-specification.html
+--- @enum BLEAddressType
+BLEAddress.Type = {
+ PUBLIC = 0, -- Public Device Address
+ RANDOM = 1, -- Random Device Address
+ RPA_PUBLIC = 2, -- RPA resolved to Public Identity Address
+ RPA_RANDOM = 3, -- RPA resolved to Random Static Identity Address
+}
+
+--- Convert a Bluetooth MAC address string to a 48-bit integer.
+--- @param mac string|nil MAC address in format "AA:BB:CC:DD:EE:FF" or "AABBCCDDEEFF"
+--- @return integer|nil address 48-bit address as an integer, or nil if invalid
+function BLEAddress.fromString(mac)
+ if type(mac) ~= "string" or mac == "" then
+ return nil
+ end
+
+ -- Remove colons, dashes, spaces and convert to uppercase
+ mac = mac:gsub("[:%s%-]", ""):upper()
+
+ -- Validate length
+ if #mac ~= 12 then
+ log:warn("Invalid MAC address length: %s", mac)
+ return nil
+ end
+
+ -- Parse byte-by-byte to avoid tonumber issues with large hex strings
+ -- on some Lua runtimes where tonumber(12-digit-hex, 16) may overflow
+ local address = 0
+ for i = 1, 12, 2 do
+ local byte = tonumber(mac:sub(i, i + 1), 16)
+ if not byte then
+ log:warn("Invalid MAC address format: %s", mac)
+ return nil
+ end
+ address = address * 256 + byte
+ end
+
+ return address
+end
+
+--- Convert a 48-bit address number to MAC string.
+--- @param address number|Int64HighLow|nil The 48-bit Bluetooth MAC address as a number
+--- @return string|nil mac MAC address in format "AA:BB:CC:DD:EE:FF"
+function BLEAddress.toString(address)
+ if address == nil then
+ return nil
+ end
+ -- Handle both number and Int64HighLow format from protobuf
+ local addressNum = bit64.to_number(address, true) -- Strict since MAC addresses fit in <53 bits
+ if type(addressNum) ~= "number" then
+ log:warn("Invalid BLE address: %s", address)
+ return nil
+ end
+
+ return string
+ .format(
+ "%02X:%02X:%02X:%02X:%02X:%02X",
+ math.floor(addressNum / 0x10000000000) % 256,
+ math.floor(addressNum / 0x100000000) % 256,
+ math.floor(addressNum / 0x1000000) % 256,
+ math.floor(addressNum / 0x10000) % 256,
+ math.floor(addressNum / 0x100) % 256,
+ addressNum % 256
+ )
+ :upper()
+end
+
+return BLEAddress
diff --git a/src/esphome/ble/company_identifiers.lua b/src/esphome/ble/company_identifiers.lua
new file mode 100644
index 0000000..db5c2ce
--- /dev/null
+++ b/src/esphome/ble/company_identifiers.lua
@@ -0,0 +1,3912 @@
+--- Bluetooth SIG Company Identifiers.
+
+local M = {}
+
+--- Company identifier to name lookup table.
+--- Key is the 16-bit company ID, value is the company name.
+--- Source: https://www.bluetooth.com/specifications/assigned-numbers/
+--- @type table
+M.names = {
+ [0x0FDC] = "Beijing Hongsi Electronic Technology Co.,Ltd.",
+ [0x0FDA] = "NIVELCO PROCESS CONTROL CO.",
+ [0x0FD9] = "REMDEVICE S.R.L.",
+ [0x0FD8] = "SIVA Inotec Limited",
+ [0x0FD7] = "Hondata, Inc.",
+ [0x0FD6] = "LIOTYS",
+ [0x0FD5] = "Telemacy Ltd",
+ [0x0FD4] = "QRITAGYA LLP",
+ [0x0FD3] = "YDIIT Co., Ltd",
+ [0x0FD2] = "Domteknika S.A",
+ [0x0FD1] = "Auranova LLC",
+ [0x0FD0] = "Stark Future SL",
+ [0x0FCF] = "Nestlab AS",
+ [0x0FCE] = "NOJA Power",
+ [0x0FCD] = "Wimate Technology Solutions Pvt Ltd",
+ [0x0FCC] = "Neurotherapeutics Ltd",
+ [0x0FCB] = "Biocorp Production",
+ [0x0FCA] = "Legato Audio, Inc.",
+ [0x0FC9] = "Zirbel Bike GmbH",
+ [0x0FC8] = "Eforthink Technology Co., Ltd.",
+ [0x0FC7] = "Pharox B.V.",
+ [0x0FC6] = "ASD Lighting PLC",
+ [0x0FC5] = "Hypershell Co., Ltd",
+ [0x0FC4] = "FulScience Automotive Electronics Co., Ltd.",
+ [0x0FC3] = "Shenzhen Jimi loT Co.,Ltd",
+ [0x0FC2] = "ISSPRO, INC",
+ [0x0FC1] = "Air Automotive tracking Inc.",
+ [0x0FC0] = "Littlebird Connected Care, Inc.",
+ [0x0FBF] = "Tridentify AB",
+ [0x0FBE] = "TaigaIoT",
+ [0x0FBD] = "Locus Robotics Corp.",
+ [0x0FBC] = "ATANS TECHNOLOGY INC.",
+ [0x0FBB] = "Rollease Acmeda, Inc.",
+ [0x0FBA] = "Cosonic Intelligent Technologies Co., Ltd.",
+ [0x0FB9] = "Shenzhen Mutual Technology Co., Ltd",
+ [0x0FB8] = "SITERWELL ELECTRONICS CO.,LIMITED",
+ [0x0FB7] = "Hapn Holdings, LLC",
+ [0x0FB6] = "TQ-Systems GmbH",
+ [0x0FB5] = "Hangzhou Nano IC Technologies Co,. Ltd",
+ [0x0FB4] = "StoneDevices",
+ [0x0FB3] = "Hooked Society Oy Ltd",
+ [0x0FB2] = "PLAUD Inc.",
+ [0x0FB1] = "Avivomed Inc.",
+ [0x0FB0] = "General Resistance, LLC",
+ [0x0FAF] = "Terasite Technologies",
+ [0x0FAE] = "Capacite",
+ [0x0FAD] = "The AI Toy Company",
+ [0x0FAC] = "Flitsmeister B.V.",
+ [0x0FAB] = "Geo Radar AI Private Limited",
+ [0x0FAA] = "Suzhou Fanxi Technology Co., Ltd.",
+ [0x0FA9] = "Ranch Systems, Inc.",
+ [0x0FA8] = "Senti Technologies Private Limited",
+ [0x0FA7] = "spheretek japan",
+ [0x0FA6] = "Ugreen Group Limited",
+ [0x0FA5] = "Avanos Medical, Inc.",
+ [0x0FA4] = "Nice Spa",
+ [0x0FA3] = "B.E.G. Brueck Electronic GmbH",
+ [0x0FA2] = "BIA NEUROSCIENCE INC.",
+ [0x0FA1] = "PALRED RETAIL PRIVATE LIMITED",
+ [0x0FA0] = "DP IOT",
+ [0x0F9F] = "Seiko Future Creation Inc.",
+ [0x0F9E] = "Hibino Corporation",
+ [0x0F9D] = "IRTrans GmbH",
+ [0x0F9C] = "WePower Technologies LLC",
+ [0x0F9B] = "Hanshow Technology Co.,Ltd.",
+ [0x0F9A] = "Eiritsu Electronics Industry Co., Ltd.",
+ [0x0F99] = "DEZINE GROUP LLC",
+ [0x0F98] = "Fitnexa Inc",
+ [0x0F97] = "SPX Aids to Navigation Oy",
+ [0x0F96] = "SEIKOIST, INC",
+ [0x0F95] = "Dongguang Huasoo Automation Technology Company Limited",
+ [0x0F94] = "IDX Company, Ltd.",
+ [0x0F93] = "Aktiebolaget Ebeco",
+ [0x0F92] = "Ethos Group, Inc.",
+ [0x0F91] = "Valostra Digital Ventures LLP",
+ [0x0F90] = "MOBI7 TECNOLOGIA EM MOBILIDADE S.A.",
+ [0x0F8F] = "SIGNUM INTELLIGENCE LTD",
+ [0x0F8E] = "Biceek Inc",
+ [0x0F8D] = "JUREN CO., LTD.",
+ [0x0F8C] = "ROYAL PARTS CO.,LTD",
+ [0x0F8B] = "A.Y. McDonald Mfg. Co.",
+ [0x0F8A] = "Innogando S.L.",
+ [0x0F89] = "STOREIO TECHNOLOGIES LTD",
+ [0x0F88] = "Kohler Ventures, Inc.",
+ [0x0F87] = "Anacove Inc.",
+ [0x0F86] = "Lakecrest Pty Ltd",
+ [0x0F85] = "Hangzhou Sciener Smart Technology Co., Ltd.",
+ [0x0F84] = "KT Micro, Inc.",
+ [0x0F83] = "Calpeda S.p.A.",
+ [0x0F82] = "Amon Co.Ltd.",
+ [0x0F81] = "IN Phase International lTD",
+ [0x0F80] = "Hydro Electronic Devices, Inc.",
+ [0x0F7F] = "Badass eBikes GmbH",
+ [0x0F7E] = "shenzhen Holyiot Technology Co.,Ltd",
+ [0x0F7D] = "eQ-3 AG",
+ [0x0F7C] = "Polk Audio",
+ [0x0F7B] = "SKYROAM, INC.",
+ [0x0F7A] = "SHENZHEN GUANG QI GUO CHUANG TECHNOLOGY CO.,LTD",
+ [0x0F79] = "Walter Mueller Inc. for Industrial Electronics",
+ [0x0F78] = "Also, Inc.",
+ [0x0F77] = "CROSS BRAIN CO.,Ltd.",
+ [0x0F76] = "simatec ag",
+ [0x0F75] = "Senseonics, Incorporated",
+ [0x0F74] = "Sperry Labs LLC",
+ [0x0F73] = "Keycafe Inc.",
+ [0x0F72] = "FLORLINK, INC.",
+ [0x0F71] = "PIRITIZ LLC",
+ [0x0F70] = "TECHFLITTER SOLUTIONS PRIVATE LIMITED",
+ [0x0F6F] = "onanoff limited",
+ [0x0F6E] = "iSi Wearable Safety GmbH",
+ [0x0F6D] = "MODRETRO, INC.",
+ [0x0F6C] = "Biogents AG",
+ [0x0F6B] = "Skywalk Inc.",
+ [0x0F6A] = "Roam Devices LLC",
+ [0x0F69] = "Ancoe Industry Corporation",
+ [0x0F68] = "Navico",
+ [0x0F67] = "White Eagle Sonic Technologies, Inc.",
+ [0x0F66] = "BIGREDBEE, LLC",
+ [0x0F65] = "CZV, Inc",
+ [0x0F64] = "SafeNow GmbH",
+ [0x0F63] = "WATTER, Inc.",
+ [0x0F62] = "Kehwin Technologies Co. Ltd.",
+ [0x0F61] = "HyolimXE Co., Ltd.",
+ [0x0F60] = "Quilt Systems, Inc.",
+ [0x0F5F] = "ROBSON SRL",
+ [0x0F5E] = "WUXI WEIDA INTELLIGENT ELECTRONICS CO.,LTD.",
+ [0x0F5D] = "Leapcraft ApS",
+ [0x0F5C] = "Micro Technology Services, Inc.",
+ [0x0F5B] = "shanghai fudan electronics group company Co. Ltd",
+ [0x0F5A] = "Vibe Energy B.V.",
+ [0x0F59] = "Silicon Vandals PTY LTD",
+ [0x0F58] = "Shenzhen Guo-link Technology Co.,Ltd.",
+ [0x0F57] = "Silverlake Technologies",
+ [0x0F56] = "Astute Access Group Limited",
+ [0x0F55] = "Sensoteq Ltd",
+ [0x0F54] = "ContiTech Deutschland GmbH",
+ [0x0F53] = "Fledt & Meiton Marin AB",
+ [0x0F52] = "Shenzhen Yunke Intelligent Co.Ltd",
+ [0x0F51] = "ebm-papst Mulfingen GmbH & Co. KGaA & Co. KG",
+ [0x0F50] = "Lantern Innovations Incorporated",
+ [0x0F4F] = "G-Vision GmbH",
+ [0x0F4E] = "Ningbo Dooya Mechanic & Electronic Technology Co., Ltd",
+ [0x0F4D] = "Qulinda AB",
+ [0x0F4C] = "JL WORLD CORPORATION LIMITED",
+ [0x0F4B] = "desamisCo.,Ltd.",
+ [0x0F4A] = "Mitsubishi Motors Corporation",
+ [0x0F49] = "IYO INC.",
+ [0x0F48] = "Shenzhen Xinfeiyi Technology Co., Ltd.",
+ [0x0F47] = "Lodestar Technology Inc.",
+ [0x0F46] = "Huizhou Meicanxin Electronics Technology Co.,Ltd",
+ [0x0F45] = "Tactica Defense LLC",
+ [0x0F44] = "MAVERICK ENERGY SOLUTIONS INTERNATIONAL,INC",
+ [0x0F43] = "12mm Health Technology (Hainan) Co., Ltd.",
+ [0x0F42] = "LINKEDCHIP TECHNOLOGY INC",
+ [0x0F41] = "BONX INC.",
+ [0x0F40] = "Health Data Insight C.I.C.",
+ [0x0F3F] = "Landig + Lava GmbH & Co. KG",
+ [0x0F3E] = "Salyx Medical Inc.",
+ [0x0F3D] = "Vitio Medical S.L.",
+ [0x0F3C] = "Mobile Technology Solutions LLC",
+ [0x0F3B] = "SANWA NEWTEC CO.,LTD.",
+ [0x0F3A] = "Southern Audio Services, Inc",
+ [0x0F39] = "Seitron Spa",
+ [0x0F38] = "GILL INSTRUMENTS LIMITED",
+ [0x0F37] = "Schueco International KG",
+ [0x0F36] = "NODER Joint Stock Company",
+ [0x0F35] = "Audeze LLC",
+ [0x0F34] = "NINGBO SHARKWARD ELECTRONICS CO.,LTD",
+ [0x0F33] = "TETNET",
+ [0x0F32] = "FLINTEC UK LIMITED",
+ [0x0F31] = "TECHNOLOGIES FOR FREERIDE s.r.o",
+ [0x0F30] = "Opal Camera, Inc.",
+ [0x0F2F] = "ThingCo Limited",
+ [0x0F2E] = "BRITZ INTERNATIONAL CO.,LTD",
+ [0x0F2D] = "ClipsClips LLC",
+ [0x0F2C] = "Vision Group Inc.",
+ [0x0F2B] = "ElitEngineering LLC",
+ [0x0F2A] = "KELLER Druckmesstechnik AG",
+ [0x0F29] = "Trezor Company s.r.o.",
+ [0x0F28] = "Amp Fit Israel LTD",
+ [0x0F27] = "Global Link Distribution Corp.",
+ [0x0F26] = "Centromere Holding B.V.",
+ [0x0F25] = "Sichuan Changhong Neonet Technologies Co.,Ltd.",
+ [0x0F24] = "Easy Measure Co., Ltd.",
+ [0x0F23] = "Yuquan Semiconductor (Xiamen) Co., Ltd.",
+ [0x0F22] = "ONESPACE TECHNOLOGIES (PTY) LTD",
+ [0x0F21] = "Rokk Limited",
+ [0x0F20] = "Beijing Spring Creation Technology Co., Ltd.",
+ [0x0F1F] = "Ninebot (Changzhou) Tech Co., Ltd.",
+ [0x0F1E] = "Yolni Inc.",
+ [0x0F1D] = "ZIMMERMANN PV-Steel Group GmbH & Co. KG",
+ [0x0F1C] = "Edge Semiconductors Inc.",
+ [0x0F1B] = "PalatiumCare LLC",
+ [0x0F1A] = "FLO SCIENCES, LLC",
+ [0x0F19] = "Shenzhen SuperSound Technology Co.,Ltd",
+ [0x0F18] = "iKeyless, LLC",
+ [0x0F17] = "RORENTECH Co., Ltd.",
+ [0x0F16] = "ADHD Friendly co.Ltd",
+ [0x0F15] = "Oval Corporation",
+ [0x0F14] = "PreEvnt, LLC",
+ [0x0F13] = "Freshape SA",
+ [0x0F12] = "Endur ID, Inc.",
+ [0x0F11] = "Deep and Steep LLC",
+ [0x0F10] = "ShenZhen Doctors of Intelligence & Technology Co.,Ltd",
+ [0x0F0F] = "caive Inc.",
+ [0x0F0E] = "IDEATRONIK Limited Liability Company",
+ [0x0F0D] = "Maennl Elektronik GmbH",
+ [0x0F0C] = "Hitachi Industrial Equipment Systems Co.,Ltd.",
+ [0x0F0B] = "Brightway Innovation Intelligent Technology (Suzhou) Co., Ltd.",
+ [0x0F0A] = "LION GROUP, INC.",
+ [0x0F09] = "ANC CHINA LIMITED",
+ [0x0F08] = "Lezyne USA Inc.",
+ [0x0F07] = "Tech OVN Private Limited",
+ [0x0F06] = "SHENZHEN POWEROAK NEWENER CO., LTD",
+ [0x0F05] = "SHENZHEN GWSTAI TECHNOLOGY CO.,LTD",
+ [0x0F04] = "Lumen Labs (HK) Ltd",
+ [0x0F03] = "Olibra LLC",
+ [0x0F02] = "GE HEALTHCARE TECHNOLOGIES INC.",
+ [0x0F01] = "HAPPLABS SOFTWARE PRIVATE LIMITED",
+ [0x0F00] = "TRACERCO LIMITED",
+ [0x0EFF] = "Healthcare Technology Limited",
+ [0x0EFE] = "Teledyne Instruments, Inc.",
+ [0x0EFD] = "PRIMES GmbH",
+ [0x0EFC] = "Jano Life Inc.",
+ [0x0EFB] = "BLUEPROVIDERZ LLC",
+ [0x0EFA] = "Sensear Pty Ltd",
+ [0x0EF9] = "Aseptico, Inc.",
+ [0x0EF8] = "IoT Solutions Malta Limited",
+ [0x0EF7] = "Trackonomy Systems, Inc.",
+ [0x0EF6] = "Shenzhen Cyber Innovation Technology Co., Ltd.",
+ [0x0EF5] = "LS ELECTRIC Co., Ltd.",
+ [0x0EF3] = "Monil AS",
+ [0x0EF2] = "CAPTAIN BLINK",
+ [0x0EF1] = "Wuxi Does IOT Co., Ltd",
+ [0x0EF0] = "Seaward Electronic",
+ [0x0EEF] = "Q42 Internet B.V.",
+ [0x0EEE] = "ELLEA INGEGNERIA SRL UNIPERSONALE",
+ [0x0EED] = "BBC Bircher AG",
+ [0x0EEC] = "Willow Laboratories, Inc.",
+ [0x0EEB] = "Fujita Electric Works, Ltd",
+ [0x0EEA] = "Core Devices LLC",
+ [0x0EE9] = "PIXEL TI IND. E COM PROD ELETRONICOS",
+ [0x0EE8] = "SG Armaturen AS",
+ [0x0EE7] = "RICKARD AIR DIFFUSION (PTY) LTD",
+ [0x0EE6] = "NOCTRIX HEALTH, INC",
+ [0x0EE5] = "Ambient Life Inc.",
+ [0x0EE4] = "CAPTEMP, LDA",
+ [0x0EE3] = "TAMRON Co., Ltd.",
+ [0x0EE2] = "shenzhen hongever technology Co,. Ltd",
+ [0x0EE1] = "MA MICRO LIMITED",
+ [0x0EE0] = "MAERSK CONTAINER INDUSTRY A/S",
+ [0x0EDF] = "Dynaudio A/S",
+ [0x0EDE] = "Sony Honda Mobility Inc.",
+ [0x0EDD] = "Ceridwen Limited",
+ [0x0EDC] = "Shenzhen Zoqin Technology Co., Ltd.",
+ [0x0EDB] = "ShenZhen BoYiChuangXin",
+ [0x0EDA] = "Kodira GmbH",
+ [0x0ED9] = "Overhead Door Corporation",
+ [0x0ED8] = "DORAN MFG. LLC",
+ [0x0ED7] = "CS INSTRUMENTS GmbH & Co.KG",
+ [0x0ED6] = "Quintessential Design, Inc.",
+ [0x0ED5] = "Relish Technologies Limited",
+ [0x0ED4] = "Goerdyna Group Co., Ltd",
+ [0x0ED3] = "Lichens Innovation inc.",
+ [0x0ED2] = "SHENZHEN BESTWAY ELECTRONICS CO.,LTD",
+ [0x0ED1] = "VINYL MATT MEDIA LIMITED",
+ [0x0ED0] = "Gibson, Inc.",
+ [0x0ECF] = "GGEC America, Inc.",
+ [0x0ECE] = "Guangzhou Honor Microelectronic Co.,Ltd.",
+ [0x0ECD] = "Nature Inc.",
+ [0x0ECC] = "Shenzhen NEOECO Technology Co., Ltd.",
+ [0x0ECB] = "Zhong Shan City Richsound Electronic Industrial Ltd.",
+ [0x0ECA] = "NexRev LLC",
+ [0x0EC9] = "NeuroPace Inc",
+ [0x0EC8] = "Codie LLC",
+ [0x0EC7] = "Canyon Bicycles GmbH",
+ [0x0EC6] = "AuthGate B.V.",
+ [0x0EC5] = "Alibaba (China) Co., Ltd.",
+ [0x0EC4] = "PACIFIC MARINE BATTERIES PTY. LIMITED",
+ [0x0EC3] = "Herschel Infrared Ltd",
+ [0x0EC2] = "High Entropy, LLC",
+ [0x0EC1] = "Crossdoor",
+ [0x0EC0] = "WEST inx Ltd.",
+ [0x0EBF] = "Schulte-Schlagbaum AG",
+ [0x0EBE] = "Deity Acoustic Technology Co.",
+ [0x0EBD] = "Tongfang Health Technology (Beijing) Co., Ltd.",
+ [0x0EBC] = "GP Acoustics International Limited",
+ [0x0EBB] = "Asahi Denso Co.,Ltd.",
+ [0x0EBA] = "THERMY LTD",
+ [0x0EB9] = "egojin co,.ltd",
+ [0x0EB8] = "PARAGON ID",
+ [0x0EB7] = "Embedded Solutions LLC",
+ [0x0EB6] = "Server Products, Inc.",
+ [0x0EB5] = "Preseed Japan Corporation",
+ [0x0EB4] = "BLUEFIN DATA, LLC",
+ [0x0EB3] = "Zucchetti Axess",
+ [0x0EB2] = "PRADCO Outdoor Brands",
+ [0x0EB1] = "WearNex Limited",
+ [0x0EB0] = "FactorySense",
+ [0x0EAF] = "Unfolded Circle ApS",
+ [0x0EAE] = "BHClears Microelectronics (Shanghai) Co., Ltd.",
+ [0x0EAD] = "OPTRON Co., Ltd.",
+ [0x0EAC] = "Dynetrex Solutions Inc.",
+ [0x0EAB] = "STEYR Sport GmbH",
+ [0x0EAA] = "Hive Soundz inc.",
+ [0x0EA9] = "Makichie Co., Ltd.",
+ [0x0EA8] = "Dongguan Trangjan Industrial Co., Ltd",
+ [0x0EA7] = "BrickXter GmbH",
+ [0x0EA6] = "AMG Lab LLC",
+ [0x0EA5] = "EasyReach Solutions Private Limited",
+ [0x0EA4] = "BiTECH Automotive (Wuhu) Co.,Ltd",
+ [0x0EA3] = "OLIS ELECTRONICS, LLC",
+ [0x0EA2] = "QIKCONNEX LLC",
+ [0x0EA1] = "Culligan International Company",
+ [0x0EA0] = "ENLESS WIRELESS",
+ [0x0E9F] = "Owlet Baby Care Inc.",
+ [0x0E9E] = "Travelxp India Private Limited",
+ [0x0E9D] = "Audinor ApS",
+ [0x0E9C] = "Andrews & Arnold Ltd",
+ [0x0E9B] = "Panasonic Automotive Systems Co., Ltd.",
+ [0x0E9A] = "Scanbro OU",
+ [0x0E99] = "Medibound, Inc.",
+ [0x0E98] = "Chromatic Inc.",
+ [0x0E97] = "kokoromil Inc.",
+ [0x0E96] = "Skeed,co,Ltd.",
+ [0x0E95] = "Viaanix, Inc.",
+ [0x0E94] = "Tactrix",
+ [0x0E93] = "MIV ELECTRONICS, LTD",
+ [0x0E92] = "Glutz AG",
+ [0x0E91] = "Identita Inc.",
+ [0x0E90] = "RainMaker Solutions, Inc.",
+ [0x0E8F] = "Avetos Design LLC",
+ [0x0E8E] = "Nobest Inc",
+ [0x0E8D] = "Celebrities Management Private Limited",
+ [0x0E8C] = "Gopod Group Holding Limited",
+ [0x0E8B] = "Allgon AB",
+ [0x0E8A] = "Tele-Radio i Lysekil AB",
+ [0x0E89] = "Brudden",
+ [0x0E88] = "Skewered Fencing, LLC",
+ [0x0E87] = "OpenTech Alliance, Inc.",
+ [0x0E86] = "Mercury Marine, a division of Brunswick Corporation",
+ [0x0E85] = "TigerLight, Inc.",
+ [0x0E84] = "Tymphany HK Ltd",
+ [0x0E83] = "SPRiNTUS GmbH",
+ [0x0E82] = "CHEVALIER TECH LIMITED",
+ [0x0E81] = "Guangdong Hengqin Xingtong Technology Co.,ltd.",
+ [0x0E80] = "IQNEXXT Solutions GmbH",
+ [0x0E7F] = "SZR-Dev UG",
+ [0x0E7E] = "Archon Controls LLC",
+ [0x0E7D] = "Jiangsu XinTongda Electric Technology Co.,Ltd.",
+ [0x0E7C] = "final Inc.",
+ [0x0E7B] = "Circular",
+ [0x0E7A] = "Vivago Oy",
+ [0x0E79] = "Neptune First OU",
+ [0x0E78] = "HONG KONG COMMUNICATIONS COMPANY LIMITED",
+ [0x0E77] = "MOBILE TECH, INC.",
+ [0x0E76] = "Guangdong Nanguang Photo&Video Systems Co., Ltd.",
+ [0x0E75] = "Le Touch (Shenzhen) Electronics Co., Ltd.",
+ [0x0E74] = "Rocky Radios LLC",
+ [0x0E73] = "Adventures of the Persistently Impaired (and other tales) Limited",
+ [0x0E72] = "TOR.AI LIMITED",
+ [0x0E71] = "ENABLEWEAR LLC",
+ [0x0E70] = "Powerstick.com",
+ [0x0E6F] = "OpConnect, Inc.",
+ [0x0E6D] = "FEVOS LIMITED",
+ [0x0E6C] = "RIGH, INC.",
+ [0x0E6B] = "Shenzhen Goodocom Information Technology Co., Ltd.",
+ [0x0E6A] = "Hyena Inc.",
+ [0x0E69] = "Megatronix (Beijing) Technology Co., Ltd",
+ [0x0E68] = "EarTex Ltd",
+ [0x0E67] = "NEXT DEVICES LTDA",
+ [0x0E66] = "Shenzhen Baseus Technology Co., Ltd.",
+ [0x0E65] = "Daikin Industries, LTD",
+ [0x0E64] = "HuiTong intelligence Company Limited",
+ [0x0E63] = "LAST LOCK INC.",
+ [0x0E62] = "GOKI PTY LTD",
+ [0x0E61] = "Queclink Wireless Solutions Co., Ltd.",
+ [0x0E60] = "Ant Group Co., Ltd.",
+ [0x0E5F] = "Ruptela",
+ [0x0E5E] = "SHAPER TOOLS, INC.",
+ [0x0E5D] = "L.T.H. Electronics Limited",
+ [0x0E5C] = "Rocoto Ltd",
+ [0x0E5B] = "Wuhu Hongjing Electronic Co.,Ltd",
+ [0x0E5A] = "OmniWave Microelectronics Shanghai Co., Ltd",
+ [0x0E59] = "Loewe Technology GmbH",
+ [0x0E58] = "Urban Armor Gear, LLC",
+ [0x0E57] = "Altina Inc.",
+ [0x0E56] = "INEPRO Metering B.V.",
+ [0x0E55] = "New Cosmos Electric Co., Ltd.",
+ [0x0E54] = "Relief Technologies AS",
+ [0x0E53] = "MINIRIG",
+ [0x0E52] = "Aquana, LLC",
+ [0x0E51] = "Dyaco International Inc.",
+ [0x0E50] = "Zhejiang Desman Intelligent Technology Co., Ltd.",
+ [0x0E4F] = "eBet Gaming Sytems Pty Limited",
+ [0x0E4E] = "QSC, LLC",
+ [0x0E4D] = "Brooksee, Inc.",
+ [0x0E4C] = "Luxshare Precision Industry Co., Ltd.",
+ [0x0E4B] = "PUDSEY DIAMOND ENGINEERING LIMITED",
+ [0x0E4A] = "Nitto Denko Corporation",
+ [0x0E49] = "Deone (Shanghai) Communication & Technology Co., Ltd",
+ [0x0E48] = "KARLUNA MUHENDISLIK SANAYI VE TICARET ANONIM SIRKETI",
+ [0x0E47] = "Hosiden Besson Limited",
+ [0x0E46] = "SOUNDUCT",
+ [0x0E45] = "Filo Srl",
+ [0x0E44] = "QUANTATEC",
+ [0x0E43] = "InnoVision Medical Technologies, LLC",
+ [0x0E42] = "Z-ONE Technology Co., Ltd.",
+ [0x0E41] = "Asustek Computer Inc.",
+ [0x0E40] = "WKD Labs Ltd",
+ [0x0E3F] = "Wiser Devices, LLC",
+ [0x0E3E] = "VANBOX",
+ [0x0E3D] = "Walmart Inc.",
+ [0x0E3C] = "Viselabs",
+ [0x0E3B] = "Swift IOT Tech (Shenzhen) Co., LTD.",
+ [0x0E3A] = "OFIVE LIMITED",
+ [0x0E39] = "IRES Infrarot Energie Systeme GmbH",
+ [0x0E38] = "SLOC GmbH",
+ [0x0E37] = "CESYS Gesellschaft für angewandte Mikroelektronik mbH",
+ [0x0E36] = "Cousins and Sears LLC",
+ [0x0E35] = "SNAPPWISH LLC",
+ [0x0E34] = "Vermis, software solutions llc",
+ [0x0E33] = "Crescent NV",
+ [0x0E32] = "PACIFIC INDUSTRIAL CO., LTD.",
+ [0x0E31] = "AlphaTheta Corporation",
+ [0x0E30] = "Primax Electronics Ltd.",
+ [0x0E2F] = "ONWI",
+ [0x0E2E] = "NIHON KOHDEN CORPORATION",
+ [0x0E2D] = "ECARX (Hubei) Tech Co.,Ltd.",
+ [0x0E2C] = "9313-7263 Quebec inc.",
+ [0x0E2B] = "JE electronic a/s",
+ [0x0E2A] = "Huizhou Foryou General Electronics Co., Ltd.",
+ [0x0E29] = "Flipper Devices Inc.",
+ [0x0E28] = "PatchRx, Inc.",
+ [0x0E27] = "NextSense, Inc.",
+ [0x0E26] = "LIHJOEN SPEED METER CO., LTD.",
+ [0x0E25] = "Hangzhou Hikvision Digital Technology Co., Ltd.",
+ [0x0E24] = "Avedis Zildjian Co.",
+ [0x0E23] = "MS kajak7 UG (limited liability)",
+ [0x0E22] = "HITO INC",
+ [0x0E21] = "FOGO",
+ [0x0E20] = "CFLAB TEKNOLOJI TICARET LIMITED SIRKETI",
+ [0x0E1F] = "Blecon Ltd",
+ [0x0E1E] = "9512-5837 QUEBEC INC.",
+ [0x0E1D] = "Capte B.V.",
+ [0x0E1C] = "SHENZHEN DIGITECH CO., LTD",
+ [0x0E1B] = "Time Location Systems AS",
+ [0x0E1A] = "Sensovo GmbH",
+ [0x0E19] = "Hive-Zox International SA",
+ [0x0E18] = "Hangzhou Microimage Software Co.,Ltd.",
+ [0x0E17] = "Ad Hoc Electronics, llc.",
+ [0x0E16] = "Xiamen RUI YI Da Electronic Technology Co.,Ltd",
+ [0x0E15] = "Eastern Partner Limited",
+ [0x0E14] = "Heilongjiang Tianyouwei Electronics Co.,Ltd.",
+ [0x0E13] = "ASYSTOM",
+ [0x0E12] = "CLEVER LOGGER TECHNOLOGIES PTY LIMITED",
+ [0x0E11] = "Wanzl GmbH & Co. KGaA",
+ [0x0E10] = "Eko Health, Inc.",
+ [0x0E0F] = "SenseWorks Tecnologia Ltda.",
+ [0x0E0D] = "FrontAct Co., Ltd.",
+ [0x0E0C] = "Yuanfeng Technology Co., Ltd.",
+ [0x0E0B] = "Sounding Audio Industrial Ltd.",
+ [0x0E09] = "Evolutive Systems SL",
+ [0x0E08] = "Heinrich Kopp GmbH",
+ [0x0E07] = "Pinpoint GmbH",
+ [0x0E06] = "iodyne, LLC",
+ [0x0E05] = "SUREPULSE MEDICAL LIMITED",
+ [0x0E04] = "Doro AB",
+ [0x0E03] = "Shenzhen eMeet technology Co.,Ltd",
+ [0x0E02] = "PROSYS DEV LIMITED",
+ [0x0E01] = "Wuhu Mengbo Technology Co., Ltd.",
+ [0x0E00] = "SHENZHEN SOUNDSOUL INFORMATION TECHNOLOGY CO.,LTD",
+ [0x0DFF] = "WaveRF, Corp.",
+ [0x0DFE] = "Haptech, Inc.",
+ [0x0DFD] = "BH Technologies",
+ [0x0DFC] = "SOJI ELECTRONICS JOINT STOCK COMPANY",
+ [0x0DFB] = "Beidou Intelligent Connected Vehicle Technology Co., Ltd.",
+ [0x0DFA] = "Shenzhen Matches IoT Technology Co., Ltd.",
+ [0x0DF9] = "Belusun Technology Ltd.",
+ [0x0DF8] = "Chengdu CSCT Microelectronics Co., Ltd.",
+ [0x0DF7] = "Hangzhou Zhaotong Microelectronics Co., Ltd.",
+ [0x0DF6] = "Mutrack Co., Ltd",
+ [0x0DF5] = "Delta Faucet Company",
+ [0x0DF4] = "REEKON TOOLS INC.",
+ [0x0DF3] = "hDrop Technologies Inc.",
+ [0x0DF2] = "Weber-Stephen Products LLC",
+ [0x0DF1] = "iFLYTEK (Suzhou) Technology Co., Ltd.",
+ [0x0DF0] = "Woncan (Hong Kong) Limited",
+ [0x0DEF] = "SING SUN TECHNOLOGY (INTERNATIONAL) LIMITED",
+ [0x0DEE] = "Safety Swim LLC",
+ [0x0DED] = "Flextronic GmbH",
+ [0x0DEC] = "ATEGENOS PHARMACEUTICALS INC",
+ [0x0DEB] = "Fitz Inc.",
+ [0x0DEA] = "Kathrein Solutions GmbH",
+ [0x0DE9] = "General Laser GmbH",
+ [0x0DE8] = "Vivint, Inc.",
+ [0x0DE7] = "Dendro Technologies, Inc.",
+ [0x0DE6] = "DEXATEK Technology LTD",
+ [0x0DE5] = "Ehong Technology Co.,Ltd",
+ [0x0DE4] = "EMBEINT INC",
+ [0x0DE3] = "Shenzhen MODSEMI Co., Ltd",
+ [0x0DE2] = "TAMADIC Co., Ltd.",
+ [0x0DE1] = "Outshiny India Private Limited",
+ [0x0DE0] = "KNOG PTY. LTD.",
+ [0x0DDF] = "Shenzhen Lunci Technology Co., Ltd",
+ [0x0DDE] = "SHENZHEN DNS INDUSTRIES CO., LTD.",
+ [0x0DDD] = "Tozoa LLC",
+ [0x0DDC] = "AUTHOR-ALARM, razvoj in prodaja avtomobilskih sistemov proti kraji, d.o.o.",
+ [0x0DDB] = "KAGA FEI Co., Ltd.",
+ [0x0DDA] = "Minebea Intec GmbH",
+ [0x0DD9] = "SCHELL GmbH & Co. KG",
+ [0x0DD7] = "Xiant Technologies, Inc.",
+ [0x0DD6] = "MODULAR MEDICAL, INC.",
+ [0x0DD5] = "BEEPINGS",
+ [0x0DD4] = "KOQOON GmbH & Co.KG",
+ [0x0DD3] = "Global Satellite Engineering",
+ [0x0DD2] = "Fresh n Rebel B.V.",
+ [0x0DD1] = "OrangeMicro Limited",
+ [0x0DD0] = "ESNAH",
+ [0x0DCF] = "KUBU SMART LIMITED",
+ [0x0DCE] = "NOVAFON - Electromedical devices limited liability company",
+ [0x0DCD] = "Astra LED AG",
+ [0x0DCC] = "Trotec GmbH",
+ [0x0DCB] = "GEOPH, LLC",
+ [0x0DCA] = "Aptener Mechatronics Private Limited",
+ [0x0DC9] = "farmunited GmbH",
+ [0x0DC8] = "ETO GRUPPE TECHNOLOGIES GmbH",
+ [0x0DC7] = "MORNINGSTAR FX PTE. LTD.",
+ [0x0DC6] = "Levita",
+ [0x0DC5] = "DHL",
+ [0x0DC4] = "Hangzhou NationalChip Science & Technology Co.,Ltd",
+ [0x0DC3] = "GEARBAC TECHNOLOGIES INC.",
+ [0x0DC2] = "Cirrus Research plc",
+ [0x0DC1] = "NACHI-FUJIKOSHI CORP.",
+ [0x0DC0] = "Shenzhen Jahport Electronic Technology Co., Ltd.",
+ [0x0DBF] = "HWM-Water Limited",
+ [0x0DBE] = "HELLA GmbH & Co. KGaA",
+ [0x0DBD] = "MAATEL",
+ [0x0DBC] = "Felion Technologies Company Limited",
+ [0x0DBB] = "Nexis Link Technology Co., Ltd.",
+ [0x0DBA] = "Veo Technologies ApS",
+ [0x0DB9] = "CompanyDeep Ltd",
+ [0x0DB8] = "Shenzhen Chuangyuan Digital Technology Co., Ltd",
+ [0x0DB7] = "Morningstar Corporation",
+ [0x0DB6] = "SAFEGUARD EQUIPMENT, INC.",
+ [0x0DB5] = "Djup AB",
+ [0x0DB4] = "Franklin Control Systems",
+ [0x0DB3] = "SHENZHEN REFLYING ELECTRONIC CO., LTD",
+ [0x0DB2] = "Whirlpool",
+ [0x0DB1] = "TELE System Communications Pte. Ltd.",
+ [0x0DB0] = "Invisalert Solutions, Inc.",
+ [0x0DAF] = "Hexagon Aura Reality AG",
+ [0x0DAE] = "TITUM AUDIO, INC.",
+ [0x0DAC] = "ITALTRACTOR ITM S.P.A.",
+ [0x0DAB] = "Efento",
+ [0x0DAA] = "Shenzhen EBELONG Technology Co., Ltd.",
+ [0x0DA9] = "Inventronics GmbH",
+ [0x0DA8] = "Airwallet ApS",
+ [0x0DA7] = "Novoferm tormatic GmbH",
+ [0x0DA6] = "Generac Corporation",
+ [0x0DA5] = "PIXELA CORPORATION",
+ [0x0DA4] = "HP Tuners",
+ [0x0DA3] = "Airgraft Inc.",
+ [0x0DA2] = "KIWI.KI GmbH",
+ [0x0DA1] = "Fen Systems Ltd.",
+ [0x0DA0] = "SICK AG",
+ [0x0D9F] = "MML US, Inc",
+ [0x0D9E] = "Impulse Wellness LLC",
+ [0x0D9D] = "Cear, Inc.",
+ [0x0D9C] = "Skytech Creations Limited",
+ [0x0D9B] = "Boxyz, Inc.",
+ [0x0D9A] = "Yeasound (Xiamen) Hearing Technology Co., Ltd",
+ [0x0D99] = "Caire Inc.",
+ [0x0D98] = "E.F. Johnson Company",
+ [0x0D97] = "Zhejiang Huanfu Technology Co., LTD",
+ [0x0D96] = "NEOKOHM SISTEMAS ELETRONICOS LTDA",
+ [0x0D95] = "Hunter Industries Incorporated",
+ [0x0D93] = "HagerEnergy GmbH",
+ [0x0D92] = "TACHIKAWA CORPORATION",
+ [0x0D91] = "Beamex Oy Ab",
+ [0x0D90] = "LAAS ApS",
+ [0x0D8F] = "Canon Electronics Inc.",
+ [0x0D8E] = "Optivolt Labs, Inc.",
+ [0x0D8D] = "RF Electronics Limited",
+ [0x0D8C] = "Ultimea Technology (Shenzhen) Limited",
+ [0x0D8B] = "Software Development, LLC",
+ [0x0D8A] = "Simply Embedded Inc.",
+ [0x0D89] = "Nanohex Corp",
+ [0x0D87] = "Quectel Wireless Solutions Co., Ltd.",
+ [0x0D86] = "ROCKWELL AUTOMATION, INC.",
+ [0x0D85] = "SEW-EURODRIVE GmbH & Co KG",
+ [0x0D84] = "Testo SE & Co. KGaA",
+ [0x0D83] = "ATLANTIC SOCIETE FRANCAISE DE DEVELOPPEMENT THERMIQUE",
+ [0x0D82] = "VELCO",
+ [0x0D81] = "Beyerdynamic GmbH & Co. KG",
+ [0x0D80] = "Gravaa B.V.",
+ [0x0D7F] = "Konova",
+ [0x0D7E] = "Daihatsu Motor Co., Ltd.",
+ [0x0D7D] = "Taiko Audio B.V.",
+ [0x0D7C] = "BeiJing SmartChip Microelectronics Technology Co.,Ltd",
+ [0x0D7B] = "MindMaze SA",
+ [0x0D7A] = "Xiamen Intretech Inc.",
+ [0x0D79] = "VIVIWARE JAPAN, Inc.",
+ [0x0D78] = "MITACHI CO.,LTD.",
+ [0x0D77] = "DualNetworks SA",
+ [0x0D76] = "i-focus Co.,Ltd",
+ [0x0D75] = "Indistinguishable From Magic, Inc.",
+ [0x0D74] = "ANUME s.r.o.",
+ [0x0D73] = "iota Biosciences, Inc.",
+ [0x0D72] = "Earfun Technology (HK) Limited",
+ [0x0D71] = "Kiteras Inc.",
+ [0x0D70] = "Kindhome",
+ [0x0D6F] = "Closed Joint Stock Company NVP BOLID",
+ [0x0D6E] = "Look Cycle International",
+ [0x0D6D] = "DYNAMOX S/A",
+ [0x0D6C] = "Ambient IoT Pty Ltd",
+ [0x0D6B] = "Crane Payment Innovations, Inc.",
+ [0x0D6A] = "Helge Kaiser GmbH",
+ [0x0D69] = "AIR AROMA INTERNATIONAL PTY LTD",
+ [0x0D68] = "Status Audio LLC",
+ [0x0D67] = "BLACK BOX NETWORK SERVICES INDIA PRIVATE LIMITED",
+ [0x0D66] = "Hendrickson USA , L.L.C",
+ [0x0D65] = "Molnlycke Health Care AB",
+ [0x0D64] = "Southco",
+ [0x0D63] = "SKF France",
+ [0x0D62] = "MEBSTER s.r.o.",
+ [0x0D61] = "F.I.P. FORMATURA INIEZIONE POLIMERI - S.P.A.",
+ [0x0D60] = "Smart Products Connection, S.A.",
+ [0x0D5F] = "SiChuan Homme Intelligent Technology co.,Ltd.",
+ [0x0D5E] = "Pella Corp",
+ [0x0D5D] = "Stogger B.V.",
+ [0x0D5B] = "Axis Communications AB",
+ [0x0D5A] = "Gunnebo Aktiebolag",
+ [0x0D59] = "HYUPSUNG MACHINERY ELECTRIC CO., LTD.",
+ [0x0D58] = "ifm electronic gmbh",
+ [0x0D57] = "Nanjing Xinxiangyuan Microelectronics Co., Ltd.",
+ [0x0D56] = "Wellang.Co,.Ltd",
+ [0x0D55] = "NO CLIMB PRODUCTS LTD",
+ [0x0D54] = "ISEKI FRANCE S.A.S",
+ [0x0D53] = "Luxottica Group S.p.A",
+ [0x0D52] = "DIVAN TRADING CO., LTD.",
+ [0x0D51] = "Genetus inc.",
+ [0x0D50] = "NINGBO FOTILE KITCHENWARE CO., LTD.",
+ [0x0D4F] = "Movano Inc.",
+ [0x0D4E] = "NIKAT SOLUTIONS PRIVATE LIMITED",
+ [0x0D4D] = "Optec, LLC",
+ [0x0D4B] = "Soundwave Hearing, LLC",
+ [0x0D4A] = "Rockpile Solutions, LLC",
+ [0x0D49] = "Refrigerated Transport Electronics, Inc.",
+ [0x0D48] = "Vemcon GmbH",
+ [0x0D47] = "Shenzhen DOKE Electronic Co., Ltd",
+ [0x0D46] = "Thales Simulation & Training AG",
+ [0x0D45] = "Odeon, Inc.",
+ [0x0D44] = "Ex Makhina Inc.",
+ [0x0D43] = "Gosuncn Technology Group Co., Ltd.",
+ [0x0D42] = "TEKTRO TECHNOLOGY CORPORATION",
+ [0x0D41] = "CPAC Systems AB",
+ [0x0D40] = "SignalFire Telemetry, Inc.",
+ [0x0D3F] = "Vogels Products B.V.",
+ [0x0D3E] = "LUMINOAH, INC.",
+ [0x0D3D] = "bHaptics Inc.",
+ [0x0D3C] = "SIRONA Dental Systems GmbH",
+ [0x0D3B] = "Lone Star Marine Pty Ltd",
+ [0x0D3A] = "Frost Solutions, LLC",
+ [0x0D39] = "Systemic Games, LLC",
+ [0x0D38] = "CycLock",
+ [0x0D37] = "Zerene Inc.",
+ [0x0D36] = "XIHAO INTELLIGENGT TECHNOLOGY CO., LTD",
+ [0x0D35] = "Universidad Politecnica de Madrid",
+ [0x0D34] = "ZILLIOT TECHNOLOGIES PRIVATE LIMITED",
+ [0x0D33] = "Micropower Group AB",
+ [0x0D32] = "Badger Meter",
+ [0x0D31] = "SYNCHRON, INC.",
+ [0x0D30] = "Laxmi Therapeutic Devices, Inc.",
+ [0x0D2F] = "Delta Development Team, Inc",
+ [0x0D2E] = "Advanced Electronic Applications, Inc",
+ [0x0D2D] = "Cooler Pro, LLC",
+ [0x0D2C] = "SIL System Integration Laboratory GmbH",
+ [0x0D2B] = "Sensoryx AG",
+ [0x0D2A] = "PhysioLogic Devices, Inc.",
+ [0x0D29] = "MIYAKAWA ELECTRIC WORKS LTD.",
+ [0x0D28] = "FUJITSU COMPONENT LIMITED",
+ [0x0D27] = "velocitux",
+ [0x0D26] = "Burkert Werke GmbH & Co. KG",
+ [0x0D25] = "System Elite Holdings Group Limited",
+ [0x0D24] = "Japan Display Inc.",
+ [0x0D23] = "GREE Electric Appliances, Inc. of Zhuhai",
+ [0x0D22] = "Cedarware, Corp.",
+ [0x0D21] = "Cennox Group Limited",
+ [0x0D20] = "SCIENTERRA LIMITED",
+ [0x0D1F] = "Synkopi, Inc.",
+ [0x0D1E] = "FESTINA LOTUS SA",
+ [0x0D1D] = "Electronics4All Inc.",
+ [0x0D1C] = "LIMBOID LLC",
+ [0x0D1B] = "RACHIO, INC.",
+ [0x0D1A] = "Maturix ApS",
+ [0x0D19] = "C.G. Air Systemes Inc.",
+ [0x0D18] = "Bioliberty Ltd",
+ [0x0D17] = "Akix S.r.l.",
+ [0x0D16] = "Nations Technologies Inc.",
+ [0x0D15] = "Spark",
+ [0x0D14] = "Merry Electronics (S) Pte Ltd",
+ [0x0D13] = "MERRY ELECTRONICS CO., LTD.",
+ [0x0D12] = "Spartek Systems Inc.",
+ [0x0D10] = "JVC KENWOOD Corporation",
+ [0x0D0F] = "Timebirds Australia Pty Ltd",
+ [0x0D0E] = "PetVoice Co., Ltd.",
+ [0x0D0D] = "C.Ed. Schulte GmbH Zylinderschlossfabrik",
+ [0x0D0C] = "Planmeca Oy",
+ [0x0D0B] = "Research Products Corporation",
+ [0x0D0A] = "CATEYE Co., Ltd.",
+ [0x0D09] = "Leica Geosystems AG",
+ [0x0D08] = "Datalogic USA, Inc.",
+ [0x0D07] = "Datalogic S.r.l.",
+ [0x0D06] = "doubleO Co., Ltd.",
+ [0x0D05] = "Energy Technology and Control Limited",
+ [0x0D04] = "Bartec Auto Id Ltd",
+ [0x0D03] = "MakuSafe Corp",
+ [0x0D02] = "Rocky Mountain ATV/MC Jake Wilson",
+ [0x0D01] = "KEEPEN",
+ [0x0D00] = "Sparkpark AS",
+ [0x0CFF] = "Ergodriven Inc",
+ [0x0CFE] = "Thule Group AB",
+ [0x0CFC] = "ElectronX design",
+ [0x0CFB] = "Tyromotion GmbH",
+ [0x0CFA] = "Protect Animals With Satellites LLC",
+ [0x0CF9] = "Tamblue Oy",
+ [0x0CF8] = "core sensing GmbH",
+ [0x0CF7] = "TVS Motor Company Ltd.",
+ [0x0CF6] = "OJ Electronics A/S",
+ [0x0CF5] = "BOS Balance of Storage Systems AG",
+ [0x0CF4] = "SOLUX PTY LTD",
+ [0x0CF3] = "Radio Sound",
+ [0x0CF1] = "Midmark",
+ [0x0CF0] = "THOTAKA TEKHNOLOGIES INDIA PRIVATE LIMITED",
+ [0x0CEF] = "POGS B.V.",
+ [0x0CEE] = "MadgeTech, Inc",
+ [0x0CED] = "CV. NURI TEKNIK",
+ [0x0CEC] = "Pacific Coast Fishery Services (2003) Inc.",
+ [0x0CEB] = "Shenzhen Tingting Technology Co. LTD",
+ [0x0CEA] = "HAYWARD INDUSTRIES, INC.",
+ [0x0CE9] = "PEAG, LLC dba JLab Audio",
+ [0x0CE8] = "Dongguan Yougo Electronics Co.,Ltd.",
+ [0x0CE7] = "TAG HEUER SA",
+ [0x0CE6] = "McWong International, Inc.",
+ [0x0CE5] = "Amina Distribution AS",
+ [0x0CE4] = "Off-Highway Powertrain Services Germany GmbH",
+ [0x0CE3] = "Taiwan Fuhsing",
+ [0x0CE2] = "CORVENT MEDICAL, INC.",
+ [0x0CE1] = "Regal Beloit America, Inc.",
+ [0x0CE0] = "VODALOGIC PTY LTD",
+ [0x0CDF] = "SHENZHEN CHENYUN ELECTRONICS CO., LTD",
+ [0x0CDD] = "Alif Semiconductor, Inc.",
+ [0x0CDC] = "Ypsomed AG",
+ [0x0CDB] = "Circus World Displays Limited",
+ [0x0CDA] = "Wolf Steel ltd",
+ [0x0CD9] = "Minami acoustics Limited",
+ [0x0CD8] = "SIA Mesh Group",
+ [0x0CD7] = "Maztech Industries, LLC",
+ [0x0CD6] = "HHO (Hangzhou) Digital Technology Co., Ltd.",
+ [0x0CD5] = "Numa Products, LLC",
+ [0x0CD4] = "Reoqoo IoT Technology Co., Ltd.",
+ [0x0CD3] = "TechSwipe",
+ [0x0CD2] = "EQOM SSC B.V.",
+ [0x0CD1] = "Imagine Marketing Limited",
+ [0x0CD0] = "MooreSilicon Semiconductor Technology (Shanghai) Co., LTD.",
+ [0x0CCF] = "Shenzhen CESI Information Technology Co., Ltd.",
+ [0x0CCE] = "SENOSPACE LLC",
+ [0x0CCD] = "YanFeng Visteon(Chongqing) Automotive Electronic Co.,Ltd",
+ [0x0CCC] = "Kord Defence Pty Ltd",
+ [0x0CCB] = "NOTHING TECHNOLOGY LIMITED",
+ [0x0CCA] = "Cyclops Marine Ltd",
+ [0x0CC9] = "Innocent Technology Co., Ltd.",
+ [0x0CC8] = "TrikThom",
+ [0x0CC7] = "SB C&S Corp.",
+ [0x0CC6] = "Serial Technology Corporation",
+ [0x0CC5] = "Open Road Solutions, Inc.",
+ [0x0CC4] = "ABUS August Bremicker Soehne Kommanditgesellschaft",
+ [0x0CC3] = "HMD Global Oy",
+ [0x0CC2] = "Anker Innovations Limited",
+ [0x0CC1] = "CLEIO Inc.",
+ [0x0CC0] = "Garnet Instruments Ltd.",
+ [0x0CBF] = "Forward Thinking Systems LLC.",
+ [0x0CBD] = "Pricer AB",
+ [0x0CBC] = "TROX GmbH",
+ [0x0CBB] = "Emlid Tech Kft.",
+ [0x0CBA] = "Ameso Tech (OPC) Private Limited",
+ [0x0CB9] = "seca GmbH & Co. KG",
+ [0x0CB7] = "Cucumber Lighting Controls Limited",
+ [0x0CB6] = "THE EELECTRIC MACARON LLC",
+ [0x0CB5] = "Racketry, d. o. o.",
+ [0x0CB4] = "Eberspaecher Climate Control Systems GmbH",
+ [0x0CB3] = "janova GmbH",
+ [0x0CB2] = "SHINKAWA Sensor Technology, Inc.",
+ [0x0CB1] = "RF Creations",
+ [0x0CB0] = "SwipeSense, Inc.",
+ [0x0CAF] = "NEURINNOV",
+ [0x0CAE] = "Evident Corporation",
+ [0x0CAD] = "Shenzhen Openhearing Tech CO., LTD .",
+ [0x0CAC] = "Shenzhen Shokz Co.,Ltd.",
+ [0x0CAB] = "HERUTU ELECTRONICS CORPORATION",
+ [0x0CAA] = "Shenzhen Poseidon Network Technology Co., Ltd",
+ [0x0CA9] = "Mievo Technologies Private Limited",
+ [0x0CA8] = "Sonas, Inc.",
+ [0x0CA7] = "Verve InfoTec Pty Ltd",
+ [0x0CA6] = "Megger Ltd",
+ [0x0CA5] = "Princess Cruise Lines, Ltd.",
+ [0x0CA4] = "MITSUBISHI ELECTRIC LIGHTING CO, LTD",
+ [0x0CA3] = "MAQUET GmbH",
+ [0x0CA2] = "XSENSE LTD",
+ [0x0CA1] = "YAMAHA MOTOR CO.,LTD.",
+ [0x0CA0] = "BIGBEN",
+ [0x0C9F] = "Dragonfly Energy Corp.",
+ [0x0C9E] = "ECCEL CORPORATION SAS",
+ [0x0C9D] = "Ribbiot, INC.",
+ [0x0C9C] = "Sunstone-RTLS Ipari Szolgaltato Korlatolt Felelossegu Tarsasag",
+ [0x0C9B] = "NTT sonority, Inc.",
+ [0x0C9A] = "ALF Inc.",
+ [0x0C99] = "Vire Health Oy",
+ [0x0C98] = "MiX Telematics International (PTY) LTD",
+ [0x0C97] = "Deako",
+ [0x0C96] = "H+B Hightech GmbH",
+ [0x0C95] = "Gemstone Lights Canada Ltd.",
+ [0x0C94] = "Baxter Healthcare Corporation",
+ [0x0C93] = "Movesense Oy",
+ [0x0C92] = "Kesseböhmer Ergonomietechnik GmbH",
+ [0x0C91] = "Yashu Systems",
+ [0x0C90] = "WESCO AG",
+ [0x0C8F] = "Radar Automobile Sales(Shandong)Co.,Ltd.",
+ [0x0C8E] = "Technocon Engineering Ltd.",
+ [0x0C8D] = "tonies GmbH",
+ [0x0C8C] = "T-Mobile USA",
+ [0x0C8B] = "Heavys Inc",
+ [0x0C8A] = "A.GLOBAL co.,Ltd.",
+ [0x0C89] = "AGZZX OPTOELECTRONICS TECHNOLOGY CO., LTD",
+ [0x0C88] = "Nextivity Inc.",
+ [0x0C87] = "Weltek Technologies Company Limited",
+ [0x0C86] = "Qingdao Eastsoft Communication Technology Co.,Ltd",
+ [0x0C85] = "Amlogic, Inc.",
+ [0x0C84] = "MAXON INDUSTRIES, INC.",
+ [0x0C83] = "Watchdog Systems LLC",
+ [0x0C82] = "NACON",
+ [0x0C81] = "Carrier Corporation",
+ [0x0C80] = "CARDIOID - TECHNOLOGIES, LDA",
+ [0x0C7F] = "Rochester Sensors, LLC",
+ [0x0C7D] = "3ALogics, Inc.",
+ [0x0C7C] = "Mopeka Products LLC",
+ [0x0C7B] = "PT SADAMAYA GRAHA TEKNOLOGI",
+ [0x0C7A] = "Triductor Technology (Suzhou), Inc.",
+ [0x0C79] = "Zhuhai Smartlink Technology Co., Ltd",
+ [0x0C78] = "CHARGTRON IOT PRIVATE LIMITED",
+ [0x0C77] = "TEAC Corporation",
+ [0x0C75] = "Embedded Engineering Solutions LLC",
+ [0x0C74] = "yupiteru",
+ [0x0C73] = "Truma Gerätetechnik GmbH & Co. KG",
+ [0x0C72] = "StreetCar ORV, LLC",
+ [0x0C71] = "BitGreen Technolabz (OPC) Private Limited",
+ [0x0C70] = "SCARAB SOLUTIONS LTD",
+ [0x0C6F] = "Parakey AB",
+ [0x0C6E] = "Sensa LLC",
+ [0x0C6D] = "Fidure Corp.",
+ [0x0C6C] = "SNIFF LOGIC LTD",
+ [0x0C6B] = "GILSON SAS",
+ [0x0C6A] = "CONSORCIO TRUST CONTROL - NETTEL",
+ [0x0C69] = "BLITZ electric motors. LTD",
+ [0x0C68] = "Emerja Corporation",
+ [0x0C67] = "TRACKTING S.R.L.",
+ [0x0C65] = "WAKO CO,.LTD",
+ [0x0C64] = "dormakaba Holding AG",
+ [0x0C63] = "phg Peter Hengstler GmbH + Co. KG",
+ [0x0C62] = "Phiaton Corporation",
+ [0x0C61] = "NNOXX, Inc",
+ [0x0C60] = "KEBA Energy Automation GmbH",
+ [0x0C5F] = "Wuxi Linkpower Microelectronics Co.,Ltd",
+ [0x0C5E] = "BlueID GmbH",
+ [0x0C5D] = "StepUp Solutions ApS",
+ [0x0C5C] = "MGM WIRELESSS HOLDINGS PTY LTD",
+ [0x0C5B] = "Alban Giacomo S.P.A.",
+ [0x0C5A] = "Lockswitch Sdn Bhd",
+ [0x0C59] = "CYBERDYNE Inc.",
+ [0x0C58] = "Hykso Inc.",
+ [0x0C57] = "UNEEG medical A/S",
+ [0x0C56] = "Rheem Sales Company, Inc.",
+ [0x0C55] = "Zintouch B.V.",
+ [0x0C54] = "HiViz Lighting, Inc.",
+ [0x0C53] = "Taco, Inc.",
+ [0x0C52] = "ESCEA LIMITED",
+ [0x0C51] = "INNOVA S.R.L.",
+ [0x0C50] = "Imostar Technologies Inc.",
+ [0x0C4F] = "SharkNinja Operating LLC",
+ [0x0C4E] = "Tactile Engineering, Inc.",
+ [0x0C4D] = "Seekwave Technology Co.,ltd.",
+ [0x0C4C] = "Orpyx Medical Technologies Inc.",
+ [0x0C4B] = "ADTRAN, Inc.",
+ [0x0C4A] = "atSpiro ApS",
+ [0x0C49] = "twopounds gmbh",
+ [0x0C48] = "VALEO MANAGEMENT SERVICES",
+ [0x0C47] = "Epsilon Electronics,lnc",
+ [0x0C46] = "Granwin IoT Technology (Guangzhou) Co.,Ltd",
+ [0x0C45] = "Brose Verwaltung SE, Bamberg",
+ [0x0C44] = "ONCELABS LLC",
+ [0x0C43] = "Berlinger & Co. AG",
+ [0x0C42] = "Heath Consultants Inc.",
+ [0x0C41] = "Control Solutions LLC",
+ [0x0C40] = "HORIBA, Ltd.",
+ [0x0C3F] = "Stinger Equipment, Inc.",
+ [0x0C3E] = "BELLDESIGN Inc.",
+ [0x0C3D] = "Wrmth Corp.",
+ [0x0C3C] = "Classified Cycling",
+ [0x0C3B] = "ORB Innovations Ltd",
+ [0x0C39] = "TIGER CORPORATION",
+ [0x0C38] = "Noritz Corporation.",
+ [0x0C37] = "SignalQuest, LLC",
+ [0x0C35] = "Thermokon-Sensortechnik GmbH",
+ [0x0C34] = "BYD Company Limited",
+ [0x0C33] = "Exeger Operations AB",
+ [0x0C32] = "Xian Yisuobao Electronic Technology Co., Ltd.",
+ [0x0C31] = "KINDOO LLP",
+ [0x0C30] = "McIntosh Group Inc",
+ [0x0C2F] = "BEEHERO, INC.",
+ [0x0C2E] = "Easee AS",
+ [0x0C2D] = "OTF Product Sourcing, LLC",
+ [0x0C2C] = "Zeku Technology (Shanghai) Corp., Ltd.",
+ [0x0C2B] = "GigaDevice Semiconductor Inc.",
+ [0x0C2A] = "Caresix Inc.",
+ [0x0C29] = "DENSO AIRCOOL CORPORATION",
+ [0x0C28] = "Embecta Corp.",
+ [0x0C27] = "Pal Electronics",
+ [0x0C26] = "Performance Electronics, Ltd.",
+ [0x0C25] = "JURA Elektroapparate AG",
+ [0x0C24] = "SMARTD TECHNOLOGIES INC.",
+ [0x0C23] = "KEYTEC,Inc.",
+ [0x0C22] = "Glamo Inc.",
+ [0x0C21] = "Foshan Viomi Electrical Technology Co., Ltd",
+ [0x0C20] = "COMELIT GROUP S.P.A.",
+ [0x0C1F] = "LVI Co.",
+ [0x0C1E] = "EC sense co., Ltd",
+ [0x0C1D] = "OFF Line Japan Co., Ltd.",
+ [0x0C1C] = "GEMU",
+ [0x0C1B] = "Rockchip Electronics Co., Ltd.",
+ [0x0C1A] = "Catapult Group International Ltd",
+ [0x0C19] = "Arlo Technologies, Inc.",
+ [0x0C18] = "CORROHM",
+ [0x0C17] = "SomnoMed Limited",
+ [0x0C16] = "TYKEE PTY. LTD.",
+ [0x0C15] = "Geva Sol B.V.",
+ [0x0C14] = "Fasetto, Inc.",
+ [0x0C13] = "Scandinavian Health Limited",
+ [0x0C12] = "IoSA",
+ [0x0C11] = "Gordon Murray Design Limited",
+ [0x0C10] = "Cosmed s.r.l.",
+ [0x0C0F] = "AETERLINK",
+ [0x0C0E] = "ALEX DENKO CO.,LTD.",
+ [0x0C0D] = "Mereltron bv",
+ [0x0C0C] = "Mendeltron, Inc.",
+ [0x0C0B] = "aconno GmbH",
+ [0x0C0A] = "Automated Pet Care Products, LLC",
+ [0x0C09] = "Senic Inc.",
+ [0x0C08] = 'limited liability company "Red"',
+ [0x0C07] = "CONSTRUKTS, INC.",
+ [0x0C06] = "LED Smart Inc.",
+ [0x0C05] = "Montage Connect, Inc.",
+ [0x0C04] = "Happy Health, Inc.",
+ [0x0C03] = "Puff Corp",
+ [0x0C02] = "Loomanet, Inc.",
+ [0x0C01] = "NEOWRK SISTEMAS INTELIGENTES S.A.",
+ [0x0C00] = "MQA Limited",
+ [0x0BFF] = "Ratio Electric BV",
+ [0x0BFE] = "Media-Cartec GmbH",
+ [0x0BFD] = "Esmé Solutions",
+ [0x0BFC] = "T+A elektroakustik GmbH & Co.KG",
+ [0x0BFB] = "Dodam Enersys Co., Ltd",
+ [0x0BFA] = "CleanBands Systems Ltd.",
+ [0x0BF8] = "Innovacionnye Resheniya",
+ [0x0BF7] = "Wacker Neuson SE",
+ [0x0BF6] = "greenTEG AG",
+ [0x0BF5] = "T5 tek, Inc.",
+ [0x0BF3] = "PONE BIOMETRICS AS",
+ [0x0BF2] = "Angel Medical Systems, Inc.",
+ [0x0BF1] = "Site IQ LLC",
+ [0x0BF0] = "KIDO SPORTS CO., LTD.",
+ [0x0BEF] = "Safetytest GmbH",
+ [0x0BEE] = "LINKSYS USA, INC.",
+ [0x0BED] = "CORAL-TAIYI Co. Ltd.",
+ [0x0BEC] = "Miracle-Ear, Inc.",
+ [0x0BEB] = "Luna Health, Inc.",
+ [0x0BEA] = "Twenty Five Seven, prodaja in storitve, d.o.o.",
+ [0x0BE9] = "Shindengen Electric Manufacturing Co., Ltd.",
+ [0x0BE8] = "Sensormate AG",
+ [0x0BE7] = "Fresnel Technologies, Inc.",
+ [0x0BE6] = "Puratap Pty Ltd",
+ [0x0BE5] = "ZWILLING J.A. Henckels Aktiengesellschaft",
+ [0x0BE4] = "Deepfield Connect GmbH",
+ [0x0BE3] = "Comtel Systems Ltd.",
+ [0x0BE2] = "OTC engineering",
+ [0x0BE0] = "Koizumi Lighting Technology corp.",
+ [0x0BDF] = "WINKEY ENTERPRISE (HONG KONG) LIMITED",
+ [0x0BDE] = "Yale",
+ [0x0BDD] = "Coroflo Limited",
+ [0x0BDC] = "Ledworks S.r.l.",
+ [0x0BDB] = "CHAR-BROIL, LLC",
+ [0x0BDA] = "Aardex Ltd.",
+ [0x0BD8] = "PURA SCENTS, INC.",
+ [0x0BD7] = "VINFAST TRADING AND PRODUCTION JOINT STOCK COMPANY",
+ [0x0BD6] = "Shenzhen Injoinic Technology Co., Ltd.",
+ [0x0BD5] = "Super B Lithium Power B.V.",
+ [0x0BD4] = "ndd Medizintechnik AG",
+ [0x0BD3] = "Procon Analytics, LLC",
+ [0x0BD2] = "IDEC",
+ [0x0BD1] = "Hubei Yuan Times Technology Co., Ltd.",
+ [0x0BD0] = "Durag GmbH",
+ [0x0BCF] = "LL Tec Group LLC",
+ [0x0BCE] = "Neurosity, Inc.",
+ [0x0BCD] = "Amiko srl",
+ [0x0BCC] = "Sylvac sa",
+ [0x0BCB] = "Divesoft s.r.o.",
+ [0x0BCA] = "Perimeter Technologies, Inc.",
+ [0x0BC9] = "Neuvatek Inc.",
+ [0x0BC8] = "OTF Distribution, LLC",
+ [0x0BC7] = "Signtle Inc.",
+ [0x0BC6] = "TCL COMMUNICATION EQUIPMENT CO.,LTD.",
+ [0x0BC5] = "Aperia Technologies, Inc.",
+ [0x0BC4] = "TECHTICS ENGINEERING B.V.",
+ [0x0BC3] = "MCOT INC.",
+ [0x0BC2] = "EntWick Co.",
+ [0x0BC1] = "Miele & Cie. KG",
+ [0x0BC0] = "READY FOR SKY LLP",
+ [0x0BBF] = "HIMSA II K/S",
+ [0x0BBE] = "SAAB Aktiebolag",
+ [0x0BBC] = "T2REALITY SOLUTIONS PRIVATE LIMITED",
+ [0x0BBB] = "SWISSINNO SOLUTIONS AG",
+ [0x0BBA] = "Huso, INC",
+ [0x0BB9] = "SaluStim Group Oy",
+ [0x0BB8] = "INNOVAG PTY. LTD.",
+ [0x0BB7] = "IONA Tech LLC",
+ [0x0BB6] = "Build With Robots Inc.",
+ [0x0BB5] = "Xirgo Technologies, LLC",
+ [0x0BB4] = "New Cosmos USA, Inc.",
+ [0x0BB3] = "Flender GmbH",
+ [0x0BB2] = "Fjorden Electra AS",
+ [0x0BB1] = "Beijing ranxin intelligence technology Co.,LTD",
+ [0x0BB0] = "Ecolab Inc.",
+ [0x0BAF] = "NITTO KOGYO CORPORATION",
+ [0x0BAE] = "Soma Labs LLC",
+ [0x0BAD] = "Roambotics, Inc.",
+ [0x0BAC] = "Machfu Inc.",
+ [0x0BAB] = "Grandex International Corporation",
+ [0x0BAA] = "Infinitegra, Inc.",
+ [0x0BA9] = "Allterco Robotics ltd",
+ [0x0BA8] = "GLOWFORGE INC.",
+ [0x0BA7] = "hearX Group (Pty) Ltd",
+ [0x0BA6] = "Nissan Motor Co., Ltd.",
+ [0x0BA5] = "SONICOS ENTERPRISES, LLC",
+ [0x0BA4] = "Vervent Audio Group",
+ [0x0BA3] = "Sonova Consumer Hearing GmbH",
+ [0x0BA2] = "TireCheck GmbH",
+ [0x0BA1] = "Bunn-O-Matic Corporation",
+ [0x0BA0] = "Data Sciences International",
+ [0x0B9F] = "Group Lotus Limited",
+ [0x0B9E] = "Audio Partnership Plc",
+ [0x0B9D] = "Sensoria Holdings LTD",
+ [0x0B9C] = "Komatsu Ltd.",
+ [0x0B9A] = "Beijing Wisepool Infinite Intelligence Technology Co.,Ltd",
+ [0x0B99] = "The Goodyear Tire & Rubber Company",
+ [0x0B98] = "Gymstory B.V.",
+ [0x0B97] = "SILVER TREE LABS, INC.",
+ [0x0B96] = "Telecom Design",
+ [0x0B94] = "Dreem SAS",
+ [0x0B93] = "Hangzhou BroadLink Technology Co., Ltd.",
+ [0x0B92] = "Citisend Solutions, SL",
+ [0x0B91] = "Alfen ICU B.V.",
+ [0x0B90] = "Ineos Automotive Limited",
+ [0x0B8F] = "Senscomm Semiconductor Co., Ltd.",
+ [0x0B8E] = "Gentle Energy Corp.",
+ [0x0B8D] = "Pertech Industries Inc",
+ [0x0B8C] = "MOTREX",
+ [0x0B8B] = "American Technology Components, Incorporated",
+ [0x0B8A] = "Seiko Instruments Inc.",
+ [0x0B89] = "Rotronic AG",
+ [0x0B88] = "Muguang (Guangdong) Intelligent Lighting Technology Co., Ltd",
+ [0x0B87] = "Ampetronic Ltd",
+ [0x0B86] = "Trek Bicycle",
+ [0x0B85] = "VIMANA TECH PTY LTD",
+ [0x0B84] = "Presidio Medical, Inc.",
+ [0x0B83] = "Taiga Motors Inc.",
+ [0x0B82] = "Mammut Sports Group AG",
+ [0x0B81] = "SCM Group",
+ [0x0B80] = "AXELIFE",
+ [0x0B7F] = "ICU tech GmbH",
+ [0x0B7E] = "Offcode Oy",
+ [0x0B7D] = "FoundersLane GmbH",
+ [0x0B7C] = "Scangrip A/S",
+ [0x0B7B] = "Hardcoder Oy",
+ [0x0B7A] = "Shenzhen KTC Technology Co.,Ltd.",
+ [0x0B79] = "Sankyo Air Tech Co.,Ltd.",
+ [0x0B78] = "FIELD DESIGN INC.",
+ [0x0B77] = "Aixlink(Chengdu) Co., Ltd.",
+ [0x0B76] = "MAX-co., ltd",
+ [0x0B75] = "Triple W Japan Inc.",
+ [0x0B74] = "BQN",
+ [0x0B73] = "HARADA INDUSTRY CO., LTD.",
+ [0x0B72] = "Geeknet, Inc.",
+ [0x0B71] = "lilbit ODM AS",
+ [0x0B70] = "JDRF Electromag Engineering Inc",
+ [0x0B6E] = "React Mobile",
+ [0x0B6D] = "SOLUM CO., LTD",
+ [0x0B6C] = "Sensitech, Inc.",
+ [0x0B6B] = "Samsara Networks, Inc",
+ [0x0B6A] = "Dymo",
+ [0x0B69] = "Addaday",
+ [0x0B68] = "Quha oy",
+ [0x0B67] = "CleanSpace Technology Pty Ltd",
+ [0x0B66] = "MITSUBISHI ELECTRIC AUTOMATION (THAILAND) COMPANY LIMITED",
+ [0x0B65] = "The Apache Software Foundation",
+ [0x0B64] = "NingBo klite Electric Manufacture Co.,LTD",
+ [0x0B63] = "Innolux Corporation",
+ [0x0B62] = "NOVEA ENERGIES",
+ [0x0B61] = "Sentek Pty Ltd",
+ [0x0B60] = "RATOC Systems, Inc.",
+ [0x0B5F] = "Rivieh, Inc.",
+ [0x0B5E] = "CELLCONTROL, INC.",
+ [0x0B5D] = "Fujian Newland Auto-ID Tech. Co., Ltd.",
+ [0x0B5B] = "Shenzhen ImagineVision Technology Limited",
+ [0x0B58] = "TOKAI-DENSHI INC",
+ [0x0B57] = "CONVERTRONIX TECHNOLOGIES AND SERVICES LLP",
+ [0x0B56] = "BORA - Vertriebs GmbH & Co KG",
+ [0x0B55] = "H G M Automotive Electronics, Inc.",
+ [0x0B54] = "Emotion Fitness GmbH & Co. KG",
+ [0x0B53] = "SHENZHEN KAADAS INTELLIGENT TECHNOLOGY CO.,Ltd",
+ [0x0B52] = "ZIIP Inc",
+ [0x0B51] = "FUN FACTORY GmbH",
+ [0x0B50] = "Mesh Systems LLC",
+ [0x0B4F] = "Breezi.io, Inc.",
+ [0x0B4E] = "ICP Systems B.V.",
+ [0x0B4D] = "Adam Hall GmbH",
+ [0x0B4C] = "BiosBob.Biz",
+ [0x0B4B] = "EMS Integrators, LLC",
+ [0x0B4A] = "Nomono AS",
+ [0x0B49] = "SkyHawke Technologies",
+ [0x0B48] = "NIO USA, Inc.",
+ [0x0B47] = "Gentex Corporation",
+ [0x0B45] = "Electronic Sensors, Inc.",
+ [0x0B44] = "nFore Technology Co., Ltd.",
+ [0x0B43] = "INCITAT ENVIRONNEMENT",
+ [0x0B42] = "TSI",
+ [0x0B41] = "Sentrax GmbH",
+ [0x0B40] = "Havells India Limited",
+ [0x0B3F] = "MindRhythm, Inc.",
+ [0x0B3E] = "ISEO Serrature S.p.a.",
+ [0x0B3D] = "REALTIMEID AS",
+ [0x0B3C] = "Dodge Industrial, Inc.",
+ [0x0B3B] = "AIC semiconductor (Shanghai) Co., Ltd.",
+ [0x0B3A] = "Impact Biosystems, Inc.",
+ [0x0B39] = "Red 100 Lighting Co., ltd.",
+ [0x0B38] = "WISYCOM S.R.L.",
+ [0x0B37] = "Omnivoltaic Energy Solutions Limited Company",
+ [0x0B36] = "SINTEF",
+ [0x0B35] = "BH SENS",
+ [0x0B34] = "CONZUMEX INDUSTRIES PRIVATE LIMITED",
+ [0x0B33] = "ARMATURA LLC",
+ [0x0B32] = "Hala Systems, Inc.",
+ [0x0B31] = "Silver Wolf Vehicles Inc.",
+ [0x0B30] = "ART SPA",
+ [0x0B2F] = "Duke Manufacturing Co",
+ [0x0B2E] = "MOCA System Inc.",
+ [0x0B2D] = "REDARC ELECTRONICS PTY LTD",
+ [0x0B2C] = "ILLUMAGEAR, Inc.",
+ [0x0B2B] = "MAINBOT",
+ [0x0B29] = "Tech-Venom Entertainment Private Limited",
+ [0x0B28] = "CHACON",
+ [0x0B27] = "Lumi United Technology Co., Ltd",
+ [0x0B26] = "Baracoda Daily Healthtech.",
+ [0x0B25] = "NIBROTECH LTD",
+ [0x0B24] = "BeiJing ZiJie TiaoDong KeJi Co.,Ltd.",
+ [0x0B23] = "iRhythm Technologies, Inc.",
+ [0x0B22] = "Hygiene IQ, LLC.",
+ [0x0B20] = "TKH Security B.V.",
+ [0x0B1F] = "Beijing ESWIN Computing Technology Co., Ltd.",
+ [0x0B1E] = "PB INC.",
+ [0x0B1D] = "Accelerated Systems",
+ [0x0B1C] = "Nanoleq AG",
+ [0x0B1B] = "Enerpac Tool Group Corp.",
+ [0x0B1A] = "Roca Sanitario, S.A.",
+ [0x0B19] = "WBS PROJECT H PTY LTD",
+ [0x0B18] = "DECATHLON SE",
+ [0x0B17] = "SIG SAUER, INC.",
+ [0x0B16] = "Guard RFID Solutions Inc.",
+ [0x0B15] = "NAOS JAPAN K.K.",
+ [0x0B14] = "Olumee",
+ [0x0B13] = "IOTOOLS",
+ [0x0B12] = "ToughBuilt Industries LLC",
+ [0x0B11] = "ThermoWorks, Inc.",
+ [0x0B10] = "Alfa Laval Corporate AB",
+ [0x0B0F] = "B.E.A. S.A.",
+ [0x0B0E] = "Minebea Access Solutions Inc.",
+ [0x0B0D] = "SANYO DENKO Co.,Ltd.",
+ [0x0B0C] = "BluPeak",
+ [0x0B0B] = "Sanistaal A/S",
+ [0x0B0A] = "Belun Technology Company Limited",
+ [0x0B09] = "soonisys",
+ [0x0B08] = "Shenzhen Qianfenyi Intelligent Technology Co., LTD",
+ [0x0B07] = "Workaround Gmbh",
+ [0x0B06] = "FAZUA GmbH",
+ [0x0B05] = "Marquardt GmbH",
+ [0x0B03] = "Precision Triathlon Systems Limited",
+ [0x0B02] = "IORA Technology Development Ltd. Sti.",
+ [0x0B01] = "RESIDEO TECHNOLOGIES, INC.",
+ [0x0B00] = "Flaircomm Microelectronics Inc.",
+ [0x0AFF] = "FUSEAWARE LIMITED",
+ [0x0AFE] = "Earda Technologies Co.,Ltd",
+ [0x0AFD] = "Weber Sensors, LLC",
+ [0x0AFC] = "Cerebrum Sensor Technologies Inc.",
+ [0x0AFB] = "SMT ELEKTRONIK GmbH",
+ [0x0AFA] = "Chengdu Ambit Technology Co., Ltd.",
+ [0x0AF9] = "Unisto AG",
+ [0x0AF8] = "First Design System Inc.",
+ [0x0AF7] = "Irdeto",
+ [0x0AF6] = "AMETEK, Inc.",
+ [0x0AF5] = "Unitech Electronic Inc.",
+ [0x0AF4] = "Radioworks Microelectronics PTY LTD",
+ [0x0AF3] = "701x Inc.",
+ [0x0AF2] = "Shanghai All Link Microelectronics Co.,Ltd",
+ [0x0AF1] = "CRADERS,CO.,LTD",
+ [0x0AF0] = "Leupold & Stevens, Inc.",
+ [0x0AEF] = "GLP German Light Products GmbH",
+ [0x0AEE] = "Velentium, LLC",
+ [0x0AED] = "Saxonar GmbH",
+ [0x0AEC] = "FUTEK ADVANCED SENSOR TECHNOLOGY, INC",
+ [0x0AEB] = "Square, Inc.",
+ [0x0AEA] = "Borda Technology",
+ [0x0AE9] = "FLIR Systems AB",
+ [0x0AE8] = "LEVEL, s.r.o.",
+ [0x0AE7] = "Sunplus Technology Co., Ltd.",
+ [0x0AE6] = "Hexology",
+ [0x0AE5] = "unu GmbH",
+ [0x0AE4] = "DALI Alliance",
+ [0x0AE3] = "GlobalMed",
+ [0x0ADF] = "Douglas Dynamics L.L.C.",
+ [0x0ADE] = "Vocera Communications, Inc.",
+ [0x0ADD] = "Boss Audio",
+ [0x0ADC] = "Duravit AG",
+ [0x0ADB] = "Reelables, Inc.",
+ [0x0ADA] = "Codefabrik GmbH",
+ [0x0AD9] = "Shenzhen Aimore. Co.,Ltd",
+ [0x0AD8] = "Franz Kaldewei GmbH&Co KG",
+ [0x0AD7] = "AL-KO Geraete GmbH",
+ [0x0AD6] = "nymea GmbH",
+ [0x0AD5] = "Streamit B.V.",
+ [0x0AD4] = "Zhuhai Pantum Electronisc Co., Ltd",
+ [0x0AD3] = "SSV Software Systems GmbH",
+ [0x0AD2] = "Lautsprecher Teufel GmbH",
+ [0x0AD1] = "EAGLE KINGDOM TECHNOLOGIES LIMITED",
+ [0x0AD0] = "Nordic Strong ApS",
+ [0x0ACF] = "CACI Technologies",
+ [0x0ACE] = "KOBATA GAUGE MFG. CO., LTD.",
+ [0x0ACD] = "Visuallex Sport International Limited",
+ [0x0ACC] = "Nuvoton",
+ [0x0ACB] = "ise Individuelle Software und Elektronik GmbH",
+ [0x0ACA] = "Shenzhen CoolKit Technology Co., Ltd",
+ [0x0AC9] = "Swedlock AB",
+ [0x0AC8] = "Keepin Co., Ltd.",
+ [0x0AC7] = "Chengdu Aich Technology Co.,Ltd",
+ [0x0AC6] = "Barnes Group Inc.",
+ [0x0AC5] = "Flexoptix GmbH",
+ [0x0AC4] = "CODIUM",
+ [0x0AC3] = "Kenzen, Inc.",
+ [0x0AC2] = "RealMega Microelectronics technology (Shanghai) Co. Ltd.",
+ [0x0AC1] = "Shenzhen Jingxun Technology Co., Ltd.",
+ [0x0AC0] = "Omni-ID USA, INC.",
+ [0x0ABF] = "PAUL HARTMANN AG",
+ [0x0ABE] = "Robkoo Information & Technologies Co., Ltd.",
+ [0x0ABD] = "Inventas AS",
+ [0x0ABC] = "KCCS Mobile Engineering Co., Ltd.",
+ [0x0ABB] = "R-DAS, s.r.o.",
+ [0x0ABA] = "Open Bionics Ltd.",
+ [0x0AB9] = "STL",
+ [0x0AB8] = "Sens.ai Incorporated",
+ [0x0AB7] = "LogTag North America Inc.",
+ [0x0AB6] = "Xenter, Inc.",
+ [0x0AB5] = "Elstat Electronics Ltd.",
+ [0x0AB4] = "Ellenby Technologies, Inc.",
+ [0x0AB3] = "INNER RANGE PTY. LTD.",
+ [0x0AB2] = "TouchTronics, Inc.",
+ [0x0AB1] = "InVue Security Products Inc",
+ [0x0AB0] = "Visiontronic s.r.o.",
+ [0x0AAF] = "AIAIAI ApS",
+ [0x0AAE] = "PS Engineering, Inc.",
+ [0x0AAD] = "Adevo Consulting AB",
+ [0x0AAC] = "OSM HK Limited",
+ [0x0AAB] = "Anhui Listenai Co",
+ [0x0AAA] = "Computime International Ltd",
+ [0x0AA9] = "Spintly, Inc.",
+ [0x0AA8] = "Zencontrol Pty Ltd",
+ [0x0AA7] = "Urbanista AB",
+ [0x0AA6] = "Realityworks, inc.",
+ [0x0AA5] = "Shenzhen Uascent Technology Co., Ltd",
+ [0x0AA4] = "FAZEPRO LLC",
+ [0x0AA3] = "DIC Corporation",
+ [0x0AA2] = "Care Bloom, LLC",
+ [0x0AA1] = "LINCOGN TECHNOLOGY CO. LIMITED",
+ [0x0AA0] = "Loy Tec electronics GmbH",
+ [0x0A9F] = "ista International GmbH",
+ [0x0A9D] = "Canon Finetech Nisca Inc.",
+ [0x0A9C] = "Xi'an Fengyu Information Technology Co., Ltd.",
+ [0x0A9B] = "Eello LLC",
+ [0x0A9A] = "TEMKIN ASSOCIATES, LLC",
+ [0x0A99] = "Shanghai high-flying electronics technology Co.,Ltd",
+ [0x0A98] = "Foil, Inc.",
+ [0x0A97] = "SensTek",
+ [0x0A95] = "Pamex Inc.",
+ [0x0A94] = "OOBIK Inc.",
+ [0x0A93] = "GiPStech S.r.l.",
+ [0x0A92] = "Carestream Dental LLC",
+ [0x0A91] = "Monarch International Inc.",
+ [0x0A90] = "Shenzhen Grandsun Electronic Co.,Ltd.",
+ [0x0A8F] = "TOTO LTD.",
+ [0x0A8E] = "Perfect Company",
+ [0x0A8D] = "JCM TECHNOLOGIES S.A.",
+ [0x0A8C] = "DelpSys, s.r.o.",
+ [0x0A8B] = "SANlight GmbH",
+ [0x0A8A] = "HAINBUCH GMBH SPANNENDE TECHNIK",
+ [0x0A89] = "VusionGroup",
+ [0x0A88] = "PSA Peugeot Citroen",
+ [0x0A87] = "Shanghai Smart System Technology Co., Ltd",
+ [0x0A86] = "ALIZENT International",
+ [0x0A85] = "Snowball Technology Co., Ltd.",
+ [0x0A84] = "Greennote Inc,",
+ [0x0A83] = "Rivata, Inc.",
+ [0x0A82] = "Corsair",
+ [0x0A81] = "Universal Biosensors Pty Ltd",
+ [0x0A80] = "Cleer Limited",
+ [0x0A7F] = "Intuity Medical",
+ [0x0A7E] = "KEBA Handover Automation GmbH",
+ [0x0A7D] = "Freedman Electronics Pty Ltd",
+ [0x0A7C] = "WAFERLOCK",
+ [0x0A7B] = "UniqAir Oy",
+ [0x0A7A] = "Emlid Limited",
+ [0x0A79] = "Webasto SE",
+ [0x0A78] = "Shenzhen Sunricher Technology Limited",
+ [0x0A77] = "AXTRO PTE. LTD.",
+ [0x0A76] = "Synaptics Incorporated",
+ [0x0A75] = "Delta Cycle Corporation",
+ [0x0A74] = "MICROSON S.A.",
+ [0x0A73] = "Innohome Oy",
+ [0x0A72] = "Jumo GmbH & Co. KG",
+ [0x0A71] = "Senquip Pty Ltd",
+ [0x0A70] = "Ooma",
+ [0x0A6F] = "Warner Bros.",
+ [0x0A6E] = "Pac Sane Limited",
+ [0x0A6D] = "KUUKANJYOKIN Co.,Ltd.",
+ [0x0A6C] = "Pokkels",
+ [0x0A6B] = "Olympic Ophthalmics, Inc.",
+ [0x0A6A] = "Scribble Design Inc.",
+ [0x0A69] = "HAPPIEST BABY, INC.",
+ [0x0A68] = "Focus Ingenieria SRL",
+ [0x0A67] = "Beijing SuperHexa Century Technology CO. Ltd",
+ [0x0A65] = "Lytx, INC.",
+ [0x0A64] = "Geopal system A/S",
+ [0x0A62] = "MOKO TECHNOLOGY Ltd",
+ [0x0A61] = "Smart Parks B.V.",
+ [0x0A60] = "DATANG SEMICONDUCTOR TECHNOLOGY CO.,LTD",
+ [0x0A5F] = "stryker",
+ [0x0A5D] = "MG Energy Systems B.V.",
+ [0x0A5C] = "Innovative Design Labs Inc.",
+ [0x0A5B] = "LEGIC Identsystems AG",
+ [0x0A5A] = "Sontheim Industrie Elektronik GmbH",
+ [0x0A59] = "TourBuilt, LLC",
+ [0x0A58] = "Indigo Diabetes",
+ [0x0A57] = "Meizhou Guo Wei Electronics Co., Ltd",
+ [0x0A56] = "ambie",
+ [0x0A55] = "Inugo Systems Limited",
+ [0x0A54] = "SQL Technologies Corp.",
+ [0x0A53] = "KKM COMPANY LIMITED",
+ [0x0A52] = "Follow Sense Europe B.V.",
+ [0x0A51] = "CSIRO",
+ [0x0A50] = "Nextscape Inc.",
+ [0x0A4F] = "VANMOOF Global Holding B.V.",
+ [0x0A4E] = "Toytec Corporation",
+ [0x0A4D] = "Lockn Technologies Private Limited",
+ [0x0A4C] = "SiFli Technologies (shanghai) Inc.",
+ [0x0A4B] = "MistyWest Energy and Transport Ltd.",
+ [0x0A4A] = "Map Large, Inc.",
+ [0x0A49] = "Venture Research Inc.",
+ [0x0A48] = "JRC Mobility Inc.",
+ [0x0A47] = "The Wand Company Ltd",
+ [0x0A46] = "Beijing HC-Infinite Technology Limited",
+ [0x0A45] = "3SI Security Systems, Inc",
+ [0x0A44] = "Novidan, Inc.",
+ [0x0A43] = "Busch Systems International Inc.",
+ [0x0A42] = "Motionalysis, Inc.",
+ [0x0A41] = "OPEX Corporation",
+ [0x0A3F] = "Shenzhen Yopeak Optoelectronics Technology Co., Ltd.",
+ [0x0A3D] = "DELABIE",
+ [0x0A3C] = "Siteco GmbH",
+ [0x0A3B] = "Galileo Technology Limited",
+ [0x0A3A] = "Incotex Co. Ltd.",
+ [0x0A39] = "BLUETICKETING SRL",
+ [0x0A38] = "Bouffalo Lab (Nanjing)., Ltd.",
+ [0x0A37] = "2587702 Ontario Inc.",
+ [0x0A36] = "NGK SPARK PLUG CO., LTD.",
+ [0x0A35] = "safectory GmbH",
+ [0x0A34] = "Luxer Corporation",
+ [0x0A33] = "WMF AG",
+ [0x0A32] = "Pinnacle Technology, Inc.",
+ [0x0A31] = "Nevro Corp.",
+ [0x0A30] = "Air-Weigh",
+ [0x0A2F] = "Instamic, Inc.",
+ [0x0A2D] = "Shenzhen Feasycom Technology Co., Ltd.",
+ [0x0A2C] = "Shenzhen H&T Intelligent Control Co., Ltd",
+ [0x0A2B] = "PaceBait IVS",
+ [0x0A2A] = "Yamaha Corporation",
+ [0x0A29] = "Worthcloud Technology Co.,Ltd",
+ [0x0A28] = "NanoFlex Power Corporation",
+ [0x0A27] = "AYU DEVICES PRIVATE LIMITED",
+ [0x0A26] = "Louis Vuitton",
+ [0x0A25] = "Eran Financial Services LLC",
+ [0x0A24] = "Atmosic Technologies, Inc.",
+ [0x0A23] = "BIXOLON CO.,LTD",
+ [0x0A22] = "DAIICHIKOSHO CO., LTD.",
+ [0x0A21] = "Apollogic Sp. z o.o.",
+ [0x0A20] = "Jiangxi Innotech Technology Co., Ltd",
+ [0x0A1F] = "DeVilbiss Healthcare LLC",
+ [0x0A1E] = "CombiQ AB",
+ [0x0A1D] = "API-K",
+ [0x0A1C] = "INPEAK S.C.",
+ [0x0A1B] = "Embrava Pty Ltd",
+ [0x0A1A] = "Link Labs, Inc.",
+ [0x0A19] = "Maxell, Ltd.",
+ [0x0A18] = "Cambridge Animal Technologies Ltd",
+ [0x0A17] = "Plume Design Inc",
+ [0x0A16] = "RIDE VISION LTD",
+ [0x0A15] = "Syng Inc",
+ [0x0A14] = "CROXEL, INC.",
+ [0x0A13] = "Tec4med LifeScience GmbH",
+ [0x0A12] = "Dyson Technology Limited",
+ [0x0A11] = "Sensolus",
+ [0x0A10] = "SUBARU Corporation",
+ [0x0A0F] = "LIXIL Corporation",
+ [0x0A0E] = "Roland Corporation",
+ [0x0A0D] = "Blue Peacock GmbH",
+ [0x0A0C] = "Shanghai Yidian Intelligent Technology Co., Ltd.",
+ [0x0A0B] = "SIANA Systems",
+ [0x0A0A] = "Volan Technology Inc.",
+ [0x0A08] = "Oras Oy",
+ [0x0A07] = "Reflow Pty Ltd",
+ [0x0A06] = "Shanghai wuqi microelectronics Co.,Ltd",
+ [0x0A05] = "Southwire Company, LLC",
+ [0x0A04] = "Flosonics Medical",
+ [0x0A03] = "donutrobotics Co., Ltd.",
+ [0x0A02] = "Ayxon-Dynamics GmbH",
+ [0x0A01] = "Cleveron AS",
+ [0x0A00] = "Ampler Bikes OU",
+ [0x09FF] = "AIRSTAR",
+ [0x09FE] = "Lichtvision Engineering GmbH",
+ [0x09FD] = "Keep Technologies, Inc.",
+ [0x09FC] = "Confidex",
+ [0x09FB] = "TOITU CO., LTD.",
+ [0x09FA] = "Listen Technologies Corporation",
+ [0x09F9] = "Hangzhou Yaguan Technology Co. LTD",
+ [0x09F8] = "R.O. S.R.L.",
+ [0x09F7] = "SENSATEC Co., Ltd.",
+ [0x09F6] = "Mobile Action Technology Inc.",
+ [0x09F5] = "OKI Electric Industry Co., Ltd",
+ [0x09F4] = "Spectrum Technologies, Inc.",
+ [0x09F3] = "Beijing Zero Zero Infinity Technology Co.,Ltd.",
+ [0x09F2] = "Audeara Pty Ltd",
+ [0x09F1] = "OM Digital Solutions Corporation",
+ [0x09F0] = "WatchGas B.V.",
+ [0x09EF] = "Steinel Solutions AG",
+ [0x09EE] = "OJMAR SA",
+ [0x09ED] = "Sibel Inc.",
+ [0x09EC] = "Yukon advanced optics worldwide, UAB",
+ [0x09EB] = "KEAN ELECTRONICS PTY LTD",
+ [0x09EA] = "Athlos Oy",
+ [0x09E9] = "LumenRadio AB",
+ [0x09E8] = "Melange Systems Pvt. Ltd.",
+ [0x09E7] = "Kabushikigaisha HANERON",
+ [0x09E6] = "Masonite Corporation",
+ [0x09E5] = "Mobilogix",
+ [0x09E4] = "CPS AS",
+ [0x09E3] = "Friday Home Aps",
+ [0x09E2] = "Wuhan Linptech Co.,Ltd.",
+ [0x09E1] = "Tag-N-Trac Inc",
+ [0x09DF] = "Magnus Technology Sdn Bhd",
+ [0x09DE] = "JLD Technology Solutions, LLC",
+ [0x09DD] = "Innoware Development AB",
+ [0x09DC] = "AON2 Ltd.",
+ [0x09DB] = "Bionic Avionics Inc.",
+ [0x09DA] = "Nagravision SA",
+ [0x09D9] = "VivoSensMedical GmbH",
+ [0x09D8] = "Synergy Tecnologia em Sistemas Ltda",
+ [0x09D7] = "Coyotta",
+ [0x09D6] = "EAR TEKNIK ISITME VE ODIOMETRI CIHAZLARI SANAYI VE TICARET ANONIM SIRKETI",
+ [0x09D5] = "GEAR RADIO ELECTRONICS CORP.",
+ [0x09D4] = "ORBIS Inc.",
+ [0x09D2] = "Temperature Sensitive Solutions Systems Sweden AB",
+ [0x09D1] = "ABLEPAY TECHNOLOGIES AS",
+ [0x09D0] = "Chess Wise B.V.",
+ [0x09CF] = "BlueStreak IoT, LLC",
+ [0x09CE] = "Julius Blum GmbH",
+ [0x09CC] = "Senso4s d.o.o.",
+ [0x09CB] = "Hx Engineering, LLC",
+ [0x09CA] = "Mobitrace",
+ [0x09C9] = "CrowdGlow Ltd",
+ [0x09C8] = "XUNTONG",
+ [0x09C7] = "Combustion, LLC",
+ [0x09C6] = "Honor Device Co., Ltd.",
+ [0x09C5] = "HungYi Microelectronics Co.,Ltd.",
+ [0x09C4] = "UVISIO",
+ [0x09C3] = "JAPAN TOBACCO INC.",
+ [0x09C2] = "Universal Audio, Inc.",
+ [0x09C1] = "Rosewill",
+ [0x09C0] = "AnotherBrain inc.",
+ [0x09BF] = "Span.IO, Inc.",
+ [0x09BE] = "Vessel Ltd.",
+ [0x09BD] = "Centre Suisse d'Electronique et de Microtechnique SA",
+ [0x09BC] = "Aerosens LLC",
+ [0x09BB] = "SkyStream Corporation",
+ [0x09BA] = "Elimo Engineering Ltd",
+ [0x09B9] = "SAVOY ELECTRONIC LIGHTING",
+ [0x09B8] = "PlayerData Limited",
+ [0x09B7] = "Bout Labs, LLC",
+ [0x09B6] = "Pegasus Technologies, Inc.",
+ [0x09B5] = "AUTEC Gesellschaft fuer Automationstechnik mbH",
+ [0x09B4] = "PentaLock Aps.",
+ [0x09B3] = "BlueX Microelectronics Corp Ltd.",
+ [0x09B2] = "DYPHI",
+ [0x09B1] = "BLINQY",
+ [0x09B0] = "Deublin Company, LLC",
+ [0x09AF] = "ifLink Open Community",
+ [0x09AE] = "Pozyx NV",
+ [0x09AD] = "Narhwall Inc.",
+ [0x09AC] = "Ambiq",
+ [0x09AB] = "DashLogic, Inc.",
+ [0x09AA] = "PHOTODYNAMIC INCORPORATED",
+ [0x09A9] = "Nippon Ceramic Co.,Ltd.",
+ [0x09A8] = "KHN Solutions LLC",
+ [0x09A7] = "Paybuddy ApS",
+ [0x09A6] = "BEIJING ELECTRIC VEHICLE CO.,LTD",
+ [0x09A5] = "Security Enhancement Systems, LLC",
+ [0x09A4] = "KUMHO ELECTRICS, INC",
+ [0x09A3] = "ARDUINO SA",
+ [0x09A2] = "ENGAGENOW DATA SCIENCES PRIVATE LIMITED",
+ [0x09A1] = "VOS Systems, LLC",
+ [0x09A0] = "Proof Diagnostics, Inc.",
+ [0x099F] = "Koya Medical, Inc.",
+ [0x099E] = "Step One Limited",
+ [0x099D] = "YKK AP Inc.",
+ [0x099C] = "deister electronic GmbH",
+ [0x099B] = "Sendum Wireless Corporation",
+ [0x099A] = "New Audio LLC",
+ [0x0999] = "eTactica ehf",
+ [0x0998] = "Pixie Dust Technologies, Inc.",
+ [0x0997] = "NextMind",
+ [0x0996] = "C. & E. Fein GmbH",
+ [0x0995] = "Bronkhorst High-Tech B.V.",
+ [0x0994] = "VT42 Pty Ltd",
+ [0x0993] = "Absolute Audio Labs B.V.",
+ [0x0992] = "Big Kaiser Precision Tooling Ltd",
+ [0x0991] = "Telenor ASA",
+ [0x0990] = "Anton Paar GmbH",
+ [0x098F] = "Aktiebolaget Regin",
+ [0x098E] = "ADVEEZ",
+ [0x098C] = "bGrid B.V.",
+ [0x098B] = "Mequonic Engineering, S.L.",
+ [0x098A] = "Biovigil",
+ [0x0989] = "WIKA Alexander Wiegand SE & Co.KG",
+ [0x0988] = "BHM-Tech Produktionsgesellschaft m.b.H",
+ [0x0987] = "TSE BRAKES, INC.",
+ [0x0986] = "Cello Hill, LLC",
+ [0x0985] = "Lumos Health Inc.",
+ [0x0984] = "TeraTron GmbH",
+ [0x0983] = "Feedback Sports LLC",
+ [0x0982] = "ELPRO-BUCHS AG",
+ [0x0981] = "Bernard Krone Holding SE & Co.KG",
+ [0x0980] = "DEKRA TESTING AND CERTIFICATION, S.A.U.",
+ [0x097F] = "ISEMAR S.R.L.",
+ [0x097E] = "SonicSensory Inc",
+ [0x097D] = "CLB B.V.",
+ [0x097C] = "Thorley Industries, LLC",
+ [0x097B] = "CTEK Sweden AB",
+ [0x097A] = "CORE CORPORATION",
+ [0x0979] = "BIOTRONIK SE & Co. KG",
+ [0x0978] = "ZifferEins GmbH & Co. KG",
+ [0x0977] = "TOYOTA motor corporation",
+ [0x0976] = "Fauna Audio GmbH",
+ [0x0975] = "BlueIOT(Beijing) Technology Co.,Ltd",
+ [0x0974] = "ABEYE",
+ [0x0973] = "Popit Oy",
+ [0x0972] = 'Closed Joint Stock Company "Zavod Flometr" ("Zavod Flometr" CJSC)',
+ [0x0971] = "GA",
+ [0x0970] = "IBA Dosimetry GmbH",
+ [0x096F] = "Lund Motion Products, Inc.",
+ [0x096E] = "Band Industries, inc.",
+ [0x096D] = "Gunwerks, LLC",
+ [0x096C] = "9374-7319 Quebec inc",
+ [0x096B] = "Guide ID B.V.",
+ [0x096A] = "dricos, Inc.",
+ [0x0969] = "Woan Technology (Shenzhen) Co., Ltd.",
+ [0x0968] = "Actev Motors, Inc.",
+ [0x0967] = "Neo Materials and Consulting Inc.",
+ [0x0966] = "PointGuard, LLC",
+ [0x0965] = "Asahi Kasei Corporation",
+ [0x0964] = "Countrymate Technology Limited",
+ [0x0963] = "Moonbird BV",
+ [0x0962] = "GL Solutions K.K.",
+ [0x0961] = "Linkura AB",
+ [0x0960] = "Sena Technologies Inc.",
+ [0x095F] = "NUANCE HEARING LTD",
+ [0x095E] = "BioEchoNet inc.",
+ [0x095D] = "Electronic Theatre Controls",
+ [0x095C] = "LogiLube, LLC",
+ [0x095B] = "Lismore Instruments Limited",
+ [0x0959] = "HerdDogg, Inc",
+ [0x0958] = "ZTE Corporation",
+ [0x0957] = "Ohsung Electronics",
+ [0x0956] = "Kerlink",
+ [0x0955] = "Breville Group",
+ [0x0954] = "Julbo",
+ [0x0953] = "LogiLube, LLC",
+ [0x0952] = "Apptricity Corporation",
+ [0x0951] = "PPRS",
+ [0x0950] = "Capetech",
+ [0x094F] = 'Limited Liability Company "Mikrotikls"',
+ [0x094E] = "PassiveBolt, Inc.",
+ [0x094D] = "tkLABS INC.",
+ [0x094C] = "GimmiSys GmbH",
+ [0x094B] = "Kindeva Drug Delivery L.P.",
+ [0x094A] = "Zwift, Inc.",
+ [0x0949] = "Metronom Health Europe",
+ [0x0948] = "Wearable Link Limited",
+ [0x0947] = "First Light Technologies Ltd.",
+ [0x0946] = "AMC International Alfa Metalcraft Corporation AG",
+ [0x0945] = "Globe (Jiangsu) Co., Ltd",
+ [0x0944] = "Agitron d.o.o.",
+ [0x0942] = "TRANSSION HOLDINGS LIMITED",
+ [0x0941] = "Rivian Automotive, LLC",
+ [0x0940] = "Hero Workout GmbH",
+ [0x093F] = "JEPICO Corporation",
+ [0x093E] = "Catalyft Labs, Inc.",
+ [0x093D] = "Adolf Wuerth GmbH & Co KG",
+ [0x093C] = "Xenoma Inc.",
+ [0x093B] = "ENSESO LLC",
+ [0x093A] = "LinkedSemi Microelectronics (Xiamen) Co., Ltd",
+ [0x0939] = "ASTEM Co.,Ltd.",
+ [0x0938] = "Henway Technologies, LTD.",
+ [0x0937] = "RealThingks GmbH",
+ [0x0936] = "Elekon AG",
+ [0x0935] = "Reconnect, Inc.",
+ [0x0934] = "KiteSpring Inc.",
+ [0x0933] = "SRAM",
+ [0x0932] = "BarVision, LLC",
+ [0x0931] = "BREATHINGS Co., Ltd.",
+ [0x0930] = "James Walker RotaBolt Limited",
+ [0x092F] = "C.O.B.O. SpA",
+ [0x092E] = "PS GmbH",
+ [0x092D] = "Leggett & Platt, Incorporated",
+ [0x092C] = "PCI Private Limited",
+ [0x092B] = "TekHome",
+ [0x092A] = "Sappl Verwaltungs- und Betriebs GmbH",
+ [0x0929] = "Qingdao Haier Technology Co., Ltd.",
+ [0x0928] = "AiRISTA",
+ [0x0927] = "ROOQ GmbH",
+ [0x0926] = "Gooligum Technologies Pty Ltd",
+ [0x0925] = "Yukai Engineering Inc.",
+ [0x0924] = "Fundacion Tecnalia Research and Innovation",
+ [0x0923] = "JSB TECH PTE LTD",
+ [0x0922] = "Shanghai MXCHIP Information Technology Co., Ltd.",
+ [0x0921] = "KAHA PTE. LTD.",
+ [0x091F] = "Myzee Technology",
+ [0x091E] = "Melbot Studios, Sociedad Limitada",
+ [0x091D] = "Innokind, Inc.",
+ [0x091C] = "Oblamatik AG",
+ [0x091B] = "Luminostics, Inc.",
+ [0x091A] = "Albertronic BV",
+ [0x0919] = "NO SMD LIMITED",
+ [0x0918] = "Technosphere Labs Pvt. Ltd.",
+ [0x0917] = "ASR Microelectronics(ShenZhen)Co., Ltd.",
+ [0x0916] = "Ambient Sensors LLC",
+ [0x0915] = "Honda Motor Co., Ltd.",
+ [0x0914] = "INEO-SENSE",
+ [0x0913] = "Braveheart Wireless, Inc.",
+ [0x0912] = "Nerbio Medical Software Platforms Inc",
+ [0x0911] = "Douglas Lighting Controls Inc.",
+ [0x0910] = "ASR Microelectronics (Shanghai) Co., Ltd.",
+ [0x090F] = "VC Inc.",
+ [0x090E] = "OPTIMUSIOT TECH LLP",
+ [0x090D] = "IOT Invent GmbH",
+ [0x090C] = "Radiawave Technologies Co.,Ltd.",
+ [0x090B] = "EMBR labs, INC",
+ [0x090A] = "Zhuhai Hoksi Technology CO.,LTD",
+ [0x0909] = "70mai Co.,Ltd.",
+ [0x0908] = "Pinpoint Innovations Limited",
+ [0x0907] = "User Hello, LLC",
+ [0x0906] = "Scope Logistical Solutions",
+ [0x0905] = "Yandex Services AG",
+ [0x0904] = "SUNCORPORATION",
+ [0x0903] = "DATAMARS, Inc.",
+ [0x0902] = "TSC Auto-ID Technology Co., Ltd.",
+ [0x0901] = "Lucimed",
+ [0x0900] = "Beijing Zizai Technology Co., LTD.",
+ [0x08FF] = "Plastimold Products, Inc",
+ [0x08FE] = "Ketronixs Sdn Bhd",
+ [0x08FD] = "BioIntelliSense, Inc.",
+ [0x08FC] = "Hill-Rom",
+ [0x08FB] = "Darkglass Electronics Oy",
+ [0x08FA] = "Troo Corporation",
+ [0x08F9] = "Spacelabs Medical Inc.",
+ [0x08F8] = "instagrid GmbH",
+ [0x08F7] = "MTD Products Inc & Affiliates",
+ [0x08F6] = "Dermal Photonics Corporation",
+ [0x08F5] = "Tymtix Technologies Private Limited",
+ [0x08F4] = "Kodimo Technologies Company Limited",
+ [0x08F3] = "PSP - Pauli Services & Products GmbH",
+ [0x08F2] = "Microoled",
+ [0x08F1] = "The L.S. Starrett Company",
+ [0x08F0] = "Joovv, Inc.",
+ [0x08EF] = "Cumulus Digital Systems, Inc",
+ [0x08EE] = "Askey Computer Corp.",
+ [0x08ED] = "IMI Hydronic Engineering International SA",
+ [0x08EC] = "Denso Corporation",
+ [0x08EB] = "Beijing Big Moment Technology Co., Ltd.",
+ [0x08EA] = "COWBELL ENGINEERING CO.,LTD.",
+ [0x08E9] = "Taiwan Intelligent Home Corp.",
+ [0x08E8] = "Naonext",
+ [0x08E7] = "Barrot Technology Co.,Ltd.",
+ [0x08E6] = "Eneso Tecnologia de Adaptacion S.L.",
+ [0x08E5] = "Crowd Connected Ltd",
+ [0x08E4] = "Rashidov ltd",
+ [0x08E3] = "Republic Wireless, Inc.",
+ [0x08E2] = "Shenzhen Simo Technology co. LTD",
+ [0x08E1] = "KOZO KEIKAKU ENGINEERING Inc.",
+ [0x08E0] = "Philia Technology",
+ [0x08DF] = "IRIS OHYAMA CO.,LTD.",
+ [0x08DE] = "TE Connectivity Corporation",
+ [0x08DD] = "code-Q",
+ [0x08DC] = "SHENZHEN AUKEY E BUSINESS CO., LTD",
+ [0x08DB] = "Tertium Technology",
+ [0x08DA] = "Miridia Technology Incorporated",
+ [0x08D9] = "Pointr Labs Limited",
+ [0x08D8] = "WARES",
+ [0x08D7] = "Inovonics Corp",
+ [0x08D5] = "KEYes",
+ [0x08D4] = "ADATA Technology Co., LTD.",
+ [0x08D3] = "Novel Bits, LLC",
+ [0x08D2] = "Virscient Limited",
+ [0x08D1] = "Sensovium Inc.",
+ [0x08D0] = "ESTOM Infotech Kft.",
+ [0x08CF] = "betternotstealmybike UG (with limited liability)",
+ [0x08CE] = "ZIMI CORPORATION",
+ [0x08CD] = "ifly",
+ [0x08CC] = "TGM TECHNOLOGY CO., LTD.",
+ [0x08CB] = "JT INNOVATIONS LIMITED",
+ [0x08CA] = "Nubia Technology Co.,Ltd.",
+ [0x08C9] = "Noventa AG",
+ [0x08C8] = "Liteboxer Technologies Inc.",
+ [0x08C7] = "Monadnock Systems Ltd.",
+ [0x08C6] = "Integra Optics Inc",
+ [0x08C5] = "J. Wagner GmbH",
+ [0x08C3] = "CHIPOLO d.o.o.",
+ [0x08C2] = "Lindinvent AB",
+ [0x08C1] = "Rayden.Earth LTD",
+ [0x08C0] = "Accent Advanced Systems SLU",
+ [0x08BF] = "SIRC Co., Ltd.",
+ [0x08BE] = "ubisys technologies GmbH",
+ [0x08BD] = "bf1systems limited",
+ [0x08BC] = "Prevayl Limited",
+ [0x08BB] = "Tokai-rika co.,ltd.",
+ [0x08BA] = "HYPER ICE, INC.",
+ [0x08B9] = "U-Shin Ltd.",
+ [0x08B8] = "Check Technology Solutions LLC",
+ [0x08B7] = "ABB Inc",
+ [0x08B6] = "Boehringer Ingelheim Vetmedica GmbH",
+ [0x08B5] = "TransferFi",
+ [0x08B4] = "Sengled Co., Ltd.",
+ [0x08B3] = "IONIQ Skincare GmbH & Co. KG",
+ [0x08B2] = "PF SCHWEISSTECHNOLOGIE GMBH",
+ [0x08B1] = "CORE|vision BV",
+ [0x08B0] = "Trivedi Advanced Technologies LLC",
+ [0x08AF] = "Polidea Sp. z o.o.",
+ [0x08AE] = "Moticon ReGo AG",
+ [0x08AD] = "Kayamatics Limited",
+ [0x08AC] = "Topre Corporation",
+ [0x08AB] = "Coburn Technology, LLC",
+ [0x08AA] = "SZ DJI TECHNOLOGY CO.,LTD",
+ [0x08A9] = "Fraunhofer IIS",
+ [0x08A8] = "Shanghai Kfcube Inc",
+ [0x08A7] = "TGR 1.618 Limited",
+ [0x08A6] = "Intelligenceworks Inc.",
+ [0x08A4] = "Realme Chongqing Mobile Telecommunications Corp., Ltd.",
+ [0x08A3] = "Hoffmann SE",
+ [0x08A2] = "Epic Systems Co., Ltd.",
+ [0x08A1] = "EXEO TECH CORPORATION",
+ [0x08A0] = "Aclara Technologies LLC",
+ [0x089F] = "Witschi Electronic Ltd",
+ [0x089E] = "i-SENS, inc.",
+ [0x089D] = "J-J.A.D.E. Enterprise LLC",
+ [0x089C] = "Embedded Devices Co. Company",
+ [0x089B] = "Saucon Technologies",
+ [0x089A] = 'Private limited company "Teltonika"',
+ [0x0899] = "SFS unimarket AG",
+ [0x0898] = "Sensibo, Inc.",
+ [0x0897] = "Current Lighting Solutions LLC",
+ [0x0896] = "Nokian Renkaat Oyj",
+ [0x0895] = "Gimer medical",
+ [0x0894] = "EPIFIT",
+ [0x0893] = "Maytronics Ltd",
+ [0x0892] = "Ingenieurbuero Birnfeld UG (haftungsbeschraenkt)",
+ [0x0891] = "SmartWireless GmbH & Co. KG",
+ [0x0890] = "NICHIEI INTEC CO., LTD.",
+ [0x088F] = "Tait International Limited",
+ [0x088D] = "Soliton Systems K.K.",
+ [0x088C] = "GB Solution co.,Ltd",
+ [0x088B] = "Tricorder Arraay Technologies LLC",
+ [0x088A] = "sclak s.r.l.",
+ [0x0889] = "XANTHIO",
+ [0x0888] = "EnPointe Fencing Pty Ltd",
+ [0x0887] = "Hydro-Gear Limited Partnership",
+ [0x0886] = "Movella Technologies B.V.",
+ [0x0884] = "Controlid Industria, Comercio de Hardware e Servicos de Tecnologia Ltda",
+ [0x0883] = "Wintersteiger AG",
+ [0x0882] = "PSYONIC, Inc.",
+ [0x0881] = "Optalert",
+ [0x0880] = "imagiLabs AB",
+ [0x087F] = "Phillips Connect Technologies LLC",
+ [0x087E] = "1bar.net Limited",
+ [0x087D] = "Konftel AB",
+ [0x087C] = "Crosscan GmbH",
+ [0x087B] = "BYSTAMP",
+ [0x087A] = "ZRF, LLC",
+ [0x0879] = "MIZUNO Corporation",
+ [0x0878] = "The Chamberlain Group, Inc.",
+ [0x0877] = "Valtech",
+ [0x0876] = "SmartResQ ApS",
+ [0x0875] = "Berner International LLC",
+ [0x0874] = "Treegreen Limited",
+ [0x0873] = "Innophase Incorporated",
+ [0x0872] = "11 Health & Technologies Limited",
+ [0x0871] = "Dension Elektronikai Kft.",
+ [0x0870] = "Wyze Labs, Inc",
+ [0x086F] = "Trackunit A/S",
+ [0x086E] = "Vorwerk Elektrowerke GmbH & Co. KG",
+ [0x086D] = "Biometrika d.o.o.",
+ [0x086C] = "Revvo Technologies, Inc.",
+ [0x086B] = "Pacific Track, LLC",
+ [0x086A] = "Odic Incorporated",
+ [0x0869] = "EVVA Sicherheitstechnologie GmbH",
+ [0x0868] = "WIOsense GmbH & Co. KG",
+ [0x0867] = "Western Digital Techologies, Inc.",
+ [0x0866] = "LAONZ Co.,Ltd",
+ [0x0865] = "Emergency Lighting Products Limited",
+ [0x0864] = "Rafaelmicro",
+ [0x0863] = "Yo-tronics Technology Co., Ltd.",
+ [0x0862] = "SmartDrive",
+ [0x0861] = "SmartSensor Labs Ltd",
+ [0x0860] = "Alflex Products B.V.",
+ [0x085E] = "Krog Systems LLC",
+ [0x085D] = "Guilin Zhishen Information Technology Co.,Ltd.",
+ [0x085C] = "ACOS CO.,LTD.",
+ [0x085B] = "Nisshinbo Micro Devices Inc.",
+ [0x085A] = "DAKATECH",
+ [0x0859] = "BlueUp",
+ [0x0858] = "SOUNDBOKS",
+ [0x0857] = "Parsyl Inc",
+ [0x0856] = "Canopy Growth Corporation",
+ [0x0855] = "Helios Sports, Inc.",
+ [0x0854] = "Tap Sound System",
+ [0x0853] = "Pektron Group Limited",
+ [0x0852] = "Cognosos, Inc.",
+ [0x0851] = "Subeca, Inc.",
+ [0x0850] = "Yealink (Xiamen) Network Technology Co.,LTD",
+ [0x084F] = "Embedded Fitness B.V.",
+ [0x084E] = "Carol Cole Company",
+ [0x084D] = "SafePort",
+ [0x084C] = "ORSO Inc.",
+ [0x084B] = "Biotechware SRL",
+ [0x084A] = "ARCOM",
+ [0x0849] = "Dopple Technologies B.V.",
+ [0x0848] = "JUJU JOINTS CANADA CORP.",
+ [0x0847] = "DNANUDGE LIMITED",
+ [0x0846] = "USound GmbH",
+ [0x0845] = "Dometic Corporation",
+ [0x0844] = "Pepperl + Fuchs GmbH",
+ [0x0843] = "FRAGRANCE DELIVERY TECHNOLOGIES LTD",
+ [0x0842] = "Tangshan HongJia electronic technology co., LTD.",
+ [0x0841] = "General Luminaire (Shanghai) Co., Ltd.",
+ [0x0840] = "Down Range Systems LLC",
+ [0x083F] = "D-Link Corp.",
+ [0x083E] = "Zorachka LTD",
+ [0x083D] = "Tokenize, Inc.",
+ [0x083C] = "BeerTech LTD",
+ [0x083B] = "Piaggio Fast Forward",
+ [0x083A] = "BPW Bergische Achsen Kommanditgesellschaft",
+ [0x0839] = "A puissance 3",
+ [0x0838] = "Etymotic Research, Inc.",
+ [0x0837] = "vivo Mobile Communication Co., Ltd.",
+ [0x0836] = "Bitwards Oy",
+ [0x0835] = "Canopy Growth Corporation",
+ [0x0834] = "RIKEN KEIKI CO., LTD.,",
+ [0x0833] = "Conneqtech B.V.",
+ [0x0832] = "Intermotive,Inc.",
+ [0x0831] = "Foxble, LLC",
+ [0x082F] = "Blippit AB",
+ [0x082E] = "ABB S.p.A.",
+ [0x082D] = "INCUS PERFORMANCE LTD.",
+ [0x082C] = "INGICS TECHNOLOGY CO., LTD.",
+ [0x082B] = "shenzhen fitcare electronics Co.,Ltd",
+ [0x082A] = "Mitutoyo Corporation",
+ [0x0829] = "HEXAGON METROLOGY DIVISION ROMER",
+ [0x0828] = "Shanghai Suisheng Information Technology Co., Ltd.",
+ [0x0827] = "Kickmaker",
+ [0x0826] = "Hyundai Motor Company",
+ [0x0825] = "CME PTE. LTD.",
+ [0x0824] = "8Power Limited",
+ [0x0823] = "Nexite Ltd",
+ [0x0822] = "adafruit industries",
+ [0x0821] = "INOVA Geophysical, Inc.",
+ [0x0820] = "Brilliant Home Technology, Inc.",
+ [0x081F] = "eSenseLab LTD",
+ [0x081E] = "iNFORM Technology GmbH",
+ [0x081D] = "Potrykus Holdings and Development LLC",
+ [0x081C] = "Bobrick Washroom Equipment, Inc.",
+ [0x081B] = "DIM3",
+ [0x081A] = "Shenzhen Conex",
+ [0x0819] = "Hunter Douglas Inc",
+ [0x0818] = "tatwah SA",
+ [0x0817] = "Wangs Alliance Corporation",
+ [0x0816] = "SPICA SYSTEMS LLC",
+ [0x0815] = "SKC Inc",
+ [0x0814] = "Ossur hf.",
+ [0x0813] = "Flextronics International USA Inc.",
+ [0x0812] = "Mstream Technologies., Inc.",
+ [0x0811] = "Becker Antriebe GmbH",
+ [0x0810] = "LECO Corporation",
+ [0x080F] = "Paradox Engineering SA",
+ [0x080E] = "TATTCOM LLC",
+ [0x080D] = "Azbil Co.",
+ [0x080C] = "Ingy B.V.",
+ [0x080B] = "Nanoleaf Canada Limited",
+ [0x080A] = "Altaneos",
+ [0x0809] = "Trulli Audio",
+ [0x0808] = "SISTEMAS KERN, SOCIEDAD ANÓMINA",
+ [0x0807] = "ECD Electronic Components GmbH Dresden",
+ [0x0806] = "TYRI Sweden AB",
+ [0x0805] = "Urbanminded Ltd",
+ [0x0804] = "Andon Health Co.,Ltd",
+ [0x0803] = "Domintell s.a.",
+ [0x0802] = "NantSound, Inc.",
+ [0x0801] = "CRONUS ELECTRONICS LTD",
+ [0x0800] = "Optek",
+ [0x07FF] = "maxon motor ltd.",
+ [0x07FE] = "BIROTA",
+ [0x07FD] = "JSK CO., LTD.",
+ [0x07FC] = "Renault SA",
+ [0x07FB] = "Access Co., Ltd",
+ [0x07FA] = "Klipsch Group, Inc.",
+ [0x07F9] = "Direct Communication Solutions, Inc.",
+ [0x07F8] = "quip NYC Inc.",
+ [0x07F7] = "Cesar Systems Ltd.",
+ [0x07F6] = "Shenzhen TonliScience and Technology Development Co.,Ltd",
+ [0x07F5] = "Byton North America Corporation",
+ [0x07F4] = "MEDIRLAB Orvosbiologiai Fejleszto Korlatolt Felelossegu Tarsasag",
+ [0x07F3] = "DIGISINE ENERGYTECH CO. LTD.",
+ [0x07F2] = "SERENE GROUP, INC",
+ [0x07F1] = "Zimi Innovations Pty Ltd",
+ [0x07F0] = "e-moola.com Pty Ltd",
+ [0x07EF] = "Aktiebolaget Sandvik Coromant",
+ [0x07EE] = "KidzTek LLC",
+ [0x07ED] = "Joule IQ, INC.",
+ [0x07EC] = "Frecce LLC",
+ [0x07EB] = "NOVABASE S.R.L.",
+ [0x07EA] = "ShapeLog, Inc.",
+ [0x07E9] = "Häfele GmbH & Co KG",
+ [0x07E8] = "Packetcraft, Inc.",
+ [0x07E7] = "Komfort IQ, Inc.",
+ [0x07E6] = "Waybeyond Limited",
+ [0x07E5] = "Minut, Inc.",
+ [0x07E4] = "Geeksme S.L.",
+ [0x07E3] = "Airoha Technology Corp.",
+ [0x07E2] = "Alfred Kaercher SE & Co. KG",
+ [0x07E1] = "Lucie Labs",
+ [0x07E0] = "Edifier International Limited",
+ [0x07DF] = "Snap-on Incorporated",
+ [0x07DE] = "Unlimited Engineering SL",
+ [0x07DD] = "Linear Circuits",
+ [0x07DC] = "ThingOS GmbH & Co KG",
+ [0x07DB] = "Remedee Labs",
+ [0x07DA] = "STARLITE Co., Ltd.",
+ [0x07D9] = "Micro-Design, Inc.",
+ [0x07D8] = "SOLUTIONS AMBRA INC.",
+ [0x07D7] = "Nanjing Qinheng Microelectronics Co., Ltd",
+ [0x07D6] = "ecobee Inc.",
+ [0x07D5] = "hoots classic GmbH",
+ [0x07D4] = "Kano Computing Limited",
+ [0x07D3] = "LIVNEX Co.,Ltd.",
+ [0x07D2] = "React Accessibility Limited",
+ [0x07D1] = "Shanghai Panchip Microelectronics Co., Ltd",
+ [0x07D0] = "Hangzhou Tuya Information Technology Co., Ltd",
+ [0x07CF] = "NeoSensory, Inc.",
+ [0x07CE] = "Shanghai Top-Chip Microelectronics Tech. Co., LTD",
+ [0x07CD] = "Smart Wave Technologies Canada Inc",
+ [0x07CC] = "Barnacle Systems Inc.",
+ [0x07CB] = "West Pharmaceutical Services, Inc.",
+ [0x07CA] = "Modul-System HH AB",
+ [0x07C9] = "Skullcandy, Inc.",
+ [0x07C8] = "WRLDS Creations AB",
+ [0x07C7] = "iaconicDesign Inc.",
+ [0x07C6] = "Bluenetics GmbH",
+ [0x07C5] = "June Life, Inc.",
+ [0x07C4] = "Johnson Health Tech NA",
+ [0x07C3] = "CIMTechniques, Inc.",
+ [0x07C2] = "Radinn AB",
+ [0x07C0] = "Biral AG",
+ [0x07BF] = "REGULA Ltd.",
+ [0x07BE] = "Axentia Technologies AB",
+ [0x07BD] = "Genedrive Diagnostics Ltd",
+ [0x07BC] = "KD CIRCUITS LLC",
+ [0x07BB] = "EPIC S.R.L.",
+ [0x07BA] = "Battery-Biz Inc.",
+ [0x07B9] = "Epona Biotec Limited",
+ [0x07B8] = "iSwip",
+ [0x07B7] = "ETABLISSEMENTS GEORGES RENAULT",
+ [0x07B6] = "Soundbrenner Limited",
+ [0x07B5] = "CRONO CHIP, S.L.",
+ [0x07B4] = "Hormann KG Antriebstechnik",
+ [0x07B3] = "2N TELEKOMUNIKACE a.s.",
+ [0x07B2] = "Moeco IOT Inc.",
+ [0x07B1] = "Thomas Dynamics, LLC",
+ [0x07B0] = "GV Concepts Inc.",
+ [0x07AF] = "Hong Kong Bouffalo Lab Limited",
+ [0x07AE] = "Aurea Solucoes Tecnologicas Ltda.",
+ [0x07AD] = "New H3C Technologies Co.,Ltd",
+ [0x07AC] = "LoupeDeck Oy",
+ [0x07AB] = "Granite River Solutions, Inc.",
+ [0x07AA] = "The Kroger Co.",
+ [0x07A9] = "Bruel & Kjaer Sound & Vibration",
+ [0x07A8] = "conbee GmbH",
+ [0x07A7] = "Zume, Inc.",
+ [0x07A6] = "Musen Connect, Inc.",
+ [0x07A5] = "RAB Lighting, Inc.",
+ [0x07A4] = "Xiamen Mage Information Technology Co., Ltd.",
+ [0x07A2] = "Roku, Inc.",
+ [0x07A1] = "Apollo Neuroscience, Inc.",
+ [0x07A0] = "Regent Beleuchtungskorper AG",
+ [0x079F] = "Pune Scientific LLP",
+ [0x079E] = "Smartloxx GmbH",
+ [0x079D] = "Digibale Pty Ltd",
+ [0x079C] = "Sky UK Limited",
+ [0x079B] = "CST ELECTRONICS (PROPRIETARY) LIMITED",
+ [0x079A] = "GuangDong Oppo Mobile Telecommunications Corp., Ltd.",
+ [0x0799] = "PlantChoir Inc.",
+ [0x0798] = "HoloKit, Inc.",
+ [0x0797] = "Water-i.d. GmbH",
+ [0x0796] = "StarLeaf Ltd",
+ [0x0795] = "GASTEC CORPORATION",
+ [0x0794] = "The Coca-Cola Company",
+ [0x0793] = "AEV spol. s r.o.",
+ [0x0792] = "Cricut, Inc.",
+ [0x0791] = "Scosche Industries, Inc.",
+ [0x0790] = "KOMPAN A/S",
+ [0x078F] = "Hanna Instruments, Inc.",
+ [0x078E] = "FUJIMIC NIIGATA, INC.",
+ [0x078D] = "Cybex GmbH",
+ [0x078C] = "MINIBREW HOLDING B.V",
+ [0x078B] = "Optikam Tech Inc.",
+ [0x078A] = "The Wildflower Foundation",
+ [0x0789] = "PCB Piezotronics, Inc.",
+ [0x0788] = "BubblyNet, LLC",
+ [0x0786] = "HLP Controls Pty Limited",
+ [0x0785] = "O2 Micro, Inc.",
+ [0x0784] = "audifon GmbH & Co. KG",
+ [0x0783] = "ESEMBER LIMITED LIABILITY COMPANY",
+ [0x0782] = "DeviceDrive AS",
+ [0x0781] = "Qingping Technology (Beijing) Co., Ltd.",
+ [0x0780] = "Finch Technologies Ltd.",
+ [0x077F] = "Glenview Software Corporation",
+ [0x077E] = "Sparkage Inc.",
+ [0x077D] = "Sensority, s.r.o.",
+ [0x077C] = "radius co., ltd.",
+ [0x077A] = "Niruha Systems Private Limited",
+ [0x0779] = "Loopshore Oy",
+ [0x0778] = "KOAMTAC INC.",
+ [0x0777] = "Cue",
+ [0x0776] = "Cyber Transport Control GmbH",
+ [0x0775] = "4eBusiness GmbH",
+ [0x0774] = "C-MAX Asia Limited",
+ [0x0773] = "Echoflex Solutions Inc.",
+ [0x0772] = "Thirdwayv Inc.",
+ [0x0771] = "Corvex Connected Safety",
+ [0x0770] = "InnoCon Medical ApS",
+ [0x076F] = "Successful Endeavours Pty Ltd",
+ [0x076E] = "WuQi technologies, Inc.",
+ [0x076D] = "Graesslin GmbH",
+ [0x076C] = "Noodle Technology inc",
+ [0x076B] = "Engineered Medical Technologies",
+ [0x076A] = "Dmac Mobile Developments, LLC",
+ [0x0769] = "Force Impact Technologies",
+ [0x0768] = "Peloton Interactive Inc.",
+ [0x0767] = "NITTO DENKO ASIA TECHNICAL CENTRE PTE. LTD.",
+ [0x0766] = "ART AND PROGRAM, INC.",
+ [0x0765] = "Voxx International",
+ [0x0764] = "WWZN Information Technology Company Limited",
+ [0x0763] = "PIKOLIN S.L.",
+ [0x0762] = "TerOpta Ltd",
+ [0x0761] = "Mantis Tech LLC",
+ [0x0760] = "Vimar SpA",
+ [0x075F] = "Remote Solution Co., LTD.",
+ [0x075E] = "Katerra Inc.",
+ [0x075D] = "RHOMBUS SYSTEMS, INC.",
+ [0x075C] = "Antitronics Inc.",
+ [0x075B] = "Smart Sensor Devices AB",
+ [0x075A] = "HARMAN CO.,LTD.",
+ [0x0759] = "Shanghai InGeek Cyber Security Co., Ltd.",
+ [0x0758] = "umanSense AB",
+ [0x0757] = "ELA Innovation",
+ [0x0756] = "Lumens For Less, Inc",
+ [0x0755] = "Brother Industries, Ltd",
+ [0x0754] = "Michael Parkin",
+ [0x0753] = "JLG Industries, Inc.",
+ [0x0752] = "Elatec GmbH",
+ [0x0751] = "Changsha JEMO IC Design Co.,Ltd",
+ [0x0750] = "Hamilton Professional Services of Canada Incorporated",
+ [0x074F] = "MEDIATECH S.R.L.",
+ [0x074E] = "EAGLE DETECTION SA",
+ [0x074D] = "Amtech Systems, LLC",
+ [0x074B] = "Sarvavid Software Solutions LLP",
+ [0x074A] = "Illusory Studios LLC",
+ [0x0749] = "DIAODIAO (Beijing) Technology Co., Ltd.",
+ [0x0748] = "GuangZhou KuGou Computer Technology Co.Ltd",
+ [0x0747] = "OR Technologies Pty Ltd",
+ [0x0746] = "Seitec Elektronik GmbH",
+ [0x0745] = "WIZNOVA, Inc.",
+ [0x0744] = "SOCOMEC",
+ [0x0743] = "Sanofi",
+ [0x0742] = "DML LLC",
+ [0x0741] = "MAC SRL",
+ [0x073F] = "Beijing Unisoc Technologies Co., Ltd.",
+ [0x073E] = "Bluepack S.R.L.",
+ [0x073D] = "Beijing Hao Heng Tian Tech Co., Ltd.",
+ [0x073B] = "Fantini Cosmi s.p.a.",
+ [0x073A] = "Chandler Systems Inc.",
+ [0x0739] = "Jiangsu Qinheng Co., Ltd.",
+ [0x0738] = "Glass Security Pte Ltd",
+ [0x0737] = "LLC Navitek",
+ [0x0736] = "Luna XIO, Inc.",
+ [0x0735] = "UpRight Technologies LTD",
+ [0x0734] = "DiUS Computing Pty Ltd",
+ [0x0733] = "Iguanavation, Inc.",
+ [0x0732] = "Dairy Tech, LLC",
+ [0x0731] = "ABLIC Inc.",
+ [0x0730] = "Wildlife Acoustics, Inc.",
+ [0x072F] = "OnePlus Electronics (Shenzhen) Co., Ltd.",
+ [0x072E] = "Open Platform Systems LLC",
+ [0x072D] = "Safera Oy",
+ [0x072C] = "GWA Hygiene GmbH",
+ [0x072B] = "Bitkey Inc.",
+ [0x072A] = "JMR embedded systems GmbH",
+ [0x0729] = "SwaraLink Technologies",
+ [0x0728] = "Eli Lilly and Company",
+ [0x0726] = "PHC Corporation",
+ [0x0725] = "Tedee Sp. z o.o.",
+ [0x0724] = "Guangzhou SuperSound Information Technology Co.,Ltd",
+ [0x0723] = "Ford Motor Company",
+ [0x0722] = "Xiamen Eholder Electronics Co.Ltd",
+ [0x0721] = "Clover Network, Inc.",
+ [0x0720] = "Oculeve, Inc.",
+ [0x071F] = "Dongguan Liesheng Electronic Co.Ltd",
+ [0x071E] = "DONGGUAN HELE ELECTRONICS CO., LTD",
+ [0x071D] = "exoTIC Systems",
+ [0x071C] = "F5 Sports, Inc",
+ [0x071B] = "Precor",
+ [0x071A] = "REVSMART WEARABLE HK CO LTD",
+ [0x0719] = "COREIOT PTY LTD",
+ [0x0718] = "IDIBAIX enginneering",
+ [0x0716] = "Altonics",
+ [0x0715] = "MBARC LABS Inc",
+ [0x0714] = "MindPeace Safety LLC",
+ [0x0713] = "Respiri Limited",
+ [0x0712] = "Bull Group Company Limited",
+ [0x0711] = "ABAX AS",
+ [0x0710] = "Audiodo AB",
+ [0x070F] = "California Things Inc.",
+ [0x070E] = "FiveCo Sarl",
+ [0x070D] = "SmartSnugg Pty Ltd",
+ [0x070C] = "Beijing Winner Microelectronics Co.,Ltd",
+ [0x070B] = "Element Products, Inc.",
+ [0x070A] = "Huf Hülsbeck & Fürst GmbH & Co. KG",
+ [0x0709] = "Carewear Corp.",
+ [0x0708] = "Be Interactive Co., Ltd",
+ [0x0707] = "Essity Hygiene and Health Aktiebolag",
+ [0x0706] = "Wernher von Braun Center for ASdvanced Research",
+ [0x0705] = "AB Electrolux",
+ [0x0704] = "JBX Designs Inc.",
+ [0x0703] = "Beijing Jingdong Century Trading Co., Ltd.",
+ [0x0702] = 'Akciju sabiedriba "SAF TEHNIKA"',
+ [0x0701] = "PAFERS TECH",
+ [0x0700] = "TraqFreq LLC",
+ [0x06FF] = "Redpine Signals Inc",
+ [0x06FE] = "Mahr GmbH",
+ [0x06FD] = "ESS Embedded System Solutions Inc.",
+ [0x06FC] = "Tom Communication Industrial Co.,Ltd.",
+ [0x06FB] = "Sartorius AG",
+ [0x06FA] = "Enequi AB",
+ [0x06F9] = "happybrush GmbH",
+ [0x06F8] = "BodyPlus Technology Co.,Ltd",
+ [0x06F7] = "WILKA Schliesstechnik GmbH",
+ [0x06F6] = "Vitulo Plus BV",
+ [0x06F5] = "Vigil Technologies Inc.",
+ [0x06F4] = "Touché Technology Ltd",
+ [0x06F3] = "Alfred International Inc.",
+ [0x06F2] = "Trapper Data AB",
+ [0x06F1] = "Shibutani Co., Ltd.",
+ [0x06F0] = "Chargy Technologies, SL",
+ [0x06EF] = "ALCARE Co., Ltd.",
+ [0x06ED] = "J Neades Ltd",
+ [0x06EC] = "Sigur",
+ [0x06EB] = "Houston Radar LLC",
+ [0x06EA] = "SafeLine Sweden AB",
+ [0x06E9] = "Zmartfun Electronics, Inc.",
+ [0x06E8] = "Almendo Technologies GmbH",
+ [0x06E7] = "VELUX A/S",
+ [0x06E6] = "NIHON DENGYO KOUSAKU",
+ [0x06E5] = "OPTEX CO.,LTD.",
+ [0x06E4] = "Aluna",
+ [0x06E3] = "Spinlock Ltd",
+ [0x06E2] = "Alango Technologies Ltd",
+ [0x06E1] = "Milestone AV Technologies LLC",
+ [0x06E0] = "Avaya Inc.",
+ [0x06DF] = "HLI Solutions Inc.",
+ [0x06DE] = "Navcast, Inc.",
+ [0x06DD] = "Intellithings Ltd.",
+ [0x06DC] = "Industrial Network Controls, LLC",
+ [0x06DB] = "Automatic Labs, Inc.",
+ [0x06DA] = "Zliide Technologies ApS",
+ [0x06D9] = "Shanghai Mountain View Silicon Co.,Ltd.",
+ [0x06D8] = "AW Company",
+ [0x06D7] = "FUBA Automotive Electronics GmbH",
+ [0x06D6] = "JCT Healthcare Pty Ltd",
+ [0x06D5] = "Sensirion AG",
+ [0x06D4] = "DYNAKODE TECHNOLOGY PRIVATE LIMITED",
+ [0x06D2] = "CeoTronics AG",
+ [0x06D1] = "Meyer Sound Laboratories, Incorporated",
+ [0x06D0] = "Etekcity Corporation",
+ [0x06CE] = "FIOR & GENTZ",
+ [0x06CD] = "DIG Corporation",
+ [0x06CC] = "Dongguan SmartAction Technology Co.,Ltd.",
+ [0x06CB] = "Dyeware, LLC",
+ [0x06CA] = "Shenzhen Zhongguang Infotech Technology Development Co., Ltd",
+ [0x06C9] = "MYLAPS B.V.",
+ [0x06C8] = "Storz & Bickel GmbH & Co. KG",
+ [0x06C7] = "Somatix Inc",
+ [0x06C6] = "Simm Tronic Limited",
+ [0x06C5] = "Urban Compass, Inc",
+ [0x06C4] = "Dream Labs GmbH",
+ [0x06C3] = "King I Electronics.Co.,Ltd",
+ [0x06C2] = "Measurlogic Inc.",
+ [0x06C1] = "Alarm.com Holdings, Inc",
+ [0x06C0] = "CAME S.p.A.",
+ [0x06BE] = "HitSeed Oy",
+ [0x06BD] = "ABB Oy",
+ [0x06BC] = "TWS Srl",
+ [0x06BB] = "Leaftronix Analogic Solutions Private Limited",
+ [0x06BA] = "Beaconzone Ltd",
+ [0x06B9] = "Beflex Inc.",
+ [0x06B8] = "ShadeCraft, Inc",
+ [0x06B7] = "iCOGNIZE GmbH",
+ [0x06B6] = "Sociometric Solutions, Inc.",
+ [0x06B5] = "Wabilogic Ltd.",
+ [0x06B4] = "Sencilion Oy",
+ [0x06B2] = "Tussock Innovation 2013 Limited",
+ [0x06B1] = "SimpliSafe, Inc.",
+ [0x06B0] = "BRK Brands, Inc.",
+ [0x06AF] = "Shoof Technologies",
+ [0x06AE] = "SenseQ Inc.",
+ [0x06AD] = "InnovaSea Systems Inc.",
+ [0x06AC] = "Ingchips Technology Co., Ltd.",
+ [0x06AB] = "HMS Industrial Networks AB",
+ [0x06AA] = "Produal Oy",
+ [0x06A9] = "Soundmax Electronics Limited",
+ [0x06A8] = "GD Midea Air-Conditioning Equipment Co., Ltd.",
+ [0x06A7] = "Chipsea Technologies (ShenZhen) Corp.",
+ [0x06A6] = "Roambee Corporation",
+ [0x06A5] = "TEKZITEL PTY LTD",
+ [0x06A4] = "LIMNO Co. Ltd.",
+ [0x06A3] = "Nymbus, LLC",
+ [0x06A2] = "Globalworx GmbH",
+ [0x06A1] = "Cardo Systems, Ltd",
+ [0x06A0] = "OBIQ Location Technology Inc.",
+ [0x069F] = "FlowMotion Technologies AS",
+ [0x069E] = "Delta Electronics, Inc.",
+ [0x069C] = "Noomi AB",
+ [0x069B] = "Dragonchip Limited",
+ [0x069A] = "Adero, Inc.",
+ [0x0699] = "RandomLab SAS",
+ [0x0698] = "Wood IT Security, LLC",
+ [0x0697] = "Stemco Products Inc",
+ [0x0696] = "Gunakar Private Limited",
+ [0x0695] = "Koki Holdings Co., Ltd.",
+ [0x0694] = "T&A Laboratories LLC",
+ [0x0693] = "Hach - Danaher",
+ [0x0692] = "Georg Fischer AG",
+ [0x0691] = "Curie Point AB",
+ [0x0690] = "Eccrine Systems, Inc.",
+ [0x068F] = "JRM Group Limited",
+ [0x068E] = "Razer Inc.",
+ [0x068B] = "Jiangsu Teranovo Tech Co., Ltd.",
+ [0x068A] = "Raytac Corporation",
+ [0x0689] = "Tacx b.v.",
+ [0x0688] = "Amsted Digital Solutions Inc.",
+ [0x0687] = "Cherry GmbH",
+ [0x0686] = "inQs Co., Ltd.",
+ [0x0685] = "Greenwald Industries",
+ [0x0684] = "Dermalapps, LLC",
+ [0x0683] = "Eltako GmbH",
+ [0x0682] = "Photron Limited",
+ [0x0681] = "Trade FIDES a.s.",
+ [0x0680] = "Mannkind Corporation",
+ [0x067F] = "NETGRID S.N.C. DI BISSOLI MATTEO, CAMPOREALE SIMONE, TOGNETTI FEDERICO",
+ [0x067D] = "Form Athletica Inc.",
+ [0x067C] = "Tile, Inc.",
+ [0x067B] = "I.FARM, INC.",
+ [0x067A] = "The Energy Conservatory, Inc.",
+ [0x0679] = "4iiii Innovations Inc.",
+ [0x0678] = "SABIK Offshore GmbH",
+ [0x0677] = "Innovation First, Inc.",
+ [0x0676] = "Expai Solutions Private Limited",
+ [0x0674] = "BeSpoon",
+ [0x0673] = "Innova Ideas Limited",
+ [0x0672] = "Kopi",
+ [0x0671] = "Buzz Products Ltd.",
+ [0x0670] = "Gema Switzerland GmbH",
+ [0x066F] = "Hug Technology Ltd",
+ [0x066E] = "Eurotronik Kranj d.o.o.",
+ [0x066D] = "Venso EcoSolutions AB",
+ [0x066C] = "Ztove ApS",
+ [0x066B] = "DewertOkin GmbH",
+ [0x066A] = "Brady Worldwide Inc.",
+ [0x0669] = "Livanova USA, Inc.",
+ [0x0668] = "Bleb Technology srl",
+ [0x0667] = "Spark Technology Labs Inc.",
+ [0x0666] = "WTO Werkzeug-Einrichtungen GmbH",
+ [0x0665] = "Pure International Limited",
+ [0x0664] = "RHA TECHNOLOGIES LTD",
+ [0x0663] = "Advanced Telemetry Systems, Inc.",
+ [0x0662] = "Particle Industries, Inc.",
+ [0x0661] = "Mode Lighting Limited",
+ [0x0660] = "RTC Industries, Inc.",
+ [0x065F] = "Ricoh Company Ltd",
+ [0x065E] = "Alo AB",
+ [0x065D] = "Panduit Corp.",
+ [0x065C] = "PixArt Imaging Inc.",
+ [0x065B] = "Sesam Solutions BV",
+ [0x065A] = "Marshall Group AB",
+ [0x0659] = "UnSeen Technologies Oy",
+ [0x0658] = "Payex Norge AS",
+ [0x0657] = "Meshtronix Limited",
+ [0x0656] = "ZhuHai AdvanPro Technology Company Limited",
+ [0x0655] = "Renishaw PLC",
+ [0x0654] = "Ledlenser GmbH & Co. KG",
+ [0x0653] = "Meggitt SA",
+ [0x0652] = "ITZ Innovations- und Technologiezentrum GmbH",
+ [0x0651] = "Stasis Labs, Inc.",
+ [0x0650] = "Coravin, Inc.",
+ [0x064F] = "Digital Matter Pty Ltd",
+ [0x064E] = "KRUXWorks Technologies Private Limited",
+ [0x064D] = "iLOQ Oy",
+ [0x064C] = "Zumtobel Group AG",
+ [0x064B] = "Scale-Tec, Ltd",
+ [0x064A] = "Open Research Institute, Inc.",
+ [0x0649] = "Ryeex Technology Co.,Ltd.",
+ [0x0648] = "Ultune Technologies",
+ [0x0647] = "MED-EL",
+ [0x0646] = "SGV Group Holding GmbH & Co. KG",
+ [0x0645] = "BM3",
+ [0x0644] = "Apogee Instruments",
+ [0x0643] = "makita corporation",
+ [0x0642] = "Bluetrum Technology Co.,Ltd",
+ [0x0641] = "Revenue Collection Systems FRANCE SAS",
+ [0x0640] = "Dish Network LLC",
+ [0x063F] = "LDL TECHNOLOGY",
+ [0x063E] = "The Indoor Lab, LLC",
+ [0x063D] = "Xradio Technology Co.,Ltd.",
+ [0x063C] = "Contec Medical Systems Co., Ltd.",
+ [0x063B] = "Kromek Group Plc",
+ [0x063A] = "Prolojik Limited",
+ [0x0639] = "Shenzhen Minew Technologies Co., Ltd.",
+ [0x0638] = "LX SOLUTIONS PTY LIMITED",
+ [0x0637] = "GiP Innovation Tools GmbH",
+ [0x0636] = "ELECTRONICA INTEGRAL DE SONIDO S.A.",
+ [0x0635] = "Crookwood",
+ [0x0634] = "Fanstel Corp",
+ [0x0633] = "Wangi Lai PLT",
+ [0x0632] = "Hugo Muller GmbH & Co KG",
+ [0x0631] = "Fortiori Design LLC",
+ [0x0630] = "Asthrea D.O.O.",
+ [0x062F] = "ONKYO Corporation",
+ [0x062E] = "Procept",
+ [0x062D] = "Vossloh-Schwabe Deutschland GmbH",
+ [0x062C] = "ASPion GmbH",
+ [0x062B] = "MinebeaMitsumi Inc.",
+ [0x0629] = "PHONEPE PVT LTD",
+ [0x0628] = "Ensto Oy",
+ [0x0627] = "WEG S.A.",
+ [0x0626] = "Amplifico",
+ [0x0625] = "Square Panda, Inc.",
+ [0x0624] = "Biovotion AG",
+ [0x0622] = "Beam Labs, LLC",
+ [0x0621] = "Noordung d.o.o.",
+ [0x0620] = "Forciot Oy",
+ [0x061F] = "Phrame Inc.",
+ [0x061E] = "Diamond Kinetics, Inc.",
+ [0x061D] = "SENS Innovation ApS",
+ [0x061C] = "Univations Limited",
+ [0x061B] = "silex technology, inc.",
+ [0x061A] = "R.W. Beckett Corporation",
+ [0x0619] = "Six Guys Labs, s.r.o.",
+ [0x0618] = "Audio-Technica Corporation",
+ [0x0617] = "WIZCONNECTED COMPANY LIMITED",
+ [0x0616] = "OS42 UG (haftungsbeschraenkt)",
+ [0x0615] = "INTER ACTION Corporation",
+ [0x0614] = "OnAsset Intelligence, Inc.",
+ [0x0613] = "Hans Dinslage GmbH",
+ [0x0612] = "Playfinity AS",
+ [0x0611] = "Beurer GmbH",
+ [0x0610] = "ADH GUARDIAN USA LLC",
+ [0x060F] = "Signify Netherlands B.V.",
+ [0x060E] = "Blueair AB",
+ [0x060D] = "TDK Corporation",
+ [0x060C] = "Vuzix Corporation",
+ [0x060B] = "Triax Technologies Inc",
+ [0x060A] = "IQAir AG",
+ [0x0609] = "BUCHI Labortechnik AG",
+ [0x0608] = "KeySafe-Cloud",
+ [0x0607] = "Rookery Technology Ltd",
+ [0x0606] = "John Deere",
+ [0x0605] = "FMW electronic Futterer u. Maier-Wolf OHG",
+ [0x0603] = "Fourth Evolution Inc",
+ [0x0602] = "Geberit International AG",
+ [0x0601] = "Schrader Electronics",
+ [0x0600] = "iRobot Corporation",
+ [0x05FF] = "Wellnomics Ltd",
+ [0x05FE] = "Niko nv",
+ [0x05FD] = "Innoseis",
+ [0x05FC] = "Masbando GmbH",
+ [0x05FB] = "Arblet Inc.",
+ [0x05FA] = "Konami Sports Life Co., Ltd.",
+ [0x05F9] = "Hagleitner Hygiene International GmbH",
+ [0x05F8] = "Anki Inc.",
+ [0x05F7] = "TRACMO, INC.",
+ [0x05F6] = "DPTechnics",
+ [0x05F5] = "GS TAG",
+ [0x05F4] = "Clearity, LLC",
+ [0x05F3] = "SeeScan",
+ [0x05F2] = "Try and E CO.,LTD.",
+ [0x05F1] = "The Linux Foundation",
+ [0x05F0] = "beken",
+ [0x05EF] = "SIKOM AS",
+ [0x05ED] = "Fuji Xerox Co., Ltd",
+ [0x05EC] = "Gycom Svenska AB",
+ [0x05EB] = "Bayerische Motoren Werke AG",
+ [0x05EA] = "ACS-Control-System GmbH",
+ [0x05E9] = "iconmobile GmbH",
+ [0x05E8] = "COWBOY",
+ [0x05E7] = "PressurePro",
+ [0x05E6] = "Motion Instruments Inc.",
+ [0x05E5] = "INEO ENERGY& SYSTEMS",
+ [0x05E4] = "Taiyo Yuden Co., Ltd",
+ [0x05E3] = "Elemental Machines, Inc.",
+ [0x05E2] = "stAPPtronics GmbH",
+ [0x05E1] = "Human, Incorporated",
+ [0x05E0] = "Viper Design LLC",
+ [0x05DF] = "VIRTUALCLINIC.DIRECT LIMITED",
+ [0x05DE] = "QT Medical INC.",
+ [0x05DD] = "essentim GmbH",
+ [0x05DC] = "Petronics Inc.",
+ [0x05DB] = "Avid Identification Systems, Inc.",
+ [0x05DA] = "Applied Neural Research Corp",
+ [0x05D9] = "Toyo Electronics Corporation",
+ [0x05D8] = "Farm Jenny LLC",
+ [0x05D7] = "modum.io AG",
+ [0x05D6] = "Zhuhai Jieli technology Co.,Ltd",
+ [0x05D5] = "TEGAM, Inc.",
+ [0x05D4] = "LAMPLIGHT Co., Ltd.",
+ [0x05D3] = "Acurable Limited",
+ [0x05D2] = "frogblue TECHNOLOGY GmbH",
+ [0x05D1] = "Lindab AB",
+ [0x05D0] = "Anova Applied Electronics",
+ [0x05CF] = "Biowatch SA",
+ [0x05CE] = "V-ZUG Ltd",
+ [0x05CD] = "RJ Brands LLC",
+ [0x05CC] = "WATTS ELECTRONICS",
+ [0x05CB] = "LucentWear LLC",
+ [0x05CA] = "MHL Custom Inc",
+ [0x05C9] = "TBS Electronics B.V.",
+ [0x05C8] = "SOMFY SAS",
+ [0x05C7] = "Lippert Components, INC",
+ [0x05C5] = "SELVE GmbH & Co. KG",
+ [0x05C4] = "Codecoup sp. z o.o. sp. k.",
+ [0x05C3] = "Runtime, Inc.",
+ [0x05C2] = "Grote Industries",
+ [0x05C1] = "P.I.Engineering",
+ [0x05C0] = "Nalu Medical, Inc.",
+ [0x05BF] = "Real-World-Systems Corporation",
+ [0x05BD] = "ULC Robotics Inc.",
+ [0x05BC] = "Leviton Mfg. Co., Inc.",
+ [0x05BB] = "Oxford Metrics plc",
+ [0x05BA] = "igloohome",
+ [0x05B9] = "Suzhou Pairlink Network Technology",
+ [0x05B8] = "Ambystoma Labs Inc.",
+ [0x05B7] = "Beijing Pinecone Electronics Co.,Ltd.",
+ [0x05B6] = "Elecs Industry Co.,Ltd.",
+ [0x05B5] = "verisilicon",
+ [0x05B4] = "White Horse Scientific ltd",
+ [0x05B3] = "Parabit Systems, Inc.",
+ [0x05B2] = "CAREL INDUSTRIES S.P.A.",
+ [0x05B1] = "Medallion Instrumentation Systems",
+ [0x05B0] = "NewTec GmbH",
+ [0x05AF] = "OV LOOP, INC.",
+ [0x05AE] = "CARMATE MFG.CO.,LTD",
+ [0x05AD] = "INIA",
+ [0x05AC] = "GoerTek Dynaudio Co., Ltd.",
+ [0x05AB] = "Nofence AS",
+ [0x05AA] = "Tramex Limited",
+ [0x05A9] = "Monidor",
+ [0x05A8] = "Tom Allebrandi Consulting",
+ [0x05A7] = "Sonos Inc",
+ [0x05A6] = "Telecon Mobile Limited",
+ [0x05A5] = "Kiiroo BV",
+ [0x05A4] = "O. E. M. Controls, Inc.",
+ [0x05A3] = "Axiomware Systems Incorporated",
+ [0x05A2] = "ADHERIUM(NZ) LIMITED",
+ [0x05A1] = "Shanghai Xiaoyi Technology Co.,Ltd.",
+ [0x05A0] = "Dream Devices Technologies Oy",
+ [0x059F] = "Fisher & Paykel Healthcare",
+ [0x059E] = "Polycom, Inc.",
+ [0x059D] = "Tandem Diabetes Care",
+ [0x059C] = "Macrogiga Electronics",
+ [0x059B] = "Dataflow Systems Limited",
+ [0x059A] = "Teledyne Lecroy, Inc.",
+ [0x0599] = "Lazlo326, LLC.",
+ [0x0598] = "rapitag GmbH",
+ [0x0597] = "RadioPulse Inc",
+ [0x0596] = "My Smart Blinds",
+ [0x0595] = "Inor Process AB",
+ [0x0594] = "Kohler Company",
+ [0x0593] = "Spaulding Clinical Research",
+ [0x0592] = "IZITHERM",
+ [0x0591] = "Viasat Group S.p.A.",
+ [0x0590] = "Pur3 Ltd",
+ [0x058F] = "HENDON SEMICONDUCTORS PTY LTD",
+ [0x058E] = "Meta Platforms Technologies, LLC",
+ [0x058D] = "Jungheinrich Aktiengesellschaft",
+ [0x058C] = "Fracarro Radioindustrie SRL",
+ [0x058B] = "Maxim Integrated Products",
+ [0x058A] = "START TODAY CO.,LTD.",
+ [0x0589] = "Star Technologies",
+ [0x0588] = "ALT-TEKNIK LLC",
+ [0x0587] = "Derichs GmbH",
+ [0x0586] = "LEGRAND",
+ [0x0585] = "Hearing Lab Technology",
+ [0x0584] = "Gira Giersiepen GmbH & Co. KG",
+ [0x0583] = "Code Blue Communications",
+ [0x0582] = "Breakwall Analytics, LLC",
+ [0x0581] = "LYS TECHNOLOGIES LTD",
+ [0x057F] = "Scuf Gaming International, LLC",
+ [0x057E] = "Beco, Inc",
+ [0x057D] = "Instinct Performance",
+ [0x057C] = "Toor Technologies LLC",
+ [0x057B] = "Duracell U.S. Operations Inc.",
+ [0x057A] = "OMNI Remotes",
+ [0x0578] = "Wellington Drive Technologies Ltd",
+ [0x0577] = "True Wearables, Inc.",
+ [0x0576] = "Globalstar, Inc.",
+ [0x0575] = "Integral Memroy Plc",
+ [0x0574] = "AFFORDABLE ELECTRONICS INC",
+ [0x0573] = "Lighting Science Group Corp.",
+ [0x0572] = "AntTail.com",
+ [0x0571] = "PSIKICK, INC.",
+ [0x0570] = "Consumer Sleep Solutions LLC",
+ [0x056F] = "BikeFinder AS",
+ [0x056E] = "VIZPIN INC.",
+ [0x056D] = "Redmond Industrial Group LLC",
+ [0x056C] = "Long Range Systems, LLC",
+ [0x056B] = "Rion Co., Ltd.",
+ [0x056A] = "Flipnavi Co.,Ltd.",
+ [0x0568] = "Bodyport Inc.",
+ [0x0567] = "Xiamen Everesports Goods Co., Ltd",
+ [0x0566] = "CORE TRANSPORT TECHNOLOGIES NZ LIMITED",
+ [0x0564] = "Beghelli Spa",
+ [0x0563] = "Steinel Vertrieb GmbH",
+ [0x0562] = "Thalmic Labs Inc.",
+ [0x0561] = "Finder S.p.A.",
+ [0x0560] = "Sarita CareTech APS",
+ [0x055F] = "PROTECH S.A.S. DI GIRARDI ANDREA & C.",
+ [0x055E] = "Hekatron Vertriebs GmbH",
+ [0x055D] = "Valve Corporation",
+ [0x055C] = "Lely",
+ [0x055B] = "FRANKLIN TECHNOLOGY INC",
+ [0x055A] = "CANDY HOUSE, Inc.",
+ [0x0559] = "Newcon Optik",
+ [0x0558] = "benegear, inc.",
+ [0x0557] = "Arwin Technology Limited",
+ [0x0556] = "Otodynamics Ltd",
+ [0x0555] = "KROHNE Messtechnik GmbH",
+ [0x0554] = "National Instruments",
+ [0x0553] = "Nintendo Co., Ltd.",
+ [0x0552] = "Avempace SARL",
+ [0x0551] = "Sylero",
+ [0x054F] = "Sinnoz",
+ [0x054E] = "FORTRONIK storitve d.o.o.",
+ [0x054D] = "Sensome",
+ [0x054C] = "Carefree Scott Fetzer Co Inc",
+ [0x054B] = "Advanced Electronic Designs, Inc.",
+ [0x054A] = "Linough Inc.",
+ [0x0549] = "Smart Technologies and Investment Limited",
+ [0x0548] = "Knick Elektronische Messgeraete GmbH & Co. KG",
+ [0x0547] = "LOGICDATA Electronic & Software Entwicklungs GmbH",
+ [0x0546] = "Apexar Technologies S.A.",
+ [0x0544] = "OrthoSensor, Inc.",
+ [0x0543] = "MIWA LOCK CO.,Ltd",
+ [0x0542] = "Mist Systems, Inc.",
+ [0x0541] = "Sharknet srl",
+ [0x0540] = "SilverPlus, Inc",
+ [0x053F] = "Silergy Corp",
+ [0x053E] = "CLIM8 LIMITED",
+ [0x053D] = "TESA SA",
+ [0x053C] = "Screenovate Technologies Ltd",
+ [0x053B] = "prodigy",
+ [0x053A] = "Savitech Corp.,",
+ [0x0539] = "OPPLE Lighting Co., Ltd",
+ [0x0538] = "Medela AG",
+ [0x0537] = "MetaLogics Corporation",
+ [0x0536] = "ZTR Control Systems LLC",
+ [0x0535] = "Smart Component Technologies Limited",
+ [0x0534] = "Frontiergadget, Inc.",
+ [0x0533] = "Nura Operations Pty Ltd",
+ [0x0532] = "CRESCO Wireless, Inc.",
+ [0x0531] = "D&M Holdings Inc.",
+ [0x0530] = "Adolene, Inc.",
+ [0x052F] = "Center ID Corp.",
+ [0x052E] = "LEDVANCE GmbH",
+ [0x052D] = "EXFO, Inc.",
+ [0x052C] = "Geosatis SA",
+ [0x052B] = "Novartis AG",
+ [0x052A] = "Keynes Controls Ltd",
+ [0x0529] = "Lumen UAB",
+ [0x0528] = "Lunera Lighting Inc.",
+ [0x0527] = "Albrecht JUNG",
+ [0x0526] = "Honeywell International Inc.",
+ [0x0525] = "HONGKONG NANO IC TECHNOLOGIES CO., LIMITED",
+ [0x0524] = "Hangzhou iMagic Technology Co., Ltd",
+ [0x0523] = "MTG Co., Ltd.",
+ [0x0522] = "NS Tech, Inc.",
+ [0x0521] = "IAI Corporation",
+ [0x0520] = "Target Corporation",
+ [0x051F] = "Setec Pty Ltd",
+ [0x051E] = "Detect Blue Limited",
+ [0x051D] = "OFF Line Co., Ltd.",
+ [0x051C] = "EDPS",
+ [0x051A] = "Leica Camera AG",
+ [0x0519] = "Tyto Life LLC",
+ [0x0518] = "MAMORIO.inc",
+ [0x0517] = "Amtronic Sverige AB",
+ [0x0516] = "Footmarks",
+ [0x0515] = "RB Controls Co., Ltd.",
+ [0x0514] = "FIBRO GmbH",
+ [0x0513] = "9974091 Canada Inc.",
+ [0x0512] = "Soprod SA",
+ [0x0511] = "Brookfield Equinox LLC",
+ [0x0510] = "UNI-ELECTRONICS, INC.",
+ [0x050F] = "Foundation Engineering LLC",
+ [0x050E] = "Yichip Microelectronics (Hangzhou) Co.,Ltd.",
+ [0x050D] = "TRSystems GmbH",
+ [0x050C] = "OSRAM GmbH",
+ [0x050B] = "Vibrissa Inc.",
+ [0x050A] = "Shake-on B.V.",
+ [0x0509] = "Garage Smart, Inc.",
+ [0x0508] = "Axes System sp. z o. o.",
+ [0x0507] = "Yellowcog",
+ [0x0506] = "Hager",
+ [0x0505] = "InPlay, Inc.",
+ [0x0504] = "PHYPLUS Inc",
+ [0x0503] = "Locoroll, Inc",
+ [0x0502] = "Specifi-Kali LLC",
+ [0x0501] = "Polaris IND",
+ [0x0500] = "Wiliot LTD.",
+ [0x04FF] = "Microsemi Corporation",
+ [0x04FE] = "Woosim Systems Inc.",
+ [0x04FD] = "Tapkey GmbH",
+ [0x04FC] = "SwingLync L. L. C.",
+ [0x04FB] = "Benchmark Drives GmbH & Co. KG",
+ [0x04FA] = "Androtec GmbH",
+ [0x04F9] = "Interactio",
+ [0x04F8] = "Convergence Systems Limited",
+ [0x04F7] = "Shenzhen Goodix Technology Co., Ltd",
+ [0x04F6] = "McLear Limited",
+ [0x04F5] = "Pirelli Tyre S.P.A.",
+ [0x04F4] = "ZanCompute Inc.",
+ [0x04F3] = "Cerevast Medical",
+ [0x04F2] = "InDreamer Techsol Private Limited",
+ [0x04F1] = "Theben AG",
+ [0x04F0] = "Kosi Limited",
+ [0x04EF] = "DaisyWorks, Inc",
+ [0x04EE] = "Auxivia",
+ [0x04EC] = "Motorola Solutions",
+ [0x04EB] = "Bird Home Automation GmbH",
+ [0x04EA] = "Pacific Bioscience Laboratories, Inc",
+ [0x04E9] = "Busch Jaeger Elektro GmbH",
+ [0x04E8] = "STABILO International",
+ [0x04E6] = "Smart Solution Technology, Inc.",
+ [0x04E5] = "Avack Oy",
+ [0x04E4] = "Woodenshark",
+ [0x04E3] = "Under Armour",
+ [0x04E1] = "REACTEC LIMITED",
+ [0x04E0] = "Guardtec, Inc.",
+ [0x04DF] = "Emerson Electric Co.",
+ [0x04DE] = "Lutron Electronics Co., Inc.",
+ [0x04DD] = "4MOD Technology",
+ [0x04DC] = "IOTTIVE (OPC) PRIVATE LIMITED",
+ [0x04DB] = "Engineered Audio, LLC.",
+ [0x04DA] = "Franceschi Marina snc",
+ [0x04D9] = "RM Acquisition LLC",
+ [0x04D8] = "FUJIFILM Corporation",
+ [0x04D7] = "Blincam, Inc.",
+ [0x04D6] = "LUGLOC LLC",
+ [0x04D5] = "Gooee Limited",
+ [0x04D4] = "TASKA PROSTHETICS LIMITED",
+ [0x04D3] = "Queercon, Inc",
+ [0x04D2] = "Anloq Technologies Inc.",
+ [0x04D1] = "KTS GmbH",
+ [0x04D0] = "Olympus Corporation",
+ [0x04CF] = "DOM Sicherheitstechnik GmbH & Co. KG",
+ [0x04CE] = "GOOOLED S.R.L.",
+ [0x04CD] = "Safetech Products LLC",
+ [0x04CB] = "Novo Nordisk A/S",
+ [0x04CA] = "Steiner-Optik GmbH",
+ [0x04C9] = "Thornwave Labs Inc",
+ [0x04C8] = "Shanghai Flyco Electrical Appliance Co., Ltd.",
+ [0x04C7] = "Svantek Sp. z o.o.",
+ [0x04C6] = "Insta GmbH",
+ [0x04C5] = "Seibert Williams Glass, LLC",
+ [0x04C4] = "TeAM Hutchins AB",
+ [0x04C3] = "Mantracourt Electronics Limited",
+ [0x04C2] = "Dmet Products Corp.",
+ [0x04C1] = "Sospitas, s.r.o.",
+ [0x04C0] = "Statsports International",
+ [0x04BF] = "VIT Initiative, LLC",
+ [0x04BE] = "Averos FZCO",
+ [0x04BD] = "AlbynMedical",
+ [0x04BC] = "Draegerwerk AG & Co. KGaA",
+ [0x04BB] = "Neatebox Ltd",
+ [0x04BA] = "Crestron Electronics, Inc.",
+ [0x04B9] = "CSR Building Products Limited",
+ [0x04B8] = "Soraa Inc.",
+ [0x04B7] = "Analog Devices, Inc.",
+ [0x04B6] = "Diagnoptics Technologies",
+ [0x04B5] = "Swiftronix AB",
+ [0x04B4] = "Inuheat Group AB",
+ [0x04B3] = "mobike (Hong Kong) Limited",
+ [0x04B2] = "The Shadow on the Moon",
+ [0x04B1] = "Kartographers Technologies Pvt. Ltd.",
+ [0x04AF] = "BIOROWER Handelsagentur GmbH",
+ [0x04AE] = "ERM Electronic Systems LTD",
+ [0x04AD] = "Shure Inc",
+ [0x04AC] = "Undagrid B.V.",
+ [0x04AB] = "Harbortronics, Inc.",
+ [0x04AA] = "LINKIO SAS",
+ [0x04A8] = "BioTex, Inc.",
+ [0x04A7] = "Dallas Logic Corporation",
+ [0x04A6] = "Vinetech Co., Ltd",
+ [0x04A5] = "Guangzhou FiiO Electronics Technology Co.,Ltd",
+ [0x04A4] = "Herbert Waldmann GmbH & Co. KG",
+ [0x04A3] = "GT-tronics HK Ltd",
+ [0x04A2] = "ovrEngineered, LLC",
+ [0x049F] = "Popper Pay AB",
+ [0x049E] = "AND!XOR LLC",
+ [0x049D] = "Uhlmann & Zacher GmbH",
+ [0x049C] = "DyOcean",
+ [0x049B] = "nVisti, LLC",
+ [0x049A] = "Situne AS",
+ [0x0499] = "Ruuvi Innovations Ltd.",
+ [0x0498] = "METER Group, Inc. USA",
+ [0x0497] = "Cochlear Limited",
+ [0x0496] = "Polymorphic Labs LLC",
+ [0x0494] = "SENNHEISER electronic GmbH & Co. KG",
+ [0x0493] = "Lynxemi Pte Ltd",
+ [0x0492] = "ADC Technology, Inc.",
+ [0x0491] = "SOREX - Wireless Solutions GmbH",
+ [0x0490] = "Matting AB",
+ [0x048F] = "BlueKitchen GmbH",
+ [0x048E] = "Companion Medical, Inc.",
+ [0x048D] = "S-Labs Sp. z o.o.",
+ [0x048C] = "Vectronix AG",
+ [0x048A] = "Taelek Oy",
+ [0x0489] = "Igarashi Engineering",
+ [0x0488] = "Automotive Data Solutions Inc",
+ [0x0487] = "Centrica Connected Home",
+ [0x0486] = "DEV TECNOLOGIA INDUSTRIA, COMERCIO E MANUTENCAO DE EQUIPAMENTOS LTDA. - ME",
+ [0x0485] = "SKIDATA AG",
+ [0x0484] = "Revol Technologies Inc",
+ [0x0482] = "POS Tuning Udo Vosshenrich GmbH & Co. KG",
+ [0x0481] = "Quintrax Limited",
+ [0x047F] = "Pro-Mark, Inc.",
+ [0x047E] = "OurHub Dev IvS",
+ [0x047D] = "Occly LLC",
+ [0x047C] = "POWERMAT LTD",
+ [0x047B] = "MIYOSHI ELECTRONICS CORPORATION",
+ [0x047A] = "Sinosun Technology Co., Ltd.",
+ [0x0479] = "mywerk system GmbH",
+ [0x0478] = "FarSite Communications Limited",
+ [0x0477] = "Blue Spark Technologies",
+ [0x0475] = "Icom inc.",
+ [0x0474] = "iApartment co., ltd.",
+ [0x0473] = "Steelcase, Inc.",
+ [0x0472] = "Control-J Pty Ltd",
+ [0x0471] = "TiVo Corp",
+ [0x0470] = "iDesign s.r.l.",
+ [0x046F] = "Develco Products A/S",
+ [0x046E] = "Pambor Ltd.",
+ [0x046D] = "BEGA Gantenbrink-Leuchten KG",
+ [0x046C] = "Qingdao Realtime Technology Co., Ltd.",
+ [0x046B] = "PMD Solutions",
+ [0x046A] = "INSIGMA INC.",
+ [0x0469] = "Palago AB",
+ [0x0468] = "Kynesim Ltd",
+ [0x0467] = "Codenex Oy",
+ [0x0465] = "International Forte Group LLC",
+ [0x0464] = "Bellman & Symfon Group AB",
+ [0x0463] = "Fathom Systems Inc.",
+ [0x0462] = "Bonsai Systems GmbH",
+ [0x0461] = "vhf elektronik GmbH",
+ [0x0460] = "Kolibree",
+ [0x045F] = "Real Time Automation, Inc.",
+ [0x045E] = "Nuviz, Inc.",
+ [0x045D] = "Boston Scientific Corporation",
+ [0x045C] = "Delta T Corporation",
+ [0x045A] = "2048450 Ontario Inc",
+ [0x0458] = "Mini Solution Co., Ltd.",
+ [0x0457] = "RF INNOVATION",
+ [0x0456] = "Nemik Consulting Inc",
+ [0x0455] = "Atomation",
+ [0x0454] = "Sphinx Electronics GmbH & Co KG",
+ [0x0453] = "Qorvo Utrecht B.V.",
+ [0x0452] = "Svep Design Center AB",
+ [0x0451] = "Tunstall Nordic AB",
+ [0x0450] = "Teenage Engineering AB",
+ [0x044F] = "TTS Tooltechnic Systems AG & Co. KG",
+ [0x044E] = "Xtrava Inc.",
+ [0x044D] = "VEGA Grieshaber KG",
+ [0x044C] = "LifeStyle Lock, LLC",
+ [0x044B] = "Nain Inc.",
+ [0x044A] = "SHIMANO INC.",
+ [0x0449] = "1UP USA.com llc",
+ [0x0448] = "Grand Centrix GmbH",
+ [0x0447] = "Fabtronics Australia Pty Ltd",
+ [0x0446] = "NETGEAR, Inc.",
+ [0x0445] = "Kobian Canada Inc.",
+ [0x0444] = "Metanate Limited",
+ [0x0443] = "Tucker International LLC",
+ [0x0442] = "SECOM CO., LTD.",
+ [0x0440] = "Valencell, Inc.",
+ [0x043F] = "Tentacle Sync GmbH",
+ [0x043E] = "Thermomedics, Inc.",
+ [0x043D] = "Coiler Corporation",
+ [0x043B] = "Appside co., ltd.",
+ [0x043A] = "Nuheara Limited",
+ [0x0439] = "Radiance Technologies",
+ [0x0438] = "Helvar Ltd",
+ [0x0437] = "eBest IOT Inc.",
+ [0x0436] = "Drayson Technologies (Europe) Limited",
+ [0x0435] = "Blocks Wearables Ltd.",
+ [0x0434] = "Hatch Baby, Inc.",
+ [0x0433] = "Pillsy Inc.",
+ [0x0432] = "Silk Labs, Inc.",
+ [0x0431] = "Alticor Inc.",
+ [0x0430] = "SnapStyk Inc.",
+ [0x042F] = "Danfoss A/S",
+ [0x042E] = "MemCachier Inc.",
+ [0x042D] = "Meshtech AS",
+ [0x042C] = "Ticto N.V.",
+ [0x042B] = "iMicroMed Incorporated",
+ [0x042A] = "BD Medical",
+ [0x0429] = "Prolon Inc.",
+ [0x0428] = "SmallLoop, LLC",
+ [0x0427] = "Focus fleet and fuel management inc",
+ [0x0426] = "Husqvarna AB",
+ [0x0425] = "Unify Software and Solutions GmbH & Co. KG",
+ [0x0424] = "Trainesense Ltd.",
+ [0x0423] = "Chargifi Limited",
+ [0x0422] = "DELSEY SA",
+ [0x0421] = "Backbone Labs, Inc.",
+ [0x0420] = "TecBakery GmbH",
+ [0x041F] = "Kopin Corporation",
+ [0x041E] = "Dell Computer Corporation",
+ [0x041D] = "Benning Elektrotechnik und Elektronik GmbH & Co. KG",
+ [0x041C] = "WaterGuru, Inc.",
+ [0x041B] = "OrthoAccel Technologies",
+ [0x041A] = "Friday Labs Limited",
+ [0x0419] = "Novalogy LTD",
+ [0x0417] = "Fatigue Science",
+ [0x0416] = "SODA GmbH",
+ [0x0413] = "Wireless Cables Inc",
+ [0x0412] = "SEFAM",
+ [0x0411] = "Luidia Inc",
+ [0x0410] = "Fender Musical Instruments",
+ [0x040F] = "CO-AX Technology, Inc.",
+ [0x040E] = "SKF (U.K.) Limited",
+ [0x040D] = "NorthStar Battery Company, LLC",
+ [0x040C] = "Senix Corporation",
+ [0x040B] = "Jana Care Inc.",
+ [0x040A] = "ZF OPENMATICS s.r.o.",
+ [0x0409] = "RYSE INC.",
+ [0x0408] = "ToGetHome Inc.",
+ [0x0407] = "Swiss Audio SA",
+ [0x0405] = "Vertex International, Inc.",
+ [0x0404] = "Authomate Inc",
+ [0x0403] = "Gantner Electronic GmbH",
+ [0x0402] = "Sears Holdings Corporation",
+ [0x0401] = "Relations Inc.",
+ [0x0400] = "i-developer IT Beratung UG",
+ [0x03FF] = "Withings",
+ [0x03FE] = "Littelfuse",
+ [0x03FD] = "Trimble Inc.",
+ [0x03FC] = "Kimberly-Clark",
+ [0x03FB] = "Nox Medical",
+ [0x03FA] = "Vyassoft Technologies Inc",
+ [0x03F9] = "Becon Technologies Co.,Ltd.",
+ [0x03F8] = "Rockford Corp.",
+ [0x03F7] = "Owl Labs Inc.",
+ [0x03F6] = "Iton Technology Corp.",
+ [0x03F5] = "WHERE, Inc.",
+ [0x03F4] = "PAL Technologies Ltd",
+ [0x03F2] = "WindowMaster A/S",
+ [0x03F1] = "Hestan Smart Cooking Inc.",
+ [0x03F0] = "CLINK",
+ [0x03EF] = "foolography GmbH",
+ [0x03EE] = "CUBE TECHNOLOGIES",
+ [0x03ED] = "BASIC MICRO.COM,INC.",
+ [0x03EC] = "Jigowatts Inc.",
+ [0x03EB] = "Ozo Edu, Inc.",
+ [0x03EA] = "Hello Inc.",
+ [0x03E9] = "SHENZHEN LEMONJOY TECHNOLOGY CO., LTD.",
+ [0x03E8] = "Reiner Kartengeraete GmbH & Co. KG.",
+ [0x03E7] = "TRUE Fitness Technology",
+ [0x03E6] = "IoT Instruments Oy",
+ [0x03E5] = "ffly4u",
+ [0x03E4] = "Chip-ing AG",
+ [0x03E3] = "Qualcomm Life Inc",
+ [0x03E2] = "Sensoan Oy",
+ [0x03E1] = "SPD Development Company Ltd",
+ [0x03E0] = "Actions Technology Co.,Ltd",
+ [0x03DF] = "Grob Technologies, LLC",
+ [0x03DE] = "Nathan Rhoades LLC",
+ [0x03DD] = "Andreas Stihl AG & Co. KG",
+ [0x03DC] = "Nima Labs",
+ [0x03DB] = "Instabeat, Inc",
+ [0x03DA] = "EnOcean GmbH",
+ [0x03D9] = "3IWare Co., Ltd.",
+ [0x03D8] = "Zen-Me Labs Ltd",
+ [0x03D7] = "FINSECUR",
+ [0x03D6] = "Yota Devices LTD",
+ [0x03D5] = "Wyzelink Systems Inc.",
+ [0x03D4] = "PEG PEREGO SPA",
+ [0x03D3] = "Sigma Connectivity AB",
+ [0x03D2] = "IOT Pot India Private Limited",
+ [0x03D1] = "Density Inc.",
+ [0x03D0] = "Watteam Ltd",
+ [0x03CF] = "MIRA, Inc.",
+ [0x03CE] = "CONTRINEX S.A.",
+ [0x03CD] = "Wynd Technologies, Inc.",
+ [0x03CC] = "Vonkil Technologies Ltd",
+ [0x03CB] = "SYSDEV Srl",
+ [0x03CA] = "In2things Automation Pvt. Ltd.",
+ [0x03C9] = "Gallagher Group",
+ [0x03C8] = "Avvel International",
+ [0x03C7] = "Structural Health Systems, Inc.",
+ [0x03C6] = "Intricon",
+ [0x03C5] = "St. Jude Medical, Inc.",
+ [0x03C4] = "Pico Technology Inc.",
+ [0x03C3] = "Casambi Technologies Oy",
+ [0x03C2] = "Snapchat Inc",
+ [0x03C1] = "Ember Technologies, Inc.",
+ [0x03C0] = "Arch Systems Inc.",
+ [0x03BF] = "iLumi Solutions Inc.",
+ [0x03BE] = "Applied Science, Inc.",
+ [0x03BD] = "amadas",
+ [0x03BC] = "ASB Bank Ltd",
+ [0x03BB] = "Abbott",
+ [0x03BA] = "Maxscend Microelectronics Company Limited",
+ [0x03B9] = "FREDERIQUE CONSTANT SA",
+ [0x03B8] = "A-Safe Limited",
+ [0x03B7] = "Airbly Inc.",
+ [0x03B6] = "Mattel",
+ [0x03B5] = "petPOMM, Inc",
+ [0x03B4] = "Alpha Nodus, inc.",
+ [0x03B3] = "Midwest Instruments & Controls",
+ [0x03B2] = "Propagation Systems Limited",
+ [0x03B1] = "Otodata Wireless Network Inc.",
+ [0x03B0] = "VIBRADORM GmbH",
+ [0x03AF] = "Comm-N-Sense Corp DBA Verigo",
+ [0x03AE] = "Allswell Inc.",
+ [0x03AD] = "XiQ",
+ [0x03AB] = "Meizu Technology Co., Ltd.",
+ [0x03AA] = "Exon Sp. z o.o.",
+ [0x03A8] = "Esrille Inc.",
+ [0x03A7] = "AeroScout",
+ [0x03A6] = "Medela, Inc",
+ [0x03A5] = "ACE CAD Enterprise Co., Ltd. (ACECAD)",
+ [0x03A4] = "Token Zero Ltd",
+ [0x03A3] = "SmartMovt Technology Co., Ltd",
+ [0x03A2] = "Candura Instruments",
+ [0x03A1] = "Alpine Labs LLC",
+ [0x03A0] = "IVT Wireless Limited",
+ [0x039F] = "Molex Corporation",
+ [0x039E] = "SchoolBoard Limited",
+ [0x039D] = "CareView Communications, Inc.",
+ [0x039C] = "ALE International",
+ [0x039B] = "South Silicon Valley Microelectronics",
+ [0x039A] = "NeST",
+ [0x0399] = "Nikon Corporation",
+ [0x0398] = "Thetatronics Ltd",
+ [0x0397] = "LEGO System A/S",
+ [0x0396] = "BLOKS GmbH",
+ [0x0395] = "SDATAWAY",
+ [0x0393] = "LAVAZZA S.p.A.",
+ [0x0392] = "T&D",
+ [0x0391] = "Thingsquare AB",
+ [0x0390] = "INFOTECH s.r.o.",
+ [0x038F] = "Xiaomi Inc.",
+ [0x038E] = "Crownstone B.V.",
+ [0x038D] = "Resmed Ltd",
+ [0x038C] = "Appion Inc.",
+ [0x038B] = "Noke",
+ [0x038A] = "Kohler Mira Limited",
+ [0x0389] = "ActiveBlu Corporation",
+ [0x0388] = "Kapsch TrafficCom AB",
+ [0x0387] = "BluStor PMC, Inc.",
+ [0x0386] = "Aterica Inc.",
+ [0x0385] = "Embedded Electronic Solutions Ltd. dba e2Solutions",
+ [0x0383] = "Kronos Incorporated",
+ [0x0382] = "Precision Outcomes Ltd",
+ [0x0381] = "Sharp Corporation",
+ [0x0380] = 'LLC "MEGA-F service"',
+ [0x037F] = "Société des Produits Nestlé S.A.",
+ [0x037E] = "lulabytes S.L.",
+ [0x037D] = "MICRODIA Ltd.",
+ [0x037C] = "Cronologics Corporation",
+ [0x037B] = "Apption Labs Inc.",
+ [0x037A] = "Algoria",
+ [0x0379] = "Shenzhen iMCO Electronic Technology Co.,Ltd",
+ [0x0378] = "Propeller Health",
+ [0x0377] = "Plejd AB",
+ [0x0376] = "Electronic Temperature Instruments Ltd",
+ [0x0375] = "Expain AS",
+ [0x0374] = "Holman Industries",
+ [0x0373] = "AppNearMe Ltd",
+ [0x0372] = "Nixie Labs, Inc.",
+ [0x0370] = "Wazombi Labs OÜ",
+ [0x036F] = "Motiv, Inc.",
+ [0x036E] = "MOTIVE TECHNOLOGIES, INC.",
+ [0x036D] = "AirBolt Pty Ltd",
+ [0x036C] = "Zipcar",
+ [0x036B] = "BRControls Products BV",
+ [0x036A] = "SetPoint Medical",
+ [0x0369] = "littleBits",
+ [0x0368] = "Metormote AB",
+ [0x0366] = "BOLTT Sports technologies Private limited",
+ [0x0365] = "BioMech Sensor LLC",
+ [0x0364] = "Favero Electronics Srl",
+ [0x0363] = "FREELAP SA",
+ [0x0362] = "ON Semiconductor",
+ [0x0361] = "Wellinks Inc.",
+ [0x0360] = "Insulet Corporation",
+ [0x035F] = "Acromag",
+ [0x035E] = "Naya Health, Inc.",
+ [0x035D] = "KYS",
+ [0x035C] = "Eaton Corporation",
+ [0x035B] = "Matrix Inc.",
+ [0x035A] = "Phillips-Medisize A/S",
+ [0x0359] = "Novotec Medical GmbH",
+ [0x0358] = "MagniWare Ltd.",
+ [0x0357] = "Polymap Wireless",
+ [0x0356] = "Spectrum Brands, Inc.",
+ [0x0355] = "Sigma Designs, Inc.",
+ [0x0354] = "TOPPAN FORMS CO.,LTD.",
+ [0x0353] = "Alpha Audiotronics, Inc.",
+ [0x0352] = "iRiding(Xiamen)Technology Co.,Ltd.",
+ [0x0351] = "Pieps GmbH",
+ [0x0350] = "Bitstrata Systems Inc.",
+ [0x034F] = "Heartland Payment Systems",
+ [0x034D] = "TASER International, Inc.",
+ [0x034C] = "HM Electronics, Inc.",
+ [0x034B] = "Libratone A/S",
+ [0x0349] = "VersaMe",
+ [0x0347] = "Prevent Biometrics",
+ [0x0346] = "Acuity Brands Lighting, Inc",
+ [0x0345] = "Locus Positioning",
+ [0x0344] = "Whirl Inc",
+ [0x0343] = "Drekker Development Pty. Ltd.",
+ [0x0342] = "GERTEC BRASIL LTDA.",
+ [0x0341] = "Etesian Technologies LLC",
+ [0x0340] = "Letsense s.r.l.",
+ [0x033F] = "Automation Components, Inc.",
+ [0x033E] = "Monitra SA",
+ [0x033D] = "TPV Technology Limited",
+ [0x033C] = "Virtuosys",
+ [0x033B] = "Courtney Thorne Limited",
+ [0x033A] = "Appception, Inc.",
+ [0x0339] = "Blue Sky Scientific, LLC",
+ [0x0338] = "COBI GmbH",
+ [0x0337] = "AJP2 Holdings, LLC",
+ [0x0336] = "GISTIC",
+ [0x0335] = "Enlighted Inc",
+ [0x0334] = "Airthings ASA",
+ [0x0333] = "Mul-T-Lock",
+ [0x0331] = "3flares Technologies Inc.",
+ [0x0330] = "North Pole Engineering",
+ [0x032F] = "OttoQ Inc",
+ [0x032E] = "indoormap",
+ [0x032D] = "BM innovations GmbH",
+ [0x032C] = "NIPPON SMT.CO.,Ltd",
+ [0x032B] = "ESYLUX",
+ [0x032A] = "Electronic Design Lab",
+ [0x0329] = "Eargo, Inc.",
+ [0x0328] = "Grundfos A/S",
+ [0x0327] = "Essex Electronics",
+ [0x0326] = "Healthwear Technologies (Changzhou)Ltd",
+ [0x0325] = "Amotus Solutions",
+ [0x0324] = "Astro, Inc.",
+ [0x0323] = "Rotor Bike Components",
+ [0x0320] = "SONO ELECTRONICS. CO., LTD",
+ [0x031F] = "MetaSystem S.p.A.",
+ [0x031E] = "Eyefi, Inc.",
+ [0x031D] = "Enterlab ApS",
+ [0x031C] = "Lab Sensor Solutions",
+ [0x031B] = "HQ Inc",
+ [0x031A] = "Wurth Elektronik eiSos GmbH & Co. KG",
+ [0x0319] = "Eugster Frismag AG",
+ [0x0318] = "Aspenta International",
+ [0x0317] = "CHUO Electronics CO., LTD.",
+ [0x0316] = "AG Measurematics Pvt. Ltd.",
+ [0x0315] = "Thermo Fisher Scientific",
+ [0x0314] = "RIIG AI Sp. z o.o.",
+ [0x0313] = "DiveNav, Inc.",
+ [0x0312] = "Ducere Technologies Pvt Ltd",
+ [0x0311] = "PEEQ DATA",
+ [0x0310] = "SGL Italia S.r.l.",
+ [0x030F] = "Shortcut Labs",
+ [0x030D] = "Devdata S.r.l.",
+ [0x030C] = "Hilti AG",
+ [0x030B] = "Magnitude Lighting Converters",
+ [0x030A] = "Ellisys",
+ [0x0309] = "Dolby Labs",
+ [0x0308] = "Surefire, LLC",
+ [0x0307] = "FUJI INDUSTRIAL CO.,LTD.",
+ [0x0306] = "Life Laboratory Inc.",
+ [0x0305] = "Swipp ApS",
+ [0x0304] = "Oura Health Ltd",
+ [0x0303] = "IACA electronique",
+ [0x0302] = "Loop Devices, Inc",
+ [0x0301] = "Giatec Scientific Inc.",
+ [0x0300] = "World Moto Inc.",
+ [0x02FF] = "Silicon Laboratories",
+ [0x02FE] = "Lierda Science & Technology Group Co., Ltd.",
+ [0x02FC] = "Shanghai Frequen Microelectronics Co., Ltd.",
+ [0x02FB] = "Clarius Mobile Health Corp.",
+ [0x02F9] = "IMAGINATION TECHNOLOGIES LTD",
+ [0x02F8] = "Runteq Oy Ltd",
+ [0x02F6] = "Intemo Technologies",
+ [0x02F5] = "Indagem Tech LLC",
+ [0x02F4] = "Vensi, Inc.",
+ [0x02F3] = "AuthAir, Inc",
+ [0x02F2] = "GoPro, Inc.",
+ [0x02F1] = "The Idea Cave, LLC",
+ [0x02F0] = "Blackrat Software",
+ [0x02EF] = "SMART-INNOVATION.inc",
+ [0x02EE] = "Citizen Holdings Co., Ltd.",
+ [0x02ED] = "HTC Corporation",
+ [0x02EC] = "Delta Systems, Inc",
+ [0x02EB] = "Ardic Technology",
+ [0x02EA] = "Fujitsu Limited",
+ [0x02E9] = "Sensogram Technologies, Inc.",
+ [0x02E8] = "American Music Environments",
+ [0x02E7] = "Connected Yard, Inc.",
+ [0x02E6] = "Unwire",
+ [0x02E5] = "Espressif Systems (Shanghai) Co., Ltd.",
+ [0x02E4] = "Bytestorm Ltd.",
+ [0x02E3] = "Carmanah Technologies Corp.",
+ [0x02E2] = "NTT docomo",
+ [0x02E1] = "Victron Energy BV",
+ [0x02E0] = "University of Michigan",
+ [0x02DF] = "Blur Product Development",
+ [0x02DE] = "Samsung SDS Co., Ltd.",
+ [0x02DD] = "Flint Rehabilitation Devices, LLC",
+ [0x02DC] = "DeWalch Technologies, Inc.",
+ [0x02DB] = "Digi International Inc (R)",
+ [0x02DA] = "Gilvader",
+ [0x02D9] = "Fliegl Agrartechnik GmbH",
+ [0x02D8] = "Neosfar",
+ [0x02D7] = "NIPPON SYSTEMWARE CO.,LTD.",
+ [0x02D6] = "Send Solutions",
+ [0x02D5] = "OMRON Corporation",
+ [0x02D4] = "Secuyou ApS",
+ [0x02D3] = "Powercast Corporation",
+ [0x02D2] = "Afero, Inc.",
+ [0x02D1] = "Empatica Srl",
+ [0x02D0] = "3M",
+ [0x02CF] = "Anima",
+ [0x02CE] = "Teva Branded Pharmaceutical Products R&D, Inc.",
+ [0x02CD] = "BMA ergonomics b.v.",
+ [0x02CB] = "AINA-Wireless Inc.",
+ [0x02CA] = "ABOV Semiconductor",
+ [0x02C9] = "PayRange Inc.",
+ [0x02C8] = "OneSpan",
+ [0x02C7] = "Electronics Tomorrow Limited",
+ [0x02C6] = "Ayatan Sensors",
+ [0x02C5] = "Lenovo (Singapore) Pte Ltd.",
+ [0x02C4] = "Wilson Sporting Goods",
+ [0x02C3] = "Techtronic Power Tools Technology Limited",
+ [0x02C2] = "Guillemot Corporation",
+ [0x02C1] = "LINE Corporation",
+ [0x02C0] = "Dash Robotics",
+ [0x02BF] = "Redbird Flight Simulations",
+ [0x02BE] = "Seguro Technology Sp. z o.o.",
+ [0x02BD] = "Chemtronics",
+ [0x02BC] = "Genevac Ltd",
+ [0x02BB] = "YOKOWO CO., LTD.",
+ [0x02BA] = "Swissprime Technologies AG",
+ [0x02B9] = "Rinnai Corporation",
+ [0x02B8] = "Chrono Therapeutics",
+ [0x02B7] = "Oort Technologies LLC",
+ [0x02B6] = "Schneider Electric",
+ [0x02B5] = "HANSHIN ELECTRIC RAILWAY CO.,LTD.",
+ [0x02B4] = "Hyginex, Inc.",
+ [0x02B3] = "CLABER S.P.A.",
+ [0x02B2] = "Oura Health Oy",
+ [0x02B1] = "Raden Inc",
+ [0x02B0] = "Bestechnic(Shanghai),Ltd",
+ [0x02AF] = "Technicolor USA Inc.",
+ [0x02AE] = "WeatherFlow, Inc.",
+ [0x02AD] = "Rx Networks, Inc.",
+ [0x02AC] = "RTB Elektronik GmbH & Co. KG",
+ [0x02AB] = "BBPOS Limited",
+ [0x02AA] = "Doppler Lab",
+ [0x02A9] = "Chargelib",
+ [0x02A8] = "miSport Ltd.",
+ [0x02A7] = "Illuxtron international B.V.",
+ [0x02A6] = "Robert Bosch GmbH",
+ [0x02A5] = "Tendyron Corporation",
+ [0x02A4] = "Pacific Lock Company",
+ [0x02A3] = "Itude",
+ [0x02A2] = "Sera4 Ltd.",
+ [0x02A0] = "Impossible Camera GmbH",
+ [0x029F] = "Areus Engineering GmbH",
+ [0x029E] = "Kupson spol. s r.o.",
+ [0x029D] = "ALOTTAZS LABS, LLC",
+ [0x029C] = "Blue Sky Scientific, LLC",
+ [0x029B] = "C2 Development, Inc.",
+ [0x029A] = "Currant, Inc.",
+ [0x0299] = "Inexess Technology Simma KG",
+ [0x0298] = "EISST Ltd",
+ [0x0297] = "storm power ltd",
+ [0x0296] = "Petzl",
+ [0x0295] = "Sivantos GmbH",
+ [0x0294] = "ELIAS GmbH",
+ [0x0293] = "Blue Bite",
+ [0x0291] = "CliniCloud Inc",
+ [0x0290] = "Multibit Oy",
+ [0x028F] = "Church & Dwight Co., Inc",
+ [0x028E] = "RF Digital Corp",
+ [0x028C] = "NANOLINK APS",
+ [0x028B] = "Code Gears LTD",
+ [0x028A] = "Jetro AS",
+ [0x0289] = "SK Telecom",
+ [0x0287] = "Wally Ventures S.L.",
+ [0x0286] = "RF Code, Inc.",
+ [0x0285] = "WOWTech Canada Ltd.",
+ [0x0284] = "Synapse Electronics",
+ [0x0283] = "Maven Machines, Inc.",
+ [0x0282] = "Sonova AG",
+ [0x0281] = "StoneL",
+ [0x0280] = "ITEC corporation",
+ [0x027F] = "ruwido austria gmbh",
+ [0x027E] = "HabitAware, LLC",
+ [0x027D] = "HUAWEI Technologies Co., Ltd.",
+ [0x027C] = "Aseptika Ltd",
+ [0x027B] = "DEFA AS",
+ [0x027A] = "Ekomini inc.",
+ [0x0279] = "steute Schaltgerate GmbH & Co. KG",
+ [0x0278] = "Johnson Outdoors Inc",
+ [0x0277] = "bewhere inc",
+ [0x0276] = "E.G.O. Elektro-Geraetebau GmbH",
+ [0x0275] = "Geotab",
+ [0x0274] = "Motsai Research",
+ [0x0273] = "OCEASOFT",
+ [0x0272] = "Alps Alpine Co., Ltd.",
+ [0x0271] = "Animas Corp",
+ [0x0270] = "LSI ADL Technology",
+ [0x026F] = "Aptcode Solutions",
+ [0x026E] = "FLEURBAEY BVBA",
+ [0x026D] = "Technogym SPA",
+ [0x026C] = "Domster Tadeusz Szydlowski",
+ [0x026B] = "DEKA Research & Development Corp.",
+ [0x026A] = "Gemalto",
+ [0x0269] = "Torrox GmbH & Co KG",
+ [0x0268] = "Cerevo",
+ [0x0267] = "XMI Systems SA",
+ [0x0266] = "Schawbel Technologies LLC",
+ [0x0265] = "SMK Corporation",
+ [0x0264] = "DDS, Inc.",
+ [0x0263] = "Identiv, Inc.",
+ [0x0262] = "Glacial Ridge Technologies",
+ [0x0261] = "SECVRE GmbH",
+ [0x025F] = "Yardarm Technologies",
+ [0x025E] = "Fluke Corporation",
+ [0x025D] = "Lexmark International Inc.",
+ [0x025C] = "NetEase(Hangzhou)Network co.Ltd.",
+ [0x025B] = "Five Interactive, LLC dba Zendo",
+ [0x025A] = "University of Applied Sciences Valais/Haute Ecole Valaisanne",
+ [0x0259] = "ALTYOR",
+ [0x0258] = "Devialet SA",
+ [0x0257] = "AdBabble Local Commerce Inc.",
+ [0x0256] = "G24 Power Limited",
+ [0x0255] = "Dai Nippon Printing Co., Ltd.",
+ [0x0254] = "Playbrush",
+ [0x0253] = "Xicato Inc.",
+ [0x0252] = "UKC Technosolution",
+ [0x0251] = "Lumo Bodytech Inc.",
+ [0x0250] = "Sapphire Circuits LLC",
+ [0x024F] = "Schneider Schreibgeräte GmbH",
+ [0x024E] = "Microtronics Engineering GmbH",
+ [0x024D] = "M-Way Solutions GmbH",
+ [0x024C] = "Blue Clover Devices",
+ [0x024B] = "Orlan LLC",
+ [0x024A] = "Uwatec AG",
+ [0x0248] = "Parker Hannifin Corp",
+ [0x0247] = "FiftyThree Inc.",
+ [0x0246] = "ACKme Networks, Inc.",
+ [0x0245] = "Endress+Hauser",
+ [0x0244] = "Iotera Inc",
+ [0x0243] = "Masimo Corp",
+ [0x0241] = "Bragi GmbH",
+ [0x0240] = "Argenox Technologies",
+ [0x023F] = "WaveWare Technologies Inc.",
+ [0x023E] = "Raven Industries",
+ [0x023D] = "ViCentra B.V.",
+ [0x023B] = "Beijing CarePulse Electronic Technology Co, Ltd",
+ [0x023A] = "Alatech Tehnology",
+ [0x0239] = "JIN CO, Ltd",
+ [0x0238] = "Trakm8 Ltd",
+ [0x0237] = "MSHeli s.r.l.",
+ [0x0236] = "Pitpatpet Ltd",
+ [0x0235] = "Qrio Inc",
+ [0x0234] = "FengFan (BeiJing) Technology Co, Ltd",
+ [0x0233] = "Shenzhen SuLong Communication Ltd",
+ [0x0232] = "x-Senso Solutions Kft",
+ [0x0231] = "ETA SA",
+ [0x0230] = "Foster Electric Company, Ltd",
+ [0x022E] = "Siemens AG",
+ [0x022D] = "Lupine",
+ [0x022C] = "Pharynks Corporation",
+ [0x022B] = "Tesla, Inc.",
+ [0x022A] = "Stamer Musikanlagen GMBH",
+ [0x0229] = "Muoverti Limited",
+ [0x0228] = "Twocanoes Labs, LLC",
+ [0x0227] = "LifeBEAM Technologies",
+ [0x0226] = "Merlinia A/S",
+ [0x0225] = "Nestlé Nespresso S.A.",
+ [0x0224] = "Comarch SA",
+ [0x0223] = "Philip Morris Products S.A.",
+ [0x0222] = "Praxis Dynamics",
+ [0x0221] = "Mobiquity Networks Inc",
+ [0x0220] = "Manus Machina BV",
+ [0x021F] = "Luster Leaf Products Inc",
+ [0x021E] = "Goodnet, Ltd",
+ [0x021D] = "Edamic",
+ [0x021C] = "Mobicomm Inc",
+ [0x021B] = "Cisco Systems, Inc",
+ [0x021A] = "Blue Speck Labs, LLC",
+ [0x0219] = "DOTT Limited",
+ [0x0217] = "Tech4home, Lda",
+ [0x0216] = "MTI Ltd",
+ [0x0215] = "Lukoton Experience Oy",
+ [0x0214] = "IK Multimedia Production srl",
+ [0x0213] = "Wyler AG",
+ [0x0212] = "Interplan Co., Ltd",
+ [0x0211] = "Telink Semiconductor Co. Ltd",
+ [0x0210] = "ikeGPS",
+ [0x020F] = "Comodule GMBH",
+ [0x020E] = "Omron Healthcare Co., LTD",
+ [0x020C] = "CoroWare Technologies, Inc",
+ [0x020B] = "Jaguar Land Rover Limited",
+ [0x020A] = "Macnica Inc.",
+ [0x0209] = "InvisionHeart Inc.",
+ [0x0208] = "LumiGeek LLC",
+ [0x0207] = "STEMP Inc.",
+ [0x0206] = "Otter Products, LLC",
+ [0x0203] = "Kemppi Oy",
+ [0x0202] = "Rigado LLC",
+ [0x0201] = "AR Timing",
+ [0x0200] = "Verifone Systems Pte Ltd. Taiwan Branch",
+ [0x01FF] = "Freescale Semiconductor, Inc.",
+ [0x01FE] = "Radio Systems Corporation",
+ [0x01FD] = "Kontakt Micro-Location Sp. z o.o.",
+ [0x01FC] = "Wahoo Fitness, LLC",
+ [0x01FB] = "Form Lifting, LLC",
+ [0x01FA] = "Gozio Inc.",
+ [0x01F9] = "Medtronic Inc.",
+ [0x01F8] = "Anyka (Guangzhou) Microelectronics Technology Co, LTD",
+ [0x01F7] = "Gelliner Limited",
+ [0x01F6] = "DJO Global",
+ [0x01F5] = "Cool Webthings Limited",
+ [0x01F4] = "UTC Fire and Security",
+ [0x01F3] = "The University of Tokyo",
+ [0x01F2] = "Itron, Inc.",
+ [0x01F1] = "Zebra Technologies Corporation",
+ [0x01F0] = "KloudNation",
+ [0x01EF] = "Fullpower Technologies, Inc.",
+ [0x01EE] = "Valeo Service",
+ [0x01ED] = "CuteCircuit LTD",
+ [0x01EC] = "Spreadtrum Communications Shanghai Ltd",
+ [0x01EA] = "Advanced Application Design, Inc.",
+ [0x01E6] = "Technology Solutions (UK) Ltd",
+ [0x01E5] = "Dynamic Devices Ltd",
+ [0x01E4] = "Freedom Innovations",
+ [0x01E3] = "Caterpillar Inc",
+ [0x01E1] = "Jolla Ltd",
+ [0x01E0] = "Widex A/S",
+ [0x01DF] = "Bison Group Ltd.",
+ [0x01DE] = "Minelab Electronics Pty Limited",
+ [0x01DD] = "Koninklijke Philips N.V.",
+ [0x01DC] = "iParking Ltd.",
+ [0x01DB] = "Innblue Consulting",
+ [0x01DA] = "Logitech International SA",
+ [0x01D9] = "Savant Systems LLC",
+ [0x01D8] = "Code Corporation",
+ [0x01D6] = "G-wearables inc.",
+ [0x01D5] = "ELAD srl",
+ [0x01D4] = "Newlab S.r.l.",
+ [0x01D3] = "Sky Wave Design",
+ [0x01D2] = "Gill Electronics",
+ [0x01D1] = "August Home, Inc",
+ [0x01D0] = "Primus Inter Pares Ltd",
+ [0x01CF] = "BSH",
+ [0x01CE] = "HOUWA SYSTEM DESIGN, k.k.",
+ [0x01CD] = "Chengdu Synwing Technology Ltd",
+ [0x01CC] = "Sam Labs Ltd.",
+ [0x01CB] = "Fetch My Pet",
+ [0x01CA] = "Laerdal Medical AS",
+ [0x01C9] = "Avi-on",
+ [0x01C8] = "Poly-Control ApS",
+ [0x01C7] = "Abiogenix Inc.",
+ [0x01C6] = "HASWARE Inc.",
+ [0x01C5] = "Bitcraze AB",
+ [0x01C4] = "DME Microelectronics",
+ [0x01C2] = "Transenergooil AG",
+ [0x01C1] = "BRADATECH Corp.",
+ [0x01BF] = "Hongkong OnMicro Electronics Limited",
+ [0x01BE] = "Pulsate Mobile Ltd.",
+ [0x01BD] = "Syszone Co., Ltd",
+ [0x01BC] = "SenionLab AB",
+ [0x01BB] = "Cochlear Bone Anchored Solutions AB",
+ [0x01BA] = "Stages Cycling LLC",
+ [0x01B9] = "HANA Micron",
+ [0x01B8] = "i+D3 S.L.",
+ [0x01B7] = "General Electric Company",
+ [0x01B6] = "LM Technologies Ltd",
+ [0x01B5] = "Nest Labs Inc.",
+ [0x01B4] = "Trineo Sp. z o.o.",
+ [0x01B3] = "Nytec, Inc.",
+ [0x01B2] = "Nymi Inc.",
+ [0x01B1] = "Netizens Sp. z o.o.",
+ [0x01B0] = "Star Micronics Co., Ltd.",
+ [0x01AF] = "Sunrise Micro Devices, Inc.",
+ [0x01AD] = "FlightSafety International",
+ [0x01AC] = "Trividia Health, Inc.",
+ [0x01AB] = "Meta Platforms, Inc.",
+ [0x01AA] = "Geophysical Technology Inc.",
+ [0x01A9] = "Canon Inc.",
+ [0x01A8] = "Taobao",
+ [0x01A7] = "ENERGOUS CORPORATION",
+ [0x01A6] = "Wille Engineering",
+ [0x01A5] = "Icon Health and Fitness",
+ [0x01A4] = "MSA Innovation, LLC",
+ [0x01A3] = "EROAD",
+ [0x01A2] = "GIGALANE.CO.,LTD",
+ [0x01A1] = "FIAMM",
+ [0x01A0] = "Channel Enterprises (HK) Ltd.",
+ [0x019F] = "Strainstall Ltd",
+ [0x019E] = "Ceruus",
+ [0x019D] = "CVS Health",
+ [0x019C] = "Cokiya Incorporated",
+ [0x019B] = "CUBETECH s.r.o.",
+ [0x019A] = "TRON Forum",
+ [0x0199] = "SALTO SYSTEMS S.L.",
+ [0x0198] = "VENGIT Korlatolt Felelossegu Tarsasag",
+ [0x0197] = "WiSilica Inc.",
+ [0x0196] = "Paxton Access Ltd",
+ [0x0195] = "Zuli",
+ [0x0194] = "Acoustic Stream Corporation",
+ [0x0193] = "Maveric Automation LLC",
+ [0x0192] = "Cloudleaf, Inc",
+ [0x0191] = "FDK CORPORATION",
+ [0x0190] = "Intelletto Technologies Inc.",
+ [0x018E] = "Google LLC",
+ [0x018D] = "Extron Design Services",
+ [0x018C] = "Wilo SE",
+ [0x018B] = "Konica Minolta, Inc.",
+ [0x018A] = "Able Trend Technology Limited",
+ [0x0189] = "Physical Enterprises Inc.",
+ [0x0188] = "Unico RBC",
+ [0x0187] = "Seraphim Sense Ltd",
+ [0x0186] = "CORE Lighting Ltd",
+ [0x0184] = "Nectar",
+ [0x0183] = "Walt Disney",
+ [0x0182] = "HOP Ubiquitous",
+ [0x0181] = "Gecko Health Innovations, Inc.",
+ [0x0180] = "Gigaset Technologies GmbH",
+ [0x017F] = "XTel Wireless ApS",
+ [0x017E] = "BluDotz Ltd",
+ [0x017D] = "BatAndCat",
+ [0x017C] = "Mercedes-Benz Group AG",
+ [0x017B] = "taskit GmbH",
+ [0x017A] = "Telemonitor, Inc.",
+ [0x0179] = "LAPIS Semiconductor Co.,Ltd",
+ [0x0178] = "CASIO COMPUTER CO., LTD.",
+ [0x0177] = "I-SYST inc.",
+ [0x0176] = "SentriLock",
+ [0x0175] = "Dynamic Controls",
+ [0x0174] = "Everykey Inc.",
+ [0x0173] = "Kocomojo, LLC",
+ [0x0172] = "Connovate Technology Private Limited",
+ [0x0171] = "Amazon.com Services LLC",
+ [0x0170] = "Roche Diabetes Care AG",
+ [0x016F] = "Podo Labs, Inc",
+ [0x016E] = "Volantic AB",
+ [0x016D] = "LifeScan Inc",
+ [0x016C] = "MYSPHERA",
+ [0x016B] = "Qblinks",
+ [0x016A] = "Copeland Cold Chain LP",
+ [0x0169] = "emberlight",
+ [0x0168] = "Spicebox LLC",
+ [0x0167] = "Ascensia Diabetes Care US Inc.",
+ [0x0166] = "MISHIK Pte Ltd",
+ [0x0165] = "Milwaukee Electric Tools",
+ [0x0164] = "Qingdao Yeelink Information Technology Co., Ltd.",
+ [0x0163] = "PCH International",
+ [0x0162] = "MADSGlobalNZ Ltd.",
+ [0x0161] = "yikes",
+ [0x0160] = "AwoX",
+ [0x015F] = "Timer Cap Co.",
+ [0x015E] = "Unikey Technologies, Inc.",
+ [0x015D] = "Estimote, Inc.",
+ [0x015C] = "Pitius Tec S.L.",
+ [0x015B] = "Biomedical Research Ltd.",
+ [0x015A] = "micas AG",
+ [0x0159] = "ChefSteps, Inc.",
+ [0x0158] = "Inmite s.r.o.",
+ [0x0157] = "Anhui Huami Information Technology Co., Ltd.",
+ [0x0156] = "Accumulate AB",
+ [0x0155] = "NETATMO",
+ [0x0154] = "Pebble Technology",
+ [0x0153] = "ROL Ergo",
+ [0x0152] = "Vernier Software & Technology",
+ [0x0151] = "OnBeep",
+ [0x0150] = "Pioneer Corporation",
+ [0x014F] = "B&W Group Ltd.",
+ [0x014E] = "Tangerine, Inc.",
+ [0x014D] = "HUIZHOU DESAY SV AUTOMOTIVE CO., LTD.",
+ [0x014C] = "Mesh-Net Ltd",
+ [0x014B] = "Master Lock",
+ [0x014A] = "Tivoli Audio, LLC",
+ [0x0149] = "Perytons Ltd.",
+ [0x0148] = "Ambimat Electronics",
+ [0x0147] = "Mighty Cast, Inc.",
+ [0x0146] = "Ciright",
+ [0x0145] = "Novatel Wireless",
+ [0x0144] = "Lintech GmbH",
+ [0x0143] = "Bkon Connect",
+ [0x0142] = "Grape Systems Inc.",
+ [0x0141] = "FedEx Services",
+ [0x0140] = "Alpine Electronics (China) Co., Ltd",
+ [0x013E] = "Nod, Inc.",
+ [0x013D] = "WirelessWERX",
+ [0x013C] = "Murata Manufacturing Co., Ltd.",
+ [0x013B] = "Allegion",
+ [0x013A] = "Tencent Holdings Ltd.",
+ [0x0139] = "Focus Systems Corporation",
+ [0x0138] = "NTEO Inc.",
+ [0x0137] = "Prestigio Plaza Ltd.",
+ [0x0136] = "Silvair, Inc.",
+ [0x0135] = "Aireware LLC",
+ [0x0134] = "Resolution Products, Ltd.",
+ [0x0133] = "Blue Maestro Limited",
+ [0x0132] = "MADS Inc",
+ [0x0131] = "Cypress Semiconductor",
+ [0x0130] = "Warehouse Innovations",
+ [0x012F] = "Clarion Co. Inc.",
+ [0x012E] = "ASSA ABLOY",
+ [0x012D] = "Sony Corporation",
+ [0x012C] = "TEMEC Instruments B.V.",
+ [0x012B] = "SportIQ",
+ [0x012A] = "Changzhou Yongse Infotech Co., Ltd.",
+ [0x0129] = "Nimble Devices Oy",
+ [0x0128] = "GPSI Group Pty Ltd",
+ [0x0127] = "Salutica Allied Solutions",
+ [0x0126] = "Promethean Ltd.",
+ [0x0125] = "SEAT es",
+ [0x0124] = "HID Global",
+ [0x0123] = "Kinsa, Inc",
+ [0x0122] = "AirTurn, Inc.",
+ [0x0121] = "Sino Wealth Electronic Ltd.",
+ [0x0120] = "Porsche AG",
+ [0x011F] = "Volkswagen AG",
+ [0x011E] = "Skoda Auto a.s.",
+ [0x011D] = "Arendi AG",
+ [0x011C] = "Baidu",
+ [0x011B] = "Hewlett Packard Enterprise",
+ [0x011A] = "Qualcomm Labs, Inc.",
+ [0x0118] = "Radius Networks, Inc.",
+ [0x0117] = "Wimoto Technologies Inc",
+ [0x0116] = "10AK Technologies",
+ [0x0115] = "e.solutions",
+ [0x0113] = "Openbrain Technologies, Co., Ltd.",
+ [0x0112] = "Visybl Inc.",
+ [0x0111] = "Steelseries ApS",
+ [0x0110] = "Nippon Seiki Co., Ltd.",
+ [0x010F] = "HiSilicon Technologies CO., LIMITED",
+ [0x010E] = "Audi AG",
+ [0x010D] = "DENSO TEN Limited",
+ [0x010C] = "Transducers Direct, LLC",
+ [0x010B] = "ERi, Inc",
+ [0x010A] = "Codegate Ltd",
+ [0x0109] = "Atus BV",
+ [0x0108] = "Chicony Electronics Co., Ltd.",
+ [0x0107] = "Demant A/S",
+ [0x0106] = "Innovative Yachtter Solutions",
+ [0x0105] = "Ubiquitous Computing Technology Corporation",
+ [0x0104] = "PLUS Location Systems Pty Ltd",
+ [0x0103] = "Bang & Olufsen A/S",
+ [0x0102] = "Keiser Corporation",
+ [0x0101] = "Fugoo, Inc.",
+ [0x0100] = "TomTom International BV",
+ [0x00FF] = "Typo Products, LLC",
+ [0x00FE] = "Stanley Black and Decker",
+ [0x00FD] = "ValenceTech Limited",
+ [0x00FC] = "Delphi Corporation",
+ [0x00FB] = "KOUKAAM a.s.",
+ [0x00FA] = "Crystal Alarm AB",
+ [0x00F8] = "AceUni Corp., Ltd.",
+ [0x00F7] = "VSN Technologies, Inc.",
+ [0x00F6] = "Elcometer Limited",
+ [0x00F5] = "Smartifier Oy",
+ [0x00F4] = "Nautilus Inc.",
+ [0x00F3] = "Kent Displays Inc.",
+ [0x00F2] = "Morse Project Inc.",
+ [0x00F1] = "Witron Technology Limited",
+ [0x00F0] = "PayPal, Inc.",
+ [0x00EF] = "Bitsplitters GmbH",
+ [0x00EE] = "Above Average Outcomes, Inc.",
+ [0x00ED] = "Jolly Logic, LLC",
+ [0x00EC] = "BioResearch Associates",
+ [0x00EB] = "Server Technology Inc.",
+ [0x00EA] = "Nielsen-Kellerman",
+ [0x00E9] = "Vtrack Systems",
+ [0x00E8] = "ACTS Technologies",
+ [0x00E7] = "KS Technologies",
+ [0x00E5] = "Eden Software Consultants Ltd.",
+ [0x00E4] = "L.S. Research, Inc.",
+ [0x00E3] = "inMusic Brands, Inc",
+ [0x00E2] = "Semilink Inc",
+ [0x00E1] = "Danlers Ltd",
+ [0x00E0] = "Google",
+ [0x00DF] = "Misfit Wearables Corp",
+ [0x00DE] = "Muzik LLC",
+ [0x00DD] = "Hosiden Corporation",
+ [0x00DC] = "Procter & Gamble",
+ [0x00DB] = "Snuza (Pty) Ltd",
+ [0x00DA] = "txtr GmbH",
+ [0x00D9] = "Voyetra Turtle Beach",
+ [0x00D8] = "Qualcomm Connected Experiences, Inc.",
+ [0x00D7] = "Qualcomm Technologies, Inc.",
+ [0x00D6] = "Timex Group USA, Inc.",
+ [0x00D5] = "Austco Communication Systems",
+ [0x00D3] = "Taixingbang Technology (HK) Co,. LTD.",
+ [0x00D2] = "Renesas Design Netherlands B.V.",
+ [0x00D1] = "Polar Electro Europe B.V.",
+ [0x00D0] = "Dexcom, Inc.",
+ [0x00CF] = "ARCHOS SA",
+ [0x00CE] = "Eve Systems GmbH",
+ [0x00CD] = "Microchip Technology Inc.",
+ [0x00CC] = "Beats Electronics",
+ [0x00CB] = "Binauric SE",
+ [0x00CA] = "MC10",
+ [0x00C9] = "Evluma",
+ [0x00C8] = "GeLo Inc",
+ [0x00C7] = "Quuppa Oy.",
+ [0x00C6] = "Selfly BV",
+ [0x00C5] = "Onset Computer Corporation",
+ [0x00C4] = "LG Electronics",
+ [0x00C3] = "adidas AG",
+ [0x00C2] = "Geneq Inc.",
+ [0x00C1] = "Shenzhen Excelsecu Data Technology Co.,Ltd",
+ [0x00C0] = "AMICCOM Electronics Corporation",
+ [0x00BF] = "Stalmart Technology Limited",
+ [0x00BE] = "AAMP of America",
+ [0x00BD] = "Aplix Corporation",
+ [0x00BC] = "Ace Sensor Inc",
+ [0x00BB] = "S-Power Electronics Limited",
+ [0x00BA] = "Starkey Hearing Technologies",
+ [0x00B9] = "Johnson Controls, Inc.",
+ [0x00B8] = "Qualcomm Innovation Center, Inc. (QuIC)",
+ [0x00B7] = "TreLab Ltd",
+ [0x00B6] = "Meso international",
+ [0x00B5] = "Swirl Networks",
+ [0x00B4] = "BDE Technology Co., Ltd.",
+ [0x00B3] = "Clarinox Technologies Pty. Ltd.",
+ [0x00B2] = "Bekey A/S",
+ [0x00B1] = "Saris Cycling Group, Inc",
+ [0x00B0] = "Passif Semiconductor Corp",
+ [0x00AF] = "Cinetix",
+ [0x00AE] = "Omegawave Oy",
+ [0x00AD] = "Peter Systemtechnik GmbH",
+ [0x00AC] = "Green Throttle Games",
+ [0x00AB] = "Ingenieur-Systemgruppe Zahn GmbH",
+ [0x00AA] = "CAEN RFID srl",
+ [0x00A9] = "MARELLI EUROPE S.P.A.",
+ [0x00A8] = "ARP Devices Limited",
+ [0x00A7] = "Visteon Corporation",
+ [0x00A6] = "Panda Ocean Inc.",
+ [0x00A5] = "OTL Dynamics LLC",
+ [0x00A4] = "LINAK A/S",
+ [0x00A3] = "Meta Watch Ltd.",
+ [0x00A2] = "Vertu Corporation Limited",
+ [0x00A1] = "SR-Medizinelektronik",
+ [0x00A0] = "Kensington Computer Products Group",
+ [0x009F] = "Suunto Oy",
+ [0x009E] = "Bose Corporation",
+ [0x009D] = "Geoforce Inc.",
+ [0x009C] = "Colorfy, Inc.",
+ [0x009B] = "Jiangsu Toppower Automotive Electronics Co., Ltd.",
+ [0x009A] = "Alpwise",
+ [0x0099] = "i.Tech Dynamic Global Distribution Ltd.",
+ [0x0098] = "zero1.tv GmbH",
+ [0x0097] = "ConnecteDevice Ltd.",
+ [0x0096] = "ODM Technology, Inc.",
+ [0x0095] = "NEC Lighting, Ltd.",
+ [0x0094] = "Airoha Technology Corp.",
+ [0x0093] = "Universal Electronics, Inc.",
+ [0x0092] = "ThinkOptics, Inc.",
+ [0x0091] = "Advanced PANMOBIL systems GmbH & Co. KG",
+ [0x0090] = "Funai Electric Co., Ltd.",
+ [0x008F] = "Telit Wireless Solutions GmbH",
+ [0x008E] = "Quintic Corp",
+ [0x008D] = "Zscan Software",
+ [0x008C] = "Gimbal Inc.",
+ [0x008B] = "Topcon Positioning Systems, LLC",
+ [0x008A] = "Jawbone",
+ [0x0089] = "GN Hearing A/S",
+ [0x0088] = "Ecotest",
+ [0x0087] = "Garmin International, Inc.",
+ [0x0086] = "Equinux AG",
+ [0x0085] = "BlueRadios, Inc.",
+ [0x0084] = "Ludus Helsinki Ltd.",
+ [0x0083] = "TimeKeeping Systems, Inc.",
+ [0x0082] = "DSEA A/S",
+ [0x0081] = "WuXi Vimicro",
+ [0x0080] = "DeLorme Publishing Company, Inc.",
+ [0x007F] = "Autonet Mobile",
+ [0x007E] = "Sports Tracking Technologies Ltd.",
+ [0x007D] = "Seers Technology Co., Ltd.",
+ [0x007B] = "Hanlynn Technologies",
+ [0x007A] = "MStar Semiconductor, Inc.",
+ [0x0079] = "lesswire AG",
+ [0x0078] = "Nike, Inc.",
+ [0x0077] = "Laird Connectivity LLC",
+ [0x0076] = "Creative Technology Ltd.",
+ [0x0075] = "Samsung Electronics Co. Ltd.",
+ [0x0074] = "Zomm, LLC",
+ [0x0073] = "Group Sense Ltd.",
+ [0x0072] = "ShangHai Super Smart Electronics Co. Ltd.",
+ [0x0071] = "connectBlue AB",
+ [0x0070] = "Monster, LLC",
+ [0x006F] = "Sound ID",
+ [0x006E] = "Summit Data Communications, Inc.",
+ [0x006C] = "Beautiful Enterprise Co., Ltd.",
+ [0x006B] = "Polar Electro OY",
+ [0x006A] = "LTIMINDTREE LIMITED",
+ [0x0069] = "A&D Engineering, Inc.",
+ [0x0068] = "General Motors",
+ [0x0067] = "GN Hearing",
+ [0x0065] = "HP, Inc.",
+ [0x0064] = "Band XI International, LLC",
+ [0x0063] = "MiCommand Inc.",
+ [0x0062] = "Gibson Guitars",
+ [0x0061] = "RDA Microelectronics",
+ [0x0060] = "RivieraWaves S.A.S",
+ [0x005F] = "Wicentric, Inc.",
+ [0x005E] = "Stonestreet One, LLC",
+ [0x005D] = "Realtek Semiconductor Corporation",
+ [0x005C] = "Belkin International, Inc.",
+ [0x005B] = "Ralink Technology Corporation",
+ [0x005A] = "EM Microelectronic-Marin SA",
+ [0x0059] = "Nordic Semiconductor ASA",
+ [0x0058] = "Vizio, Inc.",
+ [0x0057] = "Harman International Industries, Inc.",
+ [0x0056] = "Sony Ericsson Mobile Communications",
+ [0x0055] = "Plantronics, Inc.",
+ [0x0054] = "3DiJoy Corporation",
+ [0x0053] = "Free2move AB",
+ [0x0052] = "J&M Corporation",
+ [0x0051] = "Tzero Technologies, Inc.",
+ [0x0050] = "SiRF Technology, Inc.",
+ [0x004F] = "APT Ltd.",
+ [0x004E] = "Avago Technologies",
+ [0x004D] = "Staccato Communications, Inc.",
+ [0x004C] = "Apple, Inc.",
+ [0x004B] = "Continental Automotive Systems",
+ [0x004A] = "Accel Semiconductor Ltd.",
+ [0x0049] = "3DSP Corporation",
+ [0x0048] = "Marvell Technology Group Ltd.",
+ [0x0047] = "Bluegiga",
+ [0x0046] = "MediaTek, Inc.",
+ [0x0045] = "Atheros Communications, Inc.",
+ [0x0044] = "Socket Mobile",
+ [0x0043] = "PARROT AUTOMOTIVE SAS",
+ [0x0042] = "CONWISE Technology Corporation Ltd",
+ [0x0041] = "Integrated Silicon Solution Taiwan, Inc.",
+ [0x0040] = "Seiko Epson Corporation",
+ [0x003F] = "Bluetooth SIG, Inc",
+ [0x003E] = "Systems and Chips, Inc",
+ [0x003D] = "IPextreme, Inc.",
+ [0x003C] = "BlackBerry Limited",
+ [0x003B] = "Gennum Corporation",
+ [0x003A] = "Panasonic Holdings Corporation",
+ [0x0039] = "Integrated System Solution Corp.",
+ [0x0038] = "Syntronix Corporation",
+ [0x0037] = "Mobilian Corporation",
+ [0x0036] = "Renesas Electronics Corporation",
+ [0x0035] = "Eclipse (HQ Espana) S.L.",
+ [0x0034] = "Computer Access Technology Corporation (CATC)",
+ [0x0033] = "Commil Ltd",
+ [0x0032] = "Red-M (Communications) Ltd",
+ [0x0031] = "Synopsys, Inc.",
+ [0x0030] = "ST Microelectronics",
+ [0x002F] = "MewTel Technology Inc.",
+ [0x002E] = "Norwood Systems",
+ [0x002D] = "GCT Semiconductor",
+ [0x002C] = "Macronix International Co. Ltd.",
+ [0x002B] = "Tenovis",
+ [0x002A] = "Symbol Technologies, Inc.",
+ [0x0029] = "Hitachi Ltd",
+ [0x0028] = "R F Micro Devices",
+ [0x0027] = "Open Interface",
+ [0x0026] = "C Technologies",
+ [0x0025] = "NXP B.V.",
+ [0x0024] = "Alcatel",
+ [0x0023] = "WavePlus Technology Co., Ltd.",
+ [0x0022] = "NEC Corporation",
+ [0x0021] = "Mansella Ltd",
+ [0x0020] = "BandSpeed, Inc.",
+ [0x001F] = "AVM Berlin",
+ [0x001E] = "Inventel",
+ [0x001D] = "Qualcomm",
+ [0x001C] = "Conexant Systems Inc.",
+ [0x001B] = "Signia Technologies, Inc.",
+ [0x001A] = "TTPCom Limited",
+ [0x0019] = "Rohde & Schwarz GmbH & Co. KG",
+ [0x0018] = "Transilica, Inc.",
+ [0x0017] = "Newlogic",
+ [0x0016] = "KC Technology Inc.",
+ [0x0015] = "RTX A/S",
+ [0x0014] = "Mitsubishi Electric Corporation",
+ [0x0013] = "Atmel Corporation",
+ [0x0012] = "Zeevo, Inc.",
+ [0x0011] = "Widcomm, Inc.",
+ [0x0010] = "Mitel Semiconductor",
+ [0x000F] = "Broadcom Corporation",
+ [0x000E] = "Parthus Technologies Inc.",
+ [0x000D] = "Texas Instruments Inc.",
+ [0x000C] = "Digianswer A/S",
+ [0x000B] = "Silicon Wave",
+ [0x000A] = "Qualcomm Technologies International, Ltd. (QTIL)",
+ [0x0009] = "Infineon Technologies AG",
+ [0x0008] = "Motorola",
+ [0x0007] = "Lucent",
+ [0x0006] = "Microsoft",
+ [0x0005] = "3Com",
+ [0x0004] = "Toshiba Corp.",
+ [0x0003] = "IBM Corp.",
+ [0x0002] = "Intel Corp.",
+ [0x0001] = "Nokia Mobile Phones",
+ [0x0000] = "Ericsson AB",
+}
+
+--- Lookup company name by ID
+--- @param companyId integer The company identifier
+--- @return string|nil name The company name or nil if not found
+function M.getName(companyId)
+ return M.names[companyId]
+end
+
+return M
diff --git a/src/esphome/ble/coordinator/device_registry.lua b/src/esphome/ble/coordinator/device_registry.lua
new file mode 100644
index 0000000..5dde1b6
--- /dev/null
+++ b/src/esphome/ble/coordinator/device_registry.lua
@@ -0,0 +1,270 @@
+--- Device Registry for Bluetooth Coordinator.
+--- Tracks registered BLE devices with RSSI tracking per proxy.
+
+local log = require("lib.logging")
+
+--- @class DeviceInfo
+--- @field name string? Device name from advertisement
+--- @field mac string MAC address in format "AA:BB:CC:DD:EE:FF"
+--- @field addressType BLEAddressType? Bluetooth address type
+--- @field deviceType string? Derived device type (set by scanner during scans)
+--- @field passive boolean Whether device uses passive advertisement mode
+--- @field bindingClass string? Control4 binding class (set by scanner during scans)
+--- @field bindingId integer? Dynamic binding ID for child driver
+--- @field lastSeen integer? Timestamp of last advertisement
+
+--- @class RSSIReading
+--- @field rssi number? RSSI in dBm
+--- @field timestamp integer When this reading was taken
+--- @field smoothedRssi number EMA-smoothed RSSI value
+
+--- @class AdvertisementHistory
+--- @field name string? Last device name
+--- @field mfgDataStr string Serialized manufacturer data for comparison
+--- @field serviceDataStr string Serialized service data for comparison
+
+--- @class DeviceRegistry
+--- @field _devices table Map of MAC address to device info
+--- @field _rssiMap table?> Map of MAC -> (proxyDeviceId -> RSSI reading)
+--- @field _advHistory table Map of MAC -> last advertisement data for deduplication
+local DeviceRegistry = {}
+DeviceRegistry.__index = DeviceRegistry
+
+--- Create a new DeviceRegistry instance
+--- @return DeviceRegistry
+function DeviceRegistry:new()
+ local instance = setmetatable({}, self)
+ instance._devices = {}
+ instance._rssiMap = {}
+ instance._advHistory = {}
+ return instance
+end
+
+--- Register a device for tracking
+--- @param info DeviceInfo Initial device info (deviceType, bindingClass, name, etc.)
+--- @return DeviceInfo device The registered device
+function DeviceRegistry:registerDevice(info)
+ local device = self._devices[info.mac]
+ if device then
+ -- Update existing device with new info
+ device.name = info.name or device.name
+ device.deviceType = info.deviceType or device.deviceType
+ device.passive = info.passive or device.passive
+ device.bindingClass = info.bindingClass or device.bindingClass
+ device.bindingId = info.bindingId or device.bindingId
+ log:debug("Updated registered device: %s", info.mac)
+ else
+ -- Create new device
+ --- @type DeviceInfo
+ device = {
+ name = info.name,
+ mac = info.mac,
+ addressType = info.addressType,
+ deviceType = info.deviceType,
+ passive = info.passive,
+ bindingClass = info.bindingClass,
+ bindingId = info.bindingId,
+ lastSeen = info.lastSeen,
+ }
+ self._devices[info.mac] = device
+ self._rssiMap[info.mac] = nil
+ self._advHistory[info.mac] = nil
+ log:info("Registered device: %s (%s)", info.mac, device.deviceType or "unknown")
+ end
+
+ return device
+end
+
+--- Unregister a device from tracking
+--- @param mac string MAC address
+function DeviceRegistry:unregisterDevice(mac)
+ local device = self._devices[mac]
+ if device then
+ log:info("Unregistered device: %s", mac)
+ end
+ self._devices[mac] = nil
+ self._rssiMap[mac] = nil
+ self._advHistory[mac] = nil
+end
+
+--- Process a BLE advertisement from a proxy (only for registered devices)
+--- @param proxyDeviceId integer The proxy device ID
+--- @param adv BLEAdvertisement Advertisement data from proxy
+--- @return DeviceInfo|nil device The device info if registered, nil otherwise
+--- @return boolean isDuplicate Whether this is a duplicate advertisement
+function DeviceRegistry:processAdvertisement(proxyDeviceId, adv)
+ local mac = adv.mac
+ local device = mac and self._devices[mac]
+
+ -- Only process advertisements for registered devices
+ if not device then
+ return nil, false
+ end
+
+ -- Always update RSSI
+ local rssi = adv.rssi or -999
+ self:_updateRSSI(mac, proxyDeviceId, rssi)
+
+ -- Update device info
+ device.lastSeen = os.time()
+ if not IsEmpty(adv.name) then
+ device.name = adv.name
+ end
+ if adv.addressType then
+ device.addressType = adv.addressType
+ end
+
+ -- Check for duplicate advertisement (same serviceData, manufacturerData, name)
+ local isDuplicate = self:_isDuplicateAdvertisement(mac, adv)
+
+ return device, isDuplicate
+end
+
+--- Check if an advertisement is a duplicate
+--- Compares serviceData, manufacturerData, and name to detect duplicates.
+--- @param mac string MAC address
+--- @param adv BLEAdvertisement Advertisement data
+--- @return boolean isDuplicate
+--- @private
+function DeviceRegistry:_isDuplicateAdvertisement(mac, adv)
+ local last = self._advHistory[mac]
+
+ -- Serialize for comparison (handles nil gracefully)
+ local serviceDataStr = SerializeSafe(adv.serviceData)
+ local mfgDataStr = SerializeSafe(adv.manufacturerData)
+
+ local isDup = last ~= nil
+ and last.serviceDataStr == serviceDataStr
+ and last.mfgDataStr == mfgDataStr
+ and last.name == adv.name
+
+ if not isDup then
+ self._advHistory[mac] = {
+ serviceDataStr = serviceDataStr,
+ mfgDataStr = mfgDataStr,
+ name = adv.name,
+ }
+ end
+
+ return isDup
+end
+
+--- Update RSSI reading for a device from a specific proxy
+--- @param mac string MAC address
+--- @param proxyDeviceId integer Proxy device ID
+--- @param rssi number RSSI value in dBm
+--- @param smoothingAlpha number|nil Smoothing factor (default 0.2)
+--- @private
+function DeviceRegistry:_updateRSSI(mac, proxyDeviceId, rssi, smoothingAlpha)
+ smoothingAlpha = smoothingAlpha or 0.2
+
+ if not self._rssiMap[mac] then
+ self._rssiMap[mac] = {}
+ end
+
+ --- @type RSSIReading?
+ local reading = self._rssiMap[mac][proxyDeviceId]
+ local now = os.time()
+
+ if reading then
+ -- Apply exponential moving average
+ reading.smoothedRssi = smoothingAlpha * rssi + (1 - smoothingAlpha) * reading.smoothedRssi
+ reading.rssi = rssi
+ reading.timestamp = now
+ else
+ -- First reading
+ self._rssiMap[mac][proxyDeviceId] = {
+ rssi = rssi,
+ smoothedRssi = rssi,
+ timestamp = now,
+ }
+ end
+end
+
+--- Get RSSI readings for a device from all proxies
+--- @param mac string MAC address
+--- @param maxAge number|nil Maximum age in seconds (default: no limit)
+--- @return table Map of device ID to RSSI reading
+function DeviceRegistry:getRSSIReadings(mac, maxAge)
+ local readings = mac and self._rssiMap[mac]
+ if not readings then
+ return {}
+ end
+
+ if not maxAge then
+ return readings
+ end
+
+ -- Filter by age
+ local now = os.time()
+ local result = {}
+ for proxyDeviceId, reading in pairs(readings) do
+ if (now - reading.timestamp) <= maxAge then
+ result[proxyDeviceId] = reading
+ end
+ end
+ return result
+end
+
+--- Get the best (highest) RSSI for a device
+--- @param mac string MAC address
+--- @param maxAge number|nil Maximum age in seconds
+--- @return number|nil rssi Best RSSI value
+--- @return integer|nil deviceId The proxy device ID with best RSSI
+function DeviceRegistry:getBestRSSI(mac, maxAge)
+ local readings = self:getRSSIReadings(mac, maxAge)
+ local bestRssi = nil
+ local bestDeviceId = nil
+
+ for proxyDeviceId, reading in pairs(readings) do
+ if not bestRssi or reading.smoothedRssi > bestRssi then
+ bestRssi = reading.smoothedRssi
+ bestDeviceId = proxyDeviceId
+ end
+ end
+
+ return bestRssi, bestDeviceId
+end
+
+--- Get a device by MAC address
+--- @param mac string MAC address
+--- @return DeviceInfo|nil
+function DeviceRegistry:getDevice(mac)
+ return mac and self._devices[mac]
+end
+
+--- Get count of registered devices
+--- @return integer
+function DeviceRegistry:getDeviceCount()
+ return TableLength(self._devices)
+end
+
+--- Get all registered devices
+--- @return table devices Map of MAC to device info
+function DeviceRegistry:getDevices()
+ return self._devices
+end
+
+--- Clear all device data (for reset)
+function DeviceRegistry:clear()
+ self._devices = {}
+ self._rssiMap = {}
+ self._advHistory = {}
+end
+
+--- Clear RSSI data for a specific proxy (when proxy disconnects)
+--- @param proxyDeviceId integer The proxy device ID
+function DeviceRegistry:clearProxyRSSI(proxyDeviceId)
+ local cleared = 0
+ for _, readings in pairs(self._rssiMap) do
+ if readings[proxyDeviceId] then
+ readings[proxyDeviceId] = nil
+ cleared = cleared + 1
+ end
+ end
+ if cleared > 0 then
+ log:debug("Cleared RSSI data for proxy device %d from %d devices", proxyDeviceId, cleared)
+ end
+end
+
+return DeviceRegistry:new()
diff --git a/src/esphome/ble/coordinator/presence_tracker.lua b/src/esphome/ble/coordinator/presence_tracker.lua
new file mode 100644
index 0000000..ac12ced
--- /dev/null
+++ b/src/esphome/ble/coordinator/presence_tracker.lua
@@ -0,0 +1,955 @@
+--- Presence Tracker for Bluetooth Coordinator.
+--- Implements ESPresence-style room presence detection with anti-flapping.
+
+local log = require("lib.logging")
+local events = require("lib.events")
+local values = require("lib.values")
+local bindings = require("lib.bindings")
+local proxyRegistry = require("esphome.ble.coordinator.proxy_registry")
+local deviceRegistry = require("esphome.ble.coordinator.device_registry")
+
+--- @class PresenceDeviceConfig
+--- @field mac string MAC address
+--- @field name string Display name
+--- @field type string Device type (phone, watch, beacon, etc.)
+--- @field txPower number Reference RSSI at 1 meter
+
+--- @class PresenceDeviceState
+--- @field room string|nil Current room name
+--- @field roomId integer|nil Current room ID
+--- @field lastSeen integer? Timestamp of last sighting
+--- @field pendingTransition PendingTransition|nil Pending room change
+--- @field isHome boolean Whether device is considered "home"
+
+--- @class PendingTransition
+--- @field targetRoom string The room we might transition to
+--- @field targetRoomId integer The room ID
+--- @field startTime number When we first saw this room as best
+--- @field rssiReadings number[] RSSI readings during dwell period
+
+--- @class RSSIState
+--- @field smoothedRssi number EMA-smoothed RSSI value
+--- @field lastRawRssi number Most recent raw reading
+--- @field lastUpdate number Timestamp of last update
+
+--- @class PresenceTracker
+--- @field _presenceDevices table MAC -> config
+--- @field _smoothingAlpha number EMA smoothing factor (0.1-0.5)
+--- @field _hysteresisMargin number dBm margin for room changes
+--- @field _dwellTime number Seconds to dwell before committing room change
+--- @field _awayTimeout number Seconds without signal before marking away
+--- @field _minRoomRssi number Minimum RSSI to assign room (-100 = disabled)
+--- @field _deviceState table MAC -> state
+--- @field _rssiState table "mac_proxyDeviceId" -> state
+--- @field _roomOccupancy table?> roomId -> {mac -> true}
+--- @field _roomBindings table roomId -> bindingId
+--- @field _deviceBindings table mac -> bindingId
+--- @field _roomNames table roomId -> roomName (for cleanup)
+local PresenceTracker = {}
+PresenceTracker.__index = PresenceTracker
+
+local NAMESPACE = "presence"
+local BINDINGS_NAMESPACE = "presence"
+
+--- Generate a unique display name with partial MAC suffix
+--- @param name string Base display name
+--- @param mac string MAC address
+--- @return string uniqueName The name with partial MAC, e.g., "John's Phone [AABBCCDDEEFF]"
+local function makeUniqueName(name, mac)
+ local cleanMac = mac:gsub(":", "")
+ return name .. " [" .. cleanMac .. "]"
+end
+
+--- Generate a unique room display name with room ID suffix
+--- @param roomName string The base room name
+--- @param roomId integer The room ID
+--- @return string uniqueName The name with room ID, e.g., "Kitchen [123]"
+local function makeUniqueRoomName(roomName, roomId)
+ return roomName .. " [" .. tostring(roomId) .. "]"
+end
+
+--- Create a new PresenceTracker instance
+--- @return PresenceTracker
+function PresenceTracker:new()
+ local instance = setmetatable({}, self)
+
+ -- Configuration
+ instance._presenceDevices = {}
+ instance._smoothingAlpha = 0.2
+ instance._hysteresisMargin = 6
+ instance._dwellTime = 5
+ instance._awayTimeout = 120
+ instance._minRoomRssi = -100 -- Global minimum RSSI to assign a room (-100 = disabled)
+
+ -- State
+ instance._deviceState = {}
+ instance._rssiState = {}
+ instance._roomOccupancy = {}
+
+ -- Bindings for presence sensors
+ instance._roomBindings = {}
+ instance._deviceBindings = {}
+ instance._roomNames = {}
+
+ return instance
+end
+
+--- Configure presence tracking settings
+--- @param settings table Settings from properties
+function PresenceTracker:configure(settings)
+ if settings.smoothingAlpha then
+ self._smoothingAlpha = tonumber(settings.smoothingAlpha) or 0.2
+ end
+ if settings.hysteresisMargin then
+ self._hysteresisMargin = tonumber(settings.hysteresisMargin) or 6
+ end
+ if settings.dwellTime then
+ self._dwellTime = tonumber(settings.dwellTime) or 5
+ end
+ if settings.awayTimeout then
+ self._awayTimeout = tonumber(settings.awayTimeout) or 120
+ end
+ if settings.minRoomRssi then
+ self._minRoomRssi = tointeger(settings.minRoomRssi) or -100
+ end
+
+ log:info(
+ "Presence settings: smoothing=%.2f, hysteresis=%ddBm, dwell=%ds, away=%ds, minRoomRssi=%ddBm",
+ self._smoothingAlpha,
+ self._hysteresisMargin,
+ self._dwellTime,
+ self._awayTimeout,
+ self._minRoomRssi
+ )
+end
+
+--- Add a device to track for presence
+--- @param mac string MAC address
+--- @param config PresenceDeviceConfig Configuration
+function PresenceTracker:trackDevice(mac, config)
+ self._presenceDevices[mac] = config
+
+ -- Initialize state
+ self._deviceState[mac] = {
+ room = nil,
+ roomId = nil,
+ lastSeen = nil,
+ pendingTransition = nil,
+ isHome = false,
+ }
+
+ local uniqueName = makeUniqueName(config.name, mac)
+
+ -- Create dynamic binding for this device's presence
+ self:_createDeviceBinding(mac, uniqueName)
+
+ -- Create events for this device
+ self:_createDeviceEvents(mac, uniqueName)
+
+ -- Create variables for this device
+ values:update("Presence " .. uniqueName .. " Room", "", "STRING")
+ values:update("Presence " .. uniqueName .. " Distance", "0", "NUMBER")
+ values:update("Presence " .. uniqueName .. " RSSI", "-999", "NUMBER")
+ --values:update("Presence " .. uniqueName .. " Last Seen", "", "STRING")
+
+ log:info("Added presence device: %s (%s)", uniqueName, mac)
+end
+
+--- Remove a device from presence tracking
+--- @param mac string MAC address
+function PresenceTracker:untrackDevice(mac)
+ local config = self._presenceDevices[mac]
+ if not config then
+ return
+ end
+
+ local uniqueName = makeUniqueName(config.name, mac)
+ local macKey = mac:gsub(":", "")
+
+ -- Clean up state
+ self._deviceState[mac] = nil
+ self._presenceDevices[mac] = nil
+
+ -- Clean up RSSI state
+ for key in pairs(self._rssiState) do
+ if key:match("^" .. mac .. "_") then
+ self._rssiState[key] = nil
+ end
+ end
+
+ -- Remove binding
+ if self._deviceBindings[mac] then
+ bindings:deleteBinding(BINDINGS_NAMESPACE, "device_" .. macKey)
+ self._deviceBindings[mac] = nil
+ end
+
+ -- Remove events
+ events:deleteEvent(NAMESPACE, "device_" .. macKey .. "_home")
+ events:deleteEvent(NAMESPACE, "device_" .. macKey .. "_away")
+ events:deleteEvent(NAMESPACE, "device_" .. macKey .. "_entered_room")
+ events:deleteEvent(NAMESPACE, "device_" .. macKey .. "_left_room")
+
+ -- Remove variables
+ values:delete("Presence " .. uniqueName .. " Room")
+ values:delete("Presence " .. uniqueName .. " Distance")
+ values:delete("Presence " .. uniqueName .. " RSSI")
+ --values:delete("Presence " .. uniqueName .. " Last Seen")
+
+ log:info("Removed presence device: %s", mac)
+end
+
+--- Create a contact sensor binding for a presence device
+--- @param mac string MAC address
+--- @param name string Display name
+--- @private
+function PresenceTracker:_createDeviceBinding(mac, name)
+ local binding = bindings:getOrAddDynamicBinding(
+ BINDINGS_NAMESPACE,
+ "device_" .. mac:gsub(":", ""),
+ "PROXY",
+ true,
+ name .. " Present",
+ "CONTACT_SENSOR"
+ )
+
+ if binding then
+ self._deviceBindings[mac] = binding.bindingId
+ end
+end
+
+--- Create events for a presence device
+--- @param mac string MAC address
+--- @param name string Display name
+--- @private
+--- @diagnostic disable-next-line: unused
+function PresenceTracker:_createDeviceEvents(mac, name)
+ local macKey = mac:gsub(":", "")
+
+ events:getOrAddEvent(
+ NAMESPACE,
+ "device_" .. macKey .. "_home",
+ name .. " Home",
+ "Fired when " .. name .. " arrives home"
+ )
+
+ events:getOrAddEvent(
+ NAMESPACE,
+ "device_" .. macKey .. "_away",
+ name .. " Away",
+ "Fired when " .. name .. " leaves home"
+ )
+
+ events:getOrAddEvent(
+ NAMESPACE,
+ "device_" .. macKey .. "_entered_room",
+ name .. " Entered Room",
+ "Fired when " .. name .. " enters a room (check 'Last Presence Room' variable)"
+ )
+
+ events:getOrAddEvent(
+ NAMESPACE,
+ "device_" .. macKey .. "_left_room",
+ name .. " Left Room",
+ "Fired when " .. name .. " leaves a room"
+ )
+end
+
+--- Ensure room bindings and events exist for a room
+--- @param roomId integer Room ID
+--- @param roomName string Room name
+--- @private
+function PresenceTracker:_ensureRoomSetup(roomId, roomName)
+ -- Check if room already exists
+ local existingName = self._roomNames[roomId]
+ if existingName then
+ if existingName ~= roomName then
+ -- Different proxies reporting different names for same room ID
+ -- This usually means misconfigured "Bluetooth Proxy Room" properties
+ -- Just use the existing name and don't recreate resources
+ log:debug(
+ "Room %d has conflicting names from different proxies: '%s' vs '%s' (using '%s')",
+ roomId,
+ existingName,
+ roomName,
+ existingName
+ )
+ end
+ return -- Already set up
+ end
+
+ local uniqueRoomName = makeUniqueRoomName(roomName, roomId)
+
+ -- Create binding for room occupancy
+ local binding = bindings:getOrAddDynamicBinding(
+ BINDINGS_NAMESPACE,
+ "room_" .. tostring(roomId),
+ "PROXY",
+ true,
+ uniqueRoomName .. " Occupied",
+ "CONTACT_SENSOR"
+ )
+
+ if binding then
+ self._roomBindings[roomId] = binding.bindingId
+ end
+
+ -- Create events for room
+ events:getOrAddEvent(
+ NAMESPACE,
+ "room_" .. tostring(roomId) .. "_occupied",
+ uniqueRoomName .. " Occupied",
+ "Fired when " .. uniqueRoomName .. " becomes occupied"
+ )
+
+ events:getOrAddEvent(
+ NAMESPACE,
+ "room_" .. tostring(roomId) .. "_empty",
+ uniqueRoomName .. " Empty",
+ "Fired when " .. uniqueRoomName .. " becomes empty"
+ )
+
+ -- Create variables for room
+ values:update(uniqueRoomName .. " Occupied", "false", "STRING")
+ values:update(uniqueRoomName .. " Occupant Count", "0", "NUMBER")
+ values:update(uniqueRoomName .. " Occupants", "", "STRING")
+
+ -- Initialize occupancy tracking
+ self._roomOccupancy[roomId] = {}
+
+ -- Store room name for cleanup
+ self._roomNames[roomId] = roomName
+
+ log:info("Set up presence tracking for room: %s (%d)", uniqueRoomName, roomId)
+end
+
+--- Clean up a room's presence tracking resources
+--- @param roomId integer Room ID
+--- @private
+function PresenceTracker:_cleanupRoom(roomId)
+ local roomName = self._roomNames[roomId]
+ if not roomName then
+ return -- Room was never set up
+ end
+
+ local uniqueRoomName = makeUniqueRoomName(roomName, roomId)
+ log:info("Cleaning up presence tracking for room: %s (%d)", uniqueRoomName, roomId)
+
+ -- Remove binding
+ if self._roomBindings[roomId] then
+ bindings:deleteBinding(BINDINGS_NAMESPACE, "room_" .. tostring(roomId))
+ self._roomBindings[roomId] = nil
+ end
+
+ -- Remove events
+ events:deleteEvent(NAMESPACE, "room_" .. tostring(roomId) .. "_occupied")
+ events:deleteEvent(NAMESPACE, "room_" .. tostring(roomId) .. "_empty")
+
+ -- Remove variables
+ values:delete(uniqueRoomName .. " Occupied")
+ values:delete(uniqueRoomName .. " Occupant Count")
+ values:delete(uniqueRoomName .. " Occupants")
+
+ -- Remove occupancy tracking
+ self._roomOccupancy[roomId] = nil
+
+ -- Remove room name tracking
+ self._roomNames[roomId] = nil
+end
+
+--- Clean up rooms that are no longer used by any proxy
+--- Should be called when proxies disconnect or change rooms
+function PresenceTracker:cleanupUnusedRooms()
+ local usedRoomIds = {}
+
+ -- Get all room IDs currently in use by connected proxies
+ for _, proxy in ipairs(proxyRegistry:getConnectedProxies()) do
+ if proxy.roomId then
+ usedRoomIds[proxy.roomId] = true
+ end
+ end
+
+ -- Clean up rooms that are no longer in use
+ for roomId, _ in pairs(self._roomNames) do
+ if not usedRoomIds[roomId] then
+ self:_cleanupRoom(roomId)
+ end
+ end
+end
+
+--- Calculate estimated distance from RSSI using log-distance path loss model
+--- @param rssi number Measured RSSI in dBm
+--- @param txPower number? Reference RSSI at 1 meter (default -59)
+--- @param pathLoss number? Path loss exponent (default 2.5 for indoor)
+--- @return number Estimated distance in meters
+--- @private
+--- @diagnostic disable-next-line: unused
+function PresenceTracker:_estimateDistance(rssi, txPower, pathLoss)
+ txPower = txPower or -59
+ pathLoss = pathLoss or 2.5
+ return 10 ^ ((txPower - rssi) / (10 * pathLoss))
+end
+
+--- Update smoothed RSSI for a device from a specific proxy
+--- @param mac string MAC address
+--- @param proxyDeviceId integer Proxy device ID
+--- @param rawRssi number Raw RSSI value
+--- @return number smoothedRssi The smoothed RSSI value
+--- @private
+function PresenceTracker:_updateSmoothedRSSI(mac, proxyDeviceId, rawRssi)
+ local key = mac .. "_" .. tostring(proxyDeviceId)
+ local state = self._rssiState[key]
+ local now = os.time()
+
+ if state == nil then
+ -- First reading - initialize with raw value
+ self._rssiState[key] = {
+ smoothedRssi = rawRssi,
+ lastRawRssi = rawRssi,
+ lastUpdate = now,
+ }
+ return rawRssi
+ else
+ -- Apply exponential moving average
+ state.smoothedRssi = self._smoothingAlpha * rawRssi + (1 - self._smoothingAlpha) * state.smoothedRssi
+ state.lastRawRssi = rawRssi
+ state.lastUpdate = now
+ return state.smoothedRssi
+ end
+end
+
+--- Get current room RSSI for a device
+--- @param mac string MAC address
+--- @param roomId integer Room ID
+--- @return number|nil rssi The current RSSI, or nil if not available
+--- @private
+function PresenceTracker:_getCurrentRoomRSSI(mac, roomId)
+ local proxies = proxyRegistry:getProxiesByRoom(roomId)
+ local bestRssi = nil
+
+ for _, proxy in ipairs(proxies) do
+ local key = mac .. "_" .. tostring(proxy.deviceId)
+ local state = self._rssiState[key]
+ if state and (not bestRssi or state.smoothedRssi > bestRssi) then
+ bestRssi = state.smoothedRssi
+ end
+ end
+
+ return bestRssi
+end
+
+--- Determine which room a device is in based on RSSI
+--- @param mac string MAC address
+--- @return string|nil room Room name
+--- @return integer|nil roomId Room ID
+--- @return number|nil rssi Best RSSI
+--- @return integer|nil proxyDeviceId The proxy device ID with best signal
+--- @private
+--- @diagnostic disable-next-line: unused
+function PresenceTracker:_determineRoom(mac)
+ local rssiMaxAge = 30 -- Only consider recent readings
+ local bestRssi, bestDeviceId = deviceRegistry:getBestRSSI(mac, rssiMaxAge)
+
+ if not bestDeviceId then
+ return nil, nil, nil, nil
+ end
+
+ local proxy = proxyRegistry:getProxy(bestDeviceId)
+ if not proxy or not proxy.roomId then
+ return nil, nil, bestRssi, bestDeviceId
+ end
+
+ -- Get effective minimum RSSI threshold (per-proxy override or global default)
+ local threshold = self._minRoomRssi
+ if proxy.minRssiOverride and proxy.minRssiOverride > -100 then
+ threshold = proxy.minRssiOverride
+ end
+
+ -- Check minimum RSSI threshold for room assignment
+ -- Device with signal below threshold is "home" but not in any room
+ if bestRssi and bestRssi < threshold then
+ log:debug(
+ "Device %s RSSI %d below threshold %d (proxy %s), no room assigned",
+ mac,
+ bestRssi,
+ threshold,
+ proxy.roomName or "Unknown"
+ )
+ return nil, nil, bestRssi, bestDeviceId
+ end
+
+ return proxy.roomName, proxy.roomId, bestRssi, bestDeviceId
+end
+
+--- Check if room change should occur (hysteresis check)
+--- @param mac string MAC address
+--- @param currentRoomId integer|nil Current room ID
+--- @param newRoomId integer New room ID
+--- @param newRssi number New room's RSSI
+--- @return boolean shouldChange
+--- @private
+function PresenceTracker:_shouldChangeRoom(mac, currentRoomId, newRoomId, newRssi)
+ if currentRoomId == nil then
+ return true -- No current room, accept new one
+ end
+
+ if currentRoomId == newRoomId then
+ return false -- Same room
+ end
+
+ local currentRssi = self:_getCurrentRoomRSSI(mac, currentRoomId)
+ if currentRssi == nil then
+ return true -- No current room signal, accept new one
+ end
+
+ -- Only switch if new room is significantly stronger
+ -- (Remember: RSSI is negative, so -50 > -60)
+ return newRssi > (currentRssi + self._hysteresisMargin)
+end
+
+--- Process a potential room transition with dwell time
+--- @param mac string MAC address
+--- @param candidateRoom string|nil Candidate room name
+--- @param candidateRoomId integer|nil Candidate room ID
+--- @param rssi number RSSI value
+--- @return string|nil room Final room (may be unchanged)
+--- @return integer|nil roomId Final room ID
+--- @private
+function PresenceTracker:_processRoomCandidate(mac, candidateRoom, candidateRoomId, rssi)
+ local state = self._deviceState[mac]
+ if not state then
+ return candidateRoom, candidateRoomId
+ end
+
+ local currentRoom = state.room
+ local currentRoomId = state.roomId
+ local now = os.time()
+
+ -- Same room - reset any pending transition
+ if candidateRoomId == currentRoomId then
+ state.pendingTransition = nil
+ return currentRoom, currentRoomId
+ end
+
+ -- No candidate room
+ if candidateRoomId == nil then
+ state.pendingTransition = nil
+ return currentRoom, currentRoomId
+ end
+
+ -- Check hysteresis
+ if not self:_shouldChangeRoom(mac, currentRoomId, candidateRoomId, rssi) then
+ state.pendingTransition = nil
+ return currentRoom, currentRoomId
+ end
+
+ -- Guard: if we're changing rooms, candidateRoom must be valid
+ if not candidateRoom or not candidateRoomId then
+ return currentRoom, currentRoomId
+ end
+
+ -- Start or continue pending transition
+ local pending = state.pendingTransition
+
+ if pending == nil or pending.targetRoomId ~= candidateRoomId then
+ -- Start new pending transition
+ --- @type PendingTransition
+ local transition = {
+ targetRoom = candidateRoom,
+ targetRoomId = candidateRoomId,
+ startTime = now,
+ rssiReadings = { rssi },
+ }
+ state.pendingTransition = transition
+ return currentRoom, currentRoomId -- Not yet committed
+ end
+
+ -- Continue existing pending transition
+ table.insert(pending.rssiReadings, rssi)
+
+ -- Check if dwell time has passed
+ if (now - pending.startTime) >= self._dwellTime then
+ -- Commit the transition
+ log:info("Device %s transitioning from %s to %s (dwell complete)", mac, currentRoom or "Unknown", candidateRoom)
+
+ local previousRoom = currentRoom
+ local previousRoomId = currentRoomId
+
+ state.room = candidateRoom
+ state.roomId = candidateRoomId
+ state.pendingTransition = nil
+
+ -- Fire events and update state
+ self:_onDeviceEnteredRoom(mac, candidateRoom, candidateRoomId, previousRoom, previousRoomId, rssi)
+
+ return candidateRoom, candidateRoomId
+ end
+
+ return currentRoom, currentRoomId -- Still dwelling
+end
+
+--- Process an advertisement for a presence device
+--- @param mac string MAC address
+--- @param proxyDeviceId integer The proxy device ID that saw this device
+--- @param rssi number RSSI value
+function PresenceTracker:onAdvertisement(mac, proxyDeviceId, rssi)
+ local config = self._presenceDevices[mac]
+ if not config then
+ return -- Not a tracked presence device
+ end
+
+ local state = self._deviceState[mac]
+ if not state then
+ return
+ end
+
+ -- Update smoothed RSSI
+ self:_updateSmoothedRSSI(mac, proxyDeviceId, rssi)
+
+ -- Update last seen
+ state.lastSeen = os.time()
+
+ -- Check if this is a "home" event
+ if not state.isHome then
+ state.isHome = true
+ self:_onDeviceHome(mac, config.name)
+ end
+
+ -- Determine best room
+ local candidateRoom, candidateRoomId, bestRssi, _ = self:_determineRoom(mac)
+
+ -- Ensure room is set up (use candidateRoom from _determineRoom, not the reporting proxy's room)
+ if candidateRoomId and candidateRoom then
+ self:_ensureRoomSetup(candidateRoomId, candidateRoom)
+ end
+
+ -- Handle signal dropping below threshold - device "left room" but still "home"
+ -- This happens when _determineRoom returns nil room due to RSSI below threshold
+ if candidateRoom == nil and state.room ~= nil then
+ log:info("Device %s signal below threshold, leaving room %s but staying home", mac, state.room)
+ -- Fire left_room event
+ local macKey = mac:gsub(":", "")
+ events:fire(NAMESPACE, "device_" .. macKey .. "_left_room")
+ events:fire(NAMESPACE, "any_device_left_room")
+
+ -- Update last event context
+ local uniqueName = makeUniqueName(config.name, mac)
+ values:update("Last Presence Device MAC", mac, "STRING")
+ values:update("Last Presence Device Name", uniqueName, "STRING")
+ values:update("Last Presence Room", "Home", "STRING") -- Not in a room, but home
+ values:update("Last Presence Previous Room", state.room, "STRING")
+
+ -- Update room occupancy
+ if state.roomId then
+ self:_updateRoomOccupancy(state.roomId, mac, false)
+ end
+
+ -- Clear room state
+ state.room = nil
+ state.roomId = nil
+ state.pendingTransition = nil
+
+ -- Update variables
+ local distance = self:_estimateDistance(bestRssi or rssi, config.txPower)
+ values:update("Presence " .. uniqueName .. " Room", "Home", "STRING") -- "Home" not "Away"
+ values:update("Presence " .. uniqueName .. " Distance", string.format("%.1f", distance), "NUMBER")
+ return
+ end
+
+ -- Process room transition with anti-flapping
+ local finalRoom, _finalRoomId = self:_processRoomCandidate(mac, candidateRoom, candidateRoomId, bestRssi or rssi)
+
+ -- Update variables
+ local uniqueName = makeUniqueName(config.name, mac)
+ local distance = self:_estimateDistance(bestRssi or rssi, config.txPower)
+ values:update("Presence " .. uniqueName .. " Room", finalRoom or "Home", "STRING")
+ values:update("Presence " .. uniqueName .. " Distance", string.format("%.1f", distance), "NUMBER")
+ values:update("Presence " .. uniqueName .. " RSSI", tostring(bestRssi or rssi), "NUMBER")
+ --values:update("Presence " .. uniqueName .. " Last Seen", os.date("%Y-%m-%d %H:%M:%S"), "STRING")
+end
+
+--- Check for away status on all devices
+--- Call this periodically (e.g., every 30 seconds)
+function PresenceTracker:checkAwayStatus()
+ local now = os.time()
+
+ for mac, state in pairs(self._deviceState) do
+ local config = self._presenceDevices[mac]
+ if config and state.isHome then
+ if state.lastSeen and (now - state.lastSeen) > self._awayTimeout then
+ -- Device has gone away
+ local previousRoom = state.room
+ local previousRoomId = state.roomId
+
+ state.room = nil
+ state.roomId = nil
+ state.pendingTransition = nil
+ state.isHome = false
+
+ self:_onDeviceAway(mac, config.name, previousRoom, previousRoomId)
+ end
+ end
+ end
+end
+
+--- Handle device entering a room
+--- @param mac string MAC address
+--- @param room string Room name
+--- @param roomId integer Room ID
+--- @param previousRoom string|nil Previous room name
+--- @param previousRoomId integer|nil Previous room ID
+--- @param rssi number RSSI value
+--- @private
+function PresenceTracker:_onDeviceEnteredRoom(mac, room, roomId, previousRoom, previousRoomId, rssi)
+ local config = self._presenceDevices[mac]
+ if not config then
+ return
+ end
+
+ local uniqueName = makeUniqueName(config.name, mac)
+ local distance = self:_estimateDistance(rssi, config.txPower)
+
+ -- Update per-device state variables
+ values:update("Presence " .. uniqueName .. " Room", room, "STRING")
+ values:update("Presence " .. uniqueName .. " Distance", string.format("%.1f", distance), "NUMBER")
+
+ -- Update last event context (for programming)
+ values:update("Last Presence Device MAC", mac, "STRING")
+ values:update("Last Presence Device Name", uniqueName, "STRING")
+ values:update("Last Presence Room", room, "STRING")
+ values:update("Last Presence Previous Room", previousRoom or "Away", "STRING")
+ values:update("Last Presence Distance", string.format("%.1f", distance), "NUMBER")
+
+ -- Update room occupancy
+ self:_updateRoomOccupancy(roomId, mac, true)
+ if previousRoomId then
+ self:_updateRoomOccupancy(previousRoomId, mac, false)
+ end
+
+ -- Fire device-specific event
+ local macKey = mac:gsub(":", "")
+ events:fire(NAMESPACE, "device_" .. macKey .. "_entered_room")
+
+ -- Fire generic event
+ events:fire(NAMESPACE, "any_device_entered_room")
+
+ -- Update device binding (contact sensor: CLOSED = present in tracked room)
+ local bindingId = self._deviceBindings[mac]
+ if bindingId then
+ SendToProxy(bindingId, "CLOSED", {}, "NOTIFY")
+ end
+end
+
+--- Handle device going away
+--- @param mac string MAC address
+--- @param name string Device name (base name, not unique)
+--- @param previousRoom string|nil Previous room
+--- @param previousRoomId integer|nil Previous room ID
+--- @private
+function PresenceTracker:_onDeviceAway(mac, name, previousRoom, previousRoomId)
+ local uniqueName = makeUniqueName(name, mac)
+ log:info("Device %s (%s) has gone away", uniqueName, mac)
+
+ -- Update variables
+ values:update("Presence " .. uniqueName .. " Room", "Away", "STRING")
+
+ -- Update last event context
+ values:update("Last Presence Device MAC", mac, "STRING")
+ values:update("Last Presence Device Name", uniqueName, "STRING")
+ values:update("Last Presence Room", "Away", "STRING")
+ values:update("Last Presence Previous Room", previousRoom or "Unknown", "STRING")
+
+ -- Update room occupancy
+ if previousRoomId then
+ self:_updateRoomOccupancy(previousRoomId, mac, false)
+ end
+
+ -- Fire events
+ local macKey = mac:gsub(":", "")
+ events:fire(NAMESPACE, "device_" .. macKey .. "_away")
+
+ if previousRoom then
+ events:fire(NAMESPACE, "device_" .. macKey .. "_left_room")
+ events:fire(NAMESPACE, "any_device_left_room")
+ end
+
+ -- Update device binding (contact sensor: OPENED = not present)
+ local bindingId = self._deviceBindings[mac]
+ if bindingId then
+ SendToProxy(bindingId, "OPENED", {}, "NOTIFY")
+ end
+end
+
+--- Handle device coming home
+--- @param mac string MAC address
+--- @param name string Device name
+--- @private
+--- @diagnostic disable-next-line: unused
+function PresenceTracker:_onDeviceHome(mac, name)
+ log:info("Device %s (%s) has arrived home", name, mac)
+
+ local macKey = mac:gsub(":", "")
+ events:fire(NAMESPACE, "device_" .. macKey .. "_home")
+end
+
+--- Update room occupancy tracking
+--- @param roomId integer Room ID
+--- @param mac string MAC address
+--- @param present boolean Whether device is present
+--- @private
+function PresenceTracker:_updateRoomOccupancy(roomId, mac, present)
+ if not self._roomOccupancy[roomId] then
+ self._roomOccupancy[roomId] = {}
+ end
+
+ local wasPreviouslyOccupied = next(self._roomOccupancy[roomId]) ~= nil
+
+ if present then
+ self._roomOccupancy[roomId][mac] = true
+ else
+ self._roomOccupancy[roomId][mac] = nil
+ end
+
+ local isNowOccupied = next(self._roomOccupancy[roomId]) ~= nil
+ local occupantCount = 0
+ local occupantNames = {}
+
+ for occupantMac in pairs(self._roomOccupancy[roomId]) do
+ occupantCount = occupantCount + 1
+ local config = self._presenceDevices[occupantMac]
+ if config then
+ table.insert(occupantNames, config.name)
+ end
+ end
+
+ -- Find room name
+ --- @type string?
+ local roomName = nil
+ for _, p in ipairs(proxyRegistry:getConnectedProxies()) do
+ if p.roomId == roomId then
+ roomName = p.roomName
+ break
+ end
+ end
+
+ if roomName then
+ -- Update room variables using unique room name
+ local uniqueRoomName = makeUniqueRoomName(roomName, roomId)
+ values:update(uniqueRoomName .. " Occupied", isNowOccupied and "true" or "false", "STRING")
+ values:update(uniqueRoomName .. " Occupant Count", tostring(occupantCount), "NUMBER")
+ values:update(uniqueRoomName .. " Occupants", table.concat(occupantNames, ", "), "STRING")
+ end
+
+ -- Update room binding
+ local bindingId = self._roomBindings[roomId]
+ if bindingId then
+ if isNowOccupied then
+ SendToProxy(bindingId, "CLOSED", {}, "NOTIFY") -- Occupied
+ else
+ SendToProxy(bindingId, "OPENED", {}, "NOTIFY") -- Empty
+ end
+ end
+
+ -- Fire room events on transition
+ if not wasPreviouslyOccupied and isNowOccupied then
+ events:fire(NAMESPACE, "room_" .. tostring(roomId) .. "_occupied")
+ elseif wasPreviouslyOccupied and not isNowOccupied then
+ events:fire(NAMESPACE, "room_" .. tostring(roomId) .. "_empty")
+ end
+end
+
+--- Get all tracked presence devices
+--- @return table
+function PresenceTracker:getPresenceDevices()
+ return self._presenceDevices
+end
+
+--- Handle proxy room update (when proxy's room assignment changes)
+--- Recalculates presence for all tracked devices since room mappings may have changed
+--- @param proxyDeviceId integer The proxy device ID that was updated
+function PresenceTracker:onProxyUpdated(proxyDeviceId)
+ log:trace("PresenceTracker:onProxyUpdated(%s)", proxyDeviceId)
+
+ -- Clear pending transitions that might be based on stale room info
+ for mac, state in pairs(self._deviceState) do
+ if state.pendingTransition then
+ log:debug("Clearing pending transition for %s due to proxy room change", mac)
+ state.pendingTransition = nil
+ end
+ end
+
+ -- Recalculate presence for all tracked devices
+ -- The next advertisement will use the updated room info, but we can
+ -- proactively recalculate now using cached RSSI data
+ for mac, config in pairs(self._presenceDevices) do
+ -- Re-determine which room the device is in using current RSSI data
+ local candidateRoom, candidateRoomId, bestRssi, _ = self:_determineRoom(mac)
+
+ if candidateRoomId and candidateRoom then
+ self:_ensureRoomSetup(candidateRoomId, candidateRoom)
+ end
+
+ -- Process room transition (this will handle state changes and events)
+ local state = self._deviceState[mac]
+ if state and state.roomId then
+ local finalRoom, _ = self:_processRoomCandidate(mac, candidateRoom, candidateRoomId, bestRssi or -999)
+
+ -- Update the room variable
+ local uniqueName = makeUniqueName(config.name, mac)
+ values:update("Presence " .. uniqueName .. " Room", finalRoom or "Away", "STRING")
+ end
+ end
+end
+
+--- Clear RSSI state for a specific proxy (when proxy disconnects)
+--- @param proxyDeviceId integer The proxy device ID
+function PresenceTracker:clearProxyRSSI(proxyDeviceId)
+ local suffix = "_" .. tostring(proxyDeviceId)
+ local cleared = 0
+
+ for key in pairs(self._rssiState) do
+ if key:sub(-#suffix) == suffix then
+ self._rssiState[key] = nil
+ cleared = cleared + 1
+ end
+ end
+
+ if cleared > 0 then
+ log:debug("Cleared presence RSSI state for proxy device %d (%d entries)", proxyDeviceId, cleared)
+ end
+
+ -- Reset pending transitions that might rely on the disconnected proxy
+ for _mac, state in pairs(self._deviceState) do
+ if state.pendingTransition then
+ -- If the pending transition was based on the disconnected proxy's room,
+ -- we should re-evaluate. For safety, just clear the pending transition.
+ state.pendingTransition = nil
+ end
+ end
+end
+
+--- Create generic presence events
+--- @diagnostic disable-next-line: unused
+function PresenceTracker:createGenericEvents()
+ events:getOrAddEvent(
+ NAMESPACE,
+ "any_device_entered_room",
+ "Any Device Entered Room",
+ "Fired when any device enters a room. Read 'Last Presence' variables for details."
+ )
+
+ events:getOrAddEvent(
+ NAMESPACE,
+ "any_device_left_room",
+ "Any Device Left Room",
+ "Fired when any device leaves a room. Read 'Last Presence' variables for details."
+ )
+
+ -- Create last event context variables
+ values:update("Last Presence Device MAC", "", "STRING")
+ values:update("Last Presence Device Name", "", "STRING")
+ values:update("Last Presence Room", "", "STRING")
+ values:update("Last Presence Previous Room", "", "STRING")
+ values:update("Last Presence Distance", "0", "NUMBER")
+end
+
+return PresenceTracker:new()
diff --git a/src/esphome/ble/coordinator/proxy_registry.lua b/src/esphome/ble/coordinator/proxy_registry.lua
new file mode 100644
index 0000000..96f0b57
--- /dev/null
+++ b/src/esphome/ble/coordinator/proxy_registry.lua
@@ -0,0 +1,223 @@
+--- Proxy Registry for Bluetooth Coordinator.
+--- Tracks connected ESPHome Bluetooth proxies and their status.
+
+local log = require("lib.logging")
+
+--- @class ProxyInfo
+--- @field deviceId integer The Control4 device ID of the ESPHome proxy
+--- @field proxyId string|nil String version of device ID (from proxy messages)
+--- @field roomId integer|nil The room ID where this proxy is located
+--- @field roomName string|nil The room name where this proxy is located
+--- @field connectionSlots integer Total BLE connection slots available
+--- @field freeSlots integer Currently available BLE connection slots
+--- @field featureFlags integer Bluetooth proxy feature flags
+--- @field connected boolean Whether the proxy is currently connected
+--- @field lastSeen integer Timestamp of last message from proxy
+--- @field minRssiOverride integer|nil Per-proxy RSSI threshold override (-100 = use global default)
+
+--- @class ProxyRegistry
+--- @field _proxies table Map of device ID to proxy info
+--- @field _proxyByProxyId table Map of proxy ID string to proxy info
+--- @field _onProxyChangeCallbacks table Registered callbacks
+local ProxyRegistry = {}
+ProxyRegistry.__index = ProxyRegistry
+
+--- Create a new ProxyRegistry instance
+--- @return ProxyRegistry
+function ProxyRegistry:new()
+ local instance = setmetatable({}, self)
+ instance._proxies = {}
+ instance._proxyByProxyId = {}
+ instance._onProxyChangeCallbacks = {}
+ return instance
+end
+
+--- Register a callback to be notified when proxies change
+--- @param id string Callback identifier
+--- @param callback fun(event: string, proxyInfo: ProxyInfo) Callback function
+function ProxyRegistry:onProxyChange(id, callback)
+ self._onProxyChangeCallbacks[id] = callback
+end
+
+--- Fire proxy change callbacks
+--- @param event string Event type ("connected", "disconnected", "updated")
+--- @param proxyInfo ProxyInfo The proxy that changed
+--- @private
+function ProxyRegistry:_fireProxyChange(event, proxyInfo)
+ for _, callback in pairs(self._onProxyChangeCallbacks) do
+ local ok, err = pcall(callback, event, proxyInfo)
+ if not ok then
+ log:error("Proxy change callback error: %s", err or "unknown error")
+ end
+ end
+end
+
+--- Handle proxy connection on a binding
+--- @param deviceId integer The Control4 device ID that connected
+function ProxyRegistry:onProxyBound(deviceId)
+ log:info("Proxy bound (device %d)", deviceId)
+
+ local proxy = self._proxies[deviceId]
+ if proxy then
+ -- Update existing proxy
+ proxy.connected = true
+ proxy.lastSeen = os.time()
+ else
+ -- Create new proxy entry
+ proxy = {
+ deviceId = deviceId,
+ proxyId = nil,
+ roomId = nil,
+ roomName = nil,
+ connectionSlots = 0,
+ freeSlots = 0,
+ featureFlags = 0,
+ connected = true,
+ lastSeen = os.time(),
+ }
+ self._proxies[deviceId] = proxy
+ end
+end
+
+--- Handle proxy disconnection
+--- @param deviceId integer The device ID
+function ProxyRegistry:onProxyUnbound(deviceId)
+ local proxy = self._proxies[deviceId]
+ if proxy then
+ log:info("Proxy disconnected (device %d)", deviceId)
+ proxy.connected = false
+
+ if proxy.proxyId then
+ self._proxyByProxyId[proxy.proxyId] = nil
+ end
+
+ self:_fireProxyChange("disconnected", proxy)
+ end
+end
+
+--- Handle PROXY_CONNECTED message from a proxy
+--- @param deviceId integer The device ID
+--- @param params table Message parameters
+function ProxyRegistry:onProxyConnected(deviceId, params)
+ local proxy = self._proxies[deviceId]
+ if not proxy then
+ log:warn("Received PROXY_CONNECTED for unknown device %d", deviceId)
+ return
+ end
+
+ proxy.proxyId = params.proxyId
+ proxy.roomId = tointeger(params.roomId) or nil
+ proxy.roomName = params.roomName or nil
+ proxy.connectionSlots = tointeger(params.connectionSlots) or 0
+ proxy.freeSlots = tointeger(params.freeSlots) or 0
+ proxy.featureFlags = tointeger(params.featureFlags) or 0
+ proxy.minRssiOverride = tointeger(params.minRssiOverride) or nil
+ proxy.lastSeen = os.time()
+
+ -- Index by proxyId for faster lookups
+ if proxy.proxyId then
+ self._proxyByProxyId[proxy.proxyId] = proxy
+ end
+
+ log:info(
+ "Proxy %d connected: room=%s, slots=%d/%d",
+ deviceId,
+ proxy.roomName or "Unknown",
+ proxy.freeSlots,
+ proxy.connectionSlots
+ )
+
+ self:_fireProxyChange("connected", proxy)
+end
+
+--- Handle CONNECTION_STATE update from a proxy
+--- @param deviceId integer The device ID
+--- @param params table Message parameters
+function ProxyRegistry:onConnectionState(deviceId, params)
+ local proxy = self._proxies[deviceId]
+ if not proxy then
+ return
+ end
+
+ proxy.connectionSlots = tointeger(params.connectionSlots) or proxy.connectionSlots
+ proxy.freeSlots = tointeger(params.freeSlots) or proxy.freeSlots
+ proxy.lastSeen = os.time()
+
+ -- Update room if provided
+ local roomId = tointeger(params.roomId)
+ if roomId and not IsEmpty(params.roomName) then
+ proxy.roomId = roomId
+ proxy.roomName = params.roomName
+ end
+
+ -- Update minRssiOverride if provided
+ local minRssiOverride = tointeger(params.minRssiOverride)
+ if minRssiOverride then
+ proxy.minRssiOverride = minRssiOverride
+ end
+
+ self:_fireProxyChange("updated", proxy)
+end
+
+--- Get a proxy by device ID
+--- @param deviceId integer The device ID
+--- @return ProxyInfo|nil
+function ProxyRegistry:getProxy(deviceId)
+ return self._proxies[deviceId]
+end
+
+--- Get all connected proxies
+--- @return ProxyInfo[]
+function ProxyRegistry:getConnectedProxies()
+ local result = {}
+ for _, proxy in pairs(self._proxies) do
+ if proxy.connected then
+ table.insert(result, proxy)
+ end
+ end
+ return result
+end
+
+--- Get count of connected proxies
+--- @return integer
+function ProxyRegistry:getConnectedCount()
+ local count = 0
+ for _, proxy in pairs(self._proxies) do
+ if proxy.connected then
+ count = count + 1
+ end
+ end
+ return count
+end
+
+--- Check if a proxy is connected
+--- @param deviceId integer The device ID
+--- @return boolean
+function ProxyRegistry:isProxyConnected(deviceId)
+ local proxy = self._proxies[deviceId]
+ return proxy ~= nil and proxy.connected
+end
+
+--- Find proxies by room
+--- @param roomId integer The room ID
+--- @return ProxyInfo[]
+function ProxyRegistry:getProxiesByRoom(roomId)
+ local result = {}
+ for _, proxy in pairs(self._proxies) do
+ if proxy.connected and proxy.roomId == roomId then
+ table.insert(result, proxy)
+ end
+ end
+ return result
+end
+
+--- Update proxy's last seen timestamp
+--- @param deviceId integer The device ID
+function ProxyRegistry:updateLastSeen(deviceId)
+ local proxy = self._proxies[deviceId]
+ if proxy then
+ proxy.lastSeen = os.time()
+ end
+end
+
+return ProxyRegistry:new()
diff --git a/src/esphome/ble/coordinator/proxy_scanner_node.lua b/src/esphome/ble/coordinator/proxy_scanner_node.lua
new file mode 100644
index 0000000..c15ebfc
--- /dev/null
+++ b/src/esphome/ble/coordinator/proxy_scanner_node.lua
@@ -0,0 +1,31 @@
+--- ProxyScannerNode - Scanner node for coordinator proxy connections.
+--- Wraps a remote ESPHome proxy to receive BLE advertisements via the coordinator.
+--- Note: ESPHome proxies continuously forward advertisements once connected,
+--- so no scan commands are sent - just tracks state for the BLEScanner timing logic.
+
+local BLEScannerNode = require("esphome.ble.scanner_node")
+
+--- @class ProxyScannerNode : BLEScannerNode
+--- @field _proxyDeviceId number The Control4 device ID of the proxy
+--- @field _isConnectedFn fun(): boolean Function to check if proxy is connected
+local ProxyScannerNode = setmetatable({}, { __index = BLEScannerNode })
+ProxyScannerNode.__index = ProxyScannerNode
+
+--- Create a new ProxyScannerNode.
+--- @param proxyDeviceId number The Control4 device ID of the proxy
+--- @param isConnectedFn fun(): boolean Function to check if proxy is connected
+--- @return ProxyScannerNode
+function ProxyScannerNode:new(proxyDeviceId, isConnectedFn)
+ local instance = setmetatable(BLEScannerNode:new(proxyDeviceId), self)
+ instance._proxyDeviceId = proxyDeviceId
+ instance._isConnectedFn = isConnectedFn
+ return instance
+end
+
+--- Check if the proxy is connected.
+--- @return boolean
+function ProxyScannerNode:isConnected()
+ return self._isConnectedFn()
+end
+
+return ProxyScannerNode
diff --git a/src/esphome/ble/coordinator/router.lua b/src/esphome/ble/coordinator/router.lua
new file mode 100644
index 0000000..4d2401e
--- /dev/null
+++ b/src/esphome/ble/coordinator/router.lua
@@ -0,0 +1,505 @@
+--- Router for Bluetooth Coordinator.
+--- Handles RSSI-based proxy selection and failover logic.
+
+local log = require("lib.logging")
+local proxyRegistry = require("esphome.ble.coordinator.proxy_registry")
+local deviceRegistry = require("esphome.ble.coordinator.device_registry")
+
+--------------------------------------------------------------------------------
+-- Response Types
+--------------------------------------------------------------------------------
+
+--- @class GattConnectResponse
+--- @field requestId string The request ID for correlation
+--- @field success string "true" or "false"
+--- @field error string|nil Error message if failed
+--- @field services string|nil Serialized services list
+--- @field mtu string|nil MTU value
+
+--- @class GattWriteResponse
+--- @field requestId string The request ID for correlation
+--- @field success string "true" or "false"
+--- @field error string|nil Error message if failed
+
+--- @class GattReadResponse
+--- @field requestId string The request ID for correlation
+--- @field data string|nil Base64-encoded data if successful
+--- @field error string Error code ("0" for success)
+
+--- @class GattNotifySubscribedResponse
+--- @field requestId string The request ID for correlation
+--- @field success string "true" or "false"
+--- @field error string|nil Error message if failed
+
+--- @class GattNotifyDataResponse
+--- @field mac string MAC address of the device
+--- @field handle string GATT handle as string
+--- @field data string|nil Base64-encoded notification data
+
+--- @class GattDisconnectResponse
+--- @field mac string MAC address of the device
+
+--------------------------------------------------------------------------------
+-- Router Class
+--------------------------------------------------------------------------------
+
+--- @class Router
+--- @field _pendingRequests table Map of request ID -> pending request info
+--- @field _connectedDevices table Map of MAC -> connected proxy device ID
+--- @field _requestIdCounter integer Counter for generating unique request IDs
+local Router = {}
+Router.__index = Router
+
+--- Create a new Router instance
+--- @return Router
+function Router:new()
+ local instance = setmetatable({}, self)
+ instance._pendingRequests = {} -- Track in-flight GATT requests
+ instance._connectedDevices = {} -- Track MAC -> proxy for connected devices
+ instance._requestIdCounter = 0
+ return instance
+end
+
+--- Select the best proxy for a device based on RSSI
+--- @param mac string MAC address
+--- @param rssiMaxAge number? Maximum age of RSSI readings in seconds (default: 60)
+--- @param excludeProxies table? Proxy device IDs to exclude
+--- @return ProxyInfo|nil bestProxy The best proxy, or nil if none available
+--- @return number|nil rssi The RSSI value from that proxy
+--- @private
+--- @diagnostic disable-next-line: unused
+function Router:_selectBestProxy(mac, rssiMaxAge, excludeProxies)
+ rssiMaxAge = rssiMaxAge or 60
+ excludeProxies = excludeProxies or {}
+
+ local readings = deviceRegistry:getRSSIReadings(mac, rssiMaxAge)
+ --- @type {deviceId: integer, rssi: number, timestamp: number}[]
+ local candidates = {}
+
+ for proxyDeviceId, reading in pairs(readings) do
+ if not excludeProxies[proxyDeviceId] and proxyRegistry:isProxyConnected(proxyDeviceId) then
+ table.insert(candidates, {
+ deviceId = proxyDeviceId,
+ rssi = reading.smoothedRssi,
+ timestamp = reading.timestamp,
+ })
+ end
+ end
+
+ -- Sort by RSSI (highest = best signal)
+ table.sort(candidates, function(a, b)
+ return a.rssi > b.rssi
+ end)
+
+ local best = candidates[1]
+ if not best then
+ return nil, nil
+ end
+
+ local proxy = proxyRegistry:getProxy(best.deviceId)
+
+ return proxy, best.rssi
+end
+
+--- Generate a unique request ID for tracking GATT operations
+--- @return string
+--- @private
+function Router:_generateRequestId()
+ self._requestIdCounter = self._requestIdCounter + 1
+ return string.format("req_%d_%d", os.time(), self._requestIdCounter)
+end
+
+--- Get the proxy to use for a device operation.
+--- Returns the connected proxy if device has an active connection, otherwise selects by RSSI.
+--- @param mac string MAC address
+--- @return ProxyInfo|nil proxy The proxy to use
+--- @private
+function Router:_getProxyForDevice(mac)
+ -- First check if device is already connected via a specific proxy
+ local connectedProxyId = mac and self._connectedDevices[mac]
+ if connectedProxyId and proxyRegistry:isProxyConnected(connectedProxyId) then
+ return proxyRegistry:getProxy(connectedProxyId)
+ end
+
+ -- Fall back to RSSI-based selection
+ return (self:_selectBestProxy(mac))
+end
+
+--- Connect to a device with failover support
+--- @param mac string MAC address
+--- @param maxAttempts integer|nil Maximum connection attempts (default: 3)
+--- @param rssiMaxAge number|nil Maximum age of RSSI readings in seconds
+--- @param callback fun(success: boolean, result: GattConnectResponse|string) Called with connection result or error message
+function Router:connectWithFailover(mac, maxAttempts, rssiMaxAge, callback)
+ maxAttempts = maxAttempts or 3
+ rssiMaxAge = rssiMaxAge or 60
+
+ local device = deviceRegistry:getDevice(mac)
+ if not device then
+ callback(false, "Device not found: " .. mac)
+ return
+ end
+
+ local attempted = {}
+ local attempt = 0
+
+ local function tryNextProxy()
+ attempt = attempt + 1
+ if attempt > maxAttempts then
+ callback(false, "All connection attempts failed")
+ return
+ end
+
+ local best, rssi = self:_selectBestProxy(mac, rssiMaxAge, attempted)
+ if not best then
+ callback(false, "No available proxy for " .. mac)
+ return
+ end
+
+ attempted[best.deviceId] = true
+ log:info("Attempt %d: Connecting to %s via proxy device %d (RSSI: %d)", attempt, mac, best.deviceId, rssi or -999)
+
+ local requestId = self:_generateRequestId()
+
+ -- Store pending request
+ self._pendingRequests[requestId] = {
+ mac = mac,
+ proxyDeviceId = best.deviceId,
+ type = "connect",
+ callback = function(success, result)
+ if success then
+ callback(true, result)
+ else
+ log:warn("Connection failed via proxy device %d, trying next...", best.deviceId)
+ tryNextProxy()
+ end
+ end,
+ }
+
+ -- Send connect command to proxy via C4:SendToDevice
+ SendToDevice(best.deviceId, "GATT_CONNECT", {
+ mac = mac,
+
+ addressType = tostring(device.addressType),
+ requestId = requestId,
+ })
+ end
+
+ tryNextProxy()
+end
+
+--- Send a GATT write command
+--- Uses the connected proxy if device has an active connection, otherwise selects by RSSI.
+--- @param mac string MAC address
+--- @param handle number GATT handle
+--- @param data string Data to write (raw bytes)
+--- @param needResponse boolean Whether to wait for response
+--- @param callback fun(success: boolean, error: string|nil) Called with write result
+function Router:gattWrite(mac, handle, data, needResponse, callback)
+ local device = deviceRegistry:getDevice(mac)
+ if not device then
+ callback(false, "Device not found")
+ return
+ end
+
+ local proxy = self:_getProxyForDevice(mac)
+ if not proxy then
+ callback(false, "No available proxy")
+ return
+ end
+
+ local requestId = self:_generateRequestId()
+
+ self._pendingRequests[requestId] = {
+ mac = mac,
+ proxyDeviceId = proxy.deviceId,
+ type = "write",
+ callback = callback,
+ }
+
+ SendToDevice(proxy.deviceId, "GATT_WRITE", {
+ mac = mac,
+
+ addressType = tostring(device.addressType),
+ handle = tostring(handle),
+ data = C4:Base64Encode(data),
+ response = needResponse and "true" or "false",
+ requestId = requestId,
+ })
+end
+
+--- Send a GATT read command
+--- Uses the connected proxy if device has an active connection, otherwise selects by RSSI.
+--- @param mac string MAC address
+--- @param handle number GATT handle
+--- @param callback fun(success: boolean, data: string|nil, error: string|nil) Called with read result
+function Router:gattRead(mac, handle, callback)
+ local device = deviceRegistry:getDevice(mac)
+ if not device then
+ callback(false, nil, "Device not found")
+ return
+ end
+
+ local proxy = self:_getProxyForDevice(mac)
+ if not proxy then
+ callback(false, nil, "No available proxy")
+ return
+ end
+
+ local requestId = self:_generateRequestId()
+
+ self._pendingRequests[requestId] = {
+ mac = mac,
+ proxyDeviceId = proxy.deviceId,
+ type = "read",
+ callback = callback,
+ }
+
+ SendToDevice(proxy.deviceId, "GATT_READ", {
+ mac = mac,
+
+ addressType = tostring(device.addressType),
+ handle = tostring(handle),
+ requestId = requestId,
+ })
+end
+
+--- Subscribe to GATT notifications
+--- Uses the connected proxy if device has an active connection, otherwise selects by RSSI.
+--- @param mac string MAC address
+--- @param handle number GATT handle
+--- @param enable boolean Enable or disable notifications
+--- @param dataCallback fun(data: string) Called with notification data (raw bytes)
+--- @param resultCallback (fun(success: boolean, error: string|nil))? Called with subscription result
+function Router:gattNotify(mac, handle, enable, dataCallback, resultCallback)
+ local device = deviceRegistry:getDevice(mac)
+ if not device then
+ if resultCallback then
+ resultCallback(false, "Device not found")
+ end
+ return
+ end
+
+ local proxy = self:_getProxyForDevice(mac)
+ if not proxy then
+ if resultCallback then
+ resultCallback(false, "No available proxy")
+ end
+ return
+ end
+
+ local requestId = self:_generateRequestId()
+
+ -- Store both callbacks
+ self._pendingRequests[requestId] = {
+ mac = mac,
+ proxyDeviceId = proxy.deviceId,
+ handle = handle,
+ type = "notify",
+ dataCallback = dataCallback,
+ resultCallback = resultCallback,
+ }
+
+ SendToDevice(proxy.deviceId, "GATT_NOTIFY", {
+ mac = mac,
+
+ addressType = tostring(device.addressType),
+ handle = tostring(handle),
+ enable = enable and "true" or "false",
+ requestId = requestId,
+ })
+end
+
+--- Disconnect from a device
+--- Sends disconnect to the proxy where this device is connected.
+--- @param mac string MAC address
+function Router:disconnect(mac)
+ local device = deviceRegistry:getDevice(mac)
+ if not device then
+ return
+ end
+
+ local connectedProxyId = self._connectedDevices[mac]
+ if connectedProxyId ~= nil then
+ -- Disconnect from the tracked proxy
+ SendToDevice(connectedProxyId, "GATT_DISCONNECT", {
+ mac = mac,
+ })
+ -- Clear tracking immediately (will also be cleared on response)
+ self._connectedDevices[mac] = nil
+ end
+end
+
+--- Handle GATT_CONNECT_RESPONSE from proxy
+--- @param response GattConnectResponse Response from proxy
+function Router:onGattConnectResponse(response)
+ local requestId = response.requestId
+ local request = self._pendingRequests[requestId]
+
+ if not request then
+ log:debug("Received GATT_CONNECT_RESPONSE for unknown request: %s", requestId)
+ return
+ end
+
+ self._pendingRequests[requestId] = nil
+
+ local success = response.success == "true"
+ if success then
+ -- Track this device as connected via this proxy
+ self._connectedDevices[request.mac] = request.proxyDeviceId
+
+ -- Deserialize services if provided
+ local services = nil
+ if response.services and response.services ~= "" then
+ local ok, parsed = pcall(DeserializeSafe, response.services)
+ services = ok and parsed or nil
+ end
+
+ request.callback(true, {
+ services = services,
+ mtu = tonumber(response.mtu) or 0,
+ })
+ else
+ request.callback(false, response.error or "Connection failed")
+ end
+end
+
+--- Handle GATT_WRITE_RESPONSE from proxy
+--- @param response GattWriteResponse Response from proxy
+function Router:onGattWriteResponse(response)
+ local requestId = response.requestId
+ local request = self._pendingRequests[requestId]
+
+ if not request then
+ return
+ end
+
+ self._pendingRequests[requestId] = nil
+
+ local success = response.success == "true"
+ request.callback(success, success and nil or (response.error or "Write failed"))
+end
+
+--- Handle GATT_READ_RESPONSE from proxy
+--- @param response GattReadResponse Response from proxy
+function Router:onGattReadResponse(response)
+ local requestId = response.requestId
+ local request = self._pendingRequests[requestId]
+
+ if not request then
+ return
+ end
+
+ self._pendingRequests[requestId] = nil
+
+ local success = response.error == "0"
+ local data = nil
+ if success and response.data then
+ data = C4:Base64Decode(response.data)
+ end
+
+ request.callback(success, data, success and nil or (response.error or "Read failed"))
+end
+
+--- Handle GATT_NOTIFY_SUBSCRIBED from proxy
+--- @param response GattNotifySubscribedResponse Response from proxy
+function Router:onGattNotifySubscribed(response)
+ local requestId = response.requestId
+ local request = self._pendingRequests[requestId]
+
+ if not request then
+ return
+ end
+
+ -- Don't remove request - we need it for data callbacks
+ -- But do call the result callback
+ local success = response.success == "true"
+ if request.resultCallback then
+ request.resultCallback(success, success and nil or (response.error or "Subscription failed"))
+ end
+end
+
+--- Handle GATT_NOTIFY_DATA from proxy
+--- @param notification GattNotifyDataResponse Notification data from proxy
+function Router:onGattNotifyData(notification)
+ local mac = notification.mac
+ local handle = tointeger(notification.handle)
+
+ -- Find the matching request by mac and handle
+ for _, request in pairs(self._pendingRequests) do
+ if request.mac == mac and request.handle == handle and request.type == "notify" then
+ if request.dataCallback then
+ local data = C4:Base64Decode(notification.data or "")
+ request.dataCallback(data)
+ end
+ return
+ end
+ end
+
+ log:debug("Received GATT_NOTIFY_DATA for untracked notification: %s handle %d", mac, handle)
+end
+
+--- Handle GATT_DISCONNECT_RESPONSE from proxy
+--- @param response GattDisconnectResponse Response from proxy
+function Router:onGattDisconnectResponse(response)
+ local mac = response.mac
+ if not mac then
+ return
+ end
+
+ -- Clear connection tracking
+ self._connectedDevices[mac] = nil
+
+ -- Clean up any pending notification requests for this device
+ for requestId, request in pairs(self._pendingRequests) do
+ if request.mac == mac and request.type == "notify" then
+ self._pendingRequests[requestId] = nil
+ end
+ end
+end
+
+--- Handle proxy disconnection - clean up pending requests and tracked devices for that proxy
+--- @param proxyDeviceId integer The disconnected proxy's device ID
+function Router:onProxyDisconnected(proxyDeviceId)
+ -- Clear all devices connected via this proxy
+ for mac, connectedProxyId in pairs(self._connectedDevices) do
+ if connectedProxyId == proxyDeviceId then
+ self._connectedDevices[mac] = nil
+ end
+ end
+
+ -- Cancel pending requests
+ local cancelled = 0
+ for requestId, request in pairs(self._pendingRequests) do
+ if request.proxyDeviceId == proxyDeviceId then
+ cancelled = cancelled + 1
+
+ -- Call callbacks with error
+ if request.type == "connect" then
+ if request.callback then
+ request.callback(false, "Proxy disconnected")
+ end
+ elseif request.type == "write" then
+ if request.callback then
+ request.callback(false, "Proxy disconnected")
+ end
+ elseif request.type == "read" then
+ if request.callback then
+ request.callback(false, nil, "Proxy disconnected")
+ end
+ elseif request.type == "notify" then
+ if request.resultCallback then
+ request.resultCallback(false, "Proxy disconnected")
+ end
+ end
+
+ self._pendingRequests[requestId] = nil
+ end
+ end
+
+ if cancelled > 0 then
+ log:info("Cancelled %d pending request(s) due to proxy device %d disconnection", cancelled, proxyDeviceId)
+ end
+end
+
+return Router:new()
diff --git a/src/esphome/ble/local_scanner_node.lua b/src/esphome/ble/local_scanner_node.lua
new file mode 100644
index 0000000..62cef3f
--- /dev/null
+++ b/src/esphome/ble/local_scanner_node.lua
@@ -0,0 +1,65 @@
+--- LocalScannerNode - Scanner node for direct ESPHome client connections.
+--- Wraps an ESPHomeClient to receive BLE advertisements from a local ESPHome device.
+
+local log = require("lib.logging")
+local BLEScannerNode = require("esphome.ble.scanner_node")
+
+--- @class LocalScannerNode : BLEScannerNode
+--- @field _client ESPHomeClient The ESPHome client instance
+--- @field _callbackId string Unique callback ID for this node
+--- @field _registered boolean Whether the advertisement callback is registered
+local LocalScannerNode = setmetatable({}, { __index = BLEScannerNode })
+LocalScannerNode.__index = LocalScannerNode
+
+--- Create a new LocalScannerNode.
+--- @param client ESPHomeClient The ESPHome client to wrap
+--- @return LocalScannerNode
+function LocalScannerNode:new(client)
+ local instance = setmetatable(BLEScannerNode:new("local"), self)
+ instance._client = assert(client, "client parameter is required")
+ instance._callbackId = "local_scanner_node"
+ instance._registered = false
+ return instance
+end
+
+--- Check if the node is connected.
+--- @return boolean
+function LocalScannerNode:isConnected()
+ return self._client:isConnected()
+end
+
+--- Set the callback to receive BLE advertisements.
+--- Registers with the ESPHome client's advertisement callback system.
+--- @param callback fun(advertisement: BLEAdvertisement, nodeId: string|number)|nil The callback function, or nil to clear
+function LocalScannerNode:setAdvertisementCallback(callback)
+ -- Call parent to store callback
+ BLEScannerNode.setAdvertisementCallback(self, callback)
+
+ if callback then
+ -- Register with ESPHome client
+ if not self._registered then
+ self._client:addBluetoothAdvertisementCallback(self._callbackId, function(message)
+ self:onAdvertisement(message)
+ end)
+ self._registered = true
+ log:debug("LocalScannerNode %s: registered advertisement callback", self._id)
+ end
+ else
+ -- Unregister from ESPHome client
+ self:clearAdvertisementCallback()
+ end
+end
+
+--- Clear the advertisement callback.
+--- Unregisters from the ESPHome client.
+function LocalScannerNode:clearAdvertisementCallback()
+ BLEScannerNode.clearAdvertisementCallback(self)
+
+ if self._registered then
+ self._client:removeBluetoothAdvertisementCallback(self._callbackId)
+ self._registered = false
+ log:debug("LocalScannerNode %s: unregistered advertisement callback", self._id)
+ end
+end
+
+return LocalScannerNode
diff --git a/src/esphome/ble/parsers/advertisement.lua b/src/esphome/ble/parsers/advertisement.lua
new file mode 100644
index 0000000..16dc451
--- /dev/null
+++ b/src/esphome/ble/parsers/advertisement.lua
@@ -0,0 +1,701 @@
+--- BLE advertisement parsing utilities.
+--- Parses raw Bluetooth LE advertisement data according to GAP specification.
+
+local log = require("lib.logging")
+local bit64 = require("bitn").bit64
+local BLEAddress = require("esphome.ble.address")
+local BLECompanyIds = require("esphome.ble.company_identifiers")
+
+--- A parsed service UUID entry from advertisement data.
+--- @class BLEServiceUUID
+--- @field uuid string The service UUID (4-char hex for 16-bit, full UUID for 128-bit)
+
+--- A parsed service data entry from advertisement data.
+--- @class BLEServiceData
+--- @field uuid string The service UUID (4-char hex for 16-bit, 8-char for 32-bit, full UUID for 128-bit)
+--- @field data string Raw service data bytes
+
+--- A parsed manufacturer data entry from advertisement data.
+--- @class BLEManufacturerData
+--- @field company integer The 16-bit company identifier
+--- @field companyName string|nil Human-readable company name if known
+--- @field data string Raw manufacturer-specific data bytes
+
+--- Parsed BLE advertisement flags.
+--- @class BLEFlags
+--- @field leLimitedDiscoverable boolean LE Limited Discoverable Mode
+--- @field leGeneralDiscoverable boolean LE General Discoverable Mode
+--- @field brEdrNotSupported boolean BR/EDR Not Supported (device is LE only)
+--- @field simultaneousLeBredrController boolean Simultaneous LE and BR/EDR (Controller)
+--- @field simultaneousLeBredrHost boolean Simultaneous LE and BR/EDR (Host)
+
+--- Parsed BLE advertisement data structure.
+--- @class BLEAdvertisementData
+--- @field name string|nil Device name (shortened or complete local name)
+--- @field flags BLEFlags|nil Advertisement flags (discoverable mode, BR/EDR support)
+--- @field txPower number|nil TX power level in dBm (signed)
+--- @field serviceUuids BLEServiceUUID[] List of advertised service UUIDs
+--- @field serviceData BLEServiceData[] List of service data entries
+--- @field manufacturerData BLEManufacturerData[] List of manufacturer data entries
+
+--- Enriched BLE advertisement with parsed fields merged in.
+--- @class BLEAdvertisement : BLEAdvertisementData
+--- @field mac string MAC address in format "AA:BB:CC:DD:EE:FF"
+--- @field addressType BLEAddressType? Bluetooth address type
+--- @field rssi number? RSSI in dBm
+--- @field manufacturer string? Manufacturer name from first manufacturer data entry
+
+--- @class BLEAdvertisementParser
+local BLEAdvertisementParser = {}
+
+--- Create a manufacturer data entry with company lookup.
+--- @param company integer The 16-bit company identifier
+--- @param data string Raw manufacturer-specific data bytes
+--- @return BLEManufacturerData entry The manufacturer data entry
+local function createManufacturerEntry(company, data)
+ return {
+ company = company,
+ companyName = BLECompanyIds.getName(company),
+ data = data,
+ }
+end
+
+--- Get manufacturer name from the first manufacturer data entry with a non-empty company name.
+--- @param manufacturerData BLEManufacturerData[] List of manufacturer data entries
+--- @return string|nil manufacturerName The manufacturer name, or nil if not found
+local function getManufacturerName(manufacturerData)
+ for _, entry in ipairs(manufacturerData) do
+ if not IsEmpty(entry.companyName) then
+ --- @cast entry.companyName -nil
+ return entry.companyName
+ end
+ end
+ return nil
+end
+
+--- Parse a pre-decoded advertisement response (older ESPHome format).
+--- Converts the ProtoBluetoothLEAdvertisementResponse format to the common BLEAdvertisement structure.
+--- @param advertisement ProtoBluetoothLEAdvertisementResponse Pre-decoded advertisement from ESPHome
+--- @return BLEAdvertisement|nil advertisement The parsed advertisement, or nil if the packet is invalid
+function BLEAdvertisementParser.parse(advertisement)
+ local address = advertisement.address
+ if address == nil then
+ return nil
+ end
+ -- Handle both number and Int64HighLow format from protobuf
+ address = bit64.to_number(address, true) -- Strict since MAC addresses fit in <53 bits
+ if type(address) ~= "number" then
+ return nil
+ end
+
+ local mac = BLEAddress.toString(address)
+ if IsEmpty(mac) then
+ return nil
+ end
+ --- @cast mac -nil
+
+ -- Convert service_uuids (string[]) to BLEServiceUUID[]
+ --- @type BLEServiceUUID[]
+ local serviceUuids = {}
+ if advertisement.service_uuids then
+ for _, uuid in ipairs(advertisement.service_uuids) do
+ table.insert(serviceUuids, { uuid = uuid })
+ end
+ end
+
+ -- Convert service_data (ProtoBluetoothServiceData[]) to BLEServiceData[]
+ --- @type BLEServiceData[]
+ local serviceData = {}
+ if advertisement.service_data then
+ for _, svc in ipairs(advertisement.service_data) do
+ if svc.uuid then
+ table.insert(serviceData, {
+ uuid = svc.uuid,
+ data = svc.data or "",
+ })
+ end
+ end
+ end
+
+ -- Convert manufacturer_data (ProtoBluetoothServiceData[]) to BLEManufacturerData[]
+ --- @type BLEManufacturerData[]
+ local manufacturerData = {}
+ if advertisement.manufacturer_data then
+ for _, mfg in ipairs(advertisement.manufacturer_data) do
+ -- The uuid field contains the company ID as a hex string (e.g., "004C" for Apple)
+ local company = tonumber(mfg.uuid, 16)
+ if company then
+ table.insert(manufacturerData, createManufacturerEntry(company, mfg.data or ""))
+ end
+ end
+ end
+
+ --- @type BLEAdvertisement
+ local result = {
+ name = advertisement.name,
+ addressType = advertisement.address_type --[[@as BLEAddressType?]],
+ mac = mac,
+ manufacturer = getManufacturerName(manufacturerData),
+ manufacturerData = manufacturerData,
+ serviceUuids = serviceUuids,
+ serviceData = serviceData,
+ txPower = nil, -- Not available in this format
+ rssi = advertisement.rssi,
+ }
+ return result
+end
+
+--- Parse raw BLE advertisement data according to Bluetooth Core Specification GAP format.
+--- Extracts device name, TX power, service UUIDs, service data, and manufacturer data
+--- from the raw advertisement bytes using the GAP AD type format.
+--- @param data string The raw advertisement data bytes
+--- @return BLEAdvertisementData parsed The parsed advertisement data structure
+local function parseAdvertisementData(data)
+ --- @type BLEAdvertisementData
+ local parsed = {
+ serviceUuids = {},
+ serviceData = {},
+ manufacturerData = {},
+ }
+
+ if not data or #data == 0 then
+ return parsed
+ end
+
+ local pos = 1
+ while pos <= #data do
+ -- Read length byte
+ local length = string.byte(data, pos)
+ if not length or length == 0 then
+ break -- End of data
+ end
+
+ pos = pos + 1
+ if pos + length - 1 > #data then
+ log:warn("BLE advertisement data truncated at position %d", pos)
+ break
+ end
+
+ -- Read AD type
+ local adType = string.byte(data, pos)
+ pos = pos + 1
+ local adDataLen = length - 1
+
+ -- Extract AD data
+ local adData = string.sub(data, pos, pos + adDataLen - 1)
+ pos = pos + adDataLen
+
+ -- Parse based on AD type
+ if adType == 0x01 then
+ -- Flags
+ if #adData >= 1 then
+ local flagByte = string.byte(adData, 1)
+ parsed.flags = {
+ leLimitedDiscoverable = (flagByte % 2) >= 1,
+ leGeneralDiscoverable = (math.floor(flagByte / 2) % 2) >= 1,
+ brEdrNotSupported = (math.floor(flagByte / 4) % 2) >= 1,
+ simultaneousLeBredrController = (math.floor(flagByte / 8) % 2) >= 1,
+ simultaneousLeBredrHost = (math.floor(flagByte / 16) % 2) >= 1,
+ }
+ end
+ elseif adType == 0x08 or adType == 0x09 then
+ -- Shortened or Complete Local Name
+ parsed.name = adData
+ elseif adType == 0x02 or adType == 0x03 then
+ -- Incomplete or Complete List of 16-bit Service UUIDs
+ for i = 1, #adData, 2 do
+ if i + 1 <= #adData then
+ local uuid16 = string.byte(adData, i) + string.byte(adData, i + 1) * 256
+ local uuidStr = string.format("%04X", uuid16)
+ table.insert(parsed.serviceUuids, { uuid = uuidStr })
+ end
+ end
+ elseif adType == 0x04 or adType == 0x05 then
+ -- Incomplete or Complete List of 32-bit Service UUIDs
+ for i = 1, #adData, 4 do
+ if i + 3 <= #adData then
+ local uuid32 = string.byte(adData, i)
+ + string.byte(adData, i + 1) * 0x100
+ + string.byte(adData, i + 2) * 0x10000
+ + string.byte(adData, i + 3) * 0x1000000
+ local uuidStr = string.format("%08X", uuid32)
+ table.insert(parsed.serviceUuids, { uuid = uuidStr })
+ end
+ end
+ elseif adType == 0x06 or adType == 0x07 then
+ -- Incomplete or Complete List of 128-bit Service UUIDs
+ for i = 1, #adData, 16 do
+ local uuidBytes = string.sub(adData, i, i + 15)
+ if #uuidBytes == 16 then
+ -- Format as UUID string (little-endian to standard format)
+ local uuid = string.format(
+ "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",
+ string.byte(uuidBytes, 4),
+ string.byte(uuidBytes, 3),
+ string.byte(uuidBytes, 2),
+ string.byte(uuidBytes, 1),
+ string.byte(uuidBytes, 6),
+ string.byte(uuidBytes, 5),
+ string.byte(uuidBytes, 8),
+ string.byte(uuidBytes, 7),
+ string.byte(uuidBytes, 9),
+ string.byte(uuidBytes, 10),
+ string.byte(uuidBytes, 11),
+ string.byte(uuidBytes, 12),
+ string.byte(uuidBytes, 13),
+ string.byte(uuidBytes, 14),
+ string.byte(uuidBytes, 15),
+ string.byte(uuidBytes, 16)
+ )
+ table.insert(parsed.serviceUuids, { uuid = uuid })
+ end
+ end
+ elseif adType == 0x16 then
+ -- Service Data - 16-bit UUID
+ if #adData >= 2 then
+ local uuid16 = string.byte(adData, 1) + string.byte(adData, 2) * 256
+ local uuidStr = string.format("%04X", uuid16)
+ local serviceData = string.sub(adData, 3)
+ table.insert(parsed.serviceData, { uuid = uuidStr, data = serviceData })
+ end
+ elseif adType == 0x20 then
+ -- Service Data - 32-bit UUID
+ if #adData >= 4 then
+ local uuid32 = string.byte(adData, 1)
+ + string.byte(adData, 2) * 0x100
+ + string.byte(adData, 3) * 0x10000
+ + string.byte(adData, 4) * 0x1000000
+ local uuidStr = string.format("%08X", uuid32)
+ local serviceData = string.sub(adData, 5)
+ table.insert(parsed.serviceData, { uuid = uuidStr, data = serviceData })
+ end
+ elseif adType == 0x21 then
+ -- Service Data - 128-bit UUID
+ if #adData >= 16 then
+ local uuidBytes = string.sub(adData, 1, 16)
+ -- Format as UUID string (little-endian to standard format)
+ local uuidStr = string.format(
+ "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",
+ string.byte(uuidBytes, 4),
+ string.byte(uuidBytes, 3),
+ string.byte(uuidBytes, 2),
+ string.byte(uuidBytes, 1),
+ string.byte(uuidBytes, 6),
+ string.byte(uuidBytes, 5),
+ string.byte(uuidBytes, 8),
+ string.byte(uuidBytes, 7),
+ string.byte(uuidBytes, 9),
+ string.byte(uuidBytes, 10),
+ string.byte(uuidBytes, 11),
+ string.byte(uuidBytes, 12),
+ string.byte(uuidBytes, 13),
+ string.byte(uuidBytes, 14),
+ string.byte(uuidBytes, 15),
+ string.byte(uuidBytes, 16)
+ )
+ local serviceData = string.sub(adData, 17)
+ table.insert(parsed.serviceData, { uuid = uuidStr, data = serviceData })
+ end
+ elseif adType == 0x0A then
+ -- TX Power Level (signed 8-bit)
+ if #adData >= 1 then
+ local power = string.byte(adData, 1)
+ if power > 127 then
+ power = power - 256
+ end -- Convert to signed
+ parsed.txPower = power
+ end
+ elseif adType == 0xFF then
+ -- Manufacturer Specific Data
+ if #adData >= 2 then
+ local company = string.byte(adData, 1) + string.byte(adData, 2) * 256
+ local mfgData = string.sub(adData, 3)
+ table.insert(parsed.manufacturerData, createManufacturerEntry(company, mfgData))
+ end
+ end
+ end
+
+ return parsed
+end
+
+--- Enrich a raw advertisement with parsed data.
+--- Parses the raw advertisement data and merges the extracted fields (name, TX power, service
+--- UUIDs, service data, manufacturer data) into the advertisement table.
+--- @param rawAdvertisement ProtoBluetoothLERawAdvertisement Raw advertisement from ESPHome with address, rssi, data fields
+--- @return BLEAdvertisement|nil advertisement The parsed advertisement, or nil if the packet is invalid
+function BLEAdvertisementParser.parseRaw(rawAdvertisement)
+ -- Parse the raw advertisement data
+ local address = rawAdvertisement.address
+ if address == nil then
+ return nil
+ end
+ -- Handle both number and Int64HighLow format from protobuf
+ address = bit64.to_number(address, true) -- Strict since MAC addresses fit in <53 bits
+ if type(address) ~= "number" then
+ return nil
+ end
+
+ local mac = BLEAddress.toString(address)
+ if IsEmpty(mac) then
+ return nil
+ end
+ --- @cast mac -nil
+
+ local parsedData = parseAdvertisementData(rawAdvertisement.data or "")
+
+ --- @type BLEAdvertisement
+ local advertisement = {
+ name = parsedData.name,
+ addressType = rawAdvertisement.address_type --[[@as BLEAddressType?]],
+ mac = mac,
+ flags = parsedData.flags,
+ manufacturer = getManufacturerName(parsedData.manufacturerData),
+ manufacturerData = parsedData.manufacturerData,
+ serviceUuids = parsedData.serviceUuids,
+ serviceData = parsedData.serviceData,
+ txPower = parsedData.txPower,
+ rssi = rawAdvertisement.rssi,
+ }
+ return advertisement
+end
+
+--- Convert a parsed advertisement to a human-readable string.
+--- @param advertisement BLEAdvertisement The parsed advertisement
+--- @return string str Human-readable representation
+function BLEAdvertisementParser.toString(advertisement)
+ local parts = { advertisement.mac }
+
+ -- Name
+ if advertisement.name then
+ table.insert(parts, string.format("name=%q", advertisement.name))
+ end
+
+ -- RSSI
+ if advertisement.rssi then
+ table.insert(parts, string.format("rssi=%d", advertisement.rssi))
+ end
+
+ -- Manufacturer
+ if advertisement.manufacturer then
+ table.insert(parts, string.format("mfr=%q", advertisement.manufacturer))
+ end
+
+ -- Flags
+ if advertisement.flags then
+ local flagParts = {}
+ if advertisement.flags.leGeneralDiscoverable then
+ table.insert(flagParts, "discoverable")
+ elseif advertisement.flags.leLimitedDiscoverable then
+ table.insert(flagParts, "limited")
+ end
+ if advertisement.flags.brEdrNotSupported then
+ table.insert(flagParts, "LE-only")
+ end
+ if #flagParts > 0 then
+ table.insert(parts, "flags=[" .. table.concat(flagParts, ",") .. "]")
+ end
+ end
+
+ -- TX Power
+ if advertisement.txPower then
+ table.insert(parts, string.format("txPower=%ddBm", advertisement.txPower))
+ end
+
+ -- Service UUIDs
+ if advertisement.serviceUuids and #advertisement.serviceUuids > 0 then
+ local uuids = {}
+ for _, svc in ipairs(advertisement.serviceUuids) do
+ table.insert(uuids, svc.uuid)
+ end
+ table.insert(parts, "services=[" .. table.concat(uuids, ",") .. "]")
+ end
+
+ -- Service Data (show UUIDs and data lengths)
+ if advertisement.serviceData and #advertisement.serviceData > 0 then
+ local svcData = {}
+ for _, sd in ipairs(advertisement.serviceData) do
+ table.insert(svcData, string.format("%s:%dB", sd.uuid, #(sd.data or "")))
+ end
+ table.insert(parts, "svcData=[" .. table.concat(svcData, ",") .. "]")
+ end
+
+ -- Manufacturer Data (show company and data lengths)
+ if advertisement.manufacturerData and #advertisement.manufacturerData > 0 then
+ local mfgData = {}
+ for _, md in ipairs(advertisement.manufacturerData) do
+ local name = md.companyName or string.format("0x%04X", md.company)
+ table.insert(mfgData, string.format("%s:%dB", name, #(md.data or "")))
+ end
+ table.insert(parts, "mfgData=[" .. table.concat(mfgData, ",") .. "]")
+ end
+
+ return table.concat(parts, " ")
+end
+
+--- Run self-tests for the BLE parser.
+--- @return boolean passed True if all tests passed
+function BLEAdvertisementParser.selftest()
+ print("Running BLE parser tests...")
+ local passed = 0
+ local total = 0
+
+ local unpack_fn = unpack or table.unpack
+
+ --- Deep compare two tables for equality.
+ --- @param a any
+ --- @param b any
+ --- @return boolean
+ local function deepEqual(a, b)
+ if type(a) ~= type(b) then
+ return false
+ end
+ if type(a) ~= "table" then
+ return a == b
+ end
+ -- Check all keys in a exist in b with same value
+ for k, v in pairs(a) do
+ if not deepEqual(v, b[k]) then
+ return false
+ end
+ end
+ -- Check all keys in b exist in a
+ for k, _ in pairs(b) do
+ if a[k] == nil then
+ return false
+ end
+ end
+ return true
+ end
+
+ --- Format a value for display in test output.
+ --- @param v any
+ --- @return string
+ local function formatValue(v)
+ if type(v) == "table" then
+ local parts = {}
+ for k, val in pairs(v) do
+ table.insert(parts, string.format("%s=%s", tostring(k), formatValue(val)))
+ end
+ return "{" .. table.concat(parts, ", ") .. "}"
+ elseif type(v) == "string" then
+ -- Show hex for binary strings
+ if #v > 0 and string.byte(v, 1) < 32 then
+ local hex = {}
+ for i = 1, #v do
+ table.insert(hex, string.format("%02X", string.byte(v, i)))
+ end
+ return "0x" .. table.concat(hex)
+ end
+ return string.format("%q", v)
+ else
+ return tostring(v)
+ end
+ end
+
+ -- Test vectors: { name, fn, inputs, expected, compare (optional) }
+ local test_vectors = {
+ -- Empty data
+ {
+ name = "parseAdvertisementData: empty string",
+ fn = parseAdvertisementData,
+ inputs = { "" },
+ expected = {
+ name = nil,
+ flags = nil,
+ serviceUuids = {},
+ serviceData = {},
+ manufacturerData = {},
+ },
+ },
+
+ -- Flags (0x01)
+ {
+ name = "parseAdvertisementData: flags LE General Discoverable",
+ fn = parseAdvertisementData,
+ inputs = { "\x02\x01\x06" }, -- length=2, type=0x01, flags=0x06
+ expected = {
+ name = nil,
+ flags = {
+ leLimitedDiscoverable = false,
+ leGeneralDiscoverable = true,
+ brEdrNotSupported = true,
+ simultaneousLeBredrController = false,
+ simultaneousLeBredrHost = false,
+ },
+ serviceUuids = {},
+ serviceData = {},
+ manufacturerData = {},
+ },
+ },
+ {
+ name = "parseAdvertisementData: flags all set",
+ fn = parseAdvertisementData,
+ inputs = { "\x02\x01\x1F" }, -- length=2, type=0x01, flags=0x1F (all 5 bits)
+ expected = {
+ name = nil,
+ flags = {
+ leLimitedDiscoverable = true,
+ leGeneralDiscoverable = true,
+ brEdrNotSupported = true,
+ simultaneousLeBredrController = true,
+ simultaneousLeBredrHost = true,
+ },
+ serviceUuids = {},
+ serviceData = {},
+ manufacturerData = {},
+ },
+ },
+
+ -- Complete Local Name (0x09)
+ {
+ name = "parseAdvertisementData: complete local name",
+ fn = parseAdvertisementData,
+ inputs = { "\x05\x09Test" }, -- length=5, type=0x09, "Test"
+ expected = {
+ name = "Test",
+ flags = nil,
+ serviceUuids = {},
+ serviceData = {},
+ manufacturerData = {},
+ },
+ },
+
+ -- 16-bit Service UUIDs (0x03)
+ {
+ name = "parseAdvertisementData: 16-bit service UUID",
+ fn = parseAdvertisementData,
+ inputs = { "\x03\x03\x0D\xFD" }, -- length=3, type=0x03, UUID=0xFD0D (little-endian)
+ expected = {
+ name = nil,
+ flags = nil,
+ serviceUuids = { { uuid = "FD0D" } },
+ serviceData = {},
+ manufacturerData = {},
+ },
+ },
+
+ -- 32-bit Service UUIDs (0x05)
+ {
+ name = "parseAdvertisementData: 32-bit service UUID",
+ fn = parseAdvertisementData,
+ inputs = { "\x05\x05\x78\x56\x34\x12" }, -- length=5, type=0x05, UUID=0x12345678
+ expected = {
+ name = nil,
+ flags = nil,
+ serviceUuids = { { uuid = "12345678" } },
+ serviceData = {},
+ manufacturerData = {},
+ },
+ },
+
+ -- Service Data 16-bit (0x16)
+ {
+ name = "parseAdvertisementData: service data 16-bit",
+ fn = parseAdvertisementData,
+ inputs = { "\x06\x16\x3D\xFD\x01\x02\x03" }, -- length=6, type=0x16, UUID=0xFD3D, data=010203
+ expected = {
+ name = nil,
+ flags = nil,
+ serviceUuids = {},
+ serviceData = { { uuid = "FD3D", data = "\x01\x02\x03" } },
+ manufacturerData = {},
+ },
+ },
+
+ -- Service Data 32-bit (0x20)
+ {
+ name = "parseAdvertisementData: service data 32-bit",
+ fn = parseAdvertisementData,
+ inputs = { "\x06\x20\x78\x56\x34\x12\xAB" }, -- length=6, type=0x20, UUID=0x12345678, data=AB
+ expected = {
+ name = nil,
+ flags = nil,
+ serviceUuids = {},
+ serviceData = { { uuid = "12345678", data = "\xAB" } },
+ manufacturerData = {},
+ },
+ },
+
+ -- Manufacturer Data (0xFF)
+ {
+ name = "parseAdvertisementData: manufacturer data Apple",
+ fn = parseAdvertisementData,
+ inputs = { "\x05\xFF\x4C\x00\x12\x34" }, -- length=5, type=0xFF, company=0x004C (Apple), data=1234
+ expected = {
+ name = nil,
+ flags = nil,
+ serviceUuids = {},
+ serviceData = {},
+ manufacturerData = { { company = 0x004C, companyName = "Apple, Inc.", data = "\x12\x34" } },
+ },
+ },
+
+ -- TX Power (0x0A)
+ {
+ name = "parseAdvertisementData: TX power positive",
+ fn = parseAdvertisementData,
+ inputs = { "\x02\x0A\x04" }, -- length=2, type=0x0A, power=4 dBm
+ expected = {
+ name = nil,
+ flags = nil,
+ txPower = 4,
+ serviceUuids = {},
+ serviceData = {},
+ manufacturerData = {},
+ },
+ },
+ {
+ name = "parseAdvertisementData: TX power negative",
+ fn = parseAdvertisementData,
+ inputs = { "\x02\x0A\xF0" }, -- length=2, type=0x0A, power=-16 dBm (0xF0 = 240 -> -16)
+ expected = {
+ name = nil,
+ flags = nil,
+ txPower = -16,
+ serviceUuids = {},
+ serviceData = {},
+ manufacturerData = {},
+ },
+ },
+
+ -- Combined: typical BLE advertisement
+ {
+ name = "parseAdvertisementData: combined flags + name + service UUID",
+ fn = parseAdvertisementData,
+ inputs = { "\x02\x01\x06\x05\x09Test\x03\x03\x0D\xFD" },
+ expected = {
+ name = "Test",
+ flags = {
+ leLimitedDiscoverable = false,
+ leGeneralDiscoverable = true,
+ brEdrNotSupported = true,
+ simultaneousLeBredrController = false,
+ simultaneousLeBredrHost = false,
+ },
+ serviceUuids = { { uuid = "FD0D" } },
+ serviceData = {},
+ manufacturerData = {},
+ },
+ },
+ }
+
+ for _, test in ipairs(test_vectors) do
+ total = total + 1
+ local result = test.fn(unpack_fn(test.inputs))
+ local isEqual = deepEqual(result, test.expected)
+
+ if isEqual then
+ print(" PASS: " .. test.name)
+ passed = passed + 1
+ else
+ print(" FAIL: " .. test.name)
+ print(" expected: " .. formatValue(test.expected))
+ print(" got: " .. formatValue(result))
+ end
+ end
+
+ print(string.format("\nTests: %d/%d passed\n", passed, total))
+ return passed == total
+end
+
+return BLEAdvertisementParser
diff --git a/src/esphome/ble/parsers/govee.lua b/src/esphome/ble/parsers/govee.lua
new file mode 100644
index 0000000..2ba9a2a
--- /dev/null
+++ b/src/esphome/ble/parsers/govee.lua
@@ -0,0 +1,1020 @@
+--- Govee BLE advertisement parser.
+---
+--- Sources:
+--- - https://github.com/Bluetooth-Devices/govee-ble
+--- - https://github.com/custom-components/ble_monitor
+--- - https://github.com/wcbonner/GoveeBTTempLogger
+
+local bit32 = require("bitn").bit32
+local log = require("lib.logging")
+local UUID = require("esphome.ble.uuid")
+
+--- @class Govee
+local Govee = {}
+
+--- Govee manufacturer company IDs
+--- @enum GoveeManufacturerId
+Govee.ManufacturerId = {
+ -- Temperature/Humidity sensors
+ EC88 = 0xEC88, -- H5072, H5075, H5074, H5051, H5052, H5071
+ ID_0001 = 0x0001, -- H5100-H5110, H5174, H5177, H5178, H5106, H5112
+ ID_8801 = 0x8801, -- H5179
+ ID_8803 = 0x8803, -- H5127 (occupancy)
+ -- Meat thermometer IDs
+ H5181_F861 = 0xF861,
+ H5181_388A = 0x388A,
+ H5181_EA42 = 0xEA42,
+ H5181_AAA2 = 0xAAA2,
+ H5181_D14B = 0xD14B,
+ H5182 = 0x2730,
+ H5183_67DD = 0x67DD,
+ H5183_E02F = 0xE02F,
+ H5183_F79F = 0xF79F,
+ H5184 = 0x1B36,
+ H5185_4A32 = 0x4A32,
+ H5185_0332 = 0x0332,
+ H5185_4C32 = 0x4C32,
+ H5191 = 0xAC63,
+ H5198 = 0x3022,
+}
+
+--- Govee device model codes (derived from name or service UUID)
+--- @enum GoveeDeviceModel
+Govee.DeviceModel = {
+ -- Temperature/Humidity sensors (EC88)
+ H5051 = "H5051",
+ H5052 = "H5052",
+ H5071 = "H5071",
+ H5072 = "H5072",
+ H5074 = "H5074",
+ H5075 = "H5075",
+ -- Temperature/Humidity sensors (0x0001)
+ H5100 = "H5100",
+ H5101 = "H5101",
+ H5102 = "H5102",
+ H5103 = "H5103",
+ H5104 = "H5104",
+ H5105 = "H5105",
+ H5106 = "H5106", -- Air quality (PM2.5)
+ H5108 = "H5108",
+ H5110 = "H5110",
+ H5112 = "H5112", -- Dual probe
+ H5174 = "H5174",
+ H5177 = "H5177",
+ H5178 = "H5178", -- Dual sensor
+ -- Temperature/Humidity sensors (0x8801)
+ H5179 = "H5179",
+ -- Meat thermometers
+ H5055 = "H5055",
+ H5181 = "H5181",
+ H5182 = "H5182",
+ H5183 = "H5183",
+ H5184 = "H5184",
+ H5185 = "H5185",
+ H5191 = "H5191",
+ H5198 = "H5198",
+}
+
+--- Device model to friendly name mapping
+--- @type table
+Govee.DEVICE_NAMES = {
+ -- Temperature/Humidity sensors
+ [Govee.DeviceModel.H5051] = "Govee H5051",
+ [Govee.DeviceModel.H5052] = "Govee H5052",
+ [Govee.DeviceModel.H5071] = "Govee H5071",
+ [Govee.DeviceModel.H5072] = "Govee H5072",
+ [Govee.DeviceModel.H5074] = "Govee H5074",
+ [Govee.DeviceModel.H5075] = "Govee H5075",
+ [Govee.DeviceModel.H5100] = "Govee H5100",
+ [Govee.DeviceModel.H5101] = "Govee H5101",
+ [Govee.DeviceModel.H5102] = "Govee H5102",
+ [Govee.DeviceModel.H5103] = "Govee H5103",
+ [Govee.DeviceModel.H5104] = "Govee H5104",
+ [Govee.DeviceModel.H5105] = "Govee H5105",
+ [Govee.DeviceModel.H5106] = "Govee H5106",
+ [Govee.DeviceModel.H5108] = "Govee H5108",
+ [Govee.DeviceModel.H5110] = "Govee H5110",
+ [Govee.DeviceModel.H5112] = "Govee H5112",
+ [Govee.DeviceModel.H5174] = "Govee H5174",
+ [Govee.DeviceModel.H5177] = "Govee H5177",
+ [Govee.DeviceModel.H5178] = "Govee H5178",
+ [Govee.DeviceModel.H5179] = "Govee H5179",
+ -- Meat thermometers
+ [Govee.DeviceModel.H5055] = "Govee H5055",
+ [Govee.DeviceModel.H5181] = "Govee H5181",
+ [Govee.DeviceModel.H5182] = "Govee H5182",
+ [Govee.DeviceModel.H5183] = "Govee H5183",
+ [Govee.DeviceModel.H5184] = "Govee H5184",
+ [Govee.DeviceModel.H5185] = "Govee H5185",
+ [Govee.DeviceModel.H5191] = "Govee H5191",
+ [Govee.DeviceModel.H5198] = "Govee H5198",
+}
+
+--- @class GoveeParsedData
+--- @field deviceType string Device type name
+--- @field model string Device model code
+--- @field temperature number|nil Temperature in Celsius
+--- @field humidity number|nil Humidity percentage
+--- @field battery number|nil Battery percentage (0-100)
+--- @field pm25 number|nil PM2.5 in µg/m³ (H5106 only)
+--- @field hasError boolean|nil True if device reports an error
+--- @field sensorId integer|nil Sensor ID for dual-sensor devices (H5178, H5112)
+--- @field probe1Temp number|nil Probe 1 temperature (meat thermometers)
+--- @field probe2Temp number|nil Probe 2 temperature (meat thermometers)
+--- @field probe3Temp number|nil Probe 3 temperature (meat thermometers)
+--- @field probe4Temp number|nil Probe 4 temperature (meat thermometers)
+--- @field probe1Alarm number|nil Probe 1 alarm temperature
+--- @field probe2Alarm number|nil Probe 2 alarm temperature
+--- @field ambientTemp number|nil Ambient temperature (H5191)
+
+--------------------------------------------------------------------------------
+-- Common Helper Functions (offset-based, like SwitchBot pattern)
+--------------------------------------------------------------------------------
+
+--- Safe byte extraction with bounds checking
+--- @param data string|nil The data string
+--- @param index integer 1-based index
+--- @return integer|nil byte The byte value or nil if out of bounds
+local function getByte(data, index)
+ if not data or index < 1 or index > #data then
+ return nil
+ end
+ return string.byte(data, index)
+end
+
+--- Parse battery percentage and error flag from a byte at offset
+--- @param data string|nil The data string
+--- @param offset integer 1-based offset of battery byte
+--- @return integer|nil battery Battery percentage (0-100)
+--- @return boolean|nil hasError Error flag
+local function parseBattery(data, offset)
+ local batteryByte = getByte(data, offset)
+ if not batteryByte then
+ return nil, nil
+ end
+ local battery = bit32.band(batteryByte, 0x7F)
+ local hasError = bit32.band(batteryByte, 0x80) ~= 0
+ return battery, hasError
+end
+
+--- Parse 3-byte combined temperature/humidity from data at offset
+--- Format: 3 bytes combined = base_num
+--- Temperature = int(base_num / 1000) / 10 (or negative if bit 0x800000 set)
+--- Humidity = (base_num % 1000) / 10
+--- @param data string|nil The data string
+--- @param offset integer 1-based offset of first temp/humid byte
+--- @return number|nil temperature Temperature in Celsius
+--- @return number|nil humidity Humidity percentage
+local function parse3ByteTempHumid(data, offset)
+ local b1 = getByte(data, offset)
+ local b2 = getByte(data, offset + 1)
+ local b3 = getByte(data, offset + 2)
+
+ if not b1 or not b2 or not b3 then
+ return nil, nil
+ end
+
+ local baseNum = bit32.lshift(b1, 16) + bit32.lshift(b2, 8) + b3
+ local isNegative = bit32.band(baseNum, 0x800000) ~= 0
+ local tempAsInt = bit32.band(baseNum, 0x7FFFFF)
+
+ local temperature
+ if isNegative then
+ temperature = -math.floor(tempAsInt / 1000) / 10
+ else
+ temperature = math.floor(tempAsInt / 1000) / 10
+ end
+
+ local humidity = (tempAsInt % 1000) / 10
+
+ return temperature, humidity
+end
+
+--- Convert signed 16-bit two's complement value
+--- @param value integer Unsigned 16-bit value
+--- @return integer signed Signed value
+local function toSigned16(value)
+ if value >= 0x8000 then
+ return value - 0x10000
+ end
+ return value
+end
+
+--- Parse 4-byte little-endian temperature/humidity from data at offset
+--- Format: 2 bytes signed temp (LE), 2 bytes unsigned humidity (LE), both /100
+--- @param data string|nil The data string
+--- @param offset integer 1-based offset of first byte
+--- @return number|nil temperature Temperature in Celsius
+--- @return number|nil humidity Humidity percentage
+local function parse4ByteTempHumidLE(data, offset)
+ local tempLow = getByte(data, offset)
+ local tempHigh = getByte(data, offset + 1)
+ local humLow = getByte(data, offset + 2)
+ local humHigh = getByte(data, offset + 3)
+
+ if not tempLow or not tempHigh or not humLow or not humHigh then
+ return nil, nil
+ end
+
+ local tempRaw = tempLow + bit32.lshift(tempHigh, 8)
+ local humRaw = humLow + bit32.lshift(humHigh, 8)
+
+ local temperature = toSigned16(tempRaw) / 100
+ local humidity = humRaw / 100
+
+ return temperature, humidity
+end
+
+--- Parse 16-bit big-endian value from data at offset
+--- @param data string|nil The data string
+--- @param offset integer 1-based offset of high byte
+--- @return integer|nil value The 16-bit value or nil
+local function parseBigEndian16(data, offset)
+ local high = getByte(data, offset)
+ local low = getByte(data, offset + 1)
+ if not high or not low then
+ return nil
+ end
+ return high * 256 + low
+end
+
+--- Parse signed 16-bit big-endian value from data at offset
+--- @param data string|nil The data string
+--- @param offset integer 1-based offset of high byte
+--- @return integer|nil value The signed 16-bit value or nil
+local function parseSignedBigEndian16(data, offset)
+ local value = parseBigEndian16(data, offset)
+ if not value then
+ return nil
+ end
+ return toSigned16(value)
+end
+
+--- Decode probe temperature (divide by 100, return nil if negative/invalid)
+--- @param rawValue integer|nil Raw temperature value
+--- @return number|nil temperature Temperature in Celsius or nil if invalid
+local function decodeProbeTemp(rawValue)
+ if not rawValue or rawValue < 0 then
+ return nil
+ end
+ return rawValue / 100
+end
+
+--- Create a base result object with required fields
+--- @param model GoveeDeviceModel Device model code
+--- @return GoveeParsedData|nil result Base result object or nil if model unknown
+local function createResult(model)
+ local deviceType = Govee.DEVICE_NAMES[model]
+ if not deviceType then
+ return nil
+ end
+ return {
+ deviceType = deviceType,
+ model = model,
+ }
+end
+
+--- Format bytes as hex string for debug logging
+--- @param data string Binary data
+--- @return string hex Hex string representation
+local function bytesToHex(data)
+ local hex = {}
+ for i = 1, #data do
+ table.insert(hex, string.format("%02X", string.byte(data, i)))
+ end
+ return table.concat(hex, " ")
+end
+
+--------------------------------------------------------------------------------
+-- H5106 Air Quality Sensor Helpers
+--------------------------------------------------------------------------------
+
+--- Decode temperature from 4-byte combined packet (H5106 air quality sensor)
+--- Per govee-ble: packet_value / 1000000 / 10, with sign bit at 0x80000000
+--- @param packetValue integer 4-byte value
+--- @return number temperature Temperature in Celsius
+local function decodeTempFrom4Bytes(packetValue)
+ if bit32.band(packetValue, 0x80000000) ~= 0 then
+ packetValue = bit32.band(packetValue, 0x7FFFFFFF)
+ return -math.floor(packetValue / 10000000) / 10
+ end
+ return math.floor(packetValue / 1000000) / 10
+end
+
+--- Decode humidity from 4-byte combined packet (H5106 air quality sensor)
+--- Per govee-ble: (packet_value % 1000000) / 1000 / 10
+--- @param packetValue integer 4-byte value
+--- @return number humidity Humidity percentage
+local function decodeHumidFrom4Bytes(packetValue)
+ packetValue = bit32.band(packetValue, 0x7FFFFFFF)
+ return math.floor((packetValue % 1000000) / 1000) / 10
+end
+
+--- Decode PM2.5 from 4-byte combined packet (H5106 air quality sensor)
+--- Per govee-ble: packet_value % 1000
+--- @param packetValue integer 4-byte value
+--- @return integer pm25 PM2.5 in µg/m³
+local function decodePM25From4Bytes(packetValue)
+ packetValue = bit32.band(packetValue, 0x7FFFFFFF)
+ return packetValue % 1000
+end
+
+--------------------------------------------------------------------------------
+-- Device-Specific Parsers
+--------------------------------------------------------------------------------
+
+--- Parse 3-byte format devices (H5072, H5075, H5101-H5105, H5108, H5110, H5174, H5177, H5178)
+--- @param data string Manufacturer data
+--- @param model GoveeDeviceModel Device model
+--- @param offset integer 1-based offset for temp/humid data (battery is offset+3)
+--- @return GoveeParsedData|nil
+local function parse3ByteFormat(data, model, offset)
+ if #data < offset + 3 then
+ return nil
+ end
+
+ local result = createResult(model)
+ if not result then
+ return nil
+ end
+
+ result.temperature, result.humidity = parse3ByteTempHumid(data, offset)
+ result.battery, result.hasError = parseBattery(data, offset + 3)
+
+ return result
+end
+
+--- Parse 4-byte little-endian format devices (H5074, H5051, H5052, H5071, H5179)
+--- @param data string Manufacturer data
+--- @param model GoveeDeviceModel Device model
+--- @param offset integer 1-based offset for temp/humidity data (battery is offset+4)
+--- @return GoveeParsedData|nil
+local function parse4ByteLEFormat(data, model, offset)
+ if #data < offset + 4 then
+ return nil
+ end
+
+ local result = createResult(model)
+ if not result then
+ return nil
+ end
+
+ result.temperature, result.humidity = parse4ByteTempHumidLE(data, offset)
+ result.battery, result.hasError = parseBattery(data, offset + 4)
+
+ return result
+end
+
+--- Parse H5106 air quality sensor (4-byte combined temp/humidity/PM2.5)
+--- Per govee-ble: 6 bytes, data[2:6] contains 4-byte combined value
+--- @param data string Manufacturer data
+--- @param model GoveeDeviceModel Device model
+--- @return GoveeParsedData|nil
+local function parseH5106(data, model)
+ if #data < 6 then
+ return nil
+ end
+
+ local result = createResult(model)
+ if not result then
+ return nil
+ end
+
+ -- Per govee-ble: data[2:6] as hex string, then parsed as integer
+ -- In Lua 1-indexed: bytes 3-6 (4 bytes)
+ local b1 = getByte(data, 3)
+ local b2 = getByte(data, 4)
+ local b3 = getByte(data, 5)
+ local b4 = getByte(data, 6)
+
+ if not b1 or not b2 or not b3 or not b4 then
+ return nil
+ end
+
+ -- Build 4-byte value (big-endian per govee-ble hex string parsing)
+ local packetValue = bit32.lshift(b1, 24) + bit32.lshift(b2, 16) + bit32.lshift(b3, 8) + b4
+
+ result.temperature = decodeTempFrom4Bytes(packetValue)
+ result.humidity = decodeHumidFrom4Bytes(packetValue)
+ result.pm25 = decodePM25From4Bytes(packetValue)
+
+ return result
+end
+
+--- Parse H5178 dual-sensor device
+--- @param data string Manufacturer data
+--- @param model GoveeDeviceModel Device model
+--- @return GoveeParsedData|nil
+local function parseH5178(data, model)
+ if #data < 9 then
+ return nil
+ end
+
+ local result = createResult(model)
+ if not result then
+ return nil
+ end
+
+ -- Byte 2 (index 3): Sensor ID (0=primary, 1=remote)
+ result.sensorId = getByte(data, 3)
+
+ -- data[3:7] = Lua offset 4 for temp/humid/battery
+ result.temperature, result.humidity = parse3ByteTempHumid(data, 4)
+ result.battery, result.hasError = parseBattery(data, 7)
+
+ return result
+end
+
+--- Parse H5112 dual-probe sensor
+--- Per govee-ble: 8 bytes, data[2:6] for temp/humid/battery, data[7] = probe ID
+--- @param data string Manufacturer data
+--- @param model GoveeDeviceModel Device model
+--- @return GoveeParsedData|nil
+local function parseH5112(data, model)
+ if #data < 8 then
+ return nil
+ end
+
+ local result = createResult(model)
+ if not result then
+ return nil
+ end
+
+ -- data[2:6] = Lua offset 3 for temp/humid/battery
+ result.temperature, result.humidity = parse3ByteTempHumid(data, 3)
+ result.battery, result.hasError = parseBattery(data, 6)
+
+ -- Byte 7 (index 8): Probe ID (0x41=probe 1, 0x82=probe 2)
+ local probeIdByte = getByte(data, 8)
+ if probeIdByte then
+ if probeIdByte == 0x41 then
+ result.sensorId = 1
+ elseif probeIdByte == 0x82 then
+ result.sensorId = 2
+ else
+ result.sensorId = probeIdByte
+ end
+ end
+
+ return result
+end
+
+--------------------------------------------------------------------------------
+-- Meat Thermometer Parsers
+--------------------------------------------------------------------------------
+
+--- Parse H5181/H5183 single-probe meat thermometer (14 bytes)
+--- @param data string Manufacturer data
+--- @param model GoveeDeviceModel Device model
+--- @return GoveeParsedData|nil
+local function parseH5181(data, model)
+ if #data < 14 then
+ return nil
+ end
+
+ local result = createResult(model)
+ if not result then
+ return nil
+ end
+
+ -- data[8:12] = Lua offset 9, big-endian probe temps
+ local probe1Raw = parseSignedBigEndian16(data, 9)
+ local alarm1Raw = parseSignedBigEndian16(data, 11)
+
+ result.probe1Temp = decodeProbeTemp(probe1Raw)
+ result.probe1Alarm = decodeProbeTemp(alarm1Raw)
+
+ return result
+end
+
+--- Parse H5182 dual-probe meat thermometer (17 bytes)
+--- @param data string Manufacturer data
+--- @param model GoveeDeviceModel Device model
+--- @return GoveeParsedData|nil
+local function parseH5182(data, model)
+ if #data < 17 then
+ return nil
+ end
+
+ local result = createResult(model)
+ if not result then
+ return nil
+ end
+
+ -- data[8:17] = Lua offset 9
+ local probe1Raw = parseSignedBigEndian16(data, 9)
+ local alarm1Raw = parseSignedBigEndian16(data, 11)
+ local probe2Raw = parseSignedBigEndian16(data, 13)
+ local alarm2Raw = parseSignedBigEndian16(data, 15)
+
+ result.probe1Temp = decodeProbeTemp(probe1Raw)
+ result.probe1Alarm = decodeProbeTemp(alarm1Raw)
+ result.probe2Temp = decodeProbeTemp(probe2Raw)
+ result.probe2Alarm = decodeProbeTemp(alarm2Raw)
+
+ return result
+end
+
+--- Probe mapping for H5184 (4-probe thermometer)
+--- Maps sensor ID (byte 6) to probe number assignment
+--- @type table
+local H5184_PROBE_MAPPING = {
+ [0] = { 1, 2 },
+ [1] = { 3, 4 },
+}
+
+--- Parse H5184 multi-probe meat thermometer (17 bytes, mapped probes)
+--- @param data string Manufacturer data
+--- @param model GoveeDeviceModel Device model
+--- @return GoveeParsedData|nil
+local function parseH5184(data, model)
+ if #data < 17 then
+ return nil
+ end
+
+ local result = createResult(model)
+ if not result then
+ return nil
+ end
+
+ -- Byte 6 (index 7): Sensor ID for probe mapping
+ local sensorId = getByte(data, 7)
+ local probeMap = sensorId and H5184_PROBE_MAPPING[sensorId]
+
+ -- data[8:17] = Lua offset 9
+ local probe1Raw = parseSignedBigEndian16(data, 9)
+ local alarm1Raw = parseSignedBigEndian16(data, 11)
+ local probe2Raw = parseSignedBigEndian16(data, 13)
+ local alarm2Raw = parseSignedBigEndian16(data, 15)
+
+ local temp1 = decodeProbeTemp(probe1Raw)
+ local temp2 = decodeProbeTemp(probe2Raw)
+
+ -- Map to correct probe fields based on sensor ID
+ if not probeMap or probeMap[1] == 1 then
+ result.probe1Temp = temp1
+ result.probe1Alarm = decodeProbeTemp(alarm1Raw)
+ elseif probeMap[1] == 3 then
+ result.probe3Temp = temp1
+ end
+ if not probeMap or probeMap[2] == 2 then
+ result.probe2Temp = temp2
+ result.probe2Alarm = decodeProbeTemp(alarm2Raw)
+ elseif probeMap[2] == 4 then
+ result.probe4Temp = temp2
+ end
+
+ return result
+end
+
+--- Parse H5185 dual-probe meat thermometer (20 bytes)
+--- @param data string Manufacturer data
+--- @param model GoveeDeviceModel Device model
+--- @return GoveeParsedData|nil
+local function parseH5185(data, model)
+ if #data < 20 then
+ return nil
+ end
+
+ local result = createResult(model)
+ if not result then
+ return nil
+ end
+
+ -- data[8:18] = Lua offset 9
+ local probe1Raw = parseSignedBigEndian16(data, 9)
+ local alarm1Raw = parseSignedBigEndian16(data, 11)
+ local probe2Raw = parseSignedBigEndian16(data, 13)
+ local alarm2Raw = parseSignedBigEndian16(data, 15)
+
+ result.probe1Temp = decodeProbeTemp(probe1Raw)
+ result.probe1Alarm = decodeProbeTemp(alarm1Raw)
+ result.probe2Temp = decodeProbeTemp(probe2Raw)
+ result.probe2Alarm = decodeProbeTemp(alarm2Raw)
+
+ return result
+end
+
+--- Parse H5191 meat thermometer with ambient temp (20 bytes)
+--- @param data string Manufacturer data
+--- @param model GoveeDeviceModel Device model
+--- @return GoveeParsedData|nil
+local function parseH5191(data, model)
+ if #data < 20 then
+ return nil
+ end
+
+ local result = createResult(model)
+ if not result then
+ return nil
+ end
+
+ -- data[8:16] = Lua offset 9
+ local probe1Raw = parseSignedBigEndian16(data, 9)
+ local alarm1Raw = parseSignedBigEndian16(data, 11)
+ local ambientRaw = parseSignedBigEndian16(data, 13)
+
+ result.probe1Temp = decodeProbeTemp(probe1Raw)
+ result.probe1Alarm = decodeProbeTemp(alarm1Raw)
+ result.ambientTemp = decodeProbeTemp(ambientRaw)
+
+ return result
+end
+
+--- Probe mapping for H5198 (multi-probe with high/low alarms)
+--- @type table
+local H5198_PROBE_MAPPING = {
+ [0] = { 1, 2 },
+ [1] = { 3, 4 },
+}
+
+--- Parse H5198 multi-probe meat thermometer (20 bytes)
+--- @param data string Manufacturer data
+--- @param model GoveeDeviceModel Device model
+--- @return GoveeParsedData|nil
+local function parseH5198(data, model)
+ if #data < 20 then
+ return nil
+ end
+
+ local result = createResult(model)
+ if not result then
+ return nil
+ end
+
+ -- Byte 6 (index 7): Sensor ID for probe mapping
+ local sensorId = getByte(data, 7)
+ local probeMap = sensorId and H5198_PROBE_MAPPING[sensorId]
+
+ -- data[8:20] = Lua offset 9
+ local probe1Raw = parseSignedBigEndian16(data, 9)
+ local probe2Raw = parseSignedBigEndian16(data, 11)
+
+ local temp1 = decodeProbeTemp(probe1Raw)
+ local temp2 = decodeProbeTemp(probe2Raw)
+
+ -- Map to correct probe fields based on sensor ID
+ if not probeMap or probeMap[1] == 1 then
+ result.probe1Temp = temp1
+ elseif probeMap[1] == 3 then
+ result.probe3Temp = temp1
+ end
+ if not probeMap or probeMap[2] == 2 then
+ result.probe2Temp = temp2
+ elseif probeMap[2] == 4 then
+ result.probe4Temp = temp2
+ end
+
+ return result
+end
+
+--------------------------------------------------------------------------------
+-- Model Name Matching
+--------------------------------------------------------------------------------
+
+--- Extract model from device name using substring matching.
+--- Per govee-ble: uses "H5074" in local_name style checks.
+--- @param name string|nil Device name
+--- @return GoveeDeviceModel|nil model
+local function getModelFromName(name)
+ if not name then
+ return nil
+ end
+
+ -- Check for each known model in the name (per govee-ble substring matching)
+ -- Order matters: check more specific models first (longer numbers before shorter)
+ -- Meat thermometers
+ if name:find("H5181") then
+ return Govee.DeviceModel.H5181
+ elseif name:find("H5182") then
+ return Govee.DeviceModel.H5182
+ elseif name:find("H5183") then
+ return Govee.DeviceModel.H5183
+ elseif name:find("H5184") then
+ return Govee.DeviceModel.H5184
+ elseif name:find("H5185") then
+ return Govee.DeviceModel.H5185
+ elseif name:find("H5191") then
+ return Govee.DeviceModel.H5191
+ elseif name:find("H5198") then
+ return Govee.DeviceModel.H5198
+ elseif name:find("H5055") then
+ return Govee.DeviceModel.H5055
+ -- Temperature/Humidity sensors (check longer model numbers first)
+ elseif name:find("H5174") then
+ return Govee.DeviceModel.H5174
+ elseif name:find("H5177") then
+ return Govee.DeviceModel.H5177
+ elseif name:find("H5178") or name:find("B5178") then
+ return Govee.DeviceModel.H5178
+ elseif name:find("H5179") or name:find("GV5179") then
+ return Govee.DeviceModel.H5179
+ elseif name:find("H5112") then
+ return Govee.DeviceModel.H5112
+ elseif name:find("H5110") then
+ return Govee.DeviceModel.H5110
+ elseif name:find("H5108") then
+ return Govee.DeviceModel.H5108
+ elseif name:find("H5106") then
+ return Govee.DeviceModel.H5106
+ elseif name:find("H5105") then
+ return Govee.DeviceModel.H5105
+ elseif name:find("H5104") then
+ return Govee.DeviceModel.H5104
+ elseif name:find("H5103") then
+ return Govee.DeviceModel.H5103
+ elseif name:find("H5102") then
+ return Govee.DeviceModel.H5102
+ elseif name:find("H5101") then
+ return Govee.DeviceModel.H5101
+ elseif name:find("H5100") then
+ return Govee.DeviceModel.H5100
+ elseif name:find("H5074") then
+ return Govee.DeviceModel.H5074
+ elseif name:find("H5075") then
+ return Govee.DeviceModel.H5075
+ elseif name:find("H5072") then
+ return Govee.DeviceModel.H5072
+ elseif name:find("H5071") then
+ return Govee.DeviceModel.H5071
+ elseif name:find("H5052") then
+ return Govee.DeviceModel.H5052
+ elseif name:find("H5051") then
+ return Govee.DeviceModel.H5051
+ end
+
+ return nil
+end
+
+--------------------------------------------------------------------------------
+-- Manufacturer ID Helpers
+--------------------------------------------------------------------------------
+
+--- H5181 manufacturer IDs
+--- @type table
+local H5181_MFG_IDS = {
+ [Govee.ManufacturerId.H5181_F861] = true,
+ [Govee.ManufacturerId.H5181_388A] = true,
+ [Govee.ManufacturerId.H5181_EA42] = true,
+ [Govee.ManufacturerId.H5181_AAA2] = true,
+ [Govee.ManufacturerId.H5181_D14B] = true,
+}
+
+--- H5183 manufacturer IDs
+--- @type table
+local H5183_MFG_IDS = {
+ [Govee.ManufacturerId.H5183_67DD] = true,
+ [Govee.ManufacturerId.H5183_E02F] = true,
+ [Govee.ManufacturerId.H5183_F79F] = true,
+}
+
+--- H5185 manufacturer IDs
+--- @type table
+local H5185_MFG_IDS = {
+ [Govee.ManufacturerId.H5185_4A32] = true,
+ [Govee.ManufacturerId.H5185_0332] = true,
+ [Govee.ManufacturerId.H5185_4C32] = true,
+}
+
+--- Check if manufacturer ID is a known Govee ID
+--- @param manufacturerId integer Manufacturer company ID
+--- @return boolean isGovee True if Govee manufacturer ID
+local function isGoveeManufacturer(manufacturerId)
+ return manufacturerId == Govee.ManufacturerId.EC88
+ or manufacturerId == Govee.ManufacturerId.ID_0001
+ or manufacturerId == Govee.ManufacturerId.ID_8801
+ or manufacturerId == Govee.ManufacturerId.ID_8803
+ or H5181_MFG_IDS[manufacturerId]
+ or H5183_MFG_IDS[manufacturerId]
+ or H5185_MFG_IDS[manufacturerId]
+ or manufacturerId == Govee.ManufacturerId.H5182
+ or manufacturerId == Govee.ManufacturerId.H5184
+ or manufacturerId == Govee.ManufacturerId.H5191
+ or manufacturerId == Govee.ManufacturerId.H5198
+end
+
+--- Find Govee manufacturer data
+--- @param manufacturerData BLEManufacturerData[]|nil Manufacturer data entries
+--- @return string|nil data The raw manufacturer data
+--- @return number|nil companyId The manufacturer company ID
+local function findGoveeManufacturerData(manufacturerData)
+ if not manufacturerData then
+ return nil, nil
+ end
+ for _, mfg in ipairs(manufacturerData) do
+ if isGoveeManufacturer(mfg.company) then
+ return mfg.data, mfg.company
+ end
+ end
+ return nil, nil
+end
+
+--- Govee service UUIDs (16-bit)
+--- Some Govee devices broadcast sensor data via service UUID instead of manufacturer data
+--- @type string
+local SERVICE_UUID_EC88 = "EC88" -- H5072, H5075, H5074, etc.
+
+--------------------------------------------------------------------------------
+-- Main Parser
+--------------------------------------------------------------------------------
+
+--- Parse Govee data from manufacturer data
+--- Infer device model from manufacturer ID when name doesn't provide it
+--- Some devices (like H5179 with 0x8801) can be identified by manufacturer ID alone
+--- @param companyId number Manufacturer company ID
+--- @param dataLen number Length of manufacturer data
+--- @return GoveeDeviceModel|nil model Inferred model or nil
+local function inferModelFromManufacturerId(companyId, dataLen)
+ -- H5179: manufacturer ID 0x8801 with 9 bytes is uniquely H5179
+ -- Per govee-ble: "H5179" in local_name OR mgr_id == 0x8801
+ if companyId == Govee.ManufacturerId.ID_8801 and dataLen == 9 then
+ return Govee.DeviceModel.H5179
+ end
+ return nil
+end
+
+--- @param manufacturerData BLEManufacturerData[]|nil Manufacturer data array
+--- @param serviceData BLEServiceData[]|nil Service data array
+--- @param deviceName string|nil Device name from advertisement
+--- @return GoveeParsedData|nil parsed Parsed data or nil if not Govee
+function Govee.parse(manufacturerData, serviceData, deviceName)
+ local mfgData, companyId = findGoveeManufacturerData(manufacturerData)
+
+ -- If no manufacturer data, try service data with UUID EC88
+ -- Some Govee devices broadcast via service UUID instead of manufacturer ID
+ if not mfgData then
+ local svcData = UUID.findData(serviceData, SERVICE_UUID_EC88)
+ if svcData then
+ mfgData = svcData
+ companyId = Govee.ManufacturerId.EC88
+ log:trace("Govee: found service data with UUID %s for device %q", SERVICE_UUID_EC88, deviceName or "")
+ end
+ end
+
+ if not mfgData then
+ return nil
+ end
+
+ local dataLen = #mfgData
+
+ -- Try to determine model from device name first
+ local model = getModelFromName(deviceName)
+
+ -- Fallback: infer model from manufacturer ID if name doesn't provide it
+ -- Some devices (like H5179) can be identified by manufacturer ID alone
+ if not model or Govee.DEVICE_NAMES[model] == nil then
+ model = companyId and inferModelFromManufacturerId(companyId, dataLen)
+ if model then
+ log:debug("Govee: inferred model %s from manufacturer ID 0x%04X", model, companyId)
+ end
+ end
+
+ if not model or Govee.DEVICE_NAMES[model] == nil then
+ log:trace(
+ "Govee: could not determine model for device %q with companyId=0x%04X, dataLen=%d",
+ deviceName or "",
+ companyId,
+ dataLen
+ )
+ return nil
+ end
+
+ log:debug(
+ "Govee parse: companyId=0x%04X, len=%d, model=%s, data=[%s]",
+ companyId,
+ dataLen,
+ model,
+ bytesToHex(mfgData)
+ )
+
+ -- Route to appropriate parser based on manufacturer ID and data length
+
+ -- EC88 manufacturer ID (0xEC88)
+ if companyId == Govee.ManufacturerId.EC88 then
+ -- H5072/H5075: 6 bytes, data[1:5] = Lua offset 2 (3-byte format)
+ if dataLen == 6 and (model == Govee.DeviceModel.H5072 or model == Govee.DeviceModel.H5075) then
+ return parse3ByteFormat(mfgData, model, 2)
+ -- H5074: 7 bytes, data[1:6] = Lua offset 2 (4-byte LE format)
+ elseif dataLen == 7 and model == Govee.DeviceModel.H5074 then
+ return parse4ByteLEFormat(mfgData, model, 2)
+ -- H5051/H5052/H5071: 9 bytes, data[1:6] = Lua offset 2 (4-byte LE format)
+ elseif
+ dataLen == 9
+ and (model == Govee.DeviceModel.H5051 or model == Govee.DeviceModel.H5052 or model == Govee.DeviceModel.H5071)
+ then
+ return parse4ByteLEFormat(mfgData, model, 2)
+ end
+
+ log:debug("Govee EC88: model %s with len=%d not supported", model, dataLen)
+ return nil
+ end
+
+ -- 0x0001 manufacturer ID
+ if companyId == Govee.ManufacturerId.ID_0001 then
+ -- H5106: 6 bytes, data[2:6] = 4-byte combined temp/humidity/PM2.5
+ if dataLen == 6 and model == Govee.DeviceModel.H5106 then
+ return parseH5106(mfgData, model)
+ -- H5100/H5101/H5102/H5103/H5104/H5105/H5108/H5110/H5174/H5177/H5179: 6 or 8 bytes, data[2:6] = Lua offset 3
+ -- Note: H5179 can use either 0x0001 (6 bytes, 3-byte format) or 0x8801 (9 bytes, 4-byte LE format)
+ elseif
+ (dataLen == 6 or dataLen == 8)
+ and (
+ model == Govee.DeviceModel.H5100
+ or model == Govee.DeviceModel.H5101
+ or model == Govee.DeviceModel.H5102
+ or model == Govee.DeviceModel.H5103
+ or model == Govee.DeviceModel.H5104
+ or model == Govee.DeviceModel.H5105
+ or model == Govee.DeviceModel.H5108
+ or model == Govee.DeviceModel.H5110
+ or model == Govee.DeviceModel.H5174
+ or model == Govee.DeviceModel.H5177
+ or model == Govee.DeviceModel.H5179
+ )
+ then
+ return parse3ByteFormat(mfgData, model, 3)
+ -- H5112: 8 bytes, dual probe
+ elseif dataLen == 8 and model == Govee.DeviceModel.H5112 then
+ return parseH5112(mfgData, model)
+ -- H5178: 9 bytes, data[3:7] = Lua offset 4
+ elseif dataLen == 9 and model == Govee.DeviceModel.H5178 then
+ return parseH5178(mfgData, model)
+ end
+
+ log:debug("Govee 0001: model %s with len=%d not supported", model, dataLen)
+ return nil
+ end
+
+ -- 0x8801 manufacturer ID
+ if companyId == Govee.ManufacturerId.ID_8801 then
+ -- H5179: 9 bytes, data[4:9] = Lua offset 5 (4-byte LE format)
+ if dataLen == 9 and model == Govee.DeviceModel.H5179 then
+ return parse4ByteLEFormat(mfgData, model, 5)
+ end
+
+ log:debug("Govee 8801: model %s with len=%d not supported", model, dataLen)
+ return nil
+ end
+
+ -- Meat thermometer manufacturer IDs
+ if H5181_MFG_IDS[companyId] then
+ if dataLen == 14 and model == Govee.DeviceModel.H5181 then
+ return parseH5181(mfgData, model)
+ end
+ log:debug("Govee H5181: len=%d not supported", dataLen)
+ return nil
+ end
+
+ if companyId == Govee.ManufacturerId.H5182 then
+ if dataLen == 17 and model == Govee.DeviceModel.H5182 then
+ return parseH5182(mfgData, model)
+ end
+ log:debug("Govee H5182: len=%d not supported", dataLen)
+ return nil
+ end
+
+ if H5183_MFG_IDS[companyId] then
+ if dataLen == 14 and model == Govee.DeviceModel.H5183 then
+ return parseH5181(mfgData, model) -- Same format as H5181
+ end
+ log:debug("Govee H5183: len=%d not supported", dataLen)
+ return nil
+ end
+
+ if companyId == Govee.ManufacturerId.H5184 then
+ if dataLen == 17 and model == Govee.DeviceModel.H5184 then
+ return parseH5184(mfgData, model)
+ end
+ log:debug("Govee H5184: len=%d not supported", dataLen)
+ return nil
+ end
+
+ if H5185_MFG_IDS[companyId] then
+ if dataLen == 20 and model == Govee.DeviceModel.H5185 then
+ return parseH5185(mfgData, model)
+ end
+ log:debug("Govee H5185: len=%d not supported", dataLen)
+ return nil
+ end
+
+ if companyId == Govee.ManufacturerId.H5191 then
+ if dataLen == 20 and model == Govee.DeviceModel.H5191 then
+ return parseH5191(mfgData, model)
+ end
+ log:debug("Govee H5191: len=%d not supported", dataLen)
+ return nil
+ end
+
+ if companyId == Govee.ManufacturerId.H5198 then
+ if dataLen == 20 and model == Govee.DeviceModel.H5198 then
+ return parseH5198(mfgData, model)
+ end
+ log:debug("Govee H5198: len=%d not supported", dataLen)
+ return nil
+ end
+
+ log:debug("Govee: unknown companyId=0x%04X", companyId)
+ return nil
+end
+
+return Govee
diff --git a/src/esphome/ble/parsers/switchbot.lua b/src/esphome/ble/parsers/switchbot.lua
new file mode 100644
index 0000000..aa1ac22
--- /dev/null
+++ b/src/esphome/ble/parsers/switchbot.lua
@@ -0,0 +1,672 @@
+--- SwitchBot advertisement parser.
+--- Parses SwitchBot service data (UUID FD3D) and manufacturer data.
+--- Sources:
+--- - https://github.com/OpenWonderLabs/SwitchBotAPI-BLE
+--- - https://github.com/Danielhiversen/pySwitchbot
+
+local bit32 = require("bitn").bit32
+local log = require("lib.logging")
+local UUID = require("esphome.ble.uuid")
+
+--- @class SwitchBot
+local SwitchBot = {}
+
+--- SwitchBot service UUID (16-bit)
+SwitchBot.SERVICE_UUID = "FD3D"
+
+--- SwitchBot manufacturer company ID (Woan Technology)
+SwitchBot.MANUFACTURER_ID = 0x0969
+
+--- SwitchBot device type codes (from FD3D service data byte 0, bits 6:0)
+--- @enum SwitchBotDeviceTypeCode
+SwitchBot.DeviceTypeCode = {
+ BOT = 0x48, -- 'H'
+ METER = 0x54, -- 'T'
+ METER_PLUS = 0x69, -- 'i'
+ METER_PRO = 0x34, -- '4'
+ METER_PRO_CO2 = 0x35, -- '5'
+ INDOOR_OUTDOOR_METER = 0x77, -- 'w'
+ CONTACT = 0x64, -- 'd'
+ MOTION = 0x73, -- 's'
+ PRESENCE = 0x10, -- Presence Sensor (detected via 4-byte prefix in mfr data)
+ PLUG_MINI = 0x67, -- 'g'
+ RELAY_1 = 0x3B, -- ';'
+ RELAY_1PM = 0x3C, -- '<'
+ RELAY_2PM = 0x3D, -- '='
+ REMOTE = 0x62, -- 'b'
+ WATER_LEAK = 0x26, -- '&'
+ HUMIDIFIER = 0x65, -- 'e'
+ CURTAIN = 0x63, -- 'c'
+ CURTAIN_3 = 0x7B, -- '{'
+ BULB = 0x75, -- 'u'
+ LED_STRIP = 0x72, -- 'r'
+ LOCK = 0x6F, -- 'o'
+}
+
+--- Device type code to name mapping
+--- @type table
+SwitchBot.DEVICE_NAMES = {
+ [SwitchBot.DeviceTypeCode.BOT] = "SwitchBot Bot",
+ [SwitchBot.DeviceTypeCode.METER] = "SwitchBot Meter",
+ [SwitchBot.DeviceTypeCode.METER_PLUS] = "SwitchBot Meter Plus",
+ [SwitchBot.DeviceTypeCode.METER_PRO] = "SwitchBot Meter Pro",
+ [SwitchBot.DeviceTypeCode.METER_PRO_CO2] = "SwitchBot Meter Pro CO2",
+ [SwitchBot.DeviceTypeCode.INDOOR_OUTDOOR_METER] = "SwitchBot Indoor/Outdoor Meter",
+ [SwitchBot.DeviceTypeCode.CONTACT] = "SwitchBot Contact",
+ [SwitchBot.DeviceTypeCode.MOTION] = "SwitchBot Motion",
+ [SwitchBot.DeviceTypeCode.PRESENCE] = "SwitchBot Presence",
+ [SwitchBot.DeviceTypeCode.PLUG_MINI] = "SwitchBot Plug Mini",
+ [SwitchBot.DeviceTypeCode.RELAY_1] = "SwitchBot Relay Switch 1",
+ [SwitchBot.DeviceTypeCode.RELAY_1PM] = "SwitchBot Relay Switch 1PM",
+ [SwitchBot.DeviceTypeCode.RELAY_2PM] = "SwitchBot Relay Switch 2PM",
+ [SwitchBot.DeviceTypeCode.REMOTE] = "SwitchBot Remote",
+ [SwitchBot.DeviceTypeCode.WATER_LEAK] = "SwitchBot Water Leak Detector",
+ [SwitchBot.DeviceTypeCode.HUMIDIFIER] = "SwitchBot Humidifier",
+ [SwitchBot.DeviceTypeCode.CURTAIN] = "SwitchBot Curtain",
+ [SwitchBot.DeviceTypeCode.CURTAIN_3] = "SwitchBot Curtain 3",
+ [SwitchBot.DeviceTypeCode.BULB] = "SwitchBot Bulb",
+ [SwitchBot.DeviceTypeCode.LED_STRIP] = "SwitchBot LED Strip",
+ [SwitchBot.DeviceTypeCode.LOCK] = "SwitchBot Lock",
+}
+
+--- GATT command bytes for controllable devices
+--- Commands are prefixed with 0x57 header
+SwitchBot.Commands = {
+ -- Plug Mini commands (prefix 0x50)
+ PLUG_ON = "\x57\x0F\x50\x01\x01\x80",
+ PLUG_OFF = "\x57\x0F\x50\x01\x01\x00",
+ -- Relay Switch commands (prefix 0x70)
+ RELAY_ON = "\x57\x0F\x70\x01\x01\x00",
+ RELAY_OFF = "\x57\x0F\x70\x01\x00\x00",
+ -- Relay 2PM dual channel commands
+ RELAY_2PM_CH1_ON = "\x57\x0F\x70\x01\x0D\x00",
+ RELAY_2PM_CH1_OFF = "\x57\x0F\x70\x01\x0C\x00",
+ RELAY_2PM_CH2_ON = "\x57\x0F\x70\x01\x07\x00",
+ RELAY_2PM_CH2_OFF = "\x57\x0F\x70\x01\x03\x00",
+ -- Toggle command
+ RELAY_TOGGLE = "\x57\x0F\x70\x01\x02\x00",
+}
+
+--- @class SwitchBotParsedData
+--- @field deviceCode integer Raw device type byte
+--- @field deviceType string Device type name
+--- @field battery integer|nil Battery percentage (0-100)
+--- @field temperature number|nil Temperature in Celsius
+--- @field humidity integer|nil Humidity percentage
+--- @field co2 integer|nil CO2 ppm (Meter Pro CO2 only)
+--- @field isOn boolean|nil Power state for switches/plugs
+--- @field isSwitchMode boolean|nil Bot switch mode (true = switch, false = press)
+--- @field motionDetected boolean|nil Motion sensor state
+--- @field isLight boolean|nil Light detected
+--- @field lightLevel integer|nil Light level (0-3 or 0-31 for presence)
+--- @field contactOpen boolean|nil Contact sensor open state
+--- @field contactTimeout boolean|nil Contact timeout state
+--- @field contactButtonCount integer|nil Contact sensor button press count (0-15, wraps)
+--- @field leakDetected boolean|nil Water leak detected
+--- @field tampered boolean|nil Tamper state
+--- @field lowBattery boolean|nil Low battery warning flag
+--- @field power number|nil Power in watts
+--- @field channel1On boolean|nil Relay channel 1 state
+--- @field channel2On boolean|nil Relay channel 2 state (2PM only)
+--- @field channel1Power number|nil Channel 1 power in watts
+--- @field channel2Power number|nil Channel 2 power in watts
+--- @field duration integer|nil Presence sensor motion duration in seconds
+
+--- Safe byte extraction with bounds checking
+--- @param data string|nil The data string
+--- @param index integer 1-based index
+--- @return integer|nil byte The byte value or nil if out of bounds
+local function getByte(data, index)
+ if not data or index < 1 or index > #data then
+ return nil
+ end
+ return string.byte(data, index)
+end
+
+--- Create a base result object with required fields
+--- @param deviceCode SwitchBotDeviceTypeCode Device type code
+--- @return SwitchBotParsedData|nil result Base result object or nil if model unknown
+local function createResult(deviceCode)
+ local deviceType = SwitchBot.DEVICE_NAMES[deviceCode]
+ if not deviceType then
+ return nil
+ end
+ return {
+ deviceCode = deviceCode,
+ deviceType = deviceType,
+ }
+end
+
+--- Parse battery from a byte (bits 6:0 = percentage, bit 7 = low battery flag)
+--- @param data string|nil The data string
+--- @param index integer 1-based index of battery byte
+--- @return integer|nil battery Battery percentage (0-127) or nil
+--- @return boolean|nil lowBattery Low battery warning flag or nil (only used by Water Leak Detector)
+local function parseBattery(data, index)
+ local batteryByte = getByte(data, index)
+ if not batteryByte then
+ return nil, nil
+ end
+ local battery = bit32.band(batteryByte, 0x7F)
+ -- Not all devices actually report this bit for low battery
+ local lowBattery = bit32.band(batteryByte, 0x80) ~= 0
+ return battery, lowBattery
+end
+
+--- Parse temperature and humidity from SwitchBot format
+--- Bytes are always consecutive: [decimal, integer/sign, humidity]
+--- Temperature: decimal byte (bits 3:0) + integer byte (bit 7=sign, bits 6:0=integer)
+--- Humidity: bits 6:0
+--- @param data string|nil The data string
+--- @param offset integer 1-based index of temperature decimal byte (int is offset+1, humidity is offset+2)
+--- @return number|nil temperature Temperature in Celsius
+--- @return integer|nil humidity Humidity percentage
+local function parseTempHumidity(data, offset)
+ local tempDecByte = getByte(data, offset)
+ local tempIntByte = getByte(data, offset + 1)
+ local humidityByte = getByte(data, offset + 2)
+
+ local temperature = nil
+ if tempDecByte and tempIntByte then
+ local tempSign = bit32.band(tempIntByte, 0x80) ~= 0 and 1 or -1
+ local tempInt = bit32.band(tempIntByte, 0x7F)
+ local tempDec = bit32.band(tempDecByte, 0x0F) / 10
+ temperature = tempSign * (tempInt + tempDec)
+ end
+
+ local humidity = nil
+ if humidityByte then
+ humidity = bit32.band(humidityByte, 0x7F)
+ end
+
+ return temperature, humidity
+end
+
+--- Parse a 16-bit big-endian value from two bytes
+--- @param data string|nil The data string
+--- @param offset integer 1-based offset for high byte (low byte is offset+1)
+--- @return integer|nil value The 16-bit value or nil
+local function parseBigEndian16(data, offset)
+ local high = getByte(data, offset)
+ local low = getByte(data, offset + 1)
+ if not high or not low then
+ return nil
+ end
+ return high * 256 + low
+end
+
+--- Parse power data from two bytes (little-endian, scaled by 0.1)
+--- @param data string|nil The data string
+--- @param offset integer 1-based offset for first byte
+--- @return number|nil power Power in watts
+local function parsePower(data, offset)
+ local low = getByte(data, offset)
+ local high = getByte(data, offset + 1)
+ if not low or not high then
+ return nil
+ end
+ local raw = low + high * 256
+ -- Check for invalid readings (0x7FFF typically means no data)
+ if raw >= 0x7FFF then
+ return nil
+ end
+ return raw / 10.0
+end
+
+--- Parse meter data for regular Meter and Meter Plus (service data bytes 3-5, 0-based)
+--- @param serviceData string|nil Raw FD3D service data
+--- @param deviceCode SwitchBotDeviceTypeCode Device type code
+--- @return SwitchBotParsedData|nil
+local function parseMeterBasic(serviceData, deviceCode)
+ if not serviceData or #serviceData < 6 then
+ return nil
+ end
+
+ local result = createResult(deviceCode)
+ if not result then
+ return nil
+ end
+ result.battery = parseBattery(serviceData, 3)
+ result.temperature, result.humidity = parseTempHumidity(serviceData, 4)
+
+ return result
+end
+
+--- Parse Bot data from service data
+--- Per pySwitchbot: data[1] bit 7 = switch mode, bit 6 = isOn (inverted)
+--- data[2] bits 6:0 = battery
+--- @param serviceData string|nil Raw FD3D service data
+--- @return SwitchBotParsedData|nil
+local function parseBot(serviceData)
+ if not serviceData or #serviceData < 3 then
+ return nil
+ end
+
+ local result = createResult(SwitchBot.DeviceTypeCode.BOT)
+ if not result then
+ return nil
+ end
+
+ -- Byte 1 (index 2): Mode and state flags
+ -- bit 7 = switch mode (1 = switch, 0 = press)
+ -- bit 6 = isOn state (inverted: 0 = on, 1 = off) - only valid in switch mode
+ local modeByte = getByte(serviceData, 2)
+ if modeByte then
+ local isSwitchMode = bit32.band(modeByte, 0x80) ~= 0
+ result.isSwitchMode = isSwitchMode
+ if isSwitchMode then
+ -- isOn is inverted: bit clear = on
+ result.isOn = bit32.band(modeByte, 0x40) == 0
+ end
+ end
+
+ result.battery = parseBattery(serviceData, 3)
+
+ return result
+end
+
+--- Parse meter data for Meter Pro and Meter Pro CO2
+--- Meter Pro uses manufacturer data for temp/humidity (service data is short)
+--- Per pySwitchbot: mfr_data[8:11] for temp/humidity (bytes 8, 9, 10 in Python = indices 9, 10, 11 in Lua)
+--- @param manufacturerData string|nil Raw manufacturer data with temp/humidity
+--- @param serviceData string|nil Raw FD3D service data (may be short, only has device type)
+--- @return SwitchBotParsedData|nil
+local function parseMeterPro(manufacturerData, serviceData)
+ local deviceCode = SwitchBot.DeviceTypeCode.METER_PRO
+ if not manufacturerData or #manufacturerData < 11 then
+ -- Fallback: use service data format (same as regular meters)
+ return parseMeterBasic(serviceData, deviceCode)
+ end
+
+ local result = createResult(deviceCode)
+ if not result then
+ return nil
+ end
+
+ -- Meter Pro uses manufacturer data for temp/humidity (offset 9 = byte 8 in 0-indexed)
+ result.temperature, result.humidity = parseTempHumidity(manufacturerData, 9)
+
+ -- Try to get battery from service data byte 2 (if available)
+ result.battery = parseBattery(serviceData, 3)
+
+ return result
+end
+
+--- Parse Meter Pro CO2 data including CO2 reading
+--- @param manufacturerData string|nil Raw manufacturer data
+--- @param serviceData string|nil Raw FD3D service data
+--- @return SwitchBotParsedData|nil
+local function parseMeterProCO2Full(manufacturerData, serviceData)
+ local result = parseMeterPro(manufacturerData, serviceData)
+ if not result then
+ return nil
+ end
+
+ local deviceCode = SwitchBot.DeviceTypeCode.METER_PRO_CO2
+ result.deviceType = SwitchBot.DEVICE_NAMES[deviceCode]
+ result.deviceCode = deviceCode
+
+ -- CO2: Try manufacturer data first (bytes 13-14, 0-indexed = index 14-15)
+ if manufacturerData and #manufacturerData >= 15 then
+ result.co2 = parseBigEndian16(manufacturerData, 14)
+ elseif serviceData and #serviceData >= 15 then
+ result.co2 = parseBigEndian16(serviceData, 14)
+ end
+
+ return result
+end
+
+--- Parse motion sensor data
+--- @param serviceData string|nil Raw FD3D service data
+--- @return SwitchBotParsedData|nil
+local function parseMotion(serviceData)
+ if not serviceData or #serviceData < 6 then
+ return nil
+ end
+
+ local result = createResult(SwitchBot.DeviceTypeCode.MOTION)
+ if not result then
+ return nil
+ end
+
+ -- Byte 1 (index 2): Status flags - bit 6 = motion detected
+ local statusByte = getByte(serviceData, 2)
+ if statusByte then
+ result.motionDetected = bit32.band(statusByte, 0x40) ~= 0
+ end
+
+ result.battery = parseBattery(serviceData, 3)
+
+ -- Byte 5 (index 6): Light info - bits 0-1 = light level (0-3), bit 1 = is light detected
+ local lightByte = getByte(serviceData, 6)
+ if lightByte then
+ result.lightLevel = bit32.band(lightByte, 0x03)
+ result.isLight = bit32.band(lightByte, 0x02) ~= 0
+ end
+
+ return result
+end
+
+--- Parse contact sensor data
+--- @param manufacturerData string|nil Raw manufacturer data
+--- @param serviceData string|nil Raw FD3D service data
+--- @return SwitchBotParsedData|nil
+local function parseContact(manufacturerData, serviceData)
+ if not serviceData or #serviceData < 4 then
+ return nil
+ end
+
+ local result = createResult(SwitchBot.DeviceTypeCode.CONTACT)
+ if not result then
+ return nil
+ end
+
+ -- Byte 1 (index 2): Status flags - bit 6 = motion detected
+ local statusByte = getByte(serviceData, 2)
+ if statusByte then
+ result.motionDetected = bit32.band(statusByte, 0x40) ~= 0
+ end
+
+ result.battery = parseBattery(serviceData, 3)
+
+ -- Byte 3 (index 4): Contact state - bit 0 = light, bit 1 = contact open, bit 2 = contact timeout
+ local contactByte = getByte(serviceData, 4)
+ if contactByte then
+ result.isLight = bit32.band(contactByte, 0x01) ~= 0
+ result.contactOpen = bit32.band(contactByte, 0x02) ~= 0
+ result.contactTimeout = bit32.band(contactByte, 0x04) ~= 0
+ end
+
+ -- Button count: mfr_data[12] & 0x0F (Lua index 13) or service data[8] & 0x0F (Lua index 9)
+ local buttonByte = getByte(manufacturerData, 13) or getByte(serviceData, 9)
+ if buttonByte then
+ result.contactButtonCount = bit32.band(buttonByte, 0x0F)
+ end
+
+ return result
+end
+
+--- Parse water leak detector data from manufacturer data
+--- @param manufacturerData string|nil Raw manufacturer data
+--- @return SwitchBotParsedData|nil
+local function parseWaterLeak(manufacturerData)
+ if not manufacturerData or #manufacturerData < 9 then
+ return nil
+ end
+
+ local result = createResult(SwitchBot.DeviceTypeCode.WATER_LEAK)
+ if not result then
+ return nil
+ end
+ result.battery, result.lowBattery = parseBattery(manufacturerData, 8)
+
+ -- Byte 8 (index 9): Status - bit 0 = leak detected, bit 1 = tampered
+ local statusByte = getByte(manufacturerData, 9)
+ if statusByte then
+ result.leakDetected = bit32.band(statusByte, 0x01) ~= 0
+ result.tampered = bit32.band(statusByte, 0x02) ~= 0
+ end
+
+ return result
+end
+
+--- Parse Plug Mini data from manufacturer data
+--- @param manufacturerData string|nil Raw manufacturer data
+--- @return SwitchBotParsedData|nil
+local function parsePlugMini(manufacturerData)
+ if not manufacturerData or #manufacturerData < 11 then
+ return nil
+ end
+
+ local result = createResult(SwitchBot.DeviceTypeCode.PLUG_MINI)
+ if not result then
+ return nil
+ end
+ -- Byte 7 (index 8): Power state (0x80 = on, otherwise off)
+ local stateByte = getByte(manufacturerData, 8)
+ if stateByte then
+ result.isOn = stateByte == 0x80
+ end
+
+ result.power = parsePower(manufacturerData, 11)
+
+ return result
+end
+
+--- Parse Relay Switch data from manufacturer data
+--- @param manufacturerData string|nil Raw manufacturer data
+--- @param deviceCode SwitchBotDeviceTypeCode Device type code (RELAY_1, RELAY_1PM, or RELAY_2PM)
+--- @return SwitchBotParsedData|nil
+local function parseRelaySwitch(manufacturerData, deviceCode)
+ if not manufacturerData or #manufacturerData < 8 then
+ return nil
+ end
+
+ local is2PM = deviceCode == SwitchBot.DeviceTypeCode.RELAY_2PM
+ local is1PM = deviceCode == SwitchBot.DeviceTypeCode.RELAY_1PM
+
+ local result = createResult(deviceCode)
+ if not result then
+ return nil
+ end
+
+ -- Byte 7 (index 8): Channel states - bit 7 = ch1 on, bit 6 = ch2 on (2PM only)
+ local stateByte = getByte(manufacturerData, 8)
+ if stateByte then
+ result.channel1On = bit32.band(stateByte, 0x80) ~= 0
+ result.isOn = result.channel1On -- Alias for single-channel compatibility
+ if is2PM then
+ result.channel2On = bit32.band(stateByte, 0x40) ~= 0
+ end
+ end
+
+ -- Power monitoring (1PM and 2PM only)
+ if is1PM or is2PM then
+ result.channel1Power = parsePower(manufacturerData, 11)
+ result.power = result.channel1Power -- Alias for single-channel compatibility
+ if is2PM then
+ result.channel2Power = parsePower(manufacturerData, 13)
+ end
+ end
+
+ return result
+end
+
+--- Parse remote data
+--- @param serviceData string|nil Raw FD3D service data
+--- @return SwitchBotParsedData|nil
+local function parseRemote(serviceData)
+ if not serviceData or #serviceData < 3 then
+ return nil
+ end
+
+ local result = createResult(SwitchBot.DeviceTypeCode.REMOTE)
+ if not result then
+ return nil
+ end
+ result.battery = parseBattery(serviceData, 3)
+
+ -- Note: Button events are NOT currently detectable via passive advertisement
+
+ return result
+end
+
+--- Parse Outdoor Meter (Indoor/Outdoor Thermo-Hygrometer) data
+--- @param manufacturerData string|nil Raw manufacturer data
+--- @param serviceData string|nil Raw FD3D service data
+--- @return SwitchBotParsedData|nil
+local function parseOutdoorMeter(manufacturerData, serviceData)
+ local deviceCode = SwitchBot.DeviceTypeCode.INDOOR_OUTDOOR_METER
+
+ -- Need either manufacturer data (>=12 bytes) or service data (>=6 bytes)
+ if not manufacturerData or #manufacturerData < 12 then
+ -- Fallback to service data format (same as regular meters)
+ return parseMeterBasic(serviceData, deviceCode)
+ end
+
+ local result = createResult(deviceCode)
+ if not result then
+ return nil
+ end
+ result.battery = parseBattery(serviceData, 3)
+ result.temperature, result.humidity = parseTempHumidity(manufacturerData, 9)
+
+ return result
+end
+
+--- Battery level lookup for Presence Sensor (2-bit value to percentage)
+--- @type table
+local PRESENCE_BATTERY_LEVELS = {
+ [0] = 100,
+ [1] = 80,
+ [2] = 60,
+ [3] = 40,
+}
+
+--- Parse Presence Sensor data from manufacturer data
+--- @param manufacturerData string|nil Raw manufacturer data
+--- @param serviceData string|nil Raw FD3D service data (optional, for battery)
+--- @return SwitchBotParsedData|nil
+local function parsePresenceSensor(manufacturerData, serviceData)
+ if not manufacturerData or #manufacturerData < 12 then
+ return nil
+ end
+
+ local result = createResult(SwitchBot.DeviceTypeCode.PRESENCE)
+ if not result then
+ return nil
+ end
+
+ -- Byte 7 (index 8): Packed flags - bit 6 = motion, bits 4-3 = battery level
+ local flagsByte = getByte(manufacturerData, 8)
+ if flagsByte then
+ result.motionDetected = bit32.band(flagsByte, 0x40) ~= 0
+ local batteryBits = bit32.band(bit32.rshift(flagsByte, 3), 0x03)
+ result.battery = PRESENCE_BATTERY_LEVELS[batteryBits] or 50
+ end
+
+ -- Bytes 8-9 (index 9-10): Duration (16-bit big-endian)
+ result.duration = parseBigEndian16(manufacturerData, 9)
+
+ -- Byte 11 (index 12): Light level (bits 0-4)
+ local lightByte = getByte(manufacturerData, 12)
+ if lightByte then
+ result.lightLevel = bit32.band(lightByte, 0x1F)
+ result.isLight = result.lightLevel > 0
+ end
+
+ -- Try to get more accurate battery from service data if available
+ local serviceBattery = parseBattery(serviceData, 3)
+ if serviceBattery then
+ result.battery = serviceBattery
+ end
+
+ return result
+end
+
+--- Find SwitchBot manufacturer data
+--- @param manufacturerData BLEManufacturerData[]|nil Manufacturer data entries
+--- @return string|nil data The raw manufacturer data or nil
+local function findManufacturerData(manufacturerData)
+ if not manufacturerData then
+ return nil
+ end
+ for _, mfg in ipairs(manufacturerData) do
+ if mfg.company == SwitchBot.MANUFACTURER_ID then
+ return mfg.data
+ end
+ end
+ return nil
+end
+
+--- Check if service data matches Presence Sensor signature
+--- Per pySwitchbot: check last 4 bytes of service data for signature
+--- Presence Sensor signature: (0x00 or 0x01) 0x10 0xCC 0xC8
+--- @param fd3dData string|nil FD3D service data
+--- @return boolean isPresence True if this is a Presence Sensor
+local function isPresenceSensor(fd3dData)
+ if not fd3dData or #fd3dData < 4 then
+ return false
+ end
+ -- Check last 4 bytes for signature
+ local len = #fd3dData
+ local s1 = string.byte(fd3dData, len - 3)
+ local s2 = string.byte(fd3dData, len - 2)
+ local s3 = string.byte(fd3dData, len - 1)
+ local s4 = string.byte(fd3dData, len)
+ return (s1 == 0x00 or s1 == 0x01) and s2 == 0x10 and s3 == 0xCC and s4 == 0xC8
+end
+
+--- Parse SwitchBot advertisement data.
+--- @param serviceData BLEServiceData[]|nil Service data array
+--- @param manufacturerData BLEManufacturerData[]|nil Manufacturer data array
+--- @return SwitchBotParsedData|nil parsed Parsed data or nil if not SwitchBot
+function SwitchBot.parse(serviceData, manufacturerData)
+ local fd3dData = UUID.findData(serviceData, SwitchBot.SERVICE_UUID) or ""
+ local mfgData = findManufacturerData(manufacturerData) or ""
+
+ if IsEmpty(fd3dData) and IsEmpty(mfgData) then
+ return nil
+ end
+ --- @cast fd3dData -nil
+ --- @cast mfgData -nil
+
+ -- Determine device type from service data byte 0
+ --- @type SwitchBotDeviceTypeCode|nil
+ local deviceCode = nil
+ -- Check for Presence Sensor (uses signature in last 4 bytes, overrides deviceCode)
+ if isPresenceSensor(fd3dData) then
+ deviceCode = SwitchBot.DeviceTypeCode.PRESENCE
+ elseif fd3dData and #fd3dData >= 1 then
+ local firstByte = string.byte(fd3dData, 1)
+ deviceCode = bit32.band(firstByte, 0x7F)
+ end
+
+ if not deviceCode or not SwitchBot.DEVICE_NAMES[deviceCode] then
+ return nil
+ end
+
+ --- @type SwitchBotParsedData|nil
+ local result = nil
+
+ -- Route to appropriate parser based on device type
+ if deviceCode == SwitchBot.DeviceTypeCode.METER then
+ result = parseMeterBasic(fd3dData, deviceCode)
+ elseif deviceCode == SwitchBot.DeviceTypeCode.METER_PLUS then
+ result = parseMeterBasic(fd3dData, deviceCode)
+ elseif deviceCode == SwitchBot.DeviceTypeCode.METER_PRO then
+ result = parseMeterPro(mfgData, fd3dData)
+ elseif deviceCode == SwitchBot.DeviceTypeCode.METER_PRO_CO2 then
+ result = parseMeterProCO2Full(mfgData, fd3dData)
+ elseif deviceCode == SwitchBot.DeviceTypeCode.INDOOR_OUTDOOR_METER then
+ result = parseOutdoorMeter(mfgData, fd3dData)
+ elseif deviceCode == SwitchBot.DeviceTypeCode.MOTION then
+ result = parseMotion(fd3dData)
+ elseif deviceCode == SwitchBot.DeviceTypeCode.CONTACT then
+ result = parseContact(mfgData, fd3dData)
+ elseif deviceCode == SwitchBot.DeviceTypeCode.WATER_LEAK then
+ result = parseWaterLeak(mfgData)
+ elseif deviceCode == SwitchBot.DeviceTypeCode.PLUG_MINI then
+ result = parsePlugMini(mfgData)
+ elseif deviceCode == SwitchBot.DeviceTypeCode.RELAY_1 then
+ result = parseRelaySwitch(mfgData, deviceCode)
+ elseif deviceCode == SwitchBot.DeviceTypeCode.RELAY_1PM then
+ result = parseRelaySwitch(mfgData, deviceCode)
+ elseif deviceCode == SwitchBot.DeviceTypeCode.RELAY_2PM then
+ result = parseRelaySwitch(mfgData, deviceCode)
+ elseif deviceCode == SwitchBot.DeviceTypeCode.REMOTE then
+ result = parseRemote(fd3dData)
+ elseif deviceCode == SwitchBot.DeviceTypeCode.BOT then
+ result = parseBot(fd3dData)
+ elseif deviceCode == SwitchBot.DeviceTypeCode.PRESENCE then
+ result = parsePresenceSensor(mfgData, fd3dData)
+ else
+ -- Unknown device type - log warning and return nil
+ log:warn("Unknown SwitchBot device code: 0x%02X", deviceCode)
+ end
+
+ log:trace("Parsed SwitchBot data: %s", result)
+ return result
+end
+
+return SwitchBot
diff --git a/src/esphome/ble/parsers/yale.lua b/src/esphome/ble/parsers/yale.lua
new file mode 100644
index 0000000..dfc9c9a
--- /dev/null
+++ b/src/esphome/ble/parsers/yale.lua
@@ -0,0 +1,49 @@
+--- Yale/August BLE advertisement parser.
+--- Detects Yale/August smart locks by manufacturer ID 0x01D1.
+--- Sources:
+--- - https://github.com/bdraco/yalexs-ble
+
+--- @class Yale
+local Yale = {}
+
+--- August Home / Yale manufacturer company ID
+Yale.MANUFACTURER_ID = 0x01D1
+
+--- Device type names
+Yale.DEVICE_NAMES = {
+ LOCK = "Yale Lock",
+}
+
+--- @class YaleParsedData
+--- @field deviceType string Device type name
+
+--- Find Yale manufacturer data from advertisement
+--- @param manufacturerData BLEManufacturerData[]|nil Manufacturer data entries
+--- @return boolean found True if Yale manufacturer data is present
+local function hasManufacturerData(manufacturerData)
+ if not manufacturerData then
+ return false
+ end
+ for _, mfg in ipairs(manufacturerData) do
+ if mfg.company == Yale.MANUFACTURER_ID then
+ return true
+ end
+ end
+ return false
+end
+
+--- Parse Yale BLE advertisement data.
+--- @param _serviceData BLEServiceData[]|nil Service data array (unused, Yale uses manufacturer data)
+--- @param manufacturerData BLEManufacturerData[]|nil Manufacturer data array
+--- @return YaleParsedData|nil parsed Parsed data or nil if not Yale
+function Yale.parse(_serviceData, manufacturerData)
+ if not hasManufacturerData(manufacturerData) then
+ return nil
+ end
+
+ return {
+ deviceType = Yale.DEVICE_NAMES.LOCK,
+ }
+end
+
+return Yale
diff --git a/src/esphome/ble/scanner.lua b/src/esphome/ble/scanner.lua
new file mode 100644
index 0000000..9025815
--- /dev/null
+++ b/src/esphome/ble/scanner.lua
@@ -0,0 +1,699 @@
+--- BLE scanner for discovering Bluetooth devices.
+--- Supports multiple scanner nodes (local ESPHome, coordinator proxies, etc.)
+--- Pure scanning functionality - no property management.
+
+local log = require("lib.logging")
+local persist = require("lib.persist")
+local deferred = require("deferred")
+
+local BLEAddress = require("esphome.ble.address")
+local BTHome = require("bthome")
+local Govee = require("esphome.ble.parsers.govee")
+local SwitchBot = require("esphome.ble.parsers.switchbot")
+local Yale = require("esphome.ble.parsers.yale")
+local UUID = require("esphome.ble.uuid")
+
+--- Persistence key for discovered devices
+local DISCOVERED_DEVICES_KEY = "DiscoveredBLEDevices"
+
+--- Scan duration limits (in seconds)
+--- @type number
+local MIN_SCAN_DURATION = 5
+--- @type number
+local MAX_SCAN_DURATION = 60
+--- @type number
+local DEFAULT_SCAN_DURATION = 10
+
+--- Build a display string for a device.
+--- Note: Commas are removed since C4 uses them as option separators.
+--- @param advertisement BLEAdvertisement The accumulated advertisement data
+--- @param deviceType string|nil The derived device type (e.g., "BTHome V2", "SwitchBot Meter")
+--- @param isPassive boolean Whether device uses passive mode
+--- @return string displayName The formatted display name for device selection UI
+local function buildDisplayName(advertisement, deviceType, isPassive)
+ local parts = { advertisement.mac }
+ if not IsEmpty(advertisement.name) then
+ table.insert(parts, advertisement.name)
+ else
+ table.insert(parts, "Unnamed")
+ end
+ local info = {}
+ if not IsEmpty(deviceType) then
+ table.insert(info, deviceType)
+ elseif not IsEmpty(advertisement.manufacturer) then
+ table.insert(info, advertisement.manufacturer)
+ end
+ -- Add connection type indicator
+ table.insert(info, isPassive and "Passive Connection" or "Active Connection")
+ if #info > 0 then
+ table.insert(parts, "[" .. table.concat(info, " / ") .. "]")
+ end
+ return (table.concat(parts, " - "):gsub(",", ""))
+end
+
+--- @class BLEScanner
+--- @field _nodes table Scanner nodes keyed by ID
+--- @field _scanDeferred Deferred, string>|nil The deferred for the current scan (nil if not scanning)
+--- @field _scanDuration number Scan duration in seconds
+--- @field _onScanStart fun()|nil Callback invoked when scan starts (before collecting advertisements)
+--- @field _onScanEnd fun()|nil Callback invoked when scan ends (after scan completes or is cancelled)
+--- @field _accumulatedAdvertisements table? Advertisements collected during active scan, keyed by MAC
+local BLEScanner = {}
+BLEScanner.__index = BLEScanner
+
+--- Device type to Control4 binding class mapping.
+--- Maps device type strings to the binding class for sub-drivers.
+--- @type table
+local BINDING_CLASSES = {
+ -- BTHome devices
+ ["BTHome V1"] = "ESPHOME_BTHOME",
+ ["BTHome V1 (Encrypted)"] = "ESPHOME_BTHOME",
+ ["BTHome V2"] = "ESPHOME_BTHOME",
+ -- SwitchBot devices
+ [SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.BOT]] = "ESPHOME_SWITCHBOT",
+ [SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.METER]] = "ESPHOME_SWITCHBOT",
+ [SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.METER_PLUS]] = "ESPHOME_SWITCHBOT",
+ [SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.METER_PRO]] = "ESPHOME_SWITCHBOT",
+ [SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.METER_PRO_CO2]] = "ESPHOME_SWITCHBOT",
+ [SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.INDOOR_OUTDOOR_METER]] = "ESPHOME_SWITCHBOT",
+ [SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.MOTION]] = "ESPHOME_SWITCHBOT",
+ [SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.CONTACT]] = "ESPHOME_SWITCHBOT",
+ [SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.PRESENCE]] = "ESPHOME_SWITCHBOT",
+ [SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.WATER_LEAK]] = "ESPHOME_SWITCHBOT",
+ [SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.PLUG_MINI]] = "ESPHOME_SWITCHBOT",
+ [SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.RELAY_1]] = "ESPHOME_SWITCHBOT",
+ [SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.RELAY_1PM]] = "ESPHOME_SWITCHBOT",
+ [SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.RELAY_2PM]] = "ESPHOME_SWITCHBOT",
+ [SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.REMOTE]] = "ESPHOME_SWITCHBOT",
+ -- Govee devices (temperature/humidity sensors)
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5051]] = "ESPHOME_GOVEE",
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5052]] = "ESPHOME_GOVEE",
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5071]] = "ESPHOME_GOVEE",
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5072]] = "ESPHOME_GOVEE",
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5074]] = "ESPHOME_GOVEE",
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5075]] = "ESPHOME_GOVEE",
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5100]] = "ESPHOME_GOVEE",
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5101]] = "ESPHOME_GOVEE",
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5102]] = "ESPHOME_GOVEE",
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5103]] = "ESPHOME_GOVEE",
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5104]] = "ESPHOME_GOVEE",
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5105]] = "ESPHOME_GOVEE",
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5106]] = "ESPHOME_GOVEE",
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5108]] = "ESPHOME_GOVEE",
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5110]] = "ESPHOME_GOVEE",
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5112]] = "ESPHOME_GOVEE",
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5174]] = "ESPHOME_GOVEE",
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5177]] = "ESPHOME_GOVEE",
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5178]] = "ESPHOME_GOVEE",
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5179]] = "ESPHOME_GOVEE",
+ -- Govee devices (meat thermometers)
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5055]] = "ESPHOME_GOVEE",
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5181]] = "ESPHOME_GOVEE",
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5182]] = "ESPHOME_GOVEE",
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5183]] = "ESPHOME_GOVEE",
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5184]] = "ESPHOME_GOVEE",
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5185]] = "ESPHOME_GOVEE",
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5191]] = "ESPHOME_GOVEE",
+ [Govee.DEVICE_NAMES[Govee.DeviceModel.H5198]] = "ESPHOME_GOVEE",
+ -- Yale/August locks
+ [Yale.DEVICE_NAMES.LOCK] = "ESPHOME_YALE",
+}
+
+--- Device types that require active GATT connections (use a connection slot).
+--- All other devices default to passive advertisement mode.
+--- @type table
+local ACTIVE_DEVICES = {
+ [SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.BOT]] = true,
+ [SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.PLUG_MINI]] = true,
+ [SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.RELAY_1]] = true,
+ [SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.RELAY_1PM]] = true,
+ [SwitchBot.DEVICE_NAMES[SwitchBot.DeviceTypeCode.RELAY_2PM]] = true,
+ -- Yale/August locks (require GATT connection for control)
+ [Yale.DEVICE_NAMES.LOCK] = true,
+}
+
+--- Filter functions that determine which BLE advertisements to include in discovery.
+--- Each filter is a function that returns true if the advertisement should be INCLUDED.
+--- @type table
+local FILTERS = {
+ -- Include devices with stable addresses (exclude RPA and Non-Resolvable)
+ ["Invalid MAC Address"] = function(message)
+ return message.mac ~= nil and message.mac ~= "00:00:00:00:00:00"
+ end,
+ -- Include devices with stable addresses (exclude RPA and Non-Resolvable)
+ ["Random Address"] = function(message)
+ local addrType = message.addressType
+ if addrType == nil then
+ return true -- Include if unknown
+ end
+ -- Include Public (0) and Random Static (1) addresses only
+ return addrType == BLEAddress.Type.PUBLIC or addrType == BLEAddress.Type.RANDOM
+ end,
+ -- Exclude Apple devices (they use rotating private addresses for privacy)
+ ["Apple"] = function(message)
+ for _, mfg in ipairs(message.manufacturerData or {}) do
+ -- Apple company ID: 0x004C, first byte of data is continuity type
+ if mfg.company == 0x004C then
+ return false
+ end
+ end
+ return true -- Include non-transient devices
+ end,
+}
+
+--- @class BLEDiscoveredDevice
+--- @field name string? Device name if available
+--- @field displayName string Formatted display name for UI
+--- @field mac string MAC address in format "AA:BB:CC:DD:EE:FF"
+--- @field addressType BLEAddressType? Address type (0=PUBLIC, 1=RANDOM_STATIC, 2=RPA, 3=NON_RESOLVABLE)
+--- @field manufacturer string? Manufacturer name from company ID lookup
+--- @field deviceType string? Device type derived from service data, service UUIDs, or manufacturer data
+--- @field bindingClass string? Control4 binding class if device supports a sub-driver
+--- @field passive boolean Whether device uses passive advertisement mode (no connection slot needed)
+--- @field lastSeen integer Timestamp of last advertisement
+
+--- Creates a new BLEScanner instance.
+--- @return BLEScanner scanner A new scanner instance
+function BLEScanner:new()
+ log:trace("BLEScanner:new()")
+ local instance = setmetatable({}, self)
+ instance._nodes = {}
+ instance._scanDeferred = nil
+ instance._scanDuration = DEFAULT_SCAN_DURATION
+ instance._onScanStart = nil
+ instance._onScanEnd = nil
+ instance._accumulatedAdvertisements = nil
+
+ return instance
+end
+
+------------------------------------------------------------
+-- Node Management
+------------------------------------------------------------
+
+--- Add a scanner node.
+--- @param node BLEScannerNode The node to add
+function BLEScanner:addNode(node)
+ local nodeId = node:getId()
+ if self._nodes[nodeId] then
+ log:warn("BLEScanner: replacing existing node %s", nodeId)
+ end
+ self._nodes[nodeId] = node
+ log:info("BLEScanner: added node %s (total: %d)", nodeId, self:getNodeCount())
+end
+
+--- Remove a scanner node by ID.
+--- @param nodeId string|number The node ID to remove
+--- @return boolean removed True if a node was removed
+function BLEScanner:removeNode(nodeId)
+ local node = self._nodes[nodeId]
+ if node then
+ -- Clear any active callbacks
+ node:clearAdvertisementCallback()
+ self._nodes[nodeId] = nil
+ log:info("BLEScanner: removed node %s (total: %d)", nodeId, self:getNodeCount())
+ return true
+ end
+ return false
+end
+
+--- Get a node by ID.
+--- @param nodeId string|number The node ID
+--- @return BLEScannerNode|nil node The node or nil if not found
+function BLEScanner:getNode(nodeId)
+ return self._nodes[nodeId]
+end
+
+--- Get all nodes.
+--- @return table nodes Map of node ID to node
+function BLEScanner:getNodes()
+ return self._nodes
+end
+
+--- Get the count of nodes.
+--- @return number count Number of nodes
+function BLEScanner:getNodeCount()
+ local count = 0
+ for _ in pairs(self._nodes) do
+ count = count + 1
+ end
+ return count
+end
+
+--- Check if any node is connected.
+--- @return boolean hasConnected True if at least one node is connected
+function BLEScanner:hasConnectedNodes()
+ for _, node in pairs(self._nodes) do
+ if node:isConnected() then
+ return true
+ end
+ end
+ return false
+end
+
+--- Get list of connected nodes.
+--- @return BLEScannerNode[] connectedNodes Array of connected nodes
+function BLEScanner:getConnectedNodes()
+ local connected = {}
+ for _, node in pairs(self._nodes) do
+ if node:isConnected() then
+ table.insert(connected, node)
+ end
+ end
+ return connected
+end
+
+------------------------------------------------------------
+-- Device type derivers
+-- Each entry has:
+-- name: string - Human-readable name for logging
+-- match: function(message) -> boolean - Returns true if this deriver applies
+-- derive: function(message) -> string|nil - Returns device type or nil
+------------------------------------------------------------
+
+--- @class DeviceDeriver
+--- @field name string Human-readable name for logging
+--- @field derive fun(message: BLEAdvertisement): string|nil Returns device type or nil
+
+--- Unified list of device type derivers, checked in order.
+--- @type DeviceDeriver[]
+local DEVICE_DERIVERS = {
+ {
+ name = "BTHome",
+ derive = function(message)
+ local _, uuid =
+ UUID.findData(message.serviceData, BTHome.UUID_V2, BTHome.UUID_V1_UNENCRYPTED, BTHome.UUID_V1_ENCRYPTED)
+ if BTHome.UUID_V2 == uuid then
+ return "BTHome V2"
+ elseif BTHome.UUID_V1_UNENCRYPTED == uuid then
+ return "BTHome V1"
+ elseif BTHome.UUID_V1_ENCRYPTED == uuid then
+ return "BTHome V1 (Encrypted)"
+ end
+ return nil
+ end,
+ },
+ {
+ name = "SwitchBot",
+ derive = function(message)
+ return Select(SwitchBot.parse(message.serviceData, message.manufacturerData), "deviceType")
+ end,
+ },
+ {
+ name = "Govee",
+ derive = function(message)
+ return Select(Govee.parse(message.manufacturerData, message.serviceData, message.name), "deviceType")
+ end,
+ },
+ {
+ name = "Yale",
+ derive = function(message)
+ return Select(Yale.parse(message.serviceData, message.manufacturerData), "deviceType")
+ end,
+ },
+}
+
+--- Set the scan duration.
+--- @param duration number Scan duration in seconds (clamped to MIN_SCAN_DURATION-MAX_SCAN_DURATION)
+function BLEScanner:setScanDuration(duration)
+ log:trace("BLEScanner:setScanDuration(%s)", duration)
+ local value = tointeger(duration) or DEFAULT_SCAN_DURATION
+ self._scanDuration = math.max(MIN_SCAN_DURATION, math.min(MAX_SCAN_DURATION, value))
+end
+
+--- Get the scan duration.
+--- @return number duration Scan duration in seconds
+function BLEScanner:getScanDuration()
+ return self._scanDuration or DEFAULT_SCAN_DURATION
+end
+
+--- Set the callback to invoke when a scan starts.
+--- Use this to prepare for scanning (e.g., clear advertisement filters).
+--- @param callback fun()|nil The callback function or nil to clear
+function BLEScanner:setOnScanStart(callback)
+ log:trace("BLEScanner:setOnScanStart(%s)", callback and "" or "nil")
+ self._onScanStart = callback
+end
+
+--- Set the callback to invoke when a scan ends (completes or is cancelled).
+--- Use this to restore state after scanning (e.g., restore advertisement filters).
+--- @param callback fun()|nil The callback function or nil to clear
+function BLEScanner:setOnScanEnd(callback)
+ log:trace("BLEScanner:setOnScanEnd(%s)", callback and "" or "nil")
+ self._onScanEnd = callback
+end
+
+--- Check if a scan is currently in progress.
+--- @return boolean isScanning True if scanning
+function BLEScanner:isScanning()
+ return self._scanDeferred ~= nil
+end
+
+--- Get discovered devices from persistent storage.
+--- @return table devices Map of MAC to device info
+--- @diagnostic disable-next-line: unused
+function BLEScanner:getDiscoveredDevices()
+ log:trace("BLEScanner:getDiscoveredDevices()")
+ return persist:get(DISCOVERED_DEVICES_KEY, {}) or {}
+end
+
+--- Save discovered devices to persistent storage.
+--- @private
+--- @param devices table|nil devices Map of MAC to device info (nil to clear)
+--- @diagnostic disable-next-line: unused
+function BLEScanner:_setDiscoveredDevices(devices)
+ log:trace("BLEScanner:_setDiscoveredDevices()")
+ persist:set(DISCOVERED_DEVICES_KEY, not IsEmpty(devices) and devices or nil)
+end
+
+--- Derive device type from advertisement data.
+--- Iterates through DEVICE_DERIVERS in order, returning the first match, or nil if none match.
+--- @param message BLEAdvertisement The accumulated advertisement message
+--- @return string|nil deviceType The derived device type or nil
+local function deriveDeviceType(message)
+ -- Try each deriver in order
+ for _, deriver in ipairs(DEVICE_DERIVERS) do
+ local deviceType = deriver.derive(message)
+ if not IsEmpty(deviceType) then
+ log:debug("Device type derived by %s: %s", deriver.name, deviceType)
+ return deviceType
+ end
+ end
+
+ return nil
+end
+
+--- Finalize accumulated device data into a BLEDiscoveredDevice.
+--- @private
+--- @param advertisement BLEAdvertisement The accumulated advertisement data
+--- @return BLEDiscoveredDevice device The finalized device info
+local function finalizeDevice(advertisement)
+ local deviceType = deriveDeviceType(advertisement)
+ local bindingClass = deviceType and BINDING_CLASSES[deviceType] or nil
+ local isPassive = not (deviceType and ACTIVE_DEVICES[deviceType])
+
+ --- @type BLEDiscoveredDevice
+ local device = {
+ name = advertisement.name,
+ displayName = buildDisplayName(advertisement, deviceType, isPassive),
+ mac = advertisement.mac,
+ addressType = advertisement.addressType,
+ manufacturer = advertisement.manufacturer,
+ deviceType = deviceType,
+ bindingClass = bindingClass,
+ passive = isPassive,
+ lastSeen = os.time(),
+ }
+
+ return device
+end
+
+--- Accumulate advertisement data into existing record.
+--- @private
+--- @param accumulated BLEAdvertisement The accumulated data
+--- @param message BLEAdvertisement The new advertisement
+local function accumulateAdvertisement(accumulated, message)
+ -- Store addressType (0=Public, 1=Random Static, 2=RPA, 3=Non-Resolvable)
+ if message.addressType ~= nil and accumulated.addressType == nil then
+ accumulated.addressType = message.addressType
+ end
+
+ -- Accumulate best RSSI (closest signal)
+ if type(message.rssi) == "number" and (type(accumulated.rssi) ~= "number" or message.rssi > accumulated.rssi) then
+ accumulated.rssi = message.rssi
+ end
+
+ -- Accumulate name (keep first non-nil)
+ if not IsEmpty(message.name) and IsEmpty(accumulated.name) then
+ accumulated.name = message.name
+ end
+
+ -- Accumulate manufacturer (keep first non-nil)
+ if not IsEmpty(message.manufacturer) and IsEmpty(accumulated.manufacturer) then
+ accumulated.manufacturer = message.manufacturer
+ end
+
+ -- Accumulate best TX power (highest power)
+ if
+ type(message.txPower) == "number"
+ and (type(accumulated.txPower) ~= "number" or message.txPower > accumulated.txPower)
+ then
+ accumulated.txPower = message.txPower
+ end
+
+ -- Accumulate service UUIDs (by UUID to avoid duplicates)
+ accumulated.serviceUuids = UniqueList(ConcatLists(accumulated.serviceUuids, message.serviceUuids), function(v)
+ --- @cast v BLEServiceUUID
+ return v.uuid
+ end)
+
+ -- Accumulate service data (by UUID to avoid duplicates)
+ accumulated.serviceData = UniqueList(ConcatLists(accumulated.serviceData, message.serviceData), function(v)
+ --- @cast v BLEServiceData
+ return v.uuid
+ end)
+
+ -- Accumulate manufacturer data (by company to avoid duplicates)
+ accumulated.manufacturerData = UniqueList(
+ ConcatLists(accumulated.manufacturerData, message.manufacturerData),
+ function(v)
+ --- @cast v BLEManufacturerData
+ return v.company
+ end
+ )
+end
+
+--- Check if an advertisement should be included.
+--- All filters must return true for the advertisement to be included.
+--- @param message BLEAdvertisement The enriched advertisement message
+--- @return boolean shouldInclude True if this advertisement should be included
+local function shouldInclude(message)
+ for filterName, filterFunc in pairs(FILTERS) do
+ if type(filterFunc) == "function" and not filterFunc(message) then
+ log:trace("Excluding device %s (%s)", message.mac, filterName)
+ return false
+ end
+ end
+ return true
+end
+
+--- Start a BLE scan to discover nearby devices.
+--- Scans all connected nodes and aggregates results.
+--- @return Deferred, string> deferred Resolves when scan completes
+function BLEScanner:scan()
+ log:trace("BLEScanner:scan()")
+ --- @type Deferred, string>
+ local d = deferred.new()
+
+ if self:isScanning() then
+ log:warn("BLE scan already in progress")
+ return d:reject("Scan already in progress")
+ end
+
+ local connectedNodes = self:getConnectedNodes()
+ if #connectedNodes == 0 then
+ log:error("Cannot start BLE scan: no connected nodes")
+ return d:reject("No connected nodes")
+ end
+
+ self._scanDeferred = d
+
+ -- Invoke onScanStart callback (e.g., to clear advertisement filters)
+ if self._onScanStart then
+ local ok, err = pcall(self._onScanStart)
+ if not ok then
+ log:error("onScanStart callback failed: %s", err or "unknown error")
+ end
+ end
+
+ -- Accumulate raw advertisement data during scan, finalize at end
+ -- Stored as instance variable so stopScan() can access it
+ self._accumulatedAdvertisements = {}
+
+ log:info("Starting BLE scan for %d seconds on %d node(s)...", self._scanDuration, #connectedNodes)
+
+ -- Register callbacks on all connected nodes
+ for _, node in ipairs(connectedNodes) do
+ node:setAdvertisementCallback(function(message, nodeId)
+ -- Apply filters (skip if not included)
+ if not shouldInclude(message) then
+ return
+ end
+
+ local mac = message.mac
+ local existing = self._accumulatedAdvertisements[mac]
+ if existing == nil then
+ log:debug("Discovered BLE device: %s (via node %s)", mac, nodeId)
+ self._accumulatedAdvertisements[mac] = message
+ else
+ accumulateAdvertisement(existing, message)
+ end
+ end)
+ end
+
+ -- Start scan timer
+ SetTimer("BLEScanTimeout", self._scanDuration * ONE_SECOND, function()
+ self:_finalizeScan()
+ end)
+
+ return d
+end
+
+--- Finalize an active scan, optionally saving discovered devices.
+--- Called by scan timer, stopScan(), or abortScan().
+--- @param save boolean? If true, save discovered devices and resolve; if false, discard and reject (default: true)
+function BLEScanner:_finalizeScan(save)
+ log:trace("BLEScanner:_finalizeScan(%s)", save)
+ if save == nil then
+ save = true
+ end
+
+ local d = self._scanDeferred
+ local accumulatedAdvertisements = self._accumulatedAdvertisements or {}
+
+ self._scanDeferred = nil
+ self._accumulatedAdvertisements = nil
+
+ -- Cancel the scan timer (in case called early by stopScan/abortScan)
+ CancelTimer("BLEScanTimeout")
+
+ -- Clear callbacks on all nodes
+ for _, node in pairs(self._nodes) do
+ node:clearAdvertisementCallback()
+ end
+
+ -- Finalize accumulated data into device records
+ local discoveredDevices = {}
+ for mac, accumulatedAdvertisement in pairs(accumulatedAdvertisements) do
+ discoveredDevices[mac] = finalizeDevice(accumulatedAdvertisement)
+ end
+
+ -- Invoke onScanEnd callback (e.g., to restore advertisement filters)
+ if self._onScanEnd then
+ local ok, err = pcall(self._onScanEnd)
+ if not ok then
+ log:error("onScanEnd callback failed: %s", err or "unknown error")
+ end
+ end
+
+ if save then
+ log:info("BLE scan complete. Found %d device(s)", TableLength(discoveredDevices))
+ self:_setDiscoveredDevices(discoveredDevices)
+ if d then
+ d:resolve(discoveredDevices)
+ end
+ else
+ log:info("BLE scan aborted. Discarded %d device(s)", TableLength(discoveredDevices))
+ if d then
+ d:reject("Scan aborted")
+ end
+ end
+end
+
+--- Resets the scanner, aborting any active scan and clearing discovered devices.
+function BLEScanner:reset()
+ log:trace("BLEScanner:reset()")
+ self:abortScan()
+ self:_setDiscoveredDevices(nil)
+end
+
+--- Stop an active BLE scan early, keeping devices discovered so far.
+--- Finalizes and saves accumulated advertisements, then resolves the deferred.
+--- @return boolean stopped True if a scan was stopped, false if no scan was active
+function BLEScanner:stopScan()
+ log:trace("BLEScanner:stopScan()")
+
+ if not self:isScanning() then
+ log:debug("No scan in progress to stop")
+ return false
+ end
+
+ log:debug("Stopping BLE scan (keeping discovered devices)...")
+ self:_finalizeScan(true)
+ return true
+end
+
+--- Abort an active BLE scan, discarding any devices discovered so far.
+--- Stops the scan timer and rejects the scan deferred.
+--- @return boolean aborted True if a scan was aborted, false if no scan was active
+function BLEScanner:abortScan()
+ log:trace("BLEScanner:abortScan()")
+
+ if not self:isScanning() then
+ log:debug("No scan in progress to abort")
+ return false
+ end
+
+ log:debug("Aborting BLE scan (discarding discovered devices)...")
+ self:_finalizeScan(false)
+ return true
+end
+
+--- Route an advertisement to the scanner.
+--- This is the main entry point for external advertisement sources (e.g., coordinator bindings).
+--- Only processes the advertisement if a scan is active; otherwise does nothing.
+--- @param advertisement BLEAdvertisement The pre-parsed advertisement
+--- @param nodeId string|number The source node ID
+function BLEScanner:onAdvertisement(advertisement, nodeId)
+ if not self:isScanning() then
+ return
+ end
+
+ local node = self._nodes[nodeId]
+ if node then
+ node:onAdvertisement(advertisement)
+ end
+end
+
+--- Process an advertisement passively (outside of an active scan).
+--- Use this to accumulate devices as they are discovered continuously.
+--- Updates the discovered devices in persistent storage.
+--- @param advertisement BLEAdvertisement The advertisement message
+--- @param nodeId string|number|nil The source node ID (for logging)
+--- @return BLEDiscoveredDevice|nil device The discovered device if included, nil if filtered
+function BLEScanner:processAdvertisement(advertisement, nodeId)
+ -- Apply filters
+ if not shouldInclude(advertisement) then
+ return nil
+ end
+
+ local mac = advertisement.mac
+ if not mac then
+ return nil
+ end
+
+ -- Load existing discovered devices
+ local devices = self:getDiscoveredDevices()
+ local existing = devices[mac]
+
+ if existing then
+ -- Accumulate into existing advertisement data
+ -- We need to convert the existing device back to advertisement format
+ --- @type BLEAdvertisement
+ local accumulated = {
+ mac = existing.mac,
+ addressType = existing.addressType,
+ name = existing.name,
+ manufacturer = existing.manufacturer,
+ -- Note: we don't have full service/manufacturer data from stored device,
+ -- but we can still accumulate new data
+ serviceData = advertisement.serviceData,
+ manufacturerData = advertisement.manufacturerData,
+ serviceUuids = advertisement.serviceUuids,
+ rssi = advertisement.rssi,
+ }
+ accumulateAdvertisement(accumulated, advertisement)
+ devices[mac] = finalizeDevice(accumulated)
+ else
+ -- New device
+ log:debug("Passively discovered BLE device: %s (via node %s)", mac, nodeId or "unknown")
+ devices[mac] = finalizeDevice(advertisement)
+ end
+
+ -- Save updated devices
+ self:_setDiscoveredDevices(devices)
+
+ return devices[mac]
+end
+
+return BLEScanner:new()
diff --git a/src/esphome/ble/scanner_node.lua b/src/esphome/ble/scanner_node.lua
new file mode 100644
index 0000000..378604e
--- /dev/null
+++ b/src/esphome/ble/scanner_node.lua
@@ -0,0 +1,56 @@
+--- BLEScannerNode - Base class for BLE scanner nodes.
+--- A node represents a single BLE radio that can scan for devices.
+--- Implementations include LocalScannerNode (direct ESPHome) and ProxyScannerNode (coordinator).
+
+--- @class BLEScannerNode
+--- @field _id string|number Unique identifier for this node
+--- @field _advertisementCallback fun(advertisement: BLEAdvertisement, nodeId: string|number)? The callback for received advertisements
+local BLEScannerNode = {}
+BLEScannerNode.__index = BLEScannerNode
+
+--- Create a new scanner node.
+--- @param id string|number Unique identifier for this node
+--- @return BLEScannerNode
+function BLEScannerNode:new(id)
+ local instance = setmetatable({}, self)
+ instance._id = id
+ instance._advertisementCallback = nil
+ return instance
+end
+
+--- Get the unique identifier for this node.
+--- @return string|number
+function BLEScannerNode:getId()
+ return self._id
+end
+
+--- Check if the node is connected and available for scanning.
+--- Subclasses must override this method.
+--- @return boolean
+--- @diagnostic disable-next-line: unused
+function BLEScannerNode:isConnected()
+ error("BLEScannerNode:isConnected() must be implemented by subclass")
+end
+
+--- Set the callback to receive BLE advertisements.
+--- The callback receives (advertisement, nodeId) for each advertisement.
+--- @param callback fun(advertisement: BLEAdvertisement, nodeId: string|number)|nil The callback function, or nil to clear
+function BLEScannerNode:setAdvertisementCallback(callback)
+ self._advertisementCallback = callback
+end
+
+--- Clear the advertisement callback.
+function BLEScannerNode:clearAdvertisementCallback()
+ self._advertisementCallback = nil
+end
+
+--- Called by implementations when an advertisement is received.
+--- Routes the advertisement to the registered callback.
+--- @param advertisement BLEAdvertisement The BLE advertisement data
+function BLEScannerNode:onAdvertisement(advertisement)
+ if self._advertisementCallback then
+ self._advertisementCallback(advertisement, self._id)
+ end
+end
+
+return BLEScannerNode
diff --git a/src/esphome/ble/scanner_properties.lua b/src/esphome/ble/scanner_properties.lua
new file mode 100644
index 0000000..13894a0
--- /dev/null
+++ b/src/esphome/ble/scanner_properties.lua
@@ -0,0 +1,405 @@
+--- Generic property UI for selecting BLE devices.
+--- Allows any module to register a device selection property for driver configuration.
+
+local log = require("lib.logging")
+local persist = require("lib.persist")
+local constants = require("constants")
+local bleScanner = require("esphome.ble.scanner")
+
+--- @class PropertyRegistrationOptions
+--- @field persistKey string Key for persisting selections in storage
+--- @field onChanged? fun(selectedDevices: table) Called on initial load and when selection changes
+--- @field limit? number Maximum devices that can be selected (nil = unlimited)
+--- @field filter? fun(device: BLEDiscoveredDevice): boolean Filter function; return true to include device
+
+--- @class PropertyRegistration : PropertyRegistrationOptions
+--- @field selectedDevices table Currently selected devices keyed by MAC
+
+--- @class BLEScannerProperties
+--- @field _properties table Registered properties by name
+local BLEScannerProperties = {}
+BLEScannerProperties.__index = BLEScannerProperties
+
+--- Creates a new BLEScannerProperties instance.
+--- @return BLEScannerProperties
+function BLEScannerProperties:new()
+ log:trace("BLEScannerProperties:new()")
+ local instance = setmetatable({}, self)
+ instance._properties = {}
+ return instance
+end
+
+--- Register a property for device selection.
+--- @param propertyName string The Control4 property name
+--- @param options PropertyRegistrationOptions Registration options
+function BLEScannerProperties:registerProperty(propertyName, options)
+ log:trace("BLEScannerProperties:registerProperty(%s, )", propertyName)
+
+ if not options or IsEmpty(options.persistKey) then
+ log:error("BLEScannerProperties:registerProperty requires persistKey option")
+ return
+ end
+
+ -- Load selected devices from storage (full device info, keyed by MAC)
+ --- @type table
+ local selectedDevices = persist:get(options.persistKey, {}) or {}
+
+ self._properties[propertyName] = {
+ persistKey = options.persistKey,
+ onChanged = options.onChanged,
+ selectedDevices = selectedDevices,
+ limit = options.limit,
+ filter = options.filter,
+ }
+
+ log:info(
+ "Registered property '%s' with %d selected device(s)%s",
+ propertyName,
+ TableLength(selectedDevices),
+ options.limit and string.format(" (limit: %d)", options.limit) or ""
+ )
+
+ -- Fire initial callback with current selection
+ if options.onChanged and TableLength(selectedDevices) > 0 then
+ local success, err = pcall(options.onChanged, selectedDevices)
+ if not success then
+ log:error("Property '%s' onChanged callback failed: %s", propertyName, err or "unknown error")
+ end
+ end
+
+ -- Initialize the property list with default options
+ self:updateProperty(propertyName, false)
+end
+
+--- Update the limit for a property.
+--- Call this when BluetoothConnectionsFreeResponse provides the slot limit.
+--- If limit is lower than current selection count, existing selections are kept
+--- but no new devices can be added until some are removed.
+--- @param propertyName string The property name
+--- @param limit number|nil The new limit (nil = unlimited)
+function BLEScannerProperties:setLimit(propertyName, limit)
+ log:trace("BLEScannerProperties:setLimit(%s, %s)", propertyName, limit)
+
+ local registration = self._properties[propertyName]
+ if not registration then
+ log:warn("Property '%s' not registered", propertyName)
+ return
+ end
+ limit = tointeger(limit)
+ if limit ~= nil then
+ limit = math.max(0, limit)
+ end
+ if registration.limit == limit then
+ log:trace("Limit for '%s' unchanged at %s", propertyName, limit or "unlimited")
+ return
+ end
+ registration.limit = limit
+ log:debug("Updated limit for '%s' to %s", propertyName, limit or "unlimited")
+
+ -- Warn if current selection exceeds new limit
+ if limit ~= nil then
+ local currentCount = TableLength(registration.selectedDevices or {})
+ if currentCount > limit then
+ log:print(
+ "Warning: %d device(s) selected but limit is %d. Remove %d device(s) to stay within limit.",
+ currentCount,
+ limit,
+ currentCount - limit
+ )
+ end
+ end
+end
+
+--- Get the current limit for a property.
+--- @param propertyName string The property name
+--- @return number|nil limit The current limit (nil = unlimited)
+function BLEScannerProperties:getLimit(propertyName)
+ log:trace("BLEScannerProperties:getLimit(%s)", propertyName)
+
+ local registration = self._properties[propertyName]
+ if not registration then
+ log:warn("Property '%s' not registered", propertyName)
+ return nil
+ end
+ return registration.limit
+end
+
+--- Get selected devices for a property.
+--- @param propertyName string The property name
+--- @return table selectedDevices Map of MAC to device info
+function BLEScannerProperties:getSelectedDevices(propertyName)
+ log:trace("BLEScannerProperties:getSelectedDevices(%s)", propertyName)
+
+ local registration = self._properties[propertyName]
+ if not registration then
+ log:warn("Property '%s' not registered", propertyName)
+ return {}
+ end
+ return registration.selectedDevices or {}
+end
+
+--- Get all selected MACs across all properties (for cache management).
+--- @return table allSelectedMacs Map of MAC to true
+function BLEScannerProperties:getAllSelectedMacs()
+ log:trace("BLEScannerProperties:getAllSelectedMacs()")
+
+ local allMacs = {}
+ for _, registration in pairs(self._properties) do
+ for mac, _ in pairs(registration.selectedDevices or {}) do
+ allMacs[mac] = true
+ end
+ end
+ return allMacs
+end
+
+--- Count selected devices that require active connections (non-passive).
+--- @param propertyName string The property name
+--- @return number count Number of selected devices requiring active connections
+function BLEScannerProperties:getSelectedActiveCount(propertyName)
+ local registration = self._properties[propertyName]
+ if not registration then
+ return 0
+ end
+
+ local count = 0
+ for _, device in pairs(registration.selectedDevices or {}) do
+ if not device.passive then
+ count = count + 1
+ end
+ end
+ return count
+end
+
+--- Update a property's UI list.
+--- @param propertyName string The property name
+--- @param refresh boolean|nil If true, trigger a new scan first
+function BLEScannerProperties:updateProperty(propertyName, refresh)
+ log:trace("BLEScannerProperties:updateProperty(%s, %s)", propertyName, refresh)
+
+ local registration = self._properties[propertyName]
+ if not registration then
+ log:warn("Property '%s' not registered", propertyName)
+ return
+ end
+
+ local doUpdate = function()
+ local discovered = bleScanner:getDiscoveredDevices()
+ local selected = registration.selectedDevices or {}
+ local filter = registration.filter
+
+ -- Build sorted list of devices (apply filter: true = include)
+ --- @type BLEDiscoveredDevice[]
+ local devices = {}
+ for _, device in pairs(discovered) do
+ if not filter or filter(device) then
+ table.insert(devices, device)
+ end
+ end
+
+ -- Always show selected devices even if they don't pass filter
+ for mac, device in pairs(selected) do
+ if not discovered[mac] then
+ table.insert(devices, device)
+ end
+ end
+
+ -- Sort by name, then MAC
+ table.sort(devices, function(a, b)
+ local nameA = a.name or ""
+ local nameB = b.name or ""
+ if nameA ~= nameB then
+ return nameA < nameB
+ end
+ return a.mac < b.mac
+ end)
+
+ -- Build option strings
+ --- @type string[]
+ local options = {}
+ for _, device in ipairs(devices) do
+ local displayName = device.displayName or device.mac
+ if selected[device.mac] then
+ table.insert(options, "[X] " .. displayName)
+ else
+ table.insert(options, "[ ] " .. displayName)
+ end
+ end
+
+ -- Add special options at the beginning
+ table.insert(options, 1, constants.REFRESH_LIST_OPTION)
+ table.insert(options, 1, constants.SELECT_OPTION)
+
+ -- Update the Control4 property
+ C4:UpdatePropertyList(propertyName, table.concat(options, ","), constants.SELECT_OPTION)
+ end
+
+ if refresh then
+ -- Show scanning indicator with stop/abort options
+ local scanOptions = constants.SCANNING_OPTION
+ .. ","
+ .. constants.STOP_SCAN_OPTION
+ .. ","
+ .. constants.ABORT_SCAN_OPTION
+ C4:UpdatePropertyList(propertyName, scanOptions, constants.SCANNING_OPTION)
+
+ log:print("Scanning for Bluetooth devices...")
+ bleScanner:scan():next(function()
+ doUpdate()
+ end, function(err)
+ -- Don't log error for cancelled scans
+ if err ~= "Scan cancelled" then
+ log:error("Failed to scan for Bluetooth devices: %s", err or "unknown")
+ end
+ doUpdate()
+ end)
+ else
+ doUpdate()
+ end
+end
+
+--- Handle a selection change from the UI.
+--- @param propertyName string The property name
+--- @param propertyValue string The selected property value
+--- @return boolean changed True if selection changed
+function BLEScannerProperties:handleSelection(propertyName, propertyValue)
+ log:trace("BLEScannerProperties:handleSelection(%s, %s)", propertyName, propertyValue)
+
+ local registration = self._properties[propertyName]
+ if not registration then
+ log:warn("Property '%s' not registered", propertyName)
+ return false
+ end
+
+ -- Handle special options
+ if propertyValue == constants.REFRESH_LIST_OPTION then
+ log:print("Refreshing device list for '%s'", propertyName)
+ self:updateProperty(propertyName, true)
+ return false
+ end
+
+ if propertyValue == constants.STOP_SCAN_OPTION then
+ if bleScanner:stopScan() then
+ log:print("Scan stopped")
+ end
+ return false
+ end
+
+ if propertyValue == constants.ABORT_SCAN_OPTION then
+ if bleScanner:abortScan() then
+ log:print("Scan aborted")
+ end
+ return false
+ end
+
+ if propertyValue == constants.SELECT_OPTION or propertyValue == constants.SCANNING_OPTION then
+ return false
+ end
+
+ -- Extract MAC address from the option string
+ -- Format: "[X] AA:BB:CC:DD:EE:FF - Name" or "[ ] AA:BB:CC:DD:EE:FF - Name"
+ local mac = string.match(propertyValue or "", "([%x]+:[%x]+:[%x]+:[%x]+:[%x]+:[%x]+)")
+ if not mac then
+ log:warn("Could not extract MAC address from: %s", propertyValue)
+ return false
+ end
+
+ local selected = registration.selectedDevices or {}
+ local wasSelected = selected[mac] ~= nil
+ local isUnselecting = string.match(propertyValue, "^%[X%]")
+
+ -- Get device from discovered list, or from selected list for unselection
+ local device = bleScanner:getDiscoveredDevices()[mac]
+ if IsEmpty(device) and isUnselecting then
+ -- For unselection, use the stored device info
+ device = selected[mac]
+ end
+
+ if IsEmpty(device) then
+ log:print("Cannot add device: unknown mac address. Refresh list and try again.")
+ return false
+ end
+ --- @cast device -nil
+
+ if isUnselecting then
+ -- Currently selected, remove it
+ selected[mac] = nil
+ log:info("Removed device from '%s': %s", propertyName, mac)
+ else
+ selected[mac] = device
+ log:info("Added device to '%s': %s (%s)", propertyName, mac, device.name or "unnamed")
+ end
+
+ registration.selectedDevices = selected
+
+ -- Persist the full device info (keyed by MAC)
+ persist:set(registration.persistKey, not IsEmpty(selected) and selected or nil)
+
+ -- Update the property UI
+ self:updateProperty(propertyName, false)
+
+ -- Fire callback if selection actually changed
+ local isSelected = selected[mac] ~= nil
+ if wasSelected ~= isSelected and registration.onChanged then
+ local success, err = pcall(registration.onChanged, selected)
+ if not success then
+ log:error("Property '%s' onChanged callback failed: %s", propertyName, err or "unknown error")
+ end
+ end
+
+ return true
+end
+
+--- Clear the selection for a specific property.
+--- @param propertyName string The property name
+function BLEScannerProperties:clearSelection(propertyName)
+ log:trace("BLEScannerProperties:clearSelection(%s)", propertyName)
+ local registration = self._properties[propertyName]
+ if not registration then
+ log:warn("Property '%s' not registered", propertyName)
+ return
+ end
+
+ -- Clear the persisted selection
+ persist:delete(registration.persistKey)
+
+ -- Clear the in-memory selection
+ registration.selectedDevices = {}
+
+ -- Update the property UI
+ self:updateProperty(propertyName, false)
+
+ -- Fire onChanged callback with empty selection
+ if registration.onChanged then
+ local success, err = pcall(registration.onChanged, {})
+ if not success then
+ log:error("Property '%s' onChanged callback failed: %s", propertyName, err or "unknown error")
+ end
+ end
+
+ log:info("Cleared selection for property '%s'", propertyName)
+end
+
+--- Resets all registered properties, clearing selections and persisted data.
+--- Does NOT unregister properties - they remain registered but empty.
+function BLEScannerProperties:reset()
+ log:trace("BLEScannerProperties:reset()")
+ for propertyName, registration in pairs(self._properties) do
+ log:debug("Resetting property '%s'", propertyName)
+
+ -- Clear the persisted selection
+ persist:delete(registration.persistKey)
+
+ -- Clear the in-memory selection
+ registration.selectedDevices = {}
+
+ -- Fire onChanged callback with empty selection
+ if registration.onChanged then
+ local success, err = pcall(registration.onChanged, {})
+ if not success then
+ log:error("Property '%s' onChanged callback failed: %s", propertyName, err or "unknown error")
+ end
+ end
+ end
+end
+
+return BLEScannerProperties:new()
diff --git a/src/esphome/ble/uuid.lua b/src/esphome/ble/uuid.lua
new file mode 100644
index 0000000..326db22
--- /dev/null
+++ b/src/esphome/ble/uuid.lua
@@ -0,0 +1,344 @@
+--- BLE UUID handling utilities.
+
+local bit64 = require("bitn").bit64
+
+local UUID = {}
+
+--- Convert a uint64 value (number or {high, low} table) to a hex string.
+--- @param value number|Int64HighLow|nil The uint64 value
+--- @param width number Number of hex digits (default 16)
+--- @return string hex The hex representation
+local function uint64ToHex(value, width)
+ width = width or 16
+ if type(value) == "number" then
+ -- Simple number - use string.format for what we can
+ if value < 0x100000000 then
+ return string.format("%0" .. width .. "X", value)
+ else
+ -- Large number, need to split
+ local low = value % 0x100000000
+ local high = math.floor(value / 0x100000000)
+ return string.format("%0" .. (width - 8) .. "X%08X", high, low)
+ end
+ elseif type(value) == "table" and value[1] ~= nil and value[2] ~= nil then
+ -- {high, low} pair
+ local high = value[1]
+ local low = value[2]
+ return string.format("%0" .. (width - 8) .. "X%08X", high, low)
+ else
+ return string.rep("0", width)
+ end
+end
+
+--- Convert a short UUID (16-bit or 32-bit) to a full 128-bit UUID string.
+--- @param shortUuid number|nil The 16-bit or 32-bit UUID
+--- @return string|nil uuid The full UUID string (e.g., "00001800-0000-1000-8000-00805F9B34FB")
+function UUID.shortToString(shortUuid)
+ if not shortUuid or shortUuid == 0 then
+ return nil
+ end
+ -- Short UUIDs go in the high 32 bits of the first 64-bit segment
+ -- Full UUID format: XXXXXXXX-0000-1000-8000-00805F9B34FB
+ local uuidHigh = string.format("%08X00001000", shortUuid)
+ local uuidLow = "800000805F9B34FB"
+ local full = uuidHigh .. uuidLow
+ return string.format(
+ "%s-%s-%s-%s-%s",
+ string.sub(full, 1, 8),
+ string.sub(full, 9, 12),
+ string.sub(full, 13, 16),
+ string.sub(full, 17, 20),
+ string.sub(full, 21, 32)
+ )
+end
+
+--- Convert a repeated uint64 UUID field to a UUID string.
+--- The UUID is stored as two 64-bit values: uuid[1] is high 64 bits, uuid[2] is low 64 bits.
+--- @param uuidField number[]|Int64HighLow[]|nil The repeated uint64 field (array of 1-2 values)
+--- @return string|nil uuid The UUID string or nil if not valid
+function UUID.repeatedUint64ToString(uuidField)
+ if not uuidField or type(uuidField) ~= "table" then
+ return nil
+ end
+ if #uuidField < 2 then
+ -- Only one element or empty - check if it's a short UUID encoded oddly
+ return nil
+ end
+
+ -- uuid[1] = high 64 bits, uuid[2] = low 64 bits
+ local highHex = uint64ToHex(uuidField[1], 16)
+ local lowHex = uint64ToHex(uuidField[2], 16)
+
+ -- Combine and format as UUID
+ local full = highHex .. lowHex
+ return string.format(
+ "%s-%s-%s-%s-%s",
+ string.sub(full, 1, 8),
+ string.sub(full, 9, 12),
+ string.sub(full, 13, 16),
+ string.sub(full, 17, 20),
+ string.sub(full, 21, 32)
+ )
+end
+
+--- Convert a GATT service/characteristic UUID to a string.
+--- Handles both short_uuid and uuid fields from ESPHome proto.
+--- @param obj ProtoBluetoothGATTService|ProtoBluetoothGATTCharacteristic The service or characteristic object with uuid and/or short_uuid fields
+--- @return string|nil uuid The UUID string or nil if not present
+function UUID.fromGattObject(obj)
+ if not obj then
+ return nil
+ end
+
+ -- Prefer short_uuid if available (more common for standard services)
+ if obj.short_uuid and obj.short_uuid ~= 0 then
+ return UUID.shortToString(obj.short_uuid)
+ end
+
+ -- Otherwise try the repeated uint64 uuid field
+ if obj.uuid then
+ return UUID.repeatedUint64ToString(obj.uuid)
+ end
+
+ return nil
+end
+
+--- Check if a UUID is the Bluetooth Base UUID with a given short UUID.
+--- @param uuidStr string The full UUID string
+--- @param shortUuid number The short UUID to check against
+--- @return boolean matches True if the UUID matches the short UUID
+function UUID.matchesShortUuid(uuidStr, shortUuid)
+ if not uuidStr or not shortUuid then
+ return false
+ end
+ local expected = UUID.shortToString(shortUuid)
+ return expected ~= nil and uuidStr:upper() == expected:upper()
+end
+
+--- Normalize a UUID to canonical form (uppercase hex string, no dashes).
+--- Handles numbers, strings (with/without dashes), Int64HighLow, and Int64HighLow[].
+--- @param uuid string|number|Int64HighLow|Int64HighLow[]|nil UUID in any format
+--- @return string|nil normalized Uppercase hex string without dashes, or nil if invalid
+function UUID.normalize(uuid)
+ if uuid == nil then
+ return nil
+ end
+
+ -- Number: convert to hex (handles 16-bit, 32-bit values)
+ if type(uuid) == "number" then
+ return string.format("%X", uuid)
+ end
+
+ -- String: remove dashes, convert to uppercase
+ if type(uuid) == "string" then
+ return uuid:gsub("-", ""):upper()
+ end
+
+ -- Int64HighLow: single 64-bit value
+ if bit64.is_int64(uuid) then
+ --- @cast uuid Int64HighLow
+ return uint64ToHex(uuid, 16)
+ end
+ --- @cast uuid -Int64HighLow
+
+ -- Table: could be Int64HighLow[] array (two 64-bit values for 128-bit UUID)
+ if type(uuid) == "table" and #uuid >= 2 then
+ local first = uuid[1]
+ local second = uuid[2]
+ -- Check if elements are Int64HighLow
+ if bit64.is_int64(first) and bit64.is_int64(second) then
+ --- @cast first Int64HighLow
+ --- @cast second Int64HighLow
+ local highHex = uint64ToHex(first, 16)
+ local lowHex = uint64ToHex(second, 16)
+ return highHex .. lowHex
+ end
+ end
+
+ return nil
+end
+
+--- Compare two UUIDs (handles any format: string, number, Int64HighLow).
+--- @param uuid1 string|number|Int64HighLow|nil First UUID
+--- @param uuid2 string|number|Int64HighLow|nil Second UUID
+--- @return boolean matches True if UUIDs match
+function UUID.matches(uuid1, uuid2)
+ local norm1 = UUID.normalize(uuid1)
+ local norm2 = UUID.normalize(uuid2)
+ if not norm1 or not norm2 then
+ return false
+ end
+ return norm1 == norm2
+end
+
+--- Find service data by UUID in a BLE advertisement.
+--- Searches for the first matching UUID from the provided list.
+--- @param data BLEServiceData[]|nil Array of service data entries {uuid, data}
+--- @param ... string|integer One or more UUIDs to search for (e.g., "FCD2", 0xFCD2)
+--- @return string|nil data Raw service data bytes or nil if not found
+--- @return string|integer|nil uuid The UUID that matched, or nil if not found
+--- @overload fun(data: BLEServiceData[], ...: string): (string|nil, string|nil)
+--- @overload fun(data: BLEServiceData[], ...: integer): (string|nil, integer|nil)
+function UUID.findData(data, ...)
+ if not data then
+ return nil, nil
+ end
+ local uuids = { ... }
+ if #uuids == 0 then
+ return nil, nil
+ end
+ for _, entry in ipairs(data) do
+ for _, uuid in ipairs(uuids) do
+ if UUID.matches(entry.uuid, uuid) then
+ return entry.data, uuid
+ end
+ end
+ end
+ return nil, nil
+end
+
+--- Find a service by UUID in a list of GATT services.
+--- @param services ProtoBluetoothGATTService[] List of GATT services from bluetoothGattGetServices
+--- @param targetUuid string The UUID to find
+--- @return ProtoBluetoothGATTService|nil service The matching service or nil
+function UUID.findService(services, targetUuid)
+ if not services or not targetUuid then
+ return nil
+ end
+ for _, svc in ipairs(services) do
+ local svcUuid = UUID.fromGattObject(svc)
+ if svcUuid and UUID.matches(svcUuid, targetUuid) then
+ return svc
+ end
+ end
+ return nil
+end
+
+--- Find a characteristic by UUID in a service.
+--- @param service ProtoBluetoothGATTService The GATT service
+--- @param targetUuid string The UUID to find
+--- @return ProtoBluetoothGATTCharacteristic|nil characteristic The matching characteristic or nil
+function UUID.findCharacteristic(service, targetUuid)
+ if not service or not service.characteristics or not targetUuid then
+ return nil
+ end
+ for _, chr in ipairs(service.characteristics) do
+ local chrUuid = UUID.fromGattObject(chr)
+ if chrUuid and UUID.matches(chrUuid, targetUuid) then
+ return chr
+ end
+ end
+ return nil
+end
+
+--- Find a characteristic handle by service and characteristic UUID.
+--- Combines findService + findCharacteristic and returns the integer handle.
+--- @param services ProtoBluetoothGATTService[] List of GATT services
+--- @param serviceUuid string The service UUID to find
+--- @param charUuid string The characteristic UUID to find
+--- @return integer|nil handle The characteristic handle or nil
+function UUID.findCharacteristicHandle(services, serviceUuid, charUuid)
+ local service = UUID.findService(services, serviceUuid)
+ if not service then
+ return nil
+ end
+ local characteristic = UUID.findCharacteristic(service, charUuid)
+ if not characteristic then
+ return nil
+ end
+ return tointeger(characteristic.handle)
+end
+
+--- Common Bluetooth GATT UUIDs (short form - 16-bit)
+UUID.GATT = {
+ -- Standard Services
+ GENERIC_ACCESS = 0x1800,
+ GENERIC_ATTRIBUTE = 0x1801,
+ DEVICE_INFORMATION = 0x180A,
+ BATTERY_SERVICE = 0x180F,
+
+ -- Standard Characteristics
+ DEVICE_NAME = 0x2A00,
+ APPEARANCE = 0x2A01,
+ BATTERY_LEVEL = 0x2A19,
+ MANUFACTURER_NAME = 0x2A29,
+ MODEL_NUMBER = 0x2A24,
+ SERIAL_NUMBER = 0x2A25,
+ FIRMWARE_REVISION = 0x2A26,
+
+ -- Descriptors
+ CLIENT_CHARACTERISTIC_CONFIGURATION = 0x2902,
+}
+
+--- Run self-tests for UUID module.
+--- @return boolean success True if all tests passed
+function UUID.selftest()
+ print("Testing UUID module...")
+ local passed = 0
+ local failed = 0
+
+ local function test(name, condition)
+ if condition then
+ passed = passed + 1
+ print(" PASS: " .. name)
+ else
+ failed = failed + 1
+ print(" FAIL: " .. name)
+ end
+ end
+
+ -- Test normalize with numbers
+ test("normalize(0xFCD2) = 'FCD2'", UUID.normalize(0xFCD2) == "FCD2")
+ test("normalize(0x1800) = '1800'", UUID.normalize(0x1800) == "1800")
+ test("normalize(0x12345678) = '12345678'", UUID.normalize(0x12345678) == "12345678")
+
+ -- Test normalize with strings
+ test("normalize('fcd2') = 'FCD2'", UUID.normalize("fcd2") == "FCD2")
+ test("normalize('FCD2') = 'FCD2'", UUID.normalize("FCD2") == "FCD2")
+ test(
+ "normalize with dashes",
+ UUID.normalize("12345678-1234-5678-1234-567890ABCDEF") == "12345678123456781234567890ABCDEF"
+ )
+
+ -- Test normalize with nil
+ test("normalize(nil) = nil", UUID.normalize(nil) == nil)
+
+ -- Test matches with mixed formats
+ test("matches(0xFCD2, 'FCD2')", UUID.matches(0xFCD2, "FCD2"))
+ test("matches('fcd2', 0xFCD2)", UUID.matches("fcd2", 0xFCD2))
+ test("matches('FD3D', 'fd3d')", UUID.matches("FD3D", "fd3d"))
+ test("not matches(0xFCD2, 'FD3D')", not UUID.matches(0xFCD2, "FD3D"))
+
+ -- Test findData
+ local serviceData = {
+ { uuid = "FCD2", data = "bthome_data" },
+ { uuid = "FD3D", data = "switchbot_data" },
+ }
+ local foundData, foundUuid = UUID.findData(serviceData, "FCD2")
+ test("findData with string UUID", foundData == "bthome_data" and foundUuid == "FCD2")
+ foundData, foundUuid = UUID.findData(serviceData, 0xFCD2)
+ test("findData with number UUID", foundData == "bthome_data" and foundUuid == 0xFCD2)
+ foundData, foundUuid = UUID.findData(serviceData, "fcd2")
+ test("findData case insensitive", foundData == "bthome_data" and foundUuid == "fcd2")
+ foundData, foundUuid = UUID.findData(serviceData, "1234")
+ test("findData not found", foundData == nil and foundUuid == nil)
+ foundData, foundUuid = UUID.findData(nil, "FCD2")
+ test("findData nil array", foundData == nil and foundUuid == nil)
+ -- Test findData with multiple UUIDs
+ foundData, foundUuid = UUID.findData(serviceData, "XXXX", "FD3D", "FCD2")
+ test("findData multiple UUIDs finds first match", foundData == "switchbot_data" and foundUuid == "FD3D")
+ foundData, foundUuid = UUID.findData(serviceData, "AAAA", "BBBB")
+ test("findData multiple UUIDs none match", foundData == nil and foundUuid == nil)
+
+ -- Test Int64HighLow if bit64 is available
+ local int64_1 = bit64.new(0x12345678, 0x9ABCDEF0)
+ if bit64.is_int64(int64_1) then
+ test("normalize Int64HighLow", UUID.normalize(int64_1) == "123456789ABCDEF0")
+ test("matches Int64HighLow with string", UUID.matches(int64_1, "123456789ABCDEF0"))
+ end
+
+ print(string.format("\nUUID module: %d/%d tests passed\n", passed, passed + failed))
+ return failed == 0
+end
+
+return UUID
diff --git a/src/esphome/ble/yale_protocol.lua b/src/esphome/ble/yale_protocol.lua
new file mode 100644
index 0000000..f4bac3e
--- /dev/null
+++ b/src/esphome/ble/yale_protocol.lua
@@ -0,0 +1,623 @@
+--- Yale/August BLE lock protocol implementation.
+--- Port of key logic from Home Assistant's yalexs-ble Python library.
+--- Sources:
+--- - https://github.com/bdraco/yalexs-ble
+
+local bit32 = require("bitn").bit32
+
+local yale_protocol = {}
+
+--------------------------------------------------------------------------------
+-- GATT UUIDs
+--------------------------------------------------------------------------------
+
+yale_protocol.UUID = {
+ SERVICE = "0000FE24-0000-1000-8000-00805F9B34FB",
+ WRITE = "BD4AC611-0B45-11E3-8FFD-0800200C9A66",
+ READ = "BD4AC612-0B45-11E3-8FFD-0800200C9A66",
+ SECURE_WRITE = "BD4AC613-0B45-11E3-8FFD-0800200C9A66",
+ SECURE_READ = "BD4AC614-0B45-11E3-8FFD-0800200C9A66",
+}
+
+--------------------------------------------------------------------------------
+-- Command Opcodes
+--------------------------------------------------------------------------------
+
+yale_protocol.Opcode = {
+ -- Secure handshake opcodes
+ SEC_LOCK_TO_MOBILE_KEY_EXCHANGE = 0x01,
+ SEC_MOBILE_TO_LOCK_KEY_EXCHANGE_RESP = 0x02,
+ SEC_INITIALIZATION_COMMAND = 0x03,
+ SEC_INITIALIZATION_RESP = 0x04,
+ SEC_DISCONNECT = 0x05,
+ SEC_DISCONNECT_RESP = 0x8B,
+ -- Lock operation opcodes
+ LOCK = 0x0B,
+ UNLOCK = 0x0A,
+ GET_STATUS = 0x02,
+}
+
+--- Status query subtypes (sent at offset 0x04)
+yale_protocol.StatusType = {
+ LOCK_ONLY = 0x02,
+ DOOR_ONLY = 0x2E,
+ DOOR_AND_LOCK = 0x2F,
+ BATTERY = 0x0F,
+}
+
+--------------------------------------------------------------------------------
+-- Lock / Door Status Enums
+--------------------------------------------------------------------------------
+
+--- Lock status byte values
+yale_protocol.LockStatus = {
+ UNKNOWN = 0x00,
+ CALIBRATING = 0x01,
+ UNLOCKING = 0x02,
+ UNLOCKED = 0x03,
+ LOCKING = 0x04,
+ LOCKED = 0x05,
+ SECURE_MODE = 0x0C,
+ JAMMED = 0x1B,
+}
+
+--- Door status byte values
+yale_protocol.DoorStatus = {
+ CLOSED = 0x01,
+ AJAR = 0x02,
+ OPENED = 0x03,
+}
+
+--- Map lock status byte to string
+--- @param statusByte integer Lock status byte
+--- @return string status Human-readable status
+function yale_protocol.parseLockStatus(statusByte)
+ statusByte = statusByte or 0
+ if statusByte == yale_protocol.LockStatus.LOCKED then
+ return "locked"
+ elseif statusByte == yale_protocol.LockStatus.UNLOCKED then
+ return "unlocked"
+ elseif statusByte == yale_protocol.LockStatus.JAMMED then
+ return "jammed"
+ elseif statusByte == yale_protocol.LockStatus.LOCKING then
+ return "locking"
+ elseif statusByte == yale_protocol.LockStatus.UNLOCKING then
+ return "unlocking"
+ elseif statusByte == yale_protocol.LockStatus.CALIBRATING then
+ return "calibrating"
+ elseif statusByte == yale_protocol.LockStatus.SECURE_MODE then
+ return "secure_mode"
+ end
+ return "unknown"
+end
+
+--- Map lock status to C4 lock status string
+--- @param statusByte integer Lock status byte
+--- @return string c4Status C4 lock status ("locked", "unlocked", "fault")
+function yale_protocol.toC4LockStatus(statusByte)
+ statusByte = statusByte or 0
+ if statusByte == yale_protocol.LockStatus.LOCKED then
+ return "locked"
+ elseif statusByte == yale_protocol.LockStatus.UNLOCKED then
+ return "unlocked"
+ elseif statusByte == yale_protocol.LockStatus.JAMMED then
+ return "fault"
+ elseif statusByte == yale_protocol.LockStatus.LOCKING then
+ return "unlocked" -- transitional
+ elseif statusByte == yale_protocol.LockStatus.UNLOCKING then
+ return "locked" -- transitional
+ elseif statusByte == yale_protocol.LockStatus.CALIBRATING then
+ return "fault" -- calibrating
+ elseif statusByte == yale_protocol.LockStatus.SECURE_MODE then
+ return "locked" -- secure mode = locked
+ end
+ return "unknown"
+end
+
+--- Map door status byte to string
+--- @param statusByte integer Door status byte
+--- @return string status "CLOSED", "OPENED", or "UNKNOWN"
+function yale_protocol.parseDoorStatus(statusByte)
+ statusByte = statusByte or 0
+ if statusByte == yale_protocol.DoorStatus.CLOSED then
+ return "CLOSED"
+ elseif statusByte == yale_protocol.DoorStatus.AJAR then
+ return "OPENED"
+ elseif statusByte == yale_protocol.DoorStatus.OPENED then
+ return "OPENED"
+ end
+ return "UNKNOWN"
+end
+
+--- Per-cell voltage to percentage lookup table (from yalexs-ble)
+--- @type table
+local VOLTAGE_TO_PERCENT = {
+ [1.6] = 100,
+ [1.55] = 95,
+ [1.51] = 90,
+ [1.48] = 85,
+ [1.44] = 80,
+ [1.41] = 75,
+ [1.38] = 70,
+ [1.35] = 65,
+ [1.32] = 60,
+ [1.30] = 55,
+ [1.28] = 50,
+ [1.26] = 45,
+ [1.24] = 40,
+ [1.22] = 35,
+ [1.21] = 30,
+ [1.20] = 25,
+ [1.18] = 20,
+ [1.16] = 15,
+ [1.14] = 10,
+ [1.10] = 5,
+ [1.0] = 0,
+}
+
+--- Convert battery millivolts to percentage.
+--- Yale locks use 4x AA batteries; voltage is total pack millivolts (LE u16).
+--- Divides by 4 for per-cell voltage, then uses lookup table from yalexs-ble.
+--- @param millivolts integer Total pack millivolts (little-endian u16 from response)
+--- @return integer percentage Battery percentage (0-100)
+function yale_protocol.parseBattery(millivolts)
+ local perCellVolts = (millivolts / 1000) / 4
+ -- Find the closest voltage in the lookup table
+ local bestPercent = 0
+ local bestDiff = 999
+ for voltage, percent in pairs(VOLTAGE_TO_PERCENT) do
+ local diff = math.abs(perCellVolts - voltage)
+ if diff < bestDiff then
+ bestDiff = diff
+ bestPercent = percent
+ end
+ end
+ return bestPercent
+end
+
+--------------------------------------------------------------------------------
+-- Packet Building
+--------------------------------------------------------------------------------
+
+--- Packet size for all Yale BLE commands
+local PACKET_SIZE = 18
+
+--- Build a basic command packet (18 bytes)
+--- Format: [0xEE][opcode][0x00][checksum][12 payload bytes][0x02][0x00]
+--- @param opcode integer Command opcode
+--- @return string packet 18-byte command packet
+function yale_protocol.buildCommand(opcode)
+ local buf = {}
+ for i = 1, PACKET_SIZE do
+ buf[i] = 0
+ end
+ buf[1] = 0xEE -- Non-secure prefix
+ buf[2] = opcode
+ buf[3] = 0x00
+ -- buf[4] = checksum (filled below)
+ buf[17] = 0x02
+ buf[18] = 0x00
+
+ -- Simple checksum: negate sum of all 18 bytes (byte at offset 0x03)
+ local sum = 0
+ for i = 1, PACKET_SIZE do
+ if i ~= 4 then -- skip checksum position
+ sum = sum + buf[i]
+ end
+ end
+ buf[4] = bit32.band(-sum, 0xFF)
+
+ local chars = {}
+ for i = 1, PACKET_SIZE do
+ chars[i] = string.char(buf[i])
+ end
+ return table.concat(chars)
+end
+
+--- Build an operation command packet with sub-command at offset 0x04
+--- @param opcode integer Command opcode
+--- @param subCommand integer Sub-command/status type byte
+--- @return string packet 18-byte command packet
+function yale_protocol.buildOperationCommand(opcode, subCommand)
+ local buf = {}
+ for i = 1, PACKET_SIZE do
+ buf[i] = 0
+ end
+ buf[1] = 0xEE
+ buf[2] = opcode
+ buf[3] = 0x00
+ -- buf[4] = checksum (filled below)
+ buf[5] = subCommand
+ buf[17] = 0x02
+ buf[18] = 0x00
+
+ local sum = 0
+ for i = 1, PACKET_SIZE do
+ if i ~= 4 then
+ sum = sum + buf[i]
+ end
+ end
+ buf[4] = bit32.band(-sum, 0xFF)
+
+ local chars = {}
+ for i = 1, PACKET_SIZE do
+ chars[i] = string.char(buf[i])
+ end
+ return table.concat(chars)
+end
+
+--- Read a little-endian uint32 from a byte array at 1-based offset
+--- @param buf table Byte array (integer values)
+--- @param offset integer 1-based offset
+--- @return number value 32-bit unsigned integer
+local function le_u32_from_array(buf, offset)
+ return buf[offset] + buf[offset + 1] * 256 + buf[offset + 2] * 65536 + buf[offset + 3] * 16777216
+end
+
+--- Read a little-endian uint32 from a string at 1-based offset
+--- @param s string Binary string
+--- @param offset integer 1-based offset
+--- @return number value 32-bit unsigned integer
+local function le_u32_from_string(s, offset)
+ return string.byte(s, offset)
+ + string.byte(s, offset + 1) * 256
+ + string.byte(s, offset + 2) * 65536
+ + string.byte(s, offset + 3) * 16777216
+end
+
+--- Compute security checksum for a byte array.
+--- Matches yalexs-ble: sum three LE u32 groups (bytes 0-3, 4-7, 8-11), negate mod 2^32.
+--- The checksum at bytes 12-15 contributes to bits >=32 of val3, so it's masked away.
+--- @param buf table Byte array (1-indexed, integer values)
+local function writeSecurityChecksum(buf)
+ local val1 = le_u32_from_array(buf, 1)
+ local val2 = le_u32_from_array(buf, 5)
+ local val3 = le_u32_from_array(buf, 9)
+ local sum = (val1 + val2 + val3) % 4294967296
+ local checksum = (4294967296 - sum) % 4294967296
+ -- Store as little-endian at bytes 13-16 (1-indexed)
+ buf[13] = checksum % 256
+ buf[14] = math.floor(checksum / 256) % 256
+ buf[15] = math.floor(checksum / 65536) % 256
+ buf[16] = math.floor(checksum / 16777216) % 256
+end
+
+--- Build a secure command packet (18 bytes)
+--- Format: [opcode][11 payload bytes][4-byte security checksum][0x0F][key_index]
+--- @param opcode integer Command opcode
+--- @param keyIndex integer Key index (default 1)
+--- @return string packet 18-byte secure command packet
+function yale_protocol.buildSecureCommand(opcode, keyIndex)
+ keyIndex = keyIndex or 1
+ local buf = {}
+ for i = 1, PACKET_SIZE do
+ buf[i] = 0
+ end
+ buf[1] = opcode
+ buf[17] = 0x0F
+ buf[18] = keyIndex
+
+ writeSecurityChecksum(buf)
+
+ local chars = {}
+ for i = 1, PACKET_SIZE do
+ chars[i] = string.char(buf[i])
+ end
+ return table.concat(chars)
+end
+
+--- Compute simple checksum for a packet: negate sum of all bytes except offset 0x03
+--- @param packet string 18-byte packet
+--- @return integer checksum Single byte checksum
+function yale_protocol.simpleChecksum(packet)
+ local sum = 0
+ for i = 1, #packet do
+ if i ~= 4 then
+ sum = sum + string.byte(packet, i)
+ end
+ end
+ return bit32.band(-sum, 0xFF)
+end
+
+--- Compute security checksum for a secure packet (string version).
+--- @param packet string 18-byte packet
+--- @return number checksum 32-bit checksum value
+function yale_protocol.securityChecksum(packet)
+ local val1 = le_u32_from_string(packet, 1)
+ local val2 = le_u32_from_string(packet, 5)
+ local val3 = le_u32_from_string(packet, 9)
+ local sum = (val1 + val2 + val3) % 4294967296
+ return (4294967296 - sum) % 4294967296
+end
+
+--------------------------------------------------------------------------------
+-- Secure Session (AES-ECB, used during handshake)
+--------------------------------------------------------------------------------
+
+--- @class YaleSecureSession
+--- @field _key string|nil 16-byte AES key
+local SecureSession = {}
+SecureSession.__index = SecureSession
+
+--- Create a new secure session
+--- @param offlineKey string 16-byte offline key
+--- @return YaleSecureSession session
+function SecureSession:new(offlineKey)
+ assert(#offlineKey == 16, "Offline key must be 16 bytes")
+ local instance = setmetatable({}, self)
+ instance._key = offlineKey
+ return instance
+end
+
+--- Set a new key for this session
+--- @param newKey string 16-byte key
+function SecureSession:setKey(newKey)
+ assert(#newKey == 16, "Key must be 16 bytes")
+ self._key = newKey
+end
+
+--- ECB encrypt bytes 0x00-0x0F of an 18-byte packet (in-place on first 16 bytes)
+--- @param data string 18-byte packet
+--- @return string encrypted 18-byte packet with first 16 bytes encrypted
+function SecureSession:encrypt(data)
+ assert(#data == 18, "Data must be 18 bytes")
+ local plainBlock = data:sub(1, 16)
+ local encrypted = C4:Encrypt("AES-128-ECB", self._key, "", plainBlock, { padding = false })
+ return encrypted .. data:sub(17, 18)
+end
+
+--- ECB decrypt bytes 0x00-0x0F of an 18-byte packet
+--- @param data string 18-byte packet
+--- @return string decrypted 18-byte packet with first 16 bytes decrypted
+function SecureSession:decrypt(data)
+ assert(#data == 18, "Data must be 18 bytes")
+ local cipherBlock = data:sub(1, 16)
+ local decrypted = C4:Decrypt("AES-128-ECB", self._key, "", cipherBlock, { padding = false })
+ return decrypted .. data:sub(17, 18)
+end
+
+--- Write security checksum into a byte array (exported for driver use).
+yale_protocol.writeSecurityChecksum = writeSecurityChecksum
+
+yale_protocol.SecureSession = SecureSession
+
+--------------------------------------------------------------------------------
+-- Session (AES-CBC, used for commands after handshake)
+--------------------------------------------------------------------------------
+
+--- @class YaleSession
+--- @field _key string|nil 16-byte AES key
+--- @field _encryptIV string 16-byte IV for encryption (tracks streaming state)
+--- @field _decryptIV string 16-byte IV for decryption (tracks streaming state)
+local Session = {}
+Session.__index = Session
+
+--- Create a new CBC session
+--- @return YaleSession session
+function Session:new()
+ local instance = setmetatable({}, self)
+ instance._key = nil
+ instance._encryptIV = string.rep("\0", 16)
+ instance._decryptIV = string.rep("\0", 16)
+ return instance
+end
+
+--- Set the session key (also resets IVs)
+--- @param newKey string 16-byte key
+function Session:setKey(newKey)
+ assert(#newKey == 16, "Key must be 16 bytes")
+ self._key = newKey
+ self._encryptIV = string.rep("\0", 16)
+ self._decryptIV = string.rep("\0", 16)
+end
+
+--- Check if the session has a key set
+--- @return boolean ready True if key is set
+function Session:isReady()
+ return self._key ~= nil
+end
+
+--- CBC encrypt bytes 0x00-0x0F of an 18-byte packet, tracking streaming IV
+--- @param data string 18-byte packet
+--- @return string encrypted 18-byte packet with first 16 bytes encrypted
+function Session:encrypt(data)
+ assert(#data == 18, "Data must be 18 bytes")
+ assert(self._key, "Session key not set")
+ local plainBlock = data:sub(1, 16)
+ local encrypted = C4:Encrypt("AES-128-CBC", self._key, self._encryptIV, plainBlock, { padding = false })
+ -- Update IV for next encryption (last ciphertext block)
+ self._encryptIV = encrypted
+ return encrypted .. data:sub(17, 18)
+end
+
+--- CBC decrypt bytes 0x00-0x0F of an 18-byte packet, tracking streaming IV
+--- @param data string 18-byte packet
+--- @return string decrypted 18-byte packet with first 16 bytes decrypted
+function Session:decrypt(data)
+ assert(#data == 18, "Data must be 18 bytes")
+ assert(self._key, "Session key not set")
+ local cipherBlock = data:sub(1, 16)
+ local decrypted = C4:Decrypt("AES-128-CBC", self._key, self._decryptIV, cipherBlock, { padding = false })
+ -- Update IV for next decryption (this ciphertext block)
+ self._decryptIV = cipherBlock
+ return decrypted .. data:sub(17, 18)
+end
+
+yale_protocol.Session = Session
+
+--------------------------------------------------------------------------------
+-- Response Parsing
+--------------------------------------------------------------------------------
+
+--- Parse an 18-byte decrypted response packet.
+--- Response format depends on flag AND opcode (matches yalexs-ble parsing).
+--- @param data string 18-byte decrypted response
+--- @return table|nil response Parsed response
+function yale_protocol.parseResponse(data)
+ if not data or #data < 18 then
+ return nil
+ end
+
+ local flag = string.byte(data, 1)
+ local opcode = string.byte(data, 2)
+
+ -- Status response (flag 0xBB)
+ if flag == 0xBB then
+ local result = { flag = flag, opcode = opcode }
+
+ if opcode == yale_protocol.Opcode.GET_STATUS then
+ -- GETSTATUS response: statusType at byte 0x04 (Lua 5), data at byte 0x08 (Lua 9)
+ local statusType = string.byte(data, 5)
+ result.statusType = statusType
+ if statusType == yale_protocol.StatusType.LOCK_ONLY then
+ result.lockStatus = string.byte(data, 9)
+ elseif statusType == yale_protocol.StatusType.DOOR_ONLY then
+ result.doorStatus = string.byte(data, 9)
+ elseif statusType == yale_protocol.StatusType.DOOR_AND_LOCK then
+ result.lockStatus = string.byte(data, 9)
+ result.doorStatus = string.byte(data, 10)
+ elseif statusType == yale_protocol.StatusType.BATTERY then
+ -- Battery millivolts as little-endian u16 at bytes 0x08-0x09 (Lua 9-10)
+ result.batteryMillivolts = string.byte(data, 9) + string.byte(data, 10) * 256
+ end
+ elseif opcode == yale_protocol.Opcode.LOCK or opcode == yale_protocol.Opcode.UNLOCK then
+ -- Lock/unlock command response: lock status at byte 0x03 (Lua 4)
+ result.lockStatus = string.byte(data, 4)
+ end
+
+ return result
+ end
+
+ -- Acknowledgment response (flag 0xAA) — command completed
+ if flag == 0xAA then
+ return {
+ flag = flag,
+ opcode = opcode,
+ }
+ end
+
+ -- Handshake response (no flag prefix, opcode at byte 1)
+ return {
+ flag = 0,
+ opcode = flag, -- first byte is actually the opcode for handshake responses
+ }
+end
+
+--------------------------------------------------------------------------------
+-- Self-Test
+--------------------------------------------------------------------------------
+
+--- Run self-tests with known-good test vectors from yalexs-ble.
+--- Validates security checksum (LE u32 groups) and simple checksum.
+--- @return boolean success True if all tests passed
+function yale_protocol.selftest()
+ local pass = true
+
+ local function assertEq(name, got, expected)
+ if got ~= expected then
+ print(string.format("FAIL: %s: got 0x%08X, expected 0x%08X", name, got, expected))
+ pass = false
+ end
+ end
+
+ local function hexToBytes(hex)
+ local bytes = {}
+ for i = 1, #hex, 2 do
+ bytes[#bytes + 1] = tonumber(hex:sub(i, i + 1), 16)
+ end
+ return bytes
+ end
+
+ local function bytesToString(bytes)
+ local chars = {}
+ for i = 1, #bytes do
+ chars[i] = string.char(bytes[i])
+ end
+ return table.concat(chars)
+ end
+
+ -- Security checksum test vectors (from yalexs-ble)
+ -- Each: { name, input_hex_bytes_0_11, expected_checksum_u32 }
+ local securityTests = {
+ { "all zeros", "000000000000000000000000", 0x00000000 },
+ { "all 0xFF", "ffffffffffffffffffffffff", 0x00000003 },
+ { "all 0x01", "010101010101010101010101", 0xFCFCFCFD },
+ { "ascending 0-11", "000102030405060708090a0b", 0xEAEDF0F4 },
+ { "max val1 only", "ffffffff0000000000000000", 0x00000001 },
+ { "SEC_KEY_EXCHANGE bare", "010000000000000000000000", 0xFFFFFFFF },
+ { "SEC_INIT bare", "030000000000000000000000", 0xFFFFFFFD },
+ -- With handshake payload: KEY_EXCHANGE (0x01) + handshake_keys[0:8] at offset 4
+ { "KEY_EXCHANGE with payload", "01000000aabbccddeeff0011", 0x11324467 },
+ -- SEC_INIT (0x03) + handshake_keys[8:16] at offset 4
+ { "SEC_INIT with payload", "030000002233445566778899", 0x11335575 },
+ }
+
+ for _, test in ipairs(securityTests) do
+ local name, inputHex, expected = test[1], test[2], test[3]
+ -- Build 18-byte buffer: first 12 bytes from input, zeros for checksum, then 0x0F, keyIndex
+ local bytes = hexToBytes(inputHex)
+ for i = #bytes + 1, 18 do
+ bytes[i] = 0
+ end
+ bytes[17] = 0x0F
+ bytes[18] = 0x01
+ writeSecurityChecksum(bytes)
+ -- Read computed checksum from bytes 13-16 as LE u32
+ local got = bytes[13] + bytes[14] * 256 + bytes[15] * 65536 + bytes[16] * 16777216
+ assertEq("security: " .. name, got, expected)
+ end
+
+ -- Also verify string version matches
+ for _, test in ipairs(securityTests) do
+ local name, inputHex, expected = test[1], test[2], test[3]
+ local bytes = hexToBytes(inputHex)
+ for i = #bytes + 1, 18 do
+ bytes[i] = 0
+ end
+ bytes[17] = 0x0F
+ bytes[18] = 0x01
+ local s = bytesToString(bytes)
+ local got = yale_protocol.securityChecksum(s)
+ assertEq("security(str): " .. name, got, expected)
+ end
+
+ -- Simple checksum test vectors (from yalexs-ble test_lock.py)
+ -- Each: { name, full_18_byte_hex, expected_byte3_value }
+ local simpleTests = {
+ { "UNLOCK", "ee0a00060000000000000000000000000200", 0x06 },
+ { "LOCK", "ee0b00050000000000000000000000000200", 0x05 },
+ { "GETSTATUS", "ee0200000000000000000000000000000200", 0x0E },
+ { "STATUS_LOCK", "ee0200000200000000000000000000000200", 0x0C },
+ { "STATUS_BATTERY", "ee0200000f00000000000000000000000200", 0xFF },
+ -- Verify known-good response packets sum to 0
+ { "LOCK_JAMMED resp", "bb0b001b00000000000000000000001f0000", nil },
+ { "LOCK_UNLOCKED resp", "bb0b00030000000000000000000000370000", nil },
+ }
+
+ for _, test in ipairs(simpleTests) do
+ local name, packetHex, expectedByte3 = test[1], test[2], test[3]
+ local s = bytesToString(hexToBytes(packetHex))
+ if expectedByte3 then
+ local got = yale_protocol.simpleChecksum(s)
+ assertEq("simple: " .. name, got, expectedByte3)
+ else
+ -- For response packets, verify that the full sum is 0 (checksum is valid)
+ local sum = 0
+ for i = 1, #s do
+ sum = sum + string.byte(s, i)
+ end
+ local valid = (sum % 256 == 0)
+ if not valid then
+ print(string.format("FAIL: simple: %s: sum=%d (expected 0 mod 256)", name, sum % 256))
+ pass = false
+ end
+ end
+ end
+
+ if pass then
+ print("yale_protocol: all self-tests passed")
+ end
+ return pass
+end
+
+return yale_protocol
diff --git a/src/esphome/capabilities/bluetooth_proxy.lua b/src/esphome/capabilities/bluetooth_proxy.lua
new file mode 100644
index 0000000..3a71938
--- /dev/null
+++ b/src/esphome/capabilities/bluetooth_proxy.lua
@@ -0,0 +1,1656 @@
+local bit32 = require("bitn").bit32
+
+local constants = require("constants")
+
+local log = require("lib.logging")
+local bindings = require("lib.bindings")
+
+local ESPHomeProtoSchema = require("esphome.proto_schema")
+local BLEAddress = require("esphome.ble.address")
+local UUID = require("esphome.ble.uuid")
+
+local bleScannerProperties = require("esphome.ble.scanner_properties")
+
+--- Bluetooth proxy feature flags (from ESPHome API)
+local FEATURE_FLAGS = {
+ PASSIVE_SCAN = 0x01,
+ ACTIVE_CONNECTIONS = 0x02,
+ REMOTE_CACHING = 0x04,
+ PAIRING = 0x08,
+ CACHE_CLEARING = 0x10,
+ RAW_ADVERTISEMENTS = 0x20,
+ SCANNER_STATE = 0x40,
+}
+
+--- Watchdog timeout for stuck scanner detection (in seconds).
+--- If no BLE advertisements are received for this duration while scanner should be running,
+--- attempt recovery by toggling scanner mode.
+local SCANNER_WATCHDOG_TIMEOUT_SECONDS = 90
+
+--- Timer key for the scanner watchdog
+local SCANNER_WATCHDOG_TIMER_KEY = "BLEScannerWatchdog"
+
+--- Reverse lookup for BluetoothScannerState enum (value -> display name)
+--- States that involve active scanning will have mode appended (e.g., "Scanning (Passive)")
+--- @type table
+local SCANNER_STATE_NAMES = {
+ [ESPHomeProtoSchema.Enum.BluetoothScannerState.BLUETOOTH_SCANNER_STATE_IDLE] = "Scanner Idle",
+ [ESPHomeProtoSchema.Enum.BluetoothScannerState.BLUETOOTH_SCANNER_STATE_STARTING] = "Starting Scan",
+ [ESPHomeProtoSchema.Enum.BluetoothScannerState.BLUETOOTH_SCANNER_STATE_RUNNING] = "Scanning",
+ [ESPHomeProtoSchema.Enum.BluetoothScannerState.BLUETOOTH_SCANNER_STATE_FAILED] = "Scan Failed",
+ [ESPHomeProtoSchema.Enum.BluetoothScannerState.BLUETOOTH_SCANNER_STATE_STOPPING] = "Stopping Scan",
+ [ESPHomeProtoSchema.Enum.BluetoothScannerState.BLUETOOTH_SCANNER_STATE_STOPPED] = "Scanner Stopped",
+}
+
+--- States where scanner mode should be shown
+--- @type table
+local SCANNER_STATE_SHOW_MODE = {
+ [ESPHomeProtoSchema.Enum.BluetoothScannerState.BLUETOOTH_SCANNER_STATE_STARTING] = true,
+ [ESPHomeProtoSchema.Enum.BluetoothScannerState.BLUETOOTH_SCANNER_STATE_RUNNING] = true,
+ [ESPHomeProtoSchema.Enum.BluetoothScannerState.BLUETOOTH_SCANNER_STATE_STOPPING] = true,
+}
+
+--- Reverse lookup for BluetoothScannerMode enum (value -> display name)
+--- @type table
+local SCANNER_MODE_NAMES = {
+ [ESPHomeProtoSchema.Enum.BluetoothScannerMode.BLUETOOTH_SCANNER_MODE_PASSIVE] = "Passive",
+ [ESPHomeProtoSchema.Enum.BluetoothScannerMode.BLUETOOTH_SCANNER_MODE_ACTIVE] = "Active",
+}
+
+--- @class BluetoothProxyCapability : Capability
+--- @field _client ESPHomeClient The ESPHome client instance
+--- @field _addedDevices table Added devices
+--- @field _featureFlags integer Bluetooth proxy feature flags
+--- @field _previousAllocated table Track allocated MACs for disconnect detection
+--- @field _coordinatorConnected boolean Whether coordinator is connected
+--- @field _coordinatorBindingId number|nil Binding ID for coordinator (5001 if connected)
+--- @field _coordinatorCallbackId string|nil Callback ID for coordinator advertisement forwarding
+--- @field _advertisementFilter table|nil MAC filter set (nil = pass all)
+--- @field _scannerWatchdogActive boolean Whether the scanner watchdog is active
+--- @field _scannerWatchdogSeen boolean Whether any advertisements were received since last watchdog check
+--- @field _scannerRecoveryAttempts integer Number of recovery attempts since last successful scan
+--- @field _restartButtonKey number|nil The key of the restart button entity (nil if not found)
+local BluetoothProxyCapability = {
+ TYPE = "bluetooth_proxy",
+ LABEL_PROPERTY_NAME = "Bluetooth Proxy Settings",
+ PROPERTY_NAME = "Select Bluetooth Devices",
+ STATUS_PROPERTY_NAME = "Bluetooth Proxy Status",
+ CAPABILITIES_PROPERTY_NAME = "Bluetooth Proxy Capabilities",
+ ROOM_PROPERTY_NAME = "Bluetooth Proxy Room",
+ SCAN_DURATION_PROPERTY_NAME = "Bluetooth Scan Duration",
+ MIN_RSSI_OVERRIDE_PROPERTY_NAME = "Minimum Room RSSI Override (dBm)",
+ COORDINATOR_BINDING_KEY = "coordinator", -- Key for dynamic binding
+}
+BluetoothProxyCapability.__index = BluetoothProxyCapability
+
+--- The key used to persist selected bluetooth devices.
+--- @type string
+local SELECTED_BLUETOOTH_DEVICES_PERSIST_KEY = "SelectedBluetoothDevices"
+
+--- Default BLE address type (PUBLIC) used when not provided by discovery.
+--- Home Assistant/bleak also defaults to PUBLIC for unknown devices.
+local DEFAULT_ADDRESS_TYPE = BLEAddress.Type.PUBLIC
+
+--- @class AddedDevice
+--- @field name string|nil Device name from advertisement
+--- @field addressType BLEAddressType Address type, defaults to PUBLIC (0) if not set
+--- @field services table[]|nil GATT services discovered
+--- @field deviceType string|nil Device type (e.g., "SwitchBot Bot")
+--- @field bindingClass string|nil Control4 binding class (e.g., "ESPHOME_SWITCHBOT")
+--- @field bindingId number|nil Control4 binding ID
+--- @field passive boolean Whether device uses passive advertisement mode (no GATT connection)
+
+--- Decode feature flags to human-readable capabilities.
+--- @param flags integer The feature flags bitmask
+--- @return string capabilities Comma-separated list of capabilities
+local function decodeFeatureFlags(flags)
+ local caps = {}
+
+ if bit32.band(flags, FEATURE_FLAGS.PASSIVE_SCAN) ~= 0 then
+ table.insert(caps, "Scan")
+ end
+ if bit32.band(flags, FEATURE_FLAGS.ACTIVE_CONNECTIONS) ~= 0 then
+ table.insert(caps, "Connect")
+ end
+ if bit32.band(flags, FEATURE_FLAGS.REMOTE_CACHING) ~= 0 then
+ table.insert(caps, "Cache")
+ end
+ if bit32.band(flags, FEATURE_FLAGS.PAIRING) ~= 0 then
+ table.insert(caps, "Pair")
+ end
+ if bit32.band(flags, FEATURE_FLAGS.RAW_ADVERTISEMENTS) ~= 0 then
+ table.insert(caps, "Raw")
+ end
+
+ return #caps > 0 and table.concat(caps, ", ") or "None"
+end
+
+--- Create a new instance of the bluetooth proxy capability.
+--- @param client ESPHomeClient The ESPHome client instance
+--- @return BluetoothProxyCapability capability A new instance of the BluetoothProxyCapability capability
+function BluetoothProxyCapability:new(client)
+ local instance = setmetatable({}, self)
+ instance._client = client
+ instance._addedDevices = {}
+ instance._featureFlags = 0
+ instance._previousAllocated = {}
+ instance._coordinatorConnected = false
+ instance._coordinatorCallbackId = nil
+ instance._coordinatorBindingId = nil
+ instance._advertisementFilter = nil
+ instance._scannerWatchdogActive = false
+ instance._scannerWatchdogSeen = false
+ instance._scannerRecoveryAttempts = 0
+ instance._restartButtonKey = nil
+ return instance
+end
+
+function BluetoothProxyCapability:setPropertiesAttribs(show)
+ C4:SetPropertyAttribs(self.LABEL_PROPERTY_NAME, show)
+ C4:SetPropertyAttribs(self.STATUS_PROPERTY_NAME, show)
+ C4:SetPropertyAttribs(self.CAPABILITIES_PROPERTY_NAME, show)
+
+ -- Device selection and room/minRssiOverride are mutually exclusive based on coordinator connection
+ if self._coordinatorConnected then
+ -- Coordinator mode: show room selector and minRssiOverride, hide device selection
+ C4:SetPropertyAttribs(self.ROOM_PROPERTY_NAME, show)
+ C4:SetPropertyAttribs(self.MIN_RSSI_OVERRIDE_PROPERTY_NAME, show)
+ C4:SetPropertyAttribs(self.PROPERTY_NAME, constants.HIDE_PROPERTY)
+ C4:SetPropertyAttribs(self.SCAN_DURATION_PROPERTY_NAME, constants.HIDE_PROPERTY)
+ else
+ -- Standalone mode: show device selection, hide room selector and minRssiOverride
+ C4:SetPropertyAttribs(self.ROOM_PROPERTY_NAME, constants.HIDE_PROPERTY)
+ C4:SetPropertyAttribs(self.MIN_RSSI_OVERRIDE_PROPERTY_NAME, constants.HIDE_PROPERTY)
+ C4:SetPropertyAttribs(self.PROPERTY_NAME, show)
+ C4:SetPropertyAttribs(self.SCAN_DURATION_PROPERTY_NAME, show)
+ end
+end
+
+--- Mark that an advertisement was received.
+--- Called when advertisements are received to indicate scanner is working.
+--- @private
+function BluetoothProxyCapability:_onAdvertisementReceived()
+ self._scannerWatchdogSeen = true
+end
+
+--- Start the scanner watchdog.
+--- Only starts if a restart button is available for recovery.
+--- @private
+function BluetoothProxyCapability:_startScannerWatchdog()
+ if self._scannerWatchdogActive then
+ return
+ end
+
+ -- Only enable watchdog if we have a restart button for recovery
+ if not self._restartButtonKey then
+ log:debug("Scanner watchdog not started: no restart button available for recovery")
+ return
+ end
+
+ log:debug("Starting scanner watchdog (interval: %ds)", SCANNER_WATCHDOG_TIMEOUT_SECONDS)
+ self._scannerWatchdogActive = true
+ self._scannerWatchdogSeen = false
+ self._scannerRecoveryAttempts = 0
+
+ -- Start recurring timer that checks if advertisements were received
+ SetTimer(SCANNER_WATCHDOG_TIMER_KEY, SCANNER_WATCHDOG_TIMEOUT_SECONDS * ONE_SECOND, function()
+ self:_onScannerWatchdogFired()
+ end, true) -- recurring
+end
+
+--- Set the restart button entity key for scanner recovery.
+--- Called by the driver when a restart button entity is discovered.
+--- @param key number The button entity key
+function BluetoothProxyCapability:setRestartButtonKey(key)
+ log:debug("Restart button key set: %s", key)
+ self._restartButtonKey = key
+end
+
+--- Stop the scanner watchdog.
+--- @private
+function BluetoothProxyCapability:_stopScannerWatchdog()
+ if not self._scannerWatchdogActive then
+ return
+ end
+
+ log:debug("Stopping scanner watchdog")
+ self._scannerWatchdogActive = false
+ self._scannerWatchdogSeen = false
+ CancelTimer(SCANNER_WATCHDOG_TIMER_KEY)
+end
+
+--- Called when the scanner watchdog timer fires.
+--- Checks if advertisements were received since last check; if not, attempts recovery.
+--- @private
+function BluetoothProxyCapability:_onScannerWatchdogFired()
+ -- Check if any advertisements were received since last check
+ if self._scannerWatchdogSeen then
+ -- Scanner is healthy, reset flag for next interval
+ if self._scannerRecoveryAttempts > 0 then
+ log:info("Scanner watchdog: Advertisements resumed after %d recovery attempts", self._scannerRecoveryAttempts)
+ self._scannerRecoveryAttempts = 0
+ end
+ self._scannerWatchdogSeen = false
+ return
+ end
+
+ -- No advertisements received - check if scanner should be running
+ local scannerState = self._client:getBluetoothScannerState()
+ if
+ scannerState.state ~= ESPHomeProtoSchema.Enum.BluetoothScannerState.BLUETOOTH_SCANNER_STATE_RUNNING
+ and scannerState.state ~= ESPHomeProtoSchema.Enum.BluetoothScannerState.BLUETOOTH_SCANNER_STATE_STARTING
+ then
+ log:debug("Scanner watchdog: no advertisements but scanner not running (state=%s), ignoring", scannerState.state)
+ return
+ end
+
+ -- Safety check - should not happen since watchdog only starts if we have restart button
+ if not self._restartButtonKey then
+ log:warn("Scanner watchdog fired but no restart button available, stopping watchdog")
+ self:_stopScannerWatchdog()
+ return
+ end
+
+ self._scannerRecoveryAttempts = self._scannerRecoveryAttempts + 1
+ log:warn(
+ "Scanner watchdog: No BLE advertisements received for %ds (attempt %d), rebooting device to recover",
+ SCANNER_WATCHDOG_TIMEOUT_SECONDS,
+ self._scannerRecoveryAttempts
+ )
+
+ -- Stop watchdog - device will reboot and we'll reinitialize on reconnect
+ self:_stopScannerWatchdog()
+
+ -- Attempt recovery by pressing the restart button
+ self._client:pressButton(self._restartButtonKey):next(function()
+ log:info("Scanner recovery: restart button pressed, device will reboot")
+ end, function(err)
+ log:error("Scanner recovery: failed to press restart button: %s", err)
+ -- Restart the watchdog to try again later
+ self:_startScannerWatchdog()
+ end)
+end
+
+--- Update the read-only status property.
+--- @private
+function BluetoothProxyCapability:_updateStatusProperty()
+ local connState = self._client:getBluetoothConnectionState()
+ local scannerState = self._client:getBluetoothScannerState()
+ if not connState.initialized or not scannerState.initialized then
+ C4:UpdateProperty(self.STATUS_PROPERTY_NAME, "Initializing...")
+ return
+ end
+
+ -- Format: "Standalone | Scanning (Passive) | 1/4 Active"
+ -- or with coordinator: "Coordinator | Scanning (Passive) | 0/3 Active | MAC Filter: 5"
+ local parts = {}
+
+ if self._coordinatorConnected then
+ table.insert(parts, "Coordinator Mode")
+ else
+ table.insert(parts, "Standalone Mode")
+ end
+
+ -- Build scanner status text (show mode only for active scanning states)
+ local stateName = SCANNER_STATE_NAMES[scannerState.state] or "Unknown"
+ local modeName = SCANNER_MODE_NAMES[scannerState.mode] or nil
+ if modeName and SCANNER_STATE_SHOW_MODE[scannerState.state] then
+ table.insert(parts, string.format("%s (%s)", stateName, modeName))
+ else
+ table.insert(parts, stateName)
+ end
+
+ -- Build connection slots text with optional oversubscription warning
+ local slotsText = string.format("%d/%d Active", connState.limit - connState.free, connState.limit)
+ local selectedActiveCount = bleScannerProperties:getSelectedActiveCount(self.PROPERTY_NAME)
+ if selectedActiveCount > connState.limit then
+ slotsText = slotsText .. " (Oversubscribed)"
+ end
+ table.insert(parts, slotsText)
+
+ -- Build MAC filter text if coordinator is connected
+ if self._coordinatorConnected then
+ if self._advertisementFilter then
+ local count = 0
+ for _ in pairs(self._advertisementFilter) do
+ count = count + 1
+ end
+ table.insert(parts, string.format("MAC Filter: %d device(s)", count))
+ else
+ table.insert(parts, "MAC Filter: none")
+ end
+ end
+
+ C4:UpdateProperty(self.STATUS_PROPERTY_NAME, table.concat(parts, " | "))
+ C4:UpdateProperty(self.CAPABILITIES_PROPERTY_NAME, decodeFeatureFlags(self._featureFlags))
+end
+
+--- Proceed with GATT service discovery after connection is established.
+--- @param mac string MAC address
+--- @param callback function|nil Optional callback(success) called when discovery completes
+--- @private
+function BluetoothProxyCapability:_discoverGattServices(mac, callback)
+ local device = self._addedDevices[mac]
+ if not device then
+ log:error("Device not found for GATT discovery: %s", mac)
+ if callback then
+ callback(false)
+ end
+ return
+ end
+
+ self._client:bluetoothGattGetServices(mac):next(function(services)
+ device.services = services
+ log:info("GATT service discovery complete for %s (%d services)", mac, #services)
+ if callback then
+ callback(true)
+ end
+ end, function(err)
+ log:error("GATT service discovery failed for %s: %s", mac, err)
+ if callback then
+ callback(false)
+ end
+ end)
+end
+
+--- Connect to a Bluetooth device.
+--- Checks if the device is already connected before attempting connection.
+--- @param device BLEDiscoveredDevice Device info from scanner
+function BluetoothProxyCapability:connectDevice(device)
+ local mac = device.mac
+ log:trace("BluetoothProxyCapability:connectDevice(%s)", mac)
+
+ -- Check if already tracked and connected
+ if self._addedDevices[mac] and self._client:isBluetoothDeviceAllocated(mac) then
+ log:info("Device %s already connected", mac)
+ return
+ end
+
+ log:info("Connecting to Bluetooth device: %s", mac)
+
+ -- Initialize device tracking (keyed by MAC address)
+ self._addedDevices[mac] = {
+ name = device.name,
+ addressType = device.addressType or DEFAULT_ADDRESS_TYPE,
+ services = nil,
+ deviceType = device.deviceType,
+ bindingClass = device.bindingClass,
+ bindingId = nil,
+ passive = device.passive or false,
+ }
+
+ -- Check if device is already connected
+ if self._client:isBluetoothDeviceAllocated(mac) then
+ log:info("Device %s already connected, using existing connection", mac)
+ else
+ log:debug("Device %s not currently connected, initiating connection", mac)
+ self:_initiateConnection(mac)
+ end
+end
+
+--- Initiate a new Bluetooth connection to a device.
+--- @param mac string MAC address
+--- @param callback function|nil Optional callback(success, error) when connection completes
+--- @private
+function BluetoothProxyCapability:_initiateConnection(mac, callback)
+ local device = self._addedDevices[mac]
+ if not device then
+ log:error("Device not found for connection: %s", mac)
+ if callback then
+ callback(false, "Device not found")
+ end
+ return
+ end
+
+ local addressType = device.addressType or DEFAULT_ADDRESS_TYPE
+
+ log:debug("Initiating BLE connection to %s (addressType=%d)", mac, addressType)
+
+ self._client:bluetoothDeviceConnect(mac, addressType, false):next(function(result)
+ log:debug("Bluetooth connection successful for %s: mtu=%s", mac, result.mtu)
+ if callback then
+ callback(true)
+ end
+ end, function(err)
+ log:error("Failed to connect to %s: error=%s", mac, err)
+ if callback then
+ callback(false, err)
+ end
+ end)
+end
+
+--- Connect to a device and notify the child driver when complete.
+--- Checks for existing connections, initiates if needed, discovers GATT services, then notifies.
+--- @param mac string MAC address
+--- @param idBinding number The binding ID to notify
+--- @private
+function BluetoothProxyCapability:_connectAndNotify(mac, idBinding)
+ local device = self._addedDevices[mac]
+ if not device then
+ log:error("Device not found for connectAndNotify: %s", mac)
+ SendToProxy(idBinding, "CONNECTION_FAILED", {
+ mac = mac,
+ error = "Device not found",
+ }, "NOTIFY")
+ return
+ end
+
+ --- Helper to discover GATT services and notify child driver
+ local function discoverAndNotify()
+ self:_discoverGattServices(mac, function(success)
+ if success then
+ SendToProxy(idBinding, "CONNECTED", {
+ name = device.name,
+ mac = mac,
+ deviceType = device.deviceType,
+ services = SerializeSafe(device.services or {}),
+ }, "NOTIFY")
+ else
+ SendToProxy(idBinding, "CONNECTION_FAILED", {
+ name = device.name,
+ mac = mac,
+ deviceType = device.deviceType,
+ error = "GATT discovery failed",
+ }, "NOTIFY")
+ end
+ end)
+ end
+
+ --- Helper to notify child driver of failure
+ local function onFailed(error)
+ SendToProxy(idBinding, "CONNECTION_FAILED", {
+ mac = mac,
+ error = tostring(error or "Unknown error"),
+ }, "NOTIFY")
+ end
+
+ -- Check if device is already connected
+ if self._client:isBluetoothDeviceAllocated(mac) then
+ log:info("Device %s already connected, using existing connection", mac)
+ discoverAndNotify()
+ return
+ end
+
+ -- Check slot availability from cached state (skip if not initialized yet)
+ local state = self._client:getBluetoothConnectionState()
+ if state.initialized and state.free <= 0 then
+ log:warn("No connection slots available for %s", mac)
+ onFailed("No connection slots available")
+ return
+ end
+
+ -- Initiate connection
+ log:debug("Initiating BLE connection to %s", mac)
+ self:_initiateConnection(mac, function(success, error)
+ if success then
+ -- Brief delay before GATT discovery to allow ESPHome to fully establish the connection.
+ -- Without this delay, GATT service discovery may return 0 services.
+ SetTimer("GattDiscovery_" .. mac:gsub(":", ""), 500, discoverAndNotify)
+ else
+ onFailed(error)
+ end
+ end)
+end
+
+--- Disconnect from a Bluetooth device.
+--- @param mac string MAC address
+function BluetoothProxyCapability:disconnectDevice(mac)
+ log:trace("BluetoothProxyCapability:disconnectDevice(%s)", mac)
+
+ local device = self._addedDevices[mac]
+ if not device then
+ return
+ end
+
+ -- Delete dynamic binding
+ if device.bindingId then
+ local bindingKey = "bt_" .. mac:gsub(":", "")
+ bindings:deleteBinding(self.TYPE, bindingKey)
+ end
+
+ -- Disconnect from device
+ self._client:bluetoothDeviceDisconnect(mac)
+
+ self._addedDevices[mac] = nil
+ log:info("Disconnected from Bluetooth device: %s", mac)
+end
+
+--- Handle commands from sub-driver.
+--- @param mac string MAC address
+--- @param idBinding number Binding ID
+--- @param strCommand string Command string
+--- @param tParams table Command parameters
+--- @param args table Command arguments
+function BluetoothProxyCapability:handleCommand(mac, idBinding, strCommand, tParams, args)
+ log:trace("BluetoothProxyCapability:handleCommand(%s, %s, %s, %s, %s)", mac, idBinding, strCommand, tParams, args)
+
+ local device = self._addedDevices[mac]
+ if not device then
+ log:error("Device not found for command %s: %s", strCommand, mac)
+ return
+ end
+
+ -- Handle CONNECT command - initiates BLE connection with GATT discovery
+ if strCommand == "CONNECT" then
+ -- Check if device is already allocated (connected)
+ local isAllocated = self._client:isBluetoothDeviceAllocated(mac)
+
+ if isAllocated then
+ -- Device already connected - check if we have cached services
+ if device.services and #device.services > 0 then
+ log:info("Device %s already connected with %d cached services", mac, #device.services)
+ SendToProxy(idBinding, "CONNECTED", {
+ name = device.name,
+ mac = mac,
+ deviceType = device.deviceType,
+ services = SerializeSafe(device.services),
+ }, "NOTIFY")
+ else
+ -- Connected but no cached services - discover them
+ log:info("Device %s connected but no cached services, discovering", mac)
+ self:_discoverGattServices(mac, function(success)
+ if success then
+ SendToProxy(idBinding, "CONNECTED", {
+ name = device.name,
+ mac = mac,
+ deviceType = device.deviceType,
+ services = SerializeSafe(device.services or {}),
+ }, "NOTIFY")
+ else
+ SendToProxy(idBinding, "CONNECTION_FAILED", {
+ mac = mac,
+ error = "GATT discovery failed",
+ }, "NOTIFY")
+ end
+ end)
+ end
+ else
+ -- Device not connected - check if we have a slot available
+ local state = self._client:getBluetoothConnectionState()
+ if state.initialized and state.free <= 0 then
+ log:warn("No connection slots available for %s (0/%d free)", mac, state.limit)
+ SendToProxy(idBinding, "CONNECTION_FAILED", {
+ mac = mac,
+ error = "No connection slots available",
+ }, "NOTIFY")
+ return
+ end
+
+ log:info("Initiating BLE connection for %s", mac)
+ self:_connectAndNotify(mac, idBinding)
+ end
+ return
+ end
+
+ -- Handle DISCONNECT command - releases BLE connection slot
+ if strCommand == "DISCONNECT" then
+ -- Always clear cached services on disconnect request - handles may change after reconnect
+ device.services = nil
+ if self._client:isBluetoothDeviceAllocated(mac) then
+ log:info("Disconnecting from %s (child requested)", mac)
+ self._client:bluetoothDeviceDisconnect(mac)
+ -- Note: DISCONNECTED will be sent via allocation change callback
+ else
+ log:debug("Device %s not connected, ignoring DISCONNECT", mac)
+ end
+ return
+ end
+
+ -- GATT commands use client's auto-connect feature - no need to check device.connected
+ -- The client will automatically connect if the device is not already connected
+ local addressType = device.addressType or DEFAULT_ADDRESS_TYPE
+
+ if strCommand == "GATT_WRITE" then
+ local handle = tonumber(Select(tParams, "handle"))
+ local data = C4:Base64Decode(Select(tParams, "data") or "") -- Base64 encoded to preserve null bytes
+ local needResponse = Select(tParams, "response") == "true"
+
+ if not handle or not data or #data == 0 then
+ log:error("Invalid GATT_WRITE parameters: handle=%s, data=%s", handle, data)
+ return
+ end
+
+ log:debug("GATT write to %s handle %d: %d bytes, response=%s", mac, handle, #data, needResponse)
+
+ self._client:bluetoothGattWrite(mac, handle, data, needResponse, addressType):next(function()
+ log:trace("GATT write OK for %s handle %d", mac, handle)
+ SendToProxy(idBinding, "GATT_WRITE_RESPONSE", {
+ success = "true",
+ error = "0",
+ }, "NOTIFY")
+ end, function(error)
+ log:error("GATT write FAILED for %s handle %d: %s", mac, handle, error)
+ SendToProxy(idBinding, "GATT_WRITE_RESPONSE", {
+ success = "false",
+ error = tostring(error or -1),
+ }, "NOTIFY")
+ end)
+ elseif strCommand == "GATT_READ" then
+ local handle = tonumber(Select(tParams, "handle"))
+
+ if not handle then
+ log:error("Invalid GATT_READ parameters")
+ return
+ end
+
+ log:debug("GATT read from %s handle %d", mac, handle)
+
+ self._client:bluetoothGattRead(mac, handle, addressType):next(function(data)
+ SendToProxy(idBinding, "GATT_READ_RESPONSE", {
+ data = C4:Base64Encode(data or ""), -- Base64 encoded to preserve binary data
+ error = "0",
+ }, "NOTIFY")
+ end, function(error)
+ SendToProxy(idBinding, "GATT_READ_RESPONSE", {
+ data = C4:Base64Encode(""),
+ error = tostring(error or -1),
+ }, "NOTIFY")
+ end)
+ elseif strCommand == "GATT_NOTIFY" then
+ local handle = tonumber(Select(tParams, "handle"))
+ local enable = Select(tParams, "enable") == "true"
+
+ if not handle then
+ log:error("Invalid GATT_NOTIFY parameters")
+ return
+ end
+
+ log:debug("GATT notify for %s handle %d: %s", mac, handle, enable)
+
+ self._client
+ :bluetoothGattNotify(mac, handle, enable, function(data)
+ log:trace("GATT notify data for %s handle %d: %d bytes", mac, handle, #(data or ""))
+ SendToProxy(idBinding, "GATT_NOTIFY_DATA", {
+ handle = tostring(handle),
+ data = C4:Base64Encode(data or ""),
+ }, "NOTIFY")
+ end, addressType)
+ :next(function()
+ log:trace("GATT notify subscription confirmed for %s handle %d", mac, handle)
+
+ -- V3 BLE connections require client to write the CCCD descriptor.
+ -- The ESP firmware skips this for V3, expecting the API client to handle it.
+ -- We must wait for the CCCD write to complete before notifying the child,
+ -- otherwise the child may start writing before notifications are enabled.
+ local function notifyChild()
+ SendToProxy(idBinding, "GATT_NOTIFY_SUBSCRIBED", {
+ handle = tostring(handle),
+ success = "true",
+ }, "NOTIFY")
+ end
+
+ if enable and device.services then
+ local cccdHandle, cccdValue = self:_findCccdForHandle(device.services, handle)
+ if cccdHandle and cccdValue then
+ log:debug("Writing CCCD for handle %d: descriptor handle=%d", handle, cccdHandle)
+ self._client:bluetoothGattWriteDescriptor(mac, cccdHandle, cccdValue, addressType):next(function()
+ log:trace("CCCD write confirmed for handle %d", handle)
+ notifyChild()
+ end, function(err)
+ log:warn("CCCD write failed for handle %d: %s (notifying child anyway)", handle, err)
+ notifyChild()
+ end)
+ return -- notifyChild called from promise
+ else
+ log:trace("No CCCD descriptor found for handle %d", handle)
+ end
+ else
+ log:trace("No services cached, skipping CCCD write for handle %d", handle)
+ end
+
+ notifyChild()
+ end, function(error)
+ log:error("GATT notify failed for %s handle %d: %s", mac, handle, error)
+ SendToProxy(idBinding, "GATT_NOTIFY_SUBSCRIBED", {
+ handle = tostring(handle),
+ success = "false",
+ error = tostring(error or "unknown"),
+ }, "NOTIFY")
+ end)
+ end
+end
+
+--- Set devices from BLEScannerProperties callback.
+--- Creates bindings for new devices and removes bindings for deselected ones.
+--- Actual BLE connection is deferred until child driver binds.
+--- NOTE: This is disabled when a coordinator is connected - the coordinator handles device management.
+--- @param selectedDevices table Map of MAC to device info
+function BluetoothProxyCapability:setDevices(selectedDevices)
+ log:trace("BluetoothProxyCapability:setDevices()")
+
+ -- Skip if coordinator is connected - it handles device management
+ if self._coordinatorConnected then
+ log:debug("setDevices skipped - coordinator is connected")
+ return
+ end
+
+ local newMacs = {}
+ local newCount = 0
+ for mac, deviceInfo in pairs(selectedDevices or {}) do
+ newMacs[mac] = deviceInfo
+ newCount = newCount + 1
+ end
+
+ log:info("setDevices called with %d devices", newCount)
+
+ -- Find devices to remove (keys are already MAC addresses)
+ for mac in pairs(self._addedDevices) do
+ if not newMacs[mac] then
+ self:removeDevice(mac)
+ end
+ end
+
+ -- Add new devices (create bindings, defer connection)
+ local addedCount = 0
+ for mac, deviceInfo in pairs(newMacs) do
+ if not self._addedDevices[mac] then
+ log:debug("Adding device: %s (%s)", mac, deviceInfo.deviceType or "unknown")
+ self:addDevice(deviceInfo)
+ addedCount = addedCount + 1
+ end
+ end
+
+ log:info("setDevices complete: added %d new devices, total now %d", addedCount, TableLength(self._addedDevices))
+
+ -- Update status to reflect any oversubscription changes
+ self:_updateStatusProperty()
+end
+
+--- Add a device and create its binding.
+--- Actual BLE connection is deferred until child driver binds.
+--- NOTE: This is disabled when a coordinator is connected.
+--- @param device BLEDiscoveredDevice Device info from scanner
+function BluetoothProxyCapability:addDevice(device)
+ local mac = device.mac
+ local bindingClass = device.bindingClass
+ log:trace("BluetoothProxyCapability:addDevice(%s)", mac)
+
+ -- Skip if coordinator is connected - it handles device bindings
+ if self._coordinatorConnected then
+ log:debug("addDevice skipped for %s - coordinator is connected", mac)
+ return
+ end
+
+ if not bindingClass then
+ log:warn("Device %s has no binding class, skipping", mac)
+ return
+ end
+
+ -- Initialize device tracking (not connected yet)
+ self._addedDevices[mac] = {
+ name = device.name,
+ addressType = device.addressType or DEFAULT_ADDRESS_TYPE,
+ services = nil,
+ deviceType = device.deviceType,
+ bindingClass = bindingClass,
+ bindingId = nil,
+ passive = device.passive or false,
+ }
+
+ -- Create binding immediately so child driver can be added
+ -- Always include MAC address in display name for easy identification
+ local cleanMac = mac:gsub(":", "")
+ local displayName = (device.name or device.deviceType) .. " [" .. cleanMac .. "]"
+ local binding =
+ bindings:getOrAddDynamicBinding(self.TYPE, "bt_" .. cleanMac, "PROXY", true, displayName, bindingClass)
+
+ if binding then
+ self._addedDevices[mac].bindingId = binding.bindingId
+
+ -- Register RFP handler - connection will happen on first command
+ RFP[binding.bindingId] = function(idBinding, strCommand, tParams, args)
+ self:handleCommand(mac, idBinding, strCommand, tParams, args)
+ end
+
+ -- Register OBC handler for binding changes (when child driver binds/unbinds)
+ OBC[binding.bindingId] = function(idBinding, _strClass, bIsBound, _otherDeviceId, _otherBindingId)
+ self:onBindingChanged(idBinding, bIsBound)
+ end
+
+ log:info("Created binding %s for %s (%s) - connection pending", binding.bindingId, mac, bindingClass)
+ end
+end
+
+--- Remove a device and its binding.
+--- @param mac string MAC address
+function BluetoothProxyCapability:removeDevice(mac)
+ log:trace("BluetoothProxyCapability:removeDevice(%s)", mac)
+
+ local device = mac and self._addedDevices[mac]
+ if not device then
+ return
+ end
+
+ -- Delete dynamic binding
+ if device.bindingId then
+ local bindingKey = "bt_" .. mac:gsub(":", "")
+ bindings:deleteBinding(self.TYPE, bindingKey)
+ end
+
+ -- Disconnect if connected (active devices only)
+ if not device.passive and self._client:isBluetoothDeviceAllocated(mac) then
+ self._client:bluetoothDeviceDisconnect(mac)
+ end
+
+ self._addedDevices[mac] = nil
+ log:info("Removed device: %s", mac)
+end
+
+--- Get list of added devices (may or may not be connected).
+--- @return table Map of MAC address to device info
+function BluetoothProxyCapability:getAddedDevices()
+ return self._addedDevices
+end
+
+--- Handle binding changes from Control4.
+--- When a child driver binds, automatically connect to the BLE device.
+--- @param idBinding number The binding ID that changed
+--- @param bIsBound boolean Whether a device is now bound
+function BluetoothProxyCapability:onBindingChanged(idBinding, bIsBound)
+ log:trace("BluetoothProxyCapability:onBindingChanged(%s, %s)", idBinding, bIsBound)
+
+ -- Find the device for this binding
+ local mac, device
+ for m, d in pairs(self._addedDevices) do
+ if d.bindingId == idBinding then
+ mac = m
+ device = d
+ break
+ end
+ end
+
+ if not mac or not device then
+ log:trace("onBindingChanged: binding %s not found in _addedDevices", idBinding)
+ return -- Not one of our bindings
+ end
+
+ log:debug("onBindingChanged: found device %s for binding %s (passive=%s)", mac, idBinding, device.passive)
+
+ if bIsBound then
+ -- Start passive advertisement monitoring for all devices (they parse locally)
+ log:info("Child driver bound to %s, starting advertisement monitoring", mac)
+ local advOk, advErr = pcall(function()
+ self:_startAdvertisementMonitoring(mac, idBinding)
+ end)
+ if not advOk then
+ log:error("Failed to start advertisement monitoring for %s: %s", mac, advErr or "unknown error")
+ end
+
+ -- For non-passive devices (like switches), also establish GATT connection
+ if not device.passive then
+ if self._client:isBluetoothDeviceAllocated(mac) then
+ log:info("Device %s already connected, sending CONNECTED", mac)
+ SendToProxy(idBinding, "CONNECTED", {
+ name = device.name,
+ mac = mac,
+ deviceType = device.deviceType,
+ services = SerializeSafe(device.services or {}),
+ }, "NOTIFY")
+ else
+ log:info("Initiating GATT connection to %s", mac)
+ local connOk, connErr = pcall(function()
+ self:_connectAndNotify(mac, idBinding)
+ end)
+ if not connOk then
+ log:error("Failed to initiate connection for %s: %s", mac, connErr or "unknown error")
+ end
+ end
+ end
+ else
+ log:info("Child driver unbound from %s", mac)
+ -- Stop advertisement monitoring
+ self:_stopAdvertisementMonitoring(mac)
+
+ -- Disconnect GATT if allocated (for non-passive devices)
+ if not device.passive and self._client:isBluetoothDeviceAllocated(mac) then
+ self._client:bluetoothDeviceDisconnect(mac)
+ device.services = nil
+ end
+ end
+
+ log:trace("onBindingChanged complete for %s", mac)
+end
+
+--- Start advertisement monitoring for a device.
+--- Forwards parsed advertisement data to child driver.
+--- @param mac string MAC address
+--- @param idBinding number binding ID
+--- @private
+function BluetoothProxyCapability:_startAdvertisementMonitoring(mac, idBinding)
+ log:trace("_startAdvertisementMonitoring(%s, %s)", mac, idBinding)
+
+ -- Skip if coordinator is connected - it handles advertisement forwarding
+ if self._coordinatorConnected then
+ log:debug("_startAdvertisementMonitoring skipped for %s - coordinator is connected", mac)
+ return
+ end
+
+ local device = self._addedDevices[mac]
+ if not device then
+ log:warn("_startAdvertisementMonitoring: device %s not found in _addedDevices", mac)
+ return
+ end
+
+ local callbackId = "ble_" .. mac:gsub(":", "")
+ log:debug(
+ "Registering advertisement callback for %s (callbackId: %s, binding: %s, deviceType: %s)",
+ mac,
+ callbackId,
+ idBinding,
+ device.deviceType
+ )
+
+ -- Register callback for BLE advertisements from this device
+ self._client:addBluetoothAdvertisementCallback(callbackId, function(advertisement)
+ -- Filter to only this device's MAC
+ if advertisement.mac ~= mac then
+ return
+ end
+
+ -- Forward serialized advertisement to child driver
+ -- Include device info so child can update CONNECTED_PASSIVE state
+ SendToProxy(idBinding, "BLE_ADVERTISEMENT", {
+ name = device.name,
+ mac = mac,
+ deviceType = device.deviceType,
+ advertisement = SerializeSafe(advertisement),
+ }, "NOTIFY")
+ end)
+
+ -- Send initial CONNECTED_PASSIVE message to child driver
+ SendToProxy(idBinding, "CONNECTED_PASSIVE", {
+ name = device.name,
+ mac = mac,
+ deviceType = device.deviceType,
+ }, "NOTIFY")
+
+ log:info("Started advertisement monitoring for %s (callback: %s, binding: %s)", mac, callbackId, idBinding)
+end
+
+--- Stop advertisement monitoring for a device.
+--- @param mac string MAC address
+--- @private
+function BluetoothProxyCapability:_stopAdvertisementMonitoring(mac)
+ local callbackId = "ble_" .. mac:gsub(":", "")
+ self._client:removeBluetoothAdvertisementCallback(callbackId)
+ log:info("Stopped advertisement monitoring for %s", mac)
+end
+
+--- BLE characteristic property bits
+local BLE_PROP_NOTIFY = 0x10
+local BLE_PROP_INDICATE = 0x20
+
+--- CCCD UUID (0x2902) as full 128-bit Bluetooth Base UUID
+local CCCD_UUID = "00002902-0000-1000-8000-00805F9B34FB"
+
+--- Find the CCCD descriptor handle and appropriate value for a characteristic handle.
+--- V3 BLE connections require the client to write the CCCD to enable notifications/indications.
+--- Matches by short_uuid or full UUID, same as bleak-esphome's approach.
+--- @param services ProtoBluetoothGATTService[]|nil GATT services from device discovery
+--- @param charHandle number The characteristic handle to find CCCD for
+--- @return number|nil cccdHandle The CCCD descriptor handle, or nil if not found
+--- @return string|nil cccdValue The 2-byte LE CCCD value to write, or nil
+function BluetoothProxyCapability:_findCccdForHandle(services, charHandle)
+ if not services then
+ return nil, nil
+ end
+ for _, svc in ipairs(services) do
+ for _, chr in ipairs(svc.characteristics or {}) do
+ if chr.handle == charHandle then
+ local props = chr.properties or 0
+ local hasNotify = math.floor(props / BLE_PROP_NOTIFY) % 2 == 1
+ local hasIndicate = math.floor(props / BLE_PROP_INDICATE) % 2 == 1
+ if not hasNotify and not hasIndicate then
+ return nil, nil -- characteristic doesn't support notifications
+ end
+ local value = hasIndicate and 0x0002 or 0x0001
+ for _, desc in ipairs(chr.descriptors or {}) do
+ local descUuid = UUID.fromGattObject(desc)
+ if descUuid and UUID.matches(descUuid, CCCD_UUID) then
+ return desc.handle, string.char(value % 256, math.floor(value / 256))
+ end
+ end
+ return nil, nil -- characteristic found but no CCCD descriptor
+ end
+ end
+ end
+ return nil, nil
+end
+
+--- Check if an active device has a GATT connection.
+--- Note: Passive devices (BTHome) are never "connected" - they just receive advertisements.
+--- @param mac string MAC address
+--- @return boolean connected True if device has an active GATT connection
+function BluetoothProxyCapability:isConnected(mac)
+ local device = mac and self._addedDevices[mac]
+ if not device then
+ return false
+ end
+ -- Passive devices don't have GATT connections
+ if device.passive then
+ return false
+ end
+ -- Check the authoritative allocated list from ESPHome
+ return self._client:isBluetoothDeviceAllocated(mac)
+end
+
+--- Handle the discovery of bluetooth proxy capability.
+--- Registers the device selection property with BLEScannerProperties.
+--- @param deviceInfo ProtoDeviceInfoResponse|nil Device info containing feature flags
+function BluetoothProxyCapability:discovered(deviceInfo)
+ log:trace("BluetoothProxyCapability:discovered(%s)", deviceInfo)
+
+ self._featureFlags = math.max(0, Select(deviceInfo, "bluetooth_proxy_feature_flags") or 0)
+ if self._featureFlags == 0 then
+ log:debug("Bluetooth Proxy capability not detected")
+ -- Hide Bluetooth Proxy properties
+ self:setPropertiesAttribs(constants.HIDE_PROPERTY)
+ -- Stop watchdog if running (capability no longer active)
+ self:_stopScannerWatchdog()
+ return
+ end
+
+ log:info("Bluetooth Proxy capability detected (flags: %s)", decodeFeatureFlags(self._featureFlags))
+
+ -- Show Bluetooth Proxy properties
+ self:setPropertiesAttribs(constants.SHOW_PROPERTY)
+
+ -- Create dynamic binding for coordinator communication
+ self:_createCoordinatorBinding()
+
+ -- Register property with BLEScannerProperties
+ bleScannerProperties:registerProperty(self.PROPERTY_NAME, {
+ persistKey = SELECTED_BLUETOOTH_DEVICES_PERSIST_KEY,
+ filter = function(device)
+ return device.bindingClass ~= nil
+ end,
+ onChanged = function(selectedDevices)
+ self:setDevices(selectedDevices)
+ end,
+ })
+
+ -- Register callback for ongoing connection slot updates
+ self._client:addBluetoothConnectionsCallback("bluetooth_proxy_entity", function(state)
+ log:debug("BT connection slots: %d/%d free, %d connected", state.free, state.limit, #state.allocated)
+
+ -- Build set of currently allocated MACs
+ local currentAllocated = {}
+ for _, mac in ipairs(state.allocated) do
+ currentAllocated[mac] = true
+ end
+
+ -- Detect disconnects: devices that were allocated but no longer are
+ for mac in pairs(self._previousAllocated) do
+ if not currentAllocated[mac] then
+ local device = self._addedDevices[mac]
+ if device then
+ -- Clear cached services on disconnect - handles may change after reconnect
+ device.services = nil
+ if device.bindingId then
+ log:info("Device %s disconnected (no longer allocated)", mac)
+ SendToProxy(device.bindingId, "DISCONNECTED", {
+ mac = mac,
+ reason = "BLE connection closed",
+ }, "NOTIFY")
+ end
+ end
+ end
+ end
+
+ -- Update previous allocated for next comparison
+ self._previousAllocated = currentAllocated
+
+ -- Set the limit on the property selector
+ bleScannerProperties:setLimit(self.PROPERTY_NAME, state.limit)
+
+ -- Update the status property
+ self:_updateStatusProperty()
+ end)
+
+ -- Register callback for scanner state updates
+ self._client:addBluetoothScannerStateCallback("bluetooth_proxy_entity", function(_scannerState)
+ -- Re-update status property with new scanner info
+ self:_updateStatusProperty()
+ end)
+
+ -- Register callback to mark advertisement received for scanner watchdog
+ self._client:addBluetoothAdvertisementCallback("scanner_watchdog", function(_advertisement)
+ self:_onAdvertisementReceived()
+ end)
+
+ -- Clear cached GATT services for active devices - handles may change after reconnect
+ for mac, device in pairs(self._addedDevices) do
+ if not device.passive and device.services then
+ log:debug("Clearing cached services for %s (will rediscover on reconnect)", mac)
+ device.services = nil
+ end
+ end
+
+ -- Initialize Bluetooth proxy, then check for already-bound child drivers
+ self._client:initBluetoothProxy():next(function()
+ self:_connectBoundDevices()
+ -- Start the scanner watchdog to detect stuck scanner
+ self:_startScannerWatchdog()
+ end, function(err)
+ log:error("Failed to initialize Bluetooth proxy: %s", err or "unknown")
+ end)
+end
+
+--- Check all added devices for already-bound child drivers and notify them.
+--- Called after initBluetoothProxy() completes to ensure BLE subsystem is ready.
+--- For active devices: initiates connection or sends CONNECTED if already connected.
+--- For passive devices: re-registers advertisement callback and sends CONNECTED_PASSIVE.
+--- @private
+function BluetoothProxyCapability:_connectBoundDevices()
+ log:trace("BluetoothProxyCapability:_connectBoundDevices()")
+ local deviceId = C4:GetDeviceID()
+ local deviceCount = 0
+ local boundCount = 0
+ local errorCount = 0
+
+ for mac, device in pairs(self._addedDevices) do
+ deviceCount = deviceCount + 1
+ log:debug("Checking device %s: bindingId=%s, passive=%s", mac, device.bindingId, device.passive)
+
+ if device.bindingId then
+ -- Wrap in pcall to catch any errors and continue processing remaining devices
+ local ok, err = pcall(function()
+ -- C4:GetBoundConsumerDevices may return nil for some bindings (e.g., if child driver not loaded yet)
+ local hasBoundDevices = not IsEmpty(C4:GetBoundConsumerDevices(deviceId, device.bindingId))
+ log:debug("Device %s binding %s has bound children: %s", mac, device.bindingId, hasBoundDevices)
+
+ if hasBoundDevices then
+ boundCount = boundCount + 1
+ -- Always notify bound children - they need CONNECTED/CONNECTED_PASSIVE after driver reload
+ -- onBindingChanged handles both cases: initiating connection or notifying if already connected
+ log:info("Child driver bound to %s, notifying", mac)
+ self:onBindingChanged(device.bindingId, true)
+ end
+ end)
+
+ if not ok then
+ errorCount = errorCount + 1
+ log:error("Error processing device %s (binding %s): %s", mac, device.bindingId, err or "unknown error")
+ end
+ else
+ log:warn("Device %s has no bindingId", mac)
+ end
+ end
+
+ log:info("_connectBoundDevices: %d devices, %d with bound children, %d errors", deviceCount, boundCount, errorCount)
+end
+
+--- Bluetooth proxy doesn't have state updates like other entities.
+--- @param entity table The entity data
+--- @param state table The state data
+--- @diagnostic disable-next-line: unused
+function BluetoothProxyCapability:updated(entity, state)
+ log:trace("BluetoothProxyCapability:updated(%s, %s)", entity, state)
+ -- No state updates for bluetooth proxy
+end
+
+--------------------------------------------------------------------------------
+-- Coordinator Mode Support
+--------------------------------------------------------------------------------
+
+--- Check if a Bluetooth Coordinator is connected to this proxy.
+--- When a coordinator is connected, advertisements are forwarded to it instead of
+--- being handled locally with individual device bindings.
+--- @return boolean connected True if coordinator is connected
+function BluetoothProxyCapability:isCoordinatorConnected()
+ return self._coordinatorConnected
+end
+
+--- Get room information for this proxy.
+--- Returns the room ID and name from the "Bluetooth Proxy Room" property.
+--- @return {roomId: integer, roomName: string, minRssiOverride: integer}|nil roomInfo Table with roomId, roomName, and minRssiOverride, or nil if not set
+function BluetoothProxyCapability:getRoomInfo()
+ local roomDeviceId = Properties[self.ROOM_PROPERTY_NAME]
+ local usingDefault = false
+
+ if IsEmpty(roomDeviceId) or roomDeviceId == "" then
+ -- Try to default to the driver's location
+ roomDeviceId = C4:RoomGetId()
+ usingDefault = true
+ log:debug("getRoomInfo: using default room from C4:RoomGetId() = %s", roomDeviceId)
+ end
+
+ --- @type integer?
+ local roomId = tointeger(roomDeviceId)
+ if not roomId then
+ log:warn("getRoomInfo: could not convert roomDeviceId '%s' to integer", roomDeviceId)
+ return nil
+ end
+
+ local roomName = C4:GetDeviceDisplayName(roomId) or "Unknown"
+ local minRssiOverride = tointeger(Properties[self.MIN_RSSI_OVERRIDE_PROPERTY_NAME]) or -100
+ log:debug(
+ "getRoomInfo: roomId=%d, roomName='%s', minRssiOverride=%d, usingDefault=%s",
+ roomId,
+ roomName,
+ minRssiOverride,
+ usingDefault
+ )
+
+ return {
+ roomId = roomId,
+ roomName = roomName,
+ minRssiOverride = minRssiOverride,
+ }
+end
+
+--- Handle minRssiOverride property change.
+--- Notifies the coordinator of the new threshold if connected.
+function BluetoothProxyCapability:onMinRssiOverrideChanged()
+ log:trace("BluetoothProxyCapability:onMinRssiOverrideChanged()")
+
+ if not self._coordinatorConnected or not self._coordinatorBindingId then
+ log:debug("Not connected to coordinator, skipping minRssiOverride update")
+ return
+ end
+
+ local roomInfo = self:getRoomInfo()
+ local connState = self._client:getBluetoothConnectionState()
+
+ log:info("Sending minRssiOverride update to coordinator: %d", roomInfo and roomInfo.minRssiOverride or -100)
+
+ SendToProxy(self._coordinatorBindingId, "CONNECTION_STATE", {
+ proxyId = tostring(C4:GetDeviceID()),
+ roomId = roomInfo and tostring(roomInfo.roomId) or "",
+ roomName = roomInfo and roomInfo.roomName or "",
+ connectionSlots = connState.initialized and tostring(connState.limit) or "0",
+ freeSlots = connState.initialized and tostring(connState.free) or "0",
+ minRssiOverride = roomInfo and tostring(roomInfo.minRssiOverride) or "-100",
+ }, "NOTIFY")
+end
+
+--- Handle room property change.
+--- Notifies the coordinator of the new room assignment if connected.
+function BluetoothProxyCapability:onRoomChanged()
+ log:trace("BluetoothProxyCapability:onRoomChanged()")
+
+ if not self._coordinatorConnected or not self._coordinatorBindingId then
+ log:debug("Not connected to coordinator, skipping room update")
+ return
+ end
+
+ local roomInfo = self:getRoomInfo()
+ local connState = self._client:getBluetoothConnectionState()
+
+ log:info(
+ "Sending room update to coordinator: %s (%s), minRssiOverride=%d",
+ roomInfo and roomInfo.roomName or "None",
+ roomInfo and roomInfo.roomId or "nil",
+ roomInfo and roomInfo.minRssiOverride or -100
+ )
+
+ SendToProxy(self._coordinatorBindingId, "CONNECTION_STATE", {
+ proxyId = tostring(C4:GetDeviceID()),
+ roomId = roomInfo and tostring(roomInfo.roomId) or "",
+ roomName = roomInfo and roomInfo.roomName or "",
+ connectionSlots = connState.initialized and tostring(connState.limit) or "0",
+ freeSlots = connState.initialized and tostring(connState.free) or "0",
+ minRssiOverride = roomInfo and tostring(roomInfo.minRssiOverride) or "-100",
+ }, "NOTIFY")
+end
+
+--- Handle coordinator binding state change.
+--- When coordinator connects, start forwarding all advertisements to it and disable local device management.
+--- When coordinator disconnects, stop forwarding and re-enable local device management.
+--- @param bIsBound boolean Whether coordinator is now bound
+function BluetoothProxyCapability:onCoordinatorBindingChanged(bIsBound)
+ log:info("Coordinator binding changed: %s", bIsBound)
+
+ if not self._coordinatorBindingId then
+ log:warn("Coordinator binding ID not set, ignoring binding change")
+ return
+ end
+
+ if bIsBound then
+ self._coordinatorConnected = true
+
+ -- Switch to coordinator mode properties: show room, hide device selection
+ self:setPropertiesAttribs(constants.SHOW_PROPERTY)
+
+ -- Clean up all existing standalone mode state
+ -- Collect MACs first to avoid modifying table while iterating
+ local macsToRemove = {}
+ for mac in pairs(self._addedDevices) do
+ table.insert(macsToRemove, mac)
+ end
+
+ -- Remove all devices (stops monitoring, disconnects GATT, removes bindings)
+ for _, mac in ipairs(macsToRemove) do
+ self:removeDevice(mac)
+ end
+
+ -- Clear persisted device selection
+ bleScannerProperties:clearSelection(self.PROPERTY_NAME)
+
+ -- Start forwarding all advertisements to coordinator
+ self:_startCoordinatorForwarding()
+
+ -- Send initial connection info to coordinator
+ local roomInfo = self:getRoomInfo()
+ local connState = self._client:getBluetoothConnectionState()
+
+ SendToProxy(self._coordinatorBindingId, "PROXY_CONNECTED", {
+ proxyId = tostring(C4:GetDeviceID()),
+ roomId = roomInfo and tostring(roomInfo.roomId) or "",
+ roomName = roomInfo and roomInfo.roomName or "",
+ connectionSlots = connState.initialized and tostring(connState.limit) or "0",
+ freeSlots = connState.initialized and tostring(connState.free) or "0",
+ featureFlags = tostring(self._featureFlags),
+ minRssiOverride = roomInfo and tostring(roomInfo.minRssiOverride) or "-100",
+ }, "NOTIFY")
+
+ -- Update status to indicate coordinator mode
+ self:_updateStatusProperty()
+ else
+ self._coordinatorConnected = false
+ self:_stopCoordinatorForwarding()
+
+ -- Switch back to standalone mode properties: hide room, show device selection
+ self:setPropertiesAttribs(constants.SHOW_PROPERTY)
+
+ -- Re-enable local advertisement monitoring for bound devices
+ for mac, device in pairs(self._addedDevices) do
+ if device.bindingId then
+ local deviceId = C4:GetDeviceID()
+ local hasBoundDevices = not IsEmpty(C4:GetBoundConsumerDevices(deviceId, device.bindingId))
+ if hasBoundDevices then
+ self:_startAdvertisementMonitoring(mac, device.bindingId)
+ end
+ end
+ end
+
+ -- Update status property
+ self:_updateStatusProperty()
+ end
+end
+
+--- Start forwarding BLE advertisements to the coordinator.
+--- Advertisements are filtered based on _advertisementFilter (nil = pass all).
+--- @private
+function BluetoothProxyCapability:_startCoordinatorForwarding()
+ if self._coordinatorCallbackId then
+ return -- Already forwarding
+ end
+
+ if not self._coordinatorBindingId then
+ log:warn("Cannot start coordinator forwarding: binding ID not set")
+ return
+ end
+
+ self._coordinatorCallbackId = "coordinator_fwd"
+ local bindingId = self._coordinatorBindingId
+
+ -- Register callback for BLE advertisements
+ self._client:addBluetoothAdvertisementCallback(self._coordinatorCallbackId, function(advertisement)
+ -- Apply filter: nil = pass all, otherwise check if MAC is in filter set
+ if self._advertisementFilter and not self._advertisementFilter[advertisement.mac] then
+ return -- Not in filter, skip
+ end
+
+ -- Forward parsed advertisement to coordinator
+ SendToProxy(bindingId, "BLE_ADVERTISEMENT", {
+ proxyId = tostring(C4:GetDeviceID()),
+ advertisement = SerializeSafe(advertisement),
+ }, "NOTIFY")
+ end)
+
+ log:info("Started coordinator advertisement forwarding")
+end
+
+--- Stop forwarding advertisements to coordinator.
+--- @private
+function BluetoothProxyCapability:_stopCoordinatorForwarding()
+ if not self._coordinatorCallbackId then
+ return
+ end
+
+ self._client:removeBluetoothAdvertisementCallback(self._coordinatorCallbackId)
+ self._coordinatorCallbackId = nil
+ log:info("Stopped coordinator advertisement forwarding")
+end
+
+--- Handle commands from the Bluetooth Coordinator.
+--- The coordinator can request GATT operations to be performed via this proxy.
+--- @param strCommand string The command string
+--- @param tParams table Command parameters
+function BluetoothProxyCapability:handleCoordinatorCommand(strCommand, tParams)
+ log:debug("handleCoordinatorCommand(%s, %s)", strCommand, tParams)
+
+ local bindingId = self._coordinatorBindingId
+ if not bindingId then
+ log:warn("Coordinator command received but binding ID not set")
+ return
+ end
+
+ local proxyId = tostring(C4:GetDeviceID())
+
+ if strCommand == "GATT_CONNECT" then
+ local mac = Select(tParams, "mac")
+ local addressType = tointeger(Select(tParams, "addressType")) or DEFAULT_ADDRESS_TYPE
+ local requestId = Select(tParams, "requestId")
+
+ if not mac then
+ log:error("GATT_CONNECT missing required parameters")
+ return
+ end
+
+ log:info("Coordinator requested GATT connection to %s", mac)
+
+ self._client:bluetoothDeviceConnect(mac, addressType, false):next(function(result)
+ -- Discover GATT services
+ self._client:bluetoothGattGetServices(mac):next(function(services)
+ SendToProxy(bindingId, "GATT_CONNECT_RESPONSE", {
+ proxyId = proxyId,
+ mac = mac,
+ requestId = requestId or "",
+ success = "true",
+ services = SerializeSafe(services or {}),
+ mtu = tostring(result.mtu or 0),
+ }, "NOTIFY")
+ end, function(err)
+ SendToProxy(bindingId, "GATT_CONNECT_RESPONSE", {
+ proxyId = proxyId,
+ mac = mac,
+ requestId = requestId or "",
+ success = "false",
+ error = "GATT discovery failed: " .. tostring(err),
+ }, "NOTIFY")
+ end)
+ end, function(err)
+ SendToProxy(bindingId, "GATT_CONNECT_RESPONSE", {
+ proxyId = proxyId,
+ mac = mac,
+ requestId = requestId or "",
+ success = "false",
+ error = tostring(err or "Connection failed"),
+ }, "NOTIFY")
+ end)
+ elseif strCommand == "GATT_DISCONNECT" then
+ local mac = Select(tParams, "mac")
+
+ if mac then
+ self._client:bluetoothDeviceDisconnect(mac)
+ SendToProxy(bindingId, "GATT_DISCONNECT_RESPONSE", {
+ proxyId = proxyId,
+ mac = mac,
+ success = "true",
+ }, "NOTIFY")
+ end
+ elseif strCommand == "GATT_WRITE" then
+ local mac = Select(tParams, "mac")
+ local addressType = tointeger(Select(tParams, "addressType")) or DEFAULT_ADDRESS_TYPE
+ local handle = tointeger(Select(tParams, "handle"))
+ local data = C4:Base64Decode(Select(tParams, "data") or "")
+ local needResponse = Select(tParams, "response") == "true"
+ local requestId = Select(tParams, "requestId")
+
+ if not mac or not handle then
+ log:error("GATT_WRITE missing required parameters")
+ return
+ end
+
+ self._client:bluetoothGattWrite(mac, handle, data, needResponse, addressType):next(function()
+ SendToProxy(bindingId, "GATT_WRITE_RESPONSE", {
+ proxyId = proxyId,
+ mac = mac or "",
+ requestId = requestId or "",
+ success = "true",
+ error = "0",
+ }, "NOTIFY")
+ end, function(error)
+ SendToProxy(bindingId, "GATT_WRITE_RESPONSE", {
+ proxyId = proxyId,
+ mac = mac or "",
+ requestId = requestId or "",
+ success = "false",
+ error = tostring(error or -1),
+ }, "NOTIFY")
+ end)
+ elseif strCommand == "GATT_READ" then
+ local mac = Select(tParams, "mac")
+ local addressType = tointeger(Select(tParams, "addressType")) or DEFAULT_ADDRESS_TYPE
+ local handle = tointeger(Select(tParams, "handle"))
+ local requestId = Select(tParams, "requestId")
+
+ if not mac or not handle then
+ log:error("GATT_READ missing required parameters")
+ return
+ end
+
+ self._client:bluetoothGattRead(mac, handle, addressType):next(function(data)
+ SendToProxy(bindingId, "GATT_READ_RESPONSE", {
+ proxyId = proxyId,
+ mac = mac or "",
+ requestId = requestId or "",
+ data = C4:Base64Encode(data or ""),
+ error = "0",
+ }, "NOTIFY")
+ end, function(error)
+ SendToProxy(bindingId, "GATT_READ_RESPONSE", {
+ proxyId = proxyId,
+ mac = mac or "",
+ requestId = requestId or "",
+ data = "",
+ error = tostring(error or -1),
+ }, "NOTIFY")
+ end)
+ elseif strCommand == "GATT_NOTIFY" then
+ local mac = Select(tParams, "mac")
+ local addressType = tointeger(Select(tParams, "addressType")) or DEFAULT_ADDRESS_TYPE
+ local handle = tointeger(Select(tParams, "handle"))
+ local enable = Select(tParams, "enable") == "true"
+ local requestId = Select(tParams, "requestId")
+
+ if not mac or not handle then
+ log:error("GATT_NOTIFY missing required parameters")
+ return
+ end
+
+ self._client
+ :bluetoothGattNotify(mac, handle, enable, function(data)
+ -- Forward notification data to coordinator
+ SendToProxy(bindingId, "GATT_NOTIFY_DATA", {
+ proxyId = proxyId,
+ mac = mac or "",
+ handle = tostring(handle),
+ data = C4:Base64Encode(data or ""),
+ }, "NOTIFY")
+ end, addressType)
+ :next(function()
+ SendToProxy(bindingId, "GATT_NOTIFY_SUBSCRIBED", {
+ proxyId = proxyId,
+ mac = mac or "",
+ requestId = requestId or "",
+ handle = tostring(handle),
+ success = "true",
+ }, "NOTIFY")
+ end, function(error)
+ SendToProxy(bindingId, "GATT_NOTIFY_SUBSCRIBED", {
+ proxyId = proxyId,
+ mac = mac or "",
+ requestId = requestId or "",
+ handle = tostring(handle),
+ success = "false",
+ error = tostring(error or "unknown"),
+ }, "NOTIFY")
+ end)
+ elseif strCommand == "GET_CONNECTION_STATE" then
+ local connState = self._client:getBluetoothConnectionState()
+ local roomInfo = self:getRoomInfo()
+
+ SendToProxy(bindingId, "CONNECTION_STATE", {
+ proxyId = proxyId,
+ roomId = roomInfo and tostring(roomInfo.roomId) or "",
+ roomName = roomInfo and roomInfo.roomName or "",
+ connectionSlots = connState.initialized and tostring(connState.limit) or "0",
+ freeSlots = connState.initialized and tostring(connState.free) or "0",
+ allocated = SerializeSafe(connState.allocated or {}),
+ }, "NOTIFY")
+ elseif strCommand == "SET_ADVERTISEMENT_FILTER" then
+ local macs = DeserializeSafe(tParams.macs)
+ if not macs or #macs == 0 then
+ -- Empty or nil = pass all advertisements
+ self._advertisementFilter = nil
+ log:info("Advertisement filter cleared (pass all)")
+ else
+ -- Convert to set for O(1) lookup
+ self._advertisementFilter = {}
+ for _, mac in ipairs(macs) do
+ self._advertisementFilter[mac] = true
+ end
+ log:info("Advertisement filter set: %d MAC(s)", #macs)
+ end
+ -- Update status property to reflect filter state
+ self:_updateStatusProperty()
+ else
+ log:warn("Unknown coordinator command: %s", strCommand)
+ end
+end
+
+--- Create and register the coordinator binding dynamically.
+--- Called when Bluetooth proxy capability is detected.
+--- @private
+function BluetoothProxyCapability:_createCoordinatorBinding()
+ -- Create dynamic binding for coordinator communication
+ local binding = bindings:getOrAddDynamicBinding(
+ self.TYPE,
+ self.COORDINATOR_BINDING_KEY,
+ "PROXY",
+ false,
+ "Bluetooth Coordinator",
+ "ESPHOME_BLUETOOTH"
+ )
+
+ if not binding then
+ log:error("Failed to create coordinator binding")
+ return
+ end
+
+ self._coordinatorBindingId = binding.bindingId
+ log:info("Created coordinator binding on %d", binding.bindingId)
+
+ -- Register RFP handler for coordinator commands
+ RFP[binding.bindingId] = function(_idBinding, strCommand, tParams, _args)
+ self:handleCoordinatorCommand(strCommand, tParams)
+ end
+
+ -- Register OBC handler for coordinator binding lifecycle
+ OBC[binding.bindingId] = function(_idBinding, _strClass, bIsBound, _otherDeviceId, _otherBindingId)
+ self:onCoordinatorBindingChanged(bIsBound)
+ end
+
+ -- Check if coordinator is already bound (we're a consumer, check for provider)
+ local deviceId = C4:GetDeviceID()
+ local boundProvider = C4:GetBoundProviderDevice(deviceId, binding.bindingId)
+ if boundProvider and boundProvider > 0 then
+ log:info("Coordinator already bound on startup (provider device %d), enabling forwarding", boundProvider)
+ self:onCoordinatorBindingChanged(true)
+ end
+end
+
+return BluetoothProxyCapability
diff --git a/src/esphome/capabilities/types.lua b/src/esphome/capabilities/types.lua
new file mode 100644
index 0000000..7a4553f
--- /dev/null
+++ b/src/esphome/capabilities/types.lua
@@ -0,0 +1,5 @@
+--- @class Capability
+--- @field client ESPHomeClient The ESPHome client instance.
+--- @field new (fun(client: ESPHomeClient): Capability) A constructor function to create a new capability instance.
+--- @field discovered (fun(self: Capability, deviceInfo: ProtoDeviceInfoResponse|nil): void )? A function to handle device discovery.
+--- @field updated (fun(self: Capability, entity: table, state: table): void)? A function to update the capability state.
diff --git a/src/esphome/client.lua b/src/esphome/client.lua
index 998e1e8..bc693ab 100644
--- a/src/esphome/client.lua
+++ b/src/esphome/client.lua
@@ -1,23 +1,25 @@
---- @module "esphome.client"
--- ESPHome API Client for Control4.
--- This module provides a Lua implementation for connecting to ESPHome devices
--- using the native API protocol over TCP with protobuf encoding.
--- Supports both plaintext and encrypted (Noise protocol) communication.
+local bit16 = require("bitn").bit16
+local pb = require("protobuf")
+local deferred = require("deferred")
+local noise = require("noiseprotocol")
+
local log = require("lib.logging")
-local bit16 = require("lib.bit16")
-local pb = require("lib.protobuf")
-local ESPHomeProtoSchema = require("esphome.proto-schema")
-local deferred = require("vendor.deferred")
-local noise = require("vendor.noiseprotocol")
+
+local ESPHomeProtoSchema = require("esphome.proto_schema")
+local BLEAdvertisementParser = require("esphome.ble.parsers.advertisement")
+local BLEAddress = require("esphome.ble.address")
local NULL_BYTE = "\x00"
---- @enum NoiseProtocolCallback
-local NoiseProtocolCallback = {
- -- These are negative values to avoid conflicts with message IDs
- HELLO = -1,
- HANDSHAKE = -2,
+--- @enum NoiseProtocolCallbackKey
+local NoiseProtocolCallbackKey = {
+ HELLO = "noise_hello",
+ HANDSHAKE = "noise_handshake",
}
--- @enum NoiseState
@@ -34,10 +36,37 @@ local Indicator = {
NOISE = "\x01",
}
+--- @alias CallbackKey string A key for identifying callbacks (message ID or composite)
+--- @alias CallbackFunction fun(message: table, schema?: ProtoMessageSchema): void
+
+--- @class CallbackEntry
+--- @field callback CallbackFunction The callback function to invoke
+--- @field timer C4LuaTimer|nil Optional timeout timer for auto-unregistration
+
--- A class representing the ESPHome API client.
--- @class ESPHomeClient
--- @field EntityType EntityType
+--- @field _client C4TCPClient|nil The TCP client for the ESPHome connection.
+--- @field _connected boolean Indicates if the client is connected.
+--- @field _ipAddress string|nil The IP address of the ESPHome device.
+--- @field _port number The port of the ESPHome device.
+--- @field _password string|nil The password for the ESPHome device.
+--- @field _encryptionKey string|nil The encryption key for the ESPHome device.
+--- @field _buffer string The buffer for incoming data.
+--- @field _callbacks table Registered callbacks keyed by message ID or composite key.
+--- @field _pingTimer C4LuaTimer|nil The timer for sending ping messages.
+--- @field _hs NoiseConnection|nil The Noise protocol connection for encrypted communication.
+--- @field _hsState NoiseState|nil The current state of the Noise protocol handshake.
+--- @field _fatalError string|nil Fatal error message (e.g., authentication failure).
+--- @field _logsSubscribed boolean Whether log subscription is active.
+--- @field _btConnections BluetoothConnectionState Cached Bluetooth connection state.
+--- @field _btConnectionsCallbacks table Callbacks for Bluetooth connection changes.
+--- @field _btScannerState BluetoothScannerState Cached scanner state.
+--- @field _btScannerStateCallbacks table Callbacks for scanner state changes.
+--- @field _btAdvertisementsCallbacks table Callbacks for BLE advertisements.
+--- @field _btProxyInitDeferred Deferred|nil In-flight deferred for initBluetoothProxy (re-entrancy guard).
local ESPHomeClient = {}
+ESPHomeClient.__index = ESPHomeClient
--- @enum EntityType
ESPHomeClient.EntityType = {
@@ -69,31 +98,50 @@ ESPHomeClient.EntityType = {
UPDATE = "update",
}
+--- @class BluetoothConnectionState
+--- @field free number Number of available connection slots
+--- @field limit number Maximum number of connection slots
+--- @field allocated string[] Array of MAC addresses (as "AA:BB:CC:DD:EE:FF" strings) currently connected
+--- @field initialized boolean Whether the subscription has been set up
+
+--- @class BluetoothScannerState
+--- @field state ProtoBluetoothScannerState BluetoothScannerState enum value
+--- @field mode ProtoBluetoothScannerMode BluetoothScannerMode enum value
+--- @field initialized boolean Whether state has been received from ESPHome
+
--- Create a new instance of the ESPHomeClient.
--- @return ESPHomeClient client A new instance of the ESPHomeClient client.
function ESPHomeClient:new()
- local properties = {
- _client = nil, --- @type C4TCPClient|nil The TCP client for the ESPHome connection.
- _connected = false, --- @type boolean Indicates if the client is connected.
- _authenticated = false, --- @type boolean Indicates if the client is authenticated.
- _ipAddress = nil, --- @type string|nil The IP address of the ESPHome device.
- _port = 6053, --- @type number The port of the ESPHome device.
- _password = nil, --- @type string|nil The password for the ESPHome device.
- _encryptionKey = nil, --- @type string|nil The encryption key for the ESPHome device.
- _buffer = "", --- @type string The buffer for incoming data.
- _callbacks = {}, --- @type table, schema: ProtoMessageSchema): void)|nil> The callback for the next expected response.
- _pingTimer = nil, --- @type C4LuaTimer|nil The timer for sending ping messages.
- _hs = nil, --- @type NoiseConnection|nil The Noise protocol connection for encrypted communication.
- _hsState = nil, --- @type NoiseState|nil The current state of the Noise protocol handshake.
+ log:trace("ESPHomeClient:new()")
+ local instance = setmetatable({}, self)
+ instance._client = nil
+ instance._connected = false
+ instance._ipAddress = nil
+ instance._port = 6053
+ instance._password = nil
+ instance._encryptionKey = nil
+ instance._buffer = ""
+ instance._callbacks = {}
+ instance._pingTimer = nil
+ instance._hs = nil
+ instance._hsState = nil
+ instance._fatalError = nil
+ instance._logsSubscribed = false
+ instance._btConnections = { free = 0, limit = 0, allocated = {}, initialized = false }
+ instance._btConnectionsCallbacks = {}
+ instance._btScannerState = {
+ state = ESPHomeProtoSchema.Enum.BluetoothScannerState.BLUETOOTH_SCANNER_STATE_IDLE,
+ mode = ESPHomeProtoSchema.Enum.BluetoothScannerMode.BLUETOOTH_SCANNER_MODE_PASSIVE,
+ initialized = false,
}
- setmetatable(properties, self)
- self.__index = self
- --- @cast properties ESPHomeClient
- return properties
+ instance._btScannerStateCallbacks = {}
+ instance._btAdvertisementsCallbacks = {}
+ instance._btProxyInitDeferred = nil
+ return instance
end
--- Parse the base64 encoded encryption key to 32-byte binary data.
---- @param encryptionKey string The base64 encoded encryption key.
+--- @param encryptionKey string? The base64 encoded encryption key.
--- @return string|nil decodedEncryptionKey The decoded encryption key as a 32-byte binary string, or nil if invalid.
local function parseEncryptionKey(encryptionKey)
if IsEmpty(encryptionKey) then
@@ -125,8 +173,9 @@ end
--- @param port number The port of the ESPHome device.
--- @param password? string The password for the ESPHome device (optional).
--- @param encryptionKey? string The encryption key for the ESPHome device (optional).
+--- @param useOpenssl? boolean
--- @return ESPHomeClient self The ESPHomeClient instance.
-function ESPHomeClient:setConfig(ipAddress, port, password, encryptionKey)
+function ESPHomeClient:setConfig(ipAddress, port, password, encryptionKey, useOpenssl)
log:trace(
"ESPHomeClient:setConfig(%s, %s, %s, %s)",
ipAddress,
@@ -134,11 +183,13 @@ function ESPHomeClient:setConfig(ipAddress, port, password, encryptionKey)
password and "***" or nil,
encryptionKey and "***" or nil
)
+ noise.use_openssl(toboolean(useOpenssl))
self:disconnect()
self._ipAddress = not IsEmpty(ipAddress) and ipAddress or nil
self._port = toport(port) or 6053
self._password = not IsEmpty(password) and password or nil
self._encryptionKey = parseEncryptionKey(encryptionKey)
+ self._fatalError = nil -- Clear fatal error when config changes
return self
end
@@ -150,14 +201,28 @@ function ESPHomeClient:isConfigured()
end
--- Check if the client is connected to the ESPHome device.
---- @param authRequired? boolean Whether client needs to be authenticated (optional).
--- @return boolean connected True if the client is connected, false otherwise.
-function ESPHomeClient:isConnected(authRequired)
- log:trace("ESPHomeClient:isConnected(%s)", authRequired)
- return self._client ~= nil and self._connected and (not authRequired or self._authenticated)
+function ESPHomeClient:isConnected()
+ log:trace("ESPHomeClient:isConnected()")
+ return self._client ~= nil and self._connected
+end
+
+--- Get the fatal error message if one occurred (e.g., authentication failure).
+--- @return string|nil error The fatal error message, or nil if no fatal error.
+function ESPHomeClient:getFatalError()
+ return self._fatalError
+end
+
+--- Check if the client is subscribed to device logs.
+--- @return boolean subscribed True if subscribed to logs, false otherwise.
+function ESPHomeClient:isLogsSubscribed()
+ return self._logsSubscribed
end
--- Connect to the ESPHome device.
+--- Note: This establishes the TCP connection and exchanges hello/auth messages.
+--- It does NOT guarantee authentication succeeded - auth failures are detected
+--- asynchronously and will cause subsequent operations to fail.
--- @return Deferred result A promise that resolves when the connection is established.
function ESPHomeClient:connect()
log:trace("ESPHomeClient:connect()")
@@ -177,27 +242,33 @@ function ESPHomeClient:connect()
-- Disconnect to clear any state
self:disconnect()
+ -- Reset fatal error on new connection attempt
+ self._fatalError = nil
+
-- Initialize Noise protocol state if encryption key is present
if self._encryptionKey ~= nil then
log:info("Noise protocol encryption enabled")
end
-- Add callbacks for any requests we can expect to receive from the device
- self._callbacks[ESPHomeProtoSchema.Message.PingRequest.options.id] = function(message)
+ self:_registerCallback(self:_makeMessageCallbackKey(ESPHomeProtoSchema.Message.PingRequest), function(message)
+ --- @cast message ProtoPingRequest
log:debug("Received ping request: %s", message)
self:sendMessage(ESPHomeProtoSchema.Message.PingResponse, {})
- end
- self._callbacks[ESPHomeProtoSchema.Message.GetTimeRequest.options.id] = function(message)
+ end)
+ self:_registerCallback(self:_makeMessageCallbackKey(ESPHomeProtoSchema.Message.GetTimeRequest), function(message)
+ --- @cast message ProtoGetTimeRequest
log:debug("Received get time request: %s", message)
self:sendMessage(ESPHomeProtoSchema.Message.GetTimeResponse, {
epoch_seconds = os.time(),
})
- end
- self._callbacks[ESPHomeProtoSchema.Message.DisconnectRequest.options.id] = function(message)
+ end)
+ self:_registerCallback(self:_makeMessageCallbackKey(ESPHomeProtoSchema.Message.DisconnectRequest), function(message)
+ --- @cast message ProtoDisconnectRequest
log:warn("Received disconnect request: %s", message)
self:sendMessage(ESPHomeProtoSchema.Message.DisconnectResponse, {})
self:disconnect()
- end
+ end)
-- Create a new TCP client
self._client = C4:CreateTCPClient()
@@ -205,10 +276,10 @@ function ESPHomeClient:connect()
log:debug("Connected to ESPHome device at %s:%s", self._ipAddress, self._port)
self._connected = true
- ---@type Deferred
- local d
+ --- @type Deferred
+ local dConnect
if not IsEmpty(self._encryptionKey) then
- d = self
+ dConnect = self
:sendNoiseHello()
:next(function()
log:debug("Noise hello message sent successfully")
@@ -225,33 +296,53 @@ function ESPHomeClient:connect()
end)
else
log:debug("No encryption key provided, using plaintext protocol")
- d = deferred.new():resolve(nil)
+ dConnect = deferred.new():resolve(nil)
end
- d:next(function()
- log:debug("Sending hello message to ESPHome device")
- -- Send the hello message
- return self:sendHello()
- end)
+ dConnect
+ :next(function()
+ log:debug("Sending hello message to ESPHome device")
+ -- Send the hello message
+ return self:sendHello()
+ end)
:next(function()
log:debug("Hello message sent successfully")
- return self:sendConnect()
+ -- Only authenticate when not using encryption key
+ if IsEmpty(self._encryptionKey) then
+ return self:sendAuthenticate()
+ end
+ log:debug("Skipping authentication request (using Noise encryption)")
+ return deferred.new():resolve({})
end, function(err)
log:error("Failed to send hello message: %s", err)
return reject(err)
end)
:next(function()
- log:debug("Successfully authenticated with ESPHome device")
- self._authenticated = true
-
- -- Start ping timer to keep connection alive
- self._pingTimer = C4:SetTimer(15000, function()
- self:sendPing()
+ log:debug("Connection established")
+
+ -- Start ping timer to keep connection alive (only ping when idle)
+ self._lastDataReceived = os.time()
+ self._pingTimer = C4:SetTimer(15 * ONE_SECOND, function()
+ local secondsSinceData = os.time() - (self._lastDataReceived or 0)
+ if secondsSinceData < 10 then
+ -- Received data recently, connection is alive, skip ping
+ log:trace("Skipping ping - received data %ds ago", secondsSinceData)
+ return
+ end
+ self:sendPing():next(nil, function()
+ -- Only disconnect if we haven't received data recently
+ local secondsSinceData = os.time() - (self._lastDataReceived or 0)
+ if secondsSinceData < 10 then
+ log:debug("Ignoring ping failure - received data %ds ago", secondsSinceData)
+ return
+ end
+ self:disconnect()
+ end)
end, true)
d:resolve(true)
end, function(err)
- log:error("Failed to authenticate with ESPHome device: %s", err)
+ log:error("Failed to establish connection: %s", err)
self:disconnect()
d:reject(err)
end)
@@ -270,8 +361,17 @@ function ESPHomeClient:connect()
d:reject(errMsg)
end)
:OnRead(function(client, data)
- log:debug("Received %d byte(s) from ESPHome device", #data)
- log:trace("Incoming raw data (hex): %s", to_hex(data))
+ -- Ignore stale data from old connections
+ if not self._connected or client ~= self._client then
+ log:trace("Ignoring %d byte(s) from stale connection", #data)
+ return
+ end
+
+ -- Track last data received for keepalive logic
+ self._lastDataReceived = os.time()
+
+ log:trace("Received %d byte(s) from ESPHome device", #data)
+ --log:ultra("Incoming raw data (hex): %s", to_hex(data))
self._buffer = self._buffer .. data
self:_processBuffer()
@@ -289,85 +389,110 @@ function ESPHomeClient:connect()
end
--- Disconnect from the ESPHome device.
---- @return void
function ESPHomeClient:disconnect()
log:trace("ESPHomeClient:disconnect()")
- if self._pingTimer ~= nil then
- self._pingTimer:Cancel()
- self._pingTimer = nil
- end
-
- if self._client ~= nil then
- self._client:Close()
- self._client = nil
- end
+ local client = self._client
+ local pingTimer = self._pingTimer
self._connected = false
+ self._client = nil
self._hs = nil
self._hsState = nil
- self._authenticated = false
self._buffer = ""
+ self._logsSubscribed = false
+
+ -- Cancel all callback timers before clearing
+ for _, entry in pairs(self._callbacks) do
+ if entry and entry.timer then
+ entry.timer:Cancel()
+ end
+ end
self._callbacks = {}
+ self._pingTimer = nil
+
+ -- Reset Bluetooth state so subscriptions can be re-established on reconnect
+ -- Note: We keep the callbacks registered by capabilities (they persist across reconnects)
+ -- Only reset the cached state which will be refreshed after reconnect
+ self._btConnections = { free = 0, limit = 0, allocated = {}, initialized = false }
+ self._btScannerState = {
+ state = ESPHomeProtoSchema.Enum.BluetoothScannerState.BLUETOOTH_SCANNER_STATE_IDLE,
+ mode = ESPHomeProtoSchema.Enum.BluetoothScannerMode.BLUETOOTH_SCANNER_MODE_PASSIVE,
+ initialized = false,
+ }
+ self._btProxyInitDeferred = nil
+
+ if pingTimer ~= nil then
+ pingTimer:Cancel()
+ end
+ if client ~= nil then
+ client:Close()
+ end
end
--- Get device information from the ESPHome device.
---- @return Deferred, string> result A promise that resolves with the device information.
+--- @return Deferred result A promise that resolves with the device information.
function ESPHomeClient:getDeviceInfo()
log:trace("ESPHomeClient:getDeviceInfo()")
return self:callServiceMethod(ESPHomeProtoSchema.RPC.APIConnection.device_info, {})
end
+--- Press a button entity by its key.
+--- @param key number The button entity key
+--- @return Deferred result A promise that resolves when the button is pressed.
+function ESPHomeClient:pressButton(key)
+ log:trace("ESPHomeClient:pressButton(%s)", key)
+ return self:callServiceMethod(ESPHomeProtoSchema.RPC.APIConnection.button_command, { key = key })
+end
+
--- List entities from the ESPHome device.
---- @return Deferred, string> result A promise that resolves with a table of entities.
+--- @return Deferred, string> result A promise that resolves with a table of entities.
function ESPHomeClient:listEntities()
log:trace("ESPHomeClient:listEntities()")
- --- @type Deferred, string>
+ --- @type Deferred, string>
local d = deferred.new()
- --- @type table
+ --- @type table
local entities = {}
- -- Track the callbacks that are added so they can be removed once we receive the done message
- --- @type number[]
- local addedCallbacks = {}
+ -- Track the callback keys that are added so they can be removed once we receive the done message
+ --- @type CallbackKey[]
+ local addedCallbackKeys = {}
for _, schema in pairs(ESPHomeProtoSchema.Message) do
-- HACK: No reliable way to identify list_entity responses from proto definition.
local name, _ = schema.name:match("^ListEntities(.+)Response$")
if not IsEmpty(name) then
- -- HACK: No reliable way to identify entity types from proto definition.
- local entityType = Select(self.EntityType, (Select(schema, "options", "ifdef") or ""):match("^USE_(.+)$"))
- if IsEmpty(entityType) then
- log:warn("Unknown entity type for %s", name)
- elseif schema.name ~= "ListEntitiesDoneResponse" then
- log:trace("Registering %s entity callback", name)
-
- table.insert(addedCallbacks, schema.options.id)
- self._callbacks[schema.options.id] = function(message)
- log:trace("Received %s entity: %s", entityType, message)
- message.entity_type = entityType
- entities[tostring(message.key)] = message
- end
- else
- table.insert(addedCallbacks, schema.options.id)
- local function removeCallbacks()
- -- Remove the callbacks for the entities
- for _, id in ipairs(addedCallbacks) do
- self._callbacks[id] = nil
+ if schema.name == "ListEntitiesDoneResponse" then
+ -- Register callback for ListEntitiesDoneResponse with timeout
+ local key = self:_registerCallback(
+ self:_makeMessageCallbackKey(schema),
+ function(_)
+ log:debug("Received %d entities: %s", TableLength(entities), entities)
+ self:_unregisterCallbacks(addedCallbackKeys)
+ d:resolve(entities)
+ end,
+ 10 * ONE_SECOND,
+ function()
+ self:_unregisterCallbacks(addedCallbackKeys)
+ d:reject("Timeout waiting for list entities response")
end
- end
- -- Add a timeout to the list entities request to prevent unresolved promises
- local timeoutTimer = C4:SetTimer(ONE_SECOND * 10, function()
- log:warn("Timeout waiting for list entities response")
- removeCallbacks()
- d:reject("Timeout waiting for list entities response")
- end)
- self._callbacks[schema.options.id] = function(_)
- log:debug("Received %d entities: %s", TableLength(entities), entities)
- timeoutTimer:Cancel()
- removeCallbacks()
- d:resolve(entities)
+ )
+ table.insert(addedCallbackKeys, key)
+ else
+ -- HACK: No reliable way to identify entity types from proto definition.
+ local entityType = Select(self.EntityType, (Select(schema, "options", "ifdef") or ""):match("^USE_(.+)$"))
+ if not IsEmpty(entityType) then
+ log:trace("Registering %s entity callback", name)
+
+ local key = self:_registerCallback(self:_makeMessageCallbackKey(schema), function(message)
+ log:trace("Received %s entity: %s", entityType, message)
+ message.entity_type = entityType
+ entities[tostring(message.key)] = message
+ end)
+ table.insert(addedCallbackKeys, key)
+ else
+ log:trace("Unknown entity type for %s (ifdef=%s)", name, Select(schema, "options", "ifdef") or "nil")
end
end
end
@@ -380,15 +505,22 @@ function ESPHomeClient:listEntities()
if IsEmpty(err) or type(err) ~= "string" then
err = "unknown error"
end
- log:error("Failed to send list entities message; %s", err)
+ log:error("Failed to send list entities message: %s", err)
d:reject(err)
end)
return d
end
+--- State responses that are handled separately and should not be registered here.
+--- @type table
+local EXCLUDED_STATE_RESPONSES = {
+ BluetoothScannerStateResponse = true, -- Managed in initBluetoothProxy()
+ SubscribeHomeAssistantStateResponse = true, -- Not used
+}
+
--- Subscribe to state updates from the ESPHome device.
---- @param callback (fun(message: table, schema: ProtoMessageSchema): void) The callback function to call when a state update is received.
+--- @param callback (fun(message: table, schema: ProtoMessageSchema?): void) The callback function to call when a state update is received.
--- @return Deferred result A promise that resolves after subscribing to states.
function ESPHomeClient:subscribeStates(callback)
log:trace("ESPHomeClient:subscribeStates()")
@@ -397,19 +529,20 @@ function ESPHomeClient:subscribeStates(callback)
for _, schema in pairs(ESPHomeProtoSchema.Message) do
-- HACK: No reliable way to identify state responses from proto definition.
+ -- Most state responses follow *StateResponse pattern, but EventResponse is an exception.
local name, _ = schema.name:match("^(.+)StateResponse$")
- if not IsEmpty(name) then
+ if IsEmpty(name) then
+ name = schema.name:match("^(Event)Response$")
+ end
+ if not IsEmpty(name) and not EXCLUDED_STATE_RESPONSES[schema.name] then
log:debug("Registering %s state callback", name)
- self._callbacks[schema.options.id] = function(message, messageSchema)
+ self:_registerCallback(self:_makeMessageCallbackKey(schema), function(message, messageSchema)
log:debug("Received %s state update: %s", name, message)
local callbackSuccess, err = pcall(callback, message, messageSchema)
if not callbackSuccess then
- if IsEmpty(err) or type(err) ~= "string" then
- err = "unknown error"
- end
- log:error("State callback for %s failed; %s", name, err)
+ log:error("State callback for %s failed: %s", name, err or "unknown error")
end
- end
+ end)
end
end
@@ -420,25 +553,960 @@ function ESPHomeClient:subscribeStates(callback)
if IsEmpty(err) or type(err) ~= "string" then
err = "unknown error"
end
- log:error("Failed to send subscribe states message; %s", err)
- return d:reject(err)
+ log:error("Failed to send subscribe states message: %s", err)
+ d:reject(err)
end)
return d
end
+--- Subscribe to log messages from the ESPHome device.
+--- Can only subscribe once per connection. To stop logs, disconnect and reconnect.
+--- @param callback fun(level: ProtoLogLevel?, message: string?): void The callback function to call when a log message is received. Level is ProtoLogLevel enum value.
+--- @param level? ProtoLogLevel The minimum log level to receive (default: LOG_LEVEL_DEBUG = 5).
+--- @param dumpConfig? boolean Whether to dump the device config first (default: false).
+--- @return Deferred result A promise that resolves after subscribing to logs.
+function ESPHomeClient:subscribeLogs(callback, level, dumpConfig)
+ log:trace("ESPHomeClient:subscribeLogs(level=%s, dumpConfig=%s)", level, dumpConfig)
+ --- @type Deferred
+ local d = deferred.new()
+
+ -- Guard against duplicate subscriptions
+ if self._logsSubscribed then
+ log:debug("Logs already subscribed")
+ d:resolve(nil)
+ return d
+ end
+ self._logsSubscribed = true
+
+ -- Default to DEBUG level (5)
+ level = level or ESPHomeProtoSchema.Enum.LogLevel.LOG_LEVEL_DEBUG
+
+ -- Register callback for log responses
+ self:_registerCallback(
+ self:_makeMessageCallbackKey(ESPHomeProtoSchema.Message.SubscribeLogsResponse),
+ function(message)
+ --- @cast message ProtoSubscribeLogsResponse
+ local callbackSuccess, err = pcall(callback, message.level, message.message)
+ if not callbackSuccess then
+ log:error("Log callback failed: %s", err or "unknown error")
+ end
+ end
+ )
+
+ -- Send subscription request
+ self
+ :callServiceMethod(ESPHomeProtoSchema.RPC.APIConnection.subscribe_logs, {
+ level = level,
+ dump_config = dumpConfig or false,
+ })
+ :next(function()
+ log:debug("Subscribe logs message sent successfully")
+ d:resolve(nil)
+ end, function(err)
+ if IsEmpty(err) or type(err) ~= "string" then
+ err = "unknown error"
+ end
+ log:error("Failed to send subscribe logs message: %s", err)
+ d:reject(err)
+ end)
+
+ return d
+end
+
+--- @class BluetoothConnectionResult
+--- @field connected boolean Whether the device is connected
+--- @field mtu number|nil The MTU if connected
+--- @field error number|nil Error code if failed
+
+--- Connect to a Bluetooth device via the ESPHome proxy.
+--- ESPHome sends multiple responses: intermediate (connected=nil), then final (connected=true/false).
+--- @param mac string MAC address in format "AA:BB:CC:DD:EE:FF".
+--- @param addressType? BLEAddressType The address type (optional).
+--- @param withCache? boolean Use cached services (default true).
+--- @return Deferred result A promise that resolves when connected or rejects on failure.
+function ESPHomeClient:bluetoothDeviceConnect(mac, addressType, withCache)
+ log:trace("ESPHomeClient:bluetoothDeviceConnect(%s)", mac)
+
+ local address = BLEAddress.fromString(mac)
+ local d = deferred.new()
+
+ -- Register callback for connection responses
+ local callbackKey = self:_registerCallback(
+ self:_makeBluetoothCallbackKey(ESPHomeProtoSchema.Message.BluetoothDeviceConnectionResponse, address),
+ function(message)
+ --- @cast message ProtoBluetoothDeviceConnectionResponse
+ log:debug(
+ "Bluetooth device connection response for %s: connected=%s, mtu=%s, error=%s",
+ mac,
+ message.connected,
+ message.mtu,
+ message.error
+ )
+
+ -- ESPHome sends multiple responses:
+ -- - Intermediate: connected=nil (connection in progress)
+ -- - Final: connected=true (success) or connected=false with error (failure)
+ if message.connected == false or message.error ~= nil then
+ d:reject(string.format("Connection failed with code %s", message.error or -1))
+ elseif message.connected == true then
+ d:resolve({ connected = true, mtu = message.mtu })
+ end
+ -- Ignore intermediate responses (connected=nil)
+ end,
+ 30 * ONE_SECOND, -- timeout for BLE connections
+ function()
+ d:reject("Connection timeout")
+ end
+ )
+
+ local requestType = (withCache == false)
+ and ESPHomeProtoSchema.Enum.BluetoothDeviceRequestType.BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE
+ or ESPHomeProtoSchema.Enum.BluetoothDeviceRequestType.BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITH_CACHE
+
+ --- @type ProtoBluetoothDeviceRequest
+ local body = {
+ address = address,
+ request_type = requestType,
+ }
+
+ if addressType ~= nil then
+ --- @cast addressType number
+ body.has_address_type = true
+ body.address_type = addressType
+ end
+
+ self:callServiceMethod(ESPHomeProtoSchema.RPC.APIConnection.bluetooth_device_request, body):next(nil, function(err)
+ d:reject(err or "Failed to send connection request")
+ end)
+
+ return d:next(function(message)
+ self:_unregisterCallback(callbackKey)
+ return message
+ end, function(err)
+ self:_unregisterCallback(callbackKey)
+ return reject(err)
+ end)
+end
+
+--- Disconnect from a Bluetooth device.
+--- @param mac string MAC address in format "AA:BB:CC:DD:EE:FF".
+--- @return Deferred result A promise that resolves when the disconnect request is sent.
+function ESPHomeClient:bluetoothDeviceDisconnect(mac)
+ log:trace("ESPHomeClient:bluetoothDeviceDisconnect(%s)", mac)
+
+ local address = BLEAddress.fromString(mac)
+ return self:callServiceMethod(ESPHomeProtoSchema.RPC.APIConnection.bluetooth_device_request, {
+ address = address,
+ request_type = ESPHomeProtoSchema.Enum.BluetoothDeviceRequestType.BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT,
+ })
+end
+
+--- Get GATT services for a Bluetooth device.
+--- Auto-connects if the device is not already connected.
+--- @param mac string MAC address in format "AA:BB:CC:DD:EE:FF".
+--- @param addressType? BLEAddressType The address type for auto-connect (default: 0 = PUBLIC).
+--- @return Deferred result A promise that resolves with all services or rejects with error.
+function ESPHomeClient:bluetoothGattGetServices(mac, addressType)
+ log:trace("ESPHomeClient:bluetoothGattGetServices(%s)", mac)
+
+ -- Ensure device is connected before GATT operation
+ return self:_ensureBleConnected(mac, addressType):next(function()
+ return self:_bluetoothGattGetServicesInternal(mac)
+ end)
+end
+
+--- Internal implementation of GATT service discovery (assumes device is connected).
+--- @private
+--- @param mac string MAC address in format "AA:BB:CC:DD:EE:FF".
+--- @return Deferred result A promise that resolves with all services or rejects with error.
+function ESPHomeClient:_bluetoothGattGetServicesInternal(mac)
+ local address = BLEAddress.fromString(mac)
+ --- @type Deferred
+ local d = deferred.new()
+
+ --- @type string[]
+ local callbackKeys = {}
+
+ -- Accumulate services from multiple responses
+ --- @type ProtoBluetoothGATTService[]
+ local allServices = {}
+
+ table.insert(
+ callbackKeys,
+ self:_registerCallback(
+ self:_makeBluetoothCallbackKey(ESPHomeProtoSchema.Message.BluetoothGATTGetServicesResponse, address),
+ function(message)
+ --- @cast message ProtoBluetoothGATTGetServicesResponse
+ local services = message.services or {}
+ log:debug("Bluetooth GATT services response for %s: %d services", mac, #services)
+ for _, service in ipairs(services) do
+ table.insert(allServices, service)
+ end
+ end
+ )
+ )
+
+ table.insert(
+ callbackKeys,
+ self:_registerCallback(
+ self:_makeBluetoothCallbackKey(ESPHomeProtoSchema.Message.BluetoothGATTGetServicesDoneResponse, address),
+ function(message)
+ --- @cast message ProtoBluetoothGATTGetServicesDoneResponse
+ log:debug("Bluetooth GATT service discovery done for %s: %d total services", mac, #allServices)
+ d:resolve(allServices)
+ end,
+ 30 * ONE_SECOND,
+ function()
+ d:reject("GATT service discovery timeout")
+ end
+ )
+ )
+
+ table.insert(
+ callbackKeys,
+ self:_registerCallback(
+ self:_makeBluetoothCallbackKey(ESPHomeProtoSchema.Message.BluetoothGATTErrorResponse, address),
+ function(message)
+ --- @cast message ProtoBluetoothGATTErrorResponse
+ log:warn("Bluetooth GATT error for %s: error=%s", mac, message.error)
+ d:reject(string.format("Getting GATT services failed with code %s", message.error or -1))
+ end
+ )
+ )
+
+ self
+ :callServiceMethod(ESPHomeProtoSchema.RPC.APIConnection.bluetooth_gatt_get_services, { address = address })
+ :next(nil, function(err)
+ d:reject(err)
+ end)
+
+ return d:next(function(message)
+ self:_unregisterCallbacks(callbackKeys)
+ return message
+ end, function(err)
+ self:_unregisterCallbacks(callbackKeys)
+ return reject(err)
+ end)
+end
+
+--- Read a GATT characteristic.
+--- Auto-connects if the device is not already connected.
+--- @param mac string MAC address in format "AA:BB:CC:DD:EE:FF".
+--- @param handle number The characteristic handle.
+--- @param addressType? BLEAddressType The address type for auto-connect (default: 0 = PUBLIC).
+--- @return Deferred result A promise that resolves with data or rejects with GATT error code.
+function ESPHomeClient:bluetoothGattRead(mac, handle, addressType)
+ log:trace("ESPHomeClient:bluetoothGattRead(%s, %s)", mac, handle)
+
+ -- Ensure device is connected before GATT operation
+ return self:_ensureBleConnected(mac, addressType):next(function()
+ return self:_bluetoothGattReadInternal(mac, handle)
+ end)
+end
+
+--- Internal implementation of GATT read (assumes device is connected).
+--- @private
+--- @param mac string MAC address in format "AA:BB:CC:DD:EE:FF".
+--- @param handle number The characteristic handle.
+--- @return Deferred result A promise that resolves with data or rejects with GATT error code.
+function ESPHomeClient:_bluetoothGattReadInternal(mac, handle)
+ local address = BLEAddress.fromString(mac)
+ --- @type Deferred
+ local d = deferred.new()
+
+ --- @type string[]
+ local callbackKeys = {}
+
+ table.insert(
+ callbackKeys,
+ self:_registerCallback(
+ self:_makeGattCallbackKey(ESPHomeProtoSchema.Message.BluetoothGATTReadResponse, address, handle),
+ function(message)
+ --- @cast message ProtoBluetoothGATTReadResponse
+ log:debug("Bluetooth GATT read response for %s handle %s: %d bytes", mac, handle, #(message.data or ""))
+ d:resolve(message.data or "")
+ end,
+ 10 * ONE_SECOND,
+ function()
+ d:reject("GATT read timeout")
+ end
+ )
+ )
+
+ table.insert(
+ callbackKeys,
+ self:_registerCallback(
+ self:_makeGattCallbackKey(ESPHomeProtoSchema.Message.BluetoothGATTErrorResponse, address, handle),
+ function(message)
+ --- @cast message ProtoBluetoothGATTErrorResponse
+ log:warn("Bluetooth GATT error for %s handle %s: error=%s", mac, handle, message.error)
+ d:reject(string.format("GATT read failed with code %s", message.error or -1))
+ end
+ )
+ )
+
+ self
+ :callServiceMethod(ESPHomeProtoSchema.RPC.APIConnection.bluetooth_gatt_read, { address = address, handle = handle })
+ :next(nil, function(err)
+ -- Service method failed (e.g., not connected)
+ d:reject(err)
+ end)
+
+ return d:next(function(message)
+ self:_unregisterCallbacks(callbackKeys)
+ return message
+ end, function(err)
+ self:_unregisterCallbacks(callbackKeys)
+ return reject(err)
+ end)
+end
+
+--- Write to a GATT characteristic.
+--- Auto-connects if the device is not already connected.
+--- @param mac string MAC address in format "AA:BB:CC:DD:EE:FF".
+--- @param handle number The characteristic handle.
+--- @param data string The data to write (binary string).
+--- @param response? boolean Whether to wait for a write response (default false).
+--- @param addressType? BLEAddressType The address type for auto-connect (default: 0 = PUBLIC).
+--- @return Deferred result A promise that resolves on success or rejects with GATT error code.
+function ESPHomeClient:bluetoothGattWrite(mac, handle, data, response, addressType)
+ log:trace("ESPHomeClient:bluetoothGattWrite(%s, %s, %d bytes, response=%s)", mac, handle, #data, response)
+
+ -- Ensure device is connected before GATT operation
+ return self:_ensureBleConnected(mac, addressType):next(function()
+ return self:_bluetoothGattWriteInternal(mac, handle, data, response)
+ end)
+end
+
+--- Internal implementation of GATT write (assumes device is connected).
+--- @private
+--- @param mac string MAC address in format "AA:BB:CC:DD:EE:FF".
+--- @param handle number The characteristic handle.
+--- @param data string The data to write (binary string).
+--- @param response? boolean Whether to wait for a write response (default false).
+--- @return Deferred result A promise that resolves on success or rejects with GATT error code.
+function ESPHomeClient:_bluetoothGattWriteInternal(mac, handle, data, response)
+ local address = BLEAddress.fromString(mac)
+ --- @type Deferred
+ local d = deferred.new()
+
+ --- @type string[]
+ local callbackKeys = {}
+
+ if response then
+ table.insert(
+ callbackKeys,
+ self:_registerCallback(
+ self:_makeGattCallbackKey(ESPHomeProtoSchema.Message.BluetoothGATTWriteResponse, address, handle),
+ function(message)
+ --- @cast message ProtoBluetoothGATTWriteResponse
+ log:debug("Bluetooth GATT write response for %s handle %s", mac, handle)
+ d:resolve(nil)
+ end,
+ 10 * ONE_SECOND,
+ function()
+ d:reject("GATT write timeout")
+ end
+ )
+ )
+
+ table.insert(
+ callbackKeys,
+ self:_registerCallback(
+ self:_makeGattCallbackKey(ESPHomeProtoSchema.Message.BluetoothGATTErrorResponse, address, handle),
+ function(message)
+ --- @cast message ProtoBluetoothGATTErrorResponse
+ log:warn("Bluetooth GATT write error for %s handle %s: error=%s", mac, handle, message.error)
+ d:reject(string.format("GATT write failed with code %s", message.error or -1))
+ end
+ )
+ )
+ end
+
+ self
+ :callServiceMethod(ESPHomeProtoSchema.RPC.APIConnection.bluetooth_gatt_write, {
+ address = address,
+ handle = handle,
+ response = response or false,
+ data = data,
+ })
+ :next(function()
+ -- For no-response mode, resolve immediately on success, otherwise wait for callback
+ if not response then
+ d:resolve(nil)
+ end
+ end, function(err)
+ d:reject(err)
+ end)
+
+ return d:next(function(message)
+ self:_unregisterCallbacks(callbackKeys)
+ return message
+ end, function(err)
+ self:_unregisterCallbacks(callbackKeys)
+ return reject(err)
+ end)
+end
+
+--- Write to a GATT descriptor and wait for the firmware's write response.
+--- Used to write the Client Characteristic Configuration Descriptor (CCCD) for enabling
+--- notifications or indications on V3 BLE connections where the ESP firmware does not
+--- auto-write the CCCD.
+--- @param mac string MAC address in format "AA:BB:CC:DD:EE:FF".
+--- @param handle number The descriptor handle.
+--- @param data string The data to write (binary string).
+--- @param addressType? BLEAddressType The address type for auto-connect (default: 0 = PUBLIC).
+--- @return Deferred result A promise that resolves when the write completes or rejects with GATT error.
+function ESPHomeClient:bluetoothGattWriteDescriptor(mac, handle, data, addressType)
+ log:trace("ESPHomeClient:bluetoothGattWriteDescriptor(%s, %s, %d bytes)", mac, handle, #data)
+
+ return self:_ensureBleConnected(mac, addressType):next(function()
+ local address = BLEAddress.fromString(mac)
+ --- @type Deferred
+ local d = deferred.new()
+
+ --- @type string[]
+ local callbackKeys = {}
+
+ -- The firmware sends BluetoothGATTWriteResponse for descriptor writes
+ -- (same response type as characteristic writes).
+ table.insert(
+ callbackKeys,
+ self:_registerCallback(
+ self:_makeGattCallbackKey(ESPHomeProtoSchema.Message.BluetoothGATTWriteResponse, address, handle),
+ function()
+ log:debug("Bluetooth GATT descriptor write response for %s handle %s", mac, handle)
+ d:resolve(nil)
+ end,
+ 10 * ONE_SECOND,
+ function()
+ d:reject("GATT descriptor write timeout")
+ end
+ )
+ )
+
+ table.insert(
+ callbackKeys,
+ self:_registerCallback(
+ self:_makeGattCallbackKey(ESPHomeProtoSchema.Message.BluetoothGATTErrorResponse, address, handle),
+ function(message)
+ --- @cast message ProtoBluetoothGATTErrorResponse
+ log:warn("Bluetooth GATT descriptor write error for %s handle %s: error=%s", mac, handle, message.error)
+ d:reject(string.format("GATT descriptor write failed with code %s", message.error or -1))
+ end
+ )
+ )
+
+ self
+ :callServiceMethod(ESPHomeProtoSchema.RPC.APIConnection.bluetooth_gatt_write_descriptor, {
+ address = address,
+ handle = handle,
+ data = data,
+ })
+ :next(nil, function(err)
+ d:reject(err)
+ end)
+
+ return d:next(function(message)
+ self:_unregisterCallbacks(callbackKeys)
+ return message
+ end, function(err)
+ self:_unregisterCallbacks(callbackKeys)
+ return reject(err)
+ end)
+ end)
+end
+
+--- Subscribe to GATT characteristic notifications.
+--- Auto-connects if the device is not already connected.
+--- @param mac string MAC address in format "AA:BB:CC:DD:EE:FF".
+--- @param handle number The characteristic handle.
+--- @param enable boolean Enable or disable notifications.
+--- @param callback? fun(data: string) The callback for notification data (required when enable=true).
+--- @param addressType? BLEAddressType The address type for auto-connect (default: 0 = PUBLIC).
+--- @return Deferred result A promise that resolves when subscription is confirmed or rejects with GATT error.
+function ESPHomeClient:bluetoothGattNotify(mac, handle, enable, callback, addressType)
+ log:trace("ESPHomeClient:bluetoothGattNotify(%s, %s, %s)", mac, handle, enable)
+
+ -- Ensure device is connected before GATT operation
+ return self:_ensureBleConnected(mac, addressType):next(function()
+ return self:_bluetoothGattNotifyInternal(mac, handle, enable, callback)
+ end)
+end
+
+--- Internal implementation of GATT notify subscription (assumes device is connected).
+--- @private
+--- @param mac string MAC address in format "AA:BB:CC:DD:EE:FF".
+--- @param handle number The characteristic handle.
+--- @param enable boolean Enable or disable notifications.
+--- @param callback? fun(data: string) The callback for notification data (required when enable=true).
+--- @return Deferred result A promise that resolves when subscription is confirmed or rejects with GATT error.
+function ESPHomeClient:_bluetoothGattNotifyInternal(mac, handle, enable, callback)
+ local address = BLEAddress.fromString(mac)
+ --- @type Deferred
+ local d = deferred.new()
+
+ --- @type string[]
+ local confirmCallbackKeys = {}
+ local notifyCallbackKey =
+ self:_makeGattCallbackKey(ESPHomeProtoSchema.Message.BluetoothGATTNotifyDataResponse, address, handle)
+
+ if enable then
+ -- Register persistent callback for notification data
+ if callback then
+ self:_registerCallback(notifyCallbackKey, function(message)
+ --- @cast message ProtoBluetoothGATTNotifyDataResponse
+ log:debug("Bluetooth GATT notify data for %s handle %s: %d bytes", mac, handle, #(message.data or ""))
+ local callbackSuccess, err = pcall(callback, message.data or "")
+ if not callbackSuccess then
+ log:error("Bluetooth GATT notify callback for %s handle %s failed: %s", mac, handle, err or "unknown error")
+ end
+ end)
+ end
+
+ -- Register one-time confirmation callback
+ table.insert(
+ confirmCallbackKeys,
+ self:_registerCallback(
+ self:_makeGattCallbackKey(ESPHomeProtoSchema.Message.BluetoothGATTNotifyResponse, address, handle),
+ function(message)
+ --- @cast message ProtoBluetoothGATTNotifyResponse
+ log:debug("Bluetooth GATT notify subscription confirmed for %s handle %s", mac, handle)
+ d:resolve(nil)
+ end,
+ 10 * ONE_SECOND,
+ function()
+ d:reject("GATT notify subscription timeout")
+ end
+ )
+ )
+
+ -- Register error callback
+ table.insert(
+ confirmCallbackKeys,
+ self:_registerCallback(
+ self:_makeGattCallbackKey(ESPHomeProtoSchema.Message.BluetoothGATTErrorResponse, address, handle),
+ function(message)
+ --- @cast message ProtoBluetoothGATTErrorResponse
+ log:warn("Bluetooth GATT notify error for %s handle %s: error=%s", mac, handle, message.error)
+ d:reject(string.format("GATT notify failed with code %s", message.error or -1))
+ end
+ )
+ )
+ else
+ -- Unsubscribe - clear the data callback
+ self:_unregisterCallback(notifyCallbackKey)
+
+ -- Register one-time confirmation callback for unsubscribe
+ table.insert(
+ confirmCallbackKeys,
+ self:_registerCallback(
+ self:_makeGattCallbackKey(ESPHomeProtoSchema.Message.BluetoothGATTNotifyResponse, address, handle),
+ function(message)
+ --- @cast message ProtoBluetoothGATTNotifyResponse
+ log:debug("Bluetooth GATT notify unsubscribe confirmed for %s handle %s", mac, handle)
+ d:resolve(nil)
+ end,
+ 10 * ONE_SECOND,
+ function()
+ d:reject("GATT notify unsubscription timeout")
+ end
+ )
+ )
+
+ -- Register error callback
+ table.insert(
+ confirmCallbackKeys,
+ self:_registerCallback(
+ self:_makeGattCallbackKey(ESPHomeProtoSchema.Message.BluetoothGATTErrorResponse, address, handle),
+ function(message)
+ --- @cast message ProtoBluetoothGATTErrorResponse
+ log:warn("Bluetooth GATT notify unsubscribe error for %s handle %s: error=%s", mac, handle, message.error)
+ d:reject(string.format("GATT notify failed with code %s", message.error or -1))
+ end
+ )
+ )
+ end
+
+ self
+ :callServiceMethod(ESPHomeProtoSchema.RPC.APIConnection.bluetooth_gatt_notify, {
+ address = address,
+ handle = handle,
+ enable = enable,
+ })
+ :next(nil, function(err)
+ d:reject(err)
+ end)
+
+ return d:next(function(message)
+ self:_unregisterCallbacks(confirmCallbackKeys)
+ return message
+ end, function(err)
+ self:_unregisterCallbacks(confirmCallbackKeys)
+ self:_unregisterCallback(notifyCallbackKey)
+ return reject(err)
+ end)
+end
+
+--- Subscribe to Bluetooth connection slot updates.
+--- This tells us how many BLE connection slots are available/in use.
+--- Updates cached state and notifies all registered callbacks.
+--- Use addBluetoothConnectionsCallback() to register for updates.
+--- @return Deferred result A promise that resolves when subscribed.
+function ESPHomeClient:subscribeBluetoothConnectionsFree()
+ log:trace("ESPHomeClient:subscribeBluetoothConnectionsFree()")
+
+ --- @type Deferred
+ local d = deferred.new()
+
+ -- Timeout for initial response (callback persists for ongoing updates)
+ local initialResponseTimer = C4:SetTimer(10 * ONE_SECOND, function()
+ d:reject("Bluetooth connections subscription timeout")
+ end)
+
+ self:_registerCallback(
+ self:_makeMessageCallbackKey(ESPHomeProtoSchema.Message.BluetoothConnectionsFreeResponse),
+ function(message)
+ --- @cast message ProtoBluetoothConnectionsFreeResponse
+ local free = message.free or 0
+ local limit = message.limit or 0
+ local allocated = message.allocated or {}
+
+ log:trace("Bluetooth connections free: %d/%d (connected devices: %d)", free, limit, #allocated)
+
+ -- Convert uint64 addresses to MAC strings
+ local allocatedMacs = {}
+ for i, addr in ipairs(allocated) do
+ local mac = BLEAddress.toString(addr) or "INVALID ADDRESS"
+ table.insert(allocatedMacs, mac)
+ log:trace(" Allocated slot %d: %s", i, mac)
+ end
+
+ -- Update cached state
+ self._btConnections = {
+ free = free,
+ limit = limit,
+ allocated = allocatedMacs,
+ initialized = true,
+ }
+
+ log:trace(
+ "Bluetooth connections updated: %d/%d free, %d allocated: %s",
+ free,
+ limit,
+ #allocatedMacs,
+ table.concat(allocatedMacs, ", ")
+ )
+
+ -- Notify all registered callbacks
+ for callbackId, callback in pairs(self._btConnectionsCallbacks) do
+ local callbackSuccess, err = pcall(callback, self._btConnections)
+ if not callbackSuccess then
+ log:error("Bluetooth connections callback '%s' failed: %s", callbackId, err or "unknown error")
+ end
+ end
+
+ -- Resolve the deferred only after we have received our first update
+ d:resolve(nil)
+ end
+ )
+
+ -- Send subscription request
+ self:sendMessage(ESPHomeProtoSchema.RPC.APIConnection.subscribe_bluetooth_connections_free.inputType):next(function()
+ initialResponseTimer:Cancel()
+ end, function(err)
+ initialResponseTimer:Cancel()
+ d:reject(err)
+ end)
+
+ return d
+end
+
+--- Initialize Bluetooth proxy functionality.
+--- Subscribes to BLE advertisements and connection slot updates.
+--- CRITICAL: The advertisement subscription establishes api_connection_ in ESPHome's
+--- bluetooth_proxy. Without this, the proxy's loop() treats BLE connections as orphaned
+--- and disconnects them. This subscription must remain active for BLE device connections.
+--- Safe to call multiple times - only subscribes once.
+--- @return Deferred result A promise that resolves when subscription is set up.
+function ESPHomeClient:initBluetoothProxy()
+ log:trace("ESPHomeClient:initBluetoothProxy()")
+
+ -- Already fully initialized (real data received from subscription)
+ if self._btConnections.initialized then
+ log:debug("Bluetooth proxy already initialized")
+ return deferred.new():resolve(nil)
+ end
+
+ -- Initialization already in flight - return existing deferred
+ if self._btProxyInitDeferred then
+ log:debug("Bluetooth proxy initialization already in progress")
+ return self._btProxyInitDeferred
+ end
+
+ -- Register callback for scanner state updates
+ -- ESPHome sends these when the scanner state changes (running, stopped, etc.)
+ self:_registerCallback(
+ self:_makeMessageCallbackKey(ESPHomeProtoSchema.Message.BluetoothScannerStateResponse),
+ function(message)
+ --- @cast message ProtoBluetoothScannerStateResponse
+ log:debug(
+ "Received BluetoothScannerStateResponse: state=%s, mode=%s, configured_mode=%s",
+ message.state,
+ message.mode,
+ message.configured_mode
+ )
+
+ --- @type BluetoothScannerState
+ self._btScannerState = {
+ state = message.state or ESPHomeProtoSchema.Enum.BluetoothScannerState.BLUETOOTH_SCANNER_STATE_IDLE,
+ mode = message.mode or ESPHomeProtoSchema.Enum.BluetoothScannerMode.BLUETOOTH_SCANNER_MODE_PASSIVE,
+ initialized = true,
+ }
+
+ -- Notify all registered callbacks
+ for callbackId, callback in pairs(self._btScannerStateCallbacks) do
+ local callbackSuccess, err = pcall(callback, self._btScannerState)
+ if not callbackSuccess then
+ log:error("Bluetooth scanner state callback '%s' failed: %s", callbackId, err or "unknown error")
+ end
+ end
+ end
+ )
+
+ -- Subscribe to BLE advertisements to establish api_connection_ in ESPHome's bluetooth_proxy.
+ -- CRITICAL: Without this subscription, the proxy's loop() treats BLE connections as orphaned
+ -- and disconnects them. This subscription must remain active for BLE device connections.
+ -- See: https://github.com/esphome/esphome/blob/dev/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp
+
+ -- Helper to process a parsed advertisement and dispatch to all registered callbacks
+ --- @param advertisement BLEAdvertisement
+ local function processAdvertisement(advertisement)
+ --log:trace("BLE advertisement: %s", BLEAdvertisementParser.toString(advertisement))
+
+ -- Dispatch to all registered callbacks
+ for callbackId, callback in pairs(self._btAdvertisementsCallbacks) do
+ local success, err = pcall(callback, advertisement)
+ if not success then
+ log:error("Bluetooth advertisement callback '%s' failed: %s", callbackId, err or "unknown error")
+ end
+ end
+ end
+
+ -- Register callback for decoded advertisement responses (older ESPHome format)
+ self:_registerCallback(
+ self:_makeMessageCallbackKey(ESPHomeProtoSchema.Message.BluetoothLEAdvertisementResponse),
+ function(message)
+ --- @cast message ProtoBluetoothLEAdvertisementResponse
+ local advertisement = BLEAdvertisementParser.parse(message)
+ if not advertisement then
+ log:warn("Invalid BLE advertisement: %s", message)
+ return
+ end
+ processAdvertisement(advertisement)
+ end
+ )
+
+ -- Register callback for raw advertisement responses (modern ESPHome format)
+ self:_registerCallback(
+ self:_makeMessageCallbackKey(ESPHomeProtoSchema.Message.BluetoothLERawAdvertisementsResponse),
+ function(message)
+ --- @cast message ProtoBluetoothLERawAdvertisementsResponse
+ for _, rawAdvertisement in ipairs(message.advertisements or {}) do
+ local advertisement = BLEAdvertisementParser.parseRaw(rawAdvertisement)
+ if not advertisement then
+ log:warn("Invalid raw BLE advertisement packet: %s", rawAdvertisement)
+ return
+ end
+ processAdvertisement(advertisement)
+ end
+ end
+ )
+
+ -- Chain all subscription operations so the returned deferred
+ -- only resolves after everything is established.
+ -- Store the deferred as re-entrancy guard: concurrent calls return the same
+ -- deferred, and on failure we clear it so the next call can retry.
+
+ -- Step 1: Subscribe to BLE advertisements
+ self._btProxyInitDeferred = self
+ :callServiceMethod(ESPHomeProtoSchema.RPC.APIConnection.subscribe_bluetooth_le_advertisements)
+ :next(function()
+ log:debug("BLE advertisement subscription established")
+
+ -- Step 2: Set scanner mode to active (required for BTHome devices that include
+ -- service data in scan responses rather than advertising packets)
+ return self:setBluetoothScannerMode(true)
+ end)
+ :next(function()
+ log:debug("Scanner mode set to active")
+
+ -- Step 3: Subscribe to connection slot updates
+ return self:subscribeBluetoothConnectionsFree()
+ end)
+ :next(function()
+ -- Success: _btConnections.initialized is now true (set by subscription callback).
+ -- Clear the in-flight deferred; future calls will see initialized=true and short-circuit.
+ self._btProxyInitDeferred = nil
+ end, function(err)
+ -- Failed: clear the in-flight deferred so the next call can retry
+ log:warn("Bluetooth proxy initialization failed, will retry on next attempt: %s", err or "unknown")
+ self._btProxyInitDeferred = nil
+ return deferred.new():reject(err)
+ end)
+
+ return self._btProxyInitDeferred
+end
+
+--- Get the current Bluetooth connection state (cached).
+--- @return BluetoothConnectionState state The cached connection state.
+function ESPHomeClient:getBluetoothConnectionState()
+ log:trace("ESPHomeClient:getBluetoothConnectionState()")
+ return self._btConnections
+end
+
+--- Check if a Bluetooth device is currently allocated (connected via proxy).
+--- "Allocated" means the device has an active BLE connection and is using one of the
+--- limited connection slots (typically 3-4 on ESP32).
+--- @param mac string? MAC address
+--- @return boolean isAllocated True if the device is currently connected.
+function ESPHomeClient:isBluetoothDeviceAllocated(mac)
+ log:trace("ESPHomeClient:isBluetoothDeviceAllocated(%s)", mac)
+ if not mac then
+ return false
+ end
+ for _, allocatedMac in ipairs(self._btConnections.allocated) do
+ if allocatedMac == mac then
+ return true
+ end
+ end
+ return false
+end
+
+--- Ensure a BLE device is connected before performing GATT operations.
+--- If the device already has an active connection (allocated slot), resolves immediately.
+--- Otherwise, initiates a new BLE connection first. This enables on-demand connection for GATT
+--- operations.
+--- @private
+--- @param mac string MAC address in format "AA:BB:CC:DD:EE:FF".
+--- @param addressType? BLEAddressType The address type (default: 0 = PUBLIC).
+--- @return Deferred result A promise that resolves when connected.
+function ESPHomeClient:_ensureBleConnected(mac, addressType)
+ log:trace("ESPHomeClient:_ensureBleConnected(%s)", mac)
+
+ -- Check if device already has an active connection slot
+ if self:isBluetoothDeviceAllocated(mac) then
+ log:debug("BLE device %s already connected", mac)
+ return deferred.new():resolve(nil)
+ end
+
+ -- Device not connected - initiate connection
+ log:info("BLE device %s not connected, auto-connecting for GATT operation", mac)
+ return self:bluetoothDeviceConnect(mac, addressType or BLEAddress.Type.PUBLIC, true):next(function()
+ log:debug("BLE auto-connect successful for %s", mac)
+ end)
+end
+
+--- Register a callback for Bluetooth connection state changes.
+--- If state is already available, the callback is fired immediately with current state.
+--- @param callbackId string Unique identifier for this callback (used for unregistering).
+--- @param callback fun(state: BluetoothConnectionState) The callback function.
+function ESPHomeClient:addBluetoothConnectionsCallback(callbackId, callback)
+ log:trace("ESPHomeClient:addBluetoothConnectionsCallback(%s)", callbackId)
+ self._btConnectionsCallbacks[callbackId] = callback
+
+ -- Fire callback immediately if we already have state
+ if self._btConnections.initialized then
+ local success, err = pcall(callback, self._btConnections)
+ if not success then
+ log:error("Bluetooth connections callback '%s' failed: %s", callbackId, err or "unknown error")
+ end
+ end
+end
+
+--- Unregister a Bluetooth connection state change callback.
+--- @param callbackId string The callback identifier to remove.
+function ESPHomeClient:removeBluetoothConnectionsCallback(callbackId)
+ log:trace("ESPHomeClient:removeBluetoothConnectionsCallback(%s)", callbackId)
+ self._btConnectionsCallbacks[callbackId] = nil
+end
+
+--- Get the current Bluetooth scanner state (cached).
+--- @return BluetoothScannerState state The cached scanner state { state, mode, initialized }.
+function ESPHomeClient:getBluetoothScannerState()
+ log:trace("ESPHomeClient:getBluetoothScannerState()")
+ return self._btScannerState
+end
+
+--- Register a callback for Bluetooth scanner state changes.
+--- If state is already available, the callback is fired immediately with current state.
+--- @param callbackId string Unique identifier for this callback (used for unregistering).
+--- @param callback fun(state: BluetoothScannerState) The callback function.
+function ESPHomeClient:addBluetoothScannerStateCallback(callbackId, callback)
+ log:trace("ESPHomeClient:addBluetoothScannerStateCallback(%s)", callbackId)
+ self._btScannerStateCallbacks[callbackId] = callback
+
+ -- Fire callback immediately if we already have state
+ if self._btScannerState.initialized then
+ local success, err = pcall(callback, self._btScannerState)
+ if not success then
+ log:error("Bluetooth scanner state callback '%s' failed: %s", callbackId, err or "unknown error")
+ end
+ end
+end
+
+--- Unregister a Bluetooth scanner state change callback.
+--- @param callbackId string The callback identifier to remove.
+function ESPHomeClient:removeBluetoothScannerStateCallback(callbackId)
+ log:trace("ESPHomeClient:removeBluetoothScannerStateCallback(%s)", callbackId)
+ self._btScannerStateCallbacks[callbackId] = nil
+end
+
+--- Register a callback for BLE advertisement notifications.
+--- Advertisements are received after initBluetoothProxy() is called.
+--- @param callbackId string Unique identifier for this callback.
+--- @param callback fun(advertisement: BLEAdvertisement) The callback function.
+function ESPHomeClient:addBluetoothAdvertisementCallback(callbackId, callback)
+ log:trace("ESPHomeClient:addBluetoothAdvertisementCallback(%s)", callbackId)
+ self._btAdvertisementsCallbacks[callbackId] = callback
+end
+
+--- Unregister a BLE advertisement callback.
+--- @param callbackId string The callback identifier to remove.
+function ESPHomeClient:removeBluetoothAdvertisementCallback(callbackId)
+ log:trace("ESPHomeClient:removeBluetoothAdvertisementCallback(%s)", callbackId)
+ self._btAdvertisementsCallbacks[callbackId] = nil
+end
+
+--- Set the Bluetooth scanner mode.
+--- @param active boolean True for active scanning, false for passive scanning.
+--- @return Deferred result A promise that resolves when mode is set.
+function ESPHomeClient:setBluetoothScannerMode(active)
+ log:trace("ESPHomeClient:setBluetoothScannerMode(%s)", active)
+
+ local mode = active and ESPHomeProtoSchema.Enum.BluetoothScannerMode.BLUETOOTH_SCANNER_MODE_ACTIVE
+ or ESPHomeProtoSchema.Enum.BluetoothScannerMode.BLUETOOTH_SCANNER_MODE_PASSIVE
+
+ return self:callServiceMethod(ESPHomeProtoSchema.RPC.APIConnection.bluetooth_scanner_set_mode, {
+ mode = mode,
+ })
+end
+
--- Send a hello message to the ESPHome device.
---- @return Deferred result A promise that resolves when the hello message is sent.
+--- @return Deferred result A promise that resolves when the hello message is sent.
function ESPHomeClient:sendHello()
log:trace("ESPHomeClient:sendHello()")
- local d = self:callServiceMethod(ESPHomeProtoSchema.RPC.APIConnection.hello, {
- client_info = "Control4",
+ local deviceId = C4:GetDeviceID()
+ return self:callServiceMethod(ESPHomeProtoSchema.RPC.APIConnection.hello, {
+ client_info = string.format(
+ "Control4 - %s (ID: %d)",
+ C4:GetDeviceData(deviceId, "name") or "Unknown Device",
+ deviceId
+ ),
api_version_major = 1,
api_version_minor = 0,
- }, false)
-
- --- @cast d Deferred
- return d
+ })
end
--- Check if the Noise protocol handshake is in the expected state.
@@ -457,7 +1525,7 @@ end
--- @return Deferred result A promise that resolves when the hello message is sent.
function ESPHomeClient:sendNoiseHello()
log:trace("ESPHomeClient:sendNoiseHello()")
- --- @type Deferred, string>
+ --- @type Deferred
local d = deferred.new()
if not self:isConnected() then
@@ -468,17 +1536,20 @@ function ESPHomeClient:sendNoiseHello()
-- Hello message
local frame = "\x01\x00\x00"
- local timeoutTimer = C4:SetTimer(ONE_SECOND * 5, function()
- -- Remove the callback for the response
- self._callbacks[NoiseProtocolCallback.HELLO] = nil
- self._hsState = NoiseState.ERROR
- d:reject("Timeout waiting for SERVER_HELLO response")
- end)
- self._callbacks[NoiseProtocolCallback.HELLO] = function(message)
- log:debug("Received SERVER_HELLO: node=%s, mac=%s", message.node, message.mac_address)
- timeoutTimer:Cancel()
- d:resolve(nil)
- end
+ self:_registerCallback(
+ NoiseProtocolCallbackKey.HELLO,
+ function(message)
+ --- @diagnostic disable-next-line: cast-type-mismatch
+ --- @cast message { node: string, mac_address: string }
+ log:debug("Received SERVER_HELLO: node=%s, mac=%s", message.node, message.mac_address)
+ d:resolve(nil)
+ end,
+ 5 * ONE_SECOND,
+ function()
+ self._hsState = NoiseState.ERROR
+ d:reject("Timeout waiting for SERVER_HELLO response")
+ end
+ )
self._hsState = NoiseState.HELLO
log:ultra("Sending CLIENT_HELLO frame (hex): %s", to_hex(frame))
@@ -512,36 +1583,40 @@ function ESPHomeClient:sendHandshake()
local frame = Indicator.NOISE .. bit16.u16_to_be_bytes(#handshake) .. handshake
- local timeoutTimer = C4:SetTimer(ONE_SECOND * 5, function()
- self:checkHandshakeState(NoiseState.HANDSHAKE)
-
- -- Remove the callback for the response
- self._callbacks[NoiseProtocolCallback.HANDSHAKE] = nil
- self._hsState = NoiseState.ERROR
- d:reject("Timeout waiting for HANDSHAKE response")
- end)
- self._callbacks[NoiseProtocolCallback.HANDSHAKE] = function(success, message)
- timeoutTimer:Cancel()
- self:checkHandshakeState(NoiseState.HANDSHAKE)
+ self:_registerCallback(
+ NoiseProtocolCallbackKey.HANDSHAKE,
+ function(message)
+ --- @diagnostic disable-next-line: cast-type-mismatch
+ --- @cast message { success: boolean, message: string? }
+ self:checkHandshakeState(NoiseState.HANDSHAKE)
+
+ if not message.success or not message.message then
+ log:error("HANDSHAKE failed: %s", message.message or "empty response")
+ self._hsState = NoiseState.ERROR
+ d:reject(message.message or "empty handshake response")
+ return
+ end
- if not success then
- log:error("HANDSHAKE failed: %s", message)
- self._hsState = NoiseState.ERROR
- return d:reject(message)
- end
+ assert(self._hs):read_handshake_message(message.message)
- assert(self._hs):read_handshake_message(message)
+ if not self._hs.handshake_complete then
+ log:error("Handshake not completed after reading handshake message")
+ self._hsState = NoiseState.ERROR
+ d:reject("Handshake not completed")
+ return
+ end
- if not self._hs.handshake_complete then
- log:error("Handshake not completed after reading handshake message")
+ log:debug("Handshake completed successfully")
+ self._hsState = NoiseState.READY
+ d:resolve(nil)
+ end,
+ 5 * ONE_SECOND,
+ function()
+ self:checkHandshakeState(NoiseState.HANDSHAKE)
self._hsState = NoiseState.ERROR
- return d:reject("Handshake not completed")
+ d:reject("Timeout waiting for HANDSHAKE response")
end
-
- log:debug("Handshake completed successfully")
- self._hsState = NoiseState.READY
- d:resolve(nil)
- end
+ )
self._hsState = NoiseState.HANDSHAKE
log:ultra("Sending HANDSHAKE frame (hex): %s", to_hex(frame))
@@ -549,66 +1624,60 @@ function ESPHomeClient:sendHandshake()
return d
end
---- Send a connect message to the ESPHome device.
---- @return Deferred result A promise that resolves when the connect response is received.
-function ESPHomeClient:sendConnect()
- log:trace("ESPHomeClient:sendConnect()")
- local d = self
- :callServiceMethod(ESPHomeProtoSchema.RPC.APIConnection.connect, {
- password = not IsEmpty(self._password) and self._password or "",
- }, false)
- :next(function(message)
- if message.invalid_password then
- log:error("Connect unsuccessful (invalid password)")
- return reject("Invalid password")
- else
- log:debug("Connect successful")
- end
- end, function(err)
- if IsEmpty(err) or type(err) ~= "string" then
- err = "unknown error"
- end
- log:error("Connect failed; %s", err)
- return reject(err)
- end)
+--- Send an authenticate message to the ESPHome device.
+--- ESPHome 2025.8.0+ devices without password authentication don't send AuthenticationResponse.
+--- Devices will either send error response (wrong password) or ignore (no password support).
+--- @return Deferred result A promise that resolves immediately after sending.
+function ESPHomeClient:sendAuthenticate()
+ log:trace("ESPHomeClient:sendAuthenticate()")
+
+ -- Register async handler for AuthenticationResponse (sets fatal error on invalid password)
+ local authKey = self:_makeMessageCallbackKey(ESPHomeProtoSchema.Message.AuthenticationResponse)
+ self:_registerCallback(authKey, function(message)
+ -- Remove callback immediately
+ self:_unregisterCallback(authKey)
+
+ if message.invalid_password then
+ log:error("Connect unsuccessful (invalid password)")
+ -- Set fatal error - subsequent operations will fail with this error
+ self._fatalError = "Invalid password"
+ self:disconnect()
+ else
+ log:debug("Connect successful")
+ end
+ end)
- --- @cast d Deferred
- return d
+ -- Send AuthenticationRequest without waiting for response
+ return self:sendMessage(
+ ESPHomeProtoSchema.Message.AuthenticationRequest,
+ { password = not IsEmpty(self._password) and self._password or "" },
+ nil, -- Don't wait for response
+ nil
+ )
end
--- Send a ping message to the ESPHome device.
--- @return Deferred result A promise that resolves when the ping response is received.
function ESPHomeClient:sendPing()
log:trace("ESPHomeClient:sendPing()")
- local d = self:callServiceMethod(ESPHomeProtoSchema.RPC.APIConnection.ping, {}, false):next(function()
+ return self:callServiceMethod(ESPHomeProtoSchema.RPC.APIConnection.ping, {}):next(function()
log:info("Ping successful")
end, function(err)
if IsEmpty(err) or type(err) ~= "string" then
err = "unknown error"
end
- log:error("Ping failed; %s", err)
+ log:error("Ping failed: %s", err)
return reject(err)
end)
-
- --- @cast d Deferred
- return d
end
--- Call a service method on the ESPHome device.
--- @param method ProtoServiceMethodSchema The method to call.
--- @param body? table The request body (optional).
---- @param authRequired? boolean Whether authentication is required to call the method (optional).
--- @param timeout? number The timeout for the request in milliseconds (optional). Only non-void methods support this. Default is 5 seconds.
---- @return Deferred result A promise that resolves with the response.
-function ESPHomeClient:callServiceMethod(method, body, authRequired, timeout)
- log:trace("ESPHomeClient:callServiceMethod(%s, %s, %s, %s)", method.method, body, authRequired, timeout)
- if authRequired == nil then
- authRequired = true
- end
-
- if not self:isConnected(authRequired) then
- return reject("Not connected to ESPHome device")
- end
+--- @return Deferred result A promise that resolves with the response.
+function ESPHomeClient:callServiceMethod(method, body, timeout)
+ log:trace("ESPHomeClient:callServiceMethod(%s, %s, %s)", method.method, body, timeout)
-- Determine if we expect a response
local responseSchema = nil
@@ -625,12 +1694,18 @@ end
--- @param body? table The message body (optional).
--- @param responseSchema? ProtoMessageSchema The expected response schema (optional).
--- @param timeout? number The timeout for the response in milliseconds (optional).
---- @return Deferred result A promise that resolves when the message is sent (and response received if expected).
+--- @return Deferred result A promise that resolves when the message is sent (and response received if expected).
function ESPHomeClient:sendMessage(messageSchema, body, responseSchema, timeout)
log:trace("ESPHomeClient:sendMessage(%s, %s, %s, %s)", messageSchema.name, body, responseSchema, timeout)
- --- @type Deferred
+ --- @type Deferred
local d = deferred.new()
+ -- Check for fatal error first (e.g., authentication failure)
+ if not IsEmpty(self._fatalError) then
+ --- @cast self._fatalError -nil
+ return d:reject(self._fatalError)
+ end
+
if not self:isConnected() then
return d:reject("Not connected to ESPHome device")
end
@@ -672,39 +1747,207 @@ function ESPHomeClient:sendMessage(messageSchema, body, responseSchema, timeout)
end
-- Store callback for response if one is expected
+ local responseKey
if responseSchema then
- local timeoutTimer = C4:SetTimer(timeout or ONE_SECOND * 5, function()
- log:warn("Timeout waiting for response to %s", messageSchema.name)
- -- Remove the callback for the response
- self._callbacks[responseSchema.options.id] = nil
- d:reject("Timeout waiting for response to " .. messageSchema.name)
- end)
- self._callbacks[responseSchema.options.id] = function(message)
- log:debug("Received response to %s", messageSchema.name)
- timeoutTimer:Cancel()
- d:resolve(message)
- end
+ responseKey = self:_registerCallback(
+ self:_makeMessageCallbackKey(responseSchema),
+ function(message)
+ log:debug("Received response to %s", messageSchema.name)
+ d:resolve(message)
+ end,
+ timeout or (5 * ONE_SECOND),
+ function()
+ d:reject("Timeout waiting for response to " .. messageSchema.name)
+ end
+ )
else
-- If no response is expected, resolve immediately after sending
d:resolve(nil)
end
log:debug("Sending message %s with %d byte(s) of data", messageSchema.name, #encodedData)
- log:ultra("Outgoing frame (hex): %s", to_hex(frame))
+ -- log:ultra("Outgoing frame (hex): %s", to_hex(frame))
self._client:Write(frame)
- return d
+
+ return d:next(function(message)
+ self:_unregisterCallback(responseKey)
+ return message
+ end, function(err)
+ self:_unregisterCallback(responseKey)
+ return reject(err)
+ end)
+end
+
+--
+-- Private Methods
+--
+
+--- Generate a callback key for a message schema.
+--- @param messageSchema ProtoMessageSchema The message schema
+--- @return CallbackKey key The generated callback key
+--- @private
+--- @diagnostic disable-next-line: unused
+function ESPHomeClient:_makeMessageCallbackKey(messageSchema)
+ local id = Select(messageSchema, "options", "id")
+ assert(id, "Message schema must have options.id")
+ return tostring(id)
+end
+
+--- Generate a callback key for a message schema and Bluetooth address.
+--- @param messageSchema ProtoMessageSchema The message schema
+--- @param address number|table The Bluetooth device address (uint64 or Int64HighLow)
+--- @return CallbackKey key The generated callback key
+--- @private
+function ESPHomeClient:_makeBluetoothCallbackKey(messageSchema, address)
+ local messageKey = self:_makeMessageCallbackKey(messageSchema)
+ local addrStr = BLEAddress.toString(address)
+ return string.format("%s_%s", messageKey, addrStr)
+end
+
+--- Generate a callback key for a message schema, Bluetooth address, and GATT handle.
+--- @param messageSchema ProtoMessageSchema The message schema
+--- @param address number|table The Bluetooth device address (uint64 or Int64HighLow)
+--- @param handle number GATT handle
+--- @return CallbackKey key The generated callback key
+--- @private
+function ESPHomeClient:_makeGattCallbackKey(messageSchema, address, handle)
+ local bluetoothKey = self:_makeBluetoothCallbackKey(messageSchema, address)
+ return string.format("%s_%d", bluetoothKey, handle)
+end
+
+--- Register a callback for a given key with optional timeout.
+--- @param key CallbackKey The callback key
+--- @param callback CallbackFunction The callback function
+--- @param timeout? number Optional timeout in milliseconds for auto-unregistration
+--- @param onTimeout? fun(): void Optional callback invoked on timeout (before unregistration)
+--- @return CallbackKey key The registered key (for later unregistration)
+--- @private
+function ESPHomeClient:_registerCallback(key, callback, timeout, onTimeout)
+ log:trace("ESPHomeClient:_registerCallback(%s, , %s)", key, timeout)
+
+ -- Cancel any existing timer for this key
+ local existing = self._callbacks[key]
+ if existing and existing.timer then
+ existing.timer:Cancel()
+ end
+
+ --- @type CallbackEntry
+ local entry = {
+ callback = callback,
+ timer = nil,
+ }
+
+ -- Set up timeout timer if specified
+ if timeout and timeout > 0 then
+ entry.timer = C4:SetTimer(timeout, function()
+ log:warn("Callback timeout for key: %s", key)
+ if onTimeout then
+ onTimeout()
+ end
+ self:_unregisterCallback(key)
+ end)
+ end
+
+ self._callbacks[key] = entry
+ log:trace("Registered callback for key: %s (timeout: %s ms)", key, timeout or "none")
+ return key
+end
+
+--- Unregister a callback by key. No-op if key is nil.
+--- @param key CallbackKey|nil The callback key to unregister
+--- @private
+function ESPHomeClient:_unregisterCallback(key)
+ log:trace("ESPHomeClient:_unregisterCallback(%s)", key)
+ if key == nil then
+ return
+ end
+
+ local entry = self._callbacks[key]
+ if entry then
+ if entry.timer then
+ entry.timer:Cancel()
+ end
+ self._callbacks[key] = nil
+ log:trace("Unregistered callback for key: %s", key)
+ end
+end
+
+--- Unregister multiple callbacks by key. Convenience wrapper around _unregisterCallback.
+--- @param keys CallbackKey[] Array of callback keys to unregister
+--- @private
+function ESPHomeClient:_unregisterCallbacks(keys)
+ for _, key in ipairs(keys) do
+ self:_unregisterCallback(key)
+ end
+end
+
+--- Find and invoke a callback for a message.
+--- Uses priority lookup: GATT > Bluetooth > Message ID (most specific wins).
+--- @param messageType integer The message type ID
+--- @param message table The decoded message
+--- @param schema ProtoMessageSchema The message schema
+--- @return boolean found True if a callback was found and invoked
+--- @private
+function ESPHomeClient:_invokeCallback(messageType, message, schema)
+ log:trace("ESPHomeClient:_invokeCallback(%s, , %s)", messageType, schema.name)
+
+ -- Try most specific first: message + address + handle
+ if message.address ~= nil and message.handle ~= nil then
+ local gattKey = self:_makeGattCallbackKey(schema, message.address, message.handle)
+ if self:_invokeCallbackByKey(gattKey, message, schema) then
+ return true
+ end
+ end
+
+ -- Try message + address
+ if message.address ~= nil then
+ local btKey = self:_makeBluetoothCallbackKey(schema, message.address)
+ if self:_invokeCallbackByKey(btKey, message, schema) then
+ return true
+ end
+ end
+
+ -- Fall back to message ID only
+ return self:_invokeCallbackByKey(self:_makeMessageCallbackKey(schema), message, schema)
+end
+
+--- Invoke a callback by key with variadic arguments.
+--- @param key CallbackKey The callback key
+--- @param ... any Arguments to pass to the callback
+--- @return boolean found True if a callback was found and invoked
+--- @private
+function ESPHomeClient:_invokeCallbackByKey(key, ...)
+ local entry = self._callbacks[key]
+
+ if entry and entry.callback then
+ log:trace("Invoking callback for key: %s", key)
+
+ -- Cancel the timeout timer if present (callback was invoked before timeout)
+ if entry.timer then
+ entry.timer:Cancel()
+ entry.timer = nil
+ end
+
+ local success, err = pcall(entry.callback, ...)
+ if not success then
+ log:error("Callback for key %s failed: %s", key, err or "unknown error")
+ end
+ return true
+ end
+
+ return false
end
--- Process the current data buffer and decodes any valid packets recursively.
--- Currently only plaintext packets are supported.
---- @return void
+--- @private
function ESPHomeClient:_processBuffer()
log:trace("ESPHomeClient:_processBuffer()")
-- We need at least 3 bytes to begin processing a frame
if self._buffer == nil or #self._buffer < 3 then
return
end
- log:ultra("Processing buffer (hex): %s", to_hex(self._buffer))
+ -- log:ultra("Processing buffer (hex): %s", to_hex(self._buffer))
-- Process the indicator
local indicator, indicatorEndPos = string.byte(self._buffer, 1), 2
@@ -725,6 +1968,16 @@ function ESPHomeClient:_processBuffer()
| Data | bytes | Variable | - | Protocol buffer payload |
+--------------+--------+-----------+----------+----------------------------+
--]]
+
+ -- Check for protocol mismatch: client expects encryption but device sent plaintext
+ if self._encryptionKey ~= nil then
+ log:error("Protocol mismatch: driver configured for encryption but device sent plaintext data")
+ log:error("Check that the ESPHome device has 'api: encryption: key:' configured in its YAML")
+ self._fatalError = "Encryption mismatch: device not configured for encryption"
+ self:disconnect()
+ return
+ end
+
-- Process the payload size and message type
local payloadSize, payloadSizeEndPos = pb.decode_varint(self._buffer, indicatorEndPos)
local messageType, messageTypeEndPos = pb.decode_varint(self._buffer, payloadSizeEndPos)
@@ -733,7 +1986,7 @@ function ESPHomeClient:_processBuffer()
local totalFrameSize = messageTypeEndPos + payloadSize - 1
if #self._buffer < totalFrameSize then
-- This can happen if the message is split across multiple tcp reads
- log:debug("Incomplete plaintext frame (%d bytes expected, %d bytes received)", totalFrameSize, #self._buffer)
+ log:trace("Incomplete plaintext frame (%d bytes expected, %d bytes received)", totalFrameSize, #self._buffer)
return
end
local payload = string.sub(self._buffer, messageTypeEndPos, totalFrameSize)
@@ -742,6 +1995,9 @@ function ESPHomeClient:_processBuffer()
-- Remove the processed data from the buffer
self._buffer = string.sub(self._buffer, payloadEndPos)
+ -- Update keepalive timestamp for each processed frame
+ self._lastDataReceived = os.time()
+
log:ultra("Plaintext frame - Message type: %d, Payload size: %d", messageType, payloadSize)
self:_processPayload(messageType, payload)
elseif indicator == string.byte(Indicator.NOISE) then
@@ -770,6 +2026,15 @@ function ESPHomeClient:_processBuffer()
0x01 2B 0x01 Variable
--]]
+ -- Check for protocol mismatch: device sent noise but client not configured for encryption
+ if self._encryptionKey == nil then
+ log:error("Protocol mismatch: device sent encrypted data but driver not configured for encryption")
+ log:error("Set Authentication Mode to 'Encryption Key' and enter the key from your ESPHome device")
+ self._fatalError = "Encryption mismatch: device requires encryption key"
+ self:disconnect()
+ return
+ end
+
local encryptedSize = bit16.be_bytes_to_u16(self._buffer:sub(indicatorEndPos, indicatorEndPos + 1))
local encryptedSizeEndPos = indicatorEndPos + 2
@@ -777,7 +2042,7 @@ function ESPHomeClient:_processBuffer()
local totalFrameSize = encryptedSizeEndPos + encryptedSize - 1
if #self._buffer < totalFrameSize then
-- This can happen if the message is split across multiple tcp reads
- log:debug("Incomplete noise frame (%d bytes expected, %d bytes received)", totalFrameSize, #self._buffer)
+ log:trace("Incomplete noise frame (%d bytes expected, %d bytes received)", totalFrameSize, #self._buffer)
return
end
@@ -785,9 +2050,21 @@ function ESPHomeClient:_processBuffer()
local encryptedPayload = string.sub(self._buffer, encryptedSizeEndPos, totalFrameSize)
local encryptedPayloadEndPos = totalFrameSize + 1
+ -- TODO: Lower logging level
+ log:debug(
+ "Noise frame: size=%d, totalFrameSize=%d, bufferLen=%d, remaining=%d",
+ encryptedSize,
+ totalFrameSize,
+ #self._buffer,
+ #self._buffer - totalFrameSize
+ )
+
-- Remove the processed data from the buffer
self._buffer = string.sub(self._buffer, encryptedPayloadEndPos)
+ -- Update keepalive timestamp for each processed frame (not just on OnRead)
+ self._lastDataReceived = os.time()
+
if self._hsState == NoiseState.HELLO then
-- SERVER_HELLO message structure
--[[
@@ -821,17 +2098,15 @@ function ESPHomeClient:_processBuffer()
return
end
-- Extract mac address
- local macAddress = string.sub(encryptedPayload, nodeNullTermPos + 1, macNullTermPos - 1)
+ local mac = string.sub(encryptedPayload, nodeNullTermPos + 1, macNullTermPos - 1)
- log:debug("SERVER_HELLO message - Node: %s, MAC: %s", nodeName, macAddress)
+ log:debug("SERVER_HELLO message - Node: %s, MAC: %s", nodeName, mac)
-- Call the callback for SERVER_HELLO if registered
- if type(self._callbacks[NoiseProtocolCallback.HELLO]) == "function" then
- self._callbacks[NoiseProtocolCallback.HELLO]({
- node = nodeName,
- mac_address = macAddress,
- })
- end
+ self:_invokeCallbackByKey(NoiseProtocolCallbackKey.HELLO, {
+ node = nodeName,
+ mac_address = mac,
+ })
elseif self._hsState == NoiseState.HANDSHAKE then
-- HANDSHAKE error message structure
--[[
@@ -851,9 +2126,10 @@ function ESPHomeClient:_processBuffer()
log:trace("HANDSHAKE message - Success: %s, Message: %s", success, to_hex(message))
-- Call the callback for HANDSHAKE if registered
- if type(self._callbacks[NoiseProtocolCallback.HANDSHAKE]) == "function" then
- self._callbacks[NoiseProtocolCallback.HANDSHAKE](success, message)
- end
+ self:_invokeCallbackByKey(NoiseProtocolCallbackKey.HANDSHAKE, {
+ success = success,
+ message = message,
+ })
elseif self._hsState == NoiseState.READY then
local ok, decryptedPayload = pcall(assert(self._hs).receive_message, self._hs, encryptedPayload)
if not ok or decryptedPayload == nil then
@@ -867,7 +2143,7 @@ function ESPHomeClient:_processBuffer()
end
--- @cast decryptedPayload string
- log:trace("READY message - %s", success, to_hex(decryptedPayload))
+ -- log:trace("READY message - %s", to_hex(decryptedPayload))
-- Extract the message type and data length from the decrypted payload
if #decryptedPayload < 4 then
@@ -891,8 +2167,11 @@ function ESPHomeClient:_processBuffer()
return
end
else
- -- Unknown indicator
- log:warn("Invalid esphome frame (unsupported indicator %02X)", indicator)
+ -- Unknown indicator - buffer is corrupted, clear it and disconnect
+ log:error("Invalid esphome frame (unsupported indicator %02X)", indicator)
+ log:error("Buffer corruption detected - first 32 bytes: %s", to_hex(self._buffer:sub(1, 32)))
+ self._fatalError = "Protocol error: corrupted frame data"
+ self:disconnect()
return
end
@@ -900,10 +2179,14 @@ function ESPHomeClient:_processBuffer()
self:_processBuffer()
end
+--- @param messageType integer
+--- @param payload string
+--- @private
function ESPHomeClient:_processPayload(messageType, payload)
log:trace("ESPHomeClient:_processPayload(%s, %d bytes)", messageType, #payload)
-- Find the message schema
+ --- @type ProtoMessageSchema|nil
local messageSchema = nil
for _, schema in pairs(ESPHomeProtoSchema.Message) do
if messageType == Select(schema, "options", "id") then
@@ -919,23 +2202,15 @@ function ESPHomeClient:_processPayload(messageType, payload)
-- Decode the payload data
local success, message = pcall(pb.decode, ESPHomeProtoSchema, messageSchema, payload)
if not success then
- log:warn("Invalid esphome frame (failed to decode message type %s): %s", messageType, message)
+ log:warn("Invalid esphome frame (failed to decode message type %s): %s", messageType, message or "unknown error")
return
end
+ --- @cast message -string
- log:debug("Decoded esphome message: %s(%s)", messageSchema.name, message)
+ log:ultra("Decoded esphome message: %s(%s)", messageSchema.name, message)
-- Call any registered callbacks for the message type
- if type(self._callbacks[messageType]) == "function" then
- log:debug("Calling registered callback for message type %s", messageType)
- local callbackSuccess, err = pcall(self._callbacks[messageType], message, messageSchema)
- if not callbackSuccess then
- if IsEmpty(err) or type(err) ~= "string" then
- err = "unknown error"
- end
- log:error("Callback for message type %s failed; %s", messageType, err)
- end
- end
+ self:_invokeCallback(messageType, message, messageSchema)
end
return ESPHomeClient
diff --git a/src/esphome/entities/binary_sensor.lua b/src/esphome/entities/binary_sensor.lua
index 83fdafd..ea3d1a4 100644
--- a/src/esphome/entities/binary_sensor.lua
+++ b/src/esphome/entities/binary_sensor.lua
@@ -7,18 +7,15 @@ local ESPHomeClient = require("esphome.client")
local BinarySensorEntity = {
TYPE = ESPHomeClient.EntityType.BINARY_SENSOR,
}
+BinarySensorEntity.__index = BinarySensorEntity
--- Create a new instance of the binary sensor entity.
--- @param client ESPHomeClient The ESPHome client instance.
--- @return BinarySensorEntity entity A new instance of the BinarySensorEntity entity.
function BinarySensorEntity:new(client)
- local properties = {
- client = client,
- }
- setmetatable(properties, self)
- self.__index = self
- --- @cast properties BinarySensorEntity
- return properties
+ local instance = setmetatable({}, self)
+ instance.client = client
+ return instance
end
--- Handle the discovery of a binary sensor entity.
diff --git a/src/esphome/entities/button.lua b/src/esphome/entities/button.lua
index c96f6b1..d7c77cd 100644
--- a/src/esphome/entities/button.lua
+++ b/src/esphome/entities/button.lua
@@ -1,24 +1,26 @@
local log = require("lib.logging")
local bindings = require("lib.bindings")
local ESPHomeClient = require("esphome.client")
-local ESPHomeProtoSchema = require("esphome.proto-schema")
+local ESPHomeProtoSchema = require("esphome.proto_schema")
+
+--- Registry of discovered buttons for programming commands.
+--- Maps display name to { key = number, client = ESPHomeClient }
+--- @type table>
+local buttonRegistry = {}
--- @class ButtonEntity:Entity
local ButtonEntity = {
TYPE = ESPHomeClient.EntityType.BUTTON,
}
+ButtonEntity.__index = ButtonEntity
--- Create a new instance of the button entity.
--- @param client ESPHomeClient The ESPHome client instance.
--- @return ButtonEntity entity A new instance of the ButtonEntity entity.
function ButtonEntity:new(client)
- local properties = {
- client = client,
- }
- setmetatable(properties, self)
- self.__index = self
- --- @cast properties ButtonEntity
- return properties
+ local instance = setmetatable({}, self)
+ instance.client = client
+ return instance
end
--- Handle the discovery of a button entity.
@@ -29,6 +31,12 @@ function ButtonEntity:discovered(entity)
local bindingId = assert(
bindings:getOrAddDynamicBinding(self.TYPE, "button_" .. entity.key, "CONTROL", true, entity.name, "BUTTON_LINK")
).bindingId
+
+ -- Register button for programming commands
+ buttonRegistry[entity.name] = function()
+ return self.client:callServiceMethod(ESPHomeProtoSchema.RPC.APIConnection.button_command, { key = entity.key })
+ end
+
RFP[bindingId] = function(idBinding, strCommand, tParams, args)
log:trace("RFP idBinding=%s strCommand=%s tParams=%s args=%s", idBinding, strCommand, tParams, args)
if strCommand == "DO_CLICK" then
@@ -44,4 +52,46 @@ function ButtonEntity:discovered(entity)
OBC[bindingId] = RefreshStatus
end
+--- Get sorted list of button names for programming commands.
+--- @return string[] names List of button display names.
+local function getButtonNames()
+ local names = TableKeys(buttonRegistry)
+ table.sort(names)
+ return names
+end
+
+--- Populate the Button parameter dropdown for the Press Button command.
+--- @param paramName string The parameter name being requested.
+--- @return string[] list List of button names.
+function GCPL.Press_Button(paramName)
+ log:trace("GCPL.Press_Button(%s)", paramName)
+ if paramName ~= "Button" then
+ return {}
+ end
+ return getButtonNames()
+end
+
+--- Execute the Press Button command.
+--- @param params table Command parameters containing Button name.
+function EC.Press_Button(params)
+ log:trace("EC.Press_Button(%s)", params)
+ local buttonName = Select(params, "Button")
+ if IsEmpty(buttonName) then
+ log:warn("Press Button command called without button name")
+ return
+ end
+
+ local pressButton = buttonRegistry[buttonName]
+ if not pressButton then
+ log:warn("Press Button command called for unknown button: %s", buttonName)
+ return
+ end
+
+ pressButton():next(function()
+ log:debug("Command press sent to button %s", buttonName)
+ end, function(error)
+ log:error("An error occurred sending command press to button %s; %s", buttonName, error)
+ end)
+end
+
return ButtonEntity
diff --git a/src/esphome/entities/cover.lua b/src/esphome/entities/cover.lua
index a4279e1..7b650e2 100644
--- a/src/esphome/entities/cover.lua
+++ b/src/esphome/entities/cover.lua
@@ -2,24 +2,21 @@ local log = require("lib.logging")
local bindings = require("lib.bindings")
local values = require("lib.values")
local ESPHomeClient = require("esphome.client")
-local ESPHomeProtoSchema = require("esphome.proto-schema")
+local ESPHomeProtoSchema = require("esphome.proto_schema")
--- @class CoverEntity:Entity
local CoverEntity = {
TYPE = ESPHomeClient.EntityType.COVER,
}
+CoverEntity.__index = CoverEntity
--- Create a new instance of the cover entity.
--- @param client ESPHomeClient The ESPHome client instance.
--- @return CoverEntity entity A new instance of the CoverEntity entity.
function CoverEntity:new(client)
- local properties = {
- client = client,
- }
- setmetatable(properties, self)
- self.__index = self
- --- @cast properties CoverEntity
- return properties
+ local instance = setmetatable({}, self)
+ instance.client = client
+ return instance
end
--- Handle the discovery of a cover entity.
@@ -124,7 +121,7 @@ function CoverEntity:discovered(entity)
end
-- We only trigger when the relays are turned on
- if strCommand == "ON" or strCommand == "CLOSE" or strCommand == "TOGGLE" then
+ if strCommand == "ON" or strCommand == "CLOSE" or strCommand == "TOGGLE" or strCommand == "TRIGGER" then
self.client
:callServiceMethod(ESPHomeProtoSchema.RPC.APIConnection.cover_command, {
key = entity.key,
@@ -207,14 +204,20 @@ function CoverEntity:updated(entity, state)
values:update(entity.name .. " State", stateString, "STRING")
- -- Update the cover state contacts
+ -- Update the cover state contacts (only notify when state changes)
local coverOpenBinding = bindings:getDynamicBinding(self.TYPE, "cover_open_" .. entity.key)
if coverOpenBinding ~= nil then
- SendToProxy(coverOpenBinding.bindingId, coverOpen and "CLOSED" or "OPENED", {}, "NOTIFY")
+ local coverOpenState = coverOpen and "CLOSED" or "OPENED"
+ if values:update(entity.name .. " Open", coverOpenState) then
+ SendToProxy(coverOpenBinding.bindingId, coverOpenState, {}, "NOTIFY")
+ end
end
local coverClosedBinding = bindings:getDynamicBinding(self.TYPE, "cover_closed_" .. entity.key)
if coverClosedBinding ~= nil then
- SendToProxy(coverClosedBinding.bindingId, coverClosed and "CLOSED" or "OPENED", {}, "NOTIFY")
+ local coverClosedState = coverClosed and "CLOSED" or "OPENED"
+ if values:update(entity.name .. " Closed", coverClosedState) then
+ SendToProxy(coverClosedBinding.bindingId, coverClosedState, {}, "NOTIFY")
+ end
end
-- Always open the relays since its just used to trigger the cover
diff --git a/src/esphome/entities/date.lua b/src/esphome/entities/date.lua
new file mode 100644
index 0000000..13851f5
--- /dev/null
+++ b/src/esphome/entities/date.lua
@@ -0,0 +1,60 @@
+local log = require("lib.logging")
+local values = require("lib.values")
+local ESPHomeClient = require("esphome.client")
+local ESPHomeProtoSchema = require("esphome.proto_schema")
+
+--- @class DateEntity:Entity
+local DateEntity = {
+ TYPE = ESPHomeClient.EntityType.DATETIME_DATE,
+}
+DateEntity.__index = DateEntity
+
+--- Create a new instance of the date entity.
+--- @param client ESPHomeClient The ESPHome client instance.
+--- @return DateEntity entity A new instance of the DateEntity entity.
+function DateEntity:new(client)
+ local instance = setmetatable({}, self)
+ instance.client = client
+ return instance
+end
+
+--- Handle updates to the date entity state.
+--- @param entity table The entity data received from the ESPHome client.
+--- @param state table The state data received from the ESPHome client.
+--- @return void
+function DateEntity:updated(entity, state)
+ log:trace("DateEntity:updated(%s, %s)", entity, state)
+
+ if state.missing_state then
+ values:update(entity.name, "", "STRING")
+ return
+ end
+
+ local formatted = string.format("%04d-%02d-%02d", state.year or 0, state.month or 0, state.day or 0)
+ values:update(entity.name, formatted, "STRING", function(newValue)
+ local year, month, day = (newValue or ""):match("^(%d%d%d%d)-(%d%d)-(%d%d)$")
+ if not year then
+ log:error(
+ "Invalid date format for %s.%s: %s (expected YYYY-MM-DD)",
+ entity.entity_type,
+ entity.object_id,
+ newValue or ""
+ )
+ return
+ end
+ self.client
+ :callServiceMethod(ESPHomeProtoSchema.RPC.APIConnection.date_command, {
+ key = entity.key,
+ year = tonumber(year),
+ month = tonumber(month),
+ day = tonumber(day),
+ })
+ :next(function()
+ log:info("Date updated to %s for %s.%s", newValue, entity.entity_type, entity.object_id)
+ end, function(error)
+ log:error("Failed to update date for %s.%s: %s", entity.entity_type, entity.object_id, error)
+ end)
+ end)
+end
+
+return DateEntity
diff --git a/src/esphome/entities/datetime.lua b/src/esphome/entities/datetime.lua
new file mode 100644
index 0000000..7306239
--- /dev/null
+++ b/src/esphome/entities/datetime.lua
@@ -0,0 +1,70 @@
+local log = require("lib.logging")
+local values = require("lib.values")
+local ESPHomeClient = require("esphome.client")
+local ESPHomeProtoSchema = require("esphome.proto_schema")
+
+--- @class DateTimeEntity:Entity
+local DateTimeEntity = {
+ TYPE = ESPHomeClient.EntityType.DATETIME_DATETIME,
+}
+DateTimeEntity.__index = DateTimeEntity
+
+--- Create a new instance of the datetime entity.
+--- @param client ESPHomeClient The ESPHome client instance.
+--- @return DateTimeEntity entity A new instance of the DateTimeEntity entity.
+function DateTimeEntity:new(client)
+ local instance = setmetatable({}, self)
+ instance.client = client
+ return instance
+end
+
+--- Handle updates to the datetime entity state.
+--- @param entity table The entity data received from the ESPHome client.
+--- @param state table The state data received from the ESPHome client.
+--- @return void
+function DateTimeEntity:updated(entity, state)
+ log:trace("DateTimeEntity:updated(%s, %s)", entity, state)
+
+ if state.missing_state then
+ values:update(entity.name, "", "STRING")
+ return
+ end
+
+ local epochSeconds = state.epoch_seconds or 0
+ local t = os.date("!*t", epochSeconds)
+ local formatted = string.format("%04d-%02d-%02d %02d:%02d:%02d", t.year, t.month, t.day, t.hour, t.min, t.sec)
+ values:update(entity.name, formatted, "STRING", function(newValue)
+ local year, month, day, hour, minute, second = (newValue or ""):match(
+ "^(%d%d%d%d)-(%d%d)-(%d%d) (%d%d):(%d%d):(%d%d)$"
+ )
+ if not year then
+ log:error(
+ "Invalid datetime format for %s.%s: %s (expected YYYY-MM-DD HH:MM:SS)",
+ entity.entity_type,
+ entity.object_id,
+ newValue or ""
+ )
+ return
+ end
+ local epoch = os.time({
+ year = tonumber(year),
+ month = tonumber(month),
+ day = tonumber(day),
+ hour = tonumber(hour),
+ min = tonumber(minute),
+ sec = tonumber(second),
+ })
+ self.client
+ :callServiceMethod(ESPHomeProtoSchema.RPC.APIConnection.datetime_command, {
+ key = entity.key,
+ epoch_seconds = epoch,
+ })
+ :next(function()
+ log:info("Datetime updated to %s for %s.%s", newValue, entity.entity_type, entity.object_id)
+ end, function(error)
+ log:error("Failed to update datetime for %s.%s: %s", entity.entity_type, entity.object_id, error)
+ end)
+ end)
+end
+
+return DateTimeEntity
diff --git a/src/esphome/entities/event.lua b/src/esphome/entities/event.lua
new file mode 100644
index 0000000..665befb
--- /dev/null
+++ b/src/esphome/entities/event.lua
@@ -0,0 +1,60 @@
+local log = require("lib.logging")
+local events = require("lib.events")
+local values = require("lib.values")
+local ESPHomeClient = require("esphome.client")
+
+--- @class EventEntity:Entity
+local EventEntity = {
+ TYPE = ESPHomeClient.EntityType.EVENT,
+}
+EventEntity.__index = EventEntity
+
+--- Create a new instance of the event entity.
+--- @param client ESPHomeClient The ESPHome client instance.
+--- @return EventEntity entity A new instance of the EventEntity entity.
+function EventEntity:new(client)
+ local instance = setmetatable({}, self)
+ instance.client = client
+ return instance
+end
+
+--- Handle the discovery of an event entity.
+--- @param entity table The entity data received from the ESPHome client.
+--- @return void
+function EventEntity:discovered(entity)
+ log:trace("EventEntity:discovered(%s)", entity)
+
+ -- Register a C4 event for each event type
+ local eventTypes = entity.event_types or {}
+ for _, eventType in ipairs(eventTypes) do
+ events:getOrAddEvent(
+ "event_" .. entity.key,
+ eventType,
+ entity.name .. ": " .. eventType,
+ entity.name .. " " .. eventType .. " event"
+ )
+ end
+end
+
+--- Handle updates to the event entity state.
+--- @param entity table The entity data received from the ESPHome client.
+--- @param state table The state data received from the ESPHome client.
+--- @return void
+function EventEntity:updated(entity, state)
+ log:trace("EventEntity:updated(%s, %s)", entity, state)
+
+ local eventType = state.event_type or ""
+ if IsEmpty(eventType) then
+ log:warn("Received event with empty event_type for %s.%s", entity.entity_type, entity.object_id)
+ return
+ end
+
+ -- Update the last event variable
+ values:update(entity.name .. " Last Event", eventType, "STRING")
+
+ -- Fire the corresponding C4 event
+ events:fire("event_" .. entity.key, eventType)
+ log:info("Fired event %s for %s.%s", eventType, entity.entity_type, entity.object_id)
+end
+
+return EventEntity
diff --git a/src/esphome/entities/fan.lua b/src/esphome/entities/fan.lua
new file mode 100644
index 0000000..57e8c35
--- /dev/null
+++ b/src/esphome/entities/fan.lua
@@ -0,0 +1,86 @@
+local log = require("lib.logging")
+local bindings = require("lib.bindings")
+local ESPHomeClient = require("esphome.client")
+local ESPHomeProtoSchema = require("esphome.proto_schema")
+
+--- @class FanEntity:Entity
+local FanEntity = {
+ TYPE = ESPHomeClient.EntityType.FAN,
+}
+FanEntity.__index = FanEntity
+
+--- Create a new instance of the fan entity.
+--- @param client ESPHomeClient The ESPHome client instance.
+--- @return FanEntity entity A new instance of the FanEntity entity.
+function FanEntity:new(client)
+ local instance = setmetatable({}, self)
+ instance.client = client
+ return instance
+end
+
+--- Handle the discovery of a fan entity.
+--- @param entity table The entity data received from the ESPHome client.
+--- @return void
+function FanEntity:discovered(entity)
+ log:trace("FanEntity:discovered(%s)", entity)
+ local speed_count = entity.supported_speed_count or 0
+ if speed_count <= 0 then
+ speed_count = 1
+ end
+ local class = "ESPHOME_FAN_" .. speed_count .. "_SPEED"
+ if entity.supports_direction then
+ class = class .. "_REVERSE"
+ end
+ local bindingId = assert(
+ bindings:getOrAddDynamicBinding(self.TYPE, "fan_" .. entity.key, "PROXY", true, entity.name, class)
+ ).bindingId
+ RFP[bindingId] = function(idBinding, strCommand, tParams, args)
+ log:trace("RFP idBinding=%s strCommand=%s tParams=%s args=%s", idBinding, strCommand, tParams, args)
+ if strCommand == "REFRESH_STATE" then
+ RefreshStatus()
+ elseif strCommand == "ENTITY_COMMAND" then
+ local command = ESPHomeProtoSchema.RPC.APIConnection[Select(tParams, "command")]
+ or ESPHomeProtoSchema.RPC.APIConnection.fan_command
+ local body = DeserializeSafe(Select(tParams, "body")) or {}
+ body.key = body.key or entity.key
+ self.client:callServiceMethod(command, body):next(function()
+ log:debug(
+ "Method %s.%s(%s) called by entity %s.%s",
+ command.service,
+ command.method,
+ body,
+ entity.entity_type,
+ entity.object_id
+ )
+ end, function(error)
+ log:error(
+ "An error occurred calling method %s.%s(%s) by entity %s.%s; %s",
+ command.service,
+ command.method,
+ body,
+ entity.entity_type,
+ entity.object_id,
+ error
+ )
+ end)
+ end
+ end
+ OBC[bindingId] = RefreshStatus
+end
+
+--- Handle updates to the fan entity state.
+--- @param entity table The entity data received from the ESPHome client.
+--- @param state table The state data received from the ESPHome client.
+--- @return void
+function FanEntity:updated(entity, state)
+ log:trace("FanEntity:updated(%s, %s)", entity, state)
+ local binding = bindings:getDynamicBinding(self.TYPE, "fan_" .. entity.key)
+ if binding ~= nil then
+ SendToProxy(binding.bindingId, "UPDATE_STATE", {
+ entity = SerializeSafe(entity),
+ state = SerializeSafe(state),
+ }, "NOTIFY")
+ end
+end
+
+return FanEntity
diff --git a/src/esphome/entities/light.lua b/src/esphome/entities/light.lua
index 50fb82f..2ded3b4 100644
--- a/src/esphome/entities/light.lua
+++ b/src/esphome/entities/light.lua
@@ -1,24 +1,21 @@
local log = require("lib.logging")
local bindings = require("lib.bindings")
local ESPHomeClient = require("esphome.client")
-local ESPHomeProtoSchema = require("esphome.proto-schema")
+local ESPHomeProtoSchema = require("esphome.proto_schema")
--- @class LightEntity:Entity
local LightEntity = {
TYPE = ESPHomeClient.EntityType.LIGHT,
}
+LightEntity.__index = LightEntity
--- Create a new instance of the light entity.
--- @param client ESPHomeClient The ESPHome client instance.
--- @return LightEntity entity A new instance of the LightEntity entity.
function LightEntity:new(client)
- local properties = {
- client = client,
- }
- setmetatable(properties, self)
- self.__index = self
- --- @cast properties LightEntity
- return properties
+ local instance = setmetatable({}, self)
+ instance.client = client
+ return instance
end
--- Handle the discovery of a light entity.
@@ -37,7 +34,7 @@ function LightEntity:discovered(entity)
elseif strCommand == "ENTITY_COMMAND" then
local command = ESPHomeProtoSchema.RPC.APIConnection[Select(tParams, "command")]
or ESPHomeProtoSchema.RPC.APIConnection.light_command
- local body = Deserialize(Select(tParams, "body")) or {}
+ local body = DeserializeSafe(Select(tParams, "body")) or {}
body.key = body.key or entity.key
self.client:callServiceMethod(command, body):next(function()
log:debug(
@@ -73,8 +70,8 @@ function LightEntity:updated(entity, state)
local binding = bindings:getDynamicBinding(self.TYPE, "light_" .. entity.key)
if binding ~= nil then
SendToProxy(binding.bindingId, "UPDATE_STATE", {
- entity = Serialize(entity),
- state = Serialize(state),
+ entity = SerializeSafe(entity),
+ state = SerializeSafe(state),
}, "NOTIFY")
end
end
diff --git a/src/esphome/entities/lock.lua b/src/esphome/entities/lock.lua
index ce54aaf..f6ea2d1 100644
--- a/src/esphome/entities/lock.lua
+++ b/src/esphome/entities/lock.lua
@@ -1,24 +1,21 @@
local log = require("lib.logging")
local bindings = require("lib.bindings")
local ESPHomeClient = require("esphome.client")
-local ESPHomeProtoSchema = require("esphome.proto-schema")
+local ESPHomeProtoSchema = require("esphome.proto_schema")
--- @class LockEntity:Entity
local LockEntity = {
TYPE = ESPHomeClient.EntityType.LOCK,
}
+LockEntity.__index = LockEntity
--- Create a new instance of the lock entity.
--- @param client ESPHomeClient The ESPHome client instance.
--- @return LockEntity entity A new instance of the LockEntity entity.
function LockEntity:new(client)
- local properties = {
- client = client,
- }
- setmetatable(properties, self)
- self.__index = self
- --- @cast properties LockEntity
- return properties
+ local instance = setmetatable({}, self)
+ instance.client = client
+ return instance
end
--- Handle the discovery of a lock entity.
@@ -37,7 +34,7 @@ function LockEntity:discovered(entity)
elseif strCommand == "ENTITY_COMMAND" then
local command = ESPHomeProtoSchema.RPC.APIConnection[Select(tParams, "command")]
or ESPHomeProtoSchema.RPC.APIConnection.lock_command
- local body = Deserialize(Select(tParams, "body")) or {}
+ local body = DeserializeSafe(Select(tParams, "body")) or {}
body.key = body.key or entity.key
self.client:callServiceMethod(command, body):next(function()
log:debug(
@@ -73,8 +70,8 @@ function LockEntity:updated(entity, state)
local binding = bindings:getDynamicBinding(self.TYPE, "lock_" .. entity.key)
if binding ~= nil then
SendToProxy(binding.bindingId, "UPDATE_STATE", {
- entity = Serialize(entity),
- state = Serialize(state),
+ entity = SerializeSafe(entity),
+ state = SerializeSafe(state),
}, "NOTIFY")
end
end
diff --git a/src/esphome/entities/number.lua b/src/esphome/entities/number.lua
index 989aa13..66daa6b 100644
--- a/src/esphome/entities/number.lua
+++ b/src/esphome/entities/number.lua
@@ -1,24 +1,21 @@
local log = require("lib.logging")
local values = require("lib.values")
local ESPHomeClient = require("esphome.client")
-local ESPHomeProtoSchema = require("esphome.proto-schema")
+local ESPHomeProtoSchema = require("esphome.proto_schema")
--- @class NumberEntity:Entity
local NumberEntity = {
TYPE = ESPHomeClient.EntityType.NUMBER,
}
+NumberEntity.__index = NumberEntity
--- Create a new instance of the number entity.
--- @param client ESPHomeClient The ESPHome client instance.
--- @return NumberEntity entity A new instance of the NumberEntity entity.
function NumberEntity:new(client)
- local properties = {
- client = client,
- }
- setmetatable(properties, self)
- self.__index = self
- --- @cast properties NumberEntity
- return properties
+ local instance = setmetatable({}, self)
+ instance.client = client
+ return instance
end
--- Handle updates to the number entity state.
diff --git a/src/esphome/entities/select.lua b/src/esphome/entities/select.lua
new file mode 100644
index 0000000..80df923
--- /dev/null
+++ b/src/esphome/entities/select.lua
@@ -0,0 +1,129 @@
+local log = require("lib.logging")
+local values = require("lib.values")
+local ESPHomeClient = require("esphome.client")
+local ESPHomeProtoSchema = require("esphome.proto_schema")
+
+--- Registry of discovered select entities for programming commands.
+--- Maps display name to { key = number, options = string[], client = ESPHomeClient }
+--- @type table
+local selectRegistry = {}
+
+--- @class SelectEntity:Entity
+local SelectEntity = {
+ TYPE = ESPHomeClient.EntityType.SELECT,
+}
+SelectEntity.__index = SelectEntity
+
+--- Create a new instance of the select entity.
+--- @param client ESPHomeClient The ESPHome client instance.
+--- @return SelectEntity entity A new instance of the SelectEntity entity.
+function SelectEntity:new(client)
+ local instance = setmetatable({}, self)
+ instance.client = client
+ return instance
+end
+
+--- Handle the discovery of a select entity.
+--- @param entity table The entity data received from the ESPHome client.
+--- @return void
+function SelectEntity:discovered(entity)
+ log:trace("SelectEntity:discovered(%s)", entity)
+
+ -- Register select for programming commands
+ selectRegistry[entity.name] = {
+ key = entity.key,
+ options = entity.options or {},
+ client = self.client,
+ }
+end
+
+--- Handle updates to the select entity state.
+--- @param entity table The entity data received from the ESPHome client.
+--- @param state table The state data received from the ESPHome client.
+--- @return void
+function SelectEntity:updated(entity, state)
+ log:trace("SelectEntity:updated(%s, %s)", entity, state)
+ values:update(entity.name, state.state or "", "STRING", function(newValue)
+ self.client
+ :callServiceMethod(ESPHomeProtoSchema.RPC.APIConnection.select_command, {
+ key = entity.key,
+ state = newValue or "",
+ })
+ :next(function()
+ log:info("Select option updated to '%s' for select.%s", newValue or "", entity.object_id)
+ end, function(error)
+ log:error("Failed to update select option for select.%s: %s", entity.name, error)
+ end)
+ end)
+end
+
+--- Get sorted list of select names for programming commands.
+--- @return string[] names List of select display names.
+local function getSelectNames()
+ local names = TableKeys(selectRegistry)
+ table.sort(names)
+ return names
+end
+
+--- Populate the Select parameter dropdown for the Set Select command.
+--- @param paramName string The parameter name being requested.
+--- @return string[] list List of select names or option values.
+function GCPL.Set_Select(paramName)
+ log:trace("GCPL.Set_Select(%s)", paramName)
+ if paramName == "Select" then
+ return getSelectNames()
+ elseif paramName == "Option" then
+ -- Return options for the currently selected select entity
+ -- Note: C4 does not pass previously selected param values to GCPL,
+ -- so we return all options from all selects (deduplicated, sorted)
+ local allOptions = {}
+ local seen = {}
+ for _, entry in pairs(selectRegistry) do
+ for _, option in ipairs(entry.options) do
+ if not seen[option] then
+ seen[option] = true
+ table.insert(allOptions, option)
+ end
+ end
+ end
+ table.sort(allOptions)
+ return allOptions
+ end
+ return {}
+end
+
+--- Execute the Set Select command.
+--- @param params table Command parameters containing Select name and Option value.
+function EC.Set_Select(params)
+ log:trace("EC.Set_Select(%s)", params)
+ local selectName = Select(params, "Select")
+ if IsEmpty(selectName) then
+ log:warn("Set Select command called without select name")
+ return
+ end
+
+ local optionValue = Select(params, "Option")
+ if optionValue == nil then
+ log:warn("Set Select command called without option value")
+ return
+ end
+
+ local entry = selectRegistry[selectName]
+ if not entry then
+ log:warn("Set Select command called for unknown select: %s", selectName)
+ return
+ end
+
+ entry.client
+ :callServiceMethod(ESPHomeProtoSchema.RPC.APIConnection.select_command, {
+ key = entry.key,
+ state = optionValue,
+ })
+ :next(function()
+ log:info("Select option set to '%s' for %s", optionValue, selectName)
+ end, function(error)
+ log:error("Failed to set select option for %s: %s", selectName, error)
+ end)
+end
+
+return SelectEntity
diff --git a/src/esphome/entities/sensor.lua b/src/esphome/entities/sensor.lua
index 558d659..6dc8f28 100644
--- a/src/esphome/entities/sensor.lua
+++ b/src/esphome/entities/sensor.lua
@@ -6,24 +6,22 @@ local ESPHomeClient = require("esphome.client")
local SensorEntity = {
TYPE = ESPHomeClient.EntityType.SENSOR,
}
+SensorEntity.__index = SensorEntity
--- Create a new instance of the sensor entity.
--- @param client ESPHomeClient The ESPHome client instance.
--- @return SensorEntity entity A new instance of the SensorEntity entity.
function SensorEntity:new(client)
- local properties = {
- client = client,
- }
- setmetatable(properties, self)
- self.__index = self
- --- @cast properties SensorEntity
- return properties
+ local instance = setmetatable({}, self)
+ instance.client = client
+ return instance
end
--- Handle updates to the sensor entity state.
--- @param entity table The entity data received from the ESPHome client.
--- @param state table The state data received from the ESPHome client.
--- @return void
+--- @diagnostic disable-next-line: unused
function SensorEntity:updated(entity, state)
log:trace("SensorEntity:updated(%s, %s)", entity, state)
values:update(entity.name, round(tonumber(state.state) or 0, 1), "NUMBER")
diff --git a/src/esphome/entities/switch.lua b/src/esphome/entities/switch.lua
index 63dfc80..f4eeb85 100644
--- a/src/esphome/entities/switch.lua
+++ b/src/esphome/entities/switch.lua
@@ -2,24 +2,21 @@ local log = require("lib.logging")
local bindings = require("lib.bindings")
local values = require("lib.values")
local ESPHomeClient = require("esphome.client")
-local ESPHomeProtoSchema = require("esphome.proto-schema")
+local ESPHomeProtoSchema = require("esphome.proto_schema")
--- @class SwitchEntity:Entity
local SwitchEntity = {
TYPE = ESPHomeClient.EntityType.SWITCH,
}
+SwitchEntity.__index = SwitchEntity
--- Create a new instance of the switch entity.
--- @param client ESPHomeClient The ESPHome client instance.
--- @return SwitchEntity entity A new instance of the SwitchEntity entity.
function SwitchEntity:new(client)
- local properties = {
- client = client,
- }
- setmetatable(properties, self)
- self.__index = self
- --- @cast properties SwitchEntity
- return properties
+ local instance = setmetatable({}, self)
+ instance.client = client
+ return instance
end
--- Handle the discovery of a switch entity.
@@ -27,8 +24,9 @@ end
--- @return void
function SwitchEntity:discovered(entity)
log:trace("SwitchEntity:discovered(%s)", entity)
- local bindingId =
- assert(bindings:getOrAddDynamicBinding(self.TYPE, "switch_" .. entity.key, "PROXY", true, entity.name, "RELAY"))
+ local bindingId = assert(
+ bindings:getOrAddDynamicBinding(self.TYPE, "switch_" .. entity.key, "PROXY", true, entity.name, "RELAY")
+ ).bindingId
RFP[bindingId] = function(idBinding, strCommand, tParams, args)
log:trace("RFP idBinding=%s strCommand=%s tParams=%s args=%s", idBinding, strCommand, tParams, args)
diff --git a/src/esphome/entities/text.lua b/src/esphome/entities/text.lua
index 5007364..c04df32 100644
--- a/src/esphome/entities/text.lua
+++ b/src/esphome/entities/text.lua
@@ -1,24 +1,21 @@
local log = require("lib.logging")
local values = require("lib.values")
local ESPHomeClient = require("esphome.client")
-local ESPHomeProtoSchema = require("esphome.proto-schema")
+local ESPHomeProtoSchema = require("esphome.proto_schema")
--- @class TextEntity:Entity
local TextEntity = {
TYPE = ESPHomeClient.EntityType.TEXT,
}
+TextEntity.__index = TextEntity
--- Create a new instance of the text entity.
--- @param client ESPHomeClient The ESPHome client instance.
--- @return TextEntity entity A new instance of the TextEntity entity.
function TextEntity:new(client)
- local properties = {
- client = client,
- }
- setmetatable(properties, self)
- self.__index = self
- --- @cast properties TextEntity
- return properties
+ local instance = setmetatable({}, self)
+ instance.client = client
+ return instance
end
--- Handle updates to the text entity state.
diff --git a/src/esphome/entities/text_sensor.lua b/src/esphome/entities/text_sensor.lua
index 4a984f1..23aa683 100644
--- a/src/esphome/entities/text_sensor.lua
+++ b/src/esphome/entities/text_sensor.lua
@@ -6,24 +6,22 @@ local ESPHomeClient = require("esphome.client")
local TextSensorEntity = {
TYPE = ESPHomeClient.EntityType.TEXT_SENSOR,
}
+TextSensorEntity.__index = TextSensorEntity
--- Create a new instance of the text sensor entity.
--- @param client ESPHomeClient The ESPHome client instance.
--- @return TextSensorEntity entity A new instance of the TextSensorEntity entity.
function TextSensorEntity:new(client)
- local properties = {
- client = client,
- }
- setmetatable(properties, self)
- self.__index = self
- --- @cast properties TextSensorEntity
- return properties
+ local instance = setmetatable({}, self)
+ instance.client = client
+ return instance
end
--- Handle updates to the text sensor entity state.
--- @param entity table The entity data received from the ESPHome client.
--- @param state table The state data received from the ESPHome client.
--- @return void
+--- @diagnostic disable-next-line: unused
function TextSensorEntity:updated(entity, state)
log:trace("TextSensorEntity:updated(%s, %s)", entity, state)
values:update(entity.name, state.state or "", "STRING")
diff --git a/src/esphome/entities/time.lua b/src/esphome/entities/time.lua
new file mode 100644
index 0000000..bb29dea
--- /dev/null
+++ b/src/esphome/entities/time.lua
@@ -0,0 +1,60 @@
+local log = require("lib.logging")
+local values = require("lib.values")
+local ESPHomeClient = require("esphome.client")
+local ESPHomeProtoSchema = require("esphome.proto_schema")
+
+--- @class TimeEntity:Entity
+local TimeEntity = {
+ TYPE = ESPHomeClient.EntityType.DATETIME_TIME,
+}
+TimeEntity.__index = TimeEntity
+
+--- Create a new instance of the time entity.
+--- @param client ESPHomeClient The ESPHome client instance.
+--- @return TimeEntity entity A new instance of the TimeEntity entity.
+function TimeEntity:new(client)
+ local instance = setmetatable({}, self)
+ instance.client = client
+ return instance
+end
+
+--- Handle updates to the time entity state.
+--- @param entity table The entity data received from the ESPHome client.
+--- @param state table The state data received from the ESPHome client.
+--- @return void
+function TimeEntity:updated(entity, state)
+ log:trace("TimeEntity:updated(%s, %s)", entity, state)
+
+ if state.missing_state then
+ values:update(entity.name, "", "STRING")
+ return
+ end
+
+ local formatted = string.format("%02d:%02d:%02d", state.hour or 0, state.minute or 0, state.second or 0)
+ values:update(entity.name, formatted, "STRING", function(newValue)
+ local hour, minute, second = (newValue or ""):match("^(%d%d):(%d%d):(%d%d)$")
+ if not hour then
+ log:error(
+ "Invalid time format for %s.%s: %s (expected HH:MM:SS)",
+ entity.entity_type,
+ entity.object_id,
+ newValue or ""
+ )
+ return
+ end
+ self.client
+ :callServiceMethod(ESPHomeProtoSchema.RPC.APIConnection.time_command, {
+ key = entity.key,
+ hour = tonumber(hour),
+ minute = tonumber(minute),
+ second = tonumber(second),
+ })
+ :next(function()
+ log:info("Time updated to %s for %s.%s", newValue, entity.entity_type, entity.object_id)
+ end, function(error)
+ log:error("Failed to update time for %s.%s: %s", entity.entity_type, entity.object_id, error)
+ end)
+ end)
+end
+
+return TimeEntity
diff --git a/src/esphome/entities/update.lua b/src/esphome/entities/update.lua
new file mode 100644
index 0000000..7915e18
--- /dev/null
+++ b/src/esphome/entities/update.lua
@@ -0,0 +1,204 @@
+local log = require("lib.logging")
+local values = require("lib.values")
+local Deferred = require("deferred")
+local ESPHomeClient = require("esphome.client")
+local ESPHomeProtoSchema = require("esphome.proto_schema")
+local constants = require("constants")
+
+--- Registry of discovered update entities for programming commands.
+--- Maps display name to { key = number, client = ESPHomeClient }
+--- @type table
+local updateRegistry = {}
+
+--- @class UpdateEntity:Entity
+local UpdateEntity = {
+ TYPE = ESPHomeClient.EntityType.UPDATE,
+}
+UpdateEntity.__index = UpdateEntity
+
+--- Create a new instance of the update entity.
+--- @param client ESPHomeClient The ESPHome client instance.
+--- @return UpdateEntity entity A new instance of the UpdateEntity entity.
+function UpdateEntity:new(client)
+ local instance = setmetatable({}, self)
+ instance.client = client
+ return instance
+end
+
+--- Handle the discovery of an update entity.
+--- @param entity table The entity data received from the ESPHome client.
+--- @return void
+function UpdateEntity:discovered(entity)
+ log:trace("UpdateEntity:discovered(%s)", entity)
+
+ -- Show Automatic Device Updates property now that we know we have update entities
+ if next(updateRegistry) == nil then
+ C4:SetPropertyAttribs("Automatic Device Updates", constants.SHOW_PROPERTY)
+ end
+
+ -- Register update entity for programming commands
+ updateRegistry[entity.name] = {
+ key = entity.key,
+ client = self.client,
+ }
+end
+
+--- Handle updates to the update entity state.
+--- @param entity table The entity data received from the ESPHome client.
+--- @param state table The state data received from the ESPHome client.
+--- @return void
+function UpdateEntity:updated(entity, state)
+ log:trace("UpdateEntity:updated(%s, %s)", entity, state)
+
+ if toboolean(state.missing_state) then
+ return
+ end
+
+ local currentVersion = state.current_version or ""
+ local latestVersion = state.latest_version or ""
+ local updateAvailable = latestVersion ~= "" and currentVersion ~= latestVersion
+
+ values:update(entity.name .. " Current Version", currentVersion, "STRING")
+ values:update(entity.name .. " Latest Version", latestVersion, "STRING")
+ values:update(entity.name .. " Update Available", updateAvailable and "1" or "0", "BOOL")
+ values:update(entity.name .. " Update In Progress", toboolean(state.in_progress) and "1" or "0", "BOOL")
+
+ -- Auto-install if enabled and update just became available
+ if updateAvailable and toboolean(Properties["Automatic Device Updates"]) then
+ log:info("Automatic device update enabled, installing update for %s", entity.name)
+ self.client
+ :callServiceMethod(ESPHomeProtoSchema.RPC.APIConnection.update_command, {
+ key = entity.key,
+ command = ESPHomeProtoSchema.Enum.UpdateCommand.UPDATE_COMMAND_UPDATE,
+ })
+ :next(function()
+ log:info("Auto-update command sent to %s", entity.name)
+ end, function(error)
+ log:error("An error occurred auto-updating %s; %s", entity.name, error)
+ end)
+ end
+end
+
+--- Check whether any update entities have been discovered.
+--- @return boolean hasEntities True if at least one update entity exists.
+function UpdateEntity:hasEntities()
+ return next(updateRegistry) ~= nil
+end
+
+--- Check all registered update entities for available updates.
+--- Sends UPDATE_COMMAND_CHECK to each entity, triggering state responses
+--- that will be handled by the updated() method.
+--- @return Deferred A deferred that resolves when all check commands have been sent.
+function UpdateEntity:checkAll()
+ log:trace("UpdateEntity:checkAll()")
+ local deferreds = {}
+ for entityName, entry in pairs(updateRegistry) do
+ log:debug("Checking for device updates: %s", entityName)
+ local d = entry.client
+ :callServiceMethod(ESPHomeProtoSchema.RPC.APIConnection.update_command, {
+ key = entry.key,
+ command = ESPHomeProtoSchema.Enum.UpdateCommand.UPDATE_COMMAND_CHECK,
+ })
+ :next(function()
+ log:debug("Check for updates sent to %s", entityName)
+ end, function(error)
+ log:error("An error occurred checking for updates on %s; %s", entityName, error)
+ end)
+ table.insert(deferreds, d)
+ end
+ return Deferred.all(deferreds)
+end
+
+--- Get sorted list of update entity names for programming commands.
+--- @return string[] names List of update entity display names.
+local function getUpdateNames()
+ local names = TableKeys(updateRegistry)
+ table.sort(names)
+ return names
+end
+
+--- Send an update command to the specified entity.
+--- @param entityName string The display name of the update entity.
+--- @param command number The update command enum value.
+--- @param commandName string The human-readable command name for logging.
+local function sendUpdateCommand(entityName, command, commandName)
+ local entry = updateRegistry[entityName]
+ if not entry then
+ log:warn("%s command called for unknown update entity: %s", commandName, entityName)
+ return
+ end
+
+ entry.client
+ :callServiceMethod(ESPHomeProtoSchema.RPC.APIConnection.update_command, {
+ key = entry.key,
+ command = command,
+ })
+ :next(function()
+ log:debug("Command %s sent to update entity %s", commandName, entityName)
+ end, function(error)
+ log:error("An error occurred sending command %s to update entity %s; %s", commandName, entityName, error)
+ end)
+end
+
+--- Populate the Update parameter dropdown for the Update Device command.
+--- @param paramName string The parameter name being requested.
+--- @return string[] list List of update entity names.
+function GCPL.Update_Device(paramName)
+ log:trace("GCPL.Update_Device(%s)", paramName)
+ if paramName ~= "Update" then
+ return {}
+ end
+ return getUpdateNames()
+end
+
+--- Execute the Update Device command.
+--- Checks for an update first, then installs it if available. If the Update Available
+--- boolean is already true, skips the check and installs immediately. Otherwise, sends
+--- a check command followed by an install command (the install is a no-op if no update
+--- is available after the check).
+--- @param params table Command parameters containing Update name.
+function EC.Update_Device(params)
+ log:trace("EC.Update_Device(%s)", params)
+ local entityName = Select(params, "Update")
+ if IsEmpty(entityName) then
+ log:warn("Update Device command called without entity name")
+ return
+ end
+
+ local entry = updateRegistry[entityName]
+ if not entry then
+ log:warn("Update Device command called for unknown update entity: %s", entityName)
+ return
+ end
+
+ local updateAvailable = toboolean(values:get(entityName .. " Update Available"))
+ if updateAvailable then
+ -- Update already known to be available, install directly
+ log:info("Update available for %s, installing", entityName)
+ sendUpdateCommand(entityName, ESPHomeProtoSchema.Enum.UpdateCommand.UPDATE_COMMAND_UPDATE, "Update Device")
+ else
+ -- Check first, then install. The check triggers a state response; if an update
+ -- becomes available, the install command will act on it. If no update is available
+ -- after the check, the install is a no-op on the device side.
+ log:info("Checking for update on %s before installing", entityName)
+ entry.client
+ :callServiceMethod(ESPHomeProtoSchema.RPC.APIConnection.update_command, {
+ key = entry.key,
+ command = ESPHomeProtoSchema.Enum.UpdateCommand.UPDATE_COMMAND_CHECK,
+ })
+ :next(function()
+ log:debug("Check sent to %s, now sending install command", entityName)
+ return entry.client:callServiceMethod(ESPHomeProtoSchema.RPC.APIConnection.update_command, {
+ key = entry.key,
+ command = ESPHomeProtoSchema.Enum.UpdateCommand.UPDATE_COMMAND_UPDATE,
+ })
+ end)
+ :next(function()
+ log:info("Update Device command sequence completed for %s", entityName)
+ end, function(error)
+ log:error("An error occurred during Update Device for %s; %s", entityName, error)
+ end)
+ end
+end
+
+return UpdateEntity
diff --git a/src/esphome/proto-schema.lua b/src/esphome/proto-schema.lua
deleted file mode 100644
index 2fccae4..0000000
--- a/src/esphome/proto-schema.lua
+++ /dev/null
@@ -1,5594 +0,0 @@
--- Generated Lua schema from protobuf descriptor set
--- Do not edit manually
-
---- @class ProtoFieldSchema
---- @field name string The name of the field.
---- @field wireType integer The protobuf wire type (see ProtoSchema.WireType).
---- @field type integer The protobuf type (see ProtoSchema.DataType).
---- @field repeated boolean? Whether the field is repeated (optional).
---- @field subschema ProtoMessageSchema? The subschema for nested messages (optional).
-
---- @class ProtoMessageSchema
---- @field name string The name of the message type.
---- @field options table Message options.
---- @field fields table A map of field numbers to ProtoFieldSchema definitions.
-
---- @class ProtoServiceMethodSchema
---- @field service string The name of the service.
---- @field method string The method name.
---- @field inputType ProtoMessageSchema The protobuf message type for the request.
---- @field outputType ProtoMessageSchema The protobuf message type for the response.
-
---- @class ProtoServiceSchema
---- @field [string] ProtoServiceMethodSchema Maps method names to their method definitions.
-
---- @class ProtoSchema
---- @field WireType WireType Maps protobuf wire types to their integer values.
---- @field DataType DataType Maps protobuf data types to their integer values.
---- @field Message table Maps message names to their definitions.
---- @field Enum table Maps enum names to their definitions.
---- @field RPC table Maps service names to their method definitions.
-local PROTOBUF_SCHEMA = {}
-
---- @enum WireType
-PROTOBUF_SCHEMA.WireType = {
- VARINT = 0,
- FIXED64 = 1,
- LENGTH_DELIMITED = 2,
- FIXED32 = 5,
-}
-
---- @enum DataType
-PROTOBUF_SCHEMA.DataType = {
- DOUBLE = 1,
- FLOAT = 2,
- INT64 = 3,
- UINT64 = 4,
- INT32 = 5,
- FIXED64 = 6,
- FIXED32 = 7,
- BOOL = 8,
- STRING = 9,
- MESSAGE = 11,
- BYTES = 12,
- UINT32 = 13,
- ENUM = 14,
- SFIXED32 = 15,
- SFIXED64 = 16,
- SINT32 = 17,
- SINT64 = 18,
-}
-
-PROTOBUF_SCHEMA.Enum = {
- --- @enum APISourceType
- APISourceType = {
- SOURCE_BOTH = 0,
- SOURCE_SERVER = 1,
- SOURCE_CLIENT = 2,
- },
- --- @enum EntityCategory
- EntityCategory = {
- ENTITY_CATEGORY_NONE = 0,
- ENTITY_CATEGORY_CONFIG = 1,
- ENTITY_CATEGORY_DIAGNOSTIC = 2,
- },
- --- @enum LegacyCoverState
- LegacyCoverState = {
- LEGACY_COVER_STATE_OPEN = 0,
- LEGACY_COVER_STATE_CLOSED = 1,
- },
- --- @enum CoverOperation
- CoverOperation = {
- COVER_OPERATION_IDLE = 0,
- COVER_OPERATION_IS_OPENING = 1,
- COVER_OPERATION_IS_CLOSING = 2,
- },
- --- @enum LegacyCoverCommand
- LegacyCoverCommand = {
- LEGACY_COVER_COMMAND_OPEN = 0,
- LEGACY_COVER_COMMAND_CLOSE = 1,
- LEGACY_COVER_COMMAND_STOP = 2,
- },
- --- @enum FanSpeed
- FanSpeed = {
- FAN_SPEED_LOW = 0,
- FAN_SPEED_MEDIUM = 1,
- FAN_SPEED_HIGH = 2,
- },
- --- @enum FanDirection
- FanDirection = {
- FAN_DIRECTION_FORWARD = 0,
- FAN_DIRECTION_REVERSE = 1,
- },
- --- @enum ColorMode
- ColorMode = {
- COLOR_MODE_UNKNOWN = 0,
- COLOR_MODE_ON_OFF = 1,
- COLOR_MODE_LEGACY_BRIGHTNESS = 2,
- COLOR_MODE_BRIGHTNESS = 3,
- COLOR_MODE_WHITE = 7,
- COLOR_MODE_COLOR_TEMPERATURE = 11,
- COLOR_MODE_COLD_WARM_WHITE = 19,
- COLOR_MODE_RGB = 35,
- COLOR_MODE_RGB_WHITE = 39,
- COLOR_MODE_RGB_COLOR_TEMPERATURE = 47,
- COLOR_MODE_RGB_COLD_WARM_WHITE = 51,
- },
- --- @enum SensorStateClass
- SensorStateClass = {
- STATE_CLASS_NONE = 0,
- STATE_CLASS_MEASUREMENT = 1,
- STATE_CLASS_TOTAL_INCREASING = 2,
- STATE_CLASS_TOTAL = 3,
- },
- --- @enum SensorLastResetType
- SensorLastResetType = {
- LAST_RESET_NONE = 0,
- LAST_RESET_NEVER = 1,
- LAST_RESET_AUTO = 2,
- },
- --- @enum LogLevel
- LogLevel = {
- LOG_LEVEL_NONE = 0,
- LOG_LEVEL_ERROR = 1,
- LOG_LEVEL_WARN = 2,
- LOG_LEVEL_INFO = 3,
- LOG_LEVEL_CONFIG = 4,
- LOG_LEVEL_DEBUG = 5,
- LOG_LEVEL_VERBOSE = 6,
- LOG_LEVEL_VERY_VERBOSE = 7,
- },
- --- @enum ServiceArgType
- ServiceArgType = {
- SERVICE_ARG_TYPE_BOOL = 0,
- SERVICE_ARG_TYPE_INT = 1,
- SERVICE_ARG_TYPE_FLOAT = 2,
- SERVICE_ARG_TYPE_STRING = 3,
- SERVICE_ARG_TYPE_BOOL_ARRAY = 4,
- SERVICE_ARG_TYPE_INT_ARRAY = 5,
- SERVICE_ARG_TYPE_FLOAT_ARRAY = 6,
- SERVICE_ARG_TYPE_STRING_ARRAY = 7,
- },
- --- @enum ClimateMode
- ClimateMode = {
- CLIMATE_MODE_OFF = 0,
- CLIMATE_MODE_HEAT_COOL = 1,
- CLIMATE_MODE_COOL = 2,
- CLIMATE_MODE_HEAT = 3,
- CLIMATE_MODE_FAN_ONLY = 4,
- CLIMATE_MODE_DRY = 5,
- CLIMATE_MODE_AUTO = 6,
- },
- --- @enum ClimateFanMode
- ClimateFanMode = {
- CLIMATE_FAN_ON = 0,
- CLIMATE_FAN_OFF = 1,
- CLIMATE_FAN_AUTO = 2,
- CLIMATE_FAN_LOW = 3,
- CLIMATE_FAN_MEDIUM = 4,
- CLIMATE_FAN_HIGH = 5,
- CLIMATE_FAN_MIDDLE = 6,
- CLIMATE_FAN_FOCUS = 7,
- CLIMATE_FAN_DIFFUSE = 8,
- CLIMATE_FAN_QUIET = 9,
- },
- --- @enum ClimateSwingMode
- ClimateSwingMode = {
- CLIMATE_SWING_OFF = 0,
- CLIMATE_SWING_BOTH = 1,
- CLIMATE_SWING_VERTICAL = 2,
- CLIMATE_SWING_HORIZONTAL = 3,
- },
- --- @enum ClimateAction
- ClimateAction = {
- CLIMATE_ACTION_OFF = 0,
- CLIMATE_ACTION_COOLING = 2,
- CLIMATE_ACTION_HEATING = 3,
- CLIMATE_ACTION_IDLE = 4,
- CLIMATE_ACTION_DRYING = 5,
- CLIMATE_ACTION_FAN = 6,
- },
- --- @enum ClimatePreset
- ClimatePreset = {
- CLIMATE_PRESET_NONE = 0,
- CLIMATE_PRESET_HOME = 1,
- CLIMATE_PRESET_AWAY = 2,
- CLIMATE_PRESET_BOOST = 3,
- CLIMATE_PRESET_COMFORT = 4,
- CLIMATE_PRESET_ECO = 5,
- CLIMATE_PRESET_SLEEP = 6,
- CLIMATE_PRESET_ACTIVITY = 7,
- },
- --- @enum NumberMode
- NumberMode = {
- NUMBER_MODE_AUTO = 0,
- NUMBER_MODE_BOX = 1,
- NUMBER_MODE_SLIDER = 2,
- },
- --- @enum LockState
- LockState = {
- LOCK_STATE_NONE = 0,
- LOCK_STATE_LOCKED = 1,
- LOCK_STATE_UNLOCKED = 2,
- LOCK_STATE_JAMMED = 3,
- LOCK_STATE_LOCKING = 4,
- LOCK_STATE_UNLOCKING = 5,
- },
- --- @enum LockCommand
- LockCommand = {
- LOCK_UNLOCK = 0,
- LOCK_LOCK = 1,
- LOCK_OPEN = 2,
- },
- --- @enum MediaPlayerState
- MediaPlayerState = {
- MEDIA_PLAYER_STATE_NONE = 0,
- MEDIA_PLAYER_STATE_IDLE = 1,
- MEDIA_PLAYER_STATE_PLAYING = 2,
- MEDIA_PLAYER_STATE_PAUSED = 3,
- },
- --- @enum MediaPlayerCommand
- MediaPlayerCommand = {
- MEDIA_PLAYER_COMMAND_PLAY = 0,
- MEDIA_PLAYER_COMMAND_PAUSE = 1,
- MEDIA_PLAYER_COMMAND_STOP = 2,
- MEDIA_PLAYER_COMMAND_MUTE = 3,
- MEDIA_PLAYER_COMMAND_UNMUTE = 4,
- },
- --- @enum MediaPlayerFormatPurpose
- MediaPlayerFormatPurpose = {
- MEDIA_PLAYER_FORMAT_PURPOSE_DEFAULT = 0,
- MEDIA_PLAYER_FORMAT_PURPOSE_ANNOUNCEMENT = 1,
- },
- --- @enum BluetoothDeviceRequestType
- BluetoothDeviceRequestType = {
- BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT = 0,
- BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT = 1,
- BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR = 2,
- BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR = 3,
- BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITH_CACHE = 4,
- BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE = 5,
- BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE = 6,
- },
- --- @enum BluetoothScannerState
- BluetoothScannerState = {
- BLUETOOTH_SCANNER_STATE_IDLE = 0,
- BLUETOOTH_SCANNER_STATE_STARTING = 1,
- BLUETOOTH_SCANNER_STATE_RUNNING = 2,
- BLUETOOTH_SCANNER_STATE_FAILED = 3,
- BLUETOOTH_SCANNER_STATE_STOPPING = 4,
- BLUETOOTH_SCANNER_STATE_STOPPED = 5,
- },
- --- @enum BluetoothScannerMode
- BluetoothScannerMode = {
- BLUETOOTH_SCANNER_MODE_PASSIVE = 0,
- BLUETOOTH_SCANNER_MODE_ACTIVE = 1,
- },
- --- @enum VoiceAssistantSubscribeFlag
- VoiceAssistantSubscribeFlag = {
- VOICE_ASSISTANT_SUBSCRIBE_NONE = 0,
- VOICE_ASSISTANT_SUBSCRIBE_API_AUDIO = 1,
- },
- --- @enum VoiceAssistantRequestFlag
- VoiceAssistantRequestFlag = {
- VOICE_ASSISTANT_REQUEST_NONE = 0,
- VOICE_ASSISTANT_REQUEST_USE_VAD = 1,
- VOICE_ASSISTANT_REQUEST_USE_WAKE_WORD = 2,
- },
- --- @enum VoiceAssistantEvent
- VoiceAssistantEvent = {
- VOICE_ASSISTANT_ERROR = 0,
- VOICE_ASSISTANT_RUN_START = 1,
- VOICE_ASSISTANT_RUN_END = 2,
- VOICE_ASSISTANT_STT_START = 3,
- VOICE_ASSISTANT_STT_END = 4,
- VOICE_ASSISTANT_INTENT_START = 5,
- VOICE_ASSISTANT_INTENT_END = 6,
- VOICE_ASSISTANT_TTS_START = 7,
- VOICE_ASSISTANT_TTS_END = 8,
- VOICE_ASSISTANT_WAKE_WORD_START = 9,
- VOICE_ASSISTANT_WAKE_WORD_END = 10,
- VOICE_ASSISTANT_STT_VAD_START = 11,
- VOICE_ASSISTANT_STT_VAD_END = 12,
- VOICE_ASSISTANT_TTS_STREAM_START = 98,
- VOICE_ASSISTANT_TTS_STREAM_END = 99,
- VOICE_ASSISTANT_INTENT_PROGRESS = 100,
- },
- --- @enum VoiceAssistantTimerEvent
- VoiceAssistantTimerEvent = {
- VOICE_ASSISTANT_TIMER_STARTED = 0,
- VOICE_ASSISTANT_TIMER_UPDATED = 1,
- VOICE_ASSISTANT_TIMER_CANCELLED = 2,
- VOICE_ASSISTANT_TIMER_FINISHED = 3,
- },
- --- @enum AlarmControlPanelState
- AlarmControlPanelState = {
- ALARM_STATE_DISARMED = 0,
- ALARM_STATE_ARMED_HOME = 1,
- ALARM_STATE_ARMED_AWAY = 2,
- ALARM_STATE_ARMED_NIGHT = 3,
- ALARM_STATE_ARMED_VACATION = 4,
- ALARM_STATE_ARMED_CUSTOM_BYPASS = 5,
- ALARM_STATE_PENDING = 6,
- ALARM_STATE_ARMING = 7,
- ALARM_STATE_DISARMING = 8,
- ALARM_STATE_TRIGGERED = 9,
- },
- --- @enum AlarmControlPanelStateCommand
- AlarmControlPanelStateCommand = {
- ALARM_CONTROL_PANEL_DISARM = 0,
- ALARM_CONTROL_PANEL_ARM_AWAY = 1,
- ALARM_CONTROL_PANEL_ARM_HOME = 2,
- ALARM_CONTROL_PANEL_ARM_NIGHT = 3,
- ALARM_CONTROL_PANEL_ARM_VACATION = 4,
- ALARM_CONTROL_PANEL_ARM_CUSTOM_BYPASS = 5,
- ALARM_CONTROL_PANEL_TRIGGER = 6,
- },
- --- @enum TextMode
- TextMode = {
- TEXT_MODE_TEXT = 0,
- TEXT_MODE_PASSWORD = 1,
- },
- --- @enum ValveOperation
- ValveOperation = {
- VALVE_OPERATION_IDLE = 0,
- VALVE_OPERATION_IS_OPENING = 1,
- VALVE_OPERATION_IS_CLOSING = 2,
- },
- --- @enum UpdateCommand
- UpdateCommand = {
- UPDATE_COMMAND_NONE = 0,
- UPDATE_COMMAND_UPDATE = 1,
- UPDATE_COMMAND_CHECK = 2,
- },
-}
-
---- @alias ProtoEnum APISourceType|EntityCategory|LegacyCoverState|CoverOperation|LegacyCoverCommand|FanSpeed|FanDirection|ColorMode|SensorStateClass|SensorLastResetType|LogLevel|ServiceArgType|ClimateMode|ClimateFanMode|ClimateSwingMode|ClimateAction|ClimatePreset|NumberMode|LockState|LockCommand|MediaPlayerState|MediaPlayerCommand|MediaPlayerFormatPurpose|BluetoothDeviceRequestType|BluetoothScannerState|BluetoothScannerMode|VoiceAssistantSubscribeFlag|VoiceAssistantRequestFlag|VoiceAssistantEvent|VoiceAssistantTimerEvent|AlarmControlPanelState|AlarmControlPanelStateCommand|TextMode|ValveOperation|UpdateCommand
-
-PROTOBUF_SCHEMA.Message = {
- void = {
- name = "void",
- options = {},
- fields = {},
- },
- HelloRequest = {
- name = "HelloRequest",
- options = {
- id = 1,
- source = 2,
- no_delay = 1,
- },
- fields = {
- [1] = {
- name = "client_info",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [2] = {
- name = "api_version_major",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [3] = {
- name = "api_version_minor",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- HelloResponse = {
- name = "HelloResponse",
- options = {
- id = 2,
- source = 1,
- no_delay = 1,
- },
- fields = {
- [1] = {
- name = "api_version_major",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [2] = {
- name = "api_version_minor",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [3] = {
- name = "server_info",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [4] = {
- name = "name",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- },
- },
- ConnectRequest = {
- name = "ConnectRequest",
- options = {
- id = 3,
- source = 2,
- no_delay = 1,
- },
- fields = {
- [1] = {
- name = "password",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- },
- },
- ConnectResponse = {
- name = "ConnectResponse",
- options = {
- id = 4,
- source = 1,
- no_delay = 1,
- },
- fields = {
- [1] = {
- name = "invalid_password",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- },
- },
- DisconnectRequest = {
- name = "DisconnectRequest",
- options = {
- id = 5,
- source = 0,
- no_delay = 1,
- },
- fields = {},
- },
- DisconnectResponse = {
- name = "DisconnectResponse",
- options = {
- id = 6,
- source = 0,
- no_delay = 1,
- },
- fields = {},
- },
- PingRequest = {
- name = "PingRequest",
- options = {
- id = 7,
- source = 0,
- },
- fields = {},
- },
- PingResponse = {
- name = "PingResponse",
- options = {
- id = 8,
- source = 0,
- },
- fields = {},
- },
- DeviceInfoRequest = {
- name = "DeviceInfoRequest",
- options = {
- id = 9,
- source = 2,
- },
- fields = {},
- },
- AreaInfo = {
- name = "AreaInfo",
- options = {},
- fields = {
- [1] = {
- name = "area_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [2] = {
- name = "name",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- },
- },
- DeviceInfo = {
- name = "DeviceInfo",
- options = {},
- fields = {
- [1] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [2] = {
- name = "name",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [3] = {
- name = "area_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- DeviceInfoResponse = {
- name = "DeviceInfoResponse",
- options = {
- id = 10,
- source = 1,
- },
- fields = {
- [1] = {
- name = "uses_password",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [2] = {
- name = "name",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [3] = {
- name = "mac_address",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [4] = {
- name = "esphome_version",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [5] = {
- name = "compilation_time",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [6] = {
- name = "model",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [7] = {
- name = "has_deep_sleep",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [8] = {
- name = "project_name",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [9] = {
- name = "project_version",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [10] = {
- name = "webserver_port",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [11] = {
- name = "legacy_bluetooth_proxy_version",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [15] = {
- name = "bluetooth_proxy_feature_flags",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [12] = {
- name = "manufacturer",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [13] = {
- name = "friendly_name",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [14] = {
- name = "legacy_voice_assistant_version",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [17] = {
- name = "voice_assistant_feature_flags",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [16] = {
- name = "suggested_area",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [18] = {
- name = "bluetooth_mac_address",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [19] = {
- name = "api_encryption_supported",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [20] = {
- name = "devices",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.MESSAGE,
- repeated = true,
- },
- [21] = {
- name = "areas",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.MESSAGE,
- repeated = true,
- },
- [22] = {
- name = "area",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.MESSAGE,
- },
- },
- },
- ListEntitiesRequest = {
- name = "ListEntitiesRequest",
- options = {
- id = 11,
- source = 2,
- },
- fields = {},
- },
- ListEntitiesDoneResponse = {
- name = "ListEntitiesDoneResponse",
- options = {
- id = 19,
- source = 1,
- no_delay = 1,
- },
- fields = {},
- },
- SubscribeStatesRequest = {
- name = "SubscribeStatesRequest",
- options = {
- id = 20,
- source = 2,
- },
- fields = {},
- },
- ListEntitiesBinarySensorResponse = {
- name = "ListEntitiesBinarySensorResponse",
- options = {
- id = 12,
- source = 1,
- ifdef = "USE_BINARY_SENSOR",
- base_class = "InfoResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "object_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [2] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [3] = {
- name = "name",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [4] = {
- name = "unique_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [5] = {
- name = "device_class",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [6] = {
- name = "is_status_binary_sensor",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [7] = {
- name = "disabled_by_default",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [8] = {
- name = "icon",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [9] = {
- name = "entity_category",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- EntityCategory
- },
- [10] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- BinarySensorStateResponse = {
- name = "BinarySensorStateResponse",
- options = {
- id = 21,
- source = 1,
- ifdef = "USE_BINARY_SENSOR",
- no_delay = 1,
- base_class = "StateResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "state",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [3] = {
- name = "missing_state",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [4] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- ListEntitiesCoverResponse = {
- name = "ListEntitiesCoverResponse",
- options = {
- id = 13,
- source = 1,
- ifdef = "USE_COVER",
- base_class = "InfoResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "object_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [2] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [3] = {
- name = "name",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [4] = {
- name = "unique_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [5] = {
- name = "assumed_state",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [6] = {
- name = "supports_position",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [7] = {
- name = "supports_tilt",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [8] = {
- name = "device_class",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [9] = {
- name = "disabled_by_default",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [10] = {
- name = "icon",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [11] = {
- name = "entity_category",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- EntityCategory
- },
- [12] = {
- name = "supports_stop",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [13] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- CoverStateResponse = {
- name = "CoverStateResponse",
- options = {
- id = 22,
- source = 1,
- ifdef = "USE_COVER",
- no_delay = 1,
- base_class = "StateResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "legacy_state",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- LegacyCoverState
- },
- [3] = {
- name = "position",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [4] = {
- name = "tilt",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [5] = {
- name = "current_operation",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- CoverOperation
- },
- [6] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- CoverCommandRequest = {
- name = "CoverCommandRequest",
- options = {
- id = 30,
- source = 2,
- ifdef = "USE_COVER",
- no_delay = 1,
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "has_legacy_command",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [3] = {
- name = "legacy_command",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- LegacyCoverCommand
- },
- [4] = {
- name = "has_position",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [5] = {
- name = "position",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [6] = {
- name = "has_tilt",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [7] = {
- name = "tilt",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [8] = {
- name = "stop",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- },
- },
- ListEntitiesFanResponse = {
- name = "ListEntitiesFanResponse",
- options = {
- id = 14,
- source = 1,
- ifdef = "USE_FAN",
- base_class = "InfoResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "object_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [2] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [3] = {
- name = "name",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [4] = {
- name = "unique_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [5] = {
- name = "supports_oscillation",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [6] = {
- name = "supports_speed",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [7] = {
- name = "supports_direction",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [8] = {
- name = "supported_speed_count",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.INT32,
- },
- [9] = {
- name = "disabled_by_default",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [10] = {
- name = "icon",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [11] = {
- name = "entity_category",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- EntityCategory
- },
- [12] = {
- name = "supported_preset_modes",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- repeated = true,
- },
- [13] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- FanStateResponse = {
- name = "FanStateResponse",
- options = {
- id = 23,
- source = 1,
- ifdef = "USE_FAN",
- no_delay = 1,
- base_class = "StateResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "state",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [3] = {
- name = "oscillating",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [4] = {
- name = "speed",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- FanSpeed
- },
- [5] = {
- name = "direction",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- FanDirection
- },
- [6] = {
- name = "speed_level",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.INT32,
- },
- [7] = {
- name = "preset_mode",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [8] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- FanCommandRequest = {
- name = "FanCommandRequest",
- options = {
- id = 31,
- source = 2,
- ifdef = "USE_FAN",
- no_delay = 1,
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "has_state",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [3] = {
- name = "state",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [4] = {
- name = "has_speed",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [5] = {
- name = "speed",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- FanSpeed
- },
- [6] = {
- name = "has_oscillating",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [7] = {
- name = "oscillating",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [8] = {
- name = "has_direction",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [9] = {
- name = "direction",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- FanDirection
- },
- [10] = {
- name = "has_speed_level",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [11] = {
- name = "speed_level",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.INT32,
- },
- [12] = {
- name = "has_preset_mode",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [13] = {
- name = "preset_mode",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- },
- },
- ListEntitiesLightResponse = {
- name = "ListEntitiesLightResponse",
- options = {
- id = 15,
- source = 1,
- ifdef = "USE_LIGHT",
- base_class = "InfoResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "object_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [2] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [3] = {
- name = "name",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [4] = {
- name = "unique_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [12] = {
- name = "supported_color_modes",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- ColorMode
- repeated = true,
- },
- [5] = {
- name = "legacy_supports_brightness",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [6] = {
- name = "legacy_supports_rgb",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [7] = {
- name = "legacy_supports_white_value",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [8] = {
- name = "legacy_supports_color_temperature",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [9] = {
- name = "min_mireds",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [10] = {
- name = "max_mireds",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [11] = {
- name = "effects",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- repeated = true,
- },
- [13] = {
- name = "disabled_by_default",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [14] = {
- name = "icon",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [15] = {
- name = "entity_category",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- EntityCategory
- },
- [16] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- LightStateResponse = {
- name = "LightStateResponse",
- options = {
- id = 24,
- source = 1,
- ifdef = "USE_LIGHT",
- no_delay = 1,
- base_class = "StateResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "state",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [3] = {
- name = "brightness",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [11] = {
- name = "color_mode",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- ColorMode
- },
- [10] = {
- name = "color_brightness",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [4] = {
- name = "red",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [5] = {
- name = "green",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [6] = {
- name = "blue",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [7] = {
- name = "white",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [8] = {
- name = "color_temperature",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [12] = {
- name = "cold_white",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [13] = {
- name = "warm_white",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [9] = {
- name = "effect",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [14] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- LightCommandRequest = {
- name = "LightCommandRequest",
- options = {
- id = 32,
- source = 2,
- ifdef = "USE_LIGHT",
- no_delay = 1,
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "has_state",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [3] = {
- name = "state",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [4] = {
- name = "has_brightness",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [5] = {
- name = "brightness",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [22] = {
- name = "has_color_mode",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [23] = {
- name = "color_mode",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- ColorMode
- },
- [20] = {
- name = "has_color_brightness",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [21] = {
- name = "color_brightness",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [6] = {
- name = "has_rgb",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [7] = {
- name = "red",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [8] = {
- name = "green",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [9] = {
- name = "blue",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [10] = {
- name = "has_white",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [11] = {
- name = "white",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [12] = {
- name = "has_color_temperature",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [13] = {
- name = "color_temperature",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [24] = {
- name = "has_cold_white",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [25] = {
- name = "cold_white",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [26] = {
- name = "has_warm_white",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [27] = {
- name = "warm_white",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [14] = {
- name = "has_transition_length",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [15] = {
- name = "transition_length",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [16] = {
- name = "has_flash_length",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [17] = {
- name = "flash_length",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [18] = {
- name = "has_effect",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [19] = {
- name = "effect",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- },
- },
- ListEntitiesSensorResponse = {
- name = "ListEntitiesSensorResponse",
- options = {
- id = 16,
- source = 1,
- ifdef = "USE_SENSOR",
- base_class = "InfoResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "object_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [2] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [3] = {
- name = "name",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [4] = {
- name = "unique_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [5] = {
- name = "icon",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [6] = {
- name = "unit_of_measurement",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [7] = {
- name = "accuracy_decimals",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.INT32,
- },
- [8] = {
- name = "force_update",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [9] = {
- name = "device_class",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [10] = {
- name = "state_class",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- SensorStateClass
- },
- [11] = {
- name = "legacy_last_reset_type",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- SensorLastResetType
- },
- [12] = {
- name = "disabled_by_default",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [13] = {
- name = "entity_category",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- EntityCategory
- },
- [14] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- SensorStateResponse = {
- name = "SensorStateResponse",
- options = {
- id = 25,
- source = 1,
- ifdef = "USE_SENSOR",
- no_delay = 1,
- base_class = "StateResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "state",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [3] = {
- name = "missing_state",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [4] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- ListEntitiesSwitchResponse = {
- name = "ListEntitiesSwitchResponse",
- options = {
- id = 17,
- source = 1,
- ifdef = "USE_SWITCH",
- base_class = "InfoResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "object_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [2] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [3] = {
- name = "name",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [4] = {
- name = "unique_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [5] = {
- name = "icon",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [6] = {
- name = "assumed_state",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [7] = {
- name = "disabled_by_default",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [8] = {
- name = "entity_category",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- EntityCategory
- },
- [9] = {
- name = "device_class",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [10] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- SwitchStateResponse = {
- name = "SwitchStateResponse",
- options = {
- id = 26,
- source = 1,
- ifdef = "USE_SWITCH",
- no_delay = 1,
- base_class = "StateResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "state",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [3] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- SwitchCommandRequest = {
- name = "SwitchCommandRequest",
- options = {
- id = 33,
- source = 2,
- ifdef = "USE_SWITCH",
- no_delay = 1,
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "state",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- },
- },
- ListEntitiesTextSensorResponse = {
- name = "ListEntitiesTextSensorResponse",
- options = {
- id = 18,
- source = 1,
- ifdef = "USE_TEXT_SENSOR",
- base_class = "InfoResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "object_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [2] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [3] = {
- name = "name",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [4] = {
- name = "unique_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [5] = {
- name = "icon",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [6] = {
- name = "disabled_by_default",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [7] = {
- name = "entity_category",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- EntityCategory
- },
- [8] = {
- name = "device_class",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [9] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- TextSensorStateResponse = {
- name = "TextSensorStateResponse",
- options = {
- id = 27,
- source = 1,
- ifdef = "USE_TEXT_SENSOR",
- no_delay = 1,
- base_class = "StateResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "state",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [3] = {
- name = "missing_state",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [4] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- SubscribeLogsRequest = {
- name = "SubscribeLogsRequest",
- options = {
- id = 28,
- source = 2,
- },
- fields = {
- [1] = {
- name = "level",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- LogLevel
- },
- [2] = {
- name = "dump_config",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- },
- },
- SubscribeLogsResponse = {
- name = "SubscribeLogsResponse",
- options = {
- id = 29,
- source = 1,
- log = 0,
- no_delay = 0,
- },
- fields = {
- [1] = {
- name = "level",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- LogLevel
- },
- [3] = {
- name = "message",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.BYTES,
- },
- [4] = {
- name = "send_failed",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- },
- },
- NoiseEncryptionSetKeyRequest = {
- name = "NoiseEncryptionSetKeyRequest",
- options = {
- id = 124,
- source = 2,
- ifdef = "USE_API_NOISE",
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.BYTES,
- },
- },
- },
- NoiseEncryptionSetKeyResponse = {
- name = "NoiseEncryptionSetKeyResponse",
- options = {
- id = 125,
- source = 1,
- ifdef = "USE_API_NOISE",
- },
- fields = {
- [1] = {
- name = "success",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- },
- },
- SubscribeHomeassistantServicesRequest = {
- name = "SubscribeHomeassistantServicesRequest",
- options = {
- id = 34,
- source = 2,
- },
- fields = {},
- },
- HomeassistantServiceMap = {
- name = "HomeassistantServiceMap",
- options = {},
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [2] = {
- name = "value",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- },
- },
- HomeassistantServiceResponse = {
- name = "HomeassistantServiceResponse",
- options = {
- id = 35,
- source = 1,
- no_delay = 1,
- },
- fields = {
- [1] = {
- name = "service",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [2] = {
- name = "data",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.MESSAGE,
- repeated = true,
- },
- [3] = {
- name = "data_template",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.MESSAGE,
- repeated = true,
- },
- [4] = {
- name = "variables",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.MESSAGE,
- repeated = true,
- },
- [5] = {
- name = "is_event",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- },
- },
- SubscribeHomeAssistantStatesRequest = {
- name = "SubscribeHomeAssistantStatesRequest",
- options = {
- id = 38,
- source = 2,
- },
- fields = {},
- },
- SubscribeHomeAssistantStateResponse = {
- name = "SubscribeHomeAssistantStateResponse",
- options = {
- id = 39,
- source = 1,
- },
- fields = {
- [1] = {
- name = "entity_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [2] = {
- name = "attribute",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [3] = {
- name = "once",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- },
- },
- HomeAssistantStateResponse = {
- name = "HomeAssistantStateResponse",
- options = {
- id = 40,
- source = 2,
- no_delay = 1,
- },
- fields = {
- [1] = {
- name = "entity_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [2] = {
- name = "state",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [3] = {
- name = "attribute",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- },
- },
- GetTimeRequest = {
- name = "GetTimeRequest",
- options = {
- id = 36,
- source = 0,
- },
- fields = {},
- },
- GetTimeResponse = {
- name = "GetTimeResponse",
- options = {
- id = 37,
- source = 0,
- no_delay = 1,
- },
- fields = {
- [1] = {
- name = "epoch_seconds",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- },
- },
- ListEntitiesServicesArgument = {
- name = "ListEntitiesServicesArgument",
- options = {},
- fields = {
- [1] = {
- name = "name",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [2] = {
- name = "type",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- ServiceArgType
- },
- },
- },
- ListEntitiesServicesResponse = {
- name = "ListEntitiesServicesResponse",
- options = {
- id = 41,
- source = 1,
- },
- fields = {
- [1] = {
- name = "name",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [2] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [3] = {
- name = "args",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.MESSAGE,
- repeated = true,
- },
- },
- },
- ExecuteServiceArgument = {
- name = "ExecuteServiceArgument",
- options = {},
- fields = {
- [1] = {
- name = "bool_",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [2] = {
- name = "legacy_int",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.INT32,
- },
- [3] = {
- name = "float_",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [4] = {
- name = "string_",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [5] = {
- name = "int_",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.SINT32,
- },
- [6] = {
- name = "bool_array",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- repeated = true,
- },
- [7] = {
- name = "int_array",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.SINT32,
- repeated = true,
- },
- [8] = {
- name = "float_array",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- repeated = true,
- },
- [9] = {
- name = "string_array",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- repeated = true,
- },
- },
- },
- ExecuteServiceRequest = {
- name = "ExecuteServiceRequest",
- options = {
- id = 42,
- source = 2,
- no_delay = 1,
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "args",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.MESSAGE,
- repeated = true,
- },
- },
- },
- ListEntitiesCameraResponse = {
- name = "ListEntitiesCameraResponse",
- options = {
- id = 43,
- source = 1,
- ifdef = "USE_CAMERA",
- base_class = "InfoResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "object_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [2] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [3] = {
- name = "name",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [4] = {
- name = "unique_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [5] = {
- name = "disabled_by_default",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [6] = {
- name = "icon",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [7] = {
- name = "entity_category",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- EntityCategory
- },
- [8] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- CameraImageResponse = {
- name = "CameraImageResponse",
- options = {
- id = 44,
- source = 1,
- ifdef = "USE_CAMERA",
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "data",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.BYTES,
- },
- [3] = {
- name = "done",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- },
- },
- CameraImageRequest = {
- name = "CameraImageRequest",
- options = {
- id = 45,
- source = 2,
- ifdef = "USE_CAMERA",
- no_delay = 1,
- },
- fields = {
- [1] = {
- name = "single",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [2] = {
- name = "stream",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- },
- },
- ListEntitiesClimateResponse = {
- name = "ListEntitiesClimateResponse",
- options = {
- id = 46,
- source = 1,
- ifdef = "USE_CLIMATE",
- base_class = "InfoResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "object_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [2] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [3] = {
- name = "name",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [4] = {
- name = "unique_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [5] = {
- name = "supports_current_temperature",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [6] = {
- name = "supports_two_point_target_temperature",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [7] = {
- name = "supported_modes",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- ClimateMode
- repeated = true,
- },
- [8] = {
- name = "visual_min_temperature",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [9] = {
- name = "visual_max_temperature",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [10] = {
- name = "visual_target_temperature_step",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [11] = {
- name = "legacy_supports_away",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [12] = {
- name = "supports_action",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [13] = {
- name = "supported_fan_modes",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- ClimateFanMode
- repeated = true,
- },
- [14] = {
- name = "supported_swing_modes",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- ClimateSwingMode
- repeated = true,
- },
- [15] = {
- name = "supported_custom_fan_modes",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- repeated = true,
- },
- [16] = {
- name = "supported_presets",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- ClimatePreset
- repeated = true,
- },
- [17] = {
- name = "supported_custom_presets",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- repeated = true,
- },
- [18] = {
- name = "disabled_by_default",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [19] = {
- name = "icon",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [20] = {
- name = "entity_category",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- EntityCategory
- },
- [21] = {
- name = "visual_current_temperature_step",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [22] = {
- name = "supports_current_humidity",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [23] = {
- name = "supports_target_humidity",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [24] = {
- name = "visual_min_humidity",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [25] = {
- name = "visual_max_humidity",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [26] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- ClimateStateResponse = {
- name = "ClimateStateResponse",
- options = {
- id = 47,
- source = 1,
- ifdef = "USE_CLIMATE",
- no_delay = 1,
- base_class = "StateResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "mode",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- ClimateMode
- },
- [3] = {
- name = "current_temperature",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [4] = {
- name = "target_temperature",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [5] = {
- name = "target_temperature_low",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [6] = {
- name = "target_temperature_high",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [7] = {
- name = "unused_legacy_away",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [8] = {
- name = "action",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- ClimateAction
- },
- [9] = {
- name = "fan_mode",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- ClimateFanMode
- },
- [10] = {
- name = "swing_mode",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- ClimateSwingMode
- },
- [11] = {
- name = "custom_fan_mode",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [12] = {
- name = "preset",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- ClimatePreset
- },
- [13] = {
- name = "custom_preset",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [14] = {
- name = "current_humidity",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [15] = {
- name = "target_humidity",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [16] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- ClimateCommandRequest = {
- name = "ClimateCommandRequest",
- options = {
- id = 48,
- source = 2,
- ifdef = "USE_CLIMATE",
- no_delay = 1,
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "has_mode",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [3] = {
- name = "mode",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- ClimateMode
- },
- [4] = {
- name = "has_target_temperature",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [5] = {
- name = "target_temperature",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [6] = {
- name = "has_target_temperature_low",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [7] = {
- name = "target_temperature_low",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [8] = {
- name = "has_target_temperature_high",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [9] = {
- name = "target_temperature_high",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [10] = {
- name = "unused_has_legacy_away",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [11] = {
- name = "unused_legacy_away",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [12] = {
- name = "has_fan_mode",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [13] = {
- name = "fan_mode",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- ClimateFanMode
- },
- [14] = {
- name = "has_swing_mode",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [15] = {
- name = "swing_mode",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- ClimateSwingMode
- },
- [16] = {
- name = "has_custom_fan_mode",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [17] = {
- name = "custom_fan_mode",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [18] = {
- name = "has_preset",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [19] = {
- name = "preset",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- ClimatePreset
- },
- [20] = {
- name = "has_custom_preset",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [21] = {
- name = "custom_preset",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [22] = {
- name = "has_target_humidity",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [23] = {
- name = "target_humidity",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- },
- },
- ListEntitiesNumberResponse = {
- name = "ListEntitiesNumberResponse",
- options = {
- id = 49,
- source = 1,
- ifdef = "USE_NUMBER",
- base_class = "InfoResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "object_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [2] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [3] = {
- name = "name",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [4] = {
- name = "unique_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [5] = {
- name = "icon",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [6] = {
- name = "min_value",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [7] = {
- name = "max_value",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [8] = {
- name = "step",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [9] = {
- name = "disabled_by_default",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [10] = {
- name = "entity_category",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- EntityCategory
- },
- [11] = {
- name = "unit_of_measurement",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [12] = {
- name = "mode",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- NumberMode
- },
- [13] = {
- name = "device_class",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [14] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- NumberStateResponse = {
- name = "NumberStateResponse",
- options = {
- id = 50,
- source = 1,
- ifdef = "USE_NUMBER",
- no_delay = 1,
- base_class = "StateResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "state",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [3] = {
- name = "missing_state",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [4] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- NumberCommandRequest = {
- name = "NumberCommandRequest",
- options = {
- id = 51,
- source = 2,
- ifdef = "USE_NUMBER",
- no_delay = 1,
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "state",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- },
- },
- ListEntitiesSelectResponse = {
- name = "ListEntitiesSelectResponse",
- options = {
- id = 52,
- source = 1,
- ifdef = "USE_SELECT",
- base_class = "InfoResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "object_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [2] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [3] = {
- name = "name",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [4] = {
- name = "unique_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [5] = {
- name = "icon",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [6] = {
- name = "options",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- repeated = true,
- },
- [7] = {
- name = "disabled_by_default",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [8] = {
- name = "entity_category",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- EntityCategory
- },
- [9] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- SelectStateResponse = {
- name = "SelectStateResponse",
- options = {
- id = 53,
- source = 1,
- ifdef = "USE_SELECT",
- no_delay = 1,
- base_class = "StateResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "state",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [3] = {
- name = "missing_state",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [4] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- SelectCommandRequest = {
- name = "SelectCommandRequest",
- options = {
- id = 54,
- source = 2,
- ifdef = "USE_SELECT",
- no_delay = 1,
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "state",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- },
- },
- ListEntitiesSirenResponse = {
- name = "ListEntitiesSirenResponse",
- options = {
- id = 55,
- source = 1,
- ifdef = "USE_SIREN",
- base_class = "InfoResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "object_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [2] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [3] = {
- name = "name",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [4] = {
- name = "unique_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [5] = {
- name = "icon",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [6] = {
- name = "disabled_by_default",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [7] = {
- name = "tones",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- repeated = true,
- },
- [8] = {
- name = "supports_duration",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [9] = {
- name = "supports_volume",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [10] = {
- name = "entity_category",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- EntityCategory
- },
- [11] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- SirenStateResponse = {
- name = "SirenStateResponse",
- options = {
- id = 56,
- source = 1,
- ifdef = "USE_SIREN",
- no_delay = 1,
- base_class = "StateResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "state",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [3] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- SirenCommandRequest = {
- name = "SirenCommandRequest",
- options = {
- id = 57,
- source = 2,
- ifdef = "USE_SIREN",
- no_delay = 1,
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "has_state",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [3] = {
- name = "state",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [4] = {
- name = "has_tone",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [5] = {
- name = "tone",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [6] = {
- name = "has_duration",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [7] = {
- name = "duration",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [8] = {
- name = "has_volume",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [9] = {
- name = "volume",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- },
- },
- ListEntitiesLockResponse = {
- name = "ListEntitiesLockResponse",
- options = {
- id = 58,
- source = 1,
- ifdef = "USE_LOCK",
- base_class = "InfoResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "object_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [2] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [3] = {
- name = "name",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [4] = {
- name = "unique_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [5] = {
- name = "icon",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [6] = {
- name = "disabled_by_default",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [7] = {
- name = "entity_category",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- EntityCategory
- },
- [8] = {
- name = "assumed_state",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [9] = {
- name = "supports_open",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [10] = {
- name = "requires_code",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [11] = {
- name = "code_format",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [12] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- LockStateResponse = {
- name = "LockStateResponse",
- options = {
- id = 59,
- source = 1,
- ifdef = "USE_LOCK",
- no_delay = 1,
- base_class = "StateResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "state",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- LockState
- },
- [3] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- LockCommandRequest = {
- name = "LockCommandRequest",
- options = {
- id = 60,
- source = 2,
- ifdef = "USE_LOCK",
- no_delay = 1,
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "command",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- LockCommand
- },
- [3] = {
- name = "has_code",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [4] = {
- name = "code",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- },
- },
- ListEntitiesButtonResponse = {
- name = "ListEntitiesButtonResponse",
- options = {
- id = 61,
- source = 1,
- ifdef = "USE_BUTTON",
- base_class = "InfoResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "object_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [2] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [3] = {
- name = "name",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [4] = {
- name = "unique_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [5] = {
- name = "icon",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [6] = {
- name = "disabled_by_default",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [7] = {
- name = "entity_category",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- EntityCategory
- },
- [8] = {
- name = "device_class",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [9] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- ButtonCommandRequest = {
- name = "ButtonCommandRequest",
- options = {
- id = 62,
- source = 2,
- ifdef = "USE_BUTTON",
- no_delay = 1,
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- },
- },
- MediaPlayerSupportedFormat = {
- name = "MediaPlayerSupportedFormat",
- options = {
- ifdef = "USE_MEDIA_PLAYER",
- },
- fields = {
- [1] = {
- name = "format",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [2] = {
- name = "sample_rate",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [3] = {
- name = "num_channels",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [4] = {
- name = "purpose",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- MediaPlayerFormatPurpose
- },
- [5] = {
- name = "sample_bytes",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- ListEntitiesMediaPlayerResponse = {
- name = "ListEntitiesMediaPlayerResponse",
- options = {
- id = 63,
- source = 1,
- ifdef = "USE_MEDIA_PLAYER",
- base_class = "InfoResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "object_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [2] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [3] = {
- name = "name",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [4] = {
- name = "unique_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [5] = {
- name = "icon",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [6] = {
- name = "disabled_by_default",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [7] = {
- name = "entity_category",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- EntityCategory
- },
- [8] = {
- name = "supports_pause",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [9] = {
- name = "supported_formats",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.MESSAGE,
- repeated = true,
- },
- [10] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- MediaPlayerStateResponse = {
- name = "MediaPlayerStateResponse",
- options = {
- id = 64,
- source = 1,
- ifdef = "USE_MEDIA_PLAYER",
- no_delay = 1,
- base_class = "StateResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "state",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- MediaPlayerState
- },
- [3] = {
- name = "volume",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [4] = {
- name = "muted",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [5] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- MediaPlayerCommandRequest = {
- name = "MediaPlayerCommandRequest",
- options = {
- id = 65,
- source = 2,
- ifdef = "USE_MEDIA_PLAYER",
- no_delay = 1,
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "has_command",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [3] = {
- name = "command",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- MediaPlayerCommand
- },
- [4] = {
- name = "has_volume",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [5] = {
- name = "volume",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [6] = {
- name = "has_media_url",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [7] = {
- name = "media_url",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [8] = {
- name = "has_announcement",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [9] = {
- name = "announcement",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- },
- },
- SubscribeBluetoothLEAdvertisementsRequest = {
- name = "SubscribeBluetoothLEAdvertisementsRequest",
- options = {
- id = 66,
- source = 2,
- ifdef = "USE_BLUETOOTH_PROXY",
- },
- fields = {
- [1] = {
- name = "flags",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- BluetoothServiceData = {
- name = "BluetoothServiceData",
- options = {},
- fields = {
- [1] = {
- name = "uuid",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [2] = {
- name = "legacy_data",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- repeated = true,
- },
- [3] = {
- name = "data",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.BYTES,
- },
- },
- },
- BluetoothLEAdvertisementResponse = {
- name = "BluetoothLEAdvertisementResponse",
- options = {
- id = 67,
- source = 1,
- ifdef = "USE_BLUETOOTH_PROXY",
- no_delay = 1,
- },
- fields = {
- [1] = {
- name = "address",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT64,
- },
- [2] = {
- name = "name",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.BYTES,
- },
- [3] = {
- name = "rssi",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.SINT32,
- },
- [4] = {
- name = "service_uuids",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- repeated = true,
- },
- [5] = {
- name = "service_data",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.MESSAGE,
- repeated = true,
- },
- [6] = {
- name = "manufacturer_data",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.MESSAGE,
- repeated = true,
- },
- [7] = {
- name = "address_type",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- BluetoothLERawAdvertisement = {
- name = "BluetoothLERawAdvertisement",
- options = {},
- fields = {
- [1] = {
- name = "address",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT64,
- },
- [2] = {
- name = "rssi",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.SINT32,
- },
- [3] = {
- name = "address_type",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [4] = {
- name = "data",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.BYTES,
- },
- },
- },
- BluetoothLERawAdvertisementsResponse = {
- name = "BluetoothLERawAdvertisementsResponse",
- options = {
- id = 93,
- source = 1,
- ifdef = "USE_BLUETOOTH_PROXY",
- no_delay = 1,
- },
- fields = {
- [1] = {
- name = "advertisements",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.MESSAGE,
- repeated = true,
- },
- },
- },
- BluetoothDeviceRequest = {
- name = "BluetoothDeviceRequest",
- options = {
- id = 68,
- source = 2,
- ifdef = "USE_BLUETOOTH_PROXY",
- },
- fields = {
- [1] = {
- name = "address",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT64,
- },
- [2] = {
- name = "request_type",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- BluetoothDeviceRequestType
- },
- [3] = {
- name = "has_address_type",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [4] = {
- name = "address_type",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- BluetoothDeviceConnectionResponse = {
- name = "BluetoothDeviceConnectionResponse",
- options = {
- id = 69,
- source = 1,
- ifdef = "USE_BLUETOOTH_PROXY",
- },
- fields = {
- [1] = {
- name = "address",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT64,
- },
- [2] = {
- name = "connected",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [3] = {
- name = "mtu",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [4] = {
- name = "error",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.INT32,
- },
- },
- },
- BluetoothGATTGetServicesRequest = {
- name = "BluetoothGATTGetServicesRequest",
- options = {
- id = 70,
- source = 2,
- ifdef = "USE_BLUETOOTH_PROXY",
- },
- fields = {
- [1] = {
- name = "address",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT64,
- },
- },
- },
- BluetoothGATTDescriptor = {
- name = "BluetoothGATTDescriptor",
- options = {},
- fields = {
- [1] = {
- name = "uuid",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT64,
- repeated = true,
- },
- [2] = {
- name = "handle",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- BluetoothGATTCharacteristic = {
- name = "BluetoothGATTCharacteristic",
- options = {},
- fields = {
- [1] = {
- name = "uuid",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT64,
- repeated = true,
- },
- [2] = {
- name = "handle",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [3] = {
- name = "properties",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [4] = {
- name = "descriptors",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.MESSAGE,
- repeated = true,
- },
- },
- },
- BluetoothGATTService = {
- name = "BluetoothGATTService",
- options = {},
- fields = {
- [1] = {
- name = "uuid",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT64,
- repeated = true,
- },
- [2] = {
- name = "handle",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [3] = {
- name = "characteristics",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.MESSAGE,
- repeated = true,
- },
- },
- },
- BluetoothGATTGetServicesResponse = {
- name = "BluetoothGATTGetServicesResponse",
- options = {
- id = 71,
- source = 1,
- ifdef = "USE_BLUETOOTH_PROXY",
- },
- fields = {
- [1] = {
- name = "address",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT64,
- },
- [2] = {
- name = "services",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.MESSAGE,
- repeated = true,
- },
- },
- },
- BluetoothGATTGetServicesDoneResponse = {
- name = "BluetoothGATTGetServicesDoneResponse",
- options = {
- id = 72,
- source = 1,
- ifdef = "USE_BLUETOOTH_PROXY",
- },
- fields = {
- [1] = {
- name = "address",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT64,
- },
- },
- },
- BluetoothGATTReadRequest = {
- name = "BluetoothGATTReadRequest",
- options = {
- id = 73,
- source = 2,
- ifdef = "USE_BLUETOOTH_PROXY",
- },
- fields = {
- [1] = {
- name = "address",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT64,
- },
- [2] = {
- name = "handle",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- BluetoothGATTReadResponse = {
- name = "BluetoothGATTReadResponse",
- options = {
- id = 74,
- source = 1,
- ifdef = "USE_BLUETOOTH_PROXY",
- },
- fields = {
- [1] = {
- name = "address",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT64,
- },
- [2] = {
- name = "handle",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [3] = {
- name = "data",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.BYTES,
- },
- },
- },
- BluetoothGATTWriteRequest = {
- name = "BluetoothGATTWriteRequest",
- options = {
- id = 75,
- source = 2,
- ifdef = "USE_BLUETOOTH_PROXY",
- },
- fields = {
- [1] = {
- name = "address",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT64,
- },
- [2] = {
- name = "handle",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [3] = {
- name = "response",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [4] = {
- name = "data",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.BYTES,
- },
- },
- },
- BluetoothGATTReadDescriptorRequest = {
- name = "BluetoothGATTReadDescriptorRequest",
- options = {
- id = 76,
- source = 2,
- ifdef = "USE_BLUETOOTH_PROXY",
- },
- fields = {
- [1] = {
- name = "address",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT64,
- },
- [2] = {
- name = "handle",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- BluetoothGATTWriteDescriptorRequest = {
- name = "BluetoothGATTWriteDescriptorRequest",
- options = {
- id = 77,
- source = 2,
- ifdef = "USE_BLUETOOTH_PROXY",
- },
- fields = {
- [1] = {
- name = "address",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT64,
- },
- [2] = {
- name = "handle",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [3] = {
- name = "data",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.BYTES,
- },
- },
- },
- BluetoothGATTNotifyRequest = {
- name = "BluetoothGATTNotifyRequest",
- options = {
- id = 78,
- source = 2,
- ifdef = "USE_BLUETOOTH_PROXY",
- },
- fields = {
- [1] = {
- name = "address",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT64,
- },
- [2] = {
- name = "handle",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [3] = {
- name = "enable",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- },
- },
- BluetoothGATTNotifyDataResponse = {
- name = "BluetoothGATTNotifyDataResponse",
- options = {
- id = 79,
- source = 1,
- ifdef = "USE_BLUETOOTH_PROXY",
- },
- fields = {
- [1] = {
- name = "address",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT64,
- },
- [2] = {
- name = "handle",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [3] = {
- name = "data",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.BYTES,
- },
- },
- },
- SubscribeBluetoothConnectionsFreeRequest = {
- name = "SubscribeBluetoothConnectionsFreeRequest",
- options = {
- id = 80,
- source = 2,
- ifdef = "USE_BLUETOOTH_PROXY",
- },
- fields = {},
- },
- BluetoothConnectionsFreeResponse = {
- name = "BluetoothConnectionsFreeResponse",
- options = {
- id = 81,
- source = 1,
- ifdef = "USE_BLUETOOTH_PROXY",
- },
- fields = {
- [1] = {
- name = "free",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [2] = {
- name = "limit",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [3] = {
- name = "allocated",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT64,
- repeated = true,
- },
- },
- },
- BluetoothGATTErrorResponse = {
- name = "BluetoothGATTErrorResponse",
- options = {
- id = 82,
- source = 1,
- ifdef = "USE_BLUETOOTH_PROXY",
- },
- fields = {
- [1] = {
- name = "address",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT64,
- },
- [2] = {
- name = "handle",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [3] = {
- name = "error",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.INT32,
- },
- },
- },
- BluetoothGATTWriteResponse = {
- name = "BluetoothGATTWriteResponse",
- options = {
- id = 83,
- source = 1,
- ifdef = "USE_BLUETOOTH_PROXY",
- },
- fields = {
- [1] = {
- name = "address",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT64,
- },
- [2] = {
- name = "handle",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- BluetoothGATTNotifyResponse = {
- name = "BluetoothGATTNotifyResponse",
- options = {
- id = 84,
- source = 1,
- ifdef = "USE_BLUETOOTH_PROXY",
- },
- fields = {
- [1] = {
- name = "address",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT64,
- },
- [2] = {
- name = "handle",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- BluetoothDevicePairingResponse = {
- name = "BluetoothDevicePairingResponse",
- options = {
- id = 85,
- source = 1,
- ifdef = "USE_BLUETOOTH_PROXY",
- },
- fields = {
- [1] = {
- name = "address",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT64,
- },
- [2] = {
- name = "paired",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [3] = {
- name = "error",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.INT32,
- },
- },
- },
- BluetoothDeviceUnpairingResponse = {
- name = "BluetoothDeviceUnpairingResponse",
- options = {
- id = 86,
- source = 1,
- ifdef = "USE_BLUETOOTH_PROXY",
- },
- fields = {
- [1] = {
- name = "address",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT64,
- },
- [2] = {
- name = "success",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [3] = {
- name = "error",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.INT32,
- },
- },
- },
- UnsubscribeBluetoothLEAdvertisementsRequest = {
- name = "UnsubscribeBluetoothLEAdvertisementsRequest",
- options = {
- id = 87,
- source = 2,
- ifdef = "USE_BLUETOOTH_PROXY",
- },
- fields = {},
- },
- BluetoothDeviceClearCacheResponse = {
- name = "BluetoothDeviceClearCacheResponse",
- options = {
- id = 88,
- source = 1,
- ifdef = "USE_BLUETOOTH_PROXY",
- },
- fields = {
- [1] = {
- name = "address",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT64,
- },
- [2] = {
- name = "success",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [3] = {
- name = "error",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.INT32,
- },
- },
- },
- BluetoothScannerStateResponse = {
- name = "BluetoothScannerStateResponse",
- options = {
- id = 126,
- source = 1,
- ifdef = "USE_BLUETOOTH_PROXY",
- },
- fields = {
- [1] = {
- name = "state",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- BluetoothScannerState
- },
- [2] = {
- name = "mode",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- BluetoothScannerMode
- },
- },
- },
- BluetoothScannerSetModeRequest = {
- name = "BluetoothScannerSetModeRequest",
- options = {
- id = 127,
- source = 2,
- ifdef = "USE_BLUETOOTH_PROXY",
- },
- fields = {
- [1] = {
- name = "mode",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- BluetoothScannerMode
- },
- },
- },
- SubscribeVoiceAssistantRequest = {
- name = "SubscribeVoiceAssistantRequest",
- options = {
- id = 89,
- source = 2,
- ifdef = "USE_VOICE_ASSISTANT",
- },
- fields = {
- [1] = {
- name = "subscribe",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [2] = {
- name = "flags",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- VoiceAssistantAudioSettings = {
- name = "VoiceAssistantAudioSettings",
- options = {},
- fields = {
- [1] = {
- name = "noise_suppression_level",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [2] = {
- name = "auto_gain",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [3] = {
- name = "volume_multiplier",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- },
- },
- VoiceAssistantRequest = {
- name = "VoiceAssistantRequest",
- options = {
- id = 90,
- source = 1,
- ifdef = "USE_VOICE_ASSISTANT",
- },
- fields = {
- [1] = {
- name = "start",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [2] = {
- name = "conversation_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [3] = {
- name = "flags",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [4] = {
- name = "audio_settings",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.MESSAGE,
- },
- [5] = {
- name = "wake_word_phrase",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- },
- },
- VoiceAssistantResponse = {
- name = "VoiceAssistantResponse",
- options = {
- id = 91,
- source = 2,
- ifdef = "USE_VOICE_ASSISTANT",
- },
- fields = {
- [1] = {
- name = "port",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [2] = {
- name = "error",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- },
- },
- VoiceAssistantEventData = {
- name = "VoiceAssistantEventData",
- options = {},
- fields = {
- [1] = {
- name = "name",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [2] = {
- name = "value",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- },
- },
- VoiceAssistantEventResponse = {
- name = "VoiceAssistantEventResponse",
- options = {
- id = 92,
- source = 2,
- ifdef = "USE_VOICE_ASSISTANT",
- },
- fields = {
- [1] = {
- name = "event_type",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- VoiceAssistantEvent
- },
- [2] = {
- name = "data",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.MESSAGE,
- repeated = true,
- },
- },
- },
- VoiceAssistantAudio = {
- name = "VoiceAssistantAudio",
- options = {
- id = 106,
- source = 0,
- ifdef = "USE_VOICE_ASSISTANT",
- },
- fields = {
- [1] = {
- name = "data",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.BYTES,
- },
- [2] = {
- name = "end",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- },
- },
- VoiceAssistantTimerEventResponse = {
- name = "VoiceAssistantTimerEventResponse",
- options = {
- id = 115,
- source = 2,
- ifdef = "USE_VOICE_ASSISTANT",
- },
- fields = {
- [1] = {
- name = "event_type",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- VoiceAssistantTimerEvent
- },
- [2] = {
- name = "timer_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [3] = {
- name = "name",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [4] = {
- name = "total_seconds",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [5] = {
- name = "seconds_left",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [6] = {
- name = "is_active",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- },
- },
- VoiceAssistantAnnounceRequest = {
- name = "VoiceAssistantAnnounceRequest",
- options = {
- id = 119,
- source = 2,
- ifdef = "USE_VOICE_ASSISTANT",
- },
- fields = {
- [1] = {
- name = "media_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [2] = {
- name = "text",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [3] = {
- name = "preannounce_media_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [4] = {
- name = "start_conversation",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- },
- },
- VoiceAssistantAnnounceFinished = {
- name = "VoiceAssistantAnnounceFinished",
- options = {
- id = 120,
- source = 1,
- ifdef = "USE_VOICE_ASSISTANT",
- },
- fields = {
- [1] = {
- name = "success",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- },
- },
- VoiceAssistantWakeWord = {
- name = "VoiceAssistantWakeWord",
- options = {},
- fields = {
- [1] = {
- name = "id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [2] = {
- name = "wake_word",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [3] = {
- name = "trained_languages",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- repeated = true,
- },
- },
- },
- VoiceAssistantConfigurationRequest = {
- name = "VoiceAssistantConfigurationRequest",
- options = {
- id = 121,
- source = 2,
- ifdef = "USE_VOICE_ASSISTANT",
- },
- fields = {},
- },
- VoiceAssistantConfigurationResponse = {
- name = "VoiceAssistantConfigurationResponse",
- options = {
- id = 122,
- source = 1,
- ifdef = "USE_VOICE_ASSISTANT",
- },
- fields = {
- [1] = {
- name = "available_wake_words",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.MESSAGE,
- repeated = true,
- },
- [2] = {
- name = "active_wake_words",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- repeated = true,
- },
- [3] = {
- name = "max_active_wake_words",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- VoiceAssistantSetConfiguration = {
- name = "VoiceAssistantSetConfiguration",
- options = {
- id = 123,
- source = 2,
- ifdef = "USE_VOICE_ASSISTANT",
- },
- fields = {
- [1] = {
- name = "active_wake_words",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- repeated = true,
- },
- },
- },
- ListEntitiesAlarmControlPanelResponse = {
- name = "ListEntitiesAlarmControlPanelResponse",
- options = {
- id = 94,
- source = 1,
- ifdef = "USE_ALARM_CONTROL_PANEL",
- base_class = "InfoResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "object_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [2] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [3] = {
- name = "name",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [4] = {
- name = "unique_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [5] = {
- name = "icon",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [6] = {
- name = "disabled_by_default",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [7] = {
- name = "entity_category",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- EntityCategory
- },
- [8] = {
- name = "supported_features",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [9] = {
- name = "requires_code",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [10] = {
- name = "requires_code_to_arm",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [11] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- AlarmControlPanelStateResponse = {
- name = "AlarmControlPanelStateResponse",
- options = {
- id = 95,
- source = 1,
- ifdef = "USE_ALARM_CONTROL_PANEL",
- no_delay = 1,
- base_class = "StateResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "state",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- AlarmControlPanelState
- },
- [3] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- AlarmControlPanelCommandRequest = {
- name = "AlarmControlPanelCommandRequest",
- options = {
- id = 96,
- source = 2,
- ifdef = "USE_ALARM_CONTROL_PANEL",
- no_delay = 1,
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "command",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- AlarmControlPanelStateCommand
- },
- [3] = {
- name = "code",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- },
- },
- ListEntitiesTextResponse = {
- name = "ListEntitiesTextResponse",
- options = {
- id = 97,
- source = 1,
- ifdef = "USE_TEXT",
- base_class = "InfoResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "object_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [2] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [3] = {
- name = "name",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [4] = {
- name = "unique_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [5] = {
- name = "icon",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [6] = {
- name = "disabled_by_default",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [7] = {
- name = "entity_category",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- EntityCategory
- },
- [8] = {
- name = "min_length",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [9] = {
- name = "max_length",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [10] = {
- name = "pattern",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [11] = {
- name = "mode",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- TextMode
- },
- [12] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- TextStateResponse = {
- name = "TextStateResponse",
- options = {
- id = 98,
- source = 1,
- ifdef = "USE_TEXT",
- no_delay = 1,
- base_class = "StateResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "state",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [3] = {
- name = "missing_state",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [4] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- TextCommandRequest = {
- name = "TextCommandRequest",
- options = {
- id = 99,
- source = 2,
- ifdef = "USE_TEXT",
- no_delay = 1,
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "state",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- },
- },
- ListEntitiesDateResponse = {
- name = "ListEntitiesDateResponse",
- options = {
- id = 100,
- source = 1,
- ifdef = "USE_DATETIME_DATE",
- base_class = "InfoResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "object_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [2] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [3] = {
- name = "name",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [4] = {
- name = "unique_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [5] = {
- name = "icon",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [6] = {
- name = "disabled_by_default",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [7] = {
- name = "entity_category",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- EntityCategory
- },
- [8] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- DateStateResponse = {
- name = "DateStateResponse",
- options = {
- id = 101,
- source = 1,
- ifdef = "USE_DATETIME_DATE",
- no_delay = 1,
- base_class = "StateResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "missing_state",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [3] = {
- name = "year",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [4] = {
- name = "month",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [5] = {
- name = "day",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [6] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- DateCommandRequest = {
- name = "DateCommandRequest",
- options = {
- id = 102,
- source = 2,
- ifdef = "USE_DATETIME_DATE",
- no_delay = 1,
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "year",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [3] = {
- name = "month",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [4] = {
- name = "day",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- ListEntitiesTimeResponse = {
- name = "ListEntitiesTimeResponse",
- options = {
- id = 103,
- source = 1,
- ifdef = "USE_DATETIME_TIME",
- base_class = "InfoResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "object_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [2] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [3] = {
- name = "name",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [4] = {
- name = "unique_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [5] = {
- name = "icon",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [6] = {
- name = "disabled_by_default",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [7] = {
- name = "entity_category",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- EntityCategory
- },
- [8] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- TimeStateResponse = {
- name = "TimeStateResponse",
- options = {
- id = 104,
- source = 1,
- ifdef = "USE_DATETIME_TIME",
- no_delay = 1,
- base_class = "StateResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "missing_state",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [3] = {
- name = "hour",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [4] = {
- name = "minute",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [5] = {
- name = "second",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [6] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- TimeCommandRequest = {
- name = "TimeCommandRequest",
- options = {
- id = 105,
- source = 2,
- ifdef = "USE_DATETIME_TIME",
- no_delay = 1,
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "hour",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [3] = {
- name = "minute",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- [4] = {
- name = "second",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- ListEntitiesEventResponse = {
- name = "ListEntitiesEventResponse",
- options = {
- id = 107,
- source = 1,
- ifdef = "USE_EVENT",
- base_class = "InfoResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "object_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [2] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [3] = {
- name = "name",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [4] = {
- name = "unique_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [5] = {
- name = "icon",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [6] = {
- name = "disabled_by_default",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [7] = {
- name = "entity_category",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- EntityCategory
- },
- [8] = {
- name = "device_class",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [9] = {
- name = "event_types",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- repeated = true,
- },
- [10] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- EventResponse = {
- name = "EventResponse",
- options = {
- id = 108,
- source = 1,
- ifdef = "USE_EVENT",
- base_class = "StateResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "event_type",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [3] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- ListEntitiesValveResponse = {
- name = "ListEntitiesValveResponse",
- options = {
- id = 109,
- source = 1,
- ifdef = "USE_VALVE",
- base_class = "InfoResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "object_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [2] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [3] = {
- name = "name",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [4] = {
- name = "unique_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [5] = {
- name = "icon",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [6] = {
- name = "disabled_by_default",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [7] = {
- name = "entity_category",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- EntityCategory
- },
- [8] = {
- name = "device_class",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [9] = {
- name = "assumed_state",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [10] = {
- name = "supports_position",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [11] = {
- name = "supports_stop",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [12] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- ValveStateResponse = {
- name = "ValveStateResponse",
- options = {
- id = 110,
- source = 1,
- ifdef = "USE_VALVE",
- no_delay = 1,
- base_class = "StateResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "position",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [3] = {
- name = "current_operation",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- ValveOperation
- },
- [4] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- ValveCommandRequest = {
- name = "ValveCommandRequest",
- options = {
- id = 111,
- source = 2,
- ifdef = "USE_VALVE",
- no_delay = 1,
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "has_position",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [3] = {
- name = "position",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [4] = {
- name = "stop",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- },
- },
- ListEntitiesDateTimeResponse = {
- name = "ListEntitiesDateTimeResponse",
- options = {
- id = 112,
- source = 1,
- ifdef = "USE_DATETIME_DATETIME",
- base_class = "InfoResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "object_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [2] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [3] = {
- name = "name",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [4] = {
- name = "unique_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [5] = {
- name = "icon",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [6] = {
- name = "disabled_by_default",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [7] = {
- name = "entity_category",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- EntityCategory
- },
- [8] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- DateTimeStateResponse = {
- name = "DateTimeStateResponse",
- options = {
- id = 113,
- source = 1,
- ifdef = "USE_DATETIME_DATETIME",
- no_delay = 1,
- base_class = "StateResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "missing_state",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [3] = {
- name = "epoch_seconds",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [4] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- DateTimeCommandRequest = {
- name = "DateTimeCommandRequest",
- options = {
- id = 114,
- source = 2,
- ifdef = "USE_DATETIME_DATETIME",
- no_delay = 1,
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "epoch_seconds",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- },
- },
- ListEntitiesUpdateResponse = {
- name = "ListEntitiesUpdateResponse",
- options = {
- id = 116,
- source = 1,
- ifdef = "USE_UPDATE",
- base_class = "InfoResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "object_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [2] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [3] = {
- name = "name",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [4] = {
- name = "unique_id",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [5] = {
- name = "icon",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [6] = {
- name = "disabled_by_default",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [7] = {
- name = "entity_category",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- EntityCategory
- },
- [8] = {
- name = "device_class",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [9] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- UpdateStateResponse = {
- name = "UpdateStateResponse",
- options = {
- id = 117,
- source = 1,
- ifdef = "USE_UPDATE",
- no_delay = 1,
- base_class = "StateResponseProtoMessage",
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "missing_state",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [3] = {
- name = "in_progress",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [4] = {
- name = "has_progress",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.BOOL,
- },
- [5] = {
- name = "progress",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FLOAT,
- },
- [6] = {
- name = "current_version",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [7] = {
- name = "latest_version",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [8] = {
- name = "title",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [9] = {
- name = "release_summary",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [10] = {
- name = "release_url",
- wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED,
- type = PROTOBUF_SCHEMA.DataType.STRING,
- },
- [11] = {
- name = "device_id",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.UINT32,
- },
- },
- },
- UpdateCommandRequest = {
- name = "UpdateCommandRequest",
- options = {
- id = 118,
- source = 2,
- ifdef = "USE_UPDATE",
- no_delay = 1,
- },
- fields = {
- [1] = {
- name = "key",
- wireType = PROTOBUF_SCHEMA.WireType.FIXED32,
- type = PROTOBUF_SCHEMA.DataType.FIXED32,
- },
- [2] = {
- name = "command",
- wireType = PROTOBUF_SCHEMA.WireType.VARINT,
- type = PROTOBUF_SCHEMA.DataType.ENUM, -- UpdateCommand
- },
- },
- },
-}
-
-PROTOBUF_SCHEMA.RPC = {
- APIConnection = {
- hello = {
- service = "APIConnection",
- method = "hello",
- inputType = PROTOBUF_SCHEMA.Message.HelloRequest,
- outputType = PROTOBUF_SCHEMA.Message.HelloResponse,
- },
- connect = {
- service = "APIConnection",
- method = "connect",
- inputType = PROTOBUF_SCHEMA.Message.ConnectRequest,
- outputType = PROTOBUF_SCHEMA.Message.ConnectResponse,
- },
- disconnect = {
- service = "APIConnection",
- method = "disconnect",
- inputType = PROTOBUF_SCHEMA.Message.DisconnectRequest,
- outputType = PROTOBUF_SCHEMA.Message.DisconnectResponse,
- },
- ping = {
- service = "APIConnection",
- method = "ping",
- inputType = PROTOBUF_SCHEMA.Message.PingRequest,
- outputType = PROTOBUF_SCHEMA.Message.PingResponse,
- },
- device_info = {
- service = "APIConnection",
- method = "device_info",
- inputType = PROTOBUF_SCHEMA.Message.DeviceInfoRequest,
- outputType = PROTOBUF_SCHEMA.Message.DeviceInfoResponse,
- },
- list_entities = {
- service = "APIConnection",
- method = "list_entities",
- inputType = PROTOBUF_SCHEMA.Message.ListEntitiesRequest,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- subscribe_states = {
- service = "APIConnection",
- method = "subscribe_states",
- inputType = PROTOBUF_SCHEMA.Message.SubscribeStatesRequest,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- subscribe_logs = {
- service = "APIConnection",
- method = "subscribe_logs",
- inputType = PROTOBUF_SCHEMA.Message.SubscribeLogsRequest,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- subscribe_homeassistant_services = {
- service = "APIConnection",
- method = "subscribe_homeassistant_services",
- inputType = PROTOBUF_SCHEMA.Message.SubscribeHomeassistantServicesRequest,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- subscribe_home_assistant_states = {
- service = "APIConnection",
- method = "subscribe_home_assistant_states",
- inputType = PROTOBUF_SCHEMA.Message.SubscribeHomeAssistantStatesRequest,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- get_time = {
- service = "APIConnection",
- method = "get_time",
- inputType = PROTOBUF_SCHEMA.Message.GetTimeRequest,
- outputType = PROTOBUF_SCHEMA.Message.GetTimeResponse,
- },
- execute_service = {
- service = "APIConnection",
- method = "execute_service",
- inputType = PROTOBUF_SCHEMA.Message.ExecuteServiceRequest,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- noise_encryption_set_key = {
- service = "APIConnection",
- method = "noise_encryption_set_key",
- inputType = PROTOBUF_SCHEMA.Message.NoiseEncryptionSetKeyRequest,
- outputType = PROTOBUF_SCHEMA.Message.NoiseEncryptionSetKeyResponse,
- },
- button_command = {
- service = "APIConnection",
- method = "button_command",
- inputType = PROTOBUF_SCHEMA.Message.ButtonCommandRequest,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- camera_image = {
- service = "APIConnection",
- method = "camera_image",
- inputType = PROTOBUF_SCHEMA.Message.CameraImageRequest,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- climate_command = {
- service = "APIConnection",
- method = "climate_command",
- inputType = PROTOBUF_SCHEMA.Message.ClimateCommandRequest,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- cover_command = {
- service = "APIConnection",
- method = "cover_command",
- inputType = PROTOBUF_SCHEMA.Message.CoverCommandRequest,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- date_command = {
- service = "APIConnection",
- method = "date_command",
- inputType = PROTOBUF_SCHEMA.Message.DateCommandRequest,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- datetime_command = {
- service = "APIConnection",
- method = "datetime_command",
- inputType = PROTOBUF_SCHEMA.Message.DateTimeCommandRequest,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- fan_command = {
- service = "APIConnection",
- method = "fan_command",
- inputType = PROTOBUF_SCHEMA.Message.FanCommandRequest,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- light_command = {
- service = "APIConnection",
- method = "light_command",
- inputType = PROTOBUF_SCHEMA.Message.LightCommandRequest,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- lock_command = {
- service = "APIConnection",
- method = "lock_command",
- inputType = PROTOBUF_SCHEMA.Message.LockCommandRequest,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- media_player_command = {
- service = "APIConnection",
- method = "media_player_command",
- inputType = PROTOBUF_SCHEMA.Message.MediaPlayerCommandRequest,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- number_command = {
- service = "APIConnection",
- method = "number_command",
- inputType = PROTOBUF_SCHEMA.Message.NumberCommandRequest,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- select_command = {
- service = "APIConnection",
- method = "select_command",
- inputType = PROTOBUF_SCHEMA.Message.SelectCommandRequest,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- siren_command = {
- service = "APIConnection",
- method = "siren_command",
- inputType = PROTOBUF_SCHEMA.Message.SirenCommandRequest,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- switch_command = {
- service = "APIConnection",
- method = "switch_command",
- inputType = PROTOBUF_SCHEMA.Message.SwitchCommandRequest,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- text_command = {
- service = "APIConnection",
- method = "text_command",
- inputType = PROTOBUF_SCHEMA.Message.TextCommandRequest,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- time_command = {
- service = "APIConnection",
- method = "time_command",
- inputType = PROTOBUF_SCHEMA.Message.TimeCommandRequest,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- update_command = {
- service = "APIConnection",
- method = "update_command",
- inputType = PROTOBUF_SCHEMA.Message.UpdateCommandRequest,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- valve_command = {
- service = "APIConnection",
- method = "valve_command",
- inputType = PROTOBUF_SCHEMA.Message.ValveCommandRequest,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- subscribe_bluetooth_le_advertisements = {
- service = "APIConnection",
- method = "subscribe_bluetooth_le_advertisements",
- inputType = PROTOBUF_SCHEMA.Message.SubscribeBluetoothLEAdvertisementsRequest,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- bluetooth_device_request = {
- service = "APIConnection",
- method = "bluetooth_device_request",
- inputType = PROTOBUF_SCHEMA.Message.BluetoothDeviceRequest,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- bluetooth_gatt_get_services = {
- service = "APIConnection",
- method = "bluetooth_gatt_get_services",
- inputType = PROTOBUF_SCHEMA.Message.BluetoothGATTGetServicesRequest,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- bluetooth_gatt_read = {
- service = "APIConnection",
- method = "bluetooth_gatt_read",
- inputType = PROTOBUF_SCHEMA.Message.BluetoothGATTReadRequest,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- bluetooth_gatt_write = {
- service = "APIConnection",
- method = "bluetooth_gatt_write",
- inputType = PROTOBUF_SCHEMA.Message.BluetoothGATTWriteRequest,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- bluetooth_gatt_read_descriptor = {
- service = "APIConnection",
- method = "bluetooth_gatt_read_descriptor",
- inputType = PROTOBUF_SCHEMA.Message.BluetoothGATTReadDescriptorRequest,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- bluetooth_gatt_write_descriptor = {
- service = "APIConnection",
- method = "bluetooth_gatt_write_descriptor",
- inputType = PROTOBUF_SCHEMA.Message.BluetoothGATTWriteDescriptorRequest,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- bluetooth_gatt_notify = {
- service = "APIConnection",
- method = "bluetooth_gatt_notify",
- inputType = PROTOBUF_SCHEMA.Message.BluetoothGATTNotifyRequest,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- subscribe_bluetooth_connections_free = {
- service = "APIConnection",
- method = "subscribe_bluetooth_connections_free",
- inputType = PROTOBUF_SCHEMA.Message.SubscribeBluetoothConnectionsFreeRequest,
- outputType = PROTOBUF_SCHEMA.Message.BluetoothConnectionsFreeResponse,
- },
- unsubscribe_bluetooth_le_advertisements = {
- service = "APIConnection",
- method = "unsubscribe_bluetooth_le_advertisements",
- inputType = PROTOBUF_SCHEMA.Message.UnsubscribeBluetoothLEAdvertisementsRequest,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- bluetooth_scanner_set_mode = {
- service = "APIConnection",
- method = "bluetooth_scanner_set_mode",
- inputType = PROTOBUF_SCHEMA.Message.BluetoothScannerSetModeRequest,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- subscribe_voice_assistant = {
- service = "APIConnection",
- method = "subscribe_voice_assistant",
- inputType = PROTOBUF_SCHEMA.Message.SubscribeVoiceAssistantRequest,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- voice_assistant_get_configuration = {
- service = "APIConnection",
- method = "voice_assistant_get_configuration",
- inputType = PROTOBUF_SCHEMA.Message.VoiceAssistantConfigurationRequest,
- outputType = PROTOBUF_SCHEMA.Message.VoiceAssistantConfigurationResponse,
- },
- voice_assistant_set_configuration = {
- service = "APIConnection",
- method = "voice_assistant_set_configuration",
- inputType = PROTOBUF_SCHEMA.Message.VoiceAssistantSetConfiguration,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- alarm_control_panel_command = {
- service = "APIConnection",
- method = "alarm_control_panel_command",
- inputType = PROTOBUF_SCHEMA.Message.AlarmControlPanelCommandRequest,
- outputType = PROTOBUF_SCHEMA.Message.void,
- },
- },
-}
-
-return PROTOBUF_SCHEMA
diff --git a/src/esphome/proto_schema.lua b/src/esphome/proto_schema.lua
new file mode 100644
index 0000000..c669382
--- /dev/null
+++ b/src/esphome/proto_schema.lua
@@ -0,0 +1,7610 @@
+-- Generated Lua schema from protobuf descriptor set
+-- Do not edit manually
+
+--- @class ProtoSchema
+local ProtoSchema = {}
+
+--- Maps enum names to their definitions.
+ProtoSchema.Enum = {}
+
+--- Maps message names to their definitions.
+--- @type table
+ProtoSchema.Message = {}
+
+--- Maps service names to their method definitions.
+--- @type table
+ProtoSchema.RPC = {}
+
+--- ProtoWireType Maps protobuf wire types to their integer values.
+--- @enum ProtoWireType
+ProtoSchema.WireType = {
+ VARINT = 0,
+ FIXED64 = 1,
+ LENGTH_DELIMITED = 2,
+ FIXED32 = 5,
+}
+
+--- ProtoDataType Maps protobuf data types to their integer values.
+--- @enum ProtoDataType
+ProtoSchema.DataType = {
+ DOUBLE = 1,
+ FLOAT = 2,
+ INT64 = 3,
+ UINT64 = 4,
+ INT32 = 5,
+ FIXED64 = 6,
+ FIXED32 = 7,
+ BOOL = 8,
+ STRING = 9,
+ MESSAGE = 11,
+ BYTES = 12,
+ UINT32 = 13,
+ ENUM = 14,
+ SFIXED32 = 15,
+ SFIXED64 = 16,
+ SINT32 = 17,
+ SINT64 = 18,
+}
+
+--- @class ProtoFieldSchema
+--- @field name string The name of the field.
+--- @field wireType ProtoWireType The protobuf wire type (see ProtoSchema.WireType).
+--- @field type ProtoDataType The protobuf type (see ProtoSchema.DataType).
+--- @field repeated boolean? Whether the field is repeated (optional).
+--- @field subschema string? The subschema name for nested messages (optional).
+
+--- @class ProtoMessageSchema
+--- @field name string The name of the message type.
+--- @field options table Message options.
+--- @field fields table A map of field numbers to ProtoFieldSchema definitions.
+
+--- @class ProtoServiceMethodSchema
+--- @field service string The name of the service.
+--- @field method string The method name.
+--- @field inputType ProtoMessageSchema The protobuf message type for the request.
+--- @field outputType ProtoMessageSchema The protobuf message type for the response.
+
+--- @class ProtoServiceSchema
+--- @field [string] ProtoServiceMethodSchema Maps method names to their method definitions.
+
+--- @class ProtoHelloRequest
+--- @field client_info string?
+--- @field api_version_major number?
+--- @field api_version_minor number?
+
+--- @class ProtoHelloResponse
+--- @field api_version_major number?
+--- @field api_version_minor number?
+--- @field server_info string?
+--- @field name string?
+
+--- @class ProtoAuthenticationRequest
+--- @field password string?
+
+--- @class ProtoAuthenticationResponse
+--- @field invalid_password boolean?
+
+--- @class ProtoDisconnectRequest
+
+--- @class ProtoDisconnectResponse
+
+--- @class ProtoPingRequest
+
+--- @class ProtoPingResponse
+
+--- @class ProtoDeviceInfoRequest
+
+--- @class ProtoAreaInfo
+--- @field area_id number?
+--- @field name string?
+
+--- @class ProtoDeviceInfo
+--- @field device_id number?
+--- @field name string?
+--- @field area_id number?
+
+--- @class ProtoDeviceInfoResponse
+--- @field uses_password boolean?
+--- @field name string?
+--- @field mac_address string?
+--- @field esphome_version string?
+--- @field compilation_time string?
+--- @field model string?
+--- @field has_deep_sleep boolean?
+--- @field project_name string?
+--- @field project_version string?
+--- @field webserver_port number?
+--- @field legacy_bluetooth_proxy_version number?
+--- @field bluetooth_proxy_feature_flags number?
+--- @field manufacturer string?
+--- @field friendly_name string?
+--- @field legacy_voice_assistant_version number?
+--- @field voice_assistant_feature_flags number?
+--- @field suggested_area string?
+--- @field bluetooth_mac_address string?
+--- @field api_encryption_supported boolean?
+--- @field devices ProtoDeviceInfo[]?
+--- @field areas ProtoAreaInfo[]?
+--- @field area ProtoAreaInfo?
+--- @field zwave_proxy_feature_flags number?
+--- @field zwave_home_id number?
+
+--- @class ProtoListEntitiesRequest
+
+--- @class ProtoListEntitiesDoneResponse
+
+--- @class ProtoSubscribeStatesRequest
+
+--- @class ProtoListEntitiesBinarySensorResponse
+--- @field object_id string?
+--- @field key number?
+--- @field name string?
+--- @field device_class string?
+--- @field is_status_binary_sensor boolean?
+--- @field disabled_by_default boolean?
+--- @field icon string?
+--- @field entity_category ProtoEntityCategory?
+--- @field device_id number?
+
+--- @class ProtoBinarySensorStateResponse
+--- @field key number?
+--- @field state boolean?
+--- @field missing_state boolean?
+--- @field device_id number?
+
+--- @class ProtoListEntitiesCoverResponse
+--- @field object_id string?
+--- @field key number?
+--- @field name string?
+--- @field assumed_state boolean?
+--- @field supports_position boolean?
+--- @field supports_tilt boolean?
+--- @field device_class string?
+--- @field disabled_by_default boolean?
+--- @field icon string?
+--- @field entity_category ProtoEntityCategory?
+--- @field supports_stop boolean?
+--- @field device_id number?
+
+--- @class ProtoCoverStateResponse
+--- @field key number?
+--- @field legacy_state ProtoLegacyCoverState?
+--- @field position number?
+--- @field tilt number?
+--- @field current_operation ProtoCoverOperation?
+--- @field device_id number?
+
+--- @class ProtoCoverCommandRequest
+--- @field key number?
+--- @field has_legacy_command boolean?
+--- @field legacy_command ProtoLegacyCoverCommand?
+--- @field has_position boolean?
+--- @field position number?
+--- @field has_tilt boolean?
+--- @field tilt number?
+--- @field stop boolean?
+--- @field device_id number?
+
+--- @class ProtoListEntitiesFanResponse
+--- @field object_id string?
+--- @field key number?
+--- @field name string?
+--- @field supports_oscillation boolean?
+--- @field supports_speed boolean?
+--- @field supports_direction boolean?
+--- @field supported_speed_count number?
+--- @field disabled_by_default boolean?
+--- @field icon string?
+--- @field entity_category ProtoEntityCategory?
+--- @field supported_preset_modes string[]?
+--- @field device_id number?
+
+--- @class ProtoFanStateResponse
+--- @field key number?
+--- @field state boolean?
+--- @field oscillating boolean?
+--- @field speed ProtoFanSpeed?
+--- @field direction ProtoFanDirection?
+--- @field speed_level number?
+--- @field preset_mode string?
+--- @field device_id number?
+
+--- @class ProtoFanCommandRequest
+--- @field key number?
+--- @field has_state boolean?
+--- @field state boolean?
+--- @field has_speed boolean?
+--- @field speed ProtoFanSpeed?
+--- @field has_oscillating boolean?
+--- @field oscillating boolean?
+--- @field has_direction boolean?
+--- @field direction ProtoFanDirection?
+--- @field has_speed_level boolean?
+--- @field speed_level number?
+--- @field has_preset_mode boolean?
+--- @field preset_mode string?
+--- @field device_id number?
+
+--- @class ProtoListEntitiesLightResponse
+--- @field object_id string?
+--- @field key number?
+--- @field name string?
+--- @field supported_color_modes ProtoColorMode[]?
+--- @field legacy_supports_brightness boolean?
+--- @field legacy_supports_rgb boolean?
+--- @field legacy_supports_white_value boolean?
+--- @field legacy_supports_color_temperature boolean?
+--- @field min_mireds number?
+--- @field max_mireds number?
+--- @field effects string[]?
+--- @field disabled_by_default boolean?
+--- @field icon string?
+--- @field entity_category ProtoEntityCategory?
+--- @field device_id number?
+
+--- @class ProtoLightStateResponse
+--- @field key number?
+--- @field state boolean?
+--- @field brightness number?
+--- @field color_mode ProtoColorMode?
+--- @field color_brightness number?
+--- @field red number?
+--- @field green number?
+--- @field blue number?
+--- @field white number?
+--- @field color_temperature number?
+--- @field cold_white number?
+--- @field warm_white number?
+--- @field effect string?
+--- @field device_id number?
+
+--- @class ProtoLightCommandRequest
+--- @field key number?
+--- @field has_state boolean?
+--- @field state boolean?
+--- @field has_brightness boolean?
+--- @field brightness number?
+--- @field has_color_mode boolean?
+--- @field color_mode ProtoColorMode?
+--- @field has_color_brightness boolean?
+--- @field color_brightness number?
+--- @field has_rgb boolean?
+--- @field red number?
+--- @field green number?
+--- @field blue number?
+--- @field has_white boolean?
+--- @field white number?
+--- @field has_color_temperature boolean?
+--- @field color_temperature number?
+--- @field has_cold_white boolean?
+--- @field cold_white number?
+--- @field has_warm_white boolean?
+--- @field warm_white number?
+--- @field has_transition_length boolean?
+--- @field transition_length number?
+--- @field has_flash_length boolean?
+--- @field flash_length number?
+--- @field has_effect boolean?
+--- @field effect string?
+--- @field device_id number?
+
+--- @class ProtoListEntitiesSensorResponse
+--- @field object_id string?
+--- @field key number?
+--- @field name string?
+--- @field icon string?
+--- @field unit_of_measurement string?
+--- @field accuracy_decimals number?
+--- @field force_update boolean?
+--- @field device_class string?
+--- @field state_class ProtoSensorStateClass?
+--- @field legacy_last_reset_type ProtoSensorLastResetType?
+--- @field disabled_by_default boolean?
+--- @field entity_category ProtoEntityCategory?
+--- @field device_id number?
+
+--- @class ProtoSensorStateResponse
+--- @field key number?
+--- @field state number?
+--- @field missing_state boolean?
+--- @field device_id number?
+
+--- @class ProtoListEntitiesSwitchResponse
+--- @field object_id string?
+--- @field key number?
+--- @field name string?
+--- @field icon string?
+--- @field assumed_state boolean?
+--- @field disabled_by_default boolean?
+--- @field entity_category ProtoEntityCategory?
+--- @field device_class string?
+--- @field device_id number?
+
+--- @class ProtoSwitchStateResponse
+--- @field key number?
+--- @field state boolean?
+--- @field device_id number?
+
+--- @class ProtoSwitchCommandRequest
+--- @field key number?
+--- @field state boolean?
+--- @field device_id number?
+
+--- @class ProtoListEntitiesTextSensorResponse
+--- @field object_id string?
+--- @field key number?
+--- @field name string?
+--- @field icon string?
+--- @field disabled_by_default boolean?
+--- @field entity_category ProtoEntityCategory?
+--- @field device_class string?
+--- @field device_id number?
+
+--- @class ProtoTextSensorStateResponse
+--- @field key number?
+--- @field state string?
+--- @field missing_state boolean?
+--- @field device_id number?
+
+--- @class ProtoSubscribeLogsRequest
+--- @field level ProtoLogLevel?
+--- @field dump_config boolean?
+
+--- @class ProtoSubscribeLogsResponse
+--- @field level ProtoLogLevel?
+--- @field message string?
+
+--- @class ProtoNoiseEncryptionSetKeyRequest
+--- @field key string?
+
+--- @class ProtoNoiseEncryptionSetKeyResponse
+--- @field success boolean?
+
+--- @class ProtoSubscribeHomeassistantServicesRequest
+
+--- @class ProtoHomeassistantServiceMap
+--- @field key string?
+--- @field value string?
+
+--- @class ProtoHomeassistantActionRequest
+--- @field service string?
+--- @field data ProtoHomeassistantServiceMap[]?
+--- @field data_template ProtoHomeassistantServiceMap[]?
+--- @field variables ProtoHomeassistantServiceMap[]?
+--- @field is_event boolean?
+--- @field call_id number?
+--- @field wants_response boolean?
+--- @field response_template string?
+
+--- @class ProtoHomeassistantActionResponse
+--- @field call_id number?
+--- @field success boolean?
+--- @field error_message string?
+--- @field response_data string?
+
+--- @class ProtoSubscribeHomeAssistantStatesRequest
+
+--- @class ProtoSubscribeHomeAssistantStateResponse
+--- @field entity_id string?
+--- @field attribute string?
+--- @field once boolean?
+
+--- @class ProtoHomeAssistantStateResponse
+--- @field entity_id string?
+--- @field state string?
+--- @field attribute string?
+
+--- @class ProtoGetTimeRequest
+
+--- @class ProtoGetTimeResponse
+--- @field epoch_seconds number?
+--- @field timezone string?
+
+--- @class ProtoListEntitiesServicesArgument
+--- @field name string?
+--- @field type ProtoServiceArgType?
+
+--- @class ProtoListEntitiesServicesResponse
+--- @field name string?
+--- @field key number?
+--- @field args ProtoListEntitiesServicesArgument[]?
+--- @field supports_response ProtoSupportsResponseType?
+
+--- @class ProtoExecuteServiceArgument
+--- @field bool_ boolean?
+--- @field legacy_int number?
+--- @field float_ number?
+--- @field string_ string?
+--- @field int_ number?
+--- @field bool_array boolean[]?
+--- @field int_array number[]?
+--- @field float_array number[]?
+--- @field string_array string[]?
+
+--- @class ProtoExecuteServiceRequest
+--- @field key number?
+--- @field args ProtoExecuteServiceArgument[]?
+--- @field call_id number?
+--- @field return_response boolean?
+
+--- @class ProtoExecuteServiceResponse
+--- @field call_id number?
+--- @field success boolean?
+--- @field error_message string?
+--- @field response_data string?
+
+--- @class ProtoListEntitiesCameraResponse
+--- @field object_id string?
+--- @field key number?
+--- @field name string?
+--- @field disabled_by_default boolean?
+--- @field icon string?
+--- @field entity_category ProtoEntityCategory?
+--- @field device_id number?
+
+--- @class ProtoCameraImageResponse
+--- @field key number?
+--- @field data string?
+--- @field done boolean?
+--- @field device_id number?
+
+--- @class ProtoCameraImageRequest
+--- @field single boolean?
+--- @field stream boolean?
+
+--- @class ProtoListEntitiesClimateResponse
+--- @field object_id string?
+--- @field key number?
+--- @field name string?
+--- @field supports_current_temperature boolean?
+--- @field supports_two_point_target_temperature boolean?
+--- @field supported_modes ProtoClimateMode[]?
+--- @field visual_min_temperature number?
+--- @field visual_max_temperature number?
+--- @field visual_target_temperature_step number?
+--- @field legacy_supports_away boolean?
+--- @field supports_action boolean?
+--- @field supported_fan_modes ProtoClimateFanMode[]?
+--- @field supported_swing_modes ProtoClimateSwingMode[]?
+--- @field supported_custom_fan_modes string[]?
+--- @field supported_presets ProtoClimatePreset[]?
+--- @field supported_custom_presets string[]?
+--- @field disabled_by_default boolean?
+--- @field icon string?
+--- @field entity_category ProtoEntityCategory?
+--- @field visual_current_temperature_step number?
+--- @field supports_current_humidity boolean?
+--- @field supports_target_humidity boolean?
+--- @field visual_min_humidity number?
+--- @field visual_max_humidity number?
+--- @field device_id number?
+--- @field feature_flags number?
+
+--- @class ProtoClimateStateResponse
+--- @field key number?
+--- @field mode ProtoClimateMode?
+--- @field current_temperature number?
+--- @field target_temperature number?
+--- @field target_temperature_low number?
+--- @field target_temperature_high number?
+--- @field unused_legacy_away boolean?
+--- @field action ProtoClimateAction?
+--- @field fan_mode ProtoClimateFanMode?
+--- @field swing_mode ProtoClimateSwingMode?
+--- @field custom_fan_mode string?
+--- @field preset ProtoClimatePreset?
+--- @field custom_preset string?
+--- @field current_humidity number?
+--- @field target_humidity number?
+--- @field device_id number?
+
+--- @class ProtoClimateCommandRequest
+--- @field key number?
+--- @field has_mode boolean?
+--- @field mode ProtoClimateMode?
+--- @field has_target_temperature boolean?
+--- @field target_temperature number?
+--- @field has_target_temperature_low boolean?
+--- @field target_temperature_low number?
+--- @field has_target_temperature_high boolean?
+--- @field target_temperature_high number?
+--- @field unused_has_legacy_away boolean?
+--- @field unused_legacy_away boolean?
+--- @field has_fan_mode boolean?
+--- @field fan_mode ProtoClimateFanMode?
+--- @field has_swing_mode boolean?
+--- @field swing_mode ProtoClimateSwingMode?
+--- @field has_custom_fan_mode boolean?
+--- @field custom_fan_mode string?
+--- @field has_preset boolean?
+--- @field preset ProtoClimatePreset?
+--- @field has_custom_preset boolean?
+--- @field custom_preset string?
+--- @field has_target_humidity boolean?
+--- @field target_humidity number?
+--- @field device_id number?
+
+--- @class ProtoListEntitiesWaterHeaterResponse
+--- @field object_id string?
+--- @field key number?
+--- @field name string?
+--- @field icon string?
+--- @field disabled_by_default boolean?
+--- @field entity_category ProtoEntityCategory?
+--- @field device_id number?
+--- @field min_temperature number?
+--- @field max_temperature number?
+--- @field target_temperature_step number?
+--- @field supported_modes ProtoWaterHeaterMode[]?
+--- @field supported_features number?
+
+--- @class ProtoWaterHeaterStateResponse
+--- @field key number?
+--- @field current_temperature number?
+--- @field target_temperature number?
+--- @field mode ProtoWaterHeaterMode?
+--- @field device_id number?
+--- @field state number?
+--- @field target_temperature_low number?
+--- @field target_temperature_high number?
+
+--- @class ProtoWaterHeaterCommandRequest
+--- @field key number?
+--- @field has_fields number?
+--- @field mode ProtoWaterHeaterMode?
+--- @field target_temperature number?
+--- @field device_id number?
+--- @field state number?
+--- @field target_temperature_low number?
+--- @field target_temperature_high number?
+
+--- @class ProtoListEntitiesNumberResponse
+--- @field object_id string?
+--- @field key number?
+--- @field name string?
+--- @field icon string?
+--- @field min_value number?
+--- @field max_value number?
+--- @field step number?
+--- @field disabled_by_default boolean?
+--- @field entity_category ProtoEntityCategory?
+--- @field unit_of_measurement string?
+--- @field mode ProtoNumberMode?
+--- @field device_class string?
+--- @field device_id number?
+
+--- @class ProtoNumberStateResponse
+--- @field key number?
+--- @field state number?
+--- @field missing_state boolean?
+--- @field device_id number?
+
+--- @class ProtoNumberCommandRequest
+--- @field key number?
+--- @field state number?
+--- @field device_id number?
+
+--- @class ProtoListEntitiesSelectResponse
+--- @field object_id string?
+--- @field key number?
+--- @field name string?
+--- @field icon string?
+--- @field options string[]?
+--- @field disabled_by_default boolean?
+--- @field entity_category ProtoEntityCategory?
+--- @field device_id number?
+
+--- @class ProtoSelectStateResponse
+--- @field key number?
+--- @field state string?
+--- @field missing_state boolean?
+--- @field device_id number?
+
+--- @class ProtoSelectCommandRequest
+--- @field key number?
+--- @field state string?
+--- @field device_id number?
+
+--- @class ProtoListEntitiesSirenResponse
+--- @field object_id string?
+--- @field key number?
+--- @field name string?
+--- @field icon string?
+--- @field disabled_by_default boolean?
+--- @field tones string[]?
+--- @field supports_duration boolean?
+--- @field supports_volume boolean?
+--- @field entity_category ProtoEntityCategory?
+--- @field device_id number?
+
+--- @class ProtoSirenStateResponse
+--- @field key number?
+--- @field state boolean?
+--- @field device_id number?
+
+--- @class ProtoSirenCommandRequest
+--- @field key number?
+--- @field has_state boolean?
+--- @field state boolean?
+--- @field has_tone boolean?
+--- @field tone string?
+--- @field has_duration boolean?
+--- @field duration number?
+--- @field has_volume boolean?
+--- @field volume number?
+--- @field device_id number?
+
+--- @class ProtoListEntitiesLockResponse
+--- @field object_id string?
+--- @field key number?
+--- @field name string?
+--- @field icon string?
+--- @field disabled_by_default boolean?
+--- @field entity_category ProtoEntityCategory?
+--- @field assumed_state boolean?
+--- @field supports_open boolean?
+--- @field requires_code boolean?
+--- @field code_format string?
+--- @field device_id number?
+
+--- @class ProtoLockStateResponse
+--- @field key number?
+--- @field state ProtoLockState?
+--- @field device_id number?
+
+--- @class ProtoLockCommandRequest
+--- @field key number?
+--- @field command ProtoLockCommand?
+--- @field has_code boolean?
+--- @field code string?
+--- @field device_id number?
+
+--- @class ProtoListEntitiesButtonResponse
+--- @field object_id string?
+--- @field key number?
+--- @field name string?
+--- @field icon string?
+--- @field disabled_by_default boolean?
+--- @field entity_category ProtoEntityCategory?
+--- @field device_class string?
+--- @field device_id number?
+
+--- @class ProtoButtonCommandRequest
+--- @field key number?
+--- @field device_id number?
+
+--- @class ProtoMediaPlayerSupportedFormat
+--- @field format string?
+--- @field sample_rate number?
+--- @field num_channels number?
+--- @field purpose ProtoMediaPlayerFormatPurpose?
+--- @field sample_bytes number?
+
+--- @class ProtoListEntitiesMediaPlayerResponse
+--- @field object_id string?
+--- @field key number?
+--- @field name string?
+--- @field icon string?
+--- @field disabled_by_default boolean?
+--- @field entity_category ProtoEntityCategory?
+--- @field supports_pause boolean?
+--- @field supported_formats ProtoMediaPlayerSupportedFormat[]?
+--- @field device_id number?
+--- @field feature_flags number?
+
+--- @class ProtoMediaPlayerStateResponse
+--- @field key number?
+--- @field state ProtoMediaPlayerState?
+--- @field volume number?
+--- @field muted boolean?
+--- @field device_id number?
+
+--- @class ProtoMediaPlayerCommandRequest
+--- @field key number?
+--- @field has_command boolean?
+--- @field command ProtoMediaPlayerCommand?
+--- @field has_volume boolean?
+--- @field volume number?
+--- @field has_media_url boolean?
+--- @field media_url string?
+--- @field has_announcement boolean?
+--- @field announcement boolean?
+--- @field device_id number?
+
+--- @class ProtoSubscribeBluetoothLEAdvertisementsRequest
+--- @field flags number?
+
+--- @class ProtoBluetoothServiceData
+--- @field uuid string?
+--- @field legacy_data number[]?
+--- @field data string?
+
+--- @class ProtoBluetoothLEAdvertisementResponse
+--- @field address (number|Int64HighLow)?
+--- @field name string?
+--- @field rssi number?
+--- @field service_uuids string[]?
+--- @field service_data ProtoBluetoothServiceData[]?
+--- @field manufacturer_data ProtoBluetoothServiceData[]?
+--- @field address_type number?
+
+--- @class ProtoBluetoothLERawAdvertisement
+--- @field address (number|Int64HighLow)?
+--- @field rssi number?
+--- @field address_type number?
+--- @field data string?
+
+--- @class ProtoBluetoothLERawAdvertisementsResponse
+--- @field advertisements ProtoBluetoothLERawAdvertisement[]?
+
+--- @class ProtoBluetoothDeviceRequest
+--- @field address (number|Int64HighLow)?
+--- @field request_type ProtoBluetoothDeviceRequestType?
+--- @field has_address_type boolean?
+--- @field address_type number?
+
+--- @class ProtoBluetoothDeviceConnectionResponse
+--- @field address (number|Int64HighLow)?
+--- @field connected boolean?
+--- @field mtu number?
+--- @field error number?
+
+--- @class ProtoBluetoothGATTGetServicesRequest
+--- @field address (number|Int64HighLow)?
+
+--- @class ProtoBluetoothGATTDescriptor
+--- @field uuid (number[]|Int64HighLow[])?
+--- @field handle number?
+--- @field short_uuid number?
+
+--- @class ProtoBluetoothGATTCharacteristic
+--- @field uuid (number[]|Int64HighLow[])?
+--- @field handle number?
+--- @field properties number?
+--- @field descriptors ProtoBluetoothGATTDescriptor[]?
+--- @field short_uuid number?
+
+--- @class ProtoBluetoothGATTService
+--- @field uuid (number[]|Int64HighLow[])?
+--- @field handle number?
+--- @field characteristics ProtoBluetoothGATTCharacteristic[]?
+--- @field short_uuid number?
+
+--- @class ProtoBluetoothGATTGetServicesResponse
+--- @field address (number|Int64HighLow)?
+--- @field services ProtoBluetoothGATTService[]?
+
+--- @class ProtoBluetoothGATTGetServicesDoneResponse
+--- @field address (number|Int64HighLow)?
+
+--- @class ProtoBluetoothGATTReadRequest
+--- @field address (number|Int64HighLow)?
+--- @field handle number?
+
+--- @class ProtoBluetoothGATTReadResponse
+--- @field address (number|Int64HighLow)?
+--- @field handle number?
+--- @field data string?
+
+--- @class ProtoBluetoothGATTWriteRequest
+--- @field address (number|Int64HighLow)?
+--- @field handle number?
+--- @field response boolean?
+--- @field data string?
+
+--- @class ProtoBluetoothGATTReadDescriptorRequest
+--- @field address (number|Int64HighLow)?
+--- @field handle number?
+
+--- @class ProtoBluetoothGATTWriteDescriptorRequest
+--- @field address (number|Int64HighLow)?
+--- @field handle number?
+--- @field data string?
+
+--- @class ProtoBluetoothGATTNotifyRequest
+--- @field address (number|Int64HighLow)?
+--- @field handle number?
+--- @field enable boolean?
+
+--- @class ProtoBluetoothGATTNotifyDataResponse
+--- @field address (number|Int64HighLow)?
+--- @field handle number?
+--- @field data string?
+
+--- @class ProtoSubscribeBluetoothConnectionsFreeRequest
+
+--- @class ProtoBluetoothConnectionsFreeResponse
+--- @field free number?
+--- @field limit number?
+--- @field allocated (number[]|Int64HighLow[])?
+
+--- @class ProtoBluetoothGATTErrorResponse
+--- @field address (number|Int64HighLow)?
+--- @field handle number?
+--- @field error number?
+
+--- @class ProtoBluetoothGATTWriteResponse
+--- @field address (number|Int64HighLow)?
+--- @field handle number?
+
+--- @class ProtoBluetoothGATTNotifyResponse
+--- @field address (number|Int64HighLow)?
+--- @field handle number?
+
+--- @class ProtoBluetoothDevicePairingResponse
+--- @field address (number|Int64HighLow)?
+--- @field paired boolean?
+--- @field error number?
+
+--- @class ProtoBluetoothDeviceUnpairingResponse
+--- @field address (number|Int64HighLow)?
+--- @field success boolean?
+--- @field error number?
+
+--- @class ProtoUnsubscribeBluetoothLEAdvertisementsRequest
+
+--- @class ProtoBluetoothDeviceClearCacheResponse
+--- @field address (number|Int64HighLow)?
+--- @field success boolean?
+--- @field error number?
+
+--- @class ProtoBluetoothScannerStateResponse
+--- @field state ProtoBluetoothScannerState?
+--- @field mode ProtoBluetoothScannerMode?
+--- @field configured_mode ProtoBluetoothScannerMode?
+
+--- @class ProtoBluetoothScannerSetModeRequest
+--- @field mode ProtoBluetoothScannerMode?
+
+--- @class ProtoSubscribeVoiceAssistantRequest
+--- @field subscribe boolean?
+--- @field flags number?
+
+--- @class ProtoVoiceAssistantAudioSettings
+--- @field noise_suppression_level number?
+--- @field auto_gain number?
+--- @field volume_multiplier number?
+
+--- @class ProtoVoiceAssistantRequest
+--- @field start boolean?
+--- @field conversation_id string?
+--- @field flags number?
+--- @field audio_settings ProtoVoiceAssistantAudioSettings?
+--- @field wake_word_phrase string?
+
+--- @class ProtoVoiceAssistantResponse
+--- @field port number?
+--- @field error boolean?
+
+--- @class ProtoVoiceAssistantEventData
+--- @field name string?
+--- @field value string?
+
+--- @class ProtoVoiceAssistantEventResponse
+--- @field event_type ProtoVoiceAssistantEvent?
+--- @field data ProtoVoiceAssistantEventData[]?
+
+--- @class ProtoVoiceAssistantAudio
+--- @field data string?
+--- @field end boolean?
+
+--- @class ProtoVoiceAssistantTimerEventResponse
+--- @field event_type ProtoVoiceAssistantTimerEvent?
+--- @field timer_id string?
+--- @field name string?
+--- @field total_seconds number?
+--- @field seconds_left number?
+--- @field is_active boolean?
+
+--- @class ProtoVoiceAssistantAnnounceRequest
+--- @field media_id string?
+--- @field text string?
+--- @field preannounce_media_id string?
+--- @field start_conversation boolean?
+
+--- @class ProtoVoiceAssistantAnnounceFinished
+--- @field success boolean?
+
+--- @class ProtoVoiceAssistantWakeWord
+--- @field id string?
+--- @field wake_word string?
+--- @field trained_languages string[]?
+
+--- @class ProtoVoiceAssistantExternalWakeWord
+--- @field id string?
+--- @field wake_word string?
+--- @field trained_languages string[]?
+--- @field model_type string?
+--- @field model_size number?
+--- @field model_hash string?
+--- @field url string?
+
+--- @class ProtoVoiceAssistantConfigurationRequest
+--- @field external_wake_words ProtoVoiceAssistantExternalWakeWord[]?
+
+--- @class ProtoVoiceAssistantConfigurationResponse
+--- @field available_wake_words ProtoVoiceAssistantWakeWord[]?
+--- @field active_wake_words string[]?
+--- @field max_active_wake_words number?
+
+--- @class ProtoVoiceAssistantSetConfiguration
+--- @field active_wake_words string[]?
+
+--- @class ProtoListEntitiesAlarmControlPanelResponse
+--- @field object_id string?
+--- @field key number?
+--- @field name string?
+--- @field icon string?
+--- @field disabled_by_default boolean?
+--- @field entity_category ProtoEntityCategory?
+--- @field supported_features number?
+--- @field requires_code boolean?
+--- @field requires_code_to_arm boolean?
+--- @field device_id number?
+
+--- @class ProtoAlarmControlPanelStateResponse
+--- @field key number?
+--- @field state ProtoAlarmControlPanelState?
+--- @field device_id number?
+
+--- @class ProtoAlarmControlPanelCommandRequest
+--- @field key number?
+--- @field command ProtoAlarmControlPanelStateCommand?
+--- @field code string?
+--- @field device_id number?
+
+--- @class ProtoListEntitiesTextResponse
+--- @field object_id string?
+--- @field key number?
+--- @field name string?
+--- @field icon string?
+--- @field disabled_by_default boolean?
+--- @field entity_category ProtoEntityCategory?
+--- @field min_length number?
+--- @field max_length number?
+--- @field pattern string?
+--- @field mode ProtoTextMode?
+--- @field device_id number?
+
+--- @class ProtoTextStateResponse
+--- @field key number?
+--- @field state string?
+--- @field missing_state boolean?
+--- @field device_id number?
+
+--- @class ProtoTextCommandRequest
+--- @field key number?
+--- @field state string?
+--- @field device_id number?
+
+--- @class ProtoListEntitiesDateResponse
+--- @field object_id string?
+--- @field key number?
+--- @field name string?
+--- @field icon string?
+--- @field disabled_by_default boolean?
+--- @field entity_category ProtoEntityCategory?
+--- @field device_id number?
+
+--- @class ProtoDateStateResponse
+--- @field key number?
+--- @field missing_state boolean?
+--- @field year number?
+--- @field month number?
+--- @field day number?
+--- @field device_id number?
+
+--- @class ProtoDateCommandRequest
+--- @field key number?
+--- @field year number?
+--- @field month number?
+--- @field day number?
+--- @field device_id number?
+
+--- @class ProtoListEntitiesTimeResponse
+--- @field object_id string?
+--- @field key number?
+--- @field name string?
+--- @field icon string?
+--- @field disabled_by_default boolean?
+--- @field entity_category ProtoEntityCategory?
+--- @field device_id number?
+
+--- @class ProtoTimeStateResponse
+--- @field key number?
+--- @field missing_state boolean?
+--- @field hour number?
+--- @field minute number?
+--- @field second number?
+--- @field device_id number?
+
+--- @class ProtoTimeCommandRequest
+--- @field key number?
+--- @field hour number?
+--- @field minute number?
+--- @field second number?
+--- @field device_id number?
+
+--- @class ProtoListEntitiesEventResponse
+--- @field object_id string?
+--- @field key number?
+--- @field name string?
+--- @field icon string?
+--- @field disabled_by_default boolean?
+--- @field entity_category ProtoEntityCategory?
+--- @field device_class string?
+--- @field event_types string[]?
+--- @field device_id number?
+
+--- @class ProtoEventResponse
+--- @field key number?
+--- @field event_type string?
+--- @field device_id number?
+
+--- @class ProtoListEntitiesValveResponse
+--- @field object_id string?
+--- @field key number?
+--- @field name string?
+--- @field icon string?
+--- @field disabled_by_default boolean?
+--- @field entity_category ProtoEntityCategory?
+--- @field device_class string?
+--- @field assumed_state boolean?
+--- @field supports_position boolean?
+--- @field supports_stop boolean?
+--- @field device_id number?
+
+--- @class ProtoValveStateResponse
+--- @field key number?
+--- @field position number?
+--- @field current_operation ProtoValveOperation?
+--- @field device_id number?
+
+--- @class ProtoValveCommandRequest
+--- @field key number?
+--- @field has_position boolean?
+--- @field position number?
+--- @field stop boolean?
+--- @field device_id number?
+
+--- @class ProtoListEntitiesDateTimeResponse
+--- @field object_id string?
+--- @field key number?
+--- @field name string?
+--- @field icon string?
+--- @field disabled_by_default boolean?
+--- @field entity_category ProtoEntityCategory?
+--- @field device_id number?
+
+--- @class ProtoDateTimeStateResponse
+--- @field key number?
+--- @field missing_state boolean?
+--- @field epoch_seconds number?
+--- @field device_id number?
+
+--- @class ProtoDateTimeCommandRequest
+--- @field key number?
+--- @field epoch_seconds number?
+--- @field device_id number?
+
+--- @class ProtoListEntitiesUpdateResponse
+--- @field object_id string?
+--- @field key number?
+--- @field name string?
+--- @field icon string?
+--- @field disabled_by_default boolean?
+--- @field entity_category ProtoEntityCategory?
+--- @field device_class string?
+--- @field device_id number?
+
+--- @class ProtoUpdateStateResponse
+--- @field key number?
+--- @field missing_state boolean?
+--- @field in_progress boolean?
+--- @field has_progress boolean?
+--- @field progress number?
+--- @field current_version string?
+--- @field latest_version string?
+--- @field title string?
+--- @field release_summary string?
+--- @field release_url string?
+--- @field device_id number?
+
+--- @class ProtoUpdateCommandRequest
+--- @field key number?
+--- @field command ProtoUpdateCommand?
+--- @field device_id number?
+
+--- @class ProtoZWaveProxyFrame
+--- @field data string?
+
+--- @class ProtoZWaveProxyRequest
+--- @field type ProtoZWaveProxyRequestType?
+--- @field data string?
+
+--- @class ProtoListEntitiesInfraredResponse
+--- @field object_id string?
+--- @field key number?
+--- @field name string?
+--- @field icon string?
+--- @field disabled_by_default boolean?
+--- @field entity_category ProtoEntityCategory?
+--- @field device_id number?
+--- @field capabilities number?
+
+--- @class ProtoInfraredRFTransmitRawTimingsRequest
+--- @field device_id number?
+--- @field key number?
+--- @field carrier_frequency number?
+--- @field repeat_count number?
+--- @field timings number[]?
+
+--- @class ProtoInfraredRFReceiveEvent
+--- @field device_id number?
+--- @field key number?
+--- @field timings number[]?
+
+--- @enum ProtoAPISourceType
+ProtoSchema.Enum.APISourceType = {
+ SOURCE_BOTH = 0,
+ SOURCE_SERVER = 1,
+ SOURCE_CLIENT = 2,
+}
+
+--- @enum ProtoEntityCategory
+ProtoSchema.Enum.EntityCategory = {
+ ENTITY_CATEGORY_NONE = 0,
+ ENTITY_CATEGORY_CONFIG = 1,
+ ENTITY_CATEGORY_DIAGNOSTIC = 2,
+}
+
+--- @enum ProtoLegacyCoverState
+ProtoSchema.Enum.LegacyCoverState = {
+ LEGACY_COVER_STATE_OPEN = 0,
+ LEGACY_COVER_STATE_CLOSED = 1,
+}
+
+--- @enum ProtoCoverOperation
+ProtoSchema.Enum.CoverOperation = {
+ COVER_OPERATION_IDLE = 0,
+ COVER_OPERATION_IS_OPENING = 1,
+ COVER_OPERATION_IS_CLOSING = 2,
+}
+
+--- @enum ProtoLegacyCoverCommand
+ProtoSchema.Enum.LegacyCoverCommand = {
+ LEGACY_COVER_COMMAND_OPEN = 0,
+ LEGACY_COVER_COMMAND_CLOSE = 1,
+ LEGACY_COVER_COMMAND_STOP = 2,
+}
+
+--- @enum ProtoFanSpeed
+ProtoSchema.Enum.FanSpeed = {
+ FAN_SPEED_LOW = 0,
+ FAN_SPEED_MEDIUM = 1,
+ FAN_SPEED_HIGH = 2,
+}
+
+--- @enum ProtoFanDirection
+ProtoSchema.Enum.FanDirection = {
+ FAN_DIRECTION_FORWARD = 0,
+ FAN_DIRECTION_REVERSE = 1,
+}
+
+--- @enum ProtoColorMode
+ProtoSchema.Enum.ColorMode = {
+ COLOR_MODE_UNKNOWN = 0,
+ COLOR_MODE_ON_OFF = 1,
+ COLOR_MODE_LEGACY_BRIGHTNESS = 2,
+ COLOR_MODE_BRIGHTNESS = 3,
+ COLOR_MODE_WHITE = 7,
+ COLOR_MODE_COLOR_TEMPERATURE = 11,
+ COLOR_MODE_COLD_WARM_WHITE = 19,
+ COLOR_MODE_RGB = 35,
+ COLOR_MODE_RGB_WHITE = 39,
+ COLOR_MODE_RGB_COLOR_TEMPERATURE = 47,
+ COLOR_MODE_RGB_COLD_WARM_WHITE = 51,
+}
+
+--- @enum ProtoSensorStateClass
+ProtoSchema.Enum.SensorStateClass = {
+ STATE_CLASS_NONE = 0,
+ STATE_CLASS_MEASUREMENT = 1,
+ STATE_CLASS_TOTAL_INCREASING = 2,
+ STATE_CLASS_TOTAL = 3,
+ STATE_CLASS_MEASUREMENT_ANGLE = 4,
+}
+
+--- @enum ProtoSensorLastResetType
+ProtoSchema.Enum.SensorLastResetType = {
+ LAST_RESET_NONE = 0,
+ LAST_RESET_NEVER = 1,
+ LAST_RESET_AUTO = 2,
+}
+
+--- @enum ProtoLogLevel
+ProtoSchema.Enum.LogLevel = {
+ LOG_LEVEL_NONE = 0,
+ LOG_LEVEL_ERROR = 1,
+ LOG_LEVEL_WARN = 2,
+ LOG_LEVEL_INFO = 3,
+ LOG_LEVEL_CONFIG = 4,
+ LOG_LEVEL_DEBUG = 5,
+ LOG_LEVEL_VERBOSE = 6,
+ LOG_LEVEL_VERY_VERBOSE = 7,
+}
+
+--- @enum ProtoServiceArgType
+ProtoSchema.Enum.ServiceArgType = {
+ SERVICE_ARG_TYPE_BOOL = 0,
+ SERVICE_ARG_TYPE_INT = 1,
+ SERVICE_ARG_TYPE_FLOAT = 2,
+ SERVICE_ARG_TYPE_STRING = 3,
+ SERVICE_ARG_TYPE_BOOL_ARRAY = 4,
+ SERVICE_ARG_TYPE_INT_ARRAY = 5,
+ SERVICE_ARG_TYPE_FLOAT_ARRAY = 6,
+ SERVICE_ARG_TYPE_STRING_ARRAY = 7,
+}
+
+--- @enum ProtoSupportsResponseType
+ProtoSchema.Enum.SupportsResponseType = {
+ SUPPORTS_RESPONSE_NONE = 0,
+ SUPPORTS_RESPONSE_OPTIONAL = 1,
+ SUPPORTS_RESPONSE_ONLY = 2,
+ SUPPORTS_RESPONSE_STATUS = 100,
+}
+
+--- @enum ProtoClimateMode
+ProtoSchema.Enum.ClimateMode = {
+ CLIMATE_MODE_OFF = 0,
+ CLIMATE_MODE_HEAT_COOL = 1,
+ CLIMATE_MODE_COOL = 2,
+ CLIMATE_MODE_HEAT = 3,
+ CLIMATE_MODE_FAN_ONLY = 4,
+ CLIMATE_MODE_DRY = 5,
+ CLIMATE_MODE_AUTO = 6,
+}
+
+--- @enum ProtoClimateFanMode
+ProtoSchema.Enum.ClimateFanMode = {
+ CLIMATE_FAN_ON = 0,
+ CLIMATE_FAN_OFF = 1,
+ CLIMATE_FAN_AUTO = 2,
+ CLIMATE_FAN_LOW = 3,
+ CLIMATE_FAN_MEDIUM = 4,
+ CLIMATE_FAN_HIGH = 5,
+ CLIMATE_FAN_MIDDLE = 6,
+ CLIMATE_FAN_FOCUS = 7,
+ CLIMATE_FAN_DIFFUSE = 8,
+ CLIMATE_FAN_QUIET = 9,
+}
+
+--- @enum ProtoClimateSwingMode
+ProtoSchema.Enum.ClimateSwingMode = {
+ CLIMATE_SWING_OFF = 0,
+ CLIMATE_SWING_BOTH = 1,
+ CLIMATE_SWING_VERTICAL = 2,
+ CLIMATE_SWING_HORIZONTAL = 3,
+}
+
+--- @enum ProtoClimateAction
+ProtoSchema.Enum.ClimateAction = {
+ CLIMATE_ACTION_OFF = 0,
+ CLIMATE_ACTION_COOLING = 2,
+ CLIMATE_ACTION_HEATING = 3,
+ CLIMATE_ACTION_IDLE = 4,
+ CLIMATE_ACTION_DRYING = 5,
+ CLIMATE_ACTION_FAN = 6,
+}
+
+--- @enum ProtoClimatePreset
+ProtoSchema.Enum.ClimatePreset = {
+ CLIMATE_PRESET_NONE = 0,
+ CLIMATE_PRESET_HOME = 1,
+ CLIMATE_PRESET_AWAY = 2,
+ CLIMATE_PRESET_BOOST = 3,
+ CLIMATE_PRESET_COMFORT = 4,
+ CLIMATE_PRESET_ECO = 5,
+ CLIMATE_PRESET_SLEEP = 6,
+ CLIMATE_PRESET_ACTIVITY = 7,
+}
+
+--- @enum ProtoWaterHeaterMode
+ProtoSchema.Enum.WaterHeaterMode = {
+ WATER_HEATER_MODE_OFF = 0,
+ WATER_HEATER_MODE_ECO = 1,
+ WATER_HEATER_MODE_ELECTRIC = 2,
+ WATER_HEATER_MODE_PERFORMANCE = 3,
+ WATER_HEATER_MODE_HIGH_DEMAND = 4,
+ WATER_HEATER_MODE_HEAT_PUMP = 5,
+ WATER_HEATER_MODE_GAS = 6,
+}
+
+--- @enum ProtoWaterHeaterCommandHasField
+ProtoSchema.Enum.WaterHeaterCommandHasField = {
+ WATER_HEATER_COMMAND_HAS_NONE = 0,
+ WATER_HEATER_COMMAND_HAS_MODE = 1,
+ WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE = 2,
+ WATER_HEATER_COMMAND_HAS_STATE = 4,
+ WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE_LOW = 8,
+ WATER_HEATER_COMMAND_HAS_TARGET_TEMPERATURE_HIGH = 16,
+ WATER_HEATER_COMMAND_HAS_ON_STATE = 32,
+ WATER_HEATER_COMMAND_HAS_AWAY_STATE = 64,
+}
+
+--- @enum ProtoNumberMode
+ProtoSchema.Enum.NumberMode = {
+ NUMBER_MODE_AUTO = 0,
+ NUMBER_MODE_BOX = 1,
+ NUMBER_MODE_SLIDER = 2,
+}
+
+--- @enum ProtoLockState
+ProtoSchema.Enum.LockState = {
+ LOCK_STATE_NONE = 0,
+ LOCK_STATE_LOCKED = 1,
+ LOCK_STATE_UNLOCKED = 2,
+ LOCK_STATE_JAMMED = 3,
+ LOCK_STATE_LOCKING = 4,
+ LOCK_STATE_UNLOCKING = 5,
+}
+
+--- @enum ProtoLockCommand
+ProtoSchema.Enum.LockCommand = {
+ LOCK_UNLOCK = 0,
+ LOCK_LOCK = 1,
+ LOCK_OPEN = 2,
+}
+
+--- @enum ProtoMediaPlayerState
+ProtoSchema.Enum.MediaPlayerState = {
+ MEDIA_PLAYER_STATE_NONE = 0,
+ MEDIA_PLAYER_STATE_IDLE = 1,
+ MEDIA_PLAYER_STATE_PLAYING = 2,
+ MEDIA_PLAYER_STATE_PAUSED = 3,
+ MEDIA_PLAYER_STATE_ANNOUNCING = 4,
+ MEDIA_PLAYER_STATE_OFF = 5,
+ MEDIA_PLAYER_STATE_ON = 6,
+}
+
+--- @enum ProtoMediaPlayerCommand
+ProtoSchema.Enum.MediaPlayerCommand = {
+ MEDIA_PLAYER_COMMAND_PLAY = 0,
+ MEDIA_PLAYER_COMMAND_PAUSE = 1,
+ MEDIA_PLAYER_COMMAND_STOP = 2,
+ MEDIA_PLAYER_COMMAND_MUTE = 3,
+ MEDIA_PLAYER_COMMAND_UNMUTE = 4,
+ MEDIA_PLAYER_COMMAND_TOGGLE = 5,
+ MEDIA_PLAYER_COMMAND_VOLUME_UP = 6,
+ MEDIA_PLAYER_COMMAND_VOLUME_DOWN = 7,
+ MEDIA_PLAYER_COMMAND_ENQUEUE = 8,
+ MEDIA_PLAYER_COMMAND_REPEAT_ONE = 9,
+ MEDIA_PLAYER_COMMAND_REPEAT_OFF = 10,
+ MEDIA_PLAYER_COMMAND_CLEAR_PLAYLIST = 11,
+ MEDIA_PLAYER_COMMAND_TURN_ON = 12,
+ MEDIA_PLAYER_COMMAND_TURN_OFF = 13,
+}
+
+--- @enum ProtoMediaPlayerFormatPurpose
+ProtoSchema.Enum.MediaPlayerFormatPurpose = {
+ MEDIA_PLAYER_FORMAT_PURPOSE_DEFAULT = 0,
+ MEDIA_PLAYER_FORMAT_PURPOSE_ANNOUNCEMENT = 1,
+}
+
+--- @enum ProtoBluetoothDeviceRequestType
+ProtoSchema.Enum.BluetoothDeviceRequestType = {
+ BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT = 0,
+ BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT = 1,
+ BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR = 2,
+ BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR = 3,
+ BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITH_CACHE = 4,
+ BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE = 5,
+ BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE = 6,
+}
+
+--- @enum ProtoBluetoothScannerState
+ProtoSchema.Enum.BluetoothScannerState = {
+ BLUETOOTH_SCANNER_STATE_IDLE = 0,
+ BLUETOOTH_SCANNER_STATE_STARTING = 1,
+ BLUETOOTH_SCANNER_STATE_RUNNING = 2,
+ BLUETOOTH_SCANNER_STATE_FAILED = 3,
+ BLUETOOTH_SCANNER_STATE_STOPPING = 4,
+ BLUETOOTH_SCANNER_STATE_STOPPED = 5,
+}
+
+--- @enum ProtoBluetoothScannerMode
+ProtoSchema.Enum.BluetoothScannerMode = {
+ BLUETOOTH_SCANNER_MODE_PASSIVE = 0,
+ BLUETOOTH_SCANNER_MODE_ACTIVE = 1,
+}
+
+--- @enum ProtoVoiceAssistantSubscribeFlag
+ProtoSchema.Enum.VoiceAssistantSubscribeFlag = {
+ VOICE_ASSISTANT_SUBSCRIBE_NONE = 0,
+ VOICE_ASSISTANT_SUBSCRIBE_API_AUDIO = 1,
+}
+
+--- @enum ProtoVoiceAssistantRequestFlag
+ProtoSchema.Enum.VoiceAssistantRequestFlag = {
+ VOICE_ASSISTANT_REQUEST_NONE = 0,
+ VOICE_ASSISTANT_REQUEST_USE_VAD = 1,
+ VOICE_ASSISTANT_REQUEST_USE_WAKE_WORD = 2,
+}
+
+--- @enum ProtoVoiceAssistantEvent
+ProtoSchema.Enum.VoiceAssistantEvent = {
+ VOICE_ASSISTANT_ERROR = 0,
+ VOICE_ASSISTANT_RUN_START = 1,
+ VOICE_ASSISTANT_RUN_END = 2,
+ VOICE_ASSISTANT_STT_START = 3,
+ VOICE_ASSISTANT_STT_END = 4,
+ VOICE_ASSISTANT_INTENT_START = 5,
+ VOICE_ASSISTANT_INTENT_END = 6,
+ VOICE_ASSISTANT_TTS_START = 7,
+ VOICE_ASSISTANT_TTS_END = 8,
+ VOICE_ASSISTANT_WAKE_WORD_START = 9,
+ VOICE_ASSISTANT_WAKE_WORD_END = 10,
+ VOICE_ASSISTANT_STT_VAD_START = 11,
+ VOICE_ASSISTANT_STT_VAD_END = 12,
+ VOICE_ASSISTANT_TTS_STREAM_START = 98,
+ VOICE_ASSISTANT_TTS_STREAM_END = 99,
+ VOICE_ASSISTANT_INTENT_PROGRESS = 100,
+}
+
+--- @enum ProtoVoiceAssistantTimerEvent
+ProtoSchema.Enum.VoiceAssistantTimerEvent = {
+ VOICE_ASSISTANT_TIMER_STARTED = 0,
+ VOICE_ASSISTANT_TIMER_UPDATED = 1,
+ VOICE_ASSISTANT_TIMER_CANCELLED = 2,
+ VOICE_ASSISTANT_TIMER_FINISHED = 3,
+}
+
+--- @enum ProtoAlarmControlPanelState
+ProtoSchema.Enum.AlarmControlPanelState = {
+ ALARM_STATE_DISARMED = 0,
+ ALARM_STATE_ARMED_HOME = 1,
+ ALARM_STATE_ARMED_AWAY = 2,
+ ALARM_STATE_ARMED_NIGHT = 3,
+ ALARM_STATE_ARMED_VACATION = 4,
+ ALARM_STATE_ARMED_CUSTOM_BYPASS = 5,
+ ALARM_STATE_PENDING = 6,
+ ALARM_STATE_ARMING = 7,
+ ALARM_STATE_DISARMING = 8,
+ ALARM_STATE_TRIGGERED = 9,
+}
+
+--- @enum ProtoAlarmControlPanelStateCommand
+ProtoSchema.Enum.AlarmControlPanelStateCommand = {
+ ALARM_CONTROL_PANEL_DISARM = 0,
+ ALARM_CONTROL_PANEL_ARM_AWAY = 1,
+ ALARM_CONTROL_PANEL_ARM_HOME = 2,
+ ALARM_CONTROL_PANEL_ARM_NIGHT = 3,
+ ALARM_CONTROL_PANEL_ARM_VACATION = 4,
+ ALARM_CONTROL_PANEL_ARM_CUSTOM_BYPASS = 5,
+ ALARM_CONTROL_PANEL_TRIGGER = 6,
+}
+
+--- @enum ProtoTextMode
+ProtoSchema.Enum.TextMode = {
+ TEXT_MODE_TEXT = 0,
+ TEXT_MODE_PASSWORD = 1,
+}
+
+--- @enum ProtoValveOperation
+ProtoSchema.Enum.ValveOperation = {
+ VALVE_OPERATION_IDLE = 0,
+ VALVE_OPERATION_IS_OPENING = 1,
+ VALVE_OPERATION_IS_CLOSING = 2,
+}
+
+--- @enum ProtoUpdateCommand
+ProtoSchema.Enum.UpdateCommand = {
+ UPDATE_COMMAND_NONE = 0,
+ UPDATE_COMMAND_UPDATE = 1,
+ UPDATE_COMMAND_CHECK = 2,
+}
+
+--- @enum ProtoZWaveProxyRequestType
+ProtoSchema.Enum.ZWaveProxyRequestType = {
+ ZWAVE_PROXY_REQUEST_TYPE_SUBSCRIBE = 0,
+ ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE = 1,
+ ZWAVE_PROXY_REQUEST_TYPE_HOME_ID_CHANGE = 2,
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.void = {
+ name = "void",
+ options = {},
+ fields = {},
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.HelloRequest = {
+ name = "HelloRequest",
+ options = {
+ id = 1,
+ source = 2,
+ no_delay = 1,
+ },
+ fields = {
+ [1] = {
+ name = "client_info",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "api_version_major",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [3] = {
+ name = "api_version_minor",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.HelloResponse = {
+ name = "HelloResponse",
+ options = {
+ id = 2,
+ source = 1,
+ no_delay = 1,
+ },
+ fields = {
+ [1] = {
+ name = "api_version_major",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [2] = {
+ name = "api_version_minor",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [3] = {
+ name = "server_info",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [4] = {
+ name = "name",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.AuthenticationRequest = {
+ name = "AuthenticationRequest",
+ options = {
+ id = 3,
+ source = 2,
+ no_delay = 1,
+ },
+ fields = {
+ [1] = {
+ name = "password",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.AuthenticationResponse = {
+ name = "AuthenticationResponse",
+ options = {
+ id = 4,
+ source = 1,
+ no_delay = 1,
+ },
+ fields = {
+ [1] = {
+ name = "invalid_password",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.DisconnectRequest = {
+ name = "DisconnectRequest",
+ options = {
+ id = 5,
+ source = 0,
+ no_delay = 1,
+ },
+ fields = {},
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.DisconnectResponse = {
+ name = "DisconnectResponse",
+ options = {
+ id = 6,
+ source = 0,
+ no_delay = 1,
+ },
+ fields = {},
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.PingRequest = {
+ name = "PingRequest",
+ options = {
+ id = 7,
+ source = 0,
+ },
+ fields = {},
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.PingResponse = {
+ name = "PingResponse",
+ options = {
+ id = 8,
+ source = 0,
+ },
+ fields = {},
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.DeviceInfoRequest = {
+ name = "DeviceInfoRequest",
+ options = {
+ id = 9,
+ source = 2,
+ },
+ fields = {},
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.AreaInfo = {
+ name = "AreaInfo",
+ options = {},
+ fields = {
+ [1] = {
+ name = "area_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [2] = {
+ name = "name",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.DeviceInfo = {
+ name = "DeviceInfo",
+ options = {},
+ fields = {
+ [1] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [2] = {
+ name = "name",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [3] = {
+ name = "area_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.DeviceInfoResponse = {
+ name = "DeviceInfoResponse",
+ options = {
+ id = 10,
+ source = 1,
+ },
+ fields = {
+ [1] = {
+ name = "uses_password",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [2] = {
+ name = "name",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [3] = {
+ name = "mac_address",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [4] = {
+ name = "esphome_version",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [5] = {
+ name = "compilation_time",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [6] = {
+ name = "model",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [7] = {
+ name = "has_deep_sleep",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [8] = {
+ name = "project_name",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [9] = {
+ name = "project_version",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [10] = {
+ name = "webserver_port",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [11] = {
+ name = "legacy_bluetooth_proxy_version",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [15] = {
+ name = "bluetooth_proxy_feature_flags",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [12] = {
+ name = "manufacturer",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [13] = {
+ name = "friendly_name",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [14] = {
+ name = "legacy_voice_assistant_version",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [17] = {
+ name = "voice_assistant_feature_flags",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [16] = {
+ name = "suggested_area",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [18] = {
+ name = "bluetooth_mac_address",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [19] = {
+ name = "api_encryption_supported",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [20] = {
+ name = "devices",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.MESSAGE,
+ repeated = true,
+ subschema = "DeviceInfo",
+ },
+ [21] = {
+ name = "areas",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.MESSAGE,
+ repeated = true,
+ subschema = "AreaInfo",
+ },
+ [22] = {
+ name = "area",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.MESSAGE,
+ subschema = "AreaInfo",
+ },
+ [23] = {
+ name = "zwave_proxy_feature_flags",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [24] = {
+ name = "zwave_home_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ListEntitiesRequest = {
+ name = "ListEntitiesRequest",
+ options = {
+ id = 11,
+ source = 2,
+ },
+ fields = {},
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ListEntitiesDoneResponse = {
+ name = "ListEntitiesDoneResponse",
+ options = {
+ id = 19,
+ source = 1,
+ no_delay = 1,
+ },
+ fields = {},
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.SubscribeStatesRequest = {
+ name = "SubscribeStatesRequest",
+ options = {
+ id = 20,
+ source = 2,
+ },
+ fields = {},
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ListEntitiesBinarySensorResponse = {
+ name = "ListEntitiesBinarySensorResponse",
+ options = {
+ id = 12,
+ source = 1,
+ ifdef = "USE_BINARY_SENSOR",
+ base_class = "InfoResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "object_id",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [3] = {
+ name = "name",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [5] = {
+ name = "device_class",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [6] = {
+ name = "is_status_binary_sensor",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [7] = {
+ name = "disabled_by_default",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [8] = {
+ name = "icon",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [9] = {
+ name = "entity_category",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- EntityCategory
+ },
+ [10] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.BinarySensorStateResponse = {
+ name = "BinarySensorStateResponse",
+ options = {
+ id = 21,
+ source = 1,
+ ifdef = "USE_BINARY_SENSOR",
+ no_delay = 1,
+ base_class = "StateResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "state",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [3] = {
+ name = "missing_state",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [4] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ListEntitiesCoverResponse = {
+ name = "ListEntitiesCoverResponse",
+ options = {
+ id = 13,
+ source = 1,
+ ifdef = "USE_COVER",
+ base_class = "InfoResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "object_id",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [3] = {
+ name = "name",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [5] = {
+ name = "assumed_state",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [6] = {
+ name = "supports_position",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [7] = {
+ name = "supports_tilt",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [8] = {
+ name = "device_class",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [9] = {
+ name = "disabled_by_default",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [10] = {
+ name = "icon",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [11] = {
+ name = "entity_category",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- EntityCategory
+ },
+ [12] = {
+ name = "supports_stop",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [13] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.CoverStateResponse = {
+ name = "CoverStateResponse",
+ options = {
+ id = 22,
+ source = 1,
+ ifdef = "USE_COVER",
+ no_delay = 1,
+ base_class = "StateResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "legacy_state",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- LegacyCoverState
+ },
+ [3] = {
+ name = "position",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [4] = {
+ name = "tilt",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [5] = {
+ name = "current_operation",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- CoverOperation
+ },
+ [6] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.CoverCommandRequest = {
+ name = "CoverCommandRequest",
+ options = {
+ id = 30,
+ source = 2,
+ ifdef = "USE_COVER",
+ no_delay = 1,
+ base_class = "CommandProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "has_legacy_command",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [3] = {
+ name = "legacy_command",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- LegacyCoverCommand
+ },
+ [4] = {
+ name = "has_position",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [5] = {
+ name = "position",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [6] = {
+ name = "has_tilt",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [7] = {
+ name = "tilt",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [8] = {
+ name = "stop",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [9] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ListEntitiesFanResponse = {
+ name = "ListEntitiesFanResponse",
+ options = {
+ id = 14,
+ source = 1,
+ ifdef = "USE_FAN",
+ base_class = "InfoResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "object_id",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [3] = {
+ name = "name",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [5] = {
+ name = "supports_oscillation",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [6] = {
+ name = "supports_speed",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [7] = {
+ name = "supports_direction",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [8] = {
+ name = "supported_speed_count",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.INT32,
+ },
+ [9] = {
+ name = "disabled_by_default",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [10] = {
+ name = "icon",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [11] = {
+ name = "entity_category",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- EntityCategory
+ },
+ [12] = {
+ name = "supported_preset_modes",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ repeated = true,
+ },
+ [13] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.FanStateResponse = {
+ name = "FanStateResponse",
+ options = {
+ id = 23,
+ source = 1,
+ ifdef = "USE_FAN",
+ no_delay = 1,
+ base_class = "StateResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "state",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [3] = {
+ name = "oscillating",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [4] = {
+ name = "speed",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- FanSpeed
+ },
+ [5] = {
+ name = "direction",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- FanDirection
+ },
+ [6] = {
+ name = "speed_level",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.INT32,
+ },
+ [7] = {
+ name = "preset_mode",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [8] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.FanCommandRequest = {
+ name = "FanCommandRequest",
+ options = {
+ id = 31,
+ source = 2,
+ ifdef = "USE_FAN",
+ no_delay = 1,
+ base_class = "CommandProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "has_state",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [3] = {
+ name = "state",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [4] = {
+ name = "has_speed",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [5] = {
+ name = "speed",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- FanSpeed
+ },
+ [6] = {
+ name = "has_oscillating",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [7] = {
+ name = "oscillating",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [8] = {
+ name = "has_direction",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [9] = {
+ name = "direction",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- FanDirection
+ },
+ [10] = {
+ name = "has_speed_level",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [11] = {
+ name = "speed_level",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.INT32,
+ },
+ [12] = {
+ name = "has_preset_mode",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [13] = {
+ name = "preset_mode",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [14] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ListEntitiesLightResponse = {
+ name = "ListEntitiesLightResponse",
+ options = {
+ id = 15,
+ source = 1,
+ ifdef = "USE_LIGHT",
+ base_class = "InfoResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "object_id",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [3] = {
+ name = "name",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [12] = {
+ name = "supported_color_modes",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- ColorMode
+ repeated = true,
+ },
+ [5] = {
+ name = "legacy_supports_brightness",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [6] = {
+ name = "legacy_supports_rgb",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [7] = {
+ name = "legacy_supports_white_value",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [8] = {
+ name = "legacy_supports_color_temperature",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [9] = {
+ name = "min_mireds",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [10] = {
+ name = "max_mireds",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [11] = {
+ name = "effects",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ repeated = true,
+ },
+ [13] = {
+ name = "disabled_by_default",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [14] = {
+ name = "icon",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [15] = {
+ name = "entity_category",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- EntityCategory
+ },
+ [16] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.LightStateResponse = {
+ name = "LightStateResponse",
+ options = {
+ id = 24,
+ source = 1,
+ ifdef = "USE_LIGHT",
+ no_delay = 1,
+ base_class = "StateResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "state",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [3] = {
+ name = "brightness",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [11] = {
+ name = "color_mode",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- ColorMode
+ },
+ [10] = {
+ name = "color_brightness",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [4] = {
+ name = "red",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [5] = {
+ name = "green",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [6] = {
+ name = "blue",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [7] = {
+ name = "white",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [8] = {
+ name = "color_temperature",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [12] = {
+ name = "cold_white",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [13] = {
+ name = "warm_white",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [9] = {
+ name = "effect",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [14] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.LightCommandRequest = {
+ name = "LightCommandRequest",
+ options = {
+ id = 32,
+ source = 2,
+ ifdef = "USE_LIGHT",
+ no_delay = 1,
+ base_class = "CommandProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "has_state",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [3] = {
+ name = "state",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [4] = {
+ name = "has_brightness",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [5] = {
+ name = "brightness",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [22] = {
+ name = "has_color_mode",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [23] = {
+ name = "color_mode",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- ColorMode
+ },
+ [20] = {
+ name = "has_color_brightness",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [21] = {
+ name = "color_brightness",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [6] = {
+ name = "has_rgb",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [7] = {
+ name = "red",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [8] = {
+ name = "green",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [9] = {
+ name = "blue",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [10] = {
+ name = "has_white",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [11] = {
+ name = "white",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [12] = {
+ name = "has_color_temperature",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [13] = {
+ name = "color_temperature",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [24] = {
+ name = "has_cold_white",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [25] = {
+ name = "cold_white",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [26] = {
+ name = "has_warm_white",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [27] = {
+ name = "warm_white",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [14] = {
+ name = "has_transition_length",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [15] = {
+ name = "transition_length",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [16] = {
+ name = "has_flash_length",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [17] = {
+ name = "flash_length",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [18] = {
+ name = "has_effect",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [19] = {
+ name = "effect",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [28] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ListEntitiesSensorResponse = {
+ name = "ListEntitiesSensorResponse",
+ options = {
+ id = 16,
+ source = 1,
+ ifdef = "USE_SENSOR",
+ base_class = "InfoResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "object_id",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [3] = {
+ name = "name",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [5] = {
+ name = "icon",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [6] = {
+ name = "unit_of_measurement",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [7] = {
+ name = "accuracy_decimals",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.INT32,
+ },
+ [8] = {
+ name = "force_update",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [9] = {
+ name = "device_class",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [10] = {
+ name = "state_class",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- SensorStateClass
+ },
+ [11] = {
+ name = "legacy_last_reset_type",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- SensorLastResetType
+ },
+ [12] = {
+ name = "disabled_by_default",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [13] = {
+ name = "entity_category",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- EntityCategory
+ },
+ [14] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.SensorStateResponse = {
+ name = "SensorStateResponse",
+ options = {
+ id = 25,
+ source = 1,
+ ifdef = "USE_SENSOR",
+ no_delay = 1,
+ base_class = "StateResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "state",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [3] = {
+ name = "missing_state",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [4] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ListEntitiesSwitchResponse = {
+ name = "ListEntitiesSwitchResponse",
+ options = {
+ id = 17,
+ source = 1,
+ ifdef = "USE_SWITCH",
+ base_class = "InfoResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "object_id",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [3] = {
+ name = "name",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [5] = {
+ name = "icon",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [6] = {
+ name = "assumed_state",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [7] = {
+ name = "disabled_by_default",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [8] = {
+ name = "entity_category",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- EntityCategory
+ },
+ [9] = {
+ name = "device_class",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [10] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.SwitchStateResponse = {
+ name = "SwitchStateResponse",
+ options = {
+ id = 26,
+ source = 1,
+ ifdef = "USE_SWITCH",
+ no_delay = 1,
+ base_class = "StateResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "state",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [3] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.SwitchCommandRequest = {
+ name = "SwitchCommandRequest",
+ options = {
+ id = 33,
+ source = 2,
+ ifdef = "USE_SWITCH",
+ no_delay = 1,
+ base_class = "CommandProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "state",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [3] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ListEntitiesTextSensorResponse = {
+ name = "ListEntitiesTextSensorResponse",
+ options = {
+ id = 18,
+ source = 1,
+ ifdef = "USE_TEXT_SENSOR",
+ base_class = "InfoResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "object_id",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [3] = {
+ name = "name",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [5] = {
+ name = "icon",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [6] = {
+ name = "disabled_by_default",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [7] = {
+ name = "entity_category",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- EntityCategory
+ },
+ [8] = {
+ name = "device_class",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [9] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.TextSensorStateResponse = {
+ name = "TextSensorStateResponse",
+ options = {
+ id = 27,
+ source = 1,
+ ifdef = "USE_TEXT_SENSOR",
+ no_delay = 1,
+ base_class = "StateResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "state",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [3] = {
+ name = "missing_state",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [4] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.SubscribeLogsRequest = {
+ name = "SubscribeLogsRequest",
+ options = {
+ id = 28,
+ source = 2,
+ },
+ fields = {
+ [1] = {
+ name = "level",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- LogLevel
+ },
+ [2] = {
+ name = "dump_config",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.SubscribeLogsResponse = {
+ name = "SubscribeLogsResponse",
+ options = {
+ id = 29,
+ source = 1,
+ log = 0,
+ no_delay = 0,
+ },
+ fields = {
+ [1] = {
+ name = "level",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- LogLevel
+ },
+ [3] = {
+ name = "message",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.BYTES,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.NoiseEncryptionSetKeyRequest = {
+ name = "NoiseEncryptionSetKeyRequest",
+ options = {
+ id = 124,
+ source = 2,
+ ifdef = "USE_API_NOISE",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.BYTES,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.NoiseEncryptionSetKeyResponse = {
+ name = "NoiseEncryptionSetKeyResponse",
+ options = {
+ id = 125,
+ source = 1,
+ ifdef = "USE_API_NOISE",
+ },
+ fields = {
+ [1] = {
+ name = "success",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.SubscribeHomeassistantServicesRequest = {
+ name = "SubscribeHomeassistantServicesRequest",
+ options = {
+ id = 34,
+ source = 2,
+ ifdef = "USE_API_HOMEASSISTANT_SERVICES",
+ },
+ fields = {},
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.HomeassistantServiceMap = {
+ name = "HomeassistantServiceMap",
+ options = {},
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "value",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.HomeassistantActionRequest = {
+ name = "HomeassistantActionRequest",
+ options = {
+ id = 35,
+ source = 1,
+ ifdef = "USE_API_HOMEASSISTANT_SERVICES",
+ no_delay = 1,
+ },
+ fields = {
+ [1] = {
+ name = "service",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "data",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.MESSAGE,
+ repeated = true,
+ subschema = "HomeassistantServiceMap",
+ },
+ [3] = {
+ name = "data_template",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.MESSAGE,
+ repeated = true,
+ subschema = "HomeassistantServiceMap",
+ },
+ [4] = {
+ name = "variables",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.MESSAGE,
+ repeated = true,
+ subschema = "HomeassistantServiceMap",
+ },
+ [5] = {
+ name = "is_event",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [6] = {
+ name = "call_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [7] = {
+ name = "wants_response",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [8] = {
+ name = "response_template",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.HomeassistantActionResponse = {
+ name = "HomeassistantActionResponse",
+ options = {
+ id = 130,
+ source = 2,
+ ifdef = "USE_API_HOMEASSISTANT_ACTION_RESPONSES",
+ no_delay = 1,
+ },
+ fields = {
+ [1] = {
+ name = "call_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [2] = {
+ name = "success",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [3] = {
+ name = "error_message",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [4] = {
+ name = "response_data",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.BYTES,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.SubscribeHomeAssistantStatesRequest = {
+ name = "SubscribeHomeAssistantStatesRequest",
+ options = {
+ id = 38,
+ source = 2,
+ ifdef = "USE_API_HOMEASSISTANT_STATES",
+ },
+ fields = {},
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.SubscribeHomeAssistantStateResponse = {
+ name = "SubscribeHomeAssistantStateResponse",
+ options = {
+ id = 39,
+ source = 1,
+ ifdef = "USE_API_HOMEASSISTANT_STATES",
+ },
+ fields = {
+ [1] = {
+ name = "entity_id",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "attribute",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [3] = {
+ name = "once",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.HomeAssistantStateResponse = {
+ name = "HomeAssistantStateResponse",
+ options = {
+ id = 40,
+ source = 2,
+ ifdef = "USE_API_HOMEASSISTANT_STATES",
+ no_delay = 1,
+ },
+ fields = {
+ [1] = {
+ name = "entity_id",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "state",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [3] = {
+ name = "attribute",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.GetTimeRequest = {
+ name = "GetTimeRequest",
+ options = {
+ id = 36,
+ source = 1,
+ },
+ fields = {},
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.GetTimeResponse = {
+ name = "GetTimeResponse",
+ options = {
+ id = 37,
+ source = 2,
+ no_delay = 1,
+ },
+ fields = {
+ [1] = {
+ name = "epoch_seconds",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "timezone",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ListEntitiesServicesArgument = {
+ name = "ListEntitiesServicesArgument",
+ options = {
+ ifdef = "USE_API_USER_DEFINED_ACTIONS",
+ },
+ fields = {
+ [1] = {
+ name = "name",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "type",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- ServiceArgType
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ListEntitiesServicesResponse = {
+ name = "ListEntitiesServicesResponse",
+ options = {
+ id = 41,
+ source = 1,
+ ifdef = "USE_API_USER_DEFINED_ACTIONS",
+ },
+ fields = {
+ [1] = {
+ name = "name",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [3] = {
+ name = "args",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.MESSAGE,
+ repeated = true,
+ subschema = "ListEntitiesServicesArgument",
+ },
+ [4] = {
+ name = "supports_response",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- SupportsResponseType
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ExecuteServiceArgument = {
+ name = "ExecuteServiceArgument",
+ options = {
+ ifdef = "USE_API_USER_DEFINED_ACTIONS",
+ },
+ fields = {
+ [1] = {
+ name = "bool_",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [2] = {
+ name = "legacy_int",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.INT32,
+ },
+ [3] = {
+ name = "float_",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [4] = {
+ name = "string_",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [5] = {
+ name = "int_",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.SINT32,
+ },
+ [6] = {
+ name = "bool_array",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ repeated = true,
+ },
+ [7] = {
+ name = "int_array",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.SINT32,
+ repeated = true,
+ },
+ [8] = {
+ name = "float_array",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ repeated = true,
+ },
+ [9] = {
+ name = "string_array",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ repeated = true,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ExecuteServiceRequest = {
+ name = "ExecuteServiceRequest",
+ options = {
+ id = 42,
+ source = 2,
+ ifdef = "USE_API_USER_DEFINED_ACTIONS",
+ no_delay = 1,
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "args",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.MESSAGE,
+ repeated = true,
+ subschema = "ExecuteServiceArgument",
+ },
+ [3] = {
+ name = "call_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [4] = {
+ name = "return_response",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ExecuteServiceResponse = {
+ name = "ExecuteServiceResponse",
+ options = {
+ id = 131,
+ source = 1,
+ ifdef = "USE_API_USER_DEFINED_ACTION_RESPONSES",
+ no_delay = 1,
+ },
+ fields = {
+ [1] = {
+ name = "call_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [2] = {
+ name = "success",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [3] = {
+ name = "error_message",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [4] = {
+ name = "response_data",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.BYTES,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ListEntitiesCameraResponse = {
+ name = "ListEntitiesCameraResponse",
+ options = {
+ id = 43,
+ source = 1,
+ ifdef = "USE_CAMERA",
+ base_class = "InfoResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "object_id",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [3] = {
+ name = "name",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [5] = {
+ name = "disabled_by_default",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [6] = {
+ name = "icon",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [7] = {
+ name = "entity_category",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- EntityCategory
+ },
+ [8] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.CameraImageResponse = {
+ name = "CameraImageResponse",
+ options = {
+ id = 44,
+ source = 1,
+ ifdef = "USE_CAMERA",
+ base_class = "StateResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "data",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.BYTES,
+ },
+ [3] = {
+ name = "done",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [4] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.CameraImageRequest = {
+ name = "CameraImageRequest",
+ options = {
+ id = 45,
+ source = 2,
+ ifdef = "USE_CAMERA",
+ no_delay = 1,
+ },
+ fields = {
+ [1] = {
+ name = "single",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [2] = {
+ name = "stream",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ListEntitiesClimateResponse = {
+ name = "ListEntitiesClimateResponse",
+ options = {
+ id = 46,
+ source = 1,
+ ifdef = "USE_CLIMATE",
+ base_class = "InfoResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "object_id",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [3] = {
+ name = "name",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [5] = {
+ name = "supports_current_temperature",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [6] = {
+ name = "supports_two_point_target_temperature",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [7] = {
+ name = "supported_modes",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- ClimateMode
+ repeated = true,
+ },
+ [8] = {
+ name = "visual_min_temperature",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [9] = {
+ name = "visual_max_temperature",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [10] = {
+ name = "visual_target_temperature_step",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [11] = {
+ name = "legacy_supports_away",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [12] = {
+ name = "supports_action",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [13] = {
+ name = "supported_fan_modes",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- ClimateFanMode
+ repeated = true,
+ },
+ [14] = {
+ name = "supported_swing_modes",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- ClimateSwingMode
+ repeated = true,
+ },
+ [15] = {
+ name = "supported_custom_fan_modes",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ repeated = true,
+ },
+ [16] = {
+ name = "supported_presets",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- ClimatePreset
+ repeated = true,
+ },
+ [17] = {
+ name = "supported_custom_presets",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ repeated = true,
+ },
+ [18] = {
+ name = "disabled_by_default",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [19] = {
+ name = "icon",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [20] = {
+ name = "entity_category",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- EntityCategory
+ },
+ [21] = {
+ name = "visual_current_temperature_step",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [22] = {
+ name = "supports_current_humidity",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [23] = {
+ name = "supports_target_humidity",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [24] = {
+ name = "visual_min_humidity",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [25] = {
+ name = "visual_max_humidity",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [26] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [27] = {
+ name = "feature_flags",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ClimateStateResponse = {
+ name = "ClimateStateResponse",
+ options = {
+ id = 47,
+ source = 1,
+ ifdef = "USE_CLIMATE",
+ no_delay = 1,
+ base_class = "StateResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "mode",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- ClimateMode
+ },
+ [3] = {
+ name = "current_temperature",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [4] = {
+ name = "target_temperature",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [5] = {
+ name = "target_temperature_low",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [6] = {
+ name = "target_temperature_high",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [7] = {
+ name = "unused_legacy_away",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [8] = {
+ name = "action",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- ClimateAction
+ },
+ [9] = {
+ name = "fan_mode",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- ClimateFanMode
+ },
+ [10] = {
+ name = "swing_mode",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- ClimateSwingMode
+ },
+ [11] = {
+ name = "custom_fan_mode",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [12] = {
+ name = "preset",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- ClimatePreset
+ },
+ [13] = {
+ name = "custom_preset",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [14] = {
+ name = "current_humidity",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [15] = {
+ name = "target_humidity",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [16] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ClimateCommandRequest = {
+ name = "ClimateCommandRequest",
+ options = {
+ id = 48,
+ source = 2,
+ ifdef = "USE_CLIMATE",
+ no_delay = 1,
+ base_class = "CommandProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "has_mode",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [3] = {
+ name = "mode",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- ClimateMode
+ },
+ [4] = {
+ name = "has_target_temperature",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [5] = {
+ name = "target_temperature",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [6] = {
+ name = "has_target_temperature_low",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [7] = {
+ name = "target_temperature_low",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [8] = {
+ name = "has_target_temperature_high",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [9] = {
+ name = "target_temperature_high",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [10] = {
+ name = "unused_has_legacy_away",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [11] = {
+ name = "unused_legacy_away",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [12] = {
+ name = "has_fan_mode",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [13] = {
+ name = "fan_mode",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- ClimateFanMode
+ },
+ [14] = {
+ name = "has_swing_mode",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [15] = {
+ name = "swing_mode",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- ClimateSwingMode
+ },
+ [16] = {
+ name = "has_custom_fan_mode",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [17] = {
+ name = "custom_fan_mode",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [18] = {
+ name = "has_preset",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [19] = {
+ name = "preset",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- ClimatePreset
+ },
+ [20] = {
+ name = "has_custom_preset",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [21] = {
+ name = "custom_preset",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [22] = {
+ name = "has_target_humidity",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [23] = {
+ name = "target_humidity",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [24] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ListEntitiesWaterHeaterResponse = {
+ name = "ListEntitiesWaterHeaterResponse",
+ options = {
+ id = 132,
+ source = 1,
+ ifdef = "USE_WATER_HEATER",
+ base_class = "InfoResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "object_id",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [3] = {
+ name = "name",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [4] = {
+ name = "icon",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [5] = {
+ name = "disabled_by_default",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [6] = {
+ name = "entity_category",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- EntityCategory
+ },
+ [7] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [8] = {
+ name = "min_temperature",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [9] = {
+ name = "max_temperature",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [10] = {
+ name = "target_temperature_step",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [11] = {
+ name = "supported_modes",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- WaterHeaterMode
+ repeated = true,
+ },
+ [12] = {
+ name = "supported_features",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.WaterHeaterStateResponse = {
+ name = "WaterHeaterStateResponse",
+ options = {
+ id = 133,
+ source = 1,
+ ifdef = "USE_WATER_HEATER",
+ no_delay = 1,
+ base_class = "StateResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "current_temperature",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [3] = {
+ name = "target_temperature",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [4] = {
+ name = "mode",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- WaterHeaterMode
+ },
+ [5] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [6] = {
+ name = "state",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [7] = {
+ name = "target_temperature_low",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [8] = {
+ name = "target_temperature_high",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.WaterHeaterCommandRequest = {
+ name = "WaterHeaterCommandRequest",
+ options = {
+ id = 134,
+ source = 2,
+ ifdef = "USE_WATER_HEATER",
+ no_delay = 1,
+ base_class = "CommandProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "has_fields",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [3] = {
+ name = "mode",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- WaterHeaterMode
+ },
+ [4] = {
+ name = "target_temperature",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [5] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [6] = {
+ name = "state",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [7] = {
+ name = "target_temperature_low",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [8] = {
+ name = "target_temperature_high",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ListEntitiesNumberResponse = {
+ name = "ListEntitiesNumberResponse",
+ options = {
+ id = 49,
+ source = 1,
+ ifdef = "USE_NUMBER",
+ base_class = "InfoResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "object_id",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [3] = {
+ name = "name",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [5] = {
+ name = "icon",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [6] = {
+ name = "min_value",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [7] = {
+ name = "max_value",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [8] = {
+ name = "step",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [9] = {
+ name = "disabled_by_default",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [10] = {
+ name = "entity_category",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- EntityCategory
+ },
+ [11] = {
+ name = "unit_of_measurement",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [12] = {
+ name = "mode",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- NumberMode
+ },
+ [13] = {
+ name = "device_class",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [14] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.NumberStateResponse = {
+ name = "NumberStateResponse",
+ options = {
+ id = 50,
+ source = 1,
+ ifdef = "USE_NUMBER",
+ no_delay = 1,
+ base_class = "StateResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "state",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [3] = {
+ name = "missing_state",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [4] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.NumberCommandRequest = {
+ name = "NumberCommandRequest",
+ options = {
+ id = 51,
+ source = 2,
+ ifdef = "USE_NUMBER",
+ no_delay = 1,
+ base_class = "CommandProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "state",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [3] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ListEntitiesSelectResponse = {
+ name = "ListEntitiesSelectResponse",
+ options = {
+ id = 52,
+ source = 1,
+ ifdef = "USE_SELECT",
+ base_class = "InfoResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "object_id",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [3] = {
+ name = "name",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [5] = {
+ name = "icon",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [6] = {
+ name = "options",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ repeated = true,
+ },
+ [7] = {
+ name = "disabled_by_default",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [8] = {
+ name = "entity_category",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- EntityCategory
+ },
+ [9] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.SelectStateResponse = {
+ name = "SelectStateResponse",
+ options = {
+ id = 53,
+ source = 1,
+ ifdef = "USE_SELECT",
+ no_delay = 1,
+ base_class = "StateResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "state",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [3] = {
+ name = "missing_state",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [4] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.SelectCommandRequest = {
+ name = "SelectCommandRequest",
+ options = {
+ id = 54,
+ source = 2,
+ ifdef = "USE_SELECT",
+ no_delay = 1,
+ base_class = "CommandProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "state",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [3] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ListEntitiesSirenResponse = {
+ name = "ListEntitiesSirenResponse",
+ options = {
+ id = 55,
+ source = 1,
+ ifdef = "USE_SIREN",
+ base_class = "InfoResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "object_id",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [3] = {
+ name = "name",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [5] = {
+ name = "icon",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [6] = {
+ name = "disabled_by_default",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [7] = {
+ name = "tones",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ repeated = true,
+ },
+ [8] = {
+ name = "supports_duration",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [9] = {
+ name = "supports_volume",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [10] = {
+ name = "entity_category",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- EntityCategory
+ },
+ [11] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.SirenStateResponse = {
+ name = "SirenStateResponse",
+ options = {
+ id = 56,
+ source = 1,
+ ifdef = "USE_SIREN",
+ no_delay = 1,
+ base_class = "StateResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "state",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [3] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.SirenCommandRequest = {
+ name = "SirenCommandRequest",
+ options = {
+ id = 57,
+ source = 2,
+ ifdef = "USE_SIREN",
+ no_delay = 1,
+ base_class = "CommandProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "has_state",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [3] = {
+ name = "state",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [4] = {
+ name = "has_tone",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [5] = {
+ name = "tone",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [6] = {
+ name = "has_duration",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [7] = {
+ name = "duration",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [8] = {
+ name = "has_volume",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [9] = {
+ name = "volume",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [10] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ListEntitiesLockResponse = {
+ name = "ListEntitiesLockResponse",
+ options = {
+ id = 58,
+ source = 1,
+ ifdef = "USE_LOCK",
+ base_class = "InfoResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "object_id",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [3] = {
+ name = "name",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [5] = {
+ name = "icon",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [6] = {
+ name = "disabled_by_default",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [7] = {
+ name = "entity_category",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- EntityCategory
+ },
+ [8] = {
+ name = "assumed_state",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [9] = {
+ name = "supports_open",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [10] = {
+ name = "requires_code",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [11] = {
+ name = "code_format",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [12] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.LockStateResponse = {
+ name = "LockStateResponse",
+ options = {
+ id = 59,
+ source = 1,
+ ifdef = "USE_LOCK",
+ no_delay = 1,
+ base_class = "StateResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "state",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- LockState
+ },
+ [3] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.LockCommandRequest = {
+ name = "LockCommandRequest",
+ options = {
+ id = 60,
+ source = 2,
+ ifdef = "USE_LOCK",
+ no_delay = 1,
+ base_class = "CommandProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "command",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- LockCommand
+ },
+ [3] = {
+ name = "has_code",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [4] = {
+ name = "code",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [5] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ListEntitiesButtonResponse = {
+ name = "ListEntitiesButtonResponse",
+ options = {
+ id = 61,
+ source = 1,
+ ifdef = "USE_BUTTON",
+ base_class = "InfoResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "object_id",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [3] = {
+ name = "name",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [5] = {
+ name = "icon",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [6] = {
+ name = "disabled_by_default",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [7] = {
+ name = "entity_category",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- EntityCategory
+ },
+ [8] = {
+ name = "device_class",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [9] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ButtonCommandRequest = {
+ name = "ButtonCommandRequest",
+ options = {
+ id = 62,
+ source = 2,
+ ifdef = "USE_BUTTON",
+ no_delay = 1,
+ base_class = "CommandProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.MediaPlayerSupportedFormat = {
+ name = "MediaPlayerSupportedFormat",
+ options = {
+ ifdef = "USE_MEDIA_PLAYER",
+ },
+ fields = {
+ [1] = {
+ name = "format",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "sample_rate",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [3] = {
+ name = "num_channels",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [4] = {
+ name = "purpose",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- MediaPlayerFormatPurpose
+ },
+ [5] = {
+ name = "sample_bytes",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ListEntitiesMediaPlayerResponse = {
+ name = "ListEntitiesMediaPlayerResponse",
+ options = {
+ id = 63,
+ source = 1,
+ ifdef = "USE_MEDIA_PLAYER",
+ base_class = "InfoResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "object_id",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [3] = {
+ name = "name",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [5] = {
+ name = "icon",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [6] = {
+ name = "disabled_by_default",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [7] = {
+ name = "entity_category",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- EntityCategory
+ },
+ [8] = {
+ name = "supports_pause",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [9] = {
+ name = "supported_formats",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.MESSAGE,
+ repeated = true,
+ subschema = "MediaPlayerSupportedFormat",
+ },
+ [10] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [11] = {
+ name = "feature_flags",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.MediaPlayerStateResponse = {
+ name = "MediaPlayerStateResponse",
+ options = {
+ id = 64,
+ source = 1,
+ ifdef = "USE_MEDIA_PLAYER",
+ no_delay = 1,
+ base_class = "StateResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "state",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- MediaPlayerState
+ },
+ [3] = {
+ name = "volume",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [4] = {
+ name = "muted",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [5] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.MediaPlayerCommandRequest = {
+ name = "MediaPlayerCommandRequest",
+ options = {
+ id = 65,
+ source = 2,
+ ifdef = "USE_MEDIA_PLAYER",
+ no_delay = 1,
+ base_class = "CommandProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "has_command",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [3] = {
+ name = "command",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- MediaPlayerCommand
+ },
+ [4] = {
+ name = "has_volume",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [5] = {
+ name = "volume",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [6] = {
+ name = "has_media_url",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [7] = {
+ name = "media_url",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [8] = {
+ name = "has_announcement",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [9] = {
+ name = "announcement",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [10] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.SubscribeBluetoothLEAdvertisementsRequest = {
+ name = "SubscribeBluetoothLEAdvertisementsRequest",
+ options = {
+ id = 66,
+ source = 2,
+ ifdef = "USE_BLUETOOTH_PROXY",
+ },
+ fields = {
+ [1] = {
+ name = "flags",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.BluetoothServiceData = {
+ name = "BluetoothServiceData",
+ options = {},
+ fields = {
+ [1] = {
+ name = "uuid",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "legacy_data",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ repeated = true,
+ },
+ [3] = {
+ name = "data",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.BYTES,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.BluetoothLEAdvertisementResponse = {
+ name = "BluetoothLEAdvertisementResponse",
+ options = {
+ id = 67,
+ source = 1,
+ ifdef = "USE_BLUETOOTH_PROXY",
+ no_delay = 1,
+ },
+ fields = {
+ [1] = {
+ name = "address",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT64,
+ },
+ [2] = {
+ name = "name",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.BYTES,
+ },
+ [3] = {
+ name = "rssi",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.SINT32,
+ },
+ [4] = {
+ name = "service_uuids",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ repeated = true,
+ },
+ [5] = {
+ name = "service_data",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.MESSAGE,
+ repeated = true,
+ subschema = "BluetoothServiceData",
+ },
+ [6] = {
+ name = "manufacturer_data",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.MESSAGE,
+ repeated = true,
+ subschema = "BluetoothServiceData",
+ },
+ [7] = {
+ name = "address_type",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.BluetoothLERawAdvertisement = {
+ name = "BluetoothLERawAdvertisement",
+ options = {},
+ fields = {
+ [1] = {
+ name = "address",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT64,
+ },
+ [2] = {
+ name = "rssi",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.SINT32,
+ },
+ [3] = {
+ name = "address_type",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [4] = {
+ name = "data",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.BYTES,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.BluetoothLERawAdvertisementsResponse = {
+ name = "BluetoothLERawAdvertisementsResponse",
+ options = {
+ id = 93,
+ source = 1,
+ ifdef = "USE_BLUETOOTH_PROXY",
+ no_delay = 1,
+ },
+ fields = {
+ [1] = {
+ name = "advertisements",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.MESSAGE,
+ repeated = true,
+ subschema = "BluetoothLERawAdvertisement",
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.BluetoothDeviceRequest = {
+ name = "BluetoothDeviceRequest",
+ options = {
+ id = 68,
+ source = 2,
+ ifdef = "USE_BLUETOOTH_PROXY",
+ },
+ fields = {
+ [1] = {
+ name = "address",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT64,
+ },
+ [2] = {
+ name = "request_type",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- BluetoothDeviceRequestType
+ },
+ [3] = {
+ name = "has_address_type",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [4] = {
+ name = "address_type",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.BluetoothDeviceConnectionResponse = {
+ name = "BluetoothDeviceConnectionResponse",
+ options = {
+ id = 69,
+ source = 1,
+ ifdef = "USE_BLUETOOTH_PROXY",
+ },
+ fields = {
+ [1] = {
+ name = "address",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT64,
+ },
+ [2] = {
+ name = "connected",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [3] = {
+ name = "mtu",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [4] = {
+ name = "error",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.INT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.BluetoothGATTGetServicesRequest = {
+ name = "BluetoothGATTGetServicesRequest",
+ options = {
+ id = 70,
+ source = 2,
+ ifdef = "USE_BLUETOOTH_PROXY",
+ },
+ fields = {
+ [1] = {
+ name = "address",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT64,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.BluetoothGATTDescriptor = {
+ name = "BluetoothGATTDescriptor",
+ options = {},
+ fields = {
+ [1] = {
+ name = "uuid",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT64,
+ repeated = true,
+ },
+ [2] = {
+ name = "handle",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [3] = {
+ name = "short_uuid",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.BluetoothGATTCharacteristic = {
+ name = "BluetoothGATTCharacteristic",
+ options = {},
+ fields = {
+ [1] = {
+ name = "uuid",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT64,
+ repeated = true,
+ },
+ [2] = {
+ name = "handle",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [3] = {
+ name = "properties",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [4] = {
+ name = "descriptors",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.MESSAGE,
+ repeated = true,
+ subschema = "BluetoothGATTDescriptor",
+ },
+ [5] = {
+ name = "short_uuid",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.BluetoothGATTService = {
+ name = "BluetoothGATTService",
+ options = {},
+ fields = {
+ [1] = {
+ name = "uuid",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT64,
+ repeated = true,
+ },
+ [2] = {
+ name = "handle",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [3] = {
+ name = "characteristics",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.MESSAGE,
+ repeated = true,
+ subschema = "BluetoothGATTCharacteristic",
+ },
+ [4] = {
+ name = "short_uuid",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.BluetoothGATTGetServicesResponse = {
+ name = "BluetoothGATTGetServicesResponse",
+ options = {
+ id = 71,
+ source = 1,
+ ifdef = "USE_BLUETOOTH_PROXY",
+ },
+ fields = {
+ [1] = {
+ name = "address",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT64,
+ },
+ [2] = {
+ name = "services",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.MESSAGE,
+ repeated = true,
+ subschema = "BluetoothGATTService",
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.BluetoothGATTGetServicesDoneResponse = {
+ name = "BluetoothGATTGetServicesDoneResponse",
+ options = {
+ id = 72,
+ source = 1,
+ ifdef = "USE_BLUETOOTH_PROXY",
+ },
+ fields = {
+ [1] = {
+ name = "address",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT64,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.BluetoothGATTReadRequest = {
+ name = "BluetoothGATTReadRequest",
+ options = {
+ id = 73,
+ source = 2,
+ ifdef = "USE_BLUETOOTH_PROXY",
+ },
+ fields = {
+ [1] = {
+ name = "address",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT64,
+ },
+ [2] = {
+ name = "handle",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.BluetoothGATTReadResponse = {
+ name = "BluetoothGATTReadResponse",
+ options = {
+ id = 74,
+ source = 1,
+ ifdef = "USE_BLUETOOTH_PROXY",
+ },
+ fields = {
+ [1] = {
+ name = "address",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT64,
+ },
+ [2] = {
+ name = "handle",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [3] = {
+ name = "data",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.BYTES,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.BluetoothGATTWriteRequest = {
+ name = "BluetoothGATTWriteRequest",
+ options = {
+ id = 75,
+ source = 2,
+ ifdef = "USE_BLUETOOTH_PROXY",
+ },
+ fields = {
+ [1] = {
+ name = "address",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT64,
+ },
+ [2] = {
+ name = "handle",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [3] = {
+ name = "response",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [4] = {
+ name = "data",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.BYTES,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.BluetoothGATTReadDescriptorRequest = {
+ name = "BluetoothGATTReadDescriptorRequest",
+ options = {
+ id = 76,
+ source = 2,
+ ifdef = "USE_BLUETOOTH_PROXY",
+ },
+ fields = {
+ [1] = {
+ name = "address",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT64,
+ },
+ [2] = {
+ name = "handle",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.BluetoothGATTWriteDescriptorRequest = {
+ name = "BluetoothGATTWriteDescriptorRequest",
+ options = {
+ id = 77,
+ source = 2,
+ ifdef = "USE_BLUETOOTH_PROXY",
+ },
+ fields = {
+ [1] = {
+ name = "address",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT64,
+ },
+ [2] = {
+ name = "handle",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [3] = {
+ name = "data",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.BYTES,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.BluetoothGATTNotifyRequest = {
+ name = "BluetoothGATTNotifyRequest",
+ options = {
+ id = 78,
+ source = 2,
+ ifdef = "USE_BLUETOOTH_PROXY",
+ },
+ fields = {
+ [1] = {
+ name = "address",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT64,
+ },
+ [2] = {
+ name = "handle",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [3] = {
+ name = "enable",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.BluetoothGATTNotifyDataResponse = {
+ name = "BluetoothGATTNotifyDataResponse",
+ options = {
+ id = 79,
+ source = 1,
+ ifdef = "USE_BLUETOOTH_PROXY",
+ },
+ fields = {
+ [1] = {
+ name = "address",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT64,
+ },
+ [2] = {
+ name = "handle",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [3] = {
+ name = "data",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.BYTES,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.SubscribeBluetoothConnectionsFreeRequest = {
+ name = "SubscribeBluetoothConnectionsFreeRequest",
+ options = {
+ id = 80,
+ source = 2,
+ ifdef = "USE_BLUETOOTH_PROXY",
+ },
+ fields = {},
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.BluetoothConnectionsFreeResponse = {
+ name = "BluetoothConnectionsFreeResponse",
+ options = {
+ id = 81,
+ source = 1,
+ ifdef = "USE_BLUETOOTH_PROXY",
+ },
+ fields = {
+ [1] = {
+ name = "free",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [2] = {
+ name = "limit",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [3] = {
+ name = "allocated",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT64,
+ repeated = true,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.BluetoothGATTErrorResponse = {
+ name = "BluetoothGATTErrorResponse",
+ options = {
+ id = 82,
+ source = 1,
+ ifdef = "USE_BLUETOOTH_PROXY",
+ },
+ fields = {
+ [1] = {
+ name = "address",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT64,
+ },
+ [2] = {
+ name = "handle",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [3] = {
+ name = "error",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.INT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.BluetoothGATTWriteResponse = {
+ name = "BluetoothGATTWriteResponse",
+ options = {
+ id = 83,
+ source = 1,
+ ifdef = "USE_BLUETOOTH_PROXY",
+ },
+ fields = {
+ [1] = {
+ name = "address",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT64,
+ },
+ [2] = {
+ name = "handle",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.BluetoothGATTNotifyResponse = {
+ name = "BluetoothGATTNotifyResponse",
+ options = {
+ id = 84,
+ source = 1,
+ ifdef = "USE_BLUETOOTH_PROXY",
+ },
+ fields = {
+ [1] = {
+ name = "address",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT64,
+ },
+ [2] = {
+ name = "handle",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.BluetoothDevicePairingResponse = {
+ name = "BluetoothDevicePairingResponse",
+ options = {
+ id = 85,
+ source = 1,
+ ifdef = "USE_BLUETOOTH_PROXY",
+ },
+ fields = {
+ [1] = {
+ name = "address",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT64,
+ },
+ [2] = {
+ name = "paired",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [3] = {
+ name = "error",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.INT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.BluetoothDeviceUnpairingResponse = {
+ name = "BluetoothDeviceUnpairingResponse",
+ options = {
+ id = 86,
+ source = 1,
+ ifdef = "USE_BLUETOOTH_PROXY",
+ },
+ fields = {
+ [1] = {
+ name = "address",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT64,
+ },
+ [2] = {
+ name = "success",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [3] = {
+ name = "error",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.INT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.UnsubscribeBluetoothLEAdvertisementsRequest = {
+ name = "UnsubscribeBluetoothLEAdvertisementsRequest",
+ options = {
+ id = 87,
+ source = 2,
+ ifdef = "USE_BLUETOOTH_PROXY",
+ },
+ fields = {},
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.BluetoothDeviceClearCacheResponse = {
+ name = "BluetoothDeviceClearCacheResponse",
+ options = {
+ id = 88,
+ source = 1,
+ ifdef = "USE_BLUETOOTH_PROXY",
+ },
+ fields = {
+ [1] = {
+ name = "address",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT64,
+ },
+ [2] = {
+ name = "success",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [3] = {
+ name = "error",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.INT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.BluetoothScannerStateResponse = {
+ name = "BluetoothScannerStateResponse",
+ options = {
+ id = 126,
+ source = 1,
+ ifdef = "USE_BLUETOOTH_PROXY",
+ },
+ fields = {
+ [1] = {
+ name = "state",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- BluetoothScannerState
+ },
+ [2] = {
+ name = "mode",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- BluetoothScannerMode
+ },
+ [3] = {
+ name = "configured_mode",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- BluetoothScannerMode
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.BluetoothScannerSetModeRequest = {
+ name = "BluetoothScannerSetModeRequest",
+ options = {
+ id = 127,
+ source = 2,
+ ifdef = "USE_BLUETOOTH_PROXY",
+ },
+ fields = {
+ [1] = {
+ name = "mode",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- BluetoothScannerMode
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.SubscribeVoiceAssistantRequest = {
+ name = "SubscribeVoiceAssistantRequest",
+ options = {
+ id = 89,
+ source = 2,
+ ifdef = "USE_VOICE_ASSISTANT",
+ },
+ fields = {
+ [1] = {
+ name = "subscribe",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [2] = {
+ name = "flags",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.VoiceAssistantAudioSettings = {
+ name = "VoiceAssistantAudioSettings",
+ options = {},
+ fields = {
+ [1] = {
+ name = "noise_suppression_level",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [2] = {
+ name = "auto_gain",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [3] = {
+ name = "volume_multiplier",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.VoiceAssistantRequest = {
+ name = "VoiceAssistantRequest",
+ options = {
+ id = 90,
+ source = 1,
+ ifdef = "USE_VOICE_ASSISTANT",
+ },
+ fields = {
+ [1] = {
+ name = "start",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [2] = {
+ name = "conversation_id",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [3] = {
+ name = "flags",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [4] = {
+ name = "audio_settings",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.MESSAGE,
+ subschema = "VoiceAssistantAudioSettings",
+ },
+ [5] = {
+ name = "wake_word_phrase",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.VoiceAssistantResponse = {
+ name = "VoiceAssistantResponse",
+ options = {
+ id = 91,
+ source = 2,
+ ifdef = "USE_VOICE_ASSISTANT",
+ },
+ fields = {
+ [1] = {
+ name = "port",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [2] = {
+ name = "error",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.VoiceAssistantEventData = {
+ name = "VoiceAssistantEventData",
+ options = {},
+ fields = {
+ [1] = {
+ name = "name",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "value",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.VoiceAssistantEventResponse = {
+ name = "VoiceAssistantEventResponse",
+ options = {
+ id = 92,
+ source = 2,
+ ifdef = "USE_VOICE_ASSISTANT",
+ },
+ fields = {
+ [1] = {
+ name = "event_type",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- VoiceAssistantEvent
+ },
+ [2] = {
+ name = "data",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.MESSAGE,
+ repeated = true,
+ subschema = "VoiceAssistantEventData",
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.VoiceAssistantAudio = {
+ name = "VoiceAssistantAudio",
+ options = {
+ id = 106,
+ source = 0,
+ ifdef = "USE_VOICE_ASSISTANT",
+ },
+ fields = {
+ [1] = {
+ name = "data",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.BYTES,
+ },
+ [2] = {
+ name = "end",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.VoiceAssistantTimerEventResponse = {
+ name = "VoiceAssistantTimerEventResponse",
+ options = {
+ id = 115,
+ source = 2,
+ ifdef = "USE_VOICE_ASSISTANT",
+ },
+ fields = {
+ [1] = {
+ name = "event_type",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- VoiceAssistantTimerEvent
+ },
+ [2] = {
+ name = "timer_id",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [3] = {
+ name = "name",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [4] = {
+ name = "total_seconds",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [5] = {
+ name = "seconds_left",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [6] = {
+ name = "is_active",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.VoiceAssistantAnnounceRequest = {
+ name = "VoiceAssistantAnnounceRequest",
+ options = {
+ id = 119,
+ source = 2,
+ ifdef = "USE_VOICE_ASSISTANT",
+ },
+ fields = {
+ [1] = {
+ name = "media_id",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "text",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [3] = {
+ name = "preannounce_media_id",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [4] = {
+ name = "start_conversation",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.VoiceAssistantAnnounceFinished = {
+ name = "VoiceAssistantAnnounceFinished",
+ options = {
+ id = 120,
+ source = 1,
+ ifdef = "USE_VOICE_ASSISTANT",
+ },
+ fields = {
+ [1] = {
+ name = "success",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.VoiceAssistantWakeWord = {
+ name = "VoiceAssistantWakeWord",
+ options = {},
+ fields = {
+ [1] = {
+ name = "id",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "wake_word",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [3] = {
+ name = "trained_languages",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ repeated = true,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.VoiceAssistantExternalWakeWord = {
+ name = "VoiceAssistantExternalWakeWord",
+ options = {},
+ fields = {
+ [1] = {
+ name = "id",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "wake_word",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [3] = {
+ name = "trained_languages",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ repeated = true,
+ },
+ [4] = {
+ name = "model_type",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [5] = {
+ name = "model_size",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [6] = {
+ name = "model_hash",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [7] = {
+ name = "url",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.VoiceAssistantConfigurationRequest = {
+ name = "VoiceAssistantConfigurationRequest",
+ options = {
+ id = 121,
+ source = 2,
+ ifdef = "USE_VOICE_ASSISTANT",
+ },
+ fields = {
+ [1] = {
+ name = "external_wake_words",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.MESSAGE,
+ repeated = true,
+ subschema = "VoiceAssistantExternalWakeWord",
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.VoiceAssistantConfigurationResponse = {
+ name = "VoiceAssistantConfigurationResponse",
+ options = {
+ id = 122,
+ source = 1,
+ ifdef = "USE_VOICE_ASSISTANT",
+ },
+ fields = {
+ [1] = {
+ name = "available_wake_words",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.MESSAGE,
+ repeated = true,
+ subschema = "VoiceAssistantWakeWord",
+ },
+ [2] = {
+ name = "active_wake_words",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ repeated = true,
+ },
+ [3] = {
+ name = "max_active_wake_words",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.VoiceAssistantSetConfiguration = {
+ name = "VoiceAssistantSetConfiguration",
+ options = {
+ id = 123,
+ source = 2,
+ ifdef = "USE_VOICE_ASSISTANT",
+ },
+ fields = {
+ [1] = {
+ name = "active_wake_words",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ repeated = true,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ListEntitiesAlarmControlPanelResponse = {
+ name = "ListEntitiesAlarmControlPanelResponse",
+ options = {
+ id = 94,
+ source = 1,
+ ifdef = "USE_ALARM_CONTROL_PANEL",
+ base_class = "InfoResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "object_id",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [3] = {
+ name = "name",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [5] = {
+ name = "icon",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [6] = {
+ name = "disabled_by_default",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [7] = {
+ name = "entity_category",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- EntityCategory
+ },
+ [8] = {
+ name = "supported_features",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [9] = {
+ name = "requires_code",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [10] = {
+ name = "requires_code_to_arm",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [11] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.AlarmControlPanelStateResponse = {
+ name = "AlarmControlPanelStateResponse",
+ options = {
+ id = 95,
+ source = 1,
+ ifdef = "USE_ALARM_CONTROL_PANEL",
+ no_delay = 1,
+ base_class = "StateResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "state",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- AlarmControlPanelState
+ },
+ [3] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.AlarmControlPanelCommandRequest = {
+ name = "AlarmControlPanelCommandRequest",
+ options = {
+ id = 96,
+ source = 2,
+ ifdef = "USE_ALARM_CONTROL_PANEL",
+ no_delay = 1,
+ base_class = "CommandProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "command",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- AlarmControlPanelStateCommand
+ },
+ [3] = {
+ name = "code",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [4] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ListEntitiesTextResponse = {
+ name = "ListEntitiesTextResponse",
+ options = {
+ id = 97,
+ source = 1,
+ ifdef = "USE_TEXT",
+ base_class = "InfoResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "object_id",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [3] = {
+ name = "name",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [5] = {
+ name = "icon",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [6] = {
+ name = "disabled_by_default",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [7] = {
+ name = "entity_category",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- EntityCategory
+ },
+ [8] = {
+ name = "min_length",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [9] = {
+ name = "max_length",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [10] = {
+ name = "pattern",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [11] = {
+ name = "mode",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- TextMode
+ },
+ [12] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.TextStateResponse = {
+ name = "TextStateResponse",
+ options = {
+ id = 98,
+ source = 1,
+ ifdef = "USE_TEXT",
+ no_delay = 1,
+ base_class = "StateResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "state",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [3] = {
+ name = "missing_state",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [4] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.TextCommandRequest = {
+ name = "TextCommandRequest",
+ options = {
+ id = 99,
+ source = 2,
+ ifdef = "USE_TEXT",
+ no_delay = 1,
+ base_class = "CommandProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "state",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [3] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ListEntitiesDateResponse = {
+ name = "ListEntitiesDateResponse",
+ options = {
+ id = 100,
+ source = 1,
+ ifdef = "USE_DATETIME_DATE",
+ base_class = "InfoResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "object_id",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [3] = {
+ name = "name",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [5] = {
+ name = "icon",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [6] = {
+ name = "disabled_by_default",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [7] = {
+ name = "entity_category",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- EntityCategory
+ },
+ [8] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.DateStateResponse = {
+ name = "DateStateResponse",
+ options = {
+ id = 101,
+ source = 1,
+ ifdef = "USE_DATETIME_DATE",
+ no_delay = 1,
+ base_class = "StateResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "missing_state",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [3] = {
+ name = "year",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [4] = {
+ name = "month",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [5] = {
+ name = "day",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [6] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.DateCommandRequest = {
+ name = "DateCommandRequest",
+ options = {
+ id = 102,
+ source = 2,
+ ifdef = "USE_DATETIME_DATE",
+ no_delay = 1,
+ base_class = "CommandProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "year",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [3] = {
+ name = "month",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [4] = {
+ name = "day",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [5] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ListEntitiesTimeResponse = {
+ name = "ListEntitiesTimeResponse",
+ options = {
+ id = 103,
+ source = 1,
+ ifdef = "USE_DATETIME_TIME",
+ base_class = "InfoResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "object_id",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [3] = {
+ name = "name",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [5] = {
+ name = "icon",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [6] = {
+ name = "disabled_by_default",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [7] = {
+ name = "entity_category",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- EntityCategory
+ },
+ [8] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.TimeStateResponse = {
+ name = "TimeStateResponse",
+ options = {
+ id = 104,
+ source = 1,
+ ifdef = "USE_DATETIME_TIME",
+ no_delay = 1,
+ base_class = "StateResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "missing_state",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [3] = {
+ name = "hour",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [4] = {
+ name = "minute",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [5] = {
+ name = "second",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [6] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.TimeCommandRequest = {
+ name = "TimeCommandRequest",
+ options = {
+ id = 105,
+ source = 2,
+ ifdef = "USE_DATETIME_TIME",
+ no_delay = 1,
+ base_class = "CommandProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "hour",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [3] = {
+ name = "minute",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [4] = {
+ name = "second",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [5] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ListEntitiesEventResponse = {
+ name = "ListEntitiesEventResponse",
+ options = {
+ id = 107,
+ source = 1,
+ ifdef = "USE_EVENT",
+ base_class = "InfoResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "object_id",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [3] = {
+ name = "name",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [5] = {
+ name = "icon",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [6] = {
+ name = "disabled_by_default",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [7] = {
+ name = "entity_category",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- EntityCategory
+ },
+ [8] = {
+ name = "device_class",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [9] = {
+ name = "event_types",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ repeated = true,
+ },
+ [10] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.EventResponse = {
+ name = "EventResponse",
+ options = {
+ id = 108,
+ source = 1,
+ ifdef = "USE_EVENT",
+ base_class = "StateResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "event_type",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [3] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ListEntitiesValveResponse = {
+ name = "ListEntitiesValveResponse",
+ options = {
+ id = 109,
+ source = 1,
+ ifdef = "USE_VALVE",
+ base_class = "InfoResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "object_id",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [3] = {
+ name = "name",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [5] = {
+ name = "icon",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [6] = {
+ name = "disabled_by_default",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [7] = {
+ name = "entity_category",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- EntityCategory
+ },
+ [8] = {
+ name = "device_class",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [9] = {
+ name = "assumed_state",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [10] = {
+ name = "supports_position",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [11] = {
+ name = "supports_stop",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [12] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ValveStateResponse = {
+ name = "ValveStateResponse",
+ options = {
+ id = 110,
+ source = 1,
+ ifdef = "USE_VALVE",
+ no_delay = 1,
+ base_class = "StateResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "position",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [3] = {
+ name = "current_operation",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- ValveOperation
+ },
+ [4] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ValveCommandRequest = {
+ name = "ValveCommandRequest",
+ options = {
+ id = 111,
+ source = 2,
+ ifdef = "USE_VALVE",
+ no_delay = 1,
+ base_class = "CommandProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "has_position",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [3] = {
+ name = "position",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [4] = {
+ name = "stop",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [5] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ListEntitiesDateTimeResponse = {
+ name = "ListEntitiesDateTimeResponse",
+ options = {
+ id = 112,
+ source = 1,
+ ifdef = "USE_DATETIME_DATETIME",
+ base_class = "InfoResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "object_id",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [3] = {
+ name = "name",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [5] = {
+ name = "icon",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [6] = {
+ name = "disabled_by_default",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [7] = {
+ name = "entity_category",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- EntityCategory
+ },
+ [8] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.DateTimeStateResponse = {
+ name = "DateTimeStateResponse",
+ options = {
+ id = 113,
+ source = 1,
+ ifdef = "USE_DATETIME_DATETIME",
+ no_delay = 1,
+ base_class = "StateResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "missing_state",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [3] = {
+ name = "epoch_seconds",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [4] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.DateTimeCommandRequest = {
+ name = "DateTimeCommandRequest",
+ options = {
+ id = 114,
+ source = 2,
+ ifdef = "USE_DATETIME_DATETIME",
+ no_delay = 1,
+ base_class = "CommandProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "epoch_seconds",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [3] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ListEntitiesUpdateResponse = {
+ name = "ListEntitiesUpdateResponse",
+ options = {
+ id = 116,
+ source = 1,
+ ifdef = "USE_UPDATE",
+ base_class = "InfoResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "object_id",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [3] = {
+ name = "name",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [5] = {
+ name = "icon",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [6] = {
+ name = "disabled_by_default",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [7] = {
+ name = "entity_category",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- EntityCategory
+ },
+ [8] = {
+ name = "device_class",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [9] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.UpdateStateResponse = {
+ name = "UpdateStateResponse",
+ options = {
+ id = 117,
+ source = 1,
+ ifdef = "USE_UPDATE",
+ no_delay = 1,
+ base_class = "StateResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "missing_state",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [3] = {
+ name = "in_progress",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [4] = {
+ name = "has_progress",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [5] = {
+ name = "progress",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FLOAT,
+ },
+ [6] = {
+ name = "current_version",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [7] = {
+ name = "latest_version",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [8] = {
+ name = "title",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [9] = {
+ name = "release_summary",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [10] = {
+ name = "release_url",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [11] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.UpdateCommandRequest = {
+ name = "UpdateCommandRequest",
+ options = {
+ id = 118,
+ source = 2,
+ ifdef = "USE_UPDATE",
+ no_delay = 1,
+ base_class = "CommandProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [2] = {
+ name = "command",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- UpdateCommand
+ },
+ [3] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ZWaveProxyFrame = {
+ name = "ZWaveProxyFrame",
+ options = {
+ id = 128,
+ source = 0,
+ ifdef = "USE_ZWAVE_PROXY",
+ no_delay = 1,
+ },
+ fields = {
+ [1] = {
+ name = "data",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.BYTES,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ZWaveProxyRequest = {
+ name = "ZWaveProxyRequest",
+ options = {
+ id = 129,
+ source = 0,
+ ifdef = "USE_ZWAVE_PROXY",
+ },
+ fields = {
+ [1] = {
+ name = "type",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- ZWaveProxyRequestType
+ },
+ [2] = {
+ name = "data",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.BYTES,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.ListEntitiesInfraredResponse = {
+ name = "ListEntitiesInfraredResponse",
+ options = {
+ id = 135,
+ source = 1,
+ ifdef = "USE_INFRARED",
+ base_class = "InfoResponseProtoMessage",
+ },
+ fields = {
+ [1] = {
+ name = "object_id",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [2] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [3] = {
+ name = "name",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [4] = {
+ name = "icon",
+ wireType = ProtoSchema.WireType.LENGTH_DELIMITED,
+ type = ProtoSchema.DataType.STRING,
+ },
+ [5] = {
+ name = "disabled_by_default",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.BOOL,
+ },
+ [6] = {
+ name = "entity_category",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.ENUM, -- EntityCategory
+ },
+ [7] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [8] = {
+ name = "capabilities",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.InfraredRFTransmitRawTimingsRequest = {
+ name = "InfraredRFTransmitRawTimingsRequest",
+ options = {
+ id = 136,
+ source = 2,
+ ifdef = "USE_IR_RF",
+ },
+ fields = {
+ [1] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [2] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [3] = {
+ name = "carrier_frequency",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [4] = {
+ name = "repeat_count",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [5] = {
+ name = "timings",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.SINT32,
+ repeated = true,
+ },
+ },
+}
+
+--- @type ProtoMessageSchema
+ProtoSchema.Message.InfraredRFReceiveEvent = {
+ name = "InfraredRFReceiveEvent",
+ options = {
+ id = 137,
+ source = 1,
+ ifdef = "USE_IR_RF",
+ no_delay = 1,
+ },
+ fields = {
+ [1] = {
+ name = "device_id",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.UINT32,
+ },
+ [2] = {
+ name = "key",
+ wireType = ProtoSchema.WireType.FIXED32,
+ type = ProtoSchema.DataType.FIXED32,
+ },
+ [3] = {
+ name = "timings",
+ wireType = ProtoSchema.WireType.VARINT,
+ type = ProtoSchema.DataType.SINT32,
+ repeated = true,
+ },
+ },
+}
+
+--- @type ProtoServiceSchema
+ProtoSchema.RPC.APIConnection = {
+ hello = {
+ service = "APIConnection",
+ method = "hello",
+ inputType = ProtoSchema.Message.HelloRequest,
+ outputType = ProtoSchema.Message.HelloResponse,
+ },
+ disconnect = {
+ service = "APIConnection",
+ method = "disconnect",
+ inputType = ProtoSchema.Message.DisconnectRequest,
+ outputType = ProtoSchema.Message.DisconnectResponse,
+ },
+ ping = {
+ service = "APIConnection",
+ method = "ping",
+ inputType = ProtoSchema.Message.PingRequest,
+ outputType = ProtoSchema.Message.PingResponse,
+ },
+ device_info = {
+ service = "APIConnection",
+ method = "device_info",
+ inputType = ProtoSchema.Message.DeviceInfoRequest,
+ outputType = ProtoSchema.Message.DeviceInfoResponse,
+ },
+ list_entities = {
+ service = "APIConnection",
+ method = "list_entities",
+ inputType = ProtoSchema.Message.ListEntitiesRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ subscribe_states = {
+ service = "APIConnection",
+ method = "subscribe_states",
+ inputType = ProtoSchema.Message.SubscribeStatesRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ subscribe_logs = {
+ service = "APIConnection",
+ method = "subscribe_logs",
+ inputType = ProtoSchema.Message.SubscribeLogsRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ subscribe_homeassistant_services = {
+ service = "APIConnection",
+ method = "subscribe_homeassistant_services",
+ inputType = ProtoSchema.Message.SubscribeHomeassistantServicesRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ subscribe_home_assistant_states = {
+ service = "APIConnection",
+ method = "subscribe_home_assistant_states",
+ inputType = ProtoSchema.Message.SubscribeHomeAssistantStatesRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ execute_service = {
+ service = "APIConnection",
+ method = "execute_service",
+ inputType = ProtoSchema.Message.ExecuteServiceRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ noise_encryption_set_key = {
+ service = "APIConnection",
+ method = "noise_encryption_set_key",
+ inputType = ProtoSchema.Message.NoiseEncryptionSetKeyRequest,
+ outputType = ProtoSchema.Message.NoiseEncryptionSetKeyResponse,
+ },
+ button_command = {
+ service = "APIConnection",
+ method = "button_command",
+ inputType = ProtoSchema.Message.ButtonCommandRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ camera_image = {
+ service = "APIConnection",
+ method = "camera_image",
+ inputType = ProtoSchema.Message.CameraImageRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ climate_command = {
+ service = "APIConnection",
+ method = "climate_command",
+ inputType = ProtoSchema.Message.ClimateCommandRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ cover_command = {
+ service = "APIConnection",
+ method = "cover_command",
+ inputType = ProtoSchema.Message.CoverCommandRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ date_command = {
+ service = "APIConnection",
+ method = "date_command",
+ inputType = ProtoSchema.Message.DateCommandRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ datetime_command = {
+ service = "APIConnection",
+ method = "datetime_command",
+ inputType = ProtoSchema.Message.DateTimeCommandRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ fan_command = {
+ service = "APIConnection",
+ method = "fan_command",
+ inputType = ProtoSchema.Message.FanCommandRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ light_command = {
+ service = "APIConnection",
+ method = "light_command",
+ inputType = ProtoSchema.Message.LightCommandRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ lock_command = {
+ service = "APIConnection",
+ method = "lock_command",
+ inputType = ProtoSchema.Message.LockCommandRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ media_player_command = {
+ service = "APIConnection",
+ method = "media_player_command",
+ inputType = ProtoSchema.Message.MediaPlayerCommandRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ number_command = {
+ service = "APIConnection",
+ method = "number_command",
+ inputType = ProtoSchema.Message.NumberCommandRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ select_command = {
+ service = "APIConnection",
+ method = "select_command",
+ inputType = ProtoSchema.Message.SelectCommandRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ siren_command = {
+ service = "APIConnection",
+ method = "siren_command",
+ inputType = ProtoSchema.Message.SirenCommandRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ switch_command = {
+ service = "APIConnection",
+ method = "switch_command",
+ inputType = ProtoSchema.Message.SwitchCommandRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ text_command = {
+ service = "APIConnection",
+ method = "text_command",
+ inputType = ProtoSchema.Message.TextCommandRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ time_command = {
+ service = "APIConnection",
+ method = "time_command",
+ inputType = ProtoSchema.Message.TimeCommandRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ update_command = {
+ service = "APIConnection",
+ method = "update_command",
+ inputType = ProtoSchema.Message.UpdateCommandRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ valve_command = {
+ service = "APIConnection",
+ method = "valve_command",
+ inputType = ProtoSchema.Message.ValveCommandRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ water_heater_command = {
+ service = "APIConnection",
+ method = "water_heater_command",
+ inputType = ProtoSchema.Message.WaterHeaterCommandRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ subscribe_bluetooth_le_advertisements = {
+ service = "APIConnection",
+ method = "subscribe_bluetooth_le_advertisements",
+ inputType = ProtoSchema.Message.SubscribeBluetoothLEAdvertisementsRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ bluetooth_device_request = {
+ service = "APIConnection",
+ method = "bluetooth_device_request",
+ inputType = ProtoSchema.Message.BluetoothDeviceRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ bluetooth_gatt_get_services = {
+ service = "APIConnection",
+ method = "bluetooth_gatt_get_services",
+ inputType = ProtoSchema.Message.BluetoothGATTGetServicesRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ bluetooth_gatt_read = {
+ service = "APIConnection",
+ method = "bluetooth_gatt_read",
+ inputType = ProtoSchema.Message.BluetoothGATTReadRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ bluetooth_gatt_write = {
+ service = "APIConnection",
+ method = "bluetooth_gatt_write",
+ inputType = ProtoSchema.Message.BluetoothGATTWriteRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ bluetooth_gatt_read_descriptor = {
+ service = "APIConnection",
+ method = "bluetooth_gatt_read_descriptor",
+ inputType = ProtoSchema.Message.BluetoothGATTReadDescriptorRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ bluetooth_gatt_write_descriptor = {
+ service = "APIConnection",
+ method = "bluetooth_gatt_write_descriptor",
+ inputType = ProtoSchema.Message.BluetoothGATTWriteDescriptorRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ bluetooth_gatt_notify = {
+ service = "APIConnection",
+ method = "bluetooth_gatt_notify",
+ inputType = ProtoSchema.Message.BluetoothGATTNotifyRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ subscribe_bluetooth_connections_free = {
+ service = "APIConnection",
+ method = "subscribe_bluetooth_connections_free",
+ inputType = ProtoSchema.Message.SubscribeBluetoothConnectionsFreeRequest,
+ outputType = ProtoSchema.Message.BluetoothConnectionsFreeResponse,
+ },
+ unsubscribe_bluetooth_le_advertisements = {
+ service = "APIConnection",
+ method = "unsubscribe_bluetooth_le_advertisements",
+ inputType = ProtoSchema.Message.UnsubscribeBluetoothLEAdvertisementsRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ bluetooth_scanner_set_mode = {
+ service = "APIConnection",
+ method = "bluetooth_scanner_set_mode",
+ inputType = ProtoSchema.Message.BluetoothScannerSetModeRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ subscribe_voice_assistant = {
+ service = "APIConnection",
+ method = "subscribe_voice_assistant",
+ inputType = ProtoSchema.Message.SubscribeVoiceAssistantRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ voice_assistant_get_configuration = {
+ service = "APIConnection",
+ method = "voice_assistant_get_configuration",
+ inputType = ProtoSchema.Message.VoiceAssistantConfigurationRequest,
+ outputType = ProtoSchema.Message.VoiceAssistantConfigurationResponse,
+ },
+ voice_assistant_set_configuration = {
+ service = "APIConnection",
+ method = "voice_assistant_set_configuration",
+ inputType = ProtoSchema.Message.VoiceAssistantSetConfiguration,
+ outputType = ProtoSchema.Message.void,
+ },
+ alarm_control_panel_command = {
+ service = "APIConnection",
+ method = "alarm_control_panel_command",
+ inputType = ProtoSchema.Message.AlarmControlPanelCommandRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ zwave_proxy_frame = {
+ service = "APIConnection",
+ method = "zwave_proxy_frame",
+ inputType = ProtoSchema.Message.ZWaveProxyFrame,
+ outputType = ProtoSchema.Message.void,
+ },
+ zwave_proxy_request = {
+ service = "APIConnection",
+ method = "zwave_proxy_request",
+ inputType = ProtoSchema.Message.ZWaveProxyRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+ infrared_rf_transmit_raw_timings = {
+ service = "APIConnection",
+ method = "infrared_rf_transmit_raw_timings",
+ inputType = ProtoSchema.Message.InfraredRFTransmitRawTimingsRequest,
+ outputType = ProtoSchema.Message.void,
+ },
+}
+
+return ProtoSchema
diff --git a/src/lib/bindings.lua b/src/lib/bindings.lua
index b189e46..5dcd73d 100644
--- a/src/lib/bindings.lua
+++ b/src/lib/bindings.lua
@@ -1,4 +1,3 @@
---- @module "lib.bindings"
--- Bindings module for managing dynamic bindings.
--- This module provides functionality to create, retrieve, delete, and restore dynamic bindings.
--- It also handles persistent storage of bindings and ensures unique binding IDs.
@@ -7,10 +6,10 @@ local log = require("lib.logging")
local persist = require("lib.persist")
--- Create a binding between two devices if it doesn't already exist.
---- @param idDeviceProvider number Provider device ID
---- @param idBindingProvider number Provider binding ID
---- @param idDeviceConsumer number Consumer device ID
---- @param idBindingConsumer number Consumer binding ID
+--- @param idDeviceProvider integer Provider device ID
+--- @param idBindingProvider integer Provider binding ID
+--- @param idDeviceConsumer integer Consumer device ID
+--- @param idBindingConsumer integer Consumer binding ID
--- @param strClass string Binding class
--- @return boolean true if binding was created, false if it already existed
function Bind(idDeviceProvider, idBindingProvider, idDeviceConsumer, idBindingConsumer, strClass)
@@ -33,30 +32,31 @@ end
--- @class Bindings
--- A class representing dynamic bindings.
local Bindings = {}
+Bindings.__index = Bindings
--- Persistent storage key for connection bindings.
--- @type string
local CONNECTION_BINDINGS_PERSIST_KEY = "ConnectionBindings"
--- The starting ID for control bindings.
---- @type number
+--- @type integer
local CONTROL_BINDING_START = 10
--- The ending ID for control bindings.
---- @type number
+--- @type integer
local CONTROL_BINDING_END = 999
--- The starting ID for proxy bindings.
---- @type number
+--- @type integer
local PROXY_BINDING_START = 5012
--- The ending ID for proxy bindings.
---- @type number
+--- @type integer
local PROXY_BINDING_END = 5999
--- @class Binding
--- @field key string
---- @field bindingId number
+--- @field bindingId integer
--- @field type string
--- @field provider boolean
--- @field displayName string
@@ -66,11 +66,8 @@ local PROXY_BINDING_END = 5999
--- @return Bindings bindings A new Bindings instance.
function Bindings:new()
log:trace("Binding:new()")
- local properties = {}
- setmetatable(properties, self)
- self.__index = self
- --- @cast properties Bindings
- return properties
+ local instance = setmetatable({}, self)
+ return instance
end
--- Retrieves or adds a dynamic binding.
@@ -146,7 +143,7 @@ end
function Bindings:deleteBinding(namespace, key)
log:trace("Binding:deleteBinding(%s, %s)", namespace, key)
local bindings = self:getBindings()
- --- @type number|nil
+ --- @type integer|nil
local bindingId = Select(bindings, namespace, key, "bindingId")
if IsEmpty(bindingId) then
return
@@ -169,11 +166,42 @@ function Bindings:deleteBinding(namespace, key)
self:_saveBindings(bindings)
end
+--- Delete all bindings in a namespace.
+--- @param namespace string The namespace to delete all bindings from.
+function Bindings:deleteAllBindings(namespace)
+ log:trace("Binding:deleteAllBindings(%s)", namespace)
+ local bindings = self:getBindings()
+ local nsBindings = bindings[namespace]
+
+ if IsEmpty(nsBindings) then
+ return
+ end
+
+ -- Collect keys first to avoid modifying table while iterating
+ local keys = {}
+ for key in pairs(nsBindings) do
+ table.insert(keys, key)
+ end
+
+ -- Delete each binding
+ for _, key in ipairs(keys) do
+ self:deleteBinding(namespace, key)
+ end
+end
+
+--- Check if a binding ID is within the managed dynamic binding ranges.
+--- @param bindingId integer The binding ID to check.
+--- @return boolean True if the binding ID is within a managed range.
+local function isInManagedRange(bindingId)
+ return (bindingId >= CONTROL_BINDING_START and bindingId <= CONTROL_BINDING_END)
+ or (bindingId >= PROXY_BINDING_START and bindingId <= PROXY_BINDING_END)
+end
+
--- Restores all dynamic bindings from persistent storage. Ensures that all
---- bindings are re-added and removes unknown bindings.
+--- bindings are re-added and removes unknown bindings within managed ranges.
function Bindings:restoreBindings()
log:trace("Binding:restoreBindings()")
- local deviceBindings = GetDeviceBindings(C4:GetDeviceID())
+ local deviceBindings = GetDeviceBindings(tointeger(C4:GetDeviceID()))
for _, keys in pairs(self:getBindings()) do
for _, binding in pairs(keys) do
deviceBindings[binding.bindingId] = nil
@@ -189,19 +217,24 @@ function Bindings:restoreBindings()
)
end
end
+ -- Only remove unknown bindings that are within our managed ranges
+ -- This preserves static bindings defined in driver.xml
for bindingId, _ in pairs(deviceBindings) do
- log:debug("Deleting unknown binding %s", bindingId)
- C4:RemoveDynamicBinding(bindingId)
+ if isInManagedRange(bindingId) then
+ log:debug("Deleting unknown binding %s", bindingId)
+ C4:RemoveDynamicBinding(bindingId)
+ end
end
end
--- Retrieves the next available binding ID for a given type. Ensures that the
--- ID is unique and within the allowed range.
+--- @private
--- @param type string The type of the binding (e.g., "CONTROL" or "PROXY").
---- @return number|nil bindingId The next available binding ID or nil if the maximum is exceeded.
+--- @return integer|nil bindingId The next available binding ID or nil if the maximum is exceeded.
function Bindings:_getNextBindingId(type)
log:trace("Binding:_getNextBindingId(%s)", type)
- --- @type table
+ --- @type table
local currentBindings = {}
for _, keys in pairs(self:getBindings()) do
for _, binding in pairs(keys) do
@@ -224,16 +257,34 @@ end
--- Retrieves all bindings from persistent storage.
--- @return table> bindings A table of all bindings mapped by namespace then key.
+--- @diagnostic disable-next-line: unused
function Bindings:getBindings()
log:trace("Binding:getBindings()")
return persist:get(CONNECTION_BINDINGS_PERSIST_KEY, {}) or {}
end
--- Saves the bindings to persistent storage.
+--- @private
--- @param bindings table>? The bindings table to save.
+--- @diagnostic disable-next-line: unused
function Bindings:_saveBindings(bindings)
log:trace("Binding:_saveBindings(%s)", bindings)
persist:set(CONNECTION_BINDINGS_PERSIST_KEY, not IsEmpty(bindings) and bindings or nil)
end
+--- Resets all dynamic bindings, removing them from the system and clearing persisted storage.
+--- This does not affect static bindings defined in driver.xml.
+function Bindings:reset()
+ log:trace("Bindings:reset()")
+ for _, nsBindings in pairs(self:getBindings()) do
+ for _, binding in pairs(nsBindings) do
+ log:debug("Removing binding '%s' (id=%s)", binding.displayName, binding.bindingId)
+ C4:RemoveDynamicBinding(binding.bindingId)
+ RFP[binding.bindingId] = nil
+ OBC[binding.bindingId] = nil
+ end
+ end
+ self:_saveBindings(nil)
+end
+
return Bindings:new()
diff --git a/src/lib/bit16.lua b/src/lib/bit16.lua
deleted file mode 100644
index 968201b..0000000
--- a/src/lib/bit16.lua
+++ /dev/null
@@ -1,94 +0,0 @@
---- @module "lib.bit16"
---- 16-bit unsigned integer operations
-
---- @class bit16
-local bit16 = {}
-
---- Mask a number to a 16-bit unsigned integer
---- @param n integer The number to mask
---- @return integer n 16-bit unsigned integer
-function bit16.mask(n)
- return math.floor(n % 0x10000)
-end
-
---- Convert 16-bit unsigned integer to 2 bytes (big-endian)
---- @param n integer 16-bit unsigned integer
---- @return string b 2-byte string in big-endian order
-function bit16.u16_to_be_bytes(n)
- n = bit16.mask(n)
- return string.char(math.floor(n / 256), math.floor(n % 256))
-end
-
---- Convert 2 bytes (big-endian) to a 16-bit unsigned integer
---- @param bytes string 2-byte string in big-endian order
---- @return integer n 16-bit unsigned integer
-function bit16.be_bytes_to_u16(bytes)
- assert(#bytes == 2, "Input must be exactly 2 bytes")
- local b1, b2 = string.byte(bytes, 1, 2)
- return b1 * 256 + b2
-end
-
---- Run comprehensive self-test with test vectors
---- @return boolean result True if all tests pass, false otherwise
-function bit16.selftest()
- print("Running 16-bit operations test vectors...")
- local passed = 0
- local total = 0
-
- --- @class B16TestVector
- --- @field name string Test name
- --- @field fn fun(...): integer Function to test
- --- @field inputs any[] Input values
- --- @field expected integer|string Expected result
-
- --- @type B16TestVector[]
- local test_vectors = {
- -- mask tests
- { name = "mask(0)", fn = bit16.mask, inputs = { 0 }, expected = 0 },
- { name = "mask(255)", fn = bit16.mask, inputs = { 255 }, expected = 255 },
- { name = "mask(256)", fn = bit16.mask, inputs = { 256 }, expected = 256 },
- { name = "mask(65535)", fn = bit16.mask, inputs = { 65535 }, expected = 65535 },
- { name = "mask(65536)", fn = bit16.mask, inputs = { 65536 }, expected = 0 },
- { name = "mask(65537)", fn = bit16.mask, inputs = { 65537 }, expected = 1 },
- { name = "mask(131071)", fn = bit16.mask, inputs = { 131071 }, expected = 65535 },
- { name = "mask(-1)", fn = bit16.mask, inputs = { -1 }, expected = 65535 },
- { name = "mask(-256)", fn = bit16.mask, inputs = { -256 }, expected = 65280 },
- { name = "u16_to_be_bytes(0)", fn = bit16.u16_to_be_bytes, inputs = { 0 }, expected = "\x00\x00" },
- { name = "u16_to_be_bytes(1)", fn = bit16.u16_to_be_bytes, inputs = { 1 }, expected = "\x00\x01" },
- { name = "u16_to_be_bytes(255)", fn = bit16.u16_to_be_bytes, inputs = { 255 }, expected = "\x00\xFF" },
- { name = "u16_to_be_bytes(256)", fn = bit16.u16_to_be_bytes, inputs = { 256 }, expected = "\x01\x00" },
- { name = "u16_to_be_bytes(258)", fn = bit16.u16_to_be_bytes, inputs = { 258 }, expected = "\x01\x02" },
- { name = "u16_to_be_bytes(32768)", fn = bit16.u16_to_be_bytes, inputs = { 32768 }, expected = "\x80\x00" },
- { name = "u16_to_be_bytes(65535)", fn = bit16.u16_to_be_bytes, inputs = { 65535 }, expected = "\xFF\xFF" },
- { name = "u16_to_be_bytes(65536)", fn = bit16.u16_to_be_bytes, inputs = { 65536 }, expected = "\x00\x00" }, -- wraps around
- { name = "u16_to_be_bytes(65537)", fn = bit16.u16_to_be_bytes, inputs = { 65537 }, expected = "\x00\x01" }, -- wraps around
- { name = "be_bytes_to_u16('\\x00\\x00')", fn = bit16.be_bytes_to_u16, inputs = { "\x00\x00" }, expected = 0 },
- { name = "be_bytes_to_u16('\\x00\\x01')", fn = bit16.be_bytes_to_u16, inputs = { "\x00\x01" }, expected = 1 },
- { name = "be_bytes_to_u16('\\x00\\xFF')", fn = bit16.be_bytes_to_u16, inputs = { "\x00\xFF" }, expected = 255 },
- { name = "be_bytes_to_u16('\\x01\\x00')", fn = bit16.be_bytes_to_u16, inputs = { "\x01\x00" }, expected = 256 },
- { name = "be_bytes_to_u16('\\x01\\x02')", fn = bit16.be_bytes_to_u16, inputs = { "\x01\x02" }, expected = 258 },
- { name = "be_bytes_to_u16('\\x80\\x00')", fn = bit16.be_bytes_to_u16, inputs = { "\x80\x00" }, expected = 32768 },
- { name = "be_bytes_to_u16('\\xFF\\xFF')", fn = bit16.be_bytes_to_u16, inputs = { "\xFF\xFF" }, expected = 65535 },
- }
-
- ---@diagnostic disable-next-line: access-invisible
- local unpack_fn = unpack or table.unpack
-
- for _, test in ipairs(test_vectors) do
- total = total + 1
- local result = test.fn(unpack_fn(test.inputs))
- if result == test.expected then
- print(" ✅ PASS: " .. test.name)
- passed = passed + 1
- else
- print(" ❌ FAIL: " .. test.name)
- print(string.format(" Expected: 0x%04X", test.expected))
- print(string.format(" Got: 0x%04X", result))
- end
- end
-
- print(string.format("\n16-bit operations result: %d/%d tests passed\n", passed, total))
- return passed == total
-end
-
-return bit16
diff --git a/src/lib/conditionals.lua b/src/lib/conditionals.lua
index 7347b84..1faaf25 100644
--- a/src/lib/conditionals.lua
+++ b/src/lib/conditionals.lua
@@ -1,4 +1,3 @@
---- @module "lib.conditionals"
--- This module provides functionality for managing and persisting conditionals.
local log = require("lib.logging")
@@ -7,6 +6,7 @@ local persist = require("lib.persist")
--- @class Conditionals
--- A class representing conditionals.
local Conditionals = {}
+Conditionals.__index = Conditionals
--- The key used to persist conditionals.
--- @type string
@@ -16,38 +16,37 @@ local CONDITIONALS_PERSIST_KEY = "Conditionals"
--- @type number
local CONDITIONAL_ID_START = 10
---- @class Conditional
---- @field conditionalId number
---- @field name string
+--- @class ConditionalConfig
--- @field type string
--- @field condition_statement string
--- @field description string
+--- @class Conditional:ConditionalConfig
+--- @field conditionalId number
+--- @field name string
+
--- Creates a new instance of the `Conditionals` class.
--- @return Conditionals conditionals A new instance of the `Conditionals` class.
function Conditionals:new()
log:trace("Conditionals:new()")
- local properties = {}
- setmetatable(properties, self)
- self.__index = self
- --- @diagnostic disable-next-line: return-type-mismatch
- return properties
+ local instance = setmetatable({}, self)
+ return instance
end
--- Upserts a conditional into the conditionals table.
--- @param namespace string The namespace for the conditional.
--- @param key string The key for the conditional.
---- @param conditional Conditional The conditional object to upsert.
+--- @param conditional ConditionalConfig The conditional object to upsert.
--- @param testFunction function The test function associated with the conditional.
--- @return Conditional conditional The upserted conditional.
function Conditionals:upsertConditional(namespace, key, conditional, testFunction)
log:trace("Conditionals:upsertConditional(%s, %s, %s, )", namespace, key, conditional)
- local conditionals = self:_getConditionals()
+ local conditionals = self:getConditionals()
--- @type number
local conditionalId = Select(conditionals, namespace, key, "conditionalId") or self:_getNextConditionalId()
+ --- @type Conditional
conditional = TableDeepCopy(conditional)
- --- @cast conditional Conditional
conditional.conditionalId = conditionalId
conditional.name = "CONDITIONAL_" .. conditionalId
@@ -66,7 +65,7 @@ end
--- @param key string The key of the conditional.
function Conditionals:deleteConditional(namespace, key)
log:trace("Conditionals:deleteConditional(%s, %s)", namespace, key)
- local conditionals = self:_getConditionals()
+ local conditionals = self:getConditionals()
--- @type Conditional|nil
local conditional = Select(conditionals, namespace, key)
if IsEmpty(conditional) then
@@ -89,11 +88,12 @@ function Conditionals:deleteConditional(namespace, key)
end
--- Gets the next available conditional ID.
+--- @private
--- @return number conditionalId The next available conditional ID.
function Conditionals:_getNextConditionalId()
log:trace("Conditionals:_getNextConditionalId()")
local currentConditionals = {}
- for _, keys in pairs(self:_getConditionals()) do
+ for _, keys in pairs(self:getConditionals()) do
for _, conditional in pairs(keys) do
currentConditionals[conditional.conditionalId] = true
end
@@ -107,18 +107,33 @@ end
--- Retrieves all conditionals from persistent storage.
--- @return table> conditionals A table containing all conditionals.
-function Conditionals:_getConditionals()
- log:trace("Conditionals:_getConditionals()")
+--- @diagnostic disable-next-line: unused
+function Conditionals:getConditionals()
+ log:trace("Conditionals:getConditionals()")
return persist:get(CONDITIONALS_PERSIST_KEY, {}) or {}
end
--- Saves the conditionals to persistent storage.
+--- @private
--- @param conditionals table>? The conditionals table to save.
+--- @diagnostic disable-next-line: unused
function Conditionals:_saveConditionals(conditionals)
log:trace("Conditionals:_saveConditionals(%s)", conditionals)
persist:set(CONDITIONALS_PERSIST_KEY, not IsEmpty(conditionals) and conditionals or nil)
end
+--- Resets all conditionals, removing them from the system and clearing persisted storage.
+function Conditionals:reset()
+ log:trace("Conditionals:reset()")
+ for _, nsConditionals in pairs(self:getConditionals()) do
+ for _, conditional in pairs(nsConditionals) do
+ log:debug("Removing conditional '%s' (id=%s)", conditional.name, conditional.conditionalId)
+ TC[conditional.name] = nil
+ end
+ end
+ self:_saveConditionals(nil)
+end
+
local conditionals = Conditionals:new()
--- Retrieves all conditionals in a program-friendly format.
@@ -126,7 +141,7 @@ local conditionals = Conditionals:new()
function GetConditionals()
log:trace("GetConditionals()")
local progConditionals = {}
- for _, keys in pairs(conditionals:_getConditionals()) do
+ for _, keys in pairs(conditionals:getConditionals()) do
for _, conditional in pairs(keys) do
progConditionals[tostring(conditional.conditionalId)] = conditional
end
diff --git a/src/lib/events.lua b/src/lib/events.lua
index 8d231d4..54bf3b5 100644
--- a/src/lib/events.lua
+++ b/src/lib/events.lua
@@ -1,4 +1,3 @@
---- @module "lib.events"
--- The `Events` module provides functionality for managing dynamic events, including creating, retrieving, firing, deleting, and restoring events.
--- Events are stored persistently and are associated with unique IDs.
@@ -7,6 +6,7 @@ local persist = require("lib.persist")
--- @class Events
local Events = {}
+Events.__index = Events
--- The key used to persist events in storage.
--- @type string
@@ -29,11 +29,8 @@ local EVENT_ID_END = 999
--- @return Events events A new `Events` instance.
function Events:new()
log:trace("Events:new()")
- local properties = {}
- setmetatable(properties, self)
- self.__index = self
- --- @cast properties Events
- return properties
+ local instance = setmetatable({}, self)
+ return instance
end
--- Retrieves or adds an event. If the event does not exist, it creates a new one with a unique ID.
@@ -44,7 +41,7 @@ end
--- @return Event|nil event The event object or nil if the event could not be created.
function Events:getOrAddEvent(namespace, key, name, description)
log:trace("Events:getOrAddEvent(%s, %s, %s, %s)", namespace, key, name, description)
- local events = self:_getEvents()
+ local events = self:getEvents()
--- @type Event|nil
local event = Select(events, namespace, key)
if event == nil then
@@ -69,7 +66,7 @@ end
function Events:fire(namespace, key)
log:trace("Events:fire(%s, %s)", namespace, key)
--- @type number|nil
- local eventId = Select(self:_getEvents(), namespace, key, "eventId")
+ local eventId = Select(self:getEvents(), namespace, key, "eventId")
if IsEmpty(eventId) then
return
end
@@ -82,7 +79,7 @@ end
--- @param key string The key of the event.
function Events:deleteEvent(namespace, key)
log:trace("Events:deleteEvent(%s, %s)", namespace, key)
- local events = self:_getEvents()
+ local events = self:getEvents()
--- @type number|nil
local eventId = Select(events, namespace, key, "eventId")
if IsEmpty(eventId) then
@@ -108,7 +105,7 @@ function Events:restoreEvents()
log:trace("Events:restoreEvents()")
--- @type table
local usedEventIds = {}
- for _, keys in pairs(self:_getEvents()) do
+ for _, keys in pairs(self:getEvents()) do
for _, event in pairs(keys) do
usedEventIds[event.eventId] = true
C4:AddEvent(event.eventId, event.name, event.description)
@@ -123,12 +120,13 @@ function Events:restoreEvents()
end
--- Retrieves the next available event ID. Ensures that the ID is unique and within the allowed range.
+--- @private
--- @return number eventId The next available event ID.
function Events:_getNextEventId()
log:trace("Events:_getNextEventId()")
--- @type table
local currentEvents = {}
- for _, keys in pairs(self:_getEvents()) do
+ for _, keys in pairs(self:getEvents()) do
for _, event in pairs(keys) do
currentEvents[event.eventId] = true
end
@@ -142,16 +140,31 @@ end
--- Retrieves all events from persistent storage.
--- @return table> events A table of all events mapped by namespace then key.
-function Events:_getEvents()
- log:trace("Events:_getEvents()")
+--- @diagnostic disable-next-line: unused
+function Events:getEvents()
+ log:trace("Events:getEvents()")
return persist:get(EVENTS_PERSIST_KEY, {}) or {}
end
--- Saves the events to persistent storage.
+--- @private
--- @param events table>? The events table to save.
+--- @diagnostic disable-next-line: unused
function Events:_saveEvents(events)
log:trace("Events:_saveEvents(%s)", events)
persist:set(EVENTS_PERSIST_KEY, not IsEmpty(events) and events or nil)
end
+--- Resets all dynamic events, removing them from the system and clearing persisted storage.
+function Events:reset()
+ log:trace("Events:reset()")
+ for _, nsEvents in pairs(self:getEvents()) do
+ for _, event in pairs(nsEvents) do
+ log:debug("Removing event '%s' (id=%s)", event.name, event.eventId)
+ C4:DeleteEvent(event.eventId)
+ end
+ end
+ self:_saveEvents(nil)
+end
+
return Events:new()
diff --git a/src/lib/github-updater.lua b/src/lib/github-updater.lua
index 6908a9e..39a08f5 100644
--- a/src/lib/github-updater.lua
+++ b/src/lib/github-updater.lua
@@ -1,15 +1,15 @@
---- @module "lib.github-updater"
--- A utility module for updating drivers from GitHub releases.
--- This module provides functionality to check for, download, and install driver updates from GitHub repositories.
local http = require("lib.http")
local log = require("lib.logging")
-local deferred = require("vendor.deferred")
-local version = require("vendor.version")
+local deferred = require("deferred")
+local version = require("version")
--- Utility class for updating drivers from GitHub releases.
--- @class GitHubUpdater
local GitHubUpdater = {}
+GitHubUpdater.__index = GitHubUpdater
--- Default headers for all HTTP requests to GitHub.
--- @type table
@@ -21,17 +21,15 @@ local DEFAULT_HEADERS = {
--- Create a new instance of GitHubUpdater.
--- @return GitHubUpdater updater A new GitHubUpdater instance.
function GitHubUpdater:new()
- local properties = {}
- setmetatable(properties, self)
- self.__index = self
- --- @cast properties GitHubUpdater
- return properties
+ local instance = setmetatable({}, self)
+ return instance
end
--- Retrieve the latest release from a GitHub repository.
--- @param repo string The GitHub repository, in the format "owner/repo".
--- @param includePrereleases? boolean If true, includes pre-releases (optional).
--- @return Deferred latestRelease Deferred resolving to the latest release table, or rejected with an error message.
+--- @diagnostic disable-next-line: unused
function GitHubUpdater:getLatestRelease(repo, includePrereleases)
log:trace("GitHubUpdater:getLatestRelease(%s, %s)", repo, includePrereleases)
if IsEmpty(repo) then
@@ -170,8 +168,16 @@ end
--- @return Deferred> updatedDrivers Deferred resolving to a list of updated driver filenames, or rejected with an error table.
function GitHubUpdater:updateAll(repo, driverFilenames, includePrereleases, forceUpdate)
log:trace("GitHubUpdater:updateAll(%s, %s, %s, %s)", repo, driverFilenames, includePrereleases, forceUpdate)
+ -- Only update drivers that are already installed.
+ local installedDriverFilenames = {}
+ for _, driverFilename in pairs(driverFilenames) do
+ if not IsEmpty(C4:GetDevicesByC4iName(driverFilename) or {}) then
+ table.insert(installedDriverFilenames, driverFilename)
+ end
+ end
+
return self
- :downloadOutdatedDrivers("C4Z_ROOT", repo, driverFilenames, includePrereleases, forceUpdate)
+ :downloadOutdatedDrivers("C4Z_ROOT", repo, installedDriverFilenames, includePrereleases, forceUpdate)
:next(function(downloadedDriverFilenames)
--- @type Deferred>
local d = deferred.new()
diff --git a/src/lib/http.lua b/src/lib/http.lua
index cf402b6..f9ae839 100644
--- a/src/lib/http.lua
+++ b/src/lib/http.lua
@@ -1,7 +1,6 @@
---- @module "lib.http"
---- A simple HTTP client module for making HTTP requests with support for Deferreds.
+--- A simple HTTP client module for making HTTP requests with Deferred support.
-local deferred = require("vendor.deferred")
+local deferred = require("deferred")
local log = require("lib.logging")
@@ -16,16 +15,14 @@ local DEFAULT_TIMEOUT = 30
--- @class Http
--- A class representing an HTTP client.
local Http = {}
+Http.__index = Http
--- Creates a new instance of the Http class.
--- @return Http http A new instance of the Http class.
function Http:new()
log:trace("Http:new()")
- local properties = {}
- setmetatable(properties, self)
- self.__index = self
- --- @cast properties Http
- return properties
+ local instance = setmetatable({}, self)
+ return instance
end
--- @class HTTPResponse
@@ -48,6 +45,7 @@ end
--- @param headers? table The headers to include in the request (optional).
--- @param options? table Options for the request (e.g., timeout) (optional).
--- @return Deferred response A Deferred that resolves or rejects with the response.
+--- @diagnostic disable-next-line: unused
function Http:request(method, url, data, headers, options)
log:trace("Http:request(%s, %s, %s, %s, %s)", method, url, data, headers, options)
local d = deferred.new()
@@ -95,7 +93,7 @@ end
--- Makes an HTTP POST request.
--- @param url string The URL to send the request to.
---- @param data string|table The data to send with the request.
+--- @param data? string|table The data to send with the request (optional).
--- @param headers? table The headers to include in the request (optional).
--- @param options? table Options for the request (e.g., timeout) (optional).
--- @return Deferred response A Deferred that resolves or rejects with the response.
@@ -105,7 +103,7 @@ end
--- Makes an HTTP PUT request.
--- @param url string The URL to send the request to.
---- @param data string|table The data to send with the request.
+--- @param data? string|table The data to send with the request (optional).
--- @param headers? table The headers to include in the request (optional).
--- @param options? table Options for the request (e.g., timeout) (optional).
--- @return Deferred response A Deferred that resolves or rejects with the response.
diff --git a/src/lib/logging.lua b/src/lib/logging.lua
index 8ce627a..9a7c888 100644
--- a/src/lib/logging.lua
+++ b/src/lib/logging.lua
@@ -1,33 +1,78 @@
---- @module "lib.logging"
--- A logging utility module for managing log levels and output modes.
---- @class Log
--- A logging utility class with support for multiple log levels and output modes.
+--- @class Log
+--- @field _logName string The name of the log.
+--- @field _logLevel LogLevel The current log level.
+--- @field _outputPrint boolean Whether to output logs to print.
+--- @field _outputC4Log boolean Whether to output logs to C4 log.
+--- @field _maxTableLevels integer The maximum depth for table rendering.
local Log = {}
+Log.__index = Log
+
+--- @enum LogLevel
+Log.LogLevel = {
+ PRINT = -1,
+ FATAL = 0,
+ ERROR = 1,
+ WARN = 2,
+ INFO = 3,
+ DEBUG = 4,
+ TRACE = 5,
+ ULTRA = 6,
+}
+
+--- @type table
+Log.NameToLevel = {
+ ["0 - Fatal"] = Log.LogLevel.FATAL,
+ ["1 - Error"] = Log.LogLevel.ERROR,
+ ["2 - Warning"] = Log.LogLevel.WARN,
+ ["3 - Info"] = Log.LogLevel.INFO,
+ ["4 - Debug"] = Log.LogLevel.DEBUG,
+ ["5 - Trace"] = Log.LogLevel.TRACE,
+ ["6 - Ultra"] = Log.LogLevel.ULTRA,
+}
+
+--- @type table
+Log.LevelToName = {
+ [Log.LogLevel.FATAL] = "0 - Fatal",
+ [Log.LogLevel.ERROR] = "1 - Error",
+ [Log.LogLevel.WARN] = "2 - Warning",
+ [Log.LogLevel.INFO] = "3 - Info",
+ [Log.LogLevel.DEBUG] = "4 - Debug",
+ [Log.LogLevel.TRACE] = "5 - Trace",
+ [Log.LogLevel.ULTRA] = "6 - Ultra",
+}
+
+--- @type table
+Log.LevelPrefix = {
+ [Log.LogLevel.PRINT] = "[PRINT]",
+ [Log.LogLevel.FATAL] = "[FATAL]",
+ [Log.LogLevel.ERROR] = "[ERROR]",
+ [Log.LogLevel.WARN] = "[WARN ]",
+ [Log.LogLevel.INFO] = "[INFO ]",
+ [Log.LogLevel.DEBUG] = "[DEBUG]",
+ [Log.LogLevel.TRACE] = "[TRACE]",
+ [Log.LogLevel.ULTRA] = "[ULTRA]",
+}
--- Creates a new instance of the Log class.
--- @return Log log A new instance of the Log class.
function Log:new()
- local properties = {
- _logName = "", --- @type string The name of the log.
- _logLevel = 5, --- @type number The current log level (default is 5).
- _outputPrint = false, --- @type boolean Whether to output logs to print.
- _outputC4Log = false, --- @type boolean Whether to output logs to C4 log.
- _maxTableLevels = 10, --- @type number The maximum depth for table rendering.
- }
- setmetatable(properties, self)
- self.__index = self
- --- @cast properties Log
- return properties
+ local instance = setmetatable({}, self)
+ instance._logName = ""
+ instance._logLevel = Log.LogLevel.INFO
+ instance._outputPrint = false
+ instance._outputC4Log = false
+ instance._maxTableLevels = 10
+ return instance
end
--- Sets the name of the log.
---- @param logName string The name to set for the log.
+--- @param logName? string The name to set for the log.
function Log:setLogName(logName)
- if logName == nil or logName == "" then
- logName = ""
- else
- logName = logName .. ": "
+ if type(logName) ~= "string" then
+ return
end
self._logName = logName
@@ -40,21 +85,32 @@ function Log:getLogName()
end
--- Sets the log level.
---- @param level string|number|nil The log level to set (e.g., 3 or "3 - Info" for INFO).
+--- @param level string|integer|nil The log level to set (e.g., 3 or "3 - Info" for INFO).
function Log:setLogLevel(level)
- self._logLevel = tonumber(string.sub(level or "", 1, 1)) or self._logLevel
+ if type(level) == "string" then
+ level = self.NameToLevel[level]
+ end
+ if type(level) ~= "number" then
+ return
+ end
+ self._logLevel = math.max(self.LogLevel.PRINT, math.min(self.LogLevel.ULTRA, level))
end
--- Gets the current log level.
---- @return number level The current log level.
+--- @return LogLevel level The current log level.
function Log:getLogLevel()
return self._logLevel
end
--- Sets the log output mode.
---- @param logMode string The log mode (e.g., "Print", "Log", "Print and Log").
+--- @param logMode? string The log mode (e.g., "Print", "Log", "Print and Log").
function Log:setLogMode(logMode)
- logMode = logMode or ""
+ if logMode == nil then
+ logMode = ""
+ end
+ if type(logMode) ~= "string" then
+ return
+ end
self:setOutputPrintEnabled(logMode:find("Print") ~= nil)
self:setOutputC4LogEnabled(logMode:find("Log") ~= nil)
end
@@ -62,13 +118,13 @@ end
--- Enables or disables printing log output.
--- @param value boolean Whether to enable or disable print output.
function Log:setOutputPrintEnabled(value)
- self._outputPrint = value
+ self._outputPrint = value and true or false
end
--- Enables or disables C4 log output.
--- @param value boolean Whether to enable or disable C4 log output.
function Log:setOutputC4LogEnabled(value)
- self._outputC4Log = value
+ self._outputC4Log = value and true or false
end
--- Checks if any log output is enabled.
@@ -90,18 +146,19 @@ function Log:isC4LogEnabled()
end
--- Formats and fixes arguments for logging, ensuring they are strings or numbers.
---- @param numArgs number The number of arguments.
---- @param args table The arguments to format.
---- @return table formattedArgs The formatted arguments.
+--- @param numArgs integer The number of arguments.
+--- @param args any[] The arguments to format.
+--- @return string[] formattedArgs The formatted arguments.
local function fixFormatArgs(numArgs, args)
- for i = 1, numArgs + 1 do
+ for i = 1, numArgs do
if args[i] == nil then
args[i] = "nil"
- end
- if type(args[i]) == "table" then
+ elseif type(args[i]) == "table" then
args[i] = JSON:encode(args[i])
- end
- if type(args[i]) ~= "string" and type(args[i]) ~= "number" then
+ elseif type(args[i]) == "number" then
+ -- Use tostring_return_period to avoid scientific notation for large integers
+ args[i] = tostring_return_period(args[i])
+ elseif type(args[i]) ~= "string" then
args[i] = tostring(args[i])
end
end
@@ -110,86 +167,93 @@ end
--- Logs a fatal message.
--- @param sLogText string The log message.
---- @vararg any Additional arguments for formatting.
+--- @param ... any Additional arguments for formatting.
function Log:fatal(sLogText, ...)
- self:_log(0, sLogText, select("#", ...), { ... })
+ self:_log(self.LogLevel.FATAL, sLogText, select("#", ...), { ... })
end
--- Logs an error message.
--- @param sLogText string The log message.
---- @vararg any Additional arguments for formatting.
+--- @param ... any Additional arguments for formatting.
function Log:error(sLogText, ...)
- self:_log(1, sLogText, select("#", ...), { ... })
+ self:_log(self.LogLevel.ERROR, sLogText, select("#", ...), { ... })
end
--- Logs a warning message.
--- @param sLogText string The log message.
---- @vararg any Additional arguments for formatting.
+--- @param ... any Additional arguments for formatting.
function Log:warn(sLogText, ...)
- self:_log(2, sLogText, select("#", ...), { ... })
+ self:_log(self.LogLevel.WARN, sLogText, select("#", ...), { ... })
end
--- Logs an informational message.
--- @param sLogText string The log message.
---- @vararg any Additional arguments for formatting.
+--- @param ... any Additional arguments for formatting.
function Log:info(sLogText, ...)
- self:_log(3, sLogText, select("#", ...), { ... })
+ self:_log(self.LogLevel.INFO, sLogText, select("#", ...), { ... })
end
--- Logs a debug message.
--- @param sLogText string The log message.
---- @vararg any Additional arguments for formatting.
+--- @param ... any Additional arguments for formatting.
function Log:debug(sLogText, ...)
- self:_log(4, sLogText, select("#", ...), { ... })
+ self:_log(self.LogLevel.DEBUG, sLogText, select("#", ...), { ... })
end
--- Logs a trace message.
--- @param sLogText string The log message.
---- @vararg any Additional arguments for formatting.
+--- @param ... any Additional arguments for formatting.
function Log:trace(sLogText, ...)
- self:_log(5, sLogText, select("#", ...), { ... })
+ self:_log(self.LogLevel.TRACE, sLogText, select("#", ...), { ... })
end
--- Logs an ultra-verbose message.
--- @param sLogText string The log message.
---- @vararg any Additional arguments for formatting.
+--- @param ... any Additional arguments for formatting.
function Log:ultra(sLogText, ...)
- self:_log(6, sLogText, select("#", ...), { ... })
+ self:_log(self.LogLevel.ULTRA, sLogText, select("#", ...), { ... })
+end
+
+--- Logs a message at the given numeric level.
+--- @param level LogLevel A LogLevel value (e.g. Log.LogLevel.DEBUG).
+--- @param sLogText any The log message.
+--- @param ... any Additional arguments for formatting.
+function Log:log(level, sLogText, ...)
+ self:_log(level, sLogText, select("#", ...), { ... })
end
--- Logs a message directly to stdout.
--- @param sLogText any The log message.
---- @vararg any Additional arguments for formatting.
+--- @param ... any Additional arguments for formatting.
function Log:print(sLogText, ...)
- self:_log(-1, sLogText, select("#", ...), { ... })
+ self:_log(self.LogLevel.PRINT, sLogText, select("#", ...), { ... })
end
-local maxTableLevels = 10
-
--- Renders a table as a string for logging.
--- @param tValue table The table to render.
--- @param tableText? string The current rendered text (optional).
--- @param sIndent? string The current indentation (optional).
---- @param level? number The current depth level (optional).
+--- @param level? integer The current depth level (optional).
--- @return string renderedTable The rendered table as a string.
-local function _renderTableAsString(tValue, tableText, sIndent, level)
- tableText = tableText or ""
+function Log:_renderTableAsString(tValue, tableText, sIndent, level)
+ if tableText == nil then
+ tableText = ""
+ end
+ if sIndent == nil then
+ sIndent = ""
+ end
level = (level or 0) + 1
- sIndent = sIndent or ""
- if level <= maxTableLevels then
+ if level <= self._maxTableLevels then
if type(tValue) == "table" then
for k, v in pairs(tValue) do
if tableText == "" then
tableText = sIndent .. tostring(k) .. ": " .. tostring(v)
- if sIndent == ". " then
- sIndent = " "
- end
else
tableText = tableText .. "\n" .. sIndent .. tostring(k) .. ": " .. tostring(v)
end
if type(v) == "table" then
- tableText = _renderTableAsString(v, tableText, sIndent .. " ", level)
+ tableText = self:_renderTableAsString(v, tableText, sIndent .. " ", level)
end
end
else
@@ -205,7 +269,7 @@ end
--- @param sLogText string The log message.
--- @return string prefixedLine The log message with prefixes added.
local function addLinePrefix(sPrefix, sLogText)
- --- @type table
+ --- @type string[]
local lines = {}
for s in sLogText:gmatch("[^\r\n]+") do
table.insert(lines, sPrefix .. s)
@@ -214,29 +278,30 @@ local function addLinePrefix(sPrefix, sLogText)
end
--- Logs a message with the specified level.
---- @param level number The log level.
+--- @private
+--- @param level LogLevel The log level.
--- @param sLogText any The log message.
---- @param numArgs number The number of arguments.
---- @param args table The arguments for formatting.
+--- @param numArgs integer The number of arguments.
+--- @param args any[] The arguments for formatting.
function Log:_log(level, sLogText, numArgs, args)
- if level == -1 or (self:isEnabled() and self._logLevel >= level) then
+ if level == self.LogLevel.PRINT or (self:isEnabled() and self._logLevel >= level) then
args = fixFormatArgs(numArgs, args)
if type(sLogText) == "string" then
- sLogText = string.format(sLogText, unpack(args))
+ sLogText = string.format(sLogText, unpack(args, 1, numArgs))
end
if type(sLogText) == "table" then
- sLogText = _renderTableAsString(sLogText)
+ sLogText = self:_renderTableAsString(sLogText)
end
sLogText = tostring(sLogText)
- if level == -1 or self:isPrintEnabled() then
+ if level == self.LogLevel.PRINT or self:isPrintEnabled() then
print(addLinePrefix(self:_getPrintPrefix(level), sLogText))
end
if self:isC4LogEnabled() then
- if self._logLevel < 3 then
+ if self._logLevel < self.LogLevel.INFO then
C4:ErrorLog(addLinePrefix(self:_getLogPrefix(level), sLogText))
else
C4:DebugLog(addLinePrefix(self:_getLogPrefix(level), sLogText))
@@ -246,39 +311,33 @@ function Log:_log(level, sLogText, numArgs, args)
end
--- Gets the prefix for a log level.
---- @param level number The log level.
---- @return string prefix The prefix for the log level.
-local function _getLevelPrefix(level)
- local levelNames = {
- [-1] = "[PRINT]",
- [0] = "[FATAL]",
- [1] = "[ERROR]",
- [2] = "[WARN ]",
- [3] = "[INFO ]",
- [4] = "[DEBUG]",
- [5] = "[TRACE]",
- [6] = "[ULTRA]",
- }
- return (levelNames[level] or "[UKNWN]") .. ": "
+--- @param level LogLevel The log level.
+--- @return string|nil prefix The prefix for the log level.
+function Log:_getLevelPrefix(level)
+ local prefix = self.LevelPrefix[level]
+ return prefix and (prefix .. ": ") or nil
end
--- Gets the prefix for print output.
---- @param level number The log level.
+--- @private
+--- @param level LogLevel The log level.
--- @return string printPrefix The print prefix.
function Log:_getPrintPrefix(level)
- --- @diagnostic disable-next-line: missing-parameter
- return os.date() .. " " .. _getLevelPrefix(level)
+ local ts = tostring(os.date("%c"))
+ local prefix = self:_getLevelPrefix(level)
+ return prefix and (ts .. " " .. prefix) or ts
end
--- Gets the prefix for C4 log output.
---- @param level number The log level.
+--- @private
+--- @param level LogLevel The log level.
--- @return string logPrefix The C4 log prefix.
function Log:_getLogPrefix(level)
local prefix = ""
- if not IsEmpty(self._logName) then
+ if self._logName ~= "" then
prefix = "[" .. self._logName .. "]"
end
- return prefix .. _getLevelPrefix(level)
+ return prefix .. (self:_getLevelPrefix(level) or "")
end
return Log:new()
diff --git a/src/lib/lru.lua b/src/lib/lru.lua
new file mode 100644
index 0000000..4e6c2b0
--- /dev/null
+++ b/src/lib/lru.lua
@@ -0,0 +1,148 @@
+--- This module provides an LRU (Least Recently Used) cache implementation in Lua.
+--- Adapted from https://github.com/kenshinx/Lua-LRU-Cache
+
+--- A simple LRU cache implementation.
+--- @class LRUCache
+--- @field _max_size number|nil Maximum items to store, nil for no limit.
+--- @field _ttl number|nil Time-to-live in seconds, nil for no expiration.
+--- @field _values table Table storing cache values.
+--- @field _ttl_times table Table storing expiration times.
+--- @field _access_times table Table storing last access times.
+local LRUCache = {}
+LRUCache.__index = LRUCache
+
+--- Creates a new LRU cache instance
+--- @param max_size? number Maximum items to store, nil for no limit.
+--- @param ttl? number Time-to-live in seconds, nil for no expiration.
+--- @return LRUCache
+function LRUCache:new(max_size, ttl)
+ local instance = setmetatable({}, self)
+ instance._max_size = max_size
+ instance._ttl = ttl
+ instance._values = {}
+ instance._ttl_times = {}
+ instance._access_times = {}
+ return instance
+end
+
+--- Gets a value from the cache.
+--- @param key K The key to look up.
+--- @return V|nil value The cached value or nil if not found/expired.
+function LRUCache:get(key)
+ local time = os.time()
+ self:cleanup()
+ if self._values[key] ~= nil then
+ self._access_times[key] = time
+ return self._values[key]
+ else
+ return nil
+ end
+end
+
+--- Gets a value from the cache or returns default if not found.
+--- @param key K The key to look up.
+--- @param default? V The default value if key not found.
+--- @return V|nil value The cached value or default.
+--- @overload fun(key: K, default: V): V
+function LRUCache:getOrDefault(key, default)
+ local value = self:get(key)
+ if value == nil then
+ return default
+ end
+ return value
+end
+
+--- Gets a value from cache or sets it using callback if not found
+--- @param key K The key to look up
+--- @param callback (fun(key: K): V) Function to generate value if not found
+--- @return V value The cached or newly generated value
+function LRUCache:getOrSet(key, callback)
+ local value = self:get(key)
+ if value == nil then
+ value = callback(key)
+ self:set(key, value)
+ end
+ return value
+end
+
+--- Sets a value in the cache
+--- @param key K The key to set
+--- @param value V The value to cache
+function LRUCache:set(key, value)
+ local time = os.time()
+ self._values[key] = value
+ self._ttl_times[key] = time + (self._ttl or 0)
+ self._access_times[key] = time
+ self:cleanup()
+end
+
+--- Removes a key from the cache
+--- @param key K The key to remove
+function LRUCache:remove(key)
+ self._values[key] = nil
+ self._ttl_times[key] = nil
+ self._access_times[key] = nil
+end
+
+--- Cleans up expired and excess items from cache
+function LRUCache:cleanup()
+ -- remove expired items
+ if self._ttl ~= nil then
+ local time = os.time()
+ for k, v in pairs(self._ttl_times) do
+ if v < time then
+ self:remove(k)
+ end
+ end
+ end
+
+ if self._max_size == nil then
+ return
+ end
+ local current_size = LRUCache.__len(self)
+ if current_size <= self._max_size then
+ return
+ end
+
+ -- sort as the access time
+ local sorted_array = {}
+ for k, v in pairs(self._access_times) do
+ table.insert(sorted_array, { key = k, access = v })
+ end
+ table.sort(sorted_array, function(a, b)
+ return a.access < b.access
+ end)
+
+ -- remove oldest item
+ for _, oldest in pairs(sorted_array) do
+ self:remove(oldest.key)
+ current_size = current_size - 1
+ if current_size <= self._max_size then
+ return
+ end
+ end
+end
+
+--- String representation of cached keys
+--- @return string
+function LRUCache:__tostring()
+ local s = "{"
+ local sep = ""
+ for k, _ in pairs(self._values) do
+ s = s .. sep .. k
+ sep = ","
+ end
+ return s .. "}"
+end
+
+--- Gets number of items in cache
+--- @return number count Number of cached items
+function LRUCache:__len()
+ local count = 0
+ for _ in pairs(self._values) do
+ count = count + 1
+ end
+ return count
+end
+
+return LRUCache
diff --git a/src/lib/persist.lua b/src/lib/persist.lua
index a9bbb15..67337aa 100644
--- a/src/lib/persist.lua
+++ b/src/lib/persist.lua
@@ -1,46 +1,86 @@
---- @module "lib.persist"
--- A persistence utility module for storing and retrieving values with optional encryption.
--- This module provides a simple key-value store interface with caching capabilities.
+---
+--- ## Migrations
+---
+--- Persist supports one-time data migrations between driver versions. This is useful when the
+--- structure of persisted data needs to change (e.g., converting integer keys to string keys).
+---
+--- To define migrations, create a `src/migrations.lua` file that returns a table mapping persist
+--- keys to migration functions:
+---
+--- ```lua
+--- -- src/migrations.lua
+--- return {
+--- ["MyData"] = function(value)
+--- -- transform value from old format to new format
+--- return transformedValue
+--- end,
+--- }
+--- ```
+---
+--- Migrations are loaded automatically on the first `get()` call via `pcall(require, "migrations")`.
+--- Each migration runs once per key, transforms the value, persists the result, and removes itself.
+--- If no `migrations.lua` file exists, persist operates normally with no migrations.
+
local log = require("lib.logging")
--- A utility class for storing and retrieving values from the controller's persistence store.
--- @class Persist
--- @field _persist table A table to store the cached values.
local Persist = {}
+Persist.__index = Persist
--- Sentinel representing an empty value in the persistence store.
--- @type table
local EMPTY = {}
---- Migrate data during first retrieval. Helpful in cases where you wish to change structure of data
---- between driver versions.
---- This map is of the form:
---- {
---- "key": function(value) -> newValue
---- }
+--- Migration functions loaded from the driver's `migrations.lua` module.
+--- Populated lazily on first get() call. Each entry maps a persist key to a function that
+--- transforms the old value format into the new format.
--- @type table
local MIGRATIONS = {}
+--- Whether migrations have been loaded from the driver's migrations module.
+--- @type boolean
+local migrationsLoaded = false
+
--- Creates a new instance of the Persist class.
--- @return Persist persist A new instance of the Persist class.
function Persist:new()
log:trace("Persist:new()")
- local properties = {
- _persist = {},
- }
- setmetatable(properties, self)
- self.__index = self
- --- @cast properties Persist
- return properties
+ local instance = setmetatable({}, self)
+ instance._persist = {}
+ return instance
+end
+
+--- Loads driver-specific migrations from `migrations.lua` if present.
+--- Called automatically on first get(). Safe to call multiple times (no-op after first call).
+--- @private
+local function loadMigrations()
+ if migrationsLoaded then
+ return
+ end
+ migrationsLoaded = true
+ local ok, m = pcall(require, "migrations")
+ if ok and type(m) == "table" then
+ for key, fn in pairs(m) do
+ MIGRATIONS[key] = fn
+ end
+ end
end
--- Retrieves a value from the persistence store.
+--- On first call, loads any driver-specific migrations from `migrations.lua`.
+--- If a migration exists for the requested key, it runs once, persists the transformed value,
+--- and removes itself.
--- @param key string The key to retrieve the value for.
--- @param default? any The default value to return if the key doesn't exist (optional).
--- @param encrypted? boolean Whether the value is encrypted (optional).
--- @return any value The retrieved value, or the default if the key doesn't exist.
function Persist:get(key, default, encrypted)
log:trace("Persist:get(%s, %s, %s)", key, default, encrypted)
+ loadMigrations()
local value = self:_get(key, default, encrypted)
if type(MIGRATIONS[key]) == "function" then
@@ -52,6 +92,12 @@ function Persist:get(key, default, encrypted)
return value
end
+--- Internal get implementation with caching.
+--- @private
+--- @param key string The key to retrieve.
+--- @param default any The default value if key is not found.
+--- @param encrypted boolean? Whether the value is encrypted.
+--- @return any value The retrieved value or default.
function Persist:_get(key, default, encrypted)
log:trace("Persist:_get(%s, %s, %s)", key, default, encrypted)
if default == nil then
@@ -104,4 +150,14 @@ function Persist:delete(key)
self:set(key, nil)
end
+--- Resets/clears specified keys from the persistence store.
+--- @param keys string[] Array of keys to delete.
+--- @return void
+function Persist:reset(keys)
+ log:trace("Persist:reset(%s)", keys)
+ for _, key in ipairs(keys) do
+ self:delete(key)
+ end
+end
+
return Persist:new()
diff --git a/src/lib/protobuf.lua b/src/lib/protobuf.lua
deleted file mode 100644
index 8517789..0000000
--- a/src/lib/protobuf.lua
+++ /dev/null
@@ -1,485 +0,0 @@
---- @module "lib.protobuf"
---- A lightweight Protocol Buffers implementation for Lua.
---- This module provides encoding and decoding functions for Protocol Buffers data format.
-local bit = require("bit")
-
---- @class Protobuf
---- A class providing Protocol Buffers encoding and decoding functionality.
-local Protobuf = {}
-
---- Encodes an integer into a varint byte sequence.
---- @param value number|boolean The value to encode.
---- @return string bytes The encoded varint byte sequence.
-function Protobuf.encode_varint(value)
- local bytes = {}
- repeat
- if type(value) == "boolean" then
- value = value and 1 or 0
- end
- local byte = bit.band(value, 0x7F)
- value = bit.rshift(value, 7)
- if value > 0 then
- byte = bit.bor(byte, 0x80) -- Mark this byte as "continued"
- end
- table.insert(bytes, string.char(byte))
- until value == 0
- return table.concat(bytes)
-end
-
---- Decodes a varint byte sequence into an integer.
---- @param buffer string The buffer containing the encoded varint.
---- @param pos integer The position in the buffer to start decoding from.
---- @return integer value The decoded integer value.
---- @return integer new_pos The new position in the buffer after decoding.
-function Protobuf.decode_varint(buffer, pos)
- local result = 0
- local shift = 0
- local byte
- repeat
- byte = string.byte(buffer, pos)
- result = result + bit.lshift(bit.band(byte, 0x7F), shift)
- shift = shift + 7
- pos = pos + 1
- until bit.band(byte, 0x80) == 0
- return result, pos
-end
-
---- Encodes a 32-bit integer into a fixed-length 4-byte sequence.
---- @param value integer The 32-bit integer to encode.
---- @return string bytes The encoded 4-byte sequence.
-function Protobuf.encode_fixed32(value)
- local b1 = value % 256
- local b2 = math.floor(value / 256) % 256
- local b3 = math.floor(value / 65536) % 256
- local b4 = math.floor(value / 16777216)
- return string.char(b1, b2, b3, b4)
-end
-
---- Decodes a fixed-length 4-byte sequence into a 32-bit integer.
---- @param buffer string The buffer containing the encoded fixed32.
---- @param pos integer The position in the buffer to start decoding from.
---- @return integer value The decoded 32-bit integer value.
---- @return integer new_pos The new position in the buffer after decoding.
-function Protobuf.decode_fixed32(buffer, pos)
- local b1, b2, b3, b4 = string.byte(buffer, pos, pos + 3)
- local value = b1 + b2 * 256 + b3 * 65536 + b4 * 16777216
- --- @cast value integer
- return value, pos + 4
-end
-
---- Encodes a floating-point number into a 4-byte IEEE 754 single-precision format.
---- @param value number The floating-point number to encode.
---- @return string bytes The encoded 4-byte sequence.
-function Protobuf.encode_float(value)
- if value == 0 then
- return string.char(0, 0, 0, 0)
- end
-
- local sign = 0
- if value < 0 then
- sign = 1
- value = -value
- end
-
- --- @diagnostic disable-next-line: undefined-field
- local mantissa, exponent = math.frexp(value)
- exponent = exponent - 1
- mantissa = mantissa * 2 - 1
-
- local e = exponent + 127
- if e < 0 then
- e = 0
- mantissa = 0
- elseif e > 255 then
- e = 255
- mantissa = 0
- end
-
- local m = math.floor(mantissa * 0x800000 + 0.5)
-
- local b1 = m % 256
- local b2 = math.floor(m / 256) % 256
- local b3 = bit.bor(math.floor(m / 65536), bit.lshift(e % 2, 7))
- local b4 = bit.bor(bit.rshift(e, 1), bit.lshift(sign, 7))
-
- return string.char(b1, b2, b3, b4)
-end
-
---- Decodes a 4-byte IEEE 754 single-precision format into a floating-point number.
---- @param buffer string The buffer containing the encoded float.
---- @param pos integer The position in the buffer to start decoding from.
---- @return number value The decoded floating-point value.
---- @return integer new_pos The new position in the buffer after decoding.
-function Protobuf.decode_float(buffer, pos)
- local b1, b2, b3, b4 = string.byte(buffer, pos, pos + 3)
-
- local sign = bit.rshift(b4, 7)
- local e = bit.lshift(bit.band(b4, 0x7F), 1) + bit.rshift(b3, 7)
- local m = bit.band(b3, 0x7F) * 65536 + b2 * 256 + b1
-
- if e == 0 and m == 0 then
- return 0, pos + 4
- end
-
- --- @diagnostic disable-next-line: undefined-field
- local value = math.ldexp(1 + m / 0x800000, e - 127)
- if sign == 1 then
- value = -value
- end
-
- return value, pos + 4
-end
-
---- Encodes a length-delimited field (string or nested message).
---- @param data string The data to encode.
---- @return string bytes The encoded length-delimited data.
-function Protobuf.encode_length_delimited(data)
- return Protobuf.encode_varint(#data) .. data
-end
-
---- Decodes a length-delimited field.
---- @param buffer string The buffer containing the encoded length-delimited data.
---- @param pos integer The position in the buffer to start decoding from.
---- @return string data The decoded data.
---- @return integer new_pos The new position in the buffer after decoding.
-function Protobuf.decode_length_delimited(buffer, pos)
- local length, new_pos = Protobuf.decode_varint(buffer, pos)
- local data = string.sub(buffer, new_pos, new_pos + length - 1)
- return data, new_pos + length
-end
-
---- Encodes a message according to a schema.
---- @param protoSchema ProtoSchema The complete proto schema.
---- @param messageSchema ProtoMessageSchema The message schema to use for encoding.
---- @param message table The message body to encode.
---- @return string buffer The encoded message.
-function Protobuf.encode(protoSchema, messageSchema, message)
- local buffer = ""
-
- for field_number, field in pairs(messageSchema.fields) do
- local values = message[field.name]
- if values ~= nil then
- if field.repeated then
- if not IsList(values) then
- error("Field '" .. field.name .. "' is repeated but received a non-list value.")
- end
- else
- if IsList(values) then
- error("Field '" .. field.name .. "' is not repeated but received a list.")
- end
- values = { values } -- Wrap single value in a list for uniform processing
- end
- for _, value in ipairs(values) do
- -- Compute the key (field number and wire type)
- local key = bit.lshift(field_number, 3) + field.wireType
- buffer = buffer .. Protobuf.encode_varint(key)
-
- if field.wireType == protoSchema.WireType.VARINT then
- buffer = buffer .. Protobuf.encode_varint(value)
- elseif field.wireType == protoSchema.WireType.FIXED32 then
- if field.type == protoSchema.DataType.FLOAT then
- buffer = buffer .. Protobuf.encode_float(value)
- else
- buffer = buffer .. Protobuf.encode_fixed32(value)
- end
- elseif field.wireType == protoSchema.WireType.LENGTH_DELIMITED then
- if type(value) == "string" then
- buffer = buffer .. Protobuf.encode_length_delimited(value)
- elseif type(value) == "table" then
- if field.subschema == nil then
- error(
- "Field '"
- .. messageSchema.name
- .. "."
- .. field.name
- .. "' is a nested message but has no subschema defined."
- )
- end
- -- For nested messages
- local nested_message = Protobuf.encode(protoSchema, field.subschema, value)
- buffer = buffer .. Protobuf.encode_length_delimited(nested_message)
- end
- else
- error("Unsupported wire type: " .. tostring(field.wireType))
- end
- end
- end
- end
-
- return buffer
-end
-
---- Decodes a message according to a schema.
---- @param protoSchema ProtoSchema The complete proto schema.
---- @param messageSchema ProtoMessageSchema The schema defining the message structure.
---- @param buffer string The encoded message bytes.
---- @return table message The decoded message.
---- @return number pos The position in the buffer after decoding.
-function Protobuf.decode(protoSchema, messageSchema, buffer)
- --- @type integer
- local pos = 1
- local message = {}
-
- local key
- while pos <= #buffer do
- -- Decode the key (field number and wire type)
- key, pos = Protobuf.decode_varint(buffer, pos)
- local field_number = bit.rshift(key, 3)
- local wire_type = bit.band(key, 0x7)
-
- -- Find the corresponding field in the schema
- local field = messageSchema.fields[field_number]
- if not field then
- error("Unknown field number: " .. field_number)
- end
-
- local value
- -- Decode the value based on the wire type
- if wire_type == protoSchema.WireType.VARINT then
- value, pos = Protobuf.decode_varint(buffer, pos)
- if field.type == protoSchema.DataType.BOOL then
- value = value ~= 0 -- Convert to boolean
- end
- elseif wire_type == protoSchema.WireType.FIXED32 then
- if field.type == protoSchema.DataType.FLOAT then
- value, pos = Protobuf.decode_float(buffer, pos)
- else
- value, pos = Protobuf.decode_fixed32(buffer, pos)
- end
- elseif wire_type == protoSchema.WireType.LENGTH_DELIMITED then
- local data
- data, pos = Protobuf.decode_length_delimited(buffer, pos)
- if field.subschema then
- value, _ = Protobuf.decode(protoSchema, field.subschema, data)
- else
- value = data
- end
- else
- error("Unsupported wire type: " .. wire_type)
- end
-
- if field.repeated then
- if message[field.name] == nil then
- message[field.name] = {}
- end
- table.insert(message[field.name], value)
- else
- message[field.name] = value
- end
- end
-
- return message, pos
-end
-
---- === Test Suite ===
---- Runs tests to verify the functionality of the Protobuf module.
---- This function tests encoding and decoding of various data types.
---function run_tests()
--- --- Asserts that two values are equal.
--- --- @param a any The first value.
--- --- @param b any The second value.
--- --- @param msg? string The error message (optional).
--- local function assert_equal(a, b, msg)
--- if a ~= b then
--- error("Assertion failed: " .. tostring(a) .. " ~= " .. tostring(b) .. " | " .. (msg or ""), 2)
--- end
--- end
---
--- --- Asserts that two floating-point values are approximately equal.
--- --- @param a number The first value.
--- --- @param b number The second value.
--- --- @param epsilon? number The maximum allowed difference (optional, default: 1e-6).
--- --- @param msg? string The error message (optional).
--- local function assert_close(a, b, epsilon, msg)
--- if math.abs(a - b) > (epsilon or 1e-6) then
--- error("Assertion failed: " .. tostring(a) .. " not close to " .. tostring(b) .. " | " .. (msg or ""), 2)
--- end
--- end
---
--- --- Asserts that two tables are equal (shallow comparison).
--- --- @param a table The first table.
--- --- @param b table The second table.
--- --- @param msg? string The error message (optional).
--- local function assert_table_equal(a, b, msg)
--- if #a ~= #b then
--- error("Assertion failed: tables have different lengths | " .. (msg or ""), 2)
--- end
--- for i = 1, #a do
--- if type(a[i]) == "number" and type(b[i]) == "number" and math.abs(a[i] - b[i]) <= 1e-6 then
--- -- Float comparison
--- -- Skip, it's close enough
--- elseif a[i] ~= b[i] then
--- error(
--- "Assertion failed: tables differ at index "
--- .. i
--- .. " ("
--- .. tostring(a[i])
--- .. " vs "
--- .. tostring(b[i])
--- .. ") | "
--- .. (msg or ""),
--- 2
--- )
--- end
--- end
--- end
---
--- -- Varint
--- for _, v in ipairs({ 0, 1, 127, 128, 300, 65535 }) do --, 2 ^ 32 - 1 }) do
--- local enc = Protobuf.encode_varint(v)
--- local dec, _ = Protobuf.decode_varint(enc, 1)
--- assert_equal(dec, v, "Varint mismatch for " .. v)
--- end
---
--- ---- 64-bit
--- --local val64 = 2^40 + 12345678
--- --local enc64 = encode_64bit(val64)
--- --local dec64, _ = decode_64bit(enc64)
--- --assert_equal(dec64, val64, "64-bit mismatch")
---
--- -- 32-bit
--- local val32 = 1234567890
--- local enc32 = Protobuf.encode_fixed32(val32)
--- local dec32, _ = Protobuf.decode_fixed32(enc32, 1)
--- assert_equal(dec32, val32, "32-bit mismatch")
---
--- -- Float
--- for _, v in ipairs({ 0.0, 1.0, -1.0, 3.14159, 51.0, 1234.5678 }) do
--- local enc = Protobuf.encode_float(v)
--- local dec, _ = Protobuf.decode_float(enc, 1)
--- assert_close(dec, v, 1e-4, "Float mismatch for " .. v)
--- print("Float test: " .. v .. " encoded and decoded as " .. dec)
--- end
---
--- -- Length-delimited
--- local str = "hello world"
--- local enc2 = Protobuf.encode_length_delimited(str)
--- local dec2, _ = Protobuf.decode_length_delimited(enc2, 1)
--- assert_equal(dec2, str, "Length-delimited mismatch")
---
--- local message = {
--- id = 1,
--- name = "Test",
--- value = 42,
--- nested = {
--- id = 2,
--- name = "Nested",
--- value = 100,
--- },
--- }
--- local schema = {
--- fields = {
--- [1] = { name = "id", wireType = Protobuf.WireType.VARINT },
--- [2] = { name = "name", wireType = Protobuf.WireType.LENGTH_DELIMITED },
--- [3] = { name = "value", wireType = Protobuf.WireType.FIXED32 },
--- [4] = {
--- name = "nested",
--- wireType = Protobuf.WireType.LENGTH_DELIMITED,
--- subschema = {
--- fields = {
--- [1] = { name = "id", wireType = Protobuf.WireType.VARINT },
--- [2] = { name = "name", wireType = Protobuf.WireType.LENGTH_DELIMITED },
--- [3] = { name = "value", wireType = Protobuf.WireType.FIXED32 },
--- },
--- },
--- },
--- },
--- }
--- local encoded_message = Protobuf.encode(schema, message)
--- local decoded_message = Protobuf.decode(schema, encoded_message)
--- assert_equal(decoded_message.id, message.id, "ID mismatch")
--- assert_equal(decoded_message.name, message.name, "Name mismatch")
--- assert_equal(decoded_message.value, message.value, "Value mismatch")
--- assert_equal(decoded_message.nested.id, message.nested.id, "Nested ID mismatch")
--- assert_equal(decoded_message.nested.name, message.nested.name, "Nested Name mismatch")
--- assert_equal(decoded_message.nested.value, message.nested.value, "Nested Value mismatch")
---
--- -- Test repeated fields
--- print("Testing repeated fields...")
---
--- -- Test repeated primitive types
--- local repeated_message = {
--- int_array = { 1, 2, 3, 4, 5 },
--- float_array = { 1.1, 2.2, 3.3, 4.4, 5.5 },
--- bool_array = { true, false, true },
--- string_array = { "one", "two", "three" },
--- }
---
--- local repeated_schema = {
--- fields = {
--- [1] = { name = "int_array", wireType = Protobuf.WireType.VARINT, type = Protobuf.DataType.INT32, repeated = true },
--- [2] = { name = "float_array", wireType = Protobuf.WireType.FIXED32, type = Protobuf.DataType.FLOAT, repeated = true },
--- [3] = { name = "bool_array", wireType = Protobuf.WireType.VARINT, type = Protobuf.DataType.BOOL, repeated = true },
--- [4] = {
--- name = "string_array",
--- wireType = Protobuf.WireType.LENGTH_DELIMITED,
--- type = Protobuf.DataType.STRING,
--- repeated = true,
--- },
--- },
--- }
---
--- local encoded_repeated = Protobuf.encode(repeated_schema, repeated_message)
--- local decoded_repeated = Protobuf.decode(repeated_schema, encoded_repeated)
---
--- assert_table_equal(decoded_repeated.int_array, repeated_message.int_array, "Int array mismatch")
--- assert_table_equal(decoded_repeated.float_array, repeated_message.float_array, "Float array mismatch")
--- assert_table_equal(decoded_repeated.bool_array, repeated_message.bool_array, "Bool array mismatch")
--- assert_table_equal(decoded_repeated.string_array, repeated_message.string_array, "String array mismatch")
---
--- -- Test repeated message types
--- local repeated_nested_message = {
--- items = {
--- { id = 1, name = "Item 1", value = 10 },
--- { id = 2, name = "Item 2", value = 20 },
--- { id = 3, name = "Item 3", value = 30 },
--- },
--- }
---
--- local repeated_nested_schema = {
--- fields = {
--- [1] = {
--- name = "items",
--- wireType = Protobuf.WireType.LENGTH_DELIMITED,
--- repeated = true,
--- subschema = {
--- fields = {
--- [1] = { name = "id", wireType = Protobuf.WireType.VARINT },
--- [2] = { name = "name", wireType = Protobuf.WireType.LENGTH_DELIMITED },
--- [3] = { name = "value", wireType = Protobuf.WireType.FIXED32 },
--- },
--- },
--- },
--- },
--- }
---
--- local encoded_nested_repeated = Protobuf.encode(repeated_nested_schema, repeated_nested_message)
--- local decoded_nested_repeated = Protobuf.decode(repeated_nested_schema, encoded_nested_repeated)
---
--- assert_equal(#decoded_nested_repeated.items, #repeated_nested_message.items, "Repeated message count mismatch")
--- for i = 1, #repeated_nested_message.items do
--- assert_equal(
--- decoded_nested_repeated.items[i].id,
--- repeated_nested_message.items[i].id,
--- "Repeated message ID mismatch at index " .. i
--- )
--- assert_equal(
--- decoded_nested_repeated.items[i].name,
--- repeated_nested_message.items[i].name,
--- "Repeated message name mismatch at index " .. i
--- )
--- assert_equal(
--- decoded_nested_repeated.items[i].value,
--- repeated_nested_message.items[i].value,
--- "Repeated message value mismatch at index " .. i
--- )
--- end
---
--- print("All tests passed.")
---end
-
--- Uncomment to run the tests directly:
--- run_tests()
-
---- @return Protobuf
-return Protobuf
diff --git a/src/lib/utils.lua b/src/lib/utils.lua
index 9dc3acc..b727080 100644
--- a/src/lib/utils.lua
+++ b/src/lib/utils.lua
@@ -1,12 +1,55 @@
---- @module "lib.utils"
--- Utility module for managing devices, their bindings, properties, data, and general device-related operations in a Control4-driven environment.
-local deferred = require("vendor.deferred")
+local deferred = require("deferred")
local log = require("lib.logging")
+local lru = require("lib.lru")
local constants = require("constants")
+--- @alias DeviceId integer|string
+
+do
+ --- @type table
+ --- Global table mapping command names to functions.
+ --- Each function takes a parameter name and returns a list of parameter values.
+ GCPL = GCPL or {}
+end
+
+--- Retrieves a list of command parameters for a given command name.
+--- @param commandName string The name of the command to retrieve parameters for.
+--- @param paramName string The specific parameter to retrieve associated with the command.
+--- @return any|nil parameters Returns the list of parameters if successful; nil otherwise.
+function GetCommandParamList(commandName, paramName)
+ commandName = string.gsub(commandName, "%W", "_")
+ commandName = string.gsub(commandName, "[_]+", "_")
+ commandName = string.gsub(commandName, "^[_| ]+", "")
+ commandName = string.gsub(commandName, "[_| ]+$", "")
+
+ local init = {
+ "GetCommandParamList: " .. commandName,
+ paramName,
+ }
+ HandlerDebug(init)
+
+ local success, ret
+
+ if GCPL and GCPL[commandName] and type(GCPL[commandName]) == "function" then
+ success, ret = xpcall(function()
+ return GCPL[commandName](paramName)
+ end, debug.traceback)
+ end
+
+ if success == true then
+ return ret
+ elseif success == false then
+ print("GetCommandParamList error: ", ret, commandName, paramName)
+ if ON_HANDLER_ERROR then
+ ON_HANDLER_ERROR("GCPL." .. commandName, ret)
+ end
+ end
+end
+
--- Checks if the current OS version meets the minimum required version as defined in the driver configuration.
--- @param statusProperty string The property to update with the status message if the version check fails.
--- @return boolean meetsMinVersion True if the version check passes, false otherwise.
@@ -44,32 +87,36 @@ function GetDriverVersion(filename)
return Select(ParseXml(FileRead("driver.xml")) or {}, "devicedata", "version") or nil
end
---- Logs function calls and arguments for debugging purposes.
---- @param funcName string The name of the function being logged.
---- @vararg any A variable number of arguments passed to the function.
-local function LogC4Send(funcName, ...)
+--- Logs and invokes a C4 method, trimming trailing nil arguments.
+--- @param methodName string The C4 method name
+--- @param ... any Arguments to pass
+--- @return any
+local function C4Call(methodName, ...)
local numArgs = select("#", ...)
local args = { ... }
- local logArgsFmt = ""
- for i, _ in pairs(args) do
- logArgsFmt = logArgsFmt .. "%s"
- if i ~= numArgs then
- logArgsFmt = logArgsFmt .. ", "
+ -- Single pass: build format string and find last non-nil
+ local lastNonNil = 0
+ local fmtParts = {}
+ for i = 1, numArgs do
+ if args[i] ~= nil then
+ lastNonNil = i
end
+ fmtParts[i] = "%s"
end
- log:trace("%s(" .. logArgsFmt .. ")", funcName, unpack(args))
+
+ log:trace("C4:" .. methodName .. "(" .. table.concat(fmtParts, ", ") .. ")", unpack(args, 1, numArgs))
+ return C4[methodName](C4, unpack(args, 1, lastNonNil))
end
--- Sends a Control4 CommandMessage to a specified Control4 device driver.
---- @param deviceId number|string The ID of the driver to send the command to.
+--- @param deviceId DeviceId The ID of the driver to send the command to.
--- @param strCommand string The command to send.
--- @param tParams table A table containing parameters for the command.
--- @param allowEmptyValues? boolean Allows empty strings as parameter values (optional). Defaults to false.
--- @param logCommand? boolean If false, prevents logging of the command's content (optional). Defaults to true.
-function SendToDevice(...)
- LogC4Send("C4:SendToDevice", ...)
- return C4:SendToDevice(...)
+function SendToDevice(deviceId, strCommand, tParams, allowEmptyValues, logCommand)
+ return C4Call("SendToDevice", deviceId, strCommand, tParams, allowEmptyValues, logCommand)
end
--- Sends a Control4 BindMessage to a proxy with the specified binding ID.
@@ -78,37 +125,136 @@ end
--- @param tParams table A table containing parameters for the command.
--- @param strMessage? string Overrides message type ("COMMAND" or "NOTIFY") (optional). Defaults to "COMMAND".
--- @param allowEmptyValues? boolean Allows empty values in message parameters (optional).
-function SendToProxy(...)
- LogC4Send("C4:SendToProxy", ...)
- return C4:SendToProxy(...)
+function SendToProxy(idBinding, strCommand, tParams, strMessage, allowEmptyValues)
+ return C4Call("SendToProxy", idBinding, strCommand, tParams, strMessage, allowEmptyValues)
end
--- Sends an HTTP request to a network binding.
--- @param idBinding number The ID of the network binding to send to.
--- @param nPort number The port to use for the request.
--- @param strData string The data to send with the HTTP request.
-function SendToNetwork(...)
- LogC4Send("C4:SendToNetwork", ...)
- return C4:SendToNetwork(...)
+function SendToNetwork(idBinding, nPort, strData)
+ return C4Call("SendToNetwork", idBinding, nPort, strData)
end
--- Sends a UI Request to another driver.
---- @param id number The ID of the driver receiving the request.
+--- @param id DeviceId The ID of the driver receiving the request.
--- @param request string The request to send.
--- @param tParams table A table of parameters to send with the request. Use `{}` if no parameters.
--- @return string response The response to the request in XML format.
-function SendUIRequest(...)
- LogC4Send("C4:SendUIRequest", ...)
- return C4:SendUIRequest(...)
+function SendUIRequest(id, request, tParams)
+ return C4Call("SendUIRequest", id, request, tParams)
+end
+
+--- @type LRUCache
+local devicesCache = lru:new(1000, 180)
+
+--- @type LRUCache
+local devicesDataCache = lru:new(1000, 180)
+
+--- @type LRUCache
+local agentIdCache = lru:new(1000, 180)
+
+--- Clears the specific device ID from the cache.
+--- @param deviceId DeviceId The ID of the device to clear from the cache.
+function DeviceUpdated(deviceId)
+ log:trace("DeviceUpdated(%s)", deviceId)
+ devicesCache:remove(tostring(deviceId))
+ devicesDataCache:remove(tostring(deviceId))
+end
+
+--- @class ExtendedDeviceDefinition
+--- @field driverFileName string
+--- @field deviceName string
+--- @field roomId string
+--- @field roomName string
+--- @field protocol? table
+--- @field deviceId DeviceId
+--- @field displayName string
+--- @field ignoreRoomName? boolean Whether to ignore the room name prefix for this device.
+
+--- Retrieves an extended device definition by device ID.
+--- @param deviceId DeviceId|nil The ID of the device.
+--- @param c4iNames? string[]|string List of C4i names to filter results.
+--- @return ExtendedDeviceDefinition|nil device The device definition if found; else nil.
+function GetDevice(deviceId, c4iNames)
+ log:trace("GetDevice(%s, %s)", deviceId, c4iNames)
+ return devicesCache:getOrSet(tostring(deviceId), function()
+ local deviceIdInt = tointeger(deviceId)
+ if deviceIdInt == nil or deviceIdInt < 1 then
+ return nil
+ end
+ --- @type DeviceFilter
+ local tFilter = { DeviceIds = tostring(deviceIdInt) }
+ if not IsEmpty(c4iNames) then
+ --- @cast c4iNames -nil
+ if IsList(c4iNames) then
+ --- @cast c4iNames string[]
+ c4iNames = table.concat(c4iNames, ",")
+ end
+ --- @cast c4iNames -string[]
+ tFilter.C4iNames = c4iNames
+ end
+ local device = C4:GetDevices(tFilter)[deviceIdInt]
+ if device == nil then
+ -- Make a synthetic device for a room
+ local deviceName = C4:GetDeviceDisplayName(deviceIdInt)
+ if not IsEmpty(deviceName) then
+ --- @type ExtendedDeviceDefinition
+ device = {
+ roomId = tostring(deviceIdInt),
+ roomName = deviceName,
+ deviceName = deviceName,
+ driverFileName = "roomdevice.c4i",
+ }
+ else
+ log:warn("GetDevice -> Unknown device %s", deviceIdInt)
+ return nil
+ end
+ end
+ local displayName = device.deviceName
+ if not IsEmpty(device.roomName) then
+ displayName = string.format("%s > %s", device.roomName, device.deviceName)
+ end
+ --- @type ExtendedDeviceDefinition
+ return {
+ driverFileName = device.driverFileName,
+ deviceName = device.deviceName,
+ roomId = device.roomId,
+ roomName = device.roomName,
+ protocol = device.protocol,
+ -- Extended fields
+ deviceId = deviceIdInt,
+ displayName = displayName,
+ }
+ end)
+end
+
+--- Retrieves device data for a given device ID and specified properties.
+--- @param deviceId DeviceId The ID of the device to retrieve data for.
+--- @param ... string? Nested keys to retrieve specific properties from the device data.
+--- @return table data
+function GetDeviceData(deviceId, ...)
+ log:trace("GetDeviceData(%s, %s)", deviceId, table.concat({ ... }, ","))
+ local deviceIdInt = tointeger(deviceId)
+ if deviceIdInt == nil then
+ return {}
+ end
+ return Select(
+ devicesDataCache:getOrSet(tostring(deviceId), function()
+ return ParseXml(C4:GetDeviceData(deviceIdInt))
+ end),
+ unpack({ ... })
+ )
end
--- Retrieves the bindings for a given device, optionally filtered by type, provider, display name, and class.
---- @param deviceId number The ID of the device to retrieve bindings for.
+--- @param deviceId integer The ID of the device to retrieve bindings for.
--- @param typeFilter string|nil Optional filter for the binding type.
--- @param providerFilter boolean|nil Optional filter for the binding provider.
--- @param displayNameFilter string|nil Optional filter for the binding display name.
--- @param classFilter string|nil Optional filter for the binding class.
---- @return table bindings A table of matched bindings, where the keys are binding IDs and the values are binding details.
+--- @return table bindings A table of matched bindings, where the keys are binding IDs and the values are binding details.
function GetDeviceBindings(deviceId, typeFilter, providerFilter, displayNameFilter, classFilter)
log:trace(
"GetDeviceBindings(%s, %s, %s, %s, %s)",
@@ -120,7 +266,7 @@ function GetDeviceBindings(deviceId, typeFilter, providerFilter, displayNameFilt
)
--- @type DeviceBinding[]
local deviceBindings = Select(C4:GetBindingsByDevice(deviceId), "bindings") or {}
- --- @type table
+ --- @type table
local matchedBindings = {}
for _, binding in pairs(deviceBindings) do
if
@@ -139,12 +285,156 @@ function GetDeviceBindings(deviceId, typeFilter, providerFilter, displayNameFilt
return matchedBindings
end
-local xml2lua = require("vendor.xml.xml2lua")
-local handler = require("vendor.xml.xmlhandler.tree")
+--- Retrieves the agent ID for a given C4i name.
+--- @param c4iName string The C4i name to look up.
+--- @return integer|nil agentId The ID of the agent if found, otherwise nil.
+function GetAgentId(c4iName)
+ log:trace("GetAgentId(%s)", c4iName)
+ return agentIdCache:getOrSet(c4iName, function()
+ local agents = Select(ParseXml(C4:GetProjectItems("AGENTS")), "systemitems", "item") or {}
+ if not IsList(agents) then
+ agents = { agents }
+ end
+ for _, agent in pairs(agents) do
+ if not IsEmpty(agent) and agent.c4i == c4iName and not IsEmpty(agent.id) then
+ return tointeger(agent.id)
+ end
+ end
+ return nil
+ end)
+end
+
+--- Retrieves device properties for a given device ID.
+--- @param deviceId integer The ID of the device to retrieve properties for.
+--- @return table properties A table mapping property names to their values.
+function GetDeviceProperties(deviceId)
+ log:trace("GetDeviceProperties(%s)", deviceId)
+ local strValues = SendUIRequest(deviceId, "GET_PROPERTIES_SYNC", {})
+ if IsEmpty(strValues) then
+ strValues = SendUIRequest(deviceId, "GET_PROPERTIES", {})
+ end
+ local propertiesList = Select(ParseXml(strValues), "properties", "property")
+ local propertiesMap = {}
+ for _, property in pairs(propertiesList or {}) do
+ propertiesMap[property.name] = property.value
+ end
+ return propertiesMap
+end
+
+--- Sets device properties for a given device ID.
+--- @param deviceId integer The ID of the device to set properties on.
+--- @param properties table A table mapping property names to their values.
+--- @param onlyIfChanged? boolean If true, only update properties that have changed.
+function SetDeviceProperties(deviceId, properties, onlyIfChanged)
+ log:trace("SetDeviceProperties(%s, %s, %s)", deviceId, properties, onlyIfChanged)
+ local currentProps = onlyIfChanged and GetDeviceProperties(deviceId) or {}
+ for name, value in pairs(properties) do
+ if not onlyIfChanged or currentProps[name] ~= value then
+ SendToDevice(deviceId, "UPDATE_PROPERTY", { Name = name, Value = value })
+ end
+ end
+end
+
+--- @alias GenericCallback fun(deviceId: DeviceId, device: table, index: number): any
+
+--- Removes unknown device IDs and optionally processes known ones using a callback.
+--- This function iterates through device IDs parsed from the `propertyStr` and processes each using the optional callback.
+--- If `deleteUnknownDeviceIds` is `true`, invalid device IDs are removed from the property list.
+--- @generic T
+--- @param propertyStr string The name of the property that stores the device IDs to parse.
+--- @param callback? fun(deviceId: DeviceId, device: table, index: integer): T Optional callback function that processes each valid device.
+--- @param deleteUnknownDeviceIds? boolean When set to `true`, deletes invalid device IDs from the property list.
+--- @return table|nil devices Returns a table of processed device data or `nil` if parsing fails.
+function ParseDeviceIdPropertyList(propertyStr, callback, deleteUnknownDeviceIds)
+ if deleteUnknownDeviceIds == nil then
+ deleteUnknownDeviceIds = true
+ end
+ log:trace("ParseDeviceIdPropertyList(%s, , %s)", propertyStr, deleteUnknownDeviceIds)
+ local properties = Select(ParseXml(C4:GetDeviceData(C4:GetDeviceID(), "properties")), "property")
+ if not IsList(properties) then
+ properties = { properties }
+ end
+ for _, property in pairs(properties) do
+ if not IsEmpty(property) and property.name == propertyStr then
+ if property.type ~= "DEVICE_SELECTOR" then
+ log:error("Failed to parse '%s'; only DEVICE_SELECTOR properties can be parsed", propertyStr)
+ return nil
+ end
+ local c4iNames = {}
+ if not IsEmpty(property.items) and not IsEmpty(property.items.item) then
+ local items = property.items.item
+ if type(items) == "string" then
+ items = { items }
+ end
+ for _, item in pairs(items) do
+ if not IsEmpty(item) then
+ table.insert(c4iNames, item)
+ end
+ end
+ end
+ if IsEmpty(c4iNames) then
+ log:error("Failed to parse '%s'; no c4i name items were found", propertyStr)
+ return nil
+ end
+
+ -- Remove any invalid devices from the property
+ local currentPropertyValue = Properties[propertyStr] or ""
+ if deleteUnknownDeviceIds then
+ local currentPropertyValueLength = string.len(currentPropertyValue)
+ local validDeviceIds = TableKeys(ParseDeviceIdList(currentPropertyValue, c4iNames))
+ table.sort(validDeviceIds)
+ local newPropertyValue = table.concat(validDeviceIds, ",")
+ local newPropertyValueLength = string.len(newPropertyValue)
+ if currentPropertyValueLength ~= newPropertyValueLength then
+ UpdateProperty(propertyStr, newPropertyValue)
+ currentPropertyValue = newPropertyValue
+ end
+ end
+
+ return ParseDeviceIdList(currentPropertyValue, c4iNames, callback)
+ end
+ end
+ log:error("Failed to parse '%s'; property was not found", propertyStr)
+ return nil
+end
+
+--- Parses a comma-separated list of device IDs and processes them.
+--- Each device ID in the list is retrieved, checked for validity, and optionally passed to a callback function for processing.
+--- @param deviceIdListStr string The string of comma-separated device IDs.
+--- @param c4iNames? string[] Optional list of C4i names to filter devices.
+--- @param callback? fun(deviceId: DeviceId, device: table, index: integer): any Optional callback to process each device.
+--- @return table devices Returns a table of processed devices, keyed by device ID.
+function ParseDeviceIdList(deviceIdListStr, c4iNames, callback)
+ log:trace("ParseDeviceIdList(%s, %s, )", deviceIdListStr, c4iNames)
+ local devices = {}
+ local i = 1
+ for deviceIdStr in string.gmatch(deviceIdListStr or "", "([^,]+)") do
+ local device = GetDevice(deviceIdStr, c4iNames)
+ if device ~= nil then
+ if type(callback) == "function" then
+ local success, result = pcall(callback, device.deviceId, device, i)
+ i = i + 1
+ if success then
+ devices[device.deviceId] = result
+ else
+ log:error("Error parsing ids '%s'; %s", deviceIdListStr, result)
+ end
+ else
+ devices[device.deviceId] = device
+ end
+ else
+ log:warn("Unknown device with id '%s'", deviceIdStr)
+ end
+ end
+ return devices
+end
+
+local xml2lua = require("xml.xml2lua")
+local handler = require("xml.xmlhandler.tree")
--- Parses an XML string and converts it into a Lua table.
--- Makes use of an external XML parser library.
---- @param xmlStr string The XML string to parse.
+--- @param xmlStr string|nil The XML string to parse.
--- @return table xml A Lua table representation of the XML structure.
function ParseXml(xmlStr)
if IsEmpty(xmlStr) then
@@ -166,6 +456,63 @@ function MinifyXml(s)
return s
end
+--- Gets default values for all read-only properties from driver config XML.
+--- Parses the driver's config XML and returns a table mapping property names
+--- to their default values for all read-only, non-label properties.
+--- Use this for resetting driver state without hardcoding property lists.
+--- @param exclude? string[] Optional list of property names to exclude (e.g., user input fields)
+--- @return table defaults Map of property name to default value
+function GetPropertyResetValues(exclude)
+ local configXML = C4:GetDriverConfigInfo("config")
+ if IsEmpty(configXML) then
+ return {}
+ end
+
+ -- Parse the config XML
+ local parsed = ParseXml("" .. configXML .. "")
+ local propsArray = Select(parsed, "root", "properties", "property") or {}
+
+ -- Ensure it's a list (xml2lua returns single element if only one)
+ if not IsList(propsArray) then
+ propsArray = { propsArray }
+ end
+
+ -- Build exclusion set for fast lookup
+ local excludeSet = {}
+ for _, name in ipairs(exclude or {}) do
+ excludeSet[name] = true
+ end
+
+ -- Helper to get string value (handles empty table from xml parser)
+ local function getString(val)
+ if val == nil then
+ return nil
+ end
+ if type(val) == "table" then
+ return ""
+ end
+ return tostring(val)
+ end
+
+ -- Parse properties and collect read-only defaults
+ local defaults = {}
+ for _, prop in ipairs(propsArray) do
+ local name = getString(Select(prop, "name"))
+ if name and name ~= "" then
+ local propType = getString(Select(prop, "type")) or ""
+ local readonly = getString(Select(prop, "readonly")) == "true"
+ local password = getString(Select(prop, "password")) == "true"
+
+ -- Include only read-only, non-label, non-password, non-excluded properties
+ if readonly and propType ~= "LABEL" and not password and not excludeSet[name] then
+ defaults[name] = getString(Select(prop, "default")) or ""
+ end
+ end
+ end
+
+ return defaults
+end
+
--- Clamps a numeric value within a specified range.
--- Adjusts numbers below `min` up to the minimum or above `max` down to the maximum.
--- @param n number|nil The number to clamp.
@@ -173,6 +520,9 @@ end
--- @param max? number The upper bound (optional).
--- @return number|nil value The clamped value.
--- @overload fun(n: number, min?: number, max?: number): number
+--- @overload fun(n: number, min: number, max?: number): number
+--- @overload fun(n: number, min?: number, max: number): number
+--- @overload fun(n: number, min: number, max: number): number
function InRange(n, min, max)
if n == nil then
return nil
@@ -205,7 +555,7 @@ end
--- Computes the number of elements in a table.
--- Works for any table type, not just array-like tables.
--- @param t table The table to measure.
---- @return number length The number of elements in the table.
+--- @return integer length The number of elements in the table.
function TableLength(t)
if type(t) ~= "table" then
return 0
@@ -233,7 +583,7 @@ end
--- Retrieves all values from a table as an array.
--- @param t table The table to extract values from.
---- @return table values A list of all values in the table.
+--- @return table values A list of all values in the table.
function TableValues(t)
if type(t) ~= "table" then
return {}
@@ -247,9 +597,10 @@ end
--- Maps a function over each key-value pair in a table.
--- The function can modify both the keys and values.
---- @param t table The table to map over.
---- @param func fun(value: any, key: any): any Function to apply to each pair (value, key).
---- @return table mappedTable A new table containing the transformed pairs.
+--- @generic K,V,K_NEW,V_NEW
+--- @param t table The table to map over.
+--- @param func fun(value: V, key?: K): V_NEW, K_NEW? Function to apply to each pair. Returns (new_value, new_key?).
+--- @return table mappedTable A new table containing the transformed pairs.
function TableMap(t, func)
if IsEmpty(t) then
return {}
@@ -301,10 +652,12 @@ function TableReverse(t)
end
--- Deep copies a table, capturing nested tables and ensuring circular references are handled.
---- @param t table|nil The table to be deep-copied.
+--- @generic T: table
+--- @param t T|nil The table to be deep-copied.
--- @param seen? table Tracks tables already copied (internal use).
---- @return table|nil copiedTable A deep-copied version of the input table.
---- @overload fun(t: table, seen?: table): table
+--- @return T|nil copiedTable A deep-copied version of the input table.
+--- @overload fun(t: T, seen?: table): T
+--- @overload fun(t: T): T
function TableDeepCopy(t, seen)
seen = seen or {}
if t == nil then
@@ -332,18 +685,26 @@ end
--- Produces a list containing only unique values from the input table.
--- Removes duplicate values while maintaining the original order.
--- @param t table Array-like table to process
+--- @param mapper? fun(value: any): any Optional function to map values before checking for uniqueness.
--- @return table uniqueList New table containing unique values
-function UniqueList(t)
+function UniqueList(t, mapper)
if type(t) ~= "table" then
return {}
end
+ if type(mapper) ~= "function" then
+ mapper = function(v)
+ return v
+ end
+ end
+
local seen = {}
local list = {}
for _, v in ipairs(t) do
- if not seen[v] then
+ local key = mapper(v)
+ if not seen[key] then
table.insert(list, v)
- seen[v] = true
+ seen[key] = true
end
end
return list
@@ -455,13 +816,23 @@ end
--- Converts a value to a valid integer.
--- Rounds fractional numbers and validates string representations.
--- @param value any The value to convert to an integer. Can be a number or a string that represents a number.
---- @return number|nil Returns the rounded integer if the conversion is successful, or `nil` if the value cannot be converted.
+--- @return integer|nil int Returns the rounded integer if the conversion is successful, or `nil` if the value cannot be converted.
+--- @overload fun(value: number): integer
function tointeger(value)
- local nval = tonumber(value)
- if nval == nil then
+ value = tonumber(value)
+ if value == nil then
return nil
end
- return (nval >= 0) and math.floor(nval + 0.5) or math.ceil(nval - 0.5)
+ return (value >= 0) and math.floor(value + 0.5) or math.ceil(value - 0.5)
+end
+
+--- Asserts that a value is an integer, narrowing the type from DeviceId.
+--- @param value DeviceId The value to narrow.
+--- @return integer int The integer value.
+function assertInt(value)
+ local int = tointeger(value)
+ assert(int ~= nil, "expected integer, got: " .. tostring(value))
+ return int
end
function tonumber_locale(str, base)
@@ -496,9 +867,9 @@ end
--- Creates a delay for a specified number of milliseconds.
--- Uses deferred objects to resolve after the delay.
--- @param ms number The duration of the delay in milliseconds.
---- @return Deferred A deferred object that resolves after the delay.
+--- @return Deferred A deferred object that resolves after the delay.
function delay(ms)
- --- @type Deferred
+ --- @type Deferred
local d = deferred.new()
if IsEmpty(ms) or ms <= 0 then
return d:resolve(nil)
@@ -512,16 +883,17 @@ end
--- Creates a deferred object that is immediately rejected with an error.
--- @generic F
---- @param error F The error to reject the deferred object with.
---- @return Deferred rejected The error to reject the deferred object with.
-function reject(error)
- return deferred.new():reject(error)
+--- @param err F The error to reject the deferred object with.
+--- @return Deferred rejected The rejected deferred object.
+function reject(err)
+ return deferred.new():reject(err)
end
--- Creates a deferred object that is immediately resolved with a value.
--- @generic T
---- @param value T The value to resolve the deferred object with.
---- @return Deferred resolved The value to resolve the deferred object with.
+--- @param value T|nil The value to resolve the deferred object with.
+--- @return Deferred resolved The resolved deferred object.
+--- @overload fun(value: T): Deferred
function resolve(value)
return deferred.new():resolve(value)
end
@@ -561,3 +933,159 @@ function to_hex(str)
return string.format("%02X ", string.byte(c))
end))
end
+
+--- Convert Fahrenheit to Celsius, rounded to 1 decimal place
+--- Overrides the vendor lib function which rounds to nearest 0.5
+--- @param f number Temperature in Fahrenheit
+--- @return number|nil Temperature in Celsius, or nil if input is not a number
+function f2c(f)
+ if type(f) ~= "number" then
+ return nil
+ end
+ local c = (f - 32) * (5 / 9)
+ return round(c, 1)
+end
+
+--- Convert Celsius to Fahrenheit, rounded to 1 decimal place
+--- Overrides the vendor lib function which rounds to nearest integer
+--- @param c number Temperature in Celsius
+--- @return number|nil Temperature in Fahrenheit, or nil if input is not a number
+function c2f(c)
+ if type(c) ~= "number" then
+ return nil
+ end
+ local f = (c * (9 / 5)) + 32
+ return round(f, 1)
+end
+
+--------------------------------------------------------------------------------
+-- Binary-safe serialization
+--------------------------------------------------------------------------------
+
+--- Marker key for base64-encoded binary strings.
+--- Using an unlikely key to avoid collisions with real data.
+local BINARY_MARKER = "__b64"
+
+--- Sentinel value for nil (since Lua tables can't store nil values).
+local NIL_SENTINEL = "__null__"
+
+--- Check if a byte is binary (unsafe for transport).
+--- Safe: 0x09 (tab), 0x0A (LF), 0x0D (CR), 0x20-0x7E (printable ASCII)
+--- @param b number The byte value
+--- @return boolean isBinary True if the byte is binary/unsafe
+local function isBinaryByte(b)
+ if b <= 8 then
+ return true
+ end -- 0x00-0x08
+ if b == 11 or b == 12 then
+ return true
+ end -- 0x0B, 0x0C
+ if b >= 14 and b <= 31 then
+ return true
+ end -- 0x0E-0x1F
+ if b >= 127 then
+ return true
+ end -- 0x7F-0xFF
+ return false
+end
+
+--- Check if a string contains binary data that needs encoding.
+--- Catches null bytes (truncated by C4 proxy), control chars, and high bytes.
+--- @param s string The string to check
+--- @return boolean needsEncoding True if the string contains binary data
+local function needsBase64(s)
+ for i = 1, #s do
+ if isBinaryByte(string.byte(s, i)) then
+ return true
+ end
+ end
+ return false
+end
+
+--- Recursively encode binary strings in a table for safe JSON serialization.
+--- Strings containing binary data are wrapped as {__b64 = "base64data"}.
+--- nil values are converted to a sentinel string.
+--- @param value any The value to process
+--- @return any encoded The processed value with binary strings wrapped
+local function encodeBinaryStrings(value)
+ if value == nil then
+ return NIL_SENTINEL
+ end
+ local t = type(value)
+ if t == "string" then
+ -- Encode if binary OR if it equals the sentinel (to avoid collision)
+ if needsBase64(value) or value == NIL_SENTINEL then
+ return { [BINARY_MARKER] = C4:Base64Encode(value) }
+ end
+ return value
+ elseif t == "table" then
+ local result = {}
+ for k, v in pairs(value) do
+ result[k] = encodeBinaryStrings(v)
+ end
+ return result
+ else
+ return value
+ end
+end
+
+--- Recursively decode binary strings in a table after JSON deserialization.
+--- Unwraps {__b64 = "base64data"} back to original binary strings.
+--- Converts sentinel values back to nil.
+--- @param value any The value to process
+--- @return any decoded The processed value with binary strings unwrapped
+local function decodeBinaryStrings(value)
+ if value == NIL_SENTINEL then
+ return nil
+ end
+ if type(value) ~= "table" then
+ return value
+ end
+ -- Check if this is a binary marker wrapper
+ local b64 = value[BINARY_MARKER]
+ if b64 ~= nil and type(b64) == "string" then
+ -- This is a wrapped binary string, decode it
+ return C4:Base64Decode(b64)
+ end
+ -- Regular table, recurse into children
+ local result = {}
+ for k, v in pairs(value) do
+ result[k] = decodeBinaryStrings(v)
+ end
+ return result
+end
+
+--- Wrapper key for serialized values.
+--- Using a short key to minimize overhead.
+local WRAPPER_KEY = "__v"
+
+--- Binary-safe serialization for any value.
+--- Wraps the value in a container, encodes binary strings, then JSON + base64 encodes.
+--- Handles tables, strings (binary or plain), numbers, booleans, and nil uniformly.
+--- Use DeserializeSafe to decode.
+--- @param value any The value to serialize
+--- @return string serialized The serialized string
+function SerializeSafe(value)
+ local wrapped = { [WRAPPER_KEY] = encodeBinaryStrings(value) }
+ return C4:Base64Encode(JSON:encode(wrapped))
+end
+
+--- Binary-safe deserialization that reverses SerializeSafe.
+--- Detects and decodes values serialized by SerializeSafe.
+--- Returns the original value if it wasn't serialized by SerializeSafe.
+--- @param serialized any The serialized string from SerializeSafe
+--- @return any value The deserialized value with binary strings restored
+function DeserializeSafe(serialized)
+ if type(serialized) ~= "string" then
+ return serialized
+ end
+ local decoded = C4:Base64Decode(serialized)
+ if decoded == "" then
+ return serialized -- invalid base64, not ours
+ end
+ local success, wrapped = pcall(JSON.decode, JSON, decoded)
+ if not success or type(wrapped) ~= "table" or wrapped[WRAPPER_KEY] == nil then
+ return serialized -- not ours
+ end
+ return decodeBinaryStrings(wrapped[WRAPPER_KEY])
+end
diff --git a/src/lib/values.lua b/src/lib/values.lua
index db964bf..25c2c7a 100644
--- a/src/lib/values.lua
+++ b/src/lib/values.lua
@@ -1,4 +1,3 @@
---- @module "lib.values"
--- Values module for managing dynamic values with variable and property support.
local log = require("lib.logging")
@@ -8,6 +7,7 @@ local constants = require("constants")
--- @class Values
--- A class representing a collection of named values with optional variable/property support.
local Values = {}
+Values.__index = Values
--- Persistent storage key for values.
--- @type string
@@ -21,37 +21,59 @@ end
--- @class Value
--- @field index integer Index used for ordering values during restore.
--- @field varType VariableType? Optional variable type if registered as a variable
---- @field value string|integer|number|nil The stored value
+--- @field value string|integer|number|boolean|nil The stored value
+--- @field suffix string? Optional suffix for property display (e.g., " °C", " %")
+--- @field deleted boolean? If true, the value slot is reserved but the variable is hidden (preserves ID ordering)
--- Creates a new Values instance.
--- @return Values values A new Values instance.
function Values:new()
log:trace("Values:new()")
- local properties = {}
- setmetatable(properties, self)
- self.__index = self
- --- @cast properties Values
- return properties
+ local instance = setmetatable({}, self)
+ return instance
end
--- Updates a value. If the value does not exist, it will be created. If the
--- `name` is also a property, it will also be updated.
--- @param name string The name of the value to update or create. Must be globally unique.
---- @param value string|integer|number|nil The value to set, can be `nil`.
+--- @param value string|integer|number|boolean|nil The value to set, can be `nil`.
--- @param varType VariableType? The type of the variable, if `nil` it will not be registered as a variable.
--- @param varChangedCallback (fun(newValue: string|integer|number): void)? The callback function to be called when the variable changes.
---- @return void
-function Values:update(name, value, varType, varChangedCallback)
- log:trace("Values:update(%s, %s, %s, %s)", name, value, varType, varChangedCallback)
+--- @param propertySuffix string? Optional suffix to append to the property value (e.g., "°C" for temperature units).
+--- @return boolean changed True if the value changed, false otherwise.
+function Values:update(name, value, varType, varChangedCallback, propertySuffix)
+ log:trace("Values:update(%s, %s, %s, %s, %s)", name, value, varType, varChangedCallback, propertySuffix)
+
+ -- Convert value to appropriate type based on varType
+ if varType == "BOOL" then
+ value = toboolean(value)
+ elseif varType == "DEVICE" or varType == "INT" or varType == "ROOM" then
+ value = tointeger(value)
+ elseif varType == "FLOAT" or varType == "NUMBER" then
+ value = tonumber(value)
+ else
+ value = tostring(value)
+ end
+
local values = self:getValues()
- values[name] = {
- index = Select(values, name, "index") or self:_getNextValueId(),
- varType = varType,
- value = value,
- }
- self:_saveValues(values)
+ local existing = values[name]
- local strValue = tostring(value or "")
+ -- Check if the entry has changed
+ local changed = not existing
+ or existing.value ~= value
+ or existing.suffix ~= propertySuffix
+ or existing.varType ~= varType
+ if changed then
+ values[name] = {
+ index = Select(values, name, "index") or self:_getNextValueId(),
+ varType = varType,
+ value = value,
+ suffix = propertySuffix,
+ }
+ self:_saveValues(values)
+ end
+
+ local strValue = value == nil and "" or tostring(value)
if varType ~= nil then
-- Register an OVC handler for this variable if a callback is provided
@@ -71,14 +93,27 @@ function Values:update(name, value, varType, varChangedCallback)
C4:DeleteVariable(name)
Variables[name] = nil
end
- if Properties[name] ~= nil and Properties[name] ~= strValue then
- UpdateProperty(name, strValue, true)
+
+ if Properties[name] ~= nil then
-- Ensure the property is visible
C4:SetPropertyAttribs(name, constants.SHOW_PROPERTY)
+
+ -- Format property value with optional suffix
+ local propValue = strValue
+ if propertySuffix and strValue ~= "" then
+ propValue = strValue .. propertySuffix
+ end
+ if Properties[name] ~= propValue then
+ UpdateProperty(name, propValue, true)
+ end
end
+
+ return changed
end
---- Deletes a value and removes associated variable/property.
+--- Deletes a value. The value is marked as deleted to preserve its index slot
+--- for variable ID ordering. On next restore, a hidden placeholder will be created.
+--- Trailing deleted values are trimmed since they don't affect subsequent IDs.
--- @param name string The name of the value to delete.
--- @return void
function Values:delete(name)
@@ -89,12 +124,19 @@ function Values:delete(name)
return
end
- log:debug("Deleting value %s", name)
- values[name] = nil
+ log:debug("Deleting value %s at index %d", name, values[name].index)
+
+ -- Mark as deleted to preserve the index slot for variable ID ordering
+ values[name].deleted = true
+ values[name].value = nil
+
+ -- Trim trailing deleted values (they don't need placeholders)
+ values = self:_trimDeletedTail(values)
self:_saveValues(values)
+ -- Remove the OVC handler and delete the variable
+ OVC[ovcKey(name)] = nil
if Variables[name] ~= nil then
- OVC[ovcKey(name)] = nil
C4:DeleteVariable(name)
Variables[name] = nil
end
@@ -108,6 +150,7 @@ end
--- Retrieves all values from persistent storage.
--- @return table values A table of all values mapped by their name.
+--- @diagnostic disable-next-line: unused
function Values:getValues()
log:trace("Values:getValues()")
return persist:get(VALUES_PERSIST_KEY, {}) or {}
@@ -123,44 +166,105 @@ end
--- Restores all values from persistent storage. Ensures that all
--- values are re-added in a consistent order based on their index.
+--- Deleted values are restored as hidden placeholders to preserve
+--- variable ID ordering for subsequent variables.
--- @return void
function Values:restoreValues()
log:trace("Values:restoreValues()")
local values = self:getValues()
- -- Sort by index so that the order is consistent, this is important to retain
- -- programming associated with any variables.
- table.sort(values, function(a, b)
- return a.index < b.index
- end)
+
+ -- Build sorted array with names (table.sort doesn't work on string-keyed tables)
+ local sorted = {}
for name, value in pairs(values) do
- log:debug("Restoring %s value %s", value.varType, name)
- self:update(name, value.value, value.varType, nil)
+ table.insert(sorted, { name = name, data = value })
+ end
+ table.sort(sorted, function(a, b)
+ return a.data.index < b.data.index
+ end)
+
+ -- Restore in index order to preserve variable IDs
+ for _, entry in ipairs(sorted) do
+ if entry.data.deleted then
+ -- Create a hidden placeholder variable to preserve the ID slot
+ log:debug("Restoring hidden placeholder for deleted value %s at index %d", entry.name, entry.data.index)
+ C4:AddVariable(entry.name, "", entry.data.varType or "STRING", true, true)
+ else
+ log:debug("Restoring %s value %s at index %d", entry.data.varType, entry.name, entry.data.index)
+ self:update(entry.name, entry.data.value, entry.data.varType, nil, entry.data.suffix)
+ end
end
end
--- Saves the values to persistent storage.
+--- @private
--- @param values table? The values table to save, nil clears storage.
---- @return void
+--- @diagnostic disable-next-line: unused
function Values:_saveValues(values)
log:trace("Values:_saveValues(%s)", values)
persist:set(VALUES_PERSIST_KEY, not IsEmpty(values) and values or nil)
end
---- Retrieves the next available value ID. Ensures that the ID is unique across all values.
+--- Retrieves the next available value ID. Always returns max(existing indices) + 1
+--- to avoid reusing indices from deleted values (which would break ID ordering).
+--- @private
--- @return number valueId The next available value ID starting from 1.
function Values:_getNextValueId()
log:trace("Values:_getNextValueId()")
local values = self:getValues()
- --- @type table
- local currentValues = {}
+ local maxIndex = 0
+ for _, value in pairs(values) do
+ if value.index > maxIndex then
+ maxIndex = value.index
+ end
+ end
+ return maxIndex + 1
+end
+
+--- Removes trailing deleted entries from the values table.
+--- Deleted entries at the end don't need placeholders since there are no
+--- subsequent variables whose IDs would be affected.
+--- @private
+--- @param values table The values table to trim.
+--- @return table The trimmed values table.
+--- @diagnostic disable-next-line: unused
+function Values:_trimDeletedTail(values)
+ -- Find the maximum index among non-deleted entries
+ local maxActiveIndex = 0
for _, value in pairs(values) do
- currentValues[value.index] = true
+ if not value.deleted and value.index > maxActiveIndex then
+ maxActiveIndex = value.index
+ end
end
- local index = 1
- while currentValues[index] ~= nil do
- index = index + 1
+
+ -- Remove all deleted entries with index > maxActiveIndex
+ local toRemove = {}
+ for name, value in pairs(values) do
+ if value.deleted and value.index > maxActiveIndex then
+ table.insert(toRemove, name)
+ end
+ end
+
+ for _, name in ipairs(toRemove) do
+ log:debug("Trimming deleted tail entry %s", name)
+ values[name] = nil
+ end
+
+ return values
+end
+
+--- Resets all values, removing variables from the system and clearing persisted storage.
+function Values:reset()
+ log:trace("Values:reset()")
+ for name, value in pairs(self:getValues()) do
+ log:debug("Removing value '%s'", name)
+ -- Delete the variable if it exists
+ if value.varType ~= nil and Variables[name] ~= nil then
+ OVC[ovcKey(name)] = nil
+ C4:DeleteVariable(name)
+ Variables[name] = nil
+ end
end
- return index
+ self:_saveValues(nil)
end
return Values:new()
diff --git a/src/vendor/deferred.lua b/src/vendor/deferred.lua
deleted file mode 100644
index c87e955..0000000
--- a/src/vendor/deferred.lua
+++ /dev/null
@@ -1,288 +0,0 @@
---- A+ promises in Lua.
---- @module "vendor.deferred"
-local M = {}
-
---- @class Deferred
---- @generic S,F,V
---- @field next fun(self: Deferred, success: (fun(value: S): V), failure: (fun(reason: F): V)?): Deferred A function for chaining promises, taking success and failure callbacks and returning a new Deferred object.
---- @field state number The current state of the promise (e.g., PENDING, RESOLVING, REJECTING, RESOLVED, REJECTED).
---- @field value S|F The resolved or rejected value of the promise.
---- @field queue table> A list of chained promises.
---- @field success fun(value: S)|nil The success callback function.
---- @field failure fun(reason: F)|nil The failure callback function.
-local Deferred = {}
-Deferred.__index = Deferred
-
---- Promise states
-Deferred.PENDING = 0
-Deferred.RESOLVING = 1
-Deferred.REJECTING = 2
-Deferred.RESOLVED = 3
-Deferred.REJECTED = 4
-
---- Finalizes the promise by resolving or rejecting it.
---- @generic S,F
---- @param deferred Deferred The deferred object.
---- @param state? number The final state of the promise (RESOLVED or REJECTED).
-local function finish(deferred, state)
- state = state or Deferred.REJECTED
- for _, f in ipairs(deferred.queue) do
- if state == Deferred.RESOLVED then
- --- @cast deferred.value S
- f:resolve(deferred.value)
- else
- --- @cast deferred.value F
- f:reject(deferred.value)
- end
- end
- deferred.state = state
-end
-
---- Checks if a value is a callable function or table with a `__call` metamethod.
---- @param f any The value to check.
---- @return boolean isFunction True if the value is callable, false otherwise.
-local function isfunction(f)
- if type(f) == "table" then
- local mt = getmetatable(f)
- return mt ~= nil and type(mt.__call) == "function"
- end
- return type(f) == "function"
-end
-
---- Handles promise chaining and resolution.
---- @generic S,V,F
---- @param deferred Deferred The deferred object.
---- @param next fun(self: Deferred, success: (fun(value: S): V), failure: (fun(reason: F): V)?) The next function in the chain.
---- @param success function The success callback.
---- @param failure function The failure callback.
---- @param nonpromisecb function The callback for non-promise values.
-local function promise(deferred, next, success, failure, nonpromisecb)
- if type(deferred) == "table" and type(deferred.value) == "table" and isfunction(next) then
- local called = false
- local ok, err = pcall(next, deferred.value, function(v)
- if called then
- return
- end
- called = true
- deferred.value = v
- success()
- end, function(v)
- if called then
- return
- end
- called = true
- deferred.value = v
- failure()
- end)
- if not ok and not called then
- deferred.value = err
- failure()
- end
- else
- nonpromisecb()
- end
-end
-
---- Fires the promise resolution or rejection process.
---- @generic S,F
---- @param deferred Deferred The deferred object.
-local function fire(deferred)
- local next
- if type(deferred.value) == "table" then
- next = deferred.value.next
- end
- promise(deferred, next, function()
- deferred.state = Deferred.RESOLVING
- fire(deferred)
- end, function()
- deferred.state = Deferred.REJECTING
- fire(deferred)
- end, function()
- local ok, v
- if deferred.state == Deferred.RESOLVING and deferred.success ~= nil and isfunction(deferred.success) then
- --- @cast deferred.value S
- ok, v = pcall(deferred.success, deferred.value)
- elseif deferred.state == Deferred.REJECTING and deferred.failure ~= nil and isfunction(deferred.failure) then
- --- @cast deferred.value F
- ok, v = pcall(deferred.failure, deferred.value)
- if ok then
- deferred.state = Deferred.RESOLVING
- end
- end
-
- if ok ~= nil then
- if ok then
- deferred.value = v
- else
- deferred.value = v
- return finish(deferred)
- end
- end
-
- if deferred.value == deferred then
- deferred.value = pcall(error, "resolving promise with itself")
- return finish(deferred)
- else
- promise(deferred, next, function()
- finish(deferred, Deferred.RESOLVED)
- end, function(state)
- finish(deferred, state)
- end, function()
- finish(deferred, deferred.state == Deferred.RESOLVING and Deferred.RESOLVED or Deferred.REJECTED)
- end)
- end
- end)
-end
-
---- Resolves or rejects the promise.
---- @generic S,F
---- @param deferred Deferred The deferred object.
---- @param state number The state to resolve or reject to.
---- @param value S|F The value to resolve or reject with.
---- @return Deferred deferred The deferred object.
-local function resolve(deferred, state, value)
- if deferred.state == Deferred.PENDING then
- deferred.value = value
- deferred.state = state
- fire(deferred)
- end
- return deferred
-end
-
---- Resolves the promise with a value.
---- @generic S,F
---- @param value S The value to resolve with.
---- @return Deferred deferred The deferred object.
-function Deferred:resolve(value)
- return resolve(self, Deferred.RESOLVING, value)
-end
-
---- Rejects the promise with a value.
---- @generic S,F
---- @param value F The value to reject with.
---- @return Deferred deferred The deferred object.
-function Deferred:reject(value)
- return resolve(self, Deferred.REJECTING, value)
-end
-
---- Creates a new deferred object.
---- @generic S,F
---- @param options? table Optional configuration for the deferred object.
---- @return Deferred deferred A new deferred object.
-function M.new(options)
- options = options or {}
- local d
- d = {
- next = function(_, success, failure)
- local next = M.new({ success = success, failure = failure, extend = options.extend })
- if d.state == Deferred.RESOLVED then
- next:resolve(d.value)
- elseif d.state == Deferred.REJECTED then
- next:reject(d.value)
- else
- table.insert(d.queue, next)
- end
- return next
- end,
- state = Deferred.PENDING,
- value = nil,
- queue = {},
- success = options.success,
- failure = options.failure,
- }
- d = setmetatable(d, Deferred)
- if isfunction(options.extend) then
- options.extend(d)
- end
- return d
-end
-
---- Resolves when all promises in the list are resolved or rejected.
---- @generic S,F
---- @param args Deferred