Merge remote-tracking branch 'upstream/dev' into integration

This commit is contained in:
J. Nick Koston
2026-04-23 14:53:33 -05:00
39 changed files with 2258 additions and 165 deletions
+1 -1
View File
@@ -1 +1 @@
230a1e6c847ef029966ae195f215e29f8fcb21edd127253c6427e6b8e388b54c
1b1ce6324c50c4595703c7df0a8a479b4fe84b71ff1a8793cce1a16f17a33324
+25 -5
View File
@@ -41,16 +41,36 @@ function generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo,
let message = `${TOO_BIG_MARKER}\n### 📦 Pull Request Size\n\n`;
message +=
`Hey @${prAuthor}, thanks for the contribution! Just a heads up, ` +
`this PR is on the large side `;
if (tooManyLabels && tooManyChanges) {
message += `This PR is too large with ${nonTestChanges} line changes (excluding tests) and affects ${originalLabelCount} different components/areas.`;
message +=
`(${nonTestChanges} line changes excluding tests, across ` +
`${originalLabelCount} different components/areas)`;
} else if (tooManyLabels) {
message += `This PR affects ${originalLabelCount} different components/areas.`;
message +=
`(it touches ${originalLabelCount} different components/areas)`;
} else {
message += `This PR is too large with ${nonTestChanges} line changes (excluding tests).`;
message += `(${nonTestChanges} line changes excluding tests)`;
}
message += ` Please consider breaking it down into smaller, focused PRs to make review easier and reduce the risk of conflicts.\n\n`;
message += `For guidance on breaking down large PRs, see: https://developers.esphome.io/contributing/submitting-your-work/#how-to-approach-large-submissions`;
message += `, which makes it harder for maintainers to review.\n\n`;
message +=
`Smaller, focused PRs tend to be reviewed much faster since they ` +
`fit into the short gaps between other maintainer work; large ones ` +
`often have to wait for a rare long uninterrupted block of time. ` +
`If you can break this up into smaller pieces that can be reviewed ` +
`independently, it will almost certainly land faster overall.\n\n`;
message +=
`Before putting more time in, it's also worth popping into ` +
`\`#devs\` on [Discord](https://esphome.io/chat) so we can help ` +
`you scope things and flag anything already in flight.\n\n`;
message +=
`For more details (including how to split the work up), see: ` +
`https://developers.esphome.io/contributing/submitting-your-work/` +
`#how-to-approach-large-submissions`;
messages.push(message);
}
+1 -1
View File
@@ -600,6 +600,6 @@ esphome/components/xxtea/* @clydebarrow
esphome/components/zephyr/* @tomaszduda23
esphome/components/zephyr_mcumgr/ota/* @tomaszduda23
esphome/components/zhlt01/* @cfeenstra1024
esphome/components/zigbee/* @tomaszduda23
esphome/components/zigbee/* @luar123 @tomaszduda23
esphome/components/zio_ultrasonic/* @kahrendt
esphome/components/zwave_proxy/* @kbx81
+108 -12
View File
@@ -39,6 +39,7 @@ from esphome.const import (
CONF_MDNS,
CONF_MQTT,
CONF_NAME,
CONF_NAME_ADD_MAC_SUFFIX,
CONF_OTA,
CONF_PASSWORD,
CONF_PLATFORM,
@@ -71,6 +72,7 @@ from esphome.util import (
run_external_process,
safe_print,
)
from esphome.zeroconf import discover_mdns_devices
_LOGGER = logging.getLogger(__name__)
@@ -204,6 +206,64 @@ def _resolve_with_cache(address: str, purpose: Purpose) -> list[str]:
return [address]
def _populate_mdns_cache(hosts_to_addresses: dict[str, list[str]]) -> None:
"""Store discovered ``host -> [ips]`` entries in ``CORE.address_cache``.
Ensures ``CORE.address_cache`` exists, then records each mDNS hostname so
the downstream resolution path (``resolve_ip_address``) can skip opening a
second Zeroconf client.
"""
from esphome.address_cache import AddressCache
if CORE.address_cache is None:
CORE.address_cache = AddressCache()
for host, addresses in hosts_to_addresses.items():
if addresses:
_LOGGER.debug("Caching mDNS result %s -> %s", host, addresses)
CORE.address_cache.add_mdns_addresses(host, addresses)
def _discover_mac_suffix_devices() -> list[str] | None:
"""Discover ``<name>-<mac>.local`` devices and cache their IPs.
Returns:
- ``None`` when discovery isn't applicable (``name_add_mac_suffix`` off,
mDNS disabled, or ``CORE.address`` is already an IP). Callers should
then fall back to whatever default OTA address they normally use.
- ``[]`` when discovery ran but found nothing. Callers should NOT fall
back to the base name: with ``name_add_mac_suffix`` enabled, the base
name by definition doesn't exist on the network.
- A non-empty sorted list of ``.local`` hostnames on success.
Populates ``CORE.address_cache`` so downstream resolution (``espota2`` or
``aioesphomeapi`` via :func:`_resolve_network_devices`) reuses the IPs we
already have without opening a second Zeroconf client.
"""
if not (has_name_add_mac_suffix() and has_mdns() and has_non_ip_address()):
return None
_LOGGER.info("Discovering devices...")
if not (discovered := discover_mdns_devices(CORE.name)):
_LOGGER.warning(
"No devices matching '%s-<mac>.local' were discovered.", CORE.name
)
return []
_populate_mdns_cache(discovered)
return list(discovered)
def _ota_hostnames_for_default(purpose: Purpose) -> list[str]:
"""Return OTA hostname(s) for the ``--device OTA`` / default-resolve path.
When ``name_add_mac_suffix`` is enabled, returns discovered
``<name>-<mac>.local`` hostnames (possibly empty — in which case the
caller should not fall back to the base name). Otherwise falls back to
the cache-resolved ``CORE.address``.
"""
if (discovered := _discover_mac_suffix_devices()) is not None:
return discovered
return _resolve_with_cache(CORE.address, purpose)
def choose_upload_log_host(
default: list[str] | str | None,
check_default: str | None,
@@ -242,14 +302,14 @@ def choose_upload_log_host(
resolved.append("MQTT")
if has_api() and has_non_ip_address() and has_resolvable_address():
resolved.extend(_resolve_with_cache(CORE.address, purpose))
resolved.extend(_ota_hostnames_for_default(purpose))
elif purpose == Purpose.UPLOADING:
if has_ota() and has_mqtt_ip_lookup():
resolved.append("MQTTIP")
if has_ota() and has_non_ip_address() and has_resolvable_address():
resolved.extend(_resolve_with_cache(CORE.address, purpose))
resolved.extend(_ota_hostnames_for_default(purpose))
else:
resolved.append(device)
if not resolved:
@@ -281,22 +341,29 @@ def choose_upload_log_host(
elif bootsel.permission_error:
bootsel_permission_error = True
def add_ota_options() -> None:
"""Add OTA options, using mDNS discovery if name_add_mac_suffix is enabled."""
if (discovered := _discover_mac_suffix_devices()) is not None:
# Discovery was applicable. Use whatever we found — on empty,
# intentionally skip the base-name fallback since with
# name_add_mac_suffix on, the base name doesn't exist on the net.
for host in discovered:
options.append((f"Over The Air ({host})", host))
elif has_resolvable_address():
options.append((f"Over The Air ({CORE.address})", CORE.address))
if has_mqtt_ip_lookup():
options.append(("Over The Air (MQTT IP lookup)", "MQTTIP"))
if purpose == Purpose.LOGGING:
if has_mqtt_logging():
mqtt_config = CORE.config[CONF_MQTT]
options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT"))
if has_api():
if has_resolvable_address():
options.append((f"Over The Air ({CORE.address})", CORE.address))
if has_mqtt_ip_lookup():
options.append(("Over The Air (MQTT IP lookup)", "MQTTIP"))
add_ota_options()
elif purpose == Purpose.UPLOADING and has_ota():
if has_resolvable_address():
options.append((f"Over The Air ({CORE.address})", CORE.address))
if has_mqtt_ip_lookup():
options.append(("Over The Air (MQTT IP lookup)", "MQTTIP"))
add_ota_options()
# Show helpful BOOTSEL instructions for RP2040 when no BOOTSEL device is found
if (
@@ -407,7 +474,17 @@ def has_resolvable_address() -> bool:
return not CORE.address.endswith(".local")
def mqtt_get_ip(config: ConfigType, username: str, password: str, client_id: str):
def has_name_add_mac_suffix() -> bool:
"""Check if name_add_mac_suffix is enabled in the config."""
if CORE.config is None:
return False
esphome_config = CORE.config.get(CONF_ESPHOME, {})
return esphome_config.get(CONF_NAME_ADD_MAC_SUFFIX, False)
def mqtt_get_ip(
config: ConfigType, username: str, password: str, client_id: str
) -> list[str]:
from esphome import mqtt
return mqtt.get_esphome_device_ip(config, username, password, client_id)
@@ -420,6 +497,9 @@ def _resolve_network_devices(
This function filters the devices list to:
- Replace MQTT/MQTTIP magic strings with actual IP addresses via MQTT lookup
- Expand hostnames that are already in ``CORE.address_cache`` to their
cached IPs so downstream code (e.g. aioesphomeapi) doesn't open a second
Zeroconf client to resolve them
- Deduplicate addresses while preserving order
- Only resolve MQTT once even if multiple MQTT strings are present
- If MQTT resolution fails, log a warning and continue with other devices
@@ -444,13 +524,29 @@ def _resolve_network_devices(
mqtt_ips = mqtt_get_ip(
config, args.username, args.password, args.client_id
)
network_devices.extend(mqtt_ips)
# pylint can't infer mqtt_get_ip's return through its
# lazy ``from esphome import mqtt`` import, so it flags
# the genexpr below.
network_devices.extend(
addr
for addr in mqtt_ips # pylint: disable=not-an-iterable
if addr not in network_devices
)
except EsphomeError as err:
_LOGGER.warning(
"MQTT IP discovery failed (%s), will try other devices if available",
err,
)
mqtt_resolved = True
continue
# If the hostname is already in the address cache (e.g. populated by
# mDNS discovery), substitute the cached IPs so aioesphomeapi doesn't
# open its own Zeroconf to re-resolve it.
if CORE.address_cache and (cached := CORE.address_cache.get_addresses(device)):
network_devices.extend(
addr for addr in cached if addr not in network_devices
)
elif device not in network_devices:
# Regular network address or IP - add if not already present
network_devices.append(device)
+11
View File
@@ -101,6 +101,17 @@ class AddressCache:
"""Check if any cache entries exist."""
return bool(self.mdns_cache or self.dns_cache)
def add_mdns_addresses(self, hostname: str, addresses: list[str]) -> None:
"""Store resolved mDNS addresses for ``hostname`` in the cache.
Callers that discover ``.local`` hosts (e.g. via mDNS browse) can use
this to avoid a second resolution round-trip during the upload path.
No-op when ``addresses`` is empty.
"""
if not addresses:
return
self.mdns_cache[normalize_hostname(hostname)] = addresses
@classmethod
def from_cli_args(
cls, mdns_args: Iterable[str], dns_args: Iterable[str]
+56
View File
@@ -0,0 +1,56 @@
"""Helpers for running an async coroutine from sync code via a daemon thread.
``asyncio.run(coro())`` in the main thread blocks until the loop's cleanup
cycle finishes, which can add hundreds of milliseconds before the caller
receives the result. Running the loop in a daemon thread lets the caller
observe the result as soon as the coroutine completes while cleanup finishes
in the background.
"""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
import threading
from typing import Generic, TypeVar
_T = TypeVar("_T")
class AsyncThreadRunner(threading.Thread, Generic[_T]):
"""Run an async coroutine in a daemon thread and expose its result.
The runner catches all exceptions from the coroutine and stores them in
``exception`` so ``event`` is always set — this prevents callers waiting
on ``event`` from hanging forever when the coroutine crashes.
Typical usage::
runner = AsyncThreadRunner(lambda: my_coro(arg))
runner.start()
if not runner.event.wait(timeout=5.0):
... # timed out
if runner.exception is not None:
raise runner.exception
result = runner.result
"""
def __init__(self, coro_factory: Callable[[], Awaitable[_T]]) -> None:
super().__init__(daemon=True)
self._coro_factory = coro_factory
self.result: _T | None = None
self.exception: BaseException | None = None
self.event = threading.Event()
async def _runner(self) -> None:
try:
self.result = await self._coro_factory()
except Exception as exc: # pylint: disable=broad-except
# Capture all exceptions so ``event`` is always set — otherwise a
# crash would hang the waiter forever.
self.exception = exc
finally:
self.event.set()
def run(self) -> None:
asyncio.run(self._runner())
+11 -7
View File
@@ -93,13 +93,17 @@ async def async_run_logs(
config, raw_line, backtrace_state=backtrace_state
)
# Safe to fall back to plaintext here: the log stream is strictly
# one-way from device to client, and this code never accepts commands
# or acts on any message the device sends. The worst an on-path
# attacker can do is show fabricated log lines, which is why
# aioesphomeapi logs a warning that the device's identity cannot be
# verified. Never mirror this opt-in for any connection that sends
# data to the device or uses Home Assistant actions.
# Safe to fall back to plaintext here only for this diagnostics use
# case: the stream is one-way from device to client, and this code
# never accepts commands or acts on any message the device sends.
# An on-path attacker could still both inject fabricated log lines
# and passively read the device's log output (and any state data
# delivered when subscribe_states is enabled), so this does lose
# confidentiality as well as authentication/integrity. That tradeoff
# is acceptable for operator-visible logs, which aioesphomeapi also
# warns may come from an unverified device. Never mirror this opt-in
# for any connection that sends data to the device or uses Home
# Assistant actions.
stop = await async_run(
cli,
on_log,
+91 -18
View File
@@ -3,26 +3,42 @@ from typing import Any
from esphome import automation, core
import esphome.codegen as cg
from esphome.components.esp32 import only_on_variant
from esphome.components.esp32.const import (
VARIANT_ESP32C5,
VARIANT_ESP32C6,
VARIANT_ESP32H2,
)
from esphome.components.nrf52.boards import BOOTLOADER_CONFIG, Section
from esphome.components.zephyr import zephyr_add_pm_static, zephyr_data
from esphome.components.zephyr.const import KEY_BOOTLOADER
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_INTERNAL, CONF_NAME
from esphome.const import CONF_ID, CONF_INTERNAL, CONF_MODEL, CONF_NAME
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.types import ConfigType
from .const import (
CONF_ON_JOIN,
CONF_POWER_SOURCE,
CONF_REPORT,
CONF_ROUTER,
CONF_WIPE_ON_BOOT,
KEY_ZIGBEE,
POWER_SOURCE,
REPORT,
ZigbeeComponent,
zigbee_ns,
)
from .const_zephyr import (
CONF_IEEE802154_VENDOR_OUI,
CONF_MAX_EP_NUMBER,
CONF_ON_JOIN,
CONF_POWER_SOURCE,
CONF_WIPE_ON_BOOT,
CONF_ZIGBEE_ID,
KEY_EP_NUMBER,
KEY_ZIGBEE,
POWER_SOURCE,
ZigbeeComponent,
zigbee_ns,
)
from .zigbee_esp32 import (
final_validate_esp32,
validate_binary_sensor_esp32,
zigbee_require_vfs_select,
)
from .zigbee_zephyr import (
zephyr_binary_sensor,
@@ -33,11 +49,11 @@ from .zigbee_zephyr import (
_LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@tomaszduda23"]
CODEOWNERS = ["@luar123", "@tomaszduda23"]
def zigbee_set_core_data(config: ConfigType) -> ConfigType:
if zephyr_data()[KEY_BOOTLOADER] in BOOTLOADER_CONFIG:
if CORE.is_nrf52 and zephyr_data()[KEY_BOOTLOADER] in BOOTLOADER_CONFIG:
zephyr_add_pm_static(
[Section("empty_after_zboss_offset", 0xF4000, 0xC000, "flash_primary")]
)
@@ -45,7 +61,15 @@ def zigbee_set_core_data(config: ConfigType) -> ConfigType:
return config
BINARY_SENSOR_SCHEMA = cv.Schema({}).extend(zephyr_binary_sensor)
BINARY_SENSOR_SCHEMA = cv.Schema(
{
cv.Optional(CONF_REPORT): cv.All(
cv.requires_component("zigbee"),
cv.requires_component("esp32"),
cv.enum(REPORT, lower=True),
)
}
).extend(zephyr_binary_sensor)
SENSOR_SCHEMA = cv.Schema({}).extend(zephyr_sensor)
SWITCH_SCHEMA = cv.Schema({}).extend(zephyr_switch)
NUMBER_SCHEMA = cv.Schema({}).extend(zephyr_number)
@@ -54,16 +78,27 @@ CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(CONF_ID): cv.declare_id(ZigbeeComponent),
cv.Optional(CONF_ON_JOIN): automation.validate_automation(single=True),
cv.Optional(CONF_WIPE_ON_BOOT, default=False): cv.All(
cv.Optional(CONF_MODEL, default=CORE.name): cv.All(
cv.string, cv.Length(max=31)
),
cv.OnlyWith(CONF_ROUTER, "esp32", default=False): cv.All(
cv.requires_component("esp32"),
cv.boolean,
),
cv.Optional(CONF_ON_JOIN): cv.All(
cv.requires_component("nrf52"),
automation.validate_automation(single=True),
),
cv.OnlyWith(CONF_WIPE_ON_BOOT, "nrf52", default=False): cv.All(
cv.Any(
cv.boolean,
cv.one_of(*["once"], lower=True),
),
cv.requires_component("nrf52"),
),
cv.Optional(CONF_POWER_SOURCE, default="DC_SOURCE"): cv.enum(
POWER_SOURCE, upper=True
cv.OnlyWith(CONF_POWER_SOURCE, "nrf52", default="DC_SOURCE"): cv.All(
cv.enum(POWER_SOURCE, upper=True),
cv.requires_component("nrf52"),
),
cv.Optional(CONF_IEEE802154_VENDOR_OUI): cv.All(
cv.Any(
@@ -74,12 +109,27 @@ CONFIG_SCHEMA = cv.All(
),
}
).extend(cv.COMPONENT_SCHEMA),
zigbee_require_vfs_select,
zigbee_set_core_data,
cv.only_with_framework("zephyr"),
cv.Any(
cv.All(
cv.only_on_esp32,
only_on_variant(
supported=[
VARIANT_ESP32H2,
VARIANT_ESP32C5,
VARIANT_ESP32C6,
]
),
),
cv.only_with_framework("zephyr"),
),
)
def validate_number_of_ep(config: ConfigType) -> None:
def validate_number_of_ep(config: ConfigType) -> ConfigType:
if not CORE.is_nrf52:
return config
if KEY_ZIGBEE not in CORE.data:
raise cv.Invalid("At least one zigbee device need to be included")
count = len(CORE.data[KEY_ZIGBEE][KEY_EP_NUMBER])
@@ -90,9 +140,12 @@ def validate_number_of_ep(config: ConfigType) -> None:
if count > CONF_MAX_EP_NUMBER and not CORE.testing_mode:
raise cv.Invalid(f"Maximum number of end points is {CONF_MAX_EP_NUMBER}")
return config
FINAL_VALIDATE_SCHEMA = cv.All(
validate_number_of_ep,
final_validate_esp32,
)
@@ -103,6 +156,10 @@ async def to_code(config: ConfigType) -> None:
from .zigbee_zephyr import zephyr_to_code
await zephyr_to_code(config)
if CORE.is_esp32:
from .zigbee_esp32 import esp32_to_code
await esp32_to_code(config)
async def setup_binary_sensor(entity: cg.MockObj, config: ConfigType) -> None:
@@ -148,7 +205,7 @@ async def setup_number(
def consume_endpoint(config: ConfigType) -> ConfigType:
if not config.get(CONF_ZIGBEE_ID) or config.get(CONF_INTERNAL):
if not config.get(CONF_ZIGBEE_ID):
return config
if CONF_NAME in config and " " in config[CONF_NAME]:
_LOGGER.warning(
@@ -163,18 +220,34 @@ def consume_endpoint(config: ConfigType) -> ConfigType:
def validate_binary_sensor(config: ConfigType) -> ConfigType:
if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL):
return config
if CORE.is_esp32:
return validate_binary_sensor_esp32(config)
return consume_endpoint(config)
def validate_sensor(config: ConfigType) -> ConfigType:
if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL):
return config
if CORE.is_esp32:
return config
return consume_endpoint(config)
def validate_switch(config: ConfigType) -> ConfigType:
if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL):
return config
if CORE.is_esp32:
return config
return consume_endpoint(config)
def validate_number(config: ConfigType) -> ConfigType:
if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL):
return config
if CORE.is_esp32:
return config
return consume_endpoint(config)
+3
View File
@@ -1,6 +1,9 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_ZIGBEE
#ifdef USE_ESP32
#include "zigbee_esp32.h"
#endif
#ifdef USE_NRF52
#include "zigbee_zephyr.h"
#endif
+32
View File
@@ -0,0 +1,32 @@
import esphome.codegen as cg
zigbee_ns = cg.esphome_ns.namespace("zigbee")
ZigbeeComponent = zigbee_ns.class_("ZigbeeComponent", cg.Component)
ZigbeeAttribute = zigbee_ns.class_("ZigbeeAttribute", cg.Component)
BinaryAttrs = zigbee_ns.struct("BinaryAttrs")
AnalogAttrs = zigbee_ns.struct("AnalogAttrs")
AnalogAttrsOutput = zigbee_ns.struct("AnalogAttrsOutput")
report = zigbee_ns.enum("ZigbeeReportT")
REPORT = {
"coordinator": report.ZIGBEE_REPORT_COORDINATOR,
"enable": report.ZIGBEE_REPORT_ENABLE,
"force": report.ZIGBEE_REPORT_FORCE,
}
CONF_ON_JOIN = "on_join"
CONF_WIPE_ON_BOOT = "wipe_on_boot"
CONF_REPORT = "report"
CONF_ROUTER = "router"
CONF_POWER_SOURCE = "power_source"
POWER_SOURCE = {
"UNKNOWN": "ZB_ZCL_BASIC_POWER_SOURCE_UNKNOWN",
"MAINS_SINGLE_PHASE": "ZB_ZCL_BASIC_POWER_SOURCE_MAINS_SINGLE_PHASE",
"MAINS_THREE_PHASE": "ZB_ZCL_BASIC_POWER_SOURCE_MAINS_THREE_PHASE",
"BATTERY": "ZB_ZCL_BASIC_POWER_SOURCE_BATTERY",
"DC_SOURCE": "ZB_ZCL_BASIC_POWER_SOURCE_DC_SOURCE",
"EMERGENCY_MAINS_CONST": "ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_CONST",
"EMERGENCY_MAINS_TRANSF": "ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_TRANSF",
}
KEY_ZIGBEE = "zigbee"
+35
View File
@@ -0,0 +1,35 @@
import esphome.codegen as cg
DEVICE_TYPE = "device_type"
ROLE = "role"
CONF_MAX_EP_NUMBER = 239
CONF_NUM = "num"
CONF_CLUSTERS = "clusters"
CONF_ATTRIBUTES = "attributes"
CONF_ENDPOINT = "endpoint"
CONF_CLUSTER = "cluster"
SCALE = "scale"
CONF_ATTRIBUTE_ID = "attribute_id"
KEY_BS_EP = "binary_sensor_ep"
ha_standard_devices = cg.esphome_ns.enum("zb_ha_standard_devs_e")
DEVICE_ID = {
"RANGE_EXTENDER": ha_standard_devices.ZB_HA_RANGE_EXTENDER_DEVICE_ID,
"SIMPLE_SENSOR": ha_standard_devices.ZB_HA_SIMPLE_SENSOR_DEVICE_ID,
"CUSTOM_ATTR": ha_standard_devices.ZB_HA_CUSTOM_ATTR_DEVICE_ID,
}
cluster_id = cg.esphome_ns.enum("esp_zb_zcl_cluster_id_t")
CLUSTER_ID = {
"BASIC": cluster_id.ESP_ZB_ZCL_CLUSTER_ID_BASIC,
"BINARY_INPUT": cluster_id.ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT,
}
cluster_role = cg.esphome_ns.enum("esp_zb_zcl_cluster_role_t")
CLUSTER_ROLE = {
"SERVER": cluster_role.ESP_ZB_ZCL_CLUSTER_SERVER_ROLE,
}
attr_type = cg.esphome_ns.enum("esp_zb_zcl_attr_type_t")
ATTR_TYPE = {
"BOOL": attr_type.ESP_ZB_ZCL_ATTR_TYPE_BOOL,
"8BITMAP": attr_type.ESP_ZB_ZCL_ATTR_TYPE_8BITMAP,
"CHAR_STRING": attr_type.ESP_ZB_ZCL_ATTR_TYPE_CHAR_STRING,
}
-21
View File
@@ -1,33 +1,12 @@
import esphome.codegen as cg
zigbee_ns = cg.esphome_ns.namespace("zigbee")
ZigbeeComponent = zigbee_ns.class_("ZigbeeComponent", cg.Component)
BinaryAttrs = zigbee_ns.struct("BinaryAttrs")
AnalogAttrs = zigbee_ns.struct("AnalogAttrs")
AnalogAttrsOutput = zigbee_ns.struct("AnalogAttrsOutput")
CONF_MAX_EP_NUMBER = 8
CONF_ZIGBEE_ID = "zigbee_id"
CONF_ON_JOIN = "on_join"
CONF_WIPE_ON_BOOT = "wipe_on_boot"
CONF_ZIGBEE_BINARY_SENSOR = "zigbee_binary_sensor"
CONF_ZIGBEE_SENSOR = "zigbee_sensor"
CONF_ZIGBEE_SWITCH = "zigbee_switch"
CONF_ZIGBEE_NUMBER = "zigbee_number"
CONF_POWER_SOURCE = "power_source"
POWER_SOURCE = {
"UNKNOWN": "ZB_ZCL_BASIC_POWER_SOURCE_UNKNOWN",
"MAINS_SINGLE_PHASE": "ZB_ZCL_BASIC_POWER_SOURCE_MAINS_SINGLE_PHASE",
"MAINS_THREE_PHASE": "ZB_ZCL_BASIC_POWER_SOURCE_MAINS_THREE_PHASE",
"BATTERY": "ZB_ZCL_BASIC_POWER_SOURCE_BATTERY",
"DC_SOURCE": "ZB_ZCL_BASIC_POWER_SOURCE_DC_SOURCE",
"EMERGENCY_MAINS_CONST": "ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_CONST",
"EMERGENCY_MAINS_TRANSF": "ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_TRANSF",
}
CONF_IEEE802154_VENDOR_OUI = "ieee802154_vendor_oui"
# Keys for CORE.data storage
KEY_ZIGBEE = "zigbee"
KEY_EP_NUMBER = "ep_number"
# External ZBOSS SDK types (just strings for codegen)
+2 -1
View File
@@ -6,7 +6,8 @@ from esphome.core import CORE
from esphome.types import ConfigType
from .. import consume_endpoint
from ..const_zephyr import CONF_ZIGBEE_ID, zigbee_ns
from ..const import zigbee_ns
from ..const_zephyr import CONF_ZIGBEE_ID
from ..zigbee_zephyr import (
ZigbeeClusterDesc,
ZigbeeComponent,
@@ -0,0 +1,89 @@
#include "zigbee_attribute_esp32.h"
#include "esphome/core/log.h"
#include "esphome/core/defines.h"
#ifdef USE_ESP32
#ifdef USE_ZIGBEE
namespace esphome::zigbee {
static const char *const TAG = "zigbee.attribute";
void ZigbeeAttribute::set_attr_() {
if (!this->zb_->is_connected()) {
return;
}
if (esp_zb_lock_acquire(10 / portTICK_PERIOD_MS)) {
esp_zb_zcl_status_t state = esp_zb_zcl_set_attribute_val(this->endpoint_id_, this->cluster_id_, this->role_,
this->attr_id_, this->value_p_, false);
if (this->force_report_) {
this->report_(true);
}
this->set_attr_requested_ = false;
// Check for error
if (state != ESP_ZB_ZCL_STATUS_SUCCESS) {
ESP_LOGE(TAG, "Setting attribute failed, ZCL status: %u", static_cast<unsigned>(state));
}
esp_zb_lock_release();
}
}
void ZigbeeAttribute::report_(bool has_lock) {
if (!this->zb_->is_connected()) {
return;
}
if (has_lock or esp_zb_lock_acquire(10 / portTICK_PERIOD_MS)) {
esp_zb_zcl_report_attr_cmd_t cmd = {
.address_mode = ESP_ZB_APS_ADDR_MODE_16_ENDP_PRESENT,
.direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_CLI,
};
cmd.zcl_basic_cmd.dst_addr_u.addr_short = 0x0000;
cmd.zcl_basic_cmd.dst_endpoint = 1;
cmd.zcl_basic_cmd.src_endpoint = this->endpoint_id_;
cmd.clusterID = this->cluster_id_;
cmd.attributeID = this->attr_id_;
esp_zb_zcl_report_attr_cmd_req(&cmd);
if (!has_lock) {
esp_zb_lock_release();
}
}
}
esp_zb_zcl_reporting_info_t ZigbeeAttribute::get_reporting_info() {
esp_zb_zcl_reporting_info_t reporting_info = {
.direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_SRV,
.ep = this->endpoint_id_,
.cluster_id = this->cluster_id_,
.cluster_role = this->role_,
.attr_id = this->attr_id_,
.manuf_code = ESP_ZB_ZCL_ATTR_NON_MANUFACTURER_SPECIFIC,
};
reporting_info.dst.profile_id = ESP_ZB_AF_HA_PROFILE_ID;
reporting_info.u.send_info.min_interval = 10; /*!< Actual minimum reporting interval */
reporting_info.u.send_info.max_interval = 0; /*!< Actual maximum reporting interval */
reporting_info.u.send_info.def_min_interval = 10; /*!< Default minimum reporting interval */
reporting_info.u.send_info.def_max_interval = 0; /*!< Default maximum reporting interval */
reporting_info.u.send_info.delta.s16 = 0; /*!< Actual reportable change */
return reporting_info;
}
void ZigbeeAttribute::set_report(bool force) {
this->report_enabled = true;
this->force_report_ = force;
}
void ZigbeeAttribute::loop() {
if (this->set_attr_requested_) {
this->set_attr_();
}
if (!this->set_attr_requested_) {
this->disable_loop();
}
}
} // namespace esphome::zigbee
#endif
#endif
@@ -0,0 +1,90 @@
#pragma once
#include <type_traits>
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#ifdef USE_ESP32
#ifdef USE_ZIGBEE
#include "esp_zigbee_core.h"
#include "zigbee_esp32.h"
#ifdef USE_BINARY_SENSOR
#include "esphome/components/binary_sensor/binary_sensor.h"
#endif
namespace esphome::zigbee {
enum ZigbeeReportT {
ZIGBEE_REPORT_COORDINATOR,
ZIGBEE_REPORT_ENABLE,
ZIGBEE_REPORT_FORCE,
};
class ZigbeeAttribute : public Component {
public:
ZigbeeAttribute(ZigbeeComponent *parent, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id,
uint8_t attr_type, float scale, uint8_t max_size)
: zb_(parent),
endpoint_id_(endpoint_id),
cluster_id_(cluster_id),
role_(role),
attr_id_(attr_id),
attr_type_(attr_type),
scale_(scale),
max_size_(max_size) {}
void loop() override;
template<typename T> void add_attr(T value);
esp_zb_zcl_reporting_info_t get_reporting_info();
template<typename T> void set_attr(const T &value);
uint8_t attr_type() { return attr_type_; }
void set_report(bool force);
#ifdef USE_BINARY_SENSOR
template<typename T> void connect(binary_sensor::BinarySensor *sensor);
#endif
bool report_enabled = false;
protected:
void set_attr_();
void report_(bool has_lock);
ZigbeeComponent *zb_;
uint8_t endpoint_id_;
uint16_t cluster_id_;
uint8_t role_;
uint16_t attr_id_;
uint8_t attr_type_;
uint8_t max_size_;
float scale_;
void *value_p_{nullptr};
bool set_attr_requested_{false};
bool force_report_{false};
};
template<typename T> void ZigbeeAttribute::add_attr(T value) {
// Attribute type does never change and add_attr is only called once during startup, so this is safe.
// For now we need to support only simple numeric/bool types for (binary) sensors.
// For strings and arrays we would need to allocate a buffer of the maximum size.
this->value_p_ = (void *) (new T);
this->zb_->add_attr(this, this->endpoint_id_, this->cluster_id_, this->role_, this->attr_id_, this->max_size_,
std::move(value));
}
template<typename T> void ZigbeeAttribute::set_attr(const T &value) {
*static_cast<T *>(this->value_p_) = value;
this->set_attr_requested_ = true;
this->enable_loop();
}
#ifdef USE_BINARY_SENSOR
template<typename T> void ZigbeeAttribute::connect(binary_sensor::BinarySensor *sensor) {
sensor->add_on_state_callback([this](bool value) { this->set_attr((T) (this->scale_ * value)); });
}
#endif
} // namespace esphome::zigbee
#endif
#endif
@@ -0,0 +1,70 @@
from typing import Any
import esphome.config_validation as cv
from esphome.const import CONF_DEVICE, CONF_ID, CONF_TYPE
from .const import CONF_REPORT, REPORT
from .const_esp32 import (
CLUSTER_ROLE,
CONF_ATTRIBUTE_ID,
CONF_ATTRIBUTES,
CONF_CLUSTERS,
CONF_MAX_EP_NUMBER,
CONF_NUM,
DEVICE_TYPE,
ROLE,
)
# endpoint configs:
ep_configs: dict[str, dict[str, Any]] = {
"binary_input": {
DEVICE_TYPE: "SIMPLE_SENSOR",
CONF_CLUSTERS: [
{
CONF_ID: "BINARY_INPUT",
ROLE: CLUSTER_ROLE["SERVER"],
CONF_ATTRIBUTES: [
{
CONF_ATTRIBUTE_ID: 0x55,
CONF_TYPE: "BOOL",
CONF_REPORT: REPORT["enable"],
CONF_DEVICE: None,
},
{
CONF_ATTRIBUTE_ID: 0x51,
CONF_TYPE: "BOOL",
},
{
CONF_ATTRIBUTE_ID: 0x6F,
CONF_TYPE: "8BITMAP",
},
{
CONF_ATTRIBUTE_ID: 0x1C,
CONF_TYPE: "CHAR_STRING",
},
],
},
],
},
}
def create_ep(ep_list: list[dict[str, Any]], router: bool) -> list[dict[str, Any]]:
# create dummy endpoint if list is empty
if not ep_list:
ep_type = "CUSTOM_ATTR"
if router:
ep_type = "RANGE_EXTENDER"
ep_list = [
{
DEVICE_TYPE: ep_type,
}
]
# enumerate endpoints
for i, ep in enumerate(ep_list, 1):
ep[CONF_NUM] = i
if len(ep_list) > CONF_MAX_EP_NUMBER:
raise cv.Invalid(
f"Too many devices. Zigbee can define only {CONF_MAX_EP_NUMBER} endpoints."
)
return ep_list
+313
View File
@@ -0,0 +1,313 @@
#include "esphome/core/defines.h"
#ifdef USE_ESP32
#ifdef USE_ZIGBEE
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_check.h"
#include "nvs_flash.h"
#include "zigbee_attribute_esp32.h"
#include "zigbee_esp32.h"
#include "esphome/core/application.h"
#include "esphome/core/log.h"
#include "zigbee_helpers_esp32.h"
#ifdef USE_WIFI
#include "esp_coexist.h"
#endif
namespace esphome::zigbee {
static const char *const TAG = "zigbee";
static ZigbeeComponent *global_zigbee = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
uint8_t *get_zcl_string(const char *str, uint8_t max_size, bool use_max_size) {
uint8_t str_len = static_cast<uint8_t>(strlen(str));
uint8_t zcl_str_size = use_max_size ? max_size : std::min(max_size, str_len);
uint8_t *zcl_str = new uint8_t[zcl_str_size + 1]; // string + length octet
zcl_str[0] = zcl_str_size;
// Initialize payload to avoid leaking uninitialized heap contents and clamp copy length
memset(zcl_str + 1, 0, zcl_str_size);
uint8_t copy_len = std::min(zcl_str_size, str_len);
if (copy_len > 0) {
memcpy(zcl_str + 1, str, copy_len);
}
return zcl_str;
}
static void bdb_start_top_level_commissioning_cb(uint8_t mode_mask) {
if (esp_zb_bdb_start_top_level_commissioning(mode_mask) != ESP_OK) {
ESP_LOGE(TAG, "Start network steering failed!");
}
}
void esp_zb_app_signal_handler(esp_zb_app_signal_t *signal_struct) {
static uint8_t steering_retry_count = 0;
uint32_t *p_sg_p = signal_struct->p_app_signal;
esp_err_t err_status = signal_struct->esp_err_status;
esp_zb_app_signal_type_t sig_type = (esp_zb_app_signal_type_t) *p_sg_p;
esp_zb_zdo_signal_leave_params_t *leave_params = NULL;
switch (sig_type) {
case ESP_ZB_ZDO_SIGNAL_SKIP_STARTUP:
ESP_LOGD(TAG, "Zigbee stack initialized");
esp_zb_bdb_start_top_level_commissioning(ESP_ZB_BDB_MODE_INITIALIZATION);
break;
case ESP_ZB_BDB_SIGNAL_DEVICE_FIRST_START:
case ESP_ZB_BDB_SIGNAL_DEVICE_REBOOT:
if (err_status == ESP_OK) {
ESP_LOGD(TAG, "Device started up in %sfactory-reset mode", esp_zb_bdb_is_factory_new() ? "" : "non ");
global_zigbee->started = true;
if (esp_zb_bdb_is_factory_new()) {
ESP_LOGD(TAG, "Start network steering");
esp_zb_bdb_start_top_level_commissioning(ESP_ZB_BDB_MODE_NETWORK_STEERING);
} else {
ESP_LOGD(TAG, "Device rebooted");
global_zigbee->connected = true;
}
} else {
ESP_LOGE(TAG, "FIRST_START. Device started up in %sfactory-reset mode with an error %d (%s)",
esp_zb_bdb_is_factory_new() ? "" : "non ", err_status, esp_err_to_name(err_status));
ESP_LOGW(TAG, "Failed to initialize Zigbee stack (status: %s)", esp_err_to_name(err_status));
esp_zb_scheduler_alarm((esp_zb_callback_t) bdb_start_top_level_commissioning_cb, ESP_ZB_BDB_MODE_INITIALIZATION,
1000);
}
break;
case ESP_ZB_BDB_SIGNAL_STEERING:
if (err_status == ESP_OK) {
steering_retry_count = 0;
ESP_LOGI(TAG, "Joined network successfully (PAN ID: 0x%04hx, Channel:%d)", esp_zb_get_pan_id(),
esp_zb_get_current_channel());
global_zigbee->connected = true;
} else {
ESP_LOGI(TAG, "Network steering was not successful (status: %s)", esp_err_to_name(err_status));
if (steering_retry_count < 10) {
steering_retry_count++;
esp_zb_scheduler_alarm((esp_zb_callback_t) bdb_start_top_level_commissioning_cb,
ESP_ZB_BDB_MODE_NETWORK_STEERING, 1000);
} else {
esp_zb_scheduler_alarm((esp_zb_callback_t) bdb_start_top_level_commissioning_cb,
ESP_ZB_BDB_MODE_NETWORK_STEERING, 600 * 1000);
}
}
break;
case ESP_ZB_ZDO_SIGNAL_LEAVE:
leave_params = (esp_zb_zdo_signal_leave_params_t *) esp_zb_app_signal_get_params(p_sg_p);
if (leave_params->leave_type == ESP_ZB_NWK_LEAVE_TYPE_RESET) {
esp_zb_factory_reset();
}
break;
default:
ESP_LOGD(TAG, "ZDO signal: %s (0x%x), status: %s", esp_zb_zdo_signal_to_string(sig_type), sig_type,
esp_err_to_name(err_status));
break;
}
}
static esp_err_t zb_attribute_handler(const esp_zb_zcl_set_attr_value_message_t *message) {
esp_err_t ret = ESP_OK;
ESP_RETURN_ON_FALSE(message, ESP_FAIL, TAG, "Empty message");
ESP_RETURN_ON_FALSE(message->info.status == ESP_ZB_ZCL_STATUS_SUCCESS, ESP_ERR_INVALID_ARG, TAG,
"Received message: error status(%d)", message->info.status);
ESP_LOGD(TAG, "Received message: endpoint(%d), cluster(0x%x), attribute(0x%x), data size(%d)",
message->info.dst_endpoint, message->info.cluster, message->attribute.id, message->attribute.data.size);
return ret;
}
static esp_err_t zb_action_handler(esp_zb_core_action_callback_id_t callback_id, const void *message) {
esp_err_t ret = ESP_OK;
switch (callback_id) {
case ESP_ZB_CORE_SET_ATTR_VALUE_CB_ID:
ret = zb_attribute_handler((esp_zb_zcl_set_attr_value_message_t *) message);
break;
default:
ESP_LOGD(TAG, "Receive Zigbee action(0x%x) callback", callback_id);
break;
}
return ret;
}
void ZigbeeComponent::create_default_cluster(uint8_t endpoint_id, zb_ha_standard_devs_e device_id) {
esp_zb_cluster_list_t *cluster_list = esp_zb_zcl_cluster_list_create();
this->endpoint_list_[endpoint_id] =
std::tuple<zb_ha_standard_devs_e, esp_zb_cluster_list_t *>(device_id, cluster_list);
// Add basic cluster
this->add_cluster(endpoint_id, ESP_ZB_ZCL_CLUSTER_ID_BASIC, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE);
// Add identify cluster if not already present
if (esp_zb_cluster_list_get_cluster(cluster_list, ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE) ==
nullptr) {
this->add_cluster(endpoint_id, ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE);
}
}
void ZigbeeComponent::add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role) {
esp_zb_attribute_list_t *attr_list;
if (cluster_id == 0) {
attr_list = create_basic_cluster_();
} else {
attr_list = esphome_zb_default_attr_list_create(cluster_id);
}
this->attribute_list_[{endpoint_id, cluster_id, role}] = attr_list;
}
void ZigbeeComponent::set_basic_cluster(const char *model, const char *manufacturer) {
char date_buf[16];
time_t time_val = App.get_build_time();
struct tm *timeinfo = localtime(&time_val);
strftime(date_buf, sizeof(date_buf), "%Y%m%d %H%M%S", timeinfo);
this->basic_cluster_data_ = {
.model = get_zcl_string(model, 31),
.manufacturer = get_zcl_string(manufacturer, 31),
.date = get_zcl_string(date_buf, 15),
};
}
esp_zb_attribute_list_t *ZigbeeComponent::create_basic_cluster_() {
esp_zb_basic_cluster_cfg_t basic_cluster_cfg = {
.zcl_version = ESP_ZB_ZCL_BASIC_ZCL_VERSION_DEFAULT_VALUE,
.power_source = 0,
};
esp_zb_attribute_list_t *attr_list = esp_zb_basic_cluster_create(&basic_cluster_cfg);
esp_zb_basic_cluster_add_attr(attr_list, ESP_ZB_ZCL_ATTR_BASIC_MANUFACTURER_NAME_ID,
this->basic_cluster_data_.manufacturer);
esp_zb_basic_cluster_add_attr(attr_list, ESP_ZB_ZCL_ATTR_BASIC_MODEL_IDENTIFIER_ID, this->basic_cluster_data_.model);
esp_zb_basic_cluster_add_attr(attr_list, ESP_ZB_ZCL_ATTR_BASIC_DATE_CODE_ID, this->basic_cluster_data_.date);
return attr_list;
}
esp_err_t ZigbeeComponent::create_endpoint(uint8_t endpoint_id, zb_ha_standard_devs_e device_id,
esp_zb_cluster_list_t *esp_zb_cluster_list) {
esp_zb_endpoint_config_t endpoint_config = {.endpoint = endpoint_id,
.app_profile_id = ESP_ZB_AF_HA_PROFILE_ID,
.app_device_id = device_id,
.app_device_version = 0};
return esp_zb_ep_list_add_ep(this->esp_zb_ep_list_, esp_zb_cluster_list, endpoint_config);
}
static void esp_zb_task_(void *pvParameters) {
if (esp_zb_start(false) != ESP_OK) {
ESP_LOGE(TAG, "Could not setup Zigbee");
vTaskDelete(NULL);
}
esp_zb_set_node_descriptor_power_source(1);
esp_zb_stack_main_loop();
}
void ZigbeeComponent::setup() {
global_zigbee = this;
esp_zb_platform_config_t config = {
.radio_config = ESP_ZB_DEFAULT_RADIO_CONFIG(),
.host_config = ESP_ZB_DEFAULT_HOST_CONFIG(),
};
#ifdef USE_WIFI
if (esp_coex_wifi_i154_enable() != ESP_OK) {
this->mark_failed();
return;
}
#endif
if (esp_zb_platform_config(&config) != ESP_OK) {
this->mark_failed();
return;
}
esp_zb_zed_cfg_t zb_zed_cfg = {
.ed_timeout = ESP_ZB_ED_AGING_TIMEOUT_64MIN,
.keep_alive = ED_KEEP_ALIVE,
};
esp_zb_zczr_cfg_t zb_zczr_cfg = {
.max_children = MAX_CHILDREN,
};
esp_zb_cfg_t zb_nwk_cfg = {
.esp_zb_role = this->device_role_,
.install_code_policy = false,
};
#ifdef ZB_ROUTER_ROLE
zb_nwk_cfg.nwk_cfg.zczr_cfg = zb_zczr_cfg;
#else
zb_nwk_cfg.nwk_cfg.zed_cfg = zb_zed_cfg;
#endif
esp_zb_init(&zb_nwk_cfg);
esp_err_t ret;
for (auto const &[key, val] : this->attribute_list_) {
esp_zb_cluster_list_t *esp_zb_cluster_list = std::get<1>(this->endpoint_list_[std::get<0>(key)]);
ret = esphome_zb_cluster_list_add_or_update_cluster(std::get<1>(key), esp_zb_cluster_list, val, std::get<2>(key));
if (ret != ESP_OK) {
ESP_LOGE(TAG, "Could not create cluster 0x%04X with role %u: %s", std::get<1>(key), std::get<2>(key),
esp_err_to_name(ret));
} else {
ESP_LOGD(TAG, "Endpoint %u: Added cluster 0x%04X with role %u", std::get<0>(key), std::get<1>(key),
std::get<2>(key));
#ifdef ESPHOME_LOG_HAS_VERBOSE
// Dump cluster attributes in verbose log
ESP_LOGV(TAG, "Cluster 0x%04X attributes:", std::get<1>(key));
esp_zb_attribute_list_t *attr_list = val;
while (attr_list) {
esp_zb_zcl_attr_t *attr = &attr_list->attribute;
ESP_LOGV(TAG, " Attr ID: 0x%04X, Type: 0x%02X, Access: 0x%02X", attr->id, attr->type, attr->access);
attr_list = attr_list->next;
}
#endif
}
}
this->attribute_list_.clear();
for (auto const &[ep_id, dev_id] : this->endpoint_list_) {
if (create_endpoint(ep_id, std::get<0>(dev_id), std::get<1>(dev_id)) != ESP_OK) {
ESP_LOGE(TAG, "Could not create endpoint %u", ep_id);
}
}
this->endpoint_list_.clear();
if (esp_zb_device_register(this->esp_zb_ep_list_) != ESP_OK) {
ESP_LOGE(TAG, "Could not register the endpoint list");
this->mark_failed();
return;
}
esp_zb_core_action_handler_register(zb_action_handler);
if (esp_zb_set_primary_network_channel_set(ESP_ZB_TRANSCEIVER_ALL_CHANNELS_MASK) != ESP_OK) {
ESP_LOGE(TAG, "Could not setup Zigbee");
this->mark_failed();
return;
}
for (auto &[_, attribute] : this->attributes_) {
if (attribute->report_enabled) {
esp_zb_zcl_reporting_info_t reporting_info = attribute->get_reporting_info();
ESP_LOGD(TAG, "set reporting for cluster: %u", reporting_info.cluster_id);
if (esp_zb_zcl_update_reporting_info(&reporting_info) != ESP_OK) {
ESP_LOGE(TAG, "Could not configure reporting for attribute 0x%04X in cluster 0x%04X in endpoint %u",
reporting_info.attr_id, reporting_info.cluster_id, reporting_info.ep);
}
}
}
xTaskCreate(esp_zb_task_, "Zigbee_main", 4096, NULL, 24, NULL);
}
void ZigbeeComponent::dump_config() {
if (esp_zb_lock_acquire(10 / portTICK_PERIOD_MS)) {
ESP_LOGCONFIG(TAG,
"Zigbee\n"
" Model: %s\n"
" Router: %s\n"
" Device is joined to the network: %s\n"
" Current channel: %d\n"
" Short addr: 0x%04X\n"
" Short pan id: 0x%04X",
this->basic_cluster_data_.model, YESNO(this->device_role_ == ESP_ZB_DEVICE_TYPE_ROUTER),
YESNO(esp_zb_bdb_dev_joined()), esp_zb_get_current_channel(), esp_zb_get_short_address(),
esp_zb_get_pan_id());
esp_zb_lock_release();
} else {
ESP_LOGCONFIG(TAG,
"Zigbee\n"
" Model: %s\n"
" Router: %s\n",
this->basic_cluster_data_.model, YESNO(this->device_role_ == ESP_ZB_DEVICE_TYPE_ROUTER));
}
}
} // namespace esphome::zigbee
#endif
#endif
+134
View File
@@ -0,0 +1,134 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_ESP32
#ifdef USE_ZIGBEE
#include <map>
#include <tuple>
#include <atomic>
#include "esp_zigbee_core.h"
#include "zboss_api.h"
#include "ha/esp_zigbee_ha_standard.h"
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#include "zigbee_helpers_esp32.h"
#ifdef USE_BINARY_SENSOR
#include "esphome/components/binary_sensor/binary_sensor.h"
#endif
namespace esphome::zigbee {
/* Zigbee configuration */
static const uint16_t ED_KEEP_ALIVE = 3000; /* 3000 millisecond */
static const uint8_t MAX_CHILDREN = 10;
#define ESP_ZB_DEFAULT_RADIO_CONFIG() \
{ .radio_mode = ZB_RADIO_MODE_NATIVE, }
#define ESP_ZB_DEFAULT_HOST_CONFIG() \
{ .host_connection_mode = ZB_HOST_CONNECTION_MODE_NONE, }
uint8_t *get_zcl_string(const char *str, uint8_t max_size, bool use_max_size = false);
class ZigbeeAttribute;
class ZigbeeComponent : public Component {
public:
void setup() override;
void dump_config() override;
esp_err_t create_endpoint(uint8_t endpoint_id, zb_ha_standard_devs_e device_id,
esp_zb_cluster_list_t *esp_zb_cluster_list);
void set_basic_cluster(const char *model, const char *manufacturer);
void add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role);
void create_default_cluster(uint8_t endpoint_id, zb_ha_standard_devs_e device_id);
template<typename T>
void add_attr(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id,
uint8_t max_size, T value);
template<typename T>
void add_attr(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, uint8_t max_size, T value);
void factory_reset() {
esp_zb_lock_acquire(portMAX_DELAY);
esp_zb_factory_reset(); // triggers a reboot
esp_zb_lock_release();
}
bool is_started() { return this->started; }
bool is_connected() { return this->connected; }
std::atomic<bool> connected = false;
std::atomic<bool> started = false;
protected:
struct {
uint8_t *model;
uint8_t *manufacturer;
uint8_t *date;
} basic_cluster_data_;
#ifdef ZB_ED_ROLE
esp_zb_nwk_device_type_t device_role_ = ESP_ZB_DEVICE_TYPE_ED;
#else
esp_zb_nwk_device_type_t device_role_ = ESP_ZB_DEVICE_TYPE_ROUTER;
#endif
esp_zb_attribute_list_t *create_basic_cluster_();
template<typename T>
void add_attr_(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id,
T *value_p);
// endpoint_list_ and attribute_list_ are only used during setup and are cleared afterwards
// value tuple could be replaced by struct
std::map<uint8_t, std::tuple<zb_ha_standard_devs_e, esp_zb_cluster_list_t *>> endpoint_list_;
// key tuple could be replaced by single 32 bit int with bit fields for endpoint, cluster and role
std::map<std::tuple<uint8_t, uint16_t, uint8_t>, esp_zb_attribute_list_t *> attribute_list_;
// attributes_ will be used during operation in zigbee callbacks to update the attribute values and trigger
// automations
// key tuple could be replaced by single 64 (48) bit int with bit fields for endpoint, cluster, role and attr_id
std::map<std::tuple<uint8_t, uint16_t, uint8_t, uint16_t>, ZigbeeAttribute *> attributes_;
esp_zb_ep_list_t *esp_zb_ep_list_ = esp_zb_ep_list_create();
};
extern "C" void esp_zb_app_signal_handler(esp_zb_app_signal_t *signal_struct);
template<typename T>
void ZigbeeComponent::add_attr(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id,
uint8_t max_size, T value) {
this->add_attr<T>(nullptr, endpoint_id, cluster_id, role, attr_id, max_size, value);
}
template<typename T>
void ZigbeeComponent::add_attr(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role,
uint16_t attr_id, uint8_t max_size, T value) {
// The size byte of the zcl_str must be set to the maximum value,
// even though the initial string may be shorter.
if constexpr (std::is_same<T, std::string>::value) {
auto zcl_str = get_zcl_string(value.c_str(), max_size, true);
add_attr_(attr, endpoint_id, cluster_id, role, attr_id, zcl_str);
delete[] zcl_str;
} else if constexpr (std::is_convertible<T, const char *>::value) {
auto zcl_str = get_zcl_string(value, max_size, true);
add_attr_(attr, endpoint_id, cluster_id, role, attr_id, zcl_str);
delete[] zcl_str;
} else {
add_attr_(attr, endpoint_id, cluster_id, role, attr_id, &value);
}
}
template<typename T>
void ZigbeeComponent::add_attr_(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role,
uint16_t attr_id, T *value_p) {
esp_zb_attribute_list_t *attr_list = this->attribute_list_[{endpoint_id, cluster_id, role}];
esp_err_t ret = esphome_zb_cluster_add_or_update_attr(cluster_id, attr_list, attr_id, value_p);
if (attr != nullptr) {
this->attributes_[{endpoint_id, cluster_id, role, attr_id}] = attr;
}
}
} // namespace esphome::zigbee
#endif
#endif
+274
View File
@@ -0,0 +1,274 @@
import copy
import logging
import re
from typing import Any
import esphome.codegen as cg
from esphome.components.esp32 import (
CONF_PARTITIONS,
add_idf_component,
add_idf_sdkconfig_option,
add_partition,
require_vfs_select,
)
import esphome.config_validation as cv
from esphome.const import (
CONF_AP,
CONF_DEVICE,
CONF_ID,
CONF_MAX_LENGTH,
CONF_MODEL,
CONF_NAME,
CONF_TYPE,
CONF_VALUE,
CONF_WIFI,
)
from esphome.core import CORE
from esphome.coroutine import CoroPriority, coroutine_with_priority
import esphome.final_validate as fv
from esphome.types import ConfigType
from .const import CONF_REPORT, CONF_ROUTER, KEY_ZIGBEE, REPORT, ZigbeeAttribute
from .const_esp32 import (
ATTR_TYPE,
CLUSTER_ID,
CONF_ATTRIBUTE_ID,
CONF_ATTRIBUTES,
CONF_CLUSTERS,
CONF_NUM,
DEVICE_ID,
DEVICE_TYPE,
KEY_BS_EP,
ROLE,
SCALE,
)
from .zigbee_ep_esp32 import create_ep, ep_configs
_LOGGER = logging.getLogger(__name__)
def get_c_size(bits: str, options: list[int]) -> str:
return str([n for n in options if n >= int(bits)][0])
def get_c_type(attr_type: str) -> Any | None:
if attr_type == "BOOL":
return cg.bool_
if "STRING" in attr_type:
return cg.std_string
test = re.match(r"(^U?)(\d{1,2})(BITMAP$|BIT$|BIT_ENUM$|$)", attr_type)
if test and test.group(2):
return getattr(cg, "uint" + get_c_size(test.group(2), [8, 16, 32, 64]))
return None
def get_cv_by_type(attr_type: str) -> Any | None:
if attr_type == "BOOL":
return cv.boolean
if "STRING" in attr_type:
return cv.string
test = re.match(r"(^U?)(\d{1,2})(BITMAP$|BIT$|BIT_ENUM$|$)", attr_type)
if test and test.group(2):
return cv.positive_int
return None
def get_default_by_type(attr_type: str) -> str | bool | int:
if attr_type == "CHAR_STRING":
return ""
if attr_type == "BOOL":
return False
return 0
def validate_attributes(config: ConfigType) -> ConfigType:
if CONF_VALUE not in config:
config[CONF_VALUE] = get_default_by_type(config[CONF_TYPE])
config[CONF_VALUE] = get_cv_by_type(config[CONF_TYPE])(config[CONF_VALUE])
return config
def final_validate_esp32(config: ConfigType) -> ConfigType:
if not CORE.is_esp32:
return config
if CONF_WIFI in fv.full_config.get():
if config[CONF_ROUTER] and CONF_AP in fv.full_config.get()[CONF_WIFI]:
raise cv.Invalid(
"Only Zigbee End Device can be used together with a Wifi Access Point."
)
if CONF_AP in fv.full_config.get()[CONF_WIFI]:
_LOGGER.warning(
"Wifi Access Point might be unstable while Zigbee is active, use only as fallback."
)
elif config[CONF_ROUTER]:
_LOGGER.warning(
"The Zigbee Router might miss packets while Wifi is active and could destabilize "
"your network. Use only if Wifi is off most of the time."
)
if CONF_PARTITIONS in fv.full_config.get() and not isinstance(
fv.full_config.get()[CONF_PARTITIONS], list
):
with open(
CORE.relative_config_path(fv.full_config.get()[CONF_PARTITIONS]),
encoding="utf8",
) as f:
partitions_tab = f.read()
for partition, types in [
("zb_storage", {"type": "data", "subtype": "fat", "size": 0x4000}),
("zb_fct", {"type": "data", "subtype": "fat", "size": 0x1000}),
]:
if partition not in partitions_tab:
raise cv.Invalid(
f"Add '{partition}, {types['type']}, {types['subtype']}, , {types['size']},' to your custom partition table."
)
if not re.search(
rf"^{partition},\s*{types['type']},\s*{types['subtype']}",
partitions_tab,
re.MULTILINE,
):
raise cv.Invalid(
f"Partition '{partition}' in your custom partition table has wrong format. It should be: '{partition}, {types['type']}, {types['subtype']}, , {types['size']},'"
)
return config
def validate_binary_sensor_esp32(config: ConfigType) -> ConfigType:
ep = copy.deepcopy(ep_configs["binary_input"])
for cl in ep.get(CONF_CLUSTERS, []):
for attr in cl[CONF_ATTRIBUTES]:
if (
attr[CONF_ATTRIBUTE_ID] == 0x1C
and CONF_VALUE not in attr
and CONF_NAME in config
): # set name
name = (
config[CONF_NAME].encode("ascii", "ignore").decode()
) # or use unidecode
attr[CONF_VALUE] = str(name)
attr[CONF_MAX_LENGTH] = len(str(name))
if CONF_DEVICE in attr: # connect device
attr[CONF_DEVICE] = config[CONF_ID]
if CONF_REPORT in config:
attr[CONF_REPORT] = config[CONF_REPORT]
attr[CONF_ID] = cv.declare_id(ZigbeeAttribute)(None)
if "zb_attr_ids" not in config:
config["zb_attr_ids"] = []
config["zb_attr_ids"].append(attr[CONF_ID])
else:
attr[CONF_ID] = None
validate_attributes(attr)
zb_data = CORE.data.setdefault(KEY_ZIGBEE, {})
binary_sensor_ep: list[dict] = zb_data.setdefault(KEY_BS_EP, [])
binary_sensor_ep.append(ep)
return config
def zigbee_require_vfs_select(config: ConfigType) -> ConfigType:
"""Register VFS select requirement during config validation."""
# Zigbee uses esp_vfs_eventfd which requires VFS select support
if CORE.is_esp32:
require_vfs_select()
return config
@coroutine_with_priority(CoroPriority.WORKAROUNDS)
async def _zigbee_add_sdkconfigs(config: ConfigType) -> None:
"""Add sdkconfigs late so they can overwrite esp32 defaults"""
add_idf_sdkconfig_option("CONFIG_ZB_ENABLED", True)
if config.get(CONF_ROUTER):
add_idf_sdkconfig_option("CONFIG_ZB_ZCZR", True)
else:
add_idf_sdkconfig_option("CONFIG_ZB_ZED", True)
add_idf_sdkconfig_option("CONFIG_ZB_RADIO_NATIVE", True)
if CONF_WIFI in CORE.config:
add_idf_sdkconfig_option("CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE", 4096)
# The pre-built Zigbee library uses esp_log_default_level which requires
# dynamic log level control to be enabled
add_idf_sdkconfig_option("CONFIG_LOG_DYNAMIC_LEVEL_CONTROL", True)
async def attributes_to_code(
var: cg.Pvariable, ep_num: int, cl: dict[str, Any]
) -> None:
for attr in cl.get(CONF_ATTRIBUTES, []):
if attr.get(CONF_ID) is None:
cg.add(
var.add_attr(
ep_num,
CLUSTER_ID.get(cl[CONF_ID], cl[CONF_ID]),
cl[ROLE],
attr[CONF_ATTRIBUTE_ID],
attr.get(CONF_MAX_LENGTH, 0),
attr[CONF_VALUE],
)
)
continue
attr_var = cg.new_Pvariable(
attr[CONF_ID],
var,
ep_num,
CLUSTER_ID.get(cl[CONF_ID], cl[CONF_ID]),
cl[ROLE],
attr[CONF_ATTRIBUTE_ID],
ATTR_TYPE[attr[CONF_TYPE]],
attr.get(SCALE, 1),
attr.get(CONF_MAX_LENGTH, 0),
)
await cg.register_component(attr_var, attr)
cg.add(attr_var.add_attr(attr[CONF_VALUE]))
if CONF_REPORT in attr and attr[CONF_REPORT] in [
REPORT["enable"],
REPORT["force"],
]:
cg.add(attr_var.set_report(attr[CONF_REPORT] == REPORT["force"]))
if CONF_DEVICE in attr:
device = await cg.get_variable(attr[CONF_DEVICE])
template_arg = cg.TemplateArguments(get_c_type(attr[CONF_TYPE]))
cg.add(attr_var.connect(template_arg, device))
async def esp32_to_code(config: ConfigType) -> None:
add_idf_component(
name="espressif/esp-zboss-lib",
ref="1.6.4",
)
add_idf_component(
name="espressif/esp-zigbee-lib",
ref="1.6.8",
)
# add sdkconfigs later so they can overwrite esp32 defaults
CORE.add_job(_zigbee_add_sdkconfigs, config)
# add partitions for zigbee
add_partition("zb_storage", "data", "fat", 0x4000) # 16KB
add_partition("zb_fct", "data", "fat", 0x1000) # 4KB, minimum size
# create endpoints
zb_data = CORE.data.get(KEY_ZIGBEE, {})
binary_sensor_ep: list[dict] = zb_data.get(KEY_BS_EP, [])
ep_list = create_ep(binary_sensor_ep, config.get(CONF_ROUTER))
# setup zigbee components
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
cg.add(
var.set_basic_cluster(
config[CONF_MODEL],
"esphome",
)
)
for ep in ep_list:
cg.add(var.create_default_cluster(ep[CONF_NUM], DEVICE_ID[ep[DEVICE_TYPE]]))
for cl in ep.get(CONF_CLUSTERS, []):
cg.add(
var.add_cluster(
ep[CONF_NUM],
CLUSTER_ID.get(cl[CONF_ID], cl[CONF_ID]),
cl[ROLE],
)
)
await attributes_to_code(var, ep[CONF_NUM], cl)
@@ -0,0 +1,74 @@
#include "esphome/core/defines.h"
#ifdef USE_ESP32
#ifdef USE_ZIGBEE
#include "ha/esp_zigbee_ha_standard.h"
#include "zigbee_helpers_esp32.h"
esp_err_t esphome_zb_cluster_add_or_update_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list,
uint16_t attr_id, void *value_p) {
esp_err_t ret;
ret = esp_zb_cluster_update_attr(attr_list, attr_id, value_p);
if (ret != ESP_OK) {
ESP_LOGE("zigbee_helper", "Ignore previous attribute not found error");
ret = esphome_zb_cluster_add_attr(cluster_id, attr_list, attr_id, value_p);
}
if (ret != ESP_OK) {
ESP_LOGE("zigbee_helper", "Could not add attribute 0x%04X to cluster 0x%04X: %s", attr_id, cluster_id,
esp_err_to_name(ret));
}
return ret;
}
esp_err_t esphome_zb_cluster_list_add_or_update_cluster(uint16_t cluster_id, esp_zb_cluster_list_t *cluster_list,
esp_zb_attribute_list_t *attr_list, uint8_t role_mask) {
esp_err_t ret;
ret = esp_zb_cluster_list_update_cluster(cluster_list, attr_list, cluster_id, role_mask);
if (ret != ESP_OK) {
ESP_LOGE("zigbee_helper", "Ignore previous cluster not found error");
switch (cluster_id) {
case ESP_ZB_ZCL_CLUSTER_ID_BASIC:
ret = esp_zb_cluster_list_add_basic_cluster(cluster_list, attr_list, role_mask);
break;
case ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY:
ret = esp_zb_cluster_list_add_identify_cluster(cluster_list, attr_list, role_mask);
break;
case ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT:
ret = esp_zb_cluster_list_add_binary_input_cluster(cluster_list, attr_list, role_mask);
break;
default:
ret = esp_zb_cluster_list_add_custom_cluster(cluster_list, attr_list, role_mask);
}
}
return ret;
}
esp_zb_attribute_list_t *esphome_zb_default_attr_list_create(uint16_t cluster_id) {
switch (cluster_id) {
case ESP_ZB_ZCL_CLUSTER_ID_BASIC:
return esp_zb_basic_cluster_create(NULL);
case ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY:
return esp_zb_identify_cluster_create(NULL);
case ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT:
return esp_zb_binary_input_cluster_create(NULL);
default:
return esp_zb_zcl_attr_list_create(cluster_id);
}
}
esp_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list, uint16_t attr_id,
void *value_p) {
switch (cluster_id) {
case ESP_ZB_ZCL_CLUSTER_ID_BASIC:
return esp_zb_basic_cluster_add_attr(attr_list, attr_id, value_p);
case ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY:
return esp_zb_identify_cluster_add_attr(attr_list, attr_id, value_p);
case ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT:
return esp_zb_binary_input_cluster_add_attr(attr_list, attr_id, value_p);
default:
return ESP_FAIL;
}
}
#endif
#endif
@@ -0,0 +1,27 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_ESP32
#ifdef USE_ZIGBEE
#ifdef __cplusplus
extern "C" {
#endif
#include "esp_zigbee_core.h"
esp_err_t esphome_zb_cluster_list_add_or_update_cluster(uint16_t cluster_id, esp_zb_cluster_list_t *cluster_list,
esp_zb_attribute_list_t *attr_list, uint8_t role_mask);
esp_zb_attribute_list_t *esphome_zb_default_attr_list_create(uint16_t cluster_id);
esp_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list, uint16_t attr_id,
void *value_p);
esp_err_t esphome_zb_cluster_add_or_update_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list,
uint16_t attr_id, void *value_p);
#ifdef __cplusplus
}
namespace esphome::zigbee {} // namespace esphome::zigbee
#endif
#endif
#endif
+15 -12
View File
@@ -1,4 +1,4 @@
from datetime import datetime
import datetime
import random
from esphome import automation
@@ -7,6 +7,7 @@ from esphome.components.zephyr import zephyr_add_prj_conf
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
CONF_MODEL,
CONF_NAME,
CONF_UNIT_OF_MEASUREMENT,
UNIT_AMPERE,
@@ -48,19 +49,26 @@ from esphome.cpp_generator import (
)
from esphome.types import ConfigType
from .const_zephyr import (
CONF_IEEE802154_VENDOR_OUI,
from .const import (
CONF_ON_JOIN,
CONF_POWER_SOURCE,
CONF_WIPE_ON_BOOT,
KEY_ZIGBEE,
POWER_SOURCE,
AnalogAttrs,
AnalogAttrsOutput,
BinaryAttrs,
ZigbeeComponent,
zigbee_ns,
)
from .const_zephyr import (
CONF_IEEE802154_VENDOR_OUI,
CONF_ZIGBEE_BINARY_SENSOR,
CONF_ZIGBEE_ID,
CONF_ZIGBEE_NUMBER,
CONF_ZIGBEE_SENSOR,
CONF_ZIGBEE_SWITCH,
KEY_EP_NUMBER,
KEY_ZIGBEE,
POWER_SOURCE,
ZB_ZCL_BASIC_ATTRS_EXT_T,
ZB_ZCL_CLUSTER_ID_ANALOG_INPUT,
ZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT,
@@ -69,11 +77,6 @@ from .const_zephyr import (
ZB_ZCL_CLUSTER_ID_BINARY_OUTPUT,
ZB_ZCL_CLUSTER_ID_IDENTIFY,
ZB_ZCL_IDENTIFY_ATTRS_T,
AnalogAttrs,
AnalogAttrsOutput,
BinaryAttrs,
ZigbeeComponent,
zigbee_ns,
)
ZigbeeBinarySensor = zigbee_ns.class_("ZigbeeBinarySensor", cg.Component)
@@ -209,9 +212,9 @@ async def _attr_to_code(config: ConfigType) -> None:
zigbee_assign(basic_attrs.stack_version, 0),
zigbee_assign(basic_attrs.hw_version, 0),
zigbee_set_string(basic_attrs.mf_name, "esphome"),
zigbee_set_string(basic_attrs.model_id, CORE.name),
zigbee_set_string(basic_attrs.model_id, config[CONF_MODEL]),
zigbee_set_string(
basic_attrs.date_code, datetime.now().strftime("%d/%m/%y %H:%M")
basic_attrs.date_code, datetime.datetime.now().strftime("%Y%m%d %H%M%S")
),
zigbee_assign(
basic_attrs.power_source,
+3 -8
View File
@@ -222,14 +222,9 @@ class Application {
/// - ESP8266 HW WDT (~6 s): ~20x
/// - BK72xx HW WDT (10 s): ~5x <-- platform override below
#ifdef USE_BK72XX
/// BK72xx silicon requires a ~200 µs busy-wait between two watchdog register
/// key writes on every reload, making each arch_feed_wdt() ~300 µs. The
/// sctrl_dpll_delay200us() call lives in BDK's wdt_ctrl (WCMD_RELOAD_PERIOD):
/// https://github.com/libretiny-eu/framework-beken-bdk/blob/44800e7451ea30fbcbd3bb6e905315de59349fee/beken378/driver/wdt/wdt.c#L75-L87
/// LibreTiny initialises the HW WDT at 10 s, so 2000 ms keeps a 5x safety
/// margin — matching the ESP8266 ratio that motivated the generic 300 ms
/// value — while cutting feed frequency ~6x and recovering ~50 ms/min of
/// main-loop overhead on typical configs.
// BDK busy-waits 200us per WDT reload (sctrl_dpll_delay200us). LibreTiny
// sets HW WDT to 10s; 2000ms keeps ~5x margin. See wdt_ctrl WCMD_RELOAD_PERIOD:
// https://github.com/libretiny-eu/framework-beken-bdk/blob/44800e7451ea30fbcbd3bb6e905315de59349fee/beken378/driver/wdt/wdt.c#L75-L87
static constexpr uint32_t WDT_FEED_INTERVAL_MS = 2000;
#else
static constexpr uint32_t WDT_FEED_INTERVAL_MS = 300;
+1
View File
@@ -322,6 +322,7 @@
#define USE_MICRO_WAKE_WORD_VAD
#if defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2)
#define USE_OPENTHREAD
#define USE_ZIGBEE
#endif
#endif
+12 -8
View File
@@ -550,17 +550,18 @@ class Scheduler {
}
// Increment to_add_count_ (no-op on single-threaded platforms).
// On NO_ATOMICS the caller must hold lock_; the atomic store pairs with
// the reader's __atomic_load_n in to_add_empty_(). The input-value read is
// plain — safe because only writers (serialised by lock_) modify the
// counter, and concurrent readers only atomic-load (no conflicting write).
// On NO_ATOMICS the caller must hold lock_; both load and store go through
// __atomic_*_n with __ATOMIC_RELAXED to keep every access to the counter
// explicitly atomic in the C++ memory model (same ARMv5TE codegen as
// plain LDR+STR).
void to_add_count_increment_locked_() {
#if defined(ESPHOME_THREAD_SINGLE)
// No counter needed — to_add_empty_() checks the vector directly
#elif defined(ESPHOME_THREAD_MULTI_ATOMICS)
this->to_add_count_.fetch_add(1, std::memory_order_relaxed);
#else
__atomic_store_n(&this->to_add_count_, this->to_add_count_ + 1, __ATOMIC_RELAXED);
uint32_t v = __atomic_load_n(&this->to_add_count_, __ATOMIC_RELAXED);
__atomic_store_n(&this->to_add_count_, v + 1, __ATOMIC_RELAXED);
#endif
}
@@ -603,7 +604,8 @@ class Scheduler {
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
this->defer_count_.fetch_add(1, std::memory_order_relaxed);
#else
__atomic_store_n(&this->defer_count_, this->defer_count_ + 1, __ATOMIC_RELAXED);
uint32_t v = __atomic_load_n(&this->defer_count_, __ATOMIC_RELAXED);
__atomic_store_n(&this->defer_count_, v + 1, __ATOMIC_RELAXED);
#endif
}
@@ -641,7 +643,8 @@ class Scheduler {
#if defined(ESPHOME_THREAD_MULTI_ATOMICS)
this->to_remove_.fetch_add(count, std::memory_order_relaxed);
#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS)
__atomic_store_n(&this->to_remove_, this->to_remove_ + count, __ATOMIC_RELAXED);
uint32_t v = __atomic_load_n(&this->to_remove_, __ATOMIC_RELAXED);
__atomic_store_n(&this->to_remove_, v + count, __ATOMIC_RELAXED);
#else
this->to_remove_ += count;
#endif
@@ -651,7 +654,8 @@ class Scheduler {
#if defined(ESPHOME_THREAD_MULTI_ATOMICS)
this->to_remove_.fetch_sub(1, std::memory_order_relaxed);
#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS)
__atomic_store_n(&this->to_remove_, this->to_remove_ - 1, __ATOMIC_RELAXED);
uint32_t v = __atomic_load_n(&this->to_remove_, __ATOMIC_RELAXED);
__atomic_store_n(&this->to_remove_, v - 1, __ATOMIC_RELAXED);
#else
this->to_remove_--;
#endif
+8
View File
@@ -37,6 +37,14 @@ dependencies:
version: "2.0.0"
rules:
- if: "target in [esp32, esp32p4]"
espressif/esp-zboss-lib:
version: 1.6.4
rules:
- if: "target in [esp32h2, esp32c5, esp32c6]"
espressif/esp-zigbee-lib:
version: 1.6.8
rules:
- if: "target in [esp32h2, esp32c5, esp32c6]"
espressif/lan87xx:
version: "1.0.0"
rules:
+17 -31
View File
@@ -2,66 +2,52 @@
from __future__ import annotations
import asyncio
import threading
from aioesphomeapi.core import ResolveAPIError, ResolveTimeoutAPIError
import aioesphomeapi.host_resolver as hr
from esphome.async_thread import AsyncThreadRunner
from esphome.core import EsphomeError
RESOLVE_TIMEOUT = 10.0 # seconds
class AsyncResolver(threading.Thread):
class AsyncResolver:
"""Resolver using aioesphomeapi that runs in a thread for faster results.
This resolver uses aioesphomeapi's async_resolve_host to handle DNS resolution,
including proper .local domain fallback. Running in a thread allows us to get
the result immediately without waiting for asyncio.run() to complete its
cleanup cycle, which can take significant time.
This resolver uses aioesphomeapi's async_resolve_host to handle DNS
resolution, including proper .local domain fallback. Running in a thread
(via :class:`AsyncThreadRunner`) allows us to get the result immediately
without waiting for ``asyncio.run()`` to complete its cleanup cycle, which
can take significant time.
"""
def __init__(self, hosts: list[str], port: int) -> None:
"""Initialize the resolver."""
super().__init__(daemon=True)
self.hosts = hosts
self.port = port
self.result: list[hr.AddrInfo] | None = None
self.exception: Exception | None = None
self.event = threading.Event()
async def _resolve(self) -> None:
async def _resolve(self) -> list[hr.AddrInfo]:
"""Resolve hostnames to IP addresses."""
try:
self.result = await hr.async_resolve_host(
self.hosts, self.port, timeout=RESOLVE_TIMEOUT
)
except Exception as e: # pylint: disable=broad-except
# We need to catch all exceptions to ensure the event is set
# Otherwise the thread could hang forever
self.exception = e
finally:
self.event.set()
def run(self) -> None:
"""Run the DNS resolution."""
asyncio.run(self._resolve())
return await hr.async_resolve_host(
self.hosts, self.port, timeout=RESOLVE_TIMEOUT
)
def resolve(self) -> list[hr.AddrInfo]:
"""Start the thread and wait for the result."""
self.start()
runner: AsyncThreadRunner[list[hr.AddrInfo]] = AsyncThreadRunner(self._resolve)
runner.start()
if not self.event.wait(
if not runner.event.wait(
timeout=RESOLVE_TIMEOUT + 1.0
): # Give it 1 second more than the resolver timeout
raise EsphomeError("Timeout resolving IP address")
if exc := self.exception:
if exc := runner.exception:
if isinstance(exc, ResolveTimeoutAPIError):
raise EsphomeError(f"Timeout resolving IP address: {exc}") from exc
if isinstance(exc, ResolveAPIError):
raise EsphomeError(f"Error resolving IP address: {exc}") from exc
raise exc
return self.result
assert runner.result is not None # guaranteed when event set and no exception
return runner.result
+174 -7
View File
@@ -14,8 +14,13 @@ from zeroconf import (
)
from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf
from esphome.async_thread import AsyncThreadRunner
from esphome.storage_json import StorageJSON, ext_storage_path
# Length of the MAC suffix appended when name_add_mac_suffix is enabled.
MAC_SUFFIX_LEN = 6
_HEX_CHARS = frozenset("0123456789abcdef")
_LOGGER = logging.getLogger(__name__)
DEFAULT_TIMEOUT = 10.0
@@ -188,15 +193,177 @@ class EsphomeZeroconf(Zeroconf):
return None
async def async_resolve_hosts(
zeroconf: Zeroconf, hosts: list[str], timeout: float = DEFAULT_TIMEOUT
) -> dict[str, list[str]]:
"""Resolve ``hosts`` to IPs using a shared ``Zeroconf`` instance.
Tries the cache synchronously first (so hosts already primed by a recent
browse return immediately with no network round-trip), then issues
``async_request`` for the remaining misses in parallel via
``asyncio.gather``. Returns a dict mapping each host to its list of
addresses (empty list when unresolved). Only ``<short>.local`` form is
queried, matching the name scheme the resolvers below expect.
"""
resolvers: dict[str, AddressResolver] = {}
pending: list[str] = []
for host in hosts:
resolver = AddressResolver(f"{host.partition('.')[0]}.local.")
resolvers[host] = resolver
if not resolver.load_from_cache(zeroconf):
pending.append(host)
if pending and timeout:
results = await asyncio.gather(
*(
resolvers[host].async_request(zeroconf, timeout * 1000)
for host in pending
),
return_exceptions=True,
)
for host, result in zip(pending, results):
if isinstance(result, BaseException):
_LOGGER.debug("Failed to resolve %s: %s", host, result)
return {
host: resolver.parsed_scoped_addresses(IPVersion.All)
for host, resolver in resolvers.items()
}
class AsyncEsphomeZeroconf(AsyncZeroconf):
async def async_resolve_host(
self, host: str, timeout: float = DEFAULT_TIMEOUT
) -> list[str] | None:
"""Resolve a host name to an IP address."""
info = AddressResolver(f"{host.partition('.')[0]}.local.")
if (
info.load_from_cache(self.zeroconf)
or (timeout and await info.async_request(self.zeroconf, timeout * 1000))
) and (addresses := info.parsed_scoped_addresses(IPVersion.All)):
return addresses
return None
addresses = (await async_resolve_hosts(self.zeroconf, [host], timeout))[host]
return addresses or None
def _is_mac_suffix_match(device_name: str, prefix: str) -> bool:
"""Return True if ``device_name`` is ``prefix`` followed by a 6-char hex MAC."""
if not device_name.startswith(prefix):
return False
suffix = device_name[len(prefix) :]
return len(suffix) == MAC_SUFFIX_LEN and all(c in _HEX_CHARS for c in suffix)
async def async_discover_mdns_devices(
base_name: str, timeout: float = 5.0
) -> dict[str, list[str]]:
"""Discover ESPHome devices via mDNS that match the base name + MAC suffix.
When ``name_add_mac_suffix`` is enabled, devices advertise as
``<base_name>-<6-hex-mac>.local``. This function uses a single
``AsyncEsphomeZeroconf`` lifecycle to both browse for matching services and
resolve their IP addresses, so callers get resolved addresses without
opening a second Zeroconf client.
Args:
base_name: The base device name (without MAC suffix).
timeout: How long to wait for mDNS responses (default 5 seconds).
Returns:
Mapping of ``<device>.local`` hostnames to their resolved IP addresses
(may be empty for a device if resolution failed within the timeout).
"""
prefix = f"{base_name}-"
# Preserves insertion order for stable output and deduplicates
discovered: dict[str, list[str]] = {}
def on_service_state_change(
zeroconf: Zeroconf,
service_type: str,
name: str,
state_change: ServiceStateChange,
) -> None:
if state_change not in (ServiceStateChange.Added, ServiceStateChange.Updated):
return
device_name = name.partition(".")[0]
if not _is_mac_suffix_match(device_name, prefix):
_LOGGER.debug(
"Ignoring %s (%s): does not match '%s<6-hex>'",
device_name,
state_change.name,
prefix,
)
return
host = f"{device_name}.local"
if host in discovered:
return
discovered[host] = []
_LOGGER.debug("Discovered %s (%s)", host, state_change.name)
_LOGGER.debug(
"Starting mDNS discovery for '%s<mac>.local' (timeout=%.1fs)",
prefix,
timeout,
)
try:
aiozc = AsyncEsphomeZeroconf()
except Exception as err: # pylint: disable=broad-except
# Zeroconf init can raise OSError, NonUniqueNameException, etc.
# Any failure here just means we can't discover — log and move on.
_LOGGER.warning("mDNS discovery failed to initialize: %s", err)
return {}
try:
browser = AsyncServiceBrowser(
aiozc.zeroconf,
ESPHOME_SERVICE_TYPE,
handlers=[on_service_state_change],
)
try:
await asyncio.sleep(timeout)
finally:
await browser.async_cancel()
_LOGGER.debug(
"Browse finished: %d device(s) matched '%s<mac>'",
len(discovered),
prefix,
)
# Resolve each discovered hostname on the SAME Zeroconf instance so
# we don't spin up a second client. ``async_resolve_hosts`` tries the
# cache synchronously (the browse usually primes it) before issuing
# any ``async_request`` in parallel for misses.
resolved = await async_resolve_hosts(aiozc.zeroconf, list(discovered))
for host, addresses in resolved.items():
if addresses:
discovered[host] = addresses
_LOGGER.debug("Resolved %s -> %s", host, addresses)
else:
_LOGGER.debug("No addresses returned for %s", host)
finally:
await aiozc.async_close()
return dict(sorted(discovered.items()))
def _await_discovery(
runner: AsyncThreadRunner[dict[str, list[str]]], timeout: float
) -> dict[str, list[str]]:
"""Wait for ``runner`` to finish and return its discovery result.
Split out of :func:`discover_mdns_devices` so the timeout branch is
testable without patching ``asyncio`` or ``threading`` internals — a test
passes a stub whose ``event.wait`` returns ``False``.
"""
# Give the discovery an extra second over the browse timeout for the
# resolution + cleanup pass.
if not runner.event.wait(timeout=timeout + 2.0):
_LOGGER.warning("mDNS discovery timed out after %.1fs", timeout)
return {}
if runner.exception is not None:
_LOGGER.warning("mDNS discovery failed: %s", runner.exception)
return {}
return runner.result or {}
def discover_mdns_devices(base_name: str, timeout: float = 5.0) -> dict[str, list[str]]:
"""Synchronous wrapper around :func:`async_discover_mdns_devices`."""
runner = AsyncThreadRunner(
lambda: async_discover_mdns_devices(base_name, timeout=timeout)
)
runner.start()
return _await_discovery(runner, timeout)
+5
View File
@@ -20,3 +20,8 @@ CONFIG_BT_ENABLED=y
# esp32_camera
CONFIG_RTCIO_SUPPORT_RTC_GPIO_DESC=y
CONFIG_ESP32_SPIRAM_SUPPORT=y
# zigbee
CONFIG_ZB_ENABLED=y
CONFIG_ZB_ZED=y
CONFIG_ZB_RADIO_NATIVE=y
-10
View File
@@ -1,4 +1,3 @@
---
binary_sensor:
- platform: template
name: "Garage Door Open 1"
@@ -22,12 +21,6 @@ sensor:
lambda: return 12.0;
internal: True
zigbee:
wipe_on_boot: true
on_join:
then:
- logger.log: "Joined network"
output:
- platform: template
id: output_factory
@@ -35,9 +28,6 @@ output:
write_action:
- zigbee.factory_reset
time:
- platform: zigbee
switch:
- platform: template
name: "Template Switch"
+14
View File
@@ -0,0 +1,14 @@
binary_sensor:
- platform: template
name: "Garage Door Open 10"
report: "enable"
- platform: template
name: "Garage Door Open 11"
report: "coordinator"
- platform: template
name: "Garage Door Open 12"
report: "force"
zigbee:
model: zigbee_test
router: true
+12
View File
@@ -0,0 +1,12 @@
packages:
- !include common.yaml
zigbee:
model: zigbee_test
wipe_on_boot: true
on_join:
then:
- logger.log: "Joined network"
time:
- platform: zigbee
@@ -0,0 +1 @@
<<: !include common_esp32.yaml
@@ -1 +1 @@
<<: !include common.yaml
<<: !include common_nrf52.yaml
@@ -1 +1 @@
<<: !include common.yaml
<<: !include common_nrf52.yaml
@@ -1,4 +1,4 @@
<<: !include common.yaml
<<: !include common_nrf52.yaml
zigbee:
wipe_on_boot: once
+20
View File
@@ -121,6 +121,26 @@ def test_get_addresses_auto_detection() -> None:
assert cache.get_addresses("unknown.com") is None
def test_add_mdns_addresses_stores_and_normalizes() -> None:
"""add_mdns_addresses inserts entries under the normalized hostname."""
cache = AddressCache()
cache.add_mdns_addresses("Device.Local.", ["192.168.1.10", "192.168.1.11"])
assert cache.mdns_cache == {
normalize_hostname("Device.Local."): ["192.168.1.10", "192.168.1.11"]
}
# Overwrites on subsequent calls for the same host
cache.add_mdns_addresses("device.local", ["10.0.0.1"])
assert cache.mdns_cache[normalize_hostname("device.local")] == ["10.0.0.1"]
def test_add_mdns_addresses_empty_is_noop() -> None:
"""Passing an empty address list must not create an entry."""
cache = AddressCache()
cache.add_mdns_addresses("device.local", [])
assert cache.mdns_cache == {}
def test_has_cache() -> None:
"""Test checking if cache has entries."""
# Empty cache
+511 -2
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from collections.abc import Generator
from collections.abc import Callable, Generator
from dataclasses import dataclass
import json
import logging
@@ -12,16 +12,18 @@ import re
import sys
import time
from typing import Any
from unittest.mock import MagicMock, Mock, patch
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from pytest import CaptureFixture
from zeroconf import ServiceStateChange
from esphome import platformio_api
from esphome.__main__ import (
Purpose,
_get_configured_xtal_freq,
_make_crystal_freq_callback,
_resolve_network_devices,
choose_upload_log_host,
command_analyze_memory,
command_bundle,
@@ -36,6 +38,7 @@ from esphome.__main__ import (
has_mqtt,
has_mqtt_ip_lookup,
has_mqtt_logging,
has_name_add_mac_suffix,
has_non_ip_address,
has_ota,
has_resolvable_address,
@@ -48,6 +51,7 @@ from esphome.__main__ import (
upload_using_picotool,
upload_using_platformio,
)
from esphome.address_cache import AddressCache
from esphome.bundle import BUNDLE_EXTENSION, BundleFile, BundleResult
from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT, VARIANT_ESP32
from esphome.const import (
@@ -62,6 +66,7 @@ from esphome.const import (
CONF_MDNS,
CONF_MQTT,
CONF_NAME,
CONF_NAME_ADD_MAC_SUFFIX,
CONF_OTA,
CONF_PASSWORD,
CONF_PLATFORM,
@@ -79,6 +84,7 @@ from esphome.const import (
)
from esphome.core import CORE, EsphomeError
from esphome.util import BootselResult
from esphome.zeroconf import _await_discovery, discover_mdns_devices
def strip_ansi_codes(text: str) -> str:
@@ -2218,6 +2224,509 @@ def test_has_resolvable_address() -> None:
assert has_resolvable_address() is False
def test_has_name_add_mac_suffix() -> None:
"""Test has_name_add_mac_suffix function."""
# Test with name_add_mac_suffix enabled
setup_core(config={CONF_ESPHOME: {CONF_NAME_ADD_MAC_SUFFIX: True}})
assert has_name_add_mac_suffix() is True
# Test with name_add_mac_suffix disabled
setup_core(config={CONF_ESPHOME: {CONF_NAME_ADD_MAC_SUFFIX: False}})
assert has_name_add_mac_suffix() is False
# Test with name_add_mac_suffix not set (defaults to False)
setup_core(config={CONF_ESPHOME: {}})
assert has_name_add_mac_suffix() is False
# Test with no esphome config
setup_core(config={})
assert has_name_add_mac_suffix() is False
# Test with no config at all
CORE.config = None
assert has_name_add_mac_suffix() is False
@pytest.fixture
def mock_mdns_discovery() -> Generator[MagicMock]:
"""Fixture to mock the async mDNS discovery infrastructure.
Patches ``AsyncEsphomeZeroconf``, ``AsyncServiceBrowser`` and
``AddressResolver`` in ``esphome.zeroconf`` and exposes hooks for tests to
stage browser events and control resolution results. The default
``AddressResolver`` stub simulates a cache hit returning no addresses, so
matched hosts appear in the discovery output with empty address lists
unless the test overrides ``_resolver_setup``.
"""
with (
patch("esphome.zeroconf.AsyncEsphomeZeroconf") as mock_aiozc_class,
patch("esphome.zeroconf.AsyncServiceBrowser") as mock_browser_class,
patch("esphome.zeroconf.AddressResolver") as mock_resolver_class,
):
mock_aiozc = MagicMock()
mock_aiozc.zeroconf = MagicMock()
mock_aiozc.async_close = AsyncMock(return_value=None)
mock_aiozc_class.return_value = mock_aiozc
mock_browser = MagicMock()
mock_browser.async_cancel = AsyncMock(return_value=None)
# Default: each host gets a fresh resolver that hits the cache and
# returns no addresses. Tests can override via ``_resolver_setup``.
def default_resolver_factory(name: str) -> MagicMock:
resolver = MagicMock()
resolver._name = name
resolver.load_from_cache.return_value = True
resolver.async_request = AsyncMock(return_value=True)
resolver.parsed_scoped_addresses.return_value = []
return resolver
mock_resolver_class.side_effect = default_resolver_factory
# Store references for test access
mock_aiozc._mock_browser_class = mock_browser_class
mock_aiozc._mock_browser = mock_browser
mock_aiozc._mock_class = mock_aiozc_class
mock_aiozc._mock_resolver_class = mock_resolver_class
yield mock_aiozc
@pytest.mark.parametrize(
("discovered_services", "base_name", "expected_hosts"),
[
# Matching devices; different-prefix device is filtered out
(
[
("mydevice-abc123._esphomelib._tcp.local.", ServiceStateChange.Added),
("mydevice-def456._esphomelib._tcp.local.", ServiceStateChange.Added),
(
"otherdevice-abcdef._esphomelib._tcp.local.",
ServiceStateChange.Added,
),
],
"mydevice",
["mydevice-abc123.local", "mydevice-def456.local"],
),
# No matches at all
(
[
(
"otherdevice-abcdef._esphomelib._tcp.local.",
ServiceStateChange.Added,
),
],
"mydevice",
[],
),
# Deduplication (same device Added then Updated)
(
[
("mydevice-abc123._esphomelib._tcp.local.", ServiceStateChange.Added),
("mydevice-abc123._esphomelib._tcp.local.", ServiceStateChange.Updated),
],
"mydevice",
["mydevice-abc123.local"],
),
# Suffix must be exactly 6 hex chars: wrong length and non-hex are rejected
(
[
# too short
("mydevice-abcd._esphomelib._tcp.local.", ServiceStateChange.Added),
# too long
(
"mydevice-abcdef1._esphomelib._tcp.local.",
ServiceStateChange.Added,
),
# non-hex
("mydevice-xyz123._esphomelib._tcp.local.", ServiceStateChange.Added),
# valid
("mydevice-012345._esphomelib._tcp.local.", ServiceStateChange.Added),
],
"mydevice",
["mydevice-012345.local"],
),
# Prefix-collision: base "foo" must not match "foo-bar-abc123"
(
[
("foo-abcdef._esphomelib._tcp.local.", ServiceStateChange.Added),
("foo-bar-abcdef._esphomelib._tcp.local.", ServiceStateChange.Added),
],
"foo",
["foo-abcdef.local"],
),
],
ids=[
"matching_with_filter",
"no_matches",
"deduplication",
"hex_suffix_filter",
"prefix_collision",
],
)
def test_discover_mdns_devices(
mock_mdns_discovery: MagicMock,
discovered_services: list[tuple[str, ServiceStateChange]],
base_name: str,
expected_hosts: list[str],
) -> None:
"""Test discover_mdns_devices filtering and deduplication."""
mock_browser = mock_mdns_discovery._mock_browser
def capture_callback(
zc: MagicMock,
service_type: str,
handlers: list[Callable[..., None]],
) -> MagicMock:
callback = handlers[0]
for service_name, state_change in discovered_services:
callback(
mock_mdns_discovery.zeroconf, service_type, service_name, state_change
)
return mock_browser
mock_mdns_discovery._mock_browser_class.side_effect = capture_callback
# Each discovered host gets a resolver that returns a unique IP string
# derived from its server name so we can assert per-host.
def resolver_factory(name: str) -> MagicMock:
resolver = MagicMock()
resolver._name = name
resolver.load_from_cache.return_value = True
resolver.async_request = AsyncMock(return_value=True)
resolver.parsed_scoped_addresses.return_value = [f"10.0.0.1#{name}"]
return resolver
mock_mdns_discovery._mock_resolver_class.side_effect = resolver_factory
result = discover_mdns_devices(base_name, timeout=0)
assert sorted(result) == expected_hosts
# Resolved addresses should be stored for matched hosts. AddressResolver
# receives the fully-qualified name (``<device>.local.``).
for host in expected_hosts:
short = host.partition(".")[0]
assert result[host] == [f"10.0.0.1#{short}.local."]
mock_browser.async_cancel.assert_awaited_once()
mock_mdns_discovery.async_close.assert_awaited_once()
def test_discover_mdns_devices_init_failure(caplog: pytest.LogCaptureFixture) -> None:
"""If AsyncEsphomeZeroconf fails to init, return empty dict and log warning."""
with (
patch(
"esphome.zeroconf.AsyncEsphomeZeroconf",
side_effect=OSError("no network"),
),
caplog.at_level(logging.WARNING, logger="esphome.zeroconf"),
):
result = discover_mdns_devices("mydevice", timeout=0)
assert result == {}
assert "mDNS discovery failed to initialize" in caplog.text
def test_discover_mdns_devices_resolution_failure(
mock_mdns_discovery: MagicMock,
) -> None:
"""If resolution raises, the host is still listed with an empty address list."""
mock_browser = mock_mdns_discovery._mock_browser
def capture_callback(
zc: MagicMock,
service_type: str,
handlers: list[Callable[..., None]],
) -> MagicMock:
handlers[0](
mock_mdns_discovery.zeroconf,
service_type,
"mydevice-abc123._esphomelib._tcp.local.",
ServiceStateChange.Added,
)
return mock_browser
mock_mdns_discovery._mock_browser_class.side_effect = capture_callback
# Resolver misses the cache, then async_request raises.
def failing_resolver_factory(name: str) -> MagicMock:
resolver = MagicMock()
resolver.load_from_cache.return_value = False
resolver.async_request = AsyncMock(side_effect=OSError("boom"))
resolver.parsed_scoped_addresses.return_value = []
return resolver
mock_mdns_discovery._mock_resolver_class.side_effect = failing_resolver_factory
result = discover_mdns_devices("mydevice", timeout=0)
assert result == {"mydevice-abc123.local": []}
def test_discover_mdns_devices_ignores_removed_state(
mock_mdns_discovery: MagicMock,
) -> None:
"""``Removed`` state changes are ignored and do not appear in the result."""
mock_browser = mock_mdns_discovery._mock_browser
def capture_callback(
zc: MagicMock,
service_type: str,
handlers: list[Callable[..., None]],
) -> MagicMock:
handlers[0](
mock_mdns_discovery.zeroconf,
service_type,
"mydevice-abc123._esphomelib._tcp.local.",
ServiceStateChange.Removed,
)
return mock_browser
mock_mdns_discovery._mock_browser_class.side_effect = capture_callback
result = discover_mdns_devices("mydevice", timeout=0)
assert result == {}
# No AddressResolver should have been constructed since no host matched.
mock_mdns_discovery._mock_resolver_class.assert_not_called()
def test_discover_mdns_devices_empty_resolution(
mock_mdns_discovery: MagicMock,
) -> None:
"""Host is listed with empty addresses when resolver returns no addresses."""
mock_browser = mock_mdns_discovery._mock_browser
def capture_callback(
zc: MagicMock,
service_type: str,
handlers: list[Callable[..., None]],
) -> MagicMock:
handlers[0](
mock_mdns_discovery.zeroconf,
service_type,
"mydevice-abc123._esphomelib._tcp.local.",
ServiceStateChange.Added,
)
return mock_browser
mock_mdns_discovery._mock_browser_class.side_effect = capture_callback
# Default fixture resolver is a cache-hit with no addresses — simulates
# the "browse found it but no A/AAAA records are available" case.
result = discover_mdns_devices("mydevice", timeout=0)
assert result == {"mydevice-abc123.local": []}
def test_resolve_network_devices_expands_cached_mdns_hosts(tmp_path: Path) -> None:
"""Hostnames in ``CORE.address_cache`` are expanded to their cached IPs."""
setup_core(tmp_path=tmp_path)
CORE.address_cache = AddressCache(
mdns_cache={
"device-abc123.local": ["10.0.0.1", "10.0.0.2"],
}
)
result = _resolve_network_devices(
["device-abc123.local", "192.168.1.50", "device-abc123.local"],
CORE.config,
MockArgs(),
)
# Cached hostname is replaced with its IPs (deduplicated across repeats)
# and the literal IP is preserved after.
assert result == ["10.0.0.1", "10.0.0.2", "192.168.1.50"]
def test_resolve_network_devices_keeps_uncached_hosts(tmp_path: Path) -> None:
"""Hostnames not in the cache pass through unchanged."""
setup_core(tmp_path=tmp_path)
CORE.address_cache = AddressCache()
result = _resolve_network_devices(
["unknown.local", "192.168.1.50"],
CORE.config,
MockArgs(),
)
assert result == ["unknown.local", "192.168.1.50"]
def test_await_discovery_timeout_returns_empty(
caplog: pytest.LogCaptureFixture,
) -> None:
"""If the discovery runner never sets its event, return {} and warn."""
stub = MagicMock()
stub.event.wait.return_value = False
stub.exception = None
stub.result = {"should_not_be_read": ["1.2.3.4"]}
with caplog.at_level(logging.WARNING, logger="esphome.zeroconf"):
result = _await_discovery(stub, timeout=0.01)
assert result == {}
assert "mDNS discovery timed out after 0.0s" in caplog.text
stub.event.wait.assert_called_once_with(timeout=pytest.approx(2.01))
def test_await_discovery_propagates_exception_as_empty(
caplog: pytest.LogCaptureFixture,
) -> None:
"""If the coroutine raised, log and return {} rather than re-raise."""
stub = MagicMock()
stub.event.wait.return_value = True
stub.exception = RuntimeError("boom")
stub.result = None
with caplog.at_level(logging.WARNING, logger="esphome.zeroconf"):
result = _await_discovery(stub, timeout=5.0)
assert result == {}
assert "mDNS discovery failed: boom" in caplog.text
@pytest.mark.usefixtures("mock_no_serial_ports")
def test_choose_upload_log_host_discovers_mac_suffix_devices(tmp_path: Path) -> None:
"""Interactive mode discovers MAC-suffixed devices and populates the cache."""
setup_core(
config={
CONF_ESPHOME: {CONF_NAME_ADD_MAC_SUFFIX: True},
CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}],
},
address="mydevice.local",
tmp_path=tmp_path,
name="mydevice",
)
CORE.address_cache = None
discovered = {
"mydevice-abc123.local": ["10.0.0.1"],
"mydevice-def456.local": ["10.0.0.2"],
}
with (
patch(
"esphome.__main__.discover_mdns_devices", return_value=discovered
) as mock_discover,
patch(
"esphome.__main__.choose_prompt", return_value="mydevice-abc123.local"
) as mock_prompt,
):
result = choose_upload_log_host(
default=None,
check_default=None,
purpose=Purpose.UPLOADING,
)
assert result == ["mydevice-abc123.local"]
mock_discover.assert_called_once_with("mydevice")
mock_prompt.assert_called_once_with(
[
("Over The Air (mydevice-abc123.local)", "mydevice-abc123.local"),
("Over The Air (mydevice-def456.local)", "mydevice-def456.local"),
],
purpose=Purpose.UPLOADING,
)
# Resolved IPs should be cached so downstream resolution skips a second
# Zeroconf lookup.
assert CORE.address_cache is not None
assert CORE.address_cache.get_mdns_addresses("mydevice-abc123.local") == [
"10.0.0.1"
]
assert CORE.address_cache.get_mdns_addresses("mydevice-def456.local") == [
"10.0.0.2"
]
@pytest.mark.usefixtures("mock_no_serial_ports")
def test_choose_upload_log_host_mac_suffix_no_devices_found(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""When discovery finds nothing, no OTA option is offered and a warning logs."""
setup_core(
config={
CONF_ESPHOME: {CONF_NAME_ADD_MAC_SUFFIX: True},
CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}],
},
address="mydevice.local",
tmp_path=tmp_path,
name="mydevice",
)
with (
patch("esphome.__main__.discover_mdns_devices", return_value={}),
caplog.at_level(logging.WARNING, logger="esphome.__main__"),
pytest.raises(EsphomeError),
):
choose_upload_log_host(
default=None,
check_default=None,
purpose=Purpose.UPLOADING,
)
assert "No devices matching 'mydevice-<mac>.local'" in caplog.text
def test_choose_upload_log_host_default_ota_discovers_mac_suffix(
tmp_path: Path,
) -> None:
"""``--device OTA`` also runs mDNS discovery when name_add_mac_suffix is on."""
setup_core(
config={
CONF_ESPHOME: {CONF_NAME_ADD_MAC_SUFFIX: True},
CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}],
},
address="mydevice.local",
tmp_path=tmp_path,
name="mydevice",
)
CORE.address_cache = None
discovered = {
"mydevice-abc123.local": ["10.0.0.1"],
"mydevice-def456.local": ["10.0.0.2"],
}
with patch(
"esphome.__main__.discover_mdns_devices", return_value=discovered
) as mock_discover:
result = choose_upload_log_host(
default="OTA",
check_default=None,
purpose=Purpose.UPLOADING,
)
# Both discovered hostnames are returned so aioesphomeapi / espota2 can
# try each in turn with the cached IPs.
assert result == ["mydevice-abc123.local", "mydevice-def456.local"]
mock_discover.assert_called_once_with("mydevice")
assert CORE.address_cache is not None
assert CORE.address_cache.get_mdns_addresses("mydevice-abc123.local") == [
"10.0.0.1"
]
def test_choose_upload_log_host_default_ota_no_suffix_discovery(
tmp_path: Path,
) -> None:
"""``--device OTA`` without name_add_mac_suffix uses CORE.address as-is."""
setup_core(
config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]},
address="192.168.1.100",
tmp_path=tmp_path,
name="mydevice",
)
with patch("esphome.__main__.discover_mdns_devices") as mock_discover:
result = choose_upload_log_host(
default="OTA",
check_default=None,
purpose=Purpose.UPLOADING,
)
assert result == ["192.168.1.100"]
# Discovery must NOT run when name_add_mac_suffix is disabled.
mock_discover.assert_not_called()
def test_command_wizard(tmp_path: Path) -> None:
"""Test command_wizard function."""
config_file = tmp_path / "test.yaml"
+15 -18
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import re
import socket
from unittest.mock import patch
from unittest.mock import MagicMock, patch
from aioesphomeapi.core import ResolveAPIError, ResolveTimeoutAPIError
from aioesphomeapi.host_resolver import AddrInfo, IPv4Sockaddr, IPv6Sockaddr
@@ -115,24 +115,21 @@ def test_async_resolver_generic_exception() -> None:
def test_async_resolver_thread_timeout() -> None:
"""Test timeout when thread doesn't complete in time."""
# Mock the start method to prevent actual thread execution
with (
patch.object(AsyncResolver, "start"),
patch("esphome.resolver.hr.async_resolve_host"),
):
resolver = AsyncResolver(["test.local"], 6053)
# Override event.wait to simulate timeout (return False = timeout occurred)
with (
patch.object(resolver.event, "wait", return_value=False),
pytest.raises(
EsphomeError, match=re.escape("Timeout resolving IP address")
),
):
resolver.resolve()
"""Test timeout when the runner thread doesn't complete in time."""
# Patch AsyncThreadRunner inside esphome.resolver so we never actually
# start a thread and can control the wait return value directly.
fake_runner = MagicMock()
fake_runner.start = MagicMock()
fake_runner.event.wait.return_value = False # simulate timeout
# Verify thread start was called
resolver.start.assert_called_once()
with (
patch("esphome.resolver.AsyncThreadRunner", return_value=fake_runner),
patch("esphome.resolver.hr.async_resolve_host"),
pytest.raises(EsphomeError, match=re.escape("Timeout resolving IP address")),
):
AsyncResolver(["test.local"], 6053).resolve()
fake_runner.start.assert_called_once()
def test_async_resolver_ip_addresses(mock_addr_info_ipv4: AddrInfo) -> None: