From d40d5ffa0c99cbcc19c3e12d55c6441daab1ac39 Mon Sep 17 00:00:00 2001 From: Derek Miller Date: Mon, 14 Jul 2025 13:57:17 -0500 Subject: [PATCH 01/37] v20250714.1: Fix bug causing entities to not be discovered on connect (#3) --- src/esphome/client.lua | 17 +++++++++-------- src/lib/github-updater.lua | 10 +++++++++- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/esphome/client.lua b/src/esphome/client.lua index 998e1e8..ed03e13 100644 --- a/src/esphome/client.lua +++ b/src/esphome/client.lua @@ -206,9 +206,9 @@ function ESPHomeClient:connect() self._connected = true ---@type Deferred - local d + local dConnect if not IsEmpty(self._encryptionKey) then - d = self + dConnect = self :sendNoiseHello() :next(function() log:debug("Noise hello message sent successfully") @@ -225,14 +225,15 @@ 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() diff --git a/src/lib/github-updater.lua b/src/lib/github-updater.lua index 6908a9e..c061b7a 100644 --- a/src/lib/github-updater.lua +++ b/src/lib/github-updater.lua @@ -170,8 +170,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() From bce6715627b304ffe7363b4d15fa8f7cf1b23efe Mon Sep 17 00:00:00 2001 From: Derek Miller Date: Mon, 14 Jul 2025 14:19:40 -0500 Subject: [PATCH 02/37] v20250715: Change v20250714.1 version to v20250715 to be compatible with drivercentral (#4) --- CHANGELOG.md | 6 ++++++ README.md | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 014acb0..4e2bd55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ [//]: # "### Removed" [//]: # "- Removed" +## v20250715 - 2025-07-14 + +### Fixed + +- Fixed bug causing entities to not be discovered on connect + ## v20250714 - 2025-07-14 ### Added diff --git a/README.md b/README.md index eee59ad..3d0d23a 100644 --- a/README.md +++ b/README.md @@ -394,6 +394,12 @@ Control4, you can file an issue on GitHub: # Changelog +## v20250715 - 2025-07-14 + +### Fixed + +- Fixed bug causing entities to not be discovered on connect + ## v20250714 - 2025-07-14 ### Added From 5025c28ed0138deb3d81e11828fc684d5bd5a07d Mon Sep 17 00:00:00 2001 From: Derek Miller Date: Mon, 11 Aug 2025 09:28:03 -0500 Subject: [PATCH 03/37] Fix incorrect binding ID assignment in SwitchEntity:discovered function (#5) --- CHANGELOG.md | 6 ++++++ README.md | 6 ++++++ src/esphome/entities/switch.lua | 5 +++-- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e2bd55..db5cd69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ [//]: # "### Removed" [//]: # "- Removed" +## v20250811 - 2025-08-11 + +### Fixed + +- Fixed switch entities not responding to bound relay proxies + ## v20250715 - 2025-07-14 ### Fixed diff --git a/README.md b/README.md index 3d0d23a..70c8ada 100644 --- a/README.md +++ b/README.md @@ -394,6 +394,12 @@ Control4, you can file an issue on GitHub: # Changelog +## v20250811 - 2025-08-11 + +### Fixed + +- Fixed switch entities not responding to bound relay proxies + ## v20250715 - 2025-07-14 ### Fixed diff --git a/src/esphome/entities/switch.lua b/src/esphome/entities/switch.lua index 63dfc80..314f422 100644 --- a/src/esphome/entities/switch.lua +++ b/src/esphome/entities/switch.lua @@ -27,8 +27,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) From ff8130c50f1d45ff72bab9a4c745193fb9076821 Mon Sep 17 00:00:00 2001 From: Derek Miller Date: Sun, 19 Oct 2025 14:00:43 -0500 Subject: [PATCH 04/37] v20251019: Add OpenSSL support for "Encryption Key" authentication mode and update dependencies (#7) --- CHANGELOG.md | 6 + README.md | 7 ++ drivers/esphome/driver.lua | 16 ++- drivers/esphome/driver.xml | 9 ++ package-lock.json | 16 +-- package.json | 4 +- src/esphome/client.lua | 6 +- src/esphome/entities/cover.lua | 2 +- src/vendor/noiseprotocol.lua | 216 ++++++++++++++++++++++----------- 9 files changed, 199 insertions(+), 83 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db5cd69..5728cb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ [//]: # "### Removed" [//]: # "- Removed" +## v20251019 - 2025-10-19 + +### Added + +- Added support for OpenSSL with "Encryption Key" authentication mode across all applicable algorithms + ## v20250811 - 2025-08-11 ### Fixed diff --git a/README.md b/README.md index 70c8ada..6d536bd 100644 --- a/README.md +++ b/README.md @@ -394,6 +394,13 @@ Control4, you can file an issue on GitHub: # Changelog +## v20251019 - 2025-10-19 + +### Added + +- Added support for OpenSSL with "Encryption Key" authentication mode + across all applicable algorithms + ## v20250811 - 2025-08-11 ### Fixed diff --git a/drivers/esphome/driver.lua b/drivers/esphome/driver.lua index a2a6656..14920c9 100644 --- a/drivers/esphome/driver.lua +++ b/drivers/esphome/driver.lua @@ -142,16 +142,19 @@ 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 Connect() end @@ -166,6 +169,11 @@ function OPC.Encryption_Key(propertyValue) Connect() end +function OPC.Use_OpenSSL(propertyValue) + log:trace("OPC.Use_OpenSSL('%s')", propertyValue) + Connect() +end + local function updateStatus(status) UpdateProperty("Driver Status", not IsEmpty(status) and status or "Unknown") end @@ -177,7 +185,13 @@ 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 diff --git a/drivers/esphome/driver.xml b/drivers/esphome/driver.xml index cdb87de..162a078 100644 --- a/drivers/esphome/driver.xml +++ b/drivers/esphome/driver.xml @@ -135,6 +135,15 @@ true + + Use OpenSSL + LIST + Yes + + Yes + No + + Device Info LABEL diff --git a/package-lock.json b/package-lock.json index 8eb1d2b..3b40628 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,10 +5,10 @@ "packages": { "": { "dependencies": { - "@johnnymorganz/stylua-bin": "^2.1.0", + "@johnnymorganz/stylua-bin": "^2.3.0", "electron-pdf": "^25.0.0", "markdown-styles": "^3.2.0", - "prettier": "^3.5.3" + "prettier": "^3.6.2" } }, "node_modules/@electron/get": { @@ -53,9 +53,9 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "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.0", + "resolved": "https://registry.npmjs.org/@johnnymorganz/stylua-bin/-/stylua-bin-2.3.0.tgz", + "integrity": "sha512-P8rq1pJ628IJ322kq8t5XjdMFRW/+PU5CUhFANhC4cKGN1uynvIq3jFFYGzUOzxOT/2MmpHJgH+o5egTCp+jCA==", "hasInstallScript": true, "license": "MPL-2.0", "dependencies": { @@ -2485,9 +2485,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.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" diff --git a/package.json b/package.json index 93790b6..a3349b0 100644 --- a/package.json +++ b/package.json @@ -30,9 +30,9 @@ "clean": "npm run clean:build && rm -rfv dist node_modules .venv" }, "dependencies": { - "@johnnymorganz/stylua-bin": "^2.1.0", + "@johnnymorganz/stylua-bin": "^2.3.0", "electron-pdf": "^25.0.0", "markdown-styles": "^3.2.0", - "prettier": "^3.5.3" + "prettier": "^3.6.2" } } diff --git a/src/esphome/client.lua b/src/esphome/client.lua index ed03e13..aab414a 100644 --- a/src/esphome/client.lua +++ b/src/esphome/client.lua @@ -93,7 +93,7 @@ function ESPHomeClient:new() 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 +125,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,6 +135,7 @@ 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 diff --git a/src/esphome/entities/cover.lua b/src/esphome/entities/cover.lua index a4279e1..b5b7760 100644 --- a/src/esphome/entities/cover.lua +++ b/src/esphome/entities/cover.lua @@ -124,7 +124,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, diff --git a/src/vendor/noiseprotocol.lua b/src/vendor/noiseprotocol.lua index 107423e..8586d1e 100644 --- a/src/vendor/noiseprotocol.lua +++ b/src/vendor/noiseprotocol.lua @@ -3,8 +3,7 @@ do package.preload["noiseprotocol.crypto"] = function(...) local arg = _G.arg --- @module "noiseprotocol.crypto" - - return { + local crypto = { -- Hash functions sha256 = require("noiseprotocol.crypto.sha256"), sha512 = require("noiseprotocol.crypto.sha512"), @@ -24,6 +23,8 @@ do x25519 = require("noiseprotocol.crypto.x25519"), x448 = require("noiseprotocol.crypto.x448"), } + + return crypto end end @@ -33,6 +34,7 @@ do local arg = _G.arg --- @module "noiseprotocol.crypto.aes_gcm" --- AES-GCM Authenticated Encryption with Associated Data (AEAD) Implementation for portability. + local aes_gcm = {} local openssl_wrapper = require("noiseprotocol.openssl_wrapper") local utils = require("noiseprotocol.utils") @@ -40,8 +42,6 @@ do local bytes = utils.bytes local benchmark_op = utils.benchmark.benchmark_op - local aes_gcm = {} - -- ============================================================================ -- AES CORE IMPLEMENTATION -- ============================================================================ @@ -701,7 +701,7 @@ do aad = aad or "" - local openssl = openssl_wrapper.get() + local openssl = openssl_wrapper.get(openssl_wrapper.Feature.AAD) if openssl then local evp = openssl.cipher.get("aes-" .. #key * 8 .. "-gcm") local e = evp:encrypt_new() @@ -777,7 +777,7 @@ do local ciphertext = string.sub(ciphertext_and_tag, 1, ciphertext_len) local received_tag = string.sub(ciphertext_and_tag, ciphertext_len + 1) - local openssl = openssl_wrapper.get() + local openssl = openssl_wrapper.get(openssl_wrapper.Feature.AAD) if openssl then local evp = openssl.cipher.get("aes-" .. #key * 8 .. "-gcm") local e = evp:decrypt_new() @@ -1154,6 +1154,7 @@ do local arg = _G.arg --- @module "noiseprotocol.crypto.blake2" --- Pure Lua BLAKE2s and BLAKE2b Implementation for portability. + local blake2 = {} local openssl_wrapper = require("noiseprotocol.openssl_wrapper") local utils = require("noiseprotocol.utils") @@ -1162,8 +1163,6 @@ do local bytes = utils.bytes local benchmark_op = utils.benchmark.benchmark_op - local blake2 = {} - -- BLAKE2s initialization vectors (first 32 bits of fractional parts of square roots of first 8 primes) --- @type HashState local BLAKE2S_IV = { @@ -1949,6 +1948,7 @@ do local arg = _G.arg --- @module "noiseprotocol.crypto.chacha20" --- ChaCha20 Stream Cipher Implementation for portability. + local chacha20 = {} local openssl_wrapper = require("noiseprotocol.openssl_wrapper") local utils = require("noiseprotocol.utils") @@ -1956,8 +1956,6 @@ do local bytes = utils.bytes local benchmark_op = utils.benchmark.benchmark_op - local chacha20 = {} - -- Type definitions for better type checking --- 16-element array of 32-bit words @@ -2462,6 +2460,7 @@ do local arg = _G.arg --- @module "noiseprotocol.crypto.chacha20_poly1305" --- ChaCha20-Poly1305 Authenticated Encryption with Associated Data (AEAD) Implementation for portability. + local chacha20_poly1305 = {} local openssl_wrapper = require("noiseprotocol.openssl_wrapper") local utils = require("noiseprotocol.utils") @@ -2470,8 +2469,6 @@ do local chacha20 = require("noiseprotocol.crypto.chacha20") local poly1305 = require("noiseprotocol.crypto.poly1305") - local chacha20_poly1305 = {} - --- Generate Poly1305 one-time key using ChaCha20 --- @param key string 32-byte ChaCha20 key --- @param nonce string 12-byte nonce @@ -2523,7 +2520,7 @@ do aad = aad or "" - local openssl = openssl_wrapper.get() + local openssl = openssl_wrapper.get(openssl_wrapper.Feature.AAD) if openssl then local evp = openssl.cipher.get("chacha20-poly1305") local e = evp:encrypt_new() @@ -2585,7 +2582,7 @@ do local ciphertext = string.sub(ciphertext_and_tag, 1, ciphertext_len) local received_tag = string.sub(ciphertext_and_tag, ciphertext_len + 1) - local openssl = openssl_wrapper.get() + local openssl = openssl_wrapper.get(openssl_wrapper.Feature.AAD) if openssl then local evp = openssl.cipher.get("chacha20-poly1305") local e = evp:decrypt_new() @@ -2927,14 +2924,13 @@ do local arg = _G.arg --- @module "noiseprotocol.crypto.poly1305" --- Poly1305 Message Authentication Code (MAC) Implementation for portability. + local poly1305 = {} local utils = require("noiseprotocol.utils") local bit32 = utils.bit32 local bytes = utils.bytes local benchmark_op = utils.benchmark.benchmark_op - local poly1305 = {} - -- Type definitions for better type checking --- 17-element limb array for 130-bit + overflow @@ -3469,6 +3465,7 @@ do local arg = _G.arg --- @module "noiseprotocol.crypto.sha256" --- Pure Lua SHA-256 Implementation for portability. + local sha256 = {} local openssl_wrapper = require("noiseprotocol.openssl_wrapper") local utils = require("noiseprotocol.utils") @@ -3476,8 +3473,6 @@ do local bytes = utils.bytes local benchmark_op = utils.benchmark.benchmark_op - local sha256 = {} - -- SHA-256 constants (first 32 bits of fractional parts of cube roots of first 64 primes) --- @type integer[64] local K = { @@ -3957,6 +3952,7 @@ do local arg = _G.arg --- @module "noiseprotocol.crypto.sha512" --- Pure Lua SHA-512 Implementation for portability. + local sha512 = {} local openssl_wrapper = require("noiseprotocol.openssl_wrapper") local utils = require("noiseprotocol.utils") @@ -3967,7 +3963,6 @@ do -- SHA-512 uses 64-bit words, but Lua numbers are limited to 2^53-1 -- We'll work with 32-bit high/low pairs for 64-bit arithmetic - local sha512 = {} -- SHA-512 round constants (first 64 bits of fractional parts of cube roots of first 80 primes) --- @type Int64HighLow[] @@ -4497,7 +4492,6 @@ do local arg = _G.arg --- @module "noiseprotocol.crypto.x25519" --- X25519 Curve25519 Elliptic Curve Diffie-Hellman Implementation for portability. - local x25519 = {} local utils = require("noiseprotocol.utils") @@ -4983,6 +4977,7 @@ do --- - Field arithmetic modulo p = 2^448 - 2^224 - 1 --- - Scalar multiplication on Curve448 --- - Key generation and Diffie-Hellman operations + local x448 = {} local utils = require("noiseprotocol.utils") local bytes = utils.bytes @@ -4995,8 +4990,6 @@ do local char = string.char local byte = string.byte - local x448 = {} - -- Constants for X448 implementation -- Field prime p = 2^448 - 2^224 - 1 (Goldilocks prime) -- We use 56 limbs of 8 bits each (56 * 8 = 448 bits) @@ -5681,10 +5674,38 @@ do --- --- Note: X25519 and X448 currently use native implementations only as they are --- not currently supported by lua-openssl. - local openssl_wrapper = {} + --- OpenSSL Feature Enum + --- + --- Identifies specific OpenSSL capabilities required by crypto operations. + --- Use these features with `openssl_wrapper.get()` to check if the installed + --- OpenSSL version supports the functionality needed. + --- + --- @enum OpenSSLFeature + local OpenSSLFeature = { + --- Additional Authenticated Data support for AEAD ciphers (ChaCha20-Poly1305, AES-GCM) + AAD = "AAD", + } + + --- Feature version requirements mapping + --- + --- Defines the minimum OpenSSL version required for each feature to work correctly. + --- Used internally by `get()` to determine feature availability based on + --- the installed OpenSSL version. + --- + --- @type table + local FeatureVersions = { + [OpenSSLFeature.AAD] = "0.9.2", + } + + -- Export Feature enum for external use + openssl_wrapper.Feature = OpenSSLFeature + + --- @type table? local _openssl_module + --- @type table + local _openssl_module_features = {} local _use_openssl = os.getenv("NOISE_USE_OPENSSL") == "1" or os.getenv("NOISE_USE_OPENSSL") == "true" --- Enable or disable OpenSSL acceleration for cryptographic operations @@ -5693,10 +5714,50 @@ do _use_openssl = use end - --- Get the cached OpenSSL module if enabled and available - --- @return table|nil openssl The OpenSSL module or nil if not enabled/available + --- Parse semantic version string into comparable components + --- @param version_str string Version string like "0.9.1" or "1.0.0-rc1" + --- @return number major Major version number + --- @return number minor Minor version number + --- @return number patch Patch version number + local function parse_version(version_str) + local major, minor, patch = version_str:match("(%d+)%.(%d+)%.(%d+)") + return tonumber(major) or 0, tonumber(minor) or 0, tonumber(patch) or 0 + end + + --- Compare two semantic versions + --- @param current_version string Current version string + --- @param required_version string Required minimum version string + --- @return boolean supported True if current version >= required version + local function version_supports(current_version, required_version) + local cur_major, cur_minor, cur_patch = parse_version(current_version) + local req_major, req_minor, req_patch = parse_version(required_version) + + -- Compare major.minor.patch + if cur_major > req_major then + return true + elseif cur_major == req_major then + if cur_minor > req_minor then + return true + elseif cur_minor == req_minor then + return cur_patch >= req_patch + end + end + + return false + end + + --- Get the OpenSSL module if enabled and supports required features + --- + --- Checks if OpenSSL is enabled and supports all specified features before + --- returning the module. This ensures that the returned module can safely + --- be used for the requested cryptographic operations. + --- + --- @param ... OpenSSLFeature One or more required features that must be supported + --- @return table|nil openssl The OpenSSL module if available and supports all features, nil otherwise --- @throws error If OpenSSL is enabled but the module cannot be loaded - function openssl_wrapper.get() + function openssl_wrapper.get(...) + local required_features = { ... } + if not _use_openssl then _openssl_module = nil elseif _openssl_module == nil then @@ -5706,6 +5767,25 @@ do end --- @cast openssl_module table _openssl_module = openssl_module + _openssl_module_features = {} + local current_version = type(_openssl_module.version) == "function" and _openssl_module.version() + if current_version then + -- Cache all supported features + for _, feature in ipairs(OpenSSLFeature) do + local required_version = FeatureVersions[feature] + + if not required_version then + error("Unknown feature: " .. tostring(feature)) + end + _openssl_module_features[feature] = version_supports(current_version, required_version) + end + end + end + -- Check all requested features + for _, required_feature in ipairs(required_features) do + if not _openssl_module_features[required_feature] then + return nil + end end return _openssl_module end @@ -5720,13 +5800,14 @@ do local arg = _G.arg --- @module "noiseprotocol.utils" --- Common utility functions for the Noise Protocol Framework - - return { + local utils = { bit32 = require("noiseprotocol.utils.bit32"), bit64 = require("noiseprotocol.utils.bit64"), bytes = require("noiseprotocol.utils.bytes"), benchmark = require("noiseprotocol.utils.benchmark"), } + + return utils end end @@ -5736,7 +5817,6 @@ do local arg = _G.arg --- @module "noiseprotocol.utils.benchmark" --- Common benchmarking utilities for performance testing - local benchmark = {} --- Run a benchmarked operation with warmup and timing @@ -5777,7 +5857,6 @@ do local arg = _G.arg --- @module "noiseprotocol.utils.bit32" --- 32-bit bitwise operations - local bit32 = {} -- 32-bit mask for ensuring results stay within 32-bit range @@ -6230,11 +6309,10 @@ do local arg = _G.arg --- @module "noiseprotocol.utils.bit64" --- 64-bit bitwise operations using high/low pairs + local bit64 = {} local bit32 = require("noiseprotocol.utils.bit32") - local bit64 = {} - -- Type definitions --- @alias Int64HighLow [integer, integer] Array with [1]=high 32 bits, [2]=low 32 bits @@ -6594,11 +6672,10 @@ do local arg = _G.arg --- @module "noiseprotocol.utils.bytes" --- Byte manipulation and conversion utilities + local bytes = {} local bit32 = require("noiseprotocol.utils.bit32") - local bytes = {} - --- Convert binary string to hexadecimal string --- @param str string Binary string --- @return string hex Hexadecimal representation @@ -7290,28 +7367,29 @@ end --- static_key = my_static_key --- }) --- ... +local noiseprotocol = {} local crypto = require("noiseprotocol.crypto") local utils = require("noiseprotocol.utils") local openssl_wrapper = require("noiseprotocol.openssl_wrapper") --- Module version -local VERSION = "v0.1.0" - -local noise = { - --- Enable or disable OpenSSL acceleration - --- @function use_openssl - --- @param use boolean True to enable OpenSSL, false to disable - --- @see noiseprotocol.openssl_wrapper.use - use_openssl = openssl_wrapper.use, - - --- Get the module version - --- @function version - --- @return string version The version string - version = function() - return VERSION - end, -} +local VERSION = "v0.2.0" + +--- Enable or disable OpenSSL acceleration +--- @function use_openssl +--- @param use boolean True to enable OpenSSL, false to disable +--- @see noiseprotocol.openssl_wrapper.use +function noiseprotocol.use_openssl(use) + openssl_wrapper.use(use) +end + +--- Get the module version +--- @function version +--- @return string version The version string +function noiseprotocol.version() + return VERSION +end -- ============================================================================ -- PROTOCOL NAME PARSING @@ -8852,19 +8930,19 @@ function NoiseConnection:new(config) -- Get cipher suite components -- Map DH functions - local dh = noise.DH[parsed.dh] + local dh = noiseprotocol.DH[parsed.dh] if dh == nil then error("Unknown DH function: " .. parsed.dh) end -- Map cipher functions - local cipher = noise.Cipher[parsed.cipher] + local cipher = noiseprotocol.Cipher[parsed.cipher] if cipher == nil then error("Unknown cipher: " .. parsed.cipher) end -- Map hash functions - local hash = noise.Hash[parsed.hash] + local hash = noiseprotocol.Hash[parsed.hash] if hash == nil then error("Unknown hash: " .. parsed.hash) end @@ -8996,7 +9074,7 @@ end --- considered cryptographically safe. --- --- @return boolean result True if all tests pass, false otherwise -function noise.selftest() +function noiseprotocol.selftest() local function functional_tests() print("Running Noise Protocol functional tests...") local passed = 0 @@ -9279,7 +9357,7 @@ function noise.selftest() protocol_name = "Noise_XXpsk0_25519_ChaChaPoly_SHA256", initiator = true, psk = nil, -- No PSK provided - psk_placement = noise.PSKPlacement.ZERO, + psk_placement = noiseprotocol.PSKPlacement.ZERO, }) client_no_psk:start_handshake("test") client_no_psk:write_handshake_message("test") -- Should fail here due to missing PSK @@ -9294,7 +9372,7 @@ function noise.selftest() protocol_name = "Noise_NN_25519_ChaChaPoly_SHA256", initiator = true, psk = psk, - psk_placement = noise.PSKPlacement.ZERO, + psk_placement = noiseprotocol.PSKPlacement.ZERO, }) client_with_psk:start_handshake("test") client_with_psk:write_handshake_message("test") -- Should work with PSK @@ -9480,19 +9558,19 @@ function noise.selftest() end --- @type table -noise.DH = { +noiseprotocol.DH = { [DH_25519.name] = DH_25519, [DH_448.name] = DH_448, } --- @type table -noise.Cipher = { +noiseprotocol.Cipher = { [CIPHER_ChaChaPoly.name] = CIPHER_ChaChaPoly, [CIPHER_AESGCM.name] = CIPHER_AESGCM, } --- @type table -noise.Hash = { +noiseprotocol.Hash = { [HASH_SHA256.name] = HASH_SHA256, [HASH_SHA512.name] = HASH_SHA512, [HASH_BLAKE2S.name] = HASH_BLAKE2S, @@ -9500,16 +9578,16 @@ noise.Hash = { } -- Utility types -noise.CipherState = CipherState -noise.SymmetricState = SymmetricState -noise.HandshakeState = HandshakeState -noise.NoiseConnection = NoiseConnection -noise.CipherSuite = CipherSuite -noise.PSKPlacement = PSKPlacement -noise.NoisePattern = NoisePattern +noiseprotocol.CipherState = CipherState +noiseprotocol.SymmetricState = SymmetricState +noiseprotocol.HandshakeState = HandshakeState +noiseprotocol.NoiseConnection = NoiseConnection +noiseprotocol.CipherSuite = CipherSuite +noiseprotocol.PSKPlacement = PSKPlacement +noiseprotocol.NoisePattern = NoisePattern -- Export submodules for convenience -noise.crypto = crypto -noise.utils = utils +noiseprotocol.crypto = crypto +noiseprotocol.utils = utils -return noise +return noiseprotocol From 042770c0d5d92e4bf5a5342451f9569d14ea155c Mon Sep 17 00:00:00 2001 From: Derek Miller Date: Sun, 19 Oct 2025 14:53:41 -0500 Subject: [PATCH 05/37] v20251019: Update Protobuf schema and fix a bug with the authentication flow (#8) --- src/esphome/client.lua | 17 +- src/esphome/proto-schema.lua | 491 +++++++++++++++++++++++++---------- 2 files changed, 360 insertions(+), 148 deletions(-) diff --git a/src/esphome/client.lua b/src/esphome/client.lua index aab414a..c88be25 100644 --- a/src/esphome/client.lua +++ b/src/esphome/client.lua @@ -238,7 +238,12 @@ function ESPHomeClient:connect() end) :next(function() log:debug("Hello message sent successfully") - return self:sendConnect() + -- Only authenticate when using password authentication + if not IsEmpty(self._password) 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) @@ -552,12 +557,12 @@ 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()") +--- Send an authenticate message to the ESPHome device. +--- @return Deferred result A promise that resolves when the authenticate response is received. +function ESPHomeClient:sendAuthenticate() + log:trace("ESPHomeClient:sendAuthenticate()") local d = self - :callServiceMethod(ESPHomeProtoSchema.RPC.APIConnection.connect, { + :callServiceMethod(ESPHomeProtoSchema.RPC.APIConnection.authenticate, { password = not IsEmpty(self._password) and self._password or "", }, false) :next(function(message) diff --git a/src/esphome/proto-schema.lua b/src/esphome/proto-schema.lua index 2fccae4..eae51ae 100644 --- a/src/esphome/proto-schema.lua +++ b/src/esphome/proto-schema.lua @@ -226,6 +226,9 @@ PROTOBUF_SCHEMA.Enum = { 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 MediaPlayerCommand MediaPlayerCommand = { @@ -234,6 +237,15 @@ PROTOBUF_SCHEMA.Enum = { 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 MediaPlayerFormatPurpose MediaPlayerFormatPurpose = { @@ -341,9 +353,15 @@ PROTOBUF_SCHEMA.Enum = { UPDATE_COMMAND_UPDATE = 1, UPDATE_COMMAND_CHECK = 2, }, + --- @enum ZWaveProxyRequestType + ZWaveProxyRequestType = { + ZWAVE_PROXY_REQUEST_TYPE_SUBSCRIBE = 0, + ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE = 1, + ZWAVE_PROXY_REQUEST_TYPE_HOME_ID_CHANGE = 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 +--- @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|ZWaveProxyRequestType PROTOBUF_SCHEMA.Message = { void = { @@ -406,11 +424,12 @@ PROTOBUF_SCHEMA.Message = { }, }, }, - ConnectRequest = { - name = "ConnectRequest", + AuthenticationRequest = { + name = "AuthenticationRequest", options = { id = 3, source = 2, + ifdef = "USE_API_PASSWORD", no_delay = 1, }, fields = { @@ -421,11 +440,12 @@ PROTOBUF_SCHEMA.Message = { }, }, }, - ConnectResponse = { - name = "ConnectResponse", + AuthenticationResponse = { + name = "AuthenticationResponse", options = { id = 4, source = 1, + ifdef = "USE_API_PASSWORD", no_delay = 1, }, fields = { @@ -634,6 +654,16 @@ PROTOBUF_SCHEMA.Message = { wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED, type = PROTOBUF_SCHEMA.DataType.MESSAGE, }, + [23] = { + name = "zwave_proxy_feature_flags", + wireType = PROTOBUF_SCHEMA.WireType.VARINT, + type = PROTOBUF_SCHEMA.DataType.UINT32, + }, + [24] = { + name = "zwave_home_id", + wireType = PROTOBUF_SCHEMA.WireType.VARINT, + type = PROTOBUF_SCHEMA.DataType.UINT32, + }, }, }, ListEntitiesRequest = { @@ -685,11 +715,6 @@ PROTOBUF_SCHEMA.Message = { 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, @@ -778,11 +803,6 @@ PROTOBUF_SCHEMA.Message = { 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, @@ -879,6 +899,7 @@ PROTOBUF_SCHEMA.Message = { source = 2, ifdef = "USE_COVER", no_delay = 1, + base_class = "CommandProtoMessage", }, fields = { [1] = { @@ -921,6 +942,11 @@ PROTOBUF_SCHEMA.Message = { wireType = PROTOBUF_SCHEMA.WireType.VARINT, type = PROTOBUF_SCHEMA.DataType.BOOL, }, + [9] = { + name = "device_id", + wireType = PROTOBUF_SCHEMA.WireType.VARINT, + type = PROTOBUF_SCHEMA.DataType.UINT32, + }, }, }, ListEntitiesFanResponse = { @@ -947,11 +973,6 @@ PROTOBUF_SCHEMA.Message = { 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, @@ -1059,6 +1080,7 @@ PROTOBUF_SCHEMA.Message = { source = 2, ifdef = "USE_FAN", no_delay = 1, + base_class = "CommandProtoMessage", }, fields = { [1] = { @@ -1126,6 +1148,11 @@ PROTOBUF_SCHEMA.Message = { 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, + }, }, }, ListEntitiesLightResponse = { @@ -1152,11 +1179,6 @@ PROTOBUF_SCHEMA.Message = { 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, @@ -1310,6 +1332,7 @@ PROTOBUF_SCHEMA.Message = { source = 2, ifdef = "USE_LIGHT", no_delay = 1, + base_class = "CommandProtoMessage", }, fields = { [1] = { @@ -1447,6 +1470,11 @@ PROTOBUF_SCHEMA.Message = { wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED, type = PROTOBUF_SCHEMA.DataType.STRING, }, + [28] = { + name = "device_id", + wireType = PROTOBUF_SCHEMA.WireType.VARINT, + type = PROTOBUF_SCHEMA.DataType.UINT32, + }, }, }, ListEntitiesSensorResponse = { @@ -1473,11 +1501,6 @@ PROTOBUF_SCHEMA.Message = { 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, @@ -1586,11 +1609,6 @@ PROTOBUF_SCHEMA.Message = { 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, @@ -1657,6 +1675,7 @@ PROTOBUF_SCHEMA.Message = { source = 2, ifdef = "USE_SWITCH", no_delay = 1, + base_class = "CommandProtoMessage", }, fields = { [1] = { @@ -1669,6 +1688,11 @@ PROTOBUF_SCHEMA.Message = { wireType = PROTOBUF_SCHEMA.WireType.VARINT, type = PROTOBUF_SCHEMA.DataType.BOOL, }, + [3] = { + name = "device_id", + wireType = PROTOBUF_SCHEMA.WireType.VARINT, + type = PROTOBUF_SCHEMA.DataType.UINT32, + }, }, }, ListEntitiesTextSensorResponse = { @@ -1695,11 +1719,6 @@ PROTOBUF_SCHEMA.Message = { 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, @@ -1797,11 +1816,6 @@ PROTOBUF_SCHEMA.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 = { @@ -1839,6 +1853,7 @@ PROTOBUF_SCHEMA.Message = { options = { id = 34, source = 2, + ifdef = "USE_API_HOMEASSISTANT_SERVICES", }, fields = {}, }, @@ -1858,11 +1873,12 @@ PROTOBUF_SCHEMA.Message = { }, }, }, - HomeassistantServiceResponse = { - name = "HomeassistantServiceResponse", + HomeassistantActionRequest = { + name = "HomeassistantActionRequest", options = { id = 35, source = 1, + ifdef = "USE_API_HOMEASSISTANT_SERVICES", no_delay = 1, }, fields = { @@ -1894,6 +1910,52 @@ PROTOBUF_SCHEMA.Message = { wireType = PROTOBUF_SCHEMA.WireType.VARINT, type = PROTOBUF_SCHEMA.DataType.BOOL, }, + [6] = { + name = "call_id", + wireType = PROTOBUF_SCHEMA.WireType.VARINT, + type = PROTOBUF_SCHEMA.DataType.UINT32, + }, + [7] = { + name = "wants_response", + wireType = PROTOBUF_SCHEMA.WireType.VARINT, + type = PROTOBUF_SCHEMA.DataType.BOOL, + }, + [8] = { + name = "response_template", + wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED, + type = PROTOBUF_SCHEMA.DataType.STRING, + }, + }, + }, + HomeassistantActionResponse = { + name = "HomeassistantActionResponse", + options = { + id = 130, + source = 2, + ifdef = "USE_API_HOMEASSISTANT_ACTION_RESPONSES", + no_delay = 1, + }, + fields = { + [1] = { + name = "call_id", + wireType = PROTOBUF_SCHEMA.WireType.VARINT, + type = PROTOBUF_SCHEMA.DataType.UINT32, + }, + [2] = { + name = "success", + wireType = PROTOBUF_SCHEMA.WireType.VARINT, + type = PROTOBUF_SCHEMA.DataType.BOOL, + }, + [3] = { + name = "error_message", + wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED, + type = PROTOBUF_SCHEMA.DataType.STRING, + }, + [4] = { + name = "response_data", + wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED, + type = PROTOBUF_SCHEMA.DataType.BYTES, + }, }, }, SubscribeHomeAssistantStatesRequest = { @@ -1901,6 +1963,7 @@ PROTOBUF_SCHEMA.Message = { options = { id = 38, source = 2, + ifdef = "USE_API_HOMEASSISTANT_STATES", }, fields = {}, }, @@ -1909,6 +1972,7 @@ PROTOBUF_SCHEMA.Message = { options = { id = 39, source = 1, + ifdef = "USE_API_HOMEASSISTANT_STATES", }, fields = { [1] = { @@ -1933,6 +1997,7 @@ PROTOBUF_SCHEMA.Message = { options = { id = 40, source = 2, + ifdef = "USE_API_HOMEASSISTANT_STATES", no_delay = 1, }, fields = { @@ -1957,7 +2022,7 @@ PROTOBUF_SCHEMA.Message = { name = "GetTimeRequest", options = { id = 36, - source = 0, + source = 1, }, fields = {}, }, @@ -1965,7 +2030,7 @@ PROTOBUF_SCHEMA.Message = { name = "GetTimeResponse", options = { id = 37, - source = 0, + source = 2, no_delay = 1, }, fields = { @@ -1974,11 +2039,18 @@ PROTOBUF_SCHEMA.Message = { wireType = PROTOBUF_SCHEMA.WireType.FIXED32, type = PROTOBUF_SCHEMA.DataType.FIXED32, }, + [2] = { + name = "timezone", + wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED, + type = PROTOBUF_SCHEMA.DataType.STRING, + }, }, }, ListEntitiesServicesArgument = { name = "ListEntitiesServicesArgument", - options = {}, + options = { + ifdef = "USE_API_SERVICES", + }, fields = { [1] = { name = "name", @@ -1997,6 +2069,7 @@ PROTOBUF_SCHEMA.Message = { options = { id = 41, source = 1, + ifdef = "USE_API_SERVICES", }, fields = { [1] = { @@ -2019,7 +2092,9 @@ PROTOBUF_SCHEMA.Message = { }, ExecuteServiceArgument = { name = "ExecuteServiceArgument", - options = {}, + options = { + ifdef = "USE_API_SERVICES", + }, fields = { [1] = { name = "bool_", @@ -2077,6 +2152,7 @@ PROTOBUF_SCHEMA.Message = { options = { id = 42, source = 2, + ifdef = "USE_API_SERVICES", no_delay = 1, }, fields = { @@ -2117,11 +2193,6 @@ PROTOBUF_SCHEMA.Message = { 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, @@ -2150,6 +2221,7 @@ PROTOBUF_SCHEMA.Message = { id = 44, source = 1, ifdef = "USE_CAMERA", + base_class = "StateResponseProtoMessage", }, fields = { [1] = { @@ -2167,6 +2239,11 @@ PROTOBUF_SCHEMA.Message = { wireType = PROTOBUF_SCHEMA.WireType.VARINT, type = PROTOBUF_SCHEMA.DataType.BOOL, }, + [4] = { + name = "device_id", + wireType = PROTOBUF_SCHEMA.WireType.VARINT, + type = PROTOBUF_SCHEMA.DataType.UINT32, + }, }, }, CameraImageRequest = { @@ -2214,11 +2291,6 @@ PROTOBUF_SCHEMA.Message = { 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, @@ -2335,6 +2407,11 @@ PROTOBUF_SCHEMA.Message = { wireType = PROTOBUF_SCHEMA.WireType.VARINT, type = PROTOBUF_SCHEMA.DataType.UINT32, }, + [27] = { + name = "feature_flags", + wireType = PROTOBUF_SCHEMA.WireType.VARINT, + type = PROTOBUF_SCHEMA.DataType.UINT32, + }, }, }, ClimateStateResponse = { @@ -2436,6 +2513,7 @@ PROTOBUF_SCHEMA.Message = { source = 2, ifdef = "USE_CLIMATE", no_delay = 1, + base_class = "CommandProtoMessage", }, fields = { [1] = { @@ -2553,6 +2631,11 @@ PROTOBUF_SCHEMA.Message = { wireType = PROTOBUF_SCHEMA.WireType.FIXED32, type = PROTOBUF_SCHEMA.DataType.FLOAT, }, + [24] = { + name = "device_id", + wireType = PROTOBUF_SCHEMA.WireType.VARINT, + type = PROTOBUF_SCHEMA.DataType.UINT32, + }, }, }, ListEntitiesNumberResponse = { @@ -2579,11 +2662,6 @@ PROTOBUF_SCHEMA.Message = { 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, @@ -2675,6 +2753,7 @@ PROTOBUF_SCHEMA.Message = { source = 2, ifdef = "USE_NUMBER", no_delay = 1, + base_class = "CommandProtoMessage", }, fields = { [1] = { @@ -2687,6 +2766,11 @@ PROTOBUF_SCHEMA.Message = { wireType = PROTOBUF_SCHEMA.WireType.FIXED32, type = PROTOBUF_SCHEMA.DataType.FLOAT, }, + [3] = { + name = "device_id", + wireType = PROTOBUF_SCHEMA.WireType.VARINT, + type = PROTOBUF_SCHEMA.DataType.UINT32, + }, }, }, ListEntitiesSelectResponse = { @@ -2713,11 +2797,6 @@ PROTOBUF_SCHEMA.Message = { 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, @@ -2785,6 +2864,7 @@ PROTOBUF_SCHEMA.Message = { source = 2, ifdef = "USE_SELECT", no_delay = 1, + base_class = "CommandProtoMessage", }, fields = { [1] = { @@ -2797,6 +2877,11 @@ PROTOBUF_SCHEMA.Message = { 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, + }, }, }, ListEntitiesSirenResponse = { @@ -2823,11 +2908,6 @@ PROTOBUF_SCHEMA.Message = { 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, @@ -2900,6 +2980,7 @@ PROTOBUF_SCHEMA.Message = { source = 2, ifdef = "USE_SIREN", no_delay = 1, + base_class = "CommandProtoMessage", }, fields = { [1] = { @@ -2947,6 +3028,11 @@ PROTOBUF_SCHEMA.Message = { wireType = PROTOBUF_SCHEMA.WireType.FIXED32, type = PROTOBUF_SCHEMA.DataType.FLOAT, }, + [10] = { + name = "device_id", + wireType = PROTOBUF_SCHEMA.WireType.VARINT, + type = PROTOBUF_SCHEMA.DataType.UINT32, + }, }, }, ListEntitiesLockResponse = { @@ -2973,11 +3059,6 @@ PROTOBUF_SCHEMA.Message = { 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, @@ -3054,6 +3135,7 @@ PROTOBUF_SCHEMA.Message = { source = 2, ifdef = "USE_LOCK", no_delay = 1, + base_class = "CommandProtoMessage", }, fields = { [1] = { @@ -3076,6 +3158,11 @@ PROTOBUF_SCHEMA.Message = { wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED, type = PROTOBUF_SCHEMA.DataType.STRING, }, + [5] = { + name = "device_id", + wireType = PROTOBUF_SCHEMA.WireType.VARINT, + type = PROTOBUF_SCHEMA.DataType.UINT32, + }, }, }, ListEntitiesButtonResponse = { @@ -3102,11 +3189,6 @@ PROTOBUF_SCHEMA.Message = { 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, @@ -3141,6 +3223,7 @@ PROTOBUF_SCHEMA.Message = { source = 2, ifdef = "USE_BUTTON", no_delay = 1, + base_class = "CommandProtoMessage", }, fields = { [1] = { @@ -3148,6 +3231,11 @@ PROTOBUF_SCHEMA.Message = { wireType = PROTOBUF_SCHEMA.WireType.FIXED32, type = PROTOBUF_SCHEMA.DataType.FIXED32, }, + [2] = { + name = "device_id", + wireType = PROTOBUF_SCHEMA.WireType.VARINT, + type = PROTOBUF_SCHEMA.DataType.UINT32, + }, }, }, MediaPlayerSupportedFormat = { @@ -3207,11 +3295,6 @@ PROTOBUF_SCHEMA.Message = { 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, @@ -3243,6 +3326,11 @@ PROTOBUF_SCHEMA.Message = { wireType = PROTOBUF_SCHEMA.WireType.VARINT, type = PROTOBUF_SCHEMA.DataType.UINT32, }, + [11] = { + name = "feature_flags", + wireType = PROTOBUF_SCHEMA.WireType.VARINT, + type = PROTOBUF_SCHEMA.DataType.UINT32, + }, }, }, MediaPlayerStateResponse = { @@ -3289,6 +3377,7 @@ PROTOBUF_SCHEMA.Message = { source = 2, ifdef = "USE_MEDIA_PLAYER", no_delay = 1, + base_class = "CommandProtoMessage", }, fields = { [1] = { @@ -3336,6 +3425,11 @@ PROTOBUF_SCHEMA.Message = { wireType = PROTOBUF_SCHEMA.WireType.VARINT, type = PROTOBUF_SCHEMA.DataType.BOOL, }, + [10] = { + name = "device_id", + wireType = PROTOBUF_SCHEMA.WireType.VARINT, + type = PROTOBUF_SCHEMA.DataType.UINT32, + }, }, }, SubscribeBluetoothLEAdvertisementsRequest = { @@ -3557,6 +3651,11 @@ PROTOBUF_SCHEMA.Message = { wireType = PROTOBUF_SCHEMA.WireType.VARINT, type = PROTOBUF_SCHEMA.DataType.UINT32, }, + [3] = { + name = "short_uuid", + wireType = PROTOBUF_SCHEMA.WireType.VARINT, + type = PROTOBUF_SCHEMA.DataType.UINT32, + }, }, }, BluetoothGATTCharacteristic = { @@ -3585,6 +3684,11 @@ PROTOBUF_SCHEMA.Message = { type = PROTOBUF_SCHEMA.DataType.MESSAGE, repeated = true, }, + [5] = { + name = "short_uuid", + wireType = PROTOBUF_SCHEMA.WireType.VARINT, + type = PROTOBUF_SCHEMA.DataType.UINT32, + }, }, }, BluetoothGATTService = { @@ -3608,6 +3712,11 @@ PROTOBUF_SCHEMA.Message = { type = PROTOBUF_SCHEMA.DataType.MESSAGE, repeated = true, }, + [4] = { + name = "short_uuid", + wireType = PROTOBUF_SCHEMA.WireType.VARINT, + type = PROTOBUF_SCHEMA.DataType.UINT32, + }, }, }, BluetoothGATTGetServicesResponse = { @@ -4018,6 +4127,11 @@ PROTOBUF_SCHEMA.Message = { wireType = PROTOBUF_SCHEMA.WireType.VARINT, type = PROTOBUF_SCHEMA.DataType.ENUM, -- BluetoothScannerMode }, + [3] = { + name = "configured_mode", + wireType = PROTOBUF_SCHEMA.WireType.VARINT, + type = PROTOBUF_SCHEMA.DataType.ENUM, -- BluetoothScannerMode + }, }, }, BluetoothScannerSetModeRequest = { @@ -4295,6 +4409,48 @@ PROTOBUF_SCHEMA.Message = { }, }, }, + VoiceAssistantExternalWakeWord = { + name = "VoiceAssistantExternalWakeWord", + 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, + }, + [4] = { + name = "model_type", + wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED, + type = PROTOBUF_SCHEMA.DataType.STRING, + }, + [5] = { + name = "model_size", + wireType = PROTOBUF_SCHEMA.WireType.VARINT, + type = PROTOBUF_SCHEMA.DataType.UINT32, + }, + [6] = { + name = "model_hash", + wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED, + type = PROTOBUF_SCHEMA.DataType.STRING, + }, + [7] = { + name = "url", + wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED, + type = PROTOBUF_SCHEMA.DataType.STRING, + }, + }, + }, VoiceAssistantConfigurationRequest = { name = "VoiceAssistantConfigurationRequest", options = { @@ -4302,7 +4458,14 @@ PROTOBUF_SCHEMA.Message = { source = 2, ifdef = "USE_VOICE_ASSISTANT", }, - fields = {}, + fields = { + [1] = { + name = "external_wake_words", + wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED, + type = PROTOBUF_SCHEMA.DataType.MESSAGE, + repeated = true, + }, + }, }, VoiceAssistantConfigurationResponse = { name = "VoiceAssistantConfigurationResponse", @@ -4371,11 +4534,6 @@ PROTOBUF_SCHEMA.Message = { 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, @@ -4447,6 +4605,7 @@ PROTOBUF_SCHEMA.Message = { source = 2, ifdef = "USE_ALARM_CONTROL_PANEL", no_delay = 1, + base_class = "CommandProtoMessage", }, fields = { [1] = { @@ -4464,6 +4623,11 @@ PROTOBUF_SCHEMA.Message = { wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED, type = PROTOBUF_SCHEMA.DataType.STRING, }, + [4] = { + name = "device_id", + wireType = PROTOBUF_SCHEMA.WireType.VARINT, + type = PROTOBUF_SCHEMA.DataType.UINT32, + }, }, }, ListEntitiesTextResponse = { @@ -4490,11 +4654,6 @@ PROTOBUF_SCHEMA.Message = { 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, @@ -4576,6 +4735,7 @@ PROTOBUF_SCHEMA.Message = { source = 2, ifdef = "USE_TEXT", no_delay = 1, + base_class = "CommandProtoMessage", }, fields = { [1] = { @@ -4588,6 +4748,11 @@ PROTOBUF_SCHEMA.Message = { 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, + }, }, }, ListEntitiesDateResponse = { @@ -4614,11 +4779,6 @@ PROTOBUF_SCHEMA.Message = { 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, @@ -4690,6 +4850,7 @@ PROTOBUF_SCHEMA.Message = { source = 2, ifdef = "USE_DATETIME_DATE", no_delay = 1, + base_class = "CommandProtoMessage", }, fields = { [1] = { @@ -4712,6 +4873,11 @@ PROTOBUF_SCHEMA.Message = { wireType = PROTOBUF_SCHEMA.WireType.VARINT, type = PROTOBUF_SCHEMA.DataType.UINT32, }, + [5] = { + name = "device_id", + wireType = PROTOBUF_SCHEMA.WireType.VARINT, + type = PROTOBUF_SCHEMA.DataType.UINT32, + }, }, }, ListEntitiesTimeResponse = { @@ -4738,11 +4904,6 @@ PROTOBUF_SCHEMA.Message = { 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, @@ -4814,6 +4975,7 @@ PROTOBUF_SCHEMA.Message = { source = 2, ifdef = "USE_DATETIME_TIME", no_delay = 1, + base_class = "CommandProtoMessage", }, fields = { [1] = { @@ -4836,6 +4998,11 @@ PROTOBUF_SCHEMA.Message = { wireType = PROTOBUF_SCHEMA.WireType.VARINT, type = PROTOBUF_SCHEMA.DataType.UINT32, }, + [5] = { + name = "device_id", + wireType = PROTOBUF_SCHEMA.WireType.VARINT, + type = PROTOBUF_SCHEMA.DataType.UINT32, + }, }, }, ListEntitiesEventResponse = { @@ -4862,11 +5029,6 @@ PROTOBUF_SCHEMA.Message = { 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, @@ -4950,11 +5112,6 @@ PROTOBUF_SCHEMA.Message = { 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, @@ -5036,6 +5193,7 @@ PROTOBUF_SCHEMA.Message = { source = 2, ifdef = "USE_VALVE", no_delay = 1, + base_class = "CommandProtoMessage", }, fields = { [1] = { @@ -5058,6 +5216,11 @@ PROTOBUF_SCHEMA.Message = { wireType = PROTOBUF_SCHEMA.WireType.VARINT, type = PROTOBUF_SCHEMA.DataType.BOOL, }, + [5] = { + name = "device_id", + wireType = PROTOBUF_SCHEMA.WireType.VARINT, + type = PROTOBUF_SCHEMA.DataType.UINT32, + }, }, }, ListEntitiesDateTimeResponse = { @@ -5084,11 +5247,6 @@ PROTOBUF_SCHEMA.Message = { 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, @@ -5150,6 +5308,7 @@ PROTOBUF_SCHEMA.Message = { source = 2, ifdef = "USE_DATETIME_DATETIME", no_delay = 1, + base_class = "CommandProtoMessage", }, fields = { [1] = { @@ -5162,6 +5321,11 @@ PROTOBUF_SCHEMA.Message = { wireType = PROTOBUF_SCHEMA.WireType.FIXED32, type = PROTOBUF_SCHEMA.DataType.FIXED32, }, + [3] = { + name = "device_id", + wireType = PROTOBUF_SCHEMA.WireType.VARINT, + type = PROTOBUF_SCHEMA.DataType.UINT32, + }, }, }, ListEntitiesUpdateResponse = { @@ -5188,11 +5352,6 @@ PROTOBUF_SCHEMA.Message = { 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, @@ -5294,6 +5453,7 @@ PROTOBUF_SCHEMA.Message = { source = 2, ifdef = "USE_UPDATE", no_delay = 1, + base_class = "CommandProtoMessage", }, fields = { [1] = { @@ -5306,6 +5466,47 @@ PROTOBUF_SCHEMA.Message = { wireType = PROTOBUF_SCHEMA.WireType.VARINT, type = PROTOBUF_SCHEMA.DataType.ENUM, -- UpdateCommand }, + [3] = { + name = "device_id", + wireType = PROTOBUF_SCHEMA.WireType.VARINT, + type = PROTOBUF_SCHEMA.DataType.UINT32, + }, + }, + }, + ZWaveProxyFrame = { + name = "ZWaveProxyFrame", + options = { + id = 128, + source = 0, + ifdef = "USE_ZWAVE_PROXY", + no_delay = 1, + }, + fields = { + [1] = { + name = "data", + wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED, + type = PROTOBUF_SCHEMA.DataType.BYTES, + }, + }, + }, + ZWaveProxyRequest = { + name = "ZWaveProxyRequest", + options = { + id = 129, + source = 0, + ifdef = "USE_ZWAVE_PROXY", + }, + fields = { + [1] = { + name = "type", + wireType = PROTOBUF_SCHEMA.WireType.VARINT, + type = PROTOBUF_SCHEMA.DataType.ENUM, -- ZWaveProxyRequestType + }, + [2] = { + name = "data", + wireType = PROTOBUF_SCHEMA.WireType.LENGTH_DELIMITED, + type = PROTOBUF_SCHEMA.DataType.BYTES, + }, }, }, } @@ -5318,11 +5519,11 @@ PROTOBUF_SCHEMA.RPC = { inputType = PROTOBUF_SCHEMA.Message.HelloRequest, outputType = PROTOBUF_SCHEMA.Message.HelloResponse, }, - connect = { + authenticate = { service = "APIConnection", - method = "connect", - inputType = PROTOBUF_SCHEMA.Message.ConnectRequest, - outputType = PROTOBUF_SCHEMA.Message.ConnectResponse, + method = "authenticate", + inputType = PROTOBUF_SCHEMA.Message.AuthenticationRequest, + outputType = PROTOBUF_SCHEMA.Message.AuthenticationResponse, }, disconnect = { service = "APIConnection", @@ -5372,12 +5573,6 @@ PROTOBUF_SCHEMA.RPC = { 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", @@ -5588,6 +5783,18 @@ PROTOBUF_SCHEMA.RPC = { inputType = PROTOBUF_SCHEMA.Message.AlarmControlPanelCommandRequest, outputType = PROTOBUF_SCHEMA.Message.void, }, + zwave_proxy_frame = { + service = "APIConnection", + method = "zwave_proxy_frame", + inputType = PROTOBUF_SCHEMA.Message.ZWaveProxyFrame, + outputType = PROTOBUF_SCHEMA.Message.void, + }, + zwave_proxy_request = { + service = "APIConnection", + method = "zwave_proxy_request", + inputType = PROTOBUF_SCHEMA.Message.ZWaveProxyRequest, + outputType = PROTOBUF_SCHEMA.Message.void, + }, }, } From 00101d1b8354beed48b54fc9088a60103d5f5937 Mon Sep 17 00:00:00 2001 From: Derek Miller Date: Sun, 19 Oct 2025 15:00:44 -0500 Subject: [PATCH 06/37] Add missing entry to the changelog (#9) --- CHANGELOG.md | 6 +++++- README.md | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5728cb6..bbfbb65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,11 @@ ### Added -- Added support for OpenSSL with "Encryption Key" authentication mode across all applicable algorithms +- 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 diff --git a/README.md b/README.md index 6d536bd..a307540 100644 --- a/README.md +++ b/README.md @@ -401,6 +401,11 @@ Control4, you can file an issue on GitHub: - 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 From f8fbeafb759399df6e6b8d576d9b1434e93bd38f Mon Sep 17 00:00:00 2001 From: Derek Miller Date: Wed, 22 Oct 2025 08:13:41 -0500 Subject: [PATCH 07/37] v20251022: Gracefully handle unknown proto fields (#10) Also make a slight change to authentication flow to still authenticate if there is no password. --- CHANGELOG.md | 6 +++ README.md | 6 +++ package.json | 2 +- src/esphome/client.lua | 4 +- src/esphome/proto-schema.lua | 5 --- src/lib/protobuf.lua | 74 ++++++++++++++++++++++-------------- 6 files changed, 60 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbfbb65..60f5ce9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ [//]: # "### Removed" [//]: # "- Removed" +## v20251022 - 2025-10-22 + +### Fixed + +- Fixed an issue with parsing unknown fields in protobuf messages + ## v20251019 - 2025-10-19 ### Added diff --git a/README.md b/README.md index a307540..481aa58 100644 --- a/README.md +++ b/README.md @@ -394,6 +394,12 @@ Control4, you can file an issue on GitHub: # Changelog +## v20251022 - 2025-10-22 + +### Fixed + +- Fixed an issue with parsing unknown fields in protobuf messages + ## v20251019 - 2025-10-19 ### Added diff --git a/package.json b/package.json index a3349b0..01b4638 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "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", + "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/release/esphome/components/api/api.proto https://raw.githubusercontent.com/esphome/esphome/refs/heads/release/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", diff --git a/src/esphome/client.lua b/src/esphome/client.lua index c88be25..45e836d 100644 --- a/src/esphome/client.lua +++ b/src/esphome/client.lua @@ -238,8 +238,8 @@ function ESPHomeClient:connect() end) :next(function() log:debug("Hello message sent successfully") - -- Only authenticate when using password authentication - if not IsEmpty(self._password) then + -- Only authenticate when not using encryption key + if IsEmpty(self._encryptionKey) then return self:sendAuthenticate() end log:debug("Skipping authentication request (using Noise encryption)") diff --git a/src/esphome/proto-schema.lua b/src/esphome/proto-schema.lua index eae51ae..214fc56 100644 --- a/src/esphome/proto-schema.lua +++ b/src/esphome/proto-schema.lua @@ -2407,11 +2407,6 @@ PROTOBUF_SCHEMA.Message = { wireType = PROTOBUF_SCHEMA.WireType.VARINT, type = PROTOBUF_SCHEMA.DataType.UINT32, }, - [27] = { - name = "feature_flags", - wireType = PROTOBUF_SCHEMA.WireType.VARINT, - type = PROTOBUF_SCHEMA.DataType.UINT32, - }, }, }, ClimateStateResponse = { diff --git a/src/lib/protobuf.lua b/src/lib/protobuf.lua index 8517789..8da92f8 100644 --- a/src/lib/protobuf.lua +++ b/src/lib/protobuf.lua @@ -230,41 +230,57 @@ function Protobuf.decode(protoSchema, messageSchema, buffer) -- 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) + -- Skip unknown field based on wire type + if wire_type == protoSchema.WireType.VARINT then + -- Decode and discard the varint + local _ + _, pos = Protobuf.decode_varint(buffer, pos) + elseif wire_type == protoSchema.WireType.FIXED32 then + -- Skip 4 bytes + pos = pos + 4 + elseif wire_type == protoSchema.WireType.LENGTH_DELIMITED then + -- Decode length and skip that many bytes + local length + length, pos = Protobuf.decode_varint(buffer, pos) + pos = pos + length else - value, pos = Protobuf.decode_fixed32(buffer, pos) + error("Unknown wire type: " .. wire_type) 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 + -- Known field - decode and store the value + 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 - value = data + error("Unsupported wire type: " .. wire_type) end - else - error("Unsupported wire type: " .. wire_type) - end - if field.repeated then - if message[field.name] == nil then - message[field.name] = {} + 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 - table.insert(message[field.name], value) - else - message[field.name] = value end end From 2cb5d2185858efc251a80eac39d1861685d340a0 Mon Sep 17 00:00:00 2001 From: Derek Miller Date: Sat, 1 Nov 2025 09:44:30 -0500 Subject: [PATCH 08/37] v20251031: Fix ESPHome 2025.10.0 passwordless device compatibility (#13) - Add fatal error mechanism for async authentication failures - Improve driver status messages during password authentication - Remove deprecated _authenticated flag and authRequired parameter Fixes #12 --- CHANGELOG.md | 8 ++++ README.md | 8 ++++ drivers/esphome/driver.lua | 11 ++++- src/esphome/client.lua | 89 ++++++++++++++++++++------------------ 4 files changed, 73 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60f5ce9..dd4f7c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,14 @@ [//]: # "### Removed" [//]: # "- Removed" +## 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 diff --git a/README.md b/README.md index 481aa58..789e453 100644 --- a/README.md +++ b/README.md @@ -394,6 +394,14 @@ Control4, you can file an issue on GitHub: # Changelog +## 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 diff --git a/drivers/esphome/driver.lua b/drivers/esphome/driver.lua index 14920c9..9f13b47 100644 --- a/drivers/esphome/driver.lua +++ b/drivers/esphome/driver.lua @@ -219,7 +219,13 @@ function Connect() elseif not esphome:isConnected() then updateStatus("Connecting") esphome:connect():next(function() - updateStatus("Connected") + -- 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) @@ -241,6 +247,8 @@ 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") @@ -318,6 +326,7 @@ function RefreshStatus() error = "unknown error" end log:error("An error occurred refreshing device status; %s", error) + updateStatus("Refresh failed: " .. error) esphome:disconnect() end) end) diff --git a/src/esphome/client.lua b/src/esphome/client.lua index 45e836d..49582b3 100644 --- a/src/esphome/client.lua +++ b/src/esphome/client.lua @@ -75,7 +75,6 @@ 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. @@ -85,6 +84,7 @@ function ESPHomeClient:new() _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. + _fatalError = nil, --- @type string|nil Fatal error message (e.g., authentication failure). } setmetatable(properties, self) self.__index = self @@ -152,14 +152,16 @@ 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 --- 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()") @@ -179,6 +181,9 @@ 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") @@ -249,8 +254,7 @@ function ESPHomeClient:connect() return reject(err) end) :next(function() - log:debug("Successfully authenticated with ESPHome device") - self._authenticated = true + log:debug("Connection established (authentication request sent)") -- Start ping timer to keep connection alive self._pingTimer = C4:SetTimer(15000, function() @@ -259,7 +263,7 @@ function ESPHomeClient:connect() 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) @@ -314,7 +318,6 @@ function ESPHomeClient:disconnect() self._connected = false self._hs = nil self._hsState = nil - self._authenticated = false self._buffer = "" self._callbacks = {} end @@ -443,7 +446,7 @@ function ESPHomeClient:sendHello() client_info = "Control4", api_version_major = 1, api_version_minor = 0, - }, false) + }) --- @cast d Deferred return d @@ -558,37 +561,41 @@ function ESPHomeClient:sendHandshake() end --- Send an authenticate message to the ESPHome device. ---- @return Deferred result A promise that resolves when the authenticate response is received. +--- 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()") - local d = self - :callServiceMethod(ESPHomeProtoSchema.RPC.APIConnection.authenticate, { - 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) - --- @cast d Deferred - return d + -- Register async handler for AuthenticationResponse (sets fatal error on invalid password) + self._callbacks[ESPHomeProtoSchema.Message.AuthenticationResponse.options.id] = function(message) + -- Remove callback immediately + self._callbacks[ESPHomeProtoSchema.Message.AuthenticationResponse.options.id] = nil + + 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 + + -- 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() + local d = self:callServiceMethod(ESPHomeProtoSchema.RPC.APIConnection.ping, {}):next(function() log:info("Ping successful") end, function(err) if IsEmpty(err) or type(err) ~= "string" then @@ -605,18 +612,10 @@ 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 +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 @@ -639,6 +638,12 @@ function ESPHomeClient:sendMessage(messageSchema, body, responseSchema, timeout) --- @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 From 57548b10225c33a397bd46b5d38495cad5130f2b Mon Sep 17 00:00:00 2001 From: Derek Miller Date: Tue, 17 Feb 2026 09:42:12 -0600 Subject: [PATCH 09/37] Add Bluetooth proxy support with multi-proxy coordination and presence tracking (#15) * Replace bit and protobuf libraries with vendored version and move all vendored libraries outside the src folder * Refactor and standardize callback handling with auto-registration, timeouts, and hierarchical lookup * Add device log forwarding feature to ESPHome driver * Upgrade bitn and protobuf libraries to v0.2.0 * Add Bluetooth proxy support with multi-proxy coordination and presence tracking Bluetooth Proxy Infrastructure: - ESPHome driver detects bluetooth_proxy capability and exposes BLE device selection - Scanner with pluggable nodes for local (ESPHome direct) and coordinator modes - Advertisement parsing for BTHome, SwitchBot, and Govee protocols - GATT connection management with auto-connect and slot tracking - Scanner watchdog with automatic device restart on stuck scanner detection Bluetooth Coordinator Driver: - Aggregates multiple ESPHome Bluetooth proxies via single binding - RSSI-based intelligent routing to optimal proxy per device - Connection failover with automatic retry through alternate proxies - Device registry with RSSI freshness tracking - Dynamic bindings for discovered BLE devices Room Presence Tracking: - ESPresense-style room detection using RSSI signal strength - Anti-flapping: RSSI smoothing (EMA), hysteresis margin, dwell time - Per-device and per-room events (entered/left room, home/away) - Contact sensor bindings for room occupancy and device presence - Minimum RSSI threshold for sparse coverage scenarios (global + per-proxy override) - Variables: Room, Distance, RSSI for each tracked device Sub-drivers: - ESPHome BTHome: Shelly BLU, BTHome v1/v2 sensors (passive) - ESPHome Govee: Temperature/humidity sensors, meat thermometers (passive) - ESPHome SwitchBot: Bot, Plug Mini, Meter, Motion, Contact (active + passive) Library improvements: - Dynamic bindings with namespace isolation and persistence - Event management with dynamic creation and cleanup - Values module for variables and properties with persistence - AES-CTR encryption for SwitchBot device communication --- CHANGELOG.md | 12 + README.md | 504 +- drivers/esphome/driver.lua | 374 +- drivers/esphome/driver.xml | 78 +- drivers/esphome/squishy | 96 +- drivers/esphome/www/documentation/index.md | 474 +- .../driver.c4zproj | 7 + .../esphome_bluetooth_coordinator/driver.lua | 768 ++ .../esphome_bluetooth_coordinator/driver.xml | 222 + drivers/esphome_bluetooth_coordinator/squishy | 73 + .../documentation/images/check-drivers.png | Bin 0 -> 48864 bytes .../documentation/images/finite-labs-logo.png | Bin 0 -> 34107 bytes .../www/documentation/images/header.png | Bin 0 -> 111740 bytes .../images/relay-controller-connections.png | Bin 0 -> 64145 bytes .../images/relay-controller-drivers.png | Bin 0 -> 76437 bytes .../images/relay-controller-properties.png | Bin 0 -> 165135 bytes .../documentation/images/search-drivers.png | Bin 0 -> 32936 bytes .../www/documentation/index.md | 808 ++ .../www/icons/device_lg.png | Bin 0 -> 1365 bytes .../www/icons/device_sm.png | Bin 0 -> 654 bytes .../www/icons/experience_100.png | Bin 0 -> 4295 bytes .../www/icons/experience_1024.png | Bin 0 -> 80839 bytes .../www/icons/experience_110.png | Bin 0 -> 4781 bytes .../www/icons/experience_120.png | Bin 0 -> 4989 bytes .../www/icons/experience_130.png | Bin 0 -> 5516 bytes .../www/icons/experience_140.png | Bin 0 -> 5660 bytes .../www/icons/experience_20.png | Bin 0 -> 900 bytes .../www/icons/experience_30.png | Bin 0 -> 1277 bytes .../www/icons/experience_300.png | Bin 0 -> 14546 bytes .../www/icons/experience_40.png | Bin 0 -> 1774 bytes .../www/icons/experience_50.png | Bin 0 -> 1976 bytes .../www/icons/experience_512.png | Bin 0 -> 29845 bytes .../www/icons/experience_60.png | Bin 0 -> 2488 bytes .../www/icons/experience_70.png | Bin 0 -> 2853 bytes .../www/icons/experience_80.png | Bin 0 -> 3426 bytes .../www/icons/experience_90.png | Bin 0 -> 3768 bytes drivers/esphome_bthome/driver.c4zproj | 7 + drivers/esphome_bthome/driver.lua | 1044 ++ drivers/esphome_bthome/driver.xml | 590 + drivers/esphome_bthome/squishy | 73 + .../documentation/images/finite-labs-logo.png | Bin 0 -> 34107 bytes .../www/documentation/images/header.png | Bin 0 -> 111740 bytes .../esphome_bthome/www/documentation/index.md | 590 + .../esphome_bthome/www/icons/device_lg.png | Bin 0 -> 1365 bytes .../esphome_bthome/www/icons/device_sm.png | Bin 0 -> 654 bytes .../www/icons/experience_100.png | Bin 0 -> 4295 bytes .../www/icons/experience_1024.png | Bin 0 -> 80839 bytes .../www/icons/experience_110.png | Bin 0 -> 4781 bytes .../www/icons/experience_120.png | Bin 0 -> 4989 bytes .../www/icons/experience_130.png | Bin 0 -> 5516 bytes .../www/icons/experience_140.png | Bin 0 -> 5660 bytes .../www/icons/experience_20.png | Bin 0 -> 900 bytes .../www/icons/experience_30.png | Bin 0 -> 1277 bytes .../www/icons/experience_300.png | Bin 0 -> 14546 bytes .../www/icons/experience_40.png | Bin 0 -> 1774 bytes .../www/icons/experience_50.png | Bin 0 -> 1976 bytes .../www/icons/experience_512.png | Bin 0 -> 29845 bytes .../www/icons/experience_60.png | Bin 0 -> 2488 bytes .../www/icons/experience_70.png | Bin 0 -> 2853 bytes .../www/icons/experience_80.png | Bin 0 -> 3426 bytes .../www/icons/experience_90.png | Bin 0 -> 3768 bytes drivers/esphome_govee/driver.c4zproj | 7 + drivers/esphome_govee/driver.lua | 719 ++ drivers/esphome_govee/driver.xml | 290 + drivers/esphome_govee/squishy | 73 + .../documentation/images/finite-labs-logo.png | Bin 0 -> 34107 bytes .../www/documentation/images/header.png | Bin 0 -> 111740 bytes .../esphome_govee/www/documentation/index.md | 434 + drivers/esphome_govee/www/icons/device_lg.png | Bin 0 -> 1365 bytes drivers/esphome_govee/www/icons/device_sm.png | Bin 0 -> 654 bytes .../www/icons/experience_100.png | Bin 0 -> 4295 bytes .../www/icons/experience_1024.png | Bin 0 -> 80839 bytes .../www/icons/experience_110.png | Bin 0 -> 4781 bytes .../www/icons/experience_120.png | Bin 0 -> 4989 bytes .../www/icons/experience_130.png | Bin 0 -> 5516 bytes .../www/icons/experience_140.png | Bin 0 -> 5660 bytes .../esphome_govee/www/icons/experience_20.png | Bin 0 -> 900 bytes .../esphome_govee/www/icons/experience_30.png | Bin 0 -> 1277 bytes .../www/icons/experience_300.png | Bin 0 -> 14546 bytes .../esphome_govee/www/icons/experience_40.png | Bin 0 -> 1774 bytes .../esphome_govee/www/icons/experience_50.png | Bin 0 -> 1976 bytes .../www/icons/experience_512.png | Bin 0 -> 29845 bytes .../esphome_govee/www/icons/experience_60.png | Bin 0 -> 2488 bytes .../esphome_govee/www/icons/experience_70.png | Bin 0 -> 2853 bytes .../esphome_govee/www/icons/experience_80.png | Bin 0 -> 3426 bytes .../esphome_govee/www/icons/experience_90.png | Bin 0 -> 3768 bytes drivers/esphome_light/driver.lua | 27 +- drivers/esphome_light/driver.xml | 2 +- drivers/esphome_light/squishy | 94 +- .../esphome_light/www/documentation/index.md | 92 +- drivers/esphome_lock/driver.lua | 28 +- drivers/esphome_lock/driver.xml | 2 +- drivers/esphome_lock/squishy | 94 +- .../esphome_lock/www/documentation/index.md | 85 +- drivers/esphome_switchbot/driver.c4zproj | 7 + drivers/esphome_switchbot/driver.lua | 2707 +++++ drivers/esphome_switchbot/driver.xml | 328 + drivers/esphome_switchbot/squishy | 73 + .../documentation/images/finite-labs-logo.png | Bin 0 -> 34107 bytes .../www/documentation/images/header.png | Bin 0 -> 111740 bytes .../www/documentation/index.md | 606 ++ .../esphome_switchbot/www/icons/device_lg.png | Bin 0 -> 1365 bytes .../esphome_switchbot/www/icons/device_sm.png | Bin 0 -> 654 bytes .../www/icons/experience_100.png | Bin 0 -> 4295 bytes .../www/icons/experience_1024.png | Bin 0 -> 80839 bytes .../www/icons/experience_110.png | Bin 0 -> 4781 bytes .../www/icons/experience_120.png | Bin 0 -> 4989 bytes .../www/icons/experience_130.png | Bin 0 -> 5516 bytes .../www/icons/experience_140.png | Bin 0 -> 5660 bytes .../www/icons/experience_20.png | Bin 0 -> 900 bytes .../www/icons/experience_30.png | Bin 0 -> 1277 bytes .../www/icons/experience_300.png | Bin 0 -> 14546 bytes .../www/icons/experience_40.png | Bin 0 -> 1774 bytes .../www/icons/experience_50.png | Bin 0 -> 1976 bytes .../www/icons/experience_512.png | Bin 0 -> 29845 bytes .../www/icons/experience_60.png | Bin 0 -> 2488 bytes .../www/icons/experience_70.png | Bin 0 -> 2853 bytes .../www/icons/experience_80.png | Bin 0 -> 3426 bytes .../www/icons/experience_90.png | Bin 0 -> 3768 bytes package.json | 11 +- src/constants.lua | 21 +- src/esphome/ble/address.lua | 74 + src/esphome/ble/company_identifiers.lua | 3912 +++++++ .../ble/coordinator/device_registry.lua | 272 + .../ble/coordinator/presence_tracker.lua | 955 ++ .../ble/coordinator/proxy_registry.lua | 223 + .../ble/coordinator/proxy_scanner_node.lua | 31 + src/esphome/ble/coordinator/router.lua | 506 + src/esphome/ble/local_scanner_node.lua | 65 + src/esphome/ble/parsers/advertisement.lua | 704 ++ src/esphome/ble/parsers/govee.lua | 1020 ++ src/esphome/ble/parsers/switchbot.lua | 672 ++ src/esphome/ble/scanner.lua | 691 ++ src/esphome/ble/scanner_node.lua | 56 + src/esphome/ble/scanner_properties.lua | 405 + src/esphome/ble/uuid.lua | 326 + src/esphome/capabilities/bluetooth_proxy.lua | 1599 +++ src/esphome/capabilities/types.lua | 5 + src/esphome/client.lua | 1563 ++- src/esphome/entities/binary_sensor.lua | 11 +- src/esphome/entities/button.lua | 66 +- src/esphome/entities/cover.lua | 13 +- src/esphome/entities/light.lua | 19 +- src/esphome/entities/lock.lua | 19 +- src/esphome/entities/number.lua | 13 +- src/esphome/entities/sensor.lua | 12 +- src/esphome/entities/switch.lua | 13 +- src/esphome/entities/text.lua | 13 +- src/esphome/entities/text_sensor.lua | 12 +- src/esphome/proto-schema.lua | 5796 ---------- src/esphome/proto_schema.lua | 7602 +++++++++++++ src/lib/aes_ctr.lua | 648 ++ src/lib/bindings.lua | 95 +- src/lib/bit16.lua | 94 - src/lib/conditionals.lua | 38 +- src/lib/events.lua | 39 +- src/lib/github-updater.lua | 14 +- src/lib/http.lua | 14 +- src/lib/logging.lua | 218 +- src/lib/persist.lua | 29 +- src/lib/protobuf.lua | 501 - src/lib/utils.lua | 394 +- src/lib/values.lua | 184 +- src/vendor/deferred.lua | 288 - src/vendor/noiseprotocol.lua | 9593 ----------------- test/c4_shim.lua | 360 + tools/gen_lua_proto_schema | 408 - tools/preprocess | 3 + {src/vendor => vendor}/JSON.lua | 0 vendor/bitn.lua | 3290 ++++++ vendor/bthome.lua | 3373 ++++++ {src/vendor => vendor}/cloud-client-byte.lua | 0 vendor/deferred.lua | 516 + .../drivers-common-public/global/handlers.lua | 2 +- .../drivers-common-public/global/lib.lua | 19 +- .../drivers-common-public/global/timer.lua | 0 .../drivers-common-public/global/url.lua | 4 +- vendor/noiseprotocol.lua | 9101 ++++++++++++++++ vendor/protobuf.lua | 1264 +++ {src/vendor => vendor}/version.lua | 0 {src/vendor => vendor}/xml/XmlParser.lua | 0 {src/vendor => vendor}/xml/xml2lua.lua | 2 +- {src/vendor => vendor}/xml/xmlhandler/dom.lua | 0 .../xml/xmlhandler/print.lua | 0 .../vendor => vendor}/xml/xmlhandler/tree.lua | 0 185 files changed, 51101 insertions(+), 17478 deletions(-) create mode 100644 drivers/esphome_bluetooth_coordinator/driver.c4zproj create mode 100644 drivers/esphome_bluetooth_coordinator/driver.lua create mode 100644 drivers/esphome_bluetooth_coordinator/driver.xml create mode 100644 drivers/esphome_bluetooth_coordinator/squishy create mode 100644 drivers/esphome_bluetooth_coordinator/www/documentation/images/check-drivers.png create mode 100644 drivers/esphome_bluetooth_coordinator/www/documentation/images/finite-labs-logo.png create mode 100644 drivers/esphome_bluetooth_coordinator/www/documentation/images/header.png create mode 100644 drivers/esphome_bluetooth_coordinator/www/documentation/images/relay-controller-connections.png create mode 100644 drivers/esphome_bluetooth_coordinator/www/documentation/images/relay-controller-drivers.png create mode 100644 drivers/esphome_bluetooth_coordinator/www/documentation/images/relay-controller-properties.png create mode 100644 drivers/esphome_bluetooth_coordinator/www/documentation/images/search-drivers.png create mode 100644 drivers/esphome_bluetooth_coordinator/www/documentation/index.md create mode 100644 drivers/esphome_bluetooth_coordinator/www/icons/device_lg.png create mode 100644 drivers/esphome_bluetooth_coordinator/www/icons/device_sm.png create mode 100644 drivers/esphome_bluetooth_coordinator/www/icons/experience_100.png create mode 100644 drivers/esphome_bluetooth_coordinator/www/icons/experience_1024.png create mode 100644 drivers/esphome_bluetooth_coordinator/www/icons/experience_110.png create mode 100644 drivers/esphome_bluetooth_coordinator/www/icons/experience_120.png create mode 100644 drivers/esphome_bluetooth_coordinator/www/icons/experience_130.png create mode 100644 drivers/esphome_bluetooth_coordinator/www/icons/experience_140.png create mode 100644 drivers/esphome_bluetooth_coordinator/www/icons/experience_20.png create mode 100644 drivers/esphome_bluetooth_coordinator/www/icons/experience_30.png create mode 100644 drivers/esphome_bluetooth_coordinator/www/icons/experience_300.png create mode 100644 drivers/esphome_bluetooth_coordinator/www/icons/experience_40.png create mode 100644 drivers/esphome_bluetooth_coordinator/www/icons/experience_50.png create mode 100644 drivers/esphome_bluetooth_coordinator/www/icons/experience_512.png create mode 100644 drivers/esphome_bluetooth_coordinator/www/icons/experience_60.png create mode 100644 drivers/esphome_bluetooth_coordinator/www/icons/experience_70.png create mode 100644 drivers/esphome_bluetooth_coordinator/www/icons/experience_80.png create mode 100644 drivers/esphome_bluetooth_coordinator/www/icons/experience_90.png create mode 100644 drivers/esphome_bthome/driver.c4zproj create mode 100644 drivers/esphome_bthome/driver.lua create mode 100644 drivers/esphome_bthome/driver.xml create mode 100644 drivers/esphome_bthome/squishy create mode 100644 drivers/esphome_bthome/www/documentation/images/finite-labs-logo.png create mode 100644 drivers/esphome_bthome/www/documentation/images/header.png create mode 100644 drivers/esphome_bthome/www/documentation/index.md create mode 100644 drivers/esphome_bthome/www/icons/device_lg.png create mode 100644 drivers/esphome_bthome/www/icons/device_sm.png create mode 100644 drivers/esphome_bthome/www/icons/experience_100.png create mode 100644 drivers/esphome_bthome/www/icons/experience_1024.png create mode 100644 drivers/esphome_bthome/www/icons/experience_110.png create mode 100644 drivers/esphome_bthome/www/icons/experience_120.png create mode 100644 drivers/esphome_bthome/www/icons/experience_130.png create mode 100644 drivers/esphome_bthome/www/icons/experience_140.png create mode 100644 drivers/esphome_bthome/www/icons/experience_20.png create mode 100644 drivers/esphome_bthome/www/icons/experience_30.png create mode 100644 drivers/esphome_bthome/www/icons/experience_300.png create mode 100644 drivers/esphome_bthome/www/icons/experience_40.png create mode 100644 drivers/esphome_bthome/www/icons/experience_50.png create mode 100644 drivers/esphome_bthome/www/icons/experience_512.png create mode 100644 drivers/esphome_bthome/www/icons/experience_60.png create mode 100644 drivers/esphome_bthome/www/icons/experience_70.png create mode 100644 drivers/esphome_bthome/www/icons/experience_80.png create mode 100644 drivers/esphome_bthome/www/icons/experience_90.png create mode 100644 drivers/esphome_govee/driver.c4zproj create mode 100644 drivers/esphome_govee/driver.lua create mode 100644 drivers/esphome_govee/driver.xml create mode 100644 drivers/esphome_govee/squishy create mode 100644 drivers/esphome_govee/www/documentation/images/finite-labs-logo.png create mode 100644 drivers/esphome_govee/www/documentation/images/header.png create mode 100644 drivers/esphome_govee/www/documentation/index.md create mode 100644 drivers/esphome_govee/www/icons/device_lg.png create mode 100644 drivers/esphome_govee/www/icons/device_sm.png create mode 100644 drivers/esphome_govee/www/icons/experience_100.png create mode 100644 drivers/esphome_govee/www/icons/experience_1024.png create mode 100644 drivers/esphome_govee/www/icons/experience_110.png create mode 100644 drivers/esphome_govee/www/icons/experience_120.png create mode 100644 drivers/esphome_govee/www/icons/experience_130.png create mode 100644 drivers/esphome_govee/www/icons/experience_140.png create mode 100644 drivers/esphome_govee/www/icons/experience_20.png create mode 100644 drivers/esphome_govee/www/icons/experience_30.png create mode 100644 drivers/esphome_govee/www/icons/experience_300.png create mode 100644 drivers/esphome_govee/www/icons/experience_40.png create mode 100644 drivers/esphome_govee/www/icons/experience_50.png create mode 100644 drivers/esphome_govee/www/icons/experience_512.png create mode 100644 drivers/esphome_govee/www/icons/experience_60.png create mode 100644 drivers/esphome_govee/www/icons/experience_70.png create mode 100644 drivers/esphome_govee/www/icons/experience_80.png create mode 100644 drivers/esphome_govee/www/icons/experience_90.png create mode 100644 drivers/esphome_switchbot/driver.c4zproj create mode 100644 drivers/esphome_switchbot/driver.lua create mode 100644 drivers/esphome_switchbot/driver.xml create mode 100644 drivers/esphome_switchbot/squishy create mode 100644 drivers/esphome_switchbot/www/documentation/images/finite-labs-logo.png create mode 100644 drivers/esphome_switchbot/www/documentation/images/header.png create mode 100644 drivers/esphome_switchbot/www/documentation/index.md create mode 100644 drivers/esphome_switchbot/www/icons/device_lg.png create mode 100644 drivers/esphome_switchbot/www/icons/device_sm.png create mode 100644 drivers/esphome_switchbot/www/icons/experience_100.png create mode 100644 drivers/esphome_switchbot/www/icons/experience_1024.png create mode 100644 drivers/esphome_switchbot/www/icons/experience_110.png create mode 100644 drivers/esphome_switchbot/www/icons/experience_120.png create mode 100644 drivers/esphome_switchbot/www/icons/experience_130.png create mode 100644 drivers/esphome_switchbot/www/icons/experience_140.png create mode 100644 drivers/esphome_switchbot/www/icons/experience_20.png create mode 100644 drivers/esphome_switchbot/www/icons/experience_30.png create mode 100644 drivers/esphome_switchbot/www/icons/experience_300.png create mode 100644 drivers/esphome_switchbot/www/icons/experience_40.png create mode 100644 drivers/esphome_switchbot/www/icons/experience_50.png create mode 100644 drivers/esphome_switchbot/www/icons/experience_512.png create mode 100644 drivers/esphome_switchbot/www/icons/experience_60.png create mode 100644 drivers/esphome_switchbot/www/icons/experience_70.png create mode 100644 drivers/esphome_switchbot/www/icons/experience_80.png create mode 100644 drivers/esphome_switchbot/www/icons/experience_90.png create mode 100644 src/esphome/ble/address.lua create mode 100644 src/esphome/ble/company_identifiers.lua create mode 100644 src/esphome/ble/coordinator/device_registry.lua create mode 100644 src/esphome/ble/coordinator/presence_tracker.lua create mode 100644 src/esphome/ble/coordinator/proxy_registry.lua create mode 100644 src/esphome/ble/coordinator/proxy_scanner_node.lua create mode 100644 src/esphome/ble/coordinator/router.lua create mode 100644 src/esphome/ble/local_scanner_node.lua create mode 100644 src/esphome/ble/parsers/advertisement.lua create mode 100644 src/esphome/ble/parsers/govee.lua create mode 100644 src/esphome/ble/parsers/switchbot.lua create mode 100644 src/esphome/ble/scanner.lua create mode 100644 src/esphome/ble/scanner_node.lua create mode 100644 src/esphome/ble/scanner_properties.lua create mode 100644 src/esphome/ble/uuid.lua create mode 100644 src/esphome/capabilities/bluetooth_proxy.lua create mode 100644 src/esphome/capabilities/types.lua delete mode 100644 src/esphome/proto-schema.lua create mode 100644 src/esphome/proto_schema.lua create mode 100644 src/lib/aes_ctr.lua delete mode 100644 src/lib/bit16.lua delete mode 100644 src/lib/protobuf.lua delete mode 100644 src/vendor/deferred.lua delete mode 100644 src/vendor/noiseprotocol.lua create mode 100644 test/c4_shim.lua delete mode 100755 tools/gen_lua_proto_schema rename {src/vendor => vendor}/JSON.lua (100%) create mode 100644 vendor/bitn.lua create mode 100644 vendor/bthome.lua rename {src/vendor => vendor}/cloud-client-byte.lua (100%) create mode 100644 vendor/deferred.lua rename {src/vendor => vendor}/drivers-common-public/global/handlers.lua (99%) rename {src/vendor => vendor}/drivers-common-public/global/lib.lua (97%) rename {src/vendor => vendor}/drivers-common-public/global/timer.lua (100%) rename {src/vendor => vendor}/drivers-common-public/global/url.lua (99%) create mode 100644 vendor/noiseprotocol.lua create mode 100644 vendor/protobuf.lua rename {src/vendor => vendor}/version.lua (100%) rename {src/vendor => vendor}/xml/XmlParser.lua (100%) rename {src/vendor => vendor}/xml/xml2lua.lua (99%) rename {src/vendor => vendor}/xml/xmlhandler/dom.lua (100%) rename {src/vendor => vendor}/xml/xmlhandler/print.lua (100%) rename {src/vendor => vendor}/xml/xmlhandler/tree.lua (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd4f7c8..fdb313b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,18 @@ [//]: # "### Removed" [//]: # "- Removed" +## Unreleased + +### 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 diff --git a/README.md b/README.md index 789e453..20fd6d5 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,13 @@ > 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 +31,14 @@ 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,7 +46,7 @@ Control4 system.
-# System requirements +# System Requirements - Control4 OS 3.3+ @@ -67,6 +71,19 @@ tested extensively with the following devices: 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 | + +See the individual sub-driver documentation for device-specific details. +
## Supported ESPHome Entities @@ -76,9 +93,9 @@ know! | Entity Type | Supported | |---------------------|-----------| | Alarm Control Panel | ❌ | -| API Noise | ❌ | +| API Noise | ✅ | | Binary Sensor | ✅ | -| Bluetooth Proxy | ❌ | +| Bluetooth Proxy | ✅ | | Button | ✅ | | Climate | ❌ | | Cover | ✅ | @@ -122,9 +139,8 @@ drivers. Below is an outline of the basic steps for your convenience. [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. @@ -143,7 +159,7 @@ drivers. Below is an outline of the basic steps for your convenience. 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 @@ -157,14 +173,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,14 +192,20 @@ 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 \] 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 @@ -195,7 +217,7 @@ 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. -##### Port +##### Port \[ 1 - 65535, default: ***6053*** \] Sets the device port. The default port for ESPHome devices is `6053`. @@ -220,9 +242,129 @@ 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. +`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 +388,75 @@ 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. -##### 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 | +| Sensor | `{name}` | NUMBER | Read-only, 1 decimal precision | +| Switch | `{name} State` | BOOL | "1" = on, "0" = off (writable) | +| Cover | `{name} State` | STRING | "open", "closed", "opening", "closing" | +| Number | `{name}` | NUMBER | Writable, 1 decimal precision | +| Text | `{name}` | STRING | Writable | +| Text Sensor | `{name}` | STRING | Read-only | +| Button | (none) | \- | Use "Press Button" command (see below) | +| Light | (none) | \- | State via Light proxy | +| Lock | (none) | \- | State via Lock proxy | + +> **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 | +| Light | `ESPHOME_LIGHT` | Bind to ESPHome Light sub-driver | +| Lock | `ESPHOME_LOCK` | Bind to ESPHome Lock sub-driver | + +> **Note:** Sensor, Number, Text, and Text Sensor entities do not create +> bindings—they expose data only through variables. + +### Commands + +| Command | Parameters | Description | +|--------------|------------|-------------------------------------------| +| Press Button | Button | Triggers an ESPHome button entity by name | + +> **Note:** The Button parameter is a dynamic list populated with +> discovered ESPHome button entities.
@@ -381,6 +577,234 @@ 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) + +### 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 | + +## 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 @@ -394,6 +818,24 @@ Control4, you can file an issue on GitHub: # Changelog +## Unreleased + +### 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 diff --git a/drivers/esphome/driver.lua b/drivers/esphome/driver.lua index 9f13b47..c82ad8d 100644 --- a/drivers/esphome/driver.lua +++ b/drivers/esphome/driver.lua @@ -1,27 +1,42 @@ +--#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", "esphome_light.c4z", "esphome_lock.c4z", + "esphome_switchbot.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") @@ -36,6 +51,14 @@ local TextSensorEntity = require("esphome.entities.text_sensor") local constants = require("constants") local esphome = ESPHomeClient:new() +local localScannerNode = LocalScannerNode:new(esphome) + +bleScanner:addNode(localScannerNode) + +local bluetoothProxyCapability = BluetoothProxyCapability:new(esphome) + +--- @type boolean +local isLeaderInstance = false --- @type table local Entities = { @@ -51,9 +74,37 @@ local Entities = { [TextSensorEntity.TYPE] = TextSensorEntity: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 +121,14 @@ function OnDriverLateInit() if not CheckMinimumVersion("Driver Status") then return end + isLeaderInstance = Select(getESPHomeDriverIds(), 1) == C4:GetDeviceID() - -- 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) + C4:FileSetDir("c29tZXNwZWNpYWxrZXk=++11") bindings:restoreBindings() values:restoreValues() @@ -82,14 +137,34 @@ 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 and not isLeaderInstance then + return + end + syncPropertyToOtherInstances("Automatic Updates", propertyValue) + --#endif +end + +--#ifndef DRIVERCENTRAL +function OPC.Update_Channel(propertyValue) + log:trace("OPC.Update_Channel('%s')", propertyValue) + if not gInitialized and not isLeaderInstance 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 +175,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 +187,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 +198,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 @@ -156,28 +249,149 @@ function OPC.Authentication_Mode(propertyValue) 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 @@ -194,6 +408,7 @@ function Connect() ) local lastUpdateTime = os.time() -- Don't check for updates on the first cycle + local lastFatalErrorTime = 0 -- Track when the last fatal error occurred local heartbeat = function() --#ifdef DRIVERCENTRAL @@ -212,13 +427,32 @@ 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)") + -- 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() + 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 @@ -253,6 +487,8 @@ function RefreshStatus() 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() @@ -275,6 +511,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 @@ -319,6 +564,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) @@ -332,28 +583,94 @@ function RefreshStatus() 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.ResetDriver(params) + log:trace("EC.ResetDriver(%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:cancelScan() + bleScanner:reset() + + -- Reset scanner properties (clears device selections) + bleScannerProperties:reset() - for name, _ in pairs(Variables or {}) do - log:info("Deleting variable '%s'", name) - values:delete(name) + -- 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 +-- 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.UpdateDrivers() log:trace("EC.UpdateDrivers()") log:print("Updating drivers") @@ -376,3 +693,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 162a078..e4d884f 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 @@ -96,6 +96,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 @@ -144,6 +154,56 @@ 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 @@ -188,8 +248,8 @@ - Reset Connections and Variables - ResetConnectionsAndVariables + Reset Driver + ResetDriver Are You Sure? @@ -202,5 +262,17 @@ + + + Press Button + Press NAME button PARAM1 + + + Button + DYNAMIC_LIST + + + + diff --git a/drivers/esphome/squishy b/drivers/esphome/squishy index 2e127e4..e34a980 100644 --- a/drivers/esphome/squishy +++ b/drivers/esphome/squishy @@ -1,46 +1,68 @@ Main "driver.lua" +Module "bitn" "../../vendor/bitn.lua" +Module "bthome" "../../vendor/bthome.lua" #ifdef DRIVERCENTRAL -Module "vendor.cloud-client-byte" "../../../../src/vendor/cloud-client-byte.lua" +Module "cloud-client-byte" "../../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 "deferred" "../../vendor/deferred.lua" +Module "drivers-common-public.global.handlers" "../../vendor/drivers-common-public/global/handlers.lua" +Module "drivers-common-public.global.lib" "../../vendor/drivers-common-public/global/lib.lua" +Module "drivers-common-public.global.timer" "../../vendor/drivers-common-public/global/timer.lua" +Module "drivers-common-public.global.url" "../../vendor/drivers-common-public/global/url.lua" +Module "JSON" "../../vendor/JSON.lua" +Module "noiseprotocol" "../../vendor/noiseprotocol.lua" +Module "protobuf" "../../vendor/protobuf.lua" +Module "version" "../../vendor/version.lua" +Module "xml.xml2lua" "../../vendor/xml/xml2lua.lua" +Module "xml.xmlhandler.dom" "../../vendor/xml/xmlhandler/dom.lua" +Module "xml.xmlhandler.print" "../../vendor/xml/xmlhandler/print.lua" +Module "xml.xmlhandler.tree" "../../vendor/xml/xmlhandler/tree.lua" +Module "xml.XmlParser" "../../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 "esphome.ble.address" "../../src/esphome/ble/address.lua" +Module "esphome.ble.company_identifiers" "../../src/esphome/ble/company_identifiers.lua" +Module "esphome.ble.coordinator.proxy_registry" "../../src/esphome/ble/coordinator/proxy_registry.lua" +Module "esphome.ble.coordinator.device_registry" "../../src/esphome/ble/coordinator/device_registry.lua" +Module "esphome.ble.coordinator.router" "../../src/esphome/ble/coordinator/router.lua" +Module "esphome.ble.coordinator.presence_tracker" "../../src/esphome/ble/coordinator/presence_tracker.lua" +Module "esphome.ble.coordinator.proxy_scanner_node" "../../src/esphome/ble/coordinator/proxy_scanner_node.lua" +Module "esphome.ble.parsers.govee" "../../src/esphome/ble/parsers/govee.lua" +Module "esphome.ble.parsers.advertisement" "../../src/esphome/ble/parsers/advertisement.lua" +Module "esphome.ble.scanner" "../../src/esphome/ble/scanner.lua" +Module "esphome.ble.scanner_node" "../../src/esphome/ble/scanner_node.lua" +Module "esphome.ble.scanner_properties" "../../src/esphome/ble/scanner_properties.lua" +Module "esphome.ble.local_scanner_node" "../../src/esphome/ble/local_scanner_node.lua" +Module "esphome.ble.parsers.switchbot" "../../src/esphome/ble/parsers/switchbot.lua" +Module "esphome.ble.uuid" "../../src/esphome/ble/uuid.lua" +Module "esphome.capabilities.bluetooth_proxy" "../../src/esphome/capabilities/bluetooth_proxy.lua" +Module "esphome.client" "../../src/esphome/client.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 "esphome.proto_schema" "../../src/esphome/proto_schema.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" +Module "lib.aes_ctr" "../../src/lib/aes_ctr.lua" +Module "lib.bindings" "../../src/lib/bindings.lua" +Module "lib.conditionals" "../../src/lib/conditionals.lua" +Module "lib.events" "../../src/lib/events.lua" +#ifndef DRIVERCENTRAL +Module "lib.github-updater" "../../src/lib/github-updater.lua" +#endif +Module "lib.http" "../../src/lib/http.lua" +Module "lib.logging" "../../src/lib/logging.lua" +Module "lib.persist" "../../src/lib/persist.lua" +Module "lib.utils" "../../src/lib/utils.lua" +Module "lib.values" "../../src/lib/values.lua" + +Module "constants" "../../src/constants.lua" #ifdef DRIVERCENTRAL Output "../../../../dist/drivercentral/esphome.lua" diff --git a/drivers/esphome/www/documentation/index.md b/drivers/esphome/www/documentation/index.md index 9112cd5..fdf5ccf 100644 --- a/drivers/esphome/www/documentation/index.md +++ b/drivers/esphome/www/documentation/index.md @@ -1,4 +1,4 @@ -[copyright]: # "Copyright 2025 Finite Labs, LLC. All rights reserved." +[copyright]: # "Copyright 2026 Finite Labs, LLC. All rights reserved." + +ESPHome Bluetooth Coordinator + +--- + +# Overview + + + +> DISCLAIMER: This software is neither affiliated with nor endorsed by either +> Control4 or ESPHome. + + + +The ESPHome Bluetooth Coordinator aggregates multiple ESPHome Bluetooth proxies +to provide intelligent BLE device management across your home. This enables: + +- **RSSI-based routing** - Commands are automatically routed to the proxy with + the strongest signal for each device +- **Automatic failover** - Failed connections retry through alternate proxies +- **Room-level presence tracking** - Track which room BLE devices are in, + similar to ESPresense or Room Assistant + +## Architecture Overview + +The following diagram shows how the drivers work together: + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ BLE Proxy 1 │ │ BLE Proxy 2 │ │ BLE Proxy 3 │ +│ (Living Room) │ │ (Kitchen) │ │ (Bedroom) │ +└────────┬────────┘ └────────┬────────┘ └────────┬────────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ ESPHome Driver │ │ ESPHome Driver │ │ ESPHome Driver │ +│ (Instance 1) │ │ (Instance 2) │ │ (Instance 3) │ +└────────┬────────┘ └────────┬────────┘ └────────┬────────┘ + │ │ │ + └───────────────────────┼───────────────────────┘ + │ + ▼ + ┌──────────────────────────┐ + │ Bluetooth Coordinator │ + │ (RSSI routing, presence │ + │ tracking, failover) │ + └────────────┬─────────────┘ + │ + ┌───────────────────┼───────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌────────────────┐ ┌─────────────────┐ ┌────────────────┐ + │ ESPHome BTHome │ │ESPHome SwitchBot│ │ ESPHome Govee │ + │ Sub-driver │ │ Sub-driver │ │ Sub-driver │ + └────────────────┘ └─────────────────┘ └────────────────┘ +``` + +Each Bluetooth proxy connects to its own ESPHome driver instance. The Bluetooth +Coordinator aggregates all proxies and routes commands to the optimal proxy +based on signal strength. Sub-drivers handle protocol-specific communication +with BLE devices. + +# Index + +
+ +- [System Requirements](#system-requirements) +- [Features](#features) +- [Installer Setup](#installer-setup) + + - [DriverCentral Cloud Setup](#drivercentral-cloud-setup) + + - [Driver Installation](#driver-installation) + - [Coordinator Setup](#coordinator-setup) +- [Driver Properties](#driver-properties) + + - [Cloud Settings](#cloud-settings) + + - [Driver Settings](#driver-settings) + - [Coordinator Status](#coordinator-status) + - [Device Settings](#device-settings) + - [Presence Settings](#presence-settings) +- [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). + +
+ +# Driver Actions + +#### Reset Driver + +> ⚠️ This will clear all device selections, presence tracking configuration, and +> dynamic bindings. + +Resets the coordinator to its initial state. Use this if you need to start fresh +or are experiencing issues. + +**Parameters:** + +- **Are You Sure?** [ **_No_** | Yes ] - Confirmation to reset the driver. + +
+ +# Presence Tracking + +## How It Works + +1. **Signal Collection** - Each proxy reports the RSSI (signal strength) when it + sees a tracked device's BLE advertisement +2. **Signal Smoothing** - RSSI values are smoothed using an exponential moving + average to filter noise +3. **Room Determination** - The device is considered to be in the room of the + proxy with the strongest smoothed signal +4. **Anti-Flapping** - Multiple safeguards prevent rapid room changes when + devices are near room boundaries + +## Anti-Flapping Algorithm + +The presence tracker uses a multi-layer approach to prevent false room changes: + +| Layer | Purpose | How It Works | +| -------------- | -------------------------- | --------------------------------------- | +| RSSI Smoothing | Filter signal noise | Exponential moving average on raw RSSI | +| Hysteresis | Prevent boundary flapping | New room must be significantly stronger | +| Dwell Time | Confirm sustained presence | New room must be "best" for N seconds | +| Away Timeout | Graceful departure | No signal for N seconds = away | + +**Example scenario:** Device is in Kitchen (RSSI -55), walks toward Living Room: + +1. Living Room proxy sees device at -58 → No change (not 6dB better than -55) +2. Device moves further, Living Room at -50 → Pending transition starts +3. 3 seconds later, still -50 → Still dwelling +4. 5 seconds later, still consistently better → **Transition to Living Room** + +## Unsupported Devices + +The following devices **cannot currently** be tracked for presence: + +- **Apple devices** (iPhone, iPad, Apple Watch, AirPods) - These devices use + randomized MAC addresses for privacy, making them unidentifiable via standard + BLE scanning +- **Android devices with MAC randomization enabled** - Some newer Android + devices also randomize their MAC address + +> **Note:** Apple device support via IRK (Identity Resolving Key) enrollment is +> planned for a future release. + +**Recommended alternatives for presence tracking:** + +- Dedicated BLE beacons (iBeacon, Eddystone) +- Tile or similar Bluetooth trackers +- Fitness bands/smartwatches that don't randomize MAC +- Any BLE device with a consistent, static MAC address + +## Events + +The coordinator creates dynamic events for Control4 programming. Per-device and +per-room events are created automatically when devices are tracked and rooms are +discovered. Display names include a unique suffix (MAC address for devices, room +ID for rooms) to avoid conflicts. + +### Per-Device Events + +| Event | Description | +| --------------------------- | ---------------------------------------------------- | +| [Device] [MAC] Home | Fired when device arrives home (first advertisement) | +| [Device] [MAC] Away | Fired when device leaves home (away timeout expired) | +| [Device] [MAC] Entered Room | Fired when device enters a room | +| [Device] [MAC] Left Room | Fired when device leaves a room | + +### Per-Room Events + +| Event | Description | +| ------------------------ | ---------------------------------------------- | +| [Room] [RoomID] Occupied | Fired when room goes from empty to occupied | +| [Room] [RoomID] Empty | Fired when last tracked device leaves the room | + +### Generic Events + +| Event | Description | +| ----------------------- | --------------------------------------------- | +| Any Device Entered Room | Fired when any tracked device enters any room | +| Any Device Left Room | Fired when any tracked device leaves any room | + +> **Tip:** Use the "Last Presence" variables with generic events to determine +> which device and room triggered the event. + +## Variables + +Variable names for per-device and per-room variables include a unique suffix +(MAC address for devices, room ID for rooms) to avoid conflicts. + +### Per-Device Variables + +| Variable | Type | Description | +| -------------------------------- | ------ | -------------------------------------------------------------------- | +| Presence [Device] [MAC] Room | STRING | Current room name, "Home" (below RSSI threshold), or "Away" | +| Presence [Device] [MAC] Distance | NUMBER | Estimated distance in meters | +| Presence [Device] [MAC] RSSI | NUMBER | Current signal strength in dBm (useful for tuning Minimum Room RSSI) | + +### Per-Room Variables + +| Variable | Type | Description | +| ------------------------------ | ------ | ------------------------------------ | +| [Room] [RoomID] Occupied | STRING | "true" or "false" | +| [Room] [RoomID] Occupant Count | NUMBER | Number of tracked devices in room | +| [Room] [RoomID] Occupants | STRING | Comma-separated list of device names | + +### Last Event Context Variables + +These are updated before generic events fire, allowing programming to identify +which device/room triggered the event: + +| Variable | Type | Description | +| --------------------------- | ------ | ---------------------------- | +| Last Presence Device MAC | STRING | MAC address of device | +| Last Presence Device Name | STRING | Display name of device | +| Last Presence Room | STRING | Room name | +| Last Presence Previous Room | STRING | Previous room (or "Away") | +| Last Presence Distance | NUMBER | Estimated distance in meters | + +## Contact Sensor Bindings + +The coordinator creates dynamic CONTACT_SENSOR bindings for integration with +Control4's occupancy features. Binding names include a unique suffix (MAC +address for devices, room ID for rooms) to avoid conflicts. + +### Room Occupancy Bindings + +- **[Room] [RoomID] Occupied** - CLOSED when room has occupants, OPENED when + empty + +### Device Presence Bindings + +- **[Device] [MAC] Present** - CLOSED when device is home, OPENED when away + +
+ +# Best Practices + +## Proxy Placement for Presence Tracking + +Effective presence tracking requires thoughtful proxy placement. Unlike simple +device control, presence detection relies on comparing signal strength across +multiple proxies to determine location. + +### Placement Guidelines + +| Guideline | Reason | +| ------------------------------ | ------------------------------------------------------ | +| **One proxy per tracked room** | Each room you want presence detection in needs a proxy | +| **Central placement** | Maximizes signal strength from anywhere in the room | +| **Chest height or higher** | Reduces signal blockage from furniture | +| **Away from metal objects** | Metal causes reflections and unstable RSSI | + +### What to Avoid + +- **Proxies too close together** - If two proxies are in the same room or very + close, they'll report similar RSSI values, making room detection unreliable +- **Behind large metal objects** - Refrigerators, filing cabinets, and metal + shelving block and reflect signals unpredictably +- **Inside cabinets or enclosures** - Blocks signal and reduces range +- **Near WiFi routers** - RF interference degrades BLE reception + +### Recommended Proxy Density + +| Home Size | Recommended Proxies | +| --------------------------- | ------------------- | +| Small apartment | 2-3 | +| Typical 2-story home | 4-6 | +| Large home / concrete walls | 6+ | + +> **Tip:** More proxies generally improve accuracy, but proxies placed too close +> together (same room) can actually hurt presence detection by providing +> redundant, similar readings. + +### Sparse Coverage Scenarios + +If you only have proxies in some rooms (not every room), consider using the +**Minimum Room RSSI** setting to prevent false room assignments: + +- Without this setting, a device in an unmonitored room (e.g., hallway, + bathroom) will be assigned to whichever proxy has the strongest signal, even + if that proxy is far away +- Set **Minimum Room RSSI** to `-75` so devices must be within reasonable range + of a proxy to be assigned to that room +- Devices with weak signals will show as "Home" but not in any specific room + +**Example:** You have proxies in Kitchen and Living Room, but not in the hallway +between them. Without a minimum RSSI threshold, a person standing in the hallway +might constantly flip between Kitchen and Living Room. With threshold set to +`-75`, they'd show as "Home" without a room assignment until they actually enter +a monitored room. + +## Understanding the Anti-Flapping Settings + +The presence tracking settings work together to prevent false room changes. +Here's how to tune them for your environment: + +### RSSI Smoothing Factor (0.1 - 0.5) + +Controls how quickly the system responds to signal changes. + +- **Lower values (0.1-0.2)** - Smoother, more stable; ignores brief signal + spikes; better for most homes +- **Higher values (0.3-0.5)** - Faster response; may cause flapping in + environments with signal reflections + +**When to increase:** If room changes feel sluggish or delayed. + +**When to decrease:** If presence flaps between rooms when you're stationary. + +### Room Change Hysteresis (3 - 15 dBm) + +The signal improvement required before changing rooms. For example, if +hysteresis is 6 dBm and the current room shows -60 dBm, the new room must show +at least -54 dBm before a transition is considered. + +- **Lower values (3-5)** - More sensitive; faster room transitions +- **Higher values (8-15)** - More stable; requires definitive signal difference + +**When to increase:** Flapping between adjacent rooms, especially near doorways. + +**When to decrease:** Room changes don't register even when moving +significantly. + +### Room Change Dwell Time (2 - 30 seconds) + +How long the new room must have the best signal before committing to the change. + +- **Lower values (2-5)** - Faster room detection; may cause brief incorrect + states +- **Higher values (10-30)** - Very stable; won't register quick pass-throughs + +**When to increase:** Brief signal spikes cause incorrect room changes. + +**When to decrease:** Entering a room takes too long to register. + +### Recommended Starting Points + +| Environment | Smoothing | Hysteresis | Dwell Time | +| -------------------- | --------- | ---------- | ---------- | +| Open floor plan | 0.2 | 8 dBm | 5 sec | +| Many small rooms | 0.15 | 6 dBm | 3 sec | +| Concrete/brick walls | 0.2 | 5 dBm | 4 sec | +| Flapping issues | 0.1 | 10 dBm | 8 sec | + +## Performance Considerations + +### Scaling Limits + +Adding more proxies and presence devices increases processing load: + +| Component | Recommended Limit | Impact When Exceeded | +| ------------------- | ----------------- | ----------------------------------------- | +| Proxies | 8-10 | Increased network traffic, slower updates | +| Presence devices | 10-15 | Higher CPU usage, potential delays | +| BLE devices (total) | 30-50 | Advertisement processing bottleneck | + +### Network Traffic + +Each proxy forwards BLE advertisements to the coordinator. In busy environments: + +- **Duplicate advertisements** - Multiple proxies seeing the same device each + send updates +- **High-frequency advertisers** - Some devices advertise multiple times per + second +- **Presence calculations** - Each advertisement triggers RSSI processing + +**Mitigation:** The coordinator filters advertisements to only process devices +you've explicitly selected. Unselected devices are ignored. + +
+ +# Troubleshooting + +## Presence Flapping Between Rooms + +**Symptoms:** Device rapidly switches between two or more rooms, or constantly +shows the wrong room. + +**Common Causes:** + +1. **Proxies too close together** - Two proxies reporting similar RSSI values +2. **Device near room boundary** - Signal strength is similar to multiple + proxies +3. **Signal reflections** - Metal objects causing unpredictable RSSI +4. **Smoothing too aggressive** - System responding to noise + +**Solutions:** + +1. Increase **Room Change Hysteresis** to 8-12 dBm +2. Increase **Dwell Time** to 8-10 seconds +3. Decrease **RSSI Smoothing Factor** to 0.1 +4. Relocate proxies further apart or reposition away from metal +5. Check that each room has a dedicated proxy + +## Device Shows Wrong Room + +**Symptoms:** Device consistently shows in the wrong room. + +**Common Causes:** + +1. **Proxy misconfigured** - Wrong room assignment in ESPHome driver +2. **Antenna differences** - One proxy has stronger/weaker antenna +3. **Environmental factors** - Walls, furniture affecting signal path + +**Solutions:** + +1. Verify "Bluetooth Proxy Room" is set correctly on each ESPHome driver +2. If one proxy consistently "wins," it may have a better antenna; consider + relocating other proxies closer to their rooms +3. Add a proxy to the room where the device should be detected + +## Device Shows "Away" When Home + +**Symptoms:** Device intermittently or constantly shows as away despite being +home. + +**Common Causes:** + +1. **Device not advertising** - Bluetooth disabled or device in deep sleep +2. **Out of range** - No proxy close enough to receive signal +3. **Away Timeout too short** - Gaps in advertisements trigger away state + +**Solutions:** + +1. Verify device has Bluetooth enabled and is advertising +2. Add a proxy closer to where the device usually is +3. Increase **Away Timeout** to 180-300 seconds +4. Check that the device has a static MAC address (see Unsupported Devices) + +## Slow Room Transitions + +**Symptoms:** Moving between rooms takes too long to register. + +**Solutions:** + +1. Decrease **Dwell Time** to 2-3 seconds +2. Decrease **Room Change Hysteresis** to 4-5 dBm +3. Increase **RSSI Smoothing Factor** to 0.25-0.3 + +## High CPU or Network Usage + +**Symptoms:** Control4 system slowdown, network congestion. + +**Solutions:** + +1. Reduce the number of tracked presence devices +2. Remove devices from "Select Bluetooth Devices" that don't need tracking +3. Consider using fewer proxies if you have more than 6-8 + + + +# Developer Information + +

+Finite Labs +

+ +Copyright © 2026 Finite Labs LLC + +All information contained herein is, and remains the property of Finite Labs LLC +and its suppliers, if any. The intellectual and technical concepts contained +herein are proprietary to Finite Labs LLC and its suppliers and may be covered +by U.S. and Foreign Patents, patents in process, and are protected by trade +secret or copyright law. Dissemination of this information or reproduction of +this material is strictly forbidden unless prior written permission is obtained +from Finite Labs LLC. For the latest information, please visit +https://drivercentral.io/platforms/control4-drivers/utility/esphome + + + +# Support + + + +If you have any questions or issues integrating this driver with Control4 or +ESPHome, you can contact us at +[driver-support@finitelabs.com](mailto:driver-support@finitelabs.com) or +call/text us at [+1 (949) 371-5805](tel:+19493715805). + + + +If you have any questions or issues integrating this driver with Control4, you +can file an issue on GitHub: + +https://github.com/finitelabs/control4-esphome/issues/new + +Buy Me A Coffee + + + +
+ + diff --git a/drivers/esphome_bluetooth_coordinator/www/icons/device_lg.png b/drivers/esphome_bluetooth_coordinator/www/icons/device_lg.png new file mode 100644 index 0000000000000000000000000000000000000000..ab1aee760e2559b04d95992815ee380a35650306 GIT binary patch literal 1365 zcmV-b1*-aqP)#CwS_8Fe~1>X2BJSiR1`$&AN^6KSSUn6QuK!)Dq@NWVni{KQmyp~8Y^fZ zb~l@p#$>aPNoG9XoxQtvW@q9icwqLPdmi68_ug|KOVlC_toX?dPk*f$v75>xZ{#7= z^Pl}=gV5^I>dW&>PzDf8@d-7UO<&F1b*qIT6o!cBfk1e8q@F&UlPXtU`KU(4noWhB zU3AIlJRwgQgxE{u1Or3Uh~v?8-N^w4dScIky|!&>k#JbAFD+RT-d?QCNnW;osY#tP zCC%|Q#L@%vwcIpirg&Rg=rDIenTbeS$4nyWxsN7?r6-1mPocC=vc zHjEHW`hC%&*RF~-`(O~bH-7hnTfK7tW#z=%pP~&SJvTBlj$~8MAI%0CxrgRh>Aa|8 zs41@#)^6d|XQoxT3ea@aS-Lyh9J{bz;-wuaIc2B<34qMtclU?ZZ+KTCW6ndY8QY$u z4~vddjSJ+(iJCMKEiYCCVM6-4o=07pM(4nf6=$Dt|NR>hZ2&Cpa_8wmIeK-BbS}@8 zr~zQR>PR|lb}zaVyIKj4f&iH8yNm)l*cU5pZs7*N)qu8Qbv^#_#VDfu$m}VWx=OZX z6`<&P>`*4L?}-^<7&f-%hf9GVZRrW;{ZEzDD1tX@r;B%Hw=v&56Cig11Ud@@{_z26 z0;tY+WWr>4Mm@}Byo{5|10n!aX9SZ6A9&EZuttdx(K$X81t}l^FnG4>j>h=~!0Uh3 zsarXKUzn(psf^w#{PjrbfolelXiRmMyvdvAp-YYCm&EQ;NK0RIkh>bHuTjp5mf2&I zCY`+CRCtPq5a&=AR1@2|ZG4?!_vB>}dS}XPxY(`nt02q=0{k?;Oha1|!W_x(`ld_; zIf)8LAixaYE)!*E2)J?FGtfc^1i<+3J5y;{dO2GY=m1W{cJ9AXi^sa9&7N)7<%dpv zUzB}st7*EuICT6p%clT<51FX72U(+Pm}@8Ka26<)(QdSoM9=mgOJz??J)9r<`(WnT zw{W4U39RJn(jn8;kj~qtC%&)$GK3%Cv63$M7#IQBE59BMio9St8JfR=w*%Q=H#xvA|6h(>GmY6MQrR}%tADC zUW>xi=Gh6FAsAqAxkCd3)AwB7$K9$tK%#os*dP!( z_!*e2)2|B_?adi3Yus9guZ;OGfod5tHgXqF*%}f6X=K~pdT=y8zqqKLyTbhxR?Qm! z5>$~NFXG7r{0stRajv#Gp654tCcISRBWTVHs8X*n_t%ubD}U4opO^4>_-#juJ}8*& zyTEJQ=&vb(Yttd>TW|P2ecwB0()m@+0OrfGW((t?bkD5;iMqr;@V~PDUwmKGF1w(P zHCr5G9qtQJcqj(0U-3GMbBogUD@aw=`64rzLh!>w2QP&xf6FQ;_XYXMr0)M{UCI9o XR0-mhJ)z%)00000NkvXXu0mjfpH_8n literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..28937f37813991f3987ad1c8127b0c149f14b961 GIT binary patch literal 654 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!6#=yXs@#Xw?AcrO0(btiIVPik{pF~z5-y$`_ zGtJkRK?}&{U|?*|WMBcZ7=c&{h#44|7BIm@1QswO*dR&qxxpuZN*Fy|978MwOM}j4 z3kM3!zj=51E@|_LUYiA~Kd5oIYB||fYzSz{I=b8ZzmDzA{SE<-!*9)=AO2B8?A8J^ z&PQppP2zVyK69J@zL0E1{=W3O{AY{?rt@pRx2%4C+*rs_n5~+5?ZOp~);x?G-44HK zJNj#q#_sT#1FQar9oLVU6n{nFVbIm4-eR*ia&t)W17P=dSAd#Io1-c7O1D zqbkwMb!e?9`zo_ThYxG2O#Zm*u%py8m-w}}FU%L(!}ENRQB;N}YwG$nd5ja^t+%}? z+J0AlS)9*N6S2sHm(P8@VQbCv?NFoSgr*erimm+hvLdqiSF;x-U--3?&HLVS_SIa= zIGG>K>bEv7Tk0srXm`@s{JeERaipADi(ULFMz5T;pGD7c-unBcbLLqshpxm(#XI?{ z&P+FAS$gel)#8_x>rAEQ8g=yuOx|(*rv6){@0{r$qyq}?pRmZR*>(3W=e^ngV&XIu zJ~Lgf*WIti?`7#~VsrD{^f|w~n9?4Z-h1iSe1E>XVg1$FhnL?s^nU7O#G+ksG54iw zO6>I-Zo8s_vyO9BI`6yv{Csix{yQ08Z%xu>6;b_|`b_oLDNdV^9cS;q*IT^v@NyTA zNr$U{@$*)u$1ie8^ay<|$K^Z<}B2 iI6FsiHMdH9);IRq=MG4JeGr%lN)(>1elF{r5}E)E-w<8^ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..bae48c3909f51541d2d6540f4a3b548e8df14f0b GIT binary patch literal 4295 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

oLOl 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 Hu0mjfP~8&b literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c5af34ef4fbec99681c5bf966d2634adda1512ae GIT binary patch literal 4989 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..dc799f0461988c521d1ed59fad00f172166917d2 GIT binary patch literal 14546 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 KMNUMnLSTXgLzMLZ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9138b4bc4fb3a7c2f9adb97830ab3249e7b169b1 GIT binary patch literal 29845 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 DKC?s< literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1998bdcfa893266360cdc1f1942f7ebbe015bdd3 GIT binary patch literal 3426 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 Ef{mJf)c^nh literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3b767d2a376782b9b9cbf5882efd7a2297140913 GIT binary patch literal 3768 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 + + + + + + diff --git a/drivers/esphome_bthome/driver.lua b/drivers/esphome_bthome/driver.lua new file mode 100644 index 0000000..f4709e2 --- /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.ResetDriver(params) + log:trace("EC.ResetDriver(%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..70e26c0 --- /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 + + + + +