mirror of
https://github.com/esphome/esphome.git
synced 2026-08-31 01:56:01 +00:00
Merge remote-tracking branch 'upstream/dev' into integration
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
230a1e6c847ef029966ae195f215e29f8fcb21edd127253c6427e6b8e388b54c
|
||||
1b1ce6324c50c4595703c7df0a8a479b4fe84b71ff1a8793cce1a16f17a33324
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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())
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user