mirror of
https://github.com/esphome/esphome.git
synced 2026-06-29 20:16:08 +00:00
Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 566476b05f | |||
| 94e300389c | |||
| 55bcf33446 | |||
| f132b7dc07 | |||
| baa6d5f96b | |||
| 773b4d887b | |||
| ac7f0f0b74 | |||
| bc7f35b569 | |||
| ae02ab3865 | |||
| eceb534895 | |||
| 404620b99c | |||
| 3ccaa771a7 | |||
| b4a86e46b2 | |||
| ddf1426f86 | |||
| 90d7bfe02e | |||
| d759f1a567 | |||
| f757cd1210 | |||
| 9b45b046a8 | |||
| 70ae614abd | |||
| 8f9b91eece | |||
| 3ca86fc3fc | |||
| b38db617a2 | |||
| 13fe881f70 | |||
| 50c181671c | |||
| adbbbe9cc5 | |||
| 46b0c9331b | |||
| b39ea3c19f | |||
| 051326e70e | |||
| 220f3d8142 | |||
| 7d0391aed6 | |||
| 9a9f9fa9f3 | |||
| 4dddccab6e | |||
| 6c115e4692 | |||
| ff26fe32c1 | |||
| 494f11ce77 | |||
| a463e25aa1 | |||
| 6aabada342 | |||
| 603d5a2b54 | |||
| 3e9f464a2c | |||
| 9a022baa06 | |||
| d9762759c0 | |||
| d217ab3cd4 | |||
| 70dd732821 | |||
| 9acfeec431 | |||
| 02f828fcbf | |||
| ab64916c37 |
+1
-1
@@ -1 +1 @@
|
||||
256216e144a626c8c9d1a458920a9db3de7dfc8c6a1b44b87946b9752e81026c
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
name: Close PR From Fork Default Branch
|
||||
|
||||
on:
|
||||
# pull_request_target is required so we have permission to comment and close PRs from forks.
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
close:
|
||||
name: Close PR opened from fork's default branch
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name
|
||||
&& github.event.pull_request.head.ref == github.event.repository.default_branch
|
||||
steps:
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const author = context.payload.pull_request.user.login;
|
||||
const defaultBranch = context.payload.repository.default_branch;
|
||||
const headRepo = context.payload.pull_request.head.repo.full_name;
|
||||
|
||||
const body = [
|
||||
`Hi @${author}, thanks for opening a pull request! :tada:`,
|
||||
``,
|
||||
`It looks like this PR was opened from the \`${defaultBranch}\` branch of your fork (\`${headRepo}\`), which is the same name as this repository's default branch. Working directly on \`${defaultBranch}\` in your fork causes a few problems:`,
|
||||
``,
|
||||
`- Your fork's \`${defaultBranch}\` branch will permanently diverge from \`esphome/esphome:${defaultBranch}\`, making it hard to keep your fork up to date.`,
|
||||
`- Any additional commits you push to \`${defaultBranch}\` will be added to this PR, so you can't easily work on multiple changes at once.`,
|
||||
`- Pushing maintainer fixes to your branch is awkward, since it means committing directly to your fork's default branch.`,
|
||||
`- It makes local collaboration painful — \`${defaultBranch}\` in a checkout becomes ambiguous between upstream and your fork, and maintainers end up with naming collisions when fetching your branch.`,
|
||||
``,
|
||||
`Please re-open this as a new PR from a dedicated feature branch. The usual flow looks like:`,
|
||||
``,
|
||||
`\`\`\`bash`,
|
||||
`# Make sure your fork's ${defaultBranch} is up to date with upstream`,
|
||||
`git remote add upstream https://github.com/${owner}/${repo}.git # if you haven't already`,
|
||||
`git fetch upstream`,
|
||||
`git checkout ${defaultBranch}`,
|
||||
`git reset --hard upstream/${defaultBranch}`,
|
||||
`git push --force-with-lease origin ${defaultBranch}`,
|
||||
``,
|
||||
`# Create a new branch for your change and cherry-pick / re-apply your commits there`,
|
||||
`git checkout -b my-feature-branch upstream/${defaultBranch}`,
|
||||
`# ...re-apply your changes, then:`,
|
||||
`git push origin my-feature-branch`,
|
||||
`\`\`\``,
|
||||
``,
|
||||
`Then open a new pull request from \`my-feature-branch\` into \`${owner}/${repo}:${defaultBranch}\`.`,
|
||||
``,
|
||||
`Closing this PR for now — sorry for the friction, and thanks again for contributing! :heart:`,
|
||||
].join('\n');
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body,
|
||||
});
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
state: 'closed',
|
||||
});
|
||||
+7
-1
@@ -56,6 +56,7 @@ esphome/components/audio_adc/* @kbx81
|
||||
esphome/components/audio_dac/* @kbx81
|
||||
esphome/components/audio_file/* @kahrendt
|
||||
esphome/components/audio_file/media_source/* @kahrendt
|
||||
esphome/components/audio_http/* @kahrendt
|
||||
esphome/components/axs15231/* @clydebarrow
|
||||
esphome/components/b_parasite/* @rbaron
|
||||
esphome/components/ballu/* @bazuchan
|
||||
@@ -439,6 +440,11 @@ esphome/components/sen0321/* @notjj
|
||||
esphome/components/sen21231/* @shreyaskarnik
|
||||
esphome/components/sen5x/* @martgras
|
||||
esphome/components/sen6x/* @martgras @mebner86 @mikelawrence @tuct
|
||||
esphome/components/sendspin/* @kahrendt
|
||||
esphome/components/sendspin/media_player/* @kahrendt
|
||||
esphome/components/sendspin/media_source/* @kahrendt
|
||||
esphome/components/sendspin/sensor/* @kahrendt
|
||||
esphome/components/sendspin/text_sensor/* @kahrendt
|
||||
esphome/components/sensirion_common/* @martgras
|
||||
esphome/components/sensor/* @esphome/core
|
||||
esphome/components/serial_proxy/* @kbx81
|
||||
@@ -600,6 +606,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,7 +93,24 @@ async def async_run_logs(
|
||||
config, raw_line, backtrace_state=backtrace_state
|
||||
)
|
||||
|
||||
stop = await async_run(cli, on_log, name=name, subscribe_states=subscribe_states)
|
||||
# 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,
|
||||
name=name,
|
||||
subscribe_states=subscribe_states,
|
||||
allow_plaintext_fallback=True,
|
||||
)
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
#include "audio_http_media_source.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace esphome::audio_http {
|
||||
|
||||
static const char *const TAG = "audio_http_media_source";
|
||||
|
||||
// Decoder task / buffer tuning. Kept here as constants so the header stays free of magic numbers.
|
||||
static constexpr size_t DEFAULT_TRANSFER_BUFFER_SIZE = 8 * 1024; // Staging buffer between HTTP reader and decoder
|
||||
static constexpr uint32_t HTTP_TIMEOUT_MS = 5000; // HTTP connect/read timeout
|
||||
static constexpr uint32_t AUDIO_WRITE_TIMEOUT_MS = 50; // Max blocking time per on_audio_write() call
|
||||
static constexpr uint32_t READER_WRITE_TIMEOUT_MS = 50; // Max blocking time when writing into the ring buffer
|
||||
static constexpr uint8_t READER_TASK_PRIORITY = 2;
|
||||
static constexpr uint8_t DECODER_TASK_PRIORITY = 2;
|
||||
static constexpr size_t READER_TASK_STACK_SIZE = 4096;
|
||||
static constexpr size_t DECODER_TASK_STACK_SIZE = 5120;
|
||||
static constexpr uint32_t PAUSE_POLL_DELAY_MS = 20;
|
||||
static constexpr const char *const HTTP_URI_PREFIX = "http://";
|
||||
static constexpr const char *const HTTPS_URI_PREFIX = "https://";
|
||||
|
||||
void AudioHTTPMediaSource::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Audio HTTP Media Source:\n"
|
||||
" Buffer Size: %zu bytes\n"
|
||||
" Decoder Task Stack in PSRAM: %s",
|
||||
this->buffer_size_, YESNO(this->decoder_task_stack_in_psram_));
|
||||
}
|
||||
|
||||
void AudioHTTPMediaSource::setup() {
|
||||
this->disable_loop();
|
||||
|
||||
micro_decoder::DecoderConfig config;
|
||||
config.ring_buffer_size = this->buffer_size_;
|
||||
// Keep the transfer buffer smaller than the ring buffer so the reader can top up the ring
|
||||
// while the decoder is still draining it, instead of oscillating between empty and full.
|
||||
config.transfer_buffer_size = std::min(DEFAULT_TRANSFER_BUFFER_SIZE, this->buffer_size_ / 2);
|
||||
config.http_timeout_ms = HTTP_TIMEOUT_MS;
|
||||
config.audio_write_timeout_ms = AUDIO_WRITE_TIMEOUT_MS;
|
||||
config.reader_write_timeout_ms = READER_WRITE_TIMEOUT_MS;
|
||||
config.reader_priority = READER_TASK_PRIORITY;
|
||||
config.decoder_priority = DECODER_TASK_PRIORITY;
|
||||
config.reader_stack_size = READER_TASK_STACK_SIZE;
|
||||
config.decoder_stack_size = DECODER_TASK_STACK_SIZE;
|
||||
config.decoder_stack_in_psram = this->decoder_task_stack_in_psram_;
|
||||
|
||||
this->decoder_ = std::make_unique<micro_decoder::DecoderSource>(config);
|
||||
if (this->decoder_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Failed to allocate decoder");
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
this->decoder_->set_listener(this); // We inherit from micro_decoder::DecoderListener
|
||||
}
|
||||
|
||||
void AudioHTTPMediaSource::loop() { this->decoder_->loop(); }
|
||||
|
||||
bool AudioHTTPMediaSource::can_handle(const std::string &uri) const {
|
||||
return uri.starts_with(HTTP_URI_PREFIX) || uri.starts_with(HTTPS_URI_PREFIX);
|
||||
}
|
||||
|
||||
// Called from the orchestrator's main loop, so no synchronization needed with loop()
|
||||
bool AudioHTTPMediaSource::play_uri(const std::string &uri) {
|
||||
if (!this->is_ready() || this->is_failed() || this->status_has_error() || !this->has_listener()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if source is already playing
|
||||
if (this->get_state() != media_source::MediaSourceState::IDLE) {
|
||||
ESP_LOGE(TAG, "Cannot play '%s': source is busy", uri.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate URI starts with "http://" or "https://"
|
||||
if (!uri.starts_with(HTTP_URI_PREFIX) && !uri.starts_with(HTTPS_URI_PREFIX)) {
|
||||
ESP_LOGE(TAG, "Invalid URI: '%s'", uri.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this->decoder_->play_url(uri)) {
|
||||
this->pause_.store(false, std::memory_order_relaxed);
|
||||
this->enable_loop();
|
||||
return true;
|
||||
}
|
||||
|
||||
ESP_LOGE(TAG, "Failed to start playback of '%s'", uri.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Called from the orchestrator's main loop, so no synchronization needed with loop()
|
||||
void AudioHTTPMediaSource::handle_command(media_source::MediaSourceCommand command) {
|
||||
switch (command) {
|
||||
case media_source::MediaSourceCommand::STOP:
|
||||
this->decoder_->stop();
|
||||
break;
|
||||
case media_source::MediaSourceCommand::PAUSE:
|
||||
// Only valid while actively playing; ignoring from IDLE/ERROR/PAUSED prevents the state
|
||||
// machine from getting stuck in PAUSED when no playback is active (which would block the
|
||||
// next play_uri() call via its IDLE-state precondition).
|
||||
if (this->get_state() != media_source::MediaSourceState::PLAYING)
|
||||
break;
|
||||
// PAUSE does not stop the decoder task. Instead, on_audio_write() returns 0 and temporarily
|
||||
// yields, which fills the ring buffer and applies back pressure that effectively pauses both
|
||||
// the decoder and HTTP reader tasks.
|
||||
this->set_state_(media_source::MediaSourceState::PAUSED);
|
||||
this->pause_.store(true, std::memory_order_relaxed);
|
||||
break;
|
||||
case media_source::MediaSourceCommand::PLAY:
|
||||
// Only resume from PAUSED; don't fabricate a PLAYING state from IDLE/ERROR.
|
||||
if (this->get_state() != media_source::MediaSourceState::PAUSED)
|
||||
break;
|
||||
this->set_state_(media_source::MediaSourceState::PLAYING);
|
||||
this->pause_.store(false, std::memory_order_relaxed);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Called from the decoder task. Forwards to the orchestrator's listener, which is responsible for
|
||||
// being thread-safe with respect to its own audio writer.
|
||||
size_t AudioHTTPMediaSource::on_audio_write(const uint8_t *data, size_t length, uint32_t timeout_ms) {
|
||||
if (this->pause_.load(std::memory_order_relaxed)) {
|
||||
vTaskDelay(pdMS_TO_TICKS(PAUSE_POLL_DELAY_MS));
|
||||
return 0;
|
||||
}
|
||||
return this->write_output(data, length, timeout_ms, this->stream_info_);
|
||||
}
|
||||
|
||||
// Called from the decoder task before the first on_audio_write().
|
||||
void AudioHTTPMediaSource::on_stream_info(const micro_decoder::AudioStreamInfo &info) {
|
||||
this->stream_info_ = audio::AudioStreamInfo(info.get_bits_per_sample(), info.get_channels(), info.get_sample_rate());
|
||||
}
|
||||
|
||||
// microDecoder invokes on_state_change() from inside decoder_->loop(), so this runs on the main
|
||||
// loop thread and it's safe to call set_state_() directly.
|
||||
void AudioHTTPMediaSource::on_state_change(micro_decoder::DecoderState state) {
|
||||
switch (state) {
|
||||
case micro_decoder::DecoderState::IDLE:
|
||||
this->set_state_(media_source::MediaSourceState::IDLE);
|
||||
this->disable_loop();
|
||||
break;
|
||||
case micro_decoder::DecoderState::PLAYING:
|
||||
this->set_state_(media_source::MediaSourceState::PLAYING);
|
||||
break;
|
||||
case micro_decoder::DecoderState::FAILED:
|
||||
this->set_state_(media_source::MediaSourceState::ERROR);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::audio_http
|
||||
|
||||
#endif // USE_ESP32
|
||||
@@ -0,0 +1,59 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include "esphome/components/audio/audio.h"
|
||||
#include "esphome/components/media_source/media_source.h"
|
||||
#include "esphome/core/component.h"
|
||||
|
||||
#include <micro_decoder/decoder_source.h>
|
||||
#include <micro_decoder/types.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
namespace esphome::audio_http {
|
||||
|
||||
// Inherits from two unrelated listener-style interfaces:
|
||||
// - media_source::MediaSource: this source reports state and writes audio *to* an orchestrator
|
||||
// (the orchestrator calls set_listener() on us with a MediaSourceListener*).
|
||||
// - micro_decoder::DecoderListener: the underlying decoder calls back *into* us with decoded
|
||||
// audio and state changes (we call decoder_->set_listener(this) in setup()).
|
||||
// The two set_listener() methods live on different base classes and serve opposite directions.
|
||||
class AudioHTTPMediaSource : public Component, public media_source::MediaSource, public micro_decoder::DecoderListener {
|
||||
public:
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
|
||||
void set_buffer_size(size_t buffer_size) { this->buffer_size_ = buffer_size; }
|
||||
void set_task_stack_in_psram(bool task_stack_in_psram) { this->decoder_task_stack_in_psram_ = task_stack_in_psram; }
|
||||
|
||||
// MediaSource interface implementation
|
||||
bool play_uri(const std::string &uri) override;
|
||||
void handle_command(media_source::MediaSourceCommand command) override;
|
||||
bool can_handle(const std::string &uri) const override;
|
||||
|
||||
// DecoderListener interface implementation
|
||||
size_t on_audio_write(const uint8_t *data, size_t length, uint32_t timeout_ms) override;
|
||||
void on_stream_info(const micro_decoder::AudioStreamInfo &info) override;
|
||||
void on_state_change(micro_decoder::DecoderState state) override;
|
||||
|
||||
protected:
|
||||
std::unique_ptr<micro_decoder::DecoderSource> decoder_;
|
||||
audio::AudioStreamInfo stream_info_;
|
||||
|
||||
size_t buffer_size_{50000};
|
||||
|
||||
// Written from the main loop in handle_command(), read from the decoder task in
|
||||
// on_audio_write(). Must be atomic to avoid a data race.
|
||||
std::atomic<bool> pause_{false};
|
||||
bool decoder_task_stack_in_psram_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::audio_http
|
||||
|
||||
#endif // USE_ESP32
|
||||
@@ -0,0 +1,59 @@
|
||||
from typing import Any
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import audio, esp32, media_source, psram
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TASK_STACK_IN_PSRAM
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@kahrendt"]
|
||||
AUTO_LOAD = ["audio"]
|
||||
|
||||
audio_http_ns = cg.esphome_ns.namespace("audio_http")
|
||||
AudioHTTPMediaSource = audio_http_ns.class_(
|
||||
"AudioHTTPMediaSource", cg.Component, media_source.MediaSource
|
||||
)
|
||||
|
||||
|
||||
def _request_micro_decoder(config: ConfigType) -> ConfigType:
|
||||
audio.request_micro_decoder_support()
|
||||
return config
|
||||
|
||||
|
||||
def _validate_task_stack_in_psram(value: Any) -> bool:
|
||||
# Only require the psram component when actually enabling PSRAM stacks; validating
|
||||
# the boolean first means `false` doesn't trigger the requires_component check.
|
||||
if value := cv.boolean(value):
|
||||
return cv.requires_component(psram.DOMAIN)(value)
|
||||
return value
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
media_source.media_source_schema(
|
||||
AudioHTTPMediaSource,
|
||||
)
|
||||
.extend(
|
||||
{
|
||||
cv.Optional(CONF_BUFFER_SIZE, default=50000): cv.int_range(
|
||||
min=5000, max=1000000
|
||||
),
|
||||
cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA),
|
||||
cv.only_on_esp32,
|
||||
_request_micro_decoder,
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await media_source.register_media_source(var, config)
|
||||
|
||||
if config.get(CONF_TASK_STACK_IN_PSRAM):
|
||||
cg.add(var.set_task_stack_in_psram(True))
|
||||
esp32.add_idf_sdkconfig_option(
|
||||
"CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True
|
||||
)
|
||||
cg.add(var.set_buffer_size(config[CONF_BUFFER_SIZE]))
|
||||
@@ -14,6 +14,7 @@ from esphome.components.esp32 import (
|
||||
VARIANT_ESP32S3,
|
||||
get_esp32_variant,
|
||||
)
|
||||
from esphome.components.zephyr import zephyr_add_prj_conf
|
||||
from esphome.config_helpers import filter_source_files_from_platform
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -33,6 +34,7 @@ from esphome.const import (
|
||||
PLATFORM_BK72XX,
|
||||
PLATFORM_ESP32,
|
||||
PLATFORM_ESP8266,
|
||||
PLATFORM_NRF52,
|
||||
PlatformFramework,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
@@ -304,7 +306,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA),
|
||||
cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX]),
|
||||
cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX, PLATFORM_NRF52]),
|
||||
validate_config,
|
||||
)
|
||||
|
||||
@@ -369,6 +371,8 @@ async def to_code(config):
|
||||
|
||||
if CONF_TOUCH_WAKEUP in config:
|
||||
cg.add(var.set_touch_wakeup(config[CONF_TOUCH_WAKEUP]))
|
||||
if CORE.using_zephyr and "zigbee" not in CORE.loaded_integrations:
|
||||
zephyr_add_prj_conf("POWEROFF", True)
|
||||
|
||||
cg.add_define("USE_DEEP_SLEEP")
|
||||
|
||||
@@ -413,7 +417,7 @@ async def deep_sleep_enter_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
var = cg.new_Pvariable(action_id, template_arg, paren)
|
||||
if CONF_SLEEP_DURATION in config:
|
||||
template_ = await cg.templatable(config[CONF_SLEEP_DURATION], args, cg.int32)
|
||||
template_ = await cg.templatable(config[CONF_SLEEP_DURATION], args, cg.uint32)
|
||||
cg.add(var.set_sleep_duration(template_))
|
||||
|
||||
if CONF_UNTIL in config:
|
||||
|
||||
@@ -59,6 +59,8 @@ void DeepSleepComponent::deep_sleep_() {
|
||||
lt_deep_sleep_enter();
|
||||
}
|
||||
|
||||
bool DeepSleepComponent::should_teardown_() { return true; }
|
||||
|
||||
} // namespace esphome::deep_sleep
|
||||
|
||||
#endif // USE_BK72XX
|
||||
|
||||
@@ -9,11 +9,22 @@ static const char *const TAG = "deep_sleep";
|
||||
// 5 seconds for deep sleep to ensure clean disconnect from Home Assistant
|
||||
static const uint32_t TEARDOWN_TIMEOUT_DEEP_SLEEP_MS = 5000;
|
||||
|
||||
bool global_has_deep_sleep = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
bool global_has_deep_sleep = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
std::atomic<DeepSleepComponent *> global_deep_sleep; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
void DeepSleepComponent::setup() {
|
||||
#ifdef USE_ZEPHYR
|
||||
k_sem_init(&this->wakeup_sem_, 0, 1);
|
||||
#endif
|
||||
global_has_deep_sleep = true;
|
||||
this->schedule_sleep_();
|
||||
// It can be used from another thread for waking up the device.
|
||||
// It should be called as last item in setup.
|
||||
global_deep_sleep.store(this);
|
||||
}
|
||||
|
||||
void DeepSleepComponent::schedule_sleep_() {
|
||||
this->next_enter_deep_sleep_ = false;
|
||||
const optional<uint32_t> run_duration = get_run_duration_();
|
||||
if (run_duration.has_value()) {
|
||||
ESP_LOGI(TAG, "Scheduling in %" PRIu32 " ms", *run_duration);
|
||||
@@ -58,13 +69,17 @@ void DeepSleepComponent::begin_sleep(bool manual) {
|
||||
if (this->sleep_duration_.has_value()) {
|
||||
ESP_LOGI(TAG, "Sleeping for %" PRId64 "us", *this->sleep_duration_);
|
||||
}
|
||||
App.run_safe_shutdown_hooks();
|
||||
// It's critical to teardown components cleanly for deep sleep to ensure
|
||||
// Home Assistant sees a clean disconnect instead of marking the device unavailable
|
||||
App.teardown_components(TEARDOWN_TIMEOUT_DEEP_SLEEP_MS);
|
||||
App.run_powerdown_hooks();
|
||||
|
||||
if (this->should_teardown_()) {
|
||||
App.run_safe_shutdown_hooks();
|
||||
// It's critical to teardown components cleanly for deep sleep to ensure
|
||||
// Home Assistant sees a clean disconnect instead of marking the device unavailable
|
||||
App.teardown_components(TEARDOWN_TIMEOUT_DEEP_SLEEP_MS);
|
||||
App.run_powerdown_hooks();
|
||||
}
|
||||
|
||||
this->deep_sleep_();
|
||||
this->schedule_sleep_();
|
||||
}
|
||||
|
||||
float DeepSleepComponent::get_setup_priority() const { return setup_priority::LATE; }
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include <atomic>
|
||||
|
||||
#ifdef USE_ESP32
|
||||
#include <esp_sleep.h>
|
||||
@@ -14,6 +15,10 @@
|
||||
#include "esphome/core/time.h"
|
||||
#endif
|
||||
|
||||
#ifdef USE_ZEPHYR
|
||||
#include <zephyr/kernel.h>
|
||||
#endif
|
||||
|
||||
#include <cinttypes>
|
||||
|
||||
namespace esphome {
|
||||
@@ -120,6 +125,9 @@ class DeepSleepComponent : public Component {
|
||||
|
||||
void prevent_deep_sleep();
|
||||
void allow_deep_sleep();
|
||||
#ifdef USE_ZEPHYR
|
||||
void wakeup();
|
||||
#endif
|
||||
|
||||
protected:
|
||||
// Returns nullopt if no run duration is set. Otherwise, returns the run
|
||||
@@ -129,6 +137,8 @@ class DeepSleepComponent : public Component {
|
||||
void dump_config_platform_();
|
||||
bool prepare_to_sleep_();
|
||||
void deep_sleep_();
|
||||
void schedule_sleep_();
|
||||
bool should_teardown_();
|
||||
|
||||
#ifdef USE_BK72XX
|
||||
bool pin_prevents_sleep_(WakeUpPinItem &pinItem) const;
|
||||
@@ -157,6 +167,9 @@ class DeepSleepComponent : public Component {
|
||||
optional<uint32_t> run_duration_;
|
||||
bool next_enter_deep_sleep_{false};
|
||||
bool prevent_{false};
|
||||
#ifdef USE_ZEPHYR
|
||||
k_sem wakeup_sem_;
|
||||
#endif
|
||||
};
|
||||
|
||||
extern bool global_has_deep_sleep; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
@@ -243,5 +256,8 @@ template<typename... Ts> class AllowDeepSleepAction : public Action<Ts...>, publ
|
||||
void play(const Ts &...x) override { this->parent_->allow_deep_sleep(); }
|
||||
};
|
||||
|
||||
extern std::atomic<DeepSleepComponent *>
|
||||
global_deep_sleep; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
} // namespace deep_sleep
|
||||
} // namespace esphome
|
||||
|
||||
@@ -165,6 +165,8 @@ void DeepSleepComponent::deep_sleep_() {
|
||||
esp_deep_sleep_start();
|
||||
}
|
||||
|
||||
bool DeepSleepComponent::should_teardown_() { return true; }
|
||||
|
||||
} // namespace deep_sleep
|
||||
} // namespace esphome
|
||||
#endif // USE_ESP32
|
||||
|
||||
@@ -18,6 +18,8 @@ void DeepSleepComponent::deep_sleep_() {
|
||||
ESP.deepSleep(this->sleep_duration_.value_or(0)); // NOLINT(readability-static-accessed-through-instance)
|
||||
}
|
||||
|
||||
bool DeepSleepComponent::should_teardown_() { return true; }
|
||||
|
||||
} // namespace deep_sleep
|
||||
} // namespace esphome
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
#include "deep_sleep_component.h"
|
||||
#ifdef USE_ZEPHYR
|
||||
#include "esphome/core/log.h"
|
||||
#include <zephyr/sys/poweroff.h>
|
||||
#include <zephyr/kernel.h>
|
||||
#include <zephyr/stats/stats.h>
|
||||
#include <zephyr/pm/pm.h>
|
||||
|
||||
namespace esphome::deep_sleep {
|
||||
|
||||
static const char *const TAG = "deep_sleep";
|
||||
|
||||
void DeepSleepComponent::wakeup() { k_sem_give(&this->wakeup_sem_); }
|
||||
|
||||
optional<uint32_t> DeepSleepComponent::get_run_duration_() const { return this->run_duration_; }
|
||||
|
||||
void DeepSleepComponent::dump_config_platform_() {}
|
||||
|
||||
bool DeepSleepComponent::prepare_to_sleep_() { return true; }
|
||||
|
||||
void DeepSleepComponent::deep_sleep_() {
|
||||
k_timeout_t sleep_duration = K_FOREVER;
|
||||
if (this->sleep_duration_.has_value()) {
|
||||
sleep_duration = K_USEC(*this->sleep_duration_);
|
||||
} else {
|
||||
#ifndef USE_ZIGBEE
|
||||
// the device can be woken up through one of the following signals:
|
||||
// - The DETECT signal, optionally generated by the GPIO peripheral.
|
||||
// - The ANADETECT signal, optionally generated by the LPCOMP module.
|
||||
// - The SENSE signal, optionally generated by the NFC module to wake-on-field.
|
||||
// - Detecting a valid USB voltage on the VBUS pin (VBUS,DETECT).
|
||||
// - A reset.
|
||||
//
|
||||
// The system is reset when it wakes up from System OFF mode.
|
||||
sys_poweroff();
|
||||
#endif
|
||||
}
|
||||
// It might wake up immediately if k_sem_give was called again after wake up
|
||||
int ret = k_sem_take(&this->wakeup_sem_, sleep_duration);
|
||||
if (ret == 0) {
|
||||
ESP_LOGD(TAG, "Woken up by another thread");
|
||||
} else {
|
||||
ESP_LOGD(TAG, "Timeout expired (normal sleep)");
|
||||
}
|
||||
}
|
||||
|
||||
bool DeepSleepComponent::should_teardown_() {
|
||||
if (this->sleep_duration_.has_value()) {
|
||||
return false;
|
||||
}
|
||||
#ifdef USE_ZIGBEE
|
||||
return false;
|
||||
#else
|
||||
return true;
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace esphome::deep_sleep
|
||||
|
||||
#endif
|
||||
@@ -84,7 +84,8 @@ static StaticTask_t loop_task_tcb; // NOLINT(cppcoreguidelines-avoid-non-
|
||||
static StackType_t
|
||||
loop_task_stack[ESPHOME_LOOP_TASK_STACK_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
void loop_task(void *pv_params) {
|
||||
void __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
|
||||
loop_task(void *pv_params) {
|
||||
setup();
|
||||
while (true) {
|
||||
App.loop();
|
||||
|
||||
@@ -77,7 +77,8 @@ uint32_t arch_get_cpu_freq_hz() { return 1000000000U; }
|
||||
|
||||
void setup();
|
||||
void loop();
|
||||
int main() {
|
||||
int __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
|
||||
main() {
|
||||
// Install signal handlers for graceful shutdown (flushes preferences to disk)
|
||||
std::signal(SIGINT, signal_handler);
|
||||
std::signal(SIGTERM, signal_handler);
|
||||
|
||||
@@ -56,7 +56,7 @@ void arch_init() {
|
||||
//
|
||||
// Raise to priority 6: above WiFi/LwIP tasks (4-5) so they don't preempt the
|
||||
// main loop, but below the TCP/IP thread (7) so packet processing keeps priority.
|
||||
// This is safe because ESPHome yields voluntarily via yield_with_select_() and
|
||||
// This is safe because ESPHome yields voluntarily via wakeable_delay() and
|
||||
// the Arduino mainTask yield() after each loop() iteration.
|
||||
static constexpr UBaseType_t MAIN_TASK_PRIORITY = 6;
|
||||
static_assert(MAIN_TASK_PRIORITY < configMAX_PRIORITIES, "MAIN_TASK_PRIORITY must be less than configMAX_PRIORITIES");
|
||||
|
||||
@@ -472,14 +472,15 @@ async def _late_logger_init(config: ConfigType) -> None:
|
||||
# esphome implement own fatal error handler which save PC/LR before reset
|
||||
zephyr_add_prj_conf("RESET_ON_FATAL_ERROR", False)
|
||||
zephyr_add_prj_conf("THREAD_LOCAL_STORAGE", True)
|
||||
if config[CONF_HARDWARE_UART] == UART0:
|
||||
zephyr_add_overlay("""&uart0 { status = "okay";};""")
|
||||
if config[CONF_HARDWARE_UART] == UART1:
|
||||
zephyr_add_overlay("""&uart1 { status = "okay";};""")
|
||||
if config[CONF_HARDWARE_UART] == USB_CDC:
|
||||
cg.add_define("USE_LOGGER_UART_SELECTION_USB_CDC")
|
||||
zephyr_add_prj_conf("UART_LINE_CTRL", True)
|
||||
zephyr_add_cdc_acm(config, 0)
|
||||
if has_serial_logging:
|
||||
if config[CONF_HARDWARE_UART] == UART0:
|
||||
zephyr_add_overlay("""&uart0 { status = "okay";};""")
|
||||
if config[CONF_HARDWARE_UART] == UART1:
|
||||
zephyr_add_overlay("""&uart1 { status = "okay";};""")
|
||||
if config[CONF_HARDWARE_UART] == USB_CDC:
|
||||
cg.add_define("USE_LOGGER_UART_SELECTION_USB_CDC")
|
||||
zephyr_add_prj_conf("UART_LINE_CTRL", True)
|
||||
zephyr_add_cdc_acm(config, 0)
|
||||
|
||||
# Register at end for safe mode
|
||||
await cg.register_component(log, config)
|
||||
|
||||
@@ -65,10 +65,12 @@ void Logger::pre_setup() {
|
||||
break;
|
||||
#ifdef USE_LOGGER_USB_CDC
|
||||
case UART_SELECTION_USB_CDC:
|
||||
#ifdef CONFIG_USB_DEVICE_STACK
|
||||
uart_dev = DEVICE_DT_GET_OR_NULL(DT_NODELABEL(cdc_acm_uart0));
|
||||
if (device_is_ready(uart_dev)) {
|
||||
usb_enable(nullptr);
|
||||
}
|
||||
#endif
|
||||
break;
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
from collections.abc import Callable
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import audio
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ENTITY_CATEGORY,
|
||||
CONF_FORMAT,
|
||||
CONF_ICON,
|
||||
CONF_ID,
|
||||
CONF_NUM_CHANNELS,
|
||||
CONF_ON_IDLE,
|
||||
CONF_ON_STATE,
|
||||
CONF_ON_TURN_OFF,
|
||||
CONF_ON_TURN_ON,
|
||||
CONF_SAMPLE_RATE,
|
||||
CONF_VOLUME,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity
|
||||
from esphome.core.entity_helpers import (
|
||||
entity_duplicate_validator,
|
||||
inherit_property_from,
|
||||
setup_entity,
|
||||
)
|
||||
from esphome.coroutine import CoroPriority, coroutine_with_priority
|
||||
from esphome.cpp_generator import MockObjClass
|
||||
from esphome.cpp_generator import MockObj, MockObjClass
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@jesserockz"]
|
||||
|
||||
@@ -34,6 +45,102 @@ MEDIA_PLAYER_FORMAT_PURPOSE_ENUM = {
|
||||
"announcement": MediaPlayerFormatPurpose.PURPOSE_ANNOUNCEMENT,
|
||||
}
|
||||
|
||||
# Public API for external components. Do not remove.
|
||||
FORMAT_MAPPING = {
|
||||
"FLAC": "flac",
|
||||
"MP3": "mp3",
|
||||
"OPUS": "opus",
|
||||
"WAV": "wav",
|
||||
}
|
||||
|
||||
|
||||
def build_supported_format_struct(
|
||||
format_config: ConfigType, purpose: MockObj
|
||||
) -> cg.StructInitializer:
|
||||
"""Build a MediaPlayerSupportedFormat struct from a format config and purpose.
|
||||
|
||||
Public API for external components. Do not remove.
|
||||
"""
|
||||
args = [
|
||||
MediaPlayerSupportedFormat,
|
||||
("format", FORMAT_MAPPING[format_config[CONF_FORMAT]]),
|
||||
("sample_rate", format_config[CONF_SAMPLE_RATE]),
|
||||
("num_channels", format_config[CONF_NUM_CHANNELS]),
|
||||
("purpose", purpose),
|
||||
]
|
||||
|
||||
# Omit sample_bytes for MP3: ffmpeg transcoding in Home Assistant fails
|
||||
# if the number of bytes per sample is specified for MP3.
|
||||
if format_config[CONF_FORMAT] != "MP3":
|
||||
args.append(("sample_bytes", 2))
|
||||
|
||||
return cg.StructInitializer(*args)
|
||||
|
||||
|
||||
def validate_preferred_format(
|
||||
component_name: str, audio_device_key: str
|
||||
) -> Callable[[ConfigType], ConfigType]:
|
||||
"""Return a validator that inherits audio device settings and validates format constraints.
|
||||
|
||||
Public API for external components. Do not remove.
|
||||
"""
|
||||
|
||||
def validator(config: ConfigType) -> ConfigType:
|
||||
# Inherit settings from audio device if not manually set
|
||||
inherit_property_from(CONF_NUM_CHANNELS, audio_device_key)(config)
|
||||
inherit_property_from(CONF_SAMPLE_RATE, audio_device_key)(config)
|
||||
|
||||
# Opus only supports 48 kHz
|
||||
if config.get(CONF_FORMAT) == "OPUS" and config.get(CONF_SAMPLE_RATE) != 48000:
|
||||
raise cv.Invalid("Opus only supports a sample rate of 48000 Hz")
|
||||
|
||||
# Validate the settings are compatible with the audio device
|
||||
audio.final_validate_audio_schema(
|
||||
component_name,
|
||||
audio_device=audio_device_key,
|
||||
bits_per_sample=16,
|
||||
channels=config.get(CONF_NUM_CHANNELS),
|
||||
sample_rate=config.get(CONF_SAMPLE_RATE),
|
||||
)(config)
|
||||
|
||||
return config
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
def request_codecs_for_format_configs(
|
||||
config: ConfigType, format_config_keys: list[str]
|
||||
) -> None:
|
||||
"""Scan format configs for configured formats and request the needed codec support.
|
||||
|
||||
If any config uses "NONE" (accepts any format), all codecs are requested.
|
||||
|
||||
Public API for external components. Do not remove.
|
||||
"""
|
||||
needed_formats: set[str] = set()
|
||||
need_all = False
|
||||
|
||||
for key in format_config_keys:
|
||||
if format_config := config.get(key):
|
||||
fmt = format_config[CONF_FORMAT]
|
||||
if fmt == "NONE":
|
||||
need_all = True
|
||||
else:
|
||||
needed_formats.add(fmt)
|
||||
|
||||
if need_all:
|
||||
audio.request_flac_support()
|
||||
audio.request_mp3_support()
|
||||
audio.request_opus_support()
|
||||
else:
|
||||
if "FLAC" in needed_formats:
|
||||
audio.request_flac_support()
|
||||
if "MP3" in needed_formats:
|
||||
audio.request_mp3_support()
|
||||
if "OPUS" in needed_formats:
|
||||
audio.request_opus_support()
|
||||
|
||||
|
||||
# Local config key constants
|
||||
CONF_ANNOUNCEMENT = "announcement"
|
||||
CONF_ON_PLAY = "on_play"
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import esp32, network, psram, socket, wifi
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_BUFFER_SIZE,
|
||||
CONF_ID,
|
||||
CONF_SAMPLE_RATE,
|
||||
CONF_TASK_STACK_IN_PSRAM,
|
||||
)
|
||||
from esphome.core import CORE, ID
|
||||
from esphome.cpp_generator import TemplateArgsType
|
||||
from esphome.types import ConfigType
|
||||
|
||||
# mdns for autodiscovery
|
||||
AUTO_LOAD = ["mdns"]
|
||||
CODEOWNERS = ["@kahrendt"]
|
||||
DEPENDENCIES = ["network"]
|
||||
DOMAIN = "sendspin"
|
||||
|
||||
CONF_SENDSPIN_ID = "sendspin_id"
|
||||
|
||||
CONF_INITIAL_STATIC_DELAY = "initial_static_delay"
|
||||
CONF_FIXED_DELAY = "fixed_delay"
|
||||
|
||||
# sendspin-cpp library lives in the global `sendspin` namespace.
|
||||
sendspin_library_ns = cg.global_ns.namespace("sendspin")
|
||||
|
||||
# Library Enums
|
||||
SendspinCodecFormat = sendspin_library_ns.enum("SendspinCodecFormat", is_class=True)
|
||||
CODEC_FORMAT_FLAC = SendspinCodecFormat.enum("FLAC")
|
||||
CODEC_FORMAT_OPUS = SendspinCodecFormat.enum("OPUS")
|
||||
CODEC_FORMAT_PCM = SendspinCodecFormat.enum("PCM")
|
||||
CODEC_FORMAT_UNSUPPORTED = SendspinCodecFormat.enum("UNSUPPORTED")
|
||||
|
||||
# Library Structs
|
||||
AudioSupportedFormatObject = sendspin_library_ns.struct("AudioSupportedFormatObject")
|
||||
PlayerRoleConfig = sendspin_library_ns.struct("PlayerRoleConfig")
|
||||
|
||||
# Trailing underscore avoids clashing with sendspin-cpp's global `sendspin` namespace.
|
||||
# Analysis tools strip the trailing underscore (same pattern as `template_`).
|
||||
sendspin_ns = cg.esphome_ns.namespace("sendspin_")
|
||||
SendspinHub = sendspin_ns.class_(
|
||||
"SendspinHub",
|
||||
cg.Component,
|
||||
)
|
||||
|
||||
|
||||
SendspinSwitchCommandAction = sendspin_ns.class_(
|
||||
"SendspinSwitchCommandAction",
|
||||
automation.Action,
|
||||
cg.Parented.template(SendspinHub),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SendspinConfiguration:
|
||||
artwork_support: bool = False
|
||||
controller_support: bool = False
|
||||
metadata_support: bool = False
|
||||
player_support: bool = False
|
||||
visualizer_support: bool = False
|
||||
|
||||
player_config: ConfigType | None = None
|
||||
|
||||
|
||||
def _get_data() -> SendspinConfiguration:
|
||||
if DOMAIN not in CORE.data:
|
||||
CORE.data[DOMAIN] = SendspinConfiguration()
|
||||
return CORE.data[DOMAIN]
|
||||
|
||||
|
||||
def request_artwork_support() -> None:
|
||||
"""Request artwork role support for Sendspin."""
|
||||
_get_data().artwork_support = True
|
||||
|
||||
|
||||
def request_controller_support() -> None:
|
||||
"""Request controller role support for Sendspin."""
|
||||
_get_data().controller_support = True
|
||||
|
||||
|
||||
def request_metadata_support() -> None:
|
||||
"""Request metadata role support for Sendspin."""
|
||||
_get_data().metadata_support = True
|
||||
|
||||
|
||||
def request_player_support() -> None:
|
||||
"""Request player role support for Sendspin."""
|
||||
_get_data().player_support = True
|
||||
|
||||
|
||||
def request_visualizer_support() -> None:
|
||||
"""Request visualizer role support for Sendspin."""
|
||||
_get_data().visualizer_support = True
|
||||
|
||||
|
||||
def register_player_config(config: ConfigType) -> None:
|
||||
"""Register the player role config from the media source subcomponent."""
|
||||
data = _get_data()
|
||||
request_player_support()
|
||||
if data.player_config is not None:
|
||||
raise cv.Invalid(
|
||||
"Only one sendspin media_source player configuration is supported"
|
||||
)
|
||||
data.player_config = config
|
||||
|
||||
|
||||
def _validate_task_stack_in_psram(value):
|
||||
value = cv.boolean(value)
|
||||
if value:
|
||||
return cv.requires_component(psram.DOMAIN)(value)
|
||||
return value
|
||||
|
||||
|
||||
def _request_high_performance_networking(config: ConfigType) -> ConfigType:
|
||||
"""Request high performance networking for Sendspin streaming.
|
||||
|
||||
Also enables wake_loop_threadsafe support for fast defer() callbacks
|
||||
from background threads (WebSocket handler, image decoder).
|
||||
"""
|
||||
network.require_high_performance_networking()
|
||||
# Socket consumption varies by mode:
|
||||
# - Server mode: 1 listening socket + 2 client connections (for handoff)
|
||||
# - Client mode: 1 outbound connection
|
||||
socket.consume_sockets(
|
||||
1, "sendspin_websocket_server", socket.SocketType.TCP_LISTEN
|
||||
)(config)
|
||||
socket.consume_sockets(2, "sendspin_websocket_server")(config)
|
||||
socket.consume_sockets(1, "sendspin_websocket_client")(config)
|
||||
|
||||
wifi.enable_runtime_power_save_control()
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(SendspinHub),
|
||||
cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram,
|
||||
}
|
||||
),
|
||||
cv.only_on_esp32,
|
||||
_request_high_performance_networking,
|
||||
)
|
||||
|
||||
|
||||
def _request_controller_role(config: ConfigType) -> ConfigType:
|
||||
"""Request the controller role for the sendspin.switch action."""
|
||||
request_controller_support()
|
||||
return config
|
||||
|
||||
|
||||
SENDSPIN_SIMPLE_ACTION_SCHEMA = cv.All(
|
||||
automation.maybe_simple_id(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(SendspinHub),
|
||||
}
|
||||
)
|
||||
),
|
||||
_request_controller_role,
|
||||
)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"sendspin.switch",
|
||||
SendspinSwitchCommandAction,
|
||||
SENDSPIN_SIMPLE_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def sendspin_switch_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
):
|
||||
var = cg.new_Pvariable(action_id, template_arg)
|
||||
await cg.register_parented(var, config[CONF_ID])
|
||||
return var
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
|
||||
if config.get(CONF_TASK_STACK_IN_PSRAM):
|
||||
cg.add(var.set_task_stack_in_psram(True))
|
||||
esp32.add_idf_sdkconfig_option(
|
||||
"CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True
|
||||
)
|
||||
|
||||
# sendspin-cpp library
|
||||
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.3.0")
|
||||
|
||||
cg.add_define("USE_SENDSPIN", True) # for MDNS
|
||||
|
||||
data = _get_data()
|
||||
|
||||
# Configure Sendspin roles based on requested features (ESPHome internally via USE_SENDSPIN_*)
|
||||
# and disable building unused code paths in the sendspin-cpp library (IDF SDKConfig via CONFIG_SENDSPIN_ENABLE_*).
|
||||
if data.artwork_support:
|
||||
cg.add_define("USE_SENDSPIN_ARTWORK", True)
|
||||
else:
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_ARTWORK", False)
|
||||
|
||||
if data.controller_support:
|
||||
cg.add_define("USE_SENDSPIN_CONTROLLER", True)
|
||||
else:
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_CONTROLLER", False)
|
||||
|
||||
if data.metadata_support:
|
||||
cg.add_define("USE_SENDSPIN_METADATA", True)
|
||||
else:
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_METADATA", False)
|
||||
|
||||
if data.player_support:
|
||||
cg.add_define("USE_SENDSPIN_PLAYER", True)
|
||||
|
||||
# Configures the player role. We always assume support for 16 bits per sample mono and stereo FLAC, Opus, and PCM at the configured sample rate
|
||||
# (with Opus only supported at 48 kHz since that's the only sample rate it supports). Users can configure the specific formats via the Sendspin server
|
||||
player_cfg = data.player_config
|
||||
sample_rate = player_cfg[CONF_SAMPLE_RATE]
|
||||
|
||||
# OPUS only supports 48 kHz audio
|
||||
codecs = [CODEC_FORMAT_FLAC]
|
||||
if sample_rate == 48000:
|
||||
codecs.append(CODEC_FORMAT_OPUS)
|
||||
codecs.append(CODEC_FORMAT_PCM)
|
||||
|
||||
def _audio_format(codec, channels):
|
||||
return cg.StructInitializer(
|
||||
AudioSupportedFormatObject,
|
||||
("codec", codec),
|
||||
("channels", channels),
|
||||
("sample_rate", sample_rate),
|
||||
("bit_depth", 16),
|
||||
)
|
||||
|
||||
audio_format_structs = [
|
||||
_audio_format(codec, channels) for codec in codecs for channels in (2, 1)
|
||||
]
|
||||
|
||||
psram_stack = player_cfg.get(CONF_TASK_STACK_IN_PSRAM, False)
|
||||
if psram_stack:
|
||||
esp32.add_idf_sdkconfig_option(
|
||||
"CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True
|
||||
)
|
||||
|
||||
player_config_struct = cg.StructInitializer(
|
||||
PlayerRoleConfig,
|
||||
("audio_formats", audio_format_structs),
|
||||
("audio_buffer_capacity", player_cfg[CONF_BUFFER_SIZE]),
|
||||
("fixed_delay_us", player_cfg[CONF_FIXED_DELAY]),
|
||||
("initial_static_delay_ms", player_cfg[CONF_INITIAL_STATIC_DELAY]),
|
||||
("psram_stack", psram_stack),
|
||||
("priority", 2),
|
||||
)
|
||||
cg.add(var.set_player_config(player_config_struct))
|
||||
else:
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_PLAYER", False)
|
||||
|
||||
if data.visualizer_support:
|
||||
cg.add_define("USE_SENDSPIN_VISUALIZER", True)
|
||||
else:
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_VISUALIZER", False)
|
||||
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include "esphome/core/automation.h"
|
||||
#include "sendspin_hub.h"
|
||||
|
||||
namespace esphome::sendspin_ {
|
||||
|
||||
#ifdef USE_SENDSPIN_CONTROLLER
|
||||
template<typename... Ts> class SendspinSwitchCommandAction : public Action<Ts...>, public Parented<SendspinHub> {
|
||||
public:
|
||||
void play(const Ts &...x) override {
|
||||
// Clear any EXTERNAL_SOURCE state so the switch command is followed
|
||||
this->parent_->update_state(sendspin::SendspinClientState::SYNCHRONIZED);
|
||||
this->parent_->send_client_command(sendspin::SendspinControllerCommand::SWITCH);
|
||||
}
|
||||
};
|
||||
#endif // USE_SENDSPIN_CONTROLLER
|
||||
|
||||
} // namespace esphome::sendspin_
|
||||
|
||||
#endif // USE_ESP32
|
||||
@@ -0,0 +1,45 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import media_player
|
||||
from esphome.components.const import CONF_VOLUME_INCREMENT
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import CONF_SENDSPIN_ID, SendspinHub, request_controller_support, sendspin_ns
|
||||
|
||||
CODEOWNERS = ["@kahrendt"]
|
||||
DEPENDENCIES = ["sendspin"]
|
||||
|
||||
SendspinMediaPlayer = sendspin_ns.class_(
|
||||
"SendspinMediaPlayer",
|
||||
media_player.MediaPlayer,
|
||||
cg.Component,
|
||||
)
|
||||
|
||||
|
||||
def _request_roles(config: ConfigType) -> ConfigType:
|
||||
"""Request the necessary Sendspin roles for the media player."""
|
||||
request_controller_support()
|
||||
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
media_player.media_player_schema(SendspinMediaPlayer).extend(
|
||||
{
|
||||
cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub),
|
||||
cv.Optional(CONF_VOLUME_INCREMENT, default=0.05): cv.percentage,
|
||||
}
|
||||
),
|
||||
cv.only_on_esp32,
|
||||
_request_roles,
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await cg.register_parented(var, config[CONF_SENDSPIN_ID])
|
||||
await media_player.register_media_player(var, config)
|
||||
|
||||
cg.add(var.set_volume_increment(config[CONF_VOLUME_INCREMENT]))
|
||||
@@ -0,0 +1,165 @@
|
||||
#include "sendspin_media_player.h"
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER)
|
||||
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <sendspin/types.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
|
||||
#include <esp_timer.h>
|
||||
|
||||
namespace esphome::sendspin_ {
|
||||
|
||||
static const char *const TAG = "sendspin.media_player";
|
||||
|
||||
// THREAD CONTEXT: Main loop. The callbacks registered here also fire on the main loop,
|
||||
// since SendspinHub dispatches group updates and controller state from client_->loop().
|
||||
void SendspinMediaPlayer::setup() {
|
||||
// Register for group updates to sync playback state
|
||||
this->parent_->add_group_update_callback([this](const sendspin::GroupUpdateObject &group_obj) {
|
||||
if (group_obj.playback_state.has_value()) {
|
||||
media_player::MediaPlayerState new_state;
|
||||
switch (group_obj.playback_state.value()) {
|
||||
case sendspin::SendspinPlaybackState::PLAYING:
|
||||
new_state = media_player::MEDIA_PLAYER_STATE_PLAYING;
|
||||
break;
|
||||
case sendspin::SendspinPlaybackState::STOPPED:
|
||||
default:
|
||||
new_state = media_player::MEDIA_PLAYER_STATE_IDLE;
|
||||
break;
|
||||
}
|
||||
if (this->state != new_state) {
|
||||
this->state = new_state;
|
||||
this->publish_state();
|
||||
ESP_LOGD(TAG, "State changed to %s", media_player::media_player_state_to_string(this->state));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this->parent_->add_controller_state_callback([this](const sendspin::ServerStateControllerObject &state) {
|
||||
float new_volume = static_cast<float>(state.volume) / 100.0f;
|
||||
bool new_muted = state.muted;
|
||||
if ((new_volume != this->volume) || (new_muted != this->muted_)) {
|
||||
this->volume = new_volume;
|
||||
this->muted_ = new_muted;
|
||||
this->publish_state();
|
||||
}
|
||||
});
|
||||
|
||||
// Publish an initial state
|
||||
this->state = media_player::MEDIA_PLAYER_STATE_IDLE;
|
||||
this->publish_state();
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (invoked by the media_player framework)
|
||||
media_player::MediaPlayerTraits SendspinMediaPlayer::get_traits() {
|
||||
auto traits = media_player::MediaPlayerTraits();
|
||||
|
||||
// By default, the base media player always enables these traits, but they are not actually supported by this media
|
||||
// player
|
||||
traits.clear_feature_flags(media_player::MediaPlayerEntityFeature::PLAY_MEDIA |
|
||||
media_player::MediaPlayerEntityFeature::BROWSE_MEDIA |
|
||||
media_player::MediaPlayerEntityFeature::MEDIA_ANNOUNCE);
|
||||
|
||||
traits.add_feature_flags(
|
||||
media_player::MediaPlayerEntityFeature::PLAY | media_player::MediaPlayerEntityFeature::PAUSE |
|
||||
media_player::MediaPlayerEntityFeature::STOP | media_player::MediaPlayerEntityFeature::VOLUME_STEP |
|
||||
media_player::MediaPlayerEntityFeature::VOLUME_SET | media_player::MediaPlayerEntityFeature::VOLUME_MUTE);
|
||||
|
||||
// NEXT_TRACK, PREVIOUS_TRACK, SHUFFLE_SET, and REPEAT_SET are intentionally not advertised: the ESPHome native API
|
||||
// does not implement the corresponding media player commands, so Home Assistant cannot actually send them even if
|
||||
// we expose the capability. They remain accessible via ESPHome YAML automations.
|
||||
|
||||
return traits;
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (invoked by the media_player framework)
|
||||
void SendspinMediaPlayer::control(const media_player::MediaPlayerCall &call) {
|
||||
if (!this->is_ready()) {
|
||||
// Ignore any commands sent before the media player is setup
|
||||
return;
|
||||
}
|
||||
|
||||
auto volume = call.get_volume();
|
||||
if (volume.has_value()) {
|
||||
uint8_t new_volume = static_cast<uint8_t>(std::roundf(volume.value() * 100.0f));
|
||||
this->parent_->send_client_command(sendspin::SendspinControllerCommand::VOLUME, new_volume, std::nullopt);
|
||||
}
|
||||
|
||||
auto command = call.get_command();
|
||||
if (!command.has_value()) {
|
||||
return;
|
||||
}
|
||||
switch (command.value()) {
|
||||
case media_player::MEDIA_PLAYER_COMMAND_TOGGLE:
|
||||
if (this->state == media_player::MediaPlayerState::MEDIA_PLAYER_STATE_PLAYING) {
|
||||
this->parent_->send_client_command(sendspin::SendspinControllerCommand::PAUSE);
|
||||
} else {
|
||||
this->parent_->send_client_command(sendspin::SendspinControllerCommand::PLAY);
|
||||
}
|
||||
break;
|
||||
case media_player::MEDIA_PLAYER_COMMAND_PLAY:
|
||||
this->parent_->send_client_command(sendspin::SendspinControllerCommand::PLAY);
|
||||
break;
|
||||
case media_player::MEDIA_PLAYER_COMMAND_PAUSE:
|
||||
this->parent_->send_client_command(sendspin::SendspinControllerCommand::PAUSE);
|
||||
break;
|
||||
case media_player::MEDIA_PLAYER_COMMAND_STOP:
|
||||
this->parent_->send_client_command(sendspin::SendspinControllerCommand::STOP);
|
||||
break;
|
||||
case media_player::MEDIA_PLAYER_COMMAND_REPEAT_OFF:
|
||||
this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_OFF);
|
||||
break;
|
||||
case media_player::MEDIA_PLAYER_COMMAND_REPEAT_ONE:
|
||||
this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_ONE);
|
||||
break;
|
||||
case media_player::MEDIA_PLAYER_COMMAND_REPEAT_ALL:
|
||||
this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_ALL);
|
||||
break;
|
||||
case media_player::MEDIA_PLAYER_COMMAND_SHUFFLE:
|
||||
this->parent_->send_client_command(sendspin::SendspinControllerCommand::SHUFFLE);
|
||||
break;
|
||||
case media_player::MEDIA_PLAYER_COMMAND_UNSHUFFLE:
|
||||
this->parent_->send_client_command(sendspin::SendspinControllerCommand::UNSHUFFLE);
|
||||
break;
|
||||
case media_player::MEDIA_PLAYER_COMMAND_NEXT:
|
||||
this->parent_->send_client_command(sendspin::SendspinControllerCommand::NEXT);
|
||||
break;
|
||||
case media_player::MEDIA_PLAYER_COMMAND_PREVIOUS:
|
||||
this->parent_->send_client_command(sendspin::SendspinControllerCommand::PREVIOUS);
|
||||
break;
|
||||
case media_player::MEDIA_PLAYER_COMMAND_VOLUME_UP:
|
||||
this->parent_->send_client_command(
|
||||
sendspin::SendspinControllerCommand::VOLUME,
|
||||
static_cast<uint8_t>(std::roundf(std::min(1.0f, this->volume + this->volume_increment_) * 100.0f)),
|
||||
std::nullopt);
|
||||
break;
|
||||
case media_player::MEDIA_PLAYER_COMMAND_VOLUME_DOWN:
|
||||
this->parent_->send_client_command(
|
||||
sendspin::SendspinControllerCommand::VOLUME,
|
||||
static_cast<uint8_t>(std::roundf(std::max(0.0f, this->volume - this->volume_increment_) * 100.0f)),
|
||||
std::nullopt);
|
||||
break;
|
||||
case media_player::MEDIA_PLAYER_COMMAND_MUTE:
|
||||
this->parent_->send_client_command(sendspin::SendspinControllerCommand::MUTE, std::nullopt, true);
|
||||
break;
|
||||
case media_player::MEDIA_PLAYER_COMMAND_UNMUTE:
|
||||
this->parent_->send_client_command(sendspin::SendspinControllerCommand::MUTE, std::nullopt, false);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void SendspinMediaPlayer::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "Sendspin Media Player: volume_increment=%.2f", this->volume_increment_);
|
||||
}
|
||||
|
||||
} // namespace esphome::sendspin_
|
||||
#endif
|
||||
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER)
|
||||
|
||||
#include "esphome/components/media_player/media_player.h"
|
||||
#include "esphome/components/sendspin/sendspin_hub.h"
|
||||
|
||||
namespace esphome::sendspin_ {
|
||||
|
||||
class SendspinMediaPlayer : public SendspinChild, public media_player::MediaPlayer {
|
||||
public:
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
|
||||
// MediaPlayer implementations
|
||||
media_player::MediaPlayerTraits get_traits() override;
|
||||
|
||||
void set_volume_increment(float volume_increment) { this->volume_increment_ = volume_increment; }
|
||||
|
||||
bool is_muted() const override { return this->muted_; }
|
||||
|
||||
protected:
|
||||
// Receives commands from HA
|
||||
void control(const media_player::MediaPlayerCall &call) override;
|
||||
|
||||
float volume_increment_{0.05f};
|
||||
bool muted_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::sendspin_
|
||||
#endif
|
||||
@@ -0,0 +1,134 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import media_source
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_BUFFER_SIZE,
|
||||
CONF_ID,
|
||||
CONF_SAMPLE_RATE,
|
||||
CONF_TASK_STACK_IN_PSRAM,
|
||||
)
|
||||
from esphome.core import ID
|
||||
from esphome.cpp_generator import MockObj, TemplateArgsType
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import (
|
||||
CONF_FIXED_DELAY,
|
||||
CONF_INITIAL_STATIC_DELAY,
|
||||
CONF_SENDSPIN_ID,
|
||||
SendspinHub,
|
||||
_validate_task_stack_in_psram,
|
||||
register_player_config,
|
||||
request_controller_support,
|
||||
sendspin_ns,
|
||||
)
|
||||
|
||||
AUTO_LOAD = ["audio"]
|
||||
CODEOWNERS = ["@kahrendt"]
|
||||
|
||||
CONF_STATIC_DELAY_ADJUSTABLE = "static_delay_adjustable"
|
||||
|
||||
|
||||
SendspinMediaSource = sendspin_ns.class_(
|
||||
"SendspinMediaSource",
|
||||
cg.Component,
|
||||
media_source.MediaSource,
|
||||
)
|
||||
|
||||
EnableStaticDelayAdjustmentAction = sendspin_ns.class_(
|
||||
"EnableStaticDelayAdjustmentAction",
|
||||
automation.Action,
|
||||
cg.Parented.template(SendspinMediaSource),
|
||||
)
|
||||
|
||||
DisableStaticDelayAdjustmentAction = sendspin_ns.class_(
|
||||
"DisableStaticDelayAdjustmentAction",
|
||||
automation.Action,
|
||||
cg.Parented.template(SendspinMediaSource),
|
||||
)
|
||||
|
||||
|
||||
def _register(config: ConfigType) -> ConfigType:
|
||||
request_controller_support()
|
||||
register_player_config(
|
||||
{
|
||||
CONF_SAMPLE_RATE: config[CONF_SAMPLE_RATE],
|
||||
CONF_BUFFER_SIZE: config[CONF_BUFFER_SIZE],
|
||||
CONF_INITIAL_STATIC_DELAY: config[CONF_INITIAL_STATIC_DELAY],
|
||||
CONF_FIXED_DELAY: config[CONF_FIXED_DELAY],
|
||||
CONF_TASK_STACK_IN_PSRAM: config.get(CONF_TASK_STACK_IN_PSRAM, False),
|
||||
}
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
media_source.media_source_schema(
|
||||
SendspinMediaSource,
|
||||
).extend(
|
||||
{
|
||||
cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub),
|
||||
cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram,
|
||||
cv.Optional(CONF_BUFFER_SIZE, default=1000000): cv.int_range(min=25000),
|
||||
cv.Optional(CONF_INITIAL_STATIC_DELAY, default="0ms"): cv.All(
|
||||
cv.positive_time_period_milliseconds,
|
||||
cv.Range(max=cv.TimePeriod(milliseconds=5000)),
|
||||
),
|
||||
cv.Optional(CONF_STATIC_DELAY_ADJUSTABLE, default=False): cv.boolean,
|
||||
cv.Optional(CONF_FIXED_DELAY, default="0us"): cv.All(
|
||||
cv.positive_time_period_microseconds,
|
||||
cv.Range(max=cv.TimePeriod(microseconds=10000)),
|
||||
),
|
||||
cv.Optional(CONF_SAMPLE_RATE, default=48000): cv.int_range(
|
||||
min=16000, max=96000
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.only_on_esp32,
|
||||
_register,
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await media_source.register_media_source(var, config)
|
||||
|
||||
sendspin_hub = await cg.get_variable(config[CONF_SENDSPIN_ID])
|
||||
await cg.register_parented(var, sendspin_hub)
|
||||
|
||||
cg.add(sendspin_hub.set_listener(var))
|
||||
|
||||
cg.add(var.set_static_delay_adjustable(config[CONF_STATIC_DELAY_ADJUSTABLE]))
|
||||
|
||||
|
||||
SENDSPIN_MEDIA_SOURCE_ACTION_SCHEMA = automation.maybe_simple_id(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(SendspinMediaSource),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"sendspin.media_source.enable_static_delay_adjustment",
|
||||
EnableStaticDelayAdjustmentAction,
|
||||
SENDSPIN_MEDIA_SOURCE_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
@automation.register_action(
|
||||
"sendspin.media_source.disable_static_delay_adjustment",
|
||||
DisableStaticDelayAdjustmentAction,
|
||||
SENDSPIN_MEDIA_SOURCE_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def sendspin_static_delay_adjustment_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
var = cg.new_Pvariable(action_id, template_arg)
|
||||
await cg.register_parented(var, config[CONF_ID])
|
||||
return var
|
||||
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_SENDSPIN_PLAYER) && defined(USE_SENDSPIN_CONTROLLER)
|
||||
|
||||
#include "esphome/core/automation.h"
|
||||
#include "sendspin_media_source.h"
|
||||
|
||||
namespace esphome::sendspin_ {
|
||||
|
||||
template<typename... Ts>
|
||||
class EnableStaticDelayAdjustmentAction : public Action<Ts...>, public Parented<SendspinMediaSource> {
|
||||
public:
|
||||
void play(const Ts &...x) override { this->parent_->set_static_delay_adjustable(true); }
|
||||
};
|
||||
|
||||
template<typename... Ts>
|
||||
class DisableStaticDelayAdjustmentAction : public Action<Ts...>, public Parented<SendspinMediaSource> {
|
||||
public:
|
||||
void play(const Ts &...x) override { this->parent_->set_static_delay_adjustable(false); }
|
||||
};
|
||||
|
||||
} // namespace esphome::sendspin_
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,207 @@
|
||||
#include "sendspin_media_source.h"
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER)
|
||||
|
||||
#include "esphome/components/audio/audio.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace esphome::sendspin_ {
|
||||
|
||||
static const char *const TAG = "sendspin.media_source";
|
||||
|
||||
static constexpr char URI_PREFIX[] = "sendspin://";
|
||||
|
||||
void SendspinMediaSource::setup() {
|
||||
this->player_role_ = this->parent_->get_player_role();
|
||||
if (!this->player_role_) {
|
||||
ESP_LOGE(TAG, "Failed to get player role from hub");
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
|
||||
// Push cached states to player role. They may have been set before setup() ran.
|
||||
this->player_role_->update_volume(std::roundf(this->cached_volume_ * 100.0f));
|
||||
this->player_role_->update_muted(this->cached_muted_);
|
||||
this->player_role_->set_static_delay_adjustable(this->static_delay_adjustable_);
|
||||
}
|
||||
|
||||
void SendspinMediaSource::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "Sendspin Media Source: static_delay_adjustable=%s", YESNO(this->static_delay_adjustable_));
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (invoked from ESPHome actions / config)
|
||||
void SendspinMediaSource::set_static_delay_adjustable(bool adjustable) {
|
||||
this->static_delay_adjustable_ = adjustable;
|
||||
if (this->player_role_) {
|
||||
this->player_role_->set_static_delay_adjustable(adjustable);
|
||||
}
|
||||
}
|
||||
|
||||
// --- MediaSource interface ---
|
||||
|
||||
bool SendspinMediaSource::can_handle(const std::string &uri) const { return uri.starts_with(URI_PREFIX); }
|
||||
|
||||
// THREAD CONTEXT: Main loop (media_source.h documents play_uri as main-loop only)
|
||||
bool SendspinMediaSource::play_uri(const std::string &uri) {
|
||||
if (!this->is_ready() || this->is_failed() || !this->has_listener()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this->get_state() != media_source::MediaSourceState::IDLE) {
|
||||
ESP_LOGE(TAG, "Cannot play '%s': source is busy", uri.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!uri.starts_with(URI_PREFIX)) {
|
||||
ESP_LOGE(TAG, "Invalid URI: '%s'", uri.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string sendspin_id = uri.substr(sizeof(URI_PREFIX) - 1);
|
||||
|
||||
if (sendspin_id.empty()) {
|
||||
ESP_LOGE(TAG, "Invalid URI: '%s'", uri.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
ESP_LOGD(TAG, "sendspin_id: %s", sendspin_id.c_str());
|
||||
|
||||
if (sendspin_id != "current") {
|
||||
// Connect to a new server as a websocket client
|
||||
this->parent_->connect_to_server("ws://" + sendspin_id);
|
||||
}
|
||||
|
||||
// Tell the orchestrator we're now playing so it routes audio output from us
|
||||
this->pending_start_ = false;
|
||||
this->set_state_(media_source::MediaSourceState::PLAYING);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (media_source.h documents handle_command as main-loop only)
|
||||
void SendspinMediaSource::handle_command(media_source::MediaSourceCommand command) {
|
||||
switch (command) {
|
||||
case media_source::MediaSourceCommand::STOP: {
|
||||
if (!this->pending_start_) {
|
||||
// Ignore stop commands if we have a pending start, since the orchestrator may send a stop command before
|
||||
// play_uri
|
||||
ESP_LOGD(TAG, "Received STOP command, updating Sendspin state to EXTERNAL_SOURCE");
|
||||
this->parent_->update_state(sendspin::SendspinClientState::EXTERNAL_SOURCE);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case media_source::MediaSourceCommand::PLAY: // NOLINT(bugprone-branch-clone)
|
||||
this->parent_->send_client_command(sendspin::SendspinControllerCommand::PLAY, std::nullopt, std::nullopt);
|
||||
break;
|
||||
case media_source::MediaSourceCommand::PAUSE:
|
||||
this->parent_->send_client_command(sendspin::SendspinControllerCommand::PAUSE, std::nullopt, std::nullopt);
|
||||
break;
|
||||
case media_source::MediaSourceCommand::NEXT:
|
||||
this->parent_->send_client_command(sendspin::SendspinControllerCommand::NEXT, std::nullopt, std::nullopt);
|
||||
break;
|
||||
case media_source::MediaSourceCommand::PREVIOUS:
|
||||
this->parent_->send_client_command(sendspin::SendspinControllerCommand::PREVIOUS, std::nullopt, std::nullopt);
|
||||
break;
|
||||
case media_source::MediaSourceCommand::REPEAT_ALL:
|
||||
this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_ALL, std::nullopt, std::nullopt);
|
||||
break;
|
||||
case media_source::MediaSourceCommand::REPEAT_ONE:
|
||||
this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_ONE, std::nullopt, std::nullopt);
|
||||
break;
|
||||
case media_source::MediaSourceCommand::REPEAT_OFF:
|
||||
this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_OFF, std::nullopt, std::nullopt);
|
||||
break;
|
||||
case media_source::MediaSourceCommand::SHUFFLE:
|
||||
this->parent_->send_client_command(sendspin::SendspinControllerCommand::SHUFFLE, std::nullopt, std::nullopt);
|
||||
break;
|
||||
case media_source::MediaSourceCommand::UNSHUFFLE:
|
||||
this->parent_->send_client_command(sendspin::SendspinControllerCommand::UNSHUFFLE, std::nullopt, std::nullopt);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (orchestrator -> source notification)
|
||||
void SendspinMediaSource::notify_volume_changed(float volume) {
|
||||
this->cached_volume_ = volume;
|
||||
if (this->player_role_) {
|
||||
this->player_role_->update_volume(std::roundf(volume * 100.0f));
|
||||
}
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (orchestrator -> source notification)
|
||||
void SendspinMediaSource::notify_mute_changed(bool is_muted) {
|
||||
this->cached_muted_ = is_muted;
|
||||
if (this->player_role_) {
|
||||
this->player_role_->update_muted(is_muted);
|
||||
}
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Speaker playback callback thread (forwarded from the speaker).
|
||||
// PlayerRole::notify_audio_played() is documented as thread-safe for this use.
|
||||
void SendspinMediaSource::notify_audio_played(uint32_t frames, int64_t timestamp) {
|
||||
if (this->player_role_) {
|
||||
this->player_role_->notify_audio_played(frames, timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Sendspin PlayerRoleListener overrides ---
|
||||
|
||||
// THREAD CONTEXT: Sendspin sync task background thread. May block up to timeout_ms.
|
||||
size_t SendspinMediaSource::on_audio_write(uint8_t *data, size_t length, uint32_t timeout_ms) {
|
||||
if (!this->has_listener() || (this->get_state() != media_source::MediaSourceState::PLAYING)) {
|
||||
vTaskDelay(pdMS_TO_TICKS(timeout_ms));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// PlayerRole::get_current_stream_params() is safe to call from the sync task.
|
||||
auto ¶ms = this->player_role_->get_current_stream_params();
|
||||
if (!params.bit_depth.has_value() || !params.channels.has_value() || !params.sample_rate.has_value()) {
|
||||
vTaskDelay(pdMS_TO_TICKS(timeout_ms));
|
||||
return 0;
|
||||
}
|
||||
audio::AudioStreamInfo stream_info(*params.bit_depth, *params.channels, *params.sample_rate);
|
||||
|
||||
return this->write_output(data, length, timeout_ms, stream_info);
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (PlayerRoleListener lifecycle callback)
|
||||
void SendspinMediaSource::on_stream_start() {
|
||||
this->parent_->update_state(sendspin::SendspinClientState::SYNCHRONIZED);
|
||||
|
||||
if (!this->pending_start_) {
|
||||
// Dedup rapid on_stream_start() calls
|
||||
this->pending_start_ = true;
|
||||
// Request the orchestrator to start this source
|
||||
this->request_play_uri_("sendspin://current");
|
||||
}
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (PlayerRoleListener lifecycle callback)
|
||||
void SendspinMediaSource::on_stream_end() {
|
||||
if (this->get_state() != media_source::MediaSourceState::IDLE) {
|
||||
// Only set to IDLE if we were previously in a non-IDLE state, to avoid duplicate state changes
|
||||
this->set_state_(media_source::MediaSourceState::IDLE);
|
||||
}
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (PlayerRoleListener lifecycle callback)
|
||||
void SendspinMediaSource::on_stream_clear() {
|
||||
if (this->get_state() != media_source::MediaSourceState::IDLE) {
|
||||
// Only set to IDLE if we were previously in a non-IDLE state, to avoid duplicate state changes
|
||||
this->set_state_(media_source::MediaSourceState::IDLE);
|
||||
}
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (PlayerRoleListener callback)
|
||||
void SendspinMediaSource::on_volume_changed(uint8_t volume) { this->request_volume_(volume / 100.0f); }
|
||||
|
||||
// THREAD CONTEXT: Main loop (PlayerRoleListener callback)
|
||||
void SendspinMediaSource::on_mute_changed(bool muted) { this->request_mute_(muted); }
|
||||
|
||||
} // namespace esphome::sendspin_
|
||||
|
||||
#endif // USE_ESP32 && USE_SENDSPIN_PLAYER && USE_SENDSPIN_CONTROLLER
|
||||
@@ -0,0 +1,72 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER)
|
||||
|
||||
#include "esphome/components/sendspin/sendspin_hub.h"
|
||||
|
||||
#include "esphome/components/media_source/media_source.h"
|
||||
|
||||
#include <sendspin/player_role.h>
|
||||
|
||||
namespace esphome::sendspin_ {
|
||||
|
||||
/// @brief Thin adapter media source for Sendspin.
|
||||
///
|
||||
/// Implements PlayerRoleListener to receive audio data from the sendspin-cpp library's
|
||||
/// SyncTask and bridges it to ESPHome's MediaSource output pipeline. Also forwards
|
||||
/// transport commands to the hub's controller role.
|
||||
class SendspinMediaSource : public SendspinChild,
|
||||
public media_source::MediaSource,
|
||||
public sendspin::PlayerRoleListener {
|
||||
public:
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
|
||||
void set_static_delay_adjustable(bool adjustable);
|
||||
|
||||
// MediaSource interface implementation
|
||||
bool play_uri(const std::string &uri) override;
|
||||
void handle_command(media_source::MediaSourceCommand command) override;
|
||||
bool can_handle(const std::string &uri) const override;
|
||||
bool has_internal_playlist() const override { return true; }
|
||||
|
||||
void notify_volume_changed(float volume) override;
|
||||
void notify_mute_changed(bool is_muted) override;
|
||||
void notify_audio_played(uint32_t frames, int64_t timestamp) override;
|
||||
|
||||
protected:
|
||||
// --- Sendspin PlayerRoleListener overrides ---
|
||||
|
||||
/// @brief Writes decoded PCM audio to ESPHome's media source output pipeline.
|
||||
/// Called from the sync task's background thread.
|
||||
size_t on_audio_write(uint8_t *data, size_t length, uint32_t timeout_ms) override;
|
||||
|
||||
/// @brief Called when a new audio stream starts (main loop thread).
|
||||
void on_stream_start() override;
|
||||
|
||||
/// @brief Called when the audio stream ends (main loop thread).
|
||||
void on_stream_end() override;
|
||||
|
||||
/// @brief Called when the audio stream is cleared (main loop thread).
|
||||
void on_stream_clear() override;
|
||||
|
||||
/// @brief Called when volume changes (main loop thread).
|
||||
void on_volume_changed(uint8_t volume) override;
|
||||
|
||||
/// @brief Called when mute state changes (main loop thread).
|
||||
void on_mute_changed(bool muted) override;
|
||||
|
||||
sendspin::PlayerRole *player_role_{nullptr};
|
||||
|
||||
float cached_volume_{0.0f};
|
||||
|
||||
bool cached_muted_{false};
|
||||
bool pending_start_{false};
|
||||
bool static_delay_adjustable_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::sendspin_
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,225 @@
|
||||
#include "sendspin_hub.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include "esphome/components/network/util.h"
|
||||
#ifdef USE_WIFI
|
||||
#include "esphome/components/wifi/wifi_component.h"
|
||||
#endif
|
||||
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/version.h"
|
||||
|
||||
#include <esp_log.h>
|
||||
|
||||
namespace esphome::sendspin_ {
|
||||
|
||||
static const char *const TAG = "sendspin.hub";
|
||||
|
||||
void SendspinHub::setup() {
|
||||
auto config = this->build_client_config_();
|
||||
this->client_ = std::make_unique<sendspin::SendspinClient>(std::move(config));
|
||||
|
||||
// Set up persistence (preferences must be initialized before providers are added to the client)
|
||||
this->last_played_server_pref_ =
|
||||
global_preferences->make_preference<LastPlayedServerPref>(fnv1a_hash("sendspin_last_played"));
|
||||
#ifdef USE_SENDSPIN_PLAYER
|
||||
this->static_delay_pref_ = global_preferences->make_preference<StaticDelayPref>(fnv1a_hash("sendspin_static_delay"));
|
||||
#endif
|
||||
|
||||
// Wire providers and client listener
|
||||
this->client_->set_listener(this);
|
||||
this->client_->set_network_provider(this);
|
||||
this->client_->set_persistence_provider(this);
|
||||
|
||||
#ifdef USE_SENDSPIN_CONTROLLER
|
||||
this->controller_role_ = &this->client_->add_controller();
|
||||
this->controller_role_->set_listener(this);
|
||||
#endif
|
||||
|
||||
#ifdef USE_SENDSPIN_METADATA
|
||||
this->metadata_role_ = &this->client_->add_metadata();
|
||||
this->metadata_role_->set_listener(this);
|
||||
#endif
|
||||
|
||||
#ifdef USE_SENDSPIN_PLAYER
|
||||
this->client_->add_player(this->player_config_).set_listener(this->player_listener_);
|
||||
#endif
|
||||
|
||||
if (!this->client_->start_server()) {
|
||||
ESP_LOGE(TAG, "Failed to start Sendspin server");
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void SendspinHub::loop() { this->client_->loop(); }
|
||||
|
||||
void SendspinHub::dump_config() {
|
||||
char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Sendspin Hub:\n"
|
||||
" Client ID: %s\n"
|
||||
" Task stack in PSRAM: %s",
|
||||
get_mac_address_pretty_into_buffer(mac_buf), YESNO(this->task_stack_in_psram_));
|
||||
}
|
||||
|
||||
// --- Delegating methods ---
|
||||
|
||||
// THREAD CONTEXT: Main loop (invoked from Sendspin components)
|
||||
void SendspinHub::connect_to_server(const std::string &url) {
|
||||
if (this->is_ready()) {
|
||||
this->client_->connect_to(url);
|
||||
}
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (invoked from Sendspin components)
|
||||
void SendspinHub::disconnect_from_server(sendspin::SendspinGoodbyeReason reason) {
|
||||
if (this->is_ready()) {
|
||||
this->client_->disconnect(reason);
|
||||
}
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (invoked from Sendspin components)
|
||||
void SendspinHub::update_state(sendspin::SendspinClientState state) {
|
||||
if (this->is_ready()) {
|
||||
this->client_->update_state(state);
|
||||
}
|
||||
}
|
||||
|
||||
sendspin::SendspinClientConfig SendspinHub::build_client_config_() {
|
||||
sendspin::SendspinClientConfig config;
|
||||
|
||||
char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
config.client_id = get_mac_address_pretty_into_buffer(mac_buf);
|
||||
config.name = App.get_friendly_name();
|
||||
config.product_name = App.get_name();
|
||||
config.manufacturer = "ESPHome";
|
||||
config.software_version = ESPHOME_VERSION;
|
||||
config.httpd_psram_stack = this->task_stack_in_psram_;
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
// --- SendspinClientListener overrides ---
|
||||
// THREAD CONTEXT: Main loop (fired from client_->loop())
|
||||
|
||||
void SendspinHub::on_group_update(const sendspin::GroupUpdateObject &group) {
|
||||
this->group_update_callbacks_.call(group);
|
||||
}
|
||||
|
||||
void SendspinHub::on_request_high_performance() {
|
||||
#ifdef USE_WIFI
|
||||
if (wifi::global_wifi_component != nullptr) {
|
||||
wifi::global_wifi_component->request_high_performance();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void SendspinHub::on_release_high_performance() {
|
||||
#ifdef USE_WIFI
|
||||
if (wifi::global_wifi_component != nullptr) {
|
||||
wifi::global_wifi_component->release_high_performance();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// --- SendspinNetworkProvider override ---
|
||||
|
||||
// THREAD CONTEXT: Main loop (polled by client_->loop())
|
||||
bool SendspinHub::is_network_ready() { return network::is_connected(); }
|
||||
|
||||
// --- SendspinPersistenceProvider overrides ---
|
||||
|
||||
// THREAD CONTEXT: Main loop (invoked by client_->loop() during lifecycle events)
|
||||
bool SendspinHub::save_last_server_hash(uint32_t hash) {
|
||||
LastPlayedServerPref pref{.server_id_hash = hash};
|
||||
bool ok = this->last_played_server_pref_.save(&pref);
|
||||
if (ok) {
|
||||
ESP_LOGD(TAG, "Persisted last played server hash: 0x%08X", hash);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Failed to persist last played server hash");
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (invoked by client_->loop() during lifecycle events)
|
||||
std::optional<uint32_t> SendspinHub::load_last_server_hash() {
|
||||
LastPlayedServerPref pref{};
|
||||
if (this->last_played_server_pref_.load(&pref)) {
|
||||
ESP_LOGI(TAG, "Loaded last played server hash: 0x%08X", pref.server_id_hash);
|
||||
return pref.server_id_hash;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// --- Sendspin role specific methods/overrides ---
|
||||
|
||||
#ifdef USE_SENDSPIN_CONTROLLER
|
||||
// THREAD CONTEXT: Main loop (invoked from ESPHome actions / other components)
|
||||
void SendspinHub::send_client_command(sendspin::SendspinControllerCommand command, std::optional<uint8_t> volume,
|
||||
std::optional<bool> mute) {
|
||||
if (this->is_ready()) {
|
||||
this->controller_role_->send_command(command, volume, mute);
|
||||
}
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (ControllerRoleListener override, fired from client_->loop())
|
||||
void SendspinHub::on_controller_state(const sendspin::ServerStateControllerObject &state) {
|
||||
this->controller_state_callbacks_.call(state);
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_SENDSPIN_METADATA
|
||||
// THREAD CONTEXT: Main loop (MetadataRoleListener override, fired from client_->loop())
|
||||
void SendspinHub::on_metadata(const sendspin::ServerMetadataStateObject &metadata) {
|
||||
this->metadata_update_callbacks_.call(metadata);
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (invoked from Sendspin components)
|
||||
uint32_t SendspinHub::get_track_progress_ms() const {
|
||||
if (this->is_ready()) {
|
||||
return this->metadata_role_->get_track_progress_ms();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_SENDSPIN_PLAYER
|
||||
// THREAD CONTEXT: Main loop, called from child component setup() after player role is created and configured
|
||||
sendspin::PlayerRole *SendspinHub::get_player_role() {
|
||||
if (this->is_ready()) {
|
||||
return this->client_->player();
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (SendspinPersistenceProvider override)
|
||||
bool SendspinHub::save_static_delay(uint16_t delay_ms) {
|
||||
StaticDelayPref pref{.delay_ms = delay_ms};
|
||||
bool ok = this->static_delay_pref_.save(&pref);
|
||||
if (ok) {
|
||||
ESP_LOGD(TAG, "Persisted static delay: %u ms", delay_ms);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Failed to persist static delay");
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (SendspinPersistenceProvider override)
|
||||
std::optional<uint16_t> SendspinHub::load_static_delay() {
|
||||
StaticDelayPref pref{};
|
||||
if (this->static_delay_pref_.load(&pref)) {
|
||||
ESP_LOGI(TAG, "Loaded static delay: %u ms", pref.delay_ms);
|
||||
return pref.delay_ms;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
} // namespace esphome::sendspin_
|
||||
|
||||
#endif // USE_ESP32
|
||||
@@ -0,0 +1,231 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/preferences.h"
|
||||
|
||||
#include <sendspin/client.h>
|
||||
#include <sendspin/config.h>
|
||||
#include <sendspin/types.h>
|
||||
|
||||
#ifdef USE_SENDSPIN_CONTROLLER
|
||||
#include <sendspin/controller_role.h>
|
||||
#endif
|
||||
#ifdef USE_SENDSPIN_METADATA
|
||||
#include <sendspin/metadata_role.h>
|
||||
#endif
|
||||
#ifdef USE_SENDSPIN_PLAYER
|
||||
#include <sendspin/player_role.h>
|
||||
#endif
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
|
||||
namespace esphome::sendspin_ {
|
||||
|
||||
/// @brief Setup priorities for the sendspin hub and its child components.
|
||||
///
|
||||
/// Centralized here so every sendspin component orders itself relative to the hub
|
||||
/// without each subcomponent having to pick a priority independently. Children run
|
||||
/// one step later than hub so they can assume hub's setup() has already completed.
|
||||
namespace sendspin_priority {
|
||||
inline constexpr float HUB = esphome::setup_priority::PROCESSOR;
|
||||
inline constexpr float CHILD = HUB - 1.0f;
|
||||
} // namespace sendspin_priority
|
||||
|
||||
/// @brief Persistent storage structure for last played server hash.
|
||||
struct LastPlayedServerPref {
|
||||
uint32_t server_id_hash;
|
||||
};
|
||||
|
||||
#ifdef USE_SENDSPIN_PLAYER
|
||||
/// @brief Persistent storage structure for player static delay.
|
||||
struct StaticDelayPref {
|
||||
uint16_t delay_ms;
|
||||
};
|
||||
#endif
|
||||
|
||||
/// @brief Thin adapter over sendspin::SendspinClient.
|
||||
///
|
||||
/// The hub owns a SendspinClient instance and bridges its listener/provider interfaces to ESPHome's CallbackManager for
|
||||
/// fan-out to child components.
|
||||
/// - Provides persistence via ESPPreferenceObject and WiFi power management integration.
|
||||
/// - Handles Sendspin roles that apply to multiple child components (artwork, controller, metadata) so their events
|
||||
/// can be fanned out. Roles specific to a single component (player) are configured by the hub but owned by the
|
||||
/// child thereafter, since no fan-out is needed.
|
||||
///
|
||||
/// The sendspin-cpp library follows this design:
|
||||
/// - Core and role configuration are passed at client/role construction time as structs. Built in our `setup()`.
|
||||
/// - Library -> user code communication happens via two interface types the user implements and registers in our
|
||||
/// `setup()`: listener interfaces (for events the library pushes; e.g., group updates) and provider interfaces
|
||||
/// (for services the library pulls; e.g., persistence, network readiness).
|
||||
/// - User -> library communication uses exposed functions on the client and role objects that the user calls.
|
||||
class SendspinHub final : public Component,
|
||||
#ifdef USE_SENDSPIN_CONTROLLER
|
||||
public sendspin::ControllerRoleListener,
|
||||
#endif
|
||||
#ifdef USE_SENDSPIN_METADATA
|
||||
public sendspin::MetadataRoleListener,
|
||||
#endif
|
||||
public sendspin::SendspinClientListener,
|
||||
public sendspin::SendspinNetworkProvider,
|
||||
public sendspin::SendspinPersistenceProvider {
|
||||
public:
|
||||
float get_setup_priority() const override { return sendspin_priority::HUB; }
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
|
||||
/// @brief Connects the underlying client to the given Sendspin server.
|
||||
///
|
||||
/// No-op if the hub's client is not ready (e.g. setup() has not completed).
|
||||
/// Must be called from the main loop thread.
|
||||
/// @param url WebSocket URL of the Sendspin server, starting with `ws://` (e.g. `ws://host:port/path`).
|
||||
void connect_to_server(const std::string &url);
|
||||
|
||||
/// @brief Disconnects the underlying client from the current server.
|
||||
///
|
||||
/// Sends a `client/goodbye` message with the given reason before closing the connection.
|
||||
/// No-op if the hub's client is not ready. Must be called from the main loop thread.
|
||||
/// @param reason Reason reported to the server:
|
||||
/// - `ANOTHER_SERVER`: client is switching to another server.
|
||||
/// - `SHUTDOWN`: client is shutting down.
|
||||
/// - `RESTART`: client is restarting.
|
||||
/// - `USER_REQUEST`: user explicitly requested disconnect.
|
||||
void disconnect_from_server(sendspin::SendspinGoodbyeReason reason);
|
||||
|
||||
/// @brief Updates the client's reported playback state on the server.
|
||||
///
|
||||
/// No-op if the hub's client is not ready. Must be called from the main loop thread.
|
||||
/// @param state New client state:
|
||||
/// - `SYNCHRONIZED`: client is synchronized and playing from the server.
|
||||
/// - `ERROR`: client encountered a playback error.
|
||||
/// - `EXTERNAL_SOURCE`: client is playing from a non-Sendspin source.
|
||||
void update_state(sendspin::SendspinClientState state);
|
||||
|
||||
// --- Configuration setters (called from codegen) ---
|
||||
|
||||
template<typename F> void add_group_update_callback(F &&callback) {
|
||||
this->group_update_callbacks_.add(std::forward<F>(callback));
|
||||
}
|
||||
|
||||
void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; }
|
||||
|
||||
// --- Sendspin role specific methods ---
|
||||
|
||||
#ifdef USE_SENDSPIN_CONTROLLER
|
||||
void send_client_command(sendspin::SendspinControllerCommand command, std::optional<uint8_t> volume = std::nullopt,
|
||||
std::optional<bool> mute = std::nullopt);
|
||||
|
||||
template<typename F> void add_controller_state_callback(F &&callback) {
|
||||
this->controller_state_callbacks_.add(std::forward<F>(callback));
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_SENDSPIN_METADATA
|
||||
template<typename F> void add_metadata_update_callback(F &&callback) {
|
||||
this->metadata_update_callbacks_.add(std::forward<F>(callback));
|
||||
}
|
||||
|
||||
/// @brief Returns the interpolated track progress in milliseconds, or 0 if the hub is not yet ready.
|
||||
uint32_t get_track_progress_ms() const;
|
||||
#endif
|
||||
|
||||
#ifdef USE_SENDSPIN_PLAYER
|
||||
void set_listener(sendspin::PlayerRoleListener *listener) { this->player_listener_ = listener; }
|
||||
void set_player_config(const sendspin::PlayerRoleConfig &config) { this->player_config_ = config; }
|
||||
|
||||
/// @brief Child components call this to get the PlayerRole instance after setup, so they can push updates to it.
|
||||
sendspin::PlayerRole *get_player_role();
|
||||
#endif
|
||||
|
||||
protected:
|
||||
/// @brief Builds the SendspinClientConfig from ESPHome configuration and platform info.
|
||||
sendspin::SendspinClientConfig build_client_config_();
|
||||
|
||||
// --- SendspinClientListener overrides ---
|
||||
void on_group_update(const sendspin::GroupUpdateObject &group) override;
|
||||
|
||||
void on_request_high_performance() override;
|
||||
|
||||
void on_release_high_performance() override;
|
||||
|
||||
// --- SendspinNetworkProvider override ---
|
||||
bool is_network_ready() override;
|
||||
|
||||
// --- SendspinPersistenceProvider overrides ---
|
||||
bool save_last_server_hash(uint32_t hash) override;
|
||||
std::optional<uint32_t> load_last_server_hash() override;
|
||||
|
||||
// --- Sendspin role specific methods/overrides/member variables ---
|
||||
|
||||
#ifdef USE_SENDSPIN_CONTROLLER
|
||||
sendspin::ControllerRole *controller_role_{nullptr};
|
||||
|
||||
void on_controller_state(const sendspin::ServerStateControllerObject &state) override;
|
||||
|
||||
// Callback fan-out to child components; they filter as needed
|
||||
CallbackManager<void(const sendspin::ServerStateControllerObject &)> controller_state_callbacks_{};
|
||||
#endif
|
||||
|
||||
#ifdef USE_SENDSPIN_METADATA
|
||||
sendspin::MetadataRole *metadata_role_{nullptr};
|
||||
|
||||
void on_metadata(const sendspin::ServerMetadataStateObject &metadata) override;
|
||||
|
||||
// Callback fan-out to child components; they filter as needed
|
||||
CallbackManager<void(const sendspin::ServerMetadataStateObject &)> metadata_update_callbacks_{};
|
||||
#endif
|
||||
|
||||
#ifdef USE_SENDSPIN_PLAYER
|
||||
sendspin::PlayerRoleListener *player_listener_{nullptr};
|
||||
sendspin::PlayerRoleConfig player_config_{};
|
||||
|
||||
// Part of SendspinPersistenceProvider overrides
|
||||
ESPPreferenceObject static_delay_pref_;
|
||||
std::optional<uint16_t> load_static_delay() override;
|
||||
bool save_static_delay(uint16_t delay_ms) override;
|
||||
#endif
|
||||
|
||||
// --- Core member variables ---
|
||||
|
||||
ESPPreferenceObject last_played_server_pref_;
|
||||
|
||||
std::unique_ptr<sendspin::SendspinClient> client_;
|
||||
|
||||
// Callback fan-out to child components
|
||||
CallbackManager<void(const sendspin::GroupUpdateObject &)> group_update_callbacks_{};
|
||||
|
||||
bool task_stack_in_psram_{false};
|
||||
};
|
||||
|
||||
/// @brief Base class for all sendspin subcomponents.
|
||||
///
|
||||
/// Consolidates the Component + Parented<SendspinHub> inheritance and pins the setup
|
||||
/// priority so the hub's setup() always runs before any child. Subcomponents should
|
||||
/// inherit from this instead of listing Component/Parented individually and must not
|
||||
/// override get_setup_priority().
|
||||
class SendspinChild : public Component, public Parented<SendspinHub> {
|
||||
public:
|
||||
float get_setup_priority() const override { return sendspin_priority::CHILD; }
|
||||
};
|
||||
|
||||
/// @brief Base class for sendspin subcomponents that need polling behavior.
|
||||
///
|
||||
/// Same purpose as SendspinChild but inherits from PollingComponent for subcomponents
|
||||
/// that poll on a fixed interval. Subcomponents should inherit from this instead of
|
||||
/// listing PollingComponent/Parented individually and must not override get_setup_priority().
|
||||
class SendspinPollingChild : public PollingComponent, public Parented<SendspinHub> {
|
||||
public:
|
||||
float get_setup_priority() const override { return sendspin_priority::CHILD; }
|
||||
};
|
||||
|
||||
} // namespace esphome::sendspin_
|
||||
|
||||
#endif // USE_ESP32
|
||||
@@ -0,0 +1,98 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ID,
|
||||
CONF_TYPE,
|
||||
CONF_YEAR,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
UNIT_MILLISECOND,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import CONF_SENDSPIN_ID, SendspinHub, request_metadata_support, sendspin_ns
|
||||
|
||||
CODEOWNERS = ["@kahrendt"]
|
||||
DEPENDENCIES = ["sendspin"]
|
||||
|
||||
CONF_TRACK = "track"
|
||||
CONF_TRACK_PROGRESS = "track_progress"
|
||||
CONF_TRACK_DURATION = "track_duration"
|
||||
|
||||
SendspinTrackProgressSensor = sendspin_ns.class_(
|
||||
"SendspinTrackProgressSensor",
|
||||
sensor.Sensor,
|
||||
cg.PollingComponent,
|
||||
)
|
||||
SendspinMetadataSensor = sendspin_ns.class_(
|
||||
"SendspinMetadataSensor",
|
||||
sensor.Sensor,
|
||||
cg.Component,
|
||||
)
|
||||
|
||||
SendspinNumericMetadataTypes = sendspin_ns.enum(
|
||||
"SendspinNumericMetadataTypes", is_class=True
|
||||
)
|
||||
_METADATA_TYPE_ENUM = {
|
||||
CONF_TRACK_DURATION: SendspinNumericMetadataTypes.TRACK_DURATION,
|
||||
CONF_YEAR: SendspinNumericMetadataTypes.YEAR,
|
||||
CONF_TRACK: SendspinNumericMetadataTypes.TRACK,
|
||||
}
|
||||
|
||||
|
||||
def _request_roles(config: ConfigType) -> ConfigType:
|
||||
"""Request the necessary Sendspin roles for the sensor."""
|
||||
request_metadata_support()
|
||||
|
||||
return config
|
||||
|
||||
|
||||
_HUB_ID_SCHEMA = cv.Schema({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)})
|
||||
|
||||
|
||||
def _metadata_schema(**sensor_kwargs):
|
||||
"""Schema for event-driven numeric metadata sensors (duration/year/track)."""
|
||||
return (
|
||||
sensor.sensor_schema(
|
||||
SendspinMetadataSensor,
|
||||
accuracy_decimals=0,
|
||||
**sensor_kwargs,
|
||||
)
|
||||
.extend(_HUB_ID_SCHEMA)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.typed_schema(
|
||||
{
|
||||
CONF_TRACK_PROGRESS: sensor.sensor_schema(
|
||||
SendspinTrackProgressSensor,
|
||||
accuracy_decimals=0,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
unit_of_measurement=UNIT_MILLISECOND,
|
||||
)
|
||||
.extend(_HUB_ID_SCHEMA)
|
||||
.extend(cv.polling_component_schema("1s")),
|
||||
CONF_TRACK_DURATION: _metadata_schema(
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
unit_of_measurement=UNIT_MILLISECOND,
|
||||
),
|
||||
CONF_YEAR: _metadata_schema(),
|
||||
CONF_TRACK: _metadata_schema(),
|
||||
},
|
||||
key=CONF_TYPE,
|
||||
),
|
||||
cv.only_on_esp32,
|
||||
_request_roles,
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await cg.register_parented(var, config[CONF_SENDSPIN_ID])
|
||||
await sensor.register_sensor(var, config)
|
||||
|
||||
if (metadata_type := _METADATA_TYPE_ENUM.get(config[CONF_TYPE])) is not None:
|
||||
cg.add(var.set_metadata_type(metadata_type))
|
||||
@@ -0,0 +1,98 @@
|
||||
#include "sendspin_sensor.h"
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_SENSOR)
|
||||
|
||||
#include <sendspin/metadata_role.h>
|
||||
|
||||
namespace esphome::sendspin_ {
|
||||
|
||||
static const char *const TAG = "sendspin.sensor";
|
||||
|
||||
// --- SendspinTrackProgressSensor ---
|
||||
|
||||
void SendspinTrackProgressSensor::dump_config() {
|
||||
LOG_SENSOR("", "Track Progress", this);
|
||||
LOG_UPDATE_INTERVAL(this);
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop. The registered metadata callback also fires on the main loop
|
||||
// (SendspinHub dispatches metadata from client_->loop()).
|
||||
void SendspinTrackProgressSensor::setup() {
|
||||
this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) {
|
||||
if (!metadata.progress.has_value()) {
|
||||
return;
|
||||
}
|
||||
const auto &progress = metadata.progress.value();
|
||||
if (progress.playback_speed == 0) {
|
||||
// Paused: freeze progress at the reported position and stop polling to save cycles.
|
||||
this->stop_poller();
|
||||
this->publish_state(progress.track_progress);
|
||||
} else {
|
||||
// Resumed: publish the fresh interpolated position immediately so the frontend doesn't show a stale
|
||||
// paused value until the next poll tick.
|
||||
this->publish_state(this->parent_->get_track_progress_ms());
|
||||
this->start_poller();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop.
|
||||
// Sendspin only pushes progress on state changes (play/pause/seek/speed change), not continuously during
|
||||
// playback. The hub helper interpolates the current position from the last server update and the playback
|
||||
// speed, giving us a fresh value on every poll.
|
||||
void SendspinTrackProgressSensor::update() { this->publish_state(this->parent_->get_track_progress_ms()); }
|
||||
|
||||
// --- SendspinMetadataSensor ---
|
||||
|
||||
void SendspinMetadataSensor::dump_config() {
|
||||
switch (this->metadata_type_) {
|
||||
case SendspinNumericMetadataTypes::TRACK_DURATION:
|
||||
LOG_SENSOR("", "Track Duration", this);
|
||||
break;
|
||||
case SendspinNumericMetadataTypes::YEAR:
|
||||
LOG_SENSOR("", "Year", this);
|
||||
break;
|
||||
case SendspinNumericMetadataTypes::TRACK:
|
||||
LOG_SENSOR("", "Track", this);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<float> SendspinMetadataSensor::extract_value_(const sendspin::ServerMetadataStateObject &metadata) const {
|
||||
switch (this->metadata_type_) {
|
||||
case SendspinNumericMetadataTypes::TRACK_DURATION:
|
||||
if (metadata.progress.has_value())
|
||||
return metadata.progress.value().track_duration;
|
||||
return std::nullopt;
|
||||
case SendspinNumericMetadataTypes::YEAR:
|
||||
if (metadata.year.has_value())
|
||||
return metadata.year.value();
|
||||
return std::nullopt;
|
||||
case SendspinNumericMetadataTypes::TRACK:
|
||||
if (metadata.track.has_value())
|
||||
return metadata.track.value();
|
||||
return std::nullopt;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop. The registered metadata callback also fires on the main loop
|
||||
// (SendspinHub dispatches metadata from client_->loop()).
|
||||
void SendspinMetadataSensor::setup() {
|
||||
this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) {
|
||||
if (auto value = this->extract_value_(metadata)) {
|
||||
this->publish_if_changed_(*value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Dedup to avoid frontend churn; Sensor::publish_state always notifies without checking for changes.
|
||||
void SendspinMetadataSensor::publish_if_changed_(float value) {
|
||||
if (this->get_raw_state() != value) {
|
||||
this->publish_state(value);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::sendspin_
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_SENSOR)
|
||||
|
||||
#include "esphome/components/sendspin/sendspin_hub.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
|
||||
#include <optional>
|
||||
|
||||
namespace esphome::sendspin_ {
|
||||
|
||||
class SendspinTrackProgressSensor : public sensor::Sensor, public SendspinPollingChild {
|
||||
public:
|
||||
void dump_config() override;
|
||||
void setup() override;
|
||||
void update() override;
|
||||
};
|
||||
|
||||
enum class SendspinNumericMetadataTypes {
|
||||
TRACK_DURATION,
|
||||
YEAR,
|
||||
TRACK,
|
||||
};
|
||||
|
||||
class SendspinMetadataSensor : public sensor::Sensor, public SendspinChild {
|
||||
public:
|
||||
void dump_config() override;
|
||||
void setup() override;
|
||||
|
||||
void set_metadata_type(SendspinNumericMetadataTypes metadata_type) { this->metadata_type_ = metadata_type; }
|
||||
|
||||
protected:
|
||||
std::optional<float> extract_value_(const sendspin::ServerMetadataStateObject &metadata) const;
|
||||
void publish_if_changed_(float value);
|
||||
|
||||
SendspinNumericMetadataTypes metadata_type_;
|
||||
};
|
||||
|
||||
} // namespace esphome::sendspin_
|
||||
#endif
|
||||
@@ -0,0 +1,53 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import text_sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_TYPE
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import CONF_SENDSPIN_ID, SendspinHub, request_metadata_support, sendspin_ns
|
||||
|
||||
CODEOWNERS = ["@kahrendt"]
|
||||
DEPENDENCIES = ["sendspin"]
|
||||
|
||||
SendspinTextSensor = sendspin_ns.class_(
|
||||
"SendspinTextSensor",
|
||||
text_sensor.TextSensor,
|
||||
cg.Component,
|
||||
)
|
||||
|
||||
SendspinTextMetadataTypes = sendspin_ns.enum("SendspinTextMetadataTypes", is_class=True)
|
||||
SENDSPIN_TEXT_METADATA_TYPES = {
|
||||
"title": SendspinTextMetadataTypes.TITLE,
|
||||
"artist": SendspinTextMetadataTypes.ARTIST,
|
||||
"album": SendspinTextMetadataTypes.ALBUM,
|
||||
"album_artist": SendspinTextMetadataTypes.ALBUM_ARTIST,
|
||||
}
|
||||
|
||||
|
||||
def _request_roles(config: ConfigType) -> ConfigType:
|
||||
"""Request the necessary Sendspin roles for the text sensor."""
|
||||
request_metadata_support()
|
||||
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
text_sensor.text_sensor_schema().extend(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(SendspinTextSensor),
|
||||
cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub),
|
||||
cv.Required(CONF_TYPE): cv.enum(SENDSPIN_TEXT_METADATA_TYPES),
|
||||
}
|
||||
),
|
||||
cv.only_on_esp32,
|
||||
_request_roles,
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await cg.register_parented(var, config[CONF_SENDSPIN_ID])
|
||||
await text_sensor.register_text_sensor(var, config)
|
||||
|
||||
cg.add(var.set_metadata_type(config[CONF_TYPE]))
|
||||
@@ -0,0 +1,56 @@
|
||||
#include "sendspin_text_sensor.h"
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR)
|
||||
|
||||
#include <sendspin/metadata_role.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace esphome::sendspin_ {
|
||||
|
||||
static const char *const TAG = "sendspin.text_sensor";
|
||||
|
||||
void SendspinTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Sendspin", this); }
|
||||
|
||||
const char *SendspinTextSensor::extract_value_(const sendspin::ServerMetadataStateObject &metadata) const {
|
||||
switch (this->metadata_type_) {
|
||||
case SendspinTextMetadataTypes::TITLE:
|
||||
if (metadata.title.has_value())
|
||||
return metadata.title.value().c_str();
|
||||
return nullptr;
|
||||
case SendspinTextMetadataTypes::ARTIST:
|
||||
if (metadata.artist.has_value())
|
||||
return metadata.artist.value().c_str();
|
||||
return nullptr;
|
||||
case SendspinTextMetadataTypes::ALBUM:
|
||||
if (metadata.album.has_value())
|
||||
return metadata.album.value().c_str();
|
||||
return nullptr;
|
||||
case SendspinTextMetadataTypes::ALBUM_ARTIST:
|
||||
if (metadata.album_artist.has_value())
|
||||
return metadata.album_artist.value().c_str();
|
||||
return nullptr;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop. The registered metadata callback also fires on the main loop
|
||||
// (SendspinHub dispatches metadata from client_->loop()).
|
||||
void SendspinTextSensor::setup() {
|
||||
this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) {
|
||||
if (const char *value = this->extract_value_(metadata)) {
|
||||
this->publish_if_changed_(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Dedup to avoid frontend churn; TextSensor::publish_state already dedups the string assign but still notifies.
|
||||
void SendspinTextSensor::publish_if_changed_(const char *value) {
|
||||
if (this->get_raw_state() != value) {
|
||||
this->publish_state(value);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::sendspin_
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR)
|
||||
|
||||
#include "esphome/components/sendspin/sendspin_hub.h"
|
||||
#include "esphome/components/text_sensor/text_sensor.h"
|
||||
|
||||
#include <sendspin/metadata_role.h>
|
||||
|
||||
namespace esphome::sendspin_ {
|
||||
|
||||
enum class SendspinTextMetadataTypes {
|
||||
TITLE,
|
||||
ARTIST,
|
||||
ALBUM,
|
||||
ALBUM_ARTIST,
|
||||
};
|
||||
|
||||
class SendspinTextSensor : public SendspinChild, public text_sensor::TextSensor {
|
||||
public:
|
||||
void dump_config() override;
|
||||
void setup() override;
|
||||
|
||||
void set_metadata_type(SendspinTextMetadataTypes metadata_type) { this->metadata_type_ = metadata_type; }
|
||||
|
||||
protected:
|
||||
const char *extract_value_(const sendspin::ServerMetadataStateObject &metadata) const;
|
||||
void publish_if_changed_(const char *value);
|
||||
|
||||
SendspinTextMetadataTypes metadata_type_;
|
||||
};
|
||||
|
||||
} // namespace esphome::sendspin_
|
||||
#endif
|
||||
@@ -32,7 +32,6 @@ from esphome.const import (
|
||||
CONF_URL,
|
||||
)
|
||||
from esphome.core import CORE, HexInt
|
||||
from esphome.core.entity_helpers import inherit_property_from
|
||||
from esphome.external_files import download_content
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -44,16 +43,12 @@ DEPENDENCIES = ["network"]
|
||||
CODEOWNERS = ["@kahrendt", "@synesthesiam"]
|
||||
DOMAIN = "media_player"
|
||||
|
||||
CODEC_SUPPORT_ALL = "all"
|
||||
CODEC_SUPPORT_NEEDED = "needed"
|
||||
CODEC_SUPPORT_NONE = "none"
|
||||
|
||||
TYPE_LOCAL = "local"
|
||||
TYPE_WEB = "web"
|
||||
|
||||
CONF_ANNOUNCEMENT = "announcement"
|
||||
CONF_ANNOUNCEMENT_PIPELINE = "announcement_pipeline"
|
||||
CONF_CODEC_SUPPORT_ENABLED = "codec_support_enabled"
|
||||
CONF_CODEC_SUPPORT_ENABLED = "codec_support_enabled" # Remove before 2026.10.0
|
||||
CONF_ENQUEUE = "enqueue"
|
||||
CONF_MEDIA_FILE = "media_file"
|
||||
CONF_MEDIA_PIPELINE = "media_pipeline"
|
||||
@@ -106,43 +101,10 @@ def _download_web_file(value):
|
||||
return value
|
||||
|
||||
|
||||
# Returns a media_player.MediaPlayerSupportedFormat struct with the configured
|
||||
# format, sample rate, number of channels, purpose, and bytes per sample
|
||||
def _get_supported_format_struct(pipeline, type):
|
||||
args = [
|
||||
media_player.MediaPlayerSupportedFormat,
|
||||
]
|
||||
|
||||
if pipeline[CONF_FORMAT] == "FLAC":
|
||||
args.append(("format", "flac"))
|
||||
elif pipeline[CONF_FORMAT] == "MP3":
|
||||
args.append(("format", "mp3"))
|
||||
elif pipeline[CONF_FORMAT] == "OPUS":
|
||||
args.append(("format", "opus"))
|
||||
elif pipeline[CONF_FORMAT] == "WAV":
|
||||
args.append(("format", "wav"))
|
||||
|
||||
args.append(("sample_rate", pipeline[CONF_SAMPLE_RATE]))
|
||||
args.append(("num_channels", pipeline[CONF_NUM_CHANNELS]))
|
||||
|
||||
if type == "MEDIA":
|
||||
args.append(
|
||||
(
|
||||
"purpose",
|
||||
media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["default"],
|
||||
)
|
||||
)
|
||||
elif type == "ANNOUNCEMENT":
|
||||
args.append(
|
||||
(
|
||||
"purpose",
|
||||
media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["announcement"],
|
||||
)
|
||||
)
|
||||
if pipeline[CONF_FORMAT] != "MP3":
|
||||
args.append(("sample_bytes", 2))
|
||||
|
||||
return cg.StructInitializer(*args)
|
||||
_PURPOSE_MAP = {
|
||||
"MEDIA": media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["default"],
|
||||
"ANNOUNCEMENT": media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["announcement"],
|
||||
}
|
||||
|
||||
|
||||
def _file_schema(value):
|
||||
@@ -210,25 +172,9 @@ def _validate_file_shorthand(value):
|
||||
)
|
||||
|
||||
|
||||
def _validate_pipeline(config):
|
||||
# Inherit transcoder settings from speaker if not manually set
|
||||
inherit_property_from(CONF_NUM_CHANNELS, CONF_SPEAKER)(config)
|
||||
inherit_property_from(CONF_SAMPLE_RATE, CONF_SPEAKER)(config)
|
||||
|
||||
# Opus only supports 48 kHz
|
||||
if config.get(CONF_FORMAT) == "OPUS" and config.get(CONF_SAMPLE_RATE) != 48000:
|
||||
raise cv.Invalid("Opus only supports a sample rate of 48000 Hz")
|
||||
|
||||
# Validate the transcoder settings is compatible with the speaker
|
||||
audio.final_validate_audio_schema(
|
||||
"speaker media_player",
|
||||
audio_device=CONF_SPEAKER,
|
||||
bits_per_sample=16,
|
||||
channels=config.get(CONF_NUM_CHANNELS),
|
||||
sample_rate=config.get(CONF_SAMPLE_RATE),
|
||||
)(config)
|
||||
|
||||
return config
|
||||
_validate_pipeline = media_player.validate_preferred_format(
|
||||
"speaker media_player", CONF_SPEAKER
|
||||
)
|
||||
|
||||
|
||||
def _validate_repeated_speaker(config):
|
||||
@@ -245,59 +191,34 @@ def _validate_repeated_speaker(config):
|
||||
|
||||
|
||||
def _final_validate(config):
|
||||
# Normalize boolean values to string equivalents
|
||||
codec_mode = config[CONF_CODEC_SUPPORT_ENABLED]
|
||||
if codec_mode is True:
|
||||
codec_mode = CODEC_SUPPORT_ALL
|
||||
elif codec_mode is False:
|
||||
codec_mode = CODEC_SUPPORT_NONE
|
||||
# Remove before 2026.10.0
|
||||
if CONF_CODEC_SUPPORT_ENABLED in config:
|
||||
_LOGGER.warning(
|
||||
"'%s' is deprecated and will be removed in 2026.10.0. "
|
||||
"Codec support is now automatically determined from the pipeline "
|
||||
"'format' setting. Set format to 'NONE' to enable all codecs.",
|
||||
CONF_CODEC_SUPPORT_ENABLED,
|
||||
)
|
||||
|
||||
use_codec = codec_mode != CODEC_SUPPORT_NONE
|
||||
|
||||
# In "needed" mode, collect formats from pipelines and files
|
||||
needed_formats = set()
|
||||
need_all = False
|
||||
if codec_mode == CODEC_SUPPORT_NEEDED:
|
||||
for pipeline_key in (CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE):
|
||||
if pipeline := config.get(pipeline_key):
|
||||
fmt = pipeline[CONF_FORMAT]
|
||||
if fmt == "NONE":
|
||||
# No preferred format means any format could arrive
|
||||
need_all = True
|
||||
else:
|
||||
needed_formats.add(fmt)
|
||||
# Request codecs based on pipeline formats
|
||||
media_player.request_codecs_for_format_configs(
|
||||
config, [CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE]
|
||||
)
|
||||
|
||||
# Validate local files and request any additional codecs they need
|
||||
for file_config in config.get(CONF_FILES, []):
|
||||
_, media_file_type = _read_audio_file_and_type(file_config)
|
||||
if str(media_file_type) == str(audio.AUDIO_FILE_TYPE_ENUM["NONE"]):
|
||||
raise cv.Invalid("Unsupported local media file")
|
||||
if not use_codec and str(media_file_type) != str(
|
||||
audio.AUDIO_FILE_TYPE_ENUM["WAV"]
|
||||
):
|
||||
# Only wav files are supported
|
||||
raise cv.Invalid(
|
||||
f"Unsupported local media file type, set {CONF_CODEC_SUPPORT_ENABLED} to true or convert the media file to wav"
|
||||
)
|
||||
# In "needed" mode, add file format to needed codecs
|
||||
if codec_mode == CODEC_SUPPORT_NEEDED:
|
||||
for fmt_name, fmt_enum in audio.AUDIO_FILE_TYPE_ENUM.items():
|
||||
if str(media_file_type) == str(fmt_enum):
|
||||
if fmt_name not in ("WAV", "NONE"):
|
||||
needed_formats.add(fmt_name)
|
||||
break
|
||||
|
||||
# Request codec support
|
||||
if codec_mode == CODEC_SUPPORT_ALL or need_all:
|
||||
audio.request_flac_support()
|
||||
audio.request_mp3_support()
|
||||
audio.request_opus_support()
|
||||
elif codec_mode == CODEC_SUPPORT_NEEDED:
|
||||
if "FLAC" in needed_formats:
|
||||
audio.request_flac_support()
|
||||
if "MP3" in needed_formats:
|
||||
audio.request_mp3_support()
|
||||
if "OPUS" in needed_formats:
|
||||
audio.request_opus_support()
|
||||
for fmt_name, fmt_enum in audio.AUDIO_FILE_TYPE_ENUM.items():
|
||||
if str(media_file_type) == str(fmt_enum):
|
||||
if fmt_name == "FLAC":
|
||||
audio.request_flac_support()
|
||||
elif fmt_name == "MP3":
|
||||
audio.request_mp3_support()
|
||||
elif fmt_name == "OPUS":
|
||||
audio.request_opus_support()
|
||||
break
|
||||
|
||||
return config
|
||||
|
||||
@@ -362,17 +283,8 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_BUFFER_SIZE, default=1000000): cv.int_range(
|
||||
min=4000, max=4000000
|
||||
),
|
||||
cv.Optional(
|
||||
CONF_CODEC_SUPPORT_ENABLED, default=CODEC_SUPPORT_NEEDED
|
||||
): cv.Any(
|
||||
cv.boolean,
|
||||
cv.one_of(
|
||||
CODEC_SUPPORT_ALL,
|
||||
CODEC_SUPPORT_NEEDED,
|
||||
CODEC_SUPPORT_NONE,
|
||||
lower=True,
|
||||
),
|
||||
),
|
||||
# Remove before 2026.10.0
|
||||
cv.Optional(CONF_CODEC_SUPPORT_ENABLED): cv.Any(cv.boolean, cv.string),
|
||||
cv.Optional(CONF_FILES): cv.ensure_list(MEDIA_FILE_TYPE_SCHEMA),
|
||||
cv.Optional(CONF_TASK_STACK_IN_PSRAM): cv.All(
|
||||
cv.boolean, cv.requires_component(psram.DOMAIN)
|
||||
@@ -432,8 +344,8 @@ async def to_code(config):
|
||||
if announcement_pipeline_config[CONF_FORMAT] != "NONE":
|
||||
cg.add(
|
||||
var.set_announcement_format(
|
||||
_get_supported_format_struct(
|
||||
announcement_pipeline_config, "ANNOUNCEMENT"
|
||||
media_player.build_supported_format_struct(
|
||||
announcement_pipeline_config, _PURPOSE_MAP["ANNOUNCEMENT"]
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -444,7 +356,9 @@ async def to_code(config):
|
||||
if media_pipeline_config[CONF_FORMAT] != "NONE":
|
||||
cg.add(
|
||||
var.set_media_format(
|
||||
_get_supported_format_struct(media_pipeline_config, "MEDIA")
|
||||
media_player.build_supported_format_struct(
|
||||
media_pipeline_config, _PURPOSE_MAP["MEDIA"]
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ from esphome.const import (
|
||||
CONF_SPEAKER,
|
||||
)
|
||||
from esphome.core import ID
|
||||
from esphome.core.entity_helpers import inherit_property_from
|
||||
from esphome.cpp_generator import MockObj, TemplateArgsType
|
||||
from esphome.types import ConfigType
|
||||
|
||||
@@ -65,53 +64,9 @@ SetPlaylistDelayAction = speaker_source_ns.class_(
|
||||
)
|
||||
|
||||
|
||||
FORMAT_MAPPING = {
|
||||
"FLAC": "flac",
|
||||
"MP3": "mp3",
|
||||
"OPUS": "opus",
|
||||
"WAV": "wav",
|
||||
}
|
||||
|
||||
|
||||
# Returns a media_player.MediaPlayerSupportedFormat struct with the configured
|
||||
# format, sample rate, number of channels, purpose, and bytes per sample
|
||||
def _get_supported_format_struct(pipeline: ConfigType, purpose: MockObj):
|
||||
args = [
|
||||
media_player.MediaPlayerSupportedFormat,
|
||||
]
|
||||
|
||||
args.append(("format", FORMAT_MAPPING[pipeline[CONF_FORMAT]]))
|
||||
|
||||
args.append(("sample_rate", pipeline[CONF_SAMPLE_RATE]))
|
||||
args.append(("num_channels", pipeline[CONF_NUM_CHANNELS]))
|
||||
args.append(("purpose", purpose))
|
||||
|
||||
# Omit sample_bytes for MP3: ffmpeg transcoding in Home Assistant fails
|
||||
# if the number of bytes per sample is specified for MP3.
|
||||
if pipeline[CONF_FORMAT] != "MP3":
|
||||
args.append(("sample_bytes", 2))
|
||||
|
||||
return cg.StructInitializer(*args)
|
||||
|
||||
|
||||
def _validate_pipeline(config: ConfigType) -> ConfigType:
|
||||
# Inherit settings from speaker if not manually set
|
||||
inherit_property_from(CONF_NUM_CHANNELS, CONF_SPEAKER)(config)
|
||||
inherit_property_from(CONF_SAMPLE_RATE, CONF_SPEAKER)(config)
|
||||
|
||||
# Opus only supports 48 kHz
|
||||
if config.get(CONF_FORMAT) == "OPUS" and config.get(CONF_SAMPLE_RATE) != 48000:
|
||||
raise cv.Invalid("Opus only supports a sample rate of 48000 Hz")
|
||||
|
||||
audio.final_validate_audio_schema(
|
||||
"speaker_source media_player",
|
||||
audio_device=CONF_SPEAKER,
|
||||
bits_per_sample=16,
|
||||
channels=config.get(CONF_NUM_CHANNELS),
|
||||
sample_rate=config.get(CONF_SAMPLE_RATE),
|
||||
)(config)
|
||||
|
||||
return config
|
||||
_validate_pipeline = media_player.validate_preferred_format(
|
||||
"speaker_source media_player", CONF_SPEAKER
|
||||
)
|
||||
|
||||
|
||||
PIPELINE_SCHEMA = cv.Schema(
|
||||
@@ -198,31 +153,9 @@ CONFIG_SCHEMA = cv.All(
|
||||
|
||||
|
||||
def _final_validate_codecs(config: ConfigType) -> ConfigType:
|
||||
# "NONE" means the pipeline accepts any format at runtime, so all optional codecs must be available.
|
||||
# When a specific format is set, only that codec is requested.
|
||||
needed_formats: set[str] = set()
|
||||
need_all = False
|
||||
|
||||
for pipeline_key in (CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE):
|
||||
if pipeline := config.get(pipeline_key):
|
||||
fmt = pipeline[CONF_FORMAT]
|
||||
if fmt == "NONE":
|
||||
need_all = True
|
||||
else:
|
||||
needed_formats.add(fmt)
|
||||
|
||||
if need_all:
|
||||
audio.request_flac_support()
|
||||
audio.request_mp3_support()
|
||||
audio.request_opus_support()
|
||||
else:
|
||||
if "FLAC" in needed_formats:
|
||||
audio.request_flac_support()
|
||||
if "MP3" in needed_formats:
|
||||
audio.request_mp3_support()
|
||||
if "OPUS" in needed_formats:
|
||||
audio.request_opus_support()
|
||||
|
||||
media_player.request_codecs_for_format_configs(
|
||||
config, [CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE]
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
@@ -264,7 +197,9 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add(
|
||||
var.set_format(
|
||||
pipeline_enum,
|
||||
_get_supported_format_struct(pipeline_config, purpose),
|
||||
media_player.build_supported_format_struct(
|
||||
pipeline_config, purpose
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -472,24 +472,36 @@ void AsyncResponseStream::printf(const char *fmt, ...) {
|
||||
|
||||
#ifdef USE_WEBSERVER
|
||||
AsyncEventSource::~AsyncEventSource() {
|
||||
for (auto *ses : this->sessions_) {
|
||||
delete ses; // NOLINT(cppcoreguidelines-owning-memory)
|
||||
LockGuard guard{this->pending_mutex_};
|
||||
for (auto *vec : {&this->sessions_, &this->pending_sessions_}) {
|
||||
for (auto *ses : *vec) {
|
||||
delete ses; // NOLINT(cppcoreguidelines-owning-memory)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void AsyncEventSource::handleRequest(AsyncWebServerRequest *request) {
|
||||
// Httpd task: set up the live httpd_req_t and park the session; main loop does the rest.
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-owning-memory,clang-analyzer-cplusplus.NewDeleteLeaks)
|
||||
auto *rsp = new AsyncEventSourceResponse(request, this, this->web_server_);
|
||||
if (this->on_connect_) {
|
||||
this->on_connect_(rsp);
|
||||
{
|
||||
LockGuard guard{this->pending_mutex_};
|
||||
this->pending_sessions_.push_back(rsp);
|
||||
this->has_pending_sessions_.store(true, std::memory_order_release);
|
||||
}
|
||||
this->sessions_.push_back(rsp);
|
||||
// Wake up WebServer::loop() to drain deferred event queues for this client.
|
||||
// Safe from httpd task context via the pending_enable_loop_ flag.
|
||||
this->web_server_->enable_loop_soon_any_context();
|
||||
}
|
||||
|
||||
// clang-analyzer traces a false-positive leak path from loop() through
|
||||
// adopt_pending_sessions_main_loop_() into start_session_main_loop_() and
|
||||
// finally ArduinoJson. Suppress along the entire in-our-code call chain.
|
||||
// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks)
|
||||
bool AsyncEventSource::loop() {
|
||||
// Fast path: one atomic load per tick. Slow path is out-of-line on connect.
|
||||
if (this->has_pending_sessions_.load(std::memory_order_acquire)) {
|
||||
this->adopt_pending_sessions_main_loop_();
|
||||
}
|
||||
|
||||
// Clean up dead sessions safely
|
||||
// This follows the ESP-IDF pattern where free_ctx marks resources as dead
|
||||
// and the main loop handles the actual cleanup to avoid race conditions
|
||||
@@ -497,7 +509,7 @@ bool AsyncEventSource::loop() {
|
||||
auto *ses = this->sessions_[i];
|
||||
// If the session has a dead socket (marked by destroy callback)
|
||||
if (ses->fd_.load() == 0) {
|
||||
ESP_LOGD(TAG, "Removing dead event source session");
|
||||
// destroy() already logged the close with the fd; don't double-log here.
|
||||
delete ses; // NOLINT(cppcoreguidelines-owning-memory)
|
||||
// Remove by swapping with last element (O(1) removal, order doesn't matter for sessions)
|
||||
this->sessions_[i] = this->sessions_.back();
|
||||
@@ -510,6 +522,30 @@ bool AsyncEventSource::loop() {
|
||||
return !this->sessions_.empty();
|
||||
}
|
||||
|
||||
void AsyncEventSource::adopt_pending_sessions_main_loop_() {
|
||||
std::vector<AsyncEventSourceResponse *> incoming;
|
||||
{
|
||||
LockGuard guard{this->pending_mutex_};
|
||||
incoming.swap(this->pending_sessions_);
|
||||
this->has_pending_sessions_.store(false, std::memory_order_relaxed);
|
||||
}
|
||||
for (auto *rsp : incoming) {
|
||||
// Already disconnected? Drop it; skip on_connect_/session start on a dead session.
|
||||
if (rsp->fd_.load() == 0) {
|
||||
delete rsp; // NOLINT(cppcoreguidelines-owning-memory)
|
||||
continue;
|
||||
}
|
||||
this->sessions_.push_back(rsp);
|
||||
// Prime first so on_connect_ observes a session that has already sent its
|
||||
// initial ping/config/sorting_groups, matching the pre-refactor ordering.
|
||||
rsp->start_session_main_loop_();
|
||||
if (this->on_connect_) {
|
||||
this->on_connect_(rsp);
|
||||
}
|
||||
}
|
||||
}
|
||||
// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks)
|
||||
|
||||
void AsyncEventSource::try_send_nodefer(const char *message, const char *event, uint32_t id, uint32_t reconnect) {
|
||||
for (auto *ses : this->sessions_) {
|
||||
if (ses->fd_.load() != 0) { // Skip dead sessions
|
||||
@@ -534,6 +570,7 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest *
|
||||
esphome::web_server_idf::AsyncEventSource *server,
|
||||
esphome::web_server::WebServer *ws)
|
||||
: server_(server), web_server_(ws), entities_iterator_(ws, server) {
|
||||
// Httpd task only. start_session_main_loop_() handles event_buffer_ / iterator setup.
|
||||
httpd_req_t *req = *request;
|
||||
|
||||
httpd_resp_set_status(req, HTTPD_200);
|
||||
@@ -555,21 +592,23 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest *
|
||||
|
||||
// Use non-blocking send to prevent watchdog timeouts when TCP buffers are full
|
||||
httpd_sess_set_send_override(this->hd_, this->fd_.load(), nonblocking_send);
|
||||
}
|
||||
|
||||
// Configure reconnect timeout and send config
|
||||
// this should always go through since the tcp send buffer is empty on connect
|
||||
// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
|
||||
void AsyncEventSourceResponse::start_session_main_loop_() {
|
||||
auto *ws = this->web_server_;
|
||||
|
||||
// tcp send buffer is empty on connect, so these should always go through
|
||||
auto message = ws->get_config_json();
|
||||
this->try_send_nodefer(message.c_str(), "ping", millis(), 30000);
|
||||
|
||||
#ifdef USE_WEBSERVER_SORTING
|
||||
for (auto &group : ws->sorting_groups_) {
|
||||
// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
|
||||
json::JsonBuilder builder;
|
||||
JsonObject root = builder.root();
|
||||
root["name"] = group.second.name;
|
||||
root["sorting_weight"] = group.second.weight;
|
||||
message = builder.serialize();
|
||||
// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks)
|
||||
|
||||
// a (very) large number of these should be able to be queued initially without defer
|
||||
// since the only thing in the send buffer at this point is the initial ping/config
|
||||
@@ -578,13 +617,8 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest *
|
||||
#endif
|
||||
|
||||
this->entities_iterator_.begin(ws->include_internal_);
|
||||
|
||||
// just dump them all up-front and take advantage of the deferred queue
|
||||
// on second thought that takes too long, but leaving the commented code here for debug purposes
|
||||
// while(!this->entities_iterator_.completed()) {
|
||||
// this->entities_iterator_.advance();
|
||||
//}
|
||||
}
|
||||
// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks)
|
||||
|
||||
void AsyncEventSourceResponse::destroy(void *ptr) {
|
||||
auto *rsp = static_cast<AsyncEventSourceResponse *>(ptr);
|
||||
|
||||
@@ -299,6 +299,9 @@ class AsyncEventSourceResponse {
|
||||
AsyncEventSourceResponse(const AsyncWebServerRequest *request, esphome::web_server_idf::AsyncEventSource *server,
|
||||
esphome::web_server::WebServer *ws);
|
||||
|
||||
// Main-loop only: sends initial ping/config/sorting_groups, starts entity iterator.
|
||||
void start_session_main_loop_();
|
||||
|
||||
void deq_push_back_with_dedup_(void *source, message_generator_t *message_generator);
|
||||
void process_deferred_queue_();
|
||||
void process_buffer_();
|
||||
@@ -335,6 +338,8 @@ class AsyncEventSource : public AsyncWebHandler {
|
||||
}
|
||||
// NOLINTNEXTLINE(readability-identifier-naming)
|
||||
void handleRequest(AsyncWebServerRequest *request) override;
|
||||
// Callback runs on the main loop (not the httpd task) after the session's
|
||||
// initial ping/config/sorting_groups have been sent.
|
||||
// NOLINTNEXTLINE(readability-identifier-naming)
|
||||
void onConnect(connect_handler_t &&cb) { this->on_connect_ = std::move(cb); }
|
||||
|
||||
@@ -347,13 +352,18 @@ class AsyncEventSource : public AsyncWebHandler {
|
||||
size_t count() const { return this->sessions_.size(); }
|
||||
|
||||
protected:
|
||||
// Cold path: move sessions from pending_sessions_ into sessions_ and greet each one.
|
||||
void __attribute__((noinline, cold)) adopt_pending_sessions_main_loop_();
|
||||
|
||||
std::string url_;
|
||||
// Use vector instead of set: SSE sessions are typically 1-5 connections (browsers, dashboards).
|
||||
// Linear search is faster than red-black tree overhead for this small dataset.
|
||||
// Only operations needed: add session, remove session, iterate sessions - no need for sorted order.
|
||||
// Main-loop only. Vector: SSE sessions are 1-5 connections, linear search beats set.
|
||||
std::vector<AsyncEventSourceResponse *> sessions_;
|
||||
// Httpd-task intake; guarded by pending_mutex_, gated by has_pending_sessions_.
|
||||
std::vector<AsyncEventSourceResponse *> pending_sessions_;
|
||||
Mutex pending_mutex_;
|
||||
connect_handler_t on_connect_{};
|
||||
esphome::web_server::WebServer *web_server_;
|
||||
std::atomic<bool> has_pending_sessions_{false};
|
||||
};
|
||||
#endif // USE_WEBSERVER
|
||||
|
||||
|
||||
@@ -1579,6 +1579,8 @@ void WiFiComponent::check_connecting_finished(uint32_t now) {
|
||||
#endif
|
||||
|
||||
this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTED;
|
||||
// Refresh is_connected() cache; loop()'s refresh ran before this transition.
|
||||
this->update_connected_state_();
|
||||
this->num_retried_ = 0;
|
||||
this->print_connect_params_();
|
||||
|
||||
|
||||
@@ -951,6 +951,8 @@ void WiFiComponent::process_pending_callbacks_() {
|
||||
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
|
||||
if (this->pending_.disconnect) {
|
||||
this->pending_.disconnect = false;
|
||||
// Refresh is_connected() cache here, not in the SDK callback (sys context).
|
||||
this->update_connected_state_();
|
||||
this->notify_disconnect_state_listeners_();
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -804,6 +804,8 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
|
||||
s_sta_connected = false;
|
||||
s_sta_connecting = false;
|
||||
error_from_callback_ = true;
|
||||
// Refresh is_connected() cache; error_from_callback_ makes it false.
|
||||
this->update_connected_state_();
|
||||
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
|
||||
this->notify_disconnect_state_listeners_();
|
||||
#endif
|
||||
|
||||
@@ -12,7 +12,12 @@
|
||||
|
||||
#ifdef USE_BK72XX
|
||||
extern "C" {
|
||||
// BDK 3.0.78 (required for BK7238) redeclares wifi_event_sta_disconnected_t,
|
||||
// which LibreTiny's Arduino WiFi API already defines. ESPHome doesn't use the
|
||||
// BDK version, so rename it across this include to avoid the collision.
|
||||
#define wifi_event_sta_disconnected_t bdk_wifi_event_sta_disconnected_t
|
||||
#include <wlan_ui_pub.h>
|
||||
#undef wifi_event_sta_disconnected_t
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -525,6 +530,8 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) {
|
||||
this->error_from_callback_ = true;
|
||||
}
|
||||
|
||||
// Refresh is_connected() cache; sta_state_/error_from_callback_ make it false.
|
||||
this->update_connected_state_();
|
||||
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
|
||||
this->notify_disconnect_state_listeners_();
|
||||
#endif
|
||||
|
||||
@@ -342,6 +342,8 @@ bool WiFiComponent::wifi_loop_() {
|
||||
s_sta_was_connected = false;
|
||||
s_sta_had_ip = false;
|
||||
ESP_LOGV(TAG, "Disconnected");
|
||||
// Refresh is_connected() cache; driver link status reports disconnected.
|
||||
this->update_connected_state_();
|
||||
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
|
||||
this->notify_disconnect_state_listeners_();
|
||||
#endif
|
||||
|
||||
@@ -15,6 +15,7 @@ from .const import (
|
||||
KEY_BOARD,
|
||||
KEY_BOOTLOADER,
|
||||
KEY_EXTRA_BUILD_FILES,
|
||||
KEY_KCONFIG,
|
||||
KEY_OVERLAY,
|
||||
KEY_PM_STATIC,
|
||||
KEY_PRJ_CONF,
|
||||
@@ -54,6 +55,7 @@ class ZephyrData(TypedDict):
|
||||
extra_build_files: dict[str, Path]
|
||||
pm_static: list[Section]
|
||||
user: dict[str, list[str]]
|
||||
kconfig: str
|
||||
|
||||
|
||||
def zephyr_set_core_data(config: ConfigType) -> None:
|
||||
@@ -65,6 +67,7 @@ def zephyr_set_core_data(config: ConfigType) -> None:
|
||||
extra_build_files={},
|
||||
pm_static=[],
|
||||
user={},
|
||||
kconfig="",
|
||||
)
|
||||
|
||||
|
||||
@@ -185,8 +188,12 @@ def zephyr_add_cdc_acm(config: ConfigType, id: int) -> None:
|
||||
)
|
||||
|
||||
|
||||
def zephyr_add_pm_static(section: Section):
|
||||
CORE.data[KEY_ZEPHYR][KEY_PM_STATIC].extend(section)
|
||||
def zephyr_add_kconfig(kconfig: str) -> None:
|
||||
zephyr_data()[KEY_KCONFIG] += textwrap.dedent(kconfig) + "\n"
|
||||
|
||||
|
||||
def zephyr_add_pm_static(sections: list[Section]) -> None:
|
||||
zephyr_data()[KEY_PM_STATIC].extend(sections)
|
||||
|
||||
|
||||
def zephyr_add_user(key, value):
|
||||
@@ -273,3 +280,18 @@ def copy_files():
|
||||
write_file_if_changed(
|
||||
CORE.relative_build_path("zephyr/pm_static.yml"), pm_static
|
||||
)
|
||||
|
||||
kconfig = zephyr_data()[KEY_KCONFIG]
|
||||
if kconfig:
|
||||
kconfig = (
|
||||
textwrap.dedent(
|
||||
"""
|
||||
menu "Zephyr"
|
||||
source "Kconfig.zephyr"
|
||||
endmenu
|
||||
"""
|
||||
)
|
||||
+ "\n"
|
||||
+ kconfig
|
||||
)
|
||||
write_file_if_changed(CORE.relative_build_path("zephyr/Kconfig"), kconfig)
|
||||
|
||||
@@ -8,6 +8,7 @@ KEY_BOOTLOADER: Final = "bootloader"
|
||||
KEY_EXTRA_BUILD_FILES: Final = "extra_build_files"
|
||||
KEY_OVERLAY: Final = "overlay"
|
||||
KEY_PM_STATIC: Final = "pm_static"
|
||||
KEY_KCONFIG: Final = "kconfig"
|
||||
KEY_PRJ_CONF: Final = "prj_conf"
|
||||
KEY_ZEPHYR = "zephyr"
|
||||
KEY_BOARD: Final = "board"
|
||||
|
||||
@@ -95,7 +95,8 @@ void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parame
|
||||
void setup();
|
||||
void loop();
|
||||
|
||||
int main() {
|
||||
int __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
|
||||
main() {
|
||||
setup();
|
||||
while (true) {
|
||||
loop();
|
||||
|
||||
@@ -3,26 +3,43 @@ 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_SLEEPY,
|
||||
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 +50,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 +62,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 +79,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(
|
||||
@@ -72,14 +108,32 @@ CONFIG_SCHEMA = cv.All(
|
||||
),
|
||||
cv.requires_component("nrf52"),
|
||||
),
|
||||
cv.OnlyWith(CONF_SLEEPY, "nrf52", default=False): cv.All(
|
||||
cv.boolean,
|
||||
),
|
||||
}
|
||||
).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 +144,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 +160,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 +209,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 +224,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,13 @@
|
||||
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_SLEEPY = "sleepy"
|
||||
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
|
||||
@@ -4,6 +4,9 @@
|
||||
#include <zephyr/settings/settings.h>
|
||||
#include <zephyr/storage/flash_map.h>
|
||||
#include "esphome/core/hal.h"
|
||||
#ifdef USE_DEEP_SLEEP
|
||||
#include "esphome/components/deep_sleep/deep_sleep_component.h"
|
||||
#endif
|
||||
|
||||
extern "C" {
|
||||
#include <zboss_api.h>
|
||||
@@ -116,6 +119,12 @@ void ZigbeeComponent::zcl_device_cb(zb_bufid_t bufid) {
|
||||
/* Set default response value. */
|
||||
p_device_cb_param->status = RET_OK;
|
||||
|
||||
#ifdef USE_DEEP_SLEEP
|
||||
if (auto *ds = deep_sleep::global_deep_sleep.load()) {
|
||||
ds->wakeup();
|
||||
}
|
||||
#endif
|
||||
|
||||
// endpoints are enumerated from 1
|
||||
if (global_zigbee->callbacks_.size() >= endpoint) {
|
||||
const auto &cb = global_zigbee->callbacks_[endpoint - 1];
|
||||
@@ -181,9 +190,11 @@ void ZigbeeComponent::setup() {
|
||||
ESP_LOGE(TAG, "Cannot load settings, err: %d", err);
|
||||
return;
|
||||
}
|
||||
zigbee_configure_sleepy_behavior(this->sleepy_);
|
||||
zigbee_enable();
|
||||
}
|
||||
|
||||
#ifdef ESPHOME_LOG_HAS_CONFIG
|
||||
static const char *role() {
|
||||
switch (zb_get_network_role()) {
|
||||
case ZB_NWK_DEVICE_TYPE_COORDINATOR:
|
||||
@@ -207,6 +218,7 @@ static const char *get_wipe_on_boot() {
|
||||
return "NO";
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
void ZigbeeComponent::dump_config() {
|
||||
char ieee_addr_buf[IEEE_ADDR_BUF_SIZE] = {0};
|
||||
@@ -222,6 +234,7 @@ void ZigbeeComponent::dump_config() {
|
||||
" Wipe on boot: %s\n"
|
||||
" Device is joined to the network: %s\n"
|
||||
" Sleep time: %us\n"
|
||||
" RX ON when idle: %s\n"
|
||||
" Current channel: %d\n"
|
||||
" Current page: %d\n"
|
||||
" Sleep threshold: %ums\n"
|
||||
@@ -230,9 +243,9 @@ void ZigbeeComponent::dump_config() {
|
||||
" Short addr: 0x%04X\n"
|
||||
" Long pan id: 0x%s\n"
|
||||
" Short pan id: 0x%04X",
|
||||
get_wipe_on_boot(), YESNO(zb_zdo_joined()), this->sleep_time_, zb_get_current_channel(),
|
||||
zb_get_current_page(), zb_get_sleep_threshold(), role(), ieee_addr_buf, zb_get_short_address(),
|
||||
extended_pan_id_buf, zb_get_pan_id());
|
||||
get_wipe_on_boot(), YESNO(zb_zdo_joined()), this->sleep_time_, YESNO(zb_get_rx_on_when_idle()),
|
||||
zb_get_current_channel(), zb_get_current_page(), zb_get_sleep_threshold(), role(), ieee_addr_buf,
|
||||
zb_get_short_address(), extended_pan_id_buf, zb_get_pan_id());
|
||||
dump_reporting_();
|
||||
}
|
||||
|
||||
@@ -302,6 +315,12 @@ void ZigbeeComponent::after_reporting_info(zb_zcl_configure_reporting_req_t *con
|
||||
|
||||
extern "C" {
|
||||
void zboss_signal_handler(zb_uint8_t param) { esphome::zigbee::global_zigbee->zboss_signal_handler_esphome(param); }
|
||||
void zb_osif_serial_put_bytes(const zb_uint8_t *buf, zb_short_t len) {
|
||||
(void) buf;
|
||||
(void) len;
|
||||
}
|
||||
void zb_osif_serial_flush() {}
|
||||
void zb_osif_serial_init() {}
|
||||
|
||||
// NOLINTBEGIN(readability-identifier-naming,bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp)
|
||||
extern zb_ret_t __real_zb_zcl_put_reporting_info_from_req(zb_zcl_configure_reporting_req_t *config_rep_req,
|
||||
|
||||
@@ -81,6 +81,7 @@ class ZigbeeComponent : public Component {
|
||||
Trigger<> *get_join_trigger() { return &this->join_trigger_; };
|
||||
void force_report();
|
||||
void loop() override;
|
||||
void set_sleepy(bool sleepy) { this->sleepy_ = sleepy; }
|
||||
|
||||
protected:
|
||||
static void zcl_device_cb(zb_bufid_t bufid);
|
||||
@@ -95,6 +96,7 @@ class ZigbeeComponent : public Component {
|
||||
bool force_report_{false};
|
||||
uint32_t sleep_time_{};
|
||||
uint32_t sleep_remainder_{};
|
||||
bool sleepy_{};
|
||||
};
|
||||
|
||||
class ZigbeeEntity {
|
||||
@@ -107,5 +109,7 @@ class ZigbeeEntity {
|
||||
ZigbeeComponent *parent_{nullptr};
|
||||
};
|
||||
|
||||
extern ZigbeeComponent *global_zigbee; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
} // namespace esphome::zigbee
|
||||
#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,27 @@ 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_SLEEPY,
|
||||
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 +78,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)
|
||||
@@ -166,6 +170,11 @@ async def zephyr_to_code(config: ConfigType) -> None:
|
||||
zephyr_add_prj_conf("NET_IP_ADDR_CHECK", False)
|
||||
zephyr_add_prj_conf("NET_UDP", False)
|
||||
|
||||
# disable all extra to reduce power and save flash
|
||||
zephyr_add_prj_conf("ZIGBEE_HAVE_SERIAL", False)
|
||||
zephyr_add_prj_conf("ZBOSS_ERROR_PRINT_TO_LOG", False)
|
||||
zephyr_add_prj_conf("DK_LIBRARY", False)
|
||||
|
||||
cg.add_build_flag("-Wl,--wrap=zb_zcl_put_reporting_info_from_req")
|
||||
|
||||
if CONF_IEEE802154_VENDOR_OUI in config:
|
||||
@@ -197,6 +206,8 @@ async def zephyr_to_code(config: ConfigType) -> None:
|
||||
|
||||
CORE.add_job(_ctx_to_code, config)
|
||||
|
||||
cg.add(var.set_sleepy(config[CONF_SLEEPY]))
|
||||
|
||||
|
||||
async def _attr_to_code(config: ConfigType) -> None:
|
||||
# Create the basic attributes structure and attribute list
|
||||
@@ -209,9 +220,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,
|
||||
|
||||
@@ -12,9 +12,6 @@
|
||||
#include <esp_ota_ops.h>
|
||||
#include <esp_bootloader_desc.h>
|
||||
#endif
|
||||
#ifdef USE_LWIP_FAST_SELECT
|
||||
#include "esphome/core/lwip_fast_select.h"
|
||||
#endif // USE_LWIP_FAST_SELECT
|
||||
#include "esphome/core/version.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include <algorithm>
|
||||
@@ -24,10 +21,6 @@
|
||||
#include "esphome/components/status_led/status_led.h"
|
||||
#endif
|
||||
|
||||
#if (defined(USE_ESP8266) || defined(USE_RP2040)) && defined(USE_SOCKET_IMPL_LWIP_TCP)
|
||||
#include "esphome/components/socket/socket.h"
|
||||
#endif
|
||||
|
||||
namespace esphome {
|
||||
|
||||
static const char *const TAG = "app";
|
||||
@@ -366,7 +359,7 @@ void Application::teardown_components(uint32_t timeout_ms) {
|
||||
|
||||
// Give some time for I/O operations if components are still pending
|
||||
if (pending_count > 0) {
|
||||
this->yield_with_select_(1);
|
||||
esphome::internal::wakeable_delay(1);
|
||||
}
|
||||
|
||||
// Update time for next iteration
|
||||
|
||||
+18
-20
@@ -24,9 +24,6 @@
|
||||
#include "esphome/core/area.h"
|
||||
#endif
|
||||
|
||||
#ifdef USE_LWIP_FAST_SELECT
|
||||
#include "esphome/core/lwip_fast_select.h"
|
||||
#endif
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
#include "esphome/components/runtime_stats/runtime_stats.h"
|
||||
#endif
|
||||
@@ -219,11 +216,19 @@ class Application {
|
||||
/// loops and scheduler items still feed after every op, so any op exceeding
|
||||
/// this threshold triggers a real feed naturally.
|
||||
/// Safety margins vs. platform watchdog timeouts:
|
||||
/// - ESP32 task WDT default (5 s): ~16x
|
||||
/// - ESP8266 soft WDT (~1.6 s): ~5x <-- floor case; any future change
|
||||
/// must keep comfortable margin here
|
||||
/// - ESP8266 HW WDT (~6 s): ~20x
|
||||
/// - ESP32 task WDT default (5 s): ~16x
|
||||
/// - ESP8266 soft WDT (~1.6 s): ~5x <-- floor case; any future change
|
||||
/// must keep comfortable margin here
|
||||
/// - ESP8266 HW WDT (~6 s): ~20x
|
||||
/// - BK72xx HW WDT (10 s): ~5x <-- platform override below
|
||||
#ifdef USE_BK72XX
|
||||
// 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;
|
||||
#endif
|
||||
|
||||
/// Feed the task watchdog. Cold entry — callers without a millis()
|
||||
/// timestamp in hand. Out of line to keep call sites tiny.
|
||||
@@ -423,10 +428,6 @@ class Application {
|
||||
void service_status_led_slow_(uint32_t time);
|
||||
#endif
|
||||
|
||||
/// Sleep for up to delay_ms, returning early if a wake event arrives.
|
||||
/// Thin wrapper over the platform wake primitive in wake.h.
|
||||
inline void ESPHOME_ALWAYS_INLINE yield_with_select_(uint32_t delay_ms);
|
||||
|
||||
// === Member variables ordered by size to minimize padding ===
|
||||
|
||||
// Pointer-sized members first
|
||||
@@ -540,7 +541,8 @@ inline ESPHOME_ALWAYS_INLINE Application::ComponentPhaseGuard::ComponentPhaseGua
|
||||
this->app_.in_loop_ = true;
|
||||
}
|
||||
|
||||
inline void ESPHOME_ALWAYS_INLINE Application::loop() {
|
||||
inline void ESPHOME_ALWAYS_INLINE __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
|
||||
Application::loop() {
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
// Capture the start of the active (non-sleeping) portion of this iteration.
|
||||
// Used to derive main-loop overhead = active time − Σ(component time) −
|
||||
@@ -664,18 +666,14 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() {
|
||||
const uint32_t until_sched = this->scheduler.next_schedule_in(now).value_or(until_phase);
|
||||
delay_time = std::min(until_phase, until_sched);
|
||||
}
|
||||
this->yield_with_select_(delay_time);
|
||||
// All platforms route loop yields through the platform wake primitive.
|
||||
// On host this drains the loopback wake socket via select(); on FreeRTOS
|
||||
// targets it uses task notifications; on ESP8266/RP2040 it uses esp_delay/WFE.
|
||||
esphome::internal::wakeable_delay(delay_time);
|
||||
|
||||
if (this->dump_config_at_ < this->components_.size()) {
|
||||
this->process_dump_config_();
|
||||
}
|
||||
}
|
||||
|
||||
// All platforms route loop yields through the platform wake primitive.
|
||||
// On host this drains the loopback wake socket via select(); on FreeRTOS
|
||||
// targets it uses task notifications; on ESP8266/RP2040 it uses esp_delay/WFE.
|
||||
inline void ESPHOME_ALWAYS_INLINE Application::yield_with_select_(uint32_t delay_ms) {
|
||||
esphome::internal::wakeable_delay(delay_ms);
|
||||
}
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
@@ -257,6 +257,11 @@
|
||||
#define USE_MICROPHONE
|
||||
#define USE_PSRAM
|
||||
#define USE_SENDSPIN
|
||||
#define USE_SENDSPIN_ARTWORK
|
||||
#define USE_SENDSPIN_CONTROLLER
|
||||
#define USE_SENDSPIN_METADATA
|
||||
#define USE_SENDSPIN_PLAYER
|
||||
#define USE_SENDSPIN_VISUALIZER
|
||||
#define USE_SENDSPIN_PORT 8928 // NOLINT
|
||||
#define USE_SOCKET_IMPL_BSD_SOCKETS
|
||||
#define USE_LWIP_FAST_SELECT
|
||||
@@ -322,6 +327,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
|
||||
|
||||
|
||||
+31
-12
@@ -235,11 +235,11 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type
|
||||
}
|
||||
target->push_back(item);
|
||||
if (target == &this->to_add_) {
|
||||
this->to_add_count_increment_();
|
||||
this->to_add_count_increment_locked_();
|
||||
}
|
||||
#ifndef ESPHOME_THREAD_SINGLE
|
||||
else {
|
||||
this->defer_count_increment_();
|
||||
this->defer_count_increment_locked_();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -414,8 +414,27 @@ bool HOT Scheduler::cancel_retry(Component *component, uint32_t id) {
|
||||
|
||||
optional<uint32_t> HOT Scheduler::next_schedule_in(uint32_t now) {
|
||||
// IMPORTANT: This method should only be called from the main thread (loop task).
|
||||
// It performs cleanup and accesses items_[0] without holding a lock, which is only
|
||||
// safe when called from the main thread. Other threads must not call this method.
|
||||
// Accesses items_[0] and the fast-path empty checks without holding a lock, which
|
||||
// is only safe from the main thread. Other threads must not call this method.
|
||||
//
|
||||
// Note: cleanup_() is only invoked on the items_[0] path below. The early returns
|
||||
// skip it because they don't read items_[0], and Scheduler::call() at the top of
|
||||
// every loop iteration already performs its own cleanup before the next sleep-
|
||||
// duration computation happens.
|
||||
|
||||
#ifndef ESPHOME_THREAD_SINGLE
|
||||
// defer() items live in a separate queue that is drained at the top of every
|
||||
// loop tick via process_defer_queue_(). If any are pending, the next loop
|
||||
// iteration has work to do right now -- don't let the caller sleep.
|
||||
if (!this->defer_empty_())
|
||||
return 0;
|
||||
#else
|
||||
// On single-threaded builds, defer() routes through set_timeout(..., 0) which
|
||||
// stages in to_add_. process_to_add() runs at the top of every scheduler.call(),
|
||||
// so anything in to_add_ becomes runnable on the next iteration; don't sleep.
|
||||
if (!this->to_add_empty_())
|
||||
return 0;
|
||||
#endif
|
||||
|
||||
// If no items, return empty optional
|
||||
if (!this->cleanup_())
|
||||
@@ -452,7 +471,7 @@ void Scheduler::full_cleanup_removed_items_() {
|
||||
this->items_.erase(this->items_.begin() + write, this->items_.end());
|
||||
// Rebuild the heap structure since items are no longer in heap order
|
||||
std::make_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp);
|
||||
this->to_remove_clear_();
|
||||
this->to_remove_clear_locked_();
|
||||
}
|
||||
|
||||
#ifndef ESPHOME_THREAD_SINGLE
|
||||
@@ -501,7 +520,7 @@ void HOT Scheduler::process_defer_queue_slow_path_(uint32_t &now) {
|
||||
|
||||
this->lock_.lock();
|
||||
// Reset counter and snapshot queue end under lock
|
||||
this->defer_count_clear_();
|
||||
this->defer_count_clear_locked_();
|
||||
size_t defer_queue_end = this->defer_queue_.size();
|
||||
if (this->defer_queue_front_ >= defer_queue_end) {
|
||||
this->lock_.unlock();
|
||||
@@ -621,7 +640,7 @@ uint32_t HOT Scheduler::call(uint32_t now) {
|
||||
LockGuard guard{this->lock_};
|
||||
if (is_item_removed_locked_(item)) {
|
||||
this->recycle_item_main_loop_(this->pop_raw_locked_());
|
||||
this->to_remove_decrement_();
|
||||
this->to_remove_decrement_locked_();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -630,7 +649,7 @@ uint32_t HOT Scheduler::call(uint32_t now) {
|
||||
if (is_item_removed_(item)) {
|
||||
LockGuard guard{this->lock_};
|
||||
this->recycle_item_main_loop_(this->pop_raw_locked_());
|
||||
this->to_remove_decrement_();
|
||||
this->to_remove_decrement_locked_();
|
||||
continue;
|
||||
}
|
||||
#endif
|
||||
@@ -658,7 +677,7 @@ uint32_t HOT Scheduler::call(uint32_t now) {
|
||||
|
||||
if (this->is_item_removed_locked_(executed_item)) {
|
||||
// We were removed/cancelled in the function call, recycle and continue
|
||||
this->to_remove_decrement_();
|
||||
this->to_remove_decrement_locked_();
|
||||
this->recycle_item_main_loop_(executed_item);
|
||||
continue;
|
||||
}
|
||||
@@ -721,7 +740,7 @@ void HOT Scheduler::process_to_add_slow_path_() {
|
||||
std::push_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp);
|
||||
}
|
||||
this->to_add_.clear();
|
||||
this->to_add_count_clear_();
|
||||
this->to_add_count_clear_locked_();
|
||||
}
|
||||
bool HOT Scheduler::cleanup_slow_path_() {
|
||||
// We must hold the lock for the entire cleanup operation because:
|
||||
@@ -737,7 +756,7 @@ bool HOT Scheduler::cleanup_slow_path_() {
|
||||
SchedulerItem *item = this->items_[0];
|
||||
if (!this->is_item_removed_locked_(item))
|
||||
break;
|
||||
this->to_remove_decrement_();
|
||||
this->to_remove_decrement_locked_();
|
||||
this->recycle_item_main_loop_(this->pop_raw_locked_());
|
||||
}
|
||||
return !this->items_.empty();
|
||||
@@ -825,7 +844,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, NameType name_type
|
||||
size_t heap_cancelled = this->mark_matching_items_removed_locked_(this->items_, component, name_type, static_name,
|
||||
hash_or_id, type, match_retry, find_first);
|
||||
total_cancelled += heap_cancelled;
|
||||
this->to_remove_add_(heap_cancelled);
|
||||
this->to_remove_add_locked_(heap_cancelled);
|
||||
if (find_first && total_cancelled > 0)
|
||||
return true;
|
||||
}
|
||||
|
||||
+57
-43
@@ -524,11 +524,13 @@ class Scheduler {
|
||||
std::vector<SchedulerItem *> to_add_;
|
||||
|
||||
#ifndef ESPHOME_THREAD_SINGLE
|
||||
// Fast-path counter for process_to_add() to skip taking the lock when there is
|
||||
// nothing to add. Uses std::atomic on platforms that support it, plain uint32_t
|
||||
// otherwise. On non-atomic platforms, callers must hold the scheduler lock when
|
||||
// mutating this counter. Not needed on single-threaded platforms where we can
|
||||
// check to_add_.empty() directly.
|
||||
// Fast-path counter for process_to_add() to skip taking the lock when there
|
||||
// is nothing to add. std::atomic on ATOMICS; plain uint32_t on NO_ATOMICS
|
||||
// (BK72xx — ARMv5TE single-core, lacks LDREX/STREX so std::atomic RMW would
|
||||
// require libatomic). Reads use __atomic_load_n(__ATOMIC_RELAXED) on
|
||||
// NO_ATOMICS — compiles to a plain LDR (aligned 32-bit load is naturally
|
||||
// atomic on ARMv5TE) but expresses the concurrent-access intent in the C++
|
||||
// memory model. Writes live behind *_locked_ helpers and must hold lock_.
|
||||
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
|
||||
std::atomic<uint32_t> to_add_count_{0};
|
||||
#else
|
||||
@@ -536,40 +538,41 @@ class Scheduler {
|
||||
#endif
|
||||
#endif /* ESPHOME_THREAD_SINGLE */
|
||||
|
||||
// Fast-path helper for process_to_add() to decide if it can try the lock-free path.
|
||||
// - On ESPHOME_THREAD_SINGLE: direct container check is safe (no concurrent writers).
|
||||
// - On ESPHOME_THREAD_MULTI_ATOMICS: performs a lock-free check via to_add_count_.
|
||||
// - On ESPHOME_THREAD_MULTI_NO_ATOMICS: always returns false to force the caller
|
||||
// down the locked path; this is NOT a lock-free emptiness check on that platform.
|
||||
// Fast-path helper for process_to_add() to decide if it can skip the lock.
|
||||
bool to_add_empty_() const {
|
||||
#ifdef ESPHOME_THREAD_SINGLE
|
||||
return this->to_add_.empty();
|
||||
#elif defined(ESPHOME_THREAD_MULTI_ATOMICS)
|
||||
return this->to_add_count_.load(std::memory_order_relaxed) == 0;
|
||||
#else
|
||||
return false;
|
||||
return __atomic_load_n(&this->to_add_count_, __ATOMIC_RELAXED) == 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
// Increment to_add_count_ (no-op on single-threaded platforms)
|
||||
void to_add_count_increment_() {
|
||||
#ifdef ESPHOME_THREAD_SINGLE
|
||||
// Increment to_add_count_ (no-op on single-threaded platforms).
|
||||
// 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
|
||||
this->to_add_count_++;
|
||||
uint32_t v = __atomic_load_n(&this->to_add_count_, __ATOMIC_RELAXED);
|
||||
__atomic_store_n(&this->to_add_count_, v + 1, __ATOMIC_RELAXED);
|
||||
#endif
|
||||
}
|
||||
|
||||
// Reset to_add_count_ (no-op on single-threaded platforms)
|
||||
void to_add_count_clear_() {
|
||||
#ifdef ESPHOME_THREAD_SINGLE
|
||||
void to_add_count_clear_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_.store(0, std::memory_order_relaxed);
|
||||
#else
|
||||
this->to_add_count_ = 0;
|
||||
__atomic_store_n(&this->to_add_count_, 0, __ATOMIC_RELAXED);
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -580,7 +583,8 @@ class Scheduler {
|
||||
std::vector<SchedulerItem *> defer_queue_; // FIFO queue for defer() calls
|
||||
size_t defer_queue_front_{0}; // Index of first valid item in defer_queue_ (tracks consumed items)
|
||||
|
||||
// Fast-path counter for process_defer_queue_() to skip lock when nothing to process.
|
||||
// Fast-path counter for process_defer_queue_() to skip lock when nothing to
|
||||
// process. See to_add_count_ above for the NO_ATOMICS rationale.
|
||||
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
|
||||
std::atomic<uint32_t> defer_count_{0};
|
||||
#else
|
||||
@@ -589,35 +593,35 @@ class Scheduler {
|
||||
|
||||
bool defer_empty_() const {
|
||||
// defer_queue_ only exists on multi-threaded platforms, so no ESPHOME_THREAD_SINGLE path
|
||||
// ESPHOME_THREAD_MULTI_NO_ATOMICS: always take the lock
|
||||
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
|
||||
return this->defer_count_.load(std::memory_order_relaxed) == 0;
|
||||
#else
|
||||
return false;
|
||||
return __atomic_load_n(&this->defer_count_, __ATOMIC_RELAXED) == 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
void defer_count_increment_() {
|
||||
void defer_count_increment_locked_() {
|
||||
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
|
||||
this->defer_count_.fetch_add(1, std::memory_order_relaxed);
|
||||
#else
|
||||
this->defer_count_++;
|
||||
uint32_t v = __atomic_load_n(&this->defer_count_, __ATOMIC_RELAXED);
|
||||
__atomic_store_n(&this->defer_count_, v + 1, __ATOMIC_RELAXED);
|
||||
#endif
|
||||
}
|
||||
|
||||
void defer_count_clear_() {
|
||||
void defer_count_clear_locked_() {
|
||||
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
|
||||
this->defer_count_.store(0, std::memory_order_relaxed);
|
||||
#else
|
||||
this->defer_count_ = 0;
|
||||
__atomic_store_n(&this->defer_count_, 0, __ATOMIC_RELAXED);
|
||||
#endif
|
||||
}
|
||||
|
||||
#endif /* ESPHOME_THREAD_SINGLE */
|
||||
|
||||
// Counter for items marked for removal. Incremented cross-thread in cancel_item_locked_().
|
||||
// On ESPHOME_THREAD_MULTI_ATOMICS this is read without a lock in the cleanup_() fast path;
|
||||
// on ESPHOME_THREAD_MULTI_NO_ATOMICS the fast path is disabled so cleanup_() always takes the lock.
|
||||
// Counter for items marked for removal. Incremented cross-thread in
|
||||
// cancel_item_locked_(). See to_add_count_ above for the NO_ATOMICS
|
||||
// rationale.
|
||||
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
|
||||
std::atomic<uint32_t> to_remove_{0};
|
||||
#else
|
||||
@@ -626,44 +630,54 @@ class Scheduler {
|
||||
|
||||
// Lock-free check if there are items to remove (for fast-path in cleanup_)
|
||||
bool to_remove_empty_() const {
|
||||
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
|
||||
#if defined(ESPHOME_THREAD_MULTI_ATOMICS)
|
||||
return this->to_remove_.load(std::memory_order_relaxed) == 0;
|
||||
#elif defined(ESPHOME_THREAD_SINGLE)
|
||||
return this->to_remove_ == 0;
|
||||
#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS)
|
||||
return __atomic_load_n(&this->to_remove_, __ATOMIC_RELAXED) == 0;
|
||||
#else
|
||||
return false; // Always take the lock path
|
||||
return this->to_remove_ == 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
void to_remove_add_(uint32_t count) {
|
||||
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
|
||||
void to_remove_add_locked_(uint32_t count) {
|
||||
#if defined(ESPHOME_THREAD_MULTI_ATOMICS)
|
||||
this->to_remove_.fetch_add(count, std::memory_order_relaxed);
|
||||
#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS)
|
||||
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;
|
||||
this->to_remove_ += count;
|
||||
#endif
|
||||
}
|
||||
|
||||
void to_remove_decrement_() {
|
||||
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
|
||||
void to_remove_decrement_locked_() {
|
||||
#if defined(ESPHOME_THREAD_MULTI_ATOMICS)
|
||||
this->to_remove_.fetch_sub(1, std::memory_order_relaxed);
|
||||
#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS)
|
||||
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_--;
|
||||
this->to_remove_--;
|
||||
#endif
|
||||
}
|
||||
|
||||
void to_remove_clear_() {
|
||||
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
|
||||
void to_remove_clear_locked_() {
|
||||
#if defined(ESPHOME_THREAD_MULTI_ATOMICS)
|
||||
this->to_remove_.store(0, std::memory_order_relaxed);
|
||||
#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS)
|
||||
__atomic_store_n(&this->to_remove_, 0, __ATOMIC_RELAXED);
|
||||
#else
|
||||
this->to_remove_ = 0;
|
||||
this->to_remove_ = 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint32_t to_remove_count_() const {
|
||||
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
|
||||
#if defined(ESPHOME_THREAD_MULTI_ATOMICS)
|
||||
return this->to_remove_.load(std::memory_order_relaxed);
|
||||
#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS)
|
||||
return __atomic_load_n(&this->to_remove_, __ATOMIC_RELAXED);
|
||||
#else
|
||||
return this->to_remove_;
|
||||
return this->to_remove_;
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -74,8 +74,8 @@ uint64_t Millis64Impl::compute(uint32_t now) {
|
||||
// 2. Always locks when detecting a large backwards jump
|
||||
// 3. Updates without lock in normal forward progression (accepting minor races)
|
||||
// This is less efficient but necessary without atomic operations.
|
||||
uint16_t major = millis_major;
|
||||
uint32_t last = last_millis;
|
||||
uint16_t major = __atomic_load_n(&millis_major, __ATOMIC_RELAXED);
|
||||
uint32_t last = __atomic_load_n(&last_millis, __ATOMIC_RELAXED);
|
||||
|
||||
// Define a safe window around the rollover point (10 seconds)
|
||||
// This covers any reasonable scheduler delays or thread preemption
|
||||
@@ -87,19 +87,26 @@ uint64_t Millis64Impl::compute(uint32_t now) {
|
||||
if (near_rollover || (now < last && (last - now) > HALF_MAX_UINT32)) {
|
||||
// Near rollover or detected a rollover - need lock for safety
|
||||
LockGuard guard{lock};
|
||||
// Re-read with lock held
|
||||
last = last_millis;
|
||||
// Re-read both values with lock held. last_millis can be updated
|
||||
// unlocked from the forward-progression branch below, so use an atomic
|
||||
// load. millis_major can only be updated under this lock, but another
|
||||
// thread may have completed a rollover between our unlocked loads above
|
||||
// and the lock acquisition — reload or we'd return a stale high word.
|
||||
last = __atomic_load_n(&last_millis, __ATOMIC_RELAXED);
|
||||
major = __atomic_load_n(&millis_major, __ATOMIC_RELAXED);
|
||||
|
||||
if (now < last && (last - now) > HALF_MAX_UINT32) {
|
||||
// True rollover detected (happens every ~49.7 days)
|
||||
millis_major++;
|
||||
// True rollover detected (happens every ~49.7 days).
|
||||
// Use the already-loaded `major` local; avoids a second read of the
|
||||
// global (equivalent under the held lock).
|
||||
major++;
|
||||
__atomic_store_n(&millis_major, major, __ATOMIC_RELAXED);
|
||||
#ifdef ESPHOME_DEBUG_SCHEDULER
|
||||
ESP_LOGD(TAG, "Detected true 32-bit rollover at %" PRIu32 "ms (was %" PRIu32 ")", now, last);
|
||||
#endif /* ESPHOME_DEBUG_SCHEDULER */
|
||||
}
|
||||
// Update last_millis while holding lock
|
||||
last_millis = now;
|
||||
__atomic_store_n(&last_millis, now, __ATOMIC_RELAXED);
|
||||
} else if (now > last) {
|
||||
// Normal case: Not near rollover and time moved forward
|
||||
// Update without lock. While this may cause minor races (microseconds of
|
||||
@@ -107,7 +114,7 @@ uint64_t Millis64Impl::compute(uint32_t now) {
|
||||
// 1. The scheduler operates at millisecond resolution, not microsecond
|
||||
// 2. We've already prevented the critical rollover race condition
|
||||
// 3. Any backwards movement is orders of magnitude smaller than scheduler delays
|
||||
last_millis = now;
|
||||
__atomic_store_n(&last_millis, now, __ATOMIC_RELAXED);
|
||||
}
|
||||
// If now <= last and we're not near rollover, don't update
|
||||
// This minimizes backwards time movement
|
||||
|
||||
@@ -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:
|
||||
@@ -83,5 +91,7 @@ dependencies:
|
||||
- if: "idf_version >=6.0.0 && target in [esp32s2, esp32s3, esp32p4]"
|
||||
esp32async/asynctcp:
|
||||
version: 3.4.91
|
||||
sendspin/sendspin-cpp:
|
||||
version: 0.3.0
|
||||
lvgl/lvgl:
|
||||
version: 9.5.0
|
||||
|
||||
+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
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ void setup() {
|
||||
App.setup();
|
||||
}
|
||||
|
||||
void loop() {
|
||||
void __attribute__((optimize("O2"))) loop() {
|
||||
App.loop();
|
||||
}
|
||||
""",
|
||||
|
||||
+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
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
psram:
|
||||
|
||||
media_source:
|
||||
- platform: audio_http
|
||||
id: audio_http_source
|
||||
buffer_size: 100000
|
||||
task_stack_in_psram: true
|
||||
@@ -0,0 +1 @@
|
||||
<<: !include common.yaml
|
||||
@@ -4,3 +4,9 @@ esphome:
|
||||
- deep_sleep.prevent
|
||||
- delay: 1s
|
||||
- deep_sleep.allow
|
||||
- if:
|
||||
condition:
|
||||
lambda: 'return false;'
|
||||
then:
|
||||
- deep_sleep.enter:
|
||||
sleep_duration: 60min
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
deep_sleep:
|
||||
run_duration: 10s
|
||||
sleep_duration: 50s
|
||||
|
||||
<<: !include common.yaml
|
||||
|
||||
zigbee:
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
name: "Temperature"
|
||||
id: temperature_sensor
|
||||
@@ -0,0 +1,8 @@
|
||||
# `sendspin.switch` action enables the controller role, so we use a standalone test
|
||||
packages:
|
||||
base: !include common.yaml
|
||||
|
||||
wifi:
|
||||
on_connect:
|
||||
then:
|
||||
- sendspin.switch:
|
||||
@@ -0,0 +1,5 @@
|
||||
<<: !include common.yaml
|
||||
|
||||
media_player:
|
||||
- platform: sendspin
|
||||
id: media_player_id
|
||||
@@ -0,0 +1,9 @@
|
||||
<<: !include common.yaml
|
||||
|
||||
media_source:
|
||||
- platform: sendspin
|
||||
id: media_source_id
|
||||
buffer_size: 500000
|
||||
initial_static_delay: 5ms
|
||||
static_delay_adjustable: true
|
||||
fixed_delay: 480us
|
||||
@@ -0,0 +1,15 @@
|
||||
<<: !include common.yaml
|
||||
|
||||
sensor:
|
||||
- platform: sendspin
|
||||
name: "Sendspin Track Progress"
|
||||
type: track_progress
|
||||
- platform: sendspin
|
||||
name: "Sendspin Track Duration"
|
||||
type: track_duration
|
||||
- platform: sendspin
|
||||
name: "Sendspin Year"
|
||||
type: year
|
||||
- platform: sendspin
|
||||
name: "Sendspin Track"
|
||||
type: track
|
||||
@@ -0,0 +1,15 @@
|
||||
<<: !include common.yaml
|
||||
|
||||
text_sensor:
|
||||
- platform: sendspin
|
||||
name: "Title"
|
||||
type: title
|
||||
- platform: sendspin
|
||||
name: "Artist"
|
||||
type: artist
|
||||
- platform: sendspin
|
||||
name: "Album"
|
||||
type: album
|
||||
- platform: sendspin
|
||||
name: "Album Artist"
|
||||
type: album_artist
|
||||
@@ -0,0 +1,9 @@
|
||||
wifi:
|
||||
ap:
|
||||
|
||||
psram:
|
||||
mode: quad
|
||||
|
||||
sendspin:
|
||||
id: sendspin_hub_id
|
||||
task_stack_in_psram: true
|
||||
@@ -0,0 +1 @@
|
||||
<<: !include common-action.yaml
|
||||
@@ -0,0 +1 @@
|
||||
<<: !include common-media_player.yaml
|
||||
@@ -0,0 +1 @@
|
||||
<<: !include common-media_source.yaml
|
||||
@@ -0,0 +1 @@
|
||||
<<: !include common-sensor.yaml
|
||||
@@ -0,0 +1 @@
|
||||
<<: !include common-text_sensor.yaml
|
||||
@@ -0,0 +1 @@
|
||||
<<: !include common.yaml
|
||||
@@ -11,9 +11,9 @@ media_player:
|
||||
id: speaker_media_player_id
|
||||
announcement_pipeline:
|
||||
speaker: speaker_id
|
||||
format: NONE
|
||||
buffer_size: 1000000
|
||||
volume_increment: 0.02
|
||||
volume_max: 0.95
|
||||
volume_min: 0.0
|
||||
task_stack_in_psram: true
|
||||
codec_support_enabled: all
|
||||
|
||||
@@ -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"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user