Merge branch 'dev' into proxy-subscribe-acks

This commit is contained in:
Keith Burzinski
2026-08-17 02:29:10 -05:00
committed by GitHub
38 changed files with 628 additions and 88 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.10.0
RUN uv pip install --no-cache-dir esphome-device-builder==1.11.0
RUN \
platformio settings set enable_telemetry No \
+13 -13
View File
@@ -160,11 +160,6 @@ APIConnection::APIConnection(std::unique_ptr<socket::Socket> sock, APIServer *pa
#else
#error "No frame helper defined"
#endif
#ifdef USE_CAMERA
if (camera::Camera::instance() != nullptr) {
this->image_reader_ = std::unique_ptr<camera::CameraImageReader>{camera::Camera::instance()->create_image_reader()};
}
#endif
}
void APIConnection::start() {
@@ -1140,6 +1135,7 @@ void APIConnection::try_send_camera_image_() {
if (!this->image_reader_)
return;
const auto *cam = camera::Camera::instance();
// Send as many chunks as possible without blocking
while (this->image_reader_->available()) {
if (!this->helper_->can_write_without_blocking())
@@ -1149,11 +1145,11 @@ void APIConnection::try_send_camera_image_() {
bool done = this->image_reader_->available() == to_send;
CameraImageResponse msg;
msg.key = camera::Camera::instance()->get_object_id_hash();
msg.key = cam->get_object_id_hash();
msg.set_data(this->image_reader_->peek_data_buffer(), to_send);
msg.done = done;
#ifdef USE_DEVICES
msg.device_id = camera::Camera::instance()->get_device_id();
msg.device_id = cam->get_device_id();
#endif
if (!this->send_message(msg)) {
@@ -1169,15 +1165,19 @@ void APIConnection::try_send_camera_image_() {
void APIConnection::set_camera_state(std::shared_ptr<camera::CameraImage> image) {
if (!this->flags_.state_subscription)
return;
if (!this->image_reader_)
if (this->image_reader_ && this->image_reader_->available())
return;
if (this->image_reader_->available())
if (!image->was_requested_by(esphome::camera::API_REQUESTER) && !image->was_requested_by(esphome::camera::IDLE))
return;
if (image->was_requested_by(esphome::camera::API_REQUESTER) || image->was_requested_by(esphome::camera::IDLE)) {
this->image_reader_->set_image(std::move(image));
// Try to send immediately to reduce latency
this->try_send_camera_image_();
if (!this->image_reader_) {
// Created on the first image this connection will send, so connections
// that never receive one never pay for a reader. Only a registered
// camera's listener can reach this, so instance() is non-null here.
this->image_reader_ = std::unique_ptr<camera::CameraImageReader>{camera::Camera::instance()->create_image_reader()};
}
this->image_reader_->set_image(std::move(image));
// Try to send immediately to reduce latency
this->try_send_camera_image_();
}
uint16_t APIConnection::try_send_camera_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) {
auto *camera = static_cast<camera::Camera *>(entity);
@@ -591,18 +591,21 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) {
*/
APIError APINoiseFrameHelper::init_handshake_() {
int err;
memset(&nid_, 0, sizeof(nid_));
// const char *proto = "Noise_NNpsk0_25519_ChaChaPoly_SHA256";
// err = noise_protocol_name_to_id(&nid_, proto, strlen(proto));
nid_.pattern_id = NOISE_PATTERN_NN;
nid_.cipher_id = NOISE_CIPHER_CHACHAPOLY;
nid_.dh_id = NOISE_DH_CURVE25519;
nid_.prefix_id = NOISE_PREFIX_STANDARD;
nid_.hybrid_id = NOISE_DH_NONE;
nid_.hash_id = NOISE_HASH_SHA256;
nid_.modifier_ids[0] = NOISE_MODIFIER_PSK0;
// Noise_NNpsk0_25519_ChaChaPoly_SHA256, built on the stack:
// noise_handshakestate_new_by_id copies it, so a member would waste
// 104 bytes per connection, and a static const would sit in RAM on
// ESP8266 (.rodata is DRAM there).
const NoiseProtocolId nid = {
.prefix_id = NOISE_PREFIX_STANDARD,
.pattern_id = NOISE_PATTERN_NN,
.modifier_ids = {NOISE_MODIFIER_PSK0},
.dh_id = NOISE_DH_CURVE25519,
.cipher_id = NOISE_CIPHER_CHACHAPOLY,
.hash_id = NOISE_HASH_SHA256,
.hybrid_id = NOISE_DH_NONE,
};
err = noise_handshakestate_new_by_id(&handshake_, &nid_, NOISE_ROLE_RESPONDER);
err = noise_handshakestate_new_by_id(&handshake_, &nid, NOISE_ROLE_RESPONDER);
APIError aerr =
handle_noise_error_(err, LOG_STR("noise_handshakestate_new_by_id"), APIError::HANDSHAKESTATE_SETUP_FAILED);
if (aerr != APIError::OK)
@@ -63,9 +63,6 @@ class APINoiseFrameHelper final : public APIFrameHelper {
// Buffer for noise handshake prologue (released after handshake)
APIBuffer prologue_;
// NoiseProtocolId (size depends on implementation)
NoiseProtocolId nid_;
// Group small types together
// Fixed-size header buffer for noise protocol:
// 1 byte for indicator + 2 bytes for message size (16-bit value, not varint)
+38 -6
View File
@@ -5,11 +5,11 @@ bring-up and the controller BLE address. Consumers (bk72xx_ble_tracker) build
on this component and contain no SDK calls of their own.
Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7238/BK7252N/BK7253
(BLE 5.2), and any future BLE-5.x SoC. Capability is detected at compile time,
not by a chip list: the C++ guards on `__has_include("ble_api.h")` — the Beken
BLE 5.x public API header, which the LibreTiny beken-72xx builder ships only
for BLE-5.x SoCs. BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) fail
with a clear #error.
(BLE 5.2), and any future BLE-5.x SoC. Known non-5.x families are rejected in
to_code; unknown families are capability-checked at compile time via
`__has_include("app_ble.h")`, a header only on the BLE 5.x include path
(ble_api.h ships for every SoC, so it cannot be the probe). A non-5.x build
fails with a clear #error.
No framework patch is needed: the LibreTiny beken-72xx builder already compiles
and links the BLE 5.x stack (CFG_SUPPORT_BLE=1 + CFG_BLE_VERSION=BLE_VERSION_5_x;
@@ -21,9 +21,16 @@ import logging
import esphome.codegen as cg
from esphome.components import libretiny
from esphome.components.libretiny.const import FAMILY_BK7231N, FAMILY_BK7238
from esphome.components.libretiny.const import (
FAMILY_BK7231N,
FAMILY_BK7231Q,
FAMILY_BK7231T,
FAMILY_BK7238,
FAMILY_BK7251,
)
import esphome.config_validation as cv
from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID
from esphome.core import EsphomeError
from esphome.types import ConfigType
DEPENDENCIES = ["bk72xx"]
@@ -50,7 +57,32 @@ CONFIG_SCHEMA = cv.Schema(
request_scan_listener_slot = cg.slot_counter("BK72XX_BLE_SCAN_LISTENER_COUNT")
def _unsupported_family_message(family: str) -> str | None:
if family in (FAMILY_BK7231T, FAMILY_BK7251):
return (
f"bk72xx_ble does not support {family}: this SoC has the Beken BLE 4.2 "
"stack; a BLE 5.x SoC such as BK7231N or BK7238 is required"
)
if family == FAMILY_BK7231Q:
return "bk72xx_ble does not support BK7231Q: this SoC has no BLE"
return None
def _final_validate(config: ConfigType) -> ConfigType:
# Warn only: a hard error here would break the validate-only CI fixtures,
# which run on a BLE 4.2 board. The hard error is raised at codegen.
if msg := _unsupported_family_message(libretiny.get_libretiny_family()):
_LOGGER.warning("%s (this configuration cannot compile)", msg)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config: ConfigType) -> None:
if msg := _unsupported_family_message(libretiny.get_libretiny_family()):
raise EsphomeError(msg)
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
+2 -2
View File
@@ -10,7 +10,7 @@
#ifdef USE_BK72XX_BLE
// Same SDK gate as bk72xx_ble.cpp (which carries the explanatory #error).
#if !defined(CLANG_TIDY) && __has_include("ble_api.h")
#if !defined(CLANG_TIDY) && __has_include("ble_api.h") && __has_include("app_ble.h")
extern "C" {
#include "app_ble.h" // app_ble_env, app_ble_run, app_ble_reset, actv_state_t,
@@ -115,5 +115,5 @@ BdkOpResult bdk_scan_release(uint8_t activity_idx, bool created, int *err_out) {
} // namespace esphome::bk72xx_ble
#endif // !CLANG_TIDY && ble_api.h
#endif // !CLANG_TIDY && ble_api.h && app_ble.h
#endif // USE_BK72XX_BLE
+13 -9
View File
@@ -34,22 +34,26 @@
// ---------------------------------------------------------------------------
// SDK-capability gate (not a chip allowlist).
// This component drives the Beken BLE *5.x* controller via its public API,
// `ble_api.h`, which the LibreTiny beken-72xx builder ships only for the
// BLE-5.x SoCs (it selects the `ble_pub` 5.x stack from CFG_BLE_VERSION; the
// 4.2 SoCs build a different, older API with no ble_api.h). Gate on the header
// itself so any BLE-5.x Beken chip — present or future — is supported without a
// hard-coded list, and a non-5.x build fails here with a clear message instead
// of a cryptic "ble_api.h: No such file or directory".
// This component drives the Beken BLE *5.x* controller. `ble_api.h` cannot be
// the probe: it ships for every SoC (driver/include) and merely switches on
// CFG_BLE_VERSION internally. `app_ble.h` is on the include path only when the
// LibreTiny beken-72xx builder selects a 5.x stack, so gating on it supports
// any BLE-5.x chip — present or future — without a hard-coded list, and a
// non-5.x build fails here with a clear message instead of a cryptic
// "app_ble.h: No such file or directory".
// ---------------------------------------------------------------------------
#if defined(CLANG_TIDY)
// The clang-tidy environment does not carry the full Beken BDK BLE 5.x API
// (its ble_api.h variant lacks parts of the 5.x surface), so there is nothing
// accurate to analyze the SDK calls against — skip the file under analysis.
#define BK72XX_BLE_NO_SDK
#elif !__has_include("ble_api.h")
#elif !__has_include("ble_api.h") || !__has_include("app_ble.h")
// Also skip the SDK body: #error does not stop the preprocessor, and on a 4.2
// SoC ble_api.h exists, so without the guard the 5.x symbols would fail one by
// one and bury this message.
#define BK72XX_BLE_NO_SDK
#error \
"bk72xx_ble requires a BLE 5.x Beken SDK (ble_api.h). Supported SoCs: BK7231N/BK7236 (BLE 5.1) and BK7238/BK7252N/BK7253 (BLE 5.2). BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) are not supported."
"bk72xx_ble requires a BLE 5.x Beken SDK (app_ble.h). Supported SoCs: BK7231N/BK7236 (BLE 5.1) and BK7238/BK7252N/BK7253 (BLE 5.2). BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) are not supported."
#endif
#ifndef BK72XX_BLE_NO_SDK
+2 -1
View File
@@ -103,7 +103,8 @@ struct CameraImageSpec {
/** Abstract camera base class. Collaborates with API.
* 1) API server starts and registers as a listener (add_listener)
* to receive new images from the camera.
* 2) New API client connects and creates a new image reader (create_image_reader).
* 2) API connection creates an image reader (create_image_reader) when it receives
* the first image it will send.
* 3) API connection receives protobuf CameraImageRequest and calls request_image.
* 3.a) API connection receives protobuf CameraImageRequest and calls start_stream.
* 4) Camera implementation provides JPEG data in the CameraImage and notifies listeners.
+15 -14
View File
@@ -360,17 +360,6 @@ static bool has_fault_addr() {
return s_raw_crash_data.exception == PANIC_EXCEPTION_FAULT && !s_raw_crash_data.pseudo_excause;
}
// Append both cores' backtrace addresses to buf; returns the new position.
static int append_all_backtraces(char *buf, int size, int pos) {
pos = append_addrs_to_hint(buf, size, pos, s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count,
s_raw_crash_data.reg_frame_count);
#if SOC_CPU_CORES_NUM > 1
pos = append_addrs_to_hint(buf, size, pos, s_raw_crash_data.other_backtrace, s_raw_crash_data.other_backtrace_count,
s_raw_crash_data.other_reg_frame_count);
#endif
return pos;
}
// The record was captured by a different firmware build (it survives soft
// resets, including the OTA reboot), so symbolizing its addresses against the
// current ELF would produce misleading symbols. Print them with lowercase
@@ -443,11 +432,23 @@ void crash_handler_log() {
}
#endif
// Build addr2line hint with all captured addresses for easy copy-paste
// Build addr2line hints for easy copy-paste. One line per core: the two
// backtraces are separate stacks, and a combined list decodes as one
// impossible call chain (and can overflow the buffer, dropping addresses).
static const char *const ADDR2LINE_CMD = "addr2line -pfiaC -e firmware.elf";
char hint[256];
int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e firmware.elf 0x%08" PRIX32, s_raw_crash_data.pc);
append_all_backtraces(hint, sizeof(hint), pos);
int pos = snprintf(hint, sizeof(hint), "Use: %s 0x%08" PRIX32, ADDR2LINE_CMD, s_raw_crash_data.pc);
append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count,
s_raw_crash_data.reg_frame_count);
ESP_LOGE(TAG, "%s", hint);
#if SOC_CPU_CORES_NUM > 1
if (s_raw_crash_data.other_backtrace_count > 0) {
pos = snprintf(hint, sizeof(hint), "Other core: %s", ADDR2LINE_CMD);
append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.other_backtrace,
s_raw_crash_data.other_backtrace_count, s_raw_crash_data.other_reg_frame_count);
ESP_LOGE(TAG, "%s", hint);
}
#endif
}
} // namespace esphome::esp32
+24 -10
View File
@@ -16,8 +16,10 @@ from esphome.const import (
CONF_VARIANT,
)
from esphome.cpp_generator import add_define
from esphome.types import ConfigType
CODEOWNERS = ["@swoboda1337"]
DEPENDENCIES = ["esp32"]
# esp32_ble raises the task watchdog around the remote BT controller bring-up
AUTO_LOAD = ["watchdog"]
@@ -124,6 +126,22 @@ CONFIG_SCHEMA = cv.typed_schema(
)
def _final_validate(config: ConfigType) -> ConfigType:
# The esp_hosted releases compatible with older ESP-IDF versions crash at
# boot with a heap double free in the SDIO RX path (fixed in esp_hosted
# 2.11.0, which requires ESP-IDF 5.3), so reject them at validation time.
if (idf_ver := esp32.idf_version()) < cv.Version(5, 3, 0):
raise cv.Invalid(
f"esp32_hosted requires ESP-IDF 5.3 or newer, got {idf_ver}. "
"Remove the framework version from your configuration to use the "
"recommended version, or pin a version at or above 5.3."
)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
def _configure_sdio(config):
slot = config[CONF_SLOT]
esp32.add_idf_sdkconfig_option(
@@ -251,18 +269,14 @@ async def to_code(config):
if config[CONF_USE_PSRAM]:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_MEMPOOL_PREFER_SPIRAM", True)
# Library versions
# Library versions; this component set requires ESP-IDF 5.3 or newer,
# which is enforced at validation time.
idf_ver = esp32.idf_version()
os.environ["ESP_IDF_VERSION"] = f"{idf_ver.major}.{idf_ver.minor}"
if idf_ver >= cv.Version(5, 5, 0):
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.6.3")
esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.3")
esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5")
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.12")
else:
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0")
esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0")
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.0.11")
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.6.3")
esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.3")
esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5")
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.12")
esp32.add_extra_script(
"post",
"esp32_hosted.py",
-2
View File
@@ -184,8 +184,6 @@ static int32_t get_firmware_int(const char *version_string) {
return result;
}
float LD2420Component::get_setup_priority() const { return setup_priority::BUS; }
void LD2420Component::dump_config() {
ESP_LOGCONFIG(TAG,
"LD2420:\n"
-1
View File
@@ -105,7 +105,6 @@ class LD2420Component final : public Component, public uart::UARTDevice {
void apply_config_action();
void factory_reset_action();
void revert_config_action();
float get_setup_priority() const override;
int send_cmd_from_array(CmdFrameT cmd_frame);
void report_gate_data();
void handle_cmd_error(uint16_t error);
@@ -220,7 +220,7 @@ void RotaryEncoderSensor::loop() {
}
if (this->pin_i_ != nullptr && this->pin_i_->digital_read()) {
this->store_.counter = 0;
this->store_.counter = std::clamp<int32_t>(0, this->store_.min_value, this->store_.max_value);
}
int counter = this->store_.counter;
if (this->store_.last_read != counter || this->publish_initial_value_) {
+6 -4
View File
@@ -283,8 +283,11 @@ DeltaFilter::DeltaFilter(float min_a0, float min_a1, float max_a0, float max_a1)
void DeltaFilter::set_baseline(float (*fn)(float)) { this->baseline_ = fn; }
optional<float> DeltaFilter::new_value(float value) {
// Always yield the first value.
if (std::isnan(this->last_value_)) {
const bool no_value = std::isnan(value);
const bool no_reference = std::isnan(this->last_value_);
if (no_value && no_reference)
return {};
if (no_value || no_reference) {
this->last_value_ = value;
return value;
}
@@ -293,8 +296,7 @@ optional<float> DeltaFilter::new_value(float value) {
float min = fabsf(this->min_a0_ + ref * this->min_a1_);
float max = fabsf(this->max_a0_ + ref * this->max_a1_);
float delta = fabsf(value - ref);
// if there is no reference, e.g. for the first value, just accept this one,
// otherwise accept only if within range.
// accept only if within range
if (delta > min && delta <= max) {
this->last_value_ = value;
return value;
@@ -307,6 +307,11 @@ void ZigbeeComponent::setup() {
return;
}
#endif
#ifdef CONFIG_ZB_ZCZR
ezb_bdb_set_router_rejoin_required(true);
#endif
ezb_aps_secur_enable_distributed_security(false);
ezb_nwk_set_min_join_lqi(32);
if (ezb_app_signal_add_handler(ZigbeeComponent::app_signal_handler) != ESP_OK) {
+1 -1
View File
@@ -285,7 +285,7 @@ async def attributes_to_code(
async def esp32_to_code(config: ConfigType) -> "MockObj":
add_idf_component(
name="espressif/esp-zigbee-lib",
ref="2.0.3",
ref="2.0.4",
)
# add sdkconfigs later so they can overwrite esp32 defaults
+7
View File
@@ -15,6 +15,13 @@ inline void ESPHOME_ALWAYS_INLINE wake_loop_impl() {
// Set the wake-requested flag BEFORE esp_schedule so the consumer is
// guaranteed to see it on its next gate check.
wake_request_set();
// Skip the post when a wake was already signalled and not yet consumed by
// wakeable_delay(): esp_schedule() -> ets_post() can enter SDK WiFi pm code,
// which must not be poked per-byte from the software serial RX ISR (see
// esphome#18409). The flag can stay latched while the loop is awake, which
// is intentional; posts are only needed to cut a suspend short.
if (g_main_loop_woke)
return;
g_main_loop_woke = true;
esp_schedule();
}
+1 -1
View File
@@ -48,7 +48,7 @@ dependencies:
rules:
- if: "target in [esp32, esp32p4]"
espressif/esp-zigbee-lib:
version: 2.0.3
version: 2.0.4
rules:
- if: "target in [esp32h2, esp32c5, esp32c6]"
espressif/lan87xx:
+31 -1
View File
@@ -5,6 +5,7 @@ import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
from typing import TYPE_CHECKING, Any
@@ -234,6 +235,35 @@ def _check_platformio_python_stamp(config: "ProjectConfig") -> None:
_write_pio_stamp_python(stamp_file, current)
def _ccache_usable() -> bool:
"""Return True when the ``ccache`` on PATH actually runs.
``shutil.which`` proves existence, not runnability: on Windows it also
matches ``.bat``/``.cmd`` wrappers and stale package-manager shims whose
target is gone. Wrapping compiles around such a find fails every compile
step with an opaque OS error, so probe once and fall back to compiling
without ccache when the probe fails.
"""
ccache = shutil.which("ccache")
if ccache is None:
return False
try:
subprocess.run(
[ccache, "--version"],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=15,
)
except (OSError, subprocess.SubprocessError):
_LOGGER.warning(
"Ignoring ccache at %s because it failed to run; compiling without ccache",
ccache,
)
return False
return True
def _ccache_env() -> dict[str, str]:
"""Return ccache settings for PlatformIO builds.
@@ -266,7 +296,7 @@ def _ccache_env() -> dict[str, str]:
if "ESPHOME_CCACHE_ENABLE" in os.environ:
enabled = get_bool_env("ESPHOME_CCACHE_ENABLE")
else:
enabled = shutil.which("ccache") is not None
enabled = _ccache_usable()
env = {"ESPHOME_CCACHE_ENABLE": "1" if enabled else "0"}
if not enabled:
return env
+1 -1
View File
@@ -12,7 +12,7 @@ pyserial==3.5
platformio==6.1.19
esptool==5.3.1
click==8.3.3
aioesphomeapi==45.10.2
aioesphomeapi==45.10.3
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
zeroconf==0.150.0
puremagic==2.2.0
@@ -0,0 +1,7 @@
esphome:
name: bk-family-gate-n
bk72xx:
board: cb2s
bk72xx_ble:
@@ -0,0 +1,7 @@
esphome:
name: bk-family-gate-q
bk72xx:
board: wa2
bk72xx_ble:
@@ -0,0 +1,7 @@
esphome:
name: bk-family-gate-t
bk72xx:
board: generic-bk7231t-qfn32-tuya
bk72xx_ble:
@@ -0,0 +1,7 @@
esphome:
name: bk-family-gate-7252
bk72xx:
board: generic-bk7252
bk72xx_ble:
@@ -0,0 +1,40 @@
"""The non-5.x family rejection lives in to_code (config validation must stay
family-agnostic for the validate-only CI fixtures), so codegen is the only
place it can be pinned."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.core import EsphomeError
@pytest.mark.parametrize(
("config_file", "match"),
[
("test_bk7231t.yaml", "BK7231T.*BLE 4.2"),
("test_bk7252.yaml", "BK7251.*BLE 4.2"),
("test_bk7231q.yaml", "BK7231Q.*no BLE"),
],
)
def test_unsupported_family_rejected(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_file: str,
match: str,
caplog: pytest.LogCaptureFixture,
) -> None:
with pytest.raises(EsphomeError, match=match):
generate_main(component_config_path(config_file))
# Validation itself must not fail (CI validate fixtures run on a BLE 4.2
# board), but it warns before codegen raises.
assert "cannot compile" in caplog.text
def test_ble5_family_generates(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(component_config_path("test_bk7231n.yaml"))
assert "bk72xx_ble::BK72xxBLE" in main_cpp
@@ -2,6 +2,6 @@ esphome:
name: slotcount-controller
bk72xx:
board: generic-bk7252
board: cb2s
bk72xx_ble:
@@ -2,6 +2,6 @@ esphome:
name: slotcount-tracker
bk72xx:
board: generic-bk7252
board: cb2s
bk72xx_ble_tracker:
@@ -0,0 +1,35 @@
"""Tests for the esp32_hosted ESP-IDF version gate."""
import pytest
from esphome import config_validation as cv
from esphome.components.esp32 import KEY_IDF_VERSION
from esphome.components.esp32_hosted import _final_validate
from esphome.const import PlatformFramework
from ..types import SetCoreConfigCallable
@pytest.mark.parametrize("idf", ["5.3.0", "5.4.2", "5.5.5"])
def test_final_validate_accepts_supported_idf(
set_core_config: SetCoreConfigCallable, idf: str
) -> None:
"""ESP-IDF 5.3 and newer passes validation unchanged."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)},
)
assert _final_validate({}) == {}
@pytest.mark.parametrize("idf", ["5.0.0", "5.2.2"])
def test_final_validate_rejects_old_idf(
set_core_config: SetCoreConfigCallable, idf: str
) -> None:
"""ESP-IDF older than 5.3 is rejected with a clear error."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)},
)
with pytest.raises(cv.Invalid, match="requires ESP-IDF 5.3 or newer"):
_final_validate({})
@@ -0,0 +1,19 @@
esphome:
name: camera-mock-test
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
mock_camera:
name: Mock Camera
# Larger than MAX_BATCH_PACKET_SIZE (1390) so the image is split across
# multiple CameraImageResponse chunks and the client must reassemble.
# Must match IMAGE_SIZE in test_camera_mock.py.
image_size: 4096
@@ -0,0 +1,28 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.core.entity_helpers import setup_entity
from esphome.types import ConfigType
CODEOWNERS = ["@esphome/tests"]
AUTO_LOAD = ["camera"]
CONF_IMAGE_SIZE = "image_size"
mock_camera_ns = cg.esphome_ns.namespace("mock_camera")
MockCamera = mock_camera_ns.class_("MockCamera", cg.Component, cg.EntityBase)
CONFIG_SCHEMA = cv.ENTITY_BASE_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(MockCamera),
cv.Optional(CONF_IMAGE_SIZE, default=1024): cv.positive_not_null_int,
}
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_CAMERA")
var = cg.new_Pvariable(config[CONF_ID])
await setup_entity(var, config, "camera")
await cg.register_component(var, config)
cg.add(var.set_image_size(config[CONF_IMAGE_SIZE]))
@@ -0,0 +1,30 @@
#include "mock_camera.h"
#include "esphome/core/application.h"
#include "esphome/core/log.h"
namespace esphome::mock_camera {
static const char *const TAG = "mock_camera";
void MockCamera::loop() {
uint8_t requesters = this->single_requesters_ | this->stream_requesters_;
if (requesters == 0)
return;
uint32_t now = App.get_loop_component_start_time();
if (now - this->last_frame_ms_ < FRAME_INTERVAL_MS)
return;
this->last_frame_ms_ = now;
this->single_requesters_ = 0;
auto image = std::make_shared<MockCameraImage>(this->image_size_, this->frame_counter_, requesters);
ESP_LOGV(TAG, "Producing frame %u (%u bytes, requesters 0x%02X)", this->frame_counter_, this->image_size_,
requesters);
this->frame_counter_++;
for (auto *listener : this->listeners_) {
listener->on_camera_image(image);
}
}
void MockCamera::dump_config() { ESP_LOGCONFIG(TAG, "Mock Camera (%u byte frames)", this->image_size_); }
} // namespace esphome::mock_camera
@@ -0,0 +1,80 @@
#pragma once
#include "esphome/components/camera/camera.h"
#include "esphome/core/component.h"
#include <memory>
#include <vector>
namespace esphome::mock_camera {
/** Deterministic in-memory camera image.
* Byte i of frame N is (N + i) & 0xFF so tests can validate
* reassembled data from just the first byte.
*/
class MockCameraImage : public camera::CameraImage {
public:
MockCameraImage(size_t size, uint8_t frame_counter, uint8_t requesters)
: data_(new uint8_t[size]), size_(size), requesters_(requesters) {
for (size_t i = 0; i < size; i++) {
this->data_[i] = static_cast<uint8_t>(frame_counter + i);
}
}
uint8_t *get_data_buffer() override { return this->data_.get(); }
size_t get_data_length() override { return this->size_; }
bool was_requested_by(camera::CameraRequester requester) const override {
return (this->requesters_ & (1 << requester)) != 0;
}
protected:
std::unique_ptr<uint8_t[]> data_;
size_t size_;
uint8_t requesters_;
};
class MockCameraImageReader : public camera::CameraImageReader {
public:
void set_image(std::shared_ptr<camera::CameraImage> image) override {
this->image_ = std::move(image);
this->offset_ = 0;
}
size_t available() const override { return this->image_ ? this->image_->get_data_length() - this->offset_ : 0; }
uint8_t *peek_data_buffer() override { return this->image_->get_data_buffer() + this->offset_; }
void consume_data(size_t consumed) override { this->offset_ += consumed; }
void return_image() override {
this->image_.reset();
this->offset_ = 0;
}
protected:
std::shared_ptr<camera::CameraImage> image_;
size_t offset_{0};
};
/** Virtual camera producing deterministic frames on request or stream. */
class MockCamera : public camera::Camera {
public:
void loop() override;
void dump_config() override;
void add_listener(camera::CameraListener *listener) override { this->listeners_.push_back(listener); }
camera::CameraImageReader *create_image_reader() override { return new MockCameraImageReader(); }
void request_image(camera::CameraRequester requester) override { this->single_requesters_ |= (1 << requester); }
void start_stream(camera::CameraRequester requester) override { this->stream_requesters_ |= (1 << requester); }
void stop_stream(camera::CameraRequester requester) override { this->stream_requesters_ &= ~(1 << requester); }
void set_image_size(uint32_t size) { this->image_size_ = size; }
protected:
static constexpr uint32_t FRAME_INTERVAL_MS = 50;
// Members ordered largest to smallest to minimize padding
std::vector<camera::CameraListener *> listeners_;
uint32_t image_size_{1024};
uint32_t last_frame_ms_{0};
uint8_t frame_counter_{0};
uint8_t single_requesters_{0};
uint8_t stream_requesters_{0};
};
} // namespace esphome::mock_camera
@@ -33,6 +33,11 @@ sensor:
id: source_sensor_5
accuracy_decimals: 1
- platform: template
name: "Source Sensor 6"
id: source_sensor_6
accuracy_decimals: 1
- platform: copy
source_id: source_sensor_1
name: "Filter Min"
@@ -81,6 +86,13 @@ sensor:
filters:
- delta: 50%
- platform: copy
source_id: source_sensor_6
name: "Filter NaN"
id: filter_nan
filters:
- delta: 0
script:
- id: test_filter_min
then:
@@ -188,6 +200,24 @@ script:
id: source_sensor_5
state: 250.0 # Passes (delta=90 > 80)
- id: test_filter_nan
then:
- sensor.template.publish:
id: source_sensor_6
state: 1.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor_6
state: !lambda "return NAN;"
- delay: 20ms
- sensor.template.publish:
id: source_sensor_6
state: !lambda "return NAN;" # Filtered out
- delay: 20ms
- sensor.template.publish:
id: source_sensor_6
state: 2.0
button:
- platform: template
name: "Test Filter Min"
@@ -218,3 +248,9 @@ button:
id: btn_filter_percentage
on_press:
- script.execute: test_filter_percentage
- platform: template
name: "Test Filter NaN"
id: btn_filter_nan
on_press:
- script.execute: test_filter_nan
+73
View File
@@ -0,0 +1,73 @@
"""Integration test for the camera API flow using a mock camera platform."""
from __future__ import annotations
import asyncio
from aioesphomeapi import CameraInfo, CameraState, EntityState
import pytest
from .state_utils import require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
# Must match image_size in fixtures/camera_mock.yaml
IMAGE_SIZE = 4096
STREAM_FRAMES = 3
def _verify_frame(data: bytes) -> int:
"""Verify the deterministic frame pattern and return the frame counter."""
assert len(data) == IMAGE_SIZE, f"expected {IMAGE_SIZE} bytes, got {len(data)}"
counter = data[0]
assert data == bytes((counter + i) & 0xFF for i in range(IMAGE_SIZE)), (
"frame pattern mismatch"
)
return counter
@pytest.mark.asyncio
async def test_camera_mock(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Single-image and stream requests deliver reassembled deterministic frames."""
async with run_compiled(yaml_config), api_client_connected() as client:
entities, _ = await client.list_entities_services()
camera = require_entity(entities, "mock_camera", CameraInfo)
loop = asyncio.get_running_loop()
images: list[bytes] = []
single_image: asyncio.Future[None] = loop.create_future()
stream_done: asyncio.Future[None] = loop.create_future()
def on_state(state: EntityState) -> None:
if not (isinstance(state, CameraState) and state.key == camera.key):
return
images.append(bytes(state.data))
if not single_image.done():
single_image.set_result(None)
elif len(images) >= STREAM_FRAMES and not stream_done.done():
stream_done.set_result(None)
client.subscribe_states(on_state)
# Single image request: one complete frame arrives, reassembled
# from multiple chunks (4096 > 1390 byte packets)
client.request_single_image()
await asyncio.wait_for(single_image, timeout=10)
first_counter = _verify_frame(images[0])
# Stream request: multiple consecutive frames arrive
images.clear()
client.request_image_stream()
await asyncio.wait_for(stream_done, timeout=10)
# Frames are distinct, ordered, and fresh per the mock's counter.
# Not exactly consecutive: the API drops frames by design while the
# previous image is still being sent, so allow small gaps.
counters = [_verify_frame(img) for img in images[:STREAM_FRAMES]]
for prev, cur in zip(counters, counters[1:], strict=False):
assert cur != prev, f"duplicate frames: {counters}"
assert ((cur - prev) & 0xFF) < 16, f"frames out of order: {counters}"
assert counters[0] != first_counter, "stream should produce new frames"
+35 -3
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
import math
from aioesphomeapi import ButtonInfo, EntityState, SensorState
import pytest
@@ -25,6 +26,7 @@ async def test_sensor_filters_delta(
"filter_baseline_max": [],
"filter_zero_delta": [],
"filter_percentage": [],
"filter_nan": [],
}
filter_min_done = loop.create_future()
@@ -32,16 +34,23 @@ async def test_sensor_filters_delta(
filter_baseline_max_done = loop.create_future()
filter_zero_delta_done = loop.create_future()
filter_percentage_done = loop.create_future()
filter_nan_done = loop.create_future()
def on_state(state: EntityState) -> None:
if not isinstance(state, SensorState) or state.missing_state:
if not isinstance(state, SensorState):
return
sensor_name = key_to_sensor.get(state.key)
if sensor_name not in sensor_values:
return
sensor_values[sensor_name].append(state.state)
if state.missing_state:
# Only the NaN test is interested in unavailable states
if sensor_name != "filter_nan":
return
sensor_values[sensor_name].append(math.nan)
else:
sensor_values[sensor_name].append(state.state)
# Check completion conditions
if (
@@ -74,6 +83,12 @@ async def test_sensor_filters_delta(
and not filter_percentage_done.done()
):
filter_percentage_done.set_result(True)
elif (
sensor_name == "filter_nan"
and len(sensor_values[sensor_name]) == 3
and not filter_nan_done.done()
):
filter_nan_done.set_result(True)
async with (
run_compiled(yaml_config),
@@ -89,6 +104,7 @@ async def test_sensor_filters_delta(
"filter_baseline_max": "Filter Baseline Max",
"filter_zero_delta": "Filter Zero Delta",
"filter_percentage": "Filter Percentage",
"filter_nan": "Filter NaN",
},
)
@@ -108,13 +124,14 @@ async def test_sensor_filters_delta(
"Test Filter Baseline Max": "filter_baseline_max",
"Test Filter Zero Delta": "filter_zero_delta",
"Test Filter Percentage": "filter_percentage",
"Test Filter NaN": "filter_nan",
}
buttons = {}
for entity in entities:
if isinstance(entity, ButtonInfo) and entity.name in button_name_map:
buttons[button_name_map[entity.name]] = entity.key
assert len(buttons) == 5, f"Expected 5 buttons, found {len(buttons)}"
assert len(buttons) == 6, f"Expected 6 buttons, found {len(buttons)}"
# Test 1: Min
sensor_values["filter_min"].clear()
@@ -186,3 +203,18 @@ async def test_sensor_filters_delta(
assert sensor_values["filter_percentage"] == pytest.approx(expected), (
f"Test 5 failed: expected {expected}, got {sensor_values['filter_percentage']}"
)
# Test 6: NaN passes through once, then is suppressed
sensor_values["filter_nan"].clear()
client.button_command(buttons["filter_nan"])
try:
await asyncio.wait_for(filter_nan_done, timeout=2.0)
except TimeoutError:
pytest.fail(f"Test 6 timed out. Values: {sensor_values['filter_nan']}")
values = sensor_values["filter_nan"]
assert values[0] == pytest.approx(1.0), f"Test 6 failed: got {values}"
assert math.isnan(values[1]), (
f"Test 6 failed: NaN not passed through, got {values}"
)
assert values[2] == pytest.approx(2.0), f"Test 6 failed: got {values}"
+47 -1
View File
@@ -9,6 +9,7 @@ import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import threading
from types import SimpleNamespace
@@ -431,6 +432,7 @@ def test_ccache_env_enabled_by_default(setup_core: Path) -> None:
with (
patch.dict(os.environ, {}, clear=True),
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
patch.object(toolchain.subprocess, "run"),
):
env = toolchain._ccache_env()
@@ -457,6 +459,44 @@ def test_ccache_env_disabled_without_binary(setup_core: Path) -> None:
assert env == {"ESPHOME_CCACHE_ENABLE": "0"}
@pytest.mark.parametrize(
"probe_error",
[
pytest.param(OSError("not runnable"), id="oserror"),
pytest.param(subprocess.CalledProcessError(1, "ccache"), id="nonzero-exit"),
pytest.param(subprocess.TimeoutExpired("ccache", 15), id="timeout"),
],
)
def test_ccache_env_disabled_when_probe_fails(
setup_core: Path, probe_error: Exception
) -> None:
"""A ccache that resolves on PATH but fails to run stays disabled."""
CORE.build_path = setup_core / "build" / "test"
with (
patch.dict(os.environ, {}, clear=True),
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
patch.object(toolchain.subprocess, "run", side_effect=probe_error),
):
env = toolchain._ccache_env()
assert env == {"ESPHOME_CCACHE_ENABLE": "0"}
def test_ccache_env_forced_on_skips_probe(setup_core: Path) -> None:
"""An explicit ESPHOME_CCACHE_ENABLE=1 does not probe the binary."""
CORE.build_path = setup_core / "build" / "test"
with (
patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "1"}, clear=True),
patch.object(toolchain.subprocess, "run") as mock_probe,
):
env = toolchain._ccache_env()
assert env["ESPHOME_CCACHE_ENABLE"] == "1"
mock_probe.assert_not_called()
def test_ccache_env_opt_out(setup_core: Path) -> None:
"""ESPHOME_CCACHE_ENABLE=0 disables ccache even with the binary present."""
CORE.build_path = setup_core / "build" / "test"
@@ -496,6 +536,7 @@ def test_ccache_env_respects_user_values_and_refreshes_basedir(
with (
patch.dict(os.environ, user_env, clear=True),
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
patch.object(toolchain.subprocess, "run"),
):
env = toolchain._ccache_env()
@@ -514,6 +555,7 @@ def test_run_platformio_cli_passes_ccache_env_to_subprocess_only(
with (
patch.dict(os.environ, {}, clear=False),
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
patch.object(toolchain.subprocess, "run"),
):
os.environ.pop("ESPHOME_CCACHE_ENABLE", None)
mock_run_external_process.return_value = 0
@@ -533,6 +575,7 @@ def test_ccache_env_requires_build_path(setup_core: Path) -> None:
with (
patch.dict(os.environ, {}, clear=True),
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
patch.object(toolchain.subprocess, "run"),
pytest.raises(ValueError, match="CORE.build_path must be set"),
):
toolchain._ccache_env()
@@ -544,7 +587,10 @@ def test_run_platformio_cli_merges_caller_env(
"""A caller-supplied env is the base and gains the ccache settings."""
CORE.build_path = str(setup_core / "build" / "test")
with patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"):
with (
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
patch.object(toolchain.subprocess, "run"),
):
mock_run_external_process.return_value = 0
toolchain.run_platformio_cli(
"test", env={"CUSTOM_VAR": "1", "ESPHOME_CCACHE_ENABLE": "0"}