mirror of
https://github.com/esphome/esphome.git
synced 2026-06-25 15:19:24 +00:00
Compare commits
95 Commits
config-ver
...
improv-ser
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab233e6d83 | ||
|
|
e87e78c544 | ||
|
|
0f25d91e68 | ||
|
|
8dbdcfc128 | ||
|
|
8950afc3c4 | ||
|
|
04d067196d | ||
|
|
502c010465 | ||
|
|
180105bb4b | ||
|
|
4c0dfb0e0d | ||
|
|
df987a7ffb | ||
|
|
c8d4420408 | ||
|
|
b084fa4490 | ||
|
|
68625a1b76 | ||
|
|
dc57969afd | ||
|
|
f092e619d8 | ||
|
|
58f6ad2d0c | ||
|
|
bc33260c61 | ||
|
|
4cab262ef8 | ||
|
|
9ad820c921 | ||
|
|
4f8feb86f0 | ||
|
|
b5ccd55f4e | ||
|
|
a437b3086b | ||
|
|
c27f9e512b | ||
|
|
f62972c2c6 | ||
|
|
f36efbc762 | ||
|
|
9caf9ee023 | ||
|
|
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 |
@@ -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',
|
||||
});
|
||||
@@ -11,7 +11,7 @@ ci:
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.15.11
|
||||
rev: v0.15.12
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff
|
||||
|
||||
@@ -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]))
|
||||
|
||||
@@ -1025,6 +1025,13 @@ message CameraImageRequest {
|
||||
bool stream = 2;
|
||||
}
|
||||
|
||||
// ==================== TEMPERATURE UNIT ====================
|
||||
enum TemperatureUnit {
|
||||
TEMPERATURE_UNIT_CELSIUS = 0;
|
||||
TEMPERATURE_UNIT_FAHRENHEIT = 1;
|
||||
TEMPERATURE_UNIT_KELVIN = 2;
|
||||
}
|
||||
|
||||
// ==================== CLIMATE ====================
|
||||
enum ClimateMode {
|
||||
CLIMATE_MODE_OFF = 0;
|
||||
@@ -1110,6 +1117,7 @@ message ListEntitiesClimateResponse {
|
||||
float visual_max_humidity = 25;
|
||||
uint32 device_id = 26 [(field_ifdef) = "USE_DEVICES"];
|
||||
uint32 feature_flags = 27;
|
||||
TemperatureUnit temperature_unit = 28;
|
||||
}
|
||||
message ClimateStateResponse {
|
||||
option (id) = 47;
|
||||
@@ -1203,6 +1211,7 @@ message ListEntitiesWaterHeaterResponse {
|
||||
repeated WaterHeaterMode supported_modes = 11 [(container_pointer_no_template) = "water_heater::WaterHeaterModeMask"];
|
||||
// Bitmask of WaterHeaterFeature flags
|
||||
uint32 supported_features = 12;
|
||||
TemperatureUnit temperature_unit = 13;
|
||||
}
|
||||
|
||||
message WaterHeaterStateResponse {
|
||||
@@ -2544,27 +2553,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);
|
||||
|
||||
@@ -1439,6 +1439,7 @@ uint8_t *ListEntitiesClimateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCO
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 26, this->device_id);
|
||||
#endif
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 27, this->feature_flags);
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 28, static_cast<uint32_t>(this->temperature_unit));
|
||||
return pos;
|
||||
}
|
||||
uint32_t ListEntitiesClimateResponse::calculate_size() const {
|
||||
@@ -1488,6 +1489,7 @@ uint32_t ListEntitiesClimateResponse::calculate_size() const {
|
||||
size += ProtoSize::calc_uint32(2, this->device_id);
|
||||
#endif
|
||||
size += ProtoSize::calc_uint32(2, this->feature_flags);
|
||||
size += this->temperature_unit ? 3 : 0;
|
||||
return size;
|
||||
}
|
||||
uint8_t *ClimateStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
@@ -1645,6 +1647,7 @@ uint8_t *ListEntitiesWaterHeaterResponse::encode(ProtoWriteBuffer &buffer PROTO_
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast<uint32_t>(it), true);
|
||||
}
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, this->supported_features);
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, static_cast<uint32_t>(this->temperature_unit));
|
||||
return pos;
|
||||
}
|
||||
uint32_t ListEntitiesWaterHeaterResponse::calculate_size() const {
|
||||
@@ -1667,6 +1670,7 @@ uint32_t ListEntitiesWaterHeaterResponse::calculate_size() const {
|
||||
size += this->supported_modes->size() * 2;
|
||||
}
|
||||
size += ProtoSize::calc_uint32(1, this->supported_features);
|
||||
size += this->temperature_unit ? 2 : 0;
|
||||
return size;
|
||||
}
|
||||
uint8_t *WaterHeaterStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
@@ -3861,7 +3865,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 +3879,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 +3935,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) {
|
||||
|
||||
@@ -92,6 +92,11 @@ enum SupportsResponseType : uint32_t {
|
||||
SUPPORTS_RESPONSE_STATUS = 100,
|
||||
};
|
||||
#endif
|
||||
enum TemperatureUnit : uint32_t {
|
||||
TEMPERATURE_UNIT_CELSIUS = 0,
|
||||
TEMPERATURE_UNIT_FAHRENHEIT = 1,
|
||||
TEMPERATURE_UNIT_KELVIN = 2,
|
||||
};
|
||||
#ifdef USE_CLIMATE
|
||||
enum ClimateMode : uint32_t {
|
||||
CLIMATE_MODE_OFF = 0,
|
||||
@@ -1372,7 +1377,7 @@ class CameraImageRequest final : public ProtoDecodableMessage {
|
||||
class ListEntitiesClimateResponse final : public InfoResponseProtoMessage {
|
||||
public:
|
||||
static constexpr uint8_t MESSAGE_TYPE = 46;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 150;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 153;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const LogString *message_name() const override { return LOG_STR("list_entities_climate_response"); }
|
||||
#endif
|
||||
@@ -1394,6 +1399,7 @@ class ListEntitiesClimateResponse final : public InfoResponseProtoMessage {
|
||||
float visual_min_humidity{0.0f};
|
||||
float visual_max_humidity{0.0f};
|
||||
uint32_t feature_flags{0};
|
||||
enums::TemperatureUnit temperature_unit{};
|
||||
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
|
||||
uint32_t calculate_size() const;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
@@ -1471,7 +1477,7 @@ class ClimateCommandRequest final : public CommandProtoMessage {
|
||||
class ListEntitiesWaterHeaterResponse final : public InfoResponseProtoMessage {
|
||||
public:
|
||||
static constexpr uint8_t MESSAGE_TYPE = 132;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 63;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 65;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const LogString *message_name() const override { return LOG_STR("list_entities_water_heater_response"); }
|
||||
#endif
|
||||
@@ -1480,6 +1486,7 @@ class ListEntitiesWaterHeaterResponse final : public InfoResponseProtoMessage {
|
||||
float target_temperature_step{0.0f};
|
||||
const water_heater::WaterHeaterModeMask *supported_modes{};
|
||||
uint32_t supported_features{0};
|
||||
enums::TemperatureUnit temperature_unit{};
|
||||
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
|
||||
uint32_t calculate_size() const;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
@@ -3054,11 +3061,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 +3078,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 +3109,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:
|
||||
|
||||
@@ -297,6 +297,18 @@ template<> const char *proto_enum_to_string<enums::SupportsResponseType>(enums::
|
||||
}
|
||||
}
|
||||
#endif
|
||||
template<> const char *proto_enum_to_string<enums::TemperatureUnit>(enums::TemperatureUnit value) {
|
||||
switch (value) {
|
||||
case enums::TEMPERATURE_UNIT_CELSIUS:
|
||||
return ESPHOME_PSTR("TEMPERATURE_UNIT_CELSIUS");
|
||||
case enums::TEMPERATURE_UNIT_FAHRENHEIT:
|
||||
return ESPHOME_PSTR("TEMPERATURE_UNIT_FAHRENHEIT");
|
||||
case enums::TEMPERATURE_UNIT_KELVIN:
|
||||
return ESPHOME_PSTR("TEMPERATURE_UNIT_KELVIN");
|
||||
default:
|
||||
return ESPHOME_PSTR("UNKNOWN");
|
||||
}
|
||||
}
|
||||
#ifdef USE_CLIMATE
|
||||
template<> const char *proto_enum_to_string<enums::ClimateMode>(enums::ClimateMode value) {
|
||||
switch (value) {
|
||||
@@ -1539,6 +1551,7 @@ const char *ListEntitiesClimateResponse::dump_to(DumpBuffer &out) const {
|
||||
dump_field(out, ESPHOME_PSTR("device_id"), this->device_id);
|
||||
#endif
|
||||
dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags);
|
||||
dump_field(out, ESPHOME_PSTR("temperature_unit"), static_cast<enums::TemperatureUnit>(this->temperature_unit));
|
||||
return out.c_str();
|
||||
}
|
||||
const char *ClimateStateResponse::dump_to(DumpBuffer &out) const {
|
||||
@@ -1612,6 +1625,7 @@ const char *ListEntitiesWaterHeaterResponse::dump_to(DumpBuffer &out) const {
|
||||
dump_field(out, ESPHOME_PSTR("supported_modes"), static_cast<enums::WaterHeaterMode>(it), 4);
|
||||
}
|
||||
dump_field(out, ESPHOME_PSTR("supported_features"), this->supported_features);
|
||||
dump_field(out, ESPHOME_PSTR("temperature_unit"), static_cast<enums::TemperatureUnit>(this->temperature_unit));
|
||||
return out.c_str();
|
||||
}
|
||||
const char *WaterHeaterStateResponse::dump_to(DumpBuffer &out) const {
|
||||
@@ -2576,7 +2590,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 +2605,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 +2620,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
|
||||
|
||||
@@ -183,19 +183,19 @@ async def at581x_settings_to_code(config, action_id, template_arg, args):
|
||||
cg.add(var.set_sensing_distance(template_))
|
||||
|
||||
if selfcheck := config.get(CONF_POWERON_SELFCHECK_TIME):
|
||||
template_ = await cg.templatable(selfcheck, args, cg.int32)
|
||||
template_ = await cg.templatable(selfcheck, args, cg.int_)
|
||||
cg.add(var.set_poweron_selfcheck_time(template_))
|
||||
|
||||
if protect := config.get(CONF_PROTECT_TIME):
|
||||
template_ = await cg.templatable(protect, args, cg.int32)
|
||||
template_ = await cg.templatable(protect, args, cg.int_)
|
||||
cg.add(var.set_protect_time(template_))
|
||||
|
||||
if trig_base := config.get(CONF_TRIGGER_BASE):
|
||||
template_ = await cg.templatable(trig_base, args, cg.int32)
|
||||
template_ = await cg.templatable(trig_base, args, cg.int_)
|
||||
cg.add(var.set_trigger_base(template_))
|
||||
|
||||
if trig_keep := config.get(CONF_TRIGGER_KEEP):
|
||||
template_ = await cg.templatable(trig_keep, args, cg.int32)
|
||||
template_ = await cg.templatable(trig_keep, args, cg.int_)
|
||||
cg.add(var.set_trigger_keep(template_))
|
||||
|
||||
if (stage_gain := config.get(CONF_STAGE_GAIN)) is not None:
|
||||
|
||||
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]))
|
||||
@@ -154,7 +154,7 @@ void BH1750Sensor::loop() {
|
||||
break;
|
||||
}
|
||||
|
||||
ESP_LOGD(TAG, "'%s': Illuminance=%.1flx", this->get_name().c_str(), lx);
|
||||
ESP_LOGV(TAG, "'%s': Illuminance=%.1flx", this->get_name().c_str(), lx);
|
||||
this->status_clear_warning();
|
||||
this->publish_state(lx);
|
||||
this->state_ = IDLE;
|
||||
|
||||
@@ -16,6 +16,7 @@ from esphome.components.libretiny.const import (
|
||||
FAMILY_BK7231N,
|
||||
FAMILY_BK7231Q,
|
||||
FAMILY_BK7231T,
|
||||
FAMILY_BK7238,
|
||||
FAMILY_BK7251,
|
||||
)
|
||||
|
||||
@@ -24,16 +25,32 @@ BK72XX_BOARDS = {
|
||||
"name": "WB2L_M1 Wi-Fi Module",
|
||||
"family": FAMILY_BK7231N,
|
||||
},
|
||||
"xh-wb3s": {
|
||||
"name": "NiceMCU XH-WB3S",
|
||||
"family": FAMILY_BK7238,
|
||||
},
|
||||
"cbu": {
|
||||
"name": "CBU Wi-Fi Module",
|
||||
"family": FAMILY_BK7231N,
|
||||
},
|
||||
"t1-u": {
|
||||
"name": "T1-U Wi-Fi Module",
|
||||
"family": FAMILY_BK7238,
|
||||
},
|
||||
"generic-bk7238-tuya": {
|
||||
"name": "Generic - BK7238 (Tuya T1)",
|
||||
"family": FAMILY_BK7238,
|
||||
},
|
||||
"t1-m": {
|
||||
"name": "T1-M Wi-Fi Module",
|
||||
"family": FAMILY_BK7238,
|
||||
},
|
||||
"generic-bk7231t-qfn32-tuya": {
|
||||
"name": "Generic - BK7231T (Tuya QFN32)",
|
||||
"name": "Generic - BK7231T (Tuya)",
|
||||
"family": FAMILY_BK7231T,
|
||||
},
|
||||
"generic-bk7231n-qfn32-tuya": {
|
||||
"name": "Generic - BK7231N (Tuya QFN32)",
|
||||
"name": "Generic - BK7231N (Tuya)",
|
||||
"family": FAMILY_BK7231N,
|
||||
},
|
||||
"cb1s": {
|
||||
@@ -64,6 +81,10 @@ BK72XX_BOARDS = {
|
||||
"name": "Generic - BK7252",
|
||||
"family": FAMILY_BK7251,
|
||||
},
|
||||
"t1-3s": {
|
||||
"name": "T1-3S Wi-Fi Module",
|
||||
"family": FAMILY_BK7238,
|
||||
},
|
||||
"wb2l": {
|
||||
"name": "WB2L Wi-Fi Module",
|
||||
"family": FAMILY_BK7231T,
|
||||
@@ -80,6 +101,10 @@ BK72XX_BOARDS = {
|
||||
"name": "CB2S Wi-Fi Module",
|
||||
"family": FAMILY_BK7231N,
|
||||
},
|
||||
"generic-bk7238": {
|
||||
"name": "Generic - BK7238",
|
||||
"family": FAMILY_BK7238,
|
||||
},
|
||||
"wa2": {
|
||||
"name": "WA2 Wi-Fi Module",
|
||||
"family": FAMILY_BK7231Q,
|
||||
@@ -100,6 +125,10 @@ BK72XX_BOARDS = {
|
||||
"name": "WB3L Wi-Fi Module",
|
||||
"family": FAMILY_BK7231T,
|
||||
},
|
||||
"t1-2s": {
|
||||
"name": "T1-2S Wi-Fi Module",
|
||||
"family": FAMILY_BK7238,
|
||||
},
|
||||
"wb2s": {
|
||||
"name": "WB2S Wi-Fi Module",
|
||||
"family": FAMILY_BK7231T,
|
||||
@@ -158,6 +187,83 @@ BK72XX_BOARD_PINS = {
|
||||
"D12": 22,
|
||||
"A0": 23,
|
||||
},
|
||||
"xh-wb3s": {
|
||||
"SPI0_CS": 15,
|
||||
"SPI0_MISO": 17,
|
||||
"SPI0_MOSI": 16,
|
||||
"SPI0_SCK": 14,
|
||||
"WIRE2_SCL_0": 15,
|
||||
"WIRE2_SCL_1": 24,
|
||||
"WIRE2_SDA_0": 17,
|
||||
"WIRE2_SDA_1": 26,
|
||||
"SERIAL1_RX": 10,
|
||||
"SERIAL1_TX": 11,
|
||||
"SERIAL2_RX": 1,
|
||||
"SERIAL2_TX": 0,
|
||||
"ADC1": 26,
|
||||
"ADC2": 24,
|
||||
"ADC3": 20,
|
||||
"ADC4": 28,
|
||||
"ADC5": 1,
|
||||
"ADC6": 10,
|
||||
"CS": 15,
|
||||
"MISO": 17,
|
||||
"MOSI": 16,
|
||||
"P0": 0,
|
||||
"P1": 1,
|
||||
"P6": 6,
|
||||
"P7": 7,
|
||||
"P8": 8,
|
||||
"P9": 9,
|
||||
"P10": 10,
|
||||
"P11": 11,
|
||||
"P14": 14,
|
||||
"P15": 15,
|
||||
"P16": 16,
|
||||
"P17": 17,
|
||||
"P20": 20,
|
||||
"P21": 21,
|
||||
"P22": 22,
|
||||
"P23": 23,
|
||||
"P24": 24,
|
||||
"P26": 26,
|
||||
"P28": 28,
|
||||
"PWM0": 6,
|
||||
"PWM1": 7,
|
||||
"PWM2": 8,
|
||||
"PWM3": 9,
|
||||
"PWM4": 24,
|
||||
"PWM5": 26,
|
||||
"RX1": 10,
|
||||
"RX2": 1,
|
||||
"SCK": 14,
|
||||
"TX1": 11,
|
||||
"TX2": 0,
|
||||
"D0": 7,
|
||||
"D1": 23,
|
||||
"D2": 14,
|
||||
"D3": 26,
|
||||
"D4": 24,
|
||||
"D5": 6,
|
||||
"D6": 9,
|
||||
"D7": 0,
|
||||
"D8": 1,
|
||||
"D9": 8,
|
||||
"D10": 10,
|
||||
"D11": 11,
|
||||
"D12": 16,
|
||||
"D13": 20,
|
||||
"D14": 21,
|
||||
"D15": 22,
|
||||
"D16": 15,
|
||||
"D17": 17,
|
||||
"A0": 28,
|
||||
"A1": 26,
|
||||
"A2": 24,
|
||||
"A3": 1,
|
||||
"A4": 10,
|
||||
"A5": 20,
|
||||
},
|
||||
"cbu": {
|
||||
"SPI0_CS": 15,
|
||||
"SPI0_MISO": 17,
|
||||
@@ -230,6 +336,204 @@ BK72XX_BOARD_PINS = {
|
||||
"D18": 21,
|
||||
"A0": 23,
|
||||
},
|
||||
"t1-u": {
|
||||
"SPI0_CS": 15,
|
||||
"SPI0_MISO": 17,
|
||||
"SPI0_MOSI": 16,
|
||||
"SPI0_SCK": 14,
|
||||
"WIRE2_SCL_0": 15,
|
||||
"WIRE2_SCL_1": 24,
|
||||
"WIRE2_SDA_0": 17,
|
||||
"WIRE2_SDA_1": 26,
|
||||
"SERIAL1_RX": 10,
|
||||
"SERIAL1_TX": 11,
|
||||
"SERIAL2_RX": 1,
|
||||
"SERIAL2_TX": 0,
|
||||
"ADC1": 26,
|
||||
"ADC2": 24,
|
||||
"ADC3": 20,
|
||||
"ADC4": 28,
|
||||
"ADC5": 1,
|
||||
"ADC6": 10,
|
||||
"CS": 15,
|
||||
"MISO": 17,
|
||||
"MOSI": 16,
|
||||
"P0": 0,
|
||||
"P1": 1,
|
||||
"P6": 6,
|
||||
"P8": 8,
|
||||
"P9": 9,
|
||||
"P10": 10,
|
||||
"P11": 11,
|
||||
"P14": 14,
|
||||
"P15": 15,
|
||||
"P16": 16,
|
||||
"P17": 17,
|
||||
"P20": 20,
|
||||
"P21": 21,
|
||||
"P22": 22,
|
||||
"P23": 23,
|
||||
"P24": 24,
|
||||
"P26": 26,
|
||||
"P28": 28,
|
||||
"PWM0": 6,
|
||||
"PWM2": 8,
|
||||
"PWM3": 9,
|
||||
"PWM4": 24,
|
||||
"PWM5": 26,
|
||||
"RX1": 10,
|
||||
"RX2": 1,
|
||||
"SCK": 14,
|
||||
"TX1": 11,
|
||||
"TX2": 0,
|
||||
"D0": 14,
|
||||
"D1": 16,
|
||||
"D2": 23,
|
||||
"D3": 22,
|
||||
"D4": 20,
|
||||
"D5": 1,
|
||||
"D6": 0,
|
||||
"D7": 24,
|
||||
"D8": 9,
|
||||
"D9": 26,
|
||||
"D10": 6,
|
||||
"D11": 8,
|
||||
"D12": 11,
|
||||
"D13": 10,
|
||||
"D14": 28,
|
||||
"D15": 21,
|
||||
"D16": 17,
|
||||
"D17": 15,
|
||||
"A0": 20,
|
||||
"A1": 1,
|
||||
"A2": 24,
|
||||
"A3": 26,
|
||||
"A4": 10,
|
||||
"A5": 28,
|
||||
},
|
||||
"generic-bk7238-tuya": {
|
||||
"SPI0_CS": 15,
|
||||
"SPI0_MISO": 17,
|
||||
"SPI0_MOSI": 16,
|
||||
"SPI0_SCK": 14,
|
||||
"WIRE2_SCL_0": 15,
|
||||
"WIRE2_SCL_1": 24,
|
||||
"WIRE2_SDA_0": 17,
|
||||
"WIRE2_SDA_1": 26,
|
||||
"SERIAL1_RX": 10,
|
||||
"SERIAL1_TX": 11,
|
||||
"SERIAL2_RX": 1,
|
||||
"SERIAL2_TX": 0,
|
||||
"ADC1": 26,
|
||||
"ADC2": 24,
|
||||
"ADC3": 20,
|
||||
"ADC4": 28,
|
||||
"ADC5": 1,
|
||||
"ADC6": 10,
|
||||
"CS": 15,
|
||||
"MISO": 17,
|
||||
"MOSI": 16,
|
||||
"P0": 0,
|
||||
"P1": 1,
|
||||
"P6": 6,
|
||||
"P7": 7,
|
||||
"P8": 8,
|
||||
"P9": 9,
|
||||
"P10": 10,
|
||||
"P11": 11,
|
||||
"P14": 14,
|
||||
"P15": 15,
|
||||
"P16": 16,
|
||||
"P17": 17,
|
||||
"P20": 20,
|
||||
"P21": 21,
|
||||
"P22": 22,
|
||||
"P23": 23,
|
||||
"P24": 24,
|
||||
"P26": 26,
|
||||
"P28": 28,
|
||||
"PWM0": 6,
|
||||
"PWM1": 7,
|
||||
"PWM2": 8,
|
||||
"PWM3": 9,
|
||||
"PWM4": 24,
|
||||
"PWM5": 26,
|
||||
"RX1": 10,
|
||||
"RX2": 1,
|
||||
"SCK": 14,
|
||||
"TX1": 11,
|
||||
"TX2": 0,
|
||||
"D0": 0,
|
||||
"D1": 1,
|
||||
"D2": 6,
|
||||
"D3": 7,
|
||||
"D4": 8,
|
||||
"D5": 9,
|
||||
"D6": 10,
|
||||
"D7": 11,
|
||||
"D8": 14,
|
||||
"D9": 15,
|
||||
"D10": 16,
|
||||
"D11": 17,
|
||||
"D12": 20,
|
||||
"D13": 21,
|
||||
"D14": 22,
|
||||
"D15": 23,
|
||||
"D16": 24,
|
||||
"D17": 26,
|
||||
"D18": 28,
|
||||
"A0": 1,
|
||||
"A1": 10,
|
||||
"A2": 20,
|
||||
"A3": 24,
|
||||
"A4": 26,
|
||||
"A5": 28,
|
||||
},
|
||||
"t1-m": {
|
||||
"WIRE2_SCL": 24,
|
||||
"WIRE2_SDA": 26,
|
||||
"SERIAL1_RX": 10,
|
||||
"SERIAL1_TX": 11,
|
||||
"SERIAL2_RX": 1,
|
||||
"SERIAL2_TX": 0,
|
||||
"ADC1": 26,
|
||||
"ADC2": 24,
|
||||
"ADC5": 1,
|
||||
"ADC6": 10,
|
||||
"P0": 0,
|
||||
"P1": 1,
|
||||
"P6": 6,
|
||||
"P8": 8,
|
||||
"P9": 9,
|
||||
"P10": 10,
|
||||
"P11": 11,
|
||||
"P24": 24,
|
||||
"P26": 26,
|
||||
"PWM0": 6,
|
||||
"PWM2": 8,
|
||||
"PWM3": 9,
|
||||
"PWM4": 24,
|
||||
"PWM5": 26,
|
||||
"RX1": 10,
|
||||
"RX2": 1,
|
||||
"SCL2": 24,
|
||||
"SDA2": 26,
|
||||
"TX1": 11,
|
||||
"TX2": 0,
|
||||
"D0": 26,
|
||||
"D1": 6,
|
||||
"D2": 8,
|
||||
"D3": 1,
|
||||
"D4": 10,
|
||||
"D5": 11,
|
||||
"D6": 9,
|
||||
"D7": 24,
|
||||
"D11": 0,
|
||||
"A0": 26,
|
||||
"A1": 10,
|
||||
"A2": 1,
|
||||
"A3": 24,
|
||||
},
|
||||
"generic-bk7231t-qfn32-tuya": {
|
||||
"SPI0_CS": 15,
|
||||
"SPI0_MISO": 17,
|
||||
@@ -781,6 +1085,75 @@ BK72XX_BOARD_PINS = {
|
||||
"A6": 12,
|
||||
"A7": 13,
|
||||
},
|
||||
"t1-3s": {
|
||||
"SPI0_CS": 15,
|
||||
"SPI0_MISO": 17,
|
||||
"SPI0_MOSI": 16,
|
||||
"SPI0_SCK": 14,
|
||||
"WIRE2_SCL_0": 15,
|
||||
"WIRE2_SCL_1": 24,
|
||||
"WIRE2_SDA_0": 17,
|
||||
"WIRE2_SDA_1": 26,
|
||||
"SERIAL1_RX": 10,
|
||||
"SERIAL1_TX": 11,
|
||||
"SERIAL2_RX": 1,
|
||||
"SERIAL2_TX": 0,
|
||||
"ADC1": 26,
|
||||
"ADC2": 24,
|
||||
"ADC3": 20,
|
||||
"ADC5": 1,
|
||||
"ADC6": 10,
|
||||
"CS": 15,
|
||||
"MISO": 17,
|
||||
"MOSI": 16,
|
||||
"P0": 0,
|
||||
"P1": 1,
|
||||
"P6": 6,
|
||||
"P8": 8,
|
||||
"P9": 9,
|
||||
"P10": 10,
|
||||
"P11": 11,
|
||||
"P14": 14,
|
||||
"P15": 15,
|
||||
"P16": 16,
|
||||
"P17": 17,
|
||||
"P20": 20,
|
||||
"P22": 22,
|
||||
"P23": 23,
|
||||
"P24": 24,
|
||||
"P26": 26,
|
||||
"PWM0": 6,
|
||||
"PWM2": 8,
|
||||
"PWM3": 9,
|
||||
"PWM4": 24,
|
||||
"PWM5": 26,
|
||||
"RX1": 10,
|
||||
"RX2": 1,
|
||||
"SCK": 14,
|
||||
"TX1": 11,
|
||||
"TX2": 0,
|
||||
"D0": 20,
|
||||
"D1": 22,
|
||||
"D2": 6,
|
||||
"D3": 8,
|
||||
"D4": 9,
|
||||
"D5": 23,
|
||||
"D6": 0,
|
||||
"D7": 1,
|
||||
"D8": 24,
|
||||
"D9": 26,
|
||||
"D10": 10,
|
||||
"D11": 11,
|
||||
"D12": 17,
|
||||
"D13": 16,
|
||||
"D14": 15,
|
||||
"D15": 14,
|
||||
"A0": 20,
|
||||
"A1": 1,
|
||||
"A2": 24,
|
||||
"A3": 26,
|
||||
"A4": 10,
|
||||
},
|
||||
"wb2l": {
|
||||
"WIRE1_SCL": 20,
|
||||
"WIRE1_SDA": 21,
|
||||
@@ -965,6 +1338,84 @@ BK72XX_BOARD_PINS = {
|
||||
"D10": 21,
|
||||
"A0": 23,
|
||||
},
|
||||
"generic-bk7238": {
|
||||
"SPI0_CS": 15,
|
||||
"SPI0_MISO": 17,
|
||||
"SPI0_MOSI": 16,
|
||||
"SPI0_SCK": 14,
|
||||
"WIRE2_SCL_0": 15,
|
||||
"WIRE2_SCL_1": 24,
|
||||
"WIRE2_SDA_0": 17,
|
||||
"WIRE2_SDA_1": 26,
|
||||
"SERIAL1_RX": 10,
|
||||
"SERIAL1_TX": 11,
|
||||
"SERIAL2_RX": 1,
|
||||
"SERIAL2_TX": 0,
|
||||
"ADC1": 26,
|
||||
"ADC2": 24,
|
||||
"ADC3": 20,
|
||||
"ADC4": 28,
|
||||
"ADC5": 1,
|
||||
"ADC6": 10,
|
||||
"CS": 15,
|
||||
"MISO": 17,
|
||||
"MOSI": 16,
|
||||
"P0": 0,
|
||||
"P1": 1,
|
||||
"P6": 6,
|
||||
"P7": 7,
|
||||
"P8": 8,
|
||||
"P9": 9,
|
||||
"P10": 10,
|
||||
"P11": 11,
|
||||
"P14": 14,
|
||||
"P15": 15,
|
||||
"P16": 16,
|
||||
"P17": 17,
|
||||
"P20": 20,
|
||||
"P21": 21,
|
||||
"P22": 22,
|
||||
"P23": 23,
|
||||
"P24": 24,
|
||||
"P26": 26,
|
||||
"P28": 28,
|
||||
"PWM0": 6,
|
||||
"PWM1": 7,
|
||||
"PWM2": 8,
|
||||
"PWM3": 9,
|
||||
"PWM4": 24,
|
||||
"PWM5": 26,
|
||||
"RX1": 10,
|
||||
"RX2": 1,
|
||||
"SCK": 14,
|
||||
"TX1": 11,
|
||||
"TX2": 0,
|
||||
"D0": 0,
|
||||
"D1": 1,
|
||||
"D2": 6,
|
||||
"D3": 7,
|
||||
"D4": 8,
|
||||
"D5": 9,
|
||||
"D6": 10,
|
||||
"D7": 11,
|
||||
"D8": 14,
|
||||
"D9": 15,
|
||||
"D10": 16,
|
||||
"D11": 17,
|
||||
"D12": 20,
|
||||
"D13": 21,
|
||||
"D14": 22,
|
||||
"D15": 23,
|
||||
"D16": 24,
|
||||
"D17": 26,
|
||||
"D18": 28,
|
||||
"A0": 1,
|
||||
"A1": 10,
|
||||
"A2": 20,
|
||||
"A3": 24,
|
||||
"A4": 26,
|
||||
"A5": 28,
|
||||
},
|
||||
"wa2": {
|
||||
"WIRE1_SCL": 20,
|
||||
"WIRE1_SDA": 21,
|
||||
@@ -1235,6 +1686,51 @@ BK72XX_BOARD_PINS = {
|
||||
"D15": 1,
|
||||
"A0": 23,
|
||||
},
|
||||
"t1-2s": {
|
||||
"WIRE2_SCL": 24,
|
||||
"WIRE2_SDA": 26,
|
||||
"SERIAL1_RX": 10,
|
||||
"SERIAL1_TX": 11,
|
||||
"SERIAL2_RX": 1,
|
||||
"SERIAL2_TX": 0,
|
||||
"ADC1": 26,
|
||||
"ADC2": 24,
|
||||
"ADC5": 1,
|
||||
"ADC6": 10,
|
||||
"P0": 0,
|
||||
"P1": 1,
|
||||
"P6": 6,
|
||||
"P8": 8,
|
||||
"P9": 9,
|
||||
"P10": 10,
|
||||
"P11": 11,
|
||||
"P24": 24,
|
||||
"P26": 26,
|
||||
"PWM0": 6,
|
||||
"PWM2": 8,
|
||||
"PWM3": 9,
|
||||
"PWM4": 24,
|
||||
"PWM5": 26,
|
||||
"RX1": 10,
|
||||
"RX2": 1,
|
||||
"SCL2": 24,
|
||||
"SDA2": 26,
|
||||
"TX1": 11,
|
||||
"TX2": 0,
|
||||
"D0": 26,
|
||||
"D1": 6,
|
||||
"D2": 8,
|
||||
"D3": 1,
|
||||
"D4": 10,
|
||||
"D5": 11,
|
||||
"D6": 9,
|
||||
"D7": 24,
|
||||
"D11": 0,
|
||||
"A0": 26,
|
||||
"A1": 10,
|
||||
"A2": 1,
|
||||
"A3": 24,
|
||||
},
|
||||
"wb2s": {
|
||||
"WIRE1_SCL": 20,
|
||||
"WIRE1_SDA": 21,
|
||||
|
||||
@@ -30,19 +30,6 @@ void BluetoothProxy::setup() {
|
||||
this->configured_scan_active_ = this->parent_->get_scan_active();
|
||||
|
||||
this->parent_->add_scanner_state_listener(this);
|
||||
|
||||
this->set_interval(100, [this]() {
|
||||
if (api::global_api_server->is_connected() && this->api_connection_ != nullptr) {
|
||||
this->flush_pending_advertisements_();
|
||||
return;
|
||||
}
|
||||
for (uint8_t i = 0; i < this->connection_count_; i++) {
|
||||
auto *connection = this->connections_[i];
|
||||
if (connection->get_address() != 0 && !connection->disconnect_pending()) {
|
||||
connection->disconnect();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void BluetoothProxy::on_scanner_state(esp32_ble_tracker::ScannerState state) {
|
||||
@@ -133,6 +120,25 @@ void BluetoothProxy::dump_config() {
|
||||
YESNO(this->active_), this->connection_count_);
|
||||
}
|
||||
|
||||
void BluetoothProxy::loop() {
|
||||
// Run advertisement flush / connection cleanup every 100ms
|
||||
uint32_t now = App.get_loop_component_start_time();
|
||||
if (now - this->last_advertisement_flush_time_ < 100)
|
||||
return;
|
||||
this->last_advertisement_flush_time_ = now;
|
||||
|
||||
if (api::global_api_server->is_connected() && this->api_connection_ != nullptr) {
|
||||
this->flush_pending_advertisements_();
|
||||
return;
|
||||
}
|
||||
for (uint8_t i = 0; i < this->connection_count_; i++) {
|
||||
auto *connection = this->connections_[i];
|
||||
if (connection->get_address() != 0 && !connection->disconnect_pending()) {
|
||||
connection->disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
esp32_ble_tracker::AdvertisementParserType BluetoothProxy::get_advertisement_parser_type() {
|
||||
return esp32_ble_tracker::AdvertisementParserType::RAW_ADVERTISEMENTS;
|
||||
}
|
||||
@@ -201,7 +207,6 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest
|
||||
connection->set_connection_type(espbt::ConnectionType::V3_WITHOUT_CACHE);
|
||||
this->log_connection_info_(connection, "v3 without cache");
|
||||
}
|
||||
uint64_to_bd_addr(msg.address, connection->remote_bda_);
|
||||
connection->set_remote_addr_type(static_cast<esp_ble_addr_type_t>(msg.address_type));
|
||||
connection->set_state(espbt::ClientState::DISCOVERED);
|
||||
this->send_connections_free();
|
||||
|
||||
@@ -65,6 +65,7 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
|
||||
bool parse_devices(const esp32_ble::BLEScanResult *scan_results, size_t count) override;
|
||||
void dump_config() override;
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override;
|
||||
|
||||
void register_connection(BluetoothConnection *connection) {
|
||||
@@ -176,6 +177,9 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
|
||||
// BLE advertisement batching
|
||||
api::BluetoothLERawAdvertisementsResponse response_;
|
||||
|
||||
// Group 3: 4-byte types
|
||||
uint32_t last_advertisement_flush_time_{0};
|
||||
|
||||
// Pre-allocated response message - always ready to send
|
||||
api::BluetoothConnectionsFreeResponse connections_free_response_;
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -1724,6 +1729,10 @@ async def to_code(config):
|
||||
cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ESP_IDF")
|
||||
if use_platformio:
|
||||
cg.add_platformio_option("framework", "espidf")
|
||||
# Strip volatile build path/time metadata from PlatformIO-managed
|
||||
# ESP-IDF builds so equivalent projects can produce reproducible
|
||||
# outputs and downstream tooling can safely reuse artifacts.
|
||||
add_idf_sdkconfig_option("CONFIG_APP_REPRODUCIBLE_BUILD", True)
|
||||
|
||||
# Wrap std::__throw_* functions to abort immediately, eliminating ~3KB of
|
||||
# exception class overhead. See throw_stubs.cpp for implementation.
|
||||
@@ -1874,6 +1883,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(); }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from esphome import git, loader
|
||||
import esphome.config_validation as cv
|
||||
@@ -17,7 +18,7 @@ from esphome.const import (
|
||||
TYPE_GIT,
|
||||
TYPE_LOCAL,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
from esphome.core import CORE, TimePeriodSeconds
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -35,17 +36,15 @@ CONFIG_SCHEMA = cv.ensure_list(
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: dict[str, Any]) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _process_git_config(config: dict, refresh, skip_update: bool = False) -> str:
|
||||
# When skip_update is True, use NEVER_REFRESH to prevent updates
|
||||
actual_refresh = git.NEVER_REFRESH if skip_update else refresh
|
||||
def _process_git_config(config: dict[str, Any], refresh: TimePeriodSeconds) -> Path:
|
||||
repo_dir, _ = git.clone_or_update(
|
||||
url=config[CONF_URL],
|
||||
ref=config.get(CONF_REF),
|
||||
refresh=actual_refresh,
|
||||
refresh=refresh,
|
||||
domain=DOMAIN,
|
||||
username=config.get(CONF_USERNAME),
|
||||
password=config.get(CONF_PASSWORD),
|
||||
@@ -72,12 +71,12 @@ def _process_git_config(config: dict, refresh, skip_update: bool = False) -> str
|
||||
return components_dir
|
||||
|
||||
|
||||
def _process_single_config(config: dict, skip_update: bool = False):
|
||||
def _process_single_config(config: dict[str, Any]) -> None:
|
||||
conf = config[CONF_SOURCE]
|
||||
if conf[CONF_TYPE] == TYPE_GIT:
|
||||
with cv.prepend_path([CONF_SOURCE]):
|
||||
components_dir = _process_git_config(
|
||||
config[CONF_SOURCE], config[CONF_REFRESH], skip_update
|
||||
config[CONF_SOURCE], config[CONF_REFRESH]
|
||||
)
|
||||
elif conf[CONF_TYPE] == TYPE_LOCAL:
|
||||
components_dir = Path(CORE.relative_config_path(conf[CONF_PATH]))
|
||||
@@ -107,7 +106,7 @@ def _process_single_config(config: dict, skip_update: bool = False):
|
||||
loader.install_meta_finder(components_dir, allowed_components=allowed_components)
|
||||
|
||||
|
||||
def do_external_components_pass(config: dict, skip_update: bool = False) -> None:
|
||||
def do_external_components_pass(config: dict[str, Any]) -> None:
|
||||
conf = config.get(DOMAIN)
|
||||
if conf is None:
|
||||
return
|
||||
@@ -115,4 +114,4 @@ def do_external_components_pass(config: dict, skip_update: bool = False) -> None
|
||||
conf = CONFIG_SCHEMA(conf)
|
||||
for i, c in enumerate(conf):
|
||||
with cv.prepend_path(i):
|
||||
_process_single_config(c, skip_update)
|
||||
_process_single_config(c)
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
#include <csignal>
|
||||
#include <sched.h>
|
||||
#include <time.h>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
|
||||
namespace {
|
||||
@@ -22,9 +21,7 @@ void HOT yield() { ::sched_yield(); }
|
||||
uint32_t IRAM_ATTR HOT millis() {
|
||||
struct timespec spec;
|
||||
clock_gettime(CLOCK_MONOTONIC, &spec);
|
||||
time_t seconds = spec.tv_sec;
|
||||
uint32_t ms = round(spec.tv_nsec / 1e6);
|
||||
return ((uint32_t) seconds) * 1000U + ms;
|
||||
return static_cast<uint32_t>(spec.tv_sec * 1000ULL + spec.tv_nsec / 1000000);
|
||||
}
|
||||
uint64_t millis_64() {
|
||||
struct timespec spec;
|
||||
@@ -43,9 +40,7 @@ void HOT delay(uint32_t ms) {
|
||||
uint32_t IRAM_ATTR HOT micros() {
|
||||
struct timespec spec;
|
||||
clock_gettime(CLOCK_MONOTONIC, &spec);
|
||||
time_t seconds = spec.tv_sec;
|
||||
uint32_t us = round(spec.tv_nsec / 1e3);
|
||||
return ((uint32_t) seconds) * 1000000U + us;
|
||||
return static_cast<uint32_t>(spec.tv_sec * 1000000ULL + spec.tv_nsec / 1000);
|
||||
}
|
||||
void IRAM_ATTR HOT delayMicroseconds(uint32_t us) {
|
||||
struct timespec ts;
|
||||
|
||||
@@ -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
|
||||
@@ -17,6 +17,7 @@ void ImprovSerialComponent::setup() {
|
||||
global_improv_serial_component = this;
|
||||
#ifdef USE_ESP32
|
||||
this->uart_num_ = logger::global_logger->get_uart_num();
|
||||
this->uart_selection_ = logger::global_logger->get_uart();
|
||||
#elif defined(USE_ARDUINO)
|
||||
this->hw_serial_ = logger::global_logger->get_hw_serial();
|
||||
#endif
|
||||
@@ -29,7 +30,8 @@ void ImprovSerialComponent::setup() {
|
||||
}
|
||||
|
||||
void ImprovSerialComponent::loop() {
|
||||
if (this->last_read_byte_ && (millis() - this->last_read_byte_ > IMPROV_SERIAL_TIMEOUT)) {
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
if (this->last_read_byte_ && (now - this->last_read_byte_ > IMPROV_SERIAL_TIMEOUT)) {
|
||||
this->last_read_byte_ = 0;
|
||||
this->rx_buffer_.clear();
|
||||
ESP_LOGV(TAG, "Timeout");
|
||||
@@ -38,7 +40,7 @@ void ImprovSerialComponent::loop() {
|
||||
auto byte = this->read_byte_();
|
||||
while (byte.has_value()) {
|
||||
if (this->parse_improv_serial_byte_(byte.value())) {
|
||||
this->last_read_byte_ = millis();
|
||||
this->last_read_byte_ = now;
|
||||
} else {
|
||||
this->last_read_byte_ = 0;
|
||||
this->rx_buffer_.clear();
|
||||
@@ -62,55 +64,6 @@ void ImprovSerialComponent::loop() {
|
||||
|
||||
void ImprovSerialComponent::dump_config() { ESP_LOGCONFIG(TAG, "Improv Serial:"); }
|
||||
|
||||
optional<uint8_t> ImprovSerialComponent::read_byte_() {
|
||||
optional<uint8_t> byte;
|
||||
uint8_t data = 0;
|
||||
#ifdef USE_ESP32
|
||||
switch (logger::global_logger->get_uart()) {
|
||||
case logger::UART_SELECTION_UART0:
|
||||
case logger::UART_SELECTION_UART1:
|
||||
#if !defined(USE_ESP32_VARIANT_ESP32C3) && !defined(USE_ESP32_VARIANT_ESP32C6) && \
|
||||
!defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32S2) && !defined(USE_ESP32_VARIANT_ESP32S3)
|
||||
case logger::UART_SELECTION_UART2:
|
||||
#endif // !USE_ESP32_VARIANT_ESP32C3 && !USE_ESP32_VARIANT_ESP32C6 && !USE_ESP32_VARIANT_ESP32C61 &&
|
||||
// !USE_ESP32_VARIANT_ESP32S2 && !USE_ESP32_VARIANT_ESP32S3
|
||||
if (this->uart_num_ >= 0) {
|
||||
size_t available;
|
||||
uart_get_buffered_data_len(this->uart_num_, &available);
|
||||
if (available) {
|
||||
uart_read_bytes(this->uart_num_, &data, 1, 0);
|
||||
byte = data;
|
||||
}
|
||||
}
|
||||
break;
|
||||
#if defined(USE_LOGGER_USB_CDC) && defined(CONFIG_ESP_CONSOLE_USB_CDC)
|
||||
case logger::UART_SELECTION_USB_CDC:
|
||||
if (esp_usb_console_available_for_read()) {
|
||||
esp_usb_console_read_buf((char *) &data, 1);
|
||||
byte = data;
|
||||
}
|
||||
break;
|
||||
#endif // USE_LOGGER_USB_CDC
|
||||
#ifdef USE_LOGGER_USB_SERIAL_JTAG
|
||||
case logger::UART_SELECTION_USB_SERIAL_JTAG: {
|
||||
if (usb_serial_jtag_read_bytes((char *) &data, 1, 0)) {
|
||||
byte = data;
|
||||
}
|
||||
break;
|
||||
}
|
||||
#endif // USE_LOGGER_USB_SERIAL_JTAG
|
||||
default:
|
||||
break;
|
||||
}
|
||||
#elif defined(USE_ARDUINO)
|
||||
if (this->hw_serial_->available()) {
|
||||
this->hw_serial_->readBytes(&data, 1);
|
||||
byte = data;
|
||||
}
|
||||
#endif
|
||||
return byte;
|
||||
}
|
||||
|
||||
void ImprovSerialComponent::write_data_(const uint8_t *data, const size_t size) {
|
||||
// First, set length field
|
||||
this->tx_header_[TX_LENGTH_IDX] = this->tx_header_[TX_TYPE_IDX] == TYPE_RPC_RESPONSE ? size : 1;
|
||||
@@ -134,7 +87,7 @@ void ImprovSerialComponent::write_data_(const uint8_t *data, const size_t size)
|
||||
this->tx_header_[TX_CHECKSUM_IDX] = checksum;
|
||||
|
||||
#ifdef USE_ESP32
|
||||
switch (logger::global_logger->get_uart()) {
|
||||
switch (this->uart_selection_) {
|
||||
case logger::UART_SELECTION_UART0:
|
||||
case logger::UART_SELECTION_UART1:
|
||||
#if !defined(USE_ESP32_VARIANT_ESP32C3) && !defined(USE_ESP32_VARIANT_ESP32C6) && \
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/improv_base/improv_base.h"
|
||||
#include "esphome/components/logger/logger.h"
|
||||
#include "esphome/components/wifi/wifi_component.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/defines.h"
|
||||
@@ -66,7 +67,53 @@ class ImprovSerialComponent : public Component, public improv_base::ImprovBase {
|
||||
std::vector<uint8_t> build_rpc_settings_response_(improv::Command command);
|
||||
std::vector<uint8_t> build_version_info_();
|
||||
|
||||
optional<uint8_t> read_byte_();
|
||||
ESPHOME_ALWAYS_INLINE optional<uint8_t> read_byte_() {
|
||||
optional<uint8_t> byte;
|
||||
uint8_t data = 0;
|
||||
#ifdef USE_ESP32
|
||||
switch (this->uart_selection_) {
|
||||
case logger::UART_SELECTION_UART0:
|
||||
case logger::UART_SELECTION_UART1:
|
||||
#if !defined(USE_ESP32_VARIANT_ESP32C3) && !defined(USE_ESP32_VARIANT_ESP32C6) && \
|
||||
!defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32S2) && !defined(USE_ESP32_VARIANT_ESP32S3)
|
||||
case logger::UART_SELECTION_UART2:
|
||||
#endif
|
||||
if (this->uart_num_ >= 0) {
|
||||
size_t available;
|
||||
uart_get_buffered_data_len(this->uart_num_, &available);
|
||||
if (available) {
|
||||
uart_read_bytes(this->uart_num_, &data, 1, 0);
|
||||
byte = data;
|
||||
}
|
||||
}
|
||||
break;
|
||||
#if defined(USE_LOGGER_USB_CDC) && defined(CONFIG_ESP_CONSOLE_USB_CDC)
|
||||
case logger::UART_SELECTION_USB_CDC:
|
||||
if (esp_usb_console_available_for_read()) {
|
||||
esp_usb_console_read_buf((char *) &data, 1);
|
||||
byte = data;
|
||||
}
|
||||
break;
|
||||
#endif
|
||||
#ifdef USE_LOGGER_USB_SERIAL_JTAG
|
||||
case logger::UART_SELECTION_USB_SERIAL_JTAG: {
|
||||
if (usb_serial_jtag_read_bytes((char *) &data, 1, 0)) {
|
||||
byte = data;
|
||||
}
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
default:
|
||||
break;
|
||||
}
|
||||
#elif defined(USE_ARDUINO)
|
||||
if (this->hw_serial_->available()) {
|
||||
this->hw_serial_->readBytes(&data, 1);
|
||||
byte = data;
|
||||
}
|
||||
#endif
|
||||
return byte;
|
||||
}
|
||||
void write_data_(const uint8_t *data = nullptr, size_t size = 0);
|
||||
|
||||
uint8_t tx_header_[TX_BUFFER_SIZE] = {
|
||||
@@ -86,6 +133,7 @@ class ImprovSerialComponent : public Component, public improv_base::ImprovBase {
|
||||
|
||||
#ifdef USE_ESP32
|
||||
uart_port_t uart_num_;
|
||||
logger::UARTSelection uart_selection_{logger::UART_SELECTION_UART0};
|
||||
#elif defined(USE_ARDUINO)
|
||||
Stream *hw_serial_{nullptr};
|
||||
#endif
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,13 +1,73 @@
|
||||
#include "ir_rf_proxy.h"
|
||||
|
||||
#include <cinttypes>
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::ir_rf_proxy {
|
||||
|
||||
static const char *const TAG = "ir_rf_proxy";
|
||||
|
||||
// ========== Shared transmit helper ==========
|
||||
// Static template: all instantiations occur in this translation unit.
|
||||
|
||||
template<typename CallT>
|
||||
static void transmit_raw_timings(remote_base::RemoteTransmitterBase *transmitter, uint32_t carrier_frequency,
|
||||
const CallT &call) {
|
||||
if (transmitter == nullptr) {
|
||||
ESP_LOGW(TAG, "No transmitter configured");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!call.has_raw_timings()) {
|
||||
ESP_LOGE(TAG, "No raw timings provided");
|
||||
return;
|
||||
}
|
||||
|
||||
auto transmit_call = transmitter->transmit();
|
||||
auto *transmit_data = transmit_call.get_data();
|
||||
transmit_data->set_carrier_frequency(carrier_frequency);
|
||||
|
||||
if (call.is_packed()) {
|
||||
transmit_data->set_data_from_packed_sint32(call.get_packed_data(), call.get_packed_length(),
|
||||
call.get_packed_count());
|
||||
ESP_LOGD(TAG, "Transmitting packed raw timings: count=%" PRIu16 ", repeat=%" PRIu32, call.get_packed_count(),
|
||||
call.get_repeat_count());
|
||||
} else if (call.is_base64url()) {
|
||||
if (!transmit_data->set_data_from_base64url(call.get_base64url_data())) {
|
||||
ESP_LOGE(TAG, "Invalid base64url data");
|
||||
return;
|
||||
}
|
||||
constexpr int32_t max_timing_us = 500000;
|
||||
for (int32_t timing : transmit_data->get_data()) {
|
||||
int32_t abs_timing = timing < 0 ? -timing : timing;
|
||||
if (abs_timing > max_timing_us) {
|
||||
ESP_LOGE(TAG, "Invalid timing value: %" PRId32 " µs (max %" PRId32 ")", timing, max_timing_us);
|
||||
return;
|
||||
}
|
||||
}
|
||||
ESP_LOGD(TAG, "Transmitting base64url raw timings: count=%zu, repeat=%" PRIu32, transmit_data->get_data().size(),
|
||||
call.get_repeat_count());
|
||||
} else {
|
||||
transmit_data->set_data(call.get_raw_timings());
|
||||
ESP_LOGD(TAG, "Transmitting raw timings: count=%zu, repeat=%" PRIu32, call.get_raw_timings().size(),
|
||||
call.get_repeat_count());
|
||||
}
|
||||
|
||||
if (call.get_repeat_count() > 0) {
|
||||
transmit_call.set_send_times(call.get_repeat_count());
|
||||
}
|
||||
|
||||
transmit_call.perform();
|
||||
}
|
||||
|
||||
// ========== IrRfProxy (Infrared platform) ==========
|
||||
|
||||
#ifdef USE_IR_RF
|
||||
|
||||
void IrRfProxy::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"IR/RF Proxy '%s'\n"
|
||||
"IR Proxy '%s'\n"
|
||||
" Supports Transmitter: %s\n"
|
||||
" Supports Receiver: %s",
|
||||
this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()),
|
||||
@@ -20,4 +80,54 @@ void IrRfProxy::dump_config() {
|
||||
}
|
||||
}
|
||||
|
||||
void IrRfProxy::control(const infrared::InfraredCall &call) {
|
||||
uint32_t carrier = call.get_carrier_frequency().value_or(0);
|
||||
transmit_raw_timings(this->transmitter_, carrier, call);
|
||||
}
|
||||
|
||||
#endif // USE_IR_RF
|
||||
|
||||
// ========== RfProxy (Radio Frequency platform) ==========
|
||||
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
|
||||
void RfProxy::setup() {
|
||||
this->traits_.set_supports_transmitter(this->transmitter_ != nullptr);
|
||||
this->traits_.set_supports_receiver(this->receiver_ != nullptr);
|
||||
|
||||
// remote_transmitter/receiver always uses OOK (on-off keying)
|
||||
this->traits_.add_supported_modulation(radio_frequency::RadioFrequencyModulation::RADIO_FREQUENCY_MODULATION_OOK);
|
||||
|
||||
if (this->receiver_ != nullptr) {
|
||||
this->receiver_->register_listener(this);
|
||||
}
|
||||
}
|
||||
|
||||
void RfProxy::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"RF Proxy '%s'\n"
|
||||
" Backend: remote_transmitter/receiver\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()));
|
||||
|
||||
const auto &traits = this->traits_;
|
||||
if (traits.get_frequency_min_hz() > 0) {
|
||||
if (traits.get_frequency_min_hz() == traits.get_frequency_max_hz()) {
|
||||
ESP_LOGCONFIG(TAG, " Frequency: %.3f MHz (fixed)", traits.get_frequency_min_hz() / 1e6f);
|
||||
} else {
|
||||
ESP_LOGCONFIG(TAG, " Frequency Range: %.3f - %.3f MHz", traits.get_frequency_min_hz() / 1e6f,
|
||||
traits.get_frequency_max_hz() / 1e6f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void RfProxy::control(const radio_frequency::RadioFrequencyCall &call) {
|
||||
// RF: no IR carrier modulation
|
||||
transmit_raw_timings(this->transmitter_, 0, call);
|
||||
}
|
||||
|
||||
#endif // USE_RADIO_FREQUENCY
|
||||
|
||||
} // namespace esphome::ir_rf_proxy
|
||||
|
||||
@@ -4,10 +4,19 @@
|
||||
// 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/components/remote_base/remote_base.h"
|
||||
|
||||
#ifdef USE_IR_RF
|
||||
#include "esphome/components/infrared/infrared.h"
|
||||
#endif
|
||||
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
#include "esphome/components/radio_frequency/radio_frequency.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::ir_rf_proxy {
|
||||
|
||||
#ifdef USE_IR_RF
|
||||
/// IrRfProxy - Infrared platform implementation using remote_transmitter/receiver as backend
|
||||
class IrRfProxy : public infrared::Infrared {
|
||||
public:
|
||||
@@ -26,8 +35,36 @@ class IrRfProxy : public infrared::Infrared {
|
||||
void set_receiver_frequency(uint32_t frequency_hz) { this->get_traits().set_receiver_frequency_hz(frequency_hz); }
|
||||
|
||||
protected:
|
||||
void control(const infrared::InfraredCall &call) override;
|
||||
|
||||
// RF frequency in kHz (Hz / 1000); 0 = infrared, non-zero = RF
|
||||
uint32_t frequency_khz_{0};
|
||||
};
|
||||
#endif // USE_IR_RF
|
||||
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
/// RfProxy - Radio Frequency platform implementation using remote_transmitter/receiver as backend
|
||||
class RfProxy : public radio_frequency::RadioFrequency {
|
||||
public:
|
||||
RfProxy() = default;
|
||||
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
|
||||
/// Set the remote transmitter component
|
||||
void set_transmitter(remote_base::RemoteTransmitterBase *transmitter) { this->transmitter_ = transmitter; }
|
||||
/// Set the remote receiver component
|
||||
void set_receiver(remote_base::RemoteReceiverBase *receiver) { this->receiver_ = receiver; }
|
||||
|
||||
/// Set the fixed carrier frequency in Hz (metadata: advertised via traits, does not tune hardware)
|
||||
void set_frequency_hz(uint32_t freq_hz) { this->traits_.set_fixed_frequency_hz(freq_hz); }
|
||||
|
||||
protected:
|
||||
void control(const radio_frequency::RadioFrequencyCall &call) override;
|
||||
|
||||
remote_base::RemoteTransmitterBase *transmitter_{nullptr};
|
||||
remote_base::RemoteReceiverBase *receiver_{nullptr};
|
||||
};
|
||||
#endif // USE_RADIO_FREQUENCY
|
||||
|
||||
} // namespace esphome::ir_rf_proxy
|
||||
|
||||
68
esphome/components/ir_rf_proxy/radio_frequency.py
Normal file
68
esphome/components/ir_rf_proxy/radio_frequency.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Radio Frequency platform implementation using remote_base (remote_transmitter/receiver)."""
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import radio_frequency, remote_receiver, remote_transmitter
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_CARRIER_DUTY_PERCENT, CONF_FREQUENCY
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import CONF_REMOTE_RECEIVER_ID, CONF_REMOTE_TRANSMITTER_ID, ir_rf_proxy_ns
|
||||
|
||||
CODEOWNERS = ["@kbx81"]
|
||||
DEPENDENCIES = ["radio_frequency"]
|
||||
|
||||
RfProxy = ir_rf_proxy_ns.class_("RfProxy", radio_frequency.RadioFrequency)
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
radio_frequency.radio_frequency_schema(RfProxy).extend(
|
||||
{
|
||||
cv.Optional(CONF_FREQUENCY): cv.frequency,
|
||||
cv.Optional(CONF_REMOTE_RECEIVER_ID): cv.use_id(
|
||||
remote_receiver.RemoteReceiverComponent
|
||||
),
|
||||
cv.Optional(CONF_REMOTE_TRANSMITTER_ID): cv.use_id(
|
||||
remote_transmitter.RemoteTransmitterComponent
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.has_exactly_one_key(CONF_REMOTE_RECEIVER_ID, CONF_REMOTE_TRANSMITTER_ID),
|
||||
)
|
||||
|
||||
|
||||
def _final_validate(config: ConfigType) -> None:
|
||||
"""Validate that RF transmitters have carrier duty set to 100%."""
|
||||
if CONF_REMOTE_TRANSMITTER_ID not in config:
|
||||
return
|
||||
|
||||
transmitter_id = config[CONF_REMOTE_TRANSMITTER_ID]
|
||||
full_config = fv.full_config.get()
|
||||
transmitter_path = full_config.get_path_for_id(transmitter_id)[:-1]
|
||||
transmitter_config = full_config.get_config_for_path(transmitter_path)
|
||||
|
||||
duty_percent = transmitter_config.get(CONF_CARRIER_DUTY_PERCENT)
|
||||
if duty_percent is not None and duty_percent != 100:
|
||||
raise cv.Invalid(
|
||||
f"Transmitter '{transmitter_id}' must have '{CONF_CARRIER_DUTY_PERCENT}' "
|
||||
"set to 100% for RF transmission. Dedicated RF hardware handles modulation; "
|
||||
"applying a carrier duty cycle would corrupt the signal"
|
||||
)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
"""Code generation for remote_base radio frequency platform."""
|
||||
var = await radio_frequency.new_radio_frequency(config)
|
||||
|
||||
if CONF_FREQUENCY in config:
|
||||
cg.add(var.set_frequency_hz(int(config[CONF_FREQUENCY])))
|
||||
|
||||
if CONF_REMOTE_TRANSMITTER_ID in config:
|
||||
transmitter = await cg.get_variable(config[CONF_REMOTE_TRANSMITTER_ID])
|
||||
cg.add(var.set_transmitter(transmitter))
|
||||
|
||||
if CONF_REMOTE_RECEIVER_ID in config:
|
||||
receiver = await cg.get_variable(config[CONF_REMOTE_RECEIVER_ID])
|
||||
cg.add(var.set_receiver(receiver))
|
||||
@@ -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)
|
||||
|
||||
@@ -58,6 +58,7 @@ COMPONENT_RTL87XX = "rtl87xx"
|
||||
FAMILY_BK7231N = "BK7231N"
|
||||
FAMILY_BK7231Q = "BK7231Q"
|
||||
FAMILY_BK7231T = "BK7231T"
|
||||
FAMILY_BK7238 = "BK7238"
|
||||
FAMILY_BK7251 = "BK7251"
|
||||
FAMILY_LN882H = "LN882H"
|
||||
FAMILY_RTL8710B = "RTL8710B"
|
||||
@@ -66,6 +67,7 @@ FAMILIES = [
|
||||
FAMILY_BK7231N,
|
||||
FAMILY_BK7231Q,
|
||||
FAMILY_BK7231T,
|
||||
FAMILY_BK7238,
|
||||
FAMILY_BK7251,
|
||||
FAMILY_LN882H,
|
||||
FAMILY_RTL8710B,
|
||||
@@ -75,6 +77,7 @@ FAMILY_FRIENDLY = {
|
||||
FAMILY_BK7231N: "BK7231N",
|
||||
FAMILY_BK7231Q: "BK7231Q",
|
||||
FAMILY_BK7231T: "BK7231T",
|
||||
FAMILY_BK7238: "BK7238",
|
||||
FAMILY_BK7251: "BK7251",
|
||||
FAMILY_LN882H: "LN882H",
|
||||
FAMILY_RTL8710B: "RTL8710B",
|
||||
@@ -84,6 +87,7 @@ FAMILY_COMPONENT = {
|
||||
FAMILY_BK7231N: COMPONENT_BK72XX,
|
||||
FAMILY_BK7231Q: COMPONENT_BK72XX,
|
||||
FAMILY_BK7231T: COMPONENT_BK72XX,
|
||||
FAMILY_BK7238: COMPONENT_BK72XX,
|
||||
FAMILY_BK7251: COMPONENT_BK72XX,
|
||||
FAMILY_LN882H: COMPONENT_LN882X,
|
||||
FAMILY_RTL8710B: COMPONENT_RTL87XX,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -352,7 +352,7 @@ void MitsubishiCN105::set_target_temperature(float target_temperature) {
|
||||
ESP_LOGD(TAG, "Setting temperature out-of-range: %.1f", target_temperature);
|
||||
return;
|
||||
}
|
||||
this->status_.target_temperature = std::round(target_temperature);
|
||||
this->status_.target_temperature = target_temperature;
|
||||
this->pending_updates_.set(UpdateFlag::TEMPERATURE);
|
||||
}
|
||||
|
||||
@@ -387,9 +387,9 @@ void MitsubishiCN105::apply_settings_() {
|
||||
if (this->pending_updates_.has(UpdateFlag::TEMPERATURE)) {
|
||||
payload[1] |= 0x04;
|
||||
if (this->use_temperature_encoding_b_) {
|
||||
payload[14] = static_cast<uint8_t>(this->status_.target_temperature * 2.0f + 128.0f);
|
||||
payload[14] = static_cast<uint8_t>(std::round(this->status_.target_temperature * 2.0f) + 128);
|
||||
} else {
|
||||
payload[5] = static_cast<uint8_t>(TARGET_TEMPERATURE_ENC_A_OFFSET - this->status_.target_temperature);
|
||||
payload[5] = static_cast<uint8_t>(TARGET_TEMPERATURE_ENC_A_OFFSET - std::round(this->status_.target_temperature));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -205,7 +205,7 @@ CONFIG_SCHEMA = cv.Any( # under `packages:` we can have either:
|
||||
)
|
||||
|
||||
|
||||
def _process_remote_package(config: dict, skip_update: bool = False) -> dict:
|
||||
def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Clone/update a git repo and load the YAML files listed in the package definition.
|
||||
|
||||
Returns ``{"packages": {<filename>: <loaded_yaml>, ...}}`` so the caller
|
||||
@@ -215,11 +215,10 @@ def _process_remote_package(config: dict, skip_update: bool = False) -> dict:
|
||||
If loading fails after cloning, attempts a revert and retry in case
|
||||
a prior cached checkout is stale.
|
||||
"""
|
||||
actual_refresh = git.NEVER_REFRESH if skip_update else config[CONF_REFRESH]
|
||||
repo_dir, revert = git.clone_or_update(
|
||||
url=config[CONF_URL],
|
||||
ref=config.get(CONF_REF),
|
||||
refresh=actual_refresh,
|
||||
refresh=config[CONF_REFRESH],
|
||||
domain=DOMAIN,
|
||||
username=config.get(CONF_USERNAME),
|
||||
password=config.get(CONF_PASSWORD),
|
||||
@@ -378,9 +377,8 @@ def _substitute_package_definition(
|
||||
Local package contents are left untouched — they will be substituted
|
||||
later during the main substitution pass.
|
||||
"""
|
||||
if isinstance(package_config, str) or (
|
||||
isinstance(package_config, dict) and is_remote_package(package_config)
|
||||
):
|
||||
|
||||
def do_substitute(package_config: dict | str) -> dict | str:
|
||||
# Collect undefined-variable errors (rather than raising strict) so the
|
||||
# path walked through a remote-package dict is preserved and the user
|
||||
# sees which field (url / path / ref / ...) referenced the undefined
|
||||
@@ -394,6 +392,22 @@ def _substitute_package_definition(
|
||||
errors=errors,
|
||||
)
|
||||
raise_first_undefined(errors, "package definition")
|
||||
return package_config
|
||||
|
||||
if isinstance(package_config, str):
|
||||
return do_substitute(package_config)
|
||||
|
||||
if isinstance(package_config, dict) and is_remote_package(package_config):
|
||||
# Mark vars as literal to avoid substituting variables in the vars block itself, since they are meant to be
|
||||
# passed as-is to the package YAML and may contain their own substitution expressions that should not
|
||||
# be prematurely evaluated here.
|
||||
if CONF_FILES in package_config:
|
||||
for file_def in package_config[CONF_FILES]:
|
||||
if isinstance(file_def, dict) and CONF_VARS in file_def:
|
||||
file_def[CONF_VARS] = yaml_util.make_literal(file_def[CONF_VARS])
|
||||
|
||||
package_config = do_substitute(package_config)
|
||||
|
||||
return package_config
|
||||
|
||||
|
||||
@@ -441,11 +455,9 @@ class _PackageProcessor:
|
||||
self,
|
||||
substitutions: UserDict,
|
||||
command_line_substitutions: dict[str, Any] | None,
|
||||
skip_update: bool,
|
||||
) -> None:
|
||||
self.substitutions = substitutions
|
||||
self.parent_context = UserDict(command_line_substitutions or {})
|
||||
self.skip_update = skip_update
|
||||
|
||||
def resolve_package(
|
||||
self,
|
||||
@@ -493,7 +505,7 @@ class _PackageProcessor:
|
||||
)
|
||||
|
||||
if is_remote_package(package_config):
|
||||
package_config = _process_remote_package(package_config, self.skip_update)
|
||||
package_config = _process_remote_package(package_config)
|
||||
return package_config
|
||||
|
||||
def collect_substitutions(self, package_config: dict) -> None:
|
||||
@@ -537,11 +549,10 @@ class _PackageProcessor:
|
||||
|
||||
|
||||
def do_packages_pass(
|
||||
config: dict,
|
||||
config: dict[str, Any],
|
||||
*,
|
||||
command_line_substitutions: dict[str, Any] | None = None,
|
||||
skip_update: bool = False,
|
||||
) -> dict:
|
||||
) -> dict[str, Any]:
|
||||
"""Load, validate, and flatten all packages in the config.
|
||||
|
||||
Returns the config with all packages loaded in-place (but not yet merged)
|
||||
@@ -556,9 +567,7 @@ def do_packages_pass(
|
||||
config.pop(CONF_SUBSTITUTIONS, {}), command_line_substitutions
|
||||
)
|
||||
)
|
||||
processor = _PackageProcessor(
|
||||
substitutions, command_line_substitutions, skip_update
|
||||
)
|
||||
processor = _PackageProcessor(substitutions, command_line_substitutions)
|
||||
_update_substitutions_context(processor.parent_context, substitutions)
|
||||
|
||||
context_vars = push_context(
|
||||
|
||||
@@ -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
|
||||
@@ -129,6 +129,6 @@ async def to_code(config):
|
||||
async def sensor_template_publish_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)
|
||||
template_ = await cg.templatable(config[CONF_VALUE], args, cg.int32)
|
||||
template_ = await cg.templatable(config[CONF_VALUE], args, cg.int_)
|
||||
cg.add(var.set_value(template_))
|
||||
return var
|
||||
|
||||
@@ -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 }}
|
||||
@@ -93,7 +93,9 @@ async def to_code(config):
|
||||
|
||||
cg.add(var.set_gain(config[CONF_GAIN]))
|
||||
|
||||
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
|
||||
if config.get(CONF_ON_FINISHED_PLAYBACK):
|
||||
cg.add_define("USE_RTTTL_FINISHED_PLAYBACK_CALLBACK")
|
||||
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
|
||||
@@ -424,7 +424,9 @@ void Rtttl::set_state_(State state) {
|
||||
// Clear loop_done when transitioning from `State::STOPPED` to any other state
|
||||
if (state == State::STOPPED) {
|
||||
this->disable_loop();
|
||||
#ifdef USE_RTTTL_FINISHED_PLAYBACK_CALLBACK
|
||||
this->on_finished_playback_callback_.call();
|
||||
#endif
|
||||
ESP_LOGD(TAG, "Playback finished");
|
||||
} else if (old_state == State::STOPPED) {
|
||||
this->enable_loop();
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#ifdef USE_OUTPUT
|
||||
#include "esphome/components/output/float_output.h"
|
||||
@@ -45,9 +47,11 @@ class Rtttl : public Component {
|
||||
|
||||
bool is_playing() { return this->state_ != State::STOPPED; }
|
||||
|
||||
#ifdef USE_RTTTL_FINISHED_PLAYBACK_CALLBACK
|
||||
template<typename F> void add_on_finished_playback_callback(F &&callback) {
|
||||
this->on_finished_playback_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
#endif
|
||||
|
||||
protected:
|
||||
inline uint16_t get_integer_() {
|
||||
@@ -106,8 +110,10 @@ class Rtttl : public Component {
|
||||
uint32_t samples_gap_{0};
|
||||
#endif // USE_SPEAKER
|
||||
|
||||
#ifdef USE_RTTTL_FINISHED_PLAYBACK_CALLBACK
|
||||
/// The callback to call when playback is finished.
|
||||
CallbackManager<void()> on_finished_playback_callback_;
|
||||
#endif
|
||||
};
|
||||
|
||||
template<typename... Ts> class PlayAction : public Action<Ts...> {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user