mirror of
https://github.com/esphome/esphome.git
synced 2026-06-25 07:10:33 +00:00
Compare commits
92 Commits
config-ver
...
app-loop-o
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
566476b05f | ||
|
|
94e300389c | ||
|
|
55bcf33446 | ||
|
|
f132b7dc07 | ||
|
|
baa6d5f96b | ||
|
|
773b4d887b | ||
|
|
ac7f0f0b74 | ||
|
|
bc7f35b569 | ||
|
|
ae02ab3865 | ||
|
|
eceb534895 | ||
|
|
404620b99c | ||
|
|
3ccaa771a7 | ||
|
|
b4a86e46b2 | ||
|
|
ddf1426f86 | ||
|
|
90d7bfe02e | ||
|
|
d759f1a567 | ||
|
|
f757cd1210 | ||
|
|
9b45b046a8 | ||
|
|
70ae614abd | ||
|
|
8f9b91eece | ||
|
|
3ca86fc3fc | ||
|
|
b38db617a2 | ||
|
|
13fe881f70 | ||
|
|
50c181671c | ||
|
|
43a371caab | ||
|
|
64290d32a1 | ||
|
|
9685d4eb0b | ||
|
|
4c2efd4165 | ||
|
|
6f00ea1457 | ||
|
|
a881121110 | ||
|
|
f8167c9a70 | ||
|
|
e1d629f0d2 | ||
|
|
224cc7b419 | ||
|
|
4d4347d33a | ||
|
|
6ca5b31fab | ||
|
|
17f9269841 | ||
|
|
6253947311 | ||
|
|
00b71208a6 | ||
|
|
76eb8f697f | ||
|
|
2a3bd8bc85 | ||
|
|
629da4d878 | ||
|
|
5c2ceb63e0 | ||
|
|
92cb6dd7fd | ||
|
|
06e5931ad7 | ||
|
|
dc5b06285d | ||
|
|
3d0a2421a6 | ||
|
|
22f6791dea | ||
|
|
70b1d9a087 | ||
|
|
36720c8495 | ||
|
|
c48ab2ef92 | ||
|
|
162ee2ecaf | ||
|
|
a73bac0b5f | ||
|
|
4e84611ae7 | ||
|
|
ea2e36e55a | ||
|
|
fcbc4d64fe | ||
|
|
dcd103cec0 | ||
|
|
5e715692d6 | ||
|
|
d5263cd46e | ||
|
|
c399cd2fa2 | ||
|
|
f6bf6dc8e5 | ||
|
|
e35b435f02 | ||
|
|
886cd7ab72 | ||
|
|
73714dc489 | ||
|
|
5218bbd791 | ||
|
|
23ad30cb4c | ||
|
|
a3b49d1ed9 | ||
|
|
9c80cbf19c | ||
|
|
699cf9690a | ||
|
|
67576d4879 | ||
|
|
edcf96d057 | ||
|
|
adbbbe9cc5 | ||
|
|
46b0c9331b | ||
|
|
b39ea3c19f | ||
|
|
051326e70e | ||
|
|
220f3d8142 | ||
|
|
7d0391aed6 | ||
|
|
9a9f9fa9f3 | ||
|
|
4dddccab6e | ||
|
|
6c115e4692 | ||
|
|
ff26fe32c1 | ||
|
|
494f11ce77 | ||
|
|
a463e25aa1 | ||
|
|
6aabada342 | ||
|
|
603d5a2b54 | ||
|
|
3e9f464a2c | ||
|
|
9a022baa06 | ||
|
|
d9762759c0 | ||
|
|
d217ab3cd4 | ||
|
|
70dd732821 | ||
|
|
9acfeec431 | ||
|
|
02f828fcbf | ||
|
|
ab64916c37 |
@@ -1 +1 @@
|
||||
c65f1a0804a7765462d570c50891ac719260592df2c9cdfe88233fc346ac59e9
|
||||
1b1ce6324c50c4595703c7df0a8a479b4fe84b71ff1a8793cce1a16f17a33324
|
||||
|
||||
30
.github/scripts/auto-label-pr/reviews.js
vendored
30
.github/scripts/auto-label-pr/reviews.js
vendored
@@ -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);
|
||||
}
|
||||
|
||||
72
.github/workflows/close-pr-from-fork-default-branch.yml
vendored
Normal file
72
.github/workflows/close-pr-from-fork-default-branch.yml
vendored
Normal file
@@ -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',
|
||||
});
|
||||
@@ -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
|
||||
@@ -403,6 +404,7 @@ esphome/components/qmp6988/* @andrewpc
|
||||
esphome/components/qr_code/* @wjtje
|
||||
esphome/components/qspi_dbi/* @clydebarrow
|
||||
esphome/components/qwiic_pir/* @kahrendt
|
||||
esphome/components/radio_frequency/* @kbx81
|
||||
esphome/components/radon_eye_ble/* @jeffeb3
|
||||
esphome/components/radon_eye_rd200/* @jeffeb3
|
||||
esphome/components/rc522/* @glmnet
|
||||
@@ -438,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
|
||||
@@ -599,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
|
||||
|
||||
@@ -4,4 +4,5 @@ include requirements.txt
|
||||
recursive-include esphome *.yaml
|
||||
recursive-include esphome *.cpp *.h *.tcc *.c
|
||||
recursive-include esphome *.py.script
|
||||
recursive-include esphome *.jinja
|
||||
recursive-include esphome LICENSE.txt
|
||||
|
||||
@@ -39,6 +39,7 @@ from esphome.const import (
|
||||
CONF_MDNS,
|
||||
CONF_MQTT,
|
||||
CONF_NAME,
|
||||
CONF_NAME_ADD_MAC_SUFFIX,
|
||||
CONF_OTA,
|
||||
CONF_PASSWORD,
|
||||
CONF_PLATFORM,
|
||||
@@ -71,6 +72,7 @@ from esphome.util import (
|
||||
run_external_process,
|
||||
safe_print,
|
||||
)
|
||||
from esphome.zeroconf import discover_mdns_devices
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -204,6 +206,64 @@ def _resolve_with_cache(address: str, purpose: Purpose) -> list[str]:
|
||||
return [address]
|
||||
|
||||
|
||||
def _populate_mdns_cache(hosts_to_addresses: dict[str, list[str]]) -> None:
|
||||
"""Store discovered ``host -> [ips]`` entries in ``CORE.address_cache``.
|
||||
|
||||
Ensures ``CORE.address_cache`` exists, then records each mDNS hostname so
|
||||
the downstream resolution path (``resolve_ip_address``) can skip opening a
|
||||
second Zeroconf client.
|
||||
"""
|
||||
from esphome.address_cache import AddressCache
|
||||
|
||||
if CORE.address_cache is None:
|
||||
CORE.address_cache = AddressCache()
|
||||
for host, addresses in hosts_to_addresses.items():
|
||||
if addresses:
|
||||
_LOGGER.debug("Caching mDNS result %s -> %s", host, addresses)
|
||||
CORE.address_cache.add_mdns_addresses(host, addresses)
|
||||
|
||||
|
||||
def _discover_mac_suffix_devices() -> list[str] | None:
|
||||
"""Discover ``<name>-<mac>.local`` devices and cache their IPs.
|
||||
|
||||
Returns:
|
||||
- ``None`` when discovery isn't applicable (``name_add_mac_suffix`` off,
|
||||
mDNS disabled, or ``CORE.address`` is already an IP). Callers should
|
||||
then fall back to whatever default OTA address they normally use.
|
||||
- ``[]`` when discovery ran but found nothing. Callers should NOT fall
|
||||
back to the base name: with ``name_add_mac_suffix`` enabled, the base
|
||||
name by definition doesn't exist on the network.
|
||||
- A non-empty sorted list of ``.local`` hostnames on success.
|
||||
|
||||
Populates ``CORE.address_cache`` so downstream resolution (``espota2`` or
|
||||
``aioesphomeapi`` via :func:`_resolve_network_devices`) reuses the IPs we
|
||||
already have without opening a second Zeroconf client.
|
||||
"""
|
||||
if not (has_name_add_mac_suffix() and has_mdns() and has_non_ip_address()):
|
||||
return None
|
||||
_LOGGER.info("Discovering devices...")
|
||||
if not (discovered := discover_mdns_devices(CORE.name)):
|
||||
_LOGGER.warning(
|
||||
"No devices matching '%s-<mac>.local' were discovered.", CORE.name
|
||||
)
|
||||
return []
|
||||
_populate_mdns_cache(discovered)
|
||||
return list(discovered)
|
||||
|
||||
|
||||
def _ota_hostnames_for_default(purpose: Purpose) -> list[str]:
|
||||
"""Return OTA hostname(s) for the ``--device OTA`` / default-resolve path.
|
||||
|
||||
When ``name_add_mac_suffix`` is enabled, returns discovered
|
||||
``<name>-<mac>.local`` hostnames (possibly empty — in which case the
|
||||
caller should not fall back to the base name). Otherwise falls back to
|
||||
the cache-resolved ``CORE.address``.
|
||||
"""
|
||||
if (discovered := _discover_mac_suffix_devices()) is not None:
|
||||
return discovered
|
||||
return _resolve_with_cache(CORE.address, purpose)
|
||||
|
||||
|
||||
def choose_upload_log_host(
|
||||
default: list[str] | str | None,
|
||||
check_default: str | None,
|
||||
@@ -242,14 +302,14 @@ def choose_upload_log_host(
|
||||
resolved.append("MQTT")
|
||||
|
||||
if has_api() and has_non_ip_address() and has_resolvable_address():
|
||||
resolved.extend(_resolve_with_cache(CORE.address, purpose))
|
||||
resolved.extend(_ota_hostnames_for_default(purpose))
|
||||
|
||||
elif purpose == Purpose.UPLOADING:
|
||||
if has_ota() and has_mqtt_ip_lookup():
|
||||
resolved.append("MQTTIP")
|
||||
|
||||
if has_ota() and has_non_ip_address() and has_resolvable_address():
|
||||
resolved.extend(_resolve_with_cache(CORE.address, purpose))
|
||||
resolved.extend(_ota_hostnames_for_default(purpose))
|
||||
else:
|
||||
resolved.append(device)
|
||||
if not resolved:
|
||||
@@ -281,22 +341,29 @@ def choose_upload_log_host(
|
||||
elif bootsel.permission_error:
|
||||
bootsel_permission_error = True
|
||||
|
||||
def add_ota_options() -> None:
|
||||
"""Add OTA options, using mDNS discovery if name_add_mac_suffix is enabled."""
|
||||
if (discovered := _discover_mac_suffix_devices()) is not None:
|
||||
# Discovery was applicable. Use whatever we found — on empty,
|
||||
# intentionally skip the base-name fallback since with
|
||||
# name_add_mac_suffix on, the base name doesn't exist on the net.
|
||||
for host in discovered:
|
||||
options.append((f"Over The Air ({host})", host))
|
||||
elif has_resolvable_address():
|
||||
options.append((f"Over The Air ({CORE.address})", CORE.address))
|
||||
if has_mqtt_ip_lookup():
|
||||
options.append(("Over The Air (MQTT IP lookup)", "MQTTIP"))
|
||||
|
||||
if purpose == Purpose.LOGGING:
|
||||
if has_mqtt_logging():
|
||||
mqtt_config = CORE.config[CONF_MQTT]
|
||||
options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT"))
|
||||
|
||||
if has_api():
|
||||
if has_resolvable_address():
|
||||
options.append((f"Over The Air ({CORE.address})", CORE.address))
|
||||
if has_mqtt_ip_lookup():
|
||||
options.append(("Over The Air (MQTT IP lookup)", "MQTTIP"))
|
||||
add_ota_options()
|
||||
|
||||
elif purpose == Purpose.UPLOADING and has_ota():
|
||||
if has_resolvable_address():
|
||||
options.append((f"Over The Air ({CORE.address})", CORE.address))
|
||||
if has_mqtt_ip_lookup():
|
||||
options.append(("Over The Air (MQTT IP lookup)", "MQTTIP"))
|
||||
add_ota_options()
|
||||
|
||||
# Show helpful BOOTSEL instructions for RP2040 when no BOOTSEL device is found
|
||||
if (
|
||||
@@ -407,7 +474,17 @@ def has_resolvable_address() -> bool:
|
||||
return not CORE.address.endswith(".local")
|
||||
|
||||
|
||||
def mqtt_get_ip(config: ConfigType, username: str, password: str, client_id: str):
|
||||
def has_name_add_mac_suffix() -> bool:
|
||||
"""Check if name_add_mac_suffix is enabled in the config."""
|
||||
if CORE.config is None:
|
||||
return False
|
||||
esphome_config = CORE.config.get(CONF_ESPHOME, {})
|
||||
return esphome_config.get(CONF_NAME_ADD_MAC_SUFFIX, False)
|
||||
|
||||
|
||||
def mqtt_get_ip(
|
||||
config: ConfigType, username: str, password: str, client_id: str
|
||||
) -> list[str]:
|
||||
from esphome import mqtt
|
||||
|
||||
return mqtt.get_esphome_device_ip(config, username, password, client_id)
|
||||
@@ -420,6 +497,9 @@ def _resolve_network_devices(
|
||||
|
||||
This function filters the devices list to:
|
||||
- Replace MQTT/MQTTIP magic strings with actual IP addresses via MQTT lookup
|
||||
- Expand hostnames that are already in ``CORE.address_cache`` to their
|
||||
cached IPs so downstream code (e.g. aioesphomeapi) doesn't open a second
|
||||
Zeroconf client to resolve them
|
||||
- Deduplicate addresses while preserving order
|
||||
- Only resolve MQTT once even if multiple MQTT strings are present
|
||||
- If MQTT resolution fails, log a warning and continue with other devices
|
||||
@@ -444,13 +524,29 @@ def _resolve_network_devices(
|
||||
mqtt_ips = mqtt_get_ip(
|
||||
config, args.username, args.password, args.client_id
|
||||
)
|
||||
network_devices.extend(mqtt_ips)
|
||||
# pylint can't infer mqtt_get_ip's return through its
|
||||
# lazy ``from esphome import mqtt`` import, so it flags
|
||||
# the genexpr below.
|
||||
network_devices.extend(
|
||||
addr
|
||||
for addr in mqtt_ips # pylint: disable=not-an-iterable
|
||||
if addr not in network_devices
|
||||
)
|
||||
except EsphomeError as err:
|
||||
_LOGGER.warning(
|
||||
"MQTT IP discovery failed (%s), will try other devices if available",
|
||||
err,
|
||||
)
|
||||
mqtt_resolved = True
|
||||
continue
|
||||
|
||||
# If the hostname is already in the address cache (e.g. populated by
|
||||
# mDNS discovery), substitute the cached IPs so aioesphomeapi doesn't
|
||||
# open its own Zeroconf to re-resolve it.
|
||||
if CORE.address_cache and (cached := CORE.address_cache.get_addresses(device)):
|
||||
network_devices.extend(
|
||||
addr for addr in cached if addr not in network_devices
|
||||
)
|
||||
elif device not in network_devices:
|
||||
# Regular network address or IP - add if not already present
|
||||
network_devices.append(device)
|
||||
|
||||
@@ -101,6 +101,17 @@ class AddressCache:
|
||||
"""Check if any cache entries exist."""
|
||||
return bool(self.mdns_cache or self.dns_cache)
|
||||
|
||||
def add_mdns_addresses(self, hostname: str, addresses: list[str]) -> None:
|
||||
"""Store resolved mDNS addresses for ``hostname`` in the cache.
|
||||
|
||||
Callers that discover ``.local`` hosts (e.g. via mDNS browse) can use
|
||||
this to avoid a second resolution round-trip during the upload path.
|
||||
No-op when ``addresses`` is empty.
|
||||
"""
|
||||
if not addresses:
|
||||
return
|
||||
self.mdns_cache[normalize_hostname(hostname)] = addresses
|
||||
|
||||
@classmethod
|
||||
def from_cli_args(
|
||||
cls, mdns_args: Iterable[str], dns_args: Iterable[str]
|
||||
|
||||
56
esphome/async_thread.py
Normal file
56
esphome/async_thread.py
Normal 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())
|
||||
@@ -190,7 +190,7 @@ void AcDimmer::setup() {
|
||||
this->zero_cross_pin_->setup();
|
||||
this->store_.zero_cross_pin = this->zero_cross_pin_->to_isr();
|
||||
this->zero_cross_pin_->attach_interrupt(&AcDimmerDataStore::s_gpio_intr, &this->store_,
|
||||
gpio::INTERRUPT_FALLING_EDGE);
|
||||
this->zero_cross_interrupt_type_);
|
||||
}
|
||||
|
||||
#ifdef USE_ESP8266
|
||||
@@ -226,19 +226,25 @@ void AcDimmer::write_state(float state) {
|
||||
void AcDimmer::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"AcDimmer:\n"
|
||||
" Min Power: %.1f%%\n"
|
||||
" Init with half cycle: %s",
|
||||
" Min Power: %.1f%%\n"
|
||||
" Init with half cycle: %s",
|
||||
this->store_.min_power / 10.0f, YESNO(this->init_with_half_cycle_));
|
||||
LOG_PIN(" Output Pin: ", this->gate_pin_);
|
||||
LOG_PIN(" Zero-Cross Pin: ", this->zero_cross_pin_);
|
||||
if (method_ == DIM_METHOD_LEADING_PULSE) {
|
||||
ESP_LOGCONFIG(TAG, " Method: leading pulse");
|
||||
} else if (method_ == DIM_METHOD_LEADING) {
|
||||
ESP_LOGCONFIG(TAG, " Method: leading");
|
||||
if (this->zero_cross_interrupt_type_ == gpio::INTERRUPT_RISING_EDGE) {
|
||||
ESP_LOGCONFIG(TAG, " Interrupt Type: rising");
|
||||
} else if (this->zero_cross_interrupt_type_ == gpio::INTERRUPT_FALLING_EDGE) {
|
||||
ESP_LOGCONFIG(TAG, " Interrupt Type: falling");
|
||||
} else {
|
||||
ESP_LOGCONFIG(TAG, " Method: trailing");
|
||||
ESP_LOGCONFIG(TAG, " Interrupt Type: any");
|
||||
}
|
||||
if (method_ == DIM_METHOD_LEADING_PULSE) {
|
||||
ESP_LOGCONFIG(TAG, " Method: leading pulse");
|
||||
} else if (method_ == DIM_METHOD_LEADING) {
|
||||
ESP_LOGCONFIG(TAG, " Method: leading");
|
||||
} else {
|
||||
ESP_LOGCONFIG(TAG, " Method: trailing");
|
||||
}
|
||||
|
||||
LOG_FLOAT_OUTPUT(this);
|
||||
ESP_LOGV(TAG, " Estimated Frequency: %.3fHz", 1e6f / this->store_.cycle_time_us / 2);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ class AcDimmer : public output::FloatOutput, public Component {
|
||||
void dump_config() override;
|
||||
void set_gate_pin(InternalGPIOPin *gate_pin) { gate_pin_ = gate_pin; }
|
||||
void set_zero_cross_pin(InternalGPIOPin *zero_cross_pin) { zero_cross_pin_ = zero_cross_pin; }
|
||||
void set_zero_cross_interrupt_type(gpio::InterruptType type) { zero_cross_interrupt_type_ = type; }
|
||||
void set_init_with_half_cycle(bool init_with_half_cycle) { init_with_half_cycle_ = init_with_half_cycle; }
|
||||
void set_method(DimMethod method) { method_ = method; }
|
||||
|
||||
@@ -56,6 +57,7 @@ class AcDimmer : public output::FloatOutput, public Component {
|
||||
|
||||
InternalGPIOPin *gate_pin_;
|
||||
InternalGPIOPin *zero_cross_pin_;
|
||||
gpio::InterruptType zero_cross_interrupt_type_;
|
||||
AcDimmerDataStore store_;
|
||||
bool init_with_half_cycle_;
|
||||
DimMethod method_;
|
||||
|
||||
@@ -7,6 +7,8 @@ from esphome.core import CORE
|
||||
|
||||
CODEOWNERS = ["@glmnet"]
|
||||
|
||||
gpio_ns = cg.esphome_ns.namespace("gpio")
|
||||
|
||||
ac_dimmer_ns = cg.esphome_ns.namespace("ac_dimmer")
|
||||
AcDimmer = ac_dimmer_ns.class_("AcDimmer", output.FloatOutput, cg.Component)
|
||||
|
||||
@@ -17,15 +19,26 @@ DIM_METHODS = {
|
||||
"TRAILING": DimMethod.DIM_METHOD_TRAILING,
|
||||
}
|
||||
|
||||
ZC_INTERRUPT_TYPES = {
|
||||
"RISING": gpio_ns.INTERRUPT_RISING_EDGE,
|
||||
"FALLING": gpio_ns.INTERRUPT_FALLING_EDGE,
|
||||
"ANY": gpio_ns.INTERRUPT_ANY_EDGE,
|
||||
}
|
||||
|
||||
CONF_GATE_PIN = "gate_pin"
|
||||
CONF_ZERO_CROSS_PIN = "zero_cross_pin"
|
||||
CONF_INIT_WITH_HALF_CYCLE = "init_with_half_cycle"
|
||||
CONF_ZERO_CROSS_INTERRUPT_TYPE = "zero_cross_interrupt_type"
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
output.FLOAT_OUTPUT_SCHEMA.extend(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.declare_id(AcDimmer),
|
||||
cv.Required(CONF_GATE_PIN): pins.internal_gpio_output_pin_schema,
|
||||
cv.Required(CONF_ZERO_CROSS_PIN): pins.internal_gpio_input_pin_schema,
|
||||
cv.Optional(CONF_ZERO_CROSS_INTERRUPT_TYPE, default="FALLING"): cv.enum(
|
||||
ZC_INTERRUPT_TYPES, upper=True, space="_"
|
||||
),
|
||||
cv.Optional(CONF_INIT_WITH_HALF_CYCLE, default=True): cv.boolean,
|
||||
cv.Optional(CONF_METHOD, default="leading pulse"): cv.enum(
|
||||
DIM_METHODS, upper=True, space="_"
|
||||
@@ -54,5 +67,6 @@ async def to_code(config):
|
||||
cg.add(var.set_gate_pin(pin))
|
||||
pin = await cg.gpio_pin_expression(config[CONF_ZERO_CROSS_PIN])
|
||||
cg.add(var.set_zero_cross_pin(pin))
|
||||
cg.add(var.set_zero_cross_interrupt_type(config[CONF_ZERO_CROSS_INTERRUPT_TYPE]))
|
||||
cg.add(var.set_init_with_half_cycle(config[CONF_INIT_WITH_HALF_CYCLE]))
|
||||
cg.add(var.set_method(config[CONF_METHOD]))
|
||||
|
||||
@@ -2544,27 +2544,50 @@ message ListEntitiesInfraredResponse {
|
||||
message InfraredRFTransmitRawTimingsRequest {
|
||||
option (id) = 136;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_IR_RF";
|
||||
option (ifdef) = "USE_IR_RF || USE_RADIO_FREQUENCY";
|
||||
|
||||
uint32 device_id = 1 [(field_ifdef) = "USE_DEVICES"];
|
||||
fixed32 key = 2 [(force) = true]; // Key identifying the transmitter instance
|
||||
uint32 carrier_frequency = 3; // Carrier frequency in Hz
|
||||
uint32 repeat_count = 4; // Number of times to transmit (1 = once, 2 = twice, etc.)
|
||||
fixed32 key = 2 [(force) = true]; // Key identifying the transmitter instance
|
||||
uint32 carrier_frequency = 3; // Carrier frequency in Hz
|
||||
uint32 repeat_count = 4; // Number of times to transmit (1 = once, 2 = twice, etc.)
|
||||
repeated sint32 timings = 5 [packed = true, (packed_buffer) = true]; // Raw timings in microseconds (zigzag-encoded): positive = mark (LED/TX on), negative = space (LED/TX off)
|
||||
uint32 modulation = 6; // RadioFrequencyModulation enum value (0 = OOK; ignored for IR entities)
|
||||
}
|
||||
|
||||
// Event message for received infrared/RF data
|
||||
message InfraredRFReceiveEvent {
|
||||
option (id) = 137;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_IR_RF";
|
||||
option (ifdef) = "USE_IR_RF || USE_RADIO_FREQUENCY";
|
||||
option (no_delay) = true;
|
||||
|
||||
uint32 device_id = 1 [(field_ifdef) = "USE_DEVICES"];
|
||||
fixed32 key = 2 [(force) = true]; // Key identifying the receiver instance
|
||||
fixed32 key = 2 [(force) = true]; // Key identifying the receiver instance
|
||||
repeated sint32 timings = 3 [packed = true, (container_pointer_no_template) = "std::vector<int32_t>"]; // Raw timings in microseconds (zigzag-encoded): alternating mark/space periods
|
||||
}
|
||||
|
||||
// ==================== RADIO FREQUENCY ====================
|
||||
|
||||
// Lists available radio frequency entity instances
|
||||
message ListEntitiesRadioFrequencyResponse {
|
||||
option (id) = 148;
|
||||
option (base_class) = "InfoResponseProtoMessage";
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_RADIO_FREQUENCY";
|
||||
|
||||
string object_id = 1 [(max_data_length) = 120, (force) = true];
|
||||
fixed32 key = 2 [(force) = true];
|
||||
string name = 3 [(max_data_length) = 120, (force) = true];
|
||||
string icon = 4 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63];
|
||||
bool disabled_by_default = 5;
|
||||
EntityCategory entity_category = 6;
|
||||
uint32 device_id = 7 [(field_ifdef) = "USE_DEVICES"];
|
||||
uint32 capabilities = 8; // Bitmask of RadioFrequencyCapabilityFlags: bit 0 = transmitter, bit 1 = receiver
|
||||
uint32 frequency_min = 9; // Minimum tunable frequency in Hz; if min == max (non-zero): fixed frequency; 0 = unspecified
|
||||
uint32 frequency_max = 10; // Maximum tunable frequency in Hz; 0 = unspecified
|
||||
uint32 supported_modulations = 11; // Bitmask of supported RadioFrequencyModulation values (bit N = modulation N supported)
|
||||
}
|
||||
|
||||
// ==================== SERIAL PROXY ====================
|
||||
|
||||
enum SerialProxyParity {
|
||||
|
||||
@@ -49,6 +49,9 @@
|
||||
#ifdef USE_INFRARED
|
||||
#include "esphome/components/infrared/infrared.h"
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
#include "esphome/components/radio_frequency/radio_frequency.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
@@ -100,6 +103,12 @@ static const int CAMERA_STOP_STREAM = 5000;
|
||||
entity_type *entity_var = App.get_##getter_name##_by_key(msg.key, msg.device_id); \
|
||||
if ((entity_var) == nullptr) \
|
||||
return;
|
||||
|
||||
// Helper macro for multi-entity dispatch: looks up an entity by key and device_id without early return or make_call().
|
||||
// Use when multiple entity types must be checked in sequence (at most one will match).
|
||||
#define ENTITY_COMMAND_LOOKUP(entity_type, entity_var, getter_name) \
|
||||
entity_type *entity_var = App.get_##getter_name##_by_key(msg.key, msg.device_id)
|
||||
|
||||
#else // No device support, use simpler macros
|
||||
// Helper macro for entity command handlers - gets entity by key, returns if not found, and creates call
|
||||
// object
|
||||
@@ -115,6 +124,12 @@ static const int CAMERA_STOP_STREAM = 5000;
|
||||
entity_type *entity_var = App.get_##getter_name##_by_key(msg.key); \
|
||||
if ((entity_var) == nullptr) \
|
||||
return;
|
||||
|
||||
// Helper macro for multi-entity dispatch: looks up an entity by key without early return or make_call().
|
||||
// Use when multiple entity types must be checked in sequence (at most one will match).
|
||||
#define ENTITY_COMMAND_LOOKUP(entity_type, entity_var, getter_name) \
|
||||
entity_type *entity_var = App.get_##getter_name##_by_key(msg.key)
|
||||
|
||||
#endif // USE_DEVICES
|
||||
|
||||
APIConnection::APIConnection(std::unique_ptr<socket::Socket> sock, APIServer *parent) : parent_(parent) {
|
||||
@@ -1471,19 +1486,36 @@ uint16_t APIConnection::try_send_event_info(EntityBase *entity, APIConnection *c
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_IR_RF
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg) {
|
||||
// TODO: When RF is implemented, add a field to the message to distinguish IR vs RF
|
||||
// and dispatch to the appropriate entity type based on that field.
|
||||
// Dispatch by key: infrared entities are checked first, then radio frequency entities.
|
||||
// The key is unique across all entity instances on a device, so at most one lookup will succeed.
|
||||
#ifdef USE_INFRARED
|
||||
ENTITY_COMMAND_MAKE_CALL(infrared::Infrared, infrared, infrared)
|
||||
call.set_carrier_frequency(msg.carrier_frequency);
|
||||
call.set_raw_timings_packed(msg.timings_data_, msg.timings_length_, msg.timings_count_);
|
||||
call.set_repeat_count(msg.repeat_count);
|
||||
call.perform();
|
||||
ENTITY_COMMAND_LOOKUP(infrared::Infrared, infrared, infrared);
|
||||
if (infrared != nullptr) {
|
||||
auto call = infrared->make_call();
|
||||
call.set_carrier_frequency(msg.carrier_frequency);
|
||||
call.set_raw_timings_packed(msg.timings_data_, msg.timings_length_, msg.timings_count_);
|
||||
call.set_repeat_count(msg.repeat_count);
|
||||
call.perform();
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
ENTITY_COMMAND_LOOKUP(radio_frequency::RadioFrequency, radio_frequency, radio_frequency);
|
||||
if (radio_frequency != nullptr) {
|
||||
auto call = radio_frequency->make_call();
|
||||
call.set_frequency(msg.carrier_frequency);
|
||||
call.set_modulation(static_cast<radio_frequency::RadioFrequencyModulation>(msg.modulation));
|
||||
call.set_repeat_count(msg.repeat_count);
|
||||
call.set_raw_timings_packed(msg.timings_data_, msg.timings_length_, msg.timings_count_);
|
||||
call.perform();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) { this->send_message(msg); }
|
||||
#endif
|
||||
|
||||
@@ -1580,6 +1612,19 @@ uint16_t APIConnection::try_send_infrared_info(EntityBase *entity, APIConnection
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
uint16_t APIConnection::try_send_radio_frequency_info(EntityBase *entity, APIConnection *conn,
|
||||
uint32_t remaining_size) {
|
||||
auto *rf = static_cast<radio_frequency::RadioFrequency *>(entity);
|
||||
ListEntitiesRadioFrequencyResponse msg;
|
||||
msg.capabilities = rf->get_capability_flags();
|
||||
msg.frequency_min = rf->get_traits().get_frequency_min_hz();
|
||||
msg.frequency_max = rf->get_traits().get_frequency_max_hz();
|
||||
msg.supported_modulations = rf->get_traits().get_supported_modulations();
|
||||
return fill_and_encode_entity_info(rf, msg, conn, remaining_size);
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_UPDATE
|
||||
bool APIConnection::send_update_state(update::UpdateEntity *update) {
|
||||
return this->send_message_smart_(update, UpdateStateResponse::MESSAGE_TYPE, UpdateStateResponse::ESTIMATED_SIZE);
|
||||
@@ -2341,6 +2386,9 @@ uint16_t APIConnection::dispatch_message_(const DeferredBatch::BatchItem &item,
|
||||
#ifdef USE_INFRARED
|
||||
CASE_INFO_ONLY(infrared, ListEntitiesInfraredResponse)
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
CASE_INFO_ONLY(radio_frequency, ListEntitiesRadioFrequencyResponse)
|
||||
#endif
|
||||
#ifdef USE_EVENT
|
||||
CASE_INFO_ONLY(event, ListEntitiesEventResponse)
|
||||
#endif
|
||||
|
||||
@@ -223,7 +223,7 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
void on_water_heater_command_request(const WaterHeaterCommandRequest &msg);
|
||||
#endif
|
||||
|
||||
#ifdef USE_IR_RF
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg);
|
||||
void send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg);
|
||||
#endif
|
||||
@@ -612,6 +612,9 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
#ifdef USE_INFRARED
|
||||
static uint16_t try_send_infrared_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size);
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
static uint16_t try_send_radio_frequency_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size);
|
||||
#endif
|
||||
#ifdef USE_EVENT
|
||||
static uint16_t try_send_event_response(event::Event *event, StringRef event_type, APIConnection *conn,
|
||||
uint32_t remaining_size);
|
||||
|
||||
@@ -3861,7 +3861,7 @@ uint32_t ListEntitiesInfraredResponse::calculate_size() const {
|
||||
return size;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_IR_RF
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
bool InfraredRFTransmitRawTimingsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
|
||||
switch (field_id) {
|
||||
#ifdef USE_DEVICES
|
||||
@@ -3875,6 +3875,9 @@ bool InfraredRFTransmitRawTimingsRequest::decode_varint(uint32_t field_id, proto
|
||||
case 4:
|
||||
this->repeat_count = value;
|
||||
break;
|
||||
case 6:
|
||||
this->modulation = value;
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@@ -3928,6 +3931,46 @@ uint32_t InfraredRFReceiveEvent::calculate_size() const {
|
||||
return size;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
uint8_t *ListEntitiesRadioFrequencyResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
uint8_t *__restrict__ pos = buffer.get_pos();
|
||||
ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id);
|
||||
ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key);
|
||||
ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name);
|
||||
#ifdef USE_ENTITY_ICON
|
||||
ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->icon);
|
||||
#endif
|
||||
ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->disabled_by_default);
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, static_cast<uint32_t>(this->entity_category));
|
||||
#ifdef USE_DEVICES
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, this->device_id);
|
||||
#endif
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->capabilities);
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->frequency_min);
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->frequency_max);
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, this->supported_modulations);
|
||||
return pos;
|
||||
}
|
||||
uint32_t ListEntitiesRadioFrequencyResponse::calculate_size() const {
|
||||
uint32_t size = 0;
|
||||
size += 2 + this->object_id.size();
|
||||
size += 5;
|
||||
size += 2 + this->name.size();
|
||||
#ifdef USE_ENTITY_ICON
|
||||
size += !this->icon.empty() ? 2 + this->icon.size() : 0;
|
||||
#endif
|
||||
size += ProtoSize::calc_bool(1, this->disabled_by_default);
|
||||
size += this->entity_category ? 2 : 0;
|
||||
#ifdef USE_DEVICES
|
||||
size += ProtoSize::calc_uint32(1, this->device_id);
|
||||
#endif
|
||||
size += ProtoSize::calc_uint32(1, this->capabilities);
|
||||
size += ProtoSize::calc_uint32(1, this->frequency_min);
|
||||
size += ProtoSize::calc_uint32(1, this->frequency_max);
|
||||
size += ProtoSize::calc_uint32(1, this->supported_modulations);
|
||||
return size;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
bool SerialProxyConfigureRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
|
||||
switch (field_id) {
|
||||
|
||||
@@ -3054,11 +3054,11 @@ class ListEntitiesInfraredResponse final : public InfoResponseProtoMessage {
|
||||
protected:
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_IR_RF
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
class InfraredRFTransmitRawTimingsRequest final : public ProtoDecodableMessage {
|
||||
public:
|
||||
static constexpr uint8_t MESSAGE_TYPE = 136;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 220;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 224;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const LogString *message_name() const override { return LOG_STR("infrared_rf_transmit_raw_timings_request"); }
|
||||
#endif
|
||||
@@ -3071,6 +3071,7 @@ class InfraredRFTransmitRawTimingsRequest final : public ProtoDecodableMessage {
|
||||
const uint8_t *timings_data_{nullptr};
|
||||
uint16_t timings_length_{0};
|
||||
uint16_t timings_count_{0};
|
||||
uint32_t modulation{0};
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const char *dump_to(DumpBuffer &out) const override;
|
||||
#endif
|
||||
@@ -3101,6 +3102,27 @@ class InfraredRFReceiveEvent final : public ProtoMessage {
|
||||
protected:
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
class ListEntitiesRadioFrequencyResponse final : public InfoResponseProtoMessage {
|
||||
public:
|
||||
static constexpr uint8_t MESSAGE_TYPE = 148;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 56;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const LogString *message_name() const override { return LOG_STR("list_entities_radio_frequency_response"); }
|
||||
#endif
|
||||
uint32_t capabilities{0};
|
||||
uint32_t frequency_min{0};
|
||||
uint32_t frequency_max{0};
|
||||
uint32_t supported_modulations{0};
|
||||
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
|
||||
uint32_t calculate_size() const;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const char *dump_to(DumpBuffer &out) const override;
|
||||
#endif
|
||||
|
||||
protected:
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
class SerialProxyConfigureRequest final : public ProtoDecodableMessage {
|
||||
public:
|
||||
|
||||
@@ -2576,7 +2576,7 @@ const char *ListEntitiesInfraredResponse::dump_to(DumpBuffer &out) const {
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_IR_RF
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
const char *InfraredRFTransmitRawTimingsRequest::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("InfraredRFTransmitRawTimingsRequest"));
|
||||
#ifdef USE_DEVICES
|
||||
@@ -2591,6 +2591,7 @@ const char *InfraredRFTransmitRawTimingsRequest::dump_to(DumpBuffer &out) const
|
||||
out.append_p(ESPHOME_PSTR(" values, "));
|
||||
append_uint(out, this->timings_length_);
|
||||
out.append_p(ESPHOME_PSTR(" bytes]\n"));
|
||||
dump_field(out, ESPHOME_PSTR("modulation"), this->modulation);
|
||||
return out.c_str();
|
||||
}
|
||||
const char *InfraredRFReceiveEvent::dump_to(DumpBuffer &out) const {
|
||||
@@ -2605,6 +2606,27 @@ const char *InfraredRFReceiveEvent::dump_to(DumpBuffer &out) const {
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
const char *ListEntitiesRadioFrequencyResponse::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesRadioFrequencyResponse"));
|
||||
dump_field(out, ESPHOME_PSTR("object_id"), this->object_id);
|
||||
dump_field(out, ESPHOME_PSTR("key"), this->key);
|
||||
dump_field(out, ESPHOME_PSTR("name"), this->name);
|
||||
#ifdef USE_ENTITY_ICON
|
||||
dump_field(out, ESPHOME_PSTR("icon"), this->icon);
|
||||
#endif
|
||||
dump_field(out, ESPHOME_PSTR("disabled_by_default"), this->disabled_by_default);
|
||||
dump_field(out, ESPHOME_PSTR("entity_category"), static_cast<enums::EntityCategory>(this->entity_category));
|
||||
#ifdef USE_DEVICES
|
||||
dump_field(out, ESPHOME_PSTR("device_id"), this->device_id);
|
||||
#endif
|
||||
dump_field(out, ESPHOME_PSTR("capabilities"), this->capabilities);
|
||||
dump_field(out, ESPHOME_PSTR("frequency_min"), this->frequency_min);
|
||||
dump_field(out, ESPHOME_PSTR("frequency_max"), this->frequency_max);
|
||||
dump_field(out, ESPHOME_PSTR("supported_modulations"), this->supported_modulations);
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
const char *SerialProxyConfigureRequest::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxyConfigureRequest"));
|
||||
|
||||
@@ -625,7 +625,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_IR_RF
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
case InfraredRFTransmitRawTimingsRequest::MESSAGE_TYPE: {
|
||||
InfraredRFTransmitRawTimingsRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
|
||||
@@ -211,7 +211,7 @@ class APIServerConnectionBase {
|
||||
void on_z_wave_proxy_request(const ZWaveProxyRequest &value){};
|
||||
#endif
|
||||
|
||||
#ifdef USE_IR_RF
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &value){};
|
||||
#endif
|
||||
|
||||
|
||||
@@ -368,7 +368,7 @@ void APIServer::on_zwave_proxy_request(const ZWaveProxyRequest &msg) {
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_IR_RF
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_id, uint32_t key,
|
||||
const std::vector<int32_t> *timings) {
|
||||
InfraredRFReceiveEvent resp{};
|
||||
|
||||
@@ -183,7 +183,7 @@ class APIServer final : public Component,
|
||||
#ifdef USE_ZWAVE_PROXY
|
||||
void on_zwave_proxy_request(const ZWaveProxyRequest &msg);
|
||||
#endif
|
||||
#ifdef USE_IR_RF
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
void send_infrared_rf_receive_event(uint32_t device_id, uint32_t key, const std::vector<int32_t> *timings);
|
||||
#endif
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -79,6 +79,9 @@ LIST_ENTITIES_HANDLER(water_heater, water_heater::WaterHeater, ListEntitiesWater
|
||||
#ifdef USE_INFRARED
|
||||
LIST_ENTITIES_HANDLER(infrared, infrared::Infrared, ListEntitiesInfraredResponse)
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
LIST_ENTITIES_HANDLER(radio_frequency, radio_frequency::RadioFrequency, ListEntitiesRadioFrequencyResponse)
|
||||
#endif
|
||||
#ifdef USE_EVENT
|
||||
LIST_ENTITIES_HANDLER(event, event::Event, ListEntitiesEventResponse)
|
||||
#endif
|
||||
|
||||
@@ -87,6 +87,9 @@ class ListEntitiesIterator final : public ComponentIterator {
|
||||
#ifdef USE_INFRARED
|
||||
bool on_infrared(infrared::Infrared *entity) override;
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
bool on_radio_frequency(radio_frequency::RadioFrequency *entity) override;
|
||||
#endif
|
||||
#ifdef USE_EVENT
|
||||
bool on_event(event::Event *entity) override;
|
||||
#endif
|
||||
|
||||
@@ -82,6 +82,9 @@ class InitialStateIterator final : public ComponentIterator {
|
||||
#ifdef USE_INFRARED
|
||||
bool on_infrared(infrared::Infrared *infrared) override { return true; };
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
bool on_radio_frequency(radio_frequency::RadioFrequency *radio_frequency) override { return true; };
|
||||
#endif
|
||||
#ifdef USE_EVENT
|
||||
bool on_event(event::Event *event) override { return true; };
|
||||
#endif
|
||||
|
||||
0
esphome/components/audio_http/__init__.py
Normal file
0
esphome/components/audio_http/__init__.py
Normal file
163
esphome/components/audio_http/audio_http_media_source.cpp
Normal file
163
esphome/components/audio_http/audio_http_media_source.cpp
Normal file
@@ -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
|
||||
59
esphome/components/audio_http/audio_http_media_source.h
Normal file
59
esphome/components/audio_http/audio_http_media_source.h
Normal file
@@ -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
|
||||
59
esphome/components/audio_http/media_source.py
Normal file
59
esphome/components/audio_http/media_source.py
Normal file
@@ -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]))
|
||||
@@ -204,24 +204,27 @@ void CSE7761Component::get_data_() {
|
||||
value = this->read_(CSE7761_REG_RMSIA, 3);
|
||||
this->data_.current_rms[0] = ((value >= 0x800000) || (value < 1600)) ? 0 : value; // No load threshold of 10mA
|
||||
value = this->read_(CSE7761_REG_POWERPA, 4);
|
||||
this->data_.active_power[0] = (0 == this->data_.current_rms[0]) ? 0 : ((uint32_t) abs((int) value));
|
||||
// PowerPA is two's complement signed 32-bit per datasheet
|
||||
this->data_.active_power[0] = (0 == this->data_.current_rms[0]) ? 0 : static_cast<int32_t>(value);
|
||||
|
||||
value = this->read_(CSE7761_REG_RMSIB, 3);
|
||||
this->data_.current_rms[1] = ((value >= 0x800000) || (value < 1600)) ? 0 : value; // No load threshold of 10mA
|
||||
value = this->read_(CSE7761_REG_POWERPB, 4);
|
||||
this->data_.active_power[1] = (0 == this->data_.current_rms[1]) ? 0 : ((uint32_t) abs((int) value));
|
||||
// PowerPB is two's complement signed 32-bit per datasheet
|
||||
this->data_.active_power[1] = (0 == this->data_.current_rms[1]) ? 0 : static_cast<int32_t>(value);
|
||||
|
||||
// convert values and publish to sensors
|
||||
|
||||
float voltage = (float) this->data_.voltage_rms / this->coefficient_by_unit_(RMS_UC);
|
||||
float voltage = static_cast<float>(this->data_.voltage_rms) / this->coefficient_by_unit_(RMS_UC);
|
||||
if (this->voltage_sensor_ != nullptr) {
|
||||
this->voltage_sensor_->publish_state(voltage);
|
||||
}
|
||||
|
||||
for (uint8_t channel = 0; channel < 2; channel++) {
|
||||
// Active power = PowerPA * PowerPAC * 1000 / 0x80000000
|
||||
float active_power = (float) this->data_.active_power[channel] / this->coefficient_by_unit_(POWER_PAC); // W
|
||||
float amps = (float) this->data_.current_rms[channel] / this->coefficient_by_unit_(RMS_IAC); // A
|
||||
float active_power =
|
||||
static_cast<float>(this->data_.active_power[channel]) / this->coefficient_by_unit_(POWER_PAC); // W
|
||||
float amps = static_cast<float>(this->data_.current_rms[channel]) / this->coefficient_by_unit_(RMS_IAC); // A
|
||||
ESP_LOGD(TAG, "Channel %d power %f W, current %f A", channel + 1, active_power, amps);
|
||||
if (channel == 0) {
|
||||
if (this->power_sensor_1_ != nullptr) {
|
||||
|
||||
@@ -11,10 +11,8 @@ struct CSE7761DataStruct {
|
||||
uint32_t frequency = 0;
|
||||
uint32_t voltage_rms = 0;
|
||||
uint32_t current_rms[2] = {0};
|
||||
uint32_t energy[2] = {0};
|
||||
uint32_t active_power[2] = {0};
|
||||
int32_t active_power[2] = {0};
|
||||
uint16_t coefficient[8] = {0};
|
||||
uint8_t energy_update = 0;
|
||||
bool ready = false;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
60
esphome/components/deep_sleep/deep_sleep_zephyr.cpp
Normal file
60
esphome/components/deep_sleep/deep_sleep_zephyr.cpp
Normal file
@@ -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
|
||||
@@ -1,8 +1,19 @@
|
||||
import logging
|
||||
|
||||
from esphome import pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import uart
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_RECEIVE_TIMEOUT, CONF_UART_ID
|
||||
from esphome.const import (
|
||||
CONF_ID,
|
||||
CONF_RECEIVE_TIMEOUT,
|
||||
CONF_RX_BUFFER_SIZE,
|
||||
CONF_UART_ID,
|
||||
)
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CODEOWNERS = ["@glmnet", "@PolarGoose"]
|
||||
|
||||
@@ -21,8 +32,7 @@ CONF_MAX_TELEGRAM_LENGTH = "max_telegram_length"
|
||||
CONF_REQUEST_INTERVAL = "request_interval"
|
||||
CONF_REQUEST_PIN = "request_pin"
|
||||
|
||||
# Hack to prevent compile error due to ambiguity with lib namespace
|
||||
dsmr_ns = cg.esphome_ns.namespace("esphome::dsmr")
|
||||
dsmr_ns = cg.esphome_ns.namespace("dsmr")
|
||||
Dsmr = dsmr_ns.class_("Dsmr", cg.Component, uart.UARTDevice)
|
||||
|
||||
|
||||
@@ -54,24 +64,47 @@ CONFIG_SCHEMA = cv.All(
|
||||
|
||||
async def to_code(config):
|
||||
uart_component = await cg.get_variable(config[CONF_UART_ID])
|
||||
var = cg.new_Pvariable(config[CONF_ID], uart_component, config[CONF_CRC_CHECK])
|
||||
cg.add(var.set_max_telegram_length(config[CONF_MAX_TELEGRAM_LENGTH]))
|
||||
if CONF_DECRYPTION_KEY in config:
|
||||
cg.add(var.set_decryption_key(config[CONF_DECRYPTION_KEY]))
|
||||
await cg.register_component(var, config)
|
||||
|
||||
if CONF_REQUEST_PIN in config:
|
||||
request_pin = await cg.gpio_pin_expression(config[CONF_REQUEST_PIN])
|
||||
cg.add(var.set_request_pin(request_pin))
|
||||
cg.add(var.set_request_interval(config[CONF_REQUEST_INTERVAL].total_milliseconds))
|
||||
cg.add(var.set_receive_timeout(config[CONF_RECEIVE_TIMEOUT].total_milliseconds))
|
||||
else:
|
||||
request_pin = cg.nullptr
|
||||
decryption_key = config.get(CONF_DECRYPTION_KEY)
|
||||
if decryption_key is None:
|
||||
decryption_key = cg.nullptr
|
||||
var = cg.new_Pvariable(
|
||||
config[CONF_ID],
|
||||
uart_component,
|
||||
config[CONF_CRC_CHECK],
|
||||
config[CONF_MAX_TELEGRAM_LENGTH],
|
||||
config[CONF_REQUEST_INTERVAL].total_milliseconds,
|
||||
config[CONF_RECEIVE_TIMEOUT].total_milliseconds,
|
||||
request_pin,
|
||||
decryption_key,
|
||||
)
|
||||
await cg.register_component(var, config)
|
||||
|
||||
cg.add_build_flag("-DDSMR_GAS_MBUS_ID=" + str(config[CONF_GAS_MBUS_ID]))
|
||||
cg.add_build_flag("-DDSMR_WATER_MBUS_ID=" + str(config[CONF_WATER_MBUS_ID]))
|
||||
cg.add_build_flag("-DDSMR_THERMAL_MBUS_ID=" + str(config[CONF_THERMAL_MBUS_ID]))
|
||||
|
||||
# DSMR Parser
|
||||
cg.add_library("esphome/dsmr_parser", "1.1.0")
|
||||
cg.add_library("esphome/dsmr_parser", "1.4.0")
|
||||
|
||||
# Crypto
|
||||
cg.add_library("polargoose/Crypto-no-arduino", "0.4.0")
|
||||
|
||||
def final_validate(config: ConfigType) -> ConfigType:
|
||||
full_config = fv.full_config.get()
|
||||
|
||||
for uart_conf in full_config["uart"]:
|
||||
if uart_conf[CONF_ID] == config[CONF_UART_ID]:
|
||||
rx_buffer_size = uart_conf[CONF_RX_BUFFER_SIZE]
|
||||
if rx_buffer_size < 1500:
|
||||
_LOGGER.warning(
|
||||
"UART '%s' rx_buffer_size should be bigger than 1500 bytes to avoid packet losses (currently %d bytes).",
|
||||
config[CONF_UART_ID],
|
||||
rx_buffer_size,
|
||||
)
|
||||
break
|
||||
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = final_validate
|
||||
|
||||
@@ -1,315 +1,183 @@
|
||||
#include "dsmr.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
// Ignore Zephyr. It doesn't have any encryption library.
|
||||
#if defined(USE_ESP32) || defined(USE_ARDUINO) || defined(USE_HOST)
|
||||
|
||||
#include <AES.h>
|
||||
#include <Crypto.h>
|
||||
#include <GCM.h>
|
||||
#include "dsmr.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <dsmr_parser/util.h>
|
||||
|
||||
namespace esphome::dsmr {
|
||||
|
||||
static const char *const TAG = "dsmr";
|
||||
static constexpr auto &TAG = "dsmr";
|
||||
|
||||
static void log_callback(dsmr_parser::LogLevel level, const char *fmt, va_list args) {
|
||||
std::array<char, 256> buf;
|
||||
vsnprintf(buf.data(), buf.size(), fmt, args);
|
||||
switch (level) {
|
||||
case dsmr_parser::LogLevel::ERROR:
|
||||
ESP_LOGE(TAG, "%s", buf.data());
|
||||
break;
|
||||
case dsmr_parser::LogLevel::WARNING:
|
||||
ESP_LOGW(TAG, "%s", buf.data());
|
||||
break;
|
||||
case dsmr_parser::LogLevel::INFO:
|
||||
ESP_LOGI(TAG, "%s", buf.data());
|
||||
break;
|
||||
case dsmr_parser::LogLevel::VERBOSE:
|
||||
ESP_LOGV(TAG, "%s", buf.data());
|
||||
break;
|
||||
case dsmr_parser::LogLevel::VERY_VERBOSE:
|
||||
ESP_LOGVV(TAG, "%s", buf.data());
|
||||
break;
|
||||
case dsmr_parser::LogLevel::DEBUG:
|
||||
ESP_LOGD(TAG, "%s", buf.data());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void Dsmr::setup() {
|
||||
this->telegram_ = new char[this->max_telegram_len_]; // NOLINT
|
||||
dsmr_parser::Logger::set_log_function(log_callback);
|
||||
if (this->request_pin_ != nullptr) {
|
||||
this->request_pin_->setup();
|
||||
}
|
||||
}
|
||||
|
||||
void Dsmr::loop() {
|
||||
if (this->ready_to_request_data_()) {
|
||||
if (this->decryption_key_.empty()) {
|
||||
this->receive_telegram_();
|
||||
} else {
|
||||
this->receive_encrypted_telegram_();
|
||||
}
|
||||
if (!this->ready_to_request_data_()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this->encryption_enabled_) {
|
||||
this->receive_encrypted_telegram_();
|
||||
} else {
|
||||
this->receive_telegram_();
|
||||
}
|
||||
}
|
||||
|
||||
bool Dsmr::ready_to_request_data_() {
|
||||
// When using a request pin, then wait for the next request interval.
|
||||
if (this->request_pin_ != nullptr) {
|
||||
if (!this->requesting_data_ && this->request_interval_reached_()) {
|
||||
this->start_requesting_data_();
|
||||
}
|
||||
}
|
||||
// Otherwise, sink serial data until next request interval.
|
||||
else {
|
||||
if (this->request_interval_reached_()) {
|
||||
this->start_requesting_data_();
|
||||
}
|
||||
if (!this->requesting_data_) {
|
||||
this->drain_rx_buffer_();
|
||||
}
|
||||
if (!this->requesting_data_ && this->request_interval_reached_()) {
|
||||
this->start_requesting_data_();
|
||||
}
|
||||
return this->requesting_data_;
|
||||
}
|
||||
|
||||
bool Dsmr::request_interval_reached_() {
|
||||
bool Dsmr::request_interval_reached_() const {
|
||||
if (this->last_request_time_ == 0) {
|
||||
return true;
|
||||
}
|
||||
return millis() - this->last_request_time_ > this->request_interval_;
|
||||
}
|
||||
|
||||
bool Dsmr::receive_timeout_reached_() { return millis() - this->last_read_time_ > this->receive_timeout_; }
|
||||
|
||||
bool Dsmr::available_within_timeout_() {
|
||||
// Data are available for reading on the UART bus?
|
||||
// Then we can start reading right away.
|
||||
if (this->available()) {
|
||||
this->last_read_time_ = millis();
|
||||
return true;
|
||||
}
|
||||
// When we're not in the process of reading a telegram, then there is
|
||||
// no need to actively wait for new data to come in.
|
||||
if (!header_found_) {
|
||||
return false;
|
||||
}
|
||||
// A telegram is being read. The smart meter might not deliver a telegram
|
||||
// in one go, but instead send it in chunks with small pauses in between.
|
||||
// When the UART RX buffer cannot hold a full telegram, then make sure
|
||||
// that the UART read buffer does not overflow while other components
|
||||
// perform their work in their loop. Do this by not returning control to
|
||||
// the main loop, until the read timeout is reached.
|
||||
if (this->parent_->get_rx_buffer_size() < this->max_telegram_len_) {
|
||||
while (!this->receive_timeout_reached_()) {
|
||||
delay(5);
|
||||
if (this->available()) {
|
||||
this->last_read_time_ = millis();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// No new data has come in during the read timeout? Then stop reading the
|
||||
// telegram and start waiting for the next one to arrive.
|
||||
if (this->receive_timeout_reached_()) {
|
||||
ESP_LOGW(TAG, "Timeout while reading data for telegram");
|
||||
this->reset_telegram_();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void Dsmr::start_requesting_data_() {
|
||||
if (!this->requesting_data_) {
|
||||
if (this->request_pin_ != nullptr) {
|
||||
ESP_LOGV(TAG, "Start requesting data from P1 port");
|
||||
this->request_pin_->digital_write(true);
|
||||
} else {
|
||||
ESP_LOGV(TAG, "Start reading data from P1 port");
|
||||
}
|
||||
this->requesting_data_ = true;
|
||||
this->last_request_time_ = millis();
|
||||
if (this->requesting_data_) {
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGV(TAG, "Start reading data from P1 port");
|
||||
this->flush_rx_buffer_();
|
||||
|
||||
if (this->request_pin_ != nullptr) {
|
||||
ESP_LOGV(TAG, "Set request pin to 1");
|
||||
this->request_pin_->digital_write(true);
|
||||
}
|
||||
|
||||
this->requesting_data_ = true;
|
||||
this->last_request_time_ = millis();
|
||||
}
|
||||
|
||||
void Dsmr::stop_requesting_data_() {
|
||||
if (this->requesting_data_) {
|
||||
if (this->request_pin_ != nullptr) {
|
||||
ESP_LOGV(TAG, "Stop requesting data from P1 port");
|
||||
this->request_pin_->digital_write(false);
|
||||
} else {
|
||||
ESP_LOGV(TAG, "Stop reading data from P1 port");
|
||||
}
|
||||
this->drain_rx_buffer_();
|
||||
this->requesting_data_ = false;
|
||||
if (!this->requesting_data_) {
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGV(TAG, "Stop reading data from P1 port");
|
||||
if (this->request_pin_ != nullptr) {
|
||||
ESP_LOGV(TAG, "Set request pin to 0");
|
||||
this->request_pin_->digital_write(false);
|
||||
}
|
||||
this->requesting_data_ = false;
|
||||
}
|
||||
|
||||
void Dsmr::drain_rx_buffer_() {
|
||||
uint8_t buf[64];
|
||||
size_t avail;
|
||||
while ((avail = this->available()) > 0) {
|
||||
if (!this->read_array(buf, std::min(avail, sizeof(buf)))) {
|
||||
break;
|
||||
}
|
||||
void Dsmr::flush_rx_buffer_() {
|
||||
ESP_LOGV(TAG, "Flush UART RX buffer");
|
||||
while (!this->uart_read_chunk_().empty()) {
|
||||
}
|
||||
}
|
||||
|
||||
void Dsmr::reset_telegram_() {
|
||||
this->header_found_ = false;
|
||||
this->footer_found_ = false;
|
||||
this->bytes_read_ = 0;
|
||||
this->crypt_bytes_read_ = 0;
|
||||
this->crypt_telegram_len_ = 0;
|
||||
}
|
||||
|
||||
void Dsmr::receive_telegram_() {
|
||||
while (this->available_within_timeout_()) {
|
||||
// Read all available bytes in batches to reduce UART call overhead.
|
||||
uint8_t buf[64];
|
||||
size_t avail = this->available();
|
||||
while (avail > 0) {
|
||||
size_t to_read = std::min(avail, sizeof(buf));
|
||||
if (!this->read_array(buf, to_read))
|
||||
for (auto data = this->uart_read_chunk_(); !data.empty(); data = this->uart_read_chunk_()) {
|
||||
for (uint8_t byte : data) {
|
||||
const auto telegram = this->packet_accumulator_.process_byte(byte);
|
||||
if (!telegram) { // No full packet received yet
|
||||
continue;
|
||||
}
|
||||
if (this->parse_telegram_(telegram.value())) {
|
||||
return;
|
||||
avail -= to_read;
|
||||
|
||||
for (size_t i = 0; i < to_read; i++) {
|
||||
const char c = static_cast<char>(buf[i]);
|
||||
|
||||
// Find a new telegram header, i.e. forward slash.
|
||||
if (c == '/') {
|
||||
ESP_LOGV(TAG, "Header of telegram found");
|
||||
this->reset_telegram_();
|
||||
this->header_found_ = true;
|
||||
}
|
||||
if (!this->header_found_)
|
||||
continue;
|
||||
|
||||
// Check for buffer overflow.
|
||||
if (this->bytes_read_ >= this->max_telegram_len_) {
|
||||
this->reset_telegram_();
|
||||
ESP_LOGE(TAG, "Error: telegram larger than buffer (%d bytes)", this->max_telegram_len_);
|
||||
return;
|
||||
}
|
||||
|
||||
// Some v2.2 or v3 meters will send a new value which starts with '('
|
||||
// in a new line, while the value belongs to the previous ObisId. For
|
||||
// proper parsing, remove these new line characters.
|
||||
if (c == '(') {
|
||||
while (true) {
|
||||
auto previous_char = this->telegram_[this->bytes_read_ - 1];
|
||||
if (previous_char == '\n' || previous_char == '\r') {
|
||||
this->bytes_read_--;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store the byte in the buffer.
|
||||
this->telegram_[this->bytes_read_] = c;
|
||||
this->bytes_read_++;
|
||||
|
||||
// Check for a footer, i.e. exclamation mark, followed by a hex checksum.
|
||||
if (c == '!') {
|
||||
ESP_LOGV(TAG, "Footer of telegram found");
|
||||
this->footer_found_ = true;
|
||||
continue;
|
||||
}
|
||||
// Check for the end of the hex checksum, i.e. a newline.
|
||||
if (this->footer_found_ && c == '\n') {
|
||||
// Parse the telegram and publish sensor values.
|
||||
this->parse_telegram();
|
||||
this->reset_telegram_();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Dsmr::receive_encrypted_telegram_() {
|
||||
while (this->available_within_timeout_()) {
|
||||
// Read all available bytes in batches to reduce UART call overhead.
|
||||
uint8_t buf[64];
|
||||
size_t avail = this->available();
|
||||
while (avail > 0) {
|
||||
size_t to_read = std::min(avail, sizeof(buf));
|
||||
if (!this->read_array(buf, to_read))
|
||||
return;
|
||||
avail -= to_read;
|
||||
|
||||
for (size_t i = 0; i < to_read; i++) {
|
||||
const char c = static_cast<char>(buf[i]);
|
||||
|
||||
// Find a new telegram start byte.
|
||||
if (!this->header_found_) {
|
||||
if ((uint8_t) c != 0xDB) {
|
||||
continue;
|
||||
}
|
||||
ESP_LOGV(TAG, "Start byte 0xDB of encrypted telegram found");
|
||||
this->reset_telegram_();
|
||||
this->header_found_ = true;
|
||||
}
|
||||
|
||||
// Check for buffer overflow.
|
||||
if (this->crypt_bytes_read_ >= this->max_telegram_len_) {
|
||||
this->reset_telegram_();
|
||||
ESP_LOGE(TAG, "Error: encrypted telegram larger than buffer (%d bytes)", this->max_telegram_len_);
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the byte in the buffer.
|
||||
this->crypt_telegram_[this->crypt_bytes_read_] = c;
|
||||
this->crypt_bytes_read_++;
|
||||
|
||||
// Read the length of the incoming encrypted telegram.
|
||||
if (this->crypt_telegram_len_ == 0 && this->crypt_bytes_read_ > 20) {
|
||||
// Complete header + data bytes
|
||||
this->crypt_telegram_len_ = 13 + (this->crypt_telegram_[11] << 8 | this->crypt_telegram_[12]);
|
||||
ESP_LOGV(TAG, "Encrypted telegram length: %d bytes", this->crypt_telegram_len_);
|
||||
}
|
||||
|
||||
// Check for the end of the encrypted telegram.
|
||||
if (this->crypt_telegram_len_ == 0 || this->crypt_bytes_read_ != this->crypt_telegram_len_) {
|
||||
continue;
|
||||
}
|
||||
ESP_LOGV(TAG, "End of encrypted telegram found");
|
||||
|
||||
// Decrypt the encrypted telegram.
|
||||
GCM<AES128> *gcmaes128{new GCM<AES128>()};
|
||||
gcmaes128->setKey(this->decryption_key_.data(), gcmaes128->keySize());
|
||||
// the iv is 8 bytes of the system title + 4 bytes frame counter
|
||||
// system title is at byte 2 and frame counter at byte 15
|
||||
for (int i = 10; i < 14; i++)
|
||||
this->crypt_telegram_[i] = this->crypt_telegram_[i + 4];
|
||||
constexpr uint16_t iv_size{12};
|
||||
gcmaes128->setIV(&this->crypt_telegram_[2], iv_size);
|
||||
gcmaes128->decrypt(reinterpret_cast<uint8_t *>(this->telegram_),
|
||||
// the ciphertext start at byte 18
|
||||
&this->crypt_telegram_[18],
|
||||
// cipher size
|
||||
this->crypt_bytes_read_ - 17);
|
||||
delete gcmaes128; // NOLINT(cppcoreguidelines-owning-memory)
|
||||
|
||||
this->bytes_read_ = strnlen(this->telegram_, this->max_telegram_len_);
|
||||
ESP_LOGV(TAG, "Decrypted telegram size: %d bytes", this->bytes_read_);
|
||||
ESP_LOGVV(TAG, "Decrypted telegram: %s", this->telegram_);
|
||||
|
||||
// Parse the decrypted telegram and publish sensor values.
|
||||
this->parse_telegram();
|
||||
this->reset_telegram_();
|
||||
return;
|
||||
for (auto data = this->uart_read_chunk_(); !data.empty(); data = this->uart_read_chunk_()) {
|
||||
for (uint8_t byte : data) {
|
||||
if (this->buffer_pos_ >= this->buffer_.size()) { // Reset buffer if overflow
|
||||
ESP_LOGW(TAG, "Encrypted buffer overflow, resetting");
|
||||
this->buffer_pos_ = 0;
|
||||
}
|
||||
|
||||
this->buffer_[this->buffer_pos_] = byte;
|
||||
this->buffer_pos_++;
|
||||
}
|
||||
this->last_read_time_ = millis();
|
||||
}
|
||||
|
||||
// Detect inter-frame delay. If no byte is received for more than receive_timeout, then the packet is complete.
|
||||
if (millis() - this->last_read_time_ > this->receive_timeout_ && this->buffer_pos_ > 0) {
|
||||
ESP_LOGV(TAG, "Encrypted telegram received (%zu bytes)", this->buffer_pos_);
|
||||
|
||||
const auto telegram = this->dlms_decryptor_.decrypt_inplace({this->buffer_.data(), this->buffer_pos_});
|
||||
|
||||
// Reset buffer position for the next packet
|
||||
this->buffer_pos_ = 0;
|
||||
this->last_read_time_ = 0;
|
||||
|
||||
if (!telegram) { // decryption failed
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse and publish the telegram
|
||||
this->parse_telegram_(telegram.value());
|
||||
}
|
||||
}
|
||||
|
||||
bool Dsmr::parse_telegram() {
|
||||
MyData data;
|
||||
ESP_LOGV(TAG, "Trying to parse telegram");
|
||||
bool Dsmr::parse_telegram_(const dsmr_parser::DsmrUnencryptedTelegram &telegram) {
|
||||
this->stop_requesting_data_();
|
||||
|
||||
const auto &res = dsmr_parser::P1Parser::parse(
|
||||
data, this->telegram_, this->bytes_read_, false,
|
||||
this->crc_check_); // Parse telegram according to data definition. Ignore unknown values.
|
||||
if (res.err) {
|
||||
// Parsing error, show it
|
||||
auto err_str = res.fullError(this->telegram_, this->telegram_ + this->bytes_read_);
|
||||
ESP_LOGE(TAG, "%s", err_str.c_str());
|
||||
return false;
|
||||
} else {
|
||||
this->status_clear_warning();
|
||||
this->publish_sensors(data);
|
||||
ESP_LOGV(TAG, "Trying to parse telegram (%zu bytes)", telegram.content().size());
|
||||
ESP_LOGVV(TAG, "Telegram content:\n %.*s", static_cast<int>(telegram.content().size()), telegram.content().data());
|
||||
|
||||
// publish the telegram, after publishing the sensors so it can also trigger action based on latest values
|
||||
if (this->s_telegram_ != nullptr) {
|
||||
this->s_telegram_->publish_state(this->telegram_, this->bytes_read_);
|
||||
}
|
||||
return true;
|
||||
MyData data;
|
||||
if (const bool res = dsmr_parser::DsmrParser::parse(data, telegram); !res) {
|
||||
ESP_LOGE(TAG, "Failed to parse telegram");
|
||||
return false;
|
||||
}
|
||||
|
||||
this->status_clear_warning();
|
||||
this->publish_sensors(data);
|
||||
|
||||
// Publish the telegram, after publishing the sensors so it can also trigger action based on latest values
|
||||
if (this->s_telegram_ != nullptr) {
|
||||
this->s_telegram_->publish_state(telegram.content().data(), telegram.content().size());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void Dsmr::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"DSMR:\n"
|
||||
" Max telegram length: %d\n"
|
||||
" Max telegram length: %zu\n"
|
||||
" Receive timeout: %.1fs",
|
||||
this->max_telegram_len_, this->receive_timeout_ / 1e3f);
|
||||
this->buffer_.size(), this->receive_timeout_ / 1e3f);
|
||||
if (this->request_pin_ != nullptr) {
|
||||
LOG_PIN(" Request Pin: ", this->request_pin_);
|
||||
}
|
||||
@@ -324,30 +192,37 @@ void Dsmr::dump_config() {
|
||||
DSMR_TEXT_SENSOR_LIST(DSMR_LOG_TEXT_SENSOR, )
|
||||
}
|
||||
|
||||
void Dsmr::set_decryption_key(const char *decryption_key) {
|
||||
void Dsmr::set_decryption_key_(const char *decryption_key) {
|
||||
if (decryption_key == nullptr || decryption_key[0] == '\0') {
|
||||
ESP_LOGI(TAG, "Disabling decryption");
|
||||
this->decryption_key_.clear();
|
||||
if (this->crypt_telegram_ != nullptr) {
|
||||
delete[] this->crypt_telegram_;
|
||||
this->crypt_telegram_ = nullptr;
|
||||
}
|
||||
this->encryption_enabled_ = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!parse_hex(decryption_key, this->decryption_key_, 16)) {
|
||||
ESP_LOGE(TAG, "Error, decryption key must be 32 hex characters");
|
||||
this->decryption_key_.clear();
|
||||
auto key = dsmr_parser::Aes128GcmDecryptionKey::from_hex(decryption_key);
|
||||
if (!key) {
|
||||
ESP_LOGE(TAG, "Error, decryption key has incorrect format");
|
||||
this->encryption_enabled_ = false;
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Decryption key is set");
|
||||
// Verbose level prints decryption key
|
||||
ESP_LOGV(TAG, "Using decryption key: %s", decryption_key);
|
||||
|
||||
if (this->crypt_telegram_ == nullptr) {
|
||||
this->crypt_telegram_ = new uint8_t[this->max_telegram_len_]; // NOLINT
|
||||
this->gcm_decryptor_.set_encryption_key(key.value());
|
||||
this->encryption_enabled_ = true;
|
||||
}
|
||||
|
||||
std::span<uint8_t> Dsmr::uart_read_chunk_() {
|
||||
const auto avail = this->available();
|
||||
if (avail == 0) {
|
||||
return {};
|
||||
}
|
||||
size_t to_read = std::min(avail, uart_chunk_reading_buf_.size());
|
||||
if (!this->read_array(uart_chunk_reading_buf_.data(), to_read)) {
|
||||
return {};
|
||||
}
|
||||
return {uart_chunk_reading_buf_.data(), to_read};
|
||||
}
|
||||
|
||||
} // namespace esphome::dsmr
|
||||
|
||||
#endif
|
||||
|
||||
@@ -1,31 +1,46 @@
|
||||
#pragma once
|
||||
|
||||
// Ignore Zephyr. It doesn't have any encryption library.
|
||||
#if defined(USE_ESP32) || defined(USE_ARDUINO) || defined(USE_HOST)
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#include "esphome/components/text_sensor/text_sensor.h"
|
||||
#include "esphome/components/uart/uart.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <dsmr_parser/dlms_packet_decryptor.h>
|
||||
#include <dsmr_parser/fields.h>
|
||||
#include <dsmr_parser/packet_accumulator.h>
|
||||
#include <dsmr_parser/parser.h>
|
||||
#include <array>
|
||||
#include <span>
|
||||
#include <vector>
|
||||
|
||||
#if __has_include(<psa/crypto.h>)
|
||||
#include <dsmr_parser/decryption/aes128gcm_tfpsa.h>
|
||||
#elif __has_include(<mbedtls/gcm.h>)
|
||||
#if __has_include(<mbedtls/esp_config.h>)
|
||||
#include <mbedtls/esp_config.h>
|
||||
#endif
|
||||
#include <dsmr_parser/decryption/aes128gcm_mbedtls.h>
|
||||
#elif __has_include(<bearssl/bearssl.h>)
|
||||
#include <dsmr_parser/decryption/aes128gcm_bearssl.h>
|
||||
#else
|
||||
#error "The platform doesn't provide a compatible encryption library for dsmr_parser"
|
||||
#endif
|
||||
|
||||
namespace esphome::dsmr {
|
||||
|
||||
using namespace dsmr_parser::fields;
|
||||
|
||||
// DSMR_**_LIST generated by ESPHome and written in esphome/core/defines
|
||||
|
||||
#if !defined(DSMR_SENSOR_LIST) && !defined(DSMR_TEXT_SENSOR_LIST)
|
||||
// Neither set, set it to a dummy value to not break build
|
||||
#define DSMR_TEXT_SENSOR_LIST(F, SEP) F(identification)
|
||||
#endif
|
||||
|
||||
#if defined(DSMR_SENSOR_LIST) && defined(DSMR_TEXT_SENSOR_LIST)
|
||||
#define DSMR_BOTH ,
|
||||
#if __has_include(<psa/crypto.h>)
|
||||
using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmTfPsa;
|
||||
#elif __has_include(<mbedtls/gcm.h>)
|
||||
using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmMbedTls;
|
||||
#else
|
||||
#define DSMR_BOTH
|
||||
using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmBearSsl;
|
||||
#endif
|
||||
|
||||
using namespace dsmr_parser::fields;
|
||||
|
||||
#ifndef DSMR_SENSOR_LIST
|
||||
#define DSMR_SENSOR_LIST(F, SEP)
|
||||
#endif
|
||||
@@ -34,21 +49,33 @@ using namespace dsmr_parser::fields;
|
||||
#define DSMR_TEXT_SENSOR_LIST(F, SEP)
|
||||
#endif
|
||||
|
||||
#define DSMR_DATA_SENSOR(s) s
|
||||
#define DSMR_IDENTITY(s) s
|
||||
#define DSMR_COMMA ,
|
||||
#define DSMR_PREPEND_COMMA(...) __VA_OPT__(, ) __VA_ARGS__
|
||||
|
||||
using MyData = dsmr_parser::ParsedData<DSMR_TEXT_SENSOR_LIST(DSMR_DATA_SENSOR, DSMR_COMMA)
|
||||
DSMR_BOTH DSMR_SENSOR_LIST(DSMR_DATA_SENSOR, DSMR_COMMA)>;
|
||||
#ifdef DSMR_TEXT_SENSOR_LIST_DEFINED
|
||||
using MyData = dsmr_parser::ParsedData<DSMR_TEXT_SENSOR_LIST(DSMR_IDENTITY, DSMR_COMMA)
|
||||
DSMR_PREPEND_COMMA(DSMR_SENSOR_LIST(DSMR_IDENTITY, DSMR_COMMA))>;
|
||||
#else
|
||||
using MyData = dsmr_parser::ParsedData<DSMR_SENSOR_LIST(DSMR_IDENTITY, DSMR_COMMA)>;
|
||||
#endif
|
||||
|
||||
class Dsmr : public Component, public uart::UARTDevice {
|
||||
public:
|
||||
Dsmr(uart::UARTComponent *uart, bool crc_check) : uart::UARTDevice(uart), crc_check_(crc_check) {}
|
||||
Dsmr(uart::UARTComponent *uart, bool crc_check, size_t max_telegram_length, uint32_t request_interval,
|
||||
uint32_t receive_timeout, GPIOPin *request_pin, const char *decryption_key)
|
||||
: uart::UARTDevice(uart),
|
||||
request_interval_(request_interval),
|
||||
receive_timeout_(receive_timeout),
|
||||
request_pin_(request_pin),
|
||||
buffer_(max_telegram_length),
|
||||
packet_accumulator_(buffer_, crc_check) {
|
||||
this->set_decryption_key_(decryption_key);
|
||||
}
|
||||
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
|
||||
bool parse_telegram();
|
||||
|
||||
void publish_sensors(MyData &data) {
|
||||
#define DSMR_PUBLISH_SENSOR(s) \
|
||||
if (data.s##_present && this->s_##s##_ != nullptr) \
|
||||
@@ -57,20 +84,15 @@ class Dsmr : public Component, public uart::UARTDevice {
|
||||
|
||||
#define DSMR_PUBLISH_TEXT_SENSOR(s) \
|
||||
if (data.s##_present && this->s_##s##_ != nullptr) \
|
||||
s_##s##_->publish_state(data.s.c_str());
|
||||
s_##s##_->publish_state(data.s.data(), data.s.size());
|
||||
DSMR_TEXT_SENSOR_LIST(DSMR_PUBLISH_TEXT_SENSOR, )
|
||||
};
|
||||
|
||||
void dump_config() override;
|
||||
|
||||
void set_decryption_key(const char *decryption_key);
|
||||
// Remove before 2026.8.0
|
||||
ESPDEPRECATED("Pass .c_str() - e.g. set_decryption_key(key.c_str()). Removed in 2026.8.0", "2026.2.0")
|
||||
void set_decryption_key(const std::string &decryption_key) { this->set_decryption_key(decryption_key.c_str()); }
|
||||
void set_max_telegram_length(size_t length) { this->max_telegram_len_ = length; }
|
||||
void set_request_pin(GPIOPin *request_pin) { this->request_pin_ = request_pin; }
|
||||
void set_request_interval(uint32_t interval) { this->request_interval_ = interval; }
|
||||
void set_receive_timeout(uint32_t timeout) { this->receive_timeout_ = timeout; }
|
||||
ESPDEPRECATED("Use 'decryption_key' configuration parameter. This method will be removed in 2026.8.0", "2026.2.0")
|
||||
void set_decryption_key(const std::string &decryption_key) { this->set_decryption_key_(decryption_key.c_str()); }
|
||||
|
||||
// Sensor setters
|
||||
#define DSMR_SET_SENSOR(s) \
|
||||
@@ -85,56 +107,40 @@ class Dsmr : public Component, public uart::UARTDevice {
|
||||
void set_telegram(text_sensor::TextSensor *sensor) { s_telegram_ = sensor; }
|
||||
|
||||
protected:
|
||||
void set_decryption_key_(const char *decryption_key);
|
||||
void receive_telegram_();
|
||||
void receive_encrypted_telegram_();
|
||||
void reset_telegram_();
|
||||
void drain_rx_buffer_();
|
||||
void flush_rx_buffer_();
|
||||
|
||||
/// Wait for UART data to become available within the read timeout.
|
||||
///
|
||||
/// The smart meter might provide data in chunks, causing available() to
|
||||
/// return 0. When we're already reading a telegram, then we don't return
|
||||
/// right away (to handle further data in an upcoming loop) but wait a
|
||||
/// little while using this method to see if more data are incoming.
|
||||
/// By not returning, we prevent other components from taking so much
|
||||
/// time that the UART RX buffer overflows and bytes of the telegram get
|
||||
/// lost in the process.
|
||||
bool available_within_timeout_();
|
||||
|
||||
// Request telegram
|
||||
uint32_t request_interval_;
|
||||
bool request_interval_reached_();
|
||||
GPIOPin *request_pin_{nullptr};
|
||||
uint32_t last_request_time_{0};
|
||||
bool requesting_data_{false};
|
||||
bool parse_telegram_(const dsmr_parser::DsmrUnencryptedTelegram &telegram);
|
||||
bool request_interval_reached_() const;
|
||||
bool ready_to_request_data_();
|
||||
void start_requesting_data_();
|
||||
void stop_requesting_data_();
|
||||
std::span<uint8_t> uart_read_chunk_();
|
||||
|
||||
// Read telegram
|
||||
// Config
|
||||
uint32_t request_interval_;
|
||||
uint32_t receive_timeout_;
|
||||
bool receive_timeout_reached_();
|
||||
size_t max_telegram_len_;
|
||||
char *telegram_{nullptr};
|
||||
size_t bytes_read_{0};
|
||||
uint8_t *crypt_telegram_{nullptr};
|
||||
size_t crypt_telegram_len_{0};
|
||||
size_t crypt_bytes_read_{0};
|
||||
uint32_t last_read_time_{0};
|
||||
bool header_found_{false};
|
||||
bool footer_found_{false};
|
||||
|
||||
// handled outside dsmr
|
||||
GPIOPin *request_pin_{nullptr};
|
||||
text_sensor::TextSensor *s_telegram_{nullptr};
|
||||
|
||||
// Sensor member pointers
|
||||
#define DSMR_DECLARE_SENSOR(s) sensor::Sensor *s_##s##_{nullptr};
|
||||
DSMR_SENSOR_LIST(DSMR_DECLARE_SENSOR, )
|
||||
|
||||
#define DSMR_DECLARE_TEXT_SENSOR(s) text_sensor::TextSensor *s_##s##_{nullptr};
|
||||
DSMR_TEXT_SENSOR_LIST(DSMR_DECLARE_TEXT_SENSOR, )
|
||||
|
||||
std::vector<uint8_t> decryption_key_{};
|
||||
bool crc_check_;
|
||||
// State
|
||||
uint32_t last_request_time_{0};
|
||||
uint32_t last_read_time_{0};
|
||||
bool requesting_data_{false};
|
||||
bool encryption_enabled_{false};
|
||||
size_t buffer_pos_{0};
|
||||
std::vector<uint8_t> buffer_;
|
||||
dsmr_parser::PacketAccumulator packet_accumulator_;
|
||||
Aes128GcmDecryptorImpl gcm_decryptor_;
|
||||
dsmr_parser::DlmsPacketDecryptor dlms_decryptor_{gcm_decryptor_};
|
||||
std::array<uint8_t, 256> uart_chunk_reading_buf_;
|
||||
};
|
||||
} // namespace esphome::dsmr
|
||||
|
||||
#endif
|
||||
|
||||
@@ -10,6 +10,7 @@ from esphome.const import (
|
||||
DEVICE_CLASS_FREQUENCY,
|
||||
DEVICE_CLASS_GAS,
|
||||
DEVICE_CLASS_POWER,
|
||||
DEVICE_CLASS_POWER_FACTOR,
|
||||
DEVICE_CLASS_REACTIVE_POWER,
|
||||
DEVICE_CLASS_VOLTAGE,
|
||||
DEVICE_CLASS_WATER,
|
||||
@@ -119,6 +120,42 @@ CONFIG_SCHEMA = cv.Schema(
|
||||
device_class=DEVICE_CLASS_ENERGY,
|
||||
state_class=STATE_CLASS_TOTAL_INCREASING,
|
||||
),
|
||||
cv.Optional("energy_delivered_tariff1_il"): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_KILOWATT_HOURS,
|
||||
accuracy_decimals=3,
|
||||
device_class=DEVICE_CLASS_ENERGY,
|
||||
state_class=STATE_CLASS_TOTAL_INCREASING,
|
||||
),
|
||||
cv.Optional("energy_delivered_tariff2_il"): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_KILOWATT_HOURS,
|
||||
accuracy_decimals=3,
|
||||
device_class=DEVICE_CLASS_ENERGY,
|
||||
state_class=STATE_CLASS_TOTAL_INCREASING,
|
||||
),
|
||||
cv.Optional("energy_delivered_tariff3_il"): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_KILOWATT_HOURS,
|
||||
accuracy_decimals=3,
|
||||
device_class=DEVICE_CLASS_ENERGY,
|
||||
state_class=STATE_CLASS_TOTAL_INCREASING,
|
||||
),
|
||||
cv.Optional("energy_returned_tariff1_il"): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_KILOWATT_HOURS,
|
||||
accuracy_decimals=3,
|
||||
device_class=DEVICE_CLASS_ENERGY,
|
||||
state_class=STATE_CLASS_TOTAL_INCREASING,
|
||||
),
|
||||
cv.Optional("energy_returned_tariff2_il"): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_KILOWATT_HOURS,
|
||||
accuracy_decimals=3,
|
||||
device_class=DEVICE_CLASS_ENERGY,
|
||||
state_class=STATE_CLASS_TOTAL_INCREASING,
|
||||
),
|
||||
cv.Optional("energy_returned_tariff3_il"): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_KILOWATT_HOURS,
|
||||
accuracy_decimals=3,
|
||||
device_class=DEVICE_CLASS_ENERGY,
|
||||
state_class=STATE_CLASS_TOTAL_INCREASING,
|
||||
),
|
||||
cv.Optional("total_imported_energy"): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
|
||||
accuracy_decimals=3,
|
||||
@@ -511,6 +548,12 @@ CONFIG_SCHEMA = cv.Schema(
|
||||
device_class=DEVICE_CLASS_GAS,
|
||||
state_class=STATE_CLASS_TOTAL_INCREASING,
|
||||
),
|
||||
cv.Optional("gas_delivered_gj"): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_GIGA_JOULE,
|
||||
accuracy_decimals=3,
|
||||
device_class=DEVICE_CLASS_ENERGY,
|
||||
state_class=STATE_CLASS_TOTAL_INCREASING,
|
||||
),
|
||||
cv.Optional("water_delivered"): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_CUBIC_METER,
|
||||
accuracy_decimals=3,
|
||||
@@ -614,6 +657,12 @@ CONFIG_SCHEMA = cv.Schema(
|
||||
device_class=DEVICE_CLASS_POWER,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional("active_demand_net"): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_KILOWATT,
|
||||
accuracy_decimals=3,
|
||||
device_class=DEVICE_CLASS_POWER,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional("active_demand_abs"): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_KILOWATT,
|
||||
accuracy_decimals=3,
|
||||
@@ -728,6 +777,37 @@ CONFIG_SCHEMA = cv.Schema(
|
||||
device_class=DEVICE_CLASS_POWER,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional("power_factor"): sensor.sensor_schema(
|
||||
accuracy_decimals=3,
|
||||
device_class=DEVICE_CLASS_POWER_FACTOR,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional("power_factor_l1"): sensor.sensor_schema(
|
||||
accuracy_decimals=3,
|
||||
device_class=DEVICE_CLASS_POWER_FACTOR,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional("power_factor_l2"): sensor.sensor_schema(
|
||||
accuracy_decimals=3,
|
||||
device_class=DEVICE_CLASS_POWER_FACTOR,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional("power_factor_l3"): sensor.sensor_schema(
|
||||
accuracy_decimals=3,
|
||||
device_class=DEVICE_CLASS_POWER_FACTOR,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional("min_power_factor"): sensor.sensor_schema(
|
||||
accuracy_decimals=3,
|
||||
device_class=DEVICE_CLASS_POWER_FACTOR,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional("period_3_for_instantaneous_values"): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_SECOND,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_DURATION,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
@@ -746,6 +826,7 @@ async def to_code(config):
|
||||
sensors.append(f"F({key})")
|
||||
|
||||
if sensors:
|
||||
cg.add_define("DSMR_SENSOR_LIST_DEFINED")
|
||||
cg.add_define(
|
||||
"DSMR_SENSOR_LIST(F, sep)", cg.RawExpression(" sep ".join(sensors))
|
||||
)
|
||||
|
||||
@@ -15,7 +15,9 @@ CONFIG_SCHEMA = cv.Schema(
|
||||
cv.Optional("p1_version_be"): text_sensor.text_sensor_schema(),
|
||||
cv.Optional("timestamp"): text_sensor.text_sensor_schema(),
|
||||
cv.Optional("electricity_tariff"): text_sensor.text_sensor_schema(),
|
||||
cv.Optional("electricity_tariff_il"): text_sensor.text_sensor_schema(),
|
||||
cv.Optional("electricity_failure_log"): text_sensor.text_sensor_schema(),
|
||||
cv.Optional("electricity_failure_log_il"): text_sensor.text_sensor_schema(),
|
||||
cv.Optional("message_short"): text_sensor.text_sensor_schema(),
|
||||
cv.Optional("message_long"): text_sensor.text_sensor_schema(),
|
||||
cv.Optional("equipment_id"): text_sensor.text_sensor_schema(),
|
||||
@@ -52,6 +54,7 @@ async def to_code(config):
|
||||
text_sensors.append(f"F({key})")
|
||||
|
||||
if text_sensors:
|
||||
cg.add_define("DSMR_TEXT_SENSOR_LIST_DEFINED")
|
||||
cg.add_define(
|
||||
"DSMR_TEXT_SENSOR_LIST(F, sep)",
|
||||
cg.RawExpression(" sep ".join(text_sensors)),
|
||||
|
||||
@@ -33,6 +33,7 @@ from esphome.const import (
|
||||
CONF_TYPE,
|
||||
CONF_VARIANT,
|
||||
CONF_VERSION,
|
||||
CONF_WATCHDOG_TIMEOUT,
|
||||
KEY_CORE,
|
||||
KEY_FRAMEWORK_VERSION,
|
||||
KEY_NAME,
|
||||
@@ -1507,6 +1508,10 @@ CONFIG_SCHEMA = cv.All(
|
||||
),
|
||||
cv.Optional(CONF_VARIANT): cv.one_of(*VARIANTS, upper=True),
|
||||
cv.Optional(CONF_FRAMEWORK): FRAMEWORK_SCHEMA,
|
||||
cv.Optional(CONF_WATCHDOG_TIMEOUT, default="5s"): cv.All(
|
||||
cv.positive_time_period_seconds,
|
||||
cv.Range(min=cv.TimePeriod(seconds=5), max=cv.TimePeriod(seconds=60)),
|
||||
),
|
||||
}
|
||||
),
|
||||
_detect_variant,
|
||||
@@ -1874,6 +1879,10 @@ async def to_code(config):
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_PANIC", True)
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0", False)
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1", False)
|
||||
add_idf_sdkconfig_option(
|
||||
"CONFIG_ESP_TASK_WDT_TIMEOUT_S",
|
||||
config[CONF_WATCHDOG_TIMEOUT].total_seconds,
|
||||
)
|
||||
|
||||
# Disable dynamic log level control to save memory
|
||||
add_idf_sdkconfig_option("CONFIG_LOG_DYNAMIC_LEVEL_CONTROL", False)
|
||||
|
||||
@@ -23,7 +23,26 @@ extern "C" __attribute__((weak)) void initArduino() {}
|
||||
namespace esphome {
|
||||
|
||||
void HOT yield() { vPortYield(); }
|
||||
uint32_t IRAM_ATTR HOT millis() { return micros_to_millis(static_cast<uint64_t>(esp_timer_get_time())); }
|
||||
// Use xTaskGetTickCount() when tick rate is 1 kHz (ESPHome's default via sdkconfig),
|
||||
// falling back to esp_timer for non-standard rates. IRAM_ATTR is required because
|
||||
// Wiegand and ZyAura call millis() from IRAM_ATTR ISR handlers on ESP32.
|
||||
// xTaskGetTickCountFromISR() is used in ISR context to satisfy the FreeRTOS API contract.
|
||||
uint32_t IRAM_ATTR HOT millis() {
|
||||
#if CONFIG_FREERTOS_HZ == 1000
|
||||
if (xPortInIsrContext()) [[unlikely]] {
|
||||
return xTaskGetTickCountFromISR();
|
||||
}
|
||||
return xTaskGetTickCount();
|
||||
#else
|
||||
return micros_to_millis(static_cast<uint64_t>(esp_timer_get_time()));
|
||||
#endif
|
||||
}
|
||||
// millis_64() stays on esp_timer — a different clock from xTaskGetTickCount(). This is
|
||||
// safe because the two are never cross-compared: millis() values are only used for
|
||||
// millis()-vs-millis() deltas (feed_wdt, warn_blocking, component start time), while
|
||||
// millis_64() is used by the Scheduler and uptime sensors. On ESP32 (USE_NATIVE_64BIT_TIME),
|
||||
// Scheduler::millis_64_from_(now) discards the 32-bit now and calls millis_64() directly,
|
||||
// so the Scheduler is internally consistent on the esp_timer clock.
|
||||
uint64_t HOT millis_64() { return micros_to_millis<uint64_t>(static_cast<uint64_t>(esp_timer_get_time())); }
|
||||
void HOT delay(uint32_t ms) { vTaskDelay(ms / portTICK_PERIOD_MS); }
|
||||
uint32_t IRAM_ATTR HOT micros() { return (uint32_t) esp_timer_get_time(); }
|
||||
@@ -65,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();
|
||||
|
||||
@@ -257,11 +257,9 @@ bool ESP32BLE::ble_setup_() {
|
||||
|
||||
if (this->name_ != nullptr) {
|
||||
if (App.is_name_add_mac_suffix_enabled()) {
|
||||
// MAC address length: 12 hex chars + null terminator
|
||||
constexpr size_t mac_address_len = 13;
|
||||
// MAC address suffix length (last 6 characters of 12-char MAC address string)
|
||||
constexpr size_t mac_address_suffix_len = 6;
|
||||
char mac_addr[mac_address_len];
|
||||
char mac_addr[MAC_ADDRESS_BUFFER_SIZE];
|
||||
get_mac_address_into_buffer(mac_addr);
|
||||
const char *mac_suffix_ptr = mac_addr + mac_address_suffix_len;
|
||||
make_name_with_suffix_to(name_buffer, sizeof(name_buffer), this->name_, strlen(this->name_), '-', mac_suffix_ptr,
|
||||
|
||||
@@ -150,10 +150,14 @@ async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
cg.add(var.set_port(config[CONF_PORT]))
|
||||
|
||||
# Password could be set to an empty string and we can assume that means no password
|
||||
if config.get(CONF_PASSWORD):
|
||||
cg.add(var.set_auth_password(config[CONF_PASSWORD]))
|
||||
# Compile the auth path whenever `password:` is present in YAML, even if empty.
|
||||
# An empty password opts in to the auth code path so set_auth_password() can be
|
||||
# called at runtime (e.g. to rotate the password from a lambda). When `password:`
|
||||
# is omitted entirely, the auth path is excluded to save flash on small devices.
|
||||
if CONF_PASSWORD in config:
|
||||
cg.add_define("USE_OTA_PASSWORD")
|
||||
if config[CONF_PASSWORD]:
|
||||
cg.add(var.set_auth_password(config[CONF_PASSWORD]))
|
||||
cg.add_define("USE_OTA_VERSION", config[CONF_VERSION])
|
||||
# Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it.
|
||||
cg.add_build_flag("-DUSE_OTA_PLATFORM_ESPHOME")
|
||||
|
||||
@@ -28,6 +28,14 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
|
||||
};
|
||||
#ifdef USE_OTA_PASSWORD
|
||||
void set_auth_password(const std::string &password) { password_ = password; }
|
||||
#else
|
||||
// Stub so lambdas referencing set_auth_password() produce a clear error instead of
|
||||
// a cryptic "no member" diagnostic. Only fires if the stub is actually instantiated.
|
||||
template<bool B = false> void set_auth_password(const std::string &) {
|
||||
static_assert(B, "set_auth_password() requires the OTA auth path to be compiled. "
|
||||
"Add 'password: \"\"' (empty string) to your 'ota: - platform: esphome' "
|
||||
"config to enable runtime password rotation.");
|
||||
}
|
||||
#endif // USE_OTA_PASSWORD
|
||||
|
||||
/// Manually set the port OTA should listen on
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -33,13 +33,16 @@ AUTO_LOAD = ["audio"]
|
||||
CODEOWNERS = ["@jesserockz", "@kahrendt"]
|
||||
DEPENDENCIES = ["i2s_audio"]
|
||||
|
||||
I2SAudioSpeaker = i2s_audio_ns.class_(
|
||||
"I2SAudioSpeaker", cg.Component, speaker.Speaker, I2SAudioOut
|
||||
I2SAudioSpeakerBase = i2s_audio_ns.class_(
|
||||
"I2SAudioSpeakerBase", cg.Component, speaker.Speaker, I2SAudioOut
|
||||
)
|
||||
I2SAudioSpeaker = i2s_audio_ns.class_("I2SAudioSpeaker", I2SAudioSpeakerBase)
|
||||
|
||||
CONF_DAC_TYPE = "dac_type"
|
||||
CONF_I2S_COMM_FMT = "i2s_comm_fmt"
|
||||
|
||||
I2SCommFmt = i2s_audio_ns.enum("I2SCommFmt", is_class=True)
|
||||
|
||||
i2s_dac_mode_t = cg.global_ns.enum("i2s_dac_mode_t")
|
||||
INTERNAL_DAC_OPTIONS = {
|
||||
CONF_LEFT: i2s_dac_mode_t.I2S_DAC_CHANNEL_LEFT_EN,
|
||||
@@ -183,11 +186,11 @@ async def to_code(config):
|
||||
await speaker.register_speaker(var, config)
|
||||
|
||||
cg.add(var.set_dout_pin(config[CONF_I2S_DOUT_PIN]))
|
||||
fmt = "std" # equals stand_i2s, stand_pcm_long, i2s_msb, pcm_long
|
||||
fmt = I2SCommFmt.STANDARD # equals stand_i2s, stand_pcm_long, i2s_msb, pcm_long
|
||||
if config[CONF_I2S_COMM_FMT] in ["stand_msb", "i2s_lsb"]:
|
||||
fmt = "msb"
|
||||
fmt = I2SCommFmt.MSB
|
||||
elif config[CONF_I2S_COMM_FMT] in ["stand_pcm_short", "pcm_short", "pcm"]:
|
||||
fmt = "pcm"
|
||||
fmt = I2SCommFmt.PCM
|
||||
cg.add(var.set_i2s_comm_fmt(fmt))
|
||||
if config[CONF_TIMEOUT] != CONF_NEVER:
|
||||
cg.add(var.set_timeout(config[CONF_TIMEOUT]))
|
||||
|
||||
@@ -13,36 +13,10 @@
|
||||
|
||||
#include "esp_timer.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace i2s_audio {
|
||||
|
||||
static const uint32_t DMA_BUFFER_DURATION_MS = 15;
|
||||
static const size_t DMA_BUFFERS_COUNT = 4;
|
||||
|
||||
static const size_t TASK_STACK_SIZE = 4096;
|
||||
static const ssize_t TASK_PRIORITY = 19;
|
||||
|
||||
static const size_t I2S_EVENT_QUEUE_COUNT = DMA_BUFFERS_COUNT + 1;
|
||||
namespace esphome::i2s_audio {
|
||||
|
||||
static const char *const TAG = "i2s_audio.speaker";
|
||||
|
||||
enum SpeakerEventGroupBits : uint32_t {
|
||||
COMMAND_START = (1 << 0), // indicates loop should start speaker task
|
||||
COMMAND_STOP = (1 << 1), // stops the speaker task
|
||||
COMMAND_STOP_GRACEFULLY = (1 << 2), // Stops the speaker task once all data has been written
|
||||
|
||||
TASK_STARTING = (1 << 10),
|
||||
TASK_RUNNING = (1 << 11),
|
||||
TASK_STOPPING = (1 << 12),
|
||||
TASK_STOPPED = (1 << 13),
|
||||
|
||||
ERR_ESP_NO_MEM = (1 << 19),
|
||||
|
||||
WARN_DROPPED_EVENT = (1 << 20),
|
||||
|
||||
ALL_BITS = 0x00FFFFFF, // All valid FreeRTOS event group bits
|
||||
};
|
||||
|
||||
// Lists the Q15 fixed point scaling factor for volume reduction.
|
||||
// Has 100 values representing silence and a reduction [49, 48.5, ... 0.5, 0] dB.
|
||||
// dB to PCM scaling factor formula: floating_point_scale_factor = 2^(-db/6.014)
|
||||
@@ -56,17 +30,21 @@ static const std::vector<int16_t> Q15_VOLUME_SCALING_FACTORS = {
|
||||
8218, 8706, 9222, 9770, 10349, 10963, 11613, 12302, 13032, 13805, 14624, 15491, 16410, 17384, 18415,
|
||||
19508, 20665, 21891, 23189, 24565, 26022, 27566, 29201, 30933, 32767};
|
||||
|
||||
void I2SAudioSpeaker::setup() {
|
||||
void I2SAudioSpeakerBase::setup() {
|
||||
this->event_group_ = xEventGroupCreate();
|
||||
|
||||
if (this->event_group_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Failed to create event group");
|
||||
ESP_LOGE(TAG, "Event group creation failed");
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize volume control. When audio_dac is configured, this sets the DAC volume.
|
||||
// When no audio_dac is configured, this initializes software volume control.
|
||||
this->set_volume(this->volume_);
|
||||
}
|
||||
|
||||
void I2SAudioSpeaker::dump_config() {
|
||||
void I2SAudioSpeakerBase::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Speaker:\n"
|
||||
" Pin: %d\n"
|
||||
@@ -75,10 +53,9 @@ void I2SAudioSpeaker::dump_config() {
|
||||
if (this->timeout_.has_value()) {
|
||||
ESP_LOGCONFIG(TAG, " Timeout: %" PRIu32 " ms", this->timeout_.value());
|
||||
}
|
||||
ESP_LOGCONFIG(TAG, " Communication format: %s", this->i2s_comm_fmt_.c_str());
|
||||
}
|
||||
|
||||
void I2SAudioSpeaker::loop() {
|
||||
void I2SAudioSpeakerBase::loop() {
|
||||
uint32_t event_group_bits = xEventGroupGetBits(this->event_group_);
|
||||
|
||||
if ((event_group_bits & SpeakerEventGroupBits::COMMAND_START) && (this->state_ == speaker::STATE_STOPPED)) {
|
||||
@@ -92,12 +69,12 @@ void I2SAudioSpeaker::loop() {
|
||||
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::TASK_STARTING);
|
||||
}
|
||||
if (event_group_bits & SpeakerEventGroupBits::TASK_RUNNING) {
|
||||
ESP_LOGD(TAG, "Started");
|
||||
ESP_LOGV(TAG, "Started");
|
||||
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING);
|
||||
this->state_ = speaker::STATE_RUNNING;
|
||||
}
|
||||
if (event_group_bits & SpeakerEventGroupBits::TASK_STOPPING) {
|
||||
ESP_LOGD(TAG, "Stopping");
|
||||
ESP_LOGV(TAG, "Stopping");
|
||||
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPING);
|
||||
this->state_ = speaker::STATE_STOPPING;
|
||||
}
|
||||
@@ -111,10 +88,12 @@ void I2SAudioSpeaker::loop() {
|
||||
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ALL_BITS);
|
||||
this->status_clear_error();
|
||||
|
||||
this->on_task_stopped();
|
||||
|
||||
this->state_ = speaker::STATE_STOPPED;
|
||||
}
|
||||
|
||||
// Log any errors encounted by the task
|
||||
// Log any errors encountered by the task
|
||||
if (event_group_bits & SpeakerEventGroupBits::ERR_ESP_NO_MEM) {
|
||||
ESP_LOGE(TAG, "Not enough memory");
|
||||
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM);
|
||||
@@ -133,14 +112,14 @@ void I2SAudioSpeaker::loop() {
|
||||
break;
|
||||
}
|
||||
|
||||
if (this->start_i2s_driver_(this->audio_stream_info_) != ESP_OK) {
|
||||
if (this->start_i2s_driver(this->audio_stream_info_) != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second");
|
||||
this->status_momentary_error("driver-faiure", 1000);
|
||||
this->status_momentary_error("driver-failure", 1000);
|
||||
break;
|
||||
}
|
||||
|
||||
if (this->speaker_task_handle_ == nullptr) {
|
||||
xTaskCreate(I2SAudioSpeaker::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY,
|
||||
xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY,
|
||||
&this->speaker_task_handle_);
|
||||
|
||||
if (this->speaker_task_handle_ == nullptr) {
|
||||
@@ -157,7 +136,7 @@ void I2SAudioSpeaker::loop() {
|
||||
}
|
||||
}
|
||||
|
||||
void I2SAudioSpeaker::set_volume(float volume) {
|
||||
void I2SAudioSpeakerBase::set_volume(float volume) {
|
||||
this->volume_ = volume;
|
||||
#ifdef USE_AUDIO_DAC
|
||||
if (this->audio_dac_ != nullptr) {
|
||||
@@ -166,15 +145,21 @@ void I2SAudioSpeaker::set_volume(float volume) {
|
||||
}
|
||||
this->audio_dac_->set_volume(volume);
|
||||
} else
|
||||
#endif
|
||||
#endif // USE_AUDIO_DAC
|
||||
{
|
||||
// Fallback to software volume control by using a Q15 fixed point scaling factor
|
||||
ssize_t decibel_index = remap<ssize_t, float>(volume, 0.0f, 1.0f, 0, Q15_VOLUME_SCALING_FACTORS.size() - 1);
|
||||
this->q15_volume_factor_ = Q15_VOLUME_SCALING_FACTORS[decibel_index];
|
||||
// Fallback to software volume control by using a Q15 fixed point scaling factor.
|
||||
// At maximum volume (1.0), set to INT16_MAX to completely bypass volume processing
|
||||
// and avoid any floating-point precision issues that could cause slight volume reduction.
|
||||
if (volume >= 1.0f) {
|
||||
this->q15_volume_factor_ = INT16_MAX;
|
||||
} else {
|
||||
ssize_t decibel_index = remap<ssize_t, float>(volume, 0.0f, 1.0f, 0, Q15_VOLUME_SCALING_FACTORS.size() - 1);
|
||||
this->q15_volume_factor_ = Q15_VOLUME_SCALING_FACTORS[decibel_index];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void I2SAudioSpeaker::set_mute_state(bool mute_state) {
|
||||
void I2SAudioSpeakerBase::set_mute_state(bool mute_state) {
|
||||
this->mute_state_ = mute_state;
|
||||
#ifdef USE_AUDIO_DAC
|
||||
if (this->audio_dac_) {
|
||||
@@ -184,7 +169,7 @@ void I2SAudioSpeaker::set_mute_state(bool mute_state) {
|
||||
this->audio_dac_->set_mute_off();
|
||||
}
|
||||
} else
|
||||
#endif
|
||||
#endif // USE_AUDIO_DAC
|
||||
{
|
||||
if (mute_state) {
|
||||
// Fallback to software volume control and scale by 0
|
||||
@@ -196,11 +181,12 @@ void I2SAudioSpeaker::set_mute_state(bool mute_state) {
|
||||
}
|
||||
}
|
||||
|
||||
size_t I2SAudioSpeaker::play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) {
|
||||
size_t I2SAudioSpeakerBase::play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) {
|
||||
if (this->is_failed()) {
|
||||
ESP_LOGE(TAG, "Setup failed; cannot play audio");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (this->state_ != speaker::STATE_RUNNING && this->state_ != speaker::STATE_STARTING) {
|
||||
this->start();
|
||||
}
|
||||
@@ -214,8 +200,8 @@ size_t I2SAudioSpeaker::play(const uint8_t *data, size_t length, TickType_t tick
|
||||
size_t bytes_written = 0;
|
||||
if (this->state_ == speaker::STATE_RUNNING) {
|
||||
std::shared_ptr<RingBuffer> temp_ring_buffer = this->audio_ring_buffer_.lock();
|
||||
if (temp_ring_buffer.use_count() == 2) {
|
||||
// Only the speaker task and this temp_ring_buffer own the ring buffer, so its safe to write to
|
||||
if (temp_ring_buffer != nullptr) {
|
||||
// The weak_ptr locks successfully only while the speaker task owns the ring buffer, so it is safe to write
|
||||
bytes_written = temp_ring_buffer->write_without_replacement((void *) data, length, ticks_to_wait);
|
||||
}
|
||||
}
|
||||
@@ -223,7 +209,7 @@ size_t I2SAudioSpeaker::play(const uint8_t *data, size_t length, TickType_t tick
|
||||
return bytes_written;
|
||||
}
|
||||
|
||||
bool I2SAudioSpeaker::has_buffered_data() const {
|
||||
bool I2SAudioSpeakerBase::has_buffered_data() const {
|
||||
if (this->audio_ring_buffer_.use_count() > 0) {
|
||||
std::shared_ptr<RingBuffer> temp_ring_buffer = this->audio_ring_buffer_.lock();
|
||||
return temp_ring_buffer->available() > 0;
|
||||
@@ -231,216 +217,27 @@ bool I2SAudioSpeaker::has_buffered_data() const {
|
||||
return false;
|
||||
}
|
||||
|
||||
void I2SAudioSpeaker::speaker_task(void *params) {
|
||||
I2SAudioSpeaker *this_speaker = (I2SAudioSpeaker *) params;
|
||||
|
||||
xEventGroupSetBits(this_speaker->event_group_, SpeakerEventGroupBits::TASK_STARTING);
|
||||
|
||||
const uint32_t dma_buffers_duration_ms = DMA_BUFFER_DURATION_MS * DMA_BUFFERS_COUNT;
|
||||
// Ensure ring buffer duration is at least the duration of all DMA buffers
|
||||
const uint32_t ring_buffer_duration = std::max(dma_buffers_duration_ms, this_speaker->buffer_duration_ms_);
|
||||
|
||||
// The DMA buffers may have more bits per sample, so calculate buffer sizes based in the input audio stream info
|
||||
const size_t ring_buffer_size = this_speaker->current_stream_info_.ms_to_bytes(ring_buffer_duration);
|
||||
|
||||
const uint32_t frames_to_fill_single_dma_buffer =
|
||||
this_speaker->current_stream_info_.ms_to_frames(DMA_BUFFER_DURATION_MS);
|
||||
const size_t bytes_to_fill_single_dma_buffer =
|
||||
this_speaker->current_stream_info_.frames_to_bytes(frames_to_fill_single_dma_buffer);
|
||||
|
||||
bool successful_setup = false;
|
||||
std::unique_ptr<audio::AudioSourceTransferBuffer> transfer_buffer =
|
||||
audio::AudioSourceTransferBuffer::create(bytes_to_fill_single_dma_buffer);
|
||||
|
||||
if (transfer_buffer != nullptr) {
|
||||
std::shared_ptr<RingBuffer> temp_ring_buffer = RingBuffer::create(ring_buffer_size);
|
||||
if (temp_ring_buffer.use_count() == 1) {
|
||||
transfer_buffer->set_source(temp_ring_buffer);
|
||||
this_speaker->audio_ring_buffer_ = temp_ring_buffer;
|
||||
successful_setup = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!successful_setup) {
|
||||
xEventGroupSetBits(this_speaker->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM);
|
||||
} else {
|
||||
bool stop_gracefully = false;
|
||||
bool tx_dma_underflow = true;
|
||||
|
||||
uint32_t frames_written = 0;
|
||||
uint32_t last_data_received_time = millis();
|
||||
|
||||
xEventGroupSetBits(this_speaker->event_group_, SpeakerEventGroupBits::TASK_RUNNING);
|
||||
|
||||
while (this_speaker->pause_state_ || !this_speaker->timeout_.has_value() ||
|
||||
(millis() - last_data_received_time) <= this_speaker->timeout_.value()) {
|
||||
uint32_t event_group_bits = xEventGroupGetBits(this_speaker->event_group_);
|
||||
|
||||
if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP) {
|
||||
xEventGroupClearBits(this_speaker->event_group_, SpeakerEventGroupBits::COMMAND_STOP);
|
||||
break;
|
||||
}
|
||||
if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY) {
|
||||
xEventGroupClearBits(this_speaker->event_group_, SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY);
|
||||
stop_gracefully = true;
|
||||
}
|
||||
|
||||
if (this_speaker->audio_stream_info_ != this_speaker->current_stream_info_) {
|
||||
// Audio stream info changed, stop the speaker task so it will restart with the proper settings.
|
||||
break;
|
||||
}
|
||||
int64_t write_timestamp;
|
||||
while (xQueueReceive(this_speaker->i2s_event_queue_, &write_timestamp, 0)) {
|
||||
// Receives timing events from the I2S on_sent callback. If actual audio data was sent in this event, it passes
|
||||
// on the timing info via the audio_output_callback.
|
||||
uint32_t frames_sent = frames_to_fill_single_dma_buffer;
|
||||
if (frames_to_fill_single_dma_buffer > frames_written) {
|
||||
tx_dma_underflow = true;
|
||||
frames_sent = frames_written;
|
||||
const uint32_t frames_zeroed = frames_to_fill_single_dma_buffer - frames_written;
|
||||
write_timestamp -= this_speaker->current_stream_info_.frames_to_microseconds(frames_zeroed);
|
||||
} else {
|
||||
tx_dma_underflow = false;
|
||||
}
|
||||
frames_written -= frames_sent;
|
||||
if (frames_sent > 0) {
|
||||
this_speaker->audio_output_callback_(frames_sent, write_timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
if (this_speaker->pause_state_) {
|
||||
// Pause state is accessed atomically, so thread safe
|
||||
// Delay so the task yields, then skip transferring audio data
|
||||
vTaskDelay(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Wait half the duration of the data already written to the DMA buffers for new audio data
|
||||
// The millisecond helper modifies the frames_written variable, so use the microsecond helper and divide by 1000
|
||||
const uint32_t read_delay =
|
||||
(this_speaker->current_stream_info_.frames_to_microseconds(frames_written) / 1000) / 2;
|
||||
|
||||
size_t bytes_read = transfer_buffer->transfer_data_from_source(pdMS_TO_TICKS(read_delay));
|
||||
uint8_t *new_data = transfer_buffer->get_buffer_end() - bytes_read;
|
||||
|
||||
if (bytes_read > 0) {
|
||||
if (this_speaker->q15_volume_factor_ < INT16_MAX) {
|
||||
// Apply the software volume adjustment by unpacking the sample into a Q31 fixed-point number, shifting it,
|
||||
// multiplying by the volume factor, and packing the sample back into the original bytes per sample.
|
||||
|
||||
const size_t bytes_per_sample = this_speaker->current_stream_info_.samples_to_bytes(1);
|
||||
const uint32_t len = bytes_read / bytes_per_sample;
|
||||
|
||||
// Use Q16 for samples with 1 or 2 bytes: shifted_sample * gain_factor is Q16 * Q15 -> Q31
|
||||
int32_t shift = 15; // Q31 -> Q16
|
||||
int32_t gain_factor = this_speaker->q15_volume_factor_; // Q15
|
||||
|
||||
if (bytes_per_sample >= 3) {
|
||||
// Use Q23 for samples with 3 or 4 bytes: shifted_sample * gain_factor is Q23 * Q8 -> Q31
|
||||
|
||||
shift = 8; // Q31 -> Q23
|
||||
gain_factor >>= 7; // Q15 -> Q8
|
||||
}
|
||||
|
||||
for (uint32_t i = 0; i < len; ++i) {
|
||||
int32_t sample =
|
||||
audio::unpack_audio_sample_to_q31(&new_data[i * bytes_per_sample], bytes_per_sample); // Q31
|
||||
sample >>= shift;
|
||||
sample *= gain_factor; // Q31
|
||||
audio::pack_q31_as_audio_sample(sample, &new_data[i * bytes_per_sample], bytes_per_sample);
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef USE_ESP32_VARIANT_ESP32
|
||||
// For ESP32 16-bit mono mode, adjacent samples need to be swapped.
|
||||
if (this_speaker->current_stream_info_.get_channels() == 1 &&
|
||||
this_speaker->current_stream_info_.get_bits_per_sample() == 16) {
|
||||
int16_t *samples = reinterpret_cast<int16_t *>(new_data);
|
||||
size_t sample_count = bytes_read / sizeof(int16_t);
|
||||
for (size_t i = 0; i + 1 < sample_count; i += 2) {
|
||||
int16_t tmp = samples[i];
|
||||
samples[i] = samples[i + 1];
|
||||
samples[i + 1] = tmp;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
if (transfer_buffer->available() == 0) {
|
||||
if (stop_gracefully && tx_dma_underflow) {
|
||||
break;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS / 2));
|
||||
} else {
|
||||
size_t bytes_written = 0;
|
||||
if (tx_dma_underflow) {
|
||||
// Temporarily disable channel and callback to reset the I2S driver's internal DMA buffer queue so timing
|
||||
// callbacks are accurate. Preload the data.
|
||||
i2s_channel_disable(this_speaker->tx_handle_);
|
||||
const i2s_event_callbacks_t callbacks = {
|
||||
.on_sent = nullptr,
|
||||
};
|
||||
|
||||
i2s_channel_register_event_callback(this_speaker->tx_handle_, &callbacks, this_speaker);
|
||||
i2s_channel_preload_data(this_speaker->tx_handle_, transfer_buffer->get_buffer_start(),
|
||||
transfer_buffer->available(), &bytes_written);
|
||||
} else {
|
||||
// Audio is already playing, use regular I2S write to add to the DMA buffers
|
||||
i2s_channel_write(this_speaker->tx_handle_, transfer_buffer->get_buffer_start(), transfer_buffer->available(),
|
||||
&bytes_written, DMA_BUFFER_DURATION_MS);
|
||||
}
|
||||
if (bytes_written > 0) {
|
||||
last_data_received_time = millis();
|
||||
frames_written += this_speaker->current_stream_info_.bytes_to_frames(bytes_written);
|
||||
transfer_buffer->decrease_buffer_length(bytes_written);
|
||||
if (tx_dma_underflow) {
|
||||
tx_dma_underflow = false;
|
||||
// Reset the event queue timestamps
|
||||
// Enable the on_sent callback to accurately track the timestamps of played audio
|
||||
// Enable the I2S channel to start sending the preloaded audio
|
||||
|
||||
xQueueReset(this_speaker->i2s_event_queue_);
|
||||
|
||||
const i2s_event_callbacks_t callbacks = {
|
||||
.on_sent = i2s_on_sent_cb,
|
||||
};
|
||||
i2s_channel_register_event_callback(this_speaker->tx_handle_, &callbacks, this_speaker);
|
||||
|
||||
i2s_channel_enable(this_speaker->tx_handle_);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
xEventGroupSetBits(this_speaker->event_group_, SpeakerEventGroupBits::TASK_STOPPING);
|
||||
|
||||
if (transfer_buffer != nullptr) {
|
||||
transfer_buffer.reset();
|
||||
}
|
||||
|
||||
xEventGroupSetBits(this_speaker->event_group_, SpeakerEventGroupBits::TASK_STOPPED);
|
||||
|
||||
while (true) {
|
||||
// Continuously delay until the loop method deletes the task
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
}
|
||||
void I2SAudioSpeakerBase::speaker_task(void *params) {
|
||||
I2SAudioSpeakerBase *this_speaker = (I2SAudioSpeakerBase *) params;
|
||||
this_speaker->run_speaker_task();
|
||||
}
|
||||
|
||||
void I2SAudioSpeaker::start() {
|
||||
void I2SAudioSpeakerBase::start() {
|
||||
if (!this->is_ready() || this->is_failed() || this->status_has_error())
|
||||
return;
|
||||
if ((this->state_ == speaker::STATE_STARTING) || (this->state_ == speaker::STATE_RUNNING))
|
||||
return;
|
||||
|
||||
// Mark STARTING immediately to avoid transient STOPPED observations before loop() processes COMMAND_START.
|
||||
this->state_ = speaker::STATE_STARTING;
|
||||
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START);
|
||||
}
|
||||
|
||||
void I2SAudioSpeaker::stop() { this->stop_(false); }
|
||||
void I2SAudioSpeakerBase::stop() { this->stop_(false); }
|
||||
|
||||
void I2SAudioSpeaker::finish() { this->stop_(true); }
|
||||
void I2SAudioSpeakerBase::finish() { this->stop_(true); }
|
||||
|
||||
void I2SAudioSpeaker::stop_(bool wait_on_empty) {
|
||||
void I2SAudioSpeakerBase::stop_(bool wait_on_empty) {
|
||||
if (this->is_failed())
|
||||
return;
|
||||
if (this->state_ == speaker::STATE_STOPPED)
|
||||
@@ -453,105 +250,16 @@ void I2SAudioSpeaker::stop_(bool wait_on_empty) {
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t I2SAudioSpeaker::start_i2s_driver_(audio::AudioStreamInfo &audio_stream_info) {
|
||||
this->current_stream_info_ = audio_stream_info; // store the stream info settings the driver will use
|
||||
|
||||
if ((this->i2s_role_ & I2S_ROLE_SLAVE) && (this->sample_rate_ != audio_stream_info.get_sample_rate())) { // NOLINT
|
||||
// Can't reconfigure I2S bus, so the sample rate must match the configured value
|
||||
ESP_LOGE(TAG, "Audio stream settings are not compatible with this I2S configuration");
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO &&
|
||||
(i2s_slot_bit_width_t) audio_stream_info.get_bits_per_sample() > this->slot_bit_width_) {
|
||||
// Currently can't handle the case when the incoming audio has more bits per sample than the configured value
|
||||
ESP_LOGE(TAG, "Audio streams with more bits per sample than the I2S speaker's configuration is not supported");
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
if (!this->parent_->try_lock()) {
|
||||
ESP_LOGE(TAG, "Parent I2S bus not free");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
uint32_t dma_buffer_length = audio_stream_info.ms_to_frames(DMA_BUFFER_DURATION_MS);
|
||||
|
||||
i2s_chan_config_t chan_cfg = {
|
||||
.id = this->parent_->get_port(),
|
||||
.role = this->i2s_role_,
|
||||
.dma_desc_num = DMA_BUFFERS_COUNT,
|
||||
.dma_frame_num = dma_buffer_length,
|
||||
.auto_clear = true,
|
||||
.intr_priority = 3,
|
||||
};
|
||||
/* Allocate a new TX channel and get the handle of this channel */
|
||||
esp_err_t I2SAudioSpeakerBase::init_i2s_channel_(const i2s_chan_config_t &chan_cfg, const i2s_std_config_t &std_cfg,
|
||||
size_t event_queue_size) {
|
||||
esp_err_t err = i2s_new_channel(&chan_cfg, &this->tx_handle_, NULL);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to allocate new I2S channel");
|
||||
ESP_LOGE(TAG, "I2S channel allocation failed: %s", esp_err_to_name(err));
|
||||
this->parent_->unlock();
|
||||
return err;
|
||||
}
|
||||
|
||||
i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT;
|
||||
#ifdef I2S_CLK_SRC_APLL
|
||||
if (this->use_apll_) {
|
||||
clk_src = I2S_CLK_SRC_APLL;
|
||||
}
|
||||
#endif
|
||||
i2s_std_gpio_config_t pin_config = this->parent_->get_pin_config();
|
||||
|
||||
i2s_std_clk_config_t clk_cfg = {
|
||||
.sample_rate_hz = audio_stream_info.get_sample_rate(),
|
||||
.clk_src = clk_src,
|
||||
.mclk_multiple = this->mclk_multiple_,
|
||||
};
|
||||
|
||||
i2s_slot_mode_t slot_mode = this->slot_mode_;
|
||||
i2s_std_slot_mask_t slot_mask = this->std_slot_mask_;
|
||||
if (audio_stream_info.get_channels() == 1) {
|
||||
slot_mode = I2S_SLOT_MODE_MONO;
|
||||
} else if (audio_stream_info.get_channels() == 2) {
|
||||
slot_mode = I2S_SLOT_MODE_STEREO;
|
||||
slot_mask = I2S_STD_SLOT_BOTH;
|
||||
}
|
||||
|
||||
i2s_std_slot_config_t std_slot_cfg;
|
||||
if (this->i2s_comm_fmt_ == "std") {
|
||||
std_slot_cfg =
|
||||
I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), slot_mode);
|
||||
} else if (this->i2s_comm_fmt_ == "pcm") {
|
||||
std_slot_cfg =
|
||||
I2S_STD_PCM_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), slot_mode);
|
||||
} else {
|
||||
std_slot_cfg =
|
||||
I2S_STD_MSB_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), slot_mode);
|
||||
}
|
||||
#ifdef USE_ESP32_VARIANT_ESP32
|
||||
// There seems to be a bug on the ESP32 (non-variant) platform where setting the slot bit width higher then the bits
|
||||
// per sample causes the audio to play too fast. Setting the ws_width to the configured slot bit width seems to
|
||||
// make it play at the correct speed while sending more bits per slot.
|
||||
if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO) {
|
||||
uint32_t configured_bit_width = static_cast<uint32_t>(this->slot_bit_width_);
|
||||
std_slot_cfg.ws_width = configured_bit_width;
|
||||
if (configured_bit_width > 16) {
|
||||
std_slot_cfg.msb_right = false;
|
||||
}
|
||||
}
|
||||
#else
|
||||
std_slot_cfg.slot_bit_width = this->slot_bit_width_;
|
||||
#endif
|
||||
std_slot_cfg.slot_mask = slot_mask;
|
||||
|
||||
pin_config.dout = this->dout_pin_;
|
||||
|
||||
i2s_std_config_t std_cfg = {
|
||||
.clk_cfg = clk_cfg,
|
||||
.slot_cfg = std_slot_cfg,
|
||||
.gpio_cfg = pin_config,
|
||||
};
|
||||
/* Initialize the channel */
|
||||
err = i2s_channel_init_std_mode(this->tx_handle_, &std_cfg);
|
||||
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to initialize channel");
|
||||
i2s_del_channel(this->tx_handle_);
|
||||
@@ -559,23 +267,34 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver_(audio::AudioStreamInfo &audio_strea
|
||||
this->parent_->unlock();
|
||||
return err;
|
||||
}
|
||||
|
||||
if (this->i2s_event_queue_ == nullptr) {
|
||||
this->i2s_event_queue_ = xQueueCreate(I2S_EVENT_QUEUE_COUNT, sizeof(int64_t));
|
||||
this->i2s_event_queue_ = xQueueCreate(event_queue_size, sizeof(int64_t));
|
||||
} else {
|
||||
// Reset queue to clear any stale events from previous task
|
||||
xQueueReset(this->i2s_event_queue_);
|
||||
}
|
||||
|
||||
i2s_channel_enable(this->tx_handle_);
|
||||
|
||||
return err;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
bool IRAM_ATTR I2SAudioSpeaker::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx) {
|
||||
void I2SAudioSpeakerBase::stop_i2s_driver_() {
|
||||
if (this->tx_handle_ != nullptr) {
|
||||
i2s_channel_disable(this->tx_handle_);
|
||||
i2s_del_channel(this->tx_handle_);
|
||||
this->tx_handle_ = nullptr;
|
||||
}
|
||||
this->parent_->unlock();
|
||||
}
|
||||
|
||||
bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx) {
|
||||
int64_t now = esp_timer_get_time();
|
||||
|
||||
BaseType_t need_yield1 = pdFALSE;
|
||||
BaseType_t need_yield2 = pdFALSE;
|
||||
BaseType_t need_yield3 = pdFALSE;
|
||||
|
||||
I2SAudioSpeaker *this_speaker = (I2SAudioSpeaker *) user_ctx;
|
||||
I2SAudioSpeakerBase *this_speaker = (I2SAudioSpeakerBase *) user_ctx;
|
||||
|
||||
if (xQueueIsQueueFullFromISR(this_speaker->i2s_event_queue_)) {
|
||||
// Queue is full, so discard the oldest event and set the warning flag to inform the user
|
||||
@@ -589,14 +308,47 @@ bool IRAM_ATTR I2SAudioSpeaker::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s_eve
|
||||
return need_yield1 | need_yield2 | need_yield3;
|
||||
}
|
||||
|
||||
void I2SAudioSpeaker::stop_i2s_driver_() {
|
||||
i2s_channel_disable(this->tx_handle_);
|
||||
i2s_del_channel(this->tx_handle_);
|
||||
this->tx_handle_ = nullptr;
|
||||
this->parent_->unlock();
|
||||
void I2SAudioSpeakerBase::apply_software_volume_(uint8_t *data, size_t bytes_read) {
|
||||
if (this->q15_volume_factor_ >= INT16_MAX) {
|
||||
return; // Max volume, no processing needed
|
||||
}
|
||||
|
||||
const size_t bytes_per_sample = this->current_stream_info_.samples_to_bytes(1);
|
||||
const uint32_t len = bytes_read / bytes_per_sample;
|
||||
|
||||
// Use Q16 for samples with 1 or 2 bytes: shifted_sample * gain_factor is Q16 * Q15 -> Q31
|
||||
int32_t shift = 15; // Q31 -> Q16
|
||||
int32_t gain_factor = this->q15_volume_factor_; // Q15
|
||||
|
||||
if (bytes_per_sample >= 3) {
|
||||
// Use Q23 for samples with 3 or 4 bytes: shifted_sample * gain_factor is Q23 * Q8 -> Q31
|
||||
shift = 8; // Q31 -> Q23
|
||||
gain_factor >>= 7; // Q15 -> Q8
|
||||
}
|
||||
|
||||
for (uint32_t i = 0; i < len; ++i) {
|
||||
int32_t sample = audio::unpack_audio_sample_to_q31(&data[i * bytes_per_sample], bytes_per_sample); // Q31
|
||||
sample >>= shift;
|
||||
sample *= gain_factor; // Q31
|
||||
audio::pack_q31_as_audio_sample(sample, &data[i * bytes_per_sample], bytes_per_sample);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace i2s_audio
|
||||
} // namespace esphome
|
||||
void I2SAudioSpeakerBase::swap_esp32_mono_samples_(uint8_t *data, size_t bytes_read) {
|
||||
#ifdef USE_ESP32_VARIANT_ESP32
|
||||
// For ESP32 16-bit mono mode, adjacent samples need to be swapped.
|
||||
if (this->current_stream_info_.get_channels() == 1 && this->current_stream_info_.get_bits_per_sample() == 16) {
|
||||
int16_t *samples = reinterpret_cast<int16_t *>(data);
|
||||
size_t sample_count = bytes_read / sizeof(int16_t);
|
||||
for (size_t i = 0; i + 1 < sample_count; i += 2) {
|
||||
int16_t tmp = samples[i];
|
||||
samples[i] = samples[i + 1];
|
||||
samples[i + 1] = tmp;
|
||||
}
|
||||
}
|
||||
#endif // USE_ESP32_VARIANT_ESP32
|
||||
}
|
||||
|
||||
} // namespace esphome::i2s_audio
|
||||
|
||||
#endif // USE_ESP32
|
||||
|
||||
@@ -16,10 +16,34 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/ring_buffer.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace i2s_audio {
|
||||
namespace esphome::i2s_audio {
|
||||
|
||||
class I2SAudioSpeaker : public I2SAudioOut, public speaker::Speaker, public Component {
|
||||
// Shared constants for I2S audio speaker implementations
|
||||
static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15;
|
||||
static constexpr size_t TASK_STACK_SIZE = 4096;
|
||||
static constexpr ssize_t TASK_PRIORITY = 19;
|
||||
|
||||
enum SpeakerEventGroupBits : uint32_t {
|
||||
COMMAND_START = (1 << 0), // indicates loop should start speaker task
|
||||
COMMAND_STOP = (1 << 1), // stops the speaker task
|
||||
COMMAND_STOP_GRACEFULLY = (1 << 2), // Stops the speaker task once all data has been written
|
||||
|
||||
TASK_STARTING = (1 << 10),
|
||||
TASK_RUNNING = (1 << 11),
|
||||
TASK_STOPPING = (1 << 12),
|
||||
TASK_STOPPED = (1 << 13),
|
||||
|
||||
ERR_ESP_NO_MEM = (1 << 19),
|
||||
|
||||
WARN_DROPPED_EVENT = (1 << 20),
|
||||
|
||||
ALL_BITS = 0x00FFFFFF, // All valid FreeRTOS event group bits
|
||||
};
|
||||
|
||||
/// @brief Abstract base class for I2S audio speaker implementations.
|
||||
/// Provides shared infrastructure (event groups, ring buffer, volume control, task lifecycle)
|
||||
/// for derived I2S speaker classes.
|
||||
class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public Component {
|
||||
public:
|
||||
float get_setup_priority() const override { return esphome::setup_priority::PROCESSOR; }
|
||||
|
||||
@@ -30,7 +54,9 @@ class I2SAudioSpeaker : public I2SAudioOut, public speaker::Speaker, public Comp
|
||||
void set_buffer_duration(uint32_t buffer_duration_ms) { this->buffer_duration_ms_ = buffer_duration_ms; }
|
||||
void set_timeout(uint32_t ms) { this->timeout_ = ms; }
|
||||
void set_dout_pin(uint8_t pin) { this->dout_pin_ = (gpio_num_t) pin; }
|
||||
void set_i2s_comm_fmt(std::string mode) { this->i2s_comm_fmt_ = std::move(mode); }
|
||||
|
||||
/// @brief Get the I2S TX channel handle
|
||||
i2s_chan_handle_t get_tx_handle() const { return this->tx_handle_; }
|
||||
|
||||
void start() override;
|
||||
void stop() override;
|
||||
@@ -63,40 +89,55 @@ class I2SAudioSpeaker : public I2SAudioOut, public speaker::Speaker, public Comp
|
||||
void set_mute_state(bool mute_state) override;
|
||||
|
||||
protected:
|
||||
/// @brief Function for the FreeRTOS task handling audio output.
|
||||
/// Allocates space for the buffers, reads audio from the ring buffer and writes audio to the I2S port. Stops
|
||||
/// immmiately after receiving the COMMAND_STOP signal and stops only after the ring buffer is empty after receiving
|
||||
/// the COMMAND_STOP_GRACEFULLY signal. Stops if the ring buffer hasn't read data for more than timeout_ milliseconds.
|
||||
/// When stopping, it deallocates the buffers. It communicates its state and any errors via ``event_group_``.
|
||||
/// @param params I2SAudioSpeaker component
|
||||
/// @brief FreeRTOS task entry point. Casts params to I2SAudioSpeakerBase and calls run_speaker_task_().
|
||||
/// @param params I2SAudioSpeakerBase component pointer
|
||||
static void speaker_task(void *params);
|
||||
|
||||
/// @brief The main speaker task loop. Implemented by derived classes for mode-specific behavior.
|
||||
virtual void run_speaker_task() = 0;
|
||||
|
||||
/// @brief Sends a stop command to the speaker task via ``event_group_``.
|
||||
/// @param wait_on_empty If false, sends the COMMAND_STOP signal. If true, sends the COMMAND_STOP_GRACEFULLY signal.
|
||||
void stop_(bool wait_on_empty);
|
||||
|
||||
/// @brief Callback function used to send playback timestamps the to the speaker task.
|
||||
/// @brief Callback function used to send playback timestamps to the speaker task.
|
||||
/// @param handle (i2s_chan_handle_t)
|
||||
/// @param event (i2s_event_data_t)
|
||||
/// @param user_ctx (void*) User context pointer that the callback accesses
|
||||
/// @return True if a higher priority task was interrupted
|
||||
static bool i2s_on_sent_cb(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx);
|
||||
|
||||
/// @brief Starts the ESP32 I2S driver.
|
||||
/// Attempts to lock the I2S port, starts the I2S driver using the passed in stream information, and sets the data out
|
||||
/// pin. If it fails, it will unlock the I2S port and uninstalls the driver, if necessary.
|
||||
/// @brief Starts the ESP32 I2S driver. Implemented by derived classes for mode-specific configuration.
|
||||
/// @param audio_stream_info Stream information for the I2S driver.
|
||||
/// @return ESP_ERR_NOT_ALLOWED if the I2S port can't play the incoming audio stream.
|
||||
/// ESP_ERR_INVALID_STATE if the I2S port is already locked.
|
||||
/// ESP_ERR_INVALID_ARG if installing the driver or setting the data outpin fails due to a parameter error.
|
||||
/// ESP_ERR_NO_MEM if the driver fails to install due to a memory allocation error.
|
||||
/// ESP_FAIL if setting the data out pin fails due to an IO error
|
||||
/// ESP_OK if successful
|
||||
esp_err_t start_i2s_driver_(audio::AudioStreamInfo &audio_stream_info);
|
||||
/// @return ESP_OK if successful, or an error code
|
||||
virtual esp_err_t start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) = 0;
|
||||
|
||||
/// @brief Shared I2S channel allocation, initialization, and event queue setup.
|
||||
/// Called by derived start_i2s_driver_() implementations after building mode-specific configs.
|
||||
/// @param chan_cfg I2S channel configuration
|
||||
/// @param std_cfg I2S standard mode configuration (clock, slot, GPIO)
|
||||
/// @param event_queue_size Size of the event queue
|
||||
/// @return ESP_OK if successful, or an error code. On failure, cleans up channel and unlocks parent.
|
||||
esp_err_t init_i2s_channel_(const i2s_chan_config_t &chan_cfg, const i2s_std_config_t &std_cfg,
|
||||
size_t event_queue_size);
|
||||
|
||||
/// @brief Stops the I2S driver and unlocks the I2S port
|
||||
void stop_i2s_driver_();
|
||||
|
||||
/// @brief Called in loop() when the task has stopped. Override for mode-specific cleanup.
|
||||
virtual void on_task_stopped() {}
|
||||
|
||||
/// @brief Apply software volume control using Q15 fixed-point scaling.
|
||||
/// @param data Pointer to audio sample data (modified in place)
|
||||
/// @param bytes_read Number of bytes of audio data
|
||||
void apply_software_volume_(uint8_t *data, size_t bytes_read);
|
||||
|
||||
/// @brief Swap adjacent 16-bit mono samples for ESP32 (non-variant) hardware quirk.
|
||||
/// Only applies when running on original ESP32 with 16-bit mono audio.
|
||||
/// @param data Pointer to audio sample data (modified in place)
|
||||
/// @param bytes_read Number of bytes of audio data
|
||||
void swap_esp32_mono_samples_(uint8_t *data, size_t bytes_read);
|
||||
|
||||
TaskHandle_t speaker_task_handle_{nullptr};
|
||||
EventGroupHandle_t event_group_{nullptr};
|
||||
|
||||
@@ -115,11 +156,9 @@ class I2SAudioSpeaker : public I2SAudioOut, public speaker::Speaker, public Comp
|
||||
audio::AudioStreamInfo current_stream_info_; // The currently loaded driver's stream info
|
||||
|
||||
gpio_num_t dout_pin_;
|
||||
std::string i2s_comm_fmt_;
|
||||
i2s_chan_handle_t tx_handle_;
|
||||
i2s_chan_handle_t tx_handle_{nullptr};
|
||||
};
|
||||
|
||||
} // namespace i2s_audio
|
||||
} // namespace esphome
|
||||
} // namespace esphome::i2s_audio
|
||||
|
||||
#endif // USE_ESP32
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
#include "i2s_audio_speaker_standard.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include <driver/i2s_std.h>
|
||||
|
||||
#include "esphome/components/audio/audio.h"
|
||||
#include "esphome/components/audio/audio_transfer_buffer.h"
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include "esp_timer.h"
|
||||
|
||||
namespace esphome::i2s_audio {
|
||||
|
||||
static const char *const TAG = "i2s_audio.speaker.std";
|
||||
|
||||
static constexpr size_t DMA_BUFFERS_COUNT = 4;
|
||||
static constexpr size_t I2S_EVENT_QUEUE_COUNT = DMA_BUFFERS_COUNT + 1;
|
||||
|
||||
void I2SAudioSpeaker::dump_config() {
|
||||
I2SAudioSpeakerBase::dump_config();
|
||||
const char *fmt_str;
|
||||
switch (this->i2s_comm_fmt_) {
|
||||
case I2SCommFmt::PCM:
|
||||
fmt_str = "pcm";
|
||||
break;
|
||||
case I2SCommFmt::MSB:
|
||||
fmt_str = "msb";
|
||||
break;
|
||||
default:
|
||||
fmt_str = "std";
|
||||
break;
|
||||
}
|
||||
ESP_LOGCONFIG(TAG, " Communication format: %s", fmt_str);
|
||||
}
|
||||
|
||||
void I2SAudioSpeaker::run_speaker_task() {
|
||||
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STARTING);
|
||||
|
||||
const uint32_t dma_buffers_duration_ms = DMA_BUFFER_DURATION_MS * DMA_BUFFERS_COUNT;
|
||||
// Ensure ring buffer duration is at least the duration of all DMA buffers
|
||||
const uint32_t ring_buffer_duration = std::max(dma_buffers_duration_ms, this->buffer_duration_ms_);
|
||||
|
||||
// The DMA buffers may have more bits per sample, so calculate buffer sizes based on the input audio stream info
|
||||
const size_t ring_buffer_size = this->current_stream_info_.ms_to_bytes(ring_buffer_duration);
|
||||
const uint32_t frames_to_fill_single_dma_buffer = this->current_stream_info_.ms_to_frames(DMA_BUFFER_DURATION_MS);
|
||||
const size_t bytes_to_fill_single_dma_buffer =
|
||||
this->current_stream_info_.frames_to_bytes(frames_to_fill_single_dma_buffer);
|
||||
|
||||
bool successful_setup = false;
|
||||
std::unique_ptr<audio::AudioSourceTransferBuffer> transfer_buffer =
|
||||
audio::AudioSourceTransferBuffer::create(bytes_to_fill_single_dma_buffer);
|
||||
|
||||
if (transfer_buffer != nullptr) {
|
||||
std::shared_ptr<RingBuffer> temp_ring_buffer = RingBuffer::create(ring_buffer_size);
|
||||
if (temp_ring_buffer.use_count() == 1) {
|
||||
transfer_buffer->set_source(temp_ring_buffer);
|
||||
this->audio_ring_buffer_ = temp_ring_buffer;
|
||||
successful_setup = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!successful_setup) {
|
||||
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM);
|
||||
} else {
|
||||
bool stop_gracefully = false;
|
||||
bool tx_dma_underflow = true;
|
||||
|
||||
uint32_t frames_written = 0;
|
||||
uint32_t last_data_received_time = millis();
|
||||
|
||||
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING);
|
||||
|
||||
// Main speaker task loop. Continues while:
|
||||
// - Paused, OR
|
||||
// - No timeout configured, OR
|
||||
// - Timeout hasn't elapsed since last data
|
||||
while (this->pause_state_ || !this->timeout_.has_value() ||
|
||||
(millis() - last_data_received_time) <= this->timeout_.value()) {
|
||||
uint32_t event_group_bits = xEventGroupGetBits(this->event_group_);
|
||||
|
||||
if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP) {
|
||||
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP);
|
||||
ESP_LOGV(TAG, "Exiting: COMMAND_STOP received");
|
||||
break;
|
||||
}
|
||||
if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY) {
|
||||
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY);
|
||||
stop_gracefully = true;
|
||||
}
|
||||
|
||||
if (this->audio_stream_info_ != this->current_stream_info_) {
|
||||
// Audio stream info changed, stop the speaker task so it will restart with the proper settings.
|
||||
ESP_LOGV(TAG, "Exiting: stream info changed");
|
||||
break;
|
||||
}
|
||||
|
||||
int64_t write_timestamp;
|
||||
while (xQueueReceive(this->i2s_event_queue_, &write_timestamp, 0)) {
|
||||
// Receives timing events from the I2S on_sent callback. If actual audio data was sent in this event, it passes
|
||||
// on the timing info via the audio_output_callback.
|
||||
uint32_t frames_sent = frames_to_fill_single_dma_buffer;
|
||||
if (frames_to_fill_single_dma_buffer > frames_written) {
|
||||
tx_dma_underflow = true;
|
||||
frames_sent = frames_written;
|
||||
const uint32_t frames_zeroed = frames_to_fill_single_dma_buffer - frames_written;
|
||||
write_timestamp -= this->current_stream_info_.frames_to_microseconds(frames_zeroed);
|
||||
} else {
|
||||
tx_dma_underflow = false;
|
||||
}
|
||||
frames_written -= frames_sent;
|
||||
|
||||
// Standard I2S mode: fire callback immediately for each event
|
||||
if (frames_sent > 0) {
|
||||
this->audio_output_callback_(frames_sent, write_timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
if (this->pause_state_) {
|
||||
// Pause state is accessed atomically, so thread safe
|
||||
// Delay so the task yields, then skip transferring audio data
|
||||
vTaskDelay(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Wait half the duration of the data already written to the DMA buffers for new audio data
|
||||
// The millisecond helper modifies the frames_written variable, so use the microsecond helper and divide by 1000
|
||||
uint32_t read_delay = (this->current_stream_info_.frames_to_microseconds(frames_written) / 1000) / 2;
|
||||
|
||||
size_t bytes_read = transfer_buffer->transfer_data_from_source(pdMS_TO_TICKS(read_delay));
|
||||
uint8_t *new_data = transfer_buffer->get_buffer_end() - bytes_read;
|
||||
|
||||
if (bytes_read > 0) {
|
||||
this->apply_software_volume_(new_data, bytes_read);
|
||||
this->swap_esp32_mono_samples_(new_data, bytes_read);
|
||||
}
|
||||
|
||||
if (transfer_buffer->available() == 0) {
|
||||
if (stop_gracefully && tx_dma_underflow) {
|
||||
break;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS / 2));
|
||||
} else {
|
||||
size_t bytes_written = 0;
|
||||
|
||||
if (tx_dma_underflow) {
|
||||
// Temporarily disable channel and callback to reset the I2S driver's internal DMA buffer queue
|
||||
i2s_channel_disable(this->tx_handle_);
|
||||
const i2s_event_callbacks_t null_callbacks = {.on_sent = nullptr};
|
||||
i2s_channel_register_event_callback(this->tx_handle_, &null_callbacks, this);
|
||||
i2s_channel_preload_data(this->tx_handle_, transfer_buffer->get_buffer_start(), transfer_buffer->available(),
|
||||
&bytes_written);
|
||||
} else {
|
||||
// Audio is already playing, use regular write to add to the DMA buffers
|
||||
i2s_channel_write(this->tx_handle_, transfer_buffer->get_buffer_start(), transfer_buffer->available(),
|
||||
&bytes_written, DMA_BUFFER_DURATION_MS);
|
||||
}
|
||||
|
||||
if (bytes_written > 0) {
|
||||
last_data_received_time = millis();
|
||||
frames_written += this->current_stream_info_.bytes_to_frames(bytes_written);
|
||||
transfer_buffer->decrease_buffer_length(bytes_written);
|
||||
|
||||
if (tx_dma_underflow) {
|
||||
tx_dma_underflow = false;
|
||||
// Enable the on_sent callback and channel after preload
|
||||
xQueueReset(this->i2s_event_queue_);
|
||||
const i2s_event_callbacks_t callbacks = {.on_sent = i2s_on_sent_cb};
|
||||
i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this);
|
||||
i2s_channel_enable(this->tx_handle_);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPING);
|
||||
|
||||
if (transfer_buffer != nullptr) {
|
||||
transfer_buffer.reset();
|
||||
}
|
||||
|
||||
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPED);
|
||||
|
||||
while (true) {
|
||||
// Continuously delay until the loop method deletes the task
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) {
|
||||
this->current_stream_info_ = audio_stream_info;
|
||||
|
||||
if ((this->i2s_role_ & I2S_ROLE_SLAVE) && (this->sample_rate_ != audio_stream_info.get_sample_rate())) { // NOLINT
|
||||
// Can't reconfigure I2S bus, so the sample rate must match the configured value
|
||||
ESP_LOGE(TAG, "Incompatible stream settings");
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO &&
|
||||
(i2s_slot_bit_width_t) audio_stream_info.get_bits_per_sample() > this->slot_bit_width_) {
|
||||
// Currently can't handle the case when the incoming audio has more bits per sample than the configured value
|
||||
ESP_LOGE(TAG, "Stream bits per sample must be less than or equal to the speaker's configuration");
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
if (!this->parent_->try_lock()) {
|
||||
ESP_LOGE(TAG, "Parent bus is busy");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
uint32_t dma_buffer_length = audio_stream_info.ms_to_frames(DMA_BUFFER_DURATION_MS);
|
||||
|
||||
i2s_role_t i2s_role = this->i2s_role_;
|
||||
i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT;
|
||||
|
||||
#if SOC_CLK_APLL_SUPPORTED
|
||||
if (this->use_apll_) {
|
||||
clk_src = i2s_clock_src_t::I2S_CLK_SRC_APLL;
|
||||
}
|
||||
#endif // SOC_CLK_APLL_SUPPORTED
|
||||
|
||||
// Log DMA configuration for debugging
|
||||
ESP_LOGV(TAG, "I2S DMA config: %zu buffers x %lu frames", (size_t) DMA_BUFFERS_COUNT,
|
||||
(unsigned long) dma_buffer_length);
|
||||
|
||||
i2s_chan_config_t chan_cfg = {
|
||||
.id = this->parent_->get_port(),
|
||||
.role = i2s_role,
|
||||
.dma_desc_num = DMA_BUFFERS_COUNT,
|
||||
.dma_frame_num = dma_buffer_length,
|
||||
.auto_clear = true,
|
||||
.intr_priority = 3,
|
||||
};
|
||||
|
||||
// Build standard I2S clock/slot/gpio configuration
|
||||
i2s_std_clk_config_t clk_cfg = {
|
||||
.sample_rate_hz = audio_stream_info.get_sample_rate(),
|
||||
.clk_src = clk_src,
|
||||
.mclk_multiple = this->mclk_multiple_,
|
||||
};
|
||||
|
||||
i2s_slot_mode_t slot_mode = this->slot_mode_;
|
||||
i2s_std_slot_mask_t slot_mask = this->std_slot_mask_;
|
||||
if (audio_stream_info.get_channels() == 1) {
|
||||
slot_mode = I2S_SLOT_MODE_MONO;
|
||||
} else if (audio_stream_info.get_channels() == 2) {
|
||||
slot_mode = I2S_SLOT_MODE_STEREO;
|
||||
slot_mask = I2S_STD_SLOT_BOTH;
|
||||
}
|
||||
|
||||
i2s_std_slot_config_t slot_cfg;
|
||||
switch (this->i2s_comm_fmt_) {
|
||||
case I2SCommFmt::PCM:
|
||||
slot_cfg =
|
||||
I2S_STD_PCM_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), slot_mode);
|
||||
break;
|
||||
case I2SCommFmt::MSB:
|
||||
slot_cfg =
|
||||
I2S_STD_MSB_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), slot_mode);
|
||||
break;
|
||||
default:
|
||||
slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(),
|
||||
slot_mode);
|
||||
break;
|
||||
}
|
||||
|
||||
#ifdef USE_ESP32_VARIANT_ESP32
|
||||
// There seems to be a bug on the ESP32 (non-variant) platform where setting the slot bit width higher than the
|
||||
// bits per sample causes the audio to play too fast. Setting the ws_width to the configured slot bit width seems
|
||||
// to make it play at the correct speed while sending more bits per slot.
|
||||
if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO) {
|
||||
uint32_t configured_bit_width = static_cast<uint32_t>(this->slot_bit_width_);
|
||||
slot_cfg.ws_width = configured_bit_width;
|
||||
if (configured_bit_width > 16) {
|
||||
slot_cfg.msb_right = false;
|
||||
}
|
||||
}
|
||||
#else
|
||||
slot_cfg.slot_bit_width = this->slot_bit_width_;
|
||||
#endif // USE_ESP32_VARIANT_ESP32
|
||||
slot_cfg.slot_mask = slot_mask;
|
||||
|
||||
i2s_std_gpio_config_t gpio_cfg = this->parent_->get_pin_config();
|
||||
gpio_cfg.dout = this->dout_pin_;
|
||||
|
||||
i2s_std_config_t std_cfg = {
|
||||
.clk_cfg = clk_cfg,
|
||||
.slot_cfg = slot_cfg,
|
||||
.gpio_cfg = gpio_cfg,
|
||||
};
|
||||
|
||||
esp_err_t err = this->init_i2s_channel_(chan_cfg, std_cfg, I2S_EVENT_QUEUE_COUNT);
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
|
||||
i2s_channel_enable(this->tx_handle_);
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
} // namespace esphome::i2s_audio
|
||||
|
||||
#endif // USE_ESP32
|
||||
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include "i2s_audio_speaker.h"
|
||||
|
||||
namespace esphome::i2s_audio {
|
||||
|
||||
enum class I2SCommFmt : uint8_t {
|
||||
STANDARD, // Philips / I2S standard
|
||||
PCM, // PCM short
|
||||
MSB, // MSB / left-justified
|
||||
};
|
||||
|
||||
/// @brief Standard I2S speaker implementation.
|
||||
/// Outputs PCM audio data directly to an I2S DAC using the standard I2S protocol.
|
||||
class I2SAudioSpeaker : public I2SAudioSpeakerBase {
|
||||
public:
|
||||
void dump_config() override;
|
||||
|
||||
void set_i2s_comm_fmt(I2SCommFmt fmt) { this->i2s_comm_fmt_ = fmt; }
|
||||
|
||||
protected:
|
||||
void run_speaker_task() override;
|
||||
esp_err_t start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) override;
|
||||
|
||||
I2SCommFmt i2s_comm_fmt_{I2SCommFmt::STANDARD};
|
||||
};
|
||||
|
||||
} // namespace esphome::i2s_audio
|
||||
|
||||
#endif // USE_ESP32
|
||||
@@ -20,8 +20,6 @@ void InternalTemperatureSensor::update() {
|
||||
success = (result == 0);
|
||||
#if defined(USE_LIBRETINY_VARIANT_BK7231N)
|
||||
temperature = raw * -0.38f + 156.0f;
|
||||
#elif defined(USE_LIBRETINY_VARIANT_BK7231T)
|
||||
temperature = raw * 0.04f;
|
||||
#else // USE_LIBRETINY_VARIANT
|
||||
temperature = raw * 0.128f;
|
||||
#endif // USE_LIBRETINY_VARIANT
|
||||
|
||||
@@ -443,6 +443,13 @@ async def component_to_code(config):
|
||||
# 4-8KB flash). Even if linked, it would use locks, so explicit FreeRTOS
|
||||
# mutexes are simpler and equivalent.
|
||||
cg.add_define(ThreadModel.MULTI_NO_ATOMICS)
|
||||
# Enable FreeRTOS static allocation so FreeRTOSQueue can use
|
||||
# xQueueCreateStatic (queue storage in BSS, no heap allocation).
|
||||
# Also moves FreeRTOS internal structures (timer command queue) to BSS.
|
||||
# BK72xx's FreeRTOSConfig.h doesn't define this, defaulting to 0.
|
||||
# The -D wins over the #ifndef default in FreeRTOS.h.
|
||||
# Not enabled on RTL87xx/LN882x — costs more heap than it saves there.
|
||||
cg.add_build_flag("-DconfigSUPPORT_STATIC_ALLOCATION=1")
|
||||
|
||||
# RTL8710B needs FreeRTOS 8.2.3+ for xTaskNotifyGive/ulTaskNotifyTake
|
||||
# required by AsyncTCP 3.4.3+ (https://github.com/esphome/esphome/issues/10220)
|
||||
|
||||
@@ -16,8 +16,29 @@ void loop();
|
||||
namespace esphome {
|
||||
|
||||
void HOT yield() { ::yield(); }
|
||||
// Inline the tick read so esphome::millis() matches MillisInternal::get()'s fast
|
||||
// path instead of going through the Arduino core's out-of-line ::millis() wrapper.
|
||||
//
|
||||
// RTL87xx / LN882x (1 kHz): xTaskGetTickCount() is already ms. IRAM_ATTR + ISR
|
||||
// dispatch are needed because ISR handlers (e.g. rotary_encoder) call millis().
|
||||
//
|
||||
// BK72xx (500 Hz): ticks * portTICK_PERIOD_MS (== 2). IRAM_ATTR and ISR dispatch
|
||||
// are both unnecessary — the SDK masks FIQ + IRQ during flash writes (see hal.h),
|
||||
// so no ISR runs while flash is stalled.
|
||||
#if defined(USE_RTL87XX) || defined(USE_LN882X)
|
||||
uint32_t IRAM_ATTR HOT millis() {
|
||||
static_assert(configTICK_RATE_HZ == 1000, "millis() fast path requires 1 kHz FreeRTOS tick");
|
||||
return in_isr_context() ? xTaskGetTickCountFromISR() : xTaskGetTickCount();
|
||||
}
|
||||
#elif defined(USE_BK72XX)
|
||||
uint32_t HOT millis() {
|
||||
static_assert(configTICK_RATE_HZ == 500, "BK72xx millis() fast path assumes 500 Hz FreeRTOS tick");
|
||||
return xTaskGetTickCount() * portTICK_PERIOD_MS;
|
||||
}
|
||||
#else
|
||||
uint32_t IRAM_ATTR HOT millis() { return ::millis(); }
|
||||
uint64_t millis_64() { return Millis64Impl::compute(::millis()); }
|
||||
#endif
|
||||
uint64_t millis_64() { return Millis64Impl::compute(millis()); }
|
||||
uint32_t IRAM_ATTR HOT micros() { return ::micros(); }
|
||||
void HOT delay(uint32_t ms) { ::delay(ms); }
|
||||
void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { ::delayMicroseconds(us); }
|
||||
@@ -35,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");
|
||||
|
||||
52
esphome/components/libretiny/freertos_static_alloc.c
Normal file
52
esphome/components/libretiny/freertos_static_alloc.c
Normal file
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* FreeRTOS static allocation callbacks for LibreTiny platforms.
|
||||
*
|
||||
* Required when configSUPPORT_STATIC_ALLOCATION is enabled. These callbacks
|
||||
* provide memory for the idle and timer tasks. Following ESP-IDF's approach,
|
||||
* we allocate from the FreeRTOS heap (pvPortMalloc) rather than using truly
|
||||
* static buffers, to avoid assumptions about memory layout.
|
||||
*
|
||||
* This enables xQueueCreateStatic, xTaskCreateStatic, etc. throughout ESPHome,
|
||||
* allowing queue storage to live in BSS with zero runtime heap allocation.
|
||||
*/
|
||||
|
||||
#ifdef USE_BK72XX
|
||||
|
||||
#include <FreeRTOS.h>
|
||||
#include <task.h>
|
||||
|
||||
#if (configSUPPORT_STATIC_ALLOCATION == 1)
|
||||
|
||||
void vApplicationGetIdleTaskMemory(StaticTask_t **ppxIdleTaskTCBBuffer, StackType_t **ppxIdleTaskStackBuffer,
|
||||
uint32_t *pulIdleTaskStackSize) {
|
||||
/* Stack grows down on ARM — allocate stack first, then TCB,
|
||||
* so the stack does not grow into the TCB. */
|
||||
StackType_t *stack = (StackType_t *) pvPortMalloc(configMINIMAL_STACK_SIZE * sizeof(StackType_t));
|
||||
StaticTask_t *tcb = (StaticTask_t *) pvPortMalloc(sizeof(StaticTask_t));
|
||||
configASSERT(stack != NULL);
|
||||
configASSERT(tcb != NULL);
|
||||
|
||||
*ppxIdleTaskTCBBuffer = tcb;
|
||||
*ppxIdleTaskStackBuffer = stack;
|
||||
*pulIdleTaskStackSize = configMINIMAL_STACK_SIZE;
|
||||
}
|
||||
|
||||
#if (configUSE_TIMERS == 1)
|
||||
|
||||
void vApplicationGetTimerTaskMemory(StaticTask_t **ppxTimerTaskTCBBuffer, StackType_t **ppxTimerTaskStackBuffer,
|
||||
uint32_t *pulTimerTaskStackSize) {
|
||||
StackType_t *stack = (StackType_t *) pvPortMalloc(configTIMER_TASK_STACK_DEPTH * sizeof(StackType_t));
|
||||
StaticTask_t *tcb = (StaticTask_t *) pvPortMalloc(sizeof(StaticTask_t));
|
||||
configASSERT(stack != NULL);
|
||||
configASSERT(tcb != NULL);
|
||||
|
||||
*ppxTimerTaskTCBBuffer = tcb;
|
||||
*ppxTimerTaskStackBuffer = stack;
|
||||
*pulTimerTaskStackSize = configTIMER_TASK_STACK_DEPTH;
|
||||
}
|
||||
|
||||
#endif /* configUSE_TIMERS */
|
||||
|
||||
#endif /* configSUPPORT_STATIC_ALLOCATION */
|
||||
|
||||
#endif /* USE_BK72XX */
|
||||
@@ -10,13 +10,10 @@ namespace esphome::light {
|
||||
|
||||
static const char *const TAG = "light";
|
||||
|
||||
// Helper functions to reduce code size for logging
|
||||
static void clamp_and_log_if_invalid(const char *name, float &value, const LogString *param_name, float min = 0.0f,
|
||||
float max = 1.0f) {
|
||||
if (value < min || value > max) {
|
||||
ESP_LOGW(TAG, "'%s': %s value %.2f is out of range [%.1f - %.1f]", name, LOG_STR_ARG(param_name), value, min, max);
|
||||
value = clamp(value, min, max);
|
||||
}
|
||||
// Cold-path logger; caller handles the clamp so the in-range hot path avoids
|
||||
// the spill/reload around the call.
|
||||
static void log_value_out_of_range(const char *name, float value, const LogString *param_name, float min, float max) {
|
||||
ESP_LOGW(TAG, "'%s': %s value %.2f is out of range [%.1f - %.1f]", name, LOG_STR_ARG(param_name), value, min, max);
|
||||
}
|
||||
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN
|
||||
@@ -57,6 +54,12 @@ static void log_invalid_parameter(const char *name, const LogString *message) {
|
||||
PROGMEM_STRING_TABLE(ColorModeHumanStrings, "Unknown", "On/Off", "Brightness", "White", "Color temperature",
|
||||
"Cold/warm white", "RGB", "RGBW", "RGB + color temperature", "RGB + cold/warm white");
|
||||
|
||||
// Indices 0-7 match FieldFlags bits 0-7; index 8 is color_temperature.
|
||||
// PROGMEM_STRING_TABLE is constexpr-init (no RAM guard variable).
|
||||
PROGMEM_STRING_TABLE(ValidateFieldNames, "Brightness", "Color brightness", "Red", "Green", "Blue", "White",
|
||||
"Cold white", "Warm white", "Color temperature");
|
||||
static constexpr uint8_t VALIDATE_CT_INDEX = 8;
|
||||
|
||||
static const LogString *color_mode_to_human(ColorMode color_mode) {
|
||||
return ColorModeHumanStrings::get_log_str(ColorModeBitPolicy::to_bit(color_mode), 0);
|
||||
}
|
||||
@@ -277,25 +280,37 @@ LightColorValues LightCall::validate_() {
|
||||
if (this->has_state())
|
||||
v.set_state(this->state_);
|
||||
|
||||
// clamp_and_log_if_invalid already clamps in-place, so assign directly
|
||||
// to avoid redundant clamp code from the setter being inlined.
|
||||
#define VALIDATE_AND_APPLY(field, name_str, ...) \
|
||||
if (this->has_##field()) { \
|
||||
clamp_and_log_if_invalid(name, this->field##_, LOG_STR(name_str), ##__VA_ARGS__); \
|
||||
v.field##_ = this->field##_; \
|
||||
// FieldFlags bits 0-7 must match unit_fields_ array indices.
|
||||
static_assert(FLAG_HAS_BRIGHTNESS == 1u << 0 && FLAG_HAS_COLOR_BRIGHTNESS == 1u << 1 && FLAG_HAS_RED == 1u << 2 &&
|
||||
FLAG_HAS_GREEN == 1u << 3 && FLAG_HAS_BLUE == 1u << 4 && FLAG_HAS_WHITE == 1u << 5 &&
|
||||
FLAG_HAS_COLD_WHITE == 1u << 6 && FLAG_HAS_WARM_WHITE == 1u << 7,
|
||||
"FieldFlags bits 0-7 must match unit_fields_ indices");
|
||||
|
||||
// Iterate set bits only (ctz + clear-lowest) — HA can drive perform()
|
||||
// at high frequency so the hot path is O(popcount).
|
||||
unsigned active = this->flags_ & CLAMP_FLAGS_MASK;
|
||||
while (active != 0) {
|
||||
unsigned bit = __builtin_ctz(active);
|
||||
active &= active - 1; // clear lowest set bit
|
||||
float &value = this->unit_fields_[bit];
|
||||
if (float_out_of_unit_range(value)) {
|
||||
log_value_out_of_range(name, value, ValidateFieldNames::get_log_str(bit, 0), 0.0f, 1.0f);
|
||||
value = clamp_unit_float(value);
|
||||
}
|
||||
v.unit_fields_[bit] = value;
|
||||
}
|
||||
|
||||
VALIDATE_AND_APPLY(brightness, "Brightness")
|
||||
VALIDATE_AND_APPLY(color_brightness, "Color brightness")
|
||||
VALIDATE_AND_APPLY(red, "Red")
|
||||
VALIDATE_AND_APPLY(green, "Green")
|
||||
VALIDATE_AND_APPLY(blue, "Blue")
|
||||
VALIDATE_AND_APPLY(white, "White")
|
||||
VALIDATE_AND_APPLY(cold_white, "Cold white")
|
||||
VALIDATE_AND_APPLY(warm_white, "Warm white")
|
||||
VALIDATE_AND_APPLY(color_temperature, "Color temperature", traits.get_min_mireds(), traits.get_max_mireds())
|
||||
|
||||
#undef VALIDATE_AND_APPLY
|
||||
// color_temperature: runtime range from traits.
|
||||
if (this->has_color_temperature()) {
|
||||
const float ct_min = traits.get_min_mireds();
|
||||
const float ct_max = traits.get_max_mireds();
|
||||
if (this->color_temperature_ < ct_min || this->color_temperature_ > ct_max) {
|
||||
log_value_out_of_range(name, this->color_temperature_, ValidateFieldNames::get_log_str(VALIDATE_CT_INDEX, 0),
|
||||
ct_min, ct_max);
|
||||
this->color_temperature_ = clamp(this->color_temperature_, ct_min, ct_max);
|
||||
}
|
||||
v.color_temperature_ = this->color_temperature_;
|
||||
}
|
||||
|
||||
v.normalize_color();
|
||||
|
||||
|
||||
@@ -195,25 +195,26 @@ class LightCall {
|
||||
/// Some color modes also can be set using non-native parameters, transform those calls.
|
||||
void transform_parameters_(const LightTraits &traits);
|
||||
|
||||
// Bitfield flags - each flag indicates whether a corresponding value has been set.
|
||||
// Bits 0-7 index unit_fields_[] in validate_(); don't reorder (asserts in light_call.cpp).
|
||||
enum FieldFlags : uint16_t {
|
||||
FLAG_HAS_STATE = 1 << 0,
|
||||
FLAG_HAS_TRANSITION = 1 << 1,
|
||||
FLAG_HAS_FLASH = 1 << 2,
|
||||
FLAG_HAS_EFFECT = 1 << 3,
|
||||
FLAG_HAS_BRIGHTNESS = 1 << 4,
|
||||
FLAG_HAS_COLOR_BRIGHTNESS = 1 << 5,
|
||||
FLAG_HAS_RED = 1 << 6,
|
||||
FLAG_HAS_GREEN = 1 << 7,
|
||||
FLAG_HAS_BLUE = 1 << 8,
|
||||
FLAG_HAS_WHITE = 1 << 9,
|
||||
FLAG_HAS_COLOR_TEMPERATURE = 1 << 10,
|
||||
FLAG_HAS_COLD_WHITE = 1 << 11,
|
||||
FLAG_HAS_WARM_WHITE = 1 << 12,
|
||||
FLAG_HAS_BRIGHTNESS = 1 << 0,
|
||||
FLAG_HAS_COLOR_BRIGHTNESS = 1 << 1,
|
||||
FLAG_HAS_RED = 1 << 2,
|
||||
FLAG_HAS_GREEN = 1 << 3,
|
||||
FLAG_HAS_BLUE = 1 << 4,
|
||||
FLAG_HAS_WHITE = 1 << 5,
|
||||
FLAG_HAS_COLD_WHITE = 1 << 6,
|
||||
FLAG_HAS_WARM_WHITE = 1 << 7,
|
||||
FLAG_HAS_COLOR_TEMPERATURE = 1 << 8,
|
||||
FLAG_HAS_STATE = 1 << 9,
|
||||
FLAG_HAS_TRANSITION = 1 << 10,
|
||||
FLAG_HAS_FLASH = 1 << 11,
|
||||
FLAG_HAS_EFFECT = 1 << 12,
|
||||
FLAG_HAS_COLOR_MODE = 1 << 13,
|
||||
FLAG_PUBLISH = 1 << 14,
|
||||
FLAG_SAVE = 1 << 15,
|
||||
};
|
||||
static constexpr uint16_t CLAMP_FLAGS_MASK = 0x00FFu; // bits 0-7
|
||||
|
||||
inline bool has_transition_() { return (this->flags_ & FLAG_HAS_TRANSITION) != 0; }
|
||||
inline bool has_flash_() { return (this->flags_ & FLAG_HAS_FLASH) != 0; }
|
||||
@@ -239,19 +240,11 @@ class LightCall {
|
||||
LightState *parent_;
|
||||
|
||||
// Light state values - use flags_ to check if a value has been set.
|
||||
// Group 4-byte aligned members first
|
||||
uint32_t transition_length_;
|
||||
uint32_t flash_length_;
|
||||
uint32_t effect_;
|
||||
float brightness_;
|
||||
float color_brightness_;
|
||||
float red_;
|
||||
float green_;
|
||||
float blue_;
|
||||
float white_;
|
||||
ESPHOME_LIGHT_UNIT_FIELDS_UNION();
|
||||
float color_temperature_;
|
||||
float cold_white_;
|
||||
float warm_white_;
|
||||
|
||||
// Smaller members at the end for better packing
|
||||
uint16_t flags_{FLAG_PUBLISH | FLAG_SAVE}; // Tracks which values are set
|
||||
|
||||
@@ -3,11 +3,62 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "color_mode.h"
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <limits>
|
||||
|
||||
namespace esphome::light {
|
||||
|
||||
inline static uint8_t to_uint8_scale(float x) { return static_cast<uint8_t>(roundf(x * 255.0f)); }
|
||||
|
||||
// IEEE 754 bit patterns. Values in [0.0f, 1.0f] have bits <= ONE_F_BITS;
|
||||
// negatives have the sign bit set (→ huge unsigned). A single unsigned compare
|
||||
// replaces two soft-float __ltsf2/__gtsf2 calls on ESP8266.
|
||||
static constexpr uint32_t ONE_F_BITS = 0x3F800000u; // 1.0f
|
||||
static constexpr uint32_t NEG_ZERO_F_BITS = 0x80000000u; // -0.0f / sign-bit mask
|
||||
static_assert(sizeof(float) == sizeof(uint32_t), "float must be 32-bit");
|
||||
static_assert(std::numeric_limits<float>::is_iec559, "IEEE 754 float required");
|
||||
|
||||
// Union pun — memcpy/bit_cast don't fold on xtensa-gcc (see api/proto.h).
|
||||
// -0.0f is numerically zero so it's reported in range (no warning, no clamp).
|
||||
inline bool float_out_of_unit_range(float x) {
|
||||
union {
|
||||
float f;
|
||||
uint32_t u;
|
||||
} pun;
|
||||
pun.f = x;
|
||||
return pun.u > ONE_F_BITS && pun.u != NEG_ZERO_F_BITS;
|
||||
}
|
||||
|
||||
// Clamps to [0.0f, 1.0f] without float compares. Out of range: sign bit set
|
||||
// (negatives, -NaN, -Inf) → 0.0f; sign bit clear (>1, +NaN, +Inf) → 1.0f.
|
||||
inline float clamp_unit_float(float x) {
|
||||
union {
|
||||
float f;
|
||||
uint32_t u;
|
||||
} pun;
|
||||
pun.f = x;
|
||||
if (pun.u <= ONE_F_BITS)
|
||||
return x;
|
||||
return (pun.u & NEG_ZERO_F_BITS) ? 0.0f : 1.0f; // sign bit → negative → clamp to 0
|
||||
}
|
||||
|
||||
// Shared anonymous union: eight unit-range floats alias unit_fields_[8] so
|
||||
// LightCall::validate_() can iterate them as a real array. GCC/Clang ext.
|
||||
#define ESPHOME_LIGHT_UNIT_FIELDS_UNION() \
|
||||
union { \
|
||||
struct { \
|
||||
float brightness_; \
|
||||
float color_brightness_; \
|
||||
float red_; \
|
||||
float green_; \
|
||||
float blue_; \
|
||||
float white_; \
|
||||
float cold_white_; \
|
||||
float warm_white_; \
|
||||
}; \
|
||||
float unit_fields_[8]; \
|
||||
}
|
||||
|
||||
/** This class represents the color state for a light object.
|
||||
*
|
||||
* The representation of the color state is dependent on the active color mode. A color mode consists of multiple
|
||||
@@ -52,9 +103,9 @@ class LightColorValues {
|
||||
green_(1.0f),
|
||||
blue_(1.0f),
|
||||
white_(1.0f),
|
||||
color_temperature_{0.0f},
|
||||
cold_white_{1.0f},
|
||||
warm_white_{1.0f},
|
||||
color_temperature_{0.0f},
|
||||
color_mode_(ColorMode::UNKNOWN) {}
|
||||
|
||||
LightColorValues(ColorMode color_mode, float state, float brightness, float color_brightness, float red, float green,
|
||||
@@ -220,39 +271,39 @@ class LightColorValues {
|
||||
/// Get the binary true/false state of these light color values.
|
||||
bool is_on() const { return this->get_state() != 0.0f; }
|
||||
/// Set the state of these light color values. In range from 0.0 (off) to 1.0 (on)
|
||||
void set_state(float state) { this->state_ = clamp(state, 0.0f, 1.0f); }
|
||||
void set_state(float state) { this->state_ = clamp_unit_float(state); }
|
||||
/// Set the state of these light color values as a binary true/false.
|
||||
void set_state(bool state) { this->state_ = state ? 1.0f : 0.0f; }
|
||||
|
||||
/// Get the brightness property of these light color values. In range 0.0 to 1.0
|
||||
float get_brightness() const { return this->brightness_; }
|
||||
/// Set the brightness property of these light color values. In range 0.0 to 1.0
|
||||
void set_brightness(float brightness) { this->brightness_ = clamp(brightness, 0.0f, 1.0f); }
|
||||
void set_brightness(float brightness) { this->brightness_ = clamp_unit_float(brightness); }
|
||||
|
||||
/// Get the color brightness property of these light color values. In range 0.0 to 1.0
|
||||
float get_color_brightness() const { return this->color_brightness_; }
|
||||
/// Set the color brightness property of these light color values. In range 0.0 to 1.0
|
||||
void set_color_brightness(float brightness) { this->color_brightness_ = clamp(brightness, 0.0f, 1.0f); }
|
||||
void set_color_brightness(float brightness) { this->color_brightness_ = clamp_unit_float(brightness); }
|
||||
|
||||
/// Get the red property of these light color values. In range 0.0 to 1.0
|
||||
float get_red() const { return this->red_; }
|
||||
/// Set the red property of these light color values. In range 0.0 to 1.0
|
||||
void set_red(float red) { this->red_ = clamp(red, 0.0f, 1.0f); }
|
||||
void set_red(float red) { this->red_ = clamp_unit_float(red); }
|
||||
|
||||
/// Get the green property of these light color values. In range 0.0 to 1.0
|
||||
float get_green() const { return this->green_; }
|
||||
/// Set the green property of these light color values. In range 0.0 to 1.0
|
||||
void set_green(float green) { this->green_ = clamp(green, 0.0f, 1.0f); }
|
||||
void set_green(float green) { this->green_ = clamp_unit_float(green); }
|
||||
|
||||
/// Get the blue property of these light color values. In range 0.0 to 1.0
|
||||
float get_blue() const { return this->blue_; }
|
||||
/// Set the blue property of these light color values. In range 0.0 to 1.0
|
||||
void set_blue(float blue) { this->blue_ = clamp(blue, 0.0f, 1.0f); }
|
||||
void set_blue(float blue) { this->blue_ = clamp_unit_float(blue); }
|
||||
|
||||
/// Get the white property of these light color values. In range 0.0 to 1.0
|
||||
float get_white() const { return white_; }
|
||||
/// Set the white property of these light color values. In range 0.0 to 1.0
|
||||
void set_white(float white) { this->white_ = clamp(white, 0.0f, 1.0f); }
|
||||
void set_white(float white) { this->white_ = clamp_unit_float(white); }
|
||||
|
||||
/// Get the color temperature property of these light color values in mired.
|
||||
float get_color_temperature() const { return this->color_temperature_; }
|
||||
@@ -277,26 +328,19 @@ class LightColorValues {
|
||||
/// Get the cold white property of these light color values. In range 0.0 to 1.0.
|
||||
float get_cold_white() const { return this->cold_white_; }
|
||||
/// Set the cold white property of these light color values. In range 0.0 to 1.0.
|
||||
void set_cold_white(float cold_white) { this->cold_white_ = clamp(cold_white, 0.0f, 1.0f); }
|
||||
void set_cold_white(float cold_white) { this->cold_white_ = clamp_unit_float(cold_white); }
|
||||
|
||||
/// Get the warm white property of these light color values. In range 0.0 to 1.0.
|
||||
float get_warm_white() const { return this->warm_white_; }
|
||||
/// Set the warm white property of these light color values. In range 0.0 to 1.0.
|
||||
void set_warm_white(float warm_white) { this->warm_white_ = clamp(warm_white, 0.0f, 1.0f); }
|
||||
void set_warm_white(float warm_white) { this->warm_white_ = clamp_unit_float(warm_white); }
|
||||
|
||||
friend class LightCall;
|
||||
|
||||
protected:
|
||||
float state_; ///< ON / OFF, float for transition
|
||||
float brightness_;
|
||||
float color_brightness_;
|
||||
float red_;
|
||||
float green_;
|
||||
float blue_;
|
||||
float white_;
|
||||
ESPHOME_LIGHT_UNIT_FIELDS_UNION();
|
||||
float color_temperature_; ///< Color Temperature in Mired
|
||||
float cold_white_;
|
||||
float warm_white_;
|
||||
ColorMode color_mode_;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ from ..defines import (
|
||||
literal,
|
||||
)
|
||||
from ..lv_validation import animated, lv_int, size
|
||||
from ..lvcode import LocalVariable, lv, lv_assign, lv_expr, lv_obj
|
||||
from ..lvcode import LocalVariable, lv, lv_assign, lv_expr, lv_obj, lv_Pvariable
|
||||
from ..schemas import container_schema, part_schema
|
||||
from ..types import LV_EVENT, LvType, ObjUpdateAction, lv_obj_t, lv_obj_t_ptr
|
||||
from . import Widget, WidgetType, add_widgets, get_widgets, set_obj_properties
|
||||
@@ -83,8 +83,8 @@ class TabviewType(WidgetType):
|
||||
await w.set_property("tab_bar_size", await size.process(config[CONF_SIZE]))
|
||||
for tab_conf in config[CONF_TABS]:
|
||||
w_id = tab_conf[CONF_ID]
|
||||
tab_obj = cg.Pvariable(w_id, cg.nullptr, type_=lv_tab_t)
|
||||
tab_widget = Widget.create(w_id, tab_obj, obj_spec)
|
||||
tab_obj = lv_Pvariable(lv_tab_t, w_id)
|
||||
tab_widget = Widget.create(w_id, tab_obj, obj_spec, tab_conf)
|
||||
lv_assign(tab_obj, lv_expr.tabview_add_tab(w.obj, tab_conf[CONF_NAME]))
|
||||
await set_obj_properties(tab_widget, tab_conf)
|
||||
await add_widgets(tab_widget, tab_conf)
|
||||
|
||||
@@ -37,7 +37,10 @@ void IRAM_ATTR MCP23016::gpio_intr(MCP23016 *arg) { arg->enable_loop_soon_any_co
|
||||
void MCP23016::loop() {
|
||||
// Invalidate cache at the start of each loop
|
||||
this->reset_pin_cache_();
|
||||
if (this->interrupt_pin_ != nullptr) {
|
||||
// Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the
|
||||
// I2C read leave INT asserted without re-firing a falling edge, which would strand us with
|
||||
// stale state forever; keep looping until the line is released so we self-heal.
|
||||
if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) {
|
||||
this->disable_loop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,10 @@ template<uint8_t N> class MCP23XXXBase : public Component, public gpio_expander:
|
||||
|
||||
void loop() override {
|
||||
this->reset_pin_cache_();
|
||||
if (this->interrupt_pin_ != nullptr) {
|
||||
// Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the
|
||||
// I2C read leave INT asserted without re-firing a falling edge, which would strand us with
|
||||
// stale state forever; keep looping until the line is released so we self-heal.
|
||||
if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) {
|
||||
this->disable_loop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -109,21 +109,21 @@ CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.SplitDefault(
|
||||
CONF_ENABLE_IPV6,
|
||||
esp8266=False,
|
||||
esp32=False,
|
||||
rp2040=False,
|
||||
bk72xx=False,
|
||||
esp32=False,
|
||||
esp8266=False,
|
||||
host=False,
|
||||
rp2040=False,
|
||||
): cv.All(
|
||||
cv.boolean,
|
||||
cv.Any(
|
||||
cv.require_framework_version(
|
||||
bk72xx_arduino=cv.Version(1, 7, 0),
|
||||
esp_idf=cv.Version(0, 0, 0),
|
||||
esp32_arduino=cv.Version(0, 0, 0),
|
||||
esp8266_arduino=cv.Version(0, 0, 0),
|
||||
rp2040_arduino=cv.Version(0, 0, 0),
|
||||
bk72xx_arduino=cv.Version(1, 7, 0),
|
||||
host=cv.Version(0, 0, 0),
|
||||
rp2040_arduino=cv.Version(0, 0, 0),
|
||||
),
|
||||
cv.boolean_false,
|
||||
),
|
||||
@@ -218,9 +218,9 @@ async def to_code(config):
|
||||
elif enable_ipv6:
|
||||
cg.add_build_flag("-DCONFIG_LWIP_IPV6")
|
||||
cg.add_build_flag("-DCONFIG_LWIP_IPV6_AUTOCONFIG")
|
||||
if CORE.is_rp2040:
|
||||
cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_ENABLE_IPV6")
|
||||
if CORE.is_esp8266:
|
||||
cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY")
|
||||
if CORE.is_bk72xx:
|
||||
cg.add_build_flag("-DCONFIG_IPV6")
|
||||
if CORE.is_esp8266:
|
||||
cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY")
|
||||
if CORE.is_rp2040:
|
||||
cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_ENABLE_IPV6")
|
||||
|
||||
@@ -57,8 +57,11 @@ void OneWireBus::search() {
|
||||
}
|
||||
}
|
||||
|
||||
void OneWireBus::skip() {
|
||||
bool OneWireBus::skip() {
|
||||
if (!this->reset_())
|
||||
return false;
|
||||
this->write8(0xCC); // skip ROM
|
||||
return true;
|
||||
}
|
||||
|
||||
const LogString *OneWireBus::get_model_str(uint8_t model) {
|
||||
|
||||
@@ -16,7 +16,8 @@ class OneWireBus {
|
||||
virtual void write64(uint64_t val) = 0;
|
||||
|
||||
/// Write a command to the bus that addresses all devices by skipping the ROM.
|
||||
void skip();
|
||||
/// Returns true if a device presence pulse is detected.
|
||||
bool skip();
|
||||
|
||||
/// Read an 8 bit word from the bus.
|
||||
virtual uint8_t read8() = 0;
|
||||
|
||||
@@ -62,7 +62,10 @@ void IRAM_ATTR PCA6416AComponent::gpio_intr(PCA6416AComponent *arg) { arg->enabl
|
||||
void PCA6416AComponent::loop() {
|
||||
// Invalidate cache at the start of each loop
|
||||
this->reset_pin_cache_();
|
||||
if (this->interrupt_pin_ != nullptr) {
|
||||
// Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the
|
||||
// I2C read leave INT asserted without re-firing a falling edge, which would strand us with
|
||||
// stale state forever; keep looping until the line is released so we self-heal.
|
||||
if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) {
|
||||
this->disable_loop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,8 +50,10 @@ void IRAM_ATTR PCA9554Component::gpio_intr(PCA9554Component *arg) { arg->enable_
|
||||
void PCA9554Component::loop() {
|
||||
// Invalidate the cache so the next digital_read() triggers a fresh I2C read
|
||||
this->reset_pin_cache_();
|
||||
if (this->interrupt_pin_ != nullptr) {
|
||||
// Interrupt-driven: disable loop until next interrupt fires
|
||||
// Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the
|
||||
// I2C read leave INT asserted without re-firing a falling edge, which would strand us with
|
||||
// stale state forever; keep looping until the line is released so we self-heal.
|
||||
if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) {
|
||||
this->disable_loop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,8 +31,10 @@ void IRAM_ATTR PCF8574Component::gpio_intr(PCF8574Component *arg) { arg->enable_
|
||||
void PCF8574Component::loop() {
|
||||
// Invalidate the cache so the next digital_read() triggers a fresh I2C read
|
||||
this->reset_pin_cache_();
|
||||
if (this->interrupt_pin_ != nullptr) {
|
||||
// Interrupt-driven: disable loop until next interrupt fires
|
||||
// Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the
|
||||
// I2C read leave INT asserted without re-firing a falling edge, which would strand us with
|
||||
// stale state forever; keep looping until the line is released so we self-heal.
|
||||
if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) {
|
||||
this->disable_loop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,10 @@ void PI4IOE5V6408Component::pin_mode(uint8_t pin, gpio::Flags flags) {
|
||||
|
||||
void PI4IOE5V6408Component::loop() {
|
||||
this->reset_pin_cache_();
|
||||
if (this->interrupt_pin_ != nullptr) {
|
||||
// Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the
|
||||
// I2C read leave INT asserted without re-firing a falling edge, which would strand us with
|
||||
// stale state forever; keep looping until the line is released so we self-heal.
|
||||
if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) {
|
||||
this->disable_loop();
|
||||
}
|
||||
}
|
||||
|
||||
77
esphome/components/radio_frequency/__init__.py
Normal file
77
esphome/components/radio_frequency/__init__.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Radio Frequency component for ESPHome.
|
||||
|
||||
WARNING: This component is EXPERIMENTAL. The API (both Python configuration
|
||||
and C++ interfaces) may change at any time without following the normal
|
||||
breaking changes policy. Use at your own risk.
|
||||
|
||||
Once the API is considered stable, this warning will be removed.
|
||||
"""
|
||||
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.core import CORE, coroutine_with_priority
|
||||
from esphome.core.entity_helpers import setup_entity
|
||||
from esphome.coroutine import CoroPriority
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@kbx81"]
|
||||
AUTO_LOAD = ["remote_base"]
|
||||
|
||||
IS_PLATFORM_COMPONENT = True
|
||||
|
||||
radio_frequency_ns = cg.esphome_ns.namespace("radio_frequency")
|
||||
RadioFrequency = radio_frequency_ns.class_(
|
||||
"RadioFrequency", cg.EntityBase, cg.Component
|
||||
)
|
||||
RadioFrequencyCall = radio_frequency_ns.class_("RadioFrequencyCall")
|
||||
RadioFrequencyTraits = radio_frequency_ns.class_("RadioFrequencyTraits")
|
||||
RadioFrequencyModulation = radio_frequency_ns.enum("RadioFrequencyModulation")
|
||||
|
||||
CONF_RADIO_FREQUENCY_ID = "radio_frequency_id"
|
||||
|
||||
|
||||
def radio_frequency_schema(class_: type[cg.MockObjClass]) -> cv.Schema:
|
||||
"""Create a schema for a radio frequency platform.
|
||||
|
||||
:param class_: The radio frequency class to use for this schema.
|
||||
:return: An extended schema for radio frequency configuration.
|
||||
"""
|
||||
entity_schema = cv.ENTITY_BASE_SCHEMA.extend(cv.COMPONENT_SCHEMA)
|
||||
return entity_schema.extend(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(class_),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@setup_entity("radio_frequency")
|
||||
async def setup_radio_frequency_core_(var: cg.Pvariable, config: ConfigType) -> None:
|
||||
"""Set up core radio frequency configuration."""
|
||||
|
||||
|
||||
async def register_radio_frequency(var: cg.Pvariable, config: ConfigType) -> None:
|
||||
"""Register a radio frequency device with the core."""
|
||||
cg.add_define("USE_RADIO_FREQUENCY")
|
||||
await cg.register_component(var, config)
|
||||
await setup_radio_frequency_core_(var, config)
|
||||
cg.add(cg.App.register_radio_frequency(var))
|
||||
CORE.register_platform_component("radio_frequency", var)
|
||||
|
||||
|
||||
async def new_radio_frequency(config: ConfigType, *args) -> cg.Pvariable:
|
||||
"""Create a new RadioFrequency instance.
|
||||
|
||||
:param config: Configuration dictionary.
|
||||
:param args: Additional arguments to pass to new_Pvariable.
|
||||
:return: The created RadioFrequency instance.
|
||||
"""
|
||||
var = cg.new_Pvariable(config[CONF_ID], *args)
|
||||
await register_radio_frequency(var, config)
|
||||
return var
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.CORE)
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
cg.add_global(radio_frequency_ns.using)
|
||||
109
esphome/components/radio_frequency/radio_frequency.cpp
Normal file
109
esphome/components/radio_frequency/radio_frequency.cpp
Normal file
@@ -0,0 +1,109 @@
|
||||
#include "radio_frequency.h"
|
||||
|
||||
#include <cinttypes>
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#ifdef USE_API
|
||||
#include "esphome/components/api/api_server.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::radio_frequency {
|
||||
|
||||
static const char *const TAG = "radio_frequency";
|
||||
|
||||
// ========== RadioFrequencyCall ==========
|
||||
|
||||
RadioFrequencyCall &RadioFrequencyCall::set_frequency(uint32_t frequency_hz) {
|
||||
this->frequency_hz_ = frequency_hz;
|
||||
return *this;
|
||||
}
|
||||
|
||||
RadioFrequencyCall &RadioFrequencyCall::set_modulation(RadioFrequencyModulation modulation) {
|
||||
this->modulation_ = modulation;
|
||||
return *this;
|
||||
}
|
||||
|
||||
RadioFrequencyCall &RadioFrequencyCall::set_raw_timings(const std::vector<int32_t> &timings) {
|
||||
this->raw_timings_ = &timings;
|
||||
this->packed_data_ = nullptr;
|
||||
this->base64url_ptr_ = nullptr;
|
||||
return *this;
|
||||
}
|
||||
|
||||
RadioFrequencyCall &RadioFrequencyCall::set_raw_timings_base64url(const std::string &base64url) {
|
||||
this->base64url_ptr_ = &base64url;
|
||||
this->raw_timings_ = nullptr;
|
||||
this->packed_data_ = nullptr;
|
||||
return *this;
|
||||
}
|
||||
|
||||
RadioFrequencyCall &RadioFrequencyCall::set_raw_timings_packed(const uint8_t *data, uint16_t length, uint16_t count) {
|
||||
this->packed_data_ = data;
|
||||
this->packed_length_ = length;
|
||||
this->packed_count_ = count;
|
||||
this->raw_timings_ = nullptr;
|
||||
this->base64url_ptr_ = nullptr;
|
||||
return *this;
|
||||
}
|
||||
|
||||
RadioFrequencyCall &RadioFrequencyCall::set_repeat_count(uint32_t count) {
|
||||
this->repeat_count_ = count;
|
||||
return *this;
|
||||
}
|
||||
|
||||
void RadioFrequencyCall::perform() {
|
||||
if (this->parent_ != nullptr) {
|
||||
this->parent_->control(*this);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== RadioFrequency ==========
|
||||
|
||||
void RadioFrequency::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Radio Frequency '%s'\n"
|
||||
" Supports Transmitter: %s\n"
|
||||
" Supports Receiver: %s",
|
||||
this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()),
|
||||
YESNO(this->traits_.get_supports_receiver()));
|
||||
if (this->traits_.get_frequency_min_hz() > 0) {
|
||||
if (this->traits_.get_frequency_min_hz() == this->traits_.get_frequency_max_hz()) {
|
||||
ESP_LOGCONFIG(TAG, " Frequency: %" PRIu32 " Hz (fixed)", this->traits_.get_frequency_min_hz());
|
||||
} else {
|
||||
ESP_LOGCONFIG(TAG, " Frequency Range: %" PRIu32 " - %" PRIu32 " Hz", this->traits_.get_frequency_min_hz(),
|
||||
this->traits_.get_frequency_max_hz());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RadioFrequencyCall RadioFrequency::make_call() { return RadioFrequencyCall(this); }
|
||||
|
||||
uint32_t RadioFrequency::get_capability_flags() const {
|
||||
uint32_t flags = 0;
|
||||
if (this->traits_.get_supports_transmitter())
|
||||
flags |= RadioFrequencyCapability::CAPABILITY_TRANSMITTER;
|
||||
if (this->traits_.get_supports_receiver())
|
||||
flags |= RadioFrequencyCapability::CAPABILITY_RECEIVER;
|
||||
return flags;
|
||||
}
|
||||
|
||||
bool RadioFrequency::on_receive(remote_base::RemoteReceiveData data) {
|
||||
// Invoke local callbacks
|
||||
this->receive_callback_.call(data);
|
||||
|
||||
// Forward received RF data to API server
|
||||
#if defined(USE_API) && defined(USE_RADIO_FREQUENCY)
|
||||
if (api::global_api_server != nullptr) {
|
||||
#ifdef USE_DEVICES
|
||||
uint32_t device_id = this->get_device_id();
|
||||
#else
|
||||
uint32_t device_id = 0;
|
||||
#endif
|
||||
api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data());
|
||||
}
|
||||
#endif
|
||||
return false; // Don't consume the event, allow other listeners to process it
|
||||
}
|
||||
|
||||
} // namespace esphome::radio_frequency
|
||||
187
esphome/components/radio_frequency/radio_frequency.h
Normal file
187
esphome/components/radio_frequency/radio_frequency.h
Normal file
@@ -0,0 +1,187 @@
|
||||
#pragma once
|
||||
|
||||
// WARNING: This component is EXPERIMENTAL. The API may change at any time
|
||||
// without following the normal breaking changes policy. Use at your own risk.
|
||||
// Once the API is considered stable, this warning will be removed.
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/entity_base.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/components/remote_base/remote_base.h"
|
||||
|
||||
#include <vector>
|
||||
|
||||
namespace esphome::radio_frequency {
|
||||
|
||||
/// Capability flags for individual radio frequency instances
|
||||
enum RadioFrequencyCapability : uint32_t {
|
||||
CAPABILITY_TRANSMITTER = 1 << 0, // Can transmit signals
|
||||
CAPABILITY_RECEIVER = 1 << 1, // Can receive signals
|
||||
};
|
||||
|
||||
/// Modulation types supported by radio frequency implementations
|
||||
enum RadioFrequencyModulation : uint8_t {
|
||||
RADIO_FREQUENCY_MODULATION_OOK = 0, // On-Off Keying / Amplitude Shift Keying
|
||||
// Future: RADIO_FREQUENCY_MODULATION_FSK, RADIO_FREQUENCY_MODULATION_GFSK, etc.
|
||||
};
|
||||
|
||||
/// Forward declarations
|
||||
class RadioFrequency;
|
||||
|
||||
/// RadioFrequencyCall - Builder pattern for transmitting radio frequency signals
|
||||
class RadioFrequencyCall {
|
||||
public:
|
||||
explicit RadioFrequencyCall(RadioFrequency *parent) : parent_(parent) {}
|
||||
|
||||
/// Set the carrier frequency in Hz (e.g. 433920000 for 433.92 MHz)
|
||||
RadioFrequencyCall &set_frequency(uint32_t frequency_hz);
|
||||
|
||||
/// Set the modulation type (defaults to OOK)
|
||||
RadioFrequencyCall &set_modulation(RadioFrequencyModulation modulation);
|
||||
|
||||
// ===== Raw Timings Methods =====
|
||||
// All set_raw_timings_* methods store pointers/references to external data.
|
||||
// The referenced data must remain valid until perform() completes.
|
||||
// Safe pattern: call.set_raw_timings_xxx(data); call.perform(); // synchronous
|
||||
// Unsafe pattern: call.set_raw_timings_xxx(data); defer([call]() { call.perform(); }); // data may be gone!
|
||||
|
||||
/// Set the raw timings from a vector (positive = mark, negative = space)
|
||||
/// @note Lifetime: Stores a pointer to the vector. The vector must outlive perform().
|
||||
/// @note Usage: Primarily for lambdas/automations where the vector is in scope.
|
||||
RadioFrequencyCall &set_raw_timings(const std::vector<int32_t> &timings);
|
||||
|
||||
/// Set the raw timings from base64url-encoded little-endian int32 data
|
||||
/// @note Lifetime: Stores a pointer to the string. The string must outlive perform().
|
||||
/// @note Usage: For web_server - base64url is fully URL-safe (uses '-' and '_').
|
||||
/// @note Decoding happens at perform() time, directly into the transmit buffer.
|
||||
RadioFrequencyCall &set_raw_timings_base64url(const std::string &base64url);
|
||||
|
||||
/// Set the raw timings from packed protobuf sint32 data (zigzag + varint encoded)
|
||||
/// @note Lifetime: Stores a pointer to the buffer. The buffer must outlive perform().
|
||||
/// @note Usage: For API component where data comes directly from the protobuf message.
|
||||
RadioFrequencyCall &set_raw_timings_packed(const uint8_t *data, uint16_t length, uint16_t count);
|
||||
|
||||
/// Set the number of times to repeat transmission (1 = transmit once, 2 = transmit twice, etc.)
|
||||
RadioFrequencyCall &set_repeat_count(uint32_t count);
|
||||
|
||||
/// Perform the transmission
|
||||
void perform();
|
||||
|
||||
/// Get the frequency in Hz
|
||||
const optional<uint32_t> &get_frequency() const { return this->frequency_hz_; }
|
||||
/// Get the modulation type
|
||||
RadioFrequencyModulation get_modulation() const { return this->modulation_; }
|
||||
/// Get the raw timings (only valid if set via set_raw_timings)
|
||||
const std::vector<int32_t> &get_raw_timings() const { return *this->raw_timings_; }
|
||||
/// Check if raw timings have been set (any format)
|
||||
bool has_raw_timings() const {
|
||||
return this->raw_timings_ != nullptr || this->packed_data_ != nullptr || this->base64url_ptr_ != nullptr;
|
||||
}
|
||||
/// Check if using packed data format
|
||||
bool is_packed() const { return this->packed_data_ != nullptr; }
|
||||
/// Check if using base64url data format
|
||||
bool is_base64url() const { return this->base64url_ptr_ != nullptr; }
|
||||
/// Get the base64url data string
|
||||
const std::string &get_base64url_data() const { return *this->base64url_ptr_; }
|
||||
/// Get packed data (only valid if set via set_raw_timings_packed)
|
||||
const uint8_t *get_packed_data() const { return this->packed_data_; }
|
||||
uint16_t get_packed_length() const { return this->packed_length_; }
|
||||
uint16_t get_packed_count() const { return this->packed_count_; }
|
||||
/// Get the repeat count
|
||||
uint32_t get_repeat_count() const { return this->repeat_count_; }
|
||||
|
||||
protected:
|
||||
optional<uint32_t> frequency_hz_{};
|
||||
uint32_t repeat_count_{1};
|
||||
RadioFrequency *parent_;
|
||||
// Pointer to vector-based timings (caller-owned, must outlive perform())
|
||||
const std::vector<int32_t> *raw_timings_{nullptr};
|
||||
// Pointer to base64url-encoded string (caller-owned, must outlive perform())
|
||||
const std::string *base64url_ptr_{nullptr};
|
||||
// Pointer to packed protobuf buffer (caller-owned, must outlive perform())
|
||||
const uint8_t *packed_data_{nullptr};
|
||||
uint16_t packed_length_{0};
|
||||
uint16_t packed_count_{0};
|
||||
RadioFrequencyModulation modulation_{RADIO_FREQUENCY_MODULATION_OOK};
|
||||
};
|
||||
|
||||
/// RadioFrequencyTraits - Describes the capabilities of a radio frequency implementation
|
||||
class RadioFrequencyTraits {
|
||||
public:
|
||||
bool get_supports_transmitter() const { return this->supports_transmitter_; }
|
||||
void set_supports_transmitter(bool supports) { this->supports_transmitter_ = supports; }
|
||||
|
||||
bool get_supports_receiver() const { return this->supports_receiver_; }
|
||||
void set_supports_receiver(bool supports) { this->supports_receiver_ = supports; }
|
||||
|
||||
/// Hardware-supported tunable frequency range in Hz.
|
||||
/// If min == max (and both non-zero): fixed-frequency hardware.
|
||||
/// If both 0: range unspecified.
|
||||
uint32_t get_frequency_min_hz() const { return this->frequency_min_hz_; }
|
||||
void set_frequency_min_hz(uint32_t freq) { this->frequency_min_hz_ = freq; }
|
||||
|
||||
uint32_t get_frequency_max_hz() const { return this->frequency_max_hz_; }
|
||||
void set_frequency_max_hz(uint32_t freq) { this->frequency_max_hz_ = freq; }
|
||||
|
||||
/// Convenience setter for fixed-frequency hardware (sets min == max).
|
||||
void set_fixed_frequency_hz(uint32_t freq) {
|
||||
this->frequency_min_hz_ = freq;
|
||||
this->frequency_max_hz_ = freq;
|
||||
}
|
||||
|
||||
/// Bitmask of supported RadioFrequencyModulation values (bit N = modulation value N supported).
|
||||
uint32_t get_supported_modulations() const { return this->supported_modulations_; }
|
||||
void set_supported_modulations(uint32_t mask) { this->supported_modulations_ = mask; }
|
||||
void add_supported_modulation(RadioFrequencyModulation mod) {
|
||||
this->supported_modulations_ |= (1u << static_cast<uint8_t>(mod));
|
||||
}
|
||||
|
||||
protected:
|
||||
uint32_t frequency_min_hz_{0}; // Minimum tunable frequency in Hz (0 = unspecified)
|
||||
uint32_t frequency_max_hz_{0}; // Maximum tunable frequency in Hz (0 = unspecified)
|
||||
uint32_t supported_modulations_{0}; // Bitmask of supported RadioFrequencyModulation values
|
||||
bool supports_transmitter_{false};
|
||||
bool supports_receiver_{false};
|
||||
};
|
||||
|
||||
/// RadioFrequency - Base class for radio frequency implementations
|
||||
class RadioFrequency : public Component, public EntityBase, public remote_base::RemoteReceiverListener {
|
||||
public:
|
||||
RadioFrequency() = default;
|
||||
|
||||
void dump_config() override;
|
||||
float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; }
|
||||
|
||||
/// Get the traits for this radio frequency implementation
|
||||
RadioFrequencyTraits &get_traits() { return this->traits_; }
|
||||
const RadioFrequencyTraits &get_traits() const { return this->traits_; }
|
||||
|
||||
/// Create a call object for transmitting
|
||||
RadioFrequencyCall make_call();
|
||||
|
||||
/// Get capability flags for this radio frequency instance
|
||||
uint32_t get_capability_flags() const;
|
||||
|
||||
/// Called when RF data is received (from RemoteReceiverListener)
|
||||
bool on_receive(remote_base::RemoteReceiveData data) override;
|
||||
|
||||
/// Add a callback to invoke when RF data is received
|
||||
template<typename F> void add_on_receive_callback(F &&callback) {
|
||||
this->receive_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
|
||||
protected:
|
||||
friend class RadioFrequencyCall;
|
||||
|
||||
/// Perform the actual transmission (called by RadioFrequencyCall::perform())
|
||||
/// Platforms must override this to implement hardware-specific transmission.
|
||||
virtual void control(const RadioFrequencyCall &call) = 0;
|
||||
|
||||
// Traits describing capabilities
|
||||
RadioFrequencyTraits traits_;
|
||||
|
||||
// Callback manager for receive events (lazy: saves memory when no callbacks registered)
|
||||
LazyCallbackManager<void(remote_base::RemoteReceiveData)> receive_callback_;
|
||||
};
|
||||
|
||||
} // namespace esphome::radio_frequency
|
||||
@@ -26,7 +26,7 @@ from esphome.core.config import BOARD_MAX_LENGTH
|
||||
from esphome.helpers import copy_file_if_changed, read_file, write_file_if_changed
|
||||
|
||||
from . import boards
|
||||
from .const import KEY_BOARD, KEY_PIO_FILES, KEY_RP2040, rp2040_ns
|
||||
from .const import KEY_BOARD, KEY_LWIP_OPTS, KEY_PIO_FILES, KEY_RP2040, rp2040_ns
|
||||
|
||||
# force import gpio to register pin schema
|
||||
from .gpio import rp2040_pin_to_code # noqa
|
||||
@@ -240,6 +240,160 @@ async def to_code(config):
|
||||
cg.add_define("USE_RP2040_WATCHDOG_TIMEOUT", config[CONF_WATCHDOG_TIMEOUT])
|
||||
cg.add_define("USE_RP2040_CRASH_HANDLER")
|
||||
|
||||
_configure_lwip()
|
||||
|
||||
|
||||
def _configure_lwip() -> None:
|
||||
"""Configure lwIP options for RP2040 by generating a custom lwipopts.h.
|
||||
|
||||
Arduino-pico's lwipopts.h has no #ifndef guards, so -D flags cannot override
|
||||
its settings. Instead, we generate a replacement lwipopts.h and place it in an
|
||||
include directory that shadows the framework's version.
|
||||
|
||||
lwIP is compiled from source on RP2040 (not pre-built), so our replacement
|
||||
header fully controls the compiled lwIP behavior.
|
||||
|
||||
RP2040 uses NO_SYS=1 (polling, no RTOS thread), LWIP_SOCKET=0, LWIP_NETCONN=0.
|
||||
DHCP/DNS use raw udp_new() which allocates from MEMP_NUM_UDP_PCB.
|
||||
|
||||
Comparison of arduino-pico defaults vs ESPHome targets (TCP_MSS=1460):
|
||||
|
||||
Setting ESP8266 ESP32 arduino-pico New
|
||||
────────────────────────────────────────────────────────────────
|
||||
TCP_SND_BUF 2×MSS 4×MSS 8×MSS 4×MSS
|
||||
TCP_WND 4×MSS 4×MSS 8×MSS 4×MSS
|
||||
MEM_LIBC_MALLOC 1 1 0 0*
|
||||
MEMP_MEM_MALLOC 1 1 0 0**
|
||||
MEM_SIZE N/A*** N/A*** 16KB 16KB
|
||||
PBUF_POOL_SIZE 10 16 24 16
|
||||
MEMP_NUM_TCP_SEG 10 16 32 17
|
||||
MEMP_NUM_TCP_PCB 5 16 5 dynamic
|
||||
MEMP_NUM_TCP_PCB_LISTEN 4 16 8**** dynamic
|
||||
MEMP_NUM_UDP_PCB 4 16 7 dynamic
|
||||
TCP_SND_QUEUELEN ~8 17 32 17
|
||||
|
||||
* MEM_LIBC_MALLOC must stay 0: arduino-pico uses
|
||||
PICO_CYW43_ARCH_THREADSAFE_BACKGROUND which runs lwIP callbacks from
|
||||
a low-priority pendsv IRQ. The pico-sdk explicitly blocks
|
||||
MEM_LIBC_MALLOC=1 because libc malloc uses mutexes (unsafe in IRQ).
|
||||
** MEMP_MEM_MALLOC must stay 0: the dedicated lwIP heap (MEM_SIZE=16KB)
|
||||
is too small to hold all pools dynamically. The PBUF_POOL alone needs
|
||||
~24KB (16 × 1524 bytes). Increasing MEM_SIZE would negate BSS savings.
|
||||
*** ESP8266/ESP32 use MEM_LIBC_MALLOC=1 (system heap, no dedicated pool).
|
||||
**** opt.h default; arduino-pico doesn't override MEMP_NUM_TCP_PCB_LISTEN.
|
||||
"dynamic" = auto-calculated from component socket registrations via
|
||||
socket.get_socket_counts() with minimums of 8 TCP / 6 UDP / 2 TCP_LISTEN.
|
||||
"""
|
||||
from esphome.components.socket import (
|
||||
MIN_TCP_LISTEN_SOCKETS,
|
||||
MIN_TCP_SOCKETS,
|
||||
MIN_UDP_SOCKETS,
|
||||
get_socket_counts,
|
||||
)
|
||||
|
||||
sc = get_socket_counts()
|
||||
# Apply platform minimums — ensure headroom for ESPHome's needs
|
||||
tcp_sockets = max(MIN_TCP_SOCKETS, sc.tcp)
|
||||
udp_sockets = max(MIN_UDP_SOCKETS, sc.udp)
|
||||
# RP2040 has more RAM (264KB) than most LibreTiny boards, so DHCP/DNS
|
||||
# UDP PCBs (2) are absorbed by the generous minimum of 6.
|
||||
listening_tcp = max(MIN_TCP_LISTEN_SOCKETS, sc.tcp_listen)
|
||||
|
||||
# TCP_SND_BUF: 4×MSS=5,840 matches ESP32. Down from arduino-pico's 8×MSS.
|
||||
# ESPAsyncWebServer allocates malloc(tcp_sndbuf()) per response chunk.
|
||||
tcp_snd_buf = "(4*TCP_MSS)"
|
||||
|
||||
# TCP_WND: receive window. 4×MSS matches ESP32. Down from arduino-pico's 8×MSS.
|
||||
tcp_wnd = "(4*TCP_MSS)"
|
||||
|
||||
# TCP_SND_QUEUELEN: max pbufs queued for send buffer
|
||||
# ESP-IDF formula: (4 * TCP_SND_BUF + (TCP_MSS - 1)) / TCP_MSS
|
||||
# With 4×MSS: (4*5840 + 1459) / 1460 = 17 — match ESP32
|
||||
tcp_snd_queuelen = 17
|
||||
# MEMP_NUM_TCP_SEG: segment pool, must be >= TCP_SND_QUEUELEN (lwIP sanity check)
|
||||
memp_num_tcp_seg = tcp_snd_queuelen
|
||||
|
||||
# PBUF_POOL_SIZE: RP2040 has 264KB RAM, more generous than LibreTiny.
|
||||
# 16 matches ESP32 (vs arduino-pico's 24). With MEMP_MEM_MALLOC=1,
|
||||
# this is a max count (allocated on demand from heap).
|
||||
pbuf_pool_size = 16
|
||||
|
||||
# Build the lwIP override defines for the Jinja2 template.
|
||||
# The template uses #include_next to chain to the framework's original
|
||||
# lwipopts.h, then #undef/#define only the values we need to change.
|
||||
#
|
||||
# Note: MEMP_MEM_MALLOC stays 0 (framework default). While the memp
|
||||
# allocations use the dedicated lwIP heap (IRQ-safe), the 16KB MEM_SIZE
|
||||
# is too small to hold all pools dynamically under stress. The PBUF_POOL
|
||||
# alone needs ~24KB (16 × 1524 bytes). Increasing MEM_SIZE would negate
|
||||
# the BSS savings.
|
||||
#
|
||||
# MEM_LIBC_MALLOC stays 0 (framework default): arduino-pico uses
|
||||
# PICO_CYW43_ARCH_THREADSAFE_BACKGROUND which runs lwIP callbacks from
|
||||
# a low-priority pendsv IRQ where libc malloc (mutex-based) is unsafe.
|
||||
lwip_defines: dict[str, str] = {
|
||||
"TCP_SND_BUF": tcp_snd_buf,
|
||||
"TCP_WND": tcp_wnd,
|
||||
"TCP_SND_QUEUELEN": str(tcp_snd_queuelen),
|
||||
"MEMP_NUM_TCP_SEG": str(memp_num_tcp_seg),
|
||||
"PBUF_POOL_SIZE": str(pbuf_pool_size),
|
||||
"MEMP_NUM_TCP_PCB": str(tcp_sockets),
|
||||
"MEMP_NUM_TCP_PCB_LISTEN": str(listening_tcp),
|
||||
"MEMP_NUM_UDP_PCB": str(udp_sockets),
|
||||
}
|
||||
|
||||
# Store for copy_files() to generate the header
|
||||
CORE.data[KEY_RP2040][KEY_LWIP_OPTS] = lwip_defines
|
||||
|
||||
# Add a pre-build extra script that injects our lwip_override directory
|
||||
# into CCFLAGS so our lwipopts.h shadows the framework's version.
|
||||
# Regular build_flags (-I/-isystem) come after -iwithprefixbefore in GCC's
|
||||
# search order, so we must prepend via an extra_scripts hook.
|
||||
cg.add_platformio_option("extra_scripts", ["pre:inject_lwip_include.py"])
|
||||
|
||||
tcp_min = " (min)" if tcp_sockets > sc.tcp else ""
|
||||
udp_min = " (min)" if udp_sockets > sc.udp else ""
|
||||
listen_min = " (min)" if listening_tcp > sc.tcp_listen else ""
|
||||
_LOGGER.info(
|
||||
"Configuring lwIP: TCP=%d%s [%s], UDP=%d%s [%s], TCP_LISTEN=%d%s [%s]",
|
||||
tcp_sockets,
|
||||
tcp_min,
|
||||
sc.tcp_details,
|
||||
udp_sockets,
|
||||
udp_min,
|
||||
sc.udp_details,
|
||||
listening_tcp,
|
||||
listen_min,
|
||||
sc.tcp_listen_details,
|
||||
)
|
||||
|
||||
|
||||
def _generate_lwipopts_h() -> None:
|
||||
"""Generate a custom lwipopts.h that shadows the framework's version.
|
||||
|
||||
Uses Jinja2 to render the template with the lwIP defines calculated
|
||||
during code generation. The generated header is placed in lwip_override/
|
||||
in the build directory, and a pre-build script injects this directory
|
||||
into the compiler include path before the framework's own include dir.
|
||||
"""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
lwip_defines = CORE.data[KEY_RP2040].get(KEY_LWIP_OPTS)
|
||||
if not lwip_defines:
|
||||
return
|
||||
|
||||
template_dir = Path(__file__).parent
|
||||
jinja_env = Environment(
|
||||
loader=FileSystemLoader(str(template_dir)),
|
||||
keep_trailing_newline=True,
|
||||
)
|
||||
template = jinja_env.get_template("lwipopts.h.jinja")
|
||||
content = template.render(**lwip_defines)
|
||||
|
||||
lwip_dir = CORE.relative_build_path("lwip_override")
|
||||
lwip_dir.mkdir(parents=True, exist_ok=True)
|
||||
write_file_if_changed(lwip_dir / "lwipopts.h", content)
|
||||
|
||||
|
||||
def add_pio_file(component: str, key: str, data: str):
|
||||
try:
|
||||
@@ -289,6 +443,12 @@ def copy_files():
|
||||
post_build_file,
|
||||
CORE.relative_build_path("post_build.py"),
|
||||
)
|
||||
inject_lwip_file = dir / "inject_lwip_include.py.script"
|
||||
copy_file_if_changed(
|
||||
inject_lwip_file,
|
||||
CORE.relative_build_path("inject_lwip_include.py"),
|
||||
)
|
||||
_generate_lwipopts_h()
|
||||
if generate_pio_files():
|
||||
path = CORE.relative_src_path("esphome.h")
|
||||
content = read_file(path).rstrip("\n")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import esphome.codegen as cg
|
||||
|
||||
KEY_BOARD = "board"
|
||||
KEY_LWIP_OPTS = "lwip_opts"
|
||||
KEY_RP2040 = "rp2040"
|
||||
KEY_PIO_FILES = "pio_files"
|
||||
|
||||
|
||||
18
esphome/components/rp2040/inject_lwip_include.py.script
Normal file
18
esphome/components/rp2040/inject_lwip_include.py.script
Normal file
@@ -0,0 +1,18 @@
|
||||
# pylint: disable=E0602
|
||||
Import("env") # noqa
|
||||
|
||||
import os
|
||||
|
||||
# PlatformIO pre-build script: inject lwip_override include path so our
|
||||
# lwipopts.h shadows the framework's version during lwIP compilation.
|
||||
#
|
||||
# The arduino-pico builder uses -iprefix + -iwithprefixbefore for includes,
|
||||
# which takes priority over CPPPATH (-I). We must inject our path into the
|
||||
# CCFLAGS BEFORE the -iprefix flag to ensure our lwipopts.h is found first.
|
||||
|
||||
lwip_dir = os.path.join(env["PROJECT_DIR"], "lwip_override")
|
||||
|
||||
if os.path.isdir(lwip_dir):
|
||||
# Insert -I<lwip_dir> at the beginning of CCFLAGS, before the framework's
|
||||
# -iprefix/-iwithprefixbefore flags which would otherwise take priority.
|
||||
env.Prepend(CCFLAGS=["-I", lwip_dir])
|
||||
46
esphome/components/rp2040/lwipopts.h.jinja
Normal file
46
esphome/components/rp2040/lwipopts.h.jinja
Normal file
@@ -0,0 +1,46 @@
|
||||
// ESPHome lwIP configuration override for RP2040.
|
||||
// Includes the framework's original lwipopts.h, then overrides specific
|
||||
// settings to tune lwIP for ESPHome's IoT use case.
|
||||
//
|
||||
// This file is found first via -I injection (see inject_lwip_include.py.script).
|
||||
// #include_next chains to the framework's original in include/lwipopts.h.
|
||||
// Since the original uses #pragma once, it won't be included again later
|
||||
// (e.g. via tusb_config.h), avoiding duplicate definition warnings.
|
||||
|
||||
// Include the framework's original lwipopts.h first
|
||||
#include_next "lwipopts.h"
|
||||
|
||||
// --- ESPHome overrides below ---
|
||||
// Only #undef and redefine values that differ from the framework defaults.
|
||||
|
||||
// TCP send/receive buffers: 4xMSS matches ESP32 (down from 8xMSS)
|
||||
#undef TCP_SND_BUF
|
||||
#define TCP_SND_BUF {{ TCP_SND_BUF }}
|
||||
|
||||
#undef TCP_WND
|
||||
#define TCP_WND {{ TCP_WND }}
|
||||
|
||||
// Queued segment limits: derived from 4xMSS buffer size, matching ESP32
|
||||
#undef TCP_SND_QUEUELEN
|
||||
#define TCP_SND_QUEUELEN {{ TCP_SND_QUEUELEN }}
|
||||
|
||||
#undef MEMP_NUM_TCP_SEG
|
||||
#define MEMP_NUM_TCP_SEG {{ MEMP_NUM_TCP_SEG }}
|
||||
|
||||
// Packet buffer pool: 16 matches ESP32 (down from 24)
|
||||
#undef PBUF_POOL_SIZE
|
||||
#define PBUF_POOL_SIZE {{ PBUF_POOL_SIZE }}
|
||||
|
||||
// PCB pools: sized to actual component needs via socket.get_socket_counts()
|
||||
#undef MEMP_NUM_TCP_PCB
|
||||
#define MEMP_NUM_TCP_PCB {{ MEMP_NUM_TCP_PCB }}
|
||||
|
||||
#undef MEMP_NUM_TCP_PCB_LISTEN
|
||||
#define MEMP_NUM_TCP_PCB_LISTEN {{ MEMP_NUM_TCP_PCB_LISTEN }}
|
||||
|
||||
#undef MEMP_NUM_UDP_PCB
|
||||
#define MEMP_NUM_UDP_PCB {{ MEMP_NUM_UDP_PCB }}
|
||||
|
||||
// Listen backlog: match component needs
|
||||
#undef TCP_DEFAULT_LISTEN_BACKLOG
|
||||
#define TCP_DEFAULT_LISTEN_BACKLOG {{ MEMP_NUM_TCP_PCB_LISTEN }}
|
||||
268
esphome/components/sendspin/__init__.py
Normal file
268
esphome/components/sendspin/__init__.py
Normal 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
esphome/components/sendspin/automation.h
Normal file
25
esphome/components/sendspin/automation.h
Normal 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
|
||||
45
esphome/components/sendspin/media_player/__init__.py
Normal file
45
esphome/components/sendspin/media_player/__init__.py
Normal file
@@ -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
|
||||
134
esphome/components/sendspin/media_source/__init__.py
Normal file
134
esphome/components/sendspin/media_source/__init__.py
Normal file
@@ -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
|
||||
26
esphome/components/sendspin/media_source/automations.h
Normal file
26
esphome/components/sendspin/media_source/automations.h
Normal file
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_SENDSPIN_PLAYER) && defined(USE_SENDSPIN_CONTROLLER)
|
||||
|
||||
#include "esphome/core/automation.h"
|
||||
#include "sendspin_media_source.h"
|
||||
|
||||
namespace esphome::sendspin_ {
|
||||
|
||||
template<typename... Ts>
|
||||
class EnableStaticDelayAdjustmentAction : public Action<Ts...>, public Parented<SendspinMediaSource> {
|
||||
public:
|
||||
void play(const Ts &...x) override { this->parent_->set_static_delay_adjustable(true); }
|
||||
};
|
||||
|
||||
template<typename... Ts>
|
||||
class DisableStaticDelayAdjustmentAction : public Action<Ts...>, public Parented<SendspinMediaSource> {
|
||||
public:
|
||||
void play(const Ts &...x) override { this->parent_->set_static_delay_adjustable(false); }
|
||||
};
|
||||
|
||||
} // namespace esphome::sendspin_
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,207 @@
|
||||
#include "sendspin_media_source.h"
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER)
|
||||
|
||||
#include "esphome/components/audio/audio.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace esphome::sendspin_ {
|
||||
|
||||
static const char *const TAG = "sendspin.media_source";
|
||||
|
||||
static constexpr char URI_PREFIX[] = "sendspin://";
|
||||
|
||||
void SendspinMediaSource::setup() {
|
||||
this->player_role_ = this->parent_->get_player_role();
|
||||
if (!this->player_role_) {
|
||||
ESP_LOGE(TAG, "Failed to get player role from hub");
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
|
||||
// Push cached states to player role. They may have been set before setup() ran.
|
||||
this->player_role_->update_volume(std::roundf(this->cached_volume_ * 100.0f));
|
||||
this->player_role_->update_muted(this->cached_muted_);
|
||||
this->player_role_->set_static_delay_adjustable(this->static_delay_adjustable_);
|
||||
}
|
||||
|
||||
void SendspinMediaSource::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "Sendspin Media Source: static_delay_adjustable=%s", YESNO(this->static_delay_adjustable_));
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (invoked from ESPHome actions / config)
|
||||
void SendspinMediaSource::set_static_delay_adjustable(bool adjustable) {
|
||||
this->static_delay_adjustable_ = adjustable;
|
||||
if (this->player_role_) {
|
||||
this->player_role_->set_static_delay_adjustable(adjustable);
|
||||
}
|
||||
}
|
||||
|
||||
// --- MediaSource interface ---
|
||||
|
||||
bool SendspinMediaSource::can_handle(const std::string &uri) const { return uri.starts_with(URI_PREFIX); }
|
||||
|
||||
// THREAD CONTEXT: Main loop (media_source.h documents play_uri as main-loop only)
|
||||
bool SendspinMediaSource::play_uri(const std::string &uri) {
|
||||
if (!this->is_ready() || this->is_failed() || !this->has_listener()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this->get_state() != media_source::MediaSourceState::IDLE) {
|
||||
ESP_LOGE(TAG, "Cannot play '%s': source is busy", uri.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!uri.starts_with(URI_PREFIX)) {
|
||||
ESP_LOGE(TAG, "Invalid URI: '%s'", uri.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string sendspin_id = uri.substr(sizeof(URI_PREFIX) - 1);
|
||||
|
||||
if (sendspin_id.empty()) {
|
||||
ESP_LOGE(TAG, "Invalid URI: '%s'", uri.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
ESP_LOGD(TAG, "sendspin_id: %s", sendspin_id.c_str());
|
||||
|
||||
if (sendspin_id != "current") {
|
||||
// Connect to a new server as a websocket client
|
||||
this->parent_->connect_to_server("ws://" + sendspin_id);
|
||||
}
|
||||
|
||||
// Tell the orchestrator we're now playing so it routes audio output from us
|
||||
this->pending_start_ = false;
|
||||
this->set_state_(media_source::MediaSourceState::PLAYING);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (media_source.h documents handle_command as main-loop only)
|
||||
void SendspinMediaSource::handle_command(media_source::MediaSourceCommand command) {
|
||||
switch (command) {
|
||||
case media_source::MediaSourceCommand::STOP: {
|
||||
if (!this->pending_start_) {
|
||||
// Ignore stop commands if we have a pending start, since the orchestrator may send a stop command before
|
||||
// play_uri
|
||||
ESP_LOGD(TAG, "Received STOP command, updating Sendspin state to EXTERNAL_SOURCE");
|
||||
this->parent_->update_state(sendspin::SendspinClientState::EXTERNAL_SOURCE);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case media_source::MediaSourceCommand::PLAY: // NOLINT(bugprone-branch-clone)
|
||||
this->parent_->send_client_command(sendspin::SendspinControllerCommand::PLAY, std::nullopt, std::nullopt);
|
||||
break;
|
||||
case media_source::MediaSourceCommand::PAUSE:
|
||||
this->parent_->send_client_command(sendspin::SendspinControllerCommand::PAUSE, std::nullopt, std::nullopt);
|
||||
break;
|
||||
case media_source::MediaSourceCommand::NEXT:
|
||||
this->parent_->send_client_command(sendspin::SendspinControllerCommand::NEXT, std::nullopt, std::nullopt);
|
||||
break;
|
||||
case media_source::MediaSourceCommand::PREVIOUS:
|
||||
this->parent_->send_client_command(sendspin::SendspinControllerCommand::PREVIOUS, std::nullopt, std::nullopt);
|
||||
break;
|
||||
case media_source::MediaSourceCommand::REPEAT_ALL:
|
||||
this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_ALL, std::nullopt, std::nullopt);
|
||||
break;
|
||||
case media_source::MediaSourceCommand::REPEAT_ONE:
|
||||
this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_ONE, std::nullopt, std::nullopt);
|
||||
break;
|
||||
case media_source::MediaSourceCommand::REPEAT_OFF:
|
||||
this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_OFF, std::nullopt, std::nullopt);
|
||||
break;
|
||||
case media_source::MediaSourceCommand::SHUFFLE:
|
||||
this->parent_->send_client_command(sendspin::SendspinControllerCommand::SHUFFLE, std::nullopt, std::nullopt);
|
||||
break;
|
||||
case media_source::MediaSourceCommand::UNSHUFFLE:
|
||||
this->parent_->send_client_command(sendspin::SendspinControllerCommand::UNSHUFFLE, std::nullopt, std::nullopt);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (orchestrator -> source notification)
|
||||
void SendspinMediaSource::notify_volume_changed(float volume) {
|
||||
this->cached_volume_ = volume;
|
||||
if (this->player_role_) {
|
||||
this->player_role_->update_volume(std::roundf(volume * 100.0f));
|
||||
}
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (orchestrator -> source notification)
|
||||
void SendspinMediaSource::notify_mute_changed(bool is_muted) {
|
||||
this->cached_muted_ = is_muted;
|
||||
if (this->player_role_) {
|
||||
this->player_role_->update_muted(is_muted);
|
||||
}
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Speaker playback callback thread (forwarded from the speaker).
|
||||
// PlayerRole::notify_audio_played() is documented as thread-safe for this use.
|
||||
void SendspinMediaSource::notify_audio_played(uint32_t frames, int64_t timestamp) {
|
||||
if (this->player_role_) {
|
||||
this->player_role_->notify_audio_played(frames, timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Sendspin PlayerRoleListener overrides ---
|
||||
|
||||
// THREAD CONTEXT: Sendspin sync task background thread. May block up to timeout_ms.
|
||||
size_t SendspinMediaSource::on_audio_write(uint8_t *data, size_t length, uint32_t timeout_ms) {
|
||||
if (!this->has_listener() || (this->get_state() != media_source::MediaSourceState::PLAYING)) {
|
||||
vTaskDelay(pdMS_TO_TICKS(timeout_ms));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// PlayerRole::get_current_stream_params() is safe to call from the sync task.
|
||||
auto ¶ms = this->player_role_->get_current_stream_params();
|
||||
if (!params.bit_depth.has_value() || !params.channels.has_value() || !params.sample_rate.has_value()) {
|
||||
vTaskDelay(pdMS_TO_TICKS(timeout_ms));
|
||||
return 0;
|
||||
}
|
||||
audio::AudioStreamInfo stream_info(*params.bit_depth, *params.channels, *params.sample_rate);
|
||||
|
||||
return this->write_output(data, length, timeout_ms, stream_info);
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (PlayerRoleListener lifecycle callback)
|
||||
void SendspinMediaSource::on_stream_start() {
|
||||
this->parent_->update_state(sendspin::SendspinClientState::SYNCHRONIZED);
|
||||
|
||||
if (!this->pending_start_) {
|
||||
// Dedup rapid on_stream_start() calls
|
||||
this->pending_start_ = true;
|
||||
// Request the orchestrator to start this source
|
||||
this->request_play_uri_("sendspin://current");
|
||||
}
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (PlayerRoleListener lifecycle callback)
|
||||
void SendspinMediaSource::on_stream_end() {
|
||||
if (this->get_state() != media_source::MediaSourceState::IDLE) {
|
||||
// Only set to IDLE if we were previously in a non-IDLE state, to avoid duplicate state changes
|
||||
this->set_state_(media_source::MediaSourceState::IDLE);
|
||||
}
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (PlayerRoleListener lifecycle callback)
|
||||
void SendspinMediaSource::on_stream_clear() {
|
||||
if (this->get_state() != media_source::MediaSourceState::IDLE) {
|
||||
// Only set to IDLE if we were previously in a non-IDLE state, to avoid duplicate state changes
|
||||
this->set_state_(media_source::MediaSourceState::IDLE);
|
||||
}
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (PlayerRoleListener callback)
|
||||
void SendspinMediaSource::on_volume_changed(uint8_t volume) { this->request_volume_(volume / 100.0f); }
|
||||
|
||||
// THREAD CONTEXT: Main loop (PlayerRoleListener callback)
|
||||
void SendspinMediaSource::on_mute_changed(bool muted) { this->request_mute_(muted); }
|
||||
|
||||
} // namespace esphome::sendspin_
|
||||
|
||||
#endif // USE_ESP32 && USE_SENDSPIN_PLAYER && USE_SENDSPIN_CONTROLLER
|
||||
@@ -0,0 +1,72 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER)
|
||||
|
||||
#include "esphome/components/sendspin/sendspin_hub.h"
|
||||
|
||||
#include "esphome/components/media_source/media_source.h"
|
||||
|
||||
#include <sendspin/player_role.h>
|
||||
|
||||
namespace esphome::sendspin_ {
|
||||
|
||||
/// @brief Thin adapter media source for Sendspin.
|
||||
///
|
||||
/// Implements PlayerRoleListener to receive audio data from the sendspin-cpp library's
|
||||
/// SyncTask and bridges it to ESPHome's MediaSource output pipeline. Also forwards
|
||||
/// transport commands to the hub's controller role.
|
||||
class SendspinMediaSource : public SendspinChild,
|
||||
public media_source::MediaSource,
|
||||
public sendspin::PlayerRoleListener {
|
||||
public:
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
|
||||
void set_static_delay_adjustable(bool adjustable);
|
||||
|
||||
// MediaSource interface implementation
|
||||
bool play_uri(const std::string &uri) override;
|
||||
void handle_command(media_source::MediaSourceCommand command) override;
|
||||
bool can_handle(const std::string &uri) const override;
|
||||
bool has_internal_playlist() const override { return true; }
|
||||
|
||||
void notify_volume_changed(float volume) override;
|
||||
void notify_mute_changed(bool is_muted) override;
|
||||
void notify_audio_played(uint32_t frames, int64_t timestamp) override;
|
||||
|
||||
protected:
|
||||
// --- Sendspin PlayerRoleListener overrides ---
|
||||
|
||||
/// @brief Writes decoded PCM audio to ESPHome's media source output pipeline.
|
||||
/// Called from the sync task's background thread.
|
||||
size_t on_audio_write(uint8_t *data, size_t length, uint32_t timeout_ms) override;
|
||||
|
||||
/// @brief Called when a new audio stream starts (main loop thread).
|
||||
void on_stream_start() override;
|
||||
|
||||
/// @brief Called when the audio stream ends (main loop thread).
|
||||
void on_stream_end() override;
|
||||
|
||||
/// @brief Called when the audio stream is cleared (main loop thread).
|
||||
void on_stream_clear() override;
|
||||
|
||||
/// @brief Called when volume changes (main loop thread).
|
||||
void on_volume_changed(uint8_t volume) override;
|
||||
|
||||
/// @brief Called when mute state changes (main loop thread).
|
||||
void on_mute_changed(bool muted) override;
|
||||
|
||||
sendspin::PlayerRole *player_role_{nullptr};
|
||||
|
||||
float cached_volume_{0.0f};
|
||||
|
||||
bool cached_muted_{false};
|
||||
bool pending_start_{false};
|
||||
bool static_delay_adjustable_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::sendspin_
|
||||
|
||||
#endif
|
||||
225
esphome/components/sendspin/sendspin_hub.cpp
Normal file
225
esphome/components/sendspin/sendspin_hub.cpp
Normal file
@@ -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
esphome/components/sendspin/sendspin_hub.h
Normal file
231
esphome/components/sendspin/sendspin_hub.h
Normal 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
|
||||
98
esphome/components/sendspin/sensor/__init__.py
Normal file
98
esphome/components/sendspin/sensor/__init__.py
Normal file
@@ -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))
|
||||
98
esphome/components/sendspin/sensor/sendspin_sensor.cpp
Normal file
98
esphome/components/sendspin/sensor/sendspin_sensor.cpp
Normal file
@@ -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
|
||||
42
esphome/components/sendspin/sensor/sendspin_sensor.h
Normal file
42
esphome/components/sendspin/sensor/sendspin_sensor.h
Normal file
@@ -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
|
||||
53
esphome/components/sendspin/text_sensor/__init__.py
Normal file
53
esphome/components/sendspin/text_sensor/__init__.py
Normal file
@@ -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
|
||||
@@ -6,6 +6,9 @@
|
||||
|
||||
#include <cstring>
|
||||
#include "esphome/core/application.h"
|
||||
#ifdef USE_HOST
|
||||
#include "esphome/core/wake.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::socket {
|
||||
|
||||
@@ -16,7 +19,7 @@ BSDSocketImpl::BSDSocketImpl(int fd, bool monitor_loop) {
|
||||
#ifdef USE_LWIP_FAST_SELECT
|
||||
this->cached_sock_ = hook_fd_for_fast_select(this->fd_);
|
||||
#else
|
||||
this->loop_monitored_ = App.register_socket_fd(this->fd_);
|
||||
this->loop_monitored_ = wake_register_fd(this->fd_);
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -36,7 +39,7 @@ int BSDSocketImpl::close() {
|
||||
this->cached_sock_ = nullptr;
|
||||
#else
|
||||
if (this->loop_monitored_) {
|
||||
App.unregister_socket_fd(this->fd_);
|
||||
wake_unregister_fd(this->fd_);
|
||||
}
|
||||
#endif
|
||||
int ret = ::close(this->fd_);
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
|
||||
#include <cstring>
|
||||
#include "esphome/core/application.h"
|
||||
#ifdef USE_HOST
|
||||
#include "esphome/core/wake.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::socket {
|
||||
|
||||
@@ -16,7 +19,7 @@ LwIPSocketImpl::LwIPSocketImpl(int fd, bool monitor_loop) {
|
||||
#ifdef USE_LWIP_FAST_SELECT
|
||||
this->cached_sock_ = hook_fd_for_fast_select(this->fd_);
|
||||
#else
|
||||
this->loop_monitored_ = App.register_socket_fd(this->fd_);
|
||||
this->loop_monitored_ = wake_register_fd(this->fd_);
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -36,7 +39,7 @@ int LwIPSocketImpl::close() {
|
||||
this->cached_sock_ = nullptr;
|
||||
#else
|
||||
if (this->loop_monitored_) {
|
||||
App.unregister_socket_fd(this->fd_);
|
||||
wake_unregister_fd(this->fd_);
|
||||
}
|
||||
#endif
|
||||
int ret = lwip_close(this->fd_);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user