mirror of
https://github.com/esphome/esphome.git
synced 2026-10-09 12:26:02 +00:00
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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_();
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"); }
|
||||
|
||||
@@ -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"); }
|
||||
|
||||
|
||||
@@ -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
@@ -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 = (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
sensor:
|
||||
- platform: ags10
|
||||
id: ags10_1
|
||||
i2c_id: i2c_bus
|
||||
tvoc:
|
||||
name: AGS10 TVOC
|
||||
update_interval: 60s
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
sensor:
|
||||
- platform: ltr501
|
||||
i2c_id: i2c_bus
|
||||
address: 0x23
|
||||
type: ALS_PS
|
||||
gain: 1X
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
sensor:
|
||||
- platform: ltr_als_ps
|
||||
i2c_id: i2c_bus
|
||||
address: 0x23
|
||||
gain: 1x
|
||||
integration_time: 100ms
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user