mirror of
https://github.com/esphome/esphome.git
synced 2026-09-18 18:48:39 +00:00
Merge branch 'dev' into fast-millis-esp8266
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
c65f1a0804a7765462d570c50891ac719260592df2c9cdfe88233fc346ac59e9
|
||||
1b1ce6324c50c4595703c7df0a8a479b4fe84b71ff1a8793cce1a16f17a33324
|
||||
|
||||
@@ -41,16 +41,36 @@ function generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo,
|
||||
|
||||
let message = `${TOO_BIG_MARKER}\n### 📦 Pull Request Size\n\n`;
|
||||
|
||||
message +=
|
||||
`Hey @${prAuthor}, thanks for the contribution! Just a heads up, ` +
|
||||
`this PR is on the large side `;
|
||||
|
||||
if (tooManyLabels && tooManyChanges) {
|
||||
message += `This PR is too large with ${nonTestChanges} line changes (excluding tests) and affects ${originalLabelCount} different components/areas.`;
|
||||
message +=
|
||||
`(${nonTestChanges} line changes excluding tests, across ` +
|
||||
`${originalLabelCount} different components/areas)`;
|
||||
} else if (tooManyLabels) {
|
||||
message += `This PR affects ${originalLabelCount} different components/areas.`;
|
||||
message +=
|
||||
`(it touches ${originalLabelCount} different components/areas)`;
|
||||
} else {
|
||||
message += `This PR is too large with ${nonTestChanges} line changes (excluding tests).`;
|
||||
message += `(${nonTestChanges} line changes excluding tests)`;
|
||||
}
|
||||
|
||||
message += ` Please consider breaking it down into smaller, focused PRs to make review easier and reduce the risk of conflicts.\n\n`;
|
||||
message += `For guidance on breaking down large PRs, see: https://developers.esphome.io/contributing/submitting-your-work/#how-to-approach-large-submissions`;
|
||||
message += `, which makes it harder for maintainers to review.\n\n`;
|
||||
message +=
|
||||
`Smaller, focused PRs tend to be reviewed much faster since they ` +
|
||||
`fit into the short gaps between other maintainer work; large ones ` +
|
||||
`often have to wait for a rare long uninterrupted block of time. ` +
|
||||
`If you can break this up into smaller pieces that can be reviewed ` +
|
||||
`independently, it will almost certainly land faster overall.\n\n`;
|
||||
message +=
|
||||
`Before putting more time in, it's also worth popping into ` +
|
||||
`\`#devs\` on [Discord](https://esphome.io/chat) so we can help ` +
|
||||
`you scope things and flag anything already in flight.\n\n`;
|
||||
message +=
|
||||
`For more details (including how to split the work up), see: ` +
|
||||
`https://developers.esphome.io/contributing/submitting-your-work/` +
|
||||
`#how-to-approach-large-submissions`;
|
||||
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
name: Close PR From Fork Default Branch
|
||||
|
||||
on:
|
||||
# pull_request_target is required so we have permission to comment and close PRs from forks.
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
close:
|
||||
name: Close PR opened from fork's default branch
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name
|
||||
&& github.event.pull_request.head.ref == github.event.repository.default_branch
|
||||
steps:
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const author = context.payload.pull_request.user.login;
|
||||
const defaultBranch = context.payload.repository.default_branch;
|
||||
const headRepo = context.payload.pull_request.head.repo.full_name;
|
||||
|
||||
const body = [
|
||||
`Hi @${author}, thanks for opening a pull request! :tada:`,
|
||||
``,
|
||||
`It looks like this PR was opened from the \`${defaultBranch}\` branch of your fork (\`${headRepo}\`), which is the same name as this repository's default branch. Working directly on \`${defaultBranch}\` in your fork causes a few problems:`,
|
||||
``,
|
||||
`- Your fork's \`${defaultBranch}\` branch will permanently diverge from \`esphome/esphome:${defaultBranch}\`, making it hard to keep your fork up to date.`,
|
||||
`- Any additional commits you push to \`${defaultBranch}\` will be added to this PR, so you can't easily work on multiple changes at once.`,
|
||||
`- Pushing maintainer fixes to your branch is awkward, since it means committing directly to your fork's default branch.`,
|
||||
`- It makes local collaboration painful — \`${defaultBranch}\` in a checkout becomes ambiguous between upstream and your fork, and maintainers end up with naming collisions when fetching your branch.`,
|
||||
``,
|
||||
`Please re-open this as a new PR from a dedicated feature branch. The usual flow looks like:`,
|
||||
``,
|
||||
`\`\`\`bash`,
|
||||
`# Make sure your fork's ${defaultBranch} is up to date with upstream`,
|
||||
`git remote add upstream https://github.com/${owner}/${repo}.git # if you haven't already`,
|
||||
`git fetch upstream`,
|
||||
`git checkout ${defaultBranch}`,
|
||||
`git reset --hard upstream/${defaultBranch}`,
|
||||
`git push --force-with-lease origin ${defaultBranch}`,
|
||||
``,
|
||||
`# Create a new branch for your change and cherry-pick / re-apply your commits there`,
|
||||
`git checkout -b my-feature-branch upstream/${defaultBranch}`,
|
||||
`# ...re-apply your changes, then:`,
|
||||
`git push origin my-feature-branch`,
|
||||
`\`\`\``,
|
||||
``,
|
||||
`Then open a new pull request from \`my-feature-branch\` into \`${owner}/${repo}:${defaultBranch}\`.`,
|
||||
``,
|
||||
`Closing this PR for now — sorry for the friction, and thanks again for contributing! :heart:`,
|
||||
].join('\n');
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body,
|
||||
});
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
state: 'closed',
|
||||
});
|
||||
+4
-1
@@ -56,6 +56,7 @@ esphome/components/audio_adc/* @kbx81
|
||||
esphome/components/audio_dac/* @kbx81
|
||||
esphome/components/audio_file/* @kahrendt
|
||||
esphome/components/audio_file/media_source/* @kahrendt
|
||||
esphome/components/audio_http/* @kahrendt
|
||||
esphome/components/axs15231/* @clydebarrow
|
||||
esphome/components/b_parasite/* @rbaron
|
||||
esphome/components/ballu/* @bazuchan
|
||||
@@ -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,7 @@ 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/sensirion_common/* @martgras
|
||||
esphome/components/sensor/* @esphome/core
|
||||
esphome/components/serial_proxy/* @kbx81
|
||||
@@ -599,6 +602,6 @@ esphome/components/xxtea/* @clydebarrow
|
||||
esphome/components/zephyr/* @tomaszduda23
|
||||
esphome/components/zephyr_mcumgr/ota/* @tomaszduda23
|
||||
esphome/components/zhlt01/* @cfeenstra1024
|
||||
esphome/components/zigbee/* @tomaszduda23
|
||||
esphome/components/zigbee/* @luar123 @tomaszduda23
|
||||
esphome/components/zio_ultrasonic/* @kahrendt
|
||||
esphome/components/zwave_proxy/* @kbx81
|
||||
|
||||
+108
-12
@@ -39,6 +39,7 @@ from esphome.const import (
|
||||
CONF_MDNS,
|
||||
CONF_MQTT,
|
||||
CONF_NAME,
|
||||
CONF_NAME_ADD_MAC_SUFFIX,
|
||||
CONF_OTA,
|
||||
CONF_PASSWORD,
|
||||
CONF_PLATFORM,
|
||||
@@ -71,6 +72,7 @@ from esphome.util import (
|
||||
run_external_process,
|
||||
safe_print,
|
||||
)
|
||||
from esphome.zeroconf import discover_mdns_devices
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -204,6 +206,64 @@ def _resolve_with_cache(address: str, purpose: Purpose) -> list[str]:
|
||||
return [address]
|
||||
|
||||
|
||||
def _populate_mdns_cache(hosts_to_addresses: dict[str, list[str]]) -> None:
|
||||
"""Store discovered ``host -> [ips]`` entries in ``CORE.address_cache``.
|
||||
|
||||
Ensures ``CORE.address_cache`` exists, then records each mDNS hostname so
|
||||
the downstream resolution path (``resolve_ip_address``) can skip opening a
|
||||
second Zeroconf client.
|
||||
"""
|
||||
from esphome.address_cache import AddressCache
|
||||
|
||||
if CORE.address_cache is None:
|
||||
CORE.address_cache = AddressCache()
|
||||
for host, addresses in hosts_to_addresses.items():
|
||||
if addresses:
|
||||
_LOGGER.debug("Caching mDNS result %s -> %s", host, addresses)
|
||||
CORE.address_cache.add_mdns_addresses(host, addresses)
|
||||
|
||||
|
||||
def _discover_mac_suffix_devices() -> list[str] | None:
|
||||
"""Discover ``<name>-<mac>.local`` devices and cache their IPs.
|
||||
|
||||
Returns:
|
||||
- ``None`` when discovery isn't applicable (``name_add_mac_suffix`` off,
|
||||
mDNS disabled, or ``CORE.address`` is already an IP). Callers should
|
||||
then fall back to whatever default OTA address they normally use.
|
||||
- ``[]`` when discovery ran but found nothing. Callers should NOT fall
|
||||
back to the base name: with ``name_add_mac_suffix`` enabled, the base
|
||||
name by definition doesn't exist on the network.
|
||||
- A non-empty sorted list of ``.local`` hostnames on success.
|
||||
|
||||
Populates ``CORE.address_cache`` so downstream resolution (``espota2`` or
|
||||
``aioesphomeapi`` via :func:`_resolve_network_devices`) reuses the IPs we
|
||||
already have without opening a second Zeroconf client.
|
||||
"""
|
||||
if not (has_name_add_mac_suffix() and has_mdns() and has_non_ip_address()):
|
||||
return None
|
||||
_LOGGER.info("Discovering devices...")
|
||||
if not (discovered := discover_mdns_devices(CORE.name)):
|
||||
_LOGGER.warning(
|
||||
"No devices matching '%s-<mac>.local' were discovered.", CORE.name
|
||||
)
|
||||
return []
|
||||
_populate_mdns_cache(discovered)
|
||||
return list(discovered)
|
||||
|
||||
|
||||
def _ota_hostnames_for_default(purpose: Purpose) -> list[str]:
|
||||
"""Return OTA hostname(s) for the ``--device OTA`` / default-resolve path.
|
||||
|
||||
When ``name_add_mac_suffix`` is enabled, returns discovered
|
||||
``<name>-<mac>.local`` hostnames (possibly empty — in which case the
|
||||
caller should not fall back to the base name). Otherwise falls back to
|
||||
the cache-resolved ``CORE.address``.
|
||||
"""
|
||||
if (discovered := _discover_mac_suffix_devices()) is not None:
|
||||
return discovered
|
||||
return _resolve_with_cache(CORE.address, purpose)
|
||||
|
||||
|
||||
def choose_upload_log_host(
|
||||
default: list[str] | str | None,
|
||||
check_default: str | None,
|
||||
@@ -242,14 +302,14 @@ def choose_upload_log_host(
|
||||
resolved.append("MQTT")
|
||||
|
||||
if has_api() and has_non_ip_address() and has_resolvable_address():
|
||||
resolved.extend(_resolve_with_cache(CORE.address, purpose))
|
||||
resolved.extend(_ota_hostnames_for_default(purpose))
|
||||
|
||||
elif purpose == Purpose.UPLOADING:
|
||||
if has_ota() and has_mqtt_ip_lookup():
|
||||
resolved.append("MQTTIP")
|
||||
|
||||
if has_ota() and has_non_ip_address() and has_resolvable_address():
|
||||
resolved.extend(_resolve_with_cache(CORE.address, purpose))
|
||||
resolved.extend(_ota_hostnames_for_default(purpose))
|
||||
else:
|
||||
resolved.append(device)
|
||||
if not resolved:
|
||||
@@ -281,22 +341,29 @@ def choose_upload_log_host(
|
||||
elif bootsel.permission_error:
|
||||
bootsel_permission_error = True
|
||||
|
||||
def add_ota_options() -> None:
|
||||
"""Add OTA options, using mDNS discovery if name_add_mac_suffix is enabled."""
|
||||
if (discovered := _discover_mac_suffix_devices()) is not None:
|
||||
# Discovery was applicable. Use whatever we found — on empty,
|
||||
# intentionally skip the base-name fallback since with
|
||||
# name_add_mac_suffix on, the base name doesn't exist on the net.
|
||||
for host in discovered:
|
||||
options.append((f"Over The Air ({host})", host))
|
||||
elif has_resolvable_address():
|
||||
options.append((f"Over The Air ({CORE.address})", CORE.address))
|
||||
if has_mqtt_ip_lookup():
|
||||
options.append(("Over The Air (MQTT IP lookup)", "MQTTIP"))
|
||||
|
||||
if purpose == Purpose.LOGGING:
|
||||
if has_mqtt_logging():
|
||||
mqtt_config = CORE.config[CONF_MQTT]
|
||||
options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT"))
|
||||
|
||||
if has_api():
|
||||
if has_resolvable_address():
|
||||
options.append((f"Over The Air ({CORE.address})", CORE.address))
|
||||
if has_mqtt_ip_lookup():
|
||||
options.append(("Over The Air (MQTT IP lookup)", "MQTTIP"))
|
||||
add_ota_options()
|
||||
|
||||
elif purpose == Purpose.UPLOADING and has_ota():
|
||||
if has_resolvable_address():
|
||||
options.append((f"Over The Air ({CORE.address})", CORE.address))
|
||||
if has_mqtt_ip_lookup():
|
||||
options.append(("Over The Air (MQTT IP lookup)", "MQTTIP"))
|
||||
add_ota_options()
|
||||
|
||||
# Show helpful BOOTSEL instructions for RP2040 when no BOOTSEL device is found
|
||||
if (
|
||||
@@ -407,7 +474,17 @@ def has_resolvable_address() -> bool:
|
||||
return not CORE.address.endswith(".local")
|
||||
|
||||
|
||||
def mqtt_get_ip(config: ConfigType, username: str, password: str, client_id: str):
|
||||
def has_name_add_mac_suffix() -> bool:
|
||||
"""Check if name_add_mac_suffix is enabled in the config."""
|
||||
if CORE.config is None:
|
||||
return False
|
||||
esphome_config = CORE.config.get(CONF_ESPHOME, {})
|
||||
return esphome_config.get(CONF_NAME_ADD_MAC_SUFFIX, False)
|
||||
|
||||
|
||||
def mqtt_get_ip(
|
||||
config: ConfigType, username: str, password: str, client_id: str
|
||||
) -> list[str]:
|
||||
from esphome import mqtt
|
||||
|
||||
return mqtt.get_esphome_device_ip(config, username, password, client_id)
|
||||
@@ -420,6 +497,9 @@ def _resolve_network_devices(
|
||||
|
||||
This function filters the devices list to:
|
||||
- Replace MQTT/MQTTIP magic strings with actual IP addresses via MQTT lookup
|
||||
- Expand hostnames that are already in ``CORE.address_cache`` to their
|
||||
cached IPs so downstream code (e.g. aioesphomeapi) doesn't open a second
|
||||
Zeroconf client to resolve them
|
||||
- Deduplicate addresses while preserving order
|
||||
- Only resolve MQTT once even if multiple MQTT strings are present
|
||||
- If MQTT resolution fails, log a warning and continue with other devices
|
||||
@@ -444,13 +524,29 @@ def _resolve_network_devices(
|
||||
mqtt_ips = mqtt_get_ip(
|
||||
config, args.username, args.password, args.client_id
|
||||
)
|
||||
network_devices.extend(mqtt_ips)
|
||||
# pylint can't infer mqtt_get_ip's return through its
|
||||
# lazy ``from esphome import mqtt`` import, so it flags
|
||||
# the genexpr below.
|
||||
network_devices.extend(
|
||||
addr
|
||||
for addr in mqtt_ips # pylint: disable=not-an-iterable
|
||||
if addr not in network_devices
|
||||
)
|
||||
except EsphomeError as err:
|
||||
_LOGGER.warning(
|
||||
"MQTT IP discovery failed (%s), will try other devices if available",
|
||||
err,
|
||||
)
|
||||
mqtt_resolved = True
|
||||
continue
|
||||
|
||||
# If the hostname is already in the address cache (e.g. populated by
|
||||
# mDNS discovery), substitute the cached IPs so aioesphomeapi doesn't
|
||||
# open its own Zeroconf to re-resolve it.
|
||||
if CORE.address_cache and (cached := CORE.address_cache.get_addresses(device)):
|
||||
network_devices.extend(
|
||||
addr for addr in cached if addr not in network_devices
|
||||
)
|
||||
elif device not in network_devices:
|
||||
# Regular network address or IP - add if not already present
|
||||
network_devices.append(device)
|
||||
|
||||
@@ -101,6 +101,17 @@ class AddressCache:
|
||||
"""Check if any cache entries exist."""
|
||||
return bool(self.mdns_cache or self.dns_cache)
|
||||
|
||||
def add_mdns_addresses(self, hostname: str, addresses: list[str]) -> None:
|
||||
"""Store resolved mDNS addresses for ``hostname`` in the cache.
|
||||
|
||||
Callers that discover ``.local`` hosts (e.g. via mDNS browse) can use
|
||||
this to avoid a second resolution round-trip during the upload path.
|
||||
No-op when ``addresses`` is empty.
|
||||
"""
|
||||
if not addresses:
|
||||
return
|
||||
self.mdns_cache[normalize_hostname(hostname)] = addresses
|
||||
|
||||
@classmethod
|
||||
def from_cli_args(
|
||||
cls, mdns_args: Iterable[str], dns_args: Iterable[str]
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Helpers for running an async coroutine from sync code via a daemon thread.
|
||||
|
||||
``asyncio.run(coro())`` in the main thread blocks until the loop's cleanup
|
||||
cycle finishes, which can add hundreds of milliseconds before the caller
|
||||
receives the result. Running the loop in a daemon thread lets the caller
|
||||
observe the result as soon as the coroutine completes while cleanup finishes
|
||||
in the background.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
import threading
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
class AsyncThreadRunner(threading.Thread, Generic[_T]):
|
||||
"""Run an async coroutine in a daemon thread and expose its result.
|
||||
|
||||
The runner catches all exceptions from the coroutine and stores them in
|
||||
``exception`` so ``event`` is always set — this prevents callers waiting
|
||||
on ``event`` from hanging forever when the coroutine crashes.
|
||||
|
||||
Typical usage::
|
||||
|
||||
runner = AsyncThreadRunner(lambda: my_coro(arg))
|
||||
runner.start()
|
||||
if not runner.event.wait(timeout=5.0):
|
||||
... # timed out
|
||||
if runner.exception is not None:
|
||||
raise runner.exception
|
||||
result = runner.result
|
||||
"""
|
||||
|
||||
def __init__(self, coro_factory: Callable[[], Awaitable[_T]]) -> None:
|
||||
super().__init__(daemon=True)
|
||||
self._coro_factory = coro_factory
|
||||
self.result: _T | None = None
|
||||
self.exception: BaseException | None = None
|
||||
self.event = threading.Event()
|
||||
|
||||
async def _runner(self) -> None:
|
||||
try:
|
||||
self.result = await self._coro_factory()
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
# Capture all exceptions so ``event`` is always set — otherwise a
|
||||
# crash would hang the waiter forever.
|
||||
self.exception = exc
|
||||
finally:
|
||||
self.event.set()
|
||||
|
||||
def run(self) -> None:
|
||||
asyncio.run(self._runner())
|
||||
@@ -190,7 +190,7 @@ void AcDimmer::setup() {
|
||||
this->zero_cross_pin_->setup();
|
||||
this->store_.zero_cross_pin = this->zero_cross_pin_->to_isr();
|
||||
this->zero_cross_pin_->attach_interrupt(&AcDimmerDataStore::s_gpio_intr, &this->store_,
|
||||
gpio::INTERRUPT_FALLING_EDGE);
|
||||
this->zero_cross_interrupt_type_);
|
||||
}
|
||||
|
||||
#ifdef USE_ESP8266
|
||||
@@ -226,19 +226,25 @@ void AcDimmer::write_state(float state) {
|
||||
void AcDimmer::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"AcDimmer:\n"
|
||||
" Min Power: %.1f%%\n"
|
||||
" Init with half cycle: %s",
|
||||
" Min Power: %.1f%%\n"
|
||||
" Init with half cycle: %s",
|
||||
this->store_.min_power / 10.0f, YESNO(this->init_with_half_cycle_));
|
||||
LOG_PIN(" Output Pin: ", this->gate_pin_);
|
||||
LOG_PIN(" Zero-Cross Pin: ", this->zero_cross_pin_);
|
||||
if (method_ == DIM_METHOD_LEADING_PULSE) {
|
||||
ESP_LOGCONFIG(TAG, " Method: leading pulse");
|
||||
} else if (method_ == DIM_METHOD_LEADING) {
|
||||
ESP_LOGCONFIG(TAG, " Method: leading");
|
||||
if (this->zero_cross_interrupt_type_ == gpio::INTERRUPT_RISING_EDGE) {
|
||||
ESP_LOGCONFIG(TAG, " Interrupt Type: rising");
|
||||
} else if (this->zero_cross_interrupt_type_ == gpio::INTERRUPT_FALLING_EDGE) {
|
||||
ESP_LOGCONFIG(TAG, " Interrupt Type: falling");
|
||||
} else {
|
||||
ESP_LOGCONFIG(TAG, " Method: trailing");
|
||||
ESP_LOGCONFIG(TAG, " Interrupt Type: any");
|
||||
}
|
||||
if (method_ == DIM_METHOD_LEADING_PULSE) {
|
||||
ESP_LOGCONFIG(TAG, " Method: leading pulse");
|
||||
} else if (method_ == DIM_METHOD_LEADING) {
|
||||
ESP_LOGCONFIG(TAG, " Method: leading");
|
||||
} else {
|
||||
ESP_LOGCONFIG(TAG, " Method: trailing");
|
||||
}
|
||||
|
||||
LOG_FLOAT_OUTPUT(this);
|
||||
ESP_LOGV(TAG, " Estimated Frequency: %.3fHz", 1e6f / this->store_.cycle_time_us / 2);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ class AcDimmer : public output::FloatOutput, public Component {
|
||||
void dump_config() override;
|
||||
void set_gate_pin(InternalGPIOPin *gate_pin) { gate_pin_ = gate_pin; }
|
||||
void set_zero_cross_pin(InternalGPIOPin *zero_cross_pin) { zero_cross_pin_ = zero_cross_pin; }
|
||||
void set_zero_cross_interrupt_type(gpio::InterruptType type) { zero_cross_interrupt_type_ = type; }
|
||||
void set_init_with_half_cycle(bool init_with_half_cycle) { init_with_half_cycle_ = init_with_half_cycle; }
|
||||
void set_method(DimMethod method) { method_ = method; }
|
||||
|
||||
@@ -56,6 +57,7 @@ class AcDimmer : public output::FloatOutput, public Component {
|
||||
|
||||
InternalGPIOPin *gate_pin_;
|
||||
InternalGPIOPin *zero_cross_pin_;
|
||||
gpio::InterruptType zero_cross_interrupt_type_;
|
||||
AcDimmerDataStore store_;
|
||||
bool init_with_half_cycle_;
|
||||
DimMethod method_;
|
||||
|
||||
@@ -7,6 +7,8 @@ from esphome.core import CORE
|
||||
|
||||
CODEOWNERS = ["@glmnet"]
|
||||
|
||||
gpio_ns = cg.esphome_ns.namespace("gpio")
|
||||
|
||||
ac_dimmer_ns = cg.esphome_ns.namespace("ac_dimmer")
|
||||
AcDimmer = ac_dimmer_ns.class_("AcDimmer", output.FloatOutput, cg.Component)
|
||||
|
||||
@@ -17,15 +19,26 @@ DIM_METHODS = {
|
||||
"TRAILING": DimMethod.DIM_METHOD_TRAILING,
|
||||
}
|
||||
|
||||
ZC_INTERRUPT_TYPES = {
|
||||
"RISING": gpio_ns.INTERRUPT_RISING_EDGE,
|
||||
"FALLING": gpio_ns.INTERRUPT_FALLING_EDGE,
|
||||
"ANY": gpio_ns.INTERRUPT_ANY_EDGE,
|
||||
}
|
||||
|
||||
CONF_GATE_PIN = "gate_pin"
|
||||
CONF_ZERO_CROSS_PIN = "zero_cross_pin"
|
||||
CONF_INIT_WITH_HALF_CYCLE = "init_with_half_cycle"
|
||||
CONF_ZERO_CROSS_INTERRUPT_TYPE = "zero_cross_interrupt_type"
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
output.FLOAT_OUTPUT_SCHEMA.extend(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.declare_id(AcDimmer),
|
||||
cv.Required(CONF_GATE_PIN): pins.internal_gpio_output_pin_schema,
|
||||
cv.Required(CONF_ZERO_CROSS_PIN): pins.internal_gpio_input_pin_schema,
|
||||
cv.Optional(CONF_ZERO_CROSS_INTERRUPT_TYPE, default="FALLING"): cv.enum(
|
||||
ZC_INTERRUPT_TYPES, upper=True, space="_"
|
||||
),
|
||||
cv.Optional(CONF_INIT_WITH_HALF_CYCLE, default=True): cv.boolean,
|
||||
cv.Optional(CONF_METHOD, default="leading pulse"): cv.enum(
|
||||
DIM_METHODS, upper=True, space="_"
|
||||
@@ -54,5 +67,6 @@ async def to_code(config):
|
||||
cg.add(var.set_gate_pin(pin))
|
||||
pin = await cg.gpio_pin_expression(config[CONF_ZERO_CROSS_PIN])
|
||||
cg.add(var.set_zero_cross_pin(pin))
|
||||
cg.add(var.set_zero_cross_interrupt_type(config[CONF_ZERO_CROSS_INTERRUPT_TYPE]))
|
||||
cg.add(var.set_init_with_half_cycle(config[CONF_INIT_WITH_HALF_CYCLE]))
|
||||
cg.add(var.set_method(config[CONF_METHOD]))
|
||||
|
||||
@@ -2544,27 +2544,50 @@ message ListEntitiesInfraredResponse {
|
||||
message InfraredRFTransmitRawTimingsRequest {
|
||||
option (id) = 136;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_IR_RF";
|
||||
option (ifdef) = "USE_IR_RF || USE_RADIO_FREQUENCY";
|
||||
|
||||
uint32 device_id = 1 [(field_ifdef) = "USE_DEVICES"];
|
||||
fixed32 key = 2 [(force) = true]; // Key identifying the transmitter instance
|
||||
uint32 carrier_frequency = 3; // Carrier frequency in Hz
|
||||
uint32 repeat_count = 4; // Number of times to transmit (1 = once, 2 = twice, etc.)
|
||||
fixed32 key = 2 [(force) = true]; // Key identifying the transmitter instance
|
||||
uint32 carrier_frequency = 3; // Carrier frequency in Hz
|
||||
uint32 repeat_count = 4; // Number of times to transmit (1 = once, 2 = twice, etc.)
|
||||
repeated sint32 timings = 5 [packed = true, (packed_buffer) = true]; // Raw timings in microseconds (zigzag-encoded): positive = mark (LED/TX on), negative = space (LED/TX off)
|
||||
uint32 modulation = 6; // RadioFrequencyModulation enum value (0 = OOK; ignored for IR entities)
|
||||
}
|
||||
|
||||
// Event message for received infrared/RF data
|
||||
message InfraredRFReceiveEvent {
|
||||
option (id) = 137;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_IR_RF";
|
||||
option (ifdef) = "USE_IR_RF || USE_RADIO_FREQUENCY";
|
||||
option (no_delay) = true;
|
||||
|
||||
uint32 device_id = 1 [(field_ifdef) = "USE_DEVICES"];
|
||||
fixed32 key = 2 [(force) = true]; // Key identifying the receiver instance
|
||||
fixed32 key = 2 [(force) = true]; // Key identifying the receiver instance
|
||||
repeated sint32 timings = 3 [packed = true, (container_pointer_no_template) = "std::vector<int32_t>"]; // Raw timings in microseconds (zigzag-encoded): alternating mark/space periods
|
||||
}
|
||||
|
||||
// ==================== RADIO FREQUENCY ====================
|
||||
|
||||
// Lists available radio frequency entity instances
|
||||
message ListEntitiesRadioFrequencyResponse {
|
||||
option (id) = 148;
|
||||
option (base_class) = "InfoResponseProtoMessage";
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_RADIO_FREQUENCY";
|
||||
|
||||
string object_id = 1 [(max_data_length) = 120, (force) = true];
|
||||
fixed32 key = 2 [(force) = true];
|
||||
string name = 3 [(max_data_length) = 120, (force) = true];
|
||||
string icon = 4 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63];
|
||||
bool disabled_by_default = 5;
|
||||
EntityCategory entity_category = 6;
|
||||
uint32 device_id = 7 [(field_ifdef) = "USE_DEVICES"];
|
||||
uint32 capabilities = 8; // Bitmask of RadioFrequencyCapabilityFlags: bit 0 = transmitter, bit 1 = receiver
|
||||
uint32 frequency_min = 9; // Minimum tunable frequency in Hz; if min == max (non-zero): fixed frequency; 0 = unspecified
|
||||
uint32 frequency_max = 10; // Maximum tunable frequency in Hz; 0 = unspecified
|
||||
uint32 supported_modulations = 11; // Bitmask of supported RadioFrequencyModulation values (bit N = modulation N supported)
|
||||
}
|
||||
|
||||
// ==================== SERIAL PROXY ====================
|
||||
|
||||
enum SerialProxyParity {
|
||||
|
||||
@@ -49,6 +49,9 @@
|
||||
#ifdef USE_INFRARED
|
||||
#include "esphome/components/infrared/infrared.h"
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
#include "esphome/components/radio_frequency/radio_frequency.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
@@ -100,6 +103,12 @@ static const int CAMERA_STOP_STREAM = 5000;
|
||||
entity_type *entity_var = App.get_##getter_name##_by_key(msg.key, msg.device_id); \
|
||||
if ((entity_var) == nullptr) \
|
||||
return;
|
||||
|
||||
// Helper macro for multi-entity dispatch: looks up an entity by key and device_id without early return or make_call().
|
||||
// Use when multiple entity types must be checked in sequence (at most one will match).
|
||||
#define ENTITY_COMMAND_LOOKUP(entity_type, entity_var, getter_name) \
|
||||
entity_type *entity_var = App.get_##getter_name##_by_key(msg.key, msg.device_id)
|
||||
|
||||
#else // No device support, use simpler macros
|
||||
// Helper macro for entity command handlers - gets entity by key, returns if not found, and creates call
|
||||
// object
|
||||
@@ -115,6 +124,12 @@ static const int CAMERA_STOP_STREAM = 5000;
|
||||
entity_type *entity_var = App.get_##getter_name##_by_key(msg.key); \
|
||||
if ((entity_var) == nullptr) \
|
||||
return;
|
||||
|
||||
// Helper macro for multi-entity dispatch: looks up an entity by key without early return or make_call().
|
||||
// Use when multiple entity types must be checked in sequence (at most one will match).
|
||||
#define ENTITY_COMMAND_LOOKUP(entity_type, entity_var, getter_name) \
|
||||
entity_type *entity_var = App.get_##getter_name##_by_key(msg.key)
|
||||
|
||||
#endif // USE_DEVICES
|
||||
|
||||
APIConnection::APIConnection(std::unique_ptr<socket::Socket> sock, APIServer *parent) : parent_(parent) {
|
||||
@@ -1471,19 +1486,36 @@ uint16_t APIConnection::try_send_event_info(EntityBase *entity, APIConnection *c
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_IR_RF
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg) {
|
||||
// TODO: When RF is implemented, add a field to the message to distinguish IR vs RF
|
||||
// and dispatch to the appropriate entity type based on that field.
|
||||
// Dispatch by key: infrared entities are checked first, then radio frequency entities.
|
||||
// The key is unique across all entity instances on a device, so at most one lookup will succeed.
|
||||
#ifdef USE_INFRARED
|
||||
ENTITY_COMMAND_MAKE_CALL(infrared::Infrared, infrared, infrared)
|
||||
call.set_carrier_frequency(msg.carrier_frequency);
|
||||
call.set_raw_timings_packed(msg.timings_data_, msg.timings_length_, msg.timings_count_);
|
||||
call.set_repeat_count(msg.repeat_count);
|
||||
call.perform();
|
||||
ENTITY_COMMAND_LOOKUP(infrared::Infrared, infrared, infrared);
|
||||
if (infrared != nullptr) {
|
||||
auto call = infrared->make_call();
|
||||
call.set_carrier_frequency(msg.carrier_frequency);
|
||||
call.set_raw_timings_packed(msg.timings_data_, msg.timings_length_, msg.timings_count_);
|
||||
call.set_repeat_count(msg.repeat_count);
|
||||
call.perform();
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
ENTITY_COMMAND_LOOKUP(radio_frequency::RadioFrequency, radio_frequency, radio_frequency);
|
||||
if (radio_frequency != nullptr) {
|
||||
auto call = radio_frequency->make_call();
|
||||
call.set_frequency(msg.carrier_frequency);
|
||||
call.set_modulation(static_cast<radio_frequency::RadioFrequencyModulation>(msg.modulation));
|
||||
call.set_repeat_count(msg.repeat_count);
|
||||
call.set_raw_timings_packed(msg.timings_data_, msg.timings_length_, msg.timings_count_);
|
||||
call.perform();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) { this->send_message(msg); }
|
||||
#endif
|
||||
|
||||
@@ -1580,6 +1612,19 @@ uint16_t APIConnection::try_send_infrared_info(EntityBase *entity, APIConnection
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
uint16_t APIConnection::try_send_radio_frequency_info(EntityBase *entity, APIConnection *conn,
|
||||
uint32_t remaining_size) {
|
||||
auto *rf = static_cast<radio_frequency::RadioFrequency *>(entity);
|
||||
ListEntitiesRadioFrequencyResponse msg;
|
||||
msg.capabilities = rf->get_capability_flags();
|
||||
msg.frequency_min = rf->get_traits().get_frequency_min_hz();
|
||||
msg.frequency_max = rf->get_traits().get_frequency_max_hz();
|
||||
msg.supported_modulations = rf->get_traits().get_supported_modulations();
|
||||
return fill_and_encode_entity_info(rf, msg, conn, remaining_size);
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_UPDATE
|
||||
bool APIConnection::send_update_state(update::UpdateEntity *update) {
|
||||
return this->send_message_smart_(update, UpdateStateResponse::MESSAGE_TYPE, UpdateStateResponse::ESTIMATED_SIZE);
|
||||
@@ -2341,6 +2386,9 @@ uint16_t APIConnection::dispatch_message_(const DeferredBatch::BatchItem &item,
|
||||
#ifdef USE_INFRARED
|
||||
CASE_INFO_ONLY(infrared, ListEntitiesInfraredResponse)
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
CASE_INFO_ONLY(radio_frequency, ListEntitiesRadioFrequencyResponse)
|
||||
#endif
|
||||
#ifdef USE_EVENT
|
||||
CASE_INFO_ONLY(event, ListEntitiesEventResponse)
|
||||
#endif
|
||||
|
||||
@@ -223,7 +223,7 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
void on_water_heater_command_request(const WaterHeaterCommandRequest &msg);
|
||||
#endif
|
||||
|
||||
#ifdef USE_IR_RF
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg);
|
||||
void send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg);
|
||||
#endif
|
||||
@@ -612,6 +612,9 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
#ifdef USE_INFRARED
|
||||
static uint16_t try_send_infrared_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size);
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
static uint16_t try_send_radio_frequency_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size);
|
||||
#endif
|
||||
#ifdef USE_EVENT
|
||||
static uint16_t try_send_event_response(event::Event *event, StringRef event_type, APIConnection *conn,
|
||||
uint32_t remaining_size);
|
||||
|
||||
@@ -3861,7 +3861,7 @@ uint32_t ListEntitiesInfraredResponse::calculate_size() const {
|
||||
return size;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_IR_RF
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
bool InfraredRFTransmitRawTimingsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
|
||||
switch (field_id) {
|
||||
#ifdef USE_DEVICES
|
||||
@@ -3875,6 +3875,9 @@ bool InfraredRFTransmitRawTimingsRequest::decode_varint(uint32_t field_id, proto
|
||||
case 4:
|
||||
this->repeat_count = value;
|
||||
break;
|
||||
case 6:
|
||||
this->modulation = value;
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@@ -3928,6 +3931,46 @@ uint32_t InfraredRFReceiveEvent::calculate_size() const {
|
||||
return size;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
uint8_t *ListEntitiesRadioFrequencyResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
uint8_t *__restrict__ pos = buffer.get_pos();
|
||||
ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id);
|
||||
ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key);
|
||||
ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name);
|
||||
#ifdef USE_ENTITY_ICON
|
||||
ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->icon);
|
||||
#endif
|
||||
ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->disabled_by_default);
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, static_cast<uint32_t>(this->entity_category));
|
||||
#ifdef USE_DEVICES
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, this->device_id);
|
||||
#endif
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->capabilities);
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->frequency_min);
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->frequency_max);
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, this->supported_modulations);
|
||||
return pos;
|
||||
}
|
||||
uint32_t ListEntitiesRadioFrequencyResponse::calculate_size() const {
|
||||
uint32_t size = 0;
|
||||
size += 2 + this->object_id.size();
|
||||
size += 5;
|
||||
size += 2 + this->name.size();
|
||||
#ifdef USE_ENTITY_ICON
|
||||
size += !this->icon.empty() ? 2 + this->icon.size() : 0;
|
||||
#endif
|
||||
size += ProtoSize::calc_bool(1, this->disabled_by_default);
|
||||
size += this->entity_category ? 2 : 0;
|
||||
#ifdef USE_DEVICES
|
||||
size += ProtoSize::calc_uint32(1, this->device_id);
|
||||
#endif
|
||||
size += ProtoSize::calc_uint32(1, this->capabilities);
|
||||
size += ProtoSize::calc_uint32(1, this->frequency_min);
|
||||
size += ProtoSize::calc_uint32(1, this->frequency_max);
|
||||
size += ProtoSize::calc_uint32(1, this->supported_modulations);
|
||||
return size;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
bool SerialProxyConfigureRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
|
||||
switch (field_id) {
|
||||
|
||||
@@ -3054,11 +3054,11 @@ class ListEntitiesInfraredResponse final : public InfoResponseProtoMessage {
|
||||
protected:
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_IR_RF
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
class InfraredRFTransmitRawTimingsRequest final : public ProtoDecodableMessage {
|
||||
public:
|
||||
static constexpr uint8_t MESSAGE_TYPE = 136;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 220;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 224;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const LogString *message_name() const override { return LOG_STR("infrared_rf_transmit_raw_timings_request"); }
|
||||
#endif
|
||||
@@ -3071,6 +3071,7 @@ class InfraredRFTransmitRawTimingsRequest final : public ProtoDecodableMessage {
|
||||
const uint8_t *timings_data_{nullptr};
|
||||
uint16_t timings_length_{0};
|
||||
uint16_t timings_count_{0};
|
||||
uint32_t modulation{0};
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const char *dump_to(DumpBuffer &out) const override;
|
||||
#endif
|
||||
@@ -3101,6 +3102,27 @@ class InfraredRFReceiveEvent final : public ProtoMessage {
|
||||
protected:
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
class ListEntitiesRadioFrequencyResponse final : public InfoResponseProtoMessage {
|
||||
public:
|
||||
static constexpr uint8_t MESSAGE_TYPE = 148;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 56;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const LogString *message_name() const override { return LOG_STR("list_entities_radio_frequency_response"); }
|
||||
#endif
|
||||
uint32_t capabilities{0};
|
||||
uint32_t frequency_min{0};
|
||||
uint32_t frequency_max{0};
|
||||
uint32_t supported_modulations{0};
|
||||
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
|
||||
uint32_t calculate_size() const;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const char *dump_to(DumpBuffer &out) const override;
|
||||
#endif
|
||||
|
||||
protected:
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
class SerialProxyConfigureRequest final : public ProtoDecodableMessage {
|
||||
public:
|
||||
|
||||
@@ -2576,7 +2576,7 @@ const char *ListEntitiesInfraredResponse::dump_to(DumpBuffer &out) const {
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_IR_RF
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
const char *InfraredRFTransmitRawTimingsRequest::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("InfraredRFTransmitRawTimingsRequest"));
|
||||
#ifdef USE_DEVICES
|
||||
@@ -2591,6 +2591,7 @@ const char *InfraredRFTransmitRawTimingsRequest::dump_to(DumpBuffer &out) const
|
||||
out.append_p(ESPHOME_PSTR(" values, "));
|
||||
append_uint(out, this->timings_length_);
|
||||
out.append_p(ESPHOME_PSTR(" bytes]\n"));
|
||||
dump_field(out, ESPHOME_PSTR("modulation"), this->modulation);
|
||||
return out.c_str();
|
||||
}
|
||||
const char *InfraredRFReceiveEvent::dump_to(DumpBuffer &out) const {
|
||||
@@ -2605,6 +2606,27 @@ const char *InfraredRFReceiveEvent::dump_to(DumpBuffer &out) const {
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
const char *ListEntitiesRadioFrequencyResponse::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesRadioFrequencyResponse"));
|
||||
dump_field(out, ESPHOME_PSTR("object_id"), this->object_id);
|
||||
dump_field(out, ESPHOME_PSTR("key"), this->key);
|
||||
dump_field(out, ESPHOME_PSTR("name"), this->name);
|
||||
#ifdef USE_ENTITY_ICON
|
||||
dump_field(out, ESPHOME_PSTR("icon"), this->icon);
|
||||
#endif
|
||||
dump_field(out, ESPHOME_PSTR("disabled_by_default"), this->disabled_by_default);
|
||||
dump_field(out, ESPHOME_PSTR("entity_category"), static_cast<enums::EntityCategory>(this->entity_category));
|
||||
#ifdef USE_DEVICES
|
||||
dump_field(out, ESPHOME_PSTR("device_id"), this->device_id);
|
||||
#endif
|
||||
dump_field(out, ESPHOME_PSTR("capabilities"), this->capabilities);
|
||||
dump_field(out, ESPHOME_PSTR("frequency_min"), this->frequency_min);
|
||||
dump_field(out, ESPHOME_PSTR("frequency_max"), this->frequency_max);
|
||||
dump_field(out, ESPHOME_PSTR("supported_modulations"), this->supported_modulations);
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
const char *SerialProxyConfigureRequest::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxyConfigureRequest"));
|
||||
|
||||
@@ -625,7 +625,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_IR_RF
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
case InfraredRFTransmitRawTimingsRequest::MESSAGE_TYPE: {
|
||||
InfraredRFTransmitRawTimingsRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
|
||||
@@ -211,7 +211,7 @@ class APIServerConnectionBase {
|
||||
void on_z_wave_proxy_request(const ZWaveProxyRequest &value){};
|
||||
#endif
|
||||
|
||||
#ifdef USE_IR_RF
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &value){};
|
||||
#endif
|
||||
|
||||
|
||||
@@ -368,7 +368,7 @@ void APIServer::on_zwave_proxy_request(const ZWaveProxyRequest &msg) {
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_IR_RF
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_id, uint32_t key,
|
||||
const std::vector<int32_t> *timings) {
|
||||
InfraredRFReceiveEvent resp{};
|
||||
|
||||
@@ -183,7 +183,7 @@ class APIServer final : public Component,
|
||||
#ifdef USE_ZWAVE_PROXY
|
||||
void on_zwave_proxy_request(const ZWaveProxyRequest &msg);
|
||||
#endif
|
||||
#ifdef USE_IR_RF
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
void send_infrared_rf_receive_event(uint32_t device_id, uint32_t key, const std::vector<int32_t> *timings);
|
||||
#endif
|
||||
|
||||
|
||||
@@ -93,7 +93,24 @@ async def async_run_logs(
|
||||
config, raw_line, backtrace_state=backtrace_state
|
||||
)
|
||||
|
||||
stop = await async_run(cli, on_log, name=name, subscribe_states=subscribe_states)
|
||||
# Safe to fall back to plaintext here only for this diagnostics use
|
||||
# case: the stream is one-way from device to client, and this code
|
||||
# never accepts commands or acts on any message the device sends.
|
||||
# An on-path attacker could still both inject fabricated log lines
|
||||
# and passively read the device's log output (and any state data
|
||||
# delivered when subscribe_states is enabled), so this does lose
|
||||
# confidentiality as well as authentication/integrity. That tradeoff
|
||||
# is acceptable for operator-visible logs, which aioesphomeapi also
|
||||
# warns may come from an unverified device. Never mirror this opt-in
|
||||
# for any connection that sends data to the device or uses Home
|
||||
# Assistant actions.
|
||||
stop = await async_run(
|
||||
cli,
|
||||
on_log,
|
||||
name=name,
|
||||
subscribe_states=subscribe_states,
|
||||
allow_plaintext_fallback=True,
|
||||
)
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
|
||||
@@ -79,6 +79,9 @@ LIST_ENTITIES_HANDLER(water_heater, water_heater::WaterHeater, ListEntitiesWater
|
||||
#ifdef USE_INFRARED
|
||||
LIST_ENTITIES_HANDLER(infrared, infrared::Infrared, ListEntitiesInfraredResponse)
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
LIST_ENTITIES_HANDLER(radio_frequency, radio_frequency::RadioFrequency, ListEntitiesRadioFrequencyResponse)
|
||||
#endif
|
||||
#ifdef USE_EVENT
|
||||
LIST_ENTITIES_HANDLER(event, event::Event, ListEntitiesEventResponse)
|
||||
#endif
|
||||
|
||||
@@ -87,6 +87,9 @@ class ListEntitiesIterator final : public ComponentIterator {
|
||||
#ifdef USE_INFRARED
|
||||
bool on_infrared(infrared::Infrared *entity) override;
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
bool on_radio_frequency(radio_frequency::RadioFrequency *entity) override;
|
||||
#endif
|
||||
#ifdef USE_EVENT
|
||||
bool on_event(event::Event *entity) override;
|
||||
#endif
|
||||
|
||||
@@ -82,6 +82,9 @@ class InitialStateIterator final : public ComponentIterator {
|
||||
#ifdef USE_INFRARED
|
||||
bool on_infrared(infrared::Infrared *infrared) override { return true; };
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
bool on_radio_frequency(radio_frequency::RadioFrequency *radio_frequency) override { return true; };
|
||||
#endif
|
||||
#ifdef USE_EVENT
|
||||
bool on_event(event::Event *event) override { return true; };
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
#include "audio_http_media_source.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace esphome::audio_http {
|
||||
|
||||
static const char *const TAG = "audio_http_media_source";
|
||||
|
||||
// Decoder task / buffer tuning. Kept here as constants so the header stays free of magic numbers.
|
||||
static constexpr size_t DEFAULT_TRANSFER_BUFFER_SIZE = 8 * 1024; // Staging buffer between HTTP reader and decoder
|
||||
static constexpr uint32_t HTTP_TIMEOUT_MS = 5000; // HTTP connect/read timeout
|
||||
static constexpr uint32_t AUDIO_WRITE_TIMEOUT_MS = 50; // Max blocking time per on_audio_write() call
|
||||
static constexpr uint32_t READER_WRITE_TIMEOUT_MS = 50; // Max blocking time when writing into the ring buffer
|
||||
static constexpr uint8_t READER_TASK_PRIORITY = 2;
|
||||
static constexpr uint8_t DECODER_TASK_PRIORITY = 2;
|
||||
static constexpr size_t READER_TASK_STACK_SIZE = 4096;
|
||||
static constexpr size_t DECODER_TASK_STACK_SIZE = 5120;
|
||||
static constexpr uint32_t PAUSE_POLL_DELAY_MS = 20;
|
||||
static constexpr const char *const HTTP_URI_PREFIX = "http://";
|
||||
static constexpr const char *const HTTPS_URI_PREFIX = "https://";
|
||||
|
||||
void AudioHTTPMediaSource::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Audio HTTP Media Source:\n"
|
||||
" Buffer Size: %zu bytes\n"
|
||||
" Decoder Task Stack in PSRAM: %s",
|
||||
this->buffer_size_, YESNO(this->decoder_task_stack_in_psram_));
|
||||
}
|
||||
|
||||
void AudioHTTPMediaSource::setup() {
|
||||
this->disable_loop();
|
||||
|
||||
micro_decoder::DecoderConfig config;
|
||||
config.ring_buffer_size = this->buffer_size_;
|
||||
// Keep the transfer buffer smaller than the ring buffer so the reader can top up the ring
|
||||
// while the decoder is still draining it, instead of oscillating between empty and full.
|
||||
config.transfer_buffer_size = std::min(DEFAULT_TRANSFER_BUFFER_SIZE, this->buffer_size_ / 2);
|
||||
config.http_timeout_ms = HTTP_TIMEOUT_MS;
|
||||
config.audio_write_timeout_ms = AUDIO_WRITE_TIMEOUT_MS;
|
||||
config.reader_write_timeout_ms = READER_WRITE_TIMEOUT_MS;
|
||||
config.reader_priority = READER_TASK_PRIORITY;
|
||||
config.decoder_priority = DECODER_TASK_PRIORITY;
|
||||
config.reader_stack_size = READER_TASK_STACK_SIZE;
|
||||
config.decoder_stack_size = DECODER_TASK_STACK_SIZE;
|
||||
config.decoder_stack_in_psram = this->decoder_task_stack_in_psram_;
|
||||
|
||||
this->decoder_ = std::make_unique<micro_decoder::DecoderSource>(config);
|
||||
if (this->decoder_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Failed to allocate decoder");
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
this->decoder_->set_listener(this); // We inherit from micro_decoder::DecoderListener
|
||||
}
|
||||
|
||||
void AudioHTTPMediaSource::loop() { this->decoder_->loop(); }
|
||||
|
||||
bool AudioHTTPMediaSource::can_handle(const std::string &uri) const {
|
||||
return uri.starts_with(HTTP_URI_PREFIX) || uri.starts_with(HTTPS_URI_PREFIX);
|
||||
}
|
||||
|
||||
// Called from the orchestrator's main loop, so no synchronization needed with loop()
|
||||
bool AudioHTTPMediaSource::play_uri(const std::string &uri) {
|
||||
if (!this->is_ready() || this->is_failed() || this->status_has_error() || !this->has_listener()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if source is already playing
|
||||
if (this->get_state() != media_source::MediaSourceState::IDLE) {
|
||||
ESP_LOGE(TAG, "Cannot play '%s': source is busy", uri.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate URI starts with "http://" or "https://"
|
||||
if (!uri.starts_with(HTTP_URI_PREFIX) && !uri.starts_with(HTTPS_URI_PREFIX)) {
|
||||
ESP_LOGE(TAG, "Invalid URI: '%s'", uri.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this->decoder_->play_url(uri)) {
|
||||
this->pause_.store(false, std::memory_order_relaxed);
|
||||
this->enable_loop();
|
||||
return true;
|
||||
}
|
||||
|
||||
ESP_LOGE(TAG, "Failed to start playback of '%s'", uri.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Called from the orchestrator's main loop, so no synchronization needed with loop()
|
||||
void AudioHTTPMediaSource::handle_command(media_source::MediaSourceCommand command) {
|
||||
switch (command) {
|
||||
case media_source::MediaSourceCommand::STOP:
|
||||
this->decoder_->stop();
|
||||
break;
|
||||
case media_source::MediaSourceCommand::PAUSE:
|
||||
// Only valid while actively playing; ignoring from IDLE/ERROR/PAUSED prevents the state
|
||||
// machine from getting stuck in PAUSED when no playback is active (which would block the
|
||||
// next play_uri() call via its IDLE-state precondition).
|
||||
if (this->get_state() != media_source::MediaSourceState::PLAYING)
|
||||
break;
|
||||
// PAUSE does not stop the decoder task. Instead, on_audio_write() returns 0 and temporarily
|
||||
// yields, which fills the ring buffer and applies back pressure that effectively pauses both
|
||||
// the decoder and HTTP reader tasks.
|
||||
this->set_state_(media_source::MediaSourceState::PAUSED);
|
||||
this->pause_.store(true, std::memory_order_relaxed);
|
||||
break;
|
||||
case media_source::MediaSourceCommand::PLAY:
|
||||
// Only resume from PAUSED; don't fabricate a PLAYING state from IDLE/ERROR.
|
||||
if (this->get_state() != media_source::MediaSourceState::PAUSED)
|
||||
break;
|
||||
this->set_state_(media_source::MediaSourceState::PLAYING);
|
||||
this->pause_.store(false, std::memory_order_relaxed);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Called from the decoder task. Forwards to the orchestrator's listener, which is responsible for
|
||||
// being thread-safe with respect to its own audio writer.
|
||||
size_t AudioHTTPMediaSource::on_audio_write(const uint8_t *data, size_t length, uint32_t timeout_ms) {
|
||||
if (this->pause_.load(std::memory_order_relaxed)) {
|
||||
vTaskDelay(pdMS_TO_TICKS(PAUSE_POLL_DELAY_MS));
|
||||
return 0;
|
||||
}
|
||||
return this->write_output(data, length, timeout_ms, this->stream_info_);
|
||||
}
|
||||
|
||||
// Called from the decoder task before the first on_audio_write().
|
||||
void AudioHTTPMediaSource::on_stream_info(const micro_decoder::AudioStreamInfo &info) {
|
||||
this->stream_info_ = audio::AudioStreamInfo(info.get_bits_per_sample(), info.get_channels(), info.get_sample_rate());
|
||||
}
|
||||
|
||||
// microDecoder invokes on_state_change() from inside decoder_->loop(), so this runs on the main
|
||||
// loop thread and it's safe to call set_state_() directly.
|
||||
void AudioHTTPMediaSource::on_state_change(micro_decoder::DecoderState state) {
|
||||
switch (state) {
|
||||
case micro_decoder::DecoderState::IDLE:
|
||||
this->set_state_(media_source::MediaSourceState::IDLE);
|
||||
this->disable_loop();
|
||||
break;
|
||||
case micro_decoder::DecoderState::PLAYING:
|
||||
this->set_state_(media_source::MediaSourceState::PLAYING);
|
||||
break;
|
||||
case micro_decoder::DecoderState::FAILED:
|
||||
this->set_state_(media_source::MediaSourceState::ERROR);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::audio_http
|
||||
|
||||
#endif // USE_ESP32
|
||||
@@ -0,0 +1,59 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include "esphome/components/audio/audio.h"
|
||||
#include "esphome/components/media_source/media_source.h"
|
||||
#include "esphome/core/component.h"
|
||||
|
||||
#include <micro_decoder/decoder_source.h>
|
||||
#include <micro_decoder/types.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
namespace esphome::audio_http {
|
||||
|
||||
// Inherits from two unrelated listener-style interfaces:
|
||||
// - media_source::MediaSource: this source reports state and writes audio *to* an orchestrator
|
||||
// (the orchestrator calls set_listener() on us with a MediaSourceListener*).
|
||||
// - micro_decoder::DecoderListener: the underlying decoder calls back *into* us with decoded
|
||||
// audio and state changes (we call decoder_->set_listener(this) in setup()).
|
||||
// The two set_listener() methods live on different base classes and serve opposite directions.
|
||||
class AudioHTTPMediaSource : public Component, public media_source::MediaSource, public micro_decoder::DecoderListener {
|
||||
public:
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
|
||||
void set_buffer_size(size_t buffer_size) { this->buffer_size_ = buffer_size; }
|
||||
void set_task_stack_in_psram(bool task_stack_in_psram) { this->decoder_task_stack_in_psram_ = task_stack_in_psram; }
|
||||
|
||||
// MediaSource interface implementation
|
||||
bool play_uri(const std::string &uri) override;
|
||||
void handle_command(media_source::MediaSourceCommand command) override;
|
||||
bool can_handle(const std::string &uri) const override;
|
||||
|
||||
// DecoderListener interface implementation
|
||||
size_t on_audio_write(const uint8_t *data, size_t length, uint32_t timeout_ms) override;
|
||||
void on_stream_info(const micro_decoder::AudioStreamInfo &info) override;
|
||||
void on_state_change(micro_decoder::DecoderState state) override;
|
||||
|
||||
protected:
|
||||
std::unique_ptr<micro_decoder::DecoderSource> decoder_;
|
||||
audio::AudioStreamInfo stream_info_;
|
||||
|
||||
size_t buffer_size_{50000};
|
||||
|
||||
// Written from the main loop in handle_command(), read from the decoder task in
|
||||
// on_audio_write(). Must be atomic to avoid a data race.
|
||||
std::atomic<bool> pause_{false};
|
||||
bool decoder_task_stack_in_psram_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::audio_http
|
||||
|
||||
#endif // USE_ESP32
|
||||
@@ -0,0 +1,59 @@
|
||||
from typing import Any
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import audio, esp32, media_source, psram
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TASK_STACK_IN_PSRAM
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@kahrendt"]
|
||||
AUTO_LOAD = ["audio"]
|
||||
|
||||
audio_http_ns = cg.esphome_ns.namespace("audio_http")
|
||||
AudioHTTPMediaSource = audio_http_ns.class_(
|
||||
"AudioHTTPMediaSource", cg.Component, media_source.MediaSource
|
||||
)
|
||||
|
||||
|
||||
def _request_micro_decoder(config: ConfigType) -> ConfigType:
|
||||
audio.request_micro_decoder_support()
|
||||
return config
|
||||
|
||||
|
||||
def _validate_task_stack_in_psram(value: Any) -> bool:
|
||||
# Only require the psram component when actually enabling PSRAM stacks; validating
|
||||
# the boolean first means `false` doesn't trigger the requires_component check.
|
||||
if value := cv.boolean(value):
|
||||
return cv.requires_component(psram.DOMAIN)(value)
|
||||
return value
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
media_source.media_source_schema(
|
||||
AudioHTTPMediaSource,
|
||||
)
|
||||
.extend(
|
||||
{
|
||||
cv.Optional(CONF_BUFFER_SIZE, default=50000): cv.int_range(
|
||||
min=5000, max=1000000
|
||||
),
|
||||
cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA),
|
||||
cv.only_on_esp32,
|
||||
_request_micro_decoder,
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await media_source.register_media_source(var, config)
|
||||
|
||||
if config.get(CONF_TASK_STACK_IN_PSRAM):
|
||||
cg.add(var.set_task_stack_in_psram(True))
|
||||
esp32.add_idf_sdkconfig_option(
|
||||
"CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True
|
||||
)
|
||||
cg.add(var.set_buffer_size(config[CONF_BUFFER_SIZE]))
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+141
-266
@@ -1,315 +1,183 @@
|
||||
#include "dsmr.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
// Ignore Zephyr. It doesn't have any encryption library.
|
||||
#if defined(USE_ESP32) || defined(USE_ARDUINO) || defined(USE_HOST)
|
||||
|
||||
#include <AES.h>
|
||||
#include <Crypto.h>
|
||||
#include <GCM.h>
|
||||
#include "dsmr.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <dsmr_parser/util.h>
|
||||
|
||||
namespace esphome::dsmr {
|
||||
|
||||
static const char *const TAG = "dsmr";
|
||||
static constexpr auto &TAG = "dsmr";
|
||||
|
||||
static void log_callback(dsmr_parser::LogLevel level, const char *fmt, va_list args) {
|
||||
std::array<char, 256> buf;
|
||||
vsnprintf(buf.data(), buf.size(), fmt, args);
|
||||
switch (level) {
|
||||
case dsmr_parser::LogLevel::ERROR:
|
||||
ESP_LOGE(TAG, "%s", buf.data());
|
||||
break;
|
||||
case dsmr_parser::LogLevel::WARNING:
|
||||
ESP_LOGW(TAG, "%s", buf.data());
|
||||
break;
|
||||
case dsmr_parser::LogLevel::INFO:
|
||||
ESP_LOGI(TAG, "%s", buf.data());
|
||||
break;
|
||||
case dsmr_parser::LogLevel::VERBOSE:
|
||||
ESP_LOGV(TAG, "%s", buf.data());
|
||||
break;
|
||||
case dsmr_parser::LogLevel::VERY_VERBOSE:
|
||||
ESP_LOGVV(TAG, "%s", buf.data());
|
||||
break;
|
||||
case dsmr_parser::LogLevel::DEBUG:
|
||||
ESP_LOGD(TAG, "%s", buf.data());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void Dsmr::setup() {
|
||||
this->telegram_ = new char[this->max_telegram_len_]; // NOLINT
|
||||
dsmr_parser::Logger::set_log_function(log_callback);
|
||||
if (this->request_pin_ != nullptr) {
|
||||
this->request_pin_->setup();
|
||||
}
|
||||
}
|
||||
|
||||
void Dsmr::loop() {
|
||||
if (this->ready_to_request_data_()) {
|
||||
if (this->decryption_key_.empty()) {
|
||||
this->receive_telegram_();
|
||||
} else {
|
||||
this->receive_encrypted_telegram_();
|
||||
}
|
||||
if (!this->ready_to_request_data_()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this->encryption_enabled_) {
|
||||
this->receive_encrypted_telegram_();
|
||||
} else {
|
||||
this->receive_telegram_();
|
||||
}
|
||||
}
|
||||
|
||||
bool Dsmr::ready_to_request_data_() {
|
||||
// When using a request pin, then wait for the next request interval.
|
||||
if (this->request_pin_ != nullptr) {
|
||||
if (!this->requesting_data_ && this->request_interval_reached_()) {
|
||||
this->start_requesting_data_();
|
||||
}
|
||||
}
|
||||
// Otherwise, sink serial data until next request interval.
|
||||
else {
|
||||
if (this->request_interval_reached_()) {
|
||||
this->start_requesting_data_();
|
||||
}
|
||||
if (!this->requesting_data_) {
|
||||
this->drain_rx_buffer_();
|
||||
}
|
||||
if (!this->requesting_data_ && this->request_interval_reached_()) {
|
||||
this->start_requesting_data_();
|
||||
}
|
||||
return this->requesting_data_;
|
||||
}
|
||||
|
||||
bool Dsmr::request_interval_reached_() {
|
||||
bool Dsmr::request_interval_reached_() const {
|
||||
if (this->last_request_time_ == 0) {
|
||||
return true;
|
||||
}
|
||||
return millis() - this->last_request_time_ > this->request_interval_;
|
||||
}
|
||||
|
||||
bool Dsmr::receive_timeout_reached_() { return millis() - this->last_read_time_ > this->receive_timeout_; }
|
||||
|
||||
bool Dsmr::available_within_timeout_() {
|
||||
// Data are available for reading on the UART bus?
|
||||
// Then we can start reading right away.
|
||||
if (this->available()) {
|
||||
this->last_read_time_ = millis();
|
||||
return true;
|
||||
}
|
||||
// When we're not in the process of reading a telegram, then there is
|
||||
// no need to actively wait for new data to come in.
|
||||
if (!header_found_) {
|
||||
return false;
|
||||
}
|
||||
// A telegram is being read. The smart meter might not deliver a telegram
|
||||
// in one go, but instead send it in chunks with small pauses in between.
|
||||
// When the UART RX buffer cannot hold a full telegram, then make sure
|
||||
// that the UART read buffer does not overflow while other components
|
||||
// perform their work in their loop. Do this by not returning control to
|
||||
// the main loop, until the read timeout is reached.
|
||||
if (this->parent_->get_rx_buffer_size() < this->max_telegram_len_) {
|
||||
while (!this->receive_timeout_reached_()) {
|
||||
delay(5);
|
||||
if (this->available()) {
|
||||
this->last_read_time_ = millis();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// No new data has come in during the read timeout? Then stop reading the
|
||||
// telegram and start waiting for the next one to arrive.
|
||||
if (this->receive_timeout_reached_()) {
|
||||
ESP_LOGW(TAG, "Timeout while reading data for telegram");
|
||||
this->reset_telegram_();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void Dsmr::start_requesting_data_() {
|
||||
if (!this->requesting_data_) {
|
||||
if (this->request_pin_ != nullptr) {
|
||||
ESP_LOGV(TAG, "Start requesting data from P1 port");
|
||||
this->request_pin_->digital_write(true);
|
||||
} else {
|
||||
ESP_LOGV(TAG, "Start reading data from P1 port");
|
||||
}
|
||||
this->requesting_data_ = true;
|
||||
this->last_request_time_ = millis();
|
||||
if (this->requesting_data_) {
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGV(TAG, "Start reading data from P1 port");
|
||||
this->flush_rx_buffer_();
|
||||
|
||||
if (this->request_pin_ != nullptr) {
|
||||
ESP_LOGV(TAG, "Set request pin to 1");
|
||||
this->request_pin_->digital_write(true);
|
||||
}
|
||||
|
||||
this->requesting_data_ = true;
|
||||
this->last_request_time_ = millis();
|
||||
}
|
||||
|
||||
void Dsmr::stop_requesting_data_() {
|
||||
if (this->requesting_data_) {
|
||||
if (this->request_pin_ != nullptr) {
|
||||
ESP_LOGV(TAG, "Stop requesting data from P1 port");
|
||||
this->request_pin_->digital_write(false);
|
||||
} else {
|
||||
ESP_LOGV(TAG, "Stop reading data from P1 port");
|
||||
}
|
||||
this->drain_rx_buffer_();
|
||||
this->requesting_data_ = false;
|
||||
if (!this->requesting_data_) {
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGV(TAG, "Stop reading data from P1 port");
|
||||
if (this->request_pin_ != nullptr) {
|
||||
ESP_LOGV(TAG, "Set request pin to 0");
|
||||
this->request_pin_->digital_write(false);
|
||||
}
|
||||
this->requesting_data_ = false;
|
||||
}
|
||||
|
||||
void Dsmr::drain_rx_buffer_() {
|
||||
uint8_t buf[64];
|
||||
size_t avail;
|
||||
while ((avail = this->available()) > 0) {
|
||||
if (!this->read_array(buf, std::min(avail, sizeof(buf)))) {
|
||||
break;
|
||||
}
|
||||
void Dsmr::flush_rx_buffer_() {
|
||||
ESP_LOGV(TAG, "Flush UART RX buffer");
|
||||
while (!this->uart_read_chunk_().empty()) {
|
||||
}
|
||||
}
|
||||
|
||||
void Dsmr::reset_telegram_() {
|
||||
this->header_found_ = false;
|
||||
this->footer_found_ = false;
|
||||
this->bytes_read_ = 0;
|
||||
this->crypt_bytes_read_ = 0;
|
||||
this->crypt_telegram_len_ = 0;
|
||||
}
|
||||
|
||||
void Dsmr::receive_telegram_() {
|
||||
while (this->available_within_timeout_()) {
|
||||
// Read all available bytes in batches to reduce UART call overhead.
|
||||
uint8_t buf[64];
|
||||
size_t avail = this->available();
|
||||
while (avail > 0) {
|
||||
size_t to_read = std::min(avail, sizeof(buf));
|
||||
if (!this->read_array(buf, to_read))
|
||||
for (auto data = this->uart_read_chunk_(); !data.empty(); data = this->uart_read_chunk_()) {
|
||||
for (uint8_t byte : data) {
|
||||
const auto telegram = this->packet_accumulator_.process_byte(byte);
|
||||
if (!telegram) { // No full packet received yet
|
||||
continue;
|
||||
}
|
||||
if (this->parse_telegram_(telegram.value())) {
|
||||
return;
|
||||
avail -= to_read;
|
||||
|
||||
for (size_t i = 0; i < to_read; i++) {
|
||||
const char c = static_cast<char>(buf[i]);
|
||||
|
||||
// Find a new telegram header, i.e. forward slash.
|
||||
if (c == '/') {
|
||||
ESP_LOGV(TAG, "Header of telegram found");
|
||||
this->reset_telegram_();
|
||||
this->header_found_ = true;
|
||||
}
|
||||
if (!this->header_found_)
|
||||
continue;
|
||||
|
||||
// Check for buffer overflow.
|
||||
if (this->bytes_read_ >= this->max_telegram_len_) {
|
||||
this->reset_telegram_();
|
||||
ESP_LOGE(TAG, "Error: telegram larger than buffer (%d bytes)", this->max_telegram_len_);
|
||||
return;
|
||||
}
|
||||
|
||||
// Some v2.2 or v3 meters will send a new value which starts with '('
|
||||
// in a new line, while the value belongs to the previous ObisId. For
|
||||
// proper parsing, remove these new line characters.
|
||||
if (c == '(') {
|
||||
while (true) {
|
||||
auto previous_char = this->telegram_[this->bytes_read_ - 1];
|
||||
if (previous_char == '\n' || previous_char == '\r') {
|
||||
this->bytes_read_--;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store the byte in the buffer.
|
||||
this->telegram_[this->bytes_read_] = c;
|
||||
this->bytes_read_++;
|
||||
|
||||
// Check for a footer, i.e. exclamation mark, followed by a hex checksum.
|
||||
if (c == '!') {
|
||||
ESP_LOGV(TAG, "Footer of telegram found");
|
||||
this->footer_found_ = true;
|
||||
continue;
|
||||
}
|
||||
// Check for the end of the hex checksum, i.e. a newline.
|
||||
if (this->footer_found_ && c == '\n') {
|
||||
// Parse the telegram and publish sensor values.
|
||||
this->parse_telegram();
|
||||
this->reset_telegram_();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Dsmr::receive_encrypted_telegram_() {
|
||||
while (this->available_within_timeout_()) {
|
||||
// Read all available bytes in batches to reduce UART call overhead.
|
||||
uint8_t buf[64];
|
||||
size_t avail = this->available();
|
||||
while (avail > 0) {
|
||||
size_t to_read = std::min(avail, sizeof(buf));
|
||||
if (!this->read_array(buf, to_read))
|
||||
return;
|
||||
avail -= to_read;
|
||||
|
||||
for (size_t i = 0; i < to_read; i++) {
|
||||
const char c = static_cast<char>(buf[i]);
|
||||
|
||||
// Find a new telegram start byte.
|
||||
if (!this->header_found_) {
|
||||
if ((uint8_t) c != 0xDB) {
|
||||
continue;
|
||||
}
|
||||
ESP_LOGV(TAG, "Start byte 0xDB of encrypted telegram found");
|
||||
this->reset_telegram_();
|
||||
this->header_found_ = true;
|
||||
}
|
||||
|
||||
// Check for buffer overflow.
|
||||
if (this->crypt_bytes_read_ >= this->max_telegram_len_) {
|
||||
this->reset_telegram_();
|
||||
ESP_LOGE(TAG, "Error: encrypted telegram larger than buffer (%d bytes)", this->max_telegram_len_);
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the byte in the buffer.
|
||||
this->crypt_telegram_[this->crypt_bytes_read_] = c;
|
||||
this->crypt_bytes_read_++;
|
||||
|
||||
// Read the length of the incoming encrypted telegram.
|
||||
if (this->crypt_telegram_len_ == 0 && this->crypt_bytes_read_ > 20) {
|
||||
// Complete header + data bytes
|
||||
this->crypt_telegram_len_ = 13 + (this->crypt_telegram_[11] << 8 | this->crypt_telegram_[12]);
|
||||
ESP_LOGV(TAG, "Encrypted telegram length: %d bytes", this->crypt_telegram_len_);
|
||||
}
|
||||
|
||||
// Check for the end of the encrypted telegram.
|
||||
if (this->crypt_telegram_len_ == 0 || this->crypt_bytes_read_ != this->crypt_telegram_len_) {
|
||||
continue;
|
||||
}
|
||||
ESP_LOGV(TAG, "End of encrypted telegram found");
|
||||
|
||||
// Decrypt the encrypted telegram.
|
||||
GCM<AES128> *gcmaes128{new GCM<AES128>()};
|
||||
gcmaes128->setKey(this->decryption_key_.data(), gcmaes128->keySize());
|
||||
// the iv is 8 bytes of the system title + 4 bytes frame counter
|
||||
// system title is at byte 2 and frame counter at byte 15
|
||||
for (int i = 10; i < 14; i++)
|
||||
this->crypt_telegram_[i] = this->crypt_telegram_[i + 4];
|
||||
constexpr uint16_t iv_size{12};
|
||||
gcmaes128->setIV(&this->crypt_telegram_[2], iv_size);
|
||||
gcmaes128->decrypt(reinterpret_cast<uint8_t *>(this->telegram_),
|
||||
// the ciphertext start at byte 18
|
||||
&this->crypt_telegram_[18],
|
||||
// cipher size
|
||||
this->crypt_bytes_read_ - 17);
|
||||
delete gcmaes128; // NOLINT(cppcoreguidelines-owning-memory)
|
||||
|
||||
this->bytes_read_ = strnlen(this->telegram_, this->max_telegram_len_);
|
||||
ESP_LOGV(TAG, "Decrypted telegram size: %d bytes", this->bytes_read_);
|
||||
ESP_LOGVV(TAG, "Decrypted telegram: %s", this->telegram_);
|
||||
|
||||
// Parse the decrypted telegram and publish sensor values.
|
||||
this->parse_telegram();
|
||||
this->reset_telegram_();
|
||||
return;
|
||||
for (auto data = this->uart_read_chunk_(); !data.empty(); data = this->uart_read_chunk_()) {
|
||||
for (uint8_t byte : data) {
|
||||
if (this->buffer_pos_ >= this->buffer_.size()) { // Reset buffer if overflow
|
||||
ESP_LOGW(TAG, "Encrypted buffer overflow, resetting");
|
||||
this->buffer_pos_ = 0;
|
||||
}
|
||||
|
||||
this->buffer_[this->buffer_pos_] = byte;
|
||||
this->buffer_pos_++;
|
||||
}
|
||||
this->last_read_time_ = millis();
|
||||
}
|
||||
|
||||
// Detect inter-frame delay. If no byte is received for more than receive_timeout, then the packet is complete.
|
||||
if (millis() - this->last_read_time_ > this->receive_timeout_ && this->buffer_pos_ > 0) {
|
||||
ESP_LOGV(TAG, "Encrypted telegram received (%zu bytes)", this->buffer_pos_);
|
||||
|
||||
const auto telegram = this->dlms_decryptor_.decrypt_inplace({this->buffer_.data(), this->buffer_pos_});
|
||||
|
||||
// Reset buffer position for the next packet
|
||||
this->buffer_pos_ = 0;
|
||||
this->last_read_time_ = 0;
|
||||
|
||||
if (!telegram) { // decryption failed
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse and publish the telegram
|
||||
this->parse_telegram_(telegram.value());
|
||||
}
|
||||
}
|
||||
|
||||
bool Dsmr::parse_telegram() {
|
||||
MyData data;
|
||||
ESP_LOGV(TAG, "Trying to parse telegram");
|
||||
bool Dsmr::parse_telegram_(const dsmr_parser::DsmrUnencryptedTelegram &telegram) {
|
||||
this->stop_requesting_data_();
|
||||
|
||||
const auto &res = dsmr_parser::P1Parser::parse(
|
||||
data, this->telegram_, this->bytes_read_, false,
|
||||
this->crc_check_); // Parse telegram according to data definition. Ignore unknown values.
|
||||
if (res.err) {
|
||||
// Parsing error, show it
|
||||
auto err_str = res.fullError(this->telegram_, this->telegram_ + this->bytes_read_);
|
||||
ESP_LOGE(TAG, "%s", err_str.c_str());
|
||||
return false;
|
||||
} else {
|
||||
this->status_clear_warning();
|
||||
this->publish_sensors(data);
|
||||
ESP_LOGV(TAG, "Trying to parse telegram (%zu bytes)", telegram.content().size());
|
||||
ESP_LOGVV(TAG, "Telegram content:\n %.*s", static_cast<int>(telegram.content().size()), telegram.content().data());
|
||||
|
||||
// publish the telegram, after publishing the sensors so it can also trigger action based on latest values
|
||||
if (this->s_telegram_ != nullptr) {
|
||||
this->s_telegram_->publish_state(this->telegram_, this->bytes_read_);
|
||||
}
|
||||
return true;
|
||||
MyData data;
|
||||
if (const bool res = dsmr_parser::DsmrParser::parse(data, telegram); !res) {
|
||||
ESP_LOGE(TAG, "Failed to parse telegram");
|
||||
return false;
|
||||
}
|
||||
|
||||
this->status_clear_warning();
|
||||
this->publish_sensors(data);
|
||||
|
||||
// Publish the telegram, after publishing the sensors so it can also trigger action based on latest values
|
||||
if (this->s_telegram_ != nullptr) {
|
||||
this->s_telegram_->publish_state(telegram.content().data(), telegram.content().size());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void Dsmr::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"DSMR:\n"
|
||||
" Max telegram length: %d\n"
|
||||
" Max telegram length: %zu\n"
|
||||
" Receive timeout: %.1fs",
|
||||
this->max_telegram_len_, this->receive_timeout_ / 1e3f);
|
||||
this->buffer_.size(), this->receive_timeout_ / 1e3f);
|
||||
if (this->request_pin_ != nullptr) {
|
||||
LOG_PIN(" Request Pin: ", this->request_pin_);
|
||||
}
|
||||
@@ -324,30 +192,37 @@ void Dsmr::dump_config() {
|
||||
DSMR_TEXT_SENSOR_LIST(DSMR_LOG_TEXT_SENSOR, )
|
||||
}
|
||||
|
||||
void Dsmr::set_decryption_key(const char *decryption_key) {
|
||||
void Dsmr::set_decryption_key_(const char *decryption_key) {
|
||||
if (decryption_key == nullptr || decryption_key[0] == '\0') {
|
||||
ESP_LOGI(TAG, "Disabling decryption");
|
||||
this->decryption_key_.clear();
|
||||
if (this->crypt_telegram_ != nullptr) {
|
||||
delete[] this->crypt_telegram_;
|
||||
this->crypt_telegram_ = nullptr;
|
||||
}
|
||||
this->encryption_enabled_ = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!parse_hex(decryption_key, this->decryption_key_, 16)) {
|
||||
ESP_LOGE(TAG, "Error, decryption key must be 32 hex characters");
|
||||
this->decryption_key_.clear();
|
||||
auto key = dsmr_parser::Aes128GcmDecryptionKey::from_hex(decryption_key);
|
||||
if (!key) {
|
||||
ESP_LOGE(TAG, "Error, decryption key has incorrect format");
|
||||
this->encryption_enabled_ = false;
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Decryption key is set");
|
||||
// Verbose level prints decryption key
|
||||
ESP_LOGV(TAG, "Using decryption key: %s", decryption_key);
|
||||
|
||||
if (this->crypt_telegram_ == nullptr) {
|
||||
this->crypt_telegram_ = new uint8_t[this->max_telegram_len_]; // NOLINT
|
||||
this->gcm_decryptor_.set_encryption_key(key.value());
|
||||
this->encryption_enabled_ = true;
|
||||
}
|
||||
|
||||
std::span<uint8_t> Dsmr::uart_read_chunk_() {
|
||||
const auto avail = this->available();
|
||||
if (avail == 0) {
|
||||
return {};
|
||||
}
|
||||
size_t to_read = std::min(avail, uart_chunk_reading_buf_.size());
|
||||
if (!this->read_array(uart_chunk_reading_buf_.data(), to_read)) {
|
||||
return {};
|
||||
}
|
||||
return {uart_chunk_reading_buf_.data(), to_read};
|
||||
}
|
||||
|
||||
} // namespace esphome::dsmr
|
||||
|
||||
#endif
|
||||
|
||||
@@ -1,31 +1,46 @@
|
||||
#pragma once
|
||||
|
||||
// Ignore Zephyr. It doesn't have any encryption library.
|
||||
#if defined(USE_ESP32) || defined(USE_ARDUINO) || defined(USE_HOST)
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#include "esphome/components/text_sensor/text_sensor.h"
|
||||
#include "esphome/components/uart/uart.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <dsmr_parser/dlms_packet_decryptor.h>
|
||||
#include <dsmr_parser/fields.h>
|
||||
#include <dsmr_parser/packet_accumulator.h>
|
||||
#include <dsmr_parser/parser.h>
|
||||
#include <array>
|
||||
#include <span>
|
||||
#include <vector>
|
||||
|
||||
#if __has_include(<psa/crypto.h>)
|
||||
#include <dsmr_parser/decryption/aes128gcm_tfpsa.h>
|
||||
#elif __has_include(<mbedtls/gcm.h>)
|
||||
#if __has_include(<mbedtls/esp_config.h>)
|
||||
#include <mbedtls/esp_config.h>
|
||||
#endif
|
||||
#include <dsmr_parser/decryption/aes128gcm_mbedtls.h>
|
||||
#elif __has_include(<bearssl/bearssl.h>)
|
||||
#include <dsmr_parser/decryption/aes128gcm_bearssl.h>
|
||||
#else
|
||||
#error "The platform doesn't provide a compatible encryption library for dsmr_parser"
|
||||
#endif
|
||||
|
||||
namespace esphome::dsmr {
|
||||
|
||||
using namespace dsmr_parser::fields;
|
||||
|
||||
// DSMR_**_LIST generated by ESPHome and written in esphome/core/defines
|
||||
|
||||
#if !defined(DSMR_SENSOR_LIST) && !defined(DSMR_TEXT_SENSOR_LIST)
|
||||
// Neither set, set it to a dummy value to not break build
|
||||
#define DSMR_TEXT_SENSOR_LIST(F, SEP) F(identification)
|
||||
#endif
|
||||
|
||||
#if defined(DSMR_SENSOR_LIST) && defined(DSMR_TEXT_SENSOR_LIST)
|
||||
#define DSMR_BOTH ,
|
||||
#if __has_include(<psa/crypto.h>)
|
||||
using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmTfPsa;
|
||||
#elif __has_include(<mbedtls/gcm.h>)
|
||||
using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmMbedTls;
|
||||
#else
|
||||
#define DSMR_BOTH
|
||||
using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmBearSsl;
|
||||
#endif
|
||||
|
||||
using namespace dsmr_parser::fields;
|
||||
|
||||
#ifndef DSMR_SENSOR_LIST
|
||||
#define DSMR_SENSOR_LIST(F, SEP)
|
||||
#endif
|
||||
@@ -34,21 +49,33 @@ using namespace dsmr_parser::fields;
|
||||
#define DSMR_TEXT_SENSOR_LIST(F, SEP)
|
||||
#endif
|
||||
|
||||
#define DSMR_DATA_SENSOR(s) s
|
||||
#define DSMR_IDENTITY(s) s
|
||||
#define DSMR_COMMA ,
|
||||
#define DSMR_PREPEND_COMMA(...) __VA_OPT__(, ) __VA_ARGS__
|
||||
|
||||
using MyData = dsmr_parser::ParsedData<DSMR_TEXT_SENSOR_LIST(DSMR_DATA_SENSOR, DSMR_COMMA)
|
||||
DSMR_BOTH DSMR_SENSOR_LIST(DSMR_DATA_SENSOR, DSMR_COMMA)>;
|
||||
#ifdef DSMR_TEXT_SENSOR_LIST_DEFINED
|
||||
using MyData = dsmr_parser::ParsedData<DSMR_TEXT_SENSOR_LIST(DSMR_IDENTITY, DSMR_COMMA)
|
||||
DSMR_PREPEND_COMMA(DSMR_SENSOR_LIST(DSMR_IDENTITY, DSMR_COMMA))>;
|
||||
#else
|
||||
using MyData = dsmr_parser::ParsedData<DSMR_SENSOR_LIST(DSMR_IDENTITY, DSMR_COMMA)>;
|
||||
#endif
|
||||
|
||||
class Dsmr : public Component, public uart::UARTDevice {
|
||||
public:
|
||||
Dsmr(uart::UARTComponent *uart, bool crc_check) : uart::UARTDevice(uart), crc_check_(crc_check) {}
|
||||
Dsmr(uart::UARTComponent *uart, bool crc_check, size_t max_telegram_length, uint32_t request_interval,
|
||||
uint32_t receive_timeout, GPIOPin *request_pin, const char *decryption_key)
|
||||
: uart::UARTDevice(uart),
|
||||
request_interval_(request_interval),
|
||||
receive_timeout_(receive_timeout),
|
||||
request_pin_(request_pin),
|
||||
buffer_(max_telegram_length),
|
||||
packet_accumulator_(buffer_, crc_check) {
|
||||
this->set_decryption_key_(decryption_key);
|
||||
}
|
||||
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
|
||||
bool parse_telegram();
|
||||
|
||||
void publish_sensors(MyData &data) {
|
||||
#define DSMR_PUBLISH_SENSOR(s) \
|
||||
if (data.s##_present && this->s_##s##_ != nullptr) \
|
||||
@@ -57,20 +84,15 @@ class Dsmr : public Component, public uart::UARTDevice {
|
||||
|
||||
#define DSMR_PUBLISH_TEXT_SENSOR(s) \
|
||||
if (data.s##_present && this->s_##s##_ != nullptr) \
|
||||
s_##s##_->publish_state(data.s.c_str());
|
||||
s_##s##_->publish_state(data.s.data(), data.s.size());
|
||||
DSMR_TEXT_SENSOR_LIST(DSMR_PUBLISH_TEXT_SENSOR, )
|
||||
};
|
||||
|
||||
void dump_config() override;
|
||||
|
||||
void set_decryption_key(const char *decryption_key);
|
||||
// Remove before 2026.8.0
|
||||
ESPDEPRECATED("Pass .c_str() - e.g. set_decryption_key(key.c_str()). Removed in 2026.8.0", "2026.2.0")
|
||||
void set_decryption_key(const std::string &decryption_key) { this->set_decryption_key(decryption_key.c_str()); }
|
||||
void set_max_telegram_length(size_t length) { this->max_telegram_len_ = length; }
|
||||
void set_request_pin(GPIOPin *request_pin) { this->request_pin_ = request_pin; }
|
||||
void set_request_interval(uint32_t interval) { this->request_interval_ = interval; }
|
||||
void set_receive_timeout(uint32_t timeout) { this->receive_timeout_ = timeout; }
|
||||
ESPDEPRECATED("Use 'decryption_key' configuration parameter. This method will be removed in 2026.8.0", "2026.2.0")
|
||||
void set_decryption_key(const std::string &decryption_key) { this->set_decryption_key_(decryption_key.c_str()); }
|
||||
|
||||
// Sensor setters
|
||||
#define DSMR_SET_SENSOR(s) \
|
||||
@@ -85,56 +107,40 @@ class Dsmr : public Component, public uart::UARTDevice {
|
||||
void set_telegram(text_sensor::TextSensor *sensor) { s_telegram_ = sensor; }
|
||||
|
||||
protected:
|
||||
void set_decryption_key_(const char *decryption_key);
|
||||
void receive_telegram_();
|
||||
void receive_encrypted_telegram_();
|
||||
void reset_telegram_();
|
||||
void drain_rx_buffer_();
|
||||
void flush_rx_buffer_();
|
||||
|
||||
/// Wait for UART data to become available within the read timeout.
|
||||
///
|
||||
/// The smart meter might provide data in chunks, causing available() to
|
||||
/// return 0. When we're already reading a telegram, then we don't return
|
||||
/// right away (to handle further data in an upcoming loop) but wait a
|
||||
/// little while using this method to see if more data are incoming.
|
||||
/// By not returning, we prevent other components from taking so much
|
||||
/// time that the UART RX buffer overflows and bytes of the telegram get
|
||||
/// lost in the process.
|
||||
bool available_within_timeout_();
|
||||
|
||||
// Request telegram
|
||||
uint32_t request_interval_;
|
||||
bool request_interval_reached_();
|
||||
GPIOPin *request_pin_{nullptr};
|
||||
uint32_t last_request_time_{0};
|
||||
bool requesting_data_{false};
|
||||
bool parse_telegram_(const dsmr_parser::DsmrUnencryptedTelegram &telegram);
|
||||
bool request_interval_reached_() const;
|
||||
bool ready_to_request_data_();
|
||||
void start_requesting_data_();
|
||||
void stop_requesting_data_();
|
||||
std::span<uint8_t> uart_read_chunk_();
|
||||
|
||||
// Read telegram
|
||||
// Config
|
||||
uint32_t request_interval_;
|
||||
uint32_t receive_timeout_;
|
||||
bool receive_timeout_reached_();
|
||||
size_t max_telegram_len_;
|
||||
char *telegram_{nullptr};
|
||||
size_t bytes_read_{0};
|
||||
uint8_t *crypt_telegram_{nullptr};
|
||||
size_t crypt_telegram_len_{0};
|
||||
size_t crypt_bytes_read_{0};
|
||||
uint32_t last_read_time_{0};
|
||||
bool header_found_{false};
|
||||
bool footer_found_{false};
|
||||
|
||||
// handled outside dsmr
|
||||
GPIOPin *request_pin_{nullptr};
|
||||
text_sensor::TextSensor *s_telegram_{nullptr};
|
||||
|
||||
// Sensor member pointers
|
||||
#define DSMR_DECLARE_SENSOR(s) sensor::Sensor *s_##s##_{nullptr};
|
||||
DSMR_SENSOR_LIST(DSMR_DECLARE_SENSOR, )
|
||||
|
||||
#define DSMR_DECLARE_TEXT_SENSOR(s) text_sensor::TextSensor *s_##s##_{nullptr};
|
||||
DSMR_TEXT_SENSOR_LIST(DSMR_DECLARE_TEXT_SENSOR, )
|
||||
|
||||
std::vector<uint8_t> decryption_key_{};
|
||||
bool crc_check_;
|
||||
// State
|
||||
uint32_t last_request_time_{0};
|
||||
uint32_t last_read_time_{0};
|
||||
bool requesting_data_{false};
|
||||
bool encryption_enabled_{false};
|
||||
size_t buffer_pos_{0};
|
||||
std::vector<uint8_t> buffer_;
|
||||
dsmr_parser::PacketAccumulator packet_accumulator_;
|
||||
Aes128GcmDecryptorImpl gcm_decryptor_;
|
||||
dsmr_parser::DlmsPacketDecryptor dlms_decryptor_{gcm_decryptor_};
|
||||
std::array<uint8_t, 256> uart_chunk_reading_buf_;
|
||||
};
|
||||
} // namespace esphome::dsmr
|
||||
|
||||
#endif
|
||||
|
||||
@@ -10,6 +10,7 @@ from esphome.const import (
|
||||
DEVICE_CLASS_FREQUENCY,
|
||||
DEVICE_CLASS_GAS,
|
||||
DEVICE_CLASS_POWER,
|
||||
DEVICE_CLASS_POWER_FACTOR,
|
||||
DEVICE_CLASS_REACTIVE_POWER,
|
||||
DEVICE_CLASS_VOLTAGE,
|
||||
DEVICE_CLASS_WATER,
|
||||
@@ -119,6 +120,42 @@ CONFIG_SCHEMA = cv.Schema(
|
||||
device_class=DEVICE_CLASS_ENERGY,
|
||||
state_class=STATE_CLASS_TOTAL_INCREASING,
|
||||
),
|
||||
cv.Optional("energy_delivered_tariff1_il"): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_KILOWATT_HOURS,
|
||||
accuracy_decimals=3,
|
||||
device_class=DEVICE_CLASS_ENERGY,
|
||||
state_class=STATE_CLASS_TOTAL_INCREASING,
|
||||
),
|
||||
cv.Optional("energy_delivered_tariff2_il"): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_KILOWATT_HOURS,
|
||||
accuracy_decimals=3,
|
||||
device_class=DEVICE_CLASS_ENERGY,
|
||||
state_class=STATE_CLASS_TOTAL_INCREASING,
|
||||
),
|
||||
cv.Optional("energy_delivered_tariff3_il"): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_KILOWATT_HOURS,
|
||||
accuracy_decimals=3,
|
||||
device_class=DEVICE_CLASS_ENERGY,
|
||||
state_class=STATE_CLASS_TOTAL_INCREASING,
|
||||
),
|
||||
cv.Optional("energy_returned_tariff1_il"): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_KILOWATT_HOURS,
|
||||
accuracy_decimals=3,
|
||||
device_class=DEVICE_CLASS_ENERGY,
|
||||
state_class=STATE_CLASS_TOTAL_INCREASING,
|
||||
),
|
||||
cv.Optional("energy_returned_tariff2_il"): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_KILOWATT_HOURS,
|
||||
accuracy_decimals=3,
|
||||
device_class=DEVICE_CLASS_ENERGY,
|
||||
state_class=STATE_CLASS_TOTAL_INCREASING,
|
||||
),
|
||||
cv.Optional("energy_returned_tariff3_il"): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_KILOWATT_HOURS,
|
||||
accuracy_decimals=3,
|
||||
device_class=DEVICE_CLASS_ENERGY,
|
||||
state_class=STATE_CLASS_TOTAL_INCREASING,
|
||||
),
|
||||
cv.Optional("total_imported_energy"): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
|
||||
accuracy_decimals=3,
|
||||
@@ -511,6 +548,12 @@ CONFIG_SCHEMA = cv.Schema(
|
||||
device_class=DEVICE_CLASS_GAS,
|
||||
state_class=STATE_CLASS_TOTAL_INCREASING,
|
||||
),
|
||||
cv.Optional("gas_delivered_gj"): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_GIGA_JOULE,
|
||||
accuracy_decimals=3,
|
||||
device_class=DEVICE_CLASS_ENERGY,
|
||||
state_class=STATE_CLASS_TOTAL_INCREASING,
|
||||
),
|
||||
cv.Optional("water_delivered"): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_CUBIC_METER,
|
||||
accuracy_decimals=3,
|
||||
@@ -614,6 +657,12 @@ CONFIG_SCHEMA = cv.Schema(
|
||||
device_class=DEVICE_CLASS_POWER,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional("active_demand_net"): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_KILOWATT,
|
||||
accuracy_decimals=3,
|
||||
device_class=DEVICE_CLASS_POWER,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional("active_demand_abs"): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_KILOWATT,
|
||||
accuracy_decimals=3,
|
||||
@@ -728,6 +777,37 @@ CONFIG_SCHEMA = cv.Schema(
|
||||
device_class=DEVICE_CLASS_POWER,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional("power_factor"): sensor.sensor_schema(
|
||||
accuracy_decimals=3,
|
||||
device_class=DEVICE_CLASS_POWER_FACTOR,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional("power_factor_l1"): sensor.sensor_schema(
|
||||
accuracy_decimals=3,
|
||||
device_class=DEVICE_CLASS_POWER_FACTOR,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional("power_factor_l2"): sensor.sensor_schema(
|
||||
accuracy_decimals=3,
|
||||
device_class=DEVICE_CLASS_POWER_FACTOR,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional("power_factor_l3"): sensor.sensor_schema(
|
||||
accuracy_decimals=3,
|
||||
device_class=DEVICE_CLASS_POWER_FACTOR,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional("min_power_factor"): sensor.sensor_schema(
|
||||
accuracy_decimals=3,
|
||||
device_class=DEVICE_CLASS_POWER_FACTOR,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional("period_3_for_instantaneous_values"): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_SECOND,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_DURATION,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
@@ -746,6 +826,7 @@ async def to_code(config):
|
||||
sensors.append(f"F({key})")
|
||||
|
||||
if sensors:
|
||||
cg.add_define("DSMR_SENSOR_LIST_DEFINED")
|
||||
cg.add_define(
|
||||
"DSMR_SENSOR_LIST(F, sep)", cg.RawExpression(" sep ".join(sensors))
|
||||
)
|
||||
|
||||
@@ -15,7 +15,9 @@ CONFIG_SCHEMA = cv.Schema(
|
||||
cv.Optional("p1_version_be"): text_sensor.text_sensor_schema(),
|
||||
cv.Optional("timestamp"): text_sensor.text_sensor_schema(),
|
||||
cv.Optional("electricity_tariff"): text_sensor.text_sensor_schema(),
|
||||
cv.Optional("electricity_tariff_il"): text_sensor.text_sensor_schema(),
|
||||
cv.Optional("electricity_failure_log"): text_sensor.text_sensor_schema(),
|
||||
cv.Optional("electricity_failure_log_il"): text_sensor.text_sensor_schema(),
|
||||
cv.Optional("message_short"): text_sensor.text_sensor_schema(),
|
||||
cv.Optional("message_long"): text_sensor.text_sensor_schema(),
|
||||
cv.Optional("equipment_id"): text_sensor.text_sensor_schema(),
|
||||
@@ -52,6 +54,7 @@ async def to_code(config):
|
||||
text_sensors.append(f"F({key})")
|
||||
|
||||
if text_sensors:
|
||||
cg.add_define("DSMR_TEXT_SENSOR_LIST_DEFINED")
|
||||
cg.add_define(
|
||||
"DSMR_TEXT_SENSOR_LIST(F, sep)",
|
||||
cg.RawExpression(" sep ".join(text_sensors)),
|
||||
|
||||
@@ -33,6 +33,7 @@ from esphome.const import (
|
||||
CONF_TYPE,
|
||||
CONF_VARIANT,
|
||||
CONF_VERSION,
|
||||
CONF_WATCHDOG_TIMEOUT,
|
||||
KEY_CORE,
|
||||
KEY_FRAMEWORK_VERSION,
|
||||
KEY_NAME,
|
||||
@@ -1507,6 +1508,10 @@ CONFIG_SCHEMA = cv.All(
|
||||
),
|
||||
cv.Optional(CONF_VARIANT): cv.one_of(*VARIANTS, upper=True),
|
||||
cv.Optional(CONF_FRAMEWORK): FRAMEWORK_SCHEMA,
|
||||
cv.Optional(CONF_WATCHDOG_TIMEOUT, default="5s"): cv.All(
|
||||
cv.positive_time_period_seconds,
|
||||
cv.Range(min=cv.TimePeriod(seconds=5), max=cv.TimePeriod(seconds=60)),
|
||||
),
|
||||
}
|
||||
),
|
||||
_detect_variant,
|
||||
@@ -1874,6 +1879,10 @@ async def to_code(config):
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_PANIC", True)
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0", False)
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1", False)
|
||||
add_idf_sdkconfig_option(
|
||||
"CONFIG_ESP_TASK_WDT_TIMEOUT_S",
|
||||
config[CONF_WATCHDOG_TIMEOUT].total_seconds,
|
||||
)
|
||||
|
||||
# Disable dynamic log level control to save memory
|
||||
add_idf_sdkconfig_option("CONFIG_LOG_DYNAMIC_LEVEL_CONTROL", False)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -33,13 +33,16 @@ AUTO_LOAD = ["audio"]
|
||||
CODEOWNERS = ["@jesserockz", "@kahrendt"]
|
||||
DEPENDENCIES = ["i2s_audio"]
|
||||
|
||||
I2SAudioSpeaker = i2s_audio_ns.class_(
|
||||
"I2SAudioSpeaker", cg.Component, speaker.Speaker, I2SAudioOut
|
||||
I2SAudioSpeakerBase = i2s_audio_ns.class_(
|
||||
"I2SAudioSpeakerBase", cg.Component, speaker.Speaker, I2SAudioOut
|
||||
)
|
||||
I2SAudioSpeaker = i2s_audio_ns.class_("I2SAudioSpeaker", I2SAudioSpeakerBase)
|
||||
|
||||
CONF_DAC_TYPE = "dac_type"
|
||||
CONF_I2S_COMM_FMT = "i2s_comm_fmt"
|
||||
|
||||
I2SCommFmt = i2s_audio_ns.enum("I2SCommFmt", is_class=True)
|
||||
|
||||
i2s_dac_mode_t = cg.global_ns.enum("i2s_dac_mode_t")
|
||||
INTERNAL_DAC_OPTIONS = {
|
||||
CONF_LEFT: i2s_dac_mode_t.I2S_DAC_CHANNEL_LEFT_EN,
|
||||
@@ -183,11 +186,11 @@ async def to_code(config):
|
||||
await speaker.register_speaker(var, config)
|
||||
|
||||
cg.add(var.set_dout_pin(config[CONF_I2S_DOUT_PIN]))
|
||||
fmt = "std" # equals stand_i2s, stand_pcm_long, i2s_msb, pcm_long
|
||||
fmt = I2SCommFmt.STANDARD # equals stand_i2s, stand_pcm_long, i2s_msb, pcm_long
|
||||
if config[CONF_I2S_COMM_FMT] in ["stand_msb", "i2s_lsb"]:
|
||||
fmt = "msb"
|
||||
fmt = I2SCommFmt.MSB
|
||||
elif config[CONF_I2S_COMM_FMT] in ["stand_pcm_short", "pcm_short", "pcm"]:
|
||||
fmt = "pcm"
|
||||
fmt = I2SCommFmt.PCM
|
||||
cg.add(var.set_i2s_comm_fmt(fmt))
|
||||
if config[CONF_TIMEOUT] != CONF_NEVER:
|
||||
cg.add(var.set_timeout(config[CONF_TIMEOUT]))
|
||||
|
||||
@@ -13,36 +13,10 @@
|
||||
|
||||
#include "esp_timer.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace i2s_audio {
|
||||
|
||||
static const uint32_t DMA_BUFFER_DURATION_MS = 15;
|
||||
static const size_t DMA_BUFFERS_COUNT = 4;
|
||||
|
||||
static const size_t TASK_STACK_SIZE = 4096;
|
||||
static const ssize_t TASK_PRIORITY = 19;
|
||||
|
||||
static const size_t I2S_EVENT_QUEUE_COUNT = DMA_BUFFERS_COUNT + 1;
|
||||
namespace esphome::i2s_audio {
|
||||
|
||||
static const char *const TAG = "i2s_audio.speaker";
|
||||
|
||||
enum SpeakerEventGroupBits : uint32_t {
|
||||
COMMAND_START = (1 << 0), // indicates loop should start speaker task
|
||||
COMMAND_STOP = (1 << 1), // stops the speaker task
|
||||
COMMAND_STOP_GRACEFULLY = (1 << 2), // Stops the speaker task once all data has been written
|
||||
|
||||
TASK_STARTING = (1 << 10),
|
||||
TASK_RUNNING = (1 << 11),
|
||||
TASK_STOPPING = (1 << 12),
|
||||
TASK_STOPPED = (1 << 13),
|
||||
|
||||
ERR_ESP_NO_MEM = (1 << 19),
|
||||
|
||||
WARN_DROPPED_EVENT = (1 << 20),
|
||||
|
||||
ALL_BITS = 0x00FFFFFF, // All valid FreeRTOS event group bits
|
||||
};
|
||||
|
||||
// Lists the Q15 fixed point scaling factor for volume reduction.
|
||||
// Has 100 values representing silence and a reduction [49, 48.5, ... 0.5, 0] dB.
|
||||
// dB to PCM scaling factor formula: floating_point_scale_factor = 2^(-db/6.014)
|
||||
@@ -56,17 +30,21 @@ static const std::vector<int16_t> Q15_VOLUME_SCALING_FACTORS = {
|
||||
8218, 8706, 9222, 9770, 10349, 10963, 11613, 12302, 13032, 13805, 14624, 15491, 16410, 17384, 18415,
|
||||
19508, 20665, 21891, 23189, 24565, 26022, 27566, 29201, 30933, 32767};
|
||||
|
||||
void I2SAudioSpeaker::setup() {
|
||||
void I2SAudioSpeakerBase::setup() {
|
||||
this->event_group_ = xEventGroupCreate();
|
||||
|
||||
if (this->event_group_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Failed to create event group");
|
||||
ESP_LOGE(TAG, "Event group creation failed");
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize volume control. When audio_dac is configured, this sets the DAC volume.
|
||||
// When no audio_dac is configured, this initializes software volume control.
|
||||
this->set_volume(this->volume_);
|
||||
}
|
||||
|
||||
void I2SAudioSpeaker::dump_config() {
|
||||
void I2SAudioSpeakerBase::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Speaker:\n"
|
||||
" Pin: %d\n"
|
||||
@@ -75,10 +53,9 @@ void I2SAudioSpeaker::dump_config() {
|
||||
if (this->timeout_.has_value()) {
|
||||
ESP_LOGCONFIG(TAG, " Timeout: %" PRIu32 " ms", this->timeout_.value());
|
||||
}
|
||||
ESP_LOGCONFIG(TAG, " Communication format: %s", this->i2s_comm_fmt_.c_str());
|
||||
}
|
||||
|
||||
void I2SAudioSpeaker::loop() {
|
||||
void I2SAudioSpeakerBase::loop() {
|
||||
uint32_t event_group_bits = xEventGroupGetBits(this->event_group_);
|
||||
|
||||
if ((event_group_bits & SpeakerEventGroupBits::COMMAND_START) && (this->state_ == speaker::STATE_STOPPED)) {
|
||||
@@ -92,12 +69,12 @@ void I2SAudioSpeaker::loop() {
|
||||
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::TASK_STARTING);
|
||||
}
|
||||
if (event_group_bits & SpeakerEventGroupBits::TASK_RUNNING) {
|
||||
ESP_LOGD(TAG, "Started");
|
||||
ESP_LOGV(TAG, "Started");
|
||||
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING);
|
||||
this->state_ = speaker::STATE_RUNNING;
|
||||
}
|
||||
if (event_group_bits & SpeakerEventGroupBits::TASK_STOPPING) {
|
||||
ESP_LOGD(TAG, "Stopping");
|
||||
ESP_LOGV(TAG, "Stopping");
|
||||
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPING);
|
||||
this->state_ = speaker::STATE_STOPPING;
|
||||
}
|
||||
@@ -111,10 +88,12 @@ void I2SAudioSpeaker::loop() {
|
||||
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ALL_BITS);
|
||||
this->status_clear_error();
|
||||
|
||||
this->on_task_stopped();
|
||||
|
||||
this->state_ = speaker::STATE_STOPPED;
|
||||
}
|
||||
|
||||
// Log any errors encounted by the task
|
||||
// Log any errors encountered by the task
|
||||
if (event_group_bits & SpeakerEventGroupBits::ERR_ESP_NO_MEM) {
|
||||
ESP_LOGE(TAG, "Not enough memory");
|
||||
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM);
|
||||
@@ -133,14 +112,14 @@ void I2SAudioSpeaker::loop() {
|
||||
break;
|
||||
}
|
||||
|
||||
if (this->start_i2s_driver_(this->audio_stream_info_) != ESP_OK) {
|
||||
if (this->start_i2s_driver(this->audio_stream_info_) != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second");
|
||||
this->status_momentary_error("driver-faiure", 1000);
|
||||
this->status_momentary_error("driver-failure", 1000);
|
||||
break;
|
||||
}
|
||||
|
||||
if (this->speaker_task_handle_ == nullptr) {
|
||||
xTaskCreate(I2SAudioSpeaker::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY,
|
||||
xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY,
|
||||
&this->speaker_task_handle_);
|
||||
|
||||
if (this->speaker_task_handle_ == nullptr) {
|
||||
@@ -157,7 +136,7 @@ void I2SAudioSpeaker::loop() {
|
||||
}
|
||||
}
|
||||
|
||||
void I2SAudioSpeaker::set_volume(float volume) {
|
||||
void I2SAudioSpeakerBase::set_volume(float volume) {
|
||||
this->volume_ = volume;
|
||||
#ifdef USE_AUDIO_DAC
|
||||
if (this->audio_dac_ != nullptr) {
|
||||
@@ -166,15 +145,21 @@ void I2SAudioSpeaker::set_volume(float volume) {
|
||||
}
|
||||
this->audio_dac_->set_volume(volume);
|
||||
} else
|
||||
#endif
|
||||
#endif // USE_AUDIO_DAC
|
||||
{
|
||||
// Fallback to software volume control by using a Q15 fixed point scaling factor
|
||||
ssize_t decibel_index = remap<ssize_t, float>(volume, 0.0f, 1.0f, 0, Q15_VOLUME_SCALING_FACTORS.size() - 1);
|
||||
this->q15_volume_factor_ = Q15_VOLUME_SCALING_FACTORS[decibel_index];
|
||||
// Fallback to software volume control by using a Q15 fixed point scaling factor.
|
||||
// At maximum volume (1.0), set to INT16_MAX to completely bypass volume processing
|
||||
// and avoid any floating-point precision issues that could cause slight volume reduction.
|
||||
if (volume >= 1.0f) {
|
||||
this->q15_volume_factor_ = INT16_MAX;
|
||||
} else {
|
||||
ssize_t decibel_index = remap<ssize_t, float>(volume, 0.0f, 1.0f, 0, Q15_VOLUME_SCALING_FACTORS.size() - 1);
|
||||
this->q15_volume_factor_ = Q15_VOLUME_SCALING_FACTORS[decibel_index];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void I2SAudioSpeaker::set_mute_state(bool mute_state) {
|
||||
void I2SAudioSpeakerBase::set_mute_state(bool mute_state) {
|
||||
this->mute_state_ = mute_state;
|
||||
#ifdef USE_AUDIO_DAC
|
||||
if (this->audio_dac_) {
|
||||
@@ -184,7 +169,7 @@ void I2SAudioSpeaker::set_mute_state(bool mute_state) {
|
||||
this->audio_dac_->set_mute_off();
|
||||
}
|
||||
} else
|
||||
#endif
|
||||
#endif // USE_AUDIO_DAC
|
||||
{
|
||||
if (mute_state) {
|
||||
// Fallback to software volume control and scale by 0
|
||||
@@ -196,11 +181,12 @@ void I2SAudioSpeaker::set_mute_state(bool mute_state) {
|
||||
}
|
||||
}
|
||||
|
||||
size_t I2SAudioSpeaker::play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) {
|
||||
size_t I2SAudioSpeakerBase::play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) {
|
||||
if (this->is_failed()) {
|
||||
ESP_LOGE(TAG, "Setup failed; cannot play audio");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (this->state_ != speaker::STATE_RUNNING && this->state_ != speaker::STATE_STARTING) {
|
||||
this->start();
|
||||
}
|
||||
@@ -214,8 +200,8 @@ size_t I2SAudioSpeaker::play(const uint8_t *data, size_t length, TickType_t tick
|
||||
size_t bytes_written = 0;
|
||||
if (this->state_ == speaker::STATE_RUNNING) {
|
||||
std::shared_ptr<RingBuffer> temp_ring_buffer = this->audio_ring_buffer_.lock();
|
||||
if (temp_ring_buffer.use_count() == 2) {
|
||||
// Only the speaker task and this temp_ring_buffer own the ring buffer, so its safe to write to
|
||||
if (temp_ring_buffer != nullptr) {
|
||||
// The weak_ptr locks successfully only while the speaker task owns the ring buffer, so it is safe to write
|
||||
bytes_written = temp_ring_buffer->write_without_replacement((void *) data, length, ticks_to_wait);
|
||||
}
|
||||
}
|
||||
@@ -223,7 +209,7 @@ size_t I2SAudioSpeaker::play(const uint8_t *data, size_t length, TickType_t tick
|
||||
return bytes_written;
|
||||
}
|
||||
|
||||
bool I2SAudioSpeaker::has_buffered_data() const {
|
||||
bool I2SAudioSpeakerBase::has_buffered_data() const {
|
||||
if (this->audio_ring_buffer_.use_count() > 0) {
|
||||
std::shared_ptr<RingBuffer> temp_ring_buffer = this->audio_ring_buffer_.lock();
|
||||
return temp_ring_buffer->available() > 0;
|
||||
@@ -231,216 +217,27 @@ bool I2SAudioSpeaker::has_buffered_data() const {
|
||||
return false;
|
||||
}
|
||||
|
||||
void I2SAudioSpeaker::speaker_task(void *params) {
|
||||
I2SAudioSpeaker *this_speaker = (I2SAudioSpeaker *) params;
|
||||
|
||||
xEventGroupSetBits(this_speaker->event_group_, SpeakerEventGroupBits::TASK_STARTING);
|
||||
|
||||
const uint32_t dma_buffers_duration_ms = DMA_BUFFER_DURATION_MS * DMA_BUFFERS_COUNT;
|
||||
// Ensure ring buffer duration is at least the duration of all DMA buffers
|
||||
const uint32_t ring_buffer_duration = std::max(dma_buffers_duration_ms, this_speaker->buffer_duration_ms_);
|
||||
|
||||
// The DMA buffers may have more bits per sample, so calculate buffer sizes based in the input audio stream info
|
||||
const size_t ring_buffer_size = this_speaker->current_stream_info_.ms_to_bytes(ring_buffer_duration);
|
||||
|
||||
const uint32_t frames_to_fill_single_dma_buffer =
|
||||
this_speaker->current_stream_info_.ms_to_frames(DMA_BUFFER_DURATION_MS);
|
||||
const size_t bytes_to_fill_single_dma_buffer =
|
||||
this_speaker->current_stream_info_.frames_to_bytes(frames_to_fill_single_dma_buffer);
|
||||
|
||||
bool successful_setup = false;
|
||||
std::unique_ptr<audio::AudioSourceTransferBuffer> transfer_buffer =
|
||||
audio::AudioSourceTransferBuffer::create(bytes_to_fill_single_dma_buffer);
|
||||
|
||||
if (transfer_buffer != nullptr) {
|
||||
std::shared_ptr<RingBuffer> temp_ring_buffer = RingBuffer::create(ring_buffer_size);
|
||||
if (temp_ring_buffer.use_count() == 1) {
|
||||
transfer_buffer->set_source(temp_ring_buffer);
|
||||
this_speaker->audio_ring_buffer_ = temp_ring_buffer;
|
||||
successful_setup = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!successful_setup) {
|
||||
xEventGroupSetBits(this_speaker->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM);
|
||||
} else {
|
||||
bool stop_gracefully = false;
|
||||
bool tx_dma_underflow = true;
|
||||
|
||||
uint32_t frames_written = 0;
|
||||
uint32_t last_data_received_time = millis();
|
||||
|
||||
xEventGroupSetBits(this_speaker->event_group_, SpeakerEventGroupBits::TASK_RUNNING);
|
||||
|
||||
while (this_speaker->pause_state_ || !this_speaker->timeout_.has_value() ||
|
||||
(millis() - last_data_received_time) <= this_speaker->timeout_.value()) {
|
||||
uint32_t event_group_bits = xEventGroupGetBits(this_speaker->event_group_);
|
||||
|
||||
if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP) {
|
||||
xEventGroupClearBits(this_speaker->event_group_, SpeakerEventGroupBits::COMMAND_STOP);
|
||||
break;
|
||||
}
|
||||
if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY) {
|
||||
xEventGroupClearBits(this_speaker->event_group_, SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY);
|
||||
stop_gracefully = true;
|
||||
}
|
||||
|
||||
if (this_speaker->audio_stream_info_ != this_speaker->current_stream_info_) {
|
||||
// Audio stream info changed, stop the speaker task so it will restart with the proper settings.
|
||||
break;
|
||||
}
|
||||
int64_t write_timestamp;
|
||||
while (xQueueReceive(this_speaker->i2s_event_queue_, &write_timestamp, 0)) {
|
||||
// Receives timing events from the I2S on_sent callback. If actual audio data was sent in this event, it passes
|
||||
// on the timing info via the audio_output_callback.
|
||||
uint32_t frames_sent = frames_to_fill_single_dma_buffer;
|
||||
if (frames_to_fill_single_dma_buffer > frames_written) {
|
||||
tx_dma_underflow = true;
|
||||
frames_sent = frames_written;
|
||||
const uint32_t frames_zeroed = frames_to_fill_single_dma_buffer - frames_written;
|
||||
write_timestamp -= this_speaker->current_stream_info_.frames_to_microseconds(frames_zeroed);
|
||||
} else {
|
||||
tx_dma_underflow = false;
|
||||
}
|
||||
frames_written -= frames_sent;
|
||||
if (frames_sent > 0) {
|
||||
this_speaker->audio_output_callback_(frames_sent, write_timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
if (this_speaker->pause_state_) {
|
||||
// Pause state is accessed atomically, so thread safe
|
||||
// Delay so the task yields, then skip transferring audio data
|
||||
vTaskDelay(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Wait half the duration of the data already written to the DMA buffers for new audio data
|
||||
// The millisecond helper modifies the frames_written variable, so use the microsecond helper and divide by 1000
|
||||
const uint32_t read_delay =
|
||||
(this_speaker->current_stream_info_.frames_to_microseconds(frames_written) / 1000) / 2;
|
||||
|
||||
size_t bytes_read = transfer_buffer->transfer_data_from_source(pdMS_TO_TICKS(read_delay));
|
||||
uint8_t *new_data = transfer_buffer->get_buffer_end() - bytes_read;
|
||||
|
||||
if (bytes_read > 0) {
|
||||
if (this_speaker->q15_volume_factor_ < INT16_MAX) {
|
||||
// Apply the software volume adjustment by unpacking the sample into a Q31 fixed-point number, shifting it,
|
||||
// multiplying by the volume factor, and packing the sample back into the original bytes per sample.
|
||||
|
||||
const size_t bytes_per_sample = this_speaker->current_stream_info_.samples_to_bytes(1);
|
||||
const uint32_t len = bytes_read / bytes_per_sample;
|
||||
|
||||
// Use Q16 for samples with 1 or 2 bytes: shifted_sample * gain_factor is Q16 * Q15 -> Q31
|
||||
int32_t shift = 15; // Q31 -> Q16
|
||||
int32_t gain_factor = this_speaker->q15_volume_factor_; // Q15
|
||||
|
||||
if (bytes_per_sample >= 3) {
|
||||
// Use Q23 for samples with 3 or 4 bytes: shifted_sample * gain_factor is Q23 * Q8 -> Q31
|
||||
|
||||
shift = 8; // Q31 -> Q23
|
||||
gain_factor >>= 7; // Q15 -> Q8
|
||||
}
|
||||
|
||||
for (uint32_t i = 0; i < len; ++i) {
|
||||
int32_t sample =
|
||||
audio::unpack_audio_sample_to_q31(&new_data[i * bytes_per_sample], bytes_per_sample); // Q31
|
||||
sample >>= shift;
|
||||
sample *= gain_factor; // Q31
|
||||
audio::pack_q31_as_audio_sample(sample, &new_data[i * bytes_per_sample], bytes_per_sample);
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef USE_ESP32_VARIANT_ESP32
|
||||
// For ESP32 16-bit mono mode, adjacent samples need to be swapped.
|
||||
if (this_speaker->current_stream_info_.get_channels() == 1 &&
|
||||
this_speaker->current_stream_info_.get_bits_per_sample() == 16) {
|
||||
int16_t *samples = reinterpret_cast<int16_t *>(new_data);
|
||||
size_t sample_count = bytes_read / sizeof(int16_t);
|
||||
for (size_t i = 0; i + 1 < sample_count; i += 2) {
|
||||
int16_t tmp = samples[i];
|
||||
samples[i] = samples[i + 1];
|
||||
samples[i + 1] = tmp;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
if (transfer_buffer->available() == 0) {
|
||||
if (stop_gracefully && tx_dma_underflow) {
|
||||
break;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS / 2));
|
||||
} else {
|
||||
size_t bytes_written = 0;
|
||||
if (tx_dma_underflow) {
|
||||
// Temporarily disable channel and callback to reset the I2S driver's internal DMA buffer queue so timing
|
||||
// callbacks are accurate. Preload the data.
|
||||
i2s_channel_disable(this_speaker->tx_handle_);
|
||||
const i2s_event_callbacks_t callbacks = {
|
||||
.on_sent = nullptr,
|
||||
};
|
||||
|
||||
i2s_channel_register_event_callback(this_speaker->tx_handle_, &callbacks, this_speaker);
|
||||
i2s_channel_preload_data(this_speaker->tx_handle_, transfer_buffer->get_buffer_start(),
|
||||
transfer_buffer->available(), &bytes_written);
|
||||
} else {
|
||||
// Audio is already playing, use regular I2S write to add to the DMA buffers
|
||||
i2s_channel_write(this_speaker->tx_handle_, transfer_buffer->get_buffer_start(), transfer_buffer->available(),
|
||||
&bytes_written, DMA_BUFFER_DURATION_MS);
|
||||
}
|
||||
if (bytes_written > 0) {
|
||||
last_data_received_time = millis();
|
||||
frames_written += this_speaker->current_stream_info_.bytes_to_frames(bytes_written);
|
||||
transfer_buffer->decrease_buffer_length(bytes_written);
|
||||
if (tx_dma_underflow) {
|
||||
tx_dma_underflow = false;
|
||||
// Reset the event queue timestamps
|
||||
// Enable the on_sent callback to accurately track the timestamps of played audio
|
||||
// Enable the I2S channel to start sending the preloaded audio
|
||||
|
||||
xQueueReset(this_speaker->i2s_event_queue_);
|
||||
|
||||
const i2s_event_callbacks_t callbacks = {
|
||||
.on_sent = i2s_on_sent_cb,
|
||||
};
|
||||
i2s_channel_register_event_callback(this_speaker->tx_handle_, &callbacks, this_speaker);
|
||||
|
||||
i2s_channel_enable(this_speaker->tx_handle_);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
xEventGroupSetBits(this_speaker->event_group_, SpeakerEventGroupBits::TASK_STOPPING);
|
||||
|
||||
if (transfer_buffer != nullptr) {
|
||||
transfer_buffer.reset();
|
||||
}
|
||||
|
||||
xEventGroupSetBits(this_speaker->event_group_, SpeakerEventGroupBits::TASK_STOPPED);
|
||||
|
||||
while (true) {
|
||||
// Continuously delay until the loop method deletes the task
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
}
|
||||
void I2SAudioSpeakerBase::speaker_task(void *params) {
|
||||
I2SAudioSpeakerBase *this_speaker = (I2SAudioSpeakerBase *) params;
|
||||
this_speaker->run_speaker_task();
|
||||
}
|
||||
|
||||
void I2SAudioSpeaker::start() {
|
||||
void I2SAudioSpeakerBase::start() {
|
||||
if (!this->is_ready() || this->is_failed() || this->status_has_error())
|
||||
return;
|
||||
if ((this->state_ == speaker::STATE_STARTING) || (this->state_ == speaker::STATE_RUNNING))
|
||||
return;
|
||||
|
||||
// Mark STARTING immediately to avoid transient STOPPED observations before loop() processes COMMAND_START.
|
||||
this->state_ = speaker::STATE_STARTING;
|
||||
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START);
|
||||
}
|
||||
|
||||
void I2SAudioSpeaker::stop() { this->stop_(false); }
|
||||
void I2SAudioSpeakerBase::stop() { this->stop_(false); }
|
||||
|
||||
void I2SAudioSpeaker::finish() { this->stop_(true); }
|
||||
void I2SAudioSpeakerBase::finish() { this->stop_(true); }
|
||||
|
||||
void I2SAudioSpeaker::stop_(bool wait_on_empty) {
|
||||
void I2SAudioSpeakerBase::stop_(bool wait_on_empty) {
|
||||
if (this->is_failed())
|
||||
return;
|
||||
if (this->state_ == speaker::STATE_STOPPED)
|
||||
@@ -453,105 +250,16 @@ void I2SAudioSpeaker::stop_(bool wait_on_empty) {
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t I2SAudioSpeaker::start_i2s_driver_(audio::AudioStreamInfo &audio_stream_info) {
|
||||
this->current_stream_info_ = audio_stream_info; // store the stream info settings the driver will use
|
||||
|
||||
if ((this->i2s_role_ & I2S_ROLE_SLAVE) && (this->sample_rate_ != audio_stream_info.get_sample_rate())) { // NOLINT
|
||||
// Can't reconfigure I2S bus, so the sample rate must match the configured value
|
||||
ESP_LOGE(TAG, "Audio stream settings are not compatible with this I2S configuration");
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO &&
|
||||
(i2s_slot_bit_width_t) audio_stream_info.get_bits_per_sample() > this->slot_bit_width_) {
|
||||
// Currently can't handle the case when the incoming audio has more bits per sample than the configured value
|
||||
ESP_LOGE(TAG, "Audio streams with more bits per sample than the I2S speaker's configuration is not supported");
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
if (!this->parent_->try_lock()) {
|
||||
ESP_LOGE(TAG, "Parent I2S bus not free");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
uint32_t dma_buffer_length = audio_stream_info.ms_to_frames(DMA_BUFFER_DURATION_MS);
|
||||
|
||||
i2s_chan_config_t chan_cfg = {
|
||||
.id = this->parent_->get_port(),
|
||||
.role = this->i2s_role_,
|
||||
.dma_desc_num = DMA_BUFFERS_COUNT,
|
||||
.dma_frame_num = dma_buffer_length,
|
||||
.auto_clear = true,
|
||||
.intr_priority = 3,
|
||||
};
|
||||
/* Allocate a new TX channel and get the handle of this channel */
|
||||
esp_err_t I2SAudioSpeakerBase::init_i2s_channel_(const i2s_chan_config_t &chan_cfg, const i2s_std_config_t &std_cfg,
|
||||
size_t event_queue_size) {
|
||||
esp_err_t err = i2s_new_channel(&chan_cfg, &this->tx_handle_, NULL);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to allocate new I2S channel");
|
||||
ESP_LOGE(TAG, "I2S channel allocation failed: %s", esp_err_to_name(err));
|
||||
this->parent_->unlock();
|
||||
return err;
|
||||
}
|
||||
|
||||
i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT;
|
||||
#ifdef I2S_CLK_SRC_APLL
|
||||
if (this->use_apll_) {
|
||||
clk_src = I2S_CLK_SRC_APLL;
|
||||
}
|
||||
#endif
|
||||
i2s_std_gpio_config_t pin_config = this->parent_->get_pin_config();
|
||||
|
||||
i2s_std_clk_config_t clk_cfg = {
|
||||
.sample_rate_hz = audio_stream_info.get_sample_rate(),
|
||||
.clk_src = clk_src,
|
||||
.mclk_multiple = this->mclk_multiple_,
|
||||
};
|
||||
|
||||
i2s_slot_mode_t slot_mode = this->slot_mode_;
|
||||
i2s_std_slot_mask_t slot_mask = this->std_slot_mask_;
|
||||
if (audio_stream_info.get_channels() == 1) {
|
||||
slot_mode = I2S_SLOT_MODE_MONO;
|
||||
} else if (audio_stream_info.get_channels() == 2) {
|
||||
slot_mode = I2S_SLOT_MODE_STEREO;
|
||||
slot_mask = I2S_STD_SLOT_BOTH;
|
||||
}
|
||||
|
||||
i2s_std_slot_config_t std_slot_cfg;
|
||||
if (this->i2s_comm_fmt_ == "std") {
|
||||
std_slot_cfg =
|
||||
I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), slot_mode);
|
||||
} else if (this->i2s_comm_fmt_ == "pcm") {
|
||||
std_slot_cfg =
|
||||
I2S_STD_PCM_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), slot_mode);
|
||||
} else {
|
||||
std_slot_cfg =
|
||||
I2S_STD_MSB_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), slot_mode);
|
||||
}
|
||||
#ifdef USE_ESP32_VARIANT_ESP32
|
||||
// There seems to be a bug on the ESP32 (non-variant) platform where setting the slot bit width higher then the bits
|
||||
// per sample causes the audio to play too fast. Setting the ws_width to the configured slot bit width seems to
|
||||
// make it play at the correct speed while sending more bits per slot.
|
||||
if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO) {
|
||||
uint32_t configured_bit_width = static_cast<uint32_t>(this->slot_bit_width_);
|
||||
std_slot_cfg.ws_width = configured_bit_width;
|
||||
if (configured_bit_width > 16) {
|
||||
std_slot_cfg.msb_right = false;
|
||||
}
|
||||
}
|
||||
#else
|
||||
std_slot_cfg.slot_bit_width = this->slot_bit_width_;
|
||||
#endif
|
||||
std_slot_cfg.slot_mask = slot_mask;
|
||||
|
||||
pin_config.dout = this->dout_pin_;
|
||||
|
||||
i2s_std_config_t std_cfg = {
|
||||
.clk_cfg = clk_cfg,
|
||||
.slot_cfg = std_slot_cfg,
|
||||
.gpio_cfg = pin_config,
|
||||
};
|
||||
/* Initialize the channel */
|
||||
err = i2s_channel_init_std_mode(this->tx_handle_, &std_cfg);
|
||||
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to initialize channel");
|
||||
i2s_del_channel(this->tx_handle_);
|
||||
@@ -559,23 +267,34 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver_(audio::AudioStreamInfo &audio_strea
|
||||
this->parent_->unlock();
|
||||
return err;
|
||||
}
|
||||
|
||||
if (this->i2s_event_queue_ == nullptr) {
|
||||
this->i2s_event_queue_ = xQueueCreate(I2S_EVENT_QUEUE_COUNT, sizeof(int64_t));
|
||||
this->i2s_event_queue_ = xQueueCreate(event_queue_size, sizeof(int64_t));
|
||||
} else {
|
||||
// Reset queue to clear any stale events from previous task
|
||||
xQueueReset(this->i2s_event_queue_);
|
||||
}
|
||||
|
||||
i2s_channel_enable(this->tx_handle_);
|
||||
|
||||
return err;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
bool IRAM_ATTR I2SAudioSpeaker::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx) {
|
||||
void I2SAudioSpeakerBase::stop_i2s_driver_() {
|
||||
if (this->tx_handle_ != nullptr) {
|
||||
i2s_channel_disable(this->tx_handle_);
|
||||
i2s_del_channel(this->tx_handle_);
|
||||
this->tx_handle_ = nullptr;
|
||||
}
|
||||
this->parent_->unlock();
|
||||
}
|
||||
|
||||
bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx) {
|
||||
int64_t now = esp_timer_get_time();
|
||||
|
||||
BaseType_t need_yield1 = pdFALSE;
|
||||
BaseType_t need_yield2 = pdFALSE;
|
||||
BaseType_t need_yield3 = pdFALSE;
|
||||
|
||||
I2SAudioSpeaker *this_speaker = (I2SAudioSpeaker *) user_ctx;
|
||||
I2SAudioSpeakerBase *this_speaker = (I2SAudioSpeakerBase *) user_ctx;
|
||||
|
||||
if (xQueueIsQueueFullFromISR(this_speaker->i2s_event_queue_)) {
|
||||
// Queue is full, so discard the oldest event and set the warning flag to inform the user
|
||||
@@ -589,14 +308,47 @@ bool IRAM_ATTR I2SAudioSpeaker::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s_eve
|
||||
return need_yield1 | need_yield2 | need_yield3;
|
||||
}
|
||||
|
||||
void I2SAudioSpeaker::stop_i2s_driver_() {
|
||||
i2s_channel_disable(this->tx_handle_);
|
||||
i2s_del_channel(this->tx_handle_);
|
||||
this->tx_handle_ = nullptr;
|
||||
this->parent_->unlock();
|
||||
void I2SAudioSpeakerBase::apply_software_volume_(uint8_t *data, size_t bytes_read) {
|
||||
if (this->q15_volume_factor_ >= INT16_MAX) {
|
||||
return; // Max volume, no processing needed
|
||||
}
|
||||
|
||||
const size_t bytes_per_sample = this->current_stream_info_.samples_to_bytes(1);
|
||||
const uint32_t len = bytes_read / bytes_per_sample;
|
||||
|
||||
// Use Q16 for samples with 1 or 2 bytes: shifted_sample * gain_factor is Q16 * Q15 -> Q31
|
||||
int32_t shift = 15; // Q31 -> Q16
|
||||
int32_t gain_factor = this->q15_volume_factor_; // Q15
|
||||
|
||||
if (bytes_per_sample >= 3) {
|
||||
// Use Q23 for samples with 3 or 4 bytes: shifted_sample * gain_factor is Q23 * Q8 -> Q31
|
||||
shift = 8; // Q31 -> Q23
|
||||
gain_factor >>= 7; // Q15 -> Q8
|
||||
}
|
||||
|
||||
for (uint32_t i = 0; i < len; ++i) {
|
||||
int32_t sample = audio::unpack_audio_sample_to_q31(&data[i * bytes_per_sample], bytes_per_sample); // Q31
|
||||
sample >>= shift;
|
||||
sample *= gain_factor; // Q31
|
||||
audio::pack_q31_as_audio_sample(sample, &data[i * bytes_per_sample], bytes_per_sample);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace i2s_audio
|
||||
} // namespace esphome
|
||||
void I2SAudioSpeakerBase::swap_esp32_mono_samples_(uint8_t *data, size_t bytes_read) {
|
||||
#ifdef USE_ESP32_VARIANT_ESP32
|
||||
// For ESP32 16-bit mono mode, adjacent samples need to be swapped.
|
||||
if (this->current_stream_info_.get_channels() == 1 && this->current_stream_info_.get_bits_per_sample() == 16) {
|
||||
int16_t *samples = reinterpret_cast<int16_t *>(data);
|
||||
size_t sample_count = bytes_read / sizeof(int16_t);
|
||||
for (size_t i = 0; i + 1 < sample_count; i += 2) {
|
||||
int16_t tmp = samples[i];
|
||||
samples[i] = samples[i + 1];
|
||||
samples[i + 1] = tmp;
|
||||
}
|
||||
}
|
||||
#endif // USE_ESP32_VARIANT_ESP32
|
||||
}
|
||||
|
||||
} // namespace esphome::i2s_audio
|
||||
|
||||
#endif // USE_ESP32
|
||||
|
||||
@@ -16,10 +16,34 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/ring_buffer.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace i2s_audio {
|
||||
namespace esphome::i2s_audio {
|
||||
|
||||
class I2SAudioSpeaker : public I2SAudioOut, public speaker::Speaker, public Component {
|
||||
// Shared constants for I2S audio speaker implementations
|
||||
static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15;
|
||||
static constexpr size_t TASK_STACK_SIZE = 4096;
|
||||
static constexpr ssize_t TASK_PRIORITY = 19;
|
||||
|
||||
enum SpeakerEventGroupBits : uint32_t {
|
||||
COMMAND_START = (1 << 0), // indicates loop should start speaker task
|
||||
COMMAND_STOP = (1 << 1), // stops the speaker task
|
||||
COMMAND_STOP_GRACEFULLY = (1 << 2), // Stops the speaker task once all data has been written
|
||||
|
||||
TASK_STARTING = (1 << 10),
|
||||
TASK_RUNNING = (1 << 11),
|
||||
TASK_STOPPING = (1 << 12),
|
||||
TASK_STOPPED = (1 << 13),
|
||||
|
||||
ERR_ESP_NO_MEM = (1 << 19),
|
||||
|
||||
WARN_DROPPED_EVENT = (1 << 20),
|
||||
|
||||
ALL_BITS = 0x00FFFFFF, // All valid FreeRTOS event group bits
|
||||
};
|
||||
|
||||
/// @brief Abstract base class for I2S audio speaker implementations.
|
||||
/// Provides shared infrastructure (event groups, ring buffer, volume control, task lifecycle)
|
||||
/// for derived I2S speaker classes.
|
||||
class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public Component {
|
||||
public:
|
||||
float get_setup_priority() const override { return esphome::setup_priority::PROCESSOR; }
|
||||
|
||||
@@ -30,7 +54,9 @@ class I2SAudioSpeaker : public I2SAudioOut, public speaker::Speaker, public Comp
|
||||
void set_buffer_duration(uint32_t buffer_duration_ms) { this->buffer_duration_ms_ = buffer_duration_ms; }
|
||||
void set_timeout(uint32_t ms) { this->timeout_ = ms; }
|
||||
void set_dout_pin(uint8_t pin) { this->dout_pin_ = (gpio_num_t) pin; }
|
||||
void set_i2s_comm_fmt(std::string mode) { this->i2s_comm_fmt_ = std::move(mode); }
|
||||
|
||||
/// @brief Get the I2S TX channel handle
|
||||
i2s_chan_handle_t get_tx_handle() const { return this->tx_handle_; }
|
||||
|
||||
void start() override;
|
||||
void stop() override;
|
||||
@@ -63,40 +89,55 @@ class I2SAudioSpeaker : public I2SAudioOut, public speaker::Speaker, public Comp
|
||||
void set_mute_state(bool mute_state) override;
|
||||
|
||||
protected:
|
||||
/// @brief Function for the FreeRTOS task handling audio output.
|
||||
/// Allocates space for the buffers, reads audio from the ring buffer and writes audio to the I2S port. Stops
|
||||
/// immmiately after receiving the COMMAND_STOP signal and stops only after the ring buffer is empty after receiving
|
||||
/// the COMMAND_STOP_GRACEFULLY signal. Stops if the ring buffer hasn't read data for more than timeout_ milliseconds.
|
||||
/// When stopping, it deallocates the buffers. It communicates its state and any errors via ``event_group_``.
|
||||
/// @param params I2SAudioSpeaker component
|
||||
/// @brief FreeRTOS task entry point. Casts params to I2SAudioSpeakerBase and calls run_speaker_task_().
|
||||
/// @param params I2SAudioSpeakerBase component pointer
|
||||
static void speaker_task(void *params);
|
||||
|
||||
/// @brief The main speaker task loop. Implemented by derived classes for mode-specific behavior.
|
||||
virtual void run_speaker_task() = 0;
|
||||
|
||||
/// @brief Sends a stop command to the speaker task via ``event_group_``.
|
||||
/// @param wait_on_empty If false, sends the COMMAND_STOP signal. If true, sends the COMMAND_STOP_GRACEFULLY signal.
|
||||
void stop_(bool wait_on_empty);
|
||||
|
||||
/// @brief Callback function used to send playback timestamps the to the speaker task.
|
||||
/// @brief Callback function used to send playback timestamps to the speaker task.
|
||||
/// @param handle (i2s_chan_handle_t)
|
||||
/// @param event (i2s_event_data_t)
|
||||
/// @param user_ctx (void*) User context pointer that the callback accesses
|
||||
/// @return True if a higher priority task was interrupted
|
||||
static bool i2s_on_sent_cb(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx);
|
||||
|
||||
/// @brief Starts the ESP32 I2S driver.
|
||||
/// Attempts to lock the I2S port, starts the I2S driver using the passed in stream information, and sets the data out
|
||||
/// pin. If it fails, it will unlock the I2S port and uninstalls the driver, if necessary.
|
||||
/// @brief Starts the ESP32 I2S driver. Implemented by derived classes for mode-specific configuration.
|
||||
/// @param audio_stream_info Stream information for the I2S driver.
|
||||
/// @return ESP_ERR_NOT_ALLOWED if the I2S port can't play the incoming audio stream.
|
||||
/// ESP_ERR_INVALID_STATE if the I2S port is already locked.
|
||||
/// ESP_ERR_INVALID_ARG if installing the driver or setting the data outpin fails due to a parameter error.
|
||||
/// ESP_ERR_NO_MEM if the driver fails to install due to a memory allocation error.
|
||||
/// ESP_FAIL if setting the data out pin fails due to an IO error
|
||||
/// ESP_OK if successful
|
||||
esp_err_t start_i2s_driver_(audio::AudioStreamInfo &audio_stream_info);
|
||||
/// @return ESP_OK if successful, or an error code
|
||||
virtual esp_err_t start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) = 0;
|
||||
|
||||
/// @brief Shared I2S channel allocation, initialization, and event queue setup.
|
||||
/// Called by derived start_i2s_driver_() implementations after building mode-specific configs.
|
||||
/// @param chan_cfg I2S channel configuration
|
||||
/// @param std_cfg I2S standard mode configuration (clock, slot, GPIO)
|
||||
/// @param event_queue_size Size of the event queue
|
||||
/// @return ESP_OK if successful, or an error code. On failure, cleans up channel and unlocks parent.
|
||||
esp_err_t init_i2s_channel_(const i2s_chan_config_t &chan_cfg, const i2s_std_config_t &std_cfg,
|
||||
size_t event_queue_size);
|
||||
|
||||
/// @brief Stops the I2S driver and unlocks the I2S port
|
||||
void stop_i2s_driver_();
|
||||
|
||||
/// @brief Called in loop() when the task has stopped. Override for mode-specific cleanup.
|
||||
virtual void on_task_stopped() {}
|
||||
|
||||
/// @brief Apply software volume control using Q15 fixed-point scaling.
|
||||
/// @param data Pointer to audio sample data (modified in place)
|
||||
/// @param bytes_read Number of bytes of audio data
|
||||
void apply_software_volume_(uint8_t *data, size_t bytes_read);
|
||||
|
||||
/// @brief Swap adjacent 16-bit mono samples for ESP32 (non-variant) hardware quirk.
|
||||
/// Only applies when running on original ESP32 with 16-bit mono audio.
|
||||
/// @param data Pointer to audio sample data (modified in place)
|
||||
/// @param bytes_read Number of bytes of audio data
|
||||
void swap_esp32_mono_samples_(uint8_t *data, size_t bytes_read);
|
||||
|
||||
TaskHandle_t speaker_task_handle_{nullptr};
|
||||
EventGroupHandle_t event_group_{nullptr};
|
||||
|
||||
@@ -115,11 +156,9 @@ class I2SAudioSpeaker : public I2SAudioOut, public speaker::Speaker, public Comp
|
||||
audio::AudioStreamInfo current_stream_info_; // The currently loaded driver's stream info
|
||||
|
||||
gpio_num_t dout_pin_;
|
||||
std::string i2s_comm_fmt_;
|
||||
i2s_chan_handle_t tx_handle_;
|
||||
i2s_chan_handle_t tx_handle_{nullptr};
|
||||
};
|
||||
|
||||
} // namespace i2s_audio
|
||||
} // namespace esphome
|
||||
} // namespace esphome::i2s_audio
|
||||
|
||||
#endif // USE_ESP32
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
#include "i2s_audio_speaker_standard.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include <driver/i2s_std.h>
|
||||
|
||||
#include "esphome/components/audio/audio.h"
|
||||
#include "esphome/components/audio/audio_transfer_buffer.h"
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include "esp_timer.h"
|
||||
|
||||
namespace esphome::i2s_audio {
|
||||
|
||||
static const char *const TAG = "i2s_audio.speaker.std";
|
||||
|
||||
static constexpr size_t DMA_BUFFERS_COUNT = 4;
|
||||
static constexpr size_t I2S_EVENT_QUEUE_COUNT = DMA_BUFFERS_COUNT + 1;
|
||||
|
||||
void I2SAudioSpeaker::dump_config() {
|
||||
I2SAudioSpeakerBase::dump_config();
|
||||
const char *fmt_str;
|
||||
switch (this->i2s_comm_fmt_) {
|
||||
case I2SCommFmt::PCM:
|
||||
fmt_str = "pcm";
|
||||
break;
|
||||
case I2SCommFmt::MSB:
|
||||
fmt_str = "msb";
|
||||
break;
|
||||
default:
|
||||
fmt_str = "std";
|
||||
break;
|
||||
}
|
||||
ESP_LOGCONFIG(TAG, " Communication format: %s", fmt_str);
|
||||
}
|
||||
|
||||
void I2SAudioSpeaker::run_speaker_task() {
|
||||
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STARTING);
|
||||
|
||||
const uint32_t dma_buffers_duration_ms = DMA_BUFFER_DURATION_MS * DMA_BUFFERS_COUNT;
|
||||
// Ensure ring buffer duration is at least the duration of all DMA buffers
|
||||
const uint32_t ring_buffer_duration = std::max(dma_buffers_duration_ms, this->buffer_duration_ms_);
|
||||
|
||||
// The DMA buffers may have more bits per sample, so calculate buffer sizes based on the input audio stream info
|
||||
const size_t ring_buffer_size = this->current_stream_info_.ms_to_bytes(ring_buffer_duration);
|
||||
const uint32_t frames_to_fill_single_dma_buffer = this->current_stream_info_.ms_to_frames(DMA_BUFFER_DURATION_MS);
|
||||
const size_t bytes_to_fill_single_dma_buffer =
|
||||
this->current_stream_info_.frames_to_bytes(frames_to_fill_single_dma_buffer);
|
||||
|
||||
bool successful_setup = false;
|
||||
std::unique_ptr<audio::AudioSourceTransferBuffer> transfer_buffer =
|
||||
audio::AudioSourceTransferBuffer::create(bytes_to_fill_single_dma_buffer);
|
||||
|
||||
if (transfer_buffer != nullptr) {
|
||||
std::shared_ptr<RingBuffer> temp_ring_buffer = RingBuffer::create(ring_buffer_size);
|
||||
if (temp_ring_buffer.use_count() == 1) {
|
||||
transfer_buffer->set_source(temp_ring_buffer);
|
||||
this->audio_ring_buffer_ = temp_ring_buffer;
|
||||
successful_setup = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!successful_setup) {
|
||||
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM);
|
||||
} else {
|
||||
bool stop_gracefully = false;
|
||||
bool tx_dma_underflow = true;
|
||||
|
||||
uint32_t frames_written = 0;
|
||||
uint32_t last_data_received_time = millis();
|
||||
|
||||
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING);
|
||||
|
||||
// Main speaker task loop. Continues while:
|
||||
// - Paused, OR
|
||||
// - No timeout configured, OR
|
||||
// - Timeout hasn't elapsed since last data
|
||||
while (this->pause_state_ || !this->timeout_.has_value() ||
|
||||
(millis() - last_data_received_time) <= this->timeout_.value()) {
|
||||
uint32_t event_group_bits = xEventGroupGetBits(this->event_group_);
|
||||
|
||||
if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP) {
|
||||
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP);
|
||||
ESP_LOGV(TAG, "Exiting: COMMAND_STOP received");
|
||||
break;
|
||||
}
|
||||
if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY) {
|
||||
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY);
|
||||
stop_gracefully = true;
|
||||
}
|
||||
|
||||
if (this->audio_stream_info_ != this->current_stream_info_) {
|
||||
// Audio stream info changed, stop the speaker task so it will restart with the proper settings.
|
||||
ESP_LOGV(TAG, "Exiting: stream info changed");
|
||||
break;
|
||||
}
|
||||
|
||||
int64_t write_timestamp;
|
||||
while (xQueueReceive(this->i2s_event_queue_, &write_timestamp, 0)) {
|
||||
// Receives timing events from the I2S on_sent callback. If actual audio data was sent in this event, it passes
|
||||
// on the timing info via the audio_output_callback.
|
||||
uint32_t frames_sent = frames_to_fill_single_dma_buffer;
|
||||
if (frames_to_fill_single_dma_buffer > frames_written) {
|
||||
tx_dma_underflow = true;
|
||||
frames_sent = frames_written;
|
||||
const uint32_t frames_zeroed = frames_to_fill_single_dma_buffer - frames_written;
|
||||
write_timestamp -= this->current_stream_info_.frames_to_microseconds(frames_zeroed);
|
||||
} else {
|
||||
tx_dma_underflow = false;
|
||||
}
|
||||
frames_written -= frames_sent;
|
||||
|
||||
// Standard I2S mode: fire callback immediately for each event
|
||||
if (frames_sent > 0) {
|
||||
this->audio_output_callback_(frames_sent, write_timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
if (this->pause_state_) {
|
||||
// Pause state is accessed atomically, so thread safe
|
||||
// Delay so the task yields, then skip transferring audio data
|
||||
vTaskDelay(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Wait half the duration of the data already written to the DMA buffers for new audio data
|
||||
// The millisecond helper modifies the frames_written variable, so use the microsecond helper and divide by 1000
|
||||
uint32_t read_delay = (this->current_stream_info_.frames_to_microseconds(frames_written) / 1000) / 2;
|
||||
|
||||
size_t bytes_read = transfer_buffer->transfer_data_from_source(pdMS_TO_TICKS(read_delay));
|
||||
uint8_t *new_data = transfer_buffer->get_buffer_end() - bytes_read;
|
||||
|
||||
if (bytes_read > 0) {
|
||||
this->apply_software_volume_(new_data, bytes_read);
|
||||
this->swap_esp32_mono_samples_(new_data, bytes_read);
|
||||
}
|
||||
|
||||
if (transfer_buffer->available() == 0) {
|
||||
if (stop_gracefully && tx_dma_underflow) {
|
||||
break;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS / 2));
|
||||
} else {
|
||||
size_t bytes_written = 0;
|
||||
|
||||
if (tx_dma_underflow) {
|
||||
// Temporarily disable channel and callback to reset the I2S driver's internal DMA buffer queue
|
||||
i2s_channel_disable(this->tx_handle_);
|
||||
const i2s_event_callbacks_t null_callbacks = {.on_sent = nullptr};
|
||||
i2s_channel_register_event_callback(this->tx_handle_, &null_callbacks, this);
|
||||
i2s_channel_preload_data(this->tx_handle_, transfer_buffer->get_buffer_start(), transfer_buffer->available(),
|
||||
&bytes_written);
|
||||
} else {
|
||||
// Audio is already playing, use regular write to add to the DMA buffers
|
||||
i2s_channel_write(this->tx_handle_, transfer_buffer->get_buffer_start(), transfer_buffer->available(),
|
||||
&bytes_written, DMA_BUFFER_DURATION_MS);
|
||||
}
|
||||
|
||||
if (bytes_written > 0) {
|
||||
last_data_received_time = millis();
|
||||
frames_written += this->current_stream_info_.bytes_to_frames(bytes_written);
|
||||
transfer_buffer->decrease_buffer_length(bytes_written);
|
||||
|
||||
if (tx_dma_underflow) {
|
||||
tx_dma_underflow = false;
|
||||
// Enable the on_sent callback and channel after preload
|
||||
xQueueReset(this->i2s_event_queue_);
|
||||
const i2s_event_callbacks_t callbacks = {.on_sent = i2s_on_sent_cb};
|
||||
i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this);
|
||||
i2s_channel_enable(this->tx_handle_);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPING);
|
||||
|
||||
if (transfer_buffer != nullptr) {
|
||||
transfer_buffer.reset();
|
||||
}
|
||||
|
||||
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPED);
|
||||
|
||||
while (true) {
|
||||
// Continuously delay until the loop method deletes the task
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) {
|
||||
this->current_stream_info_ = audio_stream_info;
|
||||
|
||||
if ((this->i2s_role_ & I2S_ROLE_SLAVE) && (this->sample_rate_ != audio_stream_info.get_sample_rate())) { // NOLINT
|
||||
// Can't reconfigure I2S bus, so the sample rate must match the configured value
|
||||
ESP_LOGE(TAG, "Incompatible stream settings");
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO &&
|
||||
(i2s_slot_bit_width_t) audio_stream_info.get_bits_per_sample() > this->slot_bit_width_) {
|
||||
// Currently can't handle the case when the incoming audio has more bits per sample than the configured value
|
||||
ESP_LOGE(TAG, "Stream bits per sample must be less than or equal to the speaker's configuration");
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
if (!this->parent_->try_lock()) {
|
||||
ESP_LOGE(TAG, "Parent bus is busy");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
uint32_t dma_buffer_length = audio_stream_info.ms_to_frames(DMA_BUFFER_DURATION_MS);
|
||||
|
||||
i2s_role_t i2s_role = this->i2s_role_;
|
||||
i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT;
|
||||
|
||||
#if SOC_CLK_APLL_SUPPORTED
|
||||
if (this->use_apll_) {
|
||||
clk_src = i2s_clock_src_t::I2S_CLK_SRC_APLL;
|
||||
}
|
||||
#endif // SOC_CLK_APLL_SUPPORTED
|
||||
|
||||
// Log DMA configuration for debugging
|
||||
ESP_LOGV(TAG, "I2S DMA config: %zu buffers x %lu frames", (size_t) DMA_BUFFERS_COUNT,
|
||||
(unsigned long) dma_buffer_length);
|
||||
|
||||
i2s_chan_config_t chan_cfg = {
|
||||
.id = this->parent_->get_port(),
|
||||
.role = i2s_role,
|
||||
.dma_desc_num = DMA_BUFFERS_COUNT,
|
||||
.dma_frame_num = dma_buffer_length,
|
||||
.auto_clear = true,
|
||||
.intr_priority = 3,
|
||||
};
|
||||
|
||||
// Build standard I2S clock/slot/gpio configuration
|
||||
i2s_std_clk_config_t clk_cfg = {
|
||||
.sample_rate_hz = audio_stream_info.get_sample_rate(),
|
||||
.clk_src = clk_src,
|
||||
.mclk_multiple = this->mclk_multiple_,
|
||||
};
|
||||
|
||||
i2s_slot_mode_t slot_mode = this->slot_mode_;
|
||||
i2s_std_slot_mask_t slot_mask = this->std_slot_mask_;
|
||||
if (audio_stream_info.get_channels() == 1) {
|
||||
slot_mode = I2S_SLOT_MODE_MONO;
|
||||
} else if (audio_stream_info.get_channels() == 2) {
|
||||
slot_mode = I2S_SLOT_MODE_STEREO;
|
||||
slot_mask = I2S_STD_SLOT_BOTH;
|
||||
}
|
||||
|
||||
i2s_std_slot_config_t slot_cfg;
|
||||
switch (this->i2s_comm_fmt_) {
|
||||
case I2SCommFmt::PCM:
|
||||
slot_cfg =
|
||||
I2S_STD_PCM_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), slot_mode);
|
||||
break;
|
||||
case I2SCommFmt::MSB:
|
||||
slot_cfg =
|
||||
I2S_STD_MSB_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), slot_mode);
|
||||
break;
|
||||
default:
|
||||
slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(),
|
||||
slot_mode);
|
||||
break;
|
||||
}
|
||||
|
||||
#ifdef USE_ESP32_VARIANT_ESP32
|
||||
// There seems to be a bug on the ESP32 (non-variant) platform where setting the slot bit width higher than the
|
||||
// bits per sample causes the audio to play too fast. Setting the ws_width to the configured slot bit width seems
|
||||
// to make it play at the correct speed while sending more bits per slot.
|
||||
if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO) {
|
||||
uint32_t configured_bit_width = static_cast<uint32_t>(this->slot_bit_width_);
|
||||
slot_cfg.ws_width = configured_bit_width;
|
||||
if (configured_bit_width > 16) {
|
||||
slot_cfg.msb_right = false;
|
||||
}
|
||||
}
|
||||
#else
|
||||
slot_cfg.slot_bit_width = this->slot_bit_width_;
|
||||
#endif // USE_ESP32_VARIANT_ESP32
|
||||
slot_cfg.slot_mask = slot_mask;
|
||||
|
||||
i2s_std_gpio_config_t gpio_cfg = this->parent_->get_pin_config();
|
||||
gpio_cfg.dout = this->dout_pin_;
|
||||
|
||||
i2s_std_config_t std_cfg = {
|
||||
.clk_cfg = clk_cfg,
|
||||
.slot_cfg = slot_cfg,
|
||||
.gpio_cfg = gpio_cfg,
|
||||
};
|
||||
|
||||
esp_err_t err = this->init_i2s_channel_(chan_cfg, std_cfg, I2S_EVENT_QUEUE_COUNT);
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
|
||||
i2s_channel_enable(this->tx_handle_);
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
} // namespace esphome::i2s_audio
|
||||
|
||||
#endif // USE_ESP32
|
||||
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include "i2s_audio_speaker.h"
|
||||
|
||||
namespace esphome::i2s_audio {
|
||||
|
||||
enum class I2SCommFmt : uint8_t {
|
||||
STANDARD, // Philips / I2S standard
|
||||
PCM, // PCM short
|
||||
MSB, // MSB / left-justified
|
||||
};
|
||||
|
||||
/// @brief Standard I2S speaker implementation.
|
||||
/// Outputs PCM audio data directly to an I2S DAC using the standard I2S protocol.
|
||||
class I2SAudioSpeaker : public I2SAudioSpeakerBase {
|
||||
public:
|
||||
void dump_config() override;
|
||||
|
||||
void set_i2s_comm_fmt(I2SCommFmt fmt) { this->i2s_comm_fmt_ = fmt; }
|
||||
|
||||
protected:
|
||||
void run_speaker_task() override;
|
||||
esp_err_t start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) override;
|
||||
|
||||
I2SCommFmt i2s_comm_fmt_{I2SCommFmt::STANDARD};
|
||||
};
|
||||
|
||||
} // namespace esphome::i2s_audio
|
||||
|
||||
#endif // USE_ESP32
|
||||
@@ -20,8 +20,6 @@ void InternalTemperatureSensor::update() {
|
||||
success = (result == 0);
|
||||
#if defined(USE_LIBRETINY_VARIANT_BK7231N)
|
||||
temperature = raw * -0.38f + 156.0f;
|
||||
#elif defined(USE_LIBRETINY_VARIANT_BK7231T)
|
||||
temperature = raw * 0.04f;
|
||||
#else // USE_LIBRETINY_VARIANT
|
||||
temperature = raw * 0.128f;
|
||||
#endif // USE_LIBRETINY_VARIANT
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,21 +109,21 @@ CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.SplitDefault(
|
||||
CONF_ENABLE_IPV6,
|
||||
esp8266=False,
|
||||
esp32=False,
|
||||
rp2040=False,
|
||||
bk72xx=False,
|
||||
esp32=False,
|
||||
esp8266=False,
|
||||
host=False,
|
||||
rp2040=False,
|
||||
): cv.All(
|
||||
cv.boolean,
|
||||
cv.Any(
|
||||
cv.require_framework_version(
|
||||
bk72xx_arduino=cv.Version(1, 7, 0),
|
||||
esp_idf=cv.Version(0, 0, 0),
|
||||
esp32_arduino=cv.Version(0, 0, 0),
|
||||
esp8266_arduino=cv.Version(0, 0, 0),
|
||||
rp2040_arduino=cv.Version(0, 0, 0),
|
||||
bk72xx_arduino=cv.Version(1, 7, 0),
|
||||
host=cv.Version(0, 0, 0),
|
||||
rp2040_arduino=cv.Version(0, 0, 0),
|
||||
),
|
||||
cv.boolean_false,
|
||||
),
|
||||
@@ -218,9 +218,9 @@ async def to_code(config):
|
||||
elif enable_ipv6:
|
||||
cg.add_build_flag("-DCONFIG_LWIP_IPV6")
|
||||
cg.add_build_flag("-DCONFIG_LWIP_IPV6_AUTOCONFIG")
|
||||
if CORE.is_rp2040:
|
||||
cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_ENABLE_IPV6")
|
||||
if CORE.is_esp8266:
|
||||
cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY")
|
||||
if CORE.is_bk72xx:
|
||||
cg.add_build_flag("-DCONFIG_IPV6")
|
||||
if CORE.is_esp8266:
|
||||
cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY")
|
||||
if CORE.is_rp2040:
|
||||
cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_ENABLE_IPV6")
|
||||
|
||||
@@ -57,8 +57,11 @@ void OneWireBus::search() {
|
||||
}
|
||||
}
|
||||
|
||||
void OneWireBus::skip() {
|
||||
bool OneWireBus::skip() {
|
||||
if (!this->reset_())
|
||||
return false;
|
||||
this->write8(0xCC); // skip ROM
|
||||
return true;
|
||||
}
|
||||
|
||||
const LogString *OneWireBus::get_model_str(uint8_t model) {
|
||||
|
||||
@@ -16,7 +16,8 @@ class OneWireBus {
|
||||
virtual void write64(uint64_t val) = 0;
|
||||
|
||||
/// Write a command to the bus that addresses all devices by skipping the ROM.
|
||||
void skip();
|
||||
/// Returns true if a device presence pulse is detected.
|
||||
bool skip();
|
||||
|
||||
/// Read an 8 bit word from the bus.
|
||||
virtual uint8_t read8() = 0;
|
||||
|
||||
@@ -62,7 +62,10 @@ void IRAM_ATTR PCA6416AComponent::gpio_intr(PCA6416AComponent *arg) { arg->enabl
|
||||
void PCA6416AComponent::loop() {
|
||||
// Invalidate cache at the start of each loop
|
||||
this->reset_pin_cache_();
|
||||
if (this->interrupt_pin_ != nullptr) {
|
||||
// Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the
|
||||
// I2C read leave INT asserted without re-firing a falling edge, which would strand us with
|
||||
// stale state forever; keep looping until the line is released so we self-heal.
|
||||
if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) {
|
||||
this->disable_loop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,8 +50,10 @@ void IRAM_ATTR PCA9554Component::gpio_intr(PCA9554Component *arg) { arg->enable_
|
||||
void PCA9554Component::loop() {
|
||||
// Invalidate the cache so the next digital_read() triggers a fresh I2C read
|
||||
this->reset_pin_cache_();
|
||||
if (this->interrupt_pin_ != nullptr) {
|
||||
// Interrupt-driven: disable loop until next interrupt fires
|
||||
// Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the
|
||||
// I2C read leave INT asserted without re-firing a falling edge, which would strand us with
|
||||
// stale state forever; keep looping until the line is released so we self-heal.
|
||||
if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) {
|
||||
this->disable_loop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,8 +31,10 @@ void IRAM_ATTR PCF8574Component::gpio_intr(PCF8574Component *arg) { arg->enable_
|
||||
void PCF8574Component::loop() {
|
||||
// Invalidate the cache so the next digital_read() triggers a fresh I2C read
|
||||
this->reset_pin_cache_();
|
||||
if (this->interrupt_pin_ != nullptr) {
|
||||
// Interrupt-driven: disable loop until next interrupt fires
|
||||
// Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the
|
||||
// I2C read leave INT asserted without re-firing a falling edge, which would strand us with
|
||||
// stale state forever; keep looping until the line is released so we self-heal.
|
||||
if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) {
|
||||
this->disable_loop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,10 @@ void PI4IOE5V6408Component::pin_mode(uint8_t pin, gpio::Flags flags) {
|
||||
|
||||
void PI4IOE5V6408Component::loop() {
|
||||
this->reset_pin_cache_();
|
||||
if (this->interrupt_pin_ != nullptr) {
|
||||
// Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the
|
||||
// I2C read leave INT asserted without re-firing a falling edge, which would strand us with
|
||||
// stale state forever; keep looping until the line is released so we self-heal.
|
||||
if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) {
|
||||
this->disable_loop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,146 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import esp32, network, psram, socket, wifi
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_TASK_STACK_IN_PSRAM
|
||||
from esphome.core import CORE
|
||||
from esphome.types import ConfigType
|
||||
|
||||
# mdns for autodiscovery
|
||||
AUTO_LOAD = ["mdns"]
|
||||
CODEOWNERS = ["@kahrendt"]
|
||||
DEPENDENCIES = ["network"]
|
||||
DOMAIN = "sendspin"
|
||||
|
||||
# Trailing underscore avoids clashing with sendspin-cpp's global `sendspin` namespace.
|
||||
# Analysis tools strip the trailing underscore (same pattern as `template_`).
|
||||
sendspin_ns = cg.esphome_ns.namespace("sendspin_")
|
||||
SendspinHub = sendspin_ns.class_(
|
||||
"SendspinHub",
|
||||
cg.Component,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SendspinConfiguration:
|
||||
artwork_support: bool = False
|
||||
controller_support: bool = False
|
||||
metadata_support: bool = False
|
||||
player_support: bool = False
|
||||
visualizer_support: bool = False
|
||||
|
||||
|
||||
def _get_data() -> SendspinConfiguration:
|
||||
if DOMAIN not in CORE.data:
|
||||
CORE.data[DOMAIN] = SendspinConfiguration()
|
||||
return CORE.data[DOMAIN]
|
||||
|
||||
|
||||
def request_artwork_support() -> None:
|
||||
"""Request artwork role support for Sendspin."""
|
||||
_get_data().artwork_support = True
|
||||
|
||||
|
||||
def request_controller_support() -> None:
|
||||
"""Request controller role support for Sendspin."""
|
||||
_get_data().controller_support = True
|
||||
|
||||
|
||||
def request_metadata_support() -> None:
|
||||
"""Request metadata role support for Sendspin."""
|
||||
_get_data().metadata_support = True
|
||||
|
||||
|
||||
def request_player_support() -> None:
|
||||
"""Request player role support for Sendspin."""
|
||||
_get_data().player_support = True
|
||||
|
||||
|
||||
def request_visualizer_support() -> None:
|
||||
"""Request visualizer role support for Sendspin."""
|
||||
_get_data().visualizer_support = True
|
||||
|
||||
|
||||
def _validate_task_stack_in_psram(value):
|
||||
value = cv.boolean(value)
|
||||
if value:
|
||||
return cv.requires_component(psram.DOMAIN)(value)
|
||||
return value
|
||||
|
||||
|
||||
def _request_high_performance_networking(config: ConfigType) -> ConfigType:
|
||||
"""Request high performance networking for Sendspin streaming.
|
||||
|
||||
Also enables wake_loop_threadsafe support for fast defer() callbacks
|
||||
from background threads (WebSocket handler, image decoder).
|
||||
"""
|
||||
network.require_high_performance_networking()
|
||||
# Socket consumption varies by mode:
|
||||
# - Server mode: 1 listening socket + 2 client connections (for handoff)
|
||||
# - Client mode: 1 outbound connection
|
||||
socket.consume_sockets(
|
||||
1, "sendspin_websocket_server", socket.SocketType.TCP_LISTEN
|
||||
)(config)
|
||||
socket.consume_sockets(2, "sendspin_websocket_server")(config)
|
||||
socket.consume_sockets(1, "sendspin_websocket_client")(config)
|
||||
|
||||
wifi.enable_runtime_power_save_control()
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(SendspinHub),
|
||||
cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram,
|
||||
}
|
||||
),
|
||||
cv.only_on_esp32,
|
||||
_request_high_performance_networking,
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
|
||||
if config.get(CONF_TASK_STACK_IN_PSRAM):
|
||||
cg.add(var.set_task_stack_in_psram(True))
|
||||
esp32.add_idf_sdkconfig_option(
|
||||
"CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True
|
||||
)
|
||||
|
||||
# sendspin-cpp library
|
||||
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.3.0")
|
||||
|
||||
cg.add_define("USE_SENDSPIN", True) # for MDNS
|
||||
|
||||
data = _get_data()
|
||||
|
||||
# Configure Sendspin roles based on requested features (ESPHome internally via USE_SENDSPIN_*)
|
||||
# and disable building unused code paths in the sendspin-cpp library (IDF SDKConfig via CONFIG_SENDSPIN_ENABLE_*).
|
||||
if data.artwork_support:
|
||||
cg.add_define("USE_SENDSPIN_ARTWORK", True)
|
||||
else:
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_ARTWORK", False)
|
||||
|
||||
if data.controller_support:
|
||||
cg.add_define("USE_SENDSPIN_CONTROLLER", True)
|
||||
else:
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_CONTROLLER", False)
|
||||
|
||||
if data.metadata_support:
|
||||
cg.add_define("USE_SENDSPIN_METADATA", True)
|
||||
else:
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_METADATA", False)
|
||||
|
||||
if data.player_support:
|
||||
cg.add_define("USE_SENDSPIN_PLAYER", True)
|
||||
else:
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_PLAYER", False)
|
||||
|
||||
if data.visualizer_support:
|
||||
cg.add_define("USE_SENDSPIN_VISUALIZER", True)
|
||||
else:
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_VISUALIZER", False)
|
||||
@@ -0,0 +1,143 @@
|
||||
#include "sendspin_hub.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include "esphome/components/network/util.h"
|
||||
#ifdef USE_WIFI
|
||||
#include "esphome/components/wifi/wifi_component.h"
|
||||
#endif
|
||||
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/version.h"
|
||||
|
||||
#include <esp_log.h>
|
||||
|
||||
namespace esphome::sendspin_ {
|
||||
|
||||
static const char *const TAG = "sendspin.hub";
|
||||
|
||||
void SendspinHub::setup() {
|
||||
auto config = this->build_client_config_();
|
||||
this->client_ = std::make_unique<sendspin::SendspinClient>(std::move(config));
|
||||
|
||||
// Set up persistence (preferences must be initialized before providers are added to the client)
|
||||
this->last_played_server_pref_ =
|
||||
global_preferences->make_preference<LastPlayedServerPref>(fnv1a_hash("sendspin_last_played"));
|
||||
|
||||
// Wire providers and client listener
|
||||
this->client_->set_listener(this);
|
||||
this->client_->set_network_provider(this);
|
||||
this->client_->set_persistence_provider(this);
|
||||
|
||||
if (!this->client_->start_server()) {
|
||||
ESP_LOGE(TAG, "Failed to start Sendspin server");
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void SendspinHub::loop() { this->client_->loop(); }
|
||||
|
||||
void SendspinHub::dump_config() {
|
||||
char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Sendspin Hub:\n"
|
||||
" Client ID: %s\n"
|
||||
" Task stack in PSRAM: %s",
|
||||
get_mac_address_pretty_into_buffer(mac_buf), YESNO(this->task_stack_in_psram_));
|
||||
}
|
||||
|
||||
// --- Delegating methods ---
|
||||
|
||||
// THREAD CONTEXT: Main loop (invoked from Sendspin components)
|
||||
void SendspinHub::connect_to_server(const std::string &url) {
|
||||
if (this->is_ready()) {
|
||||
this->client_->connect_to(url);
|
||||
}
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (invoked from Sendspin components)
|
||||
void SendspinHub::disconnect_from_server(sendspin::SendspinGoodbyeReason reason) {
|
||||
if (this->is_ready()) {
|
||||
this->client_->disconnect(reason);
|
||||
}
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (invoked from Sendspin components)
|
||||
void SendspinHub::update_state(sendspin::SendspinClientState state) {
|
||||
if (this->is_ready()) {
|
||||
this->client_->update_state(state);
|
||||
}
|
||||
}
|
||||
|
||||
sendspin::SendspinClientConfig SendspinHub::build_client_config_() {
|
||||
sendspin::SendspinClientConfig config;
|
||||
|
||||
char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
config.client_id = get_mac_address_pretty_into_buffer(mac_buf);
|
||||
config.name = App.get_friendly_name();
|
||||
config.product_name = App.get_name();
|
||||
config.manufacturer = "ESPHome";
|
||||
config.software_version = ESPHOME_VERSION;
|
||||
config.httpd_psram_stack = this->task_stack_in_psram_;
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
// --- SendspinClientListener overrides ---
|
||||
// THREAD CONTEXT: Main loop (fired from client_->loop())
|
||||
|
||||
void SendspinHub::on_group_update(const sendspin::GroupUpdateObject &group) {
|
||||
this->group_update_callbacks_.call(group);
|
||||
}
|
||||
|
||||
void SendspinHub::on_request_high_performance() {
|
||||
#ifdef USE_WIFI
|
||||
if (wifi::global_wifi_component != nullptr) {
|
||||
wifi::global_wifi_component->request_high_performance();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void SendspinHub::on_release_high_performance() {
|
||||
#ifdef USE_WIFI
|
||||
if (wifi::global_wifi_component != nullptr) {
|
||||
wifi::global_wifi_component->release_high_performance();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// --- SendspinNetworkProvider override ---
|
||||
|
||||
// THREAD CONTEXT: Main loop (polled by client_->loop())
|
||||
bool SendspinHub::is_network_ready() { return network::is_connected(); }
|
||||
|
||||
// --- SendspinPersistenceProvider overrides ---
|
||||
|
||||
// THREAD CONTEXT: Main loop (invoked by client_->loop() during lifecycle events)
|
||||
bool SendspinHub::save_last_server_hash(uint32_t hash) {
|
||||
LastPlayedServerPref pref{.server_id_hash = hash};
|
||||
bool ok = this->last_played_server_pref_.save(&pref);
|
||||
if (ok) {
|
||||
ESP_LOGD(TAG, "Persisted last played server hash: 0x%08X", hash);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Failed to persist last played server hash");
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (invoked by client_->loop() during lifecycle events)
|
||||
std::optional<uint32_t> SendspinHub::load_last_server_hash() {
|
||||
LastPlayedServerPref pref{};
|
||||
if (this->last_played_server_pref_.load(&pref)) {
|
||||
ESP_LOGI(TAG, "Loaded last played server hash: 0x%08X", pref.server_id_hash);
|
||||
return pref.server_id_hash;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
} // namespace esphome::sendspin_
|
||||
|
||||
#endif // USE_ESP32
|
||||
@@ -0,0 +1,138 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/preferences.h"
|
||||
|
||||
#include <sendspin/client.h>
|
||||
#include <sendspin/config.h>
|
||||
#include <sendspin/types.h>
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
|
||||
namespace esphome::sendspin_ {
|
||||
|
||||
/// @brief Setup priorities for the sendspin hub and its child components.
|
||||
///
|
||||
/// Centralized here so every sendspin component orders itself relative to the hub
|
||||
/// without each subcomponent having to pick a priority independently. Children run
|
||||
/// one step later than hub so they can assume hub's setup() has already completed.
|
||||
namespace sendspin_priority {
|
||||
inline constexpr float HUB = esphome::setup_priority::PROCESSOR;
|
||||
inline constexpr float CHILD = HUB - 1.0f;
|
||||
} // namespace sendspin_priority
|
||||
|
||||
/// @brief Persistent storage structure for last played server hash.
|
||||
struct LastPlayedServerPref {
|
||||
uint32_t server_id_hash;
|
||||
};
|
||||
|
||||
/// @brief Thin adapter over sendspin::SendspinClient.
|
||||
///
|
||||
/// The hub owns a SendspinClient instance and bridges its listener/provider interfaces to ESPHome's CallbackManager for
|
||||
/// fan-out to child components.
|
||||
/// - Provides persistence via ESPPreferenceObject and WiFi power management integration.
|
||||
/// - Handles Sendspin roles that apply to multiple child components (artwork, controller, metadata) so their events
|
||||
/// can be fanned out. Roles specific to a single component (player) are configured by the hub but owned by the
|
||||
/// child thereafter, since no fan-out is needed.
|
||||
///
|
||||
/// The sendspin-cpp library follows this design:
|
||||
/// - Core and role configuration are passed at client/role construction time as structs. Built in our `setup()`.
|
||||
/// - Library -> user code communication happens via two interface types the user implements and registers in our
|
||||
/// `setup()`: listener interfaces (for events the library pushes; e.g., group updates) and provider interfaces
|
||||
/// (for services the library pulls; e.g., persistence, network readiness).
|
||||
/// - User -> library communication uses exposed functions on the client and role objects that the user calls.
|
||||
class SendspinHub final : public Component,
|
||||
public sendspin::SendspinClientListener,
|
||||
public sendspin::SendspinNetworkProvider,
|
||||
public sendspin::SendspinPersistenceProvider {
|
||||
public:
|
||||
float get_setup_priority() const override { return sendspin_priority::HUB; }
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
|
||||
/// @brief Connects the underlying client to the given Sendspin server.
|
||||
///
|
||||
/// No-op if the hub's client is not ready (e.g. setup() has not completed).
|
||||
/// Must be called from the main loop thread.
|
||||
/// @param url WebSocket URL of the Sendspin server, starting with `ws://` (e.g. `ws://host:port/path`).
|
||||
void connect_to_server(const std::string &url);
|
||||
|
||||
/// @brief Disconnects the underlying client from the current server.
|
||||
///
|
||||
/// Sends a `client/goodbye` message with the given reason before closing the connection.
|
||||
/// No-op if the hub's client is not ready. Must be called from the main loop thread.
|
||||
/// @param reason Reason reported to the server:
|
||||
/// - `ANOTHER_SERVER`: client is switching to another server.
|
||||
/// - `SHUTDOWN`: client is shutting down.
|
||||
/// - `RESTART`: client is restarting.
|
||||
/// - `USER_REQUEST`: user explicitly requested disconnect.
|
||||
void disconnect_from_server(sendspin::SendspinGoodbyeReason reason);
|
||||
|
||||
/// @brief Updates the client's reported playback state on the server.
|
||||
///
|
||||
/// No-op if the hub's client is not ready. Must be called from the main loop thread.
|
||||
/// @param state New client state:
|
||||
/// - `SYNCHRONIZED`: client is synchronized and playing from the server.
|
||||
/// - `ERROR`: client encountered a playback error.
|
||||
/// - `EXTERNAL_SOURCE`: client is playing from a non-Sendspin source.
|
||||
void update_state(sendspin::SendspinClientState state);
|
||||
|
||||
// --- Configuration setters (called from codegen) ---
|
||||
|
||||
template<typename F> void add_group_update_callback(F &&callback) {
|
||||
this->group_update_callbacks_.add(std::forward<F>(callback));
|
||||
}
|
||||
|
||||
void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; }
|
||||
|
||||
protected:
|
||||
/// @brief Builds the SendspinClientConfig from ESPHome configuration and platform info.
|
||||
sendspin::SendspinClientConfig build_client_config_();
|
||||
|
||||
// --- SendspinClientListener overrides ---
|
||||
void on_group_update(const sendspin::GroupUpdateObject &group) override;
|
||||
|
||||
void on_request_high_performance() override;
|
||||
|
||||
void on_release_high_performance() override;
|
||||
|
||||
// --- SendspinNetworkProvider override ---
|
||||
bool is_network_ready() override;
|
||||
|
||||
// --- SendspinPersistenceProvider overrides ---
|
||||
bool save_last_server_hash(uint32_t hash) override;
|
||||
std::optional<uint32_t> load_last_server_hash() override;
|
||||
|
||||
ESPPreferenceObject last_played_server_pref_;
|
||||
|
||||
std::unique_ptr<sendspin::SendspinClient> client_;
|
||||
|
||||
// Callback fan-out to child components
|
||||
CallbackManager<void(const sendspin::GroupUpdateObject &)> group_update_callbacks_{};
|
||||
|
||||
bool task_stack_in_psram_{false};
|
||||
};
|
||||
|
||||
/// @brief Base class for all sendspin subcomponents.
|
||||
///
|
||||
/// Consolidates the Component + Parented<SendspinHub> inheritance and pins the setup
|
||||
/// priority so the hub's setup() always runs before any child. Subcomponents should
|
||||
/// inherit from this instead of listing Component/Parented individually and must not
|
||||
/// override get_setup_priority().
|
||||
class SendspinChild : public Component, public Parented<SendspinHub> {
|
||||
public:
|
||||
float get_setup_priority() const override { return sendspin_priority::CHILD; }
|
||||
};
|
||||
|
||||
} // namespace esphome::sendspin_
|
||||
|
||||
#endif // USE_ESP32
|
||||
@@ -6,6 +6,9 @@
|
||||
|
||||
#include <cstring>
|
||||
#include "esphome/core/application.h"
|
||||
#ifdef USE_HOST
|
||||
#include "esphome/core/wake.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::socket {
|
||||
|
||||
@@ -16,7 +19,7 @@ BSDSocketImpl::BSDSocketImpl(int fd, bool monitor_loop) {
|
||||
#ifdef USE_LWIP_FAST_SELECT
|
||||
this->cached_sock_ = hook_fd_for_fast_select(this->fd_);
|
||||
#else
|
||||
this->loop_monitored_ = App.register_socket_fd(this->fd_);
|
||||
this->loop_monitored_ = wake_register_fd(this->fd_);
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -36,7 +39,7 @@ int BSDSocketImpl::close() {
|
||||
this->cached_sock_ = nullptr;
|
||||
#else
|
||||
if (this->loop_monitored_) {
|
||||
App.unregister_socket_fd(this->fd_);
|
||||
wake_unregister_fd(this->fd_);
|
||||
}
|
||||
#endif
|
||||
int ret = ::close(this->fd_);
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
|
||||
#include <cstring>
|
||||
#include "esphome/core/application.h"
|
||||
#ifdef USE_HOST
|
||||
#include "esphome/core/wake.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::socket {
|
||||
|
||||
@@ -16,7 +19,7 @@ LwIPSocketImpl::LwIPSocketImpl(int fd, bool monitor_loop) {
|
||||
#ifdef USE_LWIP_FAST_SELECT
|
||||
this->cached_sock_ = hook_fd_for_fast_select(this->fd_);
|
||||
#else
|
||||
this->loop_monitored_ = App.register_socket_fd(this->fd_);
|
||||
this->loop_monitored_ = wake_register_fd(this->fd_);
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -36,7 +39,7 @@ int LwIPSocketImpl::close() {
|
||||
this->cached_sock_ = nullptr;
|
||||
#else
|
||||
if (this->loop_monitored_) {
|
||||
App.unregister_socket_fd(this->fd_);
|
||||
wake_unregister_fd(this->fd_);
|
||||
}
|
||||
#endif
|
||||
int ret = lwip_close(this->fd_);
|
||||
|
||||
@@ -5,13 +5,16 @@
|
||||
#include <string>
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/application.h"
|
||||
#ifdef USE_HOST
|
||||
#include "esphome/core/wake.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::socket {
|
||||
|
||||
#ifdef USE_HOST
|
||||
// Shared ready() implementation for fd-based socket implementations (BSD and LWIP sockets).
|
||||
// Checks if the Application's select() loop has marked this fd as ready.
|
||||
bool socket_ready_fd(int fd, bool loop_monitored) { return !loop_monitored || App.is_socket_ready_(fd); }
|
||||
// Checks if the host wake select() loop has marked this fd as ready.
|
||||
bool socket_ready_fd(int fd, bool loop_monitored) { return !loop_monitored || wake_fd_ready(fd); }
|
||||
#endif
|
||||
|
||||
// Platform-specific inet_ntop wrappers
|
||||
|
||||
@@ -57,7 +57,10 @@ void TCA9555Component::pin_mode(uint8_t pin, gpio::Flags flags) {
|
||||
}
|
||||
void TCA9555Component::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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import errno
|
||||
from importlib import resources
|
||||
import logging
|
||||
|
||||
@@ -74,6 +75,12 @@ def _load_tzdata(iana_key: str) -> bytes | None:
|
||||
return (resources.files(package) / resource).read_bytes()
|
||||
except (FileNotFoundError, ModuleNotFoundError, IsADirectoryError):
|
||||
return None
|
||||
except OSError as e:
|
||||
# Windows raises EINVAL for paths with NTFS-illegal chars (e.g. '<'/'>'
|
||||
# in POSIX TZ strings like "<+08>-8" that validate_tz feeds back here).
|
||||
if e.errno == errno.EINVAL:
|
||||
return None
|
||||
raise
|
||||
|
||||
|
||||
def _extract_tz_string(tzfile: bytes) -> str:
|
||||
|
||||
@@ -116,12 +116,23 @@ CONFIG_SCHEMA = cv.ensure_list(
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
# The output chunk pool/queue are compile-time-sized templates shared by all
|
||||
# USBUartChannel instances, so use the largest buffer_size across every channel
|
||||
# of every device. Each chunk is 64 bytes (USB FS MPS); add one extra slot
|
||||
# because LockFreeQueue<T,N> is a ring buffer that wastes one entry.
|
||||
max_buffer_size = max(
|
||||
channel[CONF_BUFFER_SIZE]
|
||||
for device in config
|
||||
for channel in device[CONF_CHANNELS]
|
||||
)
|
||||
output_chunk_count = max_buffer_size // 64 + 1
|
||||
cg.add_define("USB_UART_OUTPUT_CHUNK_COUNT", output_chunk_count)
|
||||
|
||||
for device in config:
|
||||
var = await register_usb_client(device)
|
||||
for index, channel in enumerate(device[CONF_CHANNELS]):
|
||||
chvar = cg.new_Pvariable(channel[CONF_ID], index, channel[CONF_BUFFER_SIZE])
|
||||
await cg.register_parented(chvar, var)
|
||||
cg.add(chvar.set_rx_buffer_size(channel[CONF_BUFFER_SIZE]))
|
||||
cg.add(chvar.set_stop_bits(channel[CONF_STOP_BITS]))
|
||||
cg.add(chvar.set_data_bits(channel[CONF_DATA_BITS]))
|
||||
cg.add(chvar.set_parity(channel[CONF_PARITY]))
|
||||
|
||||
@@ -132,8 +132,9 @@ class USBUartChannel : public uart::UARTComponent, public Parented<USBUartCompon
|
||||
friend class USBUartTypeCH34X;
|
||||
|
||||
public:
|
||||
// Number of output chunk slots per channel (8 × 64 bytes = 512 bytes peak, lazily allocated)
|
||||
static constexpr uint8_t USB_OUTPUT_CHUNK_COUNT = 8;
|
||||
// Number of output chunk slots per channel, derived from buffer_size config.
|
||||
// Computed as ceil(buffer_size / 64) + 1 in Python codegen; defaults to 5 (256 / 64 + 1).
|
||||
static constexpr uint8_t USB_OUTPUT_CHUNK_COUNT = USB_UART_OUTPUT_CHUNK_COUNT;
|
||||
|
||||
USBUartChannel(uint8_t index, uint16_t buffer_size) : index_(index), input_buffer_(RingBuffer(buffer_size)) {}
|
||||
void write_array(const uint8_t *data, size_t len) override;
|
||||
|
||||
@@ -145,6 +145,12 @@ bool ListEntitiesIterator::on_infrared(infrared::Infrared *obj) {
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
bool ListEntitiesIterator::on_radio_frequency(radio_frequency::RadioFrequency *obj) {
|
||||
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::radio_frequency_all_json_generator);
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_EVENT
|
||||
bool ListEntitiesIterator::on_event(event::Event *obj) {
|
||||
|
||||
@@ -87,6 +87,9 @@ class ListEntitiesIterator final : public ComponentIterator {
|
||||
#ifdef USE_INFRARED
|
||||
bool on_infrared(infrared::Infrared *obj) override;
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
bool on_radio_frequency(radio_frequency::RadioFrequency *obj) override;
|
||||
#endif
|
||||
#ifdef USE_EVENT
|
||||
bool on_event(event::Event *obj) override;
|
||||
#endif
|
||||
|
||||
@@ -40,6 +40,9 @@
|
||||
#ifdef USE_INFRARED
|
||||
#include "esphome/components/infrared/infrared.h"
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
#include "esphome/components/radio_frequency/radio_frequency.h"
|
||||
#endif
|
||||
|
||||
#ifdef USE_WEBSERVER_LOCAL
|
||||
#if USE_WEBSERVER_VERSION == 2
|
||||
@@ -2102,6 +2105,104 @@ json::SerializationBuffer<> WebServer::infrared_json_(infrared::Infrared *obj, J
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
void WebServer::handle_radio_frequency_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||
for (radio_frequency::RadioFrequency *obj : App.get_radio_frequencies()) {
|
||||
auto entity_match = match.match_entity(obj);
|
||||
if (!entity_match.matched)
|
||||
continue;
|
||||
|
||||
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
|
||||
auto detail = get_request_detail(request);
|
||||
auto data = this->radio_frequency_json_(obj, detail);
|
||||
request->send(200, ESPHOME_F("application/json"), data.c_str());
|
||||
return;
|
||||
}
|
||||
if (!match.method_equals(ESPHOME_F("transmit"))) {
|
||||
request->send(404);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only allow transmit if the device supports it
|
||||
if (!(obj->get_capability_flags() & radio_frequency::CAPABILITY_TRANSMITTER)) {
|
||||
request->send(400, ESPHOME_F("text/plain"), ESPHOME_F("Device does not support transmission"));
|
||||
return;
|
||||
}
|
||||
|
||||
auto call = obj->make_call();
|
||||
|
||||
// Parse carrier frequency (optional — overrides IC default)
|
||||
{
|
||||
auto value = parse_number<uint32_t>(request->arg(ESPHOME_F("frequency")).c_str());
|
||||
if (value.has_value()) {
|
||||
call.set_frequency(*value);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse repeat count (optional, defaults to 1)
|
||||
{
|
||||
auto value = parse_number<uint32_t>(request->arg(ESPHOME_F("repeat_count")).c_str());
|
||||
if (value.has_value()) {
|
||||
call.set_repeat_count(*value);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse base64url-encoded raw timings (required)
|
||||
// Base64url is URL-safe: uses A-Za-z0-9-_ (no special characters needing escaping)
|
||||
const auto &data_arg = request->arg(ESPHOME_F("data"));
|
||||
|
||||
// Validate base64url is not empty (also catches missing parameter since arg() returns empty string)
|
||||
// Arduino String has isEmpty() not empty(), use length() for cross-platform compatibility
|
||||
if (data_arg.length() == 0) { // NOLINT(readability-container-size-empty)
|
||||
request->send(400, ESPHOME_F("text/plain"), ESPHOME_F("Missing or empty 'data' parameter"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Defer to main loop for thread safety. Move encoded string into lambda to ensure
|
||||
// it outlives the call - set_raw_timings_base64url stores a pointer, so the string
|
||||
// must remain valid until perform() completes.
|
||||
// ESP8266 also needs this because ESPAsyncWebServer callbacks run in "sys" context.
|
||||
this->defer([call, encoded = std::string(data_arg.c_str(), data_arg.length())]() mutable {
|
||||
call.set_raw_timings_base64url(encoded);
|
||||
call.perform();
|
||||
});
|
||||
|
||||
request->send(200);
|
||||
return;
|
||||
}
|
||||
request->send(404);
|
||||
}
|
||||
|
||||
json::SerializationBuffer<> WebServer::radio_frequency_all_json_generator(WebServer *web_server, void *source) {
|
||||
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
|
||||
return web_server->radio_frequency_json_(static_cast<radio_frequency::RadioFrequency *>(source), DETAIL_ALL);
|
||||
}
|
||||
|
||||
json::SerializationBuffer<> WebServer::radio_frequency_json_(radio_frequency::RadioFrequency *obj,
|
||||
JsonDetail start_config) {
|
||||
json::JsonBuilder builder;
|
||||
JsonObject root = builder.root();
|
||||
|
||||
set_json_icon_state_value(root, obj, "radio_frequency", "", 0, start_config);
|
||||
|
||||
const auto &traits = obj->get_traits();
|
||||
auto caps = obj->get_capability_flags();
|
||||
|
||||
root[ESPHOME_F("supports_transmitter")] = bool(caps & radio_frequency::CAPABILITY_TRANSMITTER);
|
||||
root[ESPHOME_F("supports_receiver")] = bool(caps & radio_frequency::CAPABILITY_RECEIVER);
|
||||
if (traits.get_frequency_min_hz() != 0) {
|
||||
root[ESPHOME_F("frequency_min")] = traits.get_frequency_min_hz();
|
||||
root[ESPHOME_F("frequency_max")] = traits.get_frequency_max_hz();
|
||||
}
|
||||
|
||||
if (start_config == DETAIL_ALL) {
|
||||
this->add_sorting_info_(root, obj);
|
||||
}
|
||||
|
||||
return builder.serialize();
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_EVENT
|
||||
void WebServer::on_event(event::Event *obj) {
|
||||
if (!this->include_internal_ && obj->is_internal())
|
||||
@@ -2357,6 +2458,10 @@ bool WebServer::canHandle(AsyncWebServerRequest *request) const {
|
||||
#ifdef USE_INFRARED
|
||||
if (match.domain_equals(ESPHOME_F("infrared")))
|
||||
return true;
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
if (match.domain_equals(ESPHOME_F("radio_frequency")))
|
||||
return true;
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -2516,6 +2621,11 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) {
|
||||
else if (match.domain_equals(ESPHOME_F("infrared"))) {
|
||||
this->handle_infrared_request(request, match);
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
else if (match.domain_equals(ESPHOME_F("radio_frequency"))) {
|
||||
this->handle_radio_frequency_request(request, match);
|
||||
}
|
||||
#endif
|
||||
else {
|
||||
// No matching handler found - send 404
|
||||
|
||||
@@ -462,6 +462,12 @@ class WebServer final : public Controller, public Component, public AsyncWebHand
|
||||
|
||||
static json::SerializationBuffer<> infrared_all_json_generator(WebServer *web_server, void *source);
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
/// Handle a radio frequency request under '/radio_frequency/<id>/transmit'.
|
||||
void handle_radio_frequency_request(AsyncWebServerRequest *request, const UrlMatch &match);
|
||||
|
||||
static json::SerializationBuffer<> radio_frequency_all_json_generator(WebServer *web_server, void *source);
|
||||
#endif
|
||||
|
||||
#ifdef USE_EVENT
|
||||
void on_event(event::Event *obj) override;
|
||||
@@ -654,6 +660,9 @@ class WebServer final : public Controller, public Component, public AsyncWebHand
|
||||
#ifdef USE_INFRARED
|
||||
json::SerializationBuffer<> infrared_json_(infrared::Infrared *obj, JsonDetail start_config);
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
json::SerializationBuffer<> radio_frequency_json_(radio_frequency::RadioFrequency *obj, JsonDetail start_config);
|
||||
#endif
|
||||
#ifdef USE_UPDATE
|
||||
json::SerializationBuffer<> update_json_(update::UpdateEntity *obj, JsonDetail start_config);
|
||||
#endif
|
||||
|
||||
@@ -12,7 +12,12 @@
|
||||
|
||||
#ifdef USE_BK72XX
|
||||
extern "C" {
|
||||
// BDK 3.0.78 (required for BK7238) redeclares wifi_event_sta_disconnected_t,
|
||||
// which LibreTiny's Arduino WiFi API already defines. ESPHome doesn't use the
|
||||
// BDK version, so rename it across this include to avoid the collision.
|
||||
#define wifi_event_sta_disconnected_t bdk_wifi_event_sta_disconnected_t
|
||||
#include <wlan_ui_pub.h>
|
||||
#undef wifi_event_sta_disconnected_t
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
@@ -3,26 +3,42 @@ from typing import Any
|
||||
|
||||
from esphome import automation, core
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.esp32 import only_on_variant
|
||||
from esphome.components.esp32.const import (
|
||||
VARIANT_ESP32C5,
|
||||
VARIANT_ESP32C6,
|
||||
VARIANT_ESP32H2,
|
||||
)
|
||||
from esphome.components.nrf52.boards import BOOTLOADER_CONFIG, Section
|
||||
from esphome.components.zephyr import zephyr_add_pm_static, zephyr_data
|
||||
from esphome.components.zephyr.const import KEY_BOOTLOADER
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_INTERNAL, CONF_NAME
|
||||
from esphome.const import CONF_ID, CONF_INTERNAL, CONF_MODEL, CONF_NAME
|
||||
from esphome.core import CORE, CoroPriority, coroutine_with_priority
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .const import (
|
||||
CONF_ON_JOIN,
|
||||
CONF_POWER_SOURCE,
|
||||
CONF_REPORT,
|
||||
CONF_ROUTER,
|
||||
CONF_WIPE_ON_BOOT,
|
||||
KEY_ZIGBEE,
|
||||
POWER_SOURCE,
|
||||
REPORT,
|
||||
ZigbeeComponent,
|
||||
zigbee_ns,
|
||||
)
|
||||
from .const_zephyr import (
|
||||
CONF_IEEE802154_VENDOR_OUI,
|
||||
CONF_MAX_EP_NUMBER,
|
||||
CONF_ON_JOIN,
|
||||
CONF_POWER_SOURCE,
|
||||
CONF_WIPE_ON_BOOT,
|
||||
CONF_ZIGBEE_ID,
|
||||
KEY_EP_NUMBER,
|
||||
KEY_ZIGBEE,
|
||||
POWER_SOURCE,
|
||||
ZigbeeComponent,
|
||||
zigbee_ns,
|
||||
)
|
||||
from .zigbee_esp32 import (
|
||||
final_validate_esp32,
|
||||
validate_binary_sensor_esp32,
|
||||
zigbee_require_vfs_select,
|
||||
)
|
||||
from .zigbee_zephyr import (
|
||||
zephyr_binary_sensor,
|
||||
@@ -33,11 +49,11 @@ from .zigbee_zephyr import (
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CODEOWNERS = ["@tomaszduda23"]
|
||||
CODEOWNERS = ["@luar123", "@tomaszduda23"]
|
||||
|
||||
|
||||
def zigbee_set_core_data(config: ConfigType) -> ConfigType:
|
||||
if zephyr_data()[KEY_BOOTLOADER] in BOOTLOADER_CONFIG:
|
||||
if CORE.is_nrf52 and zephyr_data()[KEY_BOOTLOADER] in BOOTLOADER_CONFIG:
|
||||
zephyr_add_pm_static(
|
||||
[Section("empty_after_zboss_offset", 0xF4000, 0xC000, "flash_primary")]
|
||||
)
|
||||
@@ -45,7 +61,15 @@ def zigbee_set_core_data(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
BINARY_SENSOR_SCHEMA = cv.Schema({}).extend(zephyr_binary_sensor)
|
||||
BINARY_SENSOR_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_REPORT): cv.All(
|
||||
cv.requires_component("zigbee"),
|
||||
cv.requires_component("esp32"),
|
||||
cv.enum(REPORT, lower=True),
|
||||
)
|
||||
}
|
||||
).extend(zephyr_binary_sensor)
|
||||
SENSOR_SCHEMA = cv.Schema({}).extend(zephyr_sensor)
|
||||
SWITCH_SCHEMA = cv.Schema({}).extend(zephyr_switch)
|
||||
NUMBER_SCHEMA = cv.Schema({}).extend(zephyr_number)
|
||||
@@ -54,16 +78,27 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_ID): cv.declare_id(ZigbeeComponent),
|
||||
cv.Optional(CONF_ON_JOIN): automation.validate_automation(single=True),
|
||||
cv.Optional(CONF_WIPE_ON_BOOT, default=False): cv.All(
|
||||
cv.Optional(CONF_MODEL, default=CORE.name): cv.All(
|
||||
cv.string, cv.Length(max=31)
|
||||
),
|
||||
cv.OnlyWith(CONF_ROUTER, "esp32", default=False): cv.All(
|
||||
cv.requires_component("esp32"),
|
||||
cv.boolean,
|
||||
),
|
||||
cv.Optional(CONF_ON_JOIN): cv.All(
|
||||
cv.requires_component("nrf52"),
|
||||
automation.validate_automation(single=True),
|
||||
),
|
||||
cv.OnlyWith(CONF_WIPE_ON_BOOT, "nrf52", default=False): cv.All(
|
||||
cv.Any(
|
||||
cv.boolean,
|
||||
cv.one_of(*["once"], lower=True),
|
||||
),
|
||||
cv.requires_component("nrf52"),
|
||||
),
|
||||
cv.Optional(CONF_POWER_SOURCE, default="DC_SOURCE"): cv.enum(
|
||||
POWER_SOURCE, upper=True
|
||||
cv.OnlyWith(CONF_POWER_SOURCE, "nrf52", default="DC_SOURCE"): cv.All(
|
||||
cv.enum(POWER_SOURCE, upper=True),
|
||||
cv.requires_component("nrf52"),
|
||||
),
|
||||
cv.Optional(CONF_IEEE802154_VENDOR_OUI): cv.All(
|
||||
cv.Any(
|
||||
@@ -74,12 +109,27 @@ CONFIG_SCHEMA = cv.All(
|
||||
),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA),
|
||||
zigbee_require_vfs_select,
|
||||
zigbee_set_core_data,
|
||||
cv.only_with_framework("zephyr"),
|
||||
cv.Any(
|
||||
cv.All(
|
||||
cv.only_on_esp32,
|
||||
only_on_variant(
|
||||
supported=[
|
||||
VARIANT_ESP32H2,
|
||||
VARIANT_ESP32C5,
|
||||
VARIANT_ESP32C6,
|
||||
]
|
||||
),
|
||||
),
|
||||
cv.only_with_framework("zephyr"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def validate_number_of_ep(config: ConfigType) -> None:
|
||||
def validate_number_of_ep(config: ConfigType) -> ConfigType:
|
||||
if not CORE.is_nrf52:
|
||||
return config
|
||||
if KEY_ZIGBEE not in CORE.data:
|
||||
raise cv.Invalid("At least one zigbee device need to be included")
|
||||
count = len(CORE.data[KEY_ZIGBEE][KEY_EP_NUMBER])
|
||||
@@ -90,9 +140,12 @@ def validate_number_of_ep(config: ConfigType) -> None:
|
||||
if count > CONF_MAX_EP_NUMBER and not CORE.testing_mode:
|
||||
raise cv.Invalid(f"Maximum number of end points is {CONF_MAX_EP_NUMBER}")
|
||||
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = cv.All(
|
||||
validate_number_of_ep,
|
||||
final_validate_esp32,
|
||||
)
|
||||
|
||||
|
||||
@@ -103,6 +156,10 @@ async def to_code(config: ConfigType) -> None:
|
||||
from .zigbee_zephyr import zephyr_to_code
|
||||
|
||||
await zephyr_to_code(config)
|
||||
if CORE.is_esp32:
|
||||
from .zigbee_esp32 import esp32_to_code
|
||||
|
||||
await esp32_to_code(config)
|
||||
|
||||
|
||||
async def setup_binary_sensor(entity: cg.MockObj, config: ConfigType) -> None:
|
||||
@@ -148,7 +205,7 @@ async def setup_number(
|
||||
|
||||
|
||||
def consume_endpoint(config: ConfigType) -> ConfigType:
|
||||
if not config.get(CONF_ZIGBEE_ID) or config.get(CONF_INTERNAL):
|
||||
if not config.get(CONF_ZIGBEE_ID):
|
||||
return config
|
||||
if CONF_NAME in config and " " in config[CONF_NAME]:
|
||||
_LOGGER.warning(
|
||||
@@ -163,18 +220,34 @@ def consume_endpoint(config: ConfigType) -> ConfigType:
|
||||
|
||||
|
||||
def validate_binary_sensor(config: ConfigType) -> ConfigType:
|
||||
if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL):
|
||||
return config
|
||||
if CORE.is_esp32:
|
||||
return validate_binary_sensor_esp32(config)
|
||||
return consume_endpoint(config)
|
||||
|
||||
|
||||
def validate_sensor(config: ConfigType) -> ConfigType:
|
||||
if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL):
|
||||
return config
|
||||
if CORE.is_esp32:
|
||||
return config
|
||||
return consume_endpoint(config)
|
||||
|
||||
|
||||
def validate_switch(config: ConfigType) -> ConfigType:
|
||||
if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL):
|
||||
return config
|
||||
if CORE.is_esp32:
|
||||
return config
|
||||
return consume_endpoint(config)
|
||||
|
||||
|
||||
def validate_number(config: ConfigType) -> ConfigType:
|
||||
if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL):
|
||||
return config
|
||||
if CORE.is_esp32:
|
||||
return config
|
||||
return consume_endpoint(config)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
#pragma once
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_ZIGBEE
|
||||
#ifdef USE_ESP32
|
||||
#include "zigbee_esp32.h"
|
||||
#endif
|
||||
#ifdef USE_NRF52
|
||||
#include "zigbee_zephyr.h"
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import esphome.codegen as cg
|
||||
|
||||
zigbee_ns = cg.esphome_ns.namespace("zigbee")
|
||||
ZigbeeComponent = zigbee_ns.class_("ZigbeeComponent", cg.Component)
|
||||
ZigbeeAttribute = zigbee_ns.class_("ZigbeeAttribute", cg.Component)
|
||||
BinaryAttrs = zigbee_ns.struct("BinaryAttrs")
|
||||
AnalogAttrs = zigbee_ns.struct("AnalogAttrs")
|
||||
AnalogAttrsOutput = zigbee_ns.struct("AnalogAttrsOutput")
|
||||
|
||||
report = zigbee_ns.enum("ZigbeeReportT")
|
||||
REPORT = {
|
||||
"coordinator": report.ZIGBEE_REPORT_COORDINATOR,
|
||||
"enable": report.ZIGBEE_REPORT_ENABLE,
|
||||
"force": report.ZIGBEE_REPORT_FORCE,
|
||||
}
|
||||
|
||||
CONF_ON_JOIN = "on_join"
|
||||
CONF_WIPE_ON_BOOT = "wipe_on_boot"
|
||||
CONF_REPORT = "report"
|
||||
CONF_ROUTER = "router"
|
||||
CONF_POWER_SOURCE = "power_source"
|
||||
POWER_SOURCE = {
|
||||
"UNKNOWN": "ZB_ZCL_BASIC_POWER_SOURCE_UNKNOWN",
|
||||
"MAINS_SINGLE_PHASE": "ZB_ZCL_BASIC_POWER_SOURCE_MAINS_SINGLE_PHASE",
|
||||
"MAINS_THREE_PHASE": "ZB_ZCL_BASIC_POWER_SOURCE_MAINS_THREE_PHASE",
|
||||
"BATTERY": "ZB_ZCL_BASIC_POWER_SOURCE_BATTERY",
|
||||
"DC_SOURCE": "ZB_ZCL_BASIC_POWER_SOURCE_DC_SOURCE",
|
||||
"EMERGENCY_MAINS_CONST": "ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_CONST",
|
||||
"EMERGENCY_MAINS_TRANSF": "ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_TRANSF",
|
||||
}
|
||||
|
||||
KEY_ZIGBEE = "zigbee"
|
||||
@@ -0,0 +1,35 @@
|
||||
import esphome.codegen as cg
|
||||
|
||||
DEVICE_TYPE = "device_type"
|
||||
ROLE = "role"
|
||||
CONF_MAX_EP_NUMBER = 239
|
||||
CONF_NUM = "num"
|
||||
CONF_CLUSTERS = "clusters"
|
||||
CONF_ATTRIBUTES = "attributes"
|
||||
CONF_ENDPOINT = "endpoint"
|
||||
CONF_CLUSTER = "cluster"
|
||||
SCALE = "scale"
|
||||
CONF_ATTRIBUTE_ID = "attribute_id"
|
||||
KEY_BS_EP = "binary_sensor_ep"
|
||||
|
||||
ha_standard_devices = cg.esphome_ns.enum("zb_ha_standard_devs_e")
|
||||
DEVICE_ID = {
|
||||
"RANGE_EXTENDER": ha_standard_devices.ZB_HA_RANGE_EXTENDER_DEVICE_ID,
|
||||
"SIMPLE_SENSOR": ha_standard_devices.ZB_HA_SIMPLE_SENSOR_DEVICE_ID,
|
||||
"CUSTOM_ATTR": ha_standard_devices.ZB_HA_CUSTOM_ATTR_DEVICE_ID,
|
||||
}
|
||||
cluster_id = cg.esphome_ns.enum("esp_zb_zcl_cluster_id_t")
|
||||
CLUSTER_ID = {
|
||||
"BASIC": cluster_id.ESP_ZB_ZCL_CLUSTER_ID_BASIC,
|
||||
"BINARY_INPUT": cluster_id.ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT,
|
||||
}
|
||||
cluster_role = cg.esphome_ns.enum("esp_zb_zcl_cluster_role_t")
|
||||
CLUSTER_ROLE = {
|
||||
"SERVER": cluster_role.ESP_ZB_ZCL_CLUSTER_SERVER_ROLE,
|
||||
}
|
||||
attr_type = cg.esphome_ns.enum("esp_zb_zcl_attr_type_t")
|
||||
ATTR_TYPE = {
|
||||
"BOOL": attr_type.ESP_ZB_ZCL_ATTR_TYPE_BOOL,
|
||||
"8BITMAP": attr_type.ESP_ZB_ZCL_ATTR_TYPE_8BITMAP,
|
||||
"CHAR_STRING": attr_type.ESP_ZB_ZCL_ATTR_TYPE_CHAR_STRING,
|
||||
}
|
||||
@@ -1,33 +1,12 @@
|
||||
import esphome.codegen as cg
|
||||
|
||||
zigbee_ns = cg.esphome_ns.namespace("zigbee")
|
||||
ZigbeeComponent = zigbee_ns.class_("ZigbeeComponent", cg.Component)
|
||||
BinaryAttrs = zigbee_ns.struct("BinaryAttrs")
|
||||
AnalogAttrs = zigbee_ns.struct("AnalogAttrs")
|
||||
AnalogAttrsOutput = zigbee_ns.struct("AnalogAttrsOutput")
|
||||
|
||||
CONF_MAX_EP_NUMBER = 8
|
||||
CONF_ZIGBEE_ID = "zigbee_id"
|
||||
CONF_ON_JOIN = "on_join"
|
||||
CONF_WIPE_ON_BOOT = "wipe_on_boot"
|
||||
CONF_ZIGBEE_BINARY_SENSOR = "zigbee_binary_sensor"
|
||||
CONF_ZIGBEE_SENSOR = "zigbee_sensor"
|
||||
CONF_ZIGBEE_SWITCH = "zigbee_switch"
|
||||
CONF_ZIGBEE_NUMBER = "zigbee_number"
|
||||
CONF_POWER_SOURCE = "power_source"
|
||||
POWER_SOURCE = {
|
||||
"UNKNOWN": "ZB_ZCL_BASIC_POWER_SOURCE_UNKNOWN",
|
||||
"MAINS_SINGLE_PHASE": "ZB_ZCL_BASIC_POWER_SOURCE_MAINS_SINGLE_PHASE",
|
||||
"MAINS_THREE_PHASE": "ZB_ZCL_BASIC_POWER_SOURCE_MAINS_THREE_PHASE",
|
||||
"BATTERY": "ZB_ZCL_BASIC_POWER_SOURCE_BATTERY",
|
||||
"DC_SOURCE": "ZB_ZCL_BASIC_POWER_SOURCE_DC_SOURCE",
|
||||
"EMERGENCY_MAINS_CONST": "ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_CONST",
|
||||
"EMERGENCY_MAINS_TRANSF": "ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_TRANSF",
|
||||
}
|
||||
CONF_IEEE802154_VENDOR_OUI = "ieee802154_vendor_oui"
|
||||
|
||||
# Keys for CORE.data storage
|
||||
KEY_ZIGBEE = "zigbee"
|
||||
KEY_EP_NUMBER = "ep_number"
|
||||
|
||||
# External ZBOSS SDK types (just strings for codegen)
|
||||
|
||||
@@ -6,7 +6,8 @@ from esphome.core import CORE
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import consume_endpoint
|
||||
from ..const_zephyr import CONF_ZIGBEE_ID, zigbee_ns
|
||||
from ..const import zigbee_ns
|
||||
from ..const_zephyr import CONF_ZIGBEE_ID
|
||||
from ..zigbee_zephyr import (
|
||||
ZigbeeClusterDesc,
|
||||
ZigbeeComponent,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
#include "zigbee_attribute_esp32.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_ESP32
|
||||
#ifdef USE_ZIGBEE
|
||||
|
||||
namespace esphome::zigbee {
|
||||
|
||||
static const char *const TAG = "zigbee.attribute";
|
||||
|
||||
void ZigbeeAttribute::set_attr_() {
|
||||
if (!this->zb_->is_connected()) {
|
||||
return;
|
||||
}
|
||||
if (esp_zb_lock_acquire(10 / portTICK_PERIOD_MS)) {
|
||||
esp_zb_zcl_status_t state = esp_zb_zcl_set_attribute_val(this->endpoint_id_, this->cluster_id_, this->role_,
|
||||
this->attr_id_, this->value_p_, false);
|
||||
if (this->force_report_) {
|
||||
this->report_(true);
|
||||
}
|
||||
this->set_attr_requested_ = false;
|
||||
// Check for error
|
||||
if (state != ESP_ZB_ZCL_STATUS_SUCCESS) {
|
||||
ESP_LOGE(TAG, "Setting attribute failed, ZCL status: %u", static_cast<unsigned>(state));
|
||||
}
|
||||
esp_zb_lock_release();
|
||||
}
|
||||
}
|
||||
|
||||
void ZigbeeAttribute::report_(bool has_lock) {
|
||||
if (!this->zb_->is_connected()) {
|
||||
return;
|
||||
}
|
||||
if (has_lock or esp_zb_lock_acquire(10 / portTICK_PERIOD_MS)) {
|
||||
esp_zb_zcl_report_attr_cmd_t cmd = {
|
||||
.address_mode = ESP_ZB_APS_ADDR_MODE_16_ENDP_PRESENT,
|
||||
.direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_CLI,
|
||||
};
|
||||
cmd.zcl_basic_cmd.dst_addr_u.addr_short = 0x0000;
|
||||
cmd.zcl_basic_cmd.dst_endpoint = 1;
|
||||
cmd.zcl_basic_cmd.src_endpoint = this->endpoint_id_;
|
||||
cmd.clusterID = this->cluster_id_;
|
||||
cmd.attributeID = this->attr_id_;
|
||||
|
||||
esp_zb_zcl_report_attr_cmd_req(&cmd);
|
||||
if (!has_lock) {
|
||||
esp_zb_lock_release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
esp_zb_zcl_reporting_info_t ZigbeeAttribute::get_reporting_info() {
|
||||
esp_zb_zcl_reporting_info_t reporting_info = {
|
||||
.direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_SRV,
|
||||
.ep = this->endpoint_id_,
|
||||
.cluster_id = this->cluster_id_,
|
||||
.cluster_role = this->role_,
|
||||
.attr_id = this->attr_id_,
|
||||
.manuf_code = ESP_ZB_ZCL_ATTR_NON_MANUFACTURER_SPECIFIC,
|
||||
};
|
||||
reporting_info.dst.profile_id = ESP_ZB_AF_HA_PROFILE_ID;
|
||||
reporting_info.u.send_info.min_interval = 10; /*!< Actual minimum reporting interval */
|
||||
reporting_info.u.send_info.max_interval = 0; /*!< Actual maximum reporting interval */
|
||||
reporting_info.u.send_info.def_min_interval = 10; /*!< Default minimum reporting interval */
|
||||
reporting_info.u.send_info.def_max_interval = 0; /*!< Default maximum reporting interval */
|
||||
reporting_info.u.send_info.delta.s16 = 0; /*!< Actual reportable change */
|
||||
|
||||
return reporting_info;
|
||||
}
|
||||
|
||||
void ZigbeeAttribute::set_report(bool force) {
|
||||
this->report_enabled = true;
|
||||
this->force_report_ = force;
|
||||
}
|
||||
|
||||
void ZigbeeAttribute::loop() {
|
||||
if (this->set_attr_requested_) {
|
||||
this->set_attr_();
|
||||
}
|
||||
|
||||
if (!this->set_attr_requested_) {
|
||||
this->disable_loop();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::zigbee
|
||||
|
||||
#endif
|
||||
#endif
|
||||
@@ -0,0 +1,90 @@
|
||||
#pragma once
|
||||
|
||||
#include <type_traits>
|
||||
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
#ifdef USE_ZIGBEE
|
||||
|
||||
#include "esp_zigbee_core.h"
|
||||
#include "zigbee_esp32.h"
|
||||
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
#include "esphome/components/binary_sensor/binary_sensor.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::zigbee {
|
||||
|
||||
enum ZigbeeReportT {
|
||||
ZIGBEE_REPORT_COORDINATOR,
|
||||
ZIGBEE_REPORT_ENABLE,
|
||||
ZIGBEE_REPORT_FORCE,
|
||||
};
|
||||
|
||||
class ZigbeeAttribute : public Component {
|
||||
public:
|
||||
ZigbeeAttribute(ZigbeeComponent *parent, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id,
|
||||
uint8_t attr_type, float scale, uint8_t max_size)
|
||||
: zb_(parent),
|
||||
endpoint_id_(endpoint_id),
|
||||
cluster_id_(cluster_id),
|
||||
role_(role),
|
||||
attr_id_(attr_id),
|
||||
attr_type_(attr_type),
|
||||
scale_(scale),
|
||||
max_size_(max_size) {}
|
||||
void loop() override;
|
||||
template<typename T> void add_attr(T value);
|
||||
esp_zb_zcl_reporting_info_t get_reporting_info();
|
||||
template<typename T> void set_attr(const T &value);
|
||||
uint8_t attr_type() { return attr_type_; }
|
||||
void set_report(bool force);
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
template<typename T> void connect(binary_sensor::BinarySensor *sensor);
|
||||
#endif
|
||||
bool report_enabled = false;
|
||||
|
||||
protected:
|
||||
void set_attr_();
|
||||
void report_(bool has_lock);
|
||||
ZigbeeComponent *zb_;
|
||||
uint8_t endpoint_id_;
|
||||
uint16_t cluster_id_;
|
||||
uint8_t role_;
|
||||
uint16_t attr_id_;
|
||||
uint8_t attr_type_;
|
||||
uint8_t max_size_;
|
||||
float scale_;
|
||||
void *value_p_{nullptr};
|
||||
bool set_attr_requested_{false};
|
||||
bool force_report_{false};
|
||||
};
|
||||
|
||||
template<typename T> void ZigbeeAttribute::add_attr(T value) {
|
||||
// Attribute type does never change and add_attr is only called once during startup, so this is safe.
|
||||
// For now we need to support only simple numeric/bool types for (binary) sensors.
|
||||
// For strings and arrays we would need to allocate a buffer of the maximum size.
|
||||
this->value_p_ = (void *) (new T);
|
||||
this->zb_->add_attr(this, this->endpoint_id_, this->cluster_id_, this->role_, this->attr_id_, this->max_size_,
|
||||
std::move(value));
|
||||
}
|
||||
|
||||
template<typename T> void ZigbeeAttribute::set_attr(const T &value) {
|
||||
*static_cast<T *>(this->value_p_) = value;
|
||||
this->set_attr_requested_ = true;
|
||||
this->enable_loop();
|
||||
}
|
||||
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
template<typename T> void ZigbeeAttribute::connect(binary_sensor::BinarySensor *sensor) {
|
||||
sensor->add_on_state_callback([this](bool value) { this->set_attr((T) (this->scale_ * value)); });
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace esphome::zigbee
|
||||
|
||||
#endif
|
||||
#endif
|
||||
@@ -0,0 +1,70 @@
|
||||
from typing import Any
|
||||
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_DEVICE, CONF_ID, CONF_TYPE
|
||||
|
||||
from .const import CONF_REPORT, REPORT
|
||||
from .const_esp32 import (
|
||||
CLUSTER_ROLE,
|
||||
CONF_ATTRIBUTE_ID,
|
||||
CONF_ATTRIBUTES,
|
||||
CONF_CLUSTERS,
|
||||
CONF_MAX_EP_NUMBER,
|
||||
CONF_NUM,
|
||||
DEVICE_TYPE,
|
||||
ROLE,
|
||||
)
|
||||
|
||||
# endpoint configs:
|
||||
ep_configs: dict[str, dict[str, Any]] = {
|
||||
"binary_input": {
|
||||
DEVICE_TYPE: "SIMPLE_SENSOR",
|
||||
CONF_CLUSTERS: [
|
||||
{
|
||||
CONF_ID: "BINARY_INPUT",
|
||||
ROLE: CLUSTER_ROLE["SERVER"],
|
||||
CONF_ATTRIBUTES: [
|
||||
{
|
||||
CONF_ATTRIBUTE_ID: 0x55,
|
||||
CONF_TYPE: "BOOL",
|
||||
CONF_REPORT: REPORT["enable"],
|
||||
CONF_DEVICE: None,
|
||||
},
|
||||
{
|
||||
CONF_ATTRIBUTE_ID: 0x51,
|
||||
CONF_TYPE: "BOOL",
|
||||
},
|
||||
{
|
||||
CONF_ATTRIBUTE_ID: 0x6F,
|
||||
CONF_TYPE: "8BITMAP",
|
||||
},
|
||||
{
|
||||
CONF_ATTRIBUTE_ID: 0x1C,
|
||||
CONF_TYPE: "CHAR_STRING",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def create_ep(ep_list: list[dict[str, Any]], router: bool) -> list[dict[str, Any]]:
|
||||
# create dummy endpoint if list is empty
|
||||
if not ep_list:
|
||||
ep_type = "CUSTOM_ATTR"
|
||||
if router:
|
||||
ep_type = "RANGE_EXTENDER"
|
||||
ep_list = [
|
||||
{
|
||||
DEVICE_TYPE: ep_type,
|
||||
}
|
||||
]
|
||||
# enumerate endpoints
|
||||
for i, ep in enumerate(ep_list, 1):
|
||||
ep[CONF_NUM] = i
|
||||
if len(ep_list) > CONF_MAX_EP_NUMBER:
|
||||
raise cv.Invalid(
|
||||
f"Too many devices. Zigbee can define only {CONF_MAX_EP_NUMBER} endpoints."
|
||||
)
|
||||
return ep_list
|
||||
@@ -0,0 +1,313 @@
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_ESP32
|
||||
#ifdef USE_ZIGBEE
|
||||
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "esp_check.h"
|
||||
#include "nvs_flash.h"
|
||||
#include "zigbee_attribute_esp32.h"
|
||||
#include "zigbee_esp32.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "zigbee_helpers_esp32.h"
|
||||
#ifdef USE_WIFI
|
||||
#include "esp_coexist.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::zigbee {
|
||||
|
||||
static const char *const TAG = "zigbee";
|
||||
|
||||
static ZigbeeComponent *global_zigbee = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
uint8_t *get_zcl_string(const char *str, uint8_t max_size, bool use_max_size) {
|
||||
uint8_t str_len = static_cast<uint8_t>(strlen(str));
|
||||
uint8_t zcl_str_size = use_max_size ? max_size : std::min(max_size, str_len);
|
||||
uint8_t *zcl_str = new uint8_t[zcl_str_size + 1]; // string + length octet
|
||||
zcl_str[0] = zcl_str_size;
|
||||
|
||||
// Initialize payload to avoid leaking uninitialized heap contents and clamp copy length
|
||||
memset(zcl_str + 1, 0, zcl_str_size);
|
||||
uint8_t copy_len = std::min(zcl_str_size, str_len);
|
||||
if (copy_len > 0) {
|
||||
memcpy(zcl_str + 1, str, copy_len);
|
||||
}
|
||||
return zcl_str;
|
||||
}
|
||||
|
||||
static void bdb_start_top_level_commissioning_cb(uint8_t mode_mask) {
|
||||
if (esp_zb_bdb_start_top_level_commissioning(mode_mask) != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Start network steering failed!");
|
||||
}
|
||||
}
|
||||
|
||||
void esp_zb_app_signal_handler(esp_zb_app_signal_t *signal_struct) {
|
||||
static uint8_t steering_retry_count = 0;
|
||||
uint32_t *p_sg_p = signal_struct->p_app_signal;
|
||||
esp_err_t err_status = signal_struct->esp_err_status;
|
||||
esp_zb_app_signal_type_t sig_type = (esp_zb_app_signal_type_t) *p_sg_p;
|
||||
esp_zb_zdo_signal_leave_params_t *leave_params = NULL;
|
||||
switch (sig_type) {
|
||||
case ESP_ZB_ZDO_SIGNAL_SKIP_STARTUP:
|
||||
ESP_LOGD(TAG, "Zigbee stack initialized");
|
||||
esp_zb_bdb_start_top_level_commissioning(ESP_ZB_BDB_MODE_INITIALIZATION);
|
||||
break;
|
||||
case ESP_ZB_BDB_SIGNAL_DEVICE_FIRST_START:
|
||||
case ESP_ZB_BDB_SIGNAL_DEVICE_REBOOT:
|
||||
if (err_status == ESP_OK) {
|
||||
ESP_LOGD(TAG, "Device started up in %sfactory-reset mode", esp_zb_bdb_is_factory_new() ? "" : "non ");
|
||||
global_zigbee->started = true;
|
||||
if (esp_zb_bdb_is_factory_new()) {
|
||||
ESP_LOGD(TAG, "Start network steering");
|
||||
esp_zb_bdb_start_top_level_commissioning(ESP_ZB_BDB_MODE_NETWORK_STEERING);
|
||||
} else {
|
||||
ESP_LOGD(TAG, "Device rebooted");
|
||||
global_zigbee->connected = true;
|
||||
}
|
||||
} else {
|
||||
ESP_LOGE(TAG, "FIRST_START. Device started up in %sfactory-reset mode with an error %d (%s)",
|
||||
esp_zb_bdb_is_factory_new() ? "" : "non ", err_status, esp_err_to_name(err_status));
|
||||
ESP_LOGW(TAG, "Failed to initialize Zigbee stack (status: %s)", esp_err_to_name(err_status));
|
||||
esp_zb_scheduler_alarm((esp_zb_callback_t) bdb_start_top_level_commissioning_cb, ESP_ZB_BDB_MODE_INITIALIZATION,
|
||||
1000);
|
||||
}
|
||||
break;
|
||||
case ESP_ZB_BDB_SIGNAL_STEERING:
|
||||
if (err_status == ESP_OK) {
|
||||
steering_retry_count = 0;
|
||||
ESP_LOGI(TAG, "Joined network successfully (PAN ID: 0x%04hx, Channel:%d)", esp_zb_get_pan_id(),
|
||||
esp_zb_get_current_channel());
|
||||
global_zigbee->connected = true;
|
||||
} else {
|
||||
ESP_LOGI(TAG, "Network steering was not successful (status: %s)", esp_err_to_name(err_status));
|
||||
if (steering_retry_count < 10) {
|
||||
steering_retry_count++;
|
||||
esp_zb_scheduler_alarm((esp_zb_callback_t) bdb_start_top_level_commissioning_cb,
|
||||
ESP_ZB_BDB_MODE_NETWORK_STEERING, 1000);
|
||||
} else {
|
||||
esp_zb_scheduler_alarm((esp_zb_callback_t) bdb_start_top_level_commissioning_cb,
|
||||
ESP_ZB_BDB_MODE_NETWORK_STEERING, 600 * 1000);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case ESP_ZB_ZDO_SIGNAL_LEAVE:
|
||||
leave_params = (esp_zb_zdo_signal_leave_params_t *) esp_zb_app_signal_get_params(p_sg_p);
|
||||
if (leave_params->leave_type == ESP_ZB_NWK_LEAVE_TYPE_RESET) {
|
||||
esp_zb_factory_reset();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
ESP_LOGD(TAG, "ZDO signal: %s (0x%x), status: %s", esp_zb_zdo_signal_to_string(sig_type), sig_type,
|
||||
esp_err_to_name(err_status));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static esp_err_t zb_attribute_handler(const esp_zb_zcl_set_attr_value_message_t *message) {
|
||||
esp_err_t ret = ESP_OK;
|
||||
ESP_RETURN_ON_FALSE(message, ESP_FAIL, TAG, "Empty message");
|
||||
ESP_RETURN_ON_FALSE(message->info.status == ESP_ZB_ZCL_STATUS_SUCCESS, ESP_ERR_INVALID_ARG, TAG,
|
||||
"Received message: error status(%d)", message->info.status);
|
||||
ESP_LOGD(TAG, "Received message: endpoint(%d), cluster(0x%x), attribute(0x%x), data size(%d)",
|
||||
message->info.dst_endpoint, message->info.cluster, message->attribute.id, message->attribute.data.size);
|
||||
return ret;
|
||||
}
|
||||
|
||||
static esp_err_t zb_action_handler(esp_zb_core_action_callback_id_t callback_id, const void *message) {
|
||||
esp_err_t ret = ESP_OK;
|
||||
switch (callback_id) {
|
||||
case ESP_ZB_CORE_SET_ATTR_VALUE_CB_ID:
|
||||
ret = zb_attribute_handler((esp_zb_zcl_set_attr_value_message_t *) message);
|
||||
break;
|
||||
default:
|
||||
ESP_LOGD(TAG, "Receive Zigbee action(0x%x) callback", callback_id);
|
||||
break;
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
void ZigbeeComponent::create_default_cluster(uint8_t endpoint_id, zb_ha_standard_devs_e device_id) {
|
||||
esp_zb_cluster_list_t *cluster_list = esp_zb_zcl_cluster_list_create();
|
||||
this->endpoint_list_[endpoint_id] =
|
||||
std::tuple<zb_ha_standard_devs_e, esp_zb_cluster_list_t *>(device_id, cluster_list);
|
||||
// Add basic cluster
|
||||
this->add_cluster(endpoint_id, ESP_ZB_ZCL_CLUSTER_ID_BASIC, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE);
|
||||
// Add identify cluster if not already present
|
||||
if (esp_zb_cluster_list_get_cluster(cluster_list, ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE) ==
|
||||
nullptr) {
|
||||
this->add_cluster(endpoint_id, ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE);
|
||||
}
|
||||
}
|
||||
|
||||
void ZigbeeComponent::add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role) {
|
||||
esp_zb_attribute_list_t *attr_list;
|
||||
if (cluster_id == 0) {
|
||||
attr_list = create_basic_cluster_();
|
||||
} else {
|
||||
attr_list = esphome_zb_default_attr_list_create(cluster_id);
|
||||
}
|
||||
this->attribute_list_[{endpoint_id, cluster_id, role}] = attr_list;
|
||||
}
|
||||
|
||||
void ZigbeeComponent::set_basic_cluster(const char *model, const char *manufacturer) {
|
||||
char date_buf[16];
|
||||
time_t time_val = App.get_build_time();
|
||||
struct tm *timeinfo = localtime(&time_val);
|
||||
strftime(date_buf, sizeof(date_buf), "%Y%m%d %H%M%S", timeinfo);
|
||||
this->basic_cluster_data_ = {
|
||||
.model = get_zcl_string(model, 31),
|
||||
.manufacturer = get_zcl_string(manufacturer, 31),
|
||||
.date = get_zcl_string(date_buf, 15),
|
||||
};
|
||||
}
|
||||
|
||||
esp_zb_attribute_list_t *ZigbeeComponent::create_basic_cluster_() {
|
||||
esp_zb_basic_cluster_cfg_t basic_cluster_cfg = {
|
||||
.zcl_version = ESP_ZB_ZCL_BASIC_ZCL_VERSION_DEFAULT_VALUE,
|
||||
.power_source = 0,
|
||||
};
|
||||
esp_zb_attribute_list_t *attr_list = esp_zb_basic_cluster_create(&basic_cluster_cfg);
|
||||
esp_zb_basic_cluster_add_attr(attr_list, ESP_ZB_ZCL_ATTR_BASIC_MANUFACTURER_NAME_ID,
|
||||
this->basic_cluster_data_.manufacturer);
|
||||
esp_zb_basic_cluster_add_attr(attr_list, ESP_ZB_ZCL_ATTR_BASIC_MODEL_IDENTIFIER_ID, this->basic_cluster_data_.model);
|
||||
esp_zb_basic_cluster_add_attr(attr_list, ESP_ZB_ZCL_ATTR_BASIC_DATE_CODE_ID, this->basic_cluster_data_.date);
|
||||
return attr_list;
|
||||
}
|
||||
|
||||
esp_err_t ZigbeeComponent::create_endpoint(uint8_t endpoint_id, zb_ha_standard_devs_e device_id,
|
||||
esp_zb_cluster_list_t *esp_zb_cluster_list) {
|
||||
esp_zb_endpoint_config_t endpoint_config = {.endpoint = endpoint_id,
|
||||
.app_profile_id = ESP_ZB_AF_HA_PROFILE_ID,
|
||||
.app_device_id = device_id,
|
||||
.app_device_version = 0};
|
||||
return esp_zb_ep_list_add_ep(this->esp_zb_ep_list_, esp_zb_cluster_list, endpoint_config);
|
||||
}
|
||||
|
||||
static void esp_zb_task_(void *pvParameters) {
|
||||
if (esp_zb_start(false) != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Could not setup Zigbee");
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
esp_zb_set_node_descriptor_power_source(1);
|
||||
esp_zb_stack_main_loop();
|
||||
}
|
||||
|
||||
void ZigbeeComponent::setup() {
|
||||
global_zigbee = this;
|
||||
esp_zb_platform_config_t config = {
|
||||
.radio_config = ESP_ZB_DEFAULT_RADIO_CONFIG(),
|
||||
.host_config = ESP_ZB_DEFAULT_HOST_CONFIG(),
|
||||
};
|
||||
#ifdef USE_WIFI
|
||||
if (esp_coex_wifi_i154_enable() != ESP_OK) {
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
if (esp_zb_platform_config(&config) != ESP_OK) {
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
|
||||
esp_zb_zed_cfg_t zb_zed_cfg = {
|
||||
.ed_timeout = ESP_ZB_ED_AGING_TIMEOUT_64MIN,
|
||||
.keep_alive = ED_KEEP_ALIVE,
|
||||
};
|
||||
esp_zb_zczr_cfg_t zb_zczr_cfg = {
|
||||
.max_children = MAX_CHILDREN,
|
||||
};
|
||||
esp_zb_cfg_t zb_nwk_cfg = {
|
||||
.esp_zb_role = this->device_role_,
|
||||
.install_code_policy = false,
|
||||
};
|
||||
#ifdef ZB_ROUTER_ROLE
|
||||
zb_nwk_cfg.nwk_cfg.zczr_cfg = zb_zczr_cfg;
|
||||
#else
|
||||
zb_nwk_cfg.nwk_cfg.zed_cfg = zb_zed_cfg;
|
||||
#endif
|
||||
esp_zb_init(&zb_nwk_cfg);
|
||||
|
||||
esp_err_t ret;
|
||||
for (auto const &[key, val] : this->attribute_list_) {
|
||||
esp_zb_cluster_list_t *esp_zb_cluster_list = std::get<1>(this->endpoint_list_[std::get<0>(key)]);
|
||||
ret = esphome_zb_cluster_list_add_or_update_cluster(std::get<1>(key), esp_zb_cluster_list, val, std::get<2>(key));
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Could not create cluster 0x%04X with role %u: %s", std::get<1>(key), std::get<2>(key),
|
||||
esp_err_to_name(ret));
|
||||
} else {
|
||||
ESP_LOGD(TAG, "Endpoint %u: Added cluster 0x%04X with role %u", std::get<0>(key), std::get<1>(key),
|
||||
std::get<2>(key));
|
||||
#ifdef ESPHOME_LOG_HAS_VERBOSE
|
||||
// Dump cluster attributes in verbose log
|
||||
ESP_LOGV(TAG, "Cluster 0x%04X attributes:", std::get<1>(key));
|
||||
esp_zb_attribute_list_t *attr_list = val;
|
||||
while (attr_list) {
|
||||
esp_zb_zcl_attr_t *attr = &attr_list->attribute;
|
||||
ESP_LOGV(TAG, " Attr ID: 0x%04X, Type: 0x%02X, Access: 0x%02X", attr->id, attr->type, attr->access);
|
||||
attr_list = attr_list->next;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
this->attribute_list_.clear();
|
||||
|
||||
for (auto const &[ep_id, dev_id] : this->endpoint_list_) {
|
||||
if (create_endpoint(ep_id, std::get<0>(dev_id), std::get<1>(dev_id)) != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Could not create endpoint %u", ep_id);
|
||||
}
|
||||
}
|
||||
this->endpoint_list_.clear();
|
||||
|
||||
if (esp_zb_device_register(this->esp_zb_ep_list_) != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Could not register the endpoint list");
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
|
||||
esp_zb_core_action_handler_register(zb_action_handler);
|
||||
|
||||
if (esp_zb_set_primary_network_channel_set(ESP_ZB_TRANSCEIVER_ALL_CHANNELS_MASK) != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Could not setup Zigbee");
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
for (auto &[_, attribute] : this->attributes_) {
|
||||
if (attribute->report_enabled) {
|
||||
esp_zb_zcl_reporting_info_t reporting_info = attribute->get_reporting_info();
|
||||
ESP_LOGD(TAG, "set reporting for cluster: %u", reporting_info.cluster_id);
|
||||
if (esp_zb_zcl_update_reporting_info(&reporting_info) != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Could not configure reporting for attribute 0x%04X in cluster 0x%04X in endpoint %u",
|
||||
reporting_info.attr_id, reporting_info.cluster_id, reporting_info.ep);
|
||||
}
|
||||
}
|
||||
}
|
||||
xTaskCreate(esp_zb_task_, "Zigbee_main", 4096, NULL, 24, NULL);
|
||||
}
|
||||
|
||||
void ZigbeeComponent::dump_config() {
|
||||
if (esp_zb_lock_acquire(10 / portTICK_PERIOD_MS)) {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Zigbee\n"
|
||||
" Model: %s\n"
|
||||
" Router: %s\n"
|
||||
" Device is joined to the network: %s\n"
|
||||
" Current channel: %d\n"
|
||||
" Short addr: 0x%04X\n"
|
||||
" Short pan id: 0x%04X",
|
||||
this->basic_cluster_data_.model, YESNO(this->device_role_ == ESP_ZB_DEVICE_TYPE_ROUTER),
|
||||
YESNO(esp_zb_bdb_dev_joined()), esp_zb_get_current_channel(), esp_zb_get_short_address(),
|
||||
esp_zb_get_pan_id());
|
||||
esp_zb_lock_release();
|
||||
} else {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Zigbee\n"
|
||||
" Model: %s\n"
|
||||
" Router: %s\n",
|
||||
this->basic_cluster_data_.model, YESNO(this->device_role_ == ESP_ZB_DEVICE_TYPE_ROUTER));
|
||||
}
|
||||
}
|
||||
} // namespace esphome::zigbee
|
||||
|
||||
#endif
|
||||
#endif
|
||||
@@ -0,0 +1,134 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_ESP32
|
||||
#ifdef USE_ZIGBEE
|
||||
|
||||
#include <map>
|
||||
#include <tuple>
|
||||
#include <atomic>
|
||||
|
||||
#include "esp_zigbee_core.h"
|
||||
#include "zboss_api.h"
|
||||
#include "ha/esp_zigbee_ha_standard.h"
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/defines.h"
|
||||
#include "zigbee_helpers_esp32.h"
|
||||
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
#include "esphome/components/binary_sensor/binary_sensor.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::zigbee {
|
||||
|
||||
/* Zigbee configuration */
|
||||
static const uint16_t ED_KEEP_ALIVE = 3000; /* 3000 millisecond */
|
||||
static const uint8_t MAX_CHILDREN = 10;
|
||||
|
||||
#define ESP_ZB_DEFAULT_RADIO_CONFIG() \
|
||||
{ .radio_mode = ZB_RADIO_MODE_NATIVE, }
|
||||
|
||||
#define ESP_ZB_DEFAULT_HOST_CONFIG() \
|
||||
{ .host_connection_mode = ZB_HOST_CONNECTION_MODE_NONE, }
|
||||
|
||||
uint8_t *get_zcl_string(const char *str, uint8_t max_size, bool use_max_size = false);
|
||||
|
||||
class ZigbeeAttribute;
|
||||
|
||||
class ZigbeeComponent : public Component {
|
||||
public:
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
esp_err_t create_endpoint(uint8_t endpoint_id, zb_ha_standard_devs_e device_id,
|
||||
esp_zb_cluster_list_t *esp_zb_cluster_list);
|
||||
void set_basic_cluster(const char *model, const char *manufacturer);
|
||||
void add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role);
|
||||
void create_default_cluster(uint8_t endpoint_id, zb_ha_standard_devs_e device_id);
|
||||
|
||||
template<typename T>
|
||||
void add_attr(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id,
|
||||
uint8_t max_size, T value);
|
||||
|
||||
template<typename T>
|
||||
void add_attr(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, uint8_t max_size, T value);
|
||||
|
||||
void factory_reset() {
|
||||
esp_zb_lock_acquire(portMAX_DELAY);
|
||||
esp_zb_factory_reset(); // triggers a reboot
|
||||
esp_zb_lock_release();
|
||||
}
|
||||
|
||||
bool is_started() { return this->started; }
|
||||
bool is_connected() { return this->connected; }
|
||||
std::atomic<bool> connected = false;
|
||||
std::atomic<bool> started = false;
|
||||
|
||||
protected:
|
||||
struct {
|
||||
uint8_t *model;
|
||||
uint8_t *manufacturer;
|
||||
uint8_t *date;
|
||||
} basic_cluster_data_;
|
||||
#ifdef ZB_ED_ROLE
|
||||
esp_zb_nwk_device_type_t device_role_ = ESP_ZB_DEVICE_TYPE_ED;
|
||||
#else
|
||||
esp_zb_nwk_device_type_t device_role_ = ESP_ZB_DEVICE_TYPE_ROUTER;
|
||||
#endif
|
||||
esp_zb_attribute_list_t *create_basic_cluster_();
|
||||
template<typename T>
|
||||
void add_attr_(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id,
|
||||
T *value_p);
|
||||
// endpoint_list_ and attribute_list_ are only used during setup and are cleared afterwards
|
||||
// value tuple could be replaced by struct
|
||||
std::map<uint8_t, std::tuple<zb_ha_standard_devs_e, esp_zb_cluster_list_t *>> endpoint_list_;
|
||||
// key tuple could be replaced by single 32 bit int with bit fields for endpoint, cluster and role
|
||||
std::map<std::tuple<uint8_t, uint16_t, uint8_t>, esp_zb_attribute_list_t *> attribute_list_;
|
||||
// attributes_ will be used during operation in zigbee callbacks to update the attribute values and trigger
|
||||
// automations
|
||||
// key tuple could be replaced by single 64 (48) bit int with bit fields for endpoint, cluster, role and attr_id
|
||||
std::map<std::tuple<uint8_t, uint16_t, uint8_t, uint16_t>, ZigbeeAttribute *> attributes_;
|
||||
esp_zb_ep_list_t *esp_zb_ep_list_ = esp_zb_ep_list_create();
|
||||
};
|
||||
|
||||
extern "C" void esp_zb_app_signal_handler(esp_zb_app_signal_t *signal_struct);
|
||||
|
||||
template<typename T>
|
||||
void ZigbeeComponent::add_attr(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id,
|
||||
uint8_t max_size, T value) {
|
||||
this->add_attr<T>(nullptr, endpoint_id, cluster_id, role, attr_id, max_size, value);
|
||||
}
|
||||
|
||||
template<typename T>
|
||||
void ZigbeeComponent::add_attr(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role,
|
||||
uint16_t attr_id, uint8_t max_size, T value) {
|
||||
// The size byte of the zcl_str must be set to the maximum value,
|
||||
// even though the initial string may be shorter.
|
||||
if constexpr (std::is_same<T, std::string>::value) {
|
||||
auto zcl_str = get_zcl_string(value.c_str(), max_size, true);
|
||||
add_attr_(attr, endpoint_id, cluster_id, role, attr_id, zcl_str);
|
||||
delete[] zcl_str;
|
||||
} else if constexpr (std::is_convertible<T, const char *>::value) {
|
||||
auto zcl_str = get_zcl_string(value, max_size, true);
|
||||
add_attr_(attr, endpoint_id, cluster_id, role, attr_id, zcl_str);
|
||||
delete[] zcl_str;
|
||||
} else {
|
||||
add_attr_(attr, endpoint_id, cluster_id, role, attr_id, &value);
|
||||
}
|
||||
}
|
||||
|
||||
template<typename T>
|
||||
void ZigbeeComponent::add_attr_(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role,
|
||||
uint16_t attr_id, T *value_p) {
|
||||
esp_zb_attribute_list_t *attr_list = this->attribute_list_[{endpoint_id, cluster_id, role}];
|
||||
esp_err_t ret = esphome_zb_cluster_add_or_update_attr(cluster_id, attr_list, attr_id, value_p);
|
||||
|
||||
if (attr != nullptr) {
|
||||
this->attributes_[{endpoint_id, cluster_id, role, attr_id}] = attr;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::zigbee
|
||||
|
||||
#endif
|
||||
#endif
|
||||
@@ -0,0 +1,274 @@
|
||||
import copy
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.esp32 import (
|
||||
CONF_PARTITIONS,
|
||||
add_idf_component,
|
||||
add_idf_sdkconfig_option,
|
||||
add_partition,
|
||||
require_vfs_select,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_AP,
|
||||
CONF_DEVICE,
|
||||
CONF_ID,
|
||||
CONF_MAX_LENGTH,
|
||||
CONF_MODEL,
|
||||
CONF_NAME,
|
||||
CONF_TYPE,
|
||||
CONF_VALUE,
|
||||
CONF_WIFI,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
from esphome.coroutine import CoroPriority, coroutine_with_priority
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .const import CONF_REPORT, CONF_ROUTER, KEY_ZIGBEE, REPORT, ZigbeeAttribute
|
||||
from .const_esp32 import (
|
||||
ATTR_TYPE,
|
||||
CLUSTER_ID,
|
||||
CONF_ATTRIBUTE_ID,
|
||||
CONF_ATTRIBUTES,
|
||||
CONF_CLUSTERS,
|
||||
CONF_NUM,
|
||||
DEVICE_ID,
|
||||
DEVICE_TYPE,
|
||||
KEY_BS_EP,
|
||||
ROLE,
|
||||
SCALE,
|
||||
)
|
||||
from .zigbee_ep_esp32 import create_ep, ep_configs
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_c_size(bits: str, options: list[int]) -> str:
|
||||
return str([n for n in options if n >= int(bits)][0])
|
||||
|
||||
|
||||
def get_c_type(attr_type: str) -> Any | None:
|
||||
if attr_type == "BOOL":
|
||||
return cg.bool_
|
||||
if "STRING" in attr_type:
|
||||
return cg.std_string
|
||||
test = re.match(r"(^U?)(\d{1,2})(BITMAP$|BIT$|BIT_ENUM$|$)", attr_type)
|
||||
if test and test.group(2):
|
||||
return getattr(cg, "uint" + get_c_size(test.group(2), [8, 16, 32, 64]))
|
||||
return None
|
||||
|
||||
|
||||
def get_cv_by_type(attr_type: str) -> Any | None:
|
||||
if attr_type == "BOOL":
|
||||
return cv.boolean
|
||||
if "STRING" in attr_type:
|
||||
return cv.string
|
||||
test = re.match(r"(^U?)(\d{1,2})(BITMAP$|BIT$|BIT_ENUM$|$)", attr_type)
|
||||
if test and test.group(2):
|
||||
return cv.positive_int
|
||||
return None
|
||||
|
||||
|
||||
def get_default_by_type(attr_type: str) -> str | bool | int:
|
||||
if attr_type == "CHAR_STRING":
|
||||
return ""
|
||||
if attr_type == "BOOL":
|
||||
return False
|
||||
return 0
|
||||
|
||||
|
||||
def validate_attributes(config: ConfigType) -> ConfigType:
|
||||
if CONF_VALUE not in config:
|
||||
config[CONF_VALUE] = get_default_by_type(config[CONF_TYPE])
|
||||
config[CONF_VALUE] = get_cv_by_type(config[CONF_TYPE])(config[CONF_VALUE])
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def final_validate_esp32(config: ConfigType) -> ConfigType:
|
||||
if not CORE.is_esp32:
|
||||
return config
|
||||
if CONF_WIFI in fv.full_config.get():
|
||||
if config[CONF_ROUTER] and CONF_AP in fv.full_config.get()[CONF_WIFI]:
|
||||
raise cv.Invalid(
|
||||
"Only Zigbee End Device can be used together with a Wifi Access Point."
|
||||
)
|
||||
if CONF_AP in fv.full_config.get()[CONF_WIFI]:
|
||||
_LOGGER.warning(
|
||||
"Wifi Access Point might be unstable while Zigbee is active, use only as fallback."
|
||||
)
|
||||
elif config[CONF_ROUTER]:
|
||||
_LOGGER.warning(
|
||||
"The Zigbee Router might miss packets while Wifi is active and could destabilize "
|
||||
"your network. Use only if Wifi is off most of the time."
|
||||
)
|
||||
if CONF_PARTITIONS in fv.full_config.get() and not isinstance(
|
||||
fv.full_config.get()[CONF_PARTITIONS], list
|
||||
):
|
||||
with open(
|
||||
CORE.relative_config_path(fv.full_config.get()[CONF_PARTITIONS]),
|
||||
encoding="utf8",
|
||||
) as f:
|
||||
partitions_tab = f.read()
|
||||
for partition, types in [
|
||||
("zb_storage", {"type": "data", "subtype": "fat", "size": 0x4000}),
|
||||
("zb_fct", {"type": "data", "subtype": "fat", "size": 0x1000}),
|
||||
]:
|
||||
if partition not in partitions_tab:
|
||||
raise cv.Invalid(
|
||||
f"Add '{partition}, {types['type']}, {types['subtype']}, , {types['size']},' to your custom partition table."
|
||||
)
|
||||
if not re.search(
|
||||
rf"^{partition},\s*{types['type']},\s*{types['subtype']}",
|
||||
partitions_tab,
|
||||
re.MULTILINE,
|
||||
):
|
||||
raise cv.Invalid(
|
||||
f"Partition '{partition}' in your custom partition table has wrong format. It should be: '{partition}, {types['type']}, {types['subtype']}, , {types['size']},'"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def validate_binary_sensor_esp32(config: ConfigType) -> ConfigType:
|
||||
ep = copy.deepcopy(ep_configs["binary_input"])
|
||||
for cl in ep.get(CONF_CLUSTERS, []):
|
||||
for attr in cl[CONF_ATTRIBUTES]:
|
||||
if (
|
||||
attr[CONF_ATTRIBUTE_ID] == 0x1C
|
||||
and CONF_VALUE not in attr
|
||||
and CONF_NAME in config
|
||||
): # set name
|
||||
name = (
|
||||
config[CONF_NAME].encode("ascii", "ignore").decode()
|
||||
) # or use unidecode
|
||||
attr[CONF_VALUE] = str(name)
|
||||
attr[CONF_MAX_LENGTH] = len(str(name))
|
||||
if CONF_DEVICE in attr: # connect device
|
||||
attr[CONF_DEVICE] = config[CONF_ID]
|
||||
if CONF_REPORT in config:
|
||||
attr[CONF_REPORT] = config[CONF_REPORT]
|
||||
attr[CONF_ID] = cv.declare_id(ZigbeeAttribute)(None)
|
||||
if "zb_attr_ids" not in config:
|
||||
config["zb_attr_ids"] = []
|
||||
config["zb_attr_ids"].append(attr[CONF_ID])
|
||||
else:
|
||||
attr[CONF_ID] = None
|
||||
validate_attributes(attr)
|
||||
zb_data = CORE.data.setdefault(KEY_ZIGBEE, {})
|
||||
binary_sensor_ep: list[dict] = zb_data.setdefault(KEY_BS_EP, [])
|
||||
binary_sensor_ep.append(ep)
|
||||
return config
|
||||
|
||||
|
||||
def zigbee_require_vfs_select(config: ConfigType) -> ConfigType:
|
||||
"""Register VFS select requirement during config validation."""
|
||||
# Zigbee uses esp_vfs_eventfd which requires VFS select support
|
||||
if CORE.is_esp32:
|
||||
require_vfs_select()
|
||||
return config
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.WORKAROUNDS)
|
||||
async def _zigbee_add_sdkconfigs(config: ConfigType) -> None:
|
||||
"""Add sdkconfigs late so they can overwrite esp32 defaults"""
|
||||
add_idf_sdkconfig_option("CONFIG_ZB_ENABLED", True)
|
||||
if config.get(CONF_ROUTER):
|
||||
add_idf_sdkconfig_option("CONFIG_ZB_ZCZR", True)
|
||||
else:
|
||||
add_idf_sdkconfig_option("CONFIG_ZB_ZED", True)
|
||||
add_idf_sdkconfig_option("CONFIG_ZB_RADIO_NATIVE", True)
|
||||
if CONF_WIFI in CORE.config:
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE", 4096)
|
||||
# The pre-built Zigbee library uses esp_log_default_level which requires
|
||||
# dynamic log level control to be enabled
|
||||
add_idf_sdkconfig_option("CONFIG_LOG_DYNAMIC_LEVEL_CONTROL", True)
|
||||
|
||||
|
||||
async def attributes_to_code(
|
||||
var: cg.Pvariable, ep_num: int, cl: dict[str, Any]
|
||||
) -> None:
|
||||
for attr in cl.get(CONF_ATTRIBUTES, []):
|
||||
if attr.get(CONF_ID) is None:
|
||||
cg.add(
|
||||
var.add_attr(
|
||||
ep_num,
|
||||
CLUSTER_ID.get(cl[CONF_ID], cl[CONF_ID]),
|
||||
cl[ROLE],
|
||||
attr[CONF_ATTRIBUTE_ID],
|
||||
attr.get(CONF_MAX_LENGTH, 0),
|
||||
attr[CONF_VALUE],
|
||||
)
|
||||
)
|
||||
continue
|
||||
attr_var = cg.new_Pvariable(
|
||||
attr[CONF_ID],
|
||||
var,
|
||||
ep_num,
|
||||
CLUSTER_ID.get(cl[CONF_ID], cl[CONF_ID]),
|
||||
cl[ROLE],
|
||||
attr[CONF_ATTRIBUTE_ID],
|
||||
ATTR_TYPE[attr[CONF_TYPE]],
|
||||
attr.get(SCALE, 1),
|
||||
attr.get(CONF_MAX_LENGTH, 0),
|
||||
)
|
||||
await cg.register_component(attr_var, attr)
|
||||
|
||||
cg.add(attr_var.add_attr(attr[CONF_VALUE]))
|
||||
if CONF_REPORT in attr and attr[CONF_REPORT] in [
|
||||
REPORT["enable"],
|
||||
REPORT["force"],
|
||||
]:
|
||||
cg.add(attr_var.set_report(attr[CONF_REPORT] == REPORT["force"]))
|
||||
|
||||
if CONF_DEVICE in attr:
|
||||
device = await cg.get_variable(attr[CONF_DEVICE])
|
||||
template_arg = cg.TemplateArguments(get_c_type(attr[CONF_TYPE]))
|
||||
cg.add(attr_var.connect(template_arg, device))
|
||||
|
||||
|
||||
async def esp32_to_code(config: ConfigType) -> None:
|
||||
add_idf_component(
|
||||
name="espressif/esp-zboss-lib",
|
||||
ref="1.6.4",
|
||||
)
|
||||
add_idf_component(
|
||||
name="espressif/esp-zigbee-lib",
|
||||
ref="1.6.8",
|
||||
)
|
||||
|
||||
# add sdkconfigs later so they can overwrite esp32 defaults
|
||||
CORE.add_job(_zigbee_add_sdkconfigs, config)
|
||||
|
||||
# add partitions for zigbee
|
||||
add_partition("zb_storage", "data", "fat", 0x4000) # 16KB
|
||||
add_partition("zb_fct", "data", "fat", 0x1000) # 4KB, minimum size
|
||||
|
||||
# create endpoints
|
||||
zb_data = CORE.data.get(KEY_ZIGBEE, {})
|
||||
binary_sensor_ep: list[dict] = zb_data.get(KEY_BS_EP, [])
|
||||
ep_list = create_ep(binary_sensor_ep, config.get(CONF_ROUTER))
|
||||
|
||||
# setup zigbee components
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
cg.add(
|
||||
var.set_basic_cluster(
|
||||
config[CONF_MODEL],
|
||||
"esphome",
|
||||
)
|
||||
)
|
||||
for ep in ep_list:
|
||||
cg.add(var.create_default_cluster(ep[CONF_NUM], DEVICE_ID[ep[DEVICE_TYPE]]))
|
||||
for cl in ep.get(CONF_CLUSTERS, []):
|
||||
cg.add(
|
||||
var.add_cluster(
|
||||
ep[CONF_NUM],
|
||||
CLUSTER_ID.get(cl[CONF_ID], cl[CONF_ID]),
|
||||
cl[ROLE],
|
||||
)
|
||||
)
|
||||
await attributes_to_code(var, ep[CONF_NUM], cl)
|
||||
@@ -0,0 +1,74 @@
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_ESP32
|
||||
#ifdef USE_ZIGBEE
|
||||
|
||||
#include "ha/esp_zigbee_ha_standard.h"
|
||||
#include "zigbee_helpers_esp32.h"
|
||||
|
||||
esp_err_t esphome_zb_cluster_add_or_update_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list,
|
||||
uint16_t attr_id, void *value_p) {
|
||||
esp_err_t ret;
|
||||
ret = esp_zb_cluster_update_attr(attr_list, attr_id, value_p);
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE("zigbee_helper", "Ignore previous attribute not found error");
|
||||
ret = esphome_zb_cluster_add_attr(cluster_id, attr_list, attr_id, value_p);
|
||||
}
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE("zigbee_helper", "Could not add attribute 0x%04X to cluster 0x%04X: %s", attr_id, cluster_id,
|
||||
esp_err_to_name(ret));
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
esp_err_t esphome_zb_cluster_list_add_or_update_cluster(uint16_t cluster_id, esp_zb_cluster_list_t *cluster_list,
|
||||
esp_zb_attribute_list_t *attr_list, uint8_t role_mask) {
|
||||
esp_err_t ret;
|
||||
ret = esp_zb_cluster_list_update_cluster(cluster_list, attr_list, cluster_id, role_mask);
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE("zigbee_helper", "Ignore previous cluster not found error");
|
||||
switch (cluster_id) {
|
||||
case ESP_ZB_ZCL_CLUSTER_ID_BASIC:
|
||||
ret = esp_zb_cluster_list_add_basic_cluster(cluster_list, attr_list, role_mask);
|
||||
break;
|
||||
case ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY:
|
||||
ret = esp_zb_cluster_list_add_identify_cluster(cluster_list, attr_list, role_mask);
|
||||
break;
|
||||
case ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT:
|
||||
ret = esp_zb_cluster_list_add_binary_input_cluster(cluster_list, attr_list, role_mask);
|
||||
break;
|
||||
default:
|
||||
ret = esp_zb_cluster_list_add_custom_cluster(cluster_list, attr_list, role_mask);
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
esp_zb_attribute_list_t *esphome_zb_default_attr_list_create(uint16_t cluster_id) {
|
||||
switch (cluster_id) {
|
||||
case ESP_ZB_ZCL_CLUSTER_ID_BASIC:
|
||||
return esp_zb_basic_cluster_create(NULL);
|
||||
case ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY:
|
||||
return esp_zb_identify_cluster_create(NULL);
|
||||
case ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT:
|
||||
return esp_zb_binary_input_cluster_create(NULL);
|
||||
default:
|
||||
return esp_zb_zcl_attr_list_create(cluster_id);
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list, uint16_t attr_id,
|
||||
void *value_p) {
|
||||
switch (cluster_id) {
|
||||
case ESP_ZB_ZCL_CLUSTER_ID_BASIC:
|
||||
return esp_zb_basic_cluster_add_attr(attr_list, attr_id, value_p);
|
||||
case ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY:
|
||||
return esp_zb_identify_cluster_add_attr(attr_list, attr_id, value_p);
|
||||
case ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT:
|
||||
return esp_zb_binary_input_cluster_add_attr(attr_list, attr_id, value_p);
|
||||
default:
|
||||
return ESP_FAIL;
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
#endif
|
||||
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_ESP32
|
||||
#ifdef USE_ZIGBEE
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#include "esp_zigbee_core.h"
|
||||
|
||||
esp_err_t esphome_zb_cluster_list_add_or_update_cluster(uint16_t cluster_id, esp_zb_cluster_list_t *cluster_list,
|
||||
esp_zb_attribute_list_t *attr_list, uint8_t role_mask);
|
||||
esp_zb_attribute_list_t *esphome_zb_default_attr_list_create(uint16_t cluster_id);
|
||||
esp_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list, uint16_t attr_id,
|
||||
void *value_p);
|
||||
esp_err_t esphome_zb_cluster_add_or_update_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list,
|
||||
uint16_t attr_id, void *value_p);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
namespace esphome::zigbee {} // namespace esphome::zigbee
|
||||
#endif
|
||||
|
||||
#endif
|
||||
#endif
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime
|
||||
import datetime
|
||||
import random
|
||||
|
||||
from esphome import automation
|
||||
@@ -7,6 +7,7 @@ from esphome.components.zephyr import zephyr_add_prj_conf
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ID,
|
||||
CONF_MODEL,
|
||||
CONF_NAME,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
UNIT_AMPERE,
|
||||
@@ -48,19 +49,26 @@ from esphome.cpp_generator import (
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .const_zephyr import (
|
||||
CONF_IEEE802154_VENDOR_OUI,
|
||||
from .const import (
|
||||
CONF_ON_JOIN,
|
||||
CONF_POWER_SOURCE,
|
||||
CONF_WIPE_ON_BOOT,
|
||||
KEY_ZIGBEE,
|
||||
POWER_SOURCE,
|
||||
AnalogAttrs,
|
||||
AnalogAttrsOutput,
|
||||
BinaryAttrs,
|
||||
ZigbeeComponent,
|
||||
zigbee_ns,
|
||||
)
|
||||
from .const_zephyr import (
|
||||
CONF_IEEE802154_VENDOR_OUI,
|
||||
CONF_ZIGBEE_BINARY_SENSOR,
|
||||
CONF_ZIGBEE_ID,
|
||||
CONF_ZIGBEE_NUMBER,
|
||||
CONF_ZIGBEE_SENSOR,
|
||||
CONF_ZIGBEE_SWITCH,
|
||||
KEY_EP_NUMBER,
|
||||
KEY_ZIGBEE,
|
||||
POWER_SOURCE,
|
||||
ZB_ZCL_BASIC_ATTRS_EXT_T,
|
||||
ZB_ZCL_CLUSTER_ID_ANALOG_INPUT,
|
||||
ZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT,
|
||||
@@ -69,11 +77,6 @@ from .const_zephyr import (
|
||||
ZB_ZCL_CLUSTER_ID_BINARY_OUTPUT,
|
||||
ZB_ZCL_CLUSTER_ID_IDENTIFY,
|
||||
ZB_ZCL_IDENTIFY_ATTRS_T,
|
||||
AnalogAttrs,
|
||||
AnalogAttrsOutput,
|
||||
BinaryAttrs,
|
||||
ZigbeeComponent,
|
||||
zigbee_ns,
|
||||
)
|
||||
|
||||
ZigbeeBinarySensor = zigbee_ns.class_("ZigbeeBinarySensor", cg.Component)
|
||||
@@ -209,9 +212,9 @@ async def _attr_to_code(config: ConfigType) -> None:
|
||||
zigbee_assign(basic_attrs.stack_version, 0),
|
||||
zigbee_assign(basic_attrs.hw_version, 0),
|
||||
zigbee_set_string(basic_attrs.mf_name, "esphome"),
|
||||
zigbee_set_string(basic_attrs.model_id, CORE.name),
|
||||
zigbee_set_string(basic_attrs.model_id, config[CONF_MODEL]),
|
||||
zigbee_set_string(
|
||||
basic_attrs.date_code, datetime.now().strftime("%d/%m/%y %H:%M")
|
||||
basic_attrs.date_code, datetime.datetime.now().strftime("%Y%m%d %H%M%S")
|
||||
),
|
||||
zigbee_assign(
|
||||
basic_attrs.power_source,
|
||||
|
||||
+13
-191
@@ -12,9 +12,6 @@
|
||||
#include <esp_ota_ops.h>
|
||||
#include <esp_bootloader_desc.h>
|
||||
#endif
|
||||
#ifdef USE_LWIP_FAST_SELECT
|
||||
#include "esphome/core/lwip_fast_select.h"
|
||||
#endif // USE_LWIP_FAST_SELECT
|
||||
#include "esphome/core/version.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include <algorithm>
|
||||
@@ -24,14 +21,6 @@
|
||||
#include "esphome/components/status_led/status_led.h"
|
||||
#endif
|
||||
|
||||
#if (defined(USE_ESP8266) || defined(USE_RP2040)) && defined(USE_SOCKET_IMPL_LWIP_TCP)
|
||||
#include "esphome/components/socket/socket.h"
|
||||
#endif
|
||||
|
||||
#ifdef USE_HOST
|
||||
#include <cerrno>
|
||||
#endif
|
||||
|
||||
namespace esphome {
|
||||
|
||||
static const char *const TAG = "app";
|
||||
@@ -95,16 +84,16 @@ void Application::setup() {
|
||||
// interrupts during setup. During setup we always run the component
|
||||
// phase (no loop_interval_ gate), so call both helpers unconditionally.
|
||||
this->scheduler_tick_(MillisInternal::get());
|
||||
this->before_component_phase_();
|
||||
{
|
||||
ComponentPhaseGuard phase_guard{*this};
|
||||
|
||||
for (uint32_t j = 0; j <= i; j++) {
|
||||
// Update loop_component_start_time_ right before calling each component
|
||||
this->loop_component_start_time_ = MillisInternal::get();
|
||||
this->components_[j]->call();
|
||||
this->feed_wdt();
|
||||
for (uint32_t j = 0; j <= i; j++) {
|
||||
// Update loop_component_start_time_ right before calling each component
|
||||
this->loop_component_start_time_ = MillisInternal::get();
|
||||
this->components_[j]->call();
|
||||
this->feed_wdt();
|
||||
}
|
||||
}
|
||||
|
||||
this->after_component_phase_();
|
||||
yield();
|
||||
} while (!component->can_proceed() && !component->is_failed());
|
||||
}
|
||||
@@ -133,8 +122,8 @@ void Application::setup() {
|
||||
esphome_main_task_handle = xTaskGetCurrentTaskHandle();
|
||||
#endif
|
||||
#ifdef USE_HOST
|
||||
// Set up wake socket for waking main loop from tasks (platforms without fast select only)
|
||||
this->setup_wake_loop_threadsafe_();
|
||||
// Set up wake socket for waking main loop from tasks (host platform select() loop).
|
||||
wake_setup();
|
||||
#endif
|
||||
|
||||
// Ensure all active looping components are in LOOP state.
|
||||
@@ -212,16 +201,8 @@ void Application::process_dump_config_() {
|
||||
|
||||
void Application::feed_wdt() {
|
||||
// Cold entry: callers without a millis() timestamp in hand. Fetches the
|
||||
// time and takes the same rate-limit paths as feed_wdt_with_time().
|
||||
uint32_t now = MillisInternal::get();
|
||||
if (now - this->last_wdt_feed_ > WDT_FEED_INTERVAL_MS) {
|
||||
this->feed_wdt_slow_(now);
|
||||
}
|
||||
#ifdef USE_STATUS_LED
|
||||
if (now - this->last_status_led_service_ > STATUS_LED_DISPATCH_INTERVAL_MS) {
|
||||
this->service_status_led_slow_(now);
|
||||
}
|
||||
#endif
|
||||
// time and defers to the hot path.
|
||||
this->feed_wdt_with_time(MillisInternal::get());
|
||||
}
|
||||
|
||||
void HOT Application::feed_wdt_slow_(uint32_t time) {
|
||||
@@ -378,7 +359,7 @@ void Application::teardown_components(uint32_t timeout_ms) {
|
||||
|
||||
// Give some time for I/O operations if components are still pending
|
||||
if (pending_count > 0) {
|
||||
this->yield_with_select_(1);
|
||||
esphome::internal::wakeable_delay(1);
|
||||
}
|
||||
|
||||
// Update time for next iteration
|
||||
@@ -510,105 +491,6 @@ void Application::enable_pending_loops_() {
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef USE_HOST
|
||||
bool Application::register_socket_fd(int fd) {
|
||||
// WARNING: This function is NOT thread-safe and must only be called from the main loop
|
||||
// It modifies socket_fds_ and related variables without locking
|
||||
if (fd < 0)
|
||||
return false;
|
||||
|
||||
if (fd >= FD_SETSIZE) {
|
||||
ESP_LOGE(TAG, "fd %d exceeds FD_SETSIZE %d", fd, FD_SETSIZE);
|
||||
return false;
|
||||
}
|
||||
|
||||
this->socket_fds_.push_back(fd);
|
||||
this->socket_fds_changed_ = true;
|
||||
if (fd > this->max_fd_) {
|
||||
this->max_fd_ = fd;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void Application::unregister_socket_fd(int fd) {
|
||||
// WARNING: This function is NOT thread-safe and must only be called from the main loop
|
||||
// It modifies socket_fds_ and related variables without locking
|
||||
if (fd < 0)
|
||||
return;
|
||||
|
||||
for (size_t i = 0; i < this->socket_fds_.size(); i++) {
|
||||
if (this->socket_fds_[i] != fd)
|
||||
continue;
|
||||
|
||||
// Swap with last element and pop - O(1) removal since order doesn't matter.
|
||||
if (i < this->socket_fds_.size() - 1)
|
||||
this->socket_fds_[i] = this->socket_fds_.back();
|
||||
this->socket_fds_.pop_back();
|
||||
this->socket_fds_changed_ = true;
|
||||
// Only recalculate max_fd if we removed the current max
|
||||
if (fd == this->max_fd_) {
|
||||
this->max_fd_ = -1;
|
||||
for (int sock_fd : this->socket_fds_) {
|
||||
if (sock_fd > this->max_fd_)
|
||||
this->max_fd_ = sock_fd;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
// Only the select() fallback path remains in the .cpp — all other paths are inlined in application.h
|
||||
#ifdef USE_HOST
|
||||
void Application::yield_with_select_(uint32_t delay_ms) {
|
||||
// Fallback select() path (host platform and any future platforms without fast select).
|
||||
if (!this->socket_fds_.empty()) [[likely]] {
|
||||
// Update fd_set if socket list has changed
|
||||
if (this->socket_fds_changed_) [[unlikely]] {
|
||||
FD_ZERO(&this->base_read_fds_);
|
||||
// fd bounds are validated in register_socket_fd()
|
||||
for (int fd : this->socket_fds_) {
|
||||
FD_SET(fd, &this->base_read_fds_);
|
||||
}
|
||||
this->socket_fds_changed_ = false;
|
||||
}
|
||||
|
||||
// Copy base fd_set before each select
|
||||
this->read_fds_ = this->base_read_fds_;
|
||||
|
||||
// Convert delay_ms to timeval
|
||||
struct timeval tv;
|
||||
tv.tv_sec = delay_ms / 1000;
|
||||
tv.tv_usec = (delay_ms - tv.tv_sec * 1000) * 1000;
|
||||
|
||||
// Call select with timeout
|
||||
int ret = ::select(this->max_fd_ + 1, &this->read_fds_, nullptr, nullptr, &tv);
|
||||
|
||||
// Process select() result:
|
||||
// ret > 0: socket(s) have data ready - normal and expected
|
||||
// ret == 0: timeout occurred - normal and expected
|
||||
if (ret >= 0) [[likely]] {
|
||||
// Yield if zero timeout since select(0) only polls without yielding
|
||||
if (delay_ms == 0) [[unlikely]] {
|
||||
yield();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// ret < 0: error (EINTR is normal, anything else is unexpected)
|
||||
const int err = errno;
|
||||
if (err == EINTR) {
|
||||
return;
|
||||
}
|
||||
// select() error - log and fall through to delay()
|
||||
ESP_LOGW(TAG, "select() failed with errno %d", err);
|
||||
}
|
||||
// No sockets registered or select() failed - use regular delay
|
||||
delay(delay_ms);
|
||||
}
|
||||
#endif // USE_HOST
|
||||
|
||||
// App storage — asm label shares the linker symbol with "extern Application App".
|
||||
// char[] is trivially destructible, so no __cxa_atexit or destructor chain is emitted.
|
||||
// Constructed via placement new in the generated setup().
|
||||
@@ -628,66 +510,6 @@ alignas(Application) char app_storage[sizeof(Application)] asm(
|
||||
#undef ESPHOME_STRINGIFY_
|
||||
#undef ESPHOME_STRINGIFY_IMPL_
|
||||
|
||||
// Host platform wake_loop_threadsafe() and setup — needs wake_socket_fd_
|
||||
// ESP32/LibreTiny/ESP8266/RP2040 implementations are in wake.cpp
|
||||
#ifdef USE_HOST
|
||||
|
||||
void Application::setup_wake_loop_threadsafe_() {
|
||||
// Create UDP socket for wake notifications
|
||||
this->wake_socket_fd_ = ::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
|
||||
if (this->wake_socket_fd_ < 0) {
|
||||
ESP_LOGW(TAG, "Wake socket create failed: %d", errno);
|
||||
return;
|
||||
}
|
||||
|
||||
// Bind to loopback with auto-assigned port
|
||||
struct sockaddr_in addr = {};
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
addr.sin_port = 0; // Auto-assign port
|
||||
|
||||
if (::bind(this->wake_socket_fd_, (struct sockaddr *) &addr, sizeof(addr)) < 0) {
|
||||
ESP_LOGW(TAG, "Wake socket bind failed: %d", errno);
|
||||
::close(this->wake_socket_fd_);
|
||||
this->wake_socket_fd_ = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the assigned address and connect to it
|
||||
// Connecting a UDP socket allows using send() instead of sendto() for better performance
|
||||
struct sockaddr_in wake_addr;
|
||||
socklen_t len = sizeof(wake_addr);
|
||||
if (::getsockname(this->wake_socket_fd_, (struct sockaddr *) &wake_addr, &len) < 0) {
|
||||
ESP_LOGW(TAG, "Wake socket address failed: %d", errno);
|
||||
::close(this->wake_socket_fd_);
|
||||
this->wake_socket_fd_ = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Connect to self (loopback) - allows using send() instead of sendto()
|
||||
// After connect(), no need to store wake_addr - the socket remembers it
|
||||
if (::connect(this->wake_socket_fd_, (struct sockaddr *) &wake_addr, sizeof(wake_addr)) < 0) {
|
||||
ESP_LOGW(TAG, "Wake socket connect failed: %d", errno);
|
||||
::close(this->wake_socket_fd_);
|
||||
this->wake_socket_fd_ = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Set non-blocking mode
|
||||
int flags = ::fcntl(this->wake_socket_fd_, F_GETFL, 0);
|
||||
::fcntl(this->wake_socket_fd_, F_SETFL, flags | O_NONBLOCK);
|
||||
|
||||
// Register with application's select() loop
|
||||
if (!this->register_socket_fd(this->wake_socket_fd_)) {
|
||||
ESP_LOGW(TAG, "Wake socket register failed");
|
||||
::close(this->wake_socket_fd_);
|
||||
this->wake_socket_fd_ = -1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
#endif // USE_HOST
|
||||
|
||||
void Application::get_build_time_string(std::span<char, BUILD_TIME_STR_SIZE> buffer) {
|
||||
ESPHOME_strncpy_P(buffer.data(), ESPHOME_BUILD_TIME_STR, buffer.size());
|
||||
buffer[buffer.size() - 1] = '\0';
|
||||
|
||||
+39
-132
@@ -24,30 +24,12 @@
|
||||
#include "esphome/core/area.h"
|
||||
#endif
|
||||
|
||||
#ifdef USE_LWIP_FAST_SELECT
|
||||
#include "esphome/core/lwip_fast_select.h"
|
||||
#endif
|
||||
#ifdef USE_HOST
|
||||
#include <sys/select.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <netinet/in.h>
|
||||
#include <arpa/inet.h>
|
||||
#endif
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
#include "esphome/components/runtime_stats/runtime_stats.h"
|
||||
#endif
|
||||
#include "esphome/core/wake.h"
|
||||
#include "esphome/core/entity_includes.h"
|
||||
|
||||
namespace esphome::socket {
|
||||
#ifdef USE_HOST
|
||||
/// Shared ready() helper for fd-based socket implementations.
|
||||
bool socket_ready_fd(int fd, bool loop_monitored); // NOLINT(readability-redundant-declaration)
|
||||
#endif
|
||||
} // namespace esphome::socket
|
||||
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
namespace esphome::runtime_stats {
|
||||
class RuntimeStatsCollector;
|
||||
@@ -234,11 +216,19 @@ class Application {
|
||||
/// loops and scheduler items still feed after every op, so any op exceeding
|
||||
/// this threshold triggers a real feed naturally.
|
||||
/// Safety margins vs. platform watchdog timeouts:
|
||||
/// - ESP32 task WDT default (5 s): ~16x
|
||||
/// - ESP8266 soft WDT (~1.6 s): ~5x <-- floor case; any future change
|
||||
/// must keep comfortable margin here
|
||||
/// - ESP8266 HW WDT (~6 s): ~20x
|
||||
/// - ESP32 task WDT default (5 s): ~16x
|
||||
/// - ESP8266 soft WDT (~1.6 s): ~5x <-- floor case; any future change
|
||||
/// must keep comfortable margin here
|
||||
/// - ESP8266 HW WDT (~6 s): ~20x
|
||||
/// - BK72xx HW WDT (10 s): ~5x <-- platform override below
|
||||
#ifdef USE_BK72XX
|
||||
// BDK busy-waits 200us per WDT reload (sctrl_dpll_delay200us). LibreTiny
|
||||
// sets HW WDT to 10s; 2000ms keeps ~5x margin. See wdt_ctrl WCMD_RELOAD_PERIOD:
|
||||
// https://github.com/libretiny-eu/framework-beken-bdk/blob/44800e7451ea30fbcbd3bb6e905315de59349fee/beken378/driver/wdt/wdt.c#L75-L87
|
||||
static constexpr uint32_t WDT_FEED_INTERVAL_MS = 2000;
|
||||
#else
|
||||
static constexpr uint32_t WDT_FEED_INTERVAL_MS = 300;
|
||||
#endif
|
||||
|
||||
/// Feed the task watchdog. Cold entry — callers without a millis()
|
||||
/// timestamp in hand. Out of line to keep call sites tiny.
|
||||
@@ -343,18 +333,6 @@ class Application {
|
||||
|
||||
Scheduler scheduler;
|
||||
|
||||
#ifdef USE_HOST
|
||||
/// Register/unregister a socket file descriptor with the host select() fallback loop.
|
||||
/// USE_LWIP_FAST_SELECT builds do not use this API — sockets hook the lwIP netconn
|
||||
/// event_callback directly (see socket.h hook_fd_for_fast_select) and rely on FreeRTOS
|
||||
/// task notifications for wake-up.
|
||||
/// NOTE: File descriptors >= FD_SETSIZE (typically 10 on ESP) will be rejected with an error.
|
||||
/// WARNING: These functions are NOT thread-safe. They must only be called from the main loop.
|
||||
/// @return true if registration was successful, false if fd exceeds limits
|
||||
bool register_socket_fd(int fd);
|
||||
void unregister_socket_fd(int fd);
|
||||
#endif
|
||||
|
||||
/// Wake the main event loop from another thread or callback.
|
||||
/// @see esphome::wake_loop_threadsafe() in wake.h for platform details.
|
||||
void wake_loop_threadsafe() { esphome::wake_loop_threadsafe(); }
|
||||
@@ -372,21 +350,11 @@ class Application {
|
||||
|
||||
protected:
|
||||
friend Component;
|
||||
#ifdef USE_HOST
|
||||
friend bool socket::socket_ready_fd(int fd, bool loop_monitored);
|
||||
#endif
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
friend class runtime_stats::RuntimeStatsCollector;
|
||||
#endif
|
||||
friend void ::setup();
|
||||
friend void ::original_setup();
|
||||
#ifdef USE_HOST
|
||||
friend void wake_loop_threadsafe(); // Host platform accesses wake_socket_fd_
|
||||
#endif
|
||||
|
||||
#ifdef USE_HOST
|
||||
bool is_socket_ready_(int fd) const { return FD_ISSET(fd, &this->read_fds_); }
|
||||
#endif
|
||||
|
||||
/// Walk all registered components looking for any whose component_state_
|
||||
/// has the given flag set. Used by Component::status_clear_*_slow_path_()
|
||||
@@ -425,8 +393,20 @@ class Application {
|
||||
void enable_pending_loops_();
|
||||
void activate_looping_component_(uint16_t index);
|
||||
inline uint32_t ESPHOME_ALWAYS_INLINE scheduler_tick_(uint32_t now);
|
||||
inline void ESPHOME_ALWAYS_INLINE before_component_phase_();
|
||||
inline void ESPHOME_ALWAYS_INLINE after_component_phase_() { this->in_loop_ = false; }
|
||||
|
||||
// RAII guard for a component loop phase. Constructor processes any pending
|
||||
// enable_loop requests from ISRs and marks in_loop_ so reentrant
|
||||
// modifications during component.loop() are safe; destructor clears in_loop_.
|
||||
class ComponentPhaseGuard {
|
||||
public:
|
||||
inline ESPHOME_ALWAYS_INLINE explicit ComponentPhaseGuard(Application &app);
|
||||
inline ESPHOME_ALWAYS_INLINE ~ComponentPhaseGuard() { this->app_.in_loop_ = false; }
|
||||
ComponentPhaseGuard(const ComponentPhaseGuard &) = delete;
|
||||
ComponentPhaseGuard &operator=(const ComponentPhaseGuard &) = delete;
|
||||
|
||||
private:
|
||||
Application &app_;
|
||||
};
|
||||
|
||||
/// Process dump_config output one component per loop iteration.
|
||||
/// Extracted from loop() to keep cold startup/reconnect logging out of the hot path.
|
||||
@@ -448,19 +428,6 @@ class Application {
|
||||
void service_status_led_slow_(uint32_t time);
|
||||
#endif
|
||||
|
||||
/// Perform a delay while also monitoring socket file descriptors for readiness
|
||||
#ifdef USE_HOST
|
||||
// select() fallback path is too complex to inline (host platform)
|
||||
void yield_with_select_(uint32_t delay_ms);
|
||||
#else
|
||||
inline void ESPHOME_ALWAYS_INLINE yield_with_select_(uint32_t delay_ms);
|
||||
#endif
|
||||
|
||||
#ifdef USE_HOST
|
||||
void setup_wake_loop_threadsafe_(); // Create wake notification socket
|
||||
inline void drain_wake_notifications_(); // Read pending wake notifications in main loop (hot path - inlined)
|
||||
#endif
|
||||
|
||||
// === Member variables ordered by size to minimize padding ===
|
||||
|
||||
// Pointer-sized members first
|
||||
@@ -484,12 +451,6 @@ class Application {
|
||||
// and active_end_ is incremented
|
||||
// - This eliminates branch mispredictions from flag checking in the hot loop
|
||||
FixedVector<Component *> looping_components_{};
|
||||
#ifdef USE_HOST
|
||||
std::vector<int> socket_fds_; // Vector of all monitored socket file descriptors
|
||||
#endif
|
||||
#ifdef USE_HOST
|
||||
int wake_socket_fd_{-1}; // Shared wake notification socket for waking main loop from tasks
|
||||
#endif
|
||||
|
||||
// StringRef members (8 bytes each: pointer + size)
|
||||
StringRef name_;
|
||||
@@ -504,10 +465,6 @@ class Application {
|
||||
uint32_t last_status_led_service_{0};
|
||||
#endif
|
||||
|
||||
#ifdef USE_HOST
|
||||
int max_fd_{-1}; // Highest file descriptor number for select()
|
||||
#endif
|
||||
|
||||
// 2-byte members (grouped together for alignment)
|
||||
uint16_t dump_config_at_{std::numeric_limits<uint16_t>::max()}; // Index into components_ for dump_config progress
|
||||
uint16_t loop_interval_{16}; // Loop interval in ms (max 65535ms = 65.5 seconds)
|
||||
@@ -520,16 +477,6 @@ class Application {
|
||||
bool in_loop_{false};
|
||||
volatile bool has_pending_enable_loop_requests_{false};
|
||||
|
||||
#ifdef USE_HOST
|
||||
bool socket_fds_changed_{false}; // Flag to rebuild base_read_fds_ when socket_fds_ changes
|
||||
#endif
|
||||
|
||||
#ifdef USE_HOST
|
||||
// Variable-sized members (not needed with fast select — is_socket_ready_ reads rcvevent directly)
|
||||
fd_set read_fds_{}; // Working fd_set: populated by select()
|
||||
fd_set base_read_fds_{}; // Cached fd_set rebuilt only when socket_fds_ changes
|
||||
#endif
|
||||
|
||||
// StaticVectors (largest members - contain actual array data inline)
|
||||
StaticVector<Component *, ESPHOME_COMPONENT_COUNT> components_{};
|
||||
|
||||
@@ -557,30 +504,6 @@ class Application {
|
||||
/// Global storage of Application pointer - only one Application can exist.
|
||||
extern Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
#ifdef USE_HOST
|
||||
// Inline implementations for hot-path functions
|
||||
// drain_wake_notifications_() is called on every loop iteration
|
||||
|
||||
// Small buffer for draining wake notification bytes (1 byte sent per wake)
|
||||
// Size allows draining multiple notifications per recvfrom() without wasting stack
|
||||
static constexpr size_t WAKE_NOTIFY_DRAIN_BUFFER_SIZE = 16;
|
||||
|
||||
inline void Application::drain_wake_notifications_() {
|
||||
// Called from main loop to drain any pending wake notifications
|
||||
// Must check is_socket_ready_() to avoid blocking on empty socket
|
||||
if (this->wake_socket_fd_ >= 0 && this->is_socket_ready_(this->wake_socket_fd_)) {
|
||||
char buffer[WAKE_NOTIFY_DRAIN_BUFFER_SIZE];
|
||||
// Drain all pending notifications with non-blocking reads
|
||||
// Multiple wake events may have triggered multiple writes, so drain until EWOULDBLOCK
|
||||
// We control both ends of this loopback socket (always write 1 byte per wake),
|
||||
// so no error checking needed - any errors indicate catastrophic system failure
|
||||
while (::recvfrom(this->wake_socket_fd_, buffer, sizeof(buffer), 0, nullptr, nullptr) > 0) {
|
||||
// Just draining, no action needed - wake has already occurred
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif // USE_HOST
|
||||
|
||||
// Phase A: drain wake notifications and run the scheduler. Invoked on every
|
||||
// Application::loop() tick regardless of whether a component phase runs, so
|
||||
// scheduler items fire at their requested cadence even when the caller has
|
||||
@@ -590,8 +513,8 @@ inline void Application::drain_wake_notifications_() {
|
||||
// per-item feeds inside scheduler.call() without an extra millis().
|
||||
inline uint32_t ESPHOME_ALWAYS_INLINE Application::scheduler_tick_(uint32_t now) {
|
||||
#ifdef USE_HOST
|
||||
// Drain wake notifications first to clear socket for next wake
|
||||
this->drain_wake_notifications_();
|
||||
// Drain wake notifications first to clear socket for next wake.
|
||||
wake_drain_notifications();
|
||||
#endif
|
||||
return this->scheduler.call(now);
|
||||
}
|
||||
@@ -599,10 +522,10 @@ inline uint32_t ESPHOME_ALWAYS_INLINE Application::scheduler_tick_(uint32_t now)
|
||||
// Phase B entry: only invoked when a component loop phase is about to run.
|
||||
// Processes pending enable_loop requests from ISRs and marks in_loop_ so
|
||||
// reentrant modifications during component.loop() are safe.
|
||||
inline void ESPHOME_ALWAYS_INLINE Application::before_component_phase_() {
|
||||
inline ESPHOME_ALWAYS_INLINE Application::ComponentPhaseGuard::ComponentPhaseGuard(Application &app) : app_(app) {
|
||||
// Process any pending enable_loop requests from ISRs
|
||||
// This must be done before marking in_loop_ = true to avoid race conditions
|
||||
if (this->has_pending_enable_loop_requests_) {
|
||||
if (this->app_.has_pending_enable_loop_requests_) {
|
||||
// Clear flag BEFORE processing to avoid race condition
|
||||
// If ISR sets it during processing, we'll catch it next loop iteration
|
||||
// This is safe because:
|
||||
@@ -610,12 +533,12 @@ inline void ESPHOME_ALWAYS_INLINE Application::before_component_phase_() {
|
||||
// 2. If we can't process a component (wrong state), enable_pending_loops_()
|
||||
// will set this flag back to true
|
||||
// 3. Any new ISR requests during processing will set the flag again
|
||||
this->has_pending_enable_loop_requests_ = false;
|
||||
this->enable_pending_loops_();
|
||||
this->app_.has_pending_enable_loop_requests_ = false;
|
||||
this->app_.enable_pending_loops_();
|
||||
}
|
||||
|
||||
// Mark that we're in the loop for safe reentrant modifications
|
||||
this->in_loop_ = true;
|
||||
this->app_.in_loop_ = true;
|
||||
}
|
||||
|
||||
inline void ESPHOME_ALWAYS_INLINE Application::loop() {
|
||||
@@ -669,7 +592,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() {
|
||||
const bool do_component_phase = high_frequency || woke || (elapsed >= this->loop_interval_);
|
||||
|
||||
if (do_component_phase) {
|
||||
this->before_component_phase_();
|
||||
ComponentPhaseGuard phase_guard{*this};
|
||||
|
||||
uint32_t last_op_end_time = now;
|
||||
for (this->current_loop_index_ = 0; this->current_loop_index_ < this->looping_components_active_end_;
|
||||
@@ -694,7 +617,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() {
|
||||
#endif
|
||||
this->last_loop_ = last_op_end_time;
|
||||
now = last_op_end_time;
|
||||
this->after_component_phase_();
|
||||
// phase_guard destructor clears in_loop_ at scope exit
|
||||
}
|
||||
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
@@ -742,30 +665,14 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() {
|
||||
const uint32_t until_sched = this->scheduler.next_schedule_in(now).value_or(until_phase);
|
||||
delay_time = std::min(until_phase, until_sched);
|
||||
}
|
||||
this->yield_with_select_(delay_time);
|
||||
// All platforms route loop yields through the platform wake primitive.
|
||||
// On host this drains the loopback wake socket via select(); on FreeRTOS
|
||||
// targets it uses task notifications; on ESP8266/RP2040 it uses esp_delay/WFE.
|
||||
esphome::internal::wakeable_delay(delay_time);
|
||||
|
||||
if (this->dump_config_at_ < this->components_.size()) {
|
||||
this->process_dump_config_();
|
||||
}
|
||||
}
|
||||
|
||||
// Inline yield_with_select_ for all paths except the select() fallback
|
||||
#ifndef USE_HOST
|
||||
inline void ESPHOME_ALWAYS_INLINE Application::yield_with_select_(uint32_t delay_ms) {
|
||||
#ifdef USE_LWIP_FAST_SELECT
|
||||
// Fast path (ESP32/LibreTiny): FreeRTOS task notifications posted by the lwip
|
||||
// event_callback wrapper (see lwip_fast_select.c) are the single source of truth for
|
||||
// socket wake-ups. Every NETCONN_EVT_RCVPLUS posts an xTaskNotifyGive, so any notification
|
||||
// that lands between wakes keeps the counter non-zero (next ulTaskNotifyTake returns
|
||||
// immediately) or wakes a blocked Take directly. Additional wake sources:
|
||||
// wake_loop_threadsafe() from background tasks, and the delay_ms timeout.
|
||||
if (delay_ms == 0) [[unlikely]] {
|
||||
yield();
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
esphome::internal::wakeable_delay(delay_ms);
|
||||
}
|
||||
#endif // !USE_HOST
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
@@ -21,6 +21,11 @@ namespace infrared {
|
||||
class Infrared;
|
||||
} // namespace infrared
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
namespace radio_frequency {
|
||||
class RadioFrequency;
|
||||
} // namespace radio_frequency
|
||||
#endif
|
||||
|
||||
class ComponentIterator {
|
||||
public:
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
#define USE_INFRARED
|
||||
#define USE_IR_RF
|
||||
#define USE_JSON
|
||||
#define USE_RADIO_FREQUENCY
|
||||
#define USE_LIGHT
|
||||
#define USE_LIGHT_GAMMA_LUT
|
||||
#define USE_LOCK
|
||||
@@ -256,6 +257,11 @@
|
||||
#define USE_MICROPHONE
|
||||
#define USE_PSRAM
|
||||
#define USE_SENDSPIN
|
||||
#define USE_SENDSPIN_ARTWORK
|
||||
#define USE_SENDSPIN_CONTROLLER
|
||||
#define USE_SENDSPIN_METADATA
|
||||
#define USE_SENDSPIN_PLAYER
|
||||
#define USE_SENDSPIN_VISUALIZER
|
||||
#define USE_SENDSPIN_PORT 8928 // NOLINT
|
||||
#define USE_SOCKET_IMPL_BSD_SOCKETS
|
||||
#define USE_LWIP_FAST_SELECT
|
||||
@@ -286,6 +292,7 @@
|
||||
#define ESPHOME_WIFI_POWER_SAVE_LISTENERS 2
|
||||
#define USE_WIFI_RUNTIME_POWER_SAVE
|
||||
#define USB_HOST_MAX_REQUESTS 16
|
||||
#define USB_UART_OUTPUT_CHUNK_COUNT 5
|
||||
|
||||
#ifdef USE_ARDUINO
|
||||
#define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 7)
|
||||
@@ -320,6 +327,7 @@
|
||||
#define USE_MICRO_WAKE_WORD_VAD
|
||||
#if defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2)
|
||||
#define USE_OPENTHREAD
|
||||
#define USE_ZIGBEE
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -447,6 +455,7 @@
|
||||
#define ESPHOME_ENTITY_LOCK_COUNT 1
|
||||
#define ESPHOME_ENTITY_MEDIA_PLAYER_COUNT 1
|
||||
#define ESPHOME_ENTITY_NUMBER_COUNT 1
|
||||
#define ESPHOME_ENTITY_RADIO_FREQUENCY_COUNT 1
|
||||
#define ESPHOME_ENTITY_SELECT_COUNT 1
|
||||
#define ESPHOME_ENTITY_SENSOR_COUNT 1
|
||||
#define ESPHOME_ENTITY_SWITCH_COUNT 1
|
||||
|
||||
@@ -68,6 +68,9 @@
|
||||
#ifdef USE_INFRARED
|
||||
#include "esphome/components/infrared/infrared.h"
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
#include "esphome/components/radio_frequency/radio_frequency.h"
|
||||
#endif
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
#include "esphome/components/serial_proxy/serial_proxy.h"
|
||||
#endif
|
||||
|
||||
@@ -90,6 +90,10 @@ ENTITY_CONTROLLER_TYPE_(water_heater::WaterHeater, water_heater, water_heaters,
|
||||
#ifdef USE_INFRARED
|
||||
ENTITY_TYPE_(infrared::Infrared, infrared, infrareds, ESPHOME_ENTITY_INFRARED_COUNT, INFRARED)
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
ENTITY_TYPE_(radio_frequency::RadioFrequency, radio_frequency, radio_frequencies, ESPHOME_ENTITY_RADIO_FREQUENCY_COUNT,
|
||||
RADIO_FREQUENCY)
|
||||
#endif
|
||||
#ifdef USE_EVENT
|
||||
ENTITY_CONTROLLER_TYPE_(event::Event, event, events, ESPHOME_ENTITY_EVENT_COUNT, EVENT, event)
|
||||
#endif
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
#include <sdkconfig.h>
|
||||
#elif defined(USE_LIBRETINY)
|
||||
#include <FreeRTOS.h>
|
||||
#include <task.h>
|
||||
#endif
|
||||
|
||||
namespace esphome {
|
||||
@@ -14,10 +17,11 @@ namespace esphome {
|
||||
// Friend-gated accessor for a fast millis() variant intended only for
|
||||
// known task-context callers on the main loop hot path (Application::loop()
|
||||
// and WarnIfComponentBlockingGuard::finish()). It skips the ISR-context
|
||||
// dispatch that the public esphome::millis() pays on ESP32.
|
||||
// dispatch that the public esphome::millis() pays on ESP32 and libretiny.
|
||||
//
|
||||
// MUST NOT be called from ISR context: on ESP32 it calls the non-FromISR
|
||||
// FreeRTOS API directly, which is undefined behavior in ISR context.
|
||||
// MUST NOT be called from ISR context: on ESP32 and libretiny it calls the
|
||||
// non-FromISR FreeRTOS API directly, which is undefined behavior in ISR
|
||||
// context.
|
||||
//
|
||||
// Adding new callers requires adding a friend declaration here — that
|
||||
// is the review point. Do not relax the access (e.g. by making get()
|
||||
@@ -31,6 +35,16 @@ class MillisInternal {
|
||||
static ESPHOME_ALWAYS_INLINE uint32_t get() {
|
||||
#if defined(USE_ESP32) && CONFIG_FREERTOS_HZ == 1000
|
||||
return xTaskGetTickCount();
|
||||
#elif defined(USE_LIBRETINY) && (defined(USE_RTL87XX) || defined(USE_LN882X))
|
||||
// 1 kHz: xTaskGetTickCount() is already ms.
|
||||
static_assert(configTICK_RATE_HZ == 1000, "MillisInternal fast path requires 1 kHz FreeRTOS tick");
|
||||
return xTaskGetTickCount();
|
||||
#elif defined(USE_BK72XX)
|
||||
// 500 Hz: scale by portTICK_PERIOD_MS (== 2). Inlined to avoid the
|
||||
// out-of-line call to esphome::millis() (IRAM_ATTR is a no-op on BK72xx —
|
||||
// SDK masks FIQ + IRQ during flash writes, see hal.h).
|
||||
static_assert(configTICK_RATE_HZ == 500, "BK72xx MillisInternal assumes 500 Hz FreeRTOS tick");
|
||||
return xTaskGetTickCount() * portTICK_PERIOD_MS;
|
||||
#else
|
||||
return millis();
|
||||
#endif
|
||||
|
||||
+10
-10
@@ -235,11 +235,11 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type
|
||||
}
|
||||
target->push_back(item);
|
||||
if (target == &this->to_add_) {
|
||||
this->to_add_count_increment_();
|
||||
this->to_add_count_increment_locked_();
|
||||
}
|
||||
#ifndef ESPHOME_THREAD_SINGLE
|
||||
else {
|
||||
this->defer_count_increment_();
|
||||
this->defer_count_increment_locked_();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -452,7 +452,7 @@ void Scheduler::full_cleanup_removed_items_() {
|
||||
this->items_.erase(this->items_.begin() + write, this->items_.end());
|
||||
// Rebuild the heap structure since items are no longer in heap order
|
||||
std::make_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp);
|
||||
this->to_remove_clear_();
|
||||
this->to_remove_clear_locked_();
|
||||
}
|
||||
|
||||
#ifndef ESPHOME_THREAD_SINGLE
|
||||
@@ -501,7 +501,7 @@ void HOT Scheduler::process_defer_queue_slow_path_(uint32_t &now) {
|
||||
|
||||
this->lock_.lock();
|
||||
// Reset counter and snapshot queue end under lock
|
||||
this->defer_count_clear_();
|
||||
this->defer_count_clear_locked_();
|
||||
size_t defer_queue_end = this->defer_queue_.size();
|
||||
if (this->defer_queue_front_ >= defer_queue_end) {
|
||||
this->lock_.unlock();
|
||||
@@ -621,7 +621,7 @@ uint32_t HOT Scheduler::call(uint32_t now) {
|
||||
LockGuard guard{this->lock_};
|
||||
if (is_item_removed_locked_(item)) {
|
||||
this->recycle_item_main_loop_(this->pop_raw_locked_());
|
||||
this->to_remove_decrement_();
|
||||
this->to_remove_decrement_locked_();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -630,7 +630,7 @@ uint32_t HOT Scheduler::call(uint32_t now) {
|
||||
if (is_item_removed_(item)) {
|
||||
LockGuard guard{this->lock_};
|
||||
this->recycle_item_main_loop_(this->pop_raw_locked_());
|
||||
this->to_remove_decrement_();
|
||||
this->to_remove_decrement_locked_();
|
||||
continue;
|
||||
}
|
||||
#endif
|
||||
@@ -658,7 +658,7 @@ uint32_t HOT Scheduler::call(uint32_t now) {
|
||||
|
||||
if (this->is_item_removed_locked_(executed_item)) {
|
||||
// We were removed/cancelled in the function call, recycle and continue
|
||||
this->to_remove_decrement_();
|
||||
this->to_remove_decrement_locked_();
|
||||
this->recycle_item_main_loop_(executed_item);
|
||||
continue;
|
||||
}
|
||||
@@ -721,7 +721,7 @@ void HOT Scheduler::process_to_add_slow_path_() {
|
||||
std::push_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp);
|
||||
}
|
||||
this->to_add_.clear();
|
||||
this->to_add_count_clear_();
|
||||
this->to_add_count_clear_locked_();
|
||||
}
|
||||
bool HOT Scheduler::cleanup_slow_path_() {
|
||||
// We must hold the lock for the entire cleanup operation because:
|
||||
@@ -737,7 +737,7 @@ bool HOT Scheduler::cleanup_slow_path_() {
|
||||
SchedulerItem *item = this->items_[0];
|
||||
if (!this->is_item_removed_locked_(item))
|
||||
break;
|
||||
this->to_remove_decrement_();
|
||||
this->to_remove_decrement_locked_();
|
||||
this->recycle_item_main_loop_(this->pop_raw_locked_());
|
||||
}
|
||||
return !this->items_.empty();
|
||||
@@ -825,7 +825,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, NameType name_type
|
||||
size_t heap_cancelled = this->mark_matching_items_removed_locked_(this->items_, component, name_type, static_name,
|
||||
hash_or_id, type, match_retry, find_first);
|
||||
total_cancelled += heap_cancelled;
|
||||
this->to_remove_add_(heap_cancelled);
|
||||
this->to_remove_add_locked_(heap_cancelled);
|
||||
if (find_first && total_cancelled > 0)
|
||||
return true;
|
||||
}
|
||||
|
||||
+57
-43
@@ -524,11 +524,13 @@ class Scheduler {
|
||||
std::vector<SchedulerItem *> to_add_;
|
||||
|
||||
#ifndef ESPHOME_THREAD_SINGLE
|
||||
// Fast-path counter for process_to_add() to skip taking the lock when there is
|
||||
// nothing to add. Uses std::atomic on platforms that support it, plain uint32_t
|
||||
// otherwise. On non-atomic platforms, callers must hold the scheduler lock when
|
||||
// mutating this counter. Not needed on single-threaded platforms where we can
|
||||
// check to_add_.empty() directly.
|
||||
// Fast-path counter for process_to_add() to skip taking the lock when there
|
||||
// is nothing to add. std::atomic on ATOMICS; plain uint32_t on NO_ATOMICS
|
||||
// (BK72xx — ARMv5TE single-core, lacks LDREX/STREX so std::atomic RMW would
|
||||
// require libatomic). Reads use __atomic_load_n(__ATOMIC_RELAXED) on
|
||||
// NO_ATOMICS — compiles to a plain LDR (aligned 32-bit load is naturally
|
||||
// atomic on ARMv5TE) but expresses the concurrent-access intent in the C++
|
||||
// memory model. Writes live behind *_locked_ helpers and must hold lock_.
|
||||
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
|
||||
std::atomic<uint32_t> to_add_count_{0};
|
||||
#else
|
||||
@@ -536,40 +538,41 @@ class Scheduler {
|
||||
#endif
|
||||
#endif /* ESPHOME_THREAD_SINGLE */
|
||||
|
||||
// Fast-path helper for process_to_add() to decide if it can try the lock-free path.
|
||||
// - On ESPHOME_THREAD_SINGLE: direct container check is safe (no concurrent writers).
|
||||
// - On ESPHOME_THREAD_MULTI_ATOMICS: performs a lock-free check via to_add_count_.
|
||||
// - On ESPHOME_THREAD_MULTI_NO_ATOMICS: always returns false to force the caller
|
||||
// down the locked path; this is NOT a lock-free emptiness check on that platform.
|
||||
// Fast-path helper for process_to_add() to decide if it can skip the lock.
|
||||
bool to_add_empty_() const {
|
||||
#ifdef ESPHOME_THREAD_SINGLE
|
||||
return this->to_add_.empty();
|
||||
#elif defined(ESPHOME_THREAD_MULTI_ATOMICS)
|
||||
return this->to_add_count_.load(std::memory_order_relaxed) == 0;
|
||||
#else
|
||||
return false;
|
||||
return __atomic_load_n(&this->to_add_count_, __ATOMIC_RELAXED) == 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
// Increment to_add_count_ (no-op on single-threaded platforms)
|
||||
void to_add_count_increment_() {
|
||||
#ifdef ESPHOME_THREAD_SINGLE
|
||||
// Increment to_add_count_ (no-op on single-threaded platforms).
|
||||
// On NO_ATOMICS the caller must hold lock_; both load and store go through
|
||||
// __atomic_*_n with __ATOMIC_RELAXED to keep every access to the counter
|
||||
// explicitly atomic in the C++ memory model (same ARMv5TE codegen as
|
||||
// plain LDR+STR).
|
||||
void to_add_count_increment_locked_() {
|
||||
#if defined(ESPHOME_THREAD_SINGLE)
|
||||
// No counter needed — to_add_empty_() checks the vector directly
|
||||
#elif defined(ESPHOME_THREAD_MULTI_ATOMICS)
|
||||
this->to_add_count_.fetch_add(1, std::memory_order_relaxed);
|
||||
#else
|
||||
this->to_add_count_++;
|
||||
uint32_t v = __atomic_load_n(&this->to_add_count_, __ATOMIC_RELAXED);
|
||||
__atomic_store_n(&this->to_add_count_, v + 1, __ATOMIC_RELAXED);
|
||||
#endif
|
||||
}
|
||||
|
||||
// Reset to_add_count_ (no-op on single-threaded platforms)
|
||||
void to_add_count_clear_() {
|
||||
#ifdef ESPHOME_THREAD_SINGLE
|
||||
void to_add_count_clear_locked_() {
|
||||
#if defined(ESPHOME_THREAD_SINGLE)
|
||||
// No counter needed — to_add_empty_() checks the vector directly
|
||||
#elif defined(ESPHOME_THREAD_MULTI_ATOMICS)
|
||||
this->to_add_count_.store(0, std::memory_order_relaxed);
|
||||
#else
|
||||
this->to_add_count_ = 0;
|
||||
__atomic_store_n(&this->to_add_count_, 0, __ATOMIC_RELAXED);
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -580,7 +583,8 @@ class Scheduler {
|
||||
std::vector<SchedulerItem *> defer_queue_; // FIFO queue for defer() calls
|
||||
size_t defer_queue_front_{0}; // Index of first valid item in defer_queue_ (tracks consumed items)
|
||||
|
||||
// Fast-path counter for process_defer_queue_() to skip lock when nothing to process.
|
||||
// Fast-path counter for process_defer_queue_() to skip lock when nothing to
|
||||
// process. See to_add_count_ above for the NO_ATOMICS rationale.
|
||||
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
|
||||
std::atomic<uint32_t> defer_count_{0};
|
||||
#else
|
||||
@@ -589,35 +593,35 @@ class Scheduler {
|
||||
|
||||
bool defer_empty_() const {
|
||||
// defer_queue_ only exists on multi-threaded platforms, so no ESPHOME_THREAD_SINGLE path
|
||||
// ESPHOME_THREAD_MULTI_NO_ATOMICS: always take the lock
|
||||
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
|
||||
return this->defer_count_.load(std::memory_order_relaxed) == 0;
|
||||
#else
|
||||
return false;
|
||||
return __atomic_load_n(&this->defer_count_, __ATOMIC_RELAXED) == 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
void defer_count_increment_() {
|
||||
void defer_count_increment_locked_() {
|
||||
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
|
||||
this->defer_count_.fetch_add(1, std::memory_order_relaxed);
|
||||
#else
|
||||
this->defer_count_++;
|
||||
uint32_t v = __atomic_load_n(&this->defer_count_, __ATOMIC_RELAXED);
|
||||
__atomic_store_n(&this->defer_count_, v + 1, __ATOMIC_RELAXED);
|
||||
#endif
|
||||
}
|
||||
|
||||
void defer_count_clear_() {
|
||||
void defer_count_clear_locked_() {
|
||||
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
|
||||
this->defer_count_.store(0, std::memory_order_relaxed);
|
||||
#else
|
||||
this->defer_count_ = 0;
|
||||
__atomic_store_n(&this->defer_count_, 0, __ATOMIC_RELAXED);
|
||||
#endif
|
||||
}
|
||||
|
||||
#endif /* ESPHOME_THREAD_SINGLE */
|
||||
|
||||
// Counter for items marked for removal. Incremented cross-thread in cancel_item_locked_().
|
||||
// On ESPHOME_THREAD_MULTI_ATOMICS this is read without a lock in the cleanup_() fast path;
|
||||
// on ESPHOME_THREAD_MULTI_NO_ATOMICS the fast path is disabled so cleanup_() always takes the lock.
|
||||
// Counter for items marked for removal. Incremented cross-thread in
|
||||
// cancel_item_locked_(). See to_add_count_ above for the NO_ATOMICS
|
||||
// rationale.
|
||||
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
|
||||
std::atomic<uint32_t> to_remove_{0};
|
||||
#else
|
||||
@@ -626,44 +630,54 @@ class Scheduler {
|
||||
|
||||
// Lock-free check if there are items to remove (for fast-path in cleanup_)
|
||||
bool to_remove_empty_() const {
|
||||
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
|
||||
#if defined(ESPHOME_THREAD_MULTI_ATOMICS)
|
||||
return this->to_remove_.load(std::memory_order_relaxed) == 0;
|
||||
#elif defined(ESPHOME_THREAD_SINGLE)
|
||||
return this->to_remove_ == 0;
|
||||
#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS)
|
||||
return __atomic_load_n(&this->to_remove_, __ATOMIC_RELAXED) == 0;
|
||||
#else
|
||||
return false; // Always take the lock path
|
||||
return this->to_remove_ == 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
void to_remove_add_(uint32_t count) {
|
||||
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
|
||||
void to_remove_add_locked_(uint32_t count) {
|
||||
#if defined(ESPHOME_THREAD_MULTI_ATOMICS)
|
||||
this->to_remove_.fetch_add(count, std::memory_order_relaxed);
|
||||
#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS)
|
||||
uint32_t v = __atomic_load_n(&this->to_remove_, __ATOMIC_RELAXED);
|
||||
__atomic_store_n(&this->to_remove_, v + count, __ATOMIC_RELAXED);
|
||||
#else
|
||||
this->to_remove_ += count;
|
||||
this->to_remove_ += count;
|
||||
#endif
|
||||
}
|
||||
|
||||
void to_remove_decrement_() {
|
||||
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
|
||||
void to_remove_decrement_locked_() {
|
||||
#if defined(ESPHOME_THREAD_MULTI_ATOMICS)
|
||||
this->to_remove_.fetch_sub(1, std::memory_order_relaxed);
|
||||
#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS)
|
||||
uint32_t v = __atomic_load_n(&this->to_remove_, __ATOMIC_RELAXED);
|
||||
__atomic_store_n(&this->to_remove_, v - 1, __ATOMIC_RELAXED);
|
||||
#else
|
||||
this->to_remove_--;
|
||||
this->to_remove_--;
|
||||
#endif
|
||||
}
|
||||
|
||||
void to_remove_clear_() {
|
||||
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
|
||||
void to_remove_clear_locked_() {
|
||||
#if defined(ESPHOME_THREAD_MULTI_ATOMICS)
|
||||
this->to_remove_.store(0, std::memory_order_relaxed);
|
||||
#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS)
|
||||
__atomic_store_n(&this->to_remove_, 0, __ATOMIC_RELAXED);
|
||||
#else
|
||||
this->to_remove_ = 0;
|
||||
this->to_remove_ = 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint32_t to_remove_count_() const {
|
||||
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
|
||||
#if defined(ESPHOME_THREAD_MULTI_ATOMICS)
|
||||
return this->to_remove_.load(std::memory_order_relaxed);
|
||||
#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS)
|
||||
return __atomic_load_n(&this->to_remove_, __ATOMIC_RELAXED);
|
||||
#else
|
||||
return this->to_remove_;
|
||||
return this->to_remove_;
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -74,8 +74,8 @@ uint64_t Millis64Impl::compute(uint32_t now) {
|
||||
// 2. Always locks when detecting a large backwards jump
|
||||
// 3. Updates without lock in normal forward progression (accepting minor races)
|
||||
// This is less efficient but necessary without atomic operations.
|
||||
uint16_t major = millis_major;
|
||||
uint32_t last = last_millis;
|
||||
uint16_t major = __atomic_load_n(&millis_major, __ATOMIC_RELAXED);
|
||||
uint32_t last = __atomic_load_n(&last_millis, __ATOMIC_RELAXED);
|
||||
|
||||
// Define a safe window around the rollover point (10 seconds)
|
||||
// This covers any reasonable scheduler delays or thread preemption
|
||||
@@ -87,19 +87,26 @@ uint64_t Millis64Impl::compute(uint32_t now) {
|
||||
if (near_rollover || (now < last && (last - now) > HALF_MAX_UINT32)) {
|
||||
// Near rollover or detected a rollover - need lock for safety
|
||||
LockGuard guard{lock};
|
||||
// Re-read with lock held
|
||||
last = last_millis;
|
||||
// Re-read both values with lock held. last_millis can be updated
|
||||
// unlocked from the forward-progression branch below, so use an atomic
|
||||
// load. millis_major can only be updated under this lock, but another
|
||||
// thread may have completed a rollover between our unlocked loads above
|
||||
// and the lock acquisition — reload or we'd return a stale high word.
|
||||
last = __atomic_load_n(&last_millis, __ATOMIC_RELAXED);
|
||||
major = __atomic_load_n(&millis_major, __ATOMIC_RELAXED);
|
||||
|
||||
if (now < last && (last - now) > HALF_MAX_UINT32) {
|
||||
// True rollover detected (happens every ~49.7 days)
|
||||
millis_major++;
|
||||
// True rollover detected (happens every ~49.7 days).
|
||||
// Use the already-loaded `major` local; avoids a second read of the
|
||||
// global (equivalent under the held lock).
|
||||
major++;
|
||||
__atomic_store_n(&millis_major, major, __ATOMIC_RELAXED);
|
||||
#ifdef ESPHOME_DEBUG_SCHEDULER
|
||||
ESP_LOGD(TAG, "Detected true 32-bit rollover at %" PRIu32 "ms (was %" PRIu32 ")", now, last);
|
||||
#endif /* ESPHOME_DEBUG_SCHEDULER */
|
||||
}
|
||||
// Update last_millis while holding lock
|
||||
last_millis = now;
|
||||
__atomic_store_n(&last_millis, now, __ATOMIC_RELAXED);
|
||||
} else if (now > last) {
|
||||
// Normal case: Not near rollover and time moved forward
|
||||
// Update without lock. While this may cause minor races (microseconds of
|
||||
@@ -107,7 +114,7 @@ uint64_t Millis64Impl::compute(uint32_t now) {
|
||||
// 1. The scheduler operates at millisecond resolution, not microsecond
|
||||
// 2. We've already prevented the critical rollover race condition
|
||||
// 3. Any backwards movement is orders of magnitude smaller than scheduler delays
|
||||
last_millis = now;
|
||||
__atomic_store_n(&last_millis, now, __ATOMIC_RELAXED);
|
||||
}
|
||||
// If now <= last and we're not near rollover, don't update
|
||||
// This minimizes backwards time movement
|
||||
|
||||
+184
-6
@@ -1,13 +1,20 @@
|
||||
#include "esphome/core/wake.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#ifdef USE_ESP8266
|
||||
#include <coredecls.h>
|
||||
#endif
|
||||
|
||||
#ifdef USE_HOST
|
||||
#include "esphome/core/application.h"
|
||||
#include <arpa/inet.h>
|
||||
#include <cerrno>
|
||||
#include <fcntl.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/select.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
#include <vector>
|
||||
#endif
|
||||
|
||||
namespace esphome {
|
||||
@@ -58,7 +65,7 @@ static int64_t alarm_callback_(alarm_id_t id, void *user_data) {
|
||||
|
||||
namespace internal {
|
||||
void wakeable_delay(uint32_t ms) {
|
||||
if (ms == 0) {
|
||||
if (ms == 0) [[unlikely]] {
|
||||
yield();
|
||||
return;
|
||||
}
|
||||
@@ -82,17 +89,188 @@ void wakeable_delay(uint32_t ms) {
|
||||
} // namespace internal
|
||||
#endif // USE_RP2040
|
||||
|
||||
// === Host (UDP loopback socket) ===
|
||||
// === Host (UDP loopback socket + select() based fd watcher) ===
|
||||
#ifdef USE_HOST
|
||||
|
||||
static const char *const TAG = "wake";
|
||||
|
||||
namespace internal {
|
||||
// File-scope state — referenced inline by wake_drain_notifications() and
|
||||
// wake_fd_ready() in wake.h, and by the bodies in this file.
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
int g_wake_socket_fd = -1;
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
fd_set g_read_fds{};
|
||||
} // namespace internal
|
||||
|
||||
namespace {
|
||||
// File-local state owned entirely by the select() loop.
|
||||
// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
std::vector<int> s_socket_fds;
|
||||
int s_max_fd = -1;
|
||||
bool s_socket_fds_changed = false;
|
||||
fd_set s_base_read_fds{};
|
||||
// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
} // namespace
|
||||
|
||||
bool wake_register_fd(int fd) {
|
||||
// WARNING: not thread-safe — must be called only from the main loop.
|
||||
if (fd < 0)
|
||||
return false;
|
||||
|
||||
if (fd >= FD_SETSIZE) {
|
||||
ESP_LOGE(TAG, "fd %d exceeds FD_SETSIZE %d", fd, FD_SETSIZE);
|
||||
return false;
|
||||
}
|
||||
|
||||
s_socket_fds.push_back(fd);
|
||||
s_socket_fds_changed = true;
|
||||
if (fd > s_max_fd) {
|
||||
s_max_fd = fd;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void wake_unregister_fd(int fd) {
|
||||
// WARNING: not thread-safe — must be called only from the main loop.
|
||||
if (fd < 0)
|
||||
return;
|
||||
|
||||
for (size_t i = 0; i < s_socket_fds.size(); i++) {
|
||||
if (s_socket_fds[i] != fd)
|
||||
continue;
|
||||
|
||||
// Swap with last element and pop — O(1) removal since order doesn't matter.
|
||||
if (i < s_socket_fds.size() - 1)
|
||||
s_socket_fds[i] = s_socket_fds.back();
|
||||
s_socket_fds.pop_back();
|
||||
s_socket_fds_changed = true;
|
||||
// Only recalculate max_fd if we removed the current max.
|
||||
if (fd == s_max_fd) {
|
||||
s_max_fd = -1;
|
||||
for (int sock_fd : s_socket_fds) {
|
||||
if (sock_fd > s_max_fd)
|
||||
s_max_fd = sock_fd;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
namespace internal {
|
||||
void wakeable_delay(uint32_t ms) {
|
||||
// Fallback select() path for the host platform (and any future platform
|
||||
// without fast select). select() is the host equivalent of FreeRTOS task
|
||||
// notify / esp_delay / WFE used on the embedded targets.
|
||||
if (!s_socket_fds.empty()) [[likely]] {
|
||||
// Update fd_set if socket list has changed.
|
||||
if (s_socket_fds_changed) [[unlikely]] {
|
||||
FD_ZERO(&s_base_read_fds);
|
||||
// fd bounds are validated in wake_register_fd().
|
||||
for (int fd : s_socket_fds) {
|
||||
FD_SET(fd, &s_base_read_fds);
|
||||
}
|
||||
s_socket_fds_changed = false;
|
||||
}
|
||||
|
||||
// Copy base fd_set before each select.
|
||||
g_read_fds = s_base_read_fds;
|
||||
|
||||
// Convert ms to timeval.
|
||||
struct timeval tv;
|
||||
tv.tv_sec = ms / 1000;
|
||||
tv.tv_usec = (ms - tv.tv_sec * 1000) * 1000;
|
||||
|
||||
// Call select with timeout.
|
||||
int ret = ::select(s_max_fd + 1, &g_read_fds, nullptr, nullptr, &tv);
|
||||
|
||||
// Process select() result:
|
||||
// ret > 0: socket(s) have data ready - normal and expected
|
||||
// ret == 0: timeout occurred - normal and expected
|
||||
if (ret >= 0) [[likely]] {
|
||||
// Yield if zero timeout since select(0) only polls without yielding.
|
||||
if (ms == 0) [[unlikely]] {
|
||||
yield();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// ret < 0: error (EINTR is normal, anything else is unexpected).
|
||||
const int err = errno;
|
||||
if (err == EINTR) {
|
||||
return;
|
||||
}
|
||||
// select() error - log and fall through to delay().
|
||||
ESP_LOGW(TAG, "select() failed with errno %d", err);
|
||||
}
|
||||
// No sockets registered or select() failed - use regular delay.
|
||||
delay(ms);
|
||||
}
|
||||
} // namespace internal
|
||||
|
||||
void wake_loop_threadsafe() {
|
||||
// Set flag before sending so the consumer's gate check on the next loop()
|
||||
// entry observes the wake regardless of select() scheduling.
|
||||
wake_request_set();
|
||||
if (App.wake_socket_fd_ >= 0) {
|
||||
if (internal::g_wake_socket_fd >= 0) {
|
||||
const char dummy = 1;
|
||||
::send(App.wake_socket_fd_, &dummy, 1, 0);
|
||||
::send(internal::g_wake_socket_fd, &dummy, 1, 0);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
void wake_setup() {
|
||||
// Create UDP socket for wake notifications.
|
||||
internal::g_wake_socket_fd = ::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
|
||||
if (internal::g_wake_socket_fd < 0) {
|
||||
ESP_LOGW(TAG, "Wake socket create failed: %d", errno);
|
||||
return;
|
||||
}
|
||||
|
||||
// Bind to loopback with auto-assigned port.
|
||||
struct sockaddr_in addr = {};
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
addr.sin_port = 0; // Auto-assign port
|
||||
|
||||
if (::bind(internal::g_wake_socket_fd, (struct sockaddr *) &addr, sizeof(addr)) < 0) {
|
||||
ESP_LOGW(TAG, "Wake socket bind failed: %d", errno);
|
||||
::close(internal::g_wake_socket_fd);
|
||||
internal::g_wake_socket_fd = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the assigned address and connect to it.
|
||||
// Connecting a UDP socket allows using send() instead of sendto() for better performance.
|
||||
struct sockaddr_in wake_addr;
|
||||
socklen_t len = sizeof(wake_addr);
|
||||
if (::getsockname(internal::g_wake_socket_fd, (struct sockaddr *) &wake_addr, &len) < 0) {
|
||||
ESP_LOGW(TAG, "Wake socket address failed: %d", errno);
|
||||
::close(internal::g_wake_socket_fd);
|
||||
internal::g_wake_socket_fd = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Connect to self (loopback) — allows using send() instead of sendto().
|
||||
// After connect(), no need to store wake_addr — the socket remembers it.
|
||||
if (::connect(internal::g_wake_socket_fd, (struct sockaddr *) &wake_addr, sizeof(wake_addr)) < 0) {
|
||||
ESP_LOGW(TAG, "Wake socket connect failed: %d", errno);
|
||||
::close(internal::g_wake_socket_fd);
|
||||
internal::g_wake_socket_fd = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Set non-blocking mode.
|
||||
int flags = ::fcntl(internal::g_wake_socket_fd, F_GETFL, 0);
|
||||
::fcntl(internal::g_wake_socket_fd, F_SETFL, flags | O_NONBLOCK);
|
||||
|
||||
// Register with the select() loop.
|
||||
if (!wake_register_fd(internal::g_wake_socket_fd)) {
|
||||
ESP_LOGW(TAG, "Wake socket register failed");
|
||||
::close(internal::g_wake_socket_fd);
|
||||
internal::g_wake_socket_fd = -1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
#endif // USE_HOST
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
+68
-6
@@ -21,6 +21,11 @@
|
||||
#include <pico/time.h>
|
||||
#endif
|
||||
|
||||
#ifdef USE_HOST
|
||||
#include <sys/select.h>
|
||||
#include <sys/socket.h>
|
||||
#endif
|
||||
|
||||
namespace esphome {
|
||||
|
||||
// === Wake flag for ESP8266/RP2040 ===
|
||||
@@ -96,8 +101,14 @@ inline void wake_loop_threadsafe() {
|
||||
}
|
||||
|
||||
namespace internal {
|
||||
inline void wakeable_delay(uint32_t ms) {
|
||||
if (ms == 0) {
|
||||
inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) {
|
||||
// Fast path (with USE_LWIP_FAST_SELECT): FreeRTOS task notifications posted by the lwip
|
||||
// event_callback wrapper (see lwip_fast_select.c) are the single source of truth for
|
||||
// socket wake-ups. Every NETCONN_EVT_RCVPLUS posts an xTaskNotifyGive, so any notification
|
||||
// that lands between wakes keeps the counter non-zero (next ulTaskNotifyTake returns
|
||||
// immediately) or wakes a blocked Take directly. Additional wake sources:
|
||||
// wake_loop_threadsafe() from background tasks, and the ms timeout.
|
||||
if (ms == 0) [[unlikely]] {
|
||||
yield();
|
||||
return;
|
||||
}
|
||||
@@ -127,8 +138,8 @@ inline void wake_loop_threadsafe() { wake_loop_impl(); }
|
||||
inline void ESPHOME_ALWAYS_INLINE wake_loop_isrsafe() { wake_loop_impl(); }
|
||||
|
||||
namespace internal {
|
||||
inline void wakeable_delay(uint32_t ms) {
|
||||
if (ms == 0) {
|
||||
inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) {
|
||||
if (ms == 0) [[unlikely]] {
|
||||
delay(0);
|
||||
return;
|
||||
}
|
||||
@@ -164,6 +175,21 @@ void wakeable_delay(uint32_t ms);
|
||||
#ifdef USE_HOST
|
||||
/// Host: wakes select() via UDP loopback socket. Defined in wake.cpp.
|
||||
void wake_loop_threadsafe();
|
||||
|
||||
/// Register a socket file descriptor with the host select() loop. Not
|
||||
/// thread-safe — main loop only. Returns false if fd is invalid or
|
||||
/// >= FD_SETSIZE.
|
||||
bool wake_register_fd(int fd);
|
||||
|
||||
/// Unregister a socket file descriptor. Not thread-safe — main loop only.
|
||||
void wake_unregister_fd(int fd);
|
||||
|
||||
/// One-time setup of the loopback wake socket. Called from Application::setup().
|
||||
void wake_setup();
|
||||
|
||||
// wake_fd_ready() and wake_drain_notifications() are defined inline at the
|
||||
// bottom of this file — they need internal::g_read_fds / g_wake_socket_fd in
|
||||
// scope, which depend on USE_HOST-only includes pulled in above.
|
||||
#else
|
||||
/// Zephyr is currently the only platform without a wake mechanism.
|
||||
/// wake_loop_threadsafe() is a no-op and wakeable_delay() falls back to delay().
|
||||
@@ -174,15 +200,51 @@ inline void wake_loop_threadsafe() {}
|
||||
inline void wake_loop_any_context() { wake_loop_threadsafe(); }
|
||||
|
||||
namespace internal {
|
||||
inline void wakeable_delay(uint32_t ms) {
|
||||
if (ms == 0) {
|
||||
#ifdef USE_HOST
|
||||
/// Host wakeable_delay uses select() over the registered fds — defined in wake.cpp.
|
||||
void wakeable_delay(uint32_t ms);
|
||||
#else
|
||||
inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) {
|
||||
if (ms == 0) [[unlikely]] {
|
||||
yield();
|
||||
return;
|
||||
}
|
||||
delay(ms);
|
||||
}
|
||||
#endif
|
||||
} // namespace internal
|
||||
|
||||
#endif
|
||||
|
||||
#ifdef USE_HOST
|
||||
namespace internal {
|
||||
// File-scope state owned by wake.cpp. Accessed inline by wake_drain_notifications()
|
||||
// and wake_fd_ready() so the hot path stays in the header.
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
extern int g_wake_socket_fd;
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
extern fd_set g_read_fds;
|
||||
} // namespace internal
|
||||
|
||||
inline bool ESPHOME_ALWAYS_INLINE wake_fd_ready(int fd) { return FD_ISSET(fd, &internal::g_read_fds); }
|
||||
|
||||
// Small buffer for draining wake notification bytes (1 byte sent per wake).
|
||||
// Sized to drain multiple notifications per recvfrom() without wasting stack.
|
||||
inline constexpr size_t WAKE_NOTIFY_DRAIN_BUFFER_SIZE = 16;
|
||||
|
||||
inline void ESPHOME_ALWAYS_INLINE wake_drain_notifications() {
|
||||
// Called from main loop to drain any pending wake notifications.
|
||||
// Must check wake_fd_ready() to avoid blocking on empty socket.
|
||||
if (internal::g_wake_socket_fd >= 0 && wake_fd_ready(internal::g_wake_socket_fd)) {
|
||||
char buffer[WAKE_NOTIFY_DRAIN_BUFFER_SIZE];
|
||||
// Drain all pending notifications with non-blocking reads. Multiple wake events
|
||||
// may have triggered multiple writes, so drain until EWOULDBLOCK. We control
|
||||
// both ends of this loopback socket (always 1 byte per wake), so no error
|
||||
// checking — any error indicates catastrophic system failure.
|
||||
while (::recvfrom(internal::g_wake_socket_fd, buffer, sizeof(buffer), 0, nullptr, nullptr) > 0) {
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif // USE_HOST
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user