Compare commits

...

46 Commits

Author SHA1 Message Date
J. Nick Koston 566476b05f Merge branch 'dev' into app-loop-optimize-speed 2026-04-24 10:47:12 -05:00
Kevin Ahrendt 94e300389c [sendspin] remove year and track number text sensors and refactor (#15975) 2026-04-24 15:35:32 +00:00
Kevin Ahrendt 55bcf33446 [sendspin] Add metadata sensor component (#15971) 2026-04-24 14:32:47 +00:00
Kevin Ahrendt f132b7dc07 [media_player][speaker][speaker_source] Centralize preferred format codegen (#14771) 2026-04-24 14:09:03 +00:00
J. Nick Koston baa6d5f96b [web_server_idf] Fix cross-thread race on SSE session state (#15967) 2026-04-24 08:11:47 -05:00
J. Nick Koston 773b4d887b [core] Scheduler: don't sleep while defer queue is non-empty (#15968) 2026-04-24 08:11:29 -05:00
Kevin Ahrendt ac7f0f0b74 [sendspin] Add a metadata text sensor component (#15969) 2026-04-24 11:07:00 +00:00
Kevin Ahrendt bc7f35b569 [sendspin] Add a Sendspin media source component for playing audio (PR4) (#15950)
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-04-24 10:00:22 +00:00
J. Nick Koston ae02ab3865 [wifi] Fix stale wifi.connected after state transition (#15966) 2026-04-24 03:42:36 -05:00
J. Nick Koston eceb534895 [deep_sleep] Fix sleep_duration codegen type to uint32_t (#15965) 2026-04-24 07:19:59 +00:00
tomaszduda23 404620b99c [deep_sleep][logger][zephyr][zigbee] add deep sleep support with zigbee wakeup (#13950)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-23 22:31:46 -04:00
Kevin Ahrendt 3ccaa771a7 [sendspin] Add a group media player controller (PR3) (#15948)
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-04-24 01:46:25 +00:00
Kevin Ahrendt b4a86e46b2 [sendspin] Add controller role and sendspin.switch action (PR2) (#15929)
Co-authored-by: Copilot <copilot@github.com>
2026-04-23 20:22:47 -05:00
Kevin Ahrendt ddf1426f86 [sendspin] Add initial Sendspin hub component (PR1) (#15924)
Co-authored-by: Copilot <copilot@github.com>
2026-04-23 22:09:36 +00:00
J. Nick Koston 90d7bfe02e [ci] Auto-close PRs opened from a fork's default branch (#15957) 2026-04-23 16:36:32 -05:00
Kevin Ahrendt d759f1a567 [audio_http] Add a media source for playing audio from HTTP URLs (#15741)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-23 15:53:52 -05:00
luar123 f757cd1210 [zigbee][core] Add support for Zigbee binary sensors on ESP32 H2 and C6 (#11553)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-23 12:46:56 -04:00
Paulus Schoutsen 9b45b046a8 [core] Allow finding all devices as target that match mac suffix (#13135) 2026-04-23 08:43:32 -05:00
J. Nick Koston 70ae614abd [api] Fall back to plaintext for logger connections (#15938) 2026-04-23 08:23:38 -05:00
J. Nick Koston 8f9b91eece [wifi] Avoid BDK 3.0.78 wifi_event_sta_disconnected_t collision on BK72xx (#15942) 2026-04-23 08:22:17 -05:00
J. Nick Koston 3ca86fc3fc [core] Raise WDT_FEED_INTERVAL_MS to 2000ms on BK72xx (#15943) 2026-04-23 08:21:46 -05:00
J. Nick Koston b38db617a2 [core] Clean up stale includes and inline yield_with_select_ in application (#15945) 2026-04-23 08:21:05 -05:00
J. Nick Koston 13fe881f70 [scheduler][core] Lock-free fast-path on ESPHOME_THREAD_MULTI_NO_ATOMICS via __atomic builtins (#15947) 2026-04-23 08:20:31 -05:00
J. Nick Koston 50c181671c [ci] Better explain too-big bot review message (#15939) 2026-04-23 06:47:16 -05:00
pre-commit-ci-lite[bot] adbbbe9cc5 [pre-commit.ci lite] apply automatic fixes 2026-04-21 03:59:07 +00:00
J. Nick Koston 46b0c9331b Apply suggestion from @bdraco 2026-04-21 05:57:35 +02:00
J. Nick Koston b39ea3c19f Merge branch 'dev' into app-loop-optimize-speed 2026-04-21 05:13:14 +02:00
J. Nick Koston 051326e70e Merge remote-tracking branch 'upstream/dev' into app-loop-optimize-speed
# Conflicts:
#	esphome/core/application.h
2026-04-14 15:06:44 -10:00
J. Nick Koston 220f3d8142 Merge branch 'dev' into app-loop-optimize-speed 2026-04-13 17:08:24 -10:00
J. Nick Koston 7d0391aed6 Merge branch 'dev' into app-loop-optimize-speed 2026-04-13 08:44:55 -10:00
J. Nick Koston 9a9f9fa9f3 Merge branch 'dev' into app-loop-optimize-speed 2026-04-12 21:45:08 -10:00
J. Nick Koston 4dddccab6e Merge branch 'proto-speed-optimized-v2' into app-loop-optimize-speed 2026-04-12 20:34:39 -10:00
J. Nick Koston 6c115e4692 Merge branch 'benchmark-use-os-optimization' into proto-speed-optimized-v2 2026-04-12 20:24:51 -10:00
J. Nick Koston ff26fe32c1 Merge branch 'proto-speed-optimized-v2' into app-loop-optimize-speed 2026-04-12 20:24:43 -10:00
J. Nick Koston 494f11ce77 Merge branch 'dev' into benchmark-use-os-optimization 2026-04-12 20:24:33 -10:00
pre-commit-ci-lite[bot] a463e25aa1 [pre-commit.ci lite] apply automatic fixes 2026-04-13 06:23:08 +00:00
pre-commit-ci-lite[bot] 6aabada342 [pre-commit.ci lite] apply automatic fixes 2026-04-13 06:22:55 +00:00
J. Nick Koston 603d5a2b54 Fix clang-tidy NOLINT for optimize(O2) in generated protobuf code 2026-04-12 20:21:43 -10:00
J. Nick Koston 3e9f464a2c Fix clang-tidy and test for optimize(O2) attribute 2026-04-12 20:21:11 -10:00
J. Nick Koston 9a022baa06 [core] Optimize main loop with -O2
Add __attribute__((optimize("O2"))) to the main loop functions
(loop_task on ESP32, codegen loop() on other platforms) so GCC
inlines scheduler helpers and loop bookkeeping more aggressively.

Under -Os, GCC outlines small functions (Scheduler::call helpers,
millis conversions, etc.) that are called every loop iteration.
With -O2, these get inlined into the loop body, reducing call
overhead on the hottest code path in the firmware.

ESP32: loop_task grows from 303 to 416 bytes (+113 bytes).
ESP8266: no change (already fully inlined via ESPHOME_ALWAYS_INLINE).
2026-04-12 20:06:34 -10:00
J. Nick Koston d9762759c0 Merge branch 'benchmark-use-os-optimization' into proto-speed-optimized-v2 2026-04-12 19:30:45 -10:00
J. Nick Koston d217ab3cd4 [api] Add speed_optimized proto option for hot encode paths
Add a new (speed_optimized) message option that emits
__attribute__((optimize("O2"))) on the generated encode() and
calculate_size() methods. Under -Os, GCC does not inline the small
ProtoEncode helpers (write_raw_byte, encode_varint, etc.) into the
generated methods, causing significant overhead on hot paths.

Apply to SensorStateResponse and BluetoothLERawAdvertisementsResponse
which are the highest-frequency encode paths.
2026-04-12 19:23:39 -10:00
J. Nick Koston 70dd732821 [api] Add speed_optimized proto option for hot encode paths
Add a new (speed_optimized) message option that emits
__attribute__((optimize("O2"))) on the generated encode() and
calculate_size() methods. Under -Os, GCC does not inline the small
ProtoEncode helpers (write_raw_byte, encode_varint, etc.) into the
generated methods, causing significant overhead on hot paths.

Apply to SensorStateResponse and BluetoothLERawAdvertisementsResponse
which are the highest-frequency encode paths.
2026-04-12 19:21:47 -10:00
J. Nick Koston 9acfeec431 Merge branch 'dev' into benchmark-use-os-optimization 2026-04-12 18:40:32 -10:00
J. Nick Koston 02f828fcbf [benchmark] Use -Os to match firmware optimization level
CodSpeed benchmarks were building with -O2, while all firmware
targets (ESP8266, ESP32, LibreTiny) use -Os. This mismatch means
the benchmarks cannot detect inlining regressions that affect real
devices — GCC under -O2 inlines functions that -Os outlines due to
its size-conscious cost model.

Switch to -Os with -ffunction-sections/-fdata-sections for proper
dead-code stripping (needed because -Os preserves references that
-O2 optimizes away at compile time).
2026-04-12 18:37:50 -10:00
J. Nick Koston ab64916c37 [benchmark] Use -Os to match firmware optimization level
CodSpeed benchmarks were building with -O2, while all firmware
targets (ESP8266, ESP32, LibreTiny) use -Os. This mismatch means
the benchmarks cannot detect inlining regressions that affect real
devices — GCC under -O2 inlines functions that -Os outlines due to
its size-conscious cost model.

Remove the -Os unflag and -O2 override so benchmarks use the
platform default -Os, matching what actually runs on devices.
2026-04-12 18:32:03 -10:00
110 changed files with 5076 additions and 482 deletions
+1 -1
View File
@@ -1 +1 @@
256216e144a626c8c9d1a458920a9db3de7dfc8c6a1b44b87946b9752e81026c
1b1ce6324c50c4595703c7df0a8a479b4fe84b71ff1a8793cce1a16f17a33324
+25 -5
View File
@@ -41,16 +41,36 @@ function generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo,
let message = `${TOO_BIG_MARKER}\n### 📦 Pull Request Size\n\n`;
message +=
`Hey @${prAuthor}, thanks for the contribution! Just a heads up, ` +
`this PR is on the large side `;
if (tooManyLabels && tooManyChanges) {
message += `This PR is too large with ${nonTestChanges} line changes (excluding tests) and affects ${originalLabelCount} different components/areas.`;
message +=
`(${nonTestChanges} line changes excluding tests, across ` +
`${originalLabelCount} different components/areas)`;
} else if (tooManyLabels) {
message += `This PR affects ${originalLabelCount} different components/areas.`;
message +=
`(it touches ${originalLabelCount} different components/areas)`;
} else {
message += `This PR is too large with ${nonTestChanges} line changes (excluding tests).`;
message += `(${nonTestChanges} line changes excluding tests)`;
}
message += ` Please consider breaking it down into smaller, focused PRs to make review easier and reduce the risk of conflicts.\n\n`;
message += `For guidance on breaking down large PRs, see: https://developers.esphome.io/contributing/submitting-your-work/#how-to-approach-large-submissions`;
message += `, which makes it harder for maintainers to review.\n\n`;
message +=
`Smaller, focused PRs tend to be reviewed much faster since they ` +
`fit into the short gaps between other maintainer work; large ones ` +
`often have to wait for a rare long uninterrupted block of time. ` +
`If you can break this up into smaller pieces that can be reviewed ` +
`independently, it will almost certainly land faster overall.\n\n`;
message +=
`Before putting more time in, it's also worth popping into ` +
`\`#devs\` on [Discord](https://esphome.io/chat) so we can help ` +
`you scope things and flag anything already in flight.\n\n`;
message +=
`For more details (including how to split the work up), see: ` +
`https://developers.esphome.io/contributing/submitting-your-work/` +
`#how-to-approach-large-submissions`;
messages.push(message);
}
@@ -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
View File
@@ -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
View File
@@ -39,6 +39,7 @@ from esphome.const import (
CONF_MDNS,
CONF_MQTT,
CONF_NAME,
CONF_NAME_ADD_MAC_SUFFIX,
CONF_OTA,
CONF_PASSWORD,
CONF_PLATFORM,
@@ -71,6 +72,7 @@ from esphome.util import (
run_external_process,
safe_print,
)
from esphome.zeroconf import discover_mdns_devices
_LOGGER = logging.getLogger(__name__)
@@ -204,6 +206,64 @@ def _resolve_with_cache(address: str, purpose: Purpose) -> list[str]:
return [address]
def _populate_mdns_cache(hosts_to_addresses: dict[str, list[str]]) -> None:
"""Store discovered ``host -> [ips]`` entries in ``CORE.address_cache``.
Ensures ``CORE.address_cache`` exists, then records each mDNS hostname so
the downstream resolution path (``resolve_ip_address``) can skip opening a
second Zeroconf client.
"""
from esphome.address_cache import AddressCache
if CORE.address_cache is None:
CORE.address_cache = AddressCache()
for host, addresses in hosts_to_addresses.items():
if addresses:
_LOGGER.debug("Caching mDNS result %s -> %s", host, addresses)
CORE.address_cache.add_mdns_addresses(host, addresses)
def _discover_mac_suffix_devices() -> list[str] | None:
"""Discover ``<name>-<mac>.local`` devices and cache their IPs.
Returns:
- ``None`` when discovery isn't applicable (``name_add_mac_suffix`` off,
mDNS disabled, or ``CORE.address`` is already an IP). Callers should
then fall back to whatever default OTA address they normally use.
- ``[]`` when discovery ran but found nothing. Callers should NOT fall
back to the base name: with ``name_add_mac_suffix`` enabled, the base
name by definition doesn't exist on the network.
- A non-empty sorted list of ``.local`` hostnames on success.
Populates ``CORE.address_cache`` so downstream resolution (``espota2`` or
``aioesphomeapi`` via :func:`_resolve_network_devices`) reuses the IPs we
already have without opening a second Zeroconf client.
"""
if not (has_name_add_mac_suffix() and has_mdns() and has_non_ip_address()):
return None
_LOGGER.info("Discovering devices...")
if not (discovered := discover_mdns_devices(CORE.name)):
_LOGGER.warning(
"No devices matching '%s-<mac>.local' were discovered.", CORE.name
)
return []
_populate_mdns_cache(discovered)
return list(discovered)
def _ota_hostnames_for_default(purpose: Purpose) -> list[str]:
"""Return OTA hostname(s) for the ``--device OTA`` / default-resolve path.
When ``name_add_mac_suffix`` is enabled, returns discovered
``<name>-<mac>.local`` hostnames (possibly empty — in which case the
caller should not fall back to the base name). Otherwise falls back to
the cache-resolved ``CORE.address``.
"""
if (discovered := _discover_mac_suffix_devices()) is not None:
return discovered
return _resolve_with_cache(CORE.address, purpose)
def choose_upload_log_host(
default: list[str] | str | None,
check_default: str | None,
@@ -242,14 +302,14 @@ def choose_upload_log_host(
resolved.append("MQTT")
if has_api() and has_non_ip_address() and has_resolvable_address():
resolved.extend(_resolve_with_cache(CORE.address, purpose))
resolved.extend(_ota_hostnames_for_default(purpose))
elif purpose == Purpose.UPLOADING:
if has_ota() and has_mqtt_ip_lookup():
resolved.append("MQTTIP")
if has_ota() and has_non_ip_address() and has_resolvable_address():
resolved.extend(_resolve_with_cache(CORE.address, purpose))
resolved.extend(_ota_hostnames_for_default(purpose))
else:
resolved.append(device)
if not resolved:
@@ -281,22 +341,29 @@ def choose_upload_log_host(
elif bootsel.permission_error:
bootsel_permission_error = True
def add_ota_options() -> None:
"""Add OTA options, using mDNS discovery if name_add_mac_suffix is enabled."""
if (discovered := _discover_mac_suffix_devices()) is not None:
# Discovery was applicable. Use whatever we found — on empty,
# intentionally skip the base-name fallback since with
# name_add_mac_suffix on, the base name doesn't exist on the net.
for host in discovered:
options.append((f"Over The Air ({host})", host))
elif has_resolvable_address():
options.append((f"Over The Air ({CORE.address})", CORE.address))
if has_mqtt_ip_lookup():
options.append(("Over The Air (MQTT IP lookup)", "MQTTIP"))
if purpose == Purpose.LOGGING:
if has_mqtt_logging():
mqtt_config = CORE.config[CONF_MQTT]
options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT"))
if has_api():
if has_resolvable_address():
options.append((f"Over The Air ({CORE.address})", CORE.address))
if has_mqtt_ip_lookup():
options.append(("Over The Air (MQTT IP lookup)", "MQTTIP"))
add_ota_options()
elif purpose == Purpose.UPLOADING and has_ota():
if has_resolvable_address():
options.append((f"Over The Air ({CORE.address})", CORE.address))
if has_mqtt_ip_lookup():
options.append(("Over The Air (MQTT IP lookup)", "MQTTIP"))
add_ota_options()
# Show helpful BOOTSEL instructions for RP2040 when no BOOTSEL device is found
if (
@@ -407,7 +474,17 @@ def has_resolvable_address() -> bool:
return not CORE.address.endswith(".local")
def mqtt_get_ip(config: ConfigType, username: str, password: str, client_id: str):
def has_name_add_mac_suffix() -> bool:
"""Check if name_add_mac_suffix is enabled in the config."""
if CORE.config is None:
return False
esphome_config = CORE.config.get(CONF_ESPHOME, {})
return esphome_config.get(CONF_NAME_ADD_MAC_SUFFIX, False)
def mqtt_get_ip(
config: ConfigType, username: str, password: str, client_id: str
) -> list[str]:
from esphome import mqtt
return mqtt.get_esphome_device_ip(config, username, password, client_id)
@@ -420,6 +497,9 @@ def _resolve_network_devices(
This function filters the devices list to:
- Replace MQTT/MQTTIP magic strings with actual IP addresses via MQTT lookup
- Expand hostnames that are already in ``CORE.address_cache`` to their
cached IPs so downstream code (e.g. aioesphomeapi) doesn't open a second
Zeroconf client to resolve them
- Deduplicate addresses while preserving order
- Only resolve MQTT once even if multiple MQTT strings are present
- If MQTT resolution fails, log a warning and continue with other devices
@@ -444,13 +524,29 @@ def _resolve_network_devices(
mqtt_ips = mqtt_get_ip(
config, args.username, args.password, args.client_id
)
network_devices.extend(mqtt_ips)
# pylint can't infer mqtt_get_ip's return through its
# lazy ``from esphome import mqtt`` import, so it flags
# the genexpr below.
network_devices.extend(
addr
for addr in mqtt_ips # pylint: disable=not-an-iterable
if addr not in network_devices
)
except EsphomeError as err:
_LOGGER.warning(
"MQTT IP discovery failed (%s), will try other devices if available",
err,
)
mqtt_resolved = True
continue
# If the hostname is already in the address cache (e.g. populated by
# mDNS discovery), substitute the cached IPs so aioesphomeapi doesn't
# open its own Zeroconf to re-resolve it.
if CORE.address_cache and (cached := CORE.address_cache.get_addresses(device)):
network_devices.extend(
addr for addr in cached if addr not in network_devices
)
elif device not in network_devices:
# Regular network address or IP - add if not already present
network_devices.append(device)
+11
View File
@@ -101,6 +101,17 @@ class AddressCache:
"""Check if any cache entries exist."""
return bool(self.mdns_cache or self.dns_cache)
def add_mdns_addresses(self, hostname: str, addresses: list[str]) -> None:
"""Store resolved mDNS addresses for ``hostname`` in the cache.
Callers that discover ``.local`` hosts (e.g. via mDNS browse) can use
this to avoid a second resolution round-trip during the upload path.
No-op when ``addresses`` is empty.
"""
if not addresses:
return
self.mdns_cache[normalize_hostname(hostname)] = addresses
@classmethod
def from_cli_args(
cls, mdns_args: Iterable[str], dns_args: Iterable[str]
+56
View File
@@ -0,0 +1,56 @@
"""Helpers for running an async coroutine from sync code via a daemon thread.
``asyncio.run(coro())`` in the main thread blocks until the loop's cleanup
cycle finishes, which can add hundreds of milliseconds before the caller
receives the result. Running the loop in a daemon thread lets the caller
observe the result as soon as the coroutine completes while cleanup finishes
in the background.
"""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
import threading
from typing import Generic, TypeVar
_T = TypeVar("_T")
class AsyncThreadRunner(threading.Thread, Generic[_T]):
"""Run an async coroutine in a daemon thread and expose its result.
The runner catches all exceptions from the coroutine and stores them in
``exception`` so ``event`` is always set — this prevents callers waiting
on ``event`` from hanging forever when the coroutine crashes.
Typical usage::
runner = AsyncThreadRunner(lambda: my_coro(arg))
runner.start()
if not runner.event.wait(timeout=5.0):
... # timed out
if runner.exception is not None:
raise runner.exception
result = runner.result
"""
def __init__(self, coro_factory: Callable[[], Awaitable[_T]]) -> None:
super().__init__(daemon=True)
self._coro_factory = coro_factory
self.result: _T | None = None
self.exception: BaseException | None = None
self.event = threading.Event()
async def _runner(self) -> None:
try:
self.result = await self._coro_factory()
except Exception as exc: # pylint: disable=broad-except
# Capture all exceptions so ``event`` is always set — otherwise a
# crash would hang the waiter forever.
self.exception = exc
finally:
self.event.set()
def run(self) -> None:
asyncio.run(self._runner())
+18 -1
View File
@@ -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]))
+6 -2
View File
@@ -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
+2 -1
View File
@@ -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();
+2 -1
View File
@@ -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);
+1 -1
View File
@@ -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");
+9 -8
View File
@@ -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
}
+109 -2
View File
@@ -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"
+268
View File
@@ -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)
+25
View File
@@ -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 &params = 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
+231
View File
@@ -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
+24 -2
View File
@@ -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)
+1
View File
@@ -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"
+2 -1
View File
@@ -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();
+95 -18
View File
@@ -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)
+3
View File
@@ -1,6 +1,9 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_ZIGBEE
#ifdef USE_ESP32
#include "zigbee_esp32.h"
#endif
#ifdef USE_NRF52
#include "zigbee_zephyr.h"
#endif
+32
View File
@@ -0,0 +1,32 @@
import esphome.codegen as cg
zigbee_ns = cg.esphome_ns.namespace("zigbee")
ZigbeeComponent = zigbee_ns.class_("ZigbeeComponent", cg.Component)
ZigbeeAttribute = zigbee_ns.class_("ZigbeeAttribute", cg.Component)
BinaryAttrs = zigbee_ns.struct("BinaryAttrs")
AnalogAttrs = zigbee_ns.struct("AnalogAttrs")
AnalogAttrsOutput = zigbee_ns.struct("AnalogAttrsOutput")
report = zigbee_ns.enum("ZigbeeReportT")
REPORT = {
"coordinator": report.ZIGBEE_REPORT_COORDINATOR,
"enable": report.ZIGBEE_REPORT_ENABLE,
"force": report.ZIGBEE_REPORT_FORCE,
}
CONF_ON_JOIN = "on_join"
CONF_WIPE_ON_BOOT = "wipe_on_boot"
CONF_REPORT = "report"
CONF_ROUTER = "router"
CONF_POWER_SOURCE = "power_source"
POWER_SOURCE = {
"UNKNOWN": "ZB_ZCL_BASIC_POWER_SOURCE_UNKNOWN",
"MAINS_SINGLE_PHASE": "ZB_ZCL_BASIC_POWER_SOURCE_MAINS_SINGLE_PHASE",
"MAINS_THREE_PHASE": "ZB_ZCL_BASIC_POWER_SOURCE_MAINS_THREE_PHASE",
"BATTERY": "ZB_ZCL_BASIC_POWER_SOURCE_BATTERY",
"DC_SOURCE": "ZB_ZCL_BASIC_POWER_SOURCE_DC_SOURCE",
"EMERGENCY_MAINS_CONST": "ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_CONST",
"EMERGENCY_MAINS_TRANSF": "ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_TRANSF",
}
KEY_ZIGBEE = "zigbee"
+35
View File
@@ -0,0 +1,35 @@
import esphome.codegen as cg
DEVICE_TYPE = "device_type"
ROLE = "role"
CONF_MAX_EP_NUMBER = 239
CONF_NUM = "num"
CONF_CLUSTERS = "clusters"
CONF_ATTRIBUTES = "attributes"
CONF_ENDPOINT = "endpoint"
CONF_CLUSTER = "cluster"
SCALE = "scale"
CONF_ATTRIBUTE_ID = "attribute_id"
KEY_BS_EP = "binary_sensor_ep"
ha_standard_devices = cg.esphome_ns.enum("zb_ha_standard_devs_e")
DEVICE_ID = {
"RANGE_EXTENDER": ha_standard_devices.ZB_HA_RANGE_EXTENDER_DEVICE_ID,
"SIMPLE_SENSOR": ha_standard_devices.ZB_HA_SIMPLE_SENSOR_DEVICE_ID,
"CUSTOM_ATTR": ha_standard_devices.ZB_HA_CUSTOM_ATTR_DEVICE_ID,
}
cluster_id = cg.esphome_ns.enum("esp_zb_zcl_cluster_id_t")
CLUSTER_ID = {
"BASIC": cluster_id.ESP_ZB_ZCL_CLUSTER_ID_BASIC,
"BINARY_INPUT": cluster_id.ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT,
}
cluster_role = cg.esphome_ns.enum("esp_zb_zcl_cluster_role_t")
CLUSTER_ROLE = {
"SERVER": cluster_role.ESP_ZB_ZCL_CLUSTER_SERVER_ROLE,
}
attr_type = cg.esphome_ns.enum("esp_zb_zcl_attr_type_t")
ATTR_TYPE = {
"BOOL": attr_type.ESP_ZB_ZCL_ATTR_TYPE_BOOL,
"8BITMAP": attr_type.ESP_ZB_ZCL_ATTR_TYPE_8BITMAP,
"CHAR_STRING": attr_type.ESP_ZB_ZCL_ATTR_TYPE_CHAR_STRING,
}
+1 -21
View File
@@ -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)
+2 -1
View File
@@ -6,7 +6,8 @@ from esphome.core import CORE
from esphome.types import ConfigType
from .. import consume_endpoint
from ..const_zephyr import CONF_ZIGBEE_ID, zigbee_ns
from ..const import zigbee_ns
from ..const_zephyr import CONF_ZIGBEE_ID
from ..zigbee_zephyr import (
ZigbeeClusterDesc,
ZigbeeComponent,
@@ -0,0 +1,89 @@
#include "zigbee_attribute_esp32.h"
#include "esphome/core/log.h"
#include "esphome/core/defines.h"
#ifdef USE_ESP32
#ifdef USE_ZIGBEE
namespace esphome::zigbee {
static const char *const TAG = "zigbee.attribute";
void ZigbeeAttribute::set_attr_() {
if (!this->zb_->is_connected()) {
return;
}
if (esp_zb_lock_acquire(10 / portTICK_PERIOD_MS)) {
esp_zb_zcl_status_t state = esp_zb_zcl_set_attribute_val(this->endpoint_id_, this->cluster_id_, this->role_,
this->attr_id_, this->value_p_, false);
if (this->force_report_) {
this->report_(true);
}
this->set_attr_requested_ = false;
// Check for error
if (state != ESP_ZB_ZCL_STATUS_SUCCESS) {
ESP_LOGE(TAG, "Setting attribute failed, ZCL status: %u", static_cast<unsigned>(state));
}
esp_zb_lock_release();
}
}
void ZigbeeAttribute::report_(bool has_lock) {
if (!this->zb_->is_connected()) {
return;
}
if (has_lock or esp_zb_lock_acquire(10 / portTICK_PERIOD_MS)) {
esp_zb_zcl_report_attr_cmd_t cmd = {
.address_mode = ESP_ZB_APS_ADDR_MODE_16_ENDP_PRESENT,
.direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_CLI,
};
cmd.zcl_basic_cmd.dst_addr_u.addr_short = 0x0000;
cmd.zcl_basic_cmd.dst_endpoint = 1;
cmd.zcl_basic_cmd.src_endpoint = this->endpoint_id_;
cmd.clusterID = this->cluster_id_;
cmd.attributeID = this->attr_id_;
esp_zb_zcl_report_attr_cmd_req(&cmd);
if (!has_lock) {
esp_zb_lock_release();
}
}
}
esp_zb_zcl_reporting_info_t ZigbeeAttribute::get_reporting_info() {
esp_zb_zcl_reporting_info_t reporting_info = {
.direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_SRV,
.ep = this->endpoint_id_,
.cluster_id = this->cluster_id_,
.cluster_role = this->role_,
.attr_id = this->attr_id_,
.manuf_code = ESP_ZB_ZCL_ATTR_NON_MANUFACTURER_SPECIFIC,
};
reporting_info.dst.profile_id = ESP_ZB_AF_HA_PROFILE_ID;
reporting_info.u.send_info.min_interval = 10; /*!< Actual minimum reporting interval */
reporting_info.u.send_info.max_interval = 0; /*!< Actual maximum reporting interval */
reporting_info.u.send_info.def_min_interval = 10; /*!< Default minimum reporting interval */
reporting_info.u.send_info.def_max_interval = 0; /*!< Default maximum reporting interval */
reporting_info.u.send_info.delta.s16 = 0; /*!< Actual reportable change */
return reporting_info;
}
void ZigbeeAttribute::set_report(bool force) {
this->report_enabled = true;
this->force_report_ = force;
}
void ZigbeeAttribute::loop() {
if (this->set_attr_requested_) {
this->set_attr_();
}
if (!this->set_attr_requested_) {
this->disable_loop();
}
}
} // namespace esphome::zigbee
#endif
#endif
@@ -0,0 +1,90 @@
#pragma once
#include <type_traits>
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#ifdef USE_ESP32
#ifdef USE_ZIGBEE
#include "esp_zigbee_core.h"
#include "zigbee_esp32.h"
#ifdef USE_BINARY_SENSOR
#include "esphome/components/binary_sensor/binary_sensor.h"
#endif
namespace esphome::zigbee {
enum ZigbeeReportT {
ZIGBEE_REPORT_COORDINATOR,
ZIGBEE_REPORT_ENABLE,
ZIGBEE_REPORT_FORCE,
};
class ZigbeeAttribute : public Component {
public:
ZigbeeAttribute(ZigbeeComponent *parent, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id,
uint8_t attr_type, float scale, uint8_t max_size)
: zb_(parent),
endpoint_id_(endpoint_id),
cluster_id_(cluster_id),
role_(role),
attr_id_(attr_id),
attr_type_(attr_type),
scale_(scale),
max_size_(max_size) {}
void loop() override;
template<typename T> void add_attr(T value);
esp_zb_zcl_reporting_info_t get_reporting_info();
template<typename T> void set_attr(const T &value);
uint8_t attr_type() { return attr_type_; }
void set_report(bool force);
#ifdef USE_BINARY_SENSOR
template<typename T> void connect(binary_sensor::BinarySensor *sensor);
#endif
bool report_enabled = false;
protected:
void set_attr_();
void report_(bool has_lock);
ZigbeeComponent *zb_;
uint8_t endpoint_id_;
uint16_t cluster_id_;
uint8_t role_;
uint16_t attr_id_;
uint8_t attr_type_;
uint8_t max_size_;
float scale_;
void *value_p_{nullptr};
bool set_attr_requested_{false};
bool force_report_{false};
};
template<typename T> void ZigbeeAttribute::add_attr(T value) {
// Attribute type does never change and add_attr is only called once during startup, so this is safe.
// For now we need to support only simple numeric/bool types for (binary) sensors.
// For strings and arrays we would need to allocate a buffer of the maximum size.
this->value_p_ = (void *) (new T);
this->zb_->add_attr(this, this->endpoint_id_, this->cluster_id_, this->role_, this->attr_id_, this->max_size_,
std::move(value));
}
template<typename T> void ZigbeeAttribute::set_attr(const T &value) {
*static_cast<T *>(this->value_p_) = value;
this->set_attr_requested_ = true;
this->enable_loop();
}
#ifdef USE_BINARY_SENSOR
template<typename T> void ZigbeeAttribute::connect(binary_sensor::BinarySensor *sensor) {
sensor->add_on_state_callback([this](bool value) { this->set_attr((T) (this->scale_ * value)); });
}
#endif
} // namespace esphome::zigbee
#endif
#endif
@@ -0,0 +1,70 @@
from typing import Any
import esphome.config_validation as cv
from esphome.const import CONF_DEVICE, CONF_ID, CONF_TYPE
from .const import CONF_REPORT, REPORT
from .const_esp32 import (
CLUSTER_ROLE,
CONF_ATTRIBUTE_ID,
CONF_ATTRIBUTES,
CONF_CLUSTERS,
CONF_MAX_EP_NUMBER,
CONF_NUM,
DEVICE_TYPE,
ROLE,
)
# endpoint configs:
ep_configs: dict[str, dict[str, Any]] = {
"binary_input": {
DEVICE_TYPE: "SIMPLE_SENSOR",
CONF_CLUSTERS: [
{
CONF_ID: "BINARY_INPUT",
ROLE: CLUSTER_ROLE["SERVER"],
CONF_ATTRIBUTES: [
{
CONF_ATTRIBUTE_ID: 0x55,
CONF_TYPE: "BOOL",
CONF_REPORT: REPORT["enable"],
CONF_DEVICE: None,
},
{
CONF_ATTRIBUTE_ID: 0x51,
CONF_TYPE: "BOOL",
},
{
CONF_ATTRIBUTE_ID: 0x6F,
CONF_TYPE: "8BITMAP",
},
{
CONF_ATTRIBUTE_ID: 0x1C,
CONF_TYPE: "CHAR_STRING",
},
],
},
],
},
}
def create_ep(ep_list: list[dict[str, Any]], router: bool) -> list[dict[str, Any]]:
# create dummy endpoint if list is empty
if not ep_list:
ep_type = "CUSTOM_ATTR"
if router:
ep_type = "RANGE_EXTENDER"
ep_list = [
{
DEVICE_TYPE: ep_type,
}
]
# enumerate endpoints
for i, ep in enumerate(ep_list, 1):
ep[CONF_NUM] = i
if len(ep_list) > CONF_MAX_EP_NUMBER:
raise cv.Invalid(
f"Too many devices. Zigbee can define only {CONF_MAX_EP_NUMBER} endpoints."
)
return ep_list
+313
View File
@@ -0,0 +1,313 @@
#include "esphome/core/defines.h"
#ifdef USE_ESP32
#ifdef USE_ZIGBEE
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_check.h"
#include "nvs_flash.h"
#include "zigbee_attribute_esp32.h"
#include "zigbee_esp32.h"
#include "esphome/core/application.h"
#include "esphome/core/log.h"
#include "zigbee_helpers_esp32.h"
#ifdef USE_WIFI
#include "esp_coexist.h"
#endif
namespace esphome::zigbee {
static const char *const TAG = "zigbee";
static ZigbeeComponent *global_zigbee = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
uint8_t *get_zcl_string(const char *str, uint8_t max_size, bool use_max_size) {
uint8_t str_len = static_cast<uint8_t>(strlen(str));
uint8_t zcl_str_size = use_max_size ? max_size : std::min(max_size, str_len);
uint8_t *zcl_str = new uint8_t[zcl_str_size + 1]; // string + length octet
zcl_str[0] = zcl_str_size;
// Initialize payload to avoid leaking uninitialized heap contents and clamp copy length
memset(zcl_str + 1, 0, zcl_str_size);
uint8_t copy_len = std::min(zcl_str_size, str_len);
if (copy_len > 0) {
memcpy(zcl_str + 1, str, copy_len);
}
return zcl_str;
}
static void bdb_start_top_level_commissioning_cb(uint8_t mode_mask) {
if (esp_zb_bdb_start_top_level_commissioning(mode_mask) != ESP_OK) {
ESP_LOGE(TAG, "Start network steering failed!");
}
}
void esp_zb_app_signal_handler(esp_zb_app_signal_t *signal_struct) {
static uint8_t steering_retry_count = 0;
uint32_t *p_sg_p = signal_struct->p_app_signal;
esp_err_t err_status = signal_struct->esp_err_status;
esp_zb_app_signal_type_t sig_type = (esp_zb_app_signal_type_t) *p_sg_p;
esp_zb_zdo_signal_leave_params_t *leave_params = NULL;
switch (sig_type) {
case ESP_ZB_ZDO_SIGNAL_SKIP_STARTUP:
ESP_LOGD(TAG, "Zigbee stack initialized");
esp_zb_bdb_start_top_level_commissioning(ESP_ZB_BDB_MODE_INITIALIZATION);
break;
case ESP_ZB_BDB_SIGNAL_DEVICE_FIRST_START:
case ESP_ZB_BDB_SIGNAL_DEVICE_REBOOT:
if (err_status == ESP_OK) {
ESP_LOGD(TAG, "Device started up in %sfactory-reset mode", esp_zb_bdb_is_factory_new() ? "" : "non ");
global_zigbee->started = true;
if (esp_zb_bdb_is_factory_new()) {
ESP_LOGD(TAG, "Start network steering");
esp_zb_bdb_start_top_level_commissioning(ESP_ZB_BDB_MODE_NETWORK_STEERING);
} else {
ESP_LOGD(TAG, "Device rebooted");
global_zigbee->connected = true;
}
} else {
ESP_LOGE(TAG, "FIRST_START. Device started up in %sfactory-reset mode with an error %d (%s)",
esp_zb_bdb_is_factory_new() ? "" : "non ", err_status, esp_err_to_name(err_status));
ESP_LOGW(TAG, "Failed to initialize Zigbee stack (status: %s)", esp_err_to_name(err_status));
esp_zb_scheduler_alarm((esp_zb_callback_t) bdb_start_top_level_commissioning_cb, ESP_ZB_BDB_MODE_INITIALIZATION,
1000);
}
break;
case ESP_ZB_BDB_SIGNAL_STEERING:
if (err_status == ESP_OK) {
steering_retry_count = 0;
ESP_LOGI(TAG, "Joined network successfully (PAN ID: 0x%04hx, Channel:%d)", esp_zb_get_pan_id(),
esp_zb_get_current_channel());
global_zigbee->connected = true;
} else {
ESP_LOGI(TAG, "Network steering was not successful (status: %s)", esp_err_to_name(err_status));
if (steering_retry_count < 10) {
steering_retry_count++;
esp_zb_scheduler_alarm((esp_zb_callback_t) bdb_start_top_level_commissioning_cb,
ESP_ZB_BDB_MODE_NETWORK_STEERING, 1000);
} else {
esp_zb_scheduler_alarm((esp_zb_callback_t) bdb_start_top_level_commissioning_cb,
ESP_ZB_BDB_MODE_NETWORK_STEERING, 600 * 1000);
}
}
break;
case ESP_ZB_ZDO_SIGNAL_LEAVE:
leave_params = (esp_zb_zdo_signal_leave_params_t *) esp_zb_app_signal_get_params(p_sg_p);
if (leave_params->leave_type == ESP_ZB_NWK_LEAVE_TYPE_RESET) {
esp_zb_factory_reset();
}
break;
default:
ESP_LOGD(TAG, "ZDO signal: %s (0x%x), status: %s", esp_zb_zdo_signal_to_string(sig_type), sig_type,
esp_err_to_name(err_status));
break;
}
}
static esp_err_t zb_attribute_handler(const esp_zb_zcl_set_attr_value_message_t *message) {
esp_err_t ret = ESP_OK;
ESP_RETURN_ON_FALSE(message, ESP_FAIL, TAG, "Empty message");
ESP_RETURN_ON_FALSE(message->info.status == ESP_ZB_ZCL_STATUS_SUCCESS, ESP_ERR_INVALID_ARG, TAG,
"Received message: error status(%d)", message->info.status);
ESP_LOGD(TAG, "Received message: endpoint(%d), cluster(0x%x), attribute(0x%x), data size(%d)",
message->info.dst_endpoint, message->info.cluster, message->attribute.id, message->attribute.data.size);
return ret;
}
static esp_err_t zb_action_handler(esp_zb_core_action_callback_id_t callback_id, const void *message) {
esp_err_t ret = ESP_OK;
switch (callback_id) {
case ESP_ZB_CORE_SET_ATTR_VALUE_CB_ID:
ret = zb_attribute_handler((esp_zb_zcl_set_attr_value_message_t *) message);
break;
default:
ESP_LOGD(TAG, "Receive Zigbee action(0x%x) callback", callback_id);
break;
}
return ret;
}
void ZigbeeComponent::create_default_cluster(uint8_t endpoint_id, zb_ha_standard_devs_e device_id) {
esp_zb_cluster_list_t *cluster_list = esp_zb_zcl_cluster_list_create();
this->endpoint_list_[endpoint_id] =
std::tuple<zb_ha_standard_devs_e, esp_zb_cluster_list_t *>(device_id, cluster_list);
// Add basic cluster
this->add_cluster(endpoint_id, ESP_ZB_ZCL_CLUSTER_ID_BASIC, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE);
// Add identify cluster if not already present
if (esp_zb_cluster_list_get_cluster(cluster_list, ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE) ==
nullptr) {
this->add_cluster(endpoint_id, ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE);
}
}
void ZigbeeComponent::add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role) {
esp_zb_attribute_list_t *attr_list;
if (cluster_id == 0) {
attr_list = create_basic_cluster_();
} else {
attr_list = esphome_zb_default_attr_list_create(cluster_id);
}
this->attribute_list_[{endpoint_id, cluster_id, role}] = attr_list;
}
void ZigbeeComponent::set_basic_cluster(const char *model, const char *manufacturer) {
char date_buf[16];
time_t time_val = App.get_build_time();
struct tm *timeinfo = localtime(&time_val);
strftime(date_buf, sizeof(date_buf), "%Y%m%d %H%M%S", timeinfo);
this->basic_cluster_data_ = {
.model = get_zcl_string(model, 31),
.manufacturer = get_zcl_string(manufacturer, 31),
.date = get_zcl_string(date_buf, 15),
};
}
esp_zb_attribute_list_t *ZigbeeComponent::create_basic_cluster_() {
esp_zb_basic_cluster_cfg_t basic_cluster_cfg = {
.zcl_version = ESP_ZB_ZCL_BASIC_ZCL_VERSION_DEFAULT_VALUE,
.power_source = 0,
};
esp_zb_attribute_list_t *attr_list = esp_zb_basic_cluster_create(&basic_cluster_cfg);
esp_zb_basic_cluster_add_attr(attr_list, ESP_ZB_ZCL_ATTR_BASIC_MANUFACTURER_NAME_ID,
this->basic_cluster_data_.manufacturer);
esp_zb_basic_cluster_add_attr(attr_list, ESP_ZB_ZCL_ATTR_BASIC_MODEL_IDENTIFIER_ID, this->basic_cluster_data_.model);
esp_zb_basic_cluster_add_attr(attr_list, ESP_ZB_ZCL_ATTR_BASIC_DATE_CODE_ID, this->basic_cluster_data_.date);
return attr_list;
}
esp_err_t ZigbeeComponent::create_endpoint(uint8_t endpoint_id, zb_ha_standard_devs_e device_id,
esp_zb_cluster_list_t *esp_zb_cluster_list) {
esp_zb_endpoint_config_t endpoint_config = {.endpoint = endpoint_id,
.app_profile_id = ESP_ZB_AF_HA_PROFILE_ID,
.app_device_id = device_id,
.app_device_version = 0};
return esp_zb_ep_list_add_ep(this->esp_zb_ep_list_, esp_zb_cluster_list, endpoint_config);
}
static void esp_zb_task_(void *pvParameters) {
if (esp_zb_start(false) != ESP_OK) {
ESP_LOGE(TAG, "Could not setup Zigbee");
vTaskDelete(NULL);
}
esp_zb_set_node_descriptor_power_source(1);
esp_zb_stack_main_loop();
}
void ZigbeeComponent::setup() {
global_zigbee = this;
esp_zb_platform_config_t config = {
.radio_config = ESP_ZB_DEFAULT_RADIO_CONFIG(),
.host_config = ESP_ZB_DEFAULT_HOST_CONFIG(),
};
#ifdef USE_WIFI
if (esp_coex_wifi_i154_enable() != ESP_OK) {
this->mark_failed();
return;
}
#endif
if (esp_zb_platform_config(&config) != ESP_OK) {
this->mark_failed();
return;
}
esp_zb_zed_cfg_t zb_zed_cfg = {
.ed_timeout = ESP_ZB_ED_AGING_TIMEOUT_64MIN,
.keep_alive = ED_KEEP_ALIVE,
};
esp_zb_zczr_cfg_t zb_zczr_cfg = {
.max_children = MAX_CHILDREN,
};
esp_zb_cfg_t zb_nwk_cfg = {
.esp_zb_role = this->device_role_,
.install_code_policy = false,
};
#ifdef ZB_ROUTER_ROLE
zb_nwk_cfg.nwk_cfg.zczr_cfg = zb_zczr_cfg;
#else
zb_nwk_cfg.nwk_cfg.zed_cfg = zb_zed_cfg;
#endif
esp_zb_init(&zb_nwk_cfg);
esp_err_t ret;
for (auto const &[key, val] : this->attribute_list_) {
esp_zb_cluster_list_t *esp_zb_cluster_list = std::get<1>(this->endpoint_list_[std::get<0>(key)]);
ret = esphome_zb_cluster_list_add_or_update_cluster(std::get<1>(key), esp_zb_cluster_list, val, std::get<2>(key));
if (ret != ESP_OK) {
ESP_LOGE(TAG, "Could not create cluster 0x%04X with role %u: %s", std::get<1>(key), std::get<2>(key),
esp_err_to_name(ret));
} else {
ESP_LOGD(TAG, "Endpoint %u: Added cluster 0x%04X with role %u", std::get<0>(key), std::get<1>(key),
std::get<2>(key));
#ifdef ESPHOME_LOG_HAS_VERBOSE
// Dump cluster attributes in verbose log
ESP_LOGV(TAG, "Cluster 0x%04X attributes:", std::get<1>(key));
esp_zb_attribute_list_t *attr_list = val;
while (attr_list) {
esp_zb_zcl_attr_t *attr = &attr_list->attribute;
ESP_LOGV(TAG, " Attr ID: 0x%04X, Type: 0x%02X, Access: 0x%02X", attr->id, attr->type, attr->access);
attr_list = attr_list->next;
}
#endif
}
}
this->attribute_list_.clear();
for (auto const &[ep_id, dev_id] : this->endpoint_list_) {
if (create_endpoint(ep_id, std::get<0>(dev_id), std::get<1>(dev_id)) != ESP_OK) {
ESP_LOGE(TAG, "Could not create endpoint %u", ep_id);
}
}
this->endpoint_list_.clear();
if (esp_zb_device_register(this->esp_zb_ep_list_) != ESP_OK) {
ESP_LOGE(TAG, "Could not register the endpoint list");
this->mark_failed();
return;
}
esp_zb_core_action_handler_register(zb_action_handler);
if (esp_zb_set_primary_network_channel_set(ESP_ZB_TRANSCEIVER_ALL_CHANNELS_MASK) != ESP_OK) {
ESP_LOGE(TAG, "Could not setup Zigbee");
this->mark_failed();
return;
}
for (auto &[_, attribute] : this->attributes_) {
if (attribute->report_enabled) {
esp_zb_zcl_reporting_info_t reporting_info = attribute->get_reporting_info();
ESP_LOGD(TAG, "set reporting for cluster: %u", reporting_info.cluster_id);
if (esp_zb_zcl_update_reporting_info(&reporting_info) != ESP_OK) {
ESP_LOGE(TAG, "Could not configure reporting for attribute 0x%04X in cluster 0x%04X in endpoint %u",
reporting_info.attr_id, reporting_info.cluster_id, reporting_info.ep);
}
}
}
xTaskCreate(esp_zb_task_, "Zigbee_main", 4096, NULL, 24, NULL);
}
void ZigbeeComponent::dump_config() {
if (esp_zb_lock_acquire(10 / portTICK_PERIOD_MS)) {
ESP_LOGCONFIG(TAG,
"Zigbee\n"
" Model: %s\n"
" Router: %s\n"
" Device is joined to the network: %s\n"
" Current channel: %d\n"
" Short addr: 0x%04X\n"
" Short pan id: 0x%04X",
this->basic_cluster_data_.model, YESNO(this->device_role_ == ESP_ZB_DEVICE_TYPE_ROUTER),
YESNO(esp_zb_bdb_dev_joined()), esp_zb_get_current_channel(), esp_zb_get_short_address(),
esp_zb_get_pan_id());
esp_zb_lock_release();
} else {
ESP_LOGCONFIG(TAG,
"Zigbee\n"
" Model: %s\n"
" Router: %s\n",
this->basic_cluster_data_.model, YESNO(this->device_role_ == ESP_ZB_DEVICE_TYPE_ROUTER));
}
}
} // namespace esphome::zigbee
#endif
#endif
+134
View File
@@ -0,0 +1,134 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_ESP32
#ifdef USE_ZIGBEE
#include <map>
#include <tuple>
#include <atomic>
#include "esp_zigbee_core.h"
#include "zboss_api.h"
#include "ha/esp_zigbee_ha_standard.h"
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#include "zigbee_helpers_esp32.h"
#ifdef USE_BINARY_SENSOR
#include "esphome/components/binary_sensor/binary_sensor.h"
#endif
namespace esphome::zigbee {
/* Zigbee configuration */
static const uint16_t ED_KEEP_ALIVE = 3000; /* 3000 millisecond */
static const uint8_t MAX_CHILDREN = 10;
#define ESP_ZB_DEFAULT_RADIO_CONFIG() \
{ .radio_mode = ZB_RADIO_MODE_NATIVE, }
#define ESP_ZB_DEFAULT_HOST_CONFIG() \
{ .host_connection_mode = ZB_HOST_CONNECTION_MODE_NONE, }
uint8_t *get_zcl_string(const char *str, uint8_t max_size, bool use_max_size = false);
class ZigbeeAttribute;
class ZigbeeComponent : public Component {
public:
void setup() override;
void dump_config() override;
esp_err_t create_endpoint(uint8_t endpoint_id, zb_ha_standard_devs_e device_id,
esp_zb_cluster_list_t *esp_zb_cluster_list);
void set_basic_cluster(const char *model, const char *manufacturer);
void add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role);
void create_default_cluster(uint8_t endpoint_id, zb_ha_standard_devs_e device_id);
template<typename T>
void add_attr(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id,
uint8_t max_size, T value);
template<typename T>
void add_attr(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, uint8_t max_size, T value);
void factory_reset() {
esp_zb_lock_acquire(portMAX_DELAY);
esp_zb_factory_reset(); // triggers a reboot
esp_zb_lock_release();
}
bool is_started() { return this->started; }
bool is_connected() { return this->connected; }
std::atomic<bool> connected = false;
std::atomic<bool> started = false;
protected:
struct {
uint8_t *model;
uint8_t *manufacturer;
uint8_t *date;
} basic_cluster_data_;
#ifdef ZB_ED_ROLE
esp_zb_nwk_device_type_t device_role_ = ESP_ZB_DEVICE_TYPE_ED;
#else
esp_zb_nwk_device_type_t device_role_ = ESP_ZB_DEVICE_TYPE_ROUTER;
#endif
esp_zb_attribute_list_t *create_basic_cluster_();
template<typename T>
void add_attr_(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id,
T *value_p);
// endpoint_list_ and attribute_list_ are only used during setup and are cleared afterwards
// value tuple could be replaced by struct
std::map<uint8_t, std::tuple<zb_ha_standard_devs_e, esp_zb_cluster_list_t *>> endpoint_list_;
// key tuple could be replaced by single 32 bit int with bit fields for endpoint, cluster and role
std::map<std::tuple<uint8_t, uint16_t, uint8_t>, esp_zb_attribute_list_t *> attribute_list_;
// attributes_ will be used during operation in zigbee callbacks to update the attribute values and trigger
// automations
// key tuple could be replaced by single 64 (48) bit int with bit fields for endpoint, cluster, role and attr_id
std::map<std::tuple<uint8_t, uint16_t, uint8_t, uint16_t>, ZigbeeAttribute *> attributes_;
esp_zb_ep_list_t *esp_zb_ep_list_ = esp_zb_ep_list_create();
};
extern "C" void esp_zb_app_signal_handler(esp_zb_app_signal_t *signal_struct);
template<typename T>
void ZigbeeComponent::add_attr(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id,
uint8_t max_size, T value) {
this->add_attr<T>(nullptr, endpoint_id, cluster_id, role, attr_id, max_size, value);
}
template<typename T>
void ZigbeeComponent::add_attr(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role,
uint16_t attr_id, uint8_t max_size, T value) {
// The size byte of the zcl_str must be set to the maximum value,
// even though the initial string may be shorter.
if constexpr (std::is_same<T, std::string>::value) {
auto zcl_str = get_zcl_string(value.c_str(), max_size, true);
add_attr_(attr, endpoint_id, cluster_id, role, attr_id, zcl_str);
delete[] zcl_str;
} else if constexpr (std::is_convertible<T, const char *>::value) {
auto zcl_str = get_zcl_string(value, max_size, true);
add_attr_(attr, endpoint_id, cluster_id, role, attr_id, zcl_str);
delete[] zcl_str;
} else {
add_attr_(attr, endpoint_id, cluster_id, role, attr_id, &value);
}
}
template<typename T>
void ZigbeeComponent::add_attr_(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role,
uint16_t attr_id, T *value_p) {
esp_zb_attribute_list_t *attr_list = this->attribute_list_[{endpoint_id, cluster_id, role}];
esp_err_t ret = esphome_zb_cluster_add_or_update_attr(cluster_id, attr_list, attr_id, value_p);
if (attr != nullptr) {
this->attributes_[{endpoint_id, cluster_id, role, attr_id}] = attr;
}
}
} // namespace esphome::zigbee
#endif
#endif
+274
View File
@@ -0,0 +1,274 @@
import copy
import logging
import re
from typing import Any
import esphome.codegen as cg
from esphome.components.esp32 import (
CONF_PARTITIONS,
add_idf_component,
add_idf_sdkconfig_option,
add_partition,
require_vfs_select,
)
import esphome.config_validation as cv
from esphome.const import (
CONF_AP,
CONF_DEVICE,
CONF_ID,
CONF_MAX_LENGTH,
CONF_MODEL,
CONF_NAME,
CONF_TYPE,
CONF_VALUE,
CONF_WIFI,
)
from esphome.core import CORE
from esphome.coroutine import CoroPriority, coroutine_with_priority
import esphome.final_validate as fv
from esphome.types import ConfigType
from .const import CONF_REPORT, CONF_ROUTER, KEY_ZIGBEE, REPORT, ZigbeeAttribute
from .const_esp32 import (
ATTR_TYPE,
CLUSTER_ID,
CONF_ATTRIBUTE_ID,
CONF_ATTRIBUTES,
CONF_CLUSTERS,
CONF_NUM,
DEVICE_ID,
DEVICE_TYPE,
KEY_BS_EP,
ROLE,
SCALE,
)
from .zigbee_ep_esp32 import create_ep, ep_configs
_LOGGER = logging.getLogger(__name__)
def get_c_size(bits: str, options: list[int]) -> str:
return str([n for n in options if n >= int(bits)][0])
def get_c_type(attr_type: str) -> Any | None:
if attr_type == "BOOL":
return cg.bool_
if "STRING" in attr_type:
return cg.std_string
test = re.match(r"(^U?)(\d{1,2})(BITMAP$|BIT$|BIT_ENUM$|$)", attr_type)
if test and test.group(2):
return getattr(cg, "uint" + get_c_size(test.group(2), [8, 16, 32, 64]))
return None
def get_cv_by_type(attr_type: str) -> Any | None:
if attr_type == "BOOL":
return cv.boolean
if "STRING" in attr_type:
return cv.string
test = re.match(r"(^U?)(\d{1,2})(BITMAP$|BIT$|BIT_ENUM$|$)", attr_type)
if test and test.group(2):
return cv.positive_int
return None
def get_default_by_type(attr_type: str) -> str | bool | int:
if attr_type == "CHAR_STRING":
return ""
if attr_type == "BOOL":
return False
return 0
def validate_attributes(config: ConfigType) -> ConfigType:
if CONF_VALUE not in config:
config[CONF_VALUE] = get_default_by_type(config[CONF_TYPE])
config[CONF_VALUE] = get_cv_by_type(config[CONF_TYPE])(config[CONF_VALUE])
return config
def final_validate_esp32(config: ConfigType) -> ConfigType:
if not CORE.is_esp32:
return config
if CONF_WIFI in fv.full_config.get():
if config[CONF_ROUTER] and CONF_AP in fv.full_config.get()[CONF_WIFI]:
raise cv.Invalid(
"Only Zigbee End Device can be used together with a Wifi Access Point."
)
if CONF_AP in fv.full_config.get()[CONF_WIFI]:
_LOGGER.warning(
"Wifi Access Point might be unstable while Zigbee is active, use only as fallback."
)
elif config[CONF_ROUTER]:
_LOGGER.warning(
"The Zigbee Router might miss packets while Wifi is active and could destabilize "
"your network. Use only if Wifi is off most of the time."
)
if CONF_PARTITIONS in fv.full_config.get() and not isinstance(
fv.full_config.get()[CONF_PARTITIONS], list
):
with open(
CORE.relative_config_path(fv.full_config.get()[CONF_PARTITIONS]),
encoding="utf8",
) as f:
partitions_tab = f.read()
for partition, types in [
("zb_storage", {"type": "data", "subtype": "fat", "size": 0x4000}),
("zb_fct", {"type": "data", "subtype": "fat", "size": 0x1000}),
]:
if partition not in partitions_tab:
raise cv.Invalid(
f"Add '{partition}, {types['type']}, {types['subtype']}, , {types['size']},' to your custom partition table."
)
if not re.search(
rf"^{partition},\s*{types['type']},\s*{types['subtype']}",
partitions_tab,
re.MULTILINE,
):
raise cv.Invalid(
f"Partition '{partition}' in your custom partition table has wrong format. It should be: '{partition}, {types['type']}, {types['subtype']}, , {types['size']},'"
)
return config
def validate_binary_sensor_esp32(config: ConfigType) -> ConfigType:
ep = copy.deepcopy(ep_configs["binary_input"])
for cl in ep.get(CONF_CLUSTERS, []):
for attr in cl[CONF_ATTRIBUTES]:
if (
attr[CONF_ATTRIBUTE_ID] == 0x1C
and CONF_VALUE not in attr
and CONF_NAME in config
): # set name
name = (
config[CONF_NAME].encode("ascii", "ignore").decode()
) # or use unidecode
attr[CONF_VALUE] = str(name)
attr[CONF_MAX_LENGTH] = len(str(name))
if CONF_DEVICE in attr: # connect device
attr[CONF_DEVICE] = config[CONF_ID]
if CONF_REPORT in config:
attr[CONF_REPORT] = config[CONF_REPORT]
attr[CONF_ID] = cv.declare_id(ZigbeeAttribute)(None)
if "zb_attr_ids" not in config:
config["zb_attr_ids"] = []
config["zb_attr_ids"].append(attr[CONF_ID])
else:
attr[CONF_ID] = None
validate_attributes(attr)
zb_data = CORE.data.setdefault(KEY_ZIGBEE, {})
binary_sensor_ep: list[dict] = zb_data.setdefault(KEY_BS_EP, [])
binary_sensor_ep.append(ep)
return config
def zigbee_require_vfs_select(config: ConfigType) -> ConfigType:
"""Register VFS select requirement during config validation."""
# Zigbee uses esp_vfs_eventfd which requires VFS select support
if CORE.is_esp32:
require_vfs_select()
return config
@coroutine_with_priority(CoroPriority.WORKAROUNDS)
async def _zigbee_add_sdkconfigs(config: ConfigType) -> None:
"""Add sdkconfigs late so they can overwrite esp32 defaults"""
add_idf_sdkconfig_option("CONFIG_ZB_ENABLED", True)
if config.get(CONF_ROUTER):
add_idf_sdkconfig_option("CONFIG_ZB_ZCZR", True)
else:
add_idf_sdkconfig_option("CONFIG_ZB_ZED", True)
add_idf_sdkconfig_option("CONFIG_ZB_RADIO_NATIVE", True)
if CONF_WIFI in CORE.config:
add_idf_sdkconfig_option("CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE", 4096)
# The pre-built Zigbee library uses esp_log_default_level which requires
# dynamic log level control to be enabled
add_idf_sdkconfig_option("CONFIG_LOG_DYNAMIC_LEVEL_CONTROL", True)
async def attributes_to_code(
var: cg.Pvariable, ep_num: int, cl: dict[str, Any]
) -> None:
for attr in cl.get(CONF_ATTRIBUTES, []):
if attr.get(CONF_ID) is None:
cg.add(
var.add_attr(
ep_num,
CLUSTER_ID.get(cl[CONF_ID], cl[CONF_ID]),
cl[ROLE],
attr[CONF_ATTRIBUTE_ID],
attr.get(CONF_MAX_LENGTH, 0),
attr[CONF_VALUE],
)
)
continue
attr_var = cg.new_Pvariable(
attr[CONF_ID],
var,
ep_num,
CLUSTER_ID.get(cl[CONF_ID], cl[CONF_ID]),
cl[ROLE],
attr[CONF_ATTRIBUTE_ID],
ATTR_TYPE[attr[CONF_TYPE]],
attr.get(SCALE, 1),
attr.get(CONF_MAX_LENGTH, 0),
)
await cg.register_component(attr_var, attr)
cg.add(attr_var.add_attr(attr[CONF_VALUE]))
if CONF_REPORT in attr and attr[CONF_REPORT] in [
REPORT["enable"],
REPORT["force"],
]:
cg.add(attr_var.set_report(attr[CONF_REPORT] == REPORT["force"]))
if CONF_DEVICE in attr:
device = await cg.get_variable(attr[CONF_DEVICE])
template_arg = cg.TemplateArguments(get_c_type(attr[CONF_TYPE]))
cg.add(attr_var.connect(template_arg, device))
async def esp32_to_code(config: ConfigType) -> None:
add_idf_component(
name="espressif/esp-zboss-lib",
ref="1.6.4",
)
add_idf_component(
name="espressif/esp-zigbee-lib",
ref="1.6.8",
)
# add sdkconfigs later so they can overwrite esp32 defaults
CORE.add_job(_zigbee_add_sdkconfigs, config)
# add partitions for zigbee
add_partition("zb_storage", "data", "fat", 0x4000) # 16KB
add_partition("zb_fct", "data", "fat", 0x1000) # 4KB, minimum size
# create endpoints
zb_data = CORE.data.get(KEY_ZIGBEE, {})
binary_sensor_ep: list[dict] = zb_data.get(KEY_BS_EP, [])
ep_list = create_ep(binary_sensor_ep, config.get(CONF_ROUTER))
# setup zigbee components
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
cg.add(
var.set_basic_cluster(
config[CONF_MODEL],
"esphome",
)
)
for ep in ep_list:
cg.add(var.create_default_cluster(ep[CONF_NUM], DEVICE_ID[ep[DEVICE_TYPE]]))
for cl in ep.get(CONF_CLUSTERS, []):
cg.add(
var.add_cluster(
ep[CONF_NUM],
CLUSTER_ID.get(cl[CONF_ID], cl[CONF_ID]),
cl[ROLE],
)
)
await attributes_to_code(var, ep[CONF_NUM], cl)
@@ -0,0 +1,74 @@
#include "esphome/core/defines.h"
#ifdef USE_ESP32
#ifdef USE_ZIGBEE
#include "ha/esp_zigbee_ha_standard.h"
#include "zigbee_helpers_esp32.h"
esp_err_t esphome_zb_cluster_add_or_update_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list,
uint16_t attr_id, void *value_p) {
esp_err_t ret;
ret = esp_zb_cluster_update_attr(attr_list, attr_id, value_p);
if (ret != ESP_OK) {
ESP_LOGE("zigbee_helper", "Ignore previous attribute not found error");
ret = esphome_zb_cluster_add_attr(cluster_id, attr_list, attr_id, value_p);
}
if (ret != ESP_OK) {
ESP_LOGE("zigbee_helper", "Could not add attribute 0x%04X to cluster 0x%04X: %s", attr_id, cluster_id,
esp_err_to_name(ret));
}
return ret;
}
esp_err_t esphome_zb_cluster_list_add_or_update_cluster(uint16_t cluster_id, esp_zb_cluster_list_t *cluster_list,
esp_zb_attribute_list_t *attr_list, uint8_t role_mask) {
esp_err_t ret;
ret = esp_zb_cluster_list_update_cluster(cluster_list, attr_list, cluster_id, role_mask);
if (ret != ESP_OK) {
ESP_LOGE("zigbee_helper", "Ignore previous cluster not found error");
switch (cluster_id) {
case ESP_ZB_ZCL_CLUSTER_ID_BASIC:
ret = esp_zb_cluster_list_add_basic_cluster(cluster_list, attr_list, role_mask);
break;
case ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY:
ret = esp_zb_cluster_list_add_identify_cluster(cluster_list, attr_list, role_mask);
break;
case ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT:
ret = esp_zb_cluster_list_add_binary_input_cluster(cluster_list, attr_list, role_mask);
break;
default:
ret = esp_zb_cluster_list_add_custom_cluster(cluster_list, attr_list, role_mask);
}
}
return ret;
}
esp_zb_attribute_list_t *esphome_zb_default_attr_list_create(uint16_t cluster_id) {
switch (cluster_id) {
case ESP_ZB_ZCL_CLUSTER_ID_BASIC:
return esp_zb_basic_cluster_create(NULL);
case ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY:
return esp_zb_identify_cluster_create(NULL);
case ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT:
return esp_zb_binary_input_cluster_create(NULL);
default:
return esp_zb_zcl_attr_list_create(cluster_id);
}
}
esp_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list, uint16_t attr_id,
void *value_p) {
switch (cluster_id) {
case ESP_ZB_ZCL_CLUSTER_ID_BASIC:
return esp_zb_basic_cluster_add_attr(attr_list, attr_id, value_p);
case ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY:
return esp_zb_identify_cluster_add_attr(attr_list, attr_id, value_p);
case ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT:
return esp_zb_binary_input_cluster_add_attr(attr_list, attr_id, value_p);
default:
return ESP_FAIL;
}
}
#endif
#endif
@@ -0,0 +1,27 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_ESP32
#ifdef USE_ZIGBEE
#ifdef __cplusplus
extern "C" {
#endif
#include "esp_zigbee_core.h"
esp_err_t esphome_zb_cluster_list_add_or_update_cluster(uint16_t cluster_id, esp_zb_cluster_list_t *cluster_list,
esp_zb_attribute_list_t *attr_list, uint8_t role_mask);
esp_zb_attribute_list_t *esphome_zb_default_attr_list_create(uint16_t cluster_id);
esp_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list, uint16_t attr_id,
void *value_p);
esp_err_t esphome_zb_cluster_add_or_update_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list,
uint16_t attr_id, void *value_p);
#ifdef __cplusplus
}
namespace esphome::zigbee {} // namespace esphome::zigbee
#endif
#endif
#endif
+22 -3
View File
@@ -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
+23 -12
View File
@@ -1,4 +1,4 @@
from datetime import datetime
import datetime
import random
from esphome import automation
@@ -7,6 +7,7 @@ from esphome.components.zephyr import zephyr_add_prj_conf
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
CONF_MODEL,
CONF_NAME,
CONF_UNIT_OF_MEASUREMENT,
UNIT_AMPERE,
@@ -48,19 +49,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,
+1 -8
View File
@@ -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
View File
@@ -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
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+15 -8
View File
@@ -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
+10
View File
@@ -37,6 +37,14 @@ dependencies:
version: "2.0.0"
rules:
- if: "target in [esp32, esp32p4]"
espressif/esp-zboss-lib:
version: 1.6.4
rules:
- if: "target in [esp32h2, esp32c5, esp32c6]"
espressif/esp-zigbee-lib:
version: 1.6.8
rules:
- if: "target in [esp32h2, esp32c5, esp32c6]"
espressif/lan87xx:
version: "1.0.0"
rules:
@@ -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
View File
@@ -2,66 +2,52 @@
from __future__ import annotations
import asyncio
import threading
from aioesphomeapi.core import ResolveAPIError, ResolveTimeoutAPIError
import aioesphomeapi.host_resolver as hr
from esphome.async_thread import AsyncThreadRunner
from esphome.core import EsphomeError
RESOLVE_TIMEOUT = 10.0 # seconds
class AsyncResolver(threading.Thread):
class AsyncResolver:
"""Resolver using aioesphomeapi that runs in a thread for faster results.
This resolver uses aioesphomeapi's async_resolve_host to handle DNS resolution,
including proper .local domain fallback. Running in a thread allows us to get
the result immediately without waiting for asyncio.run() to complete its
cleanup cycle, which can take significant time.
This resolver uses aioesphomeapi's async_resolve_host to handle DNS
resolution, including proper .local domain fallback. Running in a thread
(via :class:`AsyncThreadRunner`) allows us to get the result immediately
without waiting for ``asyncio.run()`` to complete its cleanup cycle, which
can take significant time.
"""
def __init__(self, hosts: list[str], port: int) -> None:
"""Initialize the resolver."""
super().__init__(daemon=True)
self.hosts = hosts
self.port = port
self.result: list[hr.AddrInfo] | None = None
self.exception: Exception | None = None
self.event = threading.Event()
async def _resolve(self) -> None:
async def _resolve(self) -> list[hr.AddrInfo]:
"""Resolve hostnames to IP addresses."""
try:
self.result = await hr.async_resolve_host(
self.hosts, self.port, timeout=RESOLVE_TIMEOUT
)
except Exception as e: # pylint: disable=broad-except
# We need to catch all exceptions to ensure the event is set
# Otherwise the thread could hang forever
self.exception = e
finally:
self.event.set()
def run(self) -> None:
"""Run the DNS resolution."""
asyncio.run(self._resolve())
return await hr.async_resolve_host(
self.hosts, self.port, timeout=RESOLVE_TIMEOUT
)
def resolve(self) -> list[hr.AddrInfo]:
"""Start the thread and wait for the result."""
self.start()
runner: AsyncThreadRunner[list[hr.AddrInfo]] = AsyncThreadRunner(self._resolve)
runner.start()
if not self.event.wait(
if not runner.event.wait(
timeout=RESOLVE_TIMEOUT + 1.0
): # Give it 1 second more than the resolver timeout
raise EsphomeError("Timeout resolving IP address")
if exc := self.exception:
if exc := runner.exception:
if isinstance(exc, ResolveTimeoutAPIError):
raise EsphomeError(f"Timeout resolving IP address: {exc}") from exc
if isinstance(exc, ResolveAPIError):
raise EsphomeError(f"Error resolving IP address: {exc}") from exc
raise exc
return self.result
assert runner.result is not None # guaranteed when event set and no exception
return runner.result
+1 -1
View File
@@ -45,7 +45,7 @@ void setup() {
App.setup();
}
void loop() {
void __attribute__((optimize("O2"))) loop() {
App.loop();
}
""",
+174 -7
View File
@@ -14,8 +14,13 @@ from zeroconf import (
)
from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf
from esphome.async_thread import AsyncThreadRunner
from esphome.storage_json import StorageJSON, ext_storage_path
# Length of the MAC suffix appended when name_add_mac_suffix is enabled.
MAC_SUFFIX_LEN = 6
_HEX_CHARS = frozenset("0123456789abcdef")
_LOGGER = logging.getLogger(__name__)
DEFAULT_TIMEOUT = 10.0
@@ -188,15 +193,177 @@ class EsphomeZeroconf(Zeroconf):
return None
async def async_resolve_hosts(
zeroconf: Zeroconf, hosts: list[str], timeout: float = DEFAULT_TIMEOUT
) -> dict[str, list[str]]:
"""Resolve ``hosts`` to IPs using a shared ``Zeroconf`` instance.
Tries the cache synchronously first (so hosts already primed by a recent
browse return immediately with no network round-trip), then issues
``async_request`` for the remaining misses in parallel via
``asyncio.gather``. Returns a dict mapping each host to its list of
addresses (empty list when unresolved). Only ``<short>.local`` form is
queried, matching the name scheme the resolvers below expect.
"""
resolvers: dict[str, AddressResolver] = {}
pending: list[str] = []
for host in hosts:
resolver = AddressResolver(f"{host.partition('.')[0]}.local.")
resolvers[host] = resolver
if not resolver.load_from_cache(zeroconf):
pending.append(host)
if pending and timeout:
results = await asyncio.gather(
*(
resolvers[host].async_request(zeroconf, timeout * 1000)
for host in pending
),
return_exceptions=True,
)
for host, result in zip(pending, results):
if isinstance(result, BaseException):
_LOGGER.debug("Failed to resolve %s: %s", host, result)
return {
host: resolver.parsed_scoped_addresses(IPVersion.All)
for host, resolver in resolvers.items()
}
class AsyncEsphomeZeroconf(AsyncZeroconf):
async def async_resolve_host(
self, host: str, timeout: float = DEFAULT_TIMEOUT
) -> list[str] | None:
"""Resolve a host name to an IP address."""
info = AddressResolver(f"{host.partition('.')[0]}.local.")
if (
info.load_from_cache(self.zeroconf)
or (timeout and await info.async_request(self.zeroconf, timeout * 1000))
) and (addresses := info.parsed_scoped_addresses(IPVersion.All)):
return addresses
return None
addresses = (await async_resolve_hosts(self.zeroconf, [host], timeout))[host]
return addresses or None
def _is_mac_suffix_match(device_name: str, prefix: str) -> bool:
"""Return True if ``device_name`` is ``prefix`` followed by a 6-char hex MAC."""
if not device_name.startswith(prefix):
return False
suffix = device_name[len(prefix) :]
return len(suffix) == MAC_SUFFIX_LEN and all(c in _HEX_CHARS for c in suffix)
async def async_discover_mdns_devices(
base_name: str, timeout: float = 5.0
) -> dict[str, list[str]]:
"""Discover ESPHome devices via mDNS that match the base name + MAC suffix.
When ``name_add_mac_suffix`` is enabled, devices advertise as
``<base_name>-<6-hex-mac>.local``. This function uses a single
``AsyncEsphomeZeroconf`` lifecycle to both browse for matching services and
resolve their IP addresses, so callers get resolved addresses without
opening a second Zeroconf client.
Args:
base_name: The base device name (without MAC suffix).
timeout: How long to wait for mDNS responses (default 5 seconds).
Returns:
Mapping of ``<device>.local`` hostnames to their resolved IP addresses
(may be empty for a device if resolution failed within the timeout).
"""
prefix = f"{base_name}-"
# Preserves insertion order for stable output and deduplicates
discovered: dict[str, list[str]] = {}
def on_service_state_change(
zeroconf: Zeroconf,
service_type: str,
name: str,
state_change: ServiceStateChange,
) -> None:
if state_change not in (ServiceStateChange.Added, ServiceStateChange.Updated):
return
device_name = name.partition(".")[0]
if not _is_mac_suffix_match(device_name, prefix):
_LOGGER.debug(
"Ignoring %s (%s): does not match '%s<6-hex>'",
device_name,
state_change.name,
prefix,
)
return
host = f"{device_name}.local"
if host in discovered:
return
discovered[host] = []
_LOGGER.debug("Discovered %s (%s)", host, state_change.name)
_LOGGER.debug(
"Starting mDNS discovery for '%s<mac>.local' (timeout=%.1fs)",
prefix,
timeout,
)
try:
aiozc = AsyncEsphomeZeroconf()
except Exception as err: # pylint: disable=broad-except
# Zeroconf init can raise OSError, NonUniqueNameException, etc.
# Any failure here just means we can't discover — log and move on.
_LOGGER.warning("mDNS discovery failed to initialize: %s", err)
return {}
try:
browser = AsyncServiceBrowser(
aiozc.zeroconf,
ESPHOME_SERVICE_TYPE,
handlers=[on_service_state_change],
)
try:
await asyncio.sleep(timeout)
finally:
await browser.async_cancel()
_LOGGER.debug(
"Browse finished: %d device(s) matched '%s<mac>'",
len(discovered),
prefix,
)
# Resolve each discovered hostname on the SAME Zeroconf instance so
# we don't spin up a second client. ``async_resolve_hosts`` tries the
# cache synchronously (the browse usually primes it) before issuing
# any ``async_request`` in parallel for misses.
resolved = await async_resolve_hosts(aiozc.zeroconf, list(discovered))
for host, addresses in resolved.items():
if addresses:
discovered[host] = addresses
_LOGGER.debug("Resolved %s -> %s", host, addresses)
else:
_LOGGER.debug("No addresses returned for %s", host)
finally:
await aiozc.async_close()
return dict(sorted(discovered.items()))
def _await_discovery(
runner: AsyncThreadRunner[dict[str, list[str]]], timeout: float
) -> dict[str, list[str]]:
"""Wait for ``runner`` to finish and return its discovery result.
Split out of :func:`discover_mdns_devices` so the timeout branch is
testable without patching ``asyncio`` or ``threading`` internals a test
passes a stub whose ``event.wait`` returns ``False``.
"""
# Give the discovery an extra second over the browse timeout for the
# resolution + cleanup pass.
if not runner.event.wait(timeout=timeout + 2.0):
_LOGGER.warning("mDNS discovery timed out after %.1fs", timeout)
return {}
if runner.exception is not None:
_LOGGER.warning("mDNS discovery failed: %s", runner.exception)
return {}
return runner.result or {}
def discover_mdns_devices(base_name: str, timeout: float = 5.0) -> dict[str, list[str]]:
"""Synchronous wrapper around :func:`async_discover_mdns_devices`."""
runner = AsyncThreadRunner(
lambda: async_discover_mdns_devices(base_name, timeout=timeout)
)
runner.start()
return _await_discovery(runner, timeout)
+5
View File
@@ -20,3 +20,8 @@ CONFIG_BT_ENABLED=y
# esp32_camera
CONFIG_RTCIO_SUPPORT_RTC_GPIO_DESC=y
CONFIG_ESP32_SPIRAM_SUPPORT=y
# zigbee
CONFIG_ZB_ENABLED=y
CONFIG_ZB_ZED=y
CONFIG_ZB_RADIO_NATIVE=y
+7
View File
@@ -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
+6
View File
@@ -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
+9
View File
@@ -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
-10
View File
@@ -1,4 +1,3 @@
---
binary_sensor:
- platform: template
name: "Garage Door Open 1"
@@ -22,12 +21,6 @@ sensor:
lambda: return 12.0;
internal: True
zigbee:
wipe_on_boot: true
on_join:
then:
- logger.log: "Joined network"
output:
- platform: template
id: output_factory
@@ -35,9 +28,6 @@ output:
write_action:
- zigbee.factory_reset
time:
- platform: zigbee
switch:
- platform: template
name: "Template Switch"

Some files were not shown because too many files have changed in this diff Show More