Compare commits

...
Author SHA1 Message Date
Jesse Hills 9d530f5c56 Merge pull request #20680 from esphome/bump-2026.10.0b4
2026.10.0b4
2026-10-14 11:14:49 +13:00
Jesse Hills 5a78f636b9 Bump version to 2026.10.0b4 2026-10-14 10:43:50 +13:00
tomaszduda23andClaude 53d504137b [nrf52] Accept S140 7.2.0 and 7.3.0 in the sd140_v7 DFU package (#20661)
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-14 10:43:49 +13:00
bb0546fc0b [script] Keep the running script.wait item out of the queue while its chain runs (#20570)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-14 10:43:49 +13:00
emes30andJ. Nick Koston b43630cf8e [rp2_pio] Use pioasm 4.1.0 so PIO programs run on RP2350 (#19260)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-14 10:43:49 +13:00
Firat HajiyevandFirat17aze c9664d4e42 [wifi] Reject unsupported WPA3 on ESP8266 (#20592)
Co-authored-by: Firat17aze <187820330+Firat17aze@users.noreply.github.com>
2026-10-14 10:43:49 +13:00
Kevin Ahrendt 39ea6a3220 [audio] Bump microFLAC to v0.3.0 (#20591) 2026-10-14 10:43:49 +13:00
J. Nick Koston f4a6d6f18b [core] Create the PlatformIO HTTP cache dir before the prefetch resolve threads run (#20579) 2026-10-14 10:43:49 +13:00
esphome[bot] 6c24d4aa43 Bump aioesphomeapi from 46.9.0 to 46.10.0 (#20563) 2026-10-14 10:43:49 +13:00
Jesse Hills 2c4b1f3ed5 Merge pull request #20542 from esphome/bump-2026.10.0b3
2026.10.0b3
2026-10-12 15:42:25 +13:00
J. Nick Koston 0df7b0b366 [core] Move zstd_module from the api wizard to esphome.helpers (#20342) 2026-10-12 12:46:59 +13:00
Jesse Hills bd4b32873a Bump version to 2026.10.0b3 2026-10-12 11:52:33 +13:00
Bascht74 9e580db434 [uart] Reuse the ISR pin argument on an ESP8266 software serial reload (#20541) 2026-10-12 11:52:31 +13:00
Jesse Hills 87187c5391 [api] Let a wizard input pick multiple entities (#20537) 2026-10-12 11:52:31 +13:00
tomaszduda23andClaude 60402614cd [nrf52] fix zigbee time component (#20527)
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-12 11:52:25 +13:00
puddly 2727d35655 [usb_uart] Fix invalid stop bits and parity enum mappings (#20408) 2026-10-12 11:51:44 +13:00
J. Nick Koston fb1ef29d54 [uart] Stop ESP8266 software serial RX ISR from blocking for a whole byte (#18519) 2026-10-12 11:51:39 +13:00
Frédéric MetrichandClaude Opus 5.5 7c0f63ea20 [core] Copy trigger arguments in wait_until and script.wait (#20536)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:46:44 +13:00
heikkikiiandJ. Nick Koston 5d75828ceb [seeed_mr24hpc1] Wait for the module to boot before sending UART traffic (#18860)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 11:46:44 +13:00
bd2e8d184d [gree] Keep display light on by default for YAN/YAA/YAC/YAC1FB9 (#18516)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 11:46:44 +13:00
J. Nick Koston 3c7ce71d6f [uart_tcp] Prove the accept has run before checking what a new client receives (#20489) 2026-10-12 11:46:44 +13:00
Frédéric MetrichandClaude Opus 5.5 58e03a875f [teleinfo] Fix out-of-bounds read when splitting a frame into groups (#20513)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:46:44 +13:00
Clyde StubbsandClaude Opus 5.5 41a9fca881 [ci] Reset sdkconfig after ESP-IDF component discovery (#20511)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:46:44 +13:00
ec58e864f5 [canbus] Fix high frame rates causing crashes due to unbounded iteration (#14481)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 11:46:44 +13:00
esphome[bot] 375fbd829e Bump bundled esphome-device-builder to 1.23.1 (#20497) 2026-10-12 11:46:44 +13:00
4bae89bd4a [nrf52] Fix BLE OTA/log device discovery & transport selection on macOS (#17101)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 11:46:43 +13:00
Bascht74andJ. Nick Koston 897b2f16a9 [tests] Close leftover server connections in the tcp_uart bridge and TCP client link tests (#20215)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-12 11:46:43 +13:00
tomaszduda23 93c8796ae8 [nrf52] fix build error on 3.4.0 with pch ON (#20474) 2026-10-12 11:46:43 +13:00
J. Nick Koston 654afb536e [core] Fix the native ESP8266 and host builds on Windows without ccache (#20468) 2026-10-12 11:46:43 +13:00
jabla 97154f9e5f [espidf] Clean the build when the IDF target changes (#20473) 2026-10-12 11:46:43 +13:00
c9e49b8e8f [teleinfo] Add predefined properties for main tags of TIC standard and historical (#9167)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-12 11:42:45 +13:00
J. Nick Koston 3ae52e21cf [usb_host] Warn instead of failing when a device duplicates a usb_uart entry (#20373) 2026-10-12 11:38:38 +13:00
Zhaoqi Xu f2395f9001 [heatpumpir] Fix fan speeds for mitsubishi_msc protocol (#20460) 2026-10-12 11:38:38 +13:00
Ali Alaeiandtomaszduda23 3d248496ab [zephyr_mcumgr] Enable MCUMGR_GRP_OS for UART transport, not just BLE (#18319)
Co-authored-by: tomaszduda23 <tomaszduda23@gmail.com>
2026-10-12 11:38:38 +13:00
esphome[bot] 34eca948c4 Bump bundled esphome-device-builder to 1.23.0 (#20446) 2026-10-12 11:38:37 +13:00
J. Nick Koston 58815575ef [core] Pass a single condition to actions directly instead of wrapping it in a group (#19223) 2026-10-12 11:38:37 +13:00
J. Nick Koston 28b012389d [ci] Keep bus group partners on the same runner (#20431) 2026-10-12 11:38:37 +13:00
J. Nick Koston 54f86537ad [esp32][libretiny] Skip registering static destructors that never run (#20440) 2026-10-12 11:38:37 +13:00
J. Nick Koston 3284d93a33 [nrf52] Fake a larger flash in testing mode (#20439) 2026-10-12 11:38:37 +13:00
J. Nick Koston 01686a5f67 [improv_serial] Use a numeric scheduler id for the Wi-Fi connect timeout (#20360) 2026-10-12 11:38:37 +13:00
J. Nick Koston 3b0c485701 [mqtt] Use a numeric scheduler id for the deferred state publish (#20359) 2026-10-12 11:38:37 +13:00
J. Nick Koston e61d348f0c [wifi] Use numeric scheduler ids instead of string names (#20357) 2026-10-12 11:38:37 +13:00
J. Nick Koston 22e594bc62 [esp32_ble_client] Track conn_id per attempt and stop logging an error on a failed open (#20417) 2026-10-12 11:38:37 +13:00
J. Nick Koston 061733672f [ci] Refit component batch estimates to a full run (#20401) 2026-10-12 11:38:37 +13:00
J. Nick Koston b1f5da9561 [esp8266] Keep ArduinoJson's float tables in flash (Arduino core 3.1.2-esphome.3) (#20416) 2026-10-12 11:38:37 +13:00
J. Nick Koston db517059a3 [ota] Collapse duplicate CI test variants (#20407) 2026-10-12 11:38:37 +13:00
J. Nick Koston 0b40892775 [mcp23xxx_base] Keep the interrupt handler in IRAM on ESP8266 (#20409) 2026-10-12 11:38:37 +13:00
J. Nick Koston 8e6ed71e2a [ethernet] Collapse duplicate CI test variants (#20406) 2026-10-12 11:38:37 +13:00
J. Nick Koston cf569702c4 [ci] Stop the toolchain tests from installing ESP-IDF (#20413) 2026-10-12 11:38:37 +13:00
Jesse Hills 2affc04e62 Merge pull request #20421 from esphome/bump-2026.10.0b2
2026.10.0b2
2026-10-09 15:42:04 +13:00
Jesse Hills a648822027 Bump version to 2026.10.0b2 2026-10-09 13:45:36 +13:00
J. Nick Koston c6244fa4b9 [esp32] Record the PC for cache error and interrupt watchdog crashes (#20415) 2026-10-09 13:45:35 +13:00
J. Nick Koston f6746b4977 [api] Fix C++ unit test link failure when api is only a dependency (#20418) 2026-10-09 13:45:35 +13:00
J. Nick Koston f1f1a77151 [ci] Deal clang-tidy files across five shards (#20411) 2026-10-09 13:45:35 +13:00
J. Nick Koston ba1af627fd [ci] Pin the shared I2C bus in test configs that left it implicit (#20412) 2026-10-09 13:45:35 +13:00
J. Nick Koston b82dfd7e64 [mdns] Bump espressif/mdns to 1.14.0 (#20383) 2026-10-09 13:45:35 +13:00
Kevin AhrendtandJ. Nick Koston 7de2bcb584 [api][sendspin] Add a request for the Sendspin pairing token (#20355)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-09 13:45:35 +13:00
esphome[bot] d5d11e1077 Bump aioesphomeapi from 46.8.0 to 46.9.0 (#20358) 2026-10-09 13:45:34 +13:00
Kevin Ahrendt ab66b20944 [sendspin] Add an unpaired access switch (#20350) 2026-10-09 13:45:34 +13:00
Kevin Ahrendt baf1682635 [sendspin] Add the dynamic pairing code (#20349) 2026-10-09 13:45:34 +13:00
Kevin Ahrendt eea11834f8 [sendspin] Add the static pairing code, pairing window and unpaired access option (#20348) 2026-10-09 13:45:34 +13:00
Kevin Ahrendt 1d86cc39e9 [sendspin] Update to sendspin-cpp v0.9.3 (#20347) 2026-10-09 13:45:34 +13:00
J. Nick Koston 686252f151 [esp8266] Fix Arduino core regressions that keep data in RAM and link the unused unwinder (#20337) 2026-10-09 13:45:23 +13:00
J. Nick Koston f5c15f66a5 [esp8266] Force-include the throw stubs into the core and libraries on the native toolchain (#20336) 2026-10-09 13:45:23 +13:00
J. Nick Koston 9d6b50320d [noise] Bump noise-c to 0.1.31 and libsodium to 1.10021.12 (#20345) 2026-10-09 13:45:23 +13:00
Jesse HillsandJ. Nick Koston b903c54be4 [combination] Use cv.rename_key for the deprecated coeffecient key (#20159)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-09 13:45:23 +13:00
esphome[bot] 63b4a59588 Bump bundled esphome-device-builder to 1.22.0 (#20334) 2026-10-09 13:45:23 +13:00
Jesse Hills adc6255fd4 [core] Let cv.rename_key pass non-dict input through (#20331) 2026-10-09 13:45:23 +13:00
J. Nick Koston b28e7a1357 [esp8266] Normalize misspelled boards and fall back to PlatformIO for unknown ones (#20322) 2026-10-09 13:45:23 +13:00
J. Nick Koston e7ba9a5726 [api] Add a progmem option for bytes fields kept in flash (#20318) 2026-10-09 13:45:22 +13:00
J. Nick Koston 2586e78127 [core] Warn on every run that Intel Mac support ends by 2027.6.0 (#20303) 2026-10-09 13:45:22 +13:00
J. Nick Koston 23f6dedb42 Pin smp to 4.1.0 on Intel macOS so cbor2 installs from a wheel (#20302) 2026-10-09 13:45:22 +13:00
David Buezas 10f30ddb59 [cc1101] Keep the remote_receiver interrupt on a shared GDO0 pin in begin_tx (#20296) 2026-10-09 13:45:22 +13:00
Keith BurzinskiandClaude Opus 5.5 96a66f3ddd [core] Wake the main loop on its own task notification index on ESP32 (#20326)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 13:45:22 +13:00
esphome[bot] 5bf8ceac66 Bump aioesphomeapi from 46.7.0 to 46.8.0 (#20321) 2026-10-09 13:45:22 +13:00
mrtoy-me 4d42352ec5 [tas58xx] Improve TAS5805M audio performance and use GPIO1 for TAS5825M faults (#20319) 2026-10-09 13:45:22 +13:00
esphome[bot] 479232b6c0 Bump aioesphomeapi from 46.6.0 to 46.7.0 (#20317) 2026-10-09 13:45:22 +13:00
213 changed files with 5343 additions and 920 deletions
+12 -6
View File
@@ -893,14 +893,20 @@ jobs:
matrix:
include:
- id: clang-tidy
name: Run script/clang-tidy for ESP32 IDF 1/3
options: --environment esp32-idf-tidy --split-num 3 --split-at 1
name: Run script/clang-tidy for ESP32 IDF 1/5
options: --environment esp32-idf-tidy --split-num 5 --split-at 1
- id: clang-tidy
name: Run script/clang-tidy for ESP32 IDF 2/3
options: --environment esp32-idf-tidy --split-num 3 --split-at 2
name: Run script/clang-tidy for ESP32 IDF 2/5
options: --environment esp32-idf-tidy --split-num 5 --split-at 2
- id: clang-tidy
name: Run script/clang-tidy for ESP32 IDF 3/3
options: --environment esp32-idf-tidy --split-num 3 --split-at 3
name: Run script/clang-tidy for ESP32 IDF 3/5
options: --environment esp32-idf-tidy --split-num 5 --split-at 3
- id: clang-tidy
name: Run script/clang-tidy for ESP32 IDF 4/5
options: --environment esp32-idf-tidy --split-num 5 --split-at 4
- id: clang-tidy
name: Run script/clang-tidy for ESP32 IDF 5/5
options: --environment esp32-idf-tidy --split-num 5 --split-at 5
steps:
- name: Check out code from GitHub
+1 -1
View File
@@ -568,7 +568,7 @@ esphome/components/tca9555/* @mobrembski
esphome/components/tcl112/* @glmnet
esphome/components/tcp_uart/* @Bascht74
esphome/components/tee501/* @Stock-M
esphome/components/teleinfo/* @0hax
esphome/components/teleinfo/* @0hax @FredM67
esphome/components/tem3200/* @bakerkj
esphome/components/template/alarm_control_panel/* @grahambrown11 @hwstar
esphome/components/template/datetime/* @rfdarter
+1 -1
View File
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version
# control system is used.
PROJECT_NUMBER = 2026.10.0b1
PROJECT_NUMBER = 2026.10.0b4
# Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.21.0
RUN uv pip install --no-cache-dir esphome-device-builder==1.23.1
RUN \
platformio settings set enable_telemetry No \
+31 -5
View File
@@ -7,6 +7,7 @@ import importlib
import logging
import os
from pathlib import Path
import platform
import re
import sys
import time
@@ -54,7 +55,7 @@ from esphome.const import (
)
from esphome.core import CORE, EsphomeError, coroutine
from esphome.enum import StrEnum
from esphome.helpers import get_bool_env, indent, is_ip_address
from esphome.helpers import IS_MACOS, get_bool_env, indent, is_ip_address
from esphome.log import AnsiFore, color, setup_log
from esphome.stacktrace import LogLineProcessor
from esphome.types import ConfigType
@@ -1311,9 +1312,9 @@ def _choose_ota_platform(config: ConfigType, requested: str | None) -> str:
# platform's final-validate hook merges duplicates anyway.
available: dict[str, None] = {}
for ota_item in config.get(CONF_OTA, []):
platform = ota_item.get(CONF_PLATFORM)
if platform in (CONF_ESPHOME, CONF_WEB_SERVER):
available[platform] = None
ota_platform = ota_item.get(CONF_PLATFORM)
if ota_platform in (CONF_ESPHOME, CONF_WEB_SERVER):
available[ota_platform] = None
if not available:
raise EsphomeError(
@@ -1945,7 +1946,10 @@ def run_multiple_configs(
safe_print()
cmd = command_builder(f)
rc = run_external_process(*cmd)
# The parent already logged the Intel macOS warning; children skip it.
rc = run_external_process(
*cmd, env={**os.environ, _INTEL_MACOS_WARNED_ENV: "1"}
)
if rc == 0:
print_bar(f"[{color(AnsiFore.BOLD_GREEN, 'SUCCESS')}] {str(f)}")
@@ -2614,6 +2618,27 @@ def _warn_if_source_tree_mismatch() -> None:
)
_INTEL_MACOS_REMOVAL = "2027.6.0"
_INTEL_MACOS_WARNED_ENV = "ESPHOME_INTEL_MACOS_WARNED"
def _warn_if_intel_macos() -> None:
"""Warn that Intel (x86_64) Python on macOS loses support by _INTEL_MACOS_REMOVAL."""
if (
not IS_MACOS
or platform.machine() != "x86_64"
or _INTEL_MACOS_WARNED_ENV in os.environ
):
return
_LOGGER.warning(
"Support for Intel Macs will end in ESPHome %s or earlier. The Python "
"packages ESPHome depends on have stopped publishing Intel macOS builds, "
"so future releases will not install on this machine. On an Apple "
"Silicon Mac, switch to a native arm64 Python.",
_INTEL_MACOS_REMOVAL,
)
def run_esphome(argv):
from esphome.address_cache import AddressCache
@@ -2633,6 +2658,7 @@ def run_esphome(argv):
setup_log(log_level=args.log_level)
_warn_if_source_tree_mismatch()
_warn_if_intel_macos()
if args.command in PRE_CONFIG_ACTIONS:
try:
+3 -3
View File
@@ -49,9 +49,9 @@ class FrameworkRelease(NamedTuple):
# Arduino core version -> its build in esphome-libs/arduino-esp8266
FRAMEWORK_RELEASES: dict[Version, FrameworkRelease] = {
Version(3, 1, 2): FrameworkRelease(
"3.1.2-esphome.1",
"e80751e3123676b967143e39c61f2d8693946db4c7806f2a83dcaaf797ecd582",
37189311,
"3.1.2-esphome.3",
"3af533a85163804e164500a5b254dc23b922080e25ecb7dc37b3dd8ac84651db",
37185650,
),
}
+6 -51
View File
@@ -672,52 +672,20 @@ XorCondition = cg.esphome_ns.class_("XorCondition", Condition)
@register_condition("and", AndCondition, validate_condition_list)
async def and_condition_to_code(
config: ConfigType,
condition_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
conditions = await build_condition_list(config, template_arg, args)
return cg.new_Pvariable(
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
)
@register_condition("or", OrCondition, validate_condition_list)
async def or_condition_to_code(
config: ConfigType,
condition_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
conditions = await build_condition_list(config, template_arg, args)
return cg.new_Pvariable(
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
)
@register_condition("all", AndCondition, validate_condition_list)
async def all_condition_to_code(
config: ConfigType,
condition_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
conditions = await build_condition_list(config, template_arg, args)
return cg.new_Pvariable(
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
)
@register_condition("or", OrCondition, validate_condition_list)
@register_condition("any", OrCondition, validate_condition_list)
async def any_condition_to_code(
@register_condition("xor", XorCondition, validate_condition_list)
async def condition_group_to_code(
config: ConfigType,
condition_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
conditions = await build_condition_list(config, template_arg, args)
# A group of one is that condition for and, or and xor; skip the wrapper.
if len(conditions) == 1:
return conditions[0]
return cg.new_Pvariable(
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
)
@@ -734,19 +702,6 @@ async def not_condition_to_code(
return cg.new_Pvariable(condition_id, template_arg, condition)
@register_condition("xor", XorCondition, validate_condition_list)
async def xor_condition_to_code(
config: ConfigType,
condition_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
conditions = await build_condition_list(config, template_arg, args)
return cg.new_Pvariable(
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
)
@register_condition("lambda", LambdaCondition, cv.returning_lambda)
async def lambda_condition_to_code(
config: ConfigType,
+42 -27
View File
@@ -54,6 +54,7 @@ from esphome.components.esp8266.const import (
KEY_ESP8266,
KEY_FLASH_SIZE,
KEY_SCANF_FLOAT,
THROW_STUBS_HEADER,
)
from esphome.core import CORE, EsphomeError
from esphome.framework_helpers import get_project_cxx_compile_flags
@@ -1056,7 +1057,7 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
cxx = (toolchain_tool(paths.toolchain, "g++"),)
lines = [
*tool_lines((toolchain_tool(paths.toolchain, "gcc"),), cxx, ccache),
*compile_rule_lines(),
*compile_rule_lines(ccache),
*ar_rule_lines(toolchain_tool(paths.toolchain, "ar")),
*pch_rule_lines(),
"rule link",
@@ -1087,32 +1088,8 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
if "USE_ESP8266_WAVEFORM_STUBS" in flag_defines:
core_exclude |= _CORE_EXCLUDE_WAVEFORM
archives = []
# variant_dir existence was already enforced with the include dirs
variant_sources = collect_sources(variant_dir)
if variant_sources:
objs = compile_edges(lines, variant_sources, variant_dir, "variant")
lines.append(f"build libFrameworkArduinoVariant.a: ar {' '.join(objs)}")
archives.append("libFrameworkArduinoVariant.a")
core_objs = compile_edges(
lines, collect_sources(core_dir, core_exclude), core_dir, "core"
)
if not core_objs:
# An empty archive would link into a wall of undefined references
# (app_entry, the exception vectors) far from the cause
raise EsphomeError(
f"{_INCOMPLETE_INSTALL}: no core sources in {core_dir}; {_CLEAN_HINT}"
)
lines.append(f"build libFrameworkArduino.a: ar {' '.join(core_objs)}")
archives.append("libFrameworkArduino.a")
lib_archives, direct_objs = library_edges(lines, libraries)
archives += lib_archives
# One source of truth with the PlatformIO path: esp8266/__init__ pins
# build_src_flags (the throw_stubs force-include); -include paths
# resolve against the source root
# esp8266/__init__ pins build_src_flags (the throw_stubs force-include), as on
# the PlatformIO path; -include paths resolve against the source root
src_other: list[str] = []
src_includes: list[str] = []
src_it = iter(
@@ -1131,7 +1108,45 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
src_includes.append(tok[len("-include") :])
else:
src_other.append(_shell_token(tok))
# The throw stubs abort: none anywhere with exceptions on, else the core and
# libraries get them too (src keeps them after its pch include)
if config.exceptions:
src_includes = [h for h in src_includes if h != THROW_STUBS_HEADER]
include_flags = [f"-include {_q(src_dir / h)}" for h in src_includes]
framework_flags = ""
if THROW_STUBS_HEADER in src_includes:
stubs_flag = include_flags[src_includes.index(THROW_STUBS_HEADER)]
lines.append(f"frameworkflags = {stubs_flag}")
framework_flags = "$frameworkflags"
archives = []
# variant_dir existence was already enforced with the include dirs
variant_sources = collect_sources(variant_dir)
if variant_sources:
objs = compile_edges(
lines, variant_sources, variant_dir, "variant", flags=framework_flags
)
lines.append(f"build libFrameworkArduinoVariant.a: ar {' '.join(objs)}")
archives.append("libFrameworkArduinoVariant.a")
core_objs = compile_edges(
lines,
collect_sources(core_dir, core_exclude),
core_dir,
"core",
flags=framework_flags,
)
if not core_objs:
# An empty archive would link into a wall of undefined references
# (app_entry, the exception vectors) far from the cause
raise EsphomeError(
f"{_INCOMPLETE_INSTALL}: no core sources in {core_dir}; {_CLEAN_HINT}"
)
lines.append(f"build libFrameworkArduino.a: ar {' '.join(core_objs)}")
archives.append("libFrameworkArduino.a")
lib_archives, direct_objs = library_edges(lines, libraries, framework_flags)
archives += lib_archives
# One shared variable instead of repeating the flags line on every src
# edge (hundreds of edges in a real project)
lines.append(f"srcflags = {' '.join(src_other + include_flags)}")
+1 -1
View File
@@ -240,7 +240,7 @@ def write_project(compilers: HostCompilers, ccache: str | None) -> bool:
lines = [
*tool_lines(compilers.cc, compilers.cxx, ccache),
*compile_rule_lines(),
*compile_rule_lines(ccache),
*pch_rule_lines(),
"rule link",
" command = $cxx -o $out $linkflags @$out.rsp $archives $libdirflags $libflags",
+18 -9
View File
@@ -74,33 +74,40 @@ def tool_lines(
# path gets
f"python = {_q(strip_win_long_path_prefix(sys.executable))}",
f"buildtool = {_q(_BUILD_TOOL)}",
f"ccache = {_q(ccache) if ccache else ''}",
*([f"ccache = {_q(ccache)}"] if ccache else []),
"",
]
def compile_rule_lines() -> list[str]:
"""The compile rules; ``$own_includes`` is empty unless an edge sets it."""
def compile_rule_lines(ccache: str | None) -> list[str]:
"""The compile rules; ``$own_includes`` is empty unless an edge sets it.
``ccache`` is the launcher ``tool_lines`` was given; the same test here
keeps the variable and the rules in step. It is spliced in rather than
left as an empty variable: a leading space on the command line makes
CreateProcess fail on Windows.
"""
launcher = "$ccache " if ccache else ""
return [
"rule c",
" command = $ccache $cc -MMD -MF $out.d $own_includes $cflags $flags -c $in -o $out",
f" command = {launcher}$cc -MMD -MF $out.d $own_includes $cflags $flags -c $in -o $out",
" depfile = $out.d",
" deps = gcc",
" description = CC $out",
"rule cxx",
" command = $ccache $cxx -MMD -MF $out.d $own_includes $cxxflags $flags -c $in -o $out",
f" command = {launcher}$cxx -MMD -MF $out.d $own_includes $cxxflags $flags -c $in -o $out",
" depfile = $out.d",
" deps = gcc",
" description = CXX $out",
"rule aspp",
" command = $ccache $cc -MMD -MF $out.d -x assembler-with-cpp $own_includes $asflags $flags -c $in -o $out",
f" command = {launcher}$cc -MMD -MF $out.d -x assembler-with-cpp $own_includes $asflags $flags -c $in -o $out",
" depfile = $out.d",
" deps = gcc",
" description = AS $out",
# Plain assembler, as SCons's ASCOM: no preprocessor, so no
# depfile and no $flags (defines/includes) either
"rule asm",
" command = $ccache $cc -x assembler $asflags -c $in -o $out",
f" command = {launcher}$cc -x assembler $asflags -c $in -o $out",
" description = AS $out",
]
@@ -207,10 +214,12 @@ def compile_edges(
def library_edges(
lines: list[str], libraries: list[ArduinoLibrary]
lines: list[str], libraries: list[ArduinoLibrary], extra_flags: str = ""
) -> tuple[list[str], list[str]]:
"""Emit every library's compile and archive edges.
``extra_flags`` (raw ninja text) precede each library's own flags.
Returns the archive names and the objects that link directly. A
library's own include dirs lead its compile lines, as PlatformIO searched
them first: the include path is one global list, so another library's
@@ -232,7 +241,7 @@ def library_edges(
lib.sources,
common_parent(lib.sources),
f"lib/{lib.name}",
flags=" ".join(_shell_token(f) for f in lib.flags),
flags=" ".join(filter(None, [extra_flags, *map(_shell_token, lib.flags)])),
own_includes=" ".join(f"-I{_q(d)}" for d in lib.include_dirs),
)
if not lib.lib_archive:
+57 -7
View File
@@ -81,6 +81,8 @@ service APIConnection {
rpc subscribe_serial_proxy_identity(SubscribeSerialProxyIdentityRequest) returns (void) {}
rpc serial_proxy_request(SerialProxyRequest) returns (void) {}
rpc serial_proxy_set_mode(SerialProxySetModeRequest) returns (void) {}
rpc sendspin_pairing_token(SendspinPairingTokenRequest) returns (SendspinPairingTokenResponse) {}
}
@@ -398,6 +400,11 @@ message WizardCapabilities {
bool configured = 1;
}
message SendspinCapabilities {
// Bitmask of the Sendspin features this device supports
uint32 feature_flags = 1;
}
message DeviceCapabilitiesResponse {
option (id) = 150;
option (source) = SOURCE_SERVER;
@@ -408,6 +415,7 @@ message DeviceCapabilitiesResponse {
repeated SerialProxyInfo serial_proxies = 4
[(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"];
WizardCapabilities wizard = 5 [(field_ifdef) = "USE_API_WIZARD"];
SendspinCapabilities sendspin = 6 [(field_ifdef) = "USE_SENDSPIN"];
}
// ==================== DEVICE WIZARD ====================
@@ -436,7 +444,7 @@ message DeviceWizardRequest {
// {"version": 1,
// "pages": [{"title": "...", "description": "...",
// "entities": [{"key": 123, "device_id": 456, "description": "..."}],
// "inputs": [{"key": 789, "description": "...",
// "inputs": [{"key": 789, "description": "...", "multiple": true, "max_length": 1023,
// "entity_filters": [{"integration": "...", "domain": ["..."],
// "device_class": ["..."], "supported_features": ["..."]}]}]}]}
//
@@ -460,6 +468,14 @@ message DeviceWizardRequest {
// an entity matches a filter when it satisfies every field that is set, and an entity
// that matches any one of the filters is accepted.
//
// An input with "multiple": true lets the user pick several entities. The client sends
// them in one WizardInputSetRequest as a comma separated list with no spaces
// ("calendar.a,calendar.b"); the device also accepts spaces around each id and stores the
// list without them. "max_length" (only on a multiple input, 255 when left out) is the
// longest list in bytes the device accepts. The version stays 1: a client that does not
// know "multiple" ignores it, shows a single picker and sends one entity id, which a
// multiple input accepts.
//
// The device keeps the entity id of an input in RAM only and does not store it across
// restarts. The client therefore stores the choice itself and, after every connection
// and before it sends SubscribeHomeAssistantStatesRequest, sends a WizardInputSetRequest
@@ -468,17 +484,20 @@ message DeviceWizardResponse {
option (id) = 157;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_API_WIZARD";
// Not logged: the data is in flash, which ESP8266 cannot read for a dump
option (log) = false;
// The data is in flash, and the device sends it from there
bytes data = 1 [(pointer_to_buffer) = true];
bytes data = 1 [(pointer_to_buffer) = true, (progmem) = true];
}
// Sets the entity id of a wizard input (see WizardInputField.key). Clients send
// this only when DeviceCapabilitiesResponse.wizard.configured is set. There is no
// reply. The device ignores a request whose key matches no input or whose entity_id
// is empty, longer than 255 bytes or has no '.'. Otherwise it keeps the id in RAM,
// reply. The device ignores a request whose key matches no input. A single input is
// ignored when the entity_id is empty, longer than 255 bytes or has no '.'. A multiple
// input takes a comma separated list ("calendar.a,calendar.b"); the client sends no
// spaces, the device trims spaces around each id and stores the list without them. The
// whole request is ignored when any id is empty or has no '.', or when the list without
// spaces does not fit the input's max_length (255 by default). Otherwise it keeps the id
// or list in RAM,
// not across restarts, and when the input belongs to a homeassistant platform entity
// it subscribes to that entity's state. Clients send this for every input right
// after connecting, before SubscribeHomeAssistantStatesRequest. A request that
@@ -490,7 +509,7 @@ message WizardInputSetRequest {
option (ifdef) = "USE_API_WIZARD_INPUTS";
fixed32 key = 1;
string entity_id = 2 [(max_data_length) = 255];
string entity_id = 2 [(max_data_length) = 4095];
}
message ListEntitiesRequest {
@@ -3071,3 +3090,34 @@ message BluetoothSetConnectionParamsResponse {
uint64 address = 1;
int32 error = 2;
}
// ==================== SENDSPIN ====================
enum SendspinPairingTokenStatus {
// The Sendspin client has not started yet, so the token does not exist yet. Ask again later.
SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY = 0;
SENDSPIN_PAIRING_TOKEN_STATUS_OK = 1;
// The device has no API encryption key, so it will not send the token. Ask again once a key is set.
SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED = 2;
// Sendspin is turned off and has not started since boot, so the token does not exist yet.
SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED = 3;
// The Sendspin client failed to start, and will not start until the device reboots.
SENDSPIN_PAIRING_TOKEN_STATUS_FAILED = 4;
}
// Requests the Sendspin pairing token, which a Sendspin server uses to pair with this device
message SendspinPairingTokenRequest {
option (id) = 159;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_SENDSPIN";
// Empty
}
message SendspinPairingTokenResponse {
option (id) = 160;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_SENDSPIN";
SendspinPairingTokenStatus status = 1;
// The pairing token (Sendspin pairing.md "Pairing Token"). Set only when status is OK.
string token = 2 [(max_data_length) = 127];
}
+41 -1
View File
@@ -48,6 +48,9 @@
#ifdef USE_ZWAVE_PROXY
#include "esphome/components/zwave_proxy/zwave_proxy.h"
#endif
#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF)
#include "esphome/components/sendspin/sendspin_hub.h"
#endif
#ifdef USE_WATER_HEATER
#include "esphome/components/water_heater/water_heater.h"
#endif
@@ -1402,6 +1405,40 @@ void APIConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) {
}
#endif
#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF)
bool APIConnection::send_sendspin_pairing_token_response_() {
SendspinPairingTokenResponse resp;
#ifdef USE_API_NOISE
// has_psk() stands in for "this session proved the key": with a key active, plaintext and zero-key sessions
// never reach hello, and activating a key closes every session opened before it.
if (!this->parent_->get_noise_ctx().has_psk()) {
resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED;
return this->send_message(resp);
}
auto token = sendspin_::global_sendspin_hub->get_pairing_token();
if (token.has_value()) {
resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_OK;
resp.token = StringRef(*token);
} else if (sendspin_::global_sendspin_hub->status_has_error()) {
resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_FAILED;
} else if (sendspin_::global_sendspin_hub->is_disabled()) {
resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED;
} else {
resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY;
}
#else
resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED;
#endif
return this->send_message(resp);
}
void APIConnection::on_sendspin_pairing_token_request() {
if (!this->send_sendspin_pairing_token_response_()) {
this->on_fatal_error();
}
}
#endif
#ifdef USE_ALARM_CONTROL_PANEL
bool APIConnection::send_alarm_control_panel_state(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel) {
return this->send_message_smart_(a_alarm_control_panel, AlarmControlPanelStateResponse::MESSAGE_TYPE,
@@ -1872,7 +1909,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
HelloResponse resp;
resp.api_version_major = 1;
resp.api_version_minor = 18;
resp.api_version_minor = 19;
// Send only the version string - the client only logs this for debugging and doesn't use it otherwise
resp.server_info = ESPHOME_VERSION_REF;
resp.name = StringRef(App.get_name());
@@ -2088,6 +2125,9 @@ bool APIConnection::send_device_capabilities_response_() {
#endif
#ifdef USE_API_WIZARD
resp.wizard.configured = true;
#endif
#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF)
resp.sendspin.feature_flags = sendspin_::global_sendspin_hub->get_feature_flags();
#endif
return this->send_message(resp);
}
+7 -3
View File
@@ -223,6 +223,10 @@ class APIConnection final : public APIServerConnectionBase {
void on_z_wave_proxy_request(const ZWaveProxyRequest &msg);
#endif
#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF)
void on_sendspin_pairing_token_request();
#endif
#ifdef USE_ALARM_CONTROL_PANEL
bool send_alarm_control_panel_state(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel);
void on_alarm_control_panel_command_request(const AlarmControlPanelCommandRequest &msg);
@@ -423,9 +427,6 @@ class APIConnection final : public APIServerConnectionBase {
bool send_ping_response_();
bool send_device_info_response_();
bool send_device_capabilities_response_();
#ifdef USE_API_WIZARD
bool send_device_wizard_response_();
#endif
#ifdef USE_API_NOISE
bool send_noise_encryption_set_key_response_(const NoiseEncryptionSetKeyRequest &msg);
#endif
@@ -435,6 +436,9 @@ class APIConnection final : public APIServerConnectionBase {
#ifdef USE_VOICE_ASSISTANT
bool send_voice_assistant_get_configuration_response_(const VoiceAssistantConfigurationRequest &msg);
#endif
#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF)
bool send_sendspin_pairing_token_response_();
#endif
#ifdef USE_CAMERA
void try_send_camera_image_();
+5
View File
@@ -122,4 +122,9 @@ extend google.protobuf.FieldOptions {
// to true when the field arrives, so an all-default submessage can be told apart
// from an absent one (e.g. a UTC ParsedTimezone, which is all zeros).
optional bool track_presence = 50020 [default=false];
// progmem: The pointer_to_buffer bytes field points into flash (PROGMEM).
// The generated encode and dump copy it with progmem_memcpy, as ESP8266
// cannot read flash with ordinary loads.
optional bool progmem = 50021 [default=false];
}
+36 -1
View File
@@ -306,6 +306,19 @@ uint32_t WizardCapabilities::calc_size_msg(const void *self) {
return size;
}
#endif
#ifdef USE_SENDSPIN
uint8_t *SendspinCapabilities::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
const auto &msg = *static_cast<const SendspinCapabilities *>(self);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.feature_flags);
return pos;
}
uint32_t SendspinCapabilities::calc_size_msg(const void *self) {
const auto &msg = *static_cast<const SendspinCapabilities *>(self);
uint32_t size = 0;
size += ProtoSize::calc_uint32(1, msg.feature_flags);
return size;
}
#endif
uint8_t *DeviceCapabilitiesResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
const auto &msg = *static_cast<const DeviceCapabilitiesResponse *>(self);
#ifdef USE_BLUETOOTH_PROXY
@@ -324,6 +337,9 @@ uint8_t *DeviceCapabilitiesResponse::encode_msg(const void *self, uint8_t *__res
#endif
#ifdef USE_API_WIZARD
pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.wizard);
#endif
#ifdef USE_SENDSPIN
pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.sendspin);
#endif
return pos;
}
@@ -346,13 +362,16 @@ uint32_t DeviceCapabilitiesResponse::calc_size_msg(const void *self) {
#endif
#ifdef USE_API_WIZARD
size += ProtoSize::calc_message(1, msg.wizard.calculate_size());
#endif
#ifdef USE_SENDSPIN
size += ProtoSize::calc_message(1, msg.sendspin.calculate_size());
#endif
return size;
}
#ifdef USE_API_WIZARD
uint8_t *DeviceWizardResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
const auto &msg = *static_cast<const DeviceWizardResponse *>(self);
pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.data, msg.data_len);
pos = ProtoEncode::encode_progmem_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.data, msg.data_len);
return pos;
}
uint32_t DeviceWizardResponse::calc_size_msg(const void *self) {
@@ -4229,6 +4248,22 @@ uint32_t BluetoothSetConnectionParamsResponse::calc_size_msg(const void *self) {
return size;
}
#endif
#ifdef USE_SENDSPIN
uint8_t *SendspinPairingTokenResponse::encode_msg(const void *self,
uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
const auto &msg = *static_cast<const SendspinPairingTokenResponse *>(self);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast<uint32_t>(msg.status));
pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.token);
return pos;
}
uint32_t SendspinPairingTokenResponse::calc_size_msg(const void *self) {
const auto &msg = *static_cast<const SendspinPairingTokenResponse *>(self);
uint32_t size = 0;
size += msg.status ? 2 : 0;
size += !msg.token.empty() ? 2 + msg.token.size() : 0;
return size;
}
#endif
#ifndef HAS_PROTO_MESSAGE_DUMP
static_assert(!std::is_polymorphic_v<HelloRequest>, "decodable messages carry no vtable");
static_assert(!std::is_polymorphic_v<DisconnectRequest>, "decodable messages carry no vtable");
+53 -1
View File
@@ -383,6 +383,15 @@ enum SerialProxyIdentityFlag : uint32_t {
SERIAL_PROXY_IDENTITY_FLAG_CONNECTED = 1,
SERIAL_PROXY_IDENTITY_FLAG_ERROR = 2,
};
#ifdef USE_SENDSPIN
enum SendspinPairingTokenStatus : uint32_t {
SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY = 0,
SENDSPIN_PAIRING_TOKEN_STATUS_OK = 1,
SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED = 2,
SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED = 3,
SENDSPIN_PAIRING_TOKEN_STATUS_FAILED = 4,
};
#endif
} // namespace enums
@@ -737,10 +746,27 @@ class WizardCapabilities final : public ProtoMessage {
protected:
};
#endif
#ifdef USE_SENDSPIN
class SendspinCapabilities final : public ProtoMessage {
public:
uint32_t feature_flags{0};
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG);
}
static uint32_t calc_size_msg(const void *self);
uint32_t calculate_size() const { return calc_size_msg(this); }
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
};
#endif
class DeviceCapabilitiesResponse final : public ProtoMessage {
public:
static constexpr uint16_t MESSAGE_TYPE = 150;
static constexpr uint8_t ESTIMATED_SIZE = 119;
static constexpr uint8_t ESTIMATED_SIZE = 136;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("device_capabilities_response"); }
#endif
@@ -758,6 +784,9 @@ class DeviceCapabilitiesResponse final : public ProtoMessage {
#endif
#ifdef USE_API_WIZARD
WizardCapabilities wizard{};
#endif
#ifdef USE_SENDSPIN
SendspinCapabilities sendspin{};
#endif
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
@@ -4138,5 +4167,28 @@ class BluetoothSetConnectionParamsResponse final : public ProtoMessage {
protected:
};
#endif
#ifdef USE_SENDSPIN
class SendspinPairingTokenResponse final : public ProtoMessage {
public:
static constexpr uint16_t MESSAGE_TYPE = 160;
static constexpr uint8_t ESTIMATED_SIZE = 11;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("sendspin_pairing_token_response"); }
#endif
enums::SendspinPairingTokenStatus status{};
StringRef token{nullptr, 0}; // null until set, encode only
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG);
}
static uint32_t calc_size_msg(const void *self);
uint32_t calculate_size() const { return calc_size_msg(this); }
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
};
#endif
} // namespace esphome::api
+53 -3
View File
@@ -114,15 +114,26 @@ template<typename T> static void dump_field(DumpBuffer &out, const char *field_n
out.append("\n");
}
// Bytes shown by a bytes field dump: 160 bytes is 480 chars with separators, to fit a typical log buffer
static constexpr size_t DUMP_BYTES_MAX = 160;
// Helper for bytes fields - uses stack buffer to avoid heap allocation
// Buffer sized for 160 bytes of data (480 chars with separators) to fit typical log buffer
// field_name is a PROGMEM pointer (flash on ESP8266, regular pointer on other platforms)
static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint8_t *data, size_t len, int indent = 2) {
char hex_buf[format_hex_pretty_size(160)];
char hex_buf[format_hex_pretty_size(DUMP_BYTES_MAX)];
append_field_prefix(out, field_name, indent);
format_hex_pretty_to(hex_buf, data, len);
out.append(hex_buf).append("\n");
}
// Helper for bytes fields in flash: copies the shown bytes out with progmem_memcpy first
static void dump_progmem_bytes_field(DumpBuffer &out, const char *field_name, const uint8_t *data, size_t len,
int indent = 2) {
uint8_t data_buf[DUMP_BYTES_MAX];
len = std::min(len, sizeof(data_buf));
progmem_memcpy(data_buf, data, len);
dump_bytes_field(out, field_name, data_buf, len, indent);
}
#pragma GCC diagnostic pop
template<> const char *proto_enum_to_string<enums::DisconnectReason>(enums::DisconnectReason value) {
@@ -917,6 +928,25 @@ template<> const char *proto_enum_to_string<enums::SerialProxyIdentityFlag>(enum
return ESPHOME_PSTR("UNKNOWN");
}
}
#ifdef USE_SENDSPIN
template<>
const char *proto_enum_to_string<enums::SendspinPairingTokenStatus>(enums::SendspinPairingTokenStatus value) {
switch (value) {
case enums::SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY:
return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY");
case enums::SENDSPIN_PAIRING_TOKEN_STATUS_OK:
return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_OK");
case enums::SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED:
return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED");
case enums::SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED:
return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED");
case enums::SENDSPIN_PAIRING_TOKEN_STATUS_FAILED:
return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_FAILED");
default:
return ESPHOME_PSTR("UNKNOWN");
}
}
#endif
const char *HelloRequest::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("HelloRequest"));
@@ -1085,6 +1115,13 @@ const char *WizardCapabilities::dump_to(DumpBuffer &out) const {
return out.c_str();
}
#endif
#ifdef USE_SENDSPIN
const char *SendspinCapabilities::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("SendspinCapabilities"));
dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags);
return out.c_str();
}
#endif
const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceCapabilitiesResponse"));
#ifdef USE_BLUETOOTH_PROXY
@@ -1113,13 +1150,18 @@ const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const {
out.append(2, ' ').append_p(ESPHOME_PSTR("wizard")).append(": ");
this->wizard.dump_to(out);
out.append("\n");
#endif
#ifdef USE_SENDSPIN
out.append(2, ' ').append_p(ESPHOME_PSTR("sendspin")).append(": ");
this->sendspin.dump_to(out);
out.append("\n");
#endif
return out.c_str();
}
#ifdef USE_API_WIZARD
const char *DeviceWizardResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceWizardResponse"));
dump_bytes_field(out, ESPHOME_PSTR("data"), this->data, this->data_len);
dump_progmem_bytes_field(out, ESPHOME_PSTR("data"), this->data, this->data_len);
return out.c_str();
}
#endif
@@ -2933,6 +2975,14 @@ const char *BluetoothSetConnectionParamsResponse::dump_to(DumpBuffer &out) const
return out.c_str();
}
#endif
#ifdef USE_SENDSPIN
const char *SendspinPairingTokenResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("SendspinPairingTokenResponse"));
dump_field(out, ESPHOME_PSTR("status"), static_cast<enums::SendspinPairingTokenStatus>(this->status));
dump_field(out, ESPHOME_PSTR("token"), this->token);
return out.c_str();
}
#endif
} // namespace esphome::api
@@ -751,6 +751,15 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
this->on_wizard_input_set_request(msg);
break;
}
#endif
#ifdef USE_SENDSPIN
case 159 /* SendspinPairingTokenRequest is empty */: {
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_sendspin_pairing_token_request"));
#endif
this->on_sendspin_pairing_token_request();
break;
}
#endif
default:
break;
+4
View File
@@ -252,6 +252,10 @@ class APIServerConnectionBase {
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){};
#endif
#ifdef USE_SENDSPIN
void on_sendspin_pairing_token_request(){};
#endif
};
} // namespace esphome::api
+51 -26
View File
@@ -13,53 +13,78 @@ namespace esphome::api {
static const char *const TAG = "api.wizard";
uint8_t *wizard_encode_response(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
const auto &msg = *static_cast<const DeviceWizardResponse *>(self);
if (msg.data_len == 0)
return pos;
pos = ProtoEncode::encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, 1, 2); // type 2: Length-delimited
pos = ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, msg.data_len);
PROTO_ENCODE_CHECK_BOUNDS(pos, msg.data_len);
progmem_memcpy(pos, msg.data, msg.data_len);
return pos + msg.data_len;
}
#ifdef USE_API_WIZARD_INPUTS
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
// Walks the comma separated entity ids in value, without the spaces around each. Returns the length they take joined
// by ',', or 0 when one is empty or has no '.'. Writes the joined list to out unless it is nullptr.
static size_t wizard_join_entity_ids(const char *value, size_t length, char *out) {
size_t joined = 0;
const char *end = value + length;
while (true) {
const char *comma = static_cast<const char *>(memchr(value, ',', end - value));
const char *id_end = comma != nullptr ? comma : end;
while (value < id_end && *value == ' ')
value++;
while (id_end > value && id_end[-1] == ' ')
id_end--;
size_t id_length = id_end - value;
if (id_length == 0 || memchr(value, '.', id_length) == nullptr)
return 0;
if (joined != 0) {
if (out != nullptr)
out[joined] = ',';
joined++;
}
if (out != nullptr)
memcpy(out + joined, value, id_length);
joined += id_length;
if (comma == nullptr)
return joined;
value = comma + 1;
}
}
#endif // USE_API_WIZARD_MULTIPLE_INPUTS
static bool wizard_entity_id_valid(const char *entity_id, size_t length) {
return length > 0 && length < WIZARD_ENTITY_ID_BUFFER_SIZE && memchr(entity_id, '.', length) != nullptr;
}
const char *wizard_set_input(const WizardInputSetRequest &msg) {
if (!wizard_entity_id_valid(msg.entity_id.c_str(), msg.entity_id.size())) {
ESP_LOGW(TAG, "Ignoring an invalid entity id for wizard input");
return nullptr;
}
const char *value = msg.entity_id.c_str();
const size_t length = msg.entity_id.size();
for (size_t i = 0; i < API_WIZARD_INPUT_COUNT; i++) {
// The table is in flash, which ESP8266 can only read through progmem_memcpy
WizardInputEntry entry;
progmem_memcpy(&entry, &API_WIZARD_INPUTS[i], sizeof(entry));
if (entry.key != msg.key)
continue;
memcpy(entry.entity_id, msg.entity_id.c_str(), msg.entity_id.size());
entry.entity_id[msg.entity_id.size()] = '\0';
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
if (entry.size != 0) {
// Two passes, validate and measure then write, so a rejected request leaves the buffer unchanged
const size_t joined = wizard_join_entity_ids(value, length, nullptr);
if (joined == 0 || joined >= entry.size)
break;
wizard_join_entity_ids(value, length, entry.entity_id);
entry.entity_id[joined] = '\0';
return entry.entity_id;
}
#endif
if (!wizard_entity_id_valid(value, length))
break;
memcpy(entry.entity_id, value, length);
entry.entity_id[length] = '\0';
return entry.entity_id;
}
ESP_LOGW(TAG, "Ignoring an entity id for an unknown wizard input");
ESP_LOGW(TAG, "Ignoring an entity id for an unknown wizard input, or one that is not valid");
return nullptr;
}
#endif // USE_API_WIZARD_INPUTS
bool APIConnection::send_device_wizard_response_() {
void APIConnection::on_device_wizard_request() {
DeviceWizardResponse resp;
resp.data = API_WIZARD_DATA;
resp.data_len = API_WIZARD_DATA_SIZE;
// Not send_message: the data is in flash, so wizard_encode_response copies it out
return this->send_message_(DeviceWizardResponse::calc_size_msg(&resp), DeviceWizardResponse::MESSAGE_TYPE,
&wizard_encode_response, &resp);
}
void APIConnection::on_device_wizard_request() {
if (!this->send_device_wizard_response_()) {
if (!this->send_message(resp)) {
this->on_fatal_error();
}
}
+33 -6
View File
@@ -6,6 +6,9 @@
#include <cstddef>
#include <cstdint>
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
#include <cstring>
#endif
#include "esphome/core/hal.h"
#include "proto.h"
@@ -22,15 +25,14 @@ static constexpr size_t WIZARD_ENTITY_ID_BUFFER_SIZE = 256;
/// (components/api/wizard.py) and kept in flash. API_WIZARD_DATA_SIZE bytes long.
extern const uint8_t API_WIZARD_DATA[] PROGMEM;
/// Encodes a DeviceWizardResponse like the generated encoder would. The data is in flash, which ESP8266 can only read
/// with progmem_memcpy, so the generated encoder (a plain memcpy) cannot be used. Plain memcpy elsewhere.
uint8_t *wizard_encode_response(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
#ifdef USE_API_WIZARD_INPUTS
/// Where the entity id of an input is kept, found by the key the client uses for it.
struct WizardInputEntry {
uint32_t key; // FNV-1 hash of the ESPHome id of the input
char *entity_id; // RAM buffer of WIZARD_ENTITY_ID_BUFFER_SIZE bytes, shared with the homeassistant entity
char *entity_id; // RAM buffer, WIZARD_ENTITY_ID_BUFFER_SIZE bytes or size, shared with the homeassistant entity
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
uint16_t size; // Buffer size of a multiple input, 0 for a single input
#endif
};
/// The inputs of the wizard, in flash. API_WIZARD_INPUT_COUNT entries long.
@@ -48,9 +50,34 @@ const char *wizard_set_input(const WizardInputSetRequest &msg);
class WizardInput {
public:
explicit WizardInput(const char *entity_id) : entity_id_(entity_id) {}
/// The Home Assistant entity ID, empty until the wizard sets one.
/// The Home Assistant entity ID, empty until the wizard sets one. For a multiple input this is the whole
/// comma separated list, which homeassistant.action data can use as it is.
StringRef entity_id() const { return StringRef(this->entity_id_); }
bool has_entity_id() const { return this->entity_id_[0] != '\0'; }
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
/// The number of entity IDs in the list, 0 until the wizard sets one.
size_t entity_id_count() const {
if (!this->has_entity_id())
return 0;
size_t count = 1;
for (const char *p = this->entity_id_; (p = strchr(p, ',')) != nullptr; p++)
count++;
return count;
}
/// Call callback(StringRef) for each entity ID in the list, in order. The StringRef points into the list and is
/// not null terminated, so print it with "%.*s".
template<typename F> void for_each_entity_id(F &&callback) const {
// The device stores the list without spaces or empty entries
const char *start = this->entity_id_;
while (*start != '\0') {
const char *end = strchr(start, ',');
if (end == nullptr)
end = start + strlen(start);
callback(StringRef(start, end - start));
start = *end == ',' ? end + 1 : end;
}
}
#endif
protected:
const char *entity_id_;
+16
View File
@@ -3,6 +3,7 @@
#include "api_pb2_defines.h"
#include "api_buffer.h"
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
@@ -465,6 +466,21 @@ class ProtoEncode {
uint32_t field_id, const uint8_t *data, size_t len) {
return encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, reinterpret_cast<const char *>(data), len);
}
/// encode_bytes for data in flash (PROGMEM), which ESP8266 can only read with progmem_memcpy.
[[nodiscard]] static inline uint8_t *encode_progmem_bytes(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
uint32_t field_id, const uint8_t *data, size_t len) {
if (len == 0)
return pos;
return encode_progmem_bytes_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, data, len);
}
[[nodiscard]] static inline uint8_t *encode_progmem_bytes_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
uint32_t field_id, const uint8_t *data, size_t len) {
pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 2); // type 2: Length-delimited
pos = encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, len);
PROTO_ENCODE_CHECK_BOUNDS(pos, len);
progmem_memcpy(pos, data, len);
return pos + len;
}
[[nodiscard]] static inline uint8_t *encode_uint32_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
uint32_t field_id, uint32_t value) {
pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 0);
+54 -17
View File
@@ -6,7 +6,6 @@ Home Assistant shows the wizard when the device is added. See api_wizard.h for t
from collections.abc import Callable
import importlib
import json
from types import ModuleType
from typing import Any
from esphome import automation
@@ -20,6 +19,8 @@ from esphome.const import (
CONF_ENTITY_ID,
CONF_ID,
CONF_INTERNAL,
CONF_MAX_LENGTH,
CONF_MULTIPLE,
CONF_NAME,
CONF_PAGES,
CONF_PLATFORM,
@@ -27,7 +28,12 @@ from esphome.const import (
)
from esphome.core import CORE, ID
import esphome.final_validate as fv
from esphome.helpers import fnv1_hash, fnv1_hash_object_id, fnv1a_32bit_hash
from esphome.helpers import (
fnv1_hash,
fnv1_hash_object_id,
fnv1a_32bit_hash,
zstd_module,
)
from esphome.types import ConfigType
API_DOMAIN = "api"
@@ -46,6 +52,10 @@ WizardInput = _API.class_("WizardInput")
WIZARD_ENTITY_ID_BUFFER_SIZE = (
256 # api_wizard.h; Home Assistant entity IDs are at most 255 bytes
)
WIZARD_ENTITY_ID_MAX_LENGTH = WIZARD_ENTITY_ID_BUFFER_SIZE - 1
# The value of a multiple input fits one WizardInputSetRequest, which the device accepts up to 8 KiB on ESP8266;
# api.proto max_data_length documents the same value
WIZARD_MULTIPLE_MAX_LENGTH = 4095
# One API message must fit APIBuffer::MAX_SIZE (65535) together with the largest frame header (7 bytes,
# Noise) and footer (16 bytes, Noise MAC)
WIZARD_RESPONSE_MAX_SIZE = 65535 - 7 - 16
@@ -101,6 +111,21 @@ WIZARD_ENTITY_SCHEMA = cv.Schema(
)
def _validate_multiple(conf: ConfigType) -> ConfigType:
if conf[CONF_MULTIPLE] and CONF_ENTITY in conf:
raise cv.Invalid(
"A linked input follows the state of exactly one Home Assistant entity, so multiple "
"is only for standalone inputs (id)",
path=[CONF_MULTIPLE],
)
if CONF_MAX_LENGTH in conf and not conf[CONF_MULTIPLE]:
raise cv.Invalid(
"max_length only applies to an input with multiple: true",
path=[CONF_MAX_LENGTH],
)
return conf
# An input is either standalone (id declares a new WizardInput) or linked to a homeassistant entity that has no
# entity_id of its own: Home Assistant sets it
WIZARD_INPUT_SCHEMA = cv.All(
@@ -109,6 +134,10 @@ WIZARD_INPUT_SCHEMA = cv.All(
cv.Optional(CONF_ID): cv.declare_id(WizardInput),
cv.Optional(CONF_ENTITY): cv.use_id(cg.EntityBase),
cv.Optional(CONF_DESCRIPTION): _wizard_text(WIZARD_DESCRIPTION_MAX_LENGTH),
cv.Optional(CONF_MULTIPLE, default=False): cv.boolean,
cv.Optional(CONF_MAX_LENGTH): cv.int_range(
min=WIZARD_ENTITY_ID_MAX_LENGTH, max=WIZARD_MULTIPLE_MAX_LENGTH
),
cv.Optional(CONF_TARGET): cv.Schema(
{
cv.Required(CONF_ENTITY): cv.All(
@@ -119,6 +148,7 @@ WIZARD_INPUT_SCHEMA = cv.All(
}
),
cv.has_exactly_one_key(CONF_ID, CONF_ENTITY),
_validate_multiple,
)
WIZARD_PAGE_SCHEMA = cv.All(
@@ -243,6 +273,8 @@ def _wizard_defines(wizard: ConfigType) -> set[str]:
defines.add("USE_API_WIZARD_LINKED_INPUTS")
if any(CONF_ID in conf for conf in inputs):
defines.add("USE_API_WIZARD_STANDALONE_INPUTS")
if any(conf.get(CONF_MULTIPLE) for conf in inputs):
defines.add("USE_API_WIZARD_MULTIPLE_INPUTS")
return defines
@@ -351,14 +383,6 @@ automation.register_apply_condition(
)
def zstd_module() -> ModuleType:
"""The zstd module: the standard library one from Python 3.14, otherwise the backport."""
try:
return importlib.import_module("compression.zstd")
except ImportError:
return importlib.import_module("backports.zstd")
def _entity_document(conf: ConfigType, config: fv.FinalValidateConfig) -> ConfigType:
"""An entity of the device that the page shows, keyed as ListEntitiesResponse keys it."""
_, declaration = _wizard_input_declaration(config, conf[CONF_ID])
@@ -374,6 +398,10 @@ def _input_document(conf: ConfigType, config: fv.FinalValidateConfig) -> ConfigT
document: ConfigType = {"key": fnv1_hash(_wizard_input_id(conf).id)}
if description := conf.get(CONF_DESCRIPTION):
document[CONF_DESCRIPTION] = description
if conf.get(CONF_MULTIPLE):
document["multiple"] = True
if (max_length := conf.get(CONF_MAX_LENGTH)) is not None:
document["max_length"] = max_length
if filters := _wizard_input_filters(conf, config):
document["entity_filters"] = [dict(entity_filter) for entity_filter in filters]
return document
@@ -387,7 +415,7 @@ def wizard_document(wizard: ConfigType, config: fv.FinalValidateConfig) -> Confi
{"version": 1,
"pages": [{"title": "...", "description": "...",
"entities": [{"key": 123, "device_id": 456, "description": "..."}],
"inputs": [{"key": 789, "description": "...",
"inputs": [{"key": 789, "description": "...", "multiple": true, "max_length": 1023,
"entity_filters": [{"integration": "...", "domain": ["..."],
"device_class": ["..."], "supported_features": ["..."]}]}]}]}
@@ -399,6 +427,14 @@ def wizard_document(wizard: ConfigType, config: fv.FinalValidateConfig) -> Confi
the device it belongs to (esphome/core/config.py), and is left out for the main device.
- An input key is the FNV-1 hash of the ESPHome id of the input, or of the linked entity. A linked
entity must not set entity_id, as that is a static entry kept apart from the ones the wizard sets.
- multiple means the user may pick several entities. The client sends them in one
WizardInputSetRequest as a comma separated list ("calendar.a,calendar.b") with no spaces; the
device trims spaces around each id and stores the list without them. The whole request is
ignored when any id is empty or has no '.', or when the list without spaces does not fit
max_length (only on a multiple input, 255 when left out). A single input is ignored when the id
is empty, longer than 255 bytes or has no '.'. The version stays 1: a client that does not know
multiple ignores it, shows a single picker and sends one entity id, which a multiple input
accepts.
- entity_filters are the filters of the input, or the default filters of a linked switch,
number, text, select or button.
"""
@@ -444,14 +480,15 @@ async def to_code(wizard: ConfigType) -> None:
input_id = _wizard_input_id(conf)
# Every buffer starts empty, until the wizard sets it. A linked homeassistant entity uses it as its entity id.
buffer = _wizard_buffer_name(input_id)
cg.add_global(
cg.RawStatement(
f'static char {buffer}[{WIZARD_ENTITY_ID_BUFFER_SIZE}] = "";'
)
)
size = conf.get(CONF_MAX_LENGTH, WIZARD_ENTITY_ID_MAX_LENGTH) + 1
cg.add_global(cg.RawStatement(f'static char {buffer}[{size}] = "";'))
if CONF_ID in conf:
cg.new_Pvariable(input_id, cg.RawExpression(buffer))
entries.append(cg.RawExpression(f"{{{fnv1_hash(input_id.id)}u, {buffer}}}"))
key = fnv1_hash(input_id.id)
if conf[CONF_MULTIPLE]:
entries.append(cg.RawExpression(f"{{{key}u, {buffer}, {size}}}"))
else:
entries.append(cg.RawExpression(f"{{{key}u, {buffer}}}"))
if entries:
cg.extern_progmem_array(
"esphome::api::API_WIZARD_INPUTS",
+1 -1
View File
@@ -391,7 +391,7 @@ async def to_code(config: ConfigType) -> None:
# Adds a define and IDF component for legacy `audio_decoder.cpp`.
if data.flac_support:
cg.add_define("USE_AUDIO_FLAC_SUPPORT")
add_idf_component(name="esphome/micro-flac", ref="0.2.0")
add_idf_component(name="esphome/micro-flac", ref="0.3.0")
_emit_memory_pair(
data.flac.buffer_memory,
"CONFIG_MICRO_FLAC_PREFER_PSRAM",
+16 -4
View File
@@ -19,6 +19,9 @@ CONF_REMOTE_TRANSMISSION_REQUEST = "remote_transmission_request"
CONF_CANBUS_ID = "canbus_id"
CONF_BIT_RATE = "bit_rate"
CONF_ON_FRAME = "on_frame"
CONF_MAX_FRAMES_PER_LOOP = "max_frames_per_loop"
DEFAULT_MAX_FRAMES_PER_LOOP = 50 # Keep in sync with max_frames_per_loop_ in canbus.h
DEFAULT_BIT_RATE = "125KBPS" # Keep in sync with bit_rate_ in canbus.h
def validate_id(config: ConfigType) -> ConfigType:
@@ -86,8 +89,13 @@ CANBUS_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(CanbusComponent),
cv.Required(CONF_CAN_ID): cv.int_range(min=0, max=0x1FFFFFFF),
cv.Optional(CONF_BIT_RATE, default="125KBPS"): cv.enum(CAN_SPEEDS, upper=True),
cv.Optional(CONF_BIT_RATE, default=DEFAULT_BIT_RATE): cv.enum(
CAN_SPEEDS, upper=True
),
cv.Optional(CONF_USE_EXTENDED_ID, default=False): cv.boolean,
cv.Optional(
CONF_MAX_FRAMES_PER_LOOP, default=DEFAULT_MAX_FRAMES_PER_LOOP
): cv.positive_not_null_int,
cv.Optional(CONF_ON_FRAME): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(CanbusTrigger),
@@ -108,9 +116,13 @@ CANBUS_SCHEMA.add_extra(validate_id)
async def setup_canbus_core_(var: MockObj, config: ConfigType) -> None:
await cg.register_component(var, config)
cg.add(var.set_can_id([config[CONF_CAN_ID]]))
cg.add(var.set_use_extended_id([config[CONF_USE_EXTENDED_ID]]))
cg.add(var.set_bitrate(CAN_SPEEDS[config[CONF_BIT_RATE]]))
cg.add(var.set_can_id(config[CONF_CAN_ID]))
if config[CONF_USE_EXTENDED_ID]:
cg.add(var.set_use_extended_id(True))
if (bit_rate := config[CONF_BIT_RATE]) != DEFAULT_BIT_RATE:
cg.add(var.set_bitrate(CAN_SPEEDS[bit_rate]))
if (max_frames := config[CONF_MAX_FRAMES_PER_LOOP]) != DEFAULT_MAX_FRAMES_PER_LOOP:
cg.add(var.set_max_frames_per_loop(max_frames))
for conf in config.get(CONF_ON_FRAME, []):
can_id = conf[CONF_CAN_ID]
+15 -7
View File
@@ -67,16 +67,18 @@ void Canbus::add_trigger(CanbusTrigger *trigger) {
void Canbus::loop() {
struct CanFrame can_message;
// read all messages until queue is empty
int message_counter = 0;
while (this->read_message(&can_message) == canbus::ERROR_OK) {
message_counter++;
uint32_t message_counter = 0;
for (; message_counter < this->max_frames_per_loop_; message_counter++) {
if (this->read_message(&can_message) != canbus::ERROR_OK) {
break;
}
if (can_message.use_extended_id) {
ESP_LOGD(TAG, "received can message (#%d) extended can_id=0x%" PRIx32 " size=%d", message_counter,
ESP_LOGD(TAG, "received can message (#%" PRIu32 ") extended can_id=0x%" PRIx32 " size=%d", message_counter + 1,
can_message.can_id, can_message.can_data_length_code);
} else {
ESP_LOGD(TAG, "received can message (#%d) std can_id=0x%" PRIx32 " size=%d", message_counter, can_message.can_id,
can_message.can_data_length_code);
ESP_LOGD(TAG, "received can message (#%" PRIu32 ") std can_id=0x%" PRIx32 " size=%d", message_counter + 1,
can_message.can_id, can_message.can_data_length_code);
}
std::vector<uint8_t> data;
@@ -100,6 +102,12 @@ void Canbus::loop() {
}
}
}
if (message_counter == this->max_frames_per_loop_) {
ESP_LOGW(TAG,
"Reached max_frames_per_loop=%" PRIu32 ", deferring remaining CAN frames. Some frames might be dropped",
this->max_frames_per_loop_);
}
}
} // namespace esphome::canbus
+4 -2
View File
@@ -78,6 +78,7 @@ class Canbus : public Component {
void set_can_id(uint32_t can_id) { this->can_id_ = can_id; }
void set_use_extended_id(bool use_extended_id) { this->use_extended_id_ = use_extended_id; }
void set_bitrate(CanSpeed bit_rate) { this->bit_rate_ = bit_rate; }
void set_max_frames_per_loop(uint32_t max_frames_per_loop) { this->max_frames_per_loop_ = max_frames_per_loop; }
void add_trigger(CanbusTrigger *trigger);
/**
@@ -96,8 +97,9 @@ class Canbus : public Component {
template<typename... Ts> friend class CanbusSendAction;
std::vector<CanbusTrigger *> triggers_{};
uint32_t can_id_;
bool use_extended_id_;
CanSpeed bit_rate_;
uint32_t max_frames_per_loop_{50}; // Keep in sync with DEFAULT_MAX_FRAMES_PER_LOOP in __init__.py
CanSpeed bit_rate_{CAN_125KBPS}; // Keep in sync with DEFAULT_BIT_RATE in __init__.py
bool use_extended_id_{false};
CallbackManager<void(uint32_t can_id, bool extended_id, bool rtr, const std::vector<uint8_t> &data)>
callback_manager_{};
+7 -1
View File
@@ -253,11 +253,17 @@ void CC1101Component::dump_config() {
}
void CC1101Component::begin_tx() {
// Read the mode before the PKTCTRL0 write below overwrites it in state_
const bool packet_mode = this->state_.PKT_FORMAT == static_cast<uint8_t>(PacketFormat::PACKET_FORMAT_FIFO);
// Ensure Packet Format is 3 (Async Serial)
this->write_(Register::PKTCTRL0, 0x32);
ESP_LOGV(TAG, "Beginning TX sequence");
if (this->gdo0_pin_ != nullptr) {
this->gdo0_pin_->detach_interrupt();
// setup() only attaches the interrupt in packet mode. In async serial mode the pin may be
// shared with remote_receiver, and detaching here would remove the receiver's interrupt.
if (packet_mode) {
this->gdo0_pin_->detach_interrupt();
}
this->gdo0_pin_->pin_mode(gpio::FLAG_OUTPUT);
}
// Transition through IDLE to bypass CCA (Clear Channel Assessment) which can
+7 -25
View File
@@ -1,5 +1,3 @@
import logging
import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
@@ -18,8 +16,6 @@ from esphome.const import (
from esphome.core.entity_helpers import inherit_property_from
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@Cat-Ion", "@kahrendt"]
combination_ns = cg.esphome_ns.namespace("combination")
@@ -75,33 +71,19 @@ KALMAN_SOURCE_SCHEMA = cv.Schema(
)
def _migrate_coeffecient(config: ConfigType) -> ConfigType:
"""Migrate deprecated 'coeffecient' spelling to 'coefficient'."""
if CONF_COEFFECIENT in config:
if CONF_COEFFICIENT in config:
raise cv.Invalid(
f"Cannot specify both '{CONF_COEFFICIENT}' and '{CONF_COEFFECIENT}'"
)
_LOGGER.warning(
"'%s' is deprecated, use '%s' instead. Will be removed in 2026.12.0",
CONF_COEFFECIENT,
CONF_COEFFICIENT,
)
config[CONF_COEFFICIENT] = config.pop(CONF_COEFFECIENT)
elif CONF_COEFFICIENT not in config:
raise cv.Invalid(f"'{CONF_COEFFICIENT}' is a required option")
return config
LINEAR_SOURCE_SCHEMA = cv.All(
cv.rename_key(
CONF_COEFFECIENT,
CONF_COEFFICIENT,
removed_in="2026.12.0",
component="combination",
),
cv.Schema(
{
cv.Required(CONF_SOURCE): cv.use_id(sensor.Sensor),
cv.Optional(CONF_COEFFICIENT): cv.templatable(cv.float_),
cv.Optional(CONF_COEFFECIENT): cv.templatable(cv.float_),
cv.Required(CONF_COEFFICIENT): cv.templatable(cv.float_),
}
),
_migrate_coeffecient,
)
SENSOR_ONLY_SOURCE_SCHEMA = cv.Schema(
+9
View File
@@ -2925,6 +2925,11 @@ async def to_code(config):
# produce reproducible outputs and downstream tooling can reuse artifacts.
add_idf_sdkconfig_option("CONFIG_APP_REPRODUCIBLE_BUILD", True)
# Static destructors never run, so skip registering them. See atexit_stubs.cpp.
# --undefined: libsrc.a is scanned before the IDF libraries that also register them.
cg.add_build_flag("-Wl,--wrap=__cxa_atexit")
cg.add_build_flag("-Wl,--undefined=__wrap___cxa_atexit")
if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF:
cg.add_build_flag("-DUSE_ESP_IDF")
cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ESP_IDF")
@@ -3074,6 +3079,10 @@ async def to_code(config):
# Increase freertos tick speed from 100Hz to 1kHz so that delay() resolution is 1ms
add_idf_sdkconfig_option("CONFIG_FREERTOS_HZ", 1000)
# Main loop wakes use notification index 1; index 0 stays free for ESP-IDF waits
# such as pthread_join(), which a stray main-loop wake would otherwise end early.
add_idf_sdkconfig_option("CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES", 2)
# Place non-ISR FreeRTOS functions into flash instead of IRAM
# This saves up to 8KB of IRAM. ISR-safe functions (FromISR variants) stay in IRAM.
# In ESP-IDF 6.0 this becomes the default and CONFIG_FREERTOS_PLACE_FUNCTIONS_INTO_FLASH
+20
View File
@@ -0,0 +1,20 @@
/*
* Linker wrap stub for __cxa_atexit().
*
* GCC registers the destructor of every global and function local static
* object with __cxa_atexit(). The firmware never returns from main or calls
* exit(), so these destructors never run, but the first registration links
* newlib's exit handler tables (about 400 bytes of RAM on ESP32).
*/
#include "esphome/core/defines.h"
#ifdef USE_ESP32
namespace esphome::esp32 {}
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
extern "C" int __wrap___cxa_atexit(void (*)(void *), void *, void *) { return 0; }
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
#endif // USE_ESP32
@@ -531,6 +531,9 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) {
// Xtensa: walk the backtrace using the public API
if (info->frame != nullptr) {
auto *xt_frame = (XtExcFrame *) info->frame;
// IDF leaves addr unset for SoC-level panics (cache error, interrupt WDT)
if (info->pseudo_excause)
s_raw_crash_data.pc = xt_frame->pc;
if (!g_panic_abort) {
// Abort-class frames carry no useful cause/vaddr: TWDT task snapshots
// never wrote them and abort() traps describe only the synthetic trap.
@@ -150,6 +150,8 @@ void BLEClientBase::connect() {
// A registration whose event never arrived must not block this connection's release.
this->services_released_ = false;
this->pending_notify_regs_ = 0;
// A CONNECT_EVT seen while idle can leave the id of a link this attempt does not own.
this->conn_id_ = UNSET_CONN_ID;
// Enable loop for state processing
this->enable_loop();
// Immediately transition to CONNECTING to prevent duplicate connection attempts
@@ -350,7 +352,6 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
if (!this->check_addr(param->open.remote_bda))
return false;
this->log_gattc_lifecycle_event_("OPEN");
// conn_id was already set in ESP_GATTC_CONNECT_EVT
this->service_count_ = 0;
// ESP-IDF's BLE stack may send ESP_GATTC_OPEN_EVT after esp_ble_gattc_open() returns an
@@ -362,19 +363,24 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
break;
}
if (this->state() != espbt::ClientState::CONNECTING) {
// This should not happen but lets log it in case it does
// because it means we have a bad assumption about how the
// ESP BT stack works.
const bool open_failed = param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN;
// Bluedroid reports a failed open as DISCONNECT_EVT then a failing OPEN_EVT, with no CONNECT_EVT,
// so DISCONNECTING with no id is expected for a failure. Anything else means a bad assumption
// about how the ESP BT stack works, so log it.
const bool expected_failure =
open_failed && this->state() == espbt::ClientState::DISCONNECTING && this->conn_id_ == UNSET_CONN_ID;
if (this->state() != espbt::ClientState::CONNECTING && !expected_failure) {
ESP_LOGE(TAG, "[%d] [%s] ESP_GATTC_OPEN_EVT in %s state (status=%d)", this->connection_index_,
this->address_str_, espbt::client_state_to_string(this->state()), param->open.status);
}
if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) {
if (open_failed) {
this->log_gattc_warning_("Connection open", param->open.status);
// Connection was never established so CLOSE_EVT may not follow
this->set_idle_();
break;
}
// An open on a link that is already up sends no CONNECT_EVT, so take the id here.
this->conn_id_ = param->open.conn_id;
if (this->want_disconnect_) {
// Disconnect was requested after connecting started,
// but before the connection was established. Now that we have
+34 -6
View File
@@ -53,6 +53,7 @@ from .const import (
KEY_SERIAL1_REQUIRED,
KEY_SERIAL_REQUIRED,
KEY_WAVEFORM_REQUIRED,
THROW_STUBS_HEADER,
enable_serial,
enable_serial1,
esp8266_ns,
@@ -114,9 +115,37 @@ _validate_toolchain = cv.toolchain_enum(_TOOLCHAINS)
_resolve_toolchain = cv.resolve_toolchain("ESP8266", _TOOLCHAINS, Toolchain.ARDUINO)
# Known boards keyed by spelling with case and "-" / "_" folded away
_FOLDED_BOARDS = {board.lower().replace("-", "_"): board for board in BOARDS}
def _resolve_board(config: ConfigType) -> ConfigType:
"""Fix a misspelled board, else build an unknown one with PlatformIO."""
board = config[CONF_BOARD]
if board in BOARDS:
return config
if canonical := _FOLDED_BOARDS.get(board.lower().replace("-", "_")):
_LOGGER.warning(
"Board '%s' is not a known ESP8266 board; using '%s'", board, canonical
)
return {**config, CONF_BOARD: canonical}
if CORE.toolchain is None and CONF_TOOLCHAIN not in config:
return {**config, CONF_TOOLCHAIN: Toolchain.PLATFORMIO}
return config
def _warn_platformio_toolchain(config: ConfigType) -> ConfigType:
# Remove before 2027.4.0
if CORE.using_toolchain_platformio:
if not CORE.using_toolchain_platformio:
return config
if config[CONF_BOARD] not in BOARDS:
_LOGGER.warning(
"Board '%s' is not supported by the native 'arduino' toolchain, so it "
"builds with the deprecated 'platformio' toolchain, which will be "
"removed in ESPHome 2027.4.0",
config[CONF_BOARD],
)
else:
_LOGGER.warning(
"The 'platformio' toolchain for ESP8266 is deprecated and will be "
"removed in ESPHome 2027.4.0; the native 'arduino' toolchain is the "
@@ -329,6 +358,7 @@ CONFIG_SCHEMA = cv.All(
): _validate_toolchain,
}
),
_resolve_board,
_resolve_toolchain,
_warn_platformio_toolchain,
_validate_native_toolchain,
@@ -448,11 +478,9 @@ async def to_code(config: ConfigType) -> None:
# Force-include inline std::__throw_* overrides so GCC dead-strips the unused
# libstdc++ error message strings (e.g. "basic_string::_M_create") from DRAM.
# See throw_stubs.h. Unconditional: the native build generator reads
# the same option, keeping one source of truth.
cg.add_platformio_option(
"build_src_flags", "-include esphome/components/esp8266/throw_stubs.h"
)
# See throw_stubs.h. The native build generator reads this option, also
# passes it to the core and libraries, and drops it with exceptions on.
cg.add_platformio_option("build_src_flags", f"-include {THROW_STUBS_HEADER}")
# In testing mode, fake larger memory to allow linking grouped component tests
# Real ESP8266 hardware only has 32KB IRAM and ~80KB RAM, but for CI testing
+2
View File
@@ -17,6 +17,8 @@ KEY_SERIAL_REQUIRED = "serial_required"
KEY_SERIAL1_REQUIRED = "serial1_required"
# Set for the native (non-PlatformIO) toolchain's build generator
KEY_SCANF_FLOAT = "scanf_float"
# Force-included std::__throw_* overrides, relative to the src dir
THROW_STUBS_HEADER = "esphome/components/esp8266/throw_stubs.h"
# Per-board flash-layout override consumed by board_ld_script()
KEY_LDSCRIPT = "ldscript"
# Crash-decode tools, resolved once per run by __init__
+2 -1
View File
@@ -15,7 +15,8 @@
* unused, dead-strip the load, and drop the string entirely -- no LTO needed.
* Behavior is identical to today: a bare abort() (the message was never
* printed). This header MUST be force-included before <string>, so it is
* wired up via build_src_flags "-include ..." in this component's __init__.py.
* wired up via build_src_flags "-include ..." in this component's __init__.py;
* the native toolchain also force-includes it into the core and libraries.
*
* Note: this defines functions in namespace std (technically UB). It is safe
* here because the definitions match the existing abort() behavior exactly.
-2
View File
@@ -37,7 +37,6 @@ void GreeClimate::transmit_state() {
remote_state[1] = this->temperature_();
if (model == GREE_YAN) {
remote_state[2] = 0x20; // bits 0..3 always 0000, bits 4..7 TURBO, LIGHT, HEALTH, X-FAN
remote_state[3] = 0x50; // bits 4..7 always 0101
remote_state[4] = this->vertical_swing_();
}
@@ -61,7 +60,6 @@ void GreeClimate::transmit_state() {
}
if (model == GREE_YAA || model == GREE_YAC || model == GREE_YAC1FB9) {
remote_state[2] = 0x20; // bits 0..3 always 0000, bits 4..7 TURBO, LIGHT, HEALTH, X-FAN
remote_state[3] = 0x50; // bits 4..7 always 0101
remote_state[6] = 0x20; // YAA1FB, FAA1FB1, YB1F2 bits 4..7 always 0010
+7 -1
View File
@@ -76,6 +76,12 @@ static constexpr uint8_t GREE_PRESET_NONE = 0x00;
static constexpr uint8_t GREE_PRESET_SLEEP = 0x01;
static constexpr uint8_t GREE_PRESET_SLEEP_BIT = 0x80;
// Mode bits stored in remote_state[2] bits 4..7 for YAN/YAA/YAC/YAC1FB9.
static constexpr uint8_t GREE_MODE_BIT_TURBO = 0x10;
static constexpr uint8_t GREE_MODE_BIT_LIGHT = 0x20;
static constexpr uint8_t GREE_MODE_BIT_HEALTH = 0x40;
static constexpr uint8_t GREE_MODE_BIT_XFAN = 0x80;
// Model codes
enum Model : uint8_t { GREE_GENERIC, GREE_YAN, GREE_YAA, GREE_YAC, GREE_YAC1FB9, GREE_YX1FF, GREE_YAG };
@@ -103,7 +109,7 @@ class GreeClimate final : public climate_ir::ClimateIR {
uint8_t preset_();
Model model_{};
uint8_t mode_bits_{0}; // Combined mode bits for remote_state[2]
uint8_t mode_bits_{GREE_MODE_BIT_LIGHT}; // The display light is on until a switch says otherwise
};
} // namespace esphome::gree
+19 -6
View File
@@ -17,12 +17,22 @@ CONF_HEALTH = "health"
CONF_XFAN = "xfan"
CONF_GREE_ID = "gree_id"
# Switch configurations: (config_key, display_name, bit_mask, icon)
# Switch configurations: (config_key, display_name, mode_bit, icon)
SWITCH_CONFIGS = (
(CONF_TURBO, "Gree Turbo Switch", 0x10, "mdi:car-turbocharger"),
(CONF_LIGHT, "Gree Light Switch", 0x20, "mdi:led-outline"),
(CONF_HEALTH, "Gree Health Switch", 0x40, "mdi:pine-tree"),
(CONF_XFAN, "Gree X-FAN Switch", 0x80, "mdi:wall-sconce-flat"),
(
CONF_TURBO,
"Gree Turbo Switch",
gree_ns.GREE_MODE_BIT_TURBO,
"mdi:car-turbocharger",
),
(CONF_LIGHT, "Gree Light Switch", gree_ns.GREE_MODE_BIT_LIGHT, "mdi:led-outline"),
(CONF_HEALTH, "Gree Health Switch", gree_ns.GREE_MODE_BIT_HEALTH, "mdi:pine-tree"),
(
CONF_XFAN,
"Gree X-FAN Switch",
gree_ns.GREE_MODE_BIT_XFAN,
"mdi:wall-sconce-flat",
),
)
SUPPORTED_MODELS = {
@@ -39,7 +49,10 @@ CONFIG_SCHEMA = cv.Schema(
cv.Optional(key): switch.switch_schema(
GreeModeBitSwitch,
icon=icon,
default_restore_mode="RESTORE_DEFAULT_OFF",
# The display light was on before the switches existed, so it stays on by default
default_restore_mode="RESTORE_DEFAULT_ON"
if key == CONF_LIGHT
else "RESTORE_DEFAULT_OFF",
device_class=DEVICE_CLASS_SWITCH,
entity_category=ENTITY_CATEGORY_CONFIG,
)
+6 -3
View File
@@ -187,15 +187,18 @@ void HeatpumpIRClimate::transmit_state() {
swing_h_cmd = HDIR_SWING;
}
// MitsubishiMSCHeatpumpIR only knows FAN_1..FAN_3 and sends anything else as auto,
// so low/medium/high have to start at FAN_1 there instead of FAN_2.
const bool three_speed_fan = this->protocol_ == PROTOCOL_MITSUBISHI_MSC;
switch (this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO)) {
case climate::CLIMATE_FAN_LOW:
fan_speed_cmd = FAN_2;
fan_speed_cmd = three_speed_fan ? FAN_1 : FAN_2;
break;
case climate::CLIMATE_FAN_MEDIUM:
fan_speed_cmd = FAN_3;
fan_speed_cmd = three_speed_fan ? FAN_2 : FAN_3;
break;
case climate::CLIMATE_FAN_HIGH:
fan_speed_cmd = FAN_4;
fan_speed_cmd = three_speed_fan ? FAN_3 : FAN_4;
break;
case climate::CLIMATE_FAN_AUTO:
default:
@@ -18,6 +18,8 @@ namespace esphome::improv_serial {
static const char *const TAG = "improv_serial";
static constexpr uint32_t WIFI_CONNECT_TIMEOUT_ID = 0;
void ImprovSerialComponent::setup() {
global_improv_serial_component = this;
#ifdef USE_IMPROV_SERIAL_UART
@@ -73,7 +75,7 @@ void ImprovSerialComponent::loop() {
wifi::global_wifi_component->save_wifi_sta(this->connecting_sta_.get_ssid(),
this->connecting_sta_.get_password());
this->connecting_sta_ = {};
this->cancel_timeout("wifi-connect-timeout");
this->cancel_timeout(WIFI_CONNECT_TIMEOUT_ID);
this->set_state_(improv::STATE_PROVISIONED);
this->send_settings_response_(improv::WIFI_SETTINGS);
@@ -302,7 +304,7 @@ bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command
ESP_LOGD(TAG, "Received settings: SSID=%s, password=" LOG_SECRET("%s"), command.ssid.c_str(),
command.password.c_str());
this->set_timeout("wifi-connect-timeout", switching ? WIFI_SWITCH_TIMEOUT_MS : WIFI_CONNECT_TIMEOUT_MS,
this->set_timeout(WIFI_CONNECT_TIMEOUT_ID, switching ? WIFI_SWITCH_TIMEOUT_MS : WIFI_CONNECT_TIMEOUT_MS,
[this]() { this->on_wifi_connect_timeout_(); });
#else
// No Wi-Fi support compiled in; there is nothing to provision.
+3
View File
@@ -491,6 +491,9 @@ async def component_to_code(config):
# Not enabled on RTL87xx/LN882x — costs more heap than it saves there.
cg.add_build_flag("-DconfigSUPPORT_STATIC_ALLOCATION=1")
# Static destructors never run, so skip registering them. See atexit_stubs.cpp.
cg.add_build_flag("-Wl,--wrap=__cxa_atexit")
# LN882x: a zero-size allocation must not trip the SDK's assert (see ln882x_zero_malloc.c).
if config[CONF_COMPONENT_ID] == COMPONENT_LN882X:
cg.add_build_flag("-Wl,--wrap=pvPortMalloc")
@@ -0,0 +1,20 @@
/*
* Linker wrap stub for __cxa_atexit().
*
* GCC registers the destructor of every global and function local static
* object with __cxa_atexit(). The firmware never returns from main or calls
* exit(), so these destructors never run, but the first registration links
* newlib's exit handler tables.
*/
#include "esphome/core/defines.h"
#ifdef USE_LIBRETINY
namespace esphome::libretiny {}
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
extern "C" int __wrap___cxa_atexit(void (*)(void *), void *, void *) { return 0; }
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
#endif // USE_LIBRETINY
@@ -4,6 +4,8 @@
namespace esphome::mcp23xxx_base {
void IRAM_ATTR mcp23xxx_gpio_intr(Component *arg) { arg->enable_loop_soon_any_context(); }
template<uint8_t N> void MCP23XXXGPIOPin<N>::setup() {
this->pin_mode(flags_);
// When interrupt_pin is configured, pin_mode() already auto-enables CHANGE
@@ -8,6 +8,10 @@ namespace esphome::mcp23xxx_base {
enum MCP23XXXInterruptMode : uint8_t { MCP23XXX_NO_INTERRUPT = 0, MCP23XXX_CHANGE, MCP23XXX_RISING, MCP23XXX_FALLING };
// Not a template member: GCC 10 (ESP8266) ignores section attributes on template instantiations, leaving the
// ISR in flash.
void IRAM_ATTR mcp23xxx_gpio_intr(Component *arg);
template<uint8_t N> class MCP23XXXBase : public Component, public gpio_expander::CachedGpioExpander<uint8_t, N> {
public:
virtual void pin_mode(uint8_t pin, gpio::Flags flags);
@@ -35,7 +39,8 @@ template<uint8_t N> class MCP23XXXBase : public Component, public gpio_expander:
void setup_interrupt_pin_() {
if (this->interrupt_pin_ != nullptr) {
this->interrupt_pin_->setup();
this->interrupt_pin_->attach_interrupt(&MCP23XXXBase::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE);
this->interrupt_pin_->attach_interrupt(&mcp23xxx_gpio_intr, static_cast<Component *>(this),
gpio::INTERRUPT_FALLING_EDGE);
this->set_invalidate_on_read_(false);
}
// Disable loop until an input pin is configured via pin_mode()
@@ -43,7 +48,6 @@ template<uint8_t N> class MCP23XXXBase : public Component, public gpio_expander:
// For polling mode, loop is re-enabled when pin_mode() registers an input pin
this->disable_loop();
}
static void IRAM_ATTR gpio_intr(MCP23XXXBase *arg) { arg->enable_loop_soon_any_context(); }
// read a given register
virtual bool read_reg(uint8_t reg, uint8_t *value) = 0;
+1 -1
View File
@@ -235,7 +235,7 @@ async def to_code(config: ConfigType) -> None:
ethernet.request_ethernet_ip_state_listener()
if CORE.is_esp32:
add_idf_component(name="espressif/mdns", ref="1.12.0")
add_idf_component(name="espressif/mdns", ref="1.14.0")
# ESPHome only advertises; the browse APIs are unused
add_idf_sdkconfig_option("CONFIG_MDNS_ENABLE_BROWSE", False)
# The mdns console CLI is never used by ESPHome
+2
View File
@@ -273,6 +273,8 @@ class MQTTComponent : public Component {
void subscribe_json(const std::string &topic, const mqtt_json_callback_t &callback, uint8_t qos = 0);
protected:
static constexpr uint32_t SEND_DEFER_ID = 0;
/// Helper method to get the discovery topic for this component into a buffer.
StringRef get_discovery_topic_to_(std::span<char, MQTT_DISCOVERY_TOPIC_MAX_LEN> buf,
const MQTTDiscoveryInfo &discovery_info) const;
+1 -1
View File
@@ -121,7 +121,7 @@ void MQTTFanComponent::setup() {
});
}
this->state_->add_on_state_callback([this]() { this->defer("send", [this]() { this->publish_state(); }); });
this->state_->add_on_state_callback([this]() { this->defer(SEND_DEFER_ID, [this]() { this->publish_state(); }); });
}
void MQTTFanComponent::dump_config() {
+1 -1
View File
@@ -28,7 +28,7 @@ void MQTTJSONLightComponent::setup() {
}
void MQTTJSONLightComponent::on_light_remote_values_update() {
this->defer("send", [this]() { this->publish_state_(); });
this->defer(SEND_DEFER_ID, [this]() { this->publish_state_(); });
}
MQTTJSONLightComponent::MQTTJSONLightComponent(LightState *state) : state_(state) {}
+1 -1
View File
@@ -29,7 +29,7 @@ void MQTTLockComponent::setup() {
}
});
this->lock_->add_on_state_callback(
[this](LockState /*state*/) { this->defer("send", [this]() { this->publish_state(); }); });
[this](LockState /*state*/) { this->defer(SEND_DEFER_ID, [this]() { this->publish_state(); }); });
}
void MQTTLockComponent::dump_config() {
ESP_LOGCONFIG(TAG, "MQTT Lock '%s': ", this->lock_->get_name().c_str());
+1 -1
View File
@@ -34,7 +34,7 @@ void MQTTSwitchComponent::setup() {
}
});
this->switch_->add_on_state_callback(
[this](bool enabled) { this->defer("send", [this, enabled]() { this->publish_state(enabled); }); });
[this](bool enabled) { this->defer(SEND_DEFER_ID, [this, enabled]() { this->publish_state(enabled); }); });
}
void MQTTSwitchComponent::dump_config() {
ESP_LOGCONFIG(TAG, "MQTT Switch '%s': ", this->switch_->get_name().c_str());
+1 -1
View File
@@ -24,7 +24,7 @@ void MQTTUpdateComponent::setup() {
}
});
this->update_->add_on_state_callback([this]() { this->defer("send", [this]() { this->publish_state(); }); });
this->update_->add_on_state_callback([this]() { this->defer(SEND_DEFER_ID, [this]() { this->publish_state(); }); });
}
bool MQTTUpdateComponent::publish_state() {
+2 -2
View File
@@ -13,8 +13,8 @@ CODEOWNERS = ["@esphome/core"]
# Keep in sync with platformio.ini and esphome/idf_component.yml.
# LIBSODIUM_VERSION must match the version noise-c pins in its manifests.
NOISE_C_VERSION = "0.1.30"
LIBSODIUM_VERSION = "1.10021.11"
NOISE_C_VERSION = "0.1.31"
LIBSODIUM_VERSION = "1.10021.12"
noise_ns = cg.esphome_ns.namespace("noise")
+46 -8
View File
@@ -12,6 +12,7 @@ from esphome.build_helpers import pch
from esphome.build_helpers.ccache import resolve_ccache_path
import esphome.codegen as cg
from esphome.components.zephyr import (
Section,
add_extra_script,
copy_files as zephyr_copy_files,
zephyr_add_overlay,
@@ -125,11 +126,37 @@ def set_core_data(config: ConfigType) -> ConfigType:
CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = KEY_ZEPHYR
if config[KEY_BOOTLOADER] in BOOTLOADER_CONFIG:
zephyr_add_pm_static(BOOTLOADER_CONFIG[config[KEY_BOOTLOADER]])
sections = BOOTLOADER_CONFIG[config[KEY_BOOTLOADER]]
if CORE.testing_mode:
sections = _testing_mode_sections(sections)
zephyr_add_pm_static(sections)
return config
# In testing mode, fake a larger flash to allow linking grouped component
# tests. The nRF52840 has 1 MB and a mcumgr OTA config halves the app slot,
# which an openthread config alone fills; CI images are never flashed.
NRF52840_FLASH_SIZE = 0x100000
TESTING_FLASH_SIZE = 0x400000
def _testing_mode_sections(sections: list[Section]) -> list[Section]:
"""Move a bootloader pinned to the end of the real flash to the end of the
faked one, so the partition manager still sees a single gap for the app."""
return [
Section(
section.name,
section.address + TESTING_FLASH_SIZE - NRF52840_FLASH_SIZE,
section.size,
section.region,
)
if section.end_address == NRF52840_FLASH_SIZE
else section
for section in sections
]
_TOOLCHAINS = (Toolchain.PLATFORMIO, Toolchain.SDK_NRF)
_resolve_toolchain = cv.resolve_toolchain("nRF52", _TOOLCHAINS, Toolchain.SDK_NRF)
@@ -380,6 +407,15 @@ async def to_code(config: ConfigType) -> None:
zephyr_setup_preferences()
zephyr_to_code(config)
if CORE.testing_mode:
zephyr_add_overlay(
f"""
&flash0 {{
reg = <0x0 {TESTING_FLASH_SIZE:#x}>;
}};
"""
)
if dfu_config := config.get(CONF_DFU):
CORE.add_job(_dfu_to_code, dfu_config)
framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
@@ -695,13 +731,13 @@ def upload_program(config: ConfigType, args, host: str) -> bool:
return True # Handled: PYOCD upload
# Deferred imports: bleak/smpclient are heavy, only load for BLE/mcumgr paths
from .ble_logger import is_mac_address
from .ble_logger import is_ble_address
from .ota import smpmgr_scan, smpmgr_upload
if host == "BLE":
mcumgr_device = asyncio.run(smpmgr_scan(CORE.name))
if is_mac_address(host):
if is_ble_address(host):
mcumgr_device = host
if mcumgr_device:
@@ -716,7 +752,7 @@ def upload_program(config: ConfigType, args, host: str) -> bool:
def show_logs(config: ConfigType, args, devices: list[str]) -> bool:
address = devices[0]
from .ble_logger import is_mac_address, logger_connect, logger_scan
from .ble_logger import is_ble_address, logger_connect, logger_scan
if devices[0] == "BLE":
ble_device = asyncio.run(logger_scan(CORE.name))
@@ -725,7 +761,7 @@ def show_logs(config: ConfigType, args, devices: list[str]) -> bool:
else:
return True
if is_mac_address(address):
if is_ble_address(address):
asyncio.run(logger_connect(address))
return True
return False
@@ -798,7 +834,7 @@ _PCH_CMAKE_LINES = [
"set(esphome_kept_options)",
"set(esphome_pch_headers)",
"foreach(option IN LISTS esphome_options)",
' if(option MATCHES "imacros> (.+)$")',
' if(option MATCHES "imacros> ([^>]+)")',
' list(APPEND esphome_pch_headers "${CMAKE_MATCH_1}")',
" list(APPEND esphome_kept_options",
' "$<$<NOT:$<AND:$<COMPILE_LANGUAGE:CXX>,$<STREQUAL:$<TARGET_PROPERTY:NAME>,app>>>:${option}>")',
@@ -1003,11 +1039,13 @@ def run_compile(args, config: ConfigType) -> bool:
framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
bootloader = zephyr_data()[KEY_BOOTLOADER]
# (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes
# (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes.
# The bootloader resets on a package whose sd_req does not list its SoftDevice,
# so v7 accepts S140 7.0.1, 7.2.0 and 7.3.0 (e.g. Seeed XIAO nRF52840).
_GENPKG_PARAMS = {
BOOTLOADER_ADAFRUIT_NRF52_SD132: ("0x0051", "0x009D"),
BOOTLOADER_ADAFRUIT_NRF52_SD140_V6: ("0x0052", "0x00B6"),
BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: ("0x0052", "0x00CA"),
BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: ("0x0052", "0x00CA,0x0100,0x0123"),
}
# UF2 family IDs — nRF52832 vs nRF52840 per SoftDevice variant
_UF2_FAMILY_IDS = {
+20 -3
View File
@@ -4,6 +4,7 @@ import re
from typing import Final
from bleak import BleakClient, BleakScanner, BLEDevice
from bleak.backends.scanner import AdvertisementData
from bleak.exc import (
BleakCharacteristicNotFoundError,
BleakDBusError,
@@ -19,15 +20,31 @@ NUS_TX_CHAR_UUID = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E"
MAC_ADDRESS_PATTERN: Final = re.compile(
r"([0-9A-F]{2}[:]){5}[0-9A-F]{2}$", flags=re.IGNORECASE
)
# macOS identifies BLE peripherals by a CoreBluetooth UUID instead of a MAC address
UUID_PATTERN: Final = re.compile(
r"[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$",
flags=re.IGNORECASE,
)
def is_mac_address(value: str) -> bool:
return MAC_ADDRESS_PATTERN.match(value)
def is_ble_address(value: str) -> bool:
return bool(MAC_ADDRESS_PATTERN.match(value) or UUID_PATTERN.match(value))
def _name_matches(device: BLEDevice, adv: AdvertisementData, name: str) -> bool:
# device.name can be a stale cached name on macOS; adv.local_name is what is on the air now
return name in (device.name, adv.local_name)
async def find_device_by_name(name: str, timeout: float = 10.0) -> BLEDevice | None:
return await BleakScanner.find_device_by_filter(
lambda device, adv: _name_matches(device, adv, name), timeout=timeout
)
async def logger_scan(name: str) -> BLEDevice | None:
_LOGGER.info("Scanning bluetooth for %s...", name)
device = await BleakScanner.find_device_by_name(name)
device = await find_device_by_name(name)
if not device:
_LOGGER.error("%s Bluetooth LE device was not found!", name)
return device
+10 -11
View File
@@ -4,7 +4,6 @@ import json
import logging
from pathlib import Path
from bleak import BleakScanner
from bleak.exc import BleakDBusError, BleakDeviceNotFoundError
from smp.exceptions import SMPBadStartDelimiter
from smpclient import SMPClient
@@ -23,9 +22,8 @@ from smpclient.transport.serial import SMPSerialTransport
from esphome.core import EsphomeError
from esphome.espota2 import ProgressBar
from .ble_logger import is_mac_address
from .ble_logger import find_device_by_name, is_ble_address
SMP_SERVICE_UUID = "8D53DC1D-1DB7-4CD3-868B-8A527460AA84"
BLE_SCAN_TIMEOUT = 10.0 # seconds
RESET_DELAY = 2.0 # seconds to wait before reset, allows on_end action to execute
@@ -45,12 +43,12 @@ def _json_state(o: object) -> object:
async def smpmgr_scan(name: str) -> str:
_LOGGER.info("Scanning bluetooth for %s...", name)
for device in await BleakScanner.discover(
timeout=BLE_SCAN_TIMEOUT, service_uuids=[SMP_SERVICE_UUID]
):
if device.name == name:
return device.address
raise EsphomeError(f"BLE device {name} with OTA service not found")
# No service filter: the SMP UUID only fits in the scan response, which macOS does not filter on.
# The SMP service is checked on connect.
device = await find_device_by_name(name, timeout=BLE_SCAN_TIMEOUT)
if device is None:
raise EsphomeError(f"BLE device {name} not found")
return device.address
async def smpmgr_upload(device: str, firmware: Path) -> None:
@@ -88,7 +86,7 @@ def _get_image_tlv_sha256(file: Path) -> bytes:
async def _smpmgr_upload(device: str, firmware: Path) -> None:
image_tlv_sha256 = _get_image_tlv_sha256(firmware)
if is_mac_address(device):
if is_ble_address(device):
smp_client = SMPClient(SMPBLETransport(), device)
else:
smp_client = SMPClient(SMPSerialTransport(), device)
@@ -105,7 +103,8 @@ async def _smpmgr_upload(device: str, firmware: Path) -> None:
) from exc
raise EsphomeError(f"BLE error connecting to {device}: {exc}") from exc
except SMPBLETransportException as exc:
raise EsphomeError(f"Connection error with {device}") from exc
# Reached when a device with the right name has no SMP service, among other causes
raise EsphomeError(f"Connection error with {device}: {exc}") from exc
_LOGGER.info("Connected %s...", device)
try:
+2
View File
@@ -194,6 +194,8 @@ def _parse_platform_version(value: Any) -> str:
# * The new version needs to be thoroughly validated before changing the
# recommended version as otherwise a bunch of devices could be bricked
# * For all constants below, update platformio.ini (in this repo)
# * rp2_pio pins its own pioasm build (PIOASM_REPO_VERSION in rp2_pio/__init__.py); check
# https://github.com/earlephilhower/pico-quick-toolchain/releases for a matching release
# The default/recommended arduino framework version
# - https://github.com/earlephilhower/arduino-pico/releases
+4 -4
View File
@@ -7,9 +7,9 @@ from esphome.types import ConfigType
DEPENDENCIES = ["rp2"]
PIOASM_REPO_VERSION = "1.5.0-b"
PIOASM_REPO_VERSION = "4.1.0"
PIOASM_REPO_BASE = f"https://github.com/earlephilhower/pico-quick-toolchain/releases/download/{PIOASM_REPO_VERSION}"
PIOASM_VERSION = "pioasm-2e6142b.230216"
PIOASM_VERSION = "pioasm-9fdfe11.250530"
PIOASM_DOWNLOADS = {
"linux": {
"aarch64": f"aarch64-linux-gnu.{PIOASM_VERSION}.tar.gz",
@@ -20,8 +20,8 @@ PIOASM_DOWNLOADS = {
"amd64": f"x86_64-w64-mingw32.{PIOASM_VERSION}.zip",
},
"darwin": {
"x86_64": f"x86_64-apple-darwin14.{PIOASM_VERSION}.tar.gz",
"arm64": f"x86_64-apple-darwin14.{PIOASM_VERSION}.tar.gz",
"x86_64": f"x86_64-apple-darwin20.4.{PIOASM_VERSION}.tar.gz",
"arm64": f"aarch64-apple-darwin20.4.{PIOASM_VERSION}.tar.gz",
},
}
+8 -8
View File
@@ -295,8 +295,11 @@ template<class C, typename... Ts> class ScriptWaitAction final : public Action<T
}
void loop() override {
if (this->num_running_ == 0)
if (this->num_running_ == 0) {
// Nothing left to wait for, e.g. once the last queued item has run
this->disable_loop();
return;
}
if (this->script_->is_running())
return;
@@ -305,9 +308,10 @@ template<class C, typename... Ts> class ScriptWaitAction final : public Action<T
// Processing all items in a while loop causes infinite loops because
// play_next_() can trigger more items to be queued
if (!this->param_queue_.empty()) {
auto &params = this->param_queue_.front();
this->play_next_tuple_(params, std::make_index_sequence<sizeof...(Ts)>{});
// Take the item out first: the chain may call stop(), which clears the queue
auto params = std::move(this->param_queue_.front());
this->param_queue_.pop_front();
std::apply([this](auto &...args) { this->play_next_(args...); }, params);
} else {
// Queue is now empty - disable loop until next play_complex
this->disable_loop();
@@ -323,12 +327,8 @@ template<class C, typename... Ts> class ScriptWaitAction final : public Action<T
}
protected:
template<size_t... S> void play_next_tuple_(const std::tuple<Ts...> &tuple, std::index_sequence<S...> /*unused*/) {
this->play_next_(std::get<S>(tuple)...);
}
C *script_;
std::list<std::tuple<Ts...>> param_queue_;
std::list<std::tuple<stored_trigger_arg_t<Ts>...>> param_queue_;
};
} // namespace esphome::script
@@ -9,6 +9,8 @@ namespace esphome::seeed_mr24hpc1 {
static const char *const TAG = "seeed_mr24hpc1";
static constexpr uint32_t STARTUP_DELAY_MS = 1000;
// Prints the component's configuration data. dump_config() prints all of the component's configuration
// items in an easy-to-read format, including the configuration key-value pairs.
void MR24HPC1Component::dump_config() {
@@ -76,8 +78,7 @@ void MR24HPC1Component::setup() {
this->custom_mode_end_text_sensor_->publish_state("Not in custom mode");
}
#endif
this->set_custom_end_mode();
this->poll_time_base_func_check_ = true;
this->poll_time_base_func_check_ = false;
this->check_dev_inf_sign_ = true;
this->sg_start_query_data_ = STANDARD_FUNCTION_QUERY_PRODUCT_MODE;
this->sg_data_len_ = 0;
@@ -92,7 +93,13 @@ void MR24HPC1Component::setup() {
memset(this->sg_frame_prase_buf_, 0, FRAME_BUF_MAX_SIZE);
memset(this->sg_frame_buf_, 0, FRAME_BUF_MAX_SIZE);
this->set_interval(8000, [this]() { this->update_(); });
// The module locks up when queried during its own boot, so hold all TX for a second
this->set_timeout(STARTUP_DELAY_MS, [this]() {
this->module_ready_ = true;
this->set_custom_end_mode();
this->poll_time_base_func_check_ = true;
this->set_interval(8000, [this]() { this->update_(); });
});
}
// Timed polling of radar data
@@ -686,6 +693,8 @@ void MR24HPC1Component::r24_frame_parse_human_information_(uint8_t *data) {
// Sending data frames
void MR24HPC1Component::send_query_(const uint8_t *query, size_t string_length) {
if (!this->module_ready_)
return;
this->write_array(query, string_length);
}
@@ -153,6 +153,7 @@ class MR24HPC1Component final : public Component,
int sg_start_query_data_;
bool check_dev_inf_sign_;
bool poll_time_base_func_check_;
bool module_ready_{false};
void update_();
void r24_split_data_frame_(uint8_t value);
+177 -15
View File
@@ -1,9 +1,11 @@
from dataclasses import dataclass, field
import logging
from typing import Any
from esphome import automation
import esphome.codegen as cg
from esphome.components import esp32, mdns, network, psram, socket, wifi
from esphome.components.const import CONF_MANUFACTURER
from esphome.components.const import CONF_ENABLED, CONF_MANUFACTURER
import esphome.config_validation as cv
from esphome.const import (
CONF_BUFFER_SIZE,
@@ -25,6 +27,8 @@ from esphome.core import CORE
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
# mdns for autodiscovery
AUTO_LOAD = ["mdns"]
CODEOWNERS = ["@kahrendt"]
@@ -46,6 +50,33 @@ CONF_FIXED_DELAY = "fixed_delay"
CONF_DECODE_MEMORY = "decode_memory"
CONF_CODECS = "codecs"
CONF_STATIC_PAIRING_CODE = "static_pairing_code"
CONF_UNPAIRED_ACCESS = "unpaired_access"
CONF_ON_OPEN_PAIRING_WINDOW = "on_open_pairing_window"
CONF_ON_CLOSE_PAIRING_WINDOW = "on_close_pairing_window"
CONF_ON_DISPLAY_PAIRING_CODE = "on_display_pairing_code"
CONF_ON_CLEAR_PAIRING_CODE = "on_clear_pairing_code"
CONF_ON_PAIRING_SUCCEEDED = "on_pairing_succeeded"
CONF_ON_PAIRING_FAILED = "on_pairing_failed"
# A static pairing code is exactly 8 decimal digits.
STATIC_PAIRING_CODE_DIGITS = 8
def _validate_static_pairing_code(value: Any) -> str:
# string_strict so leading zeros survive and `!secret` works.
value = cv.string_strict(value)
if len(value) != STATIC_PAIRING_CODE_DIGITS or not (
value.isascii() and value.isdigit()
):
raise cv.Invalid(
f"{CONF_STATIC_PAIRING_CODE} must be exactly "
f"{STATIC_PAIRING_CODE_DIGITS} decimal digits "
'(quote the value so leading zeros are preserved, e.g. "01234567")'
)
return value
# Matches ARTWORK_MAX_SLOTS in sendspin-cpp.
MAX_ARTWORK_SLOTS = 4
@@ -76,7 +107,6 @@ OPUS_SAMPLE_RATE = 48000
SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True)
IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG")
IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG")
IMAGE_FORMAT_BMP = SendspinImageFormat.enum("BMP")
SendspinImageSource = sendspin_library_ns.enum("SendspinImageSource", is_class=True)
IMAGE_SOURCE_ALBUM = SendspinImageSource.enum("ALBUM")
@@ -109,6 +139,34 @@ SendspinHub = sendspin_ns.class_(
)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_OPEN_PAIRING_WINDOW, "add_on_open_pairing_window_callback"
),
automation.CallbackAutomation(
CONF_ON_CLOSE_PAIRING_WINDOW, "add_on_close_pairing_window_callback"
),
automation.CallbackAutomation(
CONF_ON_DISPLAY_PAIRING_CODE,
"add_on_display_pairing_code_callback",
[(cg.std_string, "code")],
),
automation.CallbackAutomation(
CONF_ON_CLEAR_PAIRING_CODE, "add_on_clear_pairing_code_callback"
),
automation.CallbackAutomation(
CONF_ON_PAIRING_SUCCEEDED,
"add_on_pairing_succeeded_callback",
[(cg.std_string, "server_id")],
),
automation.CallbackAutomation(
CONF_ON_PAIRING_FAILED,
"add_on_pairing_failed_callback",
[(cg.std_string, "server_id"), (cg.StringRef, "reason")],
),
)
@dataclass
class SendspinConfiguration:
artwork_support: bool = False
@@ -116,6 +174,8 @@ class SendspinConfiguration:
metadata_support: bool = False
player_support: bool = False
visualizer_support: bool = False
pairing_code_display_support: bool = False
switch_types: set[str] = field(default_factory=set)
artwork_preferences: list[ConfigType] = field(default_factory=list)
player_config: ConfigType | None = None
@@ -152,6 +212,16 @@ def request_visualizer_support() -> None:
_get_data().visualizer_support = True
def request_pairing_code_display_support() -> None:
"""Mark that the device can emit a dynamic pairing code (e.g. a pairing_code text sensor)."""
_get_data().pairing_code_display_support = True
def request_switch(switch_type: str) -> None:
"""Mark that a sendspin switch of this type drives the matching hub setting."""
_get_data().switch_types.add(switch_type)
def register_artwork_preference(config: ConfigType) -> int:
"""Register an artwork slot preference and return the slot it was given.
@@ -208,28 +278,85 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_MANUFACTURER): DEVICE_INFO_STRING,
cv.Optional(CONF_MODEL): DEVICE_INFO_STRING,
cv.Optional(CONF_FIRMWARE_VERSION): DEVICE_INFO_STRING,
cv.Optional(CONF_STATIC_PAIRING_CODE): cv.sensitive(
_validate_static_pairing_code
),
# No default: the unpaired access switch rejects this key when it is set.
cv.Optional(CONF_UNPAIRED_ACCESS): cv.boolean,
cv.Optional(CONF_ON_OPEN_PAIRING_WINDOW): automation.validate_automation(
{}
),
cv.Optional(CONF_ON_CLOSE_PAIRING_WINDOW): automation.validate_automation(
{}
),
cv.Optional(CONF_ON_DISPLAY_PAIRING_CODE): automation.validate_automation(
{}
),
cv.Optional(CONF_ON_CLEAR_PAIRING_CODE): automation.validate_automation({}),
cv.Optional(CONF_ON_PAIRING_SUCCEEDED): automation.validate_automation({}),
cv.Optional(CONF_ON_PAIRING_FAILED): automation.validate_automation({}),
}
),
cv.only_on_esp32,
# sendspin-cpp needs noise-c as an ESP-IDF component, which Arduino below IDF 6.0 cannot use.
cv.only_with_framework("esp-idf"),
_request_high_performance_networking,
)
def _offers_dynamic_pairing_code(config: ConfigType) -> bool:
"""Whether the device can show a dynamic pairing code, so dynamic_pairing_code is advertised."""
return bool(
config.get(CONF_ON_DISPLAY_PAIRING_CODE)
or _get_data().pairing_code_display_support
)
def _has_pairing_method(config: ConfigType) -> bool:
"""Whether the config gives a server any way to pair with the device."""
return CONF_STATIC_PAIRING_CODE in config or _offers_dynamic_pairing_code(config)
def _final_validate(config: ConfigType) -> ConfigType:
dynamic_code = _offers_dynamic_pairing_code(config)
# The protocol allows only one pairing code method.
if dynamic_code and CONF_STATIC_PAIRING_CODE in config:
raise cv.Invalid(
f"'{CONF_STATIC_PAIRING_CODE}' cannot be used with a dynamic pairing code "
f"({CONF_ON_DISPLAY_PAIRING_CODE} or a pairing_code text sensor), since only "
"one pairing code method can be offered",
path=[CONF_STATIC_PAIRING_CODE],
)
if not config.get(CONF_UNPAIRED_ACCESS, True) and not _has_pairing_method(config):
_LOGGER.warning(
"'%s' is off but there is no pairing method (%s or a dynamic pairing code), so no "
"new server can pair with this device",
CONF_UNPAIRED_ACCESS,
CONF_STATIC_PAIRING_CODE,
)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
def _request_controller_role(config: ConfigType) -> ConfigType:
"""Request the controller role for the sendspin.switch action."""
request_controller_support()
return config
# Selects the hub. sendspin.switch adds the controller role it needs; the pairing window actions need no role.
SENDSPIN_HUB_ACTION_SCHEMA = automation.maybe_simple_id(
cv.Schema(
{
cv.GenerateID(): cv.use_id(SendspinHub),
}
)
)
SENDSPIN_SIMPLE_ACTION_SCHEMA = cv.All(
automation.maybe_simple_id(
cv.Schema(
{
cv.GenerateID(): cv.use_id(SendspinHub),
}
)
),
_request_controller_role,
SENDSPIN_HUB_ACTION_SCHEMA, _request_controller_role
)
@@ -240,6 +367,19 @@ automation.register_apply_action(
)
automation.register_apply_action(
"sendspin.confirm_pairing_window",
SENDSPIN_HUB_ACTION_SCHEMA,
automation.ApplyCall("confirm_pairing_window()"),
)
automation.register_apply_action(
"sendspin.cancel_pairing_window",
SENDSPIN_HUB_ACTION_SCHEMA,
automation.ApplyCall("cancel_pairing_window()"),
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
@@ -264,10 +404,27 @@ async def to_code(config: ConfigType) -> None:
if value:
cg.add(setter(value))
# sendspin-cpp library
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.8.0")
if (code := config.get(CONF_STATIC_PAIRING_CODE)) is not None:
cg.add(var.set_static_pairing_code(code))
cg.add_define("USE_SENDSPIN", True) # for MDNS
# The client starts once both are set: here, or by the switch that drives the setting.
switch_types = _get_data().switch_types
if CONF_ENABLED not in switch_types:
cg.add(var.set_enabled(True))
if CONF_UNPAIRED_ACCESS not in switch_types:
cg.add(var.set_unpaired_access_enabled(config.get(CONF_UNPAIRED_ACCESS, True)))
if _offers_dynamic_pairing_code(config):
cg.add(var.set_pairing_code_display_supported(True))
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
# sendspin-cpp library
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.9.3")
# esp_websocket_client links esp_tls even for ws:// connections.
esp32.request_tls()
cg.add_define("USE_SENDSPIN", True) # for MDNS and the native API
# Service starts disabled and the hub enables it; always advertised where unsupported
if mdns.request_service_enable_disable():
@@ -276,8 +433,9 @@ async def to_code(config: ConfigType) -> None:
data = _get_data()
# The color role is not yet wired up in ESPHome; disable it in the library for now.
# The color and source roles are not yet wired up in ESPHome; disable them in the library for now.
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_COLOR", False)
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_SOURCE", False)
# 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_*).
@@ -351,7 +509,8 @@ async def to_code(config: ConfigType) -> None:
("audio_formats", audio_format_structs),
("audio_buffer_capacity", player_cfg[CONF_BUFFER_SIZE]),
("fixed_delay_us", player_cfg[CONF_FIXED_DELAY]),
("initial_static_delay_ms", player_cfg[CONF_INITIAL_STATIC_DELAY]),
# The released YAML key name is kept for compatibility.
("initial_output_delay_ms", player_cfg[CONF_INITIAL_STATIC_DELAY]),
("psram_stack", psram_stack),
]
if (decode_memory := player_cfg.get(CONF_DECODE_MEMORY)) is not None:
@@ -366,6 +525,9 @@ async def to_code(config: ConfigType) -> None:
else:
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_PLAYER", False)
if not data.player_support or CODEC_OPUS not in data.player_config[CONF_CODECS]:
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_OPUS", False)
if data.visualizer_support:
cg.add_define("USE_SENDSPIN_VISUALIZER", True)
else:
+13 -3
View File
@@ -1,5 +1,7 @@
"""Sendspin image platform."""
from typing import Any
from esphome import automation
import esphome.codegen as cg
from esphome.components import runtime_image
@@ -21,7 +23,6 @@ from esphome.types import ConfigType
from .. import (
CONF_DISPLAY_OFFSET,
CONF_SENDSPIN_ID,
IMAGE_FORMAT_BMP,
IMAGE_FORMAT_JPEG,
IMAGE_FORMAT_PNG,
IMAGE_SOURCE_ALBUM,
@@ -57,9 +58,16 @@ _FORMAT_TO_SENDSPIN_ENUM = {
"JPEG": IMAGE_FORMAT_JPEG,
"JPG": IMAGE_FORMAT_JPEG,
"PNG": IMAGE_FORMAT_PNG,
"BMP": IMAGE_FORMAT_BMP,
}
# Remove before 2027.4.0
def _reject_bmp(value: Any) -> Any:
if isinstance(value, str) and value.upper() == "BMP":
raise cv.Invalid("BMP artwork is no longer supported, use JPEG or PNG instead.")
return value
# The library's SendspinImageSource::NONE is its internal "unset" sentinel; a slot advertising it
# would never receive artwork while still paying for two frame buffers, so it is not offered here.
IMAGE_SOURCES = {
@@ -121,7 +129,9 @@ CONFIG_SCHEMA = cv.All(
cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub),
# Narrow runtime_image's format list to what the library can request, so the
# accepted set and the enum map below cannot drift apart.
cv.Required(CONF_FORMAT): cv.one_of(*_FORMAT_TO_SENDSPIN_ENUM, upper=True),
cv.Required(CONF_FORMAT): cv.All(
_reject_bmp, cv.one_of(*_FORMAT_TO_SENDSPIN_ENUM, upper=True)
),
cv.Required(CONF_RESIZE): cv.dimensions,
cv.Required(CONF_CURRENT_IMAGE): _IMAGE_SCHEMA,
cv.Optional(CONF_TRANSITION_IMAGE): _IMAGE_SCHEMA,
@@ -1,6 +1,6 @@
#include "sendspin_image.h"
#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK)
#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_ARTWORK)
#include "esphome/core/log.h"
@@ -125,7 +125,7 @@ bool SendspinImageSlot::decode_frame_(const uint8_t *data, size_t length, const
}
// A decode that asked for other dimensions had the buffer taken away from it, so it painted
// nothing (or stopped partway). JPEG and BMP report that as an error above; PNG carries on
// nothing (or stopped partway). JPEG reports that as an error above; PNG carries on
// regardless, so the frame is dropped here.
return this->decode_sink_.decoded_into(target);
}
@@ -2,7 +2,7 @@
#include "esphome/core/defines.h"
#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK)
#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_ARTWORK)
#include "esphome/components/image/image.h"
#include "esphome/components/runtime_image/runtime_image.h"
@@ -29,7 +29,7 @@ class ArtworkDecodeSink : public runtime_image::RuntimeImage {
/// @brief True when the decode ended with the given buffer still in place.
///
/// An external buffer is dropped rather than resized, so a decode that wanted other dimensions
/// leaves the sink holding nothing. The JPEG and BMP decoders report that as a decode error, but
/// leaves the sink holding nothing. The JPEG decoder reports that as a decode error, but
/// the PNG decoder ignores it and reports success, so the outcome is checked here as well.
bool decoded_into(const uint8_t *buffer) const { return this->buffer_ == buffer; }
};
@@ -1,6 +1,6 @@
#include "sendspin_media_player.h"
#if defined(USE_ESP32) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER)
#if defined(USE_ESP_IDF) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER)
#include "esphome/core/application.h"
#include "esphome/core/log.h"
@@ -2,7 +2,7 @@
#include "esphome/core/defines.h"
#if defined(USE_ESP32) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER)
#if defined(USE_ESP_IDF) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER)
#include "esphome/components/media_player/media_player.h"
#include "esphome/components/sendspin/sendspin_hub.h"
@@ -11,7 +11,9 @@ from esphome.const import (
from esphome.types import ConfigType
from .. import (
CODEC_FLAC,
CODEC_OPUS,
CODEC_PCM,
CODECS,
CONF_CODECS,
CONF_DECODE_MEMORY,
@@ -58,6 +60,11 @@ def _resolve_codecs(config: ConfigType) -> ConfigType:
f"Codec '{CODEC_OPUS}' requires a {CONF_SAMPLE_RATE} of {OPUS_SAMPLE_RATE}",
path=[CONF_CODECS],
)
if CODEC_FLAC not in codecs and CODEC_PCM not in codecs:
raise cv.Invalid(
f"Codecs must include '{CODEC_FLAC}' or '{CODEC_PCM}'",
path=[CONF_CODECS],
)
return config
@@ -1,6 +1,6 @@
#include "sendspin_media_source.h"
#if defined(USE_ESP32) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER)
#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER)
#include "esphome/components/audio/audio.h"
#include "esphome/core/log.h"
@@ -24,7 +24,7 @@ void SendspinMediaSource::setup() {
// Push cached states to player role. They may have been set before setup() ran.
this->player_role_->update_volume(std::roundf(this->cached_volume_ * 100.0f));
this->player_role_->update_muted(this->cached_muted_);
this->player_role_->set_static_delay_adjustable(this->static_delay_adjustable_);
this->player_role_->set_output_delay_adjustable(this->static_delay_adjustable_);
}
void SendspinMediaSource::dump_config() {
@@ -35,7 +35,7 @@ void SendspinMediaSource::dump_config() {
void SendspinMediaSource::set_static_delay_adjustable(bool adjustable) {
this->static_delay_adjustable_ = adjustable;
if (this->player_role_) {
this->player_role_->set_static_delay_adjustable(adjustable);
this->player_role_->set_output_delay_adjustable(adjustable);
}
}
@@ -102,8 +102,8 @@ void SendspinMediaSource::handle_command(media_source::MediaSourceCommand comman
if (!this->pending_start_) {
// Ignore stop commands if we have a pending start, since the orchestrator may send a stop command before
// play_uri
ESP_LOGD(TAG, "Received STOP command, updating Sendspin state to EXTERNAL_SOURCE");
this->parent_->update_state(sendspin::SendspinClientState::EXTERNAL_SOURCE);
ESP_LOGD(TAG, "Received STOP command, leaving the Sendspin group");
this->parent_->leave_group();
}
break;
}
@@ -185,8 +185,6 @@ size_t SendspinMediaSource::on_audio_write(uint8_t *data, size_t length, uint32_
// THREAD CONTEXT: Main loop (PlayerRoleListener lifecycle callback)
void SendspinMediaSource::on_stream_start() {
this->parent_->update_state(sendspin::SendspinClientState::SYNCHRONIZED);
if (!this->pending_start_) {
// Dedup rapid on_stream_start() calls
this->pending_start_ = true;
@@ -211,4 +209,4 @@ void SendspinMediaSource::on_mute_changed(bool muted) { this->request_mute_(mute
} // namespace esphome::sendspin_
#endif // USE_ESP32 && USE_SENDSPIN_PLAYER && USE_SENDSPIN_CONTROLLER
#endif // USE_ESP_IDF && USE_SENDSPIN_PLAYER && USE_SENDSPIN_CONTROLLER
@@ -2,7 +2,7 @@
#include "esphome/core/defines.h"
#if defined(USE_ESP32) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER)
#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER)
#include "esphome/components/sendspin/sendspin_hub.h"
+270 -71
View File
@@ -1,6 +1,6 @@
#include "sendspin_hub.h"
#ifdef USE_ESP32
#ifdef USE_ESP_IDF
#include "esphome/components/network/util.h"
#ifdef USE_ETHERNET
@@ -17,26 +17,122 @@
#include <esp_log.h>
#include <cstring>
#include <memory>
namespace esphome::sendspin_ {
static const char *const TAG = "sendspin.hub";
namespace {
// The reason passed to on_pairing_failed automations, in the protocol's own spelling.
StringRef pair_abort_reason_to_string(sendspin::SendspinPairAbortReason reason) {
using sendspin::SendspinPairAbortReason;
switch (reason) {
case SendspinPairAbortReason::ATTEMPT_TIMEOUT:
return StringRef::from_lit("attempt_timeout");
case SendspinPairAbortReason::CONCURRENT_ATTEMPT:
return StringRef::from_lit("concurrent_attempt");
case SendspinPairAbortReason::METHOD_NOT_SUPPORTED:
return StringRef::from_lit("method_not_supported");
case SendspinPairAbortReason::PAIRING_CODE_MISMATCH:
return StringRef::from_lit("pairing_code_mismatch");
case SendspinPairAbortReason::USER_CANCELLED:
return StringRef::from_lit("user_cancelled");
case SendspinPairAbortReason::UNKNOWN:
break;
}
return StringRef::from_lit("unknown");
}
// Zeroes through a volatile pointer so dead-store elimination cannot drop it.
void secure_wipe(void *data, size_t len) {
volatile auto *p = static_cast<volatile uint8_t *>(data);
for (size_t i = 0; i < len; ++i) {
p[i] = 0;
}
}
// Call once per key, from setup().
ESPPreferenceObject make_blob_pref(const std::string &name, size_t size) {
return global_preferences->make_preference(size, fnv1a_hash("sendspin_" + name));
}
// Loads straight into the returned buffer, which the library wipes after use. A failed load can
// leave part of a stored private key or PSK behind, so that path wipes it here. This only covers
// this buffer: the preference backend keeps its own copy of a pending write until it syncs.
std::optional<std::vector<uint8_t>> read_blob(ESPPreferenceObject &pref, size_t size) {
std::vector<uint8_t> out(size);
if (!pref.load(out.data(), out.size())) {
secure_wipe(out.data(), out.size());
return std::nullopt;
}
return out;
}
// The library always writes a key's fixed size; any other length is misuse and is not stored.
bool write_blob(ESPPreferenceObject &pref, const char *key, size_t size, const uint8_t *data, size_t len) {
if (len != size) {
ESP_LOGW(TAG, "\"%s\" blob of %zu bytes does not match its size of %zu; rejecting write", key, len, size);
return false;
}
if (!pref.save(data, len)) {
ESP_LOGW(TAG, "Failed to persist \"%s\" blob (%zu bytes)", key, len);
return false;
}
return true;
}
// "rec_<n>" to n; nullopt for any other key or a slot with no storage here.
std::optional<size_t> parse_record_slot(const std::string &key) {
const char *const prefix = sendspin::persistence_keys::RECORD_SLOT_PREFIX;
const size_t prefix_len = std::strlen(prefix);
if (key.size() <= prefix_len || key.compare(0, prefix_len, prefix) != 0) {
return std::nullopt;
}
size_t slot = 0;
for (size_t i = prefix_len; i < key.size(); i++) {
const char c = key[i];
if (c < '0' || c > '9') {
return std::nullopt;
}
slot = slot * 10 + static_cast<size_t>(c - '0');
if (slot >= SENDSPIN_RECORD_SLOTS) {
return std::nullopt;
}
}
return slot;
}
} // namespace
#ifdef USE_SENDSPIN_ARTWORK
// Indexed by the library enums, which start at zero and are contiguous.
static const char *const IMAGE_SOURCE_NAMES[] = {"ALBUM", "ARTIST", "NONE"};
static const char *const IMAGE_FORMAT_NAMES[] = {"JPEG", "PNG", "BMP"};
static const char *const IMAGE_FORMAT_NAMES[] = {"JPEG", "PNG"};
#endif
SendspinHub *global_sendspin_hub = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
SendspinHub::SendspinHub() { global_sendspin_hub = this; }
void SendspinHub::setup() {
auto config = this->build_client_config_();
this->client_ = std::make_unique<sendspin::SendspinClient>(std::move(config));
// Set up persistence (preferences must be initialized before providers are added to the client)
this->last_played_server_pref_ =
global_preferences->make_preference<LastPlayedServerPref>(fnv1a_hash("sendspin_last_played"));
#ifdef USE_SENDSPIN_PLAYER
this->static_delay_pref_ = global_preferences->make_preference<StaticDelayPref>(fnv1a_hash("sendspin_static_delay"));
#endif
// Set up persistence (preferences must be initialized before providers are added to the client). These key names
// are frozen; see PREFERENCE KEYS in sendspin_hub.h.
namespace keys = sendspin::persistence_keys;
this->keypair_pref_ = make_blob_pref("keypair", keys::KEYPAIR_SIZE);
this->pairing_psk_pref_ = make_blob_pref("pair_psk", keys::PAIRING_PSK_SIZE);
this->last_played_pref_ = make_blob_pref("last_played", keys::LAST_PLAYED_SIZE);
// Released firmware's name, so a calibrated delay survives.
this->output_delay_pref_ = make_blob_pref("static_delay", keys::OUTPUT_DELAY_SIZE);
for (size_t slot = 0; slot < SENDSPIN_RECORD_SLOTS; slot++) {
this->record_slot_prefs_[slot] = make_blob_pref(keys::record_slot_key(slot), keys::RECORD_SLOT_SIZE);
}
this->record_order_pref_ = make_blob_pref(keys::RECORD_ORDER, SENDSPIN_RECORD_SLOTS);
// Wire providers and client listener
this->client_->set_listener(this);
@@ -62,14 +158,15 @@ void SendspinHub::setup() {
this->client_->add_player(this->player_config_).set_listener(this->player_listener_);
#endif
#ifndef USE_SENDSPIN_SWITCH
this->enabled_ = true;
#endif
// Set before setup() by codegen; an unpaired access switch sets it later, from its own setup().
if (this->unpaired_access_.has_value()) {
this->client_->set_unpaired_access_enabled(*this->unpaired_access_);
}
}
void SendspinHub::loop() {
if (this->enabled_.has_value() && this->enabled_.value() != this->client_->is_started() &&
!this->status_has_error()) {
if (this->enabled_.has_value() && this->unpaired_access_.has_value() &&
this->enabled_.value() != this->client_->is_started() && !this->status_has_error()) {
if (!this->enabled_.value()) {
this->client_->stop();
} else if (!this->client_->start()) {
@@ -83,17 +180,37 @@ void SendspinHub::loop() {
#endif
}
// Sends each server a goodbye and writes what the client still owed its provider. Wi-Fi sets up
// before this hub, so it shuts down after it and the goodbyes can still go out.
void SendspinHub::on_shutdown() {
// Keeps loop() from starting the client again.
this->enabled_ = false;
if (this->is_client_running()) {
this->client_->stop();
}
}
void SendspinHub::dump_config() {
// client_id exists only once start() has run.
const char *client_id = "(unavailable)";
if (this->client_ != nullptr && !this->client_->client_id().empty()) {
client_id = this->client_->client_id().c_str();
}
char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
ESP_LOGCONFIG(TAG,
"Sendspin Hub:\n"
" Client ID: %s\n"
" MAC address: %s\n"
" Manufacturer: %s\n"
" Model: %s\n"
" Firmware version: %s\n"
" Task stack in PSRAM: %s",
get_client_id_into_buffer(mac_buf), this->manufacturer_, this->get_product_name_(),
this->firmware_version_, YESNO(this->task_stack_in_psram_));
" Task stack in PSRAM: %s\n"
" Unpaired access: %s\n"
" Pairing code method: %s",
client_id, get_mac_address_into_buffer(mac_buf), this->manufacturer_, this->get_product_name_(),
this->firmware_version_, YESNO(this->task_stack_in_psram_),
YESNO(this->client_ != nullptr && this->client_->is_unpaired_access_enabled()),
this->pairing_code_method_());
#ifdef USE_SENDSPIN_ARTWORK
// Slot indices come from the order the image platform entries were declared, so the log is the
@@ -108,7 +225,7 @@ void SendspinHub::dump_config() {
#endif
}
// THREAD CONTEXT: Main loop (invoked from Sendspin components)
// THREAD CONTEXT: Main loop (invoked from codegen before setup(), or from Sendspin components)
void SendspinHub::set_enabled(bool enabled) {
if (this->status_has_error()) {
ESP_LOGE(TAG, "Cannot %s: Sendspin failed to start, reboot to retry",
@@ -118,6 +235,23 @@ void SendspinHub::set_enabled(bool enabled) {
this->enabled_ = enabled;
}
// THREAD CONTEXT: Main loop
std::optional<std::string> SendspinHub::get_pairing_token() const {
if (this->client_ == nullptr) {
return std::nullopt;
}
return this->client_->pairing_token();
}
// THREAD CONTEXT: Main loop (invoked from codegen before setup(), or from Sendspin components)
void SendspinHub::set_unpaired_access_enabled(bool enabled) {
this->unpaired_access_ = enabled;
// Before setup() there is no client yet; setup() applies the stored value.
if (this->client_ != nullptr) {
this->client_->set_unpaired_access_enabled(enabled);
}
}
#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE
// THREAD CONTEXT: Main loop
void SendspinHub::update_mdns_service_() {
@@ -154,22 +288,58 @@ void SendspinHub::disconnect_from_server(sendspin::SendspinGoodbyeReason reason)
}
// THREAD CONTEXT: Main loop (invoked from Sendspin components)
void SendspinHub::update_state(sendspin::SendspinClientState state) {
if (this->is_client_running()) {
this->client_->update_state(state);
void SendspinHub::leave_group() {
if (this->is_client_running() &&
this->client_->get_group_state().playback_state == sendspin::SendspinPlaybackState::PLAYING) {
this->client_->leave();
}
}
const char *SendspinHub::get_client_id_into_buffer(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf) {
// The server matches client_id against the L2 source MAC of the device's multicast traffic.
// THREAD CONTEXT: Main loop (invoked from the sendspin.confirm_pairing_window action)
void SendspinHub::confirm_pairing_window() {
if (this->is_client_running()) {
this->client_->confirm_pairing_window();
}
}
// THREAD CONTEXT: Main loop (invoked from the sendspin.cancel_pairing_window action)
void SendspinHub::cancel_pairing_window() {
if (this->is_client_running()) {
this->client_->cancel_pairing_window();
}
}
const char *SendspinHub::get_mac_address_into_buffer(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf) {
// The server matches this MAC against the L2 source MAC of the device's multicast traffic.
// ESP-IDF derives the ethernet MAC as base+3 by default on ESP32-S3, so we cannot use the
// eFuse base MAC when ethernet is the active interface.
#ifdef USE_ETHERNET
if (ethernet::global_eth_component != nullptr) {
return ethernet::global_eth_component->get_eth_mac_address_pretty_into_buffer(buf);
ethernet::global_eth_component->get_eth_mac_address_pretty_into_buffer(buf);
} else {
get_mac_address_pretty_into_buffer(buf);
}
#else
get_mac_address_pretty_into_buffer(buf);
#endif
return get_mac_address_pretty_into_buffer(buf);
// The pretty format is uppercase, but SendspinClientConfig::mac_address must be lowercase.
for (char &c : buf) {
if (c >= 'A' && c <= 'F') {
c += 'a' - 'A';
}
}
return buf.data();
}
// Validation rejects a static code together with the dynamic code, so at most one is set.
const char *SendspinHub::pairing_code_method_() const {
if (this->pairing_code_display_supported_) {
return LOG_STR_LITERAL("dynamic");
}
if (this->static_pairing_code_ != nullptr) {
return LOG_STR_LITERAL("static");
}
return LOG_STR_LITERAL("none");
}
const char *SendspinHub::get_product_name_() const {
@@ -180,12 +350,26 @@ sendspin::SendspinClientConfig SendspinHub::build_client_config_() {
sendspin::SendspinClientConfig config;
char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
config.client_id = SendspinHub::get_client_id_into_buffer(mac_buf);
config.mac_address = SendspinHub::get_mac_address_into_buffer(mac_buf);
config.name = App.get_friendly_name();
config.product_name = this->get_product_name_();
config.manufacturer = this->manufacturer_;
config.software_version = this->firmware_version_;
config.httpd_psram_stack = this->task_stack_in_psram_;
config.protocol_task_psram_stack = this->task_stack_in_psram_;
config.max_pairing_records = SENDSPIN_RECORD_SLOTS;
// The dynamic code needs a channel and a format. Only digits, since automations get the bare string and could not
// tell a QR code token apart.
if (this->pairing_code_display_supported_) {
config.pairing_code_out_channels = {sendspin::SendspinPairingCodeChannel::DISPLAY};
config.pairing_code_formats = {sendspin::SendspinPairingCodeFormat::DIGITS};
}
if (this->static_pairing_code_ != nullptr) {
config.static_pairing_code_locations = {"operator"};
config.static_pairing_code = this->static_pairing_code_;
}
return config;
}
@@ -215,35 +399,77 @@ void SendspinHub::on_release_high_performance() {
#endif
}
void SendspinHub::on_open_pairing_window() { this->open_pairing_window_callbacks_.call(); }
void SendspinHub::on_close_pairing_window() { this->close_pairing_window_callbacks_.call(); }
// Only digits are offered, and the library refuses an activation in any other format.
void SendspinHub::on_display_pairing_code(const std::string &code, sendspin::SendspinPairingCodeFormat /*format*/) {
this->display_pairing_code_callbacks_.call(code);
}
void SendspinHub::on_clear_pairing_code() { this->clear_pairing_code_callbacks_.call(); }
void SendspinHub::on_pairing_succeeded(const std::string &server_id) {
this->pairing_succeeded_callbacks_.call(server_id);
}
void SendspinHub::on_pairing_failed(const std::string &server_id, sendspin::SendspinPairAbortReason reason) {
this->pairing_failed_callbacks_.call(server_id, pair_abort_reason_to_string(reason));
}
// --- SendspinNetworkProvider override ---
// THREAD CONTEXT: Main loop (polled by client_->loop())
// THREAD CONTEXT: Main loop (polled by start() and client_->loop())
bool SendspinHub::is_network_ready() { return network::is_connected(); }
// --- SendspinPersistenceProvider overrides ---
// THREAD CONTEXT: Main loop (the library makes every provider call there, including from start() and stop())
// 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%08" PRIX32, hash);
} else {
ESP_LOGW(TAG, "Failed to persist last played server hash");
std::pair<ESPPreferenceObject *, size_t> SendspinHub::pref_for_key_(const std::string &key) {
namespace keys = sendspin::persistence_keys;
if (auto slot = parse_record_slot(key); slot.has_value()) {
return {&this->record_slot_prefs_[*slot], keys::RECORD_SLOT_SIZE};
}
return ok;
if (key == keys::RECORD_ORDER) {
return {&this->record_order_pref_, SENDSPIN_RECORD_SLOTS};
}
if (key == keys::KEYPAIR) {
return {&this->keypair_pref_, keys::KEYPAIR_SIZE};
}
if (key == keys::PAIRING_PSK) {
return {&this->pairing_psk_pref_, keys::PAIRING_PSK_SIZE};
}
if (key == keys::LAST_PLAYED) {
return {&this->last_played_pref_, keys::LAST_PLAYED_SIZE};
}
if (key == keys::OUTPUT_DELAY) {
return {&this->output_delay_pref_, keys::OUTPUT_DELAY_SIZE};
}
return {nullptr, 0};
}
// 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%08" PRIX32, pref.server_id_hash);
return pref.server_id_hash;
std::optional<std::vector<uint8_t>> SendspinHub::load_blob(const std::string &key) {
auto [pref, size] = this->pref_for_key_(key);
if (pref == nullptr) {
ESP_LOGW(TAG, "load_blob: unknown key \"%s\"", key.c_str());
return std::nullopt;
}
return std::nullopt;
return read_blob(*pref, size);
}
bool SendspinHub::save_blob(const std::string &key, const uint8_t *data, size_t len) {
auto [pref, size] = this->pref_for_key_(key);
if (pref == nullptr) {
ESP_LOGW(TAG, "save_blob: unknown key \"%s\"", key.c_str());
return false;
}
return write_blob(*pref, key.c_str(), size, data, len);
}
// Writes every component's queued preferences, so a failure may belong to another component.
bool SendspinHub::commit() { return global_preferences->sync(); }
// --- Sendspin role specific methods/overrides ---
#ifdef USE_SENDSPIN_ARTWORK
@@ -285,11 +511,7 @@ void SendspinHub::send_client_command(sendspin::SendspinControllerCommand comman
}
// THREAD CONTEXT: Main loop (invoked from the sendspin.switch action)
void SendspinHub::switch_client() {
// Clear any EXTERNAL_SOURCE state so the switch command is followed
this->update_state(sendspin::SendspinClientState::SYNCHRONIZED);
this->send_client_command(sendspin::SendspinControllerCommand::SWITCH);
}
void SendspinHub::switch_client() { this->send_client_command(sendspin::SendspinControllerCommand::SWITCH); }
// THREAD CONTEXT: Main loop (ControllerRoleListener override, fired from client_->loop())
void SendspinHub::on_controller_state(const sendspin::ServerStateControllerObject &state) {
@@ -332,31 +554,8 @@ sendspin::PlayerRole *SendspinHub::get_player_role() {
}
return nullptr;
}
// THREAD CONTEXT: Main loop (SendspinPersistenceProvider override)
bool SendspinHub::save_static_delay(uint16_t delay_ms) {
StaticDelayPref pref{.delay_ms = delay_ms};
bool ok = this->static_delay_pref_.save(&pref);
if (ok) {
ESP_LOGD(TAG, "Persisted static delay: %u ms", delay_ms);
} else {
ESP_LOGW(TAG, "Failed to persist static delay");
}
return ok;
}
// THREAD CONTEXT: Main loop (SendspinPersistenceProvider override)
std::optional<uint16_t> SendspinHub::load_static_delay() {
StaticDelayPref pref{};
if (this->static_delay_pref_.load(&pref)) {
ESP_LOGI(TAG, "Loaded static delay: %u ms", pref.delay_ms);
return pref.delay_ms;
}
return std::nullopt;
}
#endif
} // namespace esphome::sendspin_
#endif // USE_ESP32
#endif // USE_ESP_IDF
+129 -31
View File
@@ -2,12 +2,13 @@
#include "esphome/core/defines.h"
#ifdef USE_ESP32
#ifdef USE_ESP_IDF
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/core/preferences.h"
#include "esphome/core/string_ref.h"
#include "esphome/core/version.h"
#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE
@@ -31,9 +32,15 @@
#include <sendspin/player_role.h>
#endif
#include <array>
#include <cstddef>
#include <cstdint>
#include <functional>
#include <memory>
#include <optional>
#include <string>
#include <utility>
#include <vector>
namespace esphome::sendspin_ {
@@ -44,22 +51,21 @@ namespace esphome::sendspin_ {
/// one step later than hub so they can assume hub's setup() has already completed.
namespace sendspin_priority {
// AFTER_WIFI so the hub runs after the wifi/ethernet drivers are up and we can read the active
// interface's MAC for client_id.
// interface's MAC address for the device info.
inline constexpr float HUB = esphome::setup_priority::AFTER_WIFI;
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;
};
// PREFERENCE KEYS: each library persistence key has its own preference, hashed from "sendspin_" + name. Renaming
// one erases it.
#ifdef USE_SENDSPIN_PLAYER
/// @brief Persistent storage structure for player static delay.
struct StaticDelayPref {
uint16_t delay_ms;
/// Pairing-record slots, handed to the client as max_pairing_records.
inline constexpr size_t SENDSPIN_RECORD_SLOTS = sendspin::SendspinClientConfig::DEFAULT_MAX_PAIRING_RECORDS;
/// Feature flags reported to API clients in DeviceCapabilitiesResponse.
enum SendspinFeature : uint32_t {
SENDSPIN_FEATURE_PAIRING_TOKEN = 1 << 0,
};
#endif
/// @brief Thin adapter over sendspin::SendspinClient.
///
@@ -90,9 +96,12 @@ class SendspinHub final : public Component,
public sendspin::SendspinNetworkProvider,
public sendspin::SendspinPersistenceProvider {
public:
SendspinHub();
float get_setup_priority() const override { return sendspin_priority::HUB; }
void setup() override;
void loop() override;
void on_shutdown() override;
void dump_config() override;
/// @brief Connects the underlying client to the given Sendspin server.
@@ -113,14 +122,22 @@ class SendspinHub final : public Component,
/// - `USER_REQUEST`: user explicitly requested disconnect.
void disconnect_from_server(sendspin::SendspinGoodbyeReason reason);
/// @brief Updates the client's reported playback state on the server.
/// @brief Leaves the current group so another source can use the speaker.
///
/// Sends `client/leave` while the group is playing. A stopped group is left alone, so the device stays grouped.
/// No-op if the hub's client is not running. Must be called from the main loop thread.
void leave_group();
/// @brief Confirms a pairing attempt on the device. With no attempt waiting, it opens the pairing window for the
/// next one.
///
/// No-op if the hub's client is not running. 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);
void confirm_pairing_window();
/// @brief Closes an open pairing window, so a waiting pairing attempt is not confirmed.
///
/// No-op if the hub's client is not running. Must be called from the main loop thread.
void cancel_pairing_window();
// --- Configuration setters (called from codegen) ---
@@ -128,19 +145,59 @@ class SendspinHub final : public Component,
this->group_update_callbacks_.add(std::forward<F>(callback));
}
template<typename F> void add_on_open_pairing_window_callback(F &&callback) {
this->open_pairing_window_callbacks_.add(std::forward<F>(callback));
}
template<typename F> void add_on_close_pairing_window_callback(F &&callback) {
this->close_pairing_window_callbacks_.add(std::forward<F>(callback));
}
template<typename F> void add_on_display_pairing_code_callback(F &&callback) {
this->display_pairing_code_callbacks_.add(std::forward<F>(callback));
}
template<typename F> void add_on_clear_pairing_code_callback(F &&callback) {
this->clear_pairing_code_callbacks_.add(std::forward<F>(callback));
}
template<typename F> void add_on_pairing_succeeded_callback(F &&callback) {
this->pairing_succeeded_callbacks_.add(std::forward<F>(callback));
}
template<typename F> void add_on_pairing_failed_callback(F &&callback) {
this->pairing_failed_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; }
/// @brief Requests the Sendspin client, including the server, the roles and the mDNS advertisement, to start or
/// stop.
///
/// Applied from the hub's loop(). Stopping blocks until the client is fully stopped; the roles' clear callbacks
/// fire from inside that call. With a sendspin switch configured the client stays stopped until the switch has
/// called this once. Must be called from the main loop thread.
/// fire from inside that call. The client stays stopped until this has been called once, by codegen or by the
/// enabled switch. Must be called from the main loop thread.
void set_enabled(bool enabled);
/// Turns unpaired (Sentinel) access on or off. The client waits for this call, by codegen or by the unpaired
/// access switch, before its first start. May be called before setup(). Main loop only.
void set_unpaired_access_enabled(bool enabled);
/// @brief Returns whether the Sendspin client is running.
bool is_client_running() const { return this->client_ != nullptr && this->client_->is_started(); }
/// @brief Returns whether the client has been turned off with set_enabled().
bool is_disabled() const { return this->enabled_.has_value() && !this->enabled_.value(); }
/// @brief Returns the SendspinFeature flags reported to API clients.
uint32_t get_feature_flags() const { return SENDSPIN_FEATURE_PAIRING_TOKEN; }
/// @brief Returns the pairing token a Sendspin server uses to pair with this device.
///
/// The token is a long-lived secret, so this component never logs it. Returns std::nullopt until the client has
/// started once; it stays available after the client stops. Main loop only.
std::optional<std::string> get_pairing_token() const;
/// @brief Sets the device information reported to the server in the `client/hello` message.
///
/// Each takes a pointer to a string literal emitted by codegen, so it must stay valid for the
@@ -154,6 +211,13 @@ class SendspinHub final : public Component,
void set_mdns(mdns::MDNSComponent *mdns) { this->mdns_ = mdns; }
#endif
/// The static pairing code, used on every boot.
void set_static_pairing_code(const char *code) { this->static_pairing_code_ = code; }
/// Set when on_display_pairing_code or a pairing_code text sensor is configured, so the device offers the dynamic
/// pairing code.
void set_pairing_code_display_supported(bool supported) { this->pairing_code_display_supported_ = supported; }
// --- Sendspin role specific methods ---
#ifdef USE_SENDSPIN_ARTWORK
@@ -219,12 +283,18 @@ class SendspinHub final : public Component,
/// @brief Builds the SendspinClientConfig from ESPHome configuration and platform info.
sendspin::SendspinClientConfig build_client_config_();
/// The preference a library key is stored in and its blob size, or {nullptr, 0} for a key with
/// no storage here.
std::pair<ESPPreferenceObject *, size_t> pref_for_key_(const std::string &key);
/// @brief Returns the product name reported to the server: the configured model, or the device name.
const char *get_product_name_() const;
/// @brief Writes the active network interface's MAC into @p buf and returns its data pointer.
const char *pairing_code_method_() const;
/// @brief Writes the active network interface's MAC, in lowercase, into @p buf and returns its data pointer.
/// Uses the ethernet MAC if ethernet is configured, otherwise the base MAC (used by wifi).
static const char *get_client_id_into_buffer(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf);
static const char *get_mac_address_into_buffer(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf);
#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE
/// @brief Keeps the `_sendspin` mDNS service advertised while the client is running.
@@ -238,12 +308,26 @@ class SendspinHub final : public Component,
void on_release_high_performance() override;
void on_open_pairing_window() override;
void on_close_pairing_window() override;
void on_display_pairing_code(const std::string &code, sendspin::SendspinPairingCodeFormat format) override;
void on_clear_pairing_code() override;
void on_pairing_succeeded(const std::string &server_id) override;
void on_pairing_failed(const std::string &server_id, sendspin::SendspinPairAbortReason reason) 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;
// The library calls commit() after writing pairing secrets, so they reach flash right away.
std::optional<std::vector<uint8_t>> load_blob(const std::string &key) override;
bool save_blob(const std::string &key, const uint8_t *data, size_t len) override;
bool commit() override;
// --- Sendspin role specific methods/overrides/member variables ---
@@ -291,29 +375,41 @@ class SendspinHub final : public Component,
#ifdef USE_SENDSPIN_PLAYER
sendspin::PlayerRoleListener *player_listener_{nullptr};
sendspin::PlayerRoleConfig player_config_{};
// Part of SendspinPersistenceProvider overrides
ESPPreferenceObject static_delay_pref_;
std::optional<uint16_t> load_static_delay() override;
bool save_static_delay(uint16_t delay_ms) override;
#endif
// --- Core member variables ---
ESPPreferenceObject last_played_server_pref_;
// Built once in setup(): make_preference() allocates a backend that is never freed.
ESPPreferenceObject keypair_pref_;
ESPPreferenceObject pairing_psk_pref_;
ESPPreferenceObject last_played_pref_;
ESPPreferenceObject output_delay_pref_;
std::array<ESPPreferenceObject, SENDSPIN_RECORD_SLOTS> record_slot_prefs_;
ESPPreferenceObject record_order_pref_;
std::unique_ptr<sendspin::SendspinClient> client_;
// Callback fan-out to child components
CallbackManager<void(const sendspin::GroupUpdateObject &)> group_update_callbacks_{};
// Lazy: each pairing callback is fed by an optional YAML surface.
LazyCallbackManager<void()> open_pairing_window_callbacks_{};
LazyCallbackManager<void()> close_pairing_window_callbacks_{};
LazyCallbackManager<void(const std::string &)> display_pairing_code_callbacks_{};
LazyCallbackManager<void()> clear_pairing_code_callbacks_{};
LazyCallbackManager<void(const std::string &)> pairing_succeeded_callbacks_{};
LazyCallbackManager<void(const std::string &, StringRef)> pairing_failed_callbacks_{};
const char *static_pairing_code_{nullptr}; // Codegen string literal, or nullptr when not configured
bool pairing_code_display_supported_{false};
bool task_stack_in_psram_{false};
#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE
bool mdns_advertised_{false}; // Last state requested from mdns
#endif
// Requested client state, applied from loop(). Empty until the switch restores its state.
// Requested client state, applied from loop(). The client does not start until both are set.
std::optional<bool> enabled_;
std::optional<bool> unpaired_access_;
// Device information sent in the `client/hello` message. Defaults apply when neither the
// sendspin configuration nor the project information supplies a value.
@@ -347,6 +443,8 @@ class SendspinPollingChild : public PollingComponent, public Parented<SendspinHu
float get_setup_priority() const override { return sendspin_priority::CHILD; }
};
extern SendspinHub *global_sendspin_hub; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
} // namespace esphome::sendspin_
#endif // USE_ESP32
#endif // USE_ESP_IDF
@@ -1,6 +1,6 @@
#include "sendspin_sensor.h"
#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_SENSOR)
#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_METADATA) && defined(USE_SENSOR)
#include <sendspin/metadata_role.h>
@@ -2,7 +2,7 @@
#include "esphome/core/defines.h"
#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_SENSOR)
#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_METADATA) && defined(USE_SENSOR)
#include "esphome/components/sendspin/sendspin_hub.h"
#include "esphome/components/sensor/sensor.h"
+77 -11
View File
@@ -1,35 +1,101 @@
import logging
import esphome.codegen as cg
from esphome.components import switch
from esphome.components.const import CONF_ENABLED
import esphome.config_validation as cv
from esphome.const import CONF_TYPE, ENTITY_CATEGORY_CONFIG
from esphome.const import CONF_PLATFORM, CONF_SWITCH, CONF_TYPE, ENTITY_CATEGORY_CONFIG
import esphome.final_validate as fv
from esphome.types import ConfigType
from .. import CONF_SENDSPIN_ID, SendspinHub, sendspin_ns
from .. import (
CONF_SENDSPIN_ID,
CONF_STATIC_PAIRING_CODE,
CONF_UNPAIRED_ACCESS,
DOMAIN,
SendspinHub,
_has_pairing_method,
request_switch,
sendspin_ns,
)
_LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@kahrendt"]
DEPENDENCIES = ["sendspin"]
SendspinSwitch = sendspin_ns.class_("SendspinSwitch", switch.Switch, cg.Component)
SendspinEnabledSwitch = sendspin_ns.class_(
"SendspinEnabledSwitch", switch.Switch, cg.Component
)
SendspinUnpairedAccessSwitch = sendspin_ns.class_(
"SendspinUnpairedAccessSwitch", switch.Switch, cg.Component
)
def _switch_schema(class_: cg.MockObjClass) -> cv.Schema:
return (
switch.switch_schema(
class_,
block_inverted=True,
default_restore_mode="RESTORE_DEFAULT_ON",
entity_category=ENTITY_CATEGORY_CONFIG,
)
.extend({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)})
.extend(cv.COMPONENT_SCHEMA)
)
def _request_switch(config: ConfigType) -> ConfigType:
"""Tell the hub to wait for this switch before the client's first start."""
request_switch(config[CONF_TYPE])
return config
CONFIG_SCHEMA = cv.All(
cv.typed_schema(
{
CONF_ENABLED: switch.switch_schema(
SendspinSwitch,
block_inverted=True,
default_restore_mode="RESTORE_DEFAULT_ON",
entity_category=ENTITY_CATEGORY_CONFIG,
)
.extend({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)})
.extend(cv.COMPONENT_SCHEMA),
CONF_ENABLED: _switch_schema(SendspinEnabledSwitch),
CONF_UNPAIRED_ACCESS: _switch_schema(SendspinUnpairedAccessSwitch),
},
key=CONF_TYPE,
),
cv.only_on_esp32,
_request_switch,
)
def _final_validate(config: ConfigType) -> ConfigType:
full_config = fv.full_config.get()
switch_type = config[CONF_TYPE]
same_type = [
conf
for conf in full_config.get(CONF_SWITCH, [])
if conf.get(CONF_PLATFORM) == DOMAIN and conf.get(CONF_TYPE) == switch_type
]
# Two switches of one type would each drive the same hub setting.
if len(same_type) > 1:
raise cv.Invalid(f"Only one sendspin '{switch_type}' switch is allowed")
if switch_type != CONF_UNPAIRED_ACCESS:
return config
hub_config = full_config.get(DOMAIN, {})
if CONF_UNPAIRED_ACCESS in hub_config:
raise cv.Invalid(
f"'{DOMAIN}: {CONF_UNPAIRED_ACCESS}' cannot be set together with an "
f"{CONF_UNPAIRED_ACCESS} switch; set the switch's restore_mode instead"
)
if not _has_pairing_method(hub_config):
_LOGGER.warning(
"The %s switch has no pairing method (%s or a dynamic pairing code), so "
"while it is off no new server can pair with this device",
CONF_UNPAIRED_ACCESS,
CONF_STATIC_PAIRING_CODE,
)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config: ConfigType) -> None:
var = await switch.new_switch(config)
await cg.register_component(var, config)
@@ -1,26 +1,49 @@
#include "sendspin_switch.h"
#ifdef USE_ESP32
#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_SWITCH)
#include "esphome/core/log.h"
#include "esphome/core/preferences.h"
namespace esphome::sendspin_ {
static const char *const TAG = "sendspin.switch";
void SendspinSwitch::setup() {
// --- SendspinEnabledSwitch ---
void SendspinEnabledSwitch::setup() {
// The hub waits for this request, so a restore mode without a state still has to answer.
this->control(this->get_initial_state_with_restore_mode().value_or(true));
}
void SendspinSwitch::dump_config() { LOG_SWITCH("", "Sendspin Switch", this); }
void SendspinEnabledSwitch::dump_config() { LOG_SWITCH("", "Sendspin Enabled Switch", this); }
// THREAD CONTEXT: Main loop
void SendspinSwitch::write_state(bool state) {
void SendspinEnabledSwitch::write_state(bool state) {
this->parent_->set_enabled(state);
this->publish_state(state);
}
// --- SendspinUnpairedAccessSwitch ---
void SendspinUnpairedAccessSwitch::setup() {
// The hub holds the client's first start for this, so a restore mode without a state still has to answer.
this->control(this->get_initial_state_with_restore_mode().value_or(true));
}
void SendspinUnpairedAccessSwitch::dump_config() { LOG_SWITCH("", "Sendspin Unpaired Access Switch", this); }
// THREAD CONTEXT: Main loop
void SendspinUnpairedAccessSwitch::write_state(bool state) {
const bool turning_off = !state && this->has_state() && this->state;
this->parent_->set_unpaired_access_enabled(state);
this->publish_state(state);
// Write the change to flash now: losing it in a power cut would reopen unpaired access.
if (turning_off) {
global_preferences->sync();
}
}
} // namespace esphome::sendspin_
#endif // USE_ESP32
#endif
@@ -2,7 +2,7 @@
#include "esphome/core/defines.h"
#ifdef USE_ESP32
#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_SWITCH)
#include "esphome/components/sendspin/sendspin_hub.h"
#include "esphome/components/switch/switch.h"
@@ -10,7 +10,19 @@
namespace esphome::sendspin_ {
/// @brief Switch that starts and stops the Sendspin client through the hub (see SendspinHub::set_enabled()).
class SendspinSwitch final : public switch_::Switch, public SendspinChild {
class SendspinEnabledSwitch final : public switch_::Switch, public SendspinChild {
public:
void setup() override;
void dump_config() override;
protected:
void write_state(bool state) override;
};
/// @brief Switch that turns unpaired (Sentinel) access on and off through the hub (see
/// SendspinHub::set_unpaired_access_enabled()). The library does not persist the setting, so the switch's restore
/// mode does.
class SendspinUnpairedAccessSwitch final : public switch_::Switch, public SendspinChild {
public:
void setup() override;
void dump_config() override;
@@ -21,4 +33,4 @@ class SendspinSwitch final : public switch_::Switch, public SendspinChild {
} // namespace esphome::sendspin_
#endif // USE_ESP32
#endif
@@ -4,16 +4,29 @@ import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_TYPE
from esphome.types import ConfigType
from .. import CONF_SENDSPIN_ID, SendspinHub, request_metadata_support, sendspin_ns
from .. import (
CONF_SENDSPIN_ID,
SendspinHub,
request_metadata_support,
request_pairing_code_display_support,
sendspin_ns,
)
CODEOWNERS = ["@kahrendt"]
DEPENDENCIES = ["sendspin"]
CONF_PAIRING_CODE = "pairing_code"
SendspinTextSensor = sendspin_ns.class_(
"SendspinTextSensor",
text_sensor.TextSensor,
cg.Component,
)
SendspinPairingCodeTextSensor = sendspin_ns.class_(
"SendspinPairingCodeTextSensor",
text_sensor.TextSensor,
cg.Component,
)
SendspinTextMetadataTypes = sendspin_ns.enum("SendspinTextMetadataTypes", is_class=True)
SENDSPIN_TEXT_METADATA_TYPES = {
@@ -26,18 +39,29 @@ SENDSPIN_TEXT_METADATA_TYPES = {
def _request_roles(config: ConfigType) -> ConfigType:
"""Request the necessary Sendspin roles for the text sensor."""
request_metadata_support()
if config[CONF_TYPE] == CONF_PAIRING_CODE:
# A display surface for the dynamic code, so advertise dynamic_pairing_code.
request_pairing_code_display_support()
else:
request_metadata_support()
return config
_HUB_ID_SCHEMA = cv.Schema({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)})
_METADATA_SCHEMA = text_sensor.text_sensor_schema(SendspinTextSensor).extend(
_HUB_ID_SCHEMA
)
CONFIG_SCHEMA = cv.All(
text_sensor.text_sensor_schema().extend(
cv.typed_schema(
{
cv.GenerateID(): cv.declare_id(SendspinTextSensor),
cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub),
cv.Required(CONF_TYPE): cv.enum(SENDSPIN_TEXT_METADATA_TYPES),
}
**dict.fromkeys(SENDSPIN_TEXT_METADATA_TYPES, _METADATA_SCHEMA),
CONF_PAIRING_CODE: text_sensor.text_sensor_schema(
SendspinPairingCodeTextSensor
).extend(_HUB_ID_SCHEMA),
},
key=CONF_TYPE,
),
cv.only_on_esp32,
_request_roles,
@@ -50,4 +74,7 @@ async def to_code(config: ConfigType) -> None:
await cg.register_parented(var, config[CONF_SENDSPIN_ID])
await text_sensor.register_text_sensor(var, config)
cg.add(var.set_metadata_type(config[CONF_TYPE]))
if (
metadata_type := SENDSPIN_TEXT_METADATA_TYPES.get(config[CONF_TYPE])
) is not None:
cg.add(var.set_metadata_type(metadata_type))
@@ -1,8 +1,10 @@
#include "sendspin_text_sensor.h"
#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR)
#if defined(USE_ESP_IDF) && defined(USE_TEXT_SENSOR)
#ifdef USE_SENDSPIN_METADATA
#include <sendspin/metadata_role.h>
#endif
#include <string>
@@ -10,6 +12,7 @@ namespace esphome::sendspin_ {
static const char *const TAG = "sendspin.text_sensor";
#ifdef USE_SENDSPIN_METADATA
void SendspinTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Sendspin", this); }
// A field is nullopt when the server has not provided it or has explicitly cleared it. Both mean there is nothing to
@@ -50,6 +53,16 @@ void SendspinTextSensor::publish_if_changed_(const char *value) {
this->publish_state(value);
}
}
#endif // USE_SENDSPIN_METADATA
void SendspinPairingCodeTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Pairing Code", this); }
// THREAD CONTEXT: Main loop. The registered callbacks also fire on the main loop (SendspinHub dispatches them from
// its loop()).
void SendspinPairingCodeTextSensor::setup() {
this->parent_->add_on_display_pairing_code_callback([this](const std::string &code) { this->publish_state(code); });
this->parent_->add_on_clear_pairing_code_callback([this]() { this->publish_state(""); });
}
} // namespace esphome::sendspin_
@@ -2,15 +2,19 @@
#include "esphome/core/defines.h"
#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR)
#if defined(USE_ESP_IDF) && defined(USE_TEXT_SENSOR)
#include "esphome/components/sendspin/sendspin_hub.h"
#include "esphome/components/text_sensor/text_sensor.h"
#ifdef USE_SENDSPIN_METADATA
#include <sendspin/metadata_role.h>
#endif
namespace esphome::sendspin_ {
#ifdef USE_SENDSPIN_METADATA
enum class SendspinTextMetadataTypes {
TITLE,
ARTIST,
@@ -31,6 +35,13 @@ class SendspinTextSensor final : public SendspinChild, public text_sensor::TextS
SendspinTextMetadataTypes metadata_type_;
};
#endif // USE_SENDSPIN_METADATA
class SendspinPairingCodeTextSensor final : public SendspinChild, public text_sensor::TextSensor {
public:
void dump_config() override;
void setup() override;
};
} // namespace esphome::sendspin_
#endif
@@ -15,9 +15,12 @@ static const uint8_t STARTUP_SEQUENCE[][2] PROGMEM = {
{0x60, 0x01}, // ADR_PIN_CTRL: output
{0x7D, 0x11},
{0x7E, 0xFF},
{0x00, 0x01},
{0x00, 0x01}, // Page 1
{0x51, 0x05},
{0x00, 0x00},
{0x00, 0x00}, // Page 0
// Additional register configuration
{0x53, 0x60}, // ANA_CTRL: for high audio performance use 175kHz bandwidth with Fsw=768kHz
};
static const LogString *model_name() { return LOG_STR("TAS5805M"); }
+13 -14
View File
@@ -4,13 +4,11 @@
namespace esphome::tas58xx {
// Remainder of the startup sequence, run after the reset. Register 0x00 selects the page.
// All writes except starting and last three register writes are exactly the startup sequence from TI PurePath Console.
// Registers 0x46, 0x7D, 0x7E, page 1 register 0x51 and Page 2 registers 0x1D, 0x19 are not documented in the datasheet.
// clang-format off
// Remainder of the startup sequence, run after the reset in TAS58xx::init_(). Register 0x00 selects the page.
// Based on the TI PurePath Console (PPC) export for "TAS5825M 2-Band DRC&AGL 2.0 96k".
// Registers 0x46, 0x7D, 0x7E, page 1 register 0x51 and page 2 registers 0x1D, 0x19 are not documented in datasheet.
static const uint8_t STARTUP_SEQUENCE[][2] PROGMEM = {
{0x03, 0x00}, // Deep Sleep
{0x03, 0x00}, // DEVICE_CTRL_2: deep sleep, the state the PPC sequence starts from
{0x7D, 0x11},
{0x7E, 0xFF},
{0x00, 0x01}, // Page 1
@@ -20,15 +18,16 @@ static const uint8_t STARTUP_SEQUENCE[][2] PROGMEM = {
{0x19, 0x80},
{0x00, 0x00}, // Page 0
{0x46, 0x11},
{0x02, 0x00}, // DEVICE_CTRL_1 - BD MODE, Damp to PBL MODE
{0x53, 0x01}, // ANA_CTRL PWM Phase Control - in phase
{0x54, 0x00}, // AGAIN 0dB
{0x03, 0x02}, // Hi-Z
{0x61, 0x0B}, // GPIO0_SEL - GPIO0 as FAULTZ output
{0x60, 0x01}, // GPIO0_OE - Enable GPIO0 as output
{0x77, 0x07}, // CBC_CONTROL enabling CBC function for warnings and faults
// PPC also writes 0x02 (DEVICE_CTRL_1) and 0x54 (AGAIN) here; init_() writes both from YAML
{0x53, 0x01}, // ANA_CTRL: in phase, 100kHz bandwidth for high audio performance with Fsw=384kHz
{0x03, 0x02}, // DEVICE_CTRL_2: Hi-Z
// Additional register configuration
// GPIO1 is a dedicated pin on TAS5825M (separate from ADR), used as FAULTZ
{0x62, 0x0B}, // GPIO1_SEL: GPIO1 as FAULTZ
{0x60, 0x02}, // GPIO_CTRL: GPIO1 is output
{0x77, 0x07}, // CBC_CONTROL: enable cycle-by-cycle current limit for warnings and faults
};
// clang-format on
static const LogString *model_name() { return LOG_STR("TAS5825M"); }
+2 -1
View File
@@ -4,7 +4,8 @@ import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.types import ConfigType
CODEOWNERS = ["@0hax"]
CODEOWNERS = ["@0hax", "@FredM67"]
DOMAIN = "teleinfo"
MULTI_CONF = True
teleinfo_ns = cg.esphome_ns.namespace("teleinfo")
+136 -8
View File
@@ -1,10 +1,32 @@
import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_DEVICE_CLASS,
CONF_ICON,
CONF_ID,
CONF_STATE_CLASS,
CONF_UNIT_OF_MEASUREMENT,
DEVICE_CLASS_APPARENT_POWER,
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_ENERGY,
DEVICE_CLASS_POWER,
DEVICE_CLASS_REACTIVE_ENERGY,
DEVICE_CLASS_VOLTAGE,
ICON_FLASH,
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
UNIT_AMPERE,
UNIT_KILOVOLT_AMPS,
UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
UNIT_KILOWATT,
UNIT_KILOWATT_HOURS,
UNIT_MILLIAMP,
UNIT_MILLIVOLT,
UNIT_VOLT,
UNIT_VOLT_AMPS,
UNIT_VOLT_AMPS_REACTIVE_HOURS,
UNIT_WATT,
UNIT_WATT_HOURS,
)
from esphome.types import ConfigType
@@ -13,19 +35,125 @@ from .. import CONF_TAG_NAME, CONF_TELEINFO_ID, TELEINFO_LISTENER_SCHEMA, telein
TeleInfoSensor = teleinfo_ns.class_("TeleInfoSensor", sensor.Sensor, cg.Component)
CONFIG_SCHEMA = sensor.sensor_schema(
TeleInfoSensor,
unit_of_measurement=UNIT_WATT_HOURS,
icon=ICON_FLASH,
accuracy_decimals=0,
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
).extend(TELEINFO_LISTENER_SCHEMA)
def _preset(unit: str, device_class: str, state_class: str) -> dict[str, str]:
return {
CONF_UNIT_OF_MEASUREMENT: unit,
CONF_DEVICE_CLASS: device_class,
CONF_STATE_CLASS: state_class,
}
_ENERGY = _preset(UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING)
_REACTIVE_ENERGY = _preset(
UNIT_VOLT_AMPS_REACTIVE_HOURS,
DEVICE_CLASS_REACTIVE_ENERGY,
STATE_CLASS_TOTAL_INCREASING,
)
_CURRENT = _preset(UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT)
_VOLTAGE = _preset(UNIT_VOLT, DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT)
_APPARENT_POWER_VA = _preset(
UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER, STATE_CLASS_MEASUREMENT
)
_APPARENT_POWER_KVA = _preset(
UNIT_KILOVOLT_AMPS, DEVICE_CLASS_APPARENT_POWER, STATE_CLASS_MEASUREMENT
)
_POWER = _preset(UNIT_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT)
# Legacy defaults, kept for tags without a known preset
_LEGACY_DEFAULT = {**_ENERGY, CONF_ICON: ICON_FLASH}
# Units that are valid for each device class, used to decide whether a preset's
# device class can be kept when the user overrides the unit (and vice versa)
_COMPATIBLE_UNITS = {
DEVICE_CLASS_ENERGY: {UNIT_WATT_HOURS, UNIT_KILOWATT_HOURS},
DEVICE_CLASS_REACTIVE_ENERGY: {
UNIT_VOLT_AMPS_REACTIVE_HOURS,
UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
},
DEVICE_CLASS_CURRENT: {UNIT_AMPERE, UNIT_MILLIAMP},
DEVICE_CLASS_VOLTAGE: {UNIT_VOLT, UNIT_MILLIVOLT},
DEVICE_CLASS_APPARENT_POWER: {UNIT_VOLT_AMPS, UNIT_KILOVOLT_AMPS},
DEVICE_CLASS_POWER: {UNIT_WATT, UNIT_KILOWATT},
}
# Presets by tag prefix. No prefix is a prefix of another, so order is irrelevant.
TIC_TAG_CONFIGS = {
# Standard mode
"EA": _ENERGY,
"ER": _REACTIVE_ENERGY,
"IRMS": _CURRENT,
"URMS": _VOLTAGE,
"UMOY": _VOLTAGE,
"SINST": _APPARENT_POWER_VA,
"SMAX": _APPARENT_POWER_VA,
"CC": _POWER,
"PREF": _APPARENT_POWER_KVA,
"PCOUP": _APPARENT_POWER_KVA,
# Historical mode
"BASE": _ENERGY,
"HCH": _ENERGY,
"EJP": _ENERGY,
"BBRH": _ENERGY,
"IINST": _CURRENT,
"IMAX": _CURRENT,
"ISOUSC": _CURRENT,
"ADPS": _CURRENT,
"ADIR": _CURRENT,
"PAPP": _APPARENT_POWER_VA,
"PMAX": _POWER,
}
def apply_tag_config(config: ConfigType) -> ConfigType:
"""Apply preset configurations based on the tag name.
Only keys not set by the user are filled in. For known tags, a preset unit or
device class is dropped if it is incompatible with the one set by the user.
Unknown tags get the legacy defaults, merged key by key as before.
"""
if CONF_TAG_NAME not in config:
return config
# Runs before cv.string, so the raw value may not be a string yet
tag_name = str(config[CONF_TAG_NAME])
preset = next(
(p for prefix, p in TIC_TAG_CONFIGS.items() if tag_name.startswith(prefix)),
None,
)
config = dict(config)
if preset is None:
for key, value in _LEGACY_DEFAULT.items():
config.setdefault(key, value)
return config
unit = config.get(CONF_UNIT_OF_MEASUREMENT, preset[CONF_UNIT_OF_MEASUREMENT])
device_class = config.get(CONF_DEVICE_CLASS, preset[CONF_DEVICE_CLASS])
compatible = unit in _COMPATIBLE_UNITS.get(device_class, ())
for key, value in preset.items():
if key in config:
continue
if key in (CONF_UNIT_OF_MEASUREMENT, CONF_DEVICE_CLASS) and not compatible:
continue
config[key] = value
return config
CONFIG_SCHEMA = cv.All(
apply_tag_config,
sensor.sensor_schema(
TeleInfoSensor,
accuracy_decimals=0,
).extend(TELEINFO_LISTENER_SCHEMA),
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID], config[CONF_TAG_NAME])
await cg.register_component(var, config)
await sensor.register_sensor(var, config)
teleinfo = await cg.get_variable(config[CONF_TELEINFO_ID])
cg.add(teleinfo.register_teleinfo_listener(var))
+3 -4
View File
@@ -116,8 +116,7 @@ void TeleInfo::loop() {
* 0xa | Tag | 0x9 | Timestamp | 0x9 | 0x9 | CRC | 0xd
*
*/
while ((buf_finger = static_cast<char *>(memchr(buf_finger, (int) 0xa, buf_index_ - 1))) &&
((buf_finger - buf_) < buf_index_)) { // NOLINT(clang-diagnostic-sign-compare)
while ((buf_finger = static_cast<char *>(memchr(buf_finger, (int) 0xa, buf_end - buf_finger)))) {
/*
* Make sure timesamp is nullified between each tag as some tags don't
* have a timestamp
@@ -168,8 +167,8 @@ void TeleInfo::loop() {
continue;
}
/* Advance buf_finger to end of group */
buf_finger += field_len + 1 + 1 + 1;
/* Resume after the group's 0xd; grp_end < buf_end, so this stays in bounds. */
buf_finger = grp_end + 1;
publish_value_(std::string(tag_), std::string(val_));
}
+8
View File
@@ -26,6 +26,8 @@ from esphome.const import (
CONF_SECONDS,
CONF_TIMEZONE,
CONF_TRIGGER_ID,
KEY_CORE,
KEY_FRAMEWORK_VERSION,
PLATFORM_BK72XX,
PLATFORM_ESP32,
PLATFORM_ESP8266,
@@ -462,6 +464,12 @@ async def register_time(time_var, config):
async def to_code(config):
if CORE.using_zephyr:
zephyr_add_prj_conf("POSIX_CLOCK", True)
# From sdk-nrf 2.8.0 newlib time() gets the clock through gettimeofday(),
# which Zephyr only provides with XSI_SINGLE_PROCESS. Without it time()
# always returns -1.
if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] >= cv.Version(2, 8, 0):
zephyr_add_prj_conf("POSIX_SINGLE_PROCESS", True)
zephyr_add_prj_conf("XSI_SINGLE_PROCESS", True)
cg.add_define("USE_TIME")
cg.add_global(time_ns.using)
@@ -0,0 +1,179 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include "esphome/core/helpers.h"
namespace esphome::uart {
/// Software serial RX decoder: on_edge() counts bits from the time between edges;
/// finalize() completes a byte whose idle high tail has no closing edge.
/// Times are in any steady tick unit.
class SoftwareSerialRxDecoder {
public:
static constexpr uint8_t RX_IDLE = 0xFF;
/// Configure framing and buffer; drops all state. Follow with reset().
void setup(uint32_t bit_ticks, uint8_t data_bits, bool parity, uint8_t stop_bits, uint8_t *buffer,
size_t buffer_size) {
this->bit_ticks_ = bit_ticks;
this->data_bits_ = data_bits;
this->stop_bit_ = data_bits + (parity ? 1 : 0);
this->max_run_ticks_ = bit_ticks * (this->stop_bit_ + stop_bits + 2);
this->buffer_ = buffer;
this->buffer_size_ = buffer_size;
this->in_pos_ = 0;
this->out_pos_ = 0;
this->reset(0, true);
}
/// Forget any partial frame; `level` is the current line level.
void reset(uint32_t now, bool level) {
this->bit_ = RX_IDLE;
this->cur_byte_ = 0;
this->last_level_ = level;
this->last_edge_ = now;
}
/// ISR. True to wake the loop: a byte was pushed, or the line went high in an
/// open frame (a data 1 and the idle tail look alike) so finalize() may be needed.
bool ESPHOME_ALWAYS_INLINE on_edge(uint32_t now, bool level) {
const bool last_level = this->last_level_;
// Collapsed edge
if (level == last_level)
return false;
// LX106 has no divider, so count
uint32_t delta = now - this->last_edge_;
if (delta > this->max_run_ticks_)
delta = this->max_run_ticks_;
delta += this->bit_ticks_ / 2;
uint32_t bits = 0;
while (delta >= this->bit_ticks_) {
delta -= this->bit_ticks_;
bits++;
}
const bool pushed = this->consume_run_(bits, last_level);
this->last_edge_ = now;
this->last_level_ = level;
return pushed || (level && this->bit_ != RX_IDLE);
}
bool pending() const { return this->bit_ != RX_IDLE && this->last_level_; }
bool finalize_due(uint32_t now) const {
const uint8_t bit = this->bit_;
if (bit == RX_IDLE)
return false;
return now - this->last_edge_ >= this->tail_ticks_(bit);
}
/// Call with the ISR masked.
void finalize(uint32_t now) {
if (!this->last_level_ || !this->finalize_due(now))
return;
// Remaining bits are all ones
const uint8_t bit = this->bit_;
uint8_t cur = this->cur_byte_;
if (bit < this->data_bits_)
cur |= ((1U << this->data_bits_) - 1) & ~((1U << bit) - 1);
this->push_byte(cur);
this->bit_ = RX_IDLE;
}
/// Drops the byte when full; holds buffer_size - 1 bytes.
bool ESPHOME_ALWAYS_INLINE push_byte(uint8_t data) {
size_t in = this->in_pos_;
size_t next = in + 1;
if (next == this->buffer_size_)
next = 0;
if (next == this->out_pos_)
return false;
this->buffer_[in] = data;
this->in_pos_ = next;
return true;
}
uint8_t *buffer() const { return this->buffer_; }
size_t buffer_size() const { return this->buffer_size_; }
size_t available() const {
// Read volatile in_pos_ once to avoid TOCTOU race with ISR.
size_t in = this->in_pos_;
if (in >= this->out_pos_)
return in - this->out_pos_;
return this->buffer_size_ - this->out_pos_ + in;
}
uint8_t peek_byte() const {
if (this->in_pos_ == this->out_pos_)
return 0;
return this->buffer_[this->out_pos_];
}
uint8_t read_byte() {
if (this->in_pos_ == this->out_pos_)
return 0;
uint8_t data = this->buffer_[this->out_pos_];
size_t next = this->out_pos_ + 1;
this->out_pos_ = next == this->buffer_size_ ? 0 : next;
return data;
}
protected:
uint32_t tail_ticks_(uint8_t bit) const {
return (this->stop_bit_ + 1 - bit) * this->bit_ticks_ + this->bit_ticks_ / 2;
}
bool ESPHOME_ALWAYS_INLINE consume_run_(uint32_t bits, bool level) {
uint8_t bit = this->bit_;
uint8_t cur = this->cur_byte_;
bool pushed = false;
while (bits > 0) {
if (bit == RX_IDLE) {
if (level)
break;
// Start bit
bit = 0;
cur = 0;
bits--;
} else if (bit < this->data_bits_) {
uint8_t n = this->data_bits_ - bit;
if (n > bits)
n = bits;
if (level)
cur |= ((1U << n) - 1) << bit;
bit += n;
bits -= n;
} else if (bit < this->stop_bit_) {
// Parity, not checked
bit++;
bits--;
} else {
// Stop bit; low is a framing error
if (level)
pushed = this->push_byte(cur);
bit = RX_IDLE;
break;
}
}
this->bit_ = bit;
this->cur_byte_ = cur;
return pushed;
}
// Members ordered largest to smallest to minimize padding
uint32_t bit_ticks_{0};
uint32_t max_run_ticks_{0};
volatile uint32_t last_edge_{0};
uint8_t *buffer_{nullptr};
size_t buffer_size_{0};
volatile size_t in_pos_{0};
size_t out_pos_{0}; // reader only
/// Next frame bit (data, parity, then stop at stop_bit_) or RX_IDLE.
volatile uint8_t bit_{RX_IDLE};
volatile uint8_t cur_byte_{0};
volatile bool last_level_{true};
uint8_t data_bits_{8};
uint8_t stop_bit_{8};
};
} // namespace esphome::uart
+2 -2
View File
@@ -10,8 +10,8 @@ namespace esphome::uart {
static const char *const TAG = "uart";
// UART parity strings indexed by UARTParityOptions enum (0-2): NONE, EVEN, ODD
PROGMEM_STRING_TABLE(UARTParityStrings, "NONE", "EVEN", "ODD", "UNKNOWN");
// UART parity strings indexed by UARTParityOptions enum (0-4): NONE, EVEN, ODD, MARK, SPACE
PROGMEM_STRING_TABLE(UARTParityStrings, "NONE", "EVEN", "ODD", "MARK", "SPACE", "UNKNOWN");
void UARTDevice::check_uart_settings(uint32_t baud_rate, uint8_t stop_bits, UARTParityOptions parity,
uint8_t data_bits) {
+3
View File
@@ -18,6 +18,9 @@ enum UARTParityOptions {
UART_CONFIG_PARITY_NONE,
UART_CONFIG_PARITY_EVEN,
UART_CONFIG_PARITY_ODD,
// Only USB serial adapters (usb_uart) support these
UART_CONFIG_PARITY_MARK,
UART_CONFIG_PARITY_SPACE,
};
#ifdef USE_UART_DEBUGGER
@@ -15,7 +15,13 @@
namespace esphome::uart {
static const char *const TAG = "uart";
ESPHOME_LOG_TAG(TAG, "uart");
// Edge decoder up to this baud rate; at 38400 WiFi interrupt latency corrupts bytes.
static constexpr uint32_t SW_SERIAL_EDGE_MODE_MAX_BAUD = 19200;
// Edge decoder time base: 1/16 us from the 1 MHz system timer, which keeps its rate during a
// CpuFrequencyBoost; the cycle counter does not.
static constexpr uint32_t RX_TICKS_PER_SECOND = 16000000;
__attribute__((always_inline)) static inline uint32_t rx_now() { return micros() << 4; }
bool ESP8266UartComponent::serial0_in_use = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
uint32_t ESP8266UartComponent::get_config() {
@@ -158,6 +164,11 @@ void ESP8266UartComponent::dump_config() {
"\n Wake on data RX: ENABLED"
#endif
);
if (this->rx_pin_ != nullptr) {
ESP_LOGCONFIG(TAG, " RX decoder: %s",
this->baud_rate_ <= SW_SERIAL_EDGE_MODE_MAX_BAUD ? LOG_STR_LITERAL("edge")
: LOG_STR_LITERAL("start bit sampler"));
}
}
this->check_logger_conflict();
}
@@ -239,23 +250,39 @@ UARTFlushResult ESP8266UartComponent::flush() {
void ESP8266SoftwareSerial::setup(InternalGPIOPin *tx_pin, InternalGPIOPin *rx_pin, uint32_t baud_rate,
uint8_t stop_bits, uint32_t data_bits, UARTParityOptions parity,
size_t rx_buffer_size) {
// load_settings() re-enters here
if (this->gpio_rx_pin_ != nullptr)
this->gpio_rx_pin_->detach_interrupt();
this->bit_time_ = F_CPU / baud_rate;
this->rx_buffer_size_ = rx_buffer_size;
this->stop_bits_ = stop_bits;
this->data_bits_ = data_bits;
this->parity_ = parity;
// to_isr() allocates, so call it only for a new or changed pin
if (tx_pin != nullptr) {
if (tx_pin != this->gpio_tx_pin_)
this->tx_pin_ = tx_pin->to_isr();
gpio_tx_pin_ = tx_pin;
gpio_tx_pin_->setup();
tx_pin_ = gpio_tx_pin_->to_isr();
tx_pin_.digital_write(true);
}
if (rx_pin != nullptr) {
if (rx_pin != this->gpio_rx_pin_)
this->rx_pin_ = rx_pin->to_isr();
gpio_rx_pin_ = rx_pin;
gpio_rx_pin_->setup();
rx_pin_ = gpio_rx_pin_->to_isr();
rx_buffer_ = new uint8_t[this->rx_buffer_size_]; // NOLINT
gpio_rx_pin_->attach_interrupt(ESP8266SoftwareSerial::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE);
uint8_t *buffer = this->rx_.buffer();
if (this->rx_.buffer_size() != rx_buffer_size) {
delete[] buffer; // NOLINT
buffer = new uint8_t[rx_buffer_size]; // NOLINT
}
this->rx_.setup((RX_TICKS_PER_SECOND + baud_rate / 2) / baud_rate, data_bits, parity != UART_CONFIG_PARITY_NONE,
stop_bits, buffer, rx_buffer_size);
this->rx_.reset(rx_now(), this->rx_pin_.digital_read());
if (baud_rate <= SW_SERIAL_EDGE_MODE_MAX_BAUD) {
gpio_rx_pin_->attach_interrupt(ESP8266SoftwareSerial::gpio_intr_edge, this, gpio::INTERRUPT_ANY_EDGE);
} else {
gpio_rx_pin_->attach_interrupt(ESP8266SoftwareSerial::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE);
}
}
}
// A byte can arrive while a CpuFrequencyBoost has an 80 MHz build at 160 MHz; the clock select bit doubles
@@ -288,8 +315,7 @@ void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr(ESP8266SoftwareSerial *arg) {
if (arg->stop_bits_ == 2)
arg->wait_(&wait, start, bit_time);
arg->rx_buffer_[arg->rx_in_pos_] = rec;
arg->rx_in_pos_ = (arg->rx_in_pos_ + 1) % arg->rx_buffer_size_;
arg->rx_.push_byte(rec);
// Clear RX pin so that the interrupt doesn't re-trigger right away again.
arg->rx_pin_.clear_interrupt();
#ifdef USE_UART_WAKE_LOOP_ON_RX
@@ -299,6 +325,27 @@ void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr(ESP8266SoftwareSerial *arg) {
wake_loop_isrsafe();
#endif
}
void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr_edge(ESP8266SoftwareSerial *arg) {
const uint32_t now = rx_now();
const bool level = arg->rx_pin_.digital_read();
[[maybe_unused]] const bool wake = arg->rx_.on_edge(now, level);
#ifdef USE_UART_WAKE_LOOP_ON_RX
if (wake)
wake_loop_isrsafe();
#endif
}
void ESP8266SoftwareSerial::rx_finalize_pending_() {
if (!this->rx_.finalize_due(rx_now())) {
#ifdef USE_UART_WAKE_LOOP_ON_RX
// Tail not elapsed; wake again only once drained
if (this->rx_.available() == 0)
wake_loop_threadsafe();
#endif
return;
}
InterruptLock lock;
this->rx_.finalize(rx_now());
}
void IRAM_ATTR HOT ESP8266SoftwareSerial::write_byte(uint8_t data) {
if (this->gpio_tx_pin_ == nullptr) {
ESP_LOGE(TAG, "UART doesn't have TX pins set!");
@@ -350,28 +397,19 @@ void IRAM_ATTR ESP8266SoftwareSerial::write_bit_(bool bit, uint32_t *wait, const
this->wait_(wait, start, bit_time);
}
uint8_t ESP8266SoftwareSerial::read_byte() {
if (this->rx_in_pos_ == this->rx_out_pos_)
return 0;
uint8_t data = this->rx_buffer_[this->rx_out_pos_];
this->rx_out_pos_ = (this->rx_out_pos_ + 1) % this->rx_buffer_size_;
return data;
this->rx_sync_();
return this->rx_.read_byte();
}
uint8_t ESP8266SoftwareSerial::peek_byte() {
if (this->rx_in_pos_ == this->rx_out_pos_)
return 0;
return this->rx_buffer_[this->rx_out_pos_];
this->rx_sync_();
return this->rx_.peek_byte();
}
void ESP8266SoftwareSerial::flush() {
// Flush is a NO-OP with software serial, all bytes are written immediately.
}
size_t ESP8266SoftwareSerial::available() {
// Read volatile rx_in_pos_ once to avoid TOCTOU race with ISR.
// When in >= out, data is contiguous: [out..in).
// When in < out, data wraps: [out..buf_size) + [0..in).
size_t in = this->rx_in_pos_;
if (in >= this->rx_out_pos_)
return in - this->rx_out_pos_;
return this->rx_buffer_size_ - this->rx_out_pos_ + in;
this->rx_sync_();
return this->rx_.available();
}
} // namespace esphome::uart
@@ -7,6 +7,7 @@
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include "software_serial_rx_decoder.h"
#include "uart_component.h"
namespace esphome::uart {
@@ -26,24 +27,30 @@ class ESP8266SoftwareSerial {
size_t available();
protected:
/// Above SW_SERIAL_EDGE_MODE_MAX_BAUD; reads the whole byte in the ISR.
static void gpio_intr(ESP8266SoftwareSerial *arg);
static void gpio_intr_edge(ESP8266SoftwareSerial *arg);
void wait_(uint32_t *wait, const uint32_t &start, uint32_t bit_time);
bool read_bit_(uint32_t *wait, const uint32_t &start, uint32_t bit_time);
void write_bit_(bool bit, uint32_t *wait, const uint32_t &start, uint32_t bit_time);
void rx_finalize_pending_();
void ESPHOME_ALWAYS_INLINE rx_sync_() {
if (this->rx_.pending())
this->rx_finalize_pending_();
}
// Members ordered largest to smallest to minimize padding
uint32_t bit_time_{0};
uint8_t *rx_buffer_{nullptr};
size_t rx_buffer_size_;
volatile size_t rx_in_pos_{0};
size_t rx_out_pos_{0};
uint8_t stop_bits_;
uint8_t data_bits_;
UARTParityOptions parity_;
InternalGPIOPin *gpio_tx_pin_{nullptr};
ISRInternalGPIOPin tx_pin_;
InternalGPIOPin *gpio_rx_pin_{nullptr};
ISRInternalGPIOPin rx_pin_;
SoftwareSerialRxDecoder rx_;
UARTParityOptions parity_;
uint8_t stop_bits_;
uint8_t data_bits_;
};
class ESP8266UartComponent final : public UARTComponent, public Component {
@@ -158,6 +158,8 @@ void HostUartComponent::setup() {
options.c_cflag |= PARENB;
options.c_cflag |= PARODD;
break;
default:
break;
};
// Set stop bits
if (this->stop_bits_ == 2) {
@@ -38,6 +38,8 @@ uint16_t LibreTinyUARTComponent::get_config() {
case UART_CONFIG_PARITY_ODD:
config |= SERIAL_PARITY_ODD;
break;
default:
break;
}
config |= (this->data_bits_ - 4) << 8;
+39 -14
View File
@@ -1,4 +1,5 @@
from itertools import combinations
import itertools
import logging
import esphome.codegen as cg
from esphome.components.const import CONF_MANUFACTURER
@@ -22,6 +23,8 @@ import esphome.final_validate as fv
from esphome.helpers import cpp_u16string_escape
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
AUTO_LOAD = ["bytebuffer"]
CODEOWNERS = ["@clydebarrow"]
DEPENDENCIES = ["esp32"]
@@ -71,15 +74,19 @@ def usb_device_schema(
)
def validate_usb_clients(configs: list[ConfigType]) -> list[ConfigType]:
def _clients_overlap(first: ConfigType, second: ConfigType) -> bool:
# Two entries overlap when no field they both constrain tells them apart
for first, second in combinations(configs, 2):
for key, wildcard in _FILTER_WILDCARDS.items():
a = first.get(key)
b = second.get(key)
if wildcard not in (a, b) and a != b:
break
else:
for key, wildcard in _FILTER_WILDCARDS.items():
a = first.get(key)
b = second.get(key)
if wildcard not in (a, b) and a != b:
return False
return True
def validate_usb_clients(configs: list[ConfigType]) -> list[ConfigType]:
for first, second in itertools.combinations(configs, 2):
if _clients_overlap(first, second):
raise cv.Invalid(
f"USB configs overlap: {first[CONF_ID]}, {second[CONF_ID]}"
)
@@ -87,11 +94,29 @@ def validate_usb_clients(configs: list[ConfigType]) -> list[ConfigType]:
def _final_validate(config: ConfigType) -> ConfigType:
# Every USB client on the bus, whichever component configured it: any two could
# otherwise open the same device
clients = list(config.get(CONF_DEVICES) or ())
clients.extend(fv.full_config.get().get("usb_uart") or ())
validate_usb_clients(clients)
# Any two overlapping clients could otherwise open the same device
devices = config.get(CONF_DEVICES) or []
uarts = fv.full_config.get().get("usb_uart") or []
validate_usb_clients(devices)
validate_usb_clients(uarts)
# Remove before 2027.10.0, then pool devices and uarts in one validate_usb_clients call
for device, uart in itertools.product(devices, uarts):
if not _clients_overlap(device, uart):
continue
# The device matches nothing the usb_uart entry does not already claim
redundant = all(
uart.get(key) in (wildcard, device.get(key))
for key, wildcard in _FILTER_WILDCARDS.items()
)
_LOGGER.warning(
"usb_host device '%s' matches the same USB device as usb_uart '%s'; %s. "
"This will be an error in 2027.10.0",
device[CONF_ID],
uart[CONF_ID],
"remove it from usb_host devices"
if redundant
else "narrow its filter so it no longer matches that device",
)
return config
+6 -2
View File
@@ -1,7 +1,12 @@
import esphome.codegen as cg
from esphome.components.const import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS
from esphome.components.esp32 import VARIANT_ESP32P4, get_esp32_variant
from esphome.components.uart import CONF_DEBUG_PREFIX, CONF_FLUSH_TIMEOUT, UARTComponent
from esphome.components.uart import (
CONF_DEBUG_PREFIX,
CONF_FLUSH_TIMEOUT,
UARTComponent,
UARTParityOptions,
)
from esphome.components.usb_host import (
get_max_packet_size,
register_usb_client,
@@ -38,7 +43,6 @@ def is_usb_uart_channel(uart_id: ID, full_config: ConfigType) -> bool:
)
UARTParityOptions = usb_uart_ns.enum("UARTParityOptions")
UART_PARITY_OPTIONS = {
"NONE": UARTParityOptions.UART_CONFIG_PARITY_NONE,
"EVEN": UARTParityOptions.UART_CONFIG_PARITY_EVEN,
+5 -8
View File
@@ -132,15 +132,12 @@ bool USBUartTypeCH34X::config_step(USBUartChannelBase *channel, uint8_t step, bo
factor = 256 - factor;
uint16_t value = 0xC0;
if (channel->stop_bits_ == UART_CONFIG_STOP_BITS_2)
if (channel->line_stop_bits_() == LineStopBits::LINE_STOP_BITS_2)
value |= 4;
switch (channel->parity_) {
case UART_CONFIG_PARITY_NONE:
break;
default:
value |= 8 | ((channel->parity_ - 1) << 4);
break;
}
// Parity enable, then 0 = odd, 1 = even, 2 = mark, 3 = space
const LineParity parity = channel->line_parity_();
if (parity != LineParity::LINE_PARITY_NONE)
value |= 8 | ((static_cast<uint8_t>(parity) - 1) << 4);
value |= channel->data_bits_ - 5;
value <<= 8;
value |= 0x8C;
+2 -2
View File
@@ -107,8 +107,8 @@ bool USBUartTypeCP210X::config_step(USBUartChannelBase *channel, uint8_t step, b
this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, IFC_ENABLE, 1, channel->index_);
return true;
case 1: {
uint16_t line_control = channel->stop_bits_;
line_control |= static_cast<uint8_t>(channel->parity_) << 4;
uint16_t line_control = static_cast<uint8_t>(channel->line_stop_bits_());
line_control |= static_cast<uint8_t>(channel->line_parity_()) << 4;
line_control |= channel->data_bits_ << 8;
ESP_LOGD(TAG, "Line control value 0x%X", line_control);
this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_LINE_CTL, line_control, channel->index_);
+2 -28
View File
@@ -362,34 +362,8 @@ bool USBUartTypeFT23XX::config_step(USBUartChannelBase *channel, uint8_t step, b
}
case 2: { // set line properties (data bits / parity / stop bits)
uint16_t value = channel->data_bits_;
switch (channel->parity_) {
case UART_CONFIG_PARITY_NONE:
value |= (0x00 << 8);
break;
case UART_CONFIG_PARITY_ODD:
value |= (0x01 << 8);
break;
case UART_CONFIG_PARITY_EVEN:
value |= (0x02 << 8);
break;
case UART_CONFIG_PARITY_MARK:
value |= (0x03 << 8);
break;
case UART_CONFIG_PARITY_SPACE:
value |= (0x04 << 8);
break;
}
switch (channel->stop_bits_) {
default: // 1 bit
value |= (0x00 << 11);
break;
case UART_CONFIG_STOP_BITS_1_5:
value |= (0x01 << 11);
break;
case UART_CONFIG_STOP_BITS_2:
value |= (0x02 << 11);
break;
}
value |= static_cast<uint8_t>(channel->line_parity_()) << 8;
value |= static_cast<uint8_t>(channel->line_stop_bits_()) << 11;
value |= (0x00 << 14);
this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x04, value,
channel->cdc_dev_.bulk_interface_number + 1);
+2 -27
View File
@@ -261,34 +261,9 @@ bool USBUartTypePL2303::config_step(USBUartChannelBase *channel, uint8_t step, b
encode_baud_divisor(line_coding, baud);
}
// Stop bits: 0=1, 1=1.5, 2=2
switch (channel->get_stop_bits()) {
case 2:
line_coding[4] = 2;
break;
default:
line_coding[4] = 0;
break;
}
line_coding[4] = static_cast<uint8_t>(channel->line_stop_bits_());
// Parity: 0=none, 1=odd, 2=even, 3=mark, 4=space
switch (channel->parity_) {
case UART_CONFIG_PARITY_ODD:
line_coding[5] = 1;
break;
case UART_CONFIG_PARITY_EVEN:
line_coding[5] = 2;
break;
case UART_CONFIG_PARITY_MARK:
line_coding[5] = 3;
break;
case UART_CONFIG_PARITY_SPACE:
line_coding[5] = 4;
break;
default:
line_coding[5] = 0;
break;
}
line_coding[5] = static_cast<uint8_t>(channel->line_parity_());
// Data bits
line_coding[6] = channel->get_data_bits();
+40 -10
View File
@@ -270,9 +270,10 @@ void USBUartComponent::dump_config() {
" Flush Timeout: %" PRIu32 " ms\n"
" Debug: %s\n"
" Dummy receiver: %s",
channel->index_, channel->baud_rate_, channel->data_bits_, PARITY_NAMES[channel->parity_],
STOP_BITS_NAMES[channel->stop_bits_], channel->flush_timeout_ms_, YESNO(channel->debug_),
YESNO(channel->dummy_receiver_));
channel->index_, channel->baud_rate_, channel->data_bits_,
PARITY_NAMES[static_cast<uint8_t>(channel->line_parity_())],
STOP_BITS_NAMES[static_cast<uint8_t>(channel->line_stop_bits_())], channel->flush_timeout_ms_,
YESNO(channel->debug_), YESNO(channel->dummy_receiver_));
}
}
void USBUartComponent::start_input(USBUartChannelBase *channel) {
@@ -515,14 +516,17 @@ bool USBUartTypeCdcAcm::config_step(USBUartChannelBase *channel, uint8_t step, b
// sends RSTACK.
uint32_t baud = channel->baud_rate_;
std::vector<uint8_t> line_coding = {
static_cast<uint8_t>(baud & 0xFF), static_cast<uint8_t>((baud >> 8) & 0xFF),
static_cast<uint8_t>((baud >> 16) & 0xFF), static_cast<uint8_t>((baud >> 24) & 0xFF),
static_cast<uint8_t>(channel->stop_bits_), // bCharFormat: 0=1stop, 1=1.5stop, 2=2stop
static_cast<uint8_t>(channel->parity_), // bParityType: 0=None, 1=Odd, 2=Even, 3=Mark, 4=Space
static_cast<uint8_t>(channel->data_bits_), // bDataBits
static_cast<uint8_t>(baud & 0xFF),
static_cast<uint8_t>((baud >> 8) & 0xFF),
static_cast<uint8_t>((baud >> 16) & 0xFF),
static_cast<uint8_t>((baud >> 24) & 0xFF),
static_cast<uint8_t>(channel->line_stop_bits_()), // bCharFormat
static_cast<uint8_t>(channel->line_parity_()), // bParityType
static_cast<uint8_t>(channel->data_bits_), // bDataBits
};
ESP_LOGD(TAG, "SET_LINE_CODING: baud=%u stop=%u parity=%u data=%u", (unsigned) baud, channel->stop_bits_,
(unsigned) channel->parity_, channel->data_bits_);
ESP_LOGD(TAG, "SET_LINE_CODING: baud=%u stop=%s parity=%s data=%u", (unsigned) baud,
STOP_BITS_NAMES[static_cast<uint8_t>(channel->line_stop_bits_())],
PARITY_NAMES[static_cast<uint8_t>(channel->line_parity_())], channel->data_bits_);
this->config_transfer_(CDC_REQUEST_TYPE, CDC_SET_LINE_CODING, 0, channel->cdc_dev_.interrupt_interface_number,
line_coding);
return true;
@@ -675,6 +679,32 @@ bool USBUartComponent::run_config_machine_() {
return true;
}
LineStopBits USBUartChannelBase::line_stop_bits_() const {
switch (this->stop_bits_) {
case UART_CONFIG_STOP_BITS_1_5:
return LineStopBits::LINE_STOP_BITS_1_5;
case UART_CONFIG_STOP_BITS_2:
return LineStopBits::LINE_STOP_BITS_2;
default:
return LineStopBits::LINE_STOP_BITS_1;
}
}
LineParity USBUartChannelBase::line_parity_() const {
switch (this->parity_) {
case uart::UART_CONFIG_PARITY_ODD:
return LineParity::LINE_PARITY_ODD;
case uart::UART_CONFIG_PARITY_EVEN:
return LineParity::LINE_PARITY_EVEN;
case uart::UART_CONFIG_PARITY_MARK:
return LineParity::LINE_PARITY_MARK;
case uart::UART_CONFIG_PARITY_SPACE:
return LineParity::LINE_PARITY_SPACE;
default:
return LineParity::LINE_PARITY_NONE;
}
}
void USBUartChannelBase::load_settings(bool /*dump_config*/) {
// The per-channel control transfers already log their values at debug level.
this->parent_->apply_channel_settings(this);

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