Merge pull request #20421 from esphome/bump-2026.10.0b2

2026.10.0b2
This commit is contained in:
Jesse Hills
2026-10-09 15:42:04 +13:00
committed by GitHub
91 changed files with 2107 additions and 348 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
@@ -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.0b2
# 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.22.0
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.2",
"9377d63f569c524823199b1897ed7fde2063e50e2676dd519a0d758ab73678f7",
37192128,
),
}
+41 -26
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
@@ -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)}")
+4 -2
View File
@@ -207,10 +207,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 +234,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:
+40 -3
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 ====================
@@ -468,11 +476,9 @@ 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
@@ -3071,3 +3077,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
+2 -19
View File
@@ -13,17 +13,6 @@ 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
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;
@@ -49,17 +38,11 @@ const char *wizard_set_input(const WizardInputSetRequest &msg) {
}
#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();
}
}
-4
View File
@@ -22,10 +22,6 @@ 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 {
+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);
+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(
+4
View File
@@ -3074,6 +3074,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
@@ -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.
+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.
+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 -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")
+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"); }
+5
View File
@@ -2872,9 +2872,14 @@ def rename_key(
When ``removed_in`` is set, a deprecation warning is logged if the old key is
present. Pass ``component`` (the platform/component name) alongside
``removed_in`` so the warning identifies where it originates.
Input that is not a dictionary is returned unchanged, so the schema that
follows reports it as a normal configuration error.
"""
def validator(config: dict) -> dict:
if not isinstance(config, dict):
return config
config = config.copy()
if old_key in config:
has_at_most_one_key(old_key, new_key)(config)
+1 -1
View File
@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum
__version__ = "2026.10.0b1"
__version__ = "2026.10.0b2"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (
+2 -2
View File
@@ -673,8 +673,8 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() {
#if defined(USE_LWIP_FAST_SELECT) && defined(ESPHOME_THREAD_MULTI_ATOMICS)
// Pairs with the TCP/IP thread's SYS_ARCH_UNPROTECT release on rcvevent so
// subsequent Socket::ready() checks in this iter observe the published state
// without a per-call memw. Wake is independent (xTaskNotifyGive/
// ulTaskNotifyTake), so non-losing. Skipped on MULTI_NO_ATOMICS (e.g.
// without a per-call memw. Wake is independent (esphome_main_task_notify/
// esphome_main_task_wait), so non-losing. Skipped on MULTI_NO_ATOMICS (e.g.
// BK72xx) — that path keeps `volatile` in esphome_lwip_socket_has_data()
// instead.
std::atomic_thread_fence(std::memory_order_acquire);
+5 -5
View File
@@ -99,10 +99,10 @@
// ESP32-C3/C6/H2) cross-core visibility is not an issue.
//
// FreeRTOS task notification value:
// Written by TCP/IP thread (xTaskNotifyGive in callback) and background tasks
// (xTaskNotifyGive in wake_main_loop). Read by main loop (ulTaskNotifyTake).
// Written by TCP/IP thread (esphome_main_task_notify in callback) and background tasks
// (esphome_main_task_notify in wake_main_loop). Read by main loop (esphome_main_task_wait).
// Safe: FreeRTOS notification APIs are thread-safe by design (use internal
// critical sections). Multiple concurrent xTaskNotifyGive calls are safe —
// critical sections). Multiple concurrent notify calls are safe —
// the notification count simply increments.
// USE_LWIP_FAST_SELECT is set via -D build flag (not cg.add_define) so it is
@@ -178,7 +178,7 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt
// s_original_callback is always valid here: hook_socket() sets it before swapping
// the callback pointer, so this wrapper cannot run until it's initialized.
s_original_callback(conn, evt, len);
// Wake the main loop task if sleeping in ulTaskNotifyTake().
// Wake the main loop task if sleeping in esphome_main_task_wait().
// Only notify on receive events to avoid spurious wakeups from send-ready events.
// NETCONN_EVT_ERROR is deliberately omitted: LwIP signals errors via RCVPLUS
// (rcvevent++ with a NULL pbuf or error in recvmbox), so error conditions
@@ -186,7 +186,7 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt
if (evt == NETCONN_EVT_RCVPLUS) {
#ifdef USE_OTA_PLATFORM_ESPHOME
// Mark OTA pending-enable only for events on its listen socket. MUST happen
// before xTaskNotifyGive so the flags are visible when the main task wakes.
// before the main task is notified so the flags are visible when it wakes.
if (conn == s_ota_listener_conn) {
esphome_wake_ota_component_any_context();
}
+4 -4
View File
@@ -33,9 +33,9 @@ struct lwip_sock *esphome_lwip_get_sock(int fd);
/// per-call `memw` that volatile would emit on Xtensa under default
/// -mserialize-volatile. Without atomics (e.g. BK72xx), the fence is skipped
/// and the volatile load provides ordering on its own.
/// Stale reads are harmless either way: the hooked event_callback
/// xTaskNotifyGives on RCVPLUS, so the next iteration re-snapshots and
/// ulTaskNotifyTake never loses a wake.
/// Stale reads are harmless either way: the hooked event_callback calls
/// esphome_main_task_notify() on RCVPLUS, so the next iteration re-snapshots and
/// esphome_main_task_wait() never loses a wake.
/// The offset and size are verified at compile time in lwip_fast_select.c.
static inline bool esphome_lwip_socket_has_data(struct lwip_sock *sock) {
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
@@ -46,7 +46,7 @@ static inline bool esphome_lwip_socket_has_data(struct lwip_sock *sock) {
}
/// Hook a socket's netconn callback to notify the main loop task on receive events.
/// Wraps the original event_callback with one that also calls xTaskNotifyGive().
/// Wraps the original event_callback with one that also calls esphome_main_task_notify().
/// Must be called from the main loop after socket creation.
/// The sock pointer must have been obtained from esphome_lwip_get_sock().
void esphome_lwip_hook_socket(struct lwip_sock *sock);
+26
View File
@@ -19,12 +19,25 @@ extern "C" {
extern TaskHandle_t esphome_main_task_handle;
#ifdef USE_ESP32
/// ESP-IDF waits on index 0 from the main task (e.g. pthread_join() uses an unfiltered
/// xTaskNotifyWait), so main-loop wakes on index 0 would end those waits early.
enum { ESPHOME_MAIN_TASK_NOTIFY_INDEX = 1 };
#if configTASK_NOTIFICATION_ARRAY_ENTRIES < 2
#error "CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES must be at least 2"
#endif
#endif
/// Wake the main loop task from another FreeRTOS task. NOT ISR-safe.
/// always_inline so callers placed in IRAM do not reference a flash-resident copy.
__attribute__((always_inline)) static inline void esphome_main_task_notify() {
TaskHandle_t task = esphome_main_task_handle;
if (task != NULL) {
#ifdef USE_ESP32
xTaskNotifyGiveIndexed(task, ESPHOME_MAIN_TASK_NOTIFY_INDEX);
#else
xTaskNotifyGive(task);
#endif
}
}
@@ -33,10 +46,23 @@ __attribute__((always_inline)) static inline void esphome_main_task_notify_from_
BaseType_t *px_higher_priority_task_woken) {
TaskHandle_t task = esphome_main_task_handle;
if (task != NULL) {
#ifdef USE_ESP32
vTaskNotifyGiveIndexedFromISR(task, ESPHOME_MAIN_TASK_NOTIFY_INDEX, px_higher_priority_task_woken);
#else
vTaskNotifyGiveFromISR(task, px_higher_priority_task_woken);
#endif
}
}
/// Block the main loop task until it is woken or the timeout expires.
__attribute__((always_inline)) static inline void esphome_main_task_wait(TickType_t ticks) {
#ifdef USE_ESP32
ulTaskNotifyTakeIndexed(ESPHOME_MAIN_TASK_NOTIFY_INDEX, pdTRUE, ticks);
#else
ulTaskNotifyTake(pdTRUE, ticks);
#endif
}
#ifdef __cplusplus
}
#endif
+4 -4
View File
@@ -43,15 +43,15 @@ namespace internal {
inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) {
// Fast path (with USE_LWIP_FAST_SELECT): FreeRTOS task notifications posted by the lwip
// event_callback wrapper (see lwip_fast_select.c) are the single source of truth for
// socket wake-ups. Every NETCONN_EVT_RCVPLUS posts an xTaskNotifyGive, so any notification
// that lands between wakes keeps the counter non-zero (next ulTaskNotifyTake returns
// immediately) or wakes a blocked Take directly. Additional wake sources:
// socket wake-ups. Every NETCONN_EVT_RCVPLUS calls esphome_main_task_notify(), so any notification
// that lands between wakes keeps the counter non-zero (next esphome_main_task_wait() returns
// immediately) or wakes a blocked wait directly. Additional wake sources:
// wake_loop_threadsafe() from background tasks, and the ms timeout.
if (ms == 0) [[unlikely]] {
yield();
return;
}
ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(ms));
esphome_main_task_wait(pdMS_TO_TICKS(ms));
}
} // namespace internal
+7 -4
View File
@@ -24,7 +24,7 @@ dependencies:
espressif/esp32-camera:
version: 2.1.7
espressif/mdns:
version: 1.12.0
version: 1.14.0
espressif/esp_wifi_remote:
version: 1.6.5
rules:
@@ -101,8 +101,11 @@ dependencies:
- if: "idf_version >=6.0.0 && target in [esp32s2, esp32s3, esp32s31, esp32p4, esp32h4]"
esp32async/asynctcp:
version: 3.4.91
# sendspin is ESP-IDF only (it needs noise-c as an ESP-IDF component, see below)
sendspin/sendspin-cpp:
version: 0.8.0
version: 0.9.3
rules:
- if: "$ESPHOME_ARDUINO_COMPONENT == 0"
lvgl/lvgl:
version: 9.5.0
fastled/FastLED:
@@ -113,11 +116,11 @@ dependencies:
# noise (api, ota). Arduino below IDF 6.0 bundles espressif/libsodium, which
# clashes with esphome/libsodium, so those builds use the PlatformIO libraries.
esphome/noise-c:
version: 0.1.30
version: 0.1.31
rules:
- if: "$ESPHOME_ARDUINO_COMPONENT == 0 || idf_version >= 6.0.0"
# Declared alongside noise-c so the library converter skips wireguard's copy
esphome/libsodium:
version: 1.10021.11
version: 1.10021.12
rules:
- if: "$ESPHOME_ARDUINO_COMPONENT == 0 || idf_version >= 6.0.0"
+3 -3
View File
@@ -79,7 +79,7 @@ lib_deps =
; noise (api, ota) -- on the ESP-IDF framework these come from the
; component registry instead (see esphome/idf_component.yml), so they
; are not in [common].
esphome/noise-c@0.1.30 ; noise (api, ota)
esphome/noise-c@0.1.31 ; noise (api, ota)
SPI ; spi (Arduino built-in)
Wire ; i2c (Arduino built-int)
heman/AsyncMqttClient-esphome@1.0.0 ; mqtt
@@ -247,7 +247,7 @@ lib_deps =
${common:idf-component-libs.lib_deps}
ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base
esphome/wireguard@0.4.8 ; wireguard
esphome/noise-c@0.1.30 ; noise (api, ota)
esphome/noise-c@0.1.31 ; noise (api, ota)
ESP32Async/AsyncTCP@3.4.5 ; async_tcp
DNSServer ; captive_portal
heman/AsyncMqttClient-esphome@2.0.0 ; mqtt
@@ -645,7 +645,7 @@ build_unflags =
extends = common
platform = platformio/native
lib_deps =
esphome/noise-c@0.1.30 ; used by noise (api, ota)
esphome/noise-c@0.1.31 ; used by noise (api, ota)
lvgl/lvgl@9.5.0 ; lvgl
build_flags =
${common.build_flags}
+4 -1
View File
@@ -12,7 +12,7 @@ pyserial==3.5
platformio==6.2.0
esptool==5.4.0
click==8.5.0
aioesphomeapi==46.6.0
aioesphomeapi==46.9.0
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
zeroconf==0.151.5
puremagic==2.2.0
@@ -26,6 +26,9 @@ freetype-py==2.5.1
jinja2==3.1.6
bleak==3.0.2
smpclient==7.3.0
# smp 4.2+ needs cbor2 6+, which ships no Intel macOS wheels (arm64 only).
smp==4.2.0; platform_system != "Darwin" or platform_machine != "x86_64"
smp==4.1.0; platform_system == "Darwin" and platform_machine == "x86_64"
requests==2.34.2
py7zr==1.1.3
platformdirs==4.12.3 # native esp-idf toolchain global cache dir
+43 -3
View File
@@ -536,6 +536,16 @@ def create_field_type_info(
f"track_presence on field '{field.name}' has no effect; it requires "
"a non-repeated message field in a message that is decoded"
)
if get_field_opt(field, pb.progmem, False) and (
field.label == FieldDescriptorProto.LABEL_REPEATED
or field.type != 12
or not get_field_opt(field, pb.pointer_to_buffer, False)
or get_field_opt(field, pb.fixed_array_size) is not None
):
raise ValueError(
f"progmem on field '{field.name}' requires a non-repeated bytes field "
"with pointer_to_buffer"
)
if field.label == FieldDescriptorProto.LABEL_REPEATED:
# Check if this is a packed_buffer field (zero-copy packed repeated)
if get_field_opt(field, pb.packed_buffer, False):
@@ -1158,6 +1168,11 @@ class PointerToBytesBufferType(PointerToBufferTypeBase):
reference_type = "const uint8_t*"
const_reference_type = "const uint8_t*"
@property
def progmem(self) -> bool:
"""Whether the data is in flash, so encode and dump copy it with progmem_memcpy."""
return get_field_opt(self._field, pb.progmem, False)
@property
def public_content(self) -> list[str]:
# Use uint16_t for length - max packet size is well below 65535
@@ -1168,6 +1183,14 @@ class PointerToBytesBufferType(PointerToBufferTypeBase):
@property
def encode_content(self) -> str:
if self.progmem:
return _encode_call(
"encode_progmem_bytes",
str(self.number),
f"this->{self.field_name}",
f"this->{self.field_name}_len",
force=self.force,
)
if result := self._encode_bytes_with_precomputed_tag(
f"this->{self.field_name}", f"this->{self.field_name}_len"
):
@@ -1194,8 +1217,9 @@ class PointerToBytesBufferType(PointerToBufferTypeBase):
@property
def dump_content(self) -> str:
dump_fn = "dump_progmem_bytes_field" if self.progmem else "dump_bytes_field"
return (
f'dump_bytes_field(out, ESPHOME_PSTR("{self.name}"), '
f'{dump_fn}(out, ESPHOME_PSTR("{self.name}"), '
f"this->{self.field_name}, this->{self.field_name}_len);"
)
@@ -2609,6 +2633,11 @@ def build_message_type(
):
fixed_vector_fields.append((field.name, field.number))
if needs_decode and get_field_opt(field, pb.progmem, False):
raise ValueError(
f"progmem on field '{field.name}' of {desc.name} requires a message "
"that is only encoded; received data is never in flash"
)
ti = create_field_type_info(field, needs_decode, needs_encode)
# Skip field declarations for fields that are in the base class
@@ -3319,15 +3348,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
"""
+33 -5
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
import argparse
import heapq
import os
from pathlib import Path
import queue
@@ -11,6 +12,7 @@ import subprocess
import sys
import tempfile
import threading
import time
from typing import Any
import click
@@ -278,9 +280,12 @@ def clang_options(idedata, environment):
pids = set()
def run_tidy(executable, args, options, tmpdir, path_queue, lock, failed_files):
def run_tidy(
executable, args, options, tmpdir, path_queue, lock, failed_files, timings
):
while True:
path = path_queue.get()
started = time.monotonic()
invocation = [executable]
if tmpdir is not None:
@@ -319,8 +324,9 @@ def run_tidy(executable, args, options, tmpdir, path_queue, lock, failed_files):
check=False,
close_fds=False,
)
if proc.returncode != 0:
with lock:
with lock:
timings.append((time.monotonic() - started, path))
if proc.returncode != 0:
print_error_for_file(path, proc.stdout)
failed_files.append(path)
path_queue.task_done()
@@ -333,8 +339,27 @@ def progress_bar_show(value):
def split_list(a, n):
k, m = divmod(len(a), n)
return [a[i * k + min(i, m) : (i + 1) * k + min(i + 1, m)] for i in range(n)]
"""Deal the sorted files out to n splits in turn.
Contiguous slices put whole directories on one split; expensive
neighbours such as esphome/core then land together and that split takes
several times longer than the others. Dealing spreads them out.
"""
return [a[i::n] for i in range(n)]
SLOWEST_FILES_SHOWN = 15
def print_slowest_files(timings):
"""Print the files clang-tidy spent the longest on, so the splits can be
rebalanced from CI logs."""
if not timings:
return
total = sum(seconds for seconds, _ in timings)
print(f"\nclang-tidy spent {total:.0f} s on {len(timings)} files; slowest:")
for seconds, path in heapq.nlargest(SLOWEST_FILES_SHOWN, timings):
print(f" {seconds:6.1f} s {path}")
def main():
@@ -458,6 +483,7 @@ def main():
tmpdir = tempfile.mkdtemp()
failed_files = []
timings = []
try:
executable = get_binary("clang-tidy", 22)
task_queue = queue.Queue(args.jobs)
@@ -473,6 +499,7 @@ def main():
task_queue,
lock,
failed_files,
timings,
),
)
t.daemon = True
@@ -487,6 +514,7 @@ def main():
# Wait for all threads to be done.
task_queue.join()
print_slowest_files(timings)
except FileNotFoundError:
return 1
+1 -1
View File
@@ -1616,7 +1616,7 @@ def lpt_partition(
Heaviest item first into the lightest group. Ties keep input order, so
pass pre-sorted items for deterministic output. script/clang-tidy's
split_list is the unweighted contiguous sibling.
split_list is the unweighted sibling that deals files out in turn.
"""
groups: list[list[str]] = [[] for _ in range(count)]
group_weights = [0.0] * count
+1
View File
@@ -13,6 +13,7 @@ CONFIG_LIBC_NEWLIB=y
# esp32
CONFIG_COMPILER_OPTIMIZATION_SIZE=y
CONFIG_FREERTOS_HZ=1000
CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES=2
CONFIG_ESP_TASK_WDT_INIT=y
CONFIG_ESP_TASK_WDT_PANIC=y
CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=n
@@ -0,0 +1,27 @@
"""Validation tests for the frameworks the sendspin hub builds on.
sendspin-cpp needs noise-c as an ESP-IDF component, which Arduino below IDF 6.0
cannot use, so the hub is ESP-IDF only.
"""
import pytest
from esphome import config_validation as cv
from esphome.components.sendspin import CONFIG_SCHEMA
from esphome.const import PlatformFramework
from tests.component_tests.types import SetCoreConfigCallable
HUB_CONFIG = {"id": "sendspin_hub_id"}
def test_arduino_rejected(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(PlatformFramework.ESP32_ARDUINO)
with pytest.raises(cv.Invalid, match="esp-idf"):
CONFIG_SCHEMA(HUB_CONFIG)
def test_esp_idf_accepted(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(PlatformFramework.ESP32_IDF)
assert CONFIG_SCHEMA(HUB_CONFIG)["id"].id == "sendspin_hub_id"
@@ -53,6 +53,14 @@ def test_jpeg_alias_maps_to_one_enum(
assert _get_data().artwork_preferences[0]["format"] == IMAGE_FORMAT_JPEG
def test_bmp_rejected(set_core_config: SetCoreConfigCallable) -> None:
"""The Sendspin protocol only allows JPEG and PNG artwork."""
set_core_config(PlatformFramework.ESP32_IDF)
with pytest.raises(cv.Invalid, match="BMP artwork is no longer supported"):
CONFIG_SCHEMA(_slot_config(format="BMP"))
def test_too_many_slots_rejected(set_core_config: SetCoreConfigCallable) -> None:
"""Slot numbers run out after MAX_ARTWORK_SLOTS entries."""
set_core_config(PlatformFramework.ESP32_IDF)
@@ -88,3 +88,13 @@ def test_opus_at_wrong_sample_rate_rejected(
with pytest.raises(cv.Invalid, match="requires a sample_rate of 48000"):
CONFIG_SCHEMA(_media_source_config(codecs=["opus"], sample_rate=44100))
def test_opus_only_codec_list_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""Every server supports FLAC or PCM, so sendspin-cpp requires one of them."""
set_core_config(PlatformFramework.ESP32_IDF)
with pytest.raises(cv.Invalid, match="must include 'flac' or 'pcm'"):
CONFIG_SCHEMA(_media_source_config(codecs=["opus"]))
@@ -0,0 +1,95 @@
"""Validation tests for the sendspin hub's pairing options.
These cover the rejection branches, which a compile test cannot reach: a
`test*.yaml` can only assert that a configuration is accepted.
"""
from typing import Any
import pytest
from esphome import config_validation as cv
from esphome.components.sendspin import (
CONF_STATIC_PAIRING_CODE,
CONFIG_SCHEMA,
FINAL_VALIDATE_SCHEMA,
request_pairing_code_display_support,
)
from esphome.const import PlatformFramework
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
def _hub_config(**overrides: Any) -> ConfigType:
"""Build a minimal valid hub config, allowing field overrides."""
config: ConfigType = {"id": "sendspin_hub_id"}
config.update(overrides)
return config
def test_minimal_config_is_accepted(set_core_config: SetCoreConfigCallable) -> None:
"""The baseline the rejection tests vary is itself valid."""
set_core_config(PlatformFramework.ESP32_IDF)
config = CONFIG_SCHEMA(_hub_config())
assert CONF_STATIC_PAIRING_CODE not in config
@pytest.mark.parametrize("code", ["01234567", "00000000", "99999999"])
def test_static_pairing_code_accepted(
set_core_config: SetCoreConfigCallable, code: str
) -> None:
"""Eight decimal digits, leading zeros included, pass through unchanged."""
set_core_config(PlatformFramework.ESP32_IDF)
assert (
CONFIG_SCHEMA(_hub_config(static_pairing_code=code))[CONF_STATIC_PAIRING_CODE]
== code
)
@pytest.mark.parametrize(
"code",
[
"0123456", # too short
"012345678", # too long
"0123456a", # not all decimal digits
"0123 567", # whitespace is not a digit
"01234567", # full-width digits are not ASCII
"",
],
)
def test_static_pairing_code_rejected(
set_core_config: SetCoreConfigCallable, code: str
) -> None:
"""Anything that is not exactly eight decimal digits is refused here, since the
library would otherwise refuse to start the client."""
set_core_config(PlatformFramework.ESP32_IDF)
with pytest.raises(cv.Invalid, match="exactly 8 decimal digits"):
CONFIG_SCHEMA(_hub_config(static_pairing_code=code))
def test_unquoted_static_pairing_code_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""An unquoted YAML code arrives as an int, having already lost its leading
zeros, so it is refused rather than silently pairing with the wrong value."""
set_core_config(PlatformFramework.ESP32_IDF)
with pytest.raises(cv.Invalid):
CONFIG_SCHEMA(_hub_config(static_pairing_code=1234567))
def test_static_code_with_dynamic_code_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""Only one pairing code method can be offered, so a static code next to the dynamic
code would be silently ignored."""
set_core_config(PlatformFramework.ESP32_IDF)
request_pairing_code_display_support()
config = CONFIG_SCHEMA(_hub_config(static_pairing_code="01234567"))
with pytest.raises(cv.Invalid, match="cannot be used with a dynamic pairing code"):
FINAL_VALIDATE_SCHEMA(config)
@@ -0,0 +1,60 @@
"""Validation tests for the sendspin text sensor platform.
These cover behavior a compile test cannot observe: which roles a sensor type requests.
"""
from typing import Any
import pytest
from esphome import config_validation as cv
from esphome.components.sendspin import _get_data
from esphome.components.sendspin.text_sensor import (
CONF_PAIRING_CODE,
CONFIG_SCHEMA,
SENDSPIN_TEXT_METADATA_TYPES,
)
from esphome.const import PlatformFramework
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
def _sensor_config(**overrides: Any) -> ConfigType:
"""Build a minimal valid text sensor config, allowing field overrides."""
config: ConfigType = {"name": "Sendspin Text", "type": "title"}
config.update(overrides)
return config
@pytest.mark.parametrize("sensor_type", sorted(SENDSPIN_TEXT_METADATA_TYPES))
def test_metadata_types_request_metadata_role(
set_core_config: SetCoreConfigCallable, sensor_type: str
) -> None:
"""Metadata sensors need the metadata role, and say nothing about pairing codes."""
set_core_config(PlatformFramework.ESP32_IDF)
CONFIG_SCHEMA(_sensor_config(type=sensor_type))
assert _get_data().metadata_support is True
assert _get_data().pairing_code_display_support is False
def test_pairing_code_requests_code_display(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A pairing_code sensor is a way to show the dynamic code, so it alone makes the
hub offer it. It needs no role, so a pairing-only config builds without metadata."""
set_core_config(PlatformFramework.ESP32_IDF)
CONFIG_SCHEMA(_sensor_config(type=CONF_PAIRING_CODE))
assert _get_data().pairing_code_display_support is True
assert _get_data().metadata_support is False
def test_unknown_type_rejected(set_core_config: SetCoreConfigCallable) -> None:
"""A misspelled type must fail rather than fall through to a default."""
set_core_config(PlatformFramework.ESP32_IDF)
with pytest.raises(cv.Invalid):
CONFIG_SCHEMA(_sensor_config(type="pairing_secret"))
@@ -0,0 +1,185 @@
"""Validation tests for the unpaired access policy.
The hub's `unpaired_access` key and an `unpaired_access` switch both set the
policy, so the switch platform rejects a config that has both.
"""
import logging
import pytest
from esphome import config_validation as cv
from esphome.components.sendspin import (
CONF_UNPAIRED_ACCESS,
CONFIG_SCHEMA as HUB_CONFIG_SCHEMA,
DOMAIN,
FINAL_VALIDATE_SCHEMA as HUB_FINAL_VALIDATE_SCHEMA,
_get_data,
request_pairing_code_display_support,
)
from esphome.components.sendspin.switch import (
CONFIG_SCHEMA as SWITCH_CONFIG_SCHEMA,
FINAL_VALIDATE_SCHEMA as SWITCH_FINAL_VALIDATE_SCHEMA,
)
from esphome.const import PlatformFramework
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
HUB_ID = "sendspin_hub_id"
NO_PAIRING_WARNING = "no new server can pair with this device"
def _switch_config(switch_type: str) -> ConfigType:
return SWITCH_CONFIG_SCHEMA(
{"name": "Sendspin Switch", "type": switch_type, "sendspin_id": HUB_ID}
)
def test_hub_key_absent_by_default(set_core_config: SetCoreConfigCallable) -> None:
"""With no default in the schema, the switch can tell whether the key was set."""
set_core_config(PlatformFramework.ESP32_IDF)
assert CONF_UNPAIRED_ACCESS not in HUB_CONFIG_SCHEMA({"id": HUB_ID})
@pytest.mark.parametrize("value", [True, False])
def test_hub_key_accepted(set_core_config: SetCoreConfigCallable, value: bool) -> None:
set_core_config(PlatformFramework.ESP32_IDF)
config = HUB_CONFIG_SCHEMA({"id": HUB_ID, CONF_UNPAIRED_ACCESS: value})
assert config[CONF_UNPAIRED_ACCESS] is value
def test_off_without_pairing_method_warns(
set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture
) -> None:
"""With unpaired access off and no way to pair, no new server could pair."""
set_core_config(PlatformFramework.ESP32_IDF)
config = HUB_CONFIG_SCHEMA({"id": HUB_ID, CONF_UNPAIRED_ACCESS: False})
with caplog.at_level(logging.WARNING):
HUB_FINAL_VALIDATE_SCHEMA(config)
assert NO_PAIRING_WARNING in caplog.text
@pytest.mark.parametrize("method", ["static_pairing_code", "pairing_code"])
def test_off_with_pairing_method_does_not_warn(
set_core_config: SetCoreConfigCallable,
caplog: pytest.LogCaptureFixture,
method: str,
) -> None:
set_core_config(PlatformFramework.ESP32_IDF)
hub_config = {"id": HUB_ID, CONF_UNPAIRED_ACCESS: False}
if method == "static_pairing_code":
hub_config["static_pairing_code"] = "01234567"
else:
request_pairing_code_display_support()
config = HUB_CONFIG_SCHEMA(hub_config)
with caplog.at_level(logging.WARNING):
HUB_FINAL_VALIDATE_SCHEMA(config)
assert NO_PAIRING_WARNING not in caplog.text
def test_on_does_not_warn(
set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture
) -> None:
set_core_config(PlatformFramework.ESP32_IDF)
config = HUB_CONFIG_SCHEMA({"id": HUB_ID})
with caplog.at_level(logging.WARNING):
HUB_FINAL_VALIDATE_SCHEMA(config)
assert NO_PAIRING_WARNING not in caplog.text
def test_switch_with_hub_key_rejected(set_core_config: SetCoreConfigCallable) -> None:
"""Both would set the policy, and the hub's value would be silently ignored."""
set_core_config(
PlatformFramework.ESP32_IDF,
full_config={DOMAIN: {"id": HUB_ID, CONF_UNPAIRED_ACCESS: False}},
)
config = _switch_config(CONF_UNPAIRED_ACCESS)
with pytest.raises(cv.Invalid, match="set the switch's restore_mode instead"):
SWITCH_FINAL_VALIDATE_SCHEMA(config)
def test_switch_without_hub_key_accepted(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(PlatformFramework.ESP32_IDF, full_config={DOMAIN: {"id": HUB_ID}})
config = _switch_config(CONF_UNPAIRED_ACCESS)
assert SWITCH_FINAL_VALIDATE_SCHEMA(config) is config
def test_switch_without_pairing_method_warns(
set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture
) -> None:
"""Turning the switch off would leave no way for a new server to pair."""
set_core_config(PlatformFramework.ESP32_IDF, full_config={DOMAIN: {"id": HUB_ID}})
config = _switch_config(CONF_UNPAIRED_ACCESS)
with caplog.at_level(logging.WARNING):
SWITCH_FINAL_VALIDATE_SCHEMA(config)
assert NO_PAIRING_WARNING in caplog.text
def test_switch_with_pairing_method_does_not_warn(
set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture
) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
full_config={DOMAIN: {"id": HUB_ID, "static_pairing_code": "01234567"}},
)
config = _switch_config(CONF_UNPAIRED_ACCESS)
with caplog.at_level(logging.WARNING):
SWITCH_FINAL_VALIDATE_SCHEMA(config)
assert NO_PAIRING_WARNING not in caplog.text
def test_enabled_switch_ignores_hub_key(set_core_config: SetCoreConfigCallable) -> None:
"""Only the unpaired access switch competes with the hub key."""
set_core_config(
PlatformFramework.ESP32_IDF,
full_config={DOMAIN: {"id": HUB_ID, CONF_UNPAIRED_ACCESS: False}},
)
config = _switch_config("enabled")
assert SWITCH_FINAL_VALIDATE_SCHEMA(config) is config
@pytest.mark.parametrize("switch_type", ["enabled", CONF_UNPAIRED_ACCESS])
def test_duplicate_switch_type_rejected(
set_core_config: SetCoreConfigCallable, switch_type: str
) -> None:
"""Two switches of one type would fight over the same hub setting."""
entry = {"platform": DOMAIN, "type": switch_type}
set_core_config(
PlatformFramework.ESP32_IDF,
full_config={DOMAIN: {"id": HUB_ID}, "switch": [entry, dict(entry)]},
)
config = _switch_config(switch_type)
with pytest.raises(cv.Invalid, match="Only one sendspin"):
SWITCH_FINAL_VALIDATE_SCHEMA(config)
@pytest.mark.parametrize("switch_type", ["enabled", CONF_UNPAIRED_ACCESS])
def test_switch_requests_its_type(
set_core_config: SetCoreConfigCallable, switch_type: str
) -> None:
"""The hub skips the codegen value for a setting a switch drives, so the client
waits for that switch to restore before its first start."""
set_core_config(PlatformFramework.ESP32_IDF)
_switch_config(switch_type)
assert _get_data().switch_types == {switch_type}
+1
View File
@@ -1,6 +1,7 @@
sensor:
- platform: ags10
id: ags10_1
i2c_id: i2c_bus
tvoc:
name: AGS10 TVOC
update_interval: 60s
+18 -10
View File
@@ -1,6 +1,12 @@
from pathlib import Path
import esphome.codegen as cg
from esphome.const import CONF_ESPHOME, CONF_INCLUDES
from esphome.core import CORE
from tests.testing_helpers import ComponentManifestOverride
TESTS_DIR = Path(__file__).parent
def override_manifest(manifest: ComponentManifestOverride) -> None:
# USE_API compiles every api source, so emit what they need. No socket
@@ -10,16 +16,18 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
cg.add_define("USE_API_PLAINTEXT")
# Linked wizard inputs only exist next to homeassistant entities, which need this
cg.add_define("USE_API_HOMEASSISTANT_STATES")
# test_wizard.cpp supplies the tables that codegen emits for a real build
for define in (
"USE_API_WIZARD",
"USE_API_WIZARD_INPUTS",
"USE_API_WIZARD_LINKED_INPUTS",
"USE_API_WIZARD_STANDALONE_INPUTS",
):
cg.add_define(define)
cg.add_define("API_WIZARD_DATA_SIZE", 200)
cg.add_define("API_WIZARD_INPUT_COUNT", 2)
# test_wizard.cpp supplies the tables that codegen emits for a real build;
# it is only compiled when api's own tests are, not when api is a dependency
if any(Path(p) == TESTS_DIR for p in CORE.config[CONF_ESPHOME][CONF_INCLUDES]):
for define in (
"USE_API_WIZARD",
"USE_API_WIZARD_INPUTS",
"USE_API_WIZARD_LINKED_INPUTS",
"USE_API_WIZARD_STANDALONE_INPUTS",
):
cg.add_define(define)
cg.add_define("API_WIZARD_DATA_SIZE", 200)
cg.add_define("API_WIZARD_INPUT_COUNT", 2)
cg.add_define("API_MAX_SEND_QUEUE", 8)
cg.add_define("MAX_API_CONNECTIONS", 1)
cg.add_define("USE_SOCKET_IMPL_BSD_SOCKETS")
+3 -3
View File
@@ -68,7 +68,7 @@ TEST(DeviceWizard, ResponseSendsTheDataUnchanged) {
Bytes expected{0x0a, 0xc8, 0x01};
expected.insert(expected.end(), API_WIZARD_DATA, API_WIZARD_DATA + API_WIZARD_DATA_SIZE);
EXPECT_EQ(encode(resp, &DeviceWizardResponse::calc_size_msg, &wizard_encode_response), expected);
EXPECT_EQ(encode(resp, &DeviceWizardResponse::calc_size_msg, &DeviceWizardResponse::encode_msg), expected);
}
TEST(DeviceWizard, ShortDataHasAOneByteLength) {
@@ -77,13 +77,13 @@ TEST(DeviceWizard, ShortDataHasAOneByteLength) {
resp.data_len = 3;
Bytes expected{0x0a, 0x03, API_WIZARD_DATA[0], API_WIZARD_DATA[1], API_WIZARD_DATA[2]};
EXPECT_EQ(encode(resp, &DeviceWizardResponse::calc_size_msg, &wizard_encode_response), expected);
EXPECT_EQ(encode(resp, &DeviceWizardResponse::calc_size_msg, &DeviceWizardResponse::encode_msg), expected);
}
TEST(DeviceWizard, NoDataEncodesNothing) {
DeviceWizardResponse resp;
EXPECT_EQ(resp.calculate_size(), 0u);
EXPECT_TRUE(encode(resp, &DeviceWizardResponse::calc_size_msg, &wizard_encode_response).empty());
EXPECT_TRUE(encode(resp, &DeviceWizardResponse::calc_size_msg, &DeviceWizardResponse::encode_msg).empty());
}
TEST(DeviceWizard, CapabilitiesAnnounceTheWizard) {
+1
View File
@@ -61,6 +61,7 @@ sensor:
motion:
- platform: bmi270
id: bmi270_motion
i2c_id: i2c_bus
# Accelerometer full-scale range: 2G | 4G | 8G | 16G
accelerometer_range: 4G
@@ -0,0 +1,15 @@
sensor:
- platform: template
id: template_temperature1
lambda: return 0.6;
- platform: template
id: template_temperature2
lambda: return 0.8;
- platform: combination
type: linear
name: Linearly combined temperatures
sources:
- source: template_temperature1
coeffecient: 0.5
- source: template_temperature2
coefficient: 1.5
+4
View File
@@ -5,10 +5,12 @@
# - DS2484: adjustable 1-Wire timing + RWPU pullup resistor selection
ds248x:
- id: ds2482_100
i2c_id: i2c_bus
address: 0x18
type: ds2482-100
active_pullup: true
- id: ds2482_101
i2c_id: i2c_bus
address: 0x19
type: ds2482-101
active_pullup: true
@@ -18,10 +20,12 @@ ds248x:
bus_sleep: true
hub_sleep: true
- id: ds2482_800
i2c_id: i2c_bus
address: 0x1a
type: ds2482-800
active_pullup: true
- id: ds2484_hub
i2c_id: i2c_bus
address: 0x1b
type: ds2484
active_pullup: true
+1
View File
@@ -61,6 +61,7 @@ sensor:
motion:
- platform: lsm6ds
id: lsm6ds_motion
i2c_id: i2c_bus
# Accelerometer full-scale range: 2G | 4G | 8G | 16G
accelerometer_range: 4G
+1
View File
@@ -1,5 +1,6 @@
sensor:
- platform: ltr501
i2c_id: i2c_bus
address: 0x23
type: ALS_PS
gain: 1X
+1
View File
@@ -1,5 +1,6 @@
sensor:
- platform: ltr_als_ps
i2c_id: i2c_bus
address: 0x23
gain: 1x
integration_time: 100ms
+35
View File
@@ -7,3 +7,38 @@ sendspin:
manufacturer: Test Manufacturer
model: Test Model
firmware_version: 1.2.3
# No static_pairing_code here: the on_display_pairing_code automation below makes
# this a dynamic-pairing-code device, and validation rejects both together.
# common-pairing.yaml covers the static-code build.
on_open_pairing_window:
then:
- logger.log: "Pairing window requested"
- sendspin.confirm_pairing_window:
# Calling cancel_pairing_window here only compiles the action; a real config cancels from a user input.
on_close_pairing_window:
then:
- logger.log: "Pairing window prompt dismissed"
- sendspin.cancel_pairing_window:
on_display_pairing_code:
then:
- logger.log:
format: "Pairing code: %s"
args: ["code.c_str()"]
on_clear_pairing_code:
then:
- logger.log: "Pairing code cleared"
on_pairing_succeeded:
then:
- logger.log:
format: "Paired with server %s"
args: ["server_id.c_str()"]
on_pairing_failed:
then:
- logger.log:
format: "Pairing with server %s failed: %s"
args: ["server_id.c_str()", "reason.c_str()"]
- if:
condition:
lambda: 'return reason == "user_cancelled";'
then:
- logger.log: "Pairing was cancelled"
@@ -0,0 +1,19 @@
# The hub on its own, with no other sendspin platforms, to build the pairing options.
#
# Deliberately does not include common.yaml: that file pulls in the shared hub package, and
# the point here is a separate hub configuration. sendspin depends on network, so the interface
# has to be declared locally instead.
wifi:
ap:
# Also the static-pairing-code build.
sendspin:
id: sendspin_hub_id
static_pairing_code: "01234567"
# Covers the hub's unpaired access option, which is only valid without an unpaired access switch.
unpaired_access: false
# Builds the API request for the Sendspin pairing token.
api:
encryption:
key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=
@@ -5,3 +5,6 @@ switch:
- platform: sendspin
name: "Sendspin Enabled"
type: enabled
- platform: sendspin
name: "Sendspin Unpaired Access"
type: unpaired_access
@@ -0,0 +1,17 @@
# The pairing code text sensor on its own, with no metadata text sensor to pull in the
# metadata role. This is the build where USE_SENDSPIN_METADATA is undefined while the text
# sensor platform is still compiled, which is guarded separately in sendspin_text_sensor.{h,cpp}.
#
# Deliberately does not include common.yaml: that file pulls in the shared hub package, and
# the point here is the narrower configuration. sendspin depends on network, so the interface
# has to be declared locally instead.
wifi:
ap:
sendspin:
id: sendspin_hub_id
text_sensor:
- platform: sendspin
name: "Pairing Code"
type: pairing_code
@@ -14,3 +14,7 @@ text_sensor:
- platform: sendspin
name: "Album Artist"
type: album_artist
- platform: sendspin
name: "Pairing Code"
type: pairing_code
entity_category: diagnostic
@@ -0,0 +1,2 @@
packages:
sendspin: !include common-pairing.yaml
@@ -0,0 +1,2 @@
packages:
sendspin: !include common-text_sensor-pairing.yaml
+40 -5
View File
@@ -34,7 +34,12 @@ from esphome.components.esp8266.boards import (
board_ld_script,
)
from esphome.components.esp8266.build_surgery import RATETABLE_RULE
from esphome.components.esp8266.const import KEY_BOARD, KEY_ESP8266, KEY_SCANF_FLOAT
from esphome.components.esp8266.const import (
KEY_BOARD,
KEY_ESP8266,
KEY_SCANF_FLOAT,
THROW_STUBS_HEADER,
)
import esphome.config_validation as cv
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION
from esphome.core import CORE, EsphomeError
@@ -360,18 +365,22 @@ def test_write_project_link_line_and_exclusions(tmp_path: Path) -> None:
# Assembly and C sources compile through their own rules
assert "cont.S.o: aspp" in content
assert "abi.c.o: c" in content
# throw_stubs is force-included for ESPHome sources only, via one shared
# srcflags variable rather than a copy of the flags line per edge
# throw_stubs reaches src through srcflags (after the pch include) and the
# core through frameworkflags: one shared variable each, not a copy per edge
src_lines = [line for line in content.splitlines() if "obj/src/" in line]
assert any("main.cpp.o: cxx" in line for line in src_lines)
assert content.count("throw_stubs.h") == 1
assert content.count("throw_stubs.h") == 2
assert "srcflags = -include" in content
flags_lines = [
line for line in content.splitlines() if line.startswith(" flags = ")
]
assert flags_lines
# C++ src edges consume the precompiled header; C/assembly keep srcflags
assert set(flags_lines) == {" flags = $srcflags", " flags = $srccxxflags"}
assert set(flags_lines) == {
" flags = $srcflags",
" flags = $srccxxflags",
" flags = $frameworkflags",
}
def test_write_project_pch(tmp_path: Path) -> None:
@@ -715,6 +724,8 @@ def test_write_project_libraries_and_variant(
assert "libHeadersOnly.a" not in content
assert "Library HeadersOnly has no source files" in caplog.text
assert " flags = -DMYLIB=1" in content
# With exceptions on, nothing gets the stubs
assert "throw_stubs.h" not in content
# A library's own include dirs lead its compile lines
assert " own_includes = -I" in content
assert "$own_includes $cxxflags $flags" in content
@@ -729,6 +740,30 @@ def test_write_project_libraries_and_variant(
assert f"ccache = {_shq('/cc/ccache')}" in content
def test_write_project_throw_stubs_reach_core_and_libraries(tmp_path: Path) -> None:
"""Without exceptions the core and libraries take the throw stubs too."""
paths = _make_framework(tmp_path)
lib_dir = tmp_path / "libsrc"
lib_dir.mkdir()
(lib_dir / "lib.cpp").write_text("")
library = ArduinoLibrary(
name="MyLib",
sources=[lib_dir / "lib.cpp"],
include_dirs=[lib_dir],
flags=["-DMYLIB=1"],
)
content = _write_ninja(paths, libraries=[library])
stubs = CORE.relative_src_path() / THROW_STUBS_HEADER
assert f"frameworkflags = -include {_shq(str(stubs))}" in content
# Before the library's own flags; core edges take the variable alone
assert " flags = $frameworkflags -DMYLIB=1" in content
lines = content.splitlines()
core_edge = next(
i for i, line in enumerate(lines) if "core_esp8266_main.cpp.o: cxx" in line
)
assert lines[core_edge + 1] == " flags = $frameworkflags"
def test_get_flash_ld_path(tmp_path: Path) -> None:
paths = InstalledPaths(
@@ -68,8 +68,10 @@ NEW_CAPABILITY_FIELDS: dict[str, dict[str, int]] = {
"zwave_proxy": 3,
"serial_proxies": 4,
"wizard": 5,
"sendspin": 6,
},
"WizardCapabilities": {"configured": 1},
"SendspinCapabilities": {"feature_flags": 1},
"BluetoothProxyCapabilities": {
"feature_flags": 1,
"mac_address": 2,
@@ -446,6 +448,39 @@ def test_device_wizard_rpc_requires_authentication() -> None:
assert "needs_authentication" not in body
def test_sendspin_pairing_token_messages_keep_their_wire_ids() -> None:
"""Message ids are part of the wire protocol and must not change."""
for message, expected in (
("SendspinPairingTokenRequest", 159),
("SendspinPairingTokenResponse", 160),
):
body = _extract_proto_message(PROTO_TEXT, message)
match = re.search(r"option \(id\) = (\d+);", body)
assert match is not None, f"{message} is missing `option (id)`"
assert int(match.group(1)) == expected, (
f"{message} has id {match.group(1)}, expected {expected}"
)
def test_sendspin_pairing_token_response_fields_keep_their_wire_numbers() -> None:
"""Clients decode the response purely by field number."""
body = _extract_proto_message(PROTO_TEXT, "SendspinPairingTokenResponse")
for field_name, number in (("status", 1), ("token", 2)):
line = _field_declaration_line(body, field_name)
assert re.search(rf"\b{field_name}\s*=\s*{number}\b", line), (
f"{field_name} in SendspinPairingTokenResponse is no longer field "
f"number {number}"
)
def test_sendspin_pairing_token_rpc_requires_authentication() -> None:
"""The pairing token is a long-lived secret, so the rpc must not set
`needs_authentication` and inherits the default of true.
"""
body = _extract_rpc_body(PROTO_TEXT, "sendspin_pairing_token")
assert "needs_authentication" not in body
# ==================== Group C: advertised API version ====================
@@ -360,3 +360,56 @@ def test_repeated_strings_generate_in_every_direction(source: int) -> None:
assert "nullptr" not in header
if source != SOURCE_SERVER:
assert "case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED):" in cpp
def _progmem_field(
*, pointer_to_buffer: bool = True, force: bool = False, repeated: bool = False
) -> descriptor_pb2.FieldDescriptorProto:
field = _field(12, force=force, repeated=repeated)
field.options.Extensions[pb.progmem] = True
if pointer_to_buffer:
field.options.Extensions[pb.pointer_to_buffer] = True
return field
@pytest.mark.parametrize(
("force", "encode_fn"),
[(False, "encode_progmem_bytes("), (True, "encode_progmem_bytes_force(")],
)
def test_progmem_bytes_field_copies_from_flash(force: bool, encode_fn: str) -> None:
"""A (progmem) field encodes and dumps through the progmem_memcpy helpers."""
ti = create_field_type_info(_progmem_field(force=force), needs_decode=False)
assert encode_fn in ti.encode_content
assert "encode_bytes" not in ti.encode_content
assert "dump_progmem_bytes_field(" in ti.dump_content
@pytest.mark.parametrize(
"field",
[
pytest.param(_progmem_field(pointer_to_buffer=False), id="no_pointer"),
pytest.param(_progmem_field(repeated=True), id="repeated"),
],
)
def test_progmem_rejected_where_it_cannot_apply(
field: descriptor_pb2.FieldDescriptorProto,
) -> None:
"""(progmem) on a field that would not copy from flash fails instead of silently using memcpy."""
with pytest.raises(ValueError, match="progmem on field 'value'"):
create_field_type_info(field, needs_decode=False)
def test_progmem_rejected_in_a_decoded_message() -> None:
"""Received data is never in flash, so a decoded message cannot use (progmem)."""
desc = descriptor_pb2.DescriptorProto(name="Received")
desc.field.add().CopyFrom(_progmem_field())
with pytest.raises(ValueError, match="progmem on field 'value' of Received"):
build_message_type(desc, {}, {"Received": SOURCE_CLIENT})
def test_progmem_rejected_on_non_bytes_field() -> None:
"""(progmem) only applies to bytes fields."""
field = _field(9)
field.options.Extensions[pb.progmem] = True
with pytest.raises(ValueError, match="progmem on field 'value'"):
create_field_type_info(field, needs_decode=False)
@@ -12,10 +12,12 @@ import pytest
from esphome.components import esp8266
from esphome.components.esp8266 import (
ARDUINO_FRAMEWORK_SCHEMA,
_resolve_board,
_resolve_toolchain,
_validate_native_toolchain,
_warn_platformio_toolchain,
)
from esphome.components.esp8266.const import KEY_BOARD, KEY_ESP8266
import esphome.config_validation as cv
from esphome.const import (
CONF_BOARD,
@@ -118,6 +120,57 @@ def test_unsupported_board_rejected() -> None:
_validate_native_toolchain(_config(board="not_a_board"))
def test_known_board_passes_unchanged() -> None:
config = {CONF_BOARD: "esp01_1m"}
assert _resolve_board(config) is config
@pytest.mark.parametrize(
("board", "expected"),
[("ESP01-1M", "esp01_1m"), ("SPARKFUNBLYNK", "sparkfunBlynk")],
)
def test_misspelled_board_normalized(
board: str, expected: str, caplog: pytest.LogCaptureFixture
) -> None:
assert _resolve_board({CONF_BOARD: board})[CONF_BOARD] == expected
assert f"using '{expected}'" in caplog.text
@pytest.mark.parametrize(
("board", "expected_board", "expected_toolchain"),
[
("esp01-1m", "esp01_1m", Toolchain.ARDUINO),
("my_custom_board", "my_custom_board", Toolchain.PLATFORMIO),
],
)
def test_config_schema_resolves_board_before_toolchain(
board: str,
expected_board: str,
expected_toolchain: Toolchain,
caplog: pytest.LogCaptureFixture,
) -> None:
"""The full chain normalizes or falls back, and warns once."""
CORE.toolchain = None
config = esp8266.CONFIG_SCHEMA({CONF_BOARD: board})
assert config[CONF_BOARD] == expected_board
assert CORE.data[KEY_ESP8266][KEY_BOARD] == expected_board
assert CORE.toolchain == expected_toolchain
assert len(caplog.records) == 1
@pytest.mark.parametrize(
("cli_toolchain", "yaml"),
[(Toolchain.ARDUINO, {}), (None, {CONF_TOOLCHAIN: Toolchain.ARDUINO})],
)
def test_unknown_board_keeps_explicit_toolchain(
cli_toolchain: Toolchain | None, yaml: ConfigType
) -> None:
"""A CLI or YAML toolchain is not overridden."""
CORE.toolchain = cli_toolchain
config = {CONF_BOARD: "my_custom_board", **yaml}
assert _resolve_board(config) is config
def test_yaml_toolchain_key_resolves() -> None:
"""The documented `toolchain: arduino` YAML key selects the native path."""
CORE.toolchain = None
@@ -3120,6 +3120,11 @@ def test_rename_key_absent() -> None:
assert cv.rename_key("old", "new")({"other": 5}) == {"other": 5}
@pytest.mark.parametrize("value", ["an_id", 1, None, ["old"]])
def test_rename_key_non_dict_passes_through(value: object) -> None:
assert cv.rename_key("old", "new")(value) == value
def test_rename_key_no_removed_in_is_silent(
caplog: pytest.LogCaptureFixture,
) -> None:
+39
View File
@@ -6705,6 +6705,45 @@ def test_check_permissions_unreadable_port() -> None:
check_permissions("/dev/ttyUSB99")
@pytest.mark.parametrize(
("is_macos", "machine", "warned", "warns"),
[
pytest.param(True, "x86_64", False, True, id="intel_mac"),
pytest.param(True, "x86_64", True, False, id="intel_mac_child"),
pytest.param(True, "arm64", False, False, id="apple_silicon"),
pytest.param(False, "x86_64", False, False, id="linux_x86_64"),
],
)
def test_warn_if_intel_macos(
is_macos: bool,
machine: str,
warned: bool,
warns: bool,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Only Intel Python on macOS gets the warning, and only once per invocation."""
monkeypatch.setattr(main, "IS_MACOS", is_macos)
monkeypatch.setattr(main.platform, "machine", lambda: machine)
if warned:
monkeypatch.setenv(main._INTEL_MACOS_WARNED_ENV, "1")
else:
monkeypatch.delenv(main._INTEL_MACOS_WARNED_ENV, raising=False)
with caplog.at_level(logging.WARNING):
main._warn_if_intel_macos()
assert (main._INTEL_MACOS_REMOVAL in caplog.text) is warns
def test_run_multiple_configs_marks_children_warned(tmp_path: Path) -> None:
"""Multi-config children get the env marker so they skip the Intel warning."""
with patch.object(main, "run_external_process", return_value=0) as mock_run:
main.run_multiple_configs([tmp_path / "a.yaml"], lambda f: ["esphome", str(f)])
assert mock_run.call_args.kwargs["env"][main._INTEL_MACOS_WARNED_ENV] == "1"
def _make_checkout(root: Path) -> Path:
"""Create a directory that looks like an esphome checkout."""
(root / "esphome").mkdir(parents=True)