Compare commits

...
Author SHA1 Message Date
Kevin Ahrendt af61645b84 [sendspin] Add the source role, streaming a microphone to the server
The server starts and stops the stream; the microphone runs only while it is
open. Each buffer is passed to sendspin-cpp with its capture time. Supports PCM
and Opus.
2026-10-26 08:30:50 -04:00
Kevin Ahrendt 099ffda05d [microphone] Pass a capture time with each audio buffer
Data callbacks can take the capture time of the buffer's first frame as a second
argument. Callbacks that only take the data keep working. Microphone platforms
publish through publish_audio_().
2026-10-26 08:30:42 -04:00
Bascht74andJ. Nick Koston 2d37ad6836 [api] Keep an entity's events in order under backpressure (#21567)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-25 23:57:11 -10:00
Clyde Stubbs d2f6b389d4 [runtime_image] Fix LVGL widgets on image resize (#21368) 2026-10-26 20:08:06 +11:00
Raphael Hehl 875b2076c0 [esp32_camera] Fix lost single image requests (#21613) 2026-10-25 22:04:24 -10:00
c21660251c [modbus_client] Pass the request's address to the send handlers (#20461)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Dale Klein <49771139+dalklein@users.noreply.github.com>
Co-authored-by: Bonne Eggleston <bonne@exciton.com.au>
2026-10-25 21:05:10 -10:00
esphome[bot] 995acb94b4 [ci] Refresh integration test durations (#21640) 2026-10-26 05:58:30 +00:00
Manuel MauszandJ. Nick Koston 24b7eefaad [remote_base] Add support for Cardin S438 remotes (#21507)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-25 19:56:32 -10:00
Clyde StubbsandClaude Opus 5.5 2349e0be00 [esp32] Check that code is mapped when reporting a crash (#21630)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:49:16 +11:00
Clyde StubbsandClaude Opus 5.5 63349801b7 [ci] Include dependencies added by overrides of dependency components (#21635)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:18:17 -10:00
afdfa7b298 [gree] Add use_fahrenheit for Fahrenheit display on the unit's panel (#20751)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-25 17:09:04 -10:00
JoHoenk 400407b135 [runtime_image] Allocate the JPEG decoder state from PSRAM (#21629) 2026-10-26 08:28:49 +11:00
Jonathan SwobodaandJ. Nick Koston 22b32b194a [core][scheduler] Count a cancelled item on a failed component as removed (#21626)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-25 15:36:17 -04:00
J. Nick Koston 6dcd989a7f [switch][number][text] Follow the source with plain binary sensors, sensors and text sensors (#21612) 2026-10-25 18:05:42 +00:00
J. Nick Koston 2016884210 [copy] Follow the source with plain sensors and text sensors (#21610) 2026-10-25 07:52:01 -10:00
J. Nick Koston d3f4f44c7b [core] Add follow helpers for plain sensors and text sensors (#21609) 2026-10-25 07:50:58 -10:00
Jonathan Swoboda 6df15be714 [tests] Cover remote, modbus and bus action options (#21616) 2026-10-25 07:50:12 -10:00
Jonathan Swoboda a03fe3c38e [tests] Cover network and scripting action options (#21615) 2026-10-25 07:49:52 -10:00
Jonathan Swoboda afc8c51f93 [core] Keep components that failed before setup out of the loop (#21622) 2026-10-25 07:49:32 -10:00
Jonathan Swoboda ef7ed69f9d [lightwaverf] Stop requiring the unused name on send_raw (#21620) 2026-10-25 07:48:57 -10:00
Jonathan Swoboda ec32802662 [core] Avoid division by zero in Color::random_color() (#21623) 2026-10-25 07:48:34 -10:00
Jonathan Swoboda 433d8046b1 [espota2] Stop waiting when the device closes mid-reply (#21624) 2026-10-25 07:48:07 -10:00
Jonathan Swoboda 8ecb981656 [tests] Cover device and lvgl action options (#21617) 2026-10-25 07:47:36 -10:00
Jonathan Swoboda 8c990573d7 [core] Generate valid C++ for infinite float values (#21625) 2026-10-25 07:47:24 -10:00
J. Nick Koston bdec0c1485 [copy] Add an integration test for the sensor, binary sensor and text sensor platforms (#21608) 2026-10-25 10:18:37 -04:00
Jonathan Swobodaandclydebarrow 70e85d939d [lvgl] Make canvas gradient and dashed line options compile (#21580)
Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com>
2026-10-25 08:59:32 -04:00
Clyde Stubbs 25b3c2820a [core][display] Let users take over a display's enable pins (#21605) 2026-10-25 20:47:50 +11:00
Jonathan Swoboda ef53e675ff [lvgl] Apply opa on indicator.update (#21579) 2026-10-24 21:43:38 -10:00
189 changed files with 5822 additions and 769 deletions

No files matched your search

+11 -2
View File
@@ -1555,8 +1555,17 @@ void APIConnection::on_water_heater_command_request(const WaterHeaterCommandRequ
// Event is a special case - unlike other entities with simple state fields,
// events store their state in a member accessed via obj->get_last_event_type()
void APIConnection::send_event(event::Event *event) {
this->send_message_smart_(event, EventResponse::MESSAGE_TYPE, EventResponse::ESTIMATED_SIZE,
event->get_last_event_type_index());
const uint8_t type_index = event->get_last_event_type_index();
const size_t size_before = this->deferred_batch_.size();
if (this->deferred_batch_.contains(event, EventResponse::MESSAGE_TYPE)) {
// Sending now would overtake the older event of this entity that still waits in the batch
this->schedule_message_(event, EventResponse::MESSAGE_TYPE, EventResponse::ESTIMATED_SIZE, type_index);
} else {
this->send_message_smart_(event, EventResponse::MESSAGE_TYPE, EventResponse::ESTIMATED_SIZE, type_index);
}
// Events are rare, so a queued one sends the batch now rather than after batch_delay
if (this->deferred_batch_.size() > size_before)
this->make_batch_due_now_();
}
uint16_t APIConnection::try_send_event_response(event::Event *event, StringRef event_type, APIConnection *conn,
uint32_t remaining_size) {
+5 -68
View File
@@ -2,6 +2,7 @@
#include "esphome/core/defines.h"
#ifdef USE_API
#include "api_deferred_batch.h"
#include "api_frame_helper.h"
#ifdef USE_API_NOISE
#include "api_frame_helper_noise.h"
@@ -20,7 +21,6 @@
#include "esphome/core/string_ref.h"
#include <functional>
#include <limits>
#include <vector>
namespace esphome {
@@ -656,73 +656,6 @@ class APIConnection final : public APIServerConnectionBase {
// Function pointer type for message encoding
using MessageCreatorPtr = uint16_t (*)(EntityBase *, APIConnection *, uint32_t remaining_size);
// Generic batching mechanism for both state updates and entity info
struct DeferredBatch {
// Sentinel value for unused aux_data_index
static constexpr uint8_t AUX_DATA_UNUSED = std::numeric_limits<uint8_t>::max();
struct BatchItem {
EntityBase *entity; // 4 bytes - Entity pointer
uint16_t message_type; // 2 bytes - Message type for protocol and dispatch
uint8_t estimated_size; // 1 byte - Estimated message size (max 255 bytes)
uint8_t aux_data_index{AUX_DATA_UNUSED}; // 1 byte - For events: index into entity's event_types
};
std::vector<BatchItem> items;
uint32_t batch_start_time{0};
// No pre-allocation - log connections never use batching, and for
// connections that do, buffers are released after initial sync anyway
// Add item to the batch (with deduplication)
void add_item(EntityBase *entity, uint16_t message_type, uint8_t estimated_size,
uint8_t aux_data_index = AUX_DATA_UNUSED) {
// Dedup: O(n) scan but optimized for RAM over performance
// Skip deduplication for events - they are edge-triggered, every occurrence matters
#ifdef USE_EVENT
if (message_type != EventResponse::MESSAGE_TYPE)
#endif
{
for (const auto &item : this->items) {
if (item.entity == entity && item.message_type == message_type)
return; // Already queued
}
}
this->items.push_back({entity, message_type, estimated_size, aux_data_index});
}
// Add item to the front of the batch (for high priority messages like ping)
void add_item_front(EntityBase *entity, uint16_t message_type, uint8_t estimated_size) {
// Swap to front avoids expensive vector::insert which shifts all elements
this->items.push_back({entity, message_type, estimated_size, AUX_DATA_UNUSED});
if (this->items.size() > 1) {
std::swap(this->items.front(), this->items.back());
}
}
// Clear all items
void clear() {
items.clear();
batch_start_time = 0;
}
// Remove processed items from the front — noinline to keep memmove out of warm callers
void remove_front(size_t count) __attribute__((noinline)) { items.erase(items.begin(), items.begin() + count); }
bool empty() const { return items.empty(); }
size_t size() const { return items.size(); }
const BatchItem &operator[](size_t index) const { return items[index]; }
// Release excess capacity - only releases if items already empty
void release_buffer() {
// Safe to call: batch is processed before release_buffer is called,
// and if any items remain (partial processing), we must not clear them.
// Use swap trick since shrink_to_fit() is non-binding and may be ignored.
if (items.empty()) {
std::vector<BatchItem>().swap(items);
}
}
};
// DeferredBatch here (16 bytes, 4-byte aligned)
DeferredBatch deferred_batch_;
@@ -795,6 +728,10 @@ class APIConnection final : public APIServerConnectionBase {
static constexpr size_t MAX_BATCH_PACKET_SIZE = 1390; // MTU
bool schedule_batch_();
// Sends the batch on the next loop, so a queued event does not wait for batch_delay; queued states go with it
void make_batch_due_now_() {
this->deferred_batch_.batch_start_time = App.get_loop_component_start_time() - this->get_batch_delay_ms_();
}
void process_batch_();
void process_batch_multi_(APIBuffer &shared_buf, size_t num_items, uint8_t header_padding, uint8_t footer_size)
__attribute__((noinline));
@@ -0,0 +1,88 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_API
#include "api_pb2.h"
#include "esphome/core/entity_base.h"
#include <algorithm>
#include <cstdint>
#include <limits>
#include <vector>
namespace esphome::api {
// Generic batching mechanism for both state updates and entity info
struct DeferredBatch {
// Sentinel value for unused aux_data_index
static constexpr uint8_t AUX_DATA_UNUSED = std::numeric_limits<uint8_t>::max();
struct BatchItem {
EntityBase *entity; // 4 bytes - Entity pointer
uint16_t message_type; // 2 bytes - Message type for protocol and dispatch
uint8_t estimated_size; // 1 byte - Estimated message size (max 255 bytes)
uint8_t aux_data_index{AUX_DATA_UNUSED}; // 1 byte - For events: index into entity's event_types
};
std::vector<BatchItem> items;
uint32_t batch_start_time{0};
// No pre-allocation - log connections never use batching, and for
// connections that do, buffers are released after initial sync anyway
// O(n) scan but optimized for RAM over performance
bool contains(EntityBase *entity, uint16_t message_type) const {
for (const auto &item : this->items) {
if (item.entity == entity && item.message_type == message_type)
return true;
}
return false;
}
// Add item to the batch (with deduplication)
void add_item(EntityBase *entity, uint16_t message_type, uint8_t estimated_size,
uint8_t aux_data_index = AUX_DATA_UNUSED) {
// Skip deduplication for events - they are edge-triggered, every occurrence matters
#ifdef USE_EVENT
if (message_type != EventResponse::MESSAGE_TYPE)
#endif
{
if (this->contains(entity, message_type))
return; // Already queued
}
this->items.push_back({entity, message_type, estimated_size, aux_data_index});
}
// Add item to the front of the batch (for high priority messages like ping)
void add_item_front(EntityBase *entity, uint16_t message_type, uint8_t estimated_size) {
// Shift rather than swap, so the queued items keep their order (two events of one entity must not swap)
this->items.push_back({});
std::move_backward(this->items.begin(), this->items.end() - 1, this->items.end());
this->items.front() = {entity, message_type, estimated_size, AUX_DATA_UNUSED};
}
// Clear all items
void clear() {
items.clear();
batch_start_time = 0;
}
// Remove processed items from the front — noinline to keep memmove out of warm callers
void remove_front(size_t count) __attribute__((noinline)) { items.erase(items.begin(), items.begin() + count); }
bool empty() const { return items.empty(); }
size_t size() const { return items.size(); }
const BatchItem &operator[](size_t index) const { return items[index]; }
// Release excess capacity - only releases if items already empty
void release_buffer() {
// Safe to call: batch is processed before release_buffer is called,
// and if any items remain (partial processing), we must not clear them.
// Use swap trick since shrink_to_fit() is non-binding and may be ignored.
if (items.empty()) {
std::vector<BatchItem>().swap(items);
}
}
};
} // namespace esphome::api
#endif // USE_API
+5 -16
View File
@@ -13,19 +13,10 @@ from esphome.const import (
from esphome.core.entity_helpers import inherit_property_from
from esphome.types import ConfigType
from .. import copy_ns
CopySensor = copy_ns.class_("CopySensor", sensor.Sensor, cg.Component)
CONFIG_SCHEMA = (
sensor.sensor_schema(CopySensor)
.extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(sensor.Sensor),
}
)
.extend(cv.COMPONENT_SCHEMA)
CONFIG_SCHEMA = sensor.sensor_schema(sensor.Sensor).extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(sensor.Sensor),
}
)
FINAL_VALIDATE_SCHEMA = cv.All(
@@ -40,6 +31,4 @@ FINAL_VALIDATE_SCHEMA = cv.All(
async def to_code(config: ConfigType) -> None:
var = await sensor.new_sensor(config)
await cg.register_component(var, config)
await cg.add_variable(var.set_source, config[CONF_SOURCE_ID])
sensor.follow_sensor(var, await cg.get_variable(config[CONF_SOURCE_ID]))
@@ -1,16 +0,0 @@
#include "copy_sensor.h"
#include "esphome/core/log.h"
namespace esphome::copy {
ESPHOME_LOG_TAG(TAG, "copy.sensor");
void CopySensor::setup() {
source_->add_on_state_callback([this](float value) { this->publish_state(value); });
if (source_->has_state())
this->publish_state(source_->state);
}
void CopySensor::dump_config() { LOG_SENSOR("", "Copy Sensor", this); }
} // namespace esphome::copy
@@ -1,18 +0,0 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.h"
namespace esphome::copy {
class CopySensor final : public sensor::Sensor, public Component {
public:
void set_source(sensor::Sensor *source) { source_ = source; }
void setup() override;
void dump_config() override;
protected:
sensor::Sensor *source_;
};
} // namespace esphome::copy
@@ -5,19 +5,10 @@ from esphome.const import CONF_ENTITY_CATEGORY, CONF_ICON, CONF_SOURCE_ID
from esphome.core.entity_helpers import inherit_property_from
from esphome.types import ConfigType
from .. import copy_ns
CopyTextSensor = copy_ns.class_("CopyTextSensor", text_sensor.TextSensor, cg.Component)
CONFIG_SCHEMA = (
text_sensor.text_sensor_schema(CopyTextSensor)
.extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(text_sensor.TextSensor),
}
)
.extend(cv.COMPONENT_SCHEMA)
CONFIG_SCHEMA = text_sensor.text_sensor_schema(text_sensor.TextSensor).extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(text_sensor.TextSensor),
}
)
FINAL_VALIDATE_SCHEMA = cv.All(
@@ -28,6 +19,4 @@ FINAL_VALIDATE_SCHEMA = cv.All(
async def to_code(config: ConfigType) -> None:
var = await text_sensor.new_text_sensor(config)
await cg.register_component(var, config)
await cg.add_variable(var.set_source, config[CONF_SOURCE_ID])
text_sensor.follow_text_sensor(var, await cg.get_variable(config[CONF_SOURCE_ID]))
@@ -1,16 +0,0 @@
#include "copy_text_sensor.h"
#include "esphome/core/log.h"
namespace esphome::copy {
ESPHOME_LOG_TAG(TAG, "copy.text_sensor");
void CopyTextSensor::setup() {
source_->add_on_state_callback([this](const std::string &value) { this->publish_state(value); });
if (source_->has_state())
this->publish_state(source_->state);
}
void CopyTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Copy Sensor", this); }
} // namespace esphome::copy
@@ -1,18 +0,0 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/text_sensor/text_sensor.h"
namespace esphome::copy {
class CopyTextSensor final : public text_sensor::TextSensor, public Component {
public:
void set_source(text_sensor::TextSensor *source) { source_ = source; }
void setup() override;
void dump_config() override;
protected:
text_sensor::TextSensor *source_;
};
} // namespace esphome::copy
+30 -1
View File
@@ -1,6 +1,7 @@
from dataclasses import dataclass
from typing import Any, Protocol
from esphome import automation, core
from esphome import automation, core, pins
from esphome.automation import maybe_simple_id
import esphome.codegen as cg
from esphome.components.const import (
@@ -13,6 +14,7 @@ import esphome.config_validation as cv
from esphome.const import (
CONF_AUTO_CLEAR_ENABLED,
CONF_DIMENSIONS,
CONF_ENABLE_PIN,
CONF_FROM,
CONF_HEIGHT,
CONF_ID,
@@ -54,6 +56,33 @@ DISPLAY_ROTATIONS = {
}
class DisplayModel(Protocol):
"""The parts of a display model used by enable_pin_schema()."""
def get_default(self, key: str, fallback: Any = ...) -> Any: ...
def enable_pin_schema(
model: DisplayModel, config: ConfigType
) -> dict[cv.Optional, Any]:
"""
Get the schema entry for a display's enable pins.
Pins given in the user config are used as they are. Otherwise the model's default enable
pins are used, except any that is also used elsewhere in the config, so a user can take
control of that pin, e.g. with a switch.
:param config: The display config as given by the user
"""
schema = cv.ensure_list(pins.gpio_output_pin_schema)
default = model.get_default(CONF_ENABLE_PIN, None)
if default is None or CONF_ENABLE_PIN in config:
return {cv.Optional(CONF_ENABLE_PIN): schema}
return {
cv.Optional(
CONF_ENABLE_PIN, default=default
): pins.PIN_SCHEMA_REGISTRY.optional(schema)
}
def validate_rotation(value):
value = cv.string(value)
value = value.removesuffix("°")
+1 -1
View File
@@ -146,7 +146,7 @@ def model_schema(config):
cv.GenerateID(): cv.declare_id(class_name),
cv.GenerateID(CONF_INIT_SEQUENCE_ID): cv.declare_id(cg.uint8),
cv_dimensions(CONF_DIMENSIONS): dimension_schema(model),
model.option(CONF_ENABLE_PIN): cv.ensure_list(pins.gpio_output_pin_schema),
**display.enable_pin_schema(model, config),
model.option(CONF_INIT_SEQUENCE, cv.UNDEFINED): cv.ensure_list(
map_sequence
),
@@ -1,8 +1,8 @@
from typing import Any, Self
from esphome.components.mipi import check_model_requirements
import esphome.config_validation as cv
from esphome.const import CONF_DIMENSIONS, CONF_HEIGHT, CONF_OFFSET_HEIGHT, CONF_WIDTH
from esphome.core import CORE
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
@@ -128,24 +128,5 @@ class EpaperModel:
return model
def check_requirements(self) -> None:
"""
Raise a friendly error if any component this model requires is not configured.
This runs during schema validation (before ID references are resolved) so that a
model whose default pins live on a pin expander reports the missing expander clearly
instead of a cryptic "Couldn't find ID" from the unresolved pin reference.
"""
if requirements := self.get_default("requires", set()):
# ``raw_config`` is populated before any component schema runs during a real
# validation, so presence of a required component is simply a top-level key.
# When it is absent (e.g. a unit test that invokes the schema directly) there
# is no config to check against, so skip.
global_config = CORE.raw_config
if global_config is None:
return
missing = {x for x in requirements if x not in global_config}
if missing:
reqstr = ", ".join(f"'{x}'" for x in sorted(missing))
raise cv.Invalid(
f"{self.name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured"
)
"""Check that the components this model needs are configured, see check_model_requirements()."""
check_model_requirements(self.name, self.defaults)
+16 -2
View File
@@ -31,11 +31,25 @@ static inline bool IRAM_ATTR is_code_addr(uint32_t addr) {
}
#if CONFIG_IDF_TARGET_ARCH_RISCV
// Bounds of this image's flash-mapped code, from the ESP-IDF linker script.
extern "C" char _instruction_reserved_start;
extern "C" char _instruction_reserved_end;
// Check that [start, end) lies in this image's flash-mapped code or in IRAM.
// The IROM range is larger than what the MMU maps, and reading an unmapped
// address faults.
static inline bool is_readable_code(uint32_t start, uint32_t end) {
auto irom_start = reinterpret_cast<uint32_t>(&_instruction_reserved_start);
auto irom_end = reinterpret_cast<uint32_t>(&_instruction_reserved_end);
return (start >= irom_start && end <= irom_end) || (start >= SOC_IRAM_LOW && end <= SOC_IRAM_HIGH);
}
// Check if a code address is a real return address by verifying the preceding
// instruction is a JAL or JALR with rd=ra (x1). Called at log time (not during
// panic) so flash cache is available and both IRAM and IROM are safely readable.
// panic) so flash cache is available. The address may come from an earlier
// build, so check that the bytes before it are mapped before reading them.
static inline bool is_return_addr(uint32_t addr) {
if (!is_code_addr(addr) || addr < 4)
if (!is_code_addr(addr) || !is_readable_code(addr - 4, addr))
return false;
// A return address on the stack points to the instruction after a call.
// Check for 4-byte JAL/JALR call instruction before this address.
@@ -271,7 +271,9 @@ void ESP32Camera::loop() {
fb->timestamp = timestamp;
}
#endif
this->current_image_ = std::make_shared<ESP32CameraImage>(fb, this->single_requesters_ | this->stream_requesters_);
// A request that arrives while this image is handed out is kept for the next frame
const uint8_t single_requesters = this->single_requesters_.exchange(0);
this->current_image_ = std::make_shared<ESP32CameraImage>(fb, single_requesters | this->stream_requesters_);
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
ESP_LOGV(TAG, "Got Image: len=%u", fb->len);
@@ -291,7 +293,6 @@ void ESP32Camera::loop() {
listener->on_camera_image(this->current_image_);
}
this->last_update_ = now;
this->single_requesters_ = 0;
}
/* ---------------- constructors ---------------- */
+5 -1
View File
@@ -1,7 +1,7 @@
import esphome.codegen as cg
from esphome.components import climate_ir
import esphome.config_validation as cv
from esphome.const import CONF_MODEL
from esphome.const import CONF_MODEL, CONF_USE_FAHRENHEIT
from esphome.types import ConfigType
from . import gree_ns
@@ -19,6 +19,7 @@ MODELS = Model.options(
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(GreeClimate).extend(
{
cv.Required(CONF_MODEL): cv.enum(MODELS, lower=True),
cv.Optional(CONF_USE_FAHRENHEIT, default=False): cv.boolean,
}
)
@@ -26,5 +27,8 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(GreeClimate).extend(
async def to_code(config: ConfigType) -> None:
var = await climate_ir.new_climate_ir(config)
cg.add(var.set_model(config[CONF_MODEL]))
if config[CONF_USE_FAHRENHEIT]:
cg.add_define("USE_GREE_FAHRENHEIT")
cg.add(var.set_use_fahrenheit(True))
if config[CONF_MODEL] == "yaw1f":
cg.add_define("USE_GREE_YAW1F")
+30 -9
View File
@@ -1,4 +1,6 @@
#include "gree.h"
#include <cmath>
#include "esphome/components/remote_base/remote_base.h"
#include "esphome/core/helpers.h"
@@ -34,6 +36,21 @@ void GreeClimate::set_mode_bit(uint8_t bit_mask, bool enabled) {
this->transmit_state();
}
#ifdef USE_GREE_FAHRENHEIT
uint8_t fahrenheit_half_steps(float target_celsius) {
if (!std::isfinite(target_celsius)) {
return 0;
}
// Whole degrees shown at k = 0 and at the top of the range
static constexpr int MIN_F = static_cast<int>(celsius_to_fahrenheit(GREE_TEMP_MIN));
static constexpr int MAX_F = static_cast<int>(celsius_to_fahrenheit(GREE_TEMP_MAX));
const int desired_f = static_cast<int>(roundf(clamp<float>(celsius_to_fahrenheit(target_celsius), MIN_F, MAX_F)));
// The display at k is (10 * 32 + 18 * GREE_TEMP_MIN + 9 * k) / 10; pick the smallest k that reaches desired_f
const int tenths_above_min = 10 * desired_f - (10 * 32 + 18 * GREE_TEMP_MIN);
return static_cast<uint8_t>((tenths_above_min + 8) / 9);
}
#endif
void GreeClimate::transmit_state() {
uint8_t remote_state[8] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00};
const Model model = this->model_;
@@ -41,7 +58,12 @@ void GreeClimate::transmit_state() {
const bool yag_layout = model == GREE_YAG || model == GREE_YAW1F;
remote_state[0] = this->fan_speed_() | this->operation_mode_();
#ifdef USE_GREE_FAHRENHEIT
const uint8_t half_steps = this->use_fahrenheit_ ? fahrenheit_half_steps(this->target_temperature) : 0;
remote_state[1] = this->use_fahrenheit_ ? half_steps >> 1 : this->temperature_();
#else
remote_state[1] = this->temperature_();
#endif
if (model == GREE_YAN) {
remote_state[3] = 0x50; // bits 4..7 always 0101
@@ -107,16 +129,15 @@ void GreeClimate::transmit_state() {
remote_state[0] |= GREE_PRESET_SLEEP_BIT;
}
// Calculate the checksum
if (model == GREE_YAN) {
remote_state[7] = ((remote_state[0] << 4) + (remote_state[1] << 4) + 0xC0);
} else {
remote_state[7] =
((((remote_state[0] & 0x0F) + (remote_state[1] & 0x0F) + (remote_state[2] & 0x0F) + (remote_state[3] & 0x0F) +
((remote_state[4] & 0xF0) >> 4) + ((remote_state[5] & 0xF0) >> 4) + ((remote_state[6] & 0xF0) >> 4) + 0x0A) &
0x0F)
<< 4);
#ifdef USE_GREE_FAHRENHEIT
if (this->use_fahrenheit_) {
remote_state[3] |= GREE_FAHRENHEIT_BIT;
if (half_steps & 1)
remote_state[3] |= GREE_TEMP_HALF_DEGREE_BIT;
}
#endif
remote_state[7] = frame_checksum(remote_state);
auto transmit = this->transmitter_->transmit();
auto *data = transmit.get_data();
+27
View File
@@ -1,6 +1,7 @@
#pragma once
#include "esphome/components/climate_ir/climate_ir.h"
#include "esphome/core/defines.h"
namespace esphome::gree {
@@ -90,6 +91,26 @@ static constexpr uint8_t GREE_MODE_BIT_LIGHT = 0x20;
static constexpr uint8_t GREE_MODE_BIT_HEALTH = 0x40;
static constexpr uint8_t GREE_MODE_BIT_XFAN = 0x80;
// Byte 3 Fahrenheit bits
static constexpr uint8_t GREE_FAHRENHEIT_BIT = 0x08;
static constexpr uint8_t GREE_TEMP_HALF_DEGREE_BIT = 0x04; // Half a degree Celsius added to the temperature
/// Checksum of a state frame: the low nibbles of bytes 0-3 plus the high nibbles of bytes 4-6 plus 0x0A, in the high
/// nibble.
inline uint8_t frame_checksum(const uint8_t *remote_state) {
return (
(((remote_state[0] & 0x0F) + (remote_state[1] & 0x0F) + (remote_state[2] & 0x0F) + (remote_state[3] & 0x0F) +
((remote_state[4] & 0xF0) >> 4) + ((remote_state[5] & 0xF0) >> 4) + ((remote_state[6] & 0xF0) >> 4) + 0x0A) &
0x0F)
<< 4);
}
#ifdef USE_GREE_FAHRENHEIT
/// Return k = 2 * temp_field + half_degree_bit (0..28) for a Celsius target. The unit displays
/// trunc(celsius_to_fahrenheit(GREE_TEMP_MIN + k / 2)), so k is the smallest value that shows the target in F.
uint8_t fahrenheit_half_steps(float target_celsius);
#endif
// Model codes
enum Model : uint8_t { GREE_GENERIC, GREE_YAN, GREE_YAA, GREE_YAC, GREE_YAC1FB9, GREE_YX1FF, GREE_YAG, GREE_YAW1F };
@@ -103,6 +124,9 @@ class GreeClimate final : public climate_ir::ClimateIR {
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH}) {}
void set_model(Model model);
#ifdef USE_GREE_FAHRENHEIT
void set_use_fahrenheit(bool use_fahrenheit) { this->use_fahrenheit_ = use_fahrenheit; }
#endif
void set_mode_bit(uint8_t bit_mask, bool enabled);
protected:
@@ -118,6 +142,9 @@ class GreeClimate final : public climate_ir::ClimateIR {
Model model_{};
uint8_t mode_bits_{GREE_MODE_BIT_LIGHT}; // The display light is on until a switch says otherwise
#ifdef USE_GREE_FAHRENHEIT
bool use_fahrenheit_{false};
#endif
};
} // namespace esphome::gree
@@ -277,14 +277,14 @@ void I2SAudioMicrophone::mic_task(void *params) {
xEventGroupSetBits(this_microphone->event_group_, MicrophoneEventGroupBits::TASK_RUNNING);
while (!(xEventGroupGetBits(this_microphone->event_group_) & MicrophoneEventGroupBits::COMMAND_STOP)) {
if (this_microphone->data_callbacks_.size() > 0) {
if (this_microphone->has_data_callbacks_()) {
samples.resize(bytes_to_read);
size_t bytes_read = this_microphone->read_(samples.data(), bytes_to_read, 2 * READ_DURATION_MS);
samples.resize(bytes_read);
if (this_microphone->correct_dc_offset_) {
this_microphone->fix_dc_offset_(samples);
}
this_microphone->data_callbacks_.call(samples);
this_microphone->publish_audio_(samples);
} else {
vTaskDelay(pdMS_TO_TICKS(READ_DURATION_MS));
}
+16
View File
@@ -1,6 +1,7 @@
#include "image.h"
#include <algorithm>
#include <cstring>
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
@@ -165,6 +166,21 @@ Color Image::get_pixel(int x, int y, const Color color_on, const Color color_off
}
#ifdef USE_LVGL
lv_image_dsc_t *Image::get_lv_image_dsc() {
// No pixels yet. A widget keeps this descriptor and reads it on every draw, and LVGL warns on every draw of an image
// without data, so give it a transparent pixel instead
static constexpr uint8_t TRANSPARENT_PIXEL = 0;
if (this->data_start_ == nullptr) {
if (this->dsc_.data != &TRANSPARENT_PIXEL) {
memset(&this->dsc_, 0, sizeof(this->dsc_));
this->dsc_.header.cf = LV_COLOR_FORMAT_A8;
this->dsc_.header.w = 1;
this->dsc_.header.h = 1;
this->dsc_.header.stride = 1;
this->dsc_.data_size = 1;
this->dsc_.data = &TRANSPARENT_PIXEL;
}
return &this->dsc_;
}
// lazily construct lvgl image_dsc.
if (this->dsc_.data != this->data_start_) {
this->dsc_.data = this->data_start_;
+6 -1
View File
@@ -43,7 +43,12 @@ class Image : public display::BaseImage {
bool has_transparency() const { return this->transparency_ != TRANSPARENCY_OPAQUE; }
#ifdef USE_LVGL
lv_image_dsc_t *get_lv_image_dsc();
virtual lv_image_dsc_t *get_lv_image_dsc();
#endif
#ifdef USE_LVGL_IMAGE
/// Called when an LVGL image widget is given this image. An image whose size can change keeps the widget, so it can
/// give the widget its source again when the size changes: the widget keeps the size it read from the descriptor.
virtual void add_lvgl_widget(lv_obj_t *obj) {}
#endif
protected:
/// Per-pixel draw of the clipped range, rows (true) or columns (false) outer.
+1 -3
View File
@@ -274,9 +274,7 @@ def _model_schema(config: ConfigType) -> cv.Schema:
cv.Optional(CONF_UPDATE_MODE): update_mode,
# One or more GPIOs driven high during setup to power on the panel
# (e.g. board power-enable rails), before reset and init.
cv.Optional(
CONF_ENABLE_PIN, default=model.get_default(CONF_ENABLE_PIN, [])
): cv.ensure_list(pins.gpio_output_pin_schema),
**display.enable_pin_schema(model, config),
cv.Optional(CONF_RESET_DURATION): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(max=core.TimePeriod(milliseconds=500)),
+16 -2
View File
@@ -1,3 +1,5 @@
import logging
from esphome import automation, pins
import esphome.codegen as cg
import esphome.config_validation as cv
@@ -15,6 +17,8 @@ from esphome.core import CORE
from esphome.cpp_helpers import gpio_pin_expression
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@max246"]
DOMAIN = "lightwaverf"
@@ -35,10 +39,20 @@ CONFIG_SCHEMA = cv.Schema(
).extend(cv.polling_component_schema("1s"))
def _drop_unused_name(config: ConfigType) -> ConfigType:
# Remove before 2027.5.0
if config.pop(CONF_NAME, None) is not None:
_LOGGER.warning(
"[lightwaverf] 'name' on lightwaverf.send_raw has no effect; remove it. "
"Removed in 2027.5.0"
)
return config
LIGHTWAVE_SEND_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.use_id(LIGHTWAVERFComponent),
cv.Required(CONF_NAME): cv.string,
cv.Optional(CONF_NAME): cv.string,
cv.Required(CONF_CODE): cv.All(
[cv.Any(cv.hex_uint8_t)],
cv.Length(min=10),
@@ -47,7 +61,7 @@ LIGHTWAVE_SEND_SCHEMA = cv.Schema(
cv.Optional(CONF_INVERTED, default=False): cv.boolean,
cv.Optional(CONF_PULSE_LENGTH, default=330): cv.int_,
}
)
).add_extra(_drop_unused_name)
automation.register_apply_action(
+4 -1
View File
@@ -82,7 +82,10 @@ inline void lv_style_set_text_font(lv_style_t *style, const font::Font *font) {
#ifdef USE_IMAGE
#ifdef USE_LVGL_IMAGE
// Shortcut / overload, so that the source of an image widget can easily be updated from within a lambda.
inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { ::lv_image_set_src(obj, image->get_lv_image_dsc()); }
inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) {
::lv_image_set_src(obj, image->get_lv_image_dsc());
image->add_lvgl_widget(obj);
}
inline void lv_obj_set_style_bitmap_mask_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) {
::lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector);
+33 -8
View File
@@ -28,6 +28,7 @@ from esphome.const import (
CONF_X,
CONF_Y,
)
from esphome.cpp_generator import MockObj
from esphome.cpp_types import FixedVector
from ..automation import action_to_code
@@ -41,6 +42,7 @@ from ..defines import (
CONF_RADIUS,
CONF_SRC,
CONF_START_ANGLE,
LValidator,
addr,
literal,
)
@@ -57,7 +59,7 @@ from ..lv_validation import (
)
from ..lvcode import LocalVariable, lv, lv_assign, lv_expr
from ..schemas import STYLE_PROPS, TEXT_SCHEMA, point_schema, remap_property
from ..types import LvType, ObjUpdateAction, lv_point_precise_t
from ..types import LvType, ObjUpdateAction, lv_gradient_t, lv_point_precise_t
from . import Widget, WidgetType, get_widgets
from .img import CONF_IMAGE
@@ -221,7 +223,6 @@ RECT_PROPS = {
"radius",
"bg_opa",
"bg_color",
"bg_grad",
"border_color",
"border_width",
"border_opa",
@@ -239,6 +240,30 @@ RECT_PROPS = {
"shadow_opa",
)
}
RECT_PROPS.update(
bg_grad=LValidator(
cv.use_id(lv_gradient_t), lv_gradient_t, retmapper=lambda v: MockObj(f"*{v}")
)
)
RECT_SCHEMA = {
**{cv.Optional(prop): validator for prop, validator in RECT_PROPS.items()},
cv.Optional(CONF_OPA): cv.invalid(
"Rectangles have no single opacity; use bg_opa, border_opa, outline_opa or shadow_opa"
),
}
POLYGON_PROPS = {
p: RECT_PROPS[p] for p in ("border_color", "border_width", "border_opa")
}
POLYGON_SCHEMA = {
**{cv.Optional(prop): validator for prop, validator in POLYGON_PROPS.items()},
**{
cv.Optional(prop): cv.invalid(
"Polygons are drawn as an outline; use border_color, border_width and border_opa"
)
for prop in (*RECT_PROPS, CONF_OPA)
if prop not in POLYGON_PROPS
},
}
def _draw_line(layer, dsc, points):
@@ -264,10 +289,10 @@ def _draw_line(layer, dsc, points):
ObjUpdateAction,
cv.Schema(
{
**DRAW_OPA_SCHEMA,
**DRAW_SCHEMA,
cv.Required(CONF_WIDTH): cv.templatable(cv.int_),
cv.Required(CONF_HEIGHT): cv.templatable(cv.int_),
**{cv.Optional(prop): STYLE_PROPS[prop] for prop in RECT_PROPS},
**RECT_SCHEMA,
}
),
synchronous=True,
@@ -423,8 +448,8 @@ async def canvas_draw_image(config, action_id, template_arg, args):
LINE_PROPS = {
"width": STYLE_PROPS["line_width"],
"color": STYLE_PROPS["line_color"],
"dash-width": STYLE_PROPS["line_dash_width"],
"dash-gap": STYLE_PROPS["line_dash_gap"],
"dash_width": STYLE_PROPS["line_dash_width"],
"dash_gap": STYLE_PROPS["line_dash_gap"],
"round_start": lv_bool,
"round_end": lv_bool,
}
@@ -474,7 +499,7 @@ async def canvas_draw_line(config, action_id, template_arg, args):
{
cv.GenerateID(CONF_ID): cv.use_id(lv_canvas_t),
cv.Required(CONF_POINTS): cv.ensure_list(point_schema),
**{cv.Optional(prop): STYLE_PROPS[prop] for prop in RECT_PROPS},
**POLYGON_SCHEMA,
},
).add_extra(_validate_points),
synchronous=True,
@@ -503,7 +528,7 @@ async def canvas_draw_polygon(config, action_id, template_arg, args):
_draw_line(layer, line_dsc, points)
return await draw_to_code(
config, "rect", RECT_PROPS, do_draw_polygon, action_id, template_arg, args
config, "rect", POLYGON_PROPS, do_draw_polygon, action_id, template_arg, args
)
+19 -5
View File
@@ -17,6 +17,7 @@ from esphome.const import (
CONF_WIDTH,
CONF_X,
)
from esphome.core import ID
from esphome.cpp_generator import MockObj
from esphome.cpp_types import nullptr
@@ -64,7 +65,7 @@ from ..lv_validation import (
)
from ..lvcode import LambdaContext, LocalVariable, lv, lv_add, lv_expr, lv_obj
from ..schemas import STATE_SCHEMA
from ..styles import LVStyle
from ..styles import LVStyle, create_style
from ..types import (
LV_EVENT,
LvCompound,
@@ -100,6 +101,12 @@ CONF_SCALES = "scales"
CONF_STRIDE = "stride"
CONF_TICK_STYLE = "tick_style"
def _arc_style_name(indicator_id: str) -> str:
"""Name of the style variable generated for an arc indicator."""
return f"meter_arc_{indicator_id}_style"
# LVGL 9.4 Migration: Use scale widget instead of removed meter widget
#
# The lv_meter widget was removed in LVGL 9.4 and replaced with the more
@@ -423,11 +430,9 @@ class MeterType(WidgetType):
get_warnings().add(
"The 'r_mod' indicator property is not supported in LVGL 9.x and will be ignored."
)
arc_style = LVStyle(f"meter_arc_{iid.id}", props)
style_var = await create_style(_arc_style_name(iid.id), props)
tvar = cg.Pvariable(iid, lv_expr.scale_add_section(scale_var))
lv.scale_section_set_style(
tvar, LV_PART.MAIN, await arc_style.get_var()
)
lv.scale_section_set_style(tvar, LV_PART.MAIN, style_var)
lw = Widget.create(iid, tvar, arc_indicator_type)
lw.parent = scale_var
await set_indicator_values(scale_var, lw, v)
@@ -610,6 +615,15 @@ async def indicator_update_to_code(config, action_id, template_arg, args):
async def set_value(w: Widget):
await set_indicator_values(w.parent, w, config)
if (opa := config.get(CONF_OPA)) is None:
return
opa = await opacity.process(opa)
if w.type is arc_indicator_type:
style = await cg.get_variable(ID(_arc_style_name(str(w.var))))
lv.style_set_arc_opa(style, opa)
lv_obj.invalidate(w.parent)
else:
lv_obj.set_style_opa(w.obj, opa, LV_PART.MAIN)
return await action_to_code(widget, set_value, action_id, template_arg, args)
+39 -4
View File
@@ -4,6 +4,8 @@
#include <cstddef>
#include <cstdint>
#include <type_traits>
#include <utility>
#include <vector>
#include "esphome/core/helpers.h"
@@ -16,16 +18,38 @@ enum State : uint8_t {
STATE_STOPPING,
};
/// @brief Adapts a data callback to the (data, capture_time_us) form.
///
/// A callback that already takes the capture time is returned as is; one that only takes the data is wrapped so the
/// capture time is dropped.
template<typename F> auto with_capture_time(F &&callback) {
using Fn = std::decay_t<F>;
if constexpr (std::is_invocable_v<const Fn &, const std::vector<uint8_t> &, int64_t>) {
return Fn(std::forward<F>(callback));
} else {
static_assert(std::is_invocable_v<const Fn &, const std::vector<uint8_t> &>,
"A data callback takes (const std::vector<uint8_t> &) or (const std::vector<uint8_t> &, int64_t)");
return [callback = Fn(std::forward<F>(callback))](const std::vector<uint8_t> &data, int64_t) { callback(data); };
}
}
class Microphone {
public:
virtual void start() = 0;
virtual void stop() = 0;
/// @brief Registers a callback for captured audio.
///
/// The callback takes either (const std::vector<uint8_t> &data) or (const std::vector<uint8_t> &data,
/// int64_t capture_time_us). capture_time_us is when the first frame in data was captured, in esp_timer_get_time()
/// microseconds, or 0 if unknown. Callbacks run on the microphone's own task.
template<typename F> void add_data_callback(F &&data_callback) {
this->data_callbacks_.add([this, data_callback](const std::vector<uint8_t> &data) {
this->data_callbacks_.add([this, callback = with_capture_time(std::forward<F>(data_callback))](
const std::vector<uint8_t> &data, int64_t capture_time_us) {
if (this->mute_state_) {
data_callback(std::vector<uint8_t>(data.size(), 0));
callback(std::vector<uint8_t>(data.size(), 0), capture_time_us);
} else {
data_callback(data);
callback(data, capture_time_us);
}
});
}
@@ -39,12 +63,23 @@ class Microphone {
audio::AudioStreamInfo get_audio_stream_info() { return this->audio_stream_info_; }
protected:
/// @brief Passes captured audio to every registered callback.
/// @param data Captured audio in the microphone's format
/// @param capture_time_us When the first frame in data was captured, in esp_timer_get_time() microseconds, or 0 if
/// unknown
void publish_audio_(const std::vector<uint8_t> &data, int64_t capture_time_us = 0) {
this->data_callbacks_.call(data, capture_time_us);
}
bool has_data_callbacks_() const { return this->data_callbacks_.size() > 0; }
State state_{STATE_STOPPED};
bool mute_state_{false};
audio::AudioStreamInfo audio_stream_info_;
CallbackManager<void(const std::vector<uint8_t> &)> data_callbacks_{};
private:
CallbackManager<void(const std::vector<uint8_t> &, int64_t)> data_callbacks_{};
};
} // namespace esphome::microphone
@@ -45,8 +45,11 @@ class MicrophoneSource final {
/// @param channel 0-indexed channel number to enable
void add_channel(uint8_t channel) { this->channels_.set(channel); }
/// @brief Registers a callback for the processed audio. Takes the same callback forms as
/// Microphone::add_data_callback and passes the capture time through unchanged.
template<typename F> void add_data_callback(F &&data_callback) {
this->mic_->add_data_callback([this, data_callback](const std::vector<uint8_t> &data) {
this->mic_->add_data_callback([this, callback = with_capture_time(std::forward<F>(data_callback))](
const std::vector<uint8_t> &data, int64_t capture_time_us) {
if (this->enabled_ || this->passive_) {
if (this->processed_samples_ == nullptr) {
// Create vector if its unused
@@ -56,7 +59,7 @@ class MicrophoneSource final {
// Take temporary ownership of samples vector to avoid deallocation before the callback finishes
std::shared_ptr<std::vector<uint8_t>> output_samples = this->processed_samples_;
this->process_audio_(data, *output_samples);
data_callback(*output_samples);
callback(*output_samples, capture_time_us);
}
});
}
+56 -20
View File
@@ -4,10 +4,12 @@
from collections.abc import Callable
import functools
import importlib.util
from typing import Any, Self
import voluptuous as vol
from esphome import pins
from esphome.components.const import CONF_COLOR_DEPTH
from esphome.components.display import CONF_SHOW_TEST_CARD, display_ns
import esphome.config_validation as cv
@@ -23,6 +25,7 @@ from esphome.const import (
CONF_MIRROR_X,
CONF_MIRROR_Y,
CONF_MODEL,
CONF_NUMBER,
CONF_OFFSET_HEIGHT,
CONF_OFFSET_WIDTH,
CONF_PAGES,
@@ -306,6 +309,57 @@ def model_schema_extractor(
return decorate
def _is_pin_expander(key: str) -> bool:
"""Check if a pin config key names a component that provides pins, e.g. an I/O expander."""
from esphome import loader
if key not in pins.PIN_SCHEMA_REGISTRY:
if importlib.util.find_spec(f"esphome.components.{key}") is None:
return False
# Loading the component registers its pin schema. If it fails to load (the
# loader logs why), still require it rather than quietly dropping it.
if loader.get_component(key) is None:
return True
return key in pins.PIN_SCHEMA_REGISTRY
def _pin_expanders(value: Any) -> set[str]:
"""Find the pin expander components used by any pin configs in a model's defaults."""
if isinstance(value, (list, tuple)):
return set().union(*(_pin_expanders(v) for v in value))
if not isinstance(value, dict):
return set()
found = set().union(*(_pin_expanders(v) for v in value.values()))
if CONF_NUMBER in value:
found.update(k for k in value if k != CONF_NUMBER and _is_pin_expander(k))
return found
def check_model_requirements(name: str, defaults: dict[str, Any]) -> None:
"""
Raise a friendly error if any component a display model requires is not configured.
These are the components in its ``requires`` default plus any pin expander used by its
default pins, which is needed even if the user config overrides those pins.
This runs during schema validation (before ID references are resolved) so that a
model whose default pins live on a pin expander reports the missing expander clearly
instead of a cryptic "Couldn't find ID" from the unresolved pin reference.
"""
# ``raw_config`` is populated before any component schema runs during a real
# validation, so presence of a required component is simply a top-level key.
# When it is absent (e.g. a unit test that invokes the schema directly) there
# is no config to check against, so skip.
global_config = CORE.raw_config
if global_config is None:
return
requirements = set(defaults.get("requires", ())) | _pin_expanders(defaults)
if missing := {x for x in requirements if x not in global_config}:
reqstr = ", ".join(f"'{x}'" for x in sorted(missing))
raise cv.Invalid(
f"{name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured"
)
class DriverChip:
"""
A class representing a MIPI DBI driver chip model.
@@ -671,32 +725,14 @@ class DriverChip:
def check_requirements(self) -> None:
"""
Raise a friendly error if any component this model requires is not configured.
This runs during schema validation (before ID references are resolved) so that a
model whose default pins live on a pin expander reports the missing expander clearly
instead of a cryptic "Couldn't find ID" from the unresolved pin reference.
Check that the components this model needs are configured, see check_model_requirements().
Also logs a warning if the model is deprecated.
"""
if deprecation_reason := self.get_default("deprecation_reason"):
LOGGER.warning(
"Display model %s is deprecated: %s", self.name, deprecation_reason
)
if requirements := self.get_default("requires", set()):
# ``raw_config`` is populated before any component schema runs during a real
# validation, so presence of a required component is simply a top-level key.
# When it is absent (e.g. a unit test that invokes the schema directly) there
# is no config to check against, so skip.
global_config = CORE.raw_config
if global_config is None:
return
missing = {x for x in requirements if x not in global_config}
if missing:
reqstr = ", ".join(f"'{x}'" for x in sorted(missing))
raise cv.Invalid(
f"{self.name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured"
)
check_model_requirements(self.name, self.defaults)
def requires_buffer(config) -> bool:
+8 -11
View File
@@ -85,7 +85,7 @@ COLOR_DEPTHS = {
}
def model_schema(config: ConfigType) -> cv.All:
def model_schema(config: ConfigType) -> cv.Schema:
model = MODELS[config[CONF_MODEL].upper()]
transform = model.transform_schema()
# CUSTOM model will need to provide a custom init sequence
@@ -97,16 +97,14 @@ def model_schema(config: ConfigType) -> cv.All:
# Dimensions are optional if the model has a default width
cv_dimensions = cv.Optional if model.get_default(CONF_WIDTH) else cv.Required
pixel_modes = (PIXEL_MODE_16BIT, PIXEL_MODE_24BIT, "16", "24")
schema = display.FULL_DISPLAY_SCHEMA.extend(
return display.FULL_DISPLAY_SCHEMA.extend(
{
model.option(CONF_RESET_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema,
cv.GenerateID(): cv.declare_id(MipiDsi),
cv_dimensions(CONF_DIMENSIONS): dimension_schema(
model.get_default(CONF_DRAW_ROUNDING, 1)
),
model.option(CONF_ENABLE_PIN, cv.UNDEFINED): cv.ensure_list(
pins.gpio_output_pin_schema
),
**display.enable_pin_schema(model, config),
model.option(CONF_COLOR_ORDER, MODE_BGR): cv.enum(COLOR_ORDERS, upper=True),
model.option(CONF_DRAW_ROUNDING, 2): power_of_two,
model.option(CONF_PIXEL_MODE, PIXEL_MODE_16BIT): cv.one_of(
@@ -140,11 +138,6 @@ def model_schema(config: ConfigType) -> cv.All:
model.option(CONF_VSYNC_FRONT_PORCH): cv.int_,
}
)
return cv.All(
schema,
cv.only_on_esp32,
only_on_variant(supported=[VARIANT_ESP32P4]),
)
@model_schema_extractor(MODELS, model_schema)
@@ -155,9 +148,13 @@ def _config_schema(config: ConfigType) -> ConfigType:
},
extra=cv.ALLOW_EXTRA,
)(config)
config = model_schema(config)(config)
config = cv.All(cv.only_on_esp32, only_on_variant(supported=[VARIANT_ESP32P4]))(
config
)
# Check before the schema, which would fail on default pins of a missing expander
model = MODELS[config[CONF_MODEL].upper()]
model.check_requirements()
config = model_schema(config)(config)
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
model.get_dimensions(config)
)
@@ -14,7 +14,7 @@ DsiDriverChip(
pclk_frequency="60MHz",
lane_bit_rate="730Mbps",
color_order="RGB",
requires={"psram", "pi4ioe5v6408"},
requires={"psram"},
reset_pin={"pi4ioe5v6408": {"address": 0x43}, "number": 4},
initsequence=[
(0xFF, 0x98, 0x81, 0x01), # Select Page 1
@@ -69,7 +69,7 @@ TAB5_ST7123 = DsiDriverChip(
pclk_frequency="80MHz",
lane_bit_rate="960Mbps",
color_order="RGB",
requires={"psram", "pi4ioe5v6408"},
requires={"psram"},
reset_pin={"pi4ioe5v6408": {"address": 0x43}, "number": 4},
initsequence=[
(0x01,),
@@ -122,7 +122,7 @@ DsiDriverChip(
pclk_frequency="70MHz",
lane_bit_rate="965Mbps",
color_order="RGB",
requires={"psram", "pi4ioe5v6408"},
requires={"psram"},
reset_pin={"pi4ioe5v6408": {"address": 0x43}, "number": 4},
initsequence=[
(0x01,),
+1 -1
View File
@@ -17,7 +17,7 @@ DsiDriverChip(
color_order="RGB",
enable_pin=[{"xl9535": None, "number": 0}, {"xl9535": None, "number": 7}],
reset_pin={"xl9535": None, "number": 2},
requires={"psram", "xl9535"},
requires={"psram"},
initsequence=(
(0xE0, 0x00),
(0xE1, 0x93),
+3 -5
View File
@@ -154,9 +154,7 @@ def model_schema(config: ConfigType) -> cv.Schema:
cv_dimensions(CONF_DIMENSIONS): dimension_schema(
model.get_default(CONF_DRAW_ROUNDING, 1)
),
model.option(CONF_ENABLE_PIN, cv.UNDEFINED): cv.ensure_list(
pins.gpio_output_pin_schema
),
**display.enable_pin_schema(model, config),
model.option(CONF_COLOR_ORDER, MODE_RGB): cv.enum(COLOR_ORDERS, upper=True),
model.option(CONF_DRAW_ROUNDING, 2): power_of_two,
model.option(CONF_PIXEL_MODE, PIXEL_MODE_16BIT): cv.one_of(
@@ -226,14 +224,14 @@ def _config_schema(config: ConfigType) -> ConfigType:
},
extra=cv.ALLOW_EXTRA,
)(config)
schema = model_schema(config)
config = cv.All(
schema,
cv.only_on_esp32,
only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4, VARIANT_ESP32S31]),
)(config)
# Check before the schema, which would fail on default pins of a missing expander
model = MODELS[config[CONF_MODEL].upper()]
model.check_requirements()
config = model_schema(config)(config)
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
model.get_dimensions(config)
)
+2 -2
View File
@@ -9,7 +9,7 @@ ST7701S(
spi_mode="MODE3",
cs_pin={"xl9535": None, "number": 17},
reset_pin={"xl9535": None, "number": 5},
requires={"psram", "xl9535"},
requires={"psram"},
hsync_pin=39,
vsync_pin=40,
pclk_pin=41,
@@ -57,7 +57,7 @@ t_rgb = ST7701S(
invert_colors=False,
spi_mode="MODE3",
cs_pin={"xl9535": None, "number": 3},
requires={"psram", "xl9535"},
requires={"psram"},
de_pin=45,
hsync_pin=47,
vsync_pin=41,
@@ -115,7 +115,7 @@ st7701s.extend(
vsync_pulse_width=8,
vsync_back_porch=20,
cs_pin={"pca9554": None, "number": 4},
requires={"psram", "pca9554"},
requires={"psram"},
de_pin=18,
hsync_pin=16,
vsync_pin=17,
@@ -12,7 +12,7 @@ wave_4_3 = RgbDriverChip(
pclk_frequency="16MHz",
reset_pin={"ch422g": None, "number": 3},
enable_pin={"ch422g": None, "number": 2},
requires={"psram", "ch422g"},
requires={"psram"},
de_pin=5,
hsync_pin={"number": 46, "ignore_strapping_warning": True},
vsync_pin={"number": 3, "ignore_strapping_warning": True},
+1 -3
View File
@@ -197,9 +197,7 @@ def model_schema(config: ConfigType) -> cv.All | cv.Schema:
{
cv.GenerateID(): cv.declare_id(MipiSpi),
cv_dimensions(CONF_DIMENSIONS): dimension_schema(1),
model.option(CONF_ENABLE_PIN, cv.UNDEFINED): cv.ensure_list(
pins.gpio_output_pin_schema
),
**display.enable_pin_schema(model, config),
model.option(CONF_COLOR_ORDER, MODE_BGR): cv.enum(
COLOR_ORDERS, upper=True
),
@@ -22,7 +22,6 @@ DriverChip(
transforms={CONF_MIRROR_X, CONF_MIRROR_Y},
use_axis_flips=True,
reset_delay=20,
requires={"pca9554"},
initsequence=(
delay(100),
(0xFF, 0x20, 0x10, 0x10),
+14 -11
View File
@@ -69,8 +69,9 @@ PackedBits = modbus.modbus_ns.class_("PackedBits")
# The exception code passed to on_error handlers.
ExceptionCode = modbus.modbus_ns.enum("ExceptionCode")
# Lambda argument types for the reply handlers: the device address the send targeted, and the
# request/response PDUs (function code + data). The spans are only valid for the duration of the handler.
# Lambda arguments: the shared handlers and send's on_response get the address of their own request first,
# then the request/response PDUs (function code + data). The spans are only valid for the duration of the handler.
_ADDRESS_ARG = (cg.uint8, "address")
_PDU_SPAN = cg.std_span.template(cg.uint8.operator("const"))
# The pdu lambda's return type: a stack-allocated StaticVector capped at the Modbus PDU limit
@@ -135,11 +136,11 @@ _ACTION_BASE_SCHEMA = cv.Schema(
cv.GenerateID(modbus.CONF_MODBUS_ID): cv.use_id(modbus.ModbusClient),
cv.Required(CONF_ADDRESS): cv.templatable(cv.hex_uint8_t),
# Optional handlers. on_sent fires when the frame reaches the wire; the reply handlers arrive
# later (fire-and-continue), so all run with the request/reply available - not the outer
# automation's variables.
# later (fire-and-continue), so all run with the address and request/reply available - not the
# outer automation's variables.
cv.Optional(CONF_ON_SENT): _handler_schema(),
cv.Optional(CONF_ON_ERROR): _handler_schema(),
# on_no_response takes either a returning lambda (`!lambda "return <bool>;"`, gets `request`,
# on_no_response takes either a returning lambda (`!lambda "return <bool>;"`, gets `address`/`request`,
# returns true to have the hub retry the frame) OR a `then:` automation of actions; the automation
# form may also carry an optional `retry:` returning lambda to run actions AND decide the retry.
cv.Optional(CONF_ON_NO_RESPONSE): cv.All(
@@ -199,7 +200,7 @@ async def register_client_action(
)
if sent_conf := config.get(CONF_ON_SENT):
await automation.build_automation(
var.get_sent_trigger(), [(_PDU_SPAN, "request")], sent_conf
var.get_sent_trigger(), [_ADDRESS_ARG, (_PDU_SPAN, "request")], sent_conf
)
if response_conf := config.get(CONF_ON_RESPONSE):
await automation.build_automation(
@@ -217,7 +218,7 @@ async def register_client_action(
if error_conf := config.get(CONF_ON_ERROR):
await automation.build_automation(
var.get_error_trigger(),
[(_PDU_SPAN, "request"), (ExceptionCode, "exception_code")],
[_ADDRESS_ARG, (_PDU_SPAN, "request"), (ExceptionCode, "exception_code")],
error_conf,
)
if (no_response_conf := config.get(CONF_ON_NO_RESPONSE)) is not None:
@@ -228,18 +229,20 @@ async def register_client_action(
else:
await automation.build_automation(
var.get_no_response_trigger(),
[(_PDU_SPAN, "request")],
[_ADDRESS_ARG, (_PDU_SPAN, "request")],
no_response_conf,
)
retry_conf = no_response_conf.get(CONF_RETRY)
if retry_conf is not None:
retry_lambda = await cg.process_lambda(
retry_conf, [(_PDU_SPAN, "request")], return_type=cg.bool_
retry_conf, [_ADDRESS_ARG, (_PDU_SPAN, "request")], return_type=cg.bool_
)
cg.add(var.set_retry(retry_lambda))
if not_sent_conf := config.get(CONF_ON_NOT_SENT):
await automation.build_automation(
var.get_not_sent_trigger(), [(_PDU_SPAN, "request")], not_sent_conf
var.get_not_sent_trigger(),
[_ADDRESS_ARG, (_PDU_SPAN, "request")],
not_sent_conf,
)
# Wire any command options the action's schema opted into (e.g. continuous on reads). Pass the
# matching direction so a write action never generates a read option's setter.
@@ -265,7 +268,7 @@ async def modbus_client_send_to_code(config, action_id, template_arg, args):
var,
config,
args,
[(_PDU_SPAN, "request"), (_PDU_SPAN, "response")],
[_ADDRESS_ARG, (_PDU_SPAN, "request"), (_PDU_SPAN, "response")],
)
@@ -13,45 +13,51 @@ namespace esphome::modbus_client {
/// the hub routes every reply (or its lack) straight back to the action that sent it, so there is no
/// central client object and no request matching. The device address is templatable; it is stamped on the
/// device at play() time; the hub routes each reply by device pointer, so a changed address never
/// mis-routes an earlier reply. (The address is not passed to the reply triggers - under overlapping
/// sends it could misreport, and the handler can recompute the expression it configured.)
/// mis-routes an earlier reply. The handlers wired here and send's on_response get `address` first: the
/// address of their own request, which the hub takes from its frame, so overlapping sends each report their own.
template<typename... Ts> class ClientActionBase : public Action<Ts...>, public modbus::ModbusClientDevice {
public:
TEMPLATABLE_VALUE(uint8_t, target_address) // the modbus device address
Trigger<std::span<const uint8_t>> *get_sent_trigger() { return &this->sent_trigger_; }
Trigger<std::span<const uint8_t>, modbus::ExceptionCode> *get_error_trigger() { return &this->error_trigger_; }
Trigger<std::span<const uint8_t>> *get_no_response_trigger() { return &this->no_response_trigger_; }
Trigger<std::span<const uint8_t>> *get_not_sent_trigger() { return &this->not_sent_trigger_; }
Trigger<uint8_t, std::span<const uint8_t>> *get_sent_trigger() { return &this->sent_trigger_; }
Trigger<uint8_t, std::span<const uint8_t>, modbus::ExceptionCode> *get_error_trigger() {
return &this->error_trigger_;
}
Trigger<uint8_t, std::span<const uint8_t>> *get_no_response_trigger() { return &this->no_response_trigger_; }
Trigger<uint8_t, std::span<const uint8_t>> *get_not_sent_trigger() { return &this->not_sent_trigger_; }
/// The retry decision for on_no_response: given the request PDU, return true to have the hub re-queue
/// The retry decision for on_no_response: given the request (address, PDU), return true to have the hub re-queue
/// the frame. Set from the lambda form or a then: automation's nested retry lambda; may coexist with
/// the no_response trigger (actions run, then this decides the retry).
using retry_func_t = bool (*)(std::span<const uint8_t>);
using retry_func_t = bool (*)(uint8_t, std::span<const uint8_t>);
void set_retry(retry_func_t f) { this->retry_func_ = f; }
/// The frame was written to the wire: fires once per transmission, before any reply, and never for a
/// send that ended in on_not_sent. request_pdu is the PDU sent (function code + data).
void on_sent(std::span<const uint8_t> request_pdu) override { this->sent_trigger_.trigger(request_pdu); }
void on_sent(uint8_t address, std::span<const uint8_t> request_pdu) override {
this->sent_trigger_.trigger(address, request_pdu);
}
/// Never reached the wire, from either of two sources. The hub calls this for a request it accepted
/// and then dropped, which happens only when clear_tx_queue_for_address() retires it - a modbus
/// device going offline, say. Everything the hub refuses at the door instead returns false from
/// queue_pdu() with no callback at all, so send_or_resolve_() below turns those into this same
/// callback: a full queue, a duplicate write, or an empty PDU from a rejecting builder.
void on_not_sent(std::span<const uint8_t> request_pdu) override { this->not_sent_trigger_.trigger(request_pdu); }
void on_not_sent(uint8_t address, std::span<const uint8_t> request_pdu) override {
this->not_sent_trigger_.trigger(address, request_pdu);
}
/// A Modbus exception reply. Lives here beside its trigger so every action subclass gets the pairing:
/// register_client_action() wires on_error for all of them, so a derived class must not have to
/// remember the override.
void on_error(std::span<const uint8_t> request_pdu, modbus::ExceptionCode exception_code) override {
this->error_trigger_.trigger(request_pdu, exception_code);
void on_error(uint8_t address, std::span<const uint8_t> request_pdu, modbus::ExceptionCode exception_code) override {
this->error_trigger_.trigger(address, request_pdu, exception_code);
}
/// No reply within send_wait_time. Run the on_no_response actions (empty in the pure-lambda form),
/// then let the retry lambda, if set, decide whether the hub re-queues the frame (true = retry). The
/// two coexist: a then: automation can also carry a retry lambda. No lambda = no retry.
bool on_no_response(std::span<const uint8_t> request_pdu) override {
this->no_response_trigger_.trigger(request_pdu);
bool on_no_response(uint8_t address, std::span<const uint8_t> request_pdu) override {
this->no_response_trigger_.trigger(address, request_pdu);
if (this->retry_func_ != nullptr)
return this->retry_func_(request_pdu);
return this->retry_func_(address, request_pdu);
return false;
}
/// Stamp the templated device address before every play(): subclasses cannot forget it, and the hub
@@ -70,13 +76,13 @@ template<typename... Ts> class ClientActionBase : public Action<Ts...>, public m
/// a PduBuffer parameter would widen each one to the 253-byte maximum just to cross the call.
void send_or_resolve_(std::span<const uint8_t> pdu, modbus::CommandOptions options = {}) {
if (!this->queue_pdu(pdu, options))
this->on_not_sent(pdu);
this->on_not_sent(this->address_, pdu);
}
Trigger<std::span<const uint8_t>> sent_trigger_;
Trigger<std::span<const uint8_t>, modbus::ExceptionCode> error_trigger_;
Trigger<std::span<const uint8_t>> no_response_trigger_;
Trigger<std::span<const uint8_t>> not_sent_trigger_;
Trigger<uint8_t, std::span<const uint8_t>> sent_trigger_;
Trigger<uint8_t, std::span<const uint8_t>, modbus::ExceptionCode> error_trigger_;
Trigger<uint8_t, std::span<const uint8_t>> no_response_trigger_;
Trigger<uint8_t, std::span<const uint8_t>> not_sent_trigger_;
retry_func_t retry_func_{nullptr};
};
@@ -119,7 +125,7 @@ template<typename... Ts> class WriteCommandOptions {
};
/// modbus_client.send: fire a raw PDU (function code + data; the hub adds address and CRC). The reply is
/// delivered raw - on_response(request, response) - deliberately bypassing the typed dispatch, so
/// delivered raw - on_response(address, request, response) - deliberately bypassing the typed dispatch, so
/// non-standard/custom transactions pass through untouched.
/// The PDU is a stack-allocated modbus::helpers::PduBuffer, so a pdu lambda can build one with the
/// modbus::helpers::create_*_pdu() builders and return it directly (smaller builder results convert).
@@ -133,7 +139,7 @@ class ModbusClientSendAction : public ClientActionBase<Ts...>,
public:
TEMPLATABLE_VALUE(modbus::helpers::PduBuffer, pdu)
Trigger<std::span<const uint8_t>, std::span<const uint8_t>> *get_response_trigger() {
Trigger<uint8_t, std::span<const uint8_t>, std::span<const uint8_t>> *get_response_trigger() {
return &this->response_trigger_;
}
@@ -143,12 +149,13 @@ class ModbusClientSendAction : public ClientActionBase<Ts...>,
this->send_or_resolve_(this->pdu_.value(x...), options);
}
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override {
this->response_trigger_.trigger(request_pdu, response_pdu);
void on_response(uint8_t address, std::span<const uint8_t> request_pdu,
std::span<const uint8_t> response_pdu) override {
this->response_trigger_.trigger(address, request_pdu, response_pdu);
}
protected:
Trigger<std::span<const uint8_t>, std::span<const uint8_t>> response_trigger_;
Trigger<uint8_t, std::span<const uint8_t>, std::span<const uint8_t>> response_trigger_;
};
/// Typed actions: these do NOT override the raw on_response, so the base ModbusClientDevice default runs
+10 -16
View File
@@ -2,24 +2,18 @@ import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import CONF_SOURCE_ID
from esphome.types import ConfigType
from .. import Number, number_ns
from .. import Number
NumberSensor = number_ns.class_("NumberSensor", sensor.Sensor, cg.Component)
CONFIG_SCHEMA = (
sensor.sensor_schema(NumberSensor)
.extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(Number),
}
)
.extend(cv.COMPONENT_SCHEMA)
CONFIG_SCHEMA = sensor.sensor_schema(sensor.Sensor).extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(Number),
}
)
async def to_code(config):
source = await cg.get_variable(config[CONF_SOURCE_ID])
var = await sensor.new_sensor(config, source)
await cg.register_component(var, config)
async def to_code(config: ConfigType) -> None:
var = await sensor.new_sensor(config)
# Number has the same float state callback as Sensor
sensor.follow_sensor(var, await cg.get_variable(config[CONF_SOURCE_ID]))
@@ -1,16 +0,0 @@
#include "number_sensor.h"
#include "esphome/core/log.h"
namespace esphome::number {
ESPHOME_LOG_TAG(TAG, "number.sensor");
void NumberSensor::setup() {
this->source_->add_on_state_callback([this](float value) { this->publish_state(value); });
if (this->source_->has_state())
this->publish_state(this->source_->state);
}
void NumberSensor::dump_config() { LOG_SENSOR("", "Number Sensor", this); }
} // namespace esphome::number
@@ -1,19 +0,0 @@
#pragma once
#include "../number.h"
#include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.h"
namespace esphome::number {
class NumberSensor final : public sensor::Sensor, public Component {
public:
explicit NumberSensor(Number *source) : source_(source) {}
void setup() override;
void dump_config() override;
protected:
Number *source_;
};
} // namespace esphome::number
+3
View File
@@ -42,6 +42,7 @@ ONLINE_IMAGE_SCHEMA = (
# AUTO (Content-Type detection) is online_image specific; not in the shared registry
cv.Required(CONF_FORMAT): cv.one_of(*IMAGE_FORMATS, "AUTO", upper=True),
cv.Optional(CONF_BUFFER_SIZE, default=65536): cv.int_range(256, 65536),
cv.Optional(runtime_image.CONF_DOUBLE_BUFFER, default=False): cv.boolean,
cv.Optional(CONF_REQUEST_HEADERS): cv.All(
cv.Schema({cv.string: cv.templatable(cv.string)})
),
@@ -141,6 +142,8 @@ async def setup_online_image(config: ConfigType) -> None:
)
await cg.register_component(var, config)
await cg.register_parented(var, config[CONF_HTTP_REQUEST_ID])
if settings.double_buffer:
cg.add(var.set_double_buffer(True))
for key, value in config.get(CONF_REQUEST_HEADERS, {}).items():
if isinstance(value, Lambda):
@@ -428,9 +428,8 @@ void OnlineImage::finish_download_() {
void OnlineImage::end_connection_() {
// Abort any in-progress decode; the decoder object is kept warm for the next decode.
// Use RuntimeImage::release() directly to avoid recursion with OnlineImage::release().
if (this->is_decoding()) {
RuntimeImage::release();
this->abort_decode();
}
if (this->downloader_) {
this->downloader_->end();
@@ -3004,3 +3004,73 @@ register_protocol_action(
(CONF_CHANNELS, "channels", cg.uint16),
(CONF_COMMAND, "command", cg.uint8),
)
# Cardin S438
(
CardinS438Data,
CardinS438BinarySensor,
CardinS438Trigger,
_,
CardinS438Dumper,
) = declare_protocol("CardinS438")
CARDIN_S438_CHANNELS = {
"A": 0x02,
"B": 0x00,
"C": 0x03,
"D": 0x01,
}
def _validate_cardin_s438_code(code):
"""Accept the DIP switches as a 14-character binary string."""
code = cv.string_strict(code)
if len(code) != 14 or any(bit not in "01" for bit in code):
raise cv.Invalid("code must be exactly 14 binary digits, e.g. '10100110101010'")
return int(code, 2)
CARDIN_S438_SCHEMA = cv.Schema(
{
cv.Required(CONF_CODE): _validate_cardin_s438_code,
cv.Optional(CONF_CHANNEL, default="A"): cv.enum(
CARDIN_S438_CHANNELS, upper=True
),
cv.Optional(CONF_COMMAND_REPEATS, default=3): cv.int_range(min=1, max=255),
}
)
@register_binary_sensor("cardin_s438", CardinS438BinarySensor, CARDIN_S438_SCHEMA)
def cardin_s438_binary_sensor(var: MockObj, config: ConfigType) -> None:
cg.add(
var.set_data(
cg.StructInitializer(
CardinS438Data,
("code", config[CONF_CODE]),
("channel", config[CONF_CHANNEL]),
)
)
)
@register_trigger("cardin_s438", CardinS438Trigger, CardinS438Data)
def cardin_s438_trigger(var: MockObj, config: ConfigType) -> None:
"""The trigger takes no options beyond the automation."""
@register_dumper("cardin_s438", CardinS438Dumper)
def cardin_s438_dumper(var: MockObj, config: ConfigType) -> None:
"""The dumper takes no options."""
register_protocol_action(
"cardin_s438",
"CardinS438",
CARDIN_S438_SCHEMA,
(CONF_CODE, "code", cg.uint16),
(CONF_CHANNEL, "channel", cg.uint8),
(CONF_COMMAND_REPEATS, "repeats", cg.uint8),
)
@@ -0,0 +1,159 @@
#include "cardin_s438_protocol.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::remote_base {
ESPHOME_LOG_TAG(TAG, "remote.cardin_s438");
namespace {
/*
* Cardin S438 wire format
*
* Frame: 6400 us sync mark + 40 data bits (5 bytes, LSB-first).
* 0 = 1460 us space, 750 us mark
* 1 = 750 us space, 1460 us mark
*
* Bytes:
* [0..2] Encoded pin fields and seed
* [2] (seed + marker 0x87), modulo 256
* Carries propagate into bytes 1 and 0.
* [3..4] Sum of bytes [0..2], little-endian
*
* Pin code: 14 bits, MSB-first, mapped to X/Y fields:
* Y7,Y6,X7,X6,Y5,X5,Y4,X4,Y3,X3,Y2,Y1,Y0,X2
* Channels in X bits 1:0: A=10, B=00, C=11, D=01.
*
* Three frames per transmission, with 63650 us LOW gaps.
* Seed chosen randomly; no pattern found in captures.
*/
// Frame format and timing, in microseconds.
constexpr uint8_t MARKER = 0x87;
constexpr uint16_t SYNC_MARK_US = 6400;
constexpr uint32_t FRAME_GAP_US = 63650;
constexpr uint8_t WIRE_SIZE = 5;
// Each bit is a space then a mark, and both lengths carry it
constexpr BitTimings CARDIN_TIMINGS{
.one_space = 750, .zero_space = 1460, .one_mark = 1460, .zero_mark = 750, .space_first = true};
// Maps pincode bits, most-significant first, to a bit of the X or Y field: PIN_Y marks Y, the low bits are the bit
// number. In flash, so it stays out of RAM on ESP8266.
constexpr uint8_t PIN_Y = 0x08;
constexpr uint8_t PINCODE_BITS = 14;
constexpr uint8_t PINCODE_MAP[PINCODE_BITS] PROGMEM = {
PIN_Y | 7, PIN_Y | 6, 7, 6, PIN_Y | 5, 5, PIN_Y | 4, 4, PIN_Y | 3, 3, PIN_Y | 2, PIN_Y | 1, PIN_Y | 0, 2,
};
// Convert the pincode and channel to the two decoded pin fields.
void encode_pin_fields(uint16_t pincode, uint8_t channel, uint8_t &plain0, uint8_t &plain1) {
uint8_t x = channel;
uint8_t y = 0;
for (uint8_t i = 0; i < PINCODE_BITS; ++i) {
if (((pincode >> (PINCODE_BITS - 1 - i)) & 1U) == 0)
continue;
const uint8_t entry = progmem_read_byte(&PINCODE_MAP[i]);
(entry & PIN_Y ? y : x) |= 1U << (entry & 0x07);
}
plain1 = ~y;
plain0 = plain1 - x + 1;
}
// Construct the wire bytes for one seed.
void build_wire(uint8_t wire[WIRE_SIZE], uint8_t seed, uint8_t plain0, uint8_t plain1) {
uint16_t sum = MARKER + seed;
wire[2] = sum;
uint8_t key = seed + (sum >> 8);
sum = plain1 + key;
wire[1] = sum;
key += sum >> 8;
wire[0] = plain0 + key;
// checksum over the first three bytes, little-endian.
const uint16_t checksum = sum16_bytes(wire, 3);
wire[3] = checksum;
wire[4] = checksum >> 8;
}
} // namespace
void CardinS438Protocol::encode(RemoteTransmitData *dst, const CardinS438Data &data) {
uint8_t plain0;
uint8_t plain1;
encode_pin_fields(data.code & 0x3FFF, data.channel & 0x03, plain0, plain1);
for (uint8_t frame = 0; frame < data.repeats; ++frame) {
uint8_t wire[WIRE_SIZE];
uint8_t seed = static_cast<uint8_t>(random_uint32() & 0xFF);
build_wire(wire, seed, plain0, plain1);
dst->mark(SYNC_MARK_US);
dst->item_bytes<CARDIN_TIMINGS>(wire, WIRE_SIZE);
dst->space(FRAME_GAP_US);
}
}
optional<CardinS438Data> CardinS438Protocol::decode(RemoteReceiveData src) {
while (src.is_valid()) {
// Find the frame's long sync mark.
if (!src.expect_mark(SYNC_MARK_US)) {
src.advance();
continue;
}
uint8_t wire[WIRE_SIZE];
if (!src.expect_bytes<CARDIN_TIMINGS>(wire, WIRE_SIZE) ||
encode_value_le<uint16_t>(wire + 3) != sum16_bytes(wire, 3)) {
continue;
}
// Recover the seed and undo the encoder's carry transform.
uint8_t seed = wire[2] - MARKER;
uint8_t key = seed;
key += (seed + MARKER) >> 8;
uint8_t plain1 = wire[1] - key;
key += (plain1 + key) >> 8;
uint8_t plain0 = wire[0] - key;
// Recover the original X and Y fields.
uint8_t x = plain1 - plain0 + 1;
uint8_t y = ~plain1;
// Reconstruct the 14-bit code in transmission order.
uint16_t code = 0;
for (const uint8_t &slot : PINCODE_MAP) {
const uint8_t entry = progmem_read_byte(&slot);
code = (code << 1) | (((entry & PIN_Y ? y : x) >> (entry & 0x07)) & 1U);
}
return CardinS438Data{
.code = code,
.channel = static_cast<uint8_t>(x & 0x03),
.seed = seed,
};
}
return {};
}
void CardinS438Protocol::dump(const CardinS438Data &data) {
// The DIP switches as the config takes them; channels as CARDIN_S438_CHANNELS in __init__.py maps them
char code[PINCODE_BITS + 1];
for (uint8_t i = 0; i < PINCODE_BITS; ++i)
code[i] = (data.code >> (PINCODE_BITS - 1 - i)) & 1U ? '1' : '0';
code[PINCODE_BITS] = '\0';
ESP_LOGI(TAG, "Received Cardin S438: code=%s channel=%c seed=0x%02X", code, "BDAC"[data.channel & 0x03], data.seed);
}
} // namespace esphome::remote_base
@@ -0,0 +1,25 @@
#pragma once
#include "esphome/components/remote_base/remote_base.h"
namespace esphome::remote_base {
struct CardinS438Data {
uint16_t code{0};
uint8_t channel{0};
uint8_t seed{0};
uint8_t repeats{3};
bool operator==(const CardinS438Data &rhs) const { return code == rhs.code && channel == rhs.channel; }
};
class CardinS438Protocol : public RemoteProtocol<CardinS438Data> {
public:
void encode(RemoteTransmitData *dst, const CardinS438Data &data);
optional<CardinS438Data> decode(RemoteReceiveData src);
void dump(const CardinS438Data &data);
};
DECLARE_REMOTE_PROTOCOL(CardinS438)
} // namespace esphome::remote_base
@@ -137,7 +137,7 @@ void ResamplerMicrophone::process_audio_(const std::vector<uint8_t> &data) {
}
if (this->resampler_ == nullptr) {
this->data_callbacks_.call(data);
this->publish_audio_(data);
return;
}
@@ -162,7 +162,7 @@ void ResamplerMicrophone::process_audio_(const std::vector<uint8_t> &data) {
if (results.frames_generated > 0) {
this->output_buffer_.resize(this->audio_stream_info_.frames_to_bytes(results.frames_generated));
this->data_callbacks_.call(this->output_buffer_);
this->publish_audio_(this->output_buffer_);
} else if (results.frames_used == 0) {
break; // No progress; drop the rest of the chunk instead of spinning
}
@@ -19,6 +19,7 @@ AUTO_LOAD = ["image"]
CODEOWNERS = ["@guillempages", "@clydebarrow", "@kahrendt"]
DOMAIN = "runtime_image"
CONF_DOUBLE_BUFFER = "double_buffer"
CONF_PLACEHOLDER = "placeholder"
CONF_TRANSPARENCY = "transparency"
@@ -212,6 +213,7 @@ class RuntimeImageSettings:
transparent: cg.MockObj
byte_order_big_endian: bool
placeholder: cg.MockObj | None
double_buffer: bool
async def process_runtime_image_config(config: dict) -> RuntimeImageSettings:
@@ -256,4 +258,5 @@ async def process_runtime_image_config(config: dict) -> RuntimeImageSettings:
transparent=transparent,
byte_order_big_endian=byte_order_big_endian,
placeholder=placeholder,
double_buffer=config.get(CONF_DOUBLE_BUFFER, False),
)
@@ -8,6 +8,8 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <new>
#ifdef USE_ESP_IDF
#include "esp_task_wdt.h"
#endif
@@ -94,7 +96,38 @@ static int draw_callback(JPEGDRAW *jpeg) {
return 1;
}
JpegDecoder::JpegDecoder(RuntimeImage *image) : ImageDecoder(image, JPEG) {
RAMAllocator<JPEGDEC> allocator;
JPEGDEC *jpeg = allocator.allocate(1);
if (jpeg == nullptr) {
ESP_LOGE(TAG, "Failed to allocate memory for the JPEG decoder");
return;
}
this->jpeg_ = new (jpeg) JPEGDEC();
}
JpegDecoder::~JpegDecoder() {
if (this->jpeg_ != nullptr) {
this->jpeg_->~JPEGDEC();
RAMAllocator<JPEGDEC> allocator;
allocator.deallocate(this->jpeg_, 1);
}
}
int JpegDecoder::prepare(size_t expected_size) {
// Check before the base prepare() so a failure never leaves an active session
if (this->jpeg_ == nullptr) {
ESP_LOGE(TAG, "JPEG decoder engine not initialized!");
return DECODE_ERROR_OUT_OF_MEMORY;
}
return ImageDecoder::prepare(expected_size);
}
int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) {
if (this->jpeg_ == nullptr) {
ESP_LOGE(TAG, "JPEG decoder engine not initialized!");
return DECODE_ERROR_OUT_OF_MEMORY;
}
// JPEG decoder requires complete data
// If we know the expected size, wait for it
if (this->expected_size_ > 0 && size < this->expected_size_) {
@@ -105,22 +138,22 @@ int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) {
// If size unknown, try to decode and see if it's valid
// The JPEGDEC library will fail gracefully if data is incomplete
if (!this->jpeg_.openRAM(buffer, size, draw_callback)) {
ESP_LOGE(TAG, "Could not open image for decoding: %d", this->jpeg_.getLastError());
if (!this->jpeg_->openRAM(buffer, size, draw_callback)) {
ESP_LOGE(TAG, "Could not open image for decoding: %d", this->jpeg_->getLastError());
return DECODE_ERROR_INVALID_TYPE;
}
const int err = this->start_();
if (err != 0)
return err;
if (!this->jpeg_.decode(0, 0, this->scale_option_()))
if (!this->jpeg_->decode(0, 0, this->scale_option_()))
return this->fail_();
this->decoded_bytes_ = size;
this->jpeg_.close();
this->jpeg_->close();
return size;
}
int JpegDecoder::start_() {
auto jpeg_type = this->jpeg_.getJPEGType();
auto jpeg_type = this->jpeg_->getJPEGType();
if (jpeg_type == JPEG_MODE_INVALID) {
ESP_LOGE(TAG, "Unsupported JPEG image");
return DECODE_ERROR_INVALID_TYPE;
@@ -128,11 +161,12 @@ int JpegDecoder::start_() {
ESP_LOGE(TAG, "Progressive JPEG images not supported");
return DECODE_ERROR_UNSUPPORTED_FORMAT;
}
ESP_LOGD(TAG, "Image size: %d x %d, bpp: %d", this->jpeg_.getWidth(), this->jpeg_.getHeight(), this->jpeg_.getBpp());
ESP_LOGD(TAG, "Image size: %d x %d, bpp: %d", this->jpeg_->getWidth(), this->jpeg_->getHeight(),
this->jpeg_->getBpp());
this->jpeg_.setUserPointer(this);
const int width = this->jpeg_.getWidth();
const int height = this->jpeg_.getHeight();
this->jpeg_->setUserPointer(this);
const int width = this->jpeg_->getWidth();
const int height = this->jpeg_->getHeight();
// The tables for drawing are only built once the decoded size is known, below
if (!this->resize_image_(width, height)) {
return DECODE_ERROR_OUT_OF_MEMORY;
@@ -155,17 +189,17 @@ int JpegDecoder::start_() {
if (this->direct_rgb565_) {
// JPEGDEC's RGB565_BIG_ENDIAN output is not byte swapped on every build (the x86 host build
// returns little-endian words for it), so the byte order is applied in draw_rgb565 instead
this->jpeg_.setPixelType(RGB565_LITTLE_ENDIAN);
this->jpeg_->setPixelType(RGB565_LITTLE_ENDIAN);
} else {
this->jpeg_.setPixelType(RGB8888);
this->jpeg_->setPixelType(RGB8888);
}
return 0;
}
int JpegDecoder::fail_() {
const bool read_failed = this->source_ != nullptr && this->source_failed_;
const int error = this->jpeg_.getLastError();
this->jpeg_.close();
const int error = this->jpeg_->getLastError();
this->jpeg_->close();
this->source_ = nullptr;
if (read_failed) {
ESP_LOGE(TAG, "Reading the image failed");
@@ -189,7 +223,7 @@ int JpegDecoder::fail_() {
void JpegDecoder::reset() {
// A decode from a source that was given up part way still has the image open
if (this->source_ != nullptr) {
this->jpeg_.close();
this->jpeg_->close();
this->source_ = nullptr;
}
ImageDecoder::reset();
@@ -218,6 +252,10 @@ int32_t JpegDecoder::source_seek(JPEGFILE *file, int32_t position) {
}
int JpegDecoder::begin_source(DecodeSource *source) {
if (this->jpeg_ == nullptr) {
ESP_LOGE(TAG, "JPEG decoder engine not initialized!");
return DECODE_ERROR_OUT_OF_MEMORY;
}
if (this->expected_size_ == 0 || this->expected_size_ > INT32_MAX) {
ESP_LOGE(TAG, "Image size %zu cannot be read from a source", this->expected_size_);
return DECODE_ERROR_INVALID_TYPE;
@@ -228,19 +266,19 @@ int JpegDecoder::begin_source(DecodeSource *source) {
}
this->source_ = source;
this->source_failed_ = false;
if (!this->jpeg_.open(this, this->expected_size_, source_close, source_read, source_seek, draw_callback)) {
if (!this->jpeg_->open(this, this->expected_size_, source_close, source_read, source_seek, draw_callback)) {
// The caller may recover, for example by reading the whole image first, so it reports the failure
ESP_LOGD(TAG, "Could not open image for decoding: %d", this->jpeg_.getLastError());
ESP_LOGD(TAG, "Could not open image for decoding: %d", this->jpeg_->getLastError());
this->source_ = nullptr;
return this->source_failed_ ? DECODE_ERROR_READ : DECODE_ERROR_INVALID_TYPE;
}
const int err = this->start_();
if (err != 0) {
this->jpeg_.close();
this->jpeg_->close();
this->source_ = nullptr;
return err;
}
if (!this->jpeg_.decodeBegin(0, 0, this->scale_option_()))
if (!this->jpeg_->decodeBegin(0, 0, this->scale_option_()))
return this->fail_();
return 0;
}
@@ -248,7 +286,7 @@ int JpegDecoder::begin_source(DecodeSource *source) {
int HOT JpegDecoder::decode_step() {
if (this->source_ == nullptr)
return DECODE_ERROR_INVALID_TYPE;
const int result = this->jpeg_.decodeRows(1);
const int result = this->jpeg_->decodeRows(1);
// JPEGDEC may decode on with the missing data, so check the read before its result
if (this->source_failed_)
return this->fail_();
@@ -259,7 +297,7 @@ int HOT JpegDecoder::decode_step() {
if (result == 0)
return this->fail_();
this->decoded_bytes_ = this->expected_size_;
this->jpeg_.close();
this->jpeg_->close();
this->source_ = nullptr;
return DECODE_STEP_DONE;
}
@@ -18,9 +18,10 @@ class JpegDecoder : public ImageDecoder {
*
* @param image The RuntimeImage to decode the stream into.
*/
JpegDecoder(RuntimeImage *image) : ImageDecoder(image, JPEG) {}
~JpegDecoder() override {}
JpegDecoder(RuntimeImage *image);
~JpegDecoder() override;
int prepare(size_t expected_size) override;
int HOT decode(uint8_t *buffer, size_t size) override;
void reset() override;
@@ -46,7 +47,8 @@ class JpegDecoder : public ImageDecoder {
static int32_t source_seek(JPEGFILE *file, int32_t position);
static void source_close(void *handle) {}
JPEGDEC jpeg_{};
/// About 18 KB of decoder state, allocated like pngle in PngDecoder: PSRAM first, nullptr if out of memory
JPEGDEC *jpeg_{nullptr};
int scale_{1}; // JPEGDEC decodes at 1/scale_ of the image size
DecodeSource *source_{nullptr}; // Set while a begin_source() decode is in progress
bool source_failed_{false};
@@ -3,6 +3,7 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <utility>
#include <cstdint>
#include <cstring>
#include <limits>
@@ -48,7 +49,14 @@ RuntimeImage::RuntimeImage(ImageFormat format, image::ImageType type, image::Tra
fixed_height_(fixed_height),
placeholder_(placeholder) {}
RuntimeImage::~RuntimeImage() { this->release(); }
RuntimeImage::~RuntimeImage() {
#ifdef USE_LVGL_IMAGE
for (lv_obj_t *obj : this->lvgl_widgets_)
lv_obj_remove_event_cb_with_user_data(obj, lvgl_widget_deleted, this);
this->lvgl_widgets_.clear();
#endif
this->release();
}
int RuntimeImage::resize(int width, int height) {
// Use fixed dimensions if specified (0 means auto-resize)
@@ -69,6 +77,7 @@ int RuntimeImage::resize(int width, int height) {
this->width_ = this->buffer_width_;
this->height_ = this->buffer_height_;
this->data_start_ = this->buffer_;
this->refresh_lvgl_();
}
return result;
}
@@ -176,6 +185,52 @@ void RuntimeImage::map_chroma_key(Color &color) {
}
}
#ifdef USE_LVGL
lv_image_dsc_t *RuntimeImage::get_lv_image_dsc() {
if (this->data_start_ == nullptr && this->placeholder_ != nullptr) {
this->dsc_ = *this->placeholder_->get_lv_image_dsc();
return &this->dsc_;
}
return Image::get_lv_image_dsc();
}
#endif
#ifdef USE_LVGL_IMAGE
void RuntimeImage::add_lvgl_widget(lv_obj_t *obj) {
if (std::find(this->lvgl_widgets_.begin(), this->lvgl_widgets_.end(), obj) != this->lvgl_widgets_.end())
return;
this->lvgl_widgets_.push_back(obj);
lv_obj_add_event_cb(obj, lvgl_widget_deleted, LV_EVENT_DELETE, this);
}
void RuntimeImage::lvgl_widget_deleted(lv_event_t *event) {
auto *image = static_cast<RuntimeImage *>(lv_event_get_user_data(event));
auto &widgets = image->lvgl_widgets_;
widgets.erase(std::remove(widgets.begin(), widgets.end(), lv_event_get_target_obj(event)), widgets.end());
}
#endif
void RuntimeImage::refresh_lvgl_() {
#ifdef USE_LVGL
// Not a virtual call: this also runs from the destructor
lv_image_dsc_t *dsc = this->RuntimeImage::get_lv_image_dsc();
#ifdef USE_LVGL_IMAGE
auto &widgets = this->lvgl_widgets_;
for (size_t i = 0; i < widgets.size();) {
lv_obj_t *obj = widgets[i];
if (lv_image_get_src(obj) == dsc) {
// Setting the same source makes the widget read the size again, and redraws it
::lv_image_set_src(obj, dsc);
i++;
} else {
lv_obj_remove_event_cb_with_user_data(obj, lvgl_widget_deleted, this);
widgets.erase(widgets.begin() + i);
}
}
#endif
#endif
}
void RuntimeImage::draw(int x, int y, display::Display *display, Color color_on, Color color_off) {
if (this->data_start_) {
// If we have a complete image, use the base class draw method
@@ -263,11 +318,21 @@ bool RuntimeImage::end_decode() {
}
// Finalize the image for display
if (!this->progressive_display_) {
if (this->is_double_buffered_()) {
if (this->buffer_ != nullptr) {
// Show the new image, and decode into the old one's buffer next time
std::swap(this->front_buffer_, this->buffer_);
std::swap(this->width_, this->buffer_width_);
std::swap(this->height_, this->buffer_height_);
this->data_start_ = this->front_buffer_;
this->refresh_lvgl_();
}
} else if (!this->progressive_display_) {
// Only now make the image visible
this->width_ = this->buffer_width_;
this->height_ = this->buffer_height_;
this->data_start_ = this->buffer_;
this->refresh_lvgl_();
}
// End the session; the decoder object stays warm so the next decode can
@@ -284,6 +349,15 @@ bool RuntimeImage::decoder_knows_end() const { return this->is_decoding() && thi
void RuntimeImage::release() {
this->release_buffer_();
if (this->front_buffer_) {
RAMAllocator<uint8_t> allocator;
allocator.deallocate(this->front_buffer_, this->get_buffer_size(this->width_, this->height_));
this->front_buffer_ = nullptr;
this->data_start_ = nullptr;
this->width_ = 0;
this->height_ = 0;
this->refresh_lvgl_();
}
// End any active decode session; decoders free the format-specific working buffers
// they can (PNG), while the decoder object itself is kept warm for the next decode.
if (this->decoder_) {
@@ -291,6 +365,16 @@ void RuntimeImage::release() {
}
}
void RuntimeImage::abort_decode() {
if (!this->is_double_buffered_()) {
this->release();
return;
}
if (this->decoder_) {
this->decoder_->reset();
}
}
void RuntimeImage::release_buffer_() {
if (this->buffer_) {
if (this->external_buffer_) {
@@ -302,19 +386,25 @@ void RuntimeImage::release_buffer_() {
RAMAllocator<uint8_t> allocator;
allocator.deallocate(this->buffer_, this->get_buffer_size(this->buffer_width_, this->buffer_height_));
}
const bool shown = this->data_start_ == this->buffer_;
this->buffer_ = nullptr;
this->data_start_ = nullptr;
this->width_ = 0;
this->height_ = 0;
this->buffer_width_ = 0;
this->buffer_height_ = 0;
#ifdef USE_LVGL
memset(&this->dsc_, 0, sizeof(this->dsc_));
#endif
// With double buffering the buffer decoded into is not the one on show
if (shown) {
this->data_start_ = nullptr;
this->width_ = 0;
this->height_ = 0;
this->refresh_lvgl_();
}
}
}
bool RuntimeImage::set_external_buffer(uint8_t *buffer, int width, int height) {
if (this->is_double_buffered_()) {
ESP_LOGE(TAG, "An external buffer cannot be double buffered");
return false;
}
this->release_buffer_();
if (buffer == nullptr || this->get_buffer_size(width, height) == 0) {
// Keep the released state rather than remembering a buffer that cannot be decoded into: an
@@ -3,6 +3,8 @@
#include "esphome/components/image/image.h"
#include "esphome/core/helpers.h"
#include <vector>
#include "image_decoder.h"
#include "image_format.h"
@@ -54,6 +56,13 @@ class RuntimeImage : public image::Image {
void map_chroma_key(Color &color);
int get_buffer_width() const { return this->buffer_width_; }
int get_buffer_height() const { return this->buffer_height_; }
#ifdef USE_LVGL
/// Until an image is loaded, the placeholder's descriptor if there is one
lv_image_dsc_t *get_lv_image_dsc() override;
#endif
#ifdef USE_LVGL_IMAGE
void add_lvgl_widget(lv_obj_t *obj) override;
#endif
// Image drawing interface
void draw(int x, int y, display::Display *display, Color color_on, Color color_off) override;
@@ -118,7 +127,9 @@ class RuntimeImage : public image::Image {
/**
* @brief Check if an image is currently loaded.
*/
bool is_loaded() const { return this->buffer_ != nullptr; }
bool is_loaded() const {
return this->is_double_buffered_() ? this->front_buffer_ != nullptr : this->buffer_ != nullptr;
}
/**
* @brief Get the image format.
@@ -134,6 +145,14 @@ class RuntimeImage : public image::Image {
*/
void release();
/**
* @brief End a decode that will not be finished.
*
* With double buffering the image on show stays and the buffer decoded into is kept for the next
* decode. Otherwise the same as release(), since the image on show was being decoded over.
*/
void abort_decode();
/**
* @brief Decode into a buffer the caller owns, instead of one allocated here.
*
@@ -157,6 +176,8 @@ class RuntimeImage : public image::Image {
* A null buffer or dimensions the image cannot decode at are refused, leaving the image with
* no buffer at all.
*
* Refused when double buffering is on.
*
* @param buffer Memory for a picture of the given size, at least get_buffer_size() bytes.
* @param width Width of the buffer in pixels.
* @param height Height of the buffer in pixels.
@@ -179,6 +200,13 @@ class RuntimeImage : public image::Image {
*/
void set_progressive_display(bool progressive) { this->progressive_display_ = progressive; }
/**
* @brief Set whether to decode into a second buffer, keeping the image on show until the new one is complete.
*
* Both buffers are kept, so a new image of the same size allocates nothing. Ignored with progressive display.
*/
void set_double_buffer(bool double_buffer) { this->double_buffer_ = double_buffer; }
protected:
/**
* @brief Resize the image buffer to the requested dimensions.
@@ -196,6 +224,11 @@ class RuntimeImage : public image::Image {
*/
void release_buffer_();
bool is_double_buffered_() const { return this->double_buffer_ && !this->progressive_display_; }
/// Bring the LVGL descriptor up to date after the image shown changed, and give it again to the widgets showing it
void refresh_lvgl_();
/**
* @brief Get the position in the buffer for a pixel.
*/
@@ -210,6 +243,8 @@ class RuntimeImage : public image::Image {
// Memory management
uint8_t *buffer_{nullptr};
/// With double buffering, the buffer on show, width_ by height_. buffer_ is then the one decoded into
uint8_t *front_buffer_{nullptr};
// Decoder management
std::unique_ptr<ImageDecoder> decoder_{nullptr};
@@ -246,8 +281,14 @@ class RuntimeImage : public image::Image {
// Configuration
bool progressive_display_{false};
bool double_buffer_{false};
/** Whether buffer_ belongs to the caller, so it must not be freed or resized here. */
bool external_buffer_{false};
#ifdef USE_LVGL_IMAGE
static void lvgl_widget_deleted(lv_event_t *event);
/// Widgets given this image. One moved to another image is dropped at the next refresh
std::vector<lv_obj_t *> lvgl_widgets_;
#endif
friend class ImageDecoder;
};
+230 -5
View File
@@ -4,16 +4,29 @@ 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 import (
audio,
esp32,
mdns,
microphone,
network,
psram,
socket,
wifi,
)
from esphome.components.const import CONF_ENABLED, CONF_MANUFACTURER
from esphome.components.mdns import DOMAIN as MDNS_DOMAIN
import esphome.config_validation as cv
from esphome.const import (
CONF_BITS_PER_SAMPLE,
CONF_BUFFER_DURATION,
CONF_BUFFER_SIZE,
CONF_CHANNELS,
CONF_ESPHOME,
CONF_FORMAT,
CONF_HEIGHT,
CONF_ID,
CONF_MICROPHONE,
CONF_MODEL,
CONF_NAME,
CONF_PROJECT,
@@ -25,6 +38,7 @@ from esphome.const import (
)
from esphome.core import CORE
from esphome.cpp_generator import MockObj
import esphome.final_validate as fv
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
@@ -59,6 +73,14 @@ CONF_ON_CLEAR_PAIRING_CODE = "on_clear_pairing_code"
CONF_ON_PAIRING_SUCCEEDED = "on_pairing_succeeded"
CONF_ON_PAIRING_FAILED = "on_pairing_failed"
CONF_CODEC = "codec"
CONF_CHUNK_DURATION = "chunk_duration"
CONF_OPUS_BITRATE = "opus_bitrate"
CONF_OPUS_COMPLEXITY = "opus_complexity"
CONF_ON_STREAMING_STARTED = "on_streaming_started"
CONF_ON_STREAMING_STOPPED = "on_streaming_stopped"
CONF_PRESENT = "present"
# A static pairing code is exactly 8 decimal digits.
STATIC_PAIRING_CODE_DIGITS = 8
@@ -104,6 +126,15 @@ CODECS = {
DEFAULT_CODECS = [CODEC_FLAC, CODEC_OPUS, CODEC_PCM]
OPUS_SAMPLE_RATE = 48000
SOURCE_CODECS = {
CODEC_OPUS: CODEC_FORMAT_OPUS,
CODEC_PCM: CODEC_FORMAT_PCM,
}
# The source role's limits, matching SourceRoleConfig in sendspin-cpp.
SOURCE_BITS_PER_SAMPLE = (16, 24, 32)
SOURCE_OPUS_SAMPLE_RATES = (8000, 12000, 16000, 24000, 48000)
SOURCE_OPUS_CHUNK_DURATIONS_MS = (5, 10, 20, 40, 60)
SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True)
IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG")
IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG")
@@ -117,6 +148,7 @@ AudioSupportedFormatObject = sendspin_library_ns.struct("AudioSupportedFormatObj
PlayerRoleConfig = sendspin_library_ns.struct("PlayerRoleConfig")
ArtworkRoleConfig = sendspin_library_ns.struct("ArtworkRoleConfig")
ImageSlotPreference = sendspin_library_ns.struct("ImageSlotPreference")
SourceRoleConfig = sendspin_library_ns.struct("SourceRoleConfig")
# MemoryLocation enum (from sendspin/types.h) controls SPIRAM-vs-internal-RAM placement
# preference for the player role's transfer buffers.
@@ -176,6 +208,11 @@ class SendspinConfiguration:
visualizer_support: bool = False
pairing_code_display_support: bool = False
switch_types: set[str] = field(default_factory=set)
# Set by the sendspin.source actions and conditions, which need a source block.
source_referenced: bool = False
source_line_sense: bool = False
# The microphone's sample rate, read during final validation.
source_sample_rate: int | None = None
artwork_preferences: list[ConfigType] = field(default_factory=list)
player_config: ConfigType | None = None
@@ -270,6 +307,82 @@ def _request_high_performance_networking(config: ConfigType) -> ConfigType:
return config
_SOURCE_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_STREAMING_STARTED, "add_on_source_streaming_started_callback"
),
automation.CallbackAutomation(
CONF_ON_STREAMING_STOPPED, "add_on_source_streaming_stopped_callback"
),
)
def _validate_source_format(config: ConfigType) -> ConfigType:
bits_per_sample = config[CONF_MICROPHONE][CONF_BITS_PER_SAMPLE]
if bits_per_sample not in SOURCE_BITS_PER_SAMPLE:
raise cv.Invalid(
f"{CONF_BITS_PER_SAMPLE} must be one of {SOURCE_BITS_PER_SAMPLE}",
path=[CONF_MICROPHONE, CONF_BITS_PER_SAMPLE],
)
if config[CONF_CODEC] == CODEC_OPUS:
if bits_per_sample != 16:
raise cv.Invalid(
f"Codec '{CODEC_OPUS}' requires a {CONF_BITS_PER_SAMPLE} of 16",
path=[CONF_MICROPHONE, CONF_BITS_PER_SAMPLE],
)
if (
config[CONF_CHUNK_DURATION].total_milliseconds
not in SOURCE_OPUS_CHUNK_DURATIONS_MS
):
raise cv.Invalid(
f"Codec '{CODEC_OPUS}' requires {CONF_CHUNK_DURATION} to be one of "
f"{', '.join(f'{ms}ms' for ms in SOURCE_OPUS_CHUNK_DURATIONS_MS)}",
path=[CONF_CHUNK_DURATION],
)
return config
SOURCE_SCHEMA = cv.All(
cv.Schema(
{
cv.Required(CONF_MICROPHONE): microphone.microphone_source_schema(
min_bits_per_sample=16,
max_bits_per_sample=32,
min_channels=1,
max_channels=2,
),
cv.Optional(CONF_CODEC, default=CODEC_PCM): cv.one_of(
*SOURCE_CODECS, lower=True
),
cv.Optional(CONF_CHUNK_DURATION, default="20ms"): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(
min=cv.TimePeriod(milliseconds=5),
max=cv.TimePeriod(milliseconds=150),
),
),
# sendspin-cpp refuses any single write longer than 5/8 of the buffer, so a short buffer
# would reject every microphone read.
cv.Optional(CONF_BUFFER_DURATION, default="500ms"): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(min=cv.TimePeriod(milliseconds=100)),
),
cv.Optional(audio.CONF_BUFFER_MEMORY): cv.one_of(
*MEMORY_LOCATIONS, lower=True
),
cv.Optional(CONF_OPUS_BITRATE, default="64kbps"): cv.All(
cv.bps, cv.int_, cv.int_range(min=500, max=512000)
),
cv.Optional(CONF_OPUS_COMPLEXITY, default=2): cv.int_range(min=0, max=10),
cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram,
cv.Optional(CONF_ON_STREAMING_STARTED): automation.validate_automation({}),
cv.Optional(CONF_ON_STREAMING_STOPPED): automation.validate_automation({}),
}
),
_validate_source_format,
)
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
@@ -295,6 +408,7 @@ CONFIG_SCHEMA = cv.All(
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.Optional(CONF_SOURCE): SOURCE_SCHEMA,
}
),
cv.only_on_esp32,
@@ -335,9 +449,40 @@ def _final_validate(config: ConfigType) -> ConfigType:
CONF_UNPAIRED_ACCESS,
CONF_STATIC_PAIRING_CODE,
)
_final_validate_source(config)
return config
def _final_validate_source(config: ConfigType) -> None:
data = _get_data()
if (source := config.get(CONF_SOURCE)) is None:
if data.source_referenced:
raise cv.Invalid(
f"The sendspin.source actions and conditions require a '{CONF_SOURCE}' "
"block on the sendspin component"
)
return
mic_source = source[CONF_MICROPHONE]
microphone.final_validate_microphone_source_schema(DOMAIN)(mic_source)
# MicrophoneSource cannot resample, so the stream takes the microphone's own rate.
mic_config = fv.get_declaring_config(mic_source[CONF_MICROPHONE])
sample_rate = mic_config.get(audio.CONF_MAX_SAMPLE_RATE)
if sample_rate is None or mic_config.get(audio.CONF_MIN_SAMPLE_RATE) != sample_rate:
raise cv.Invalid(
"The microphone used by the sendspin source must have a fixed sample rate",
path=[CONF_SOURCE, CONF_MICROPHONE],
)
if source[CONF_CODEC] == CODEC_OPUS and sample_rate not in SOURCE_OPUS_SAMPLE_RATES:
raise cv.Invalid(
f"Codec '{CODEC_OPUS}' requires the microphone {CONF_SAMPLE_RATE} to be one of "
f"{', '.join(str(rate) for rate in SOURCE_OPUS_SAMPLE_RATES)}",
path=[CONF_SOURCE, CONF_CODEC],
)
data.source_sample_rate = sample_rate
FINAL_VALIDATE_SCHEMA = _final_validate
@@ -347,7 +492,8 @@ def _request_controller_role(config: ConfigType) -> ConfigType:
return config
# Selects the hub. sendspin.switch adds the controller role it needs; the pairing window actions need no role.
# Selects the hub. sendspin.switch adds the controller role it needs; the pairing window actions need no role. The
# source action and condition also use it, and need a source block.
SENDSPIN_HUB_ACTION_SCHEMA = automation.maybe_simple_id(
cv.Schema(
{
@@ -381,6 +527,41 @@ automation.register_apply_action(
)
def _request_source(config: ConfigType) -> ConfigType:
"""Mark that a source action or condition is used, so a missing source block is reported."""
_get_data().source_referenced = True
return config
def _request_source_line_sense(config: ConfigType) -> ConfigType:
"""Advertise line sense for the sendspin.source.set_signal action."""
_get_data().source_line_sense = True
return _request_source(config)
automation.register_apply_condition(
"sendspin.source.is_streaming",
cv.All(SENDSPIN_HUB_ACTION_SCHEMA, _request_source),
"is_source_streaming()",
)
# Using the action advertises line sense, so the server knows to expect signal reports.
automation.register_apply_action(
"sendspin.source.set_signal",
cv.All(
cv.Schema(
{
cv.GenerateID(): cv.use_id(SendspinHub),
cv.Required(CONF_PRESENT): cv.templatable(cv.boolean),
}
),
_request_source_line_sense,
),
automation.ApplyField(CONF_PRESENT, "set_source_signal", cg.bool_),
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
@@ -433,9 +614,8 @@ async def to_code(config: ConfigType) -> None:
data = _get_data()
# The color and source roles are not yet wired up in ESPHome; disable them in the library for now.
# The color role is not yet wired up in ESPHome; disable it 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_*).
@@ -525,7 +705,52 @@ 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]:
if (source := config.get(CONF_SOURCE)) is not None:
cg.add_define("USE_SENDSPIN_SOURCE", True)
mic_source = await microphone.microphone_source_to_code(source[CONF_MICROPHONE])
cg.add(var.set_microphone_source(mic_source))
source_psram_stack = source.get(CONF_TASK_STACK_IN_PSRAM, False)
if source_psram_stack:
psram.request_external_task_stack()
# Designated initializers, so the fields follow SourceRoleConfig's declaration order.
source_struct_fields = [
("sample_rate", data.source_sample_rate),
("chunk_duration_ms", source[CONF_CHUNK_DURATION]),
("capture_buffer_ms", source[CONF_BUFFER_DURATION]),
("opus_bitrate", source[CONF_OPUS_BITRATE]),
]
if (buffer_memory := source.get(audio.CONF_BUFFER_MEMORY)) is not None:
source_struct_fields.append(
("buffer_location", MEMORY_LOCATION_ENUM[buffer_memory])
)
source_struct_fields += [
("codec", SOURCE_CODECS[source[CONF_CODEC]]),
("opus_complexity", source[CONF_OPUS_COMPLEXITY]),
("channels", len(source[CONF_MICROPHONE][CONF_CHANNELS])),
("bit_depth", source[CONF_MICROPHONE][CONF_BITS_PER_SAMPLE]),
("line_sense", data.source_line_sense),
("psram_stack", source_psram_stack),
]
cg.add(
var.set_source_config(
cg.StructInitializer(SourceRoleConfig, *source_struct_fields)
)
)
await automation.build_callback_automations(
var, source, _SOURCE_CALLBACK_AUTOMATIONS
)
else:
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_SOURCE", False)
player_uses_opus = (
data.player_support and CODEC_OPUS in data.player_config[CONF_CODECS]
)
source_uses_opus = source is not None and source[CONF_CODEC] == CODEC_OPUS
if not player_uses_opus and not source_uses_opus:
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_OPUS", False)
if data.visualizer_support:
@@ -158,6 +158,16 @@ void SendspinHub::setup() {
this->client_->add_player(this->player_config_).set_listener(this->player_listener_);
#endif
#ifdef USE_SENDSPIN_SOURCE
this->source_role_ = &this->client_->add_source(this->source_config_);
this->source_role_->set_listener(this);
// THREAD CONTEXT: Microphone task, the role's single producer. A capture time of 0 (unknown) makes the library
// stamp the buffer as ending now.
this->microphone_source_->add_data_callback([this](const std::vector<uint8_t> &data, int64_t capture_time_us) {
this->source_role_->write_audio(data.data(), data.size(), capture_time_us);
});
#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_);
@@ -223,6 +233,23 @@ void SendspinHub::dump_config() {
preference.display_offset_ms);
}
#endif
#ifdef USE_SENDSPIN_SOURCE
ESP_LOGCONFIG(TAG,
" Source:\n"
" Codec: %s\n"
" Sample rate: %" PRIu32 " Hz\n"
" Channels: %u\n"
" Bits per sample: %u\n"
" Chunk duration: %" PRIu32 " ms\n"
" Buffer duration: %" PRIu32 " ms\n"
" Line sense: %s",
this->source_config_.codec == sendspin::SendspinCodecFormat::OPUS ? LOG_STR_LITERAL("Opus")
: LOG_STR_LITERAL("PCM"),
this->source_config_.sample_rate, this->source_config_.channels, this->source_config_.bit_depth,
this->source_config_.chunk_duration_ms, this->source_config_.capture_buffer_ms,
YESNO(this->source_config_.line_sense));
#endif
}
// THREAD CONTEXT: Main loop (invoked from codegen before setup(), or from Sendspin components)
@@ -564,6 +591,27 @@ sendspin::PlayerRole *SendspinHub::get_player_role() {
}
#endif
#ifdef USE_SENDSPIN_SOURCE
// THREAD CONTEXT: Main loop (invoked from the sendspin.source.set_signal action)
void SendspinHub::set_source_signal(bool present) {
if (this->source_role_ != nullptr) {
this->source_role_->set_signal(present ? sendspin::SourceSignal::PRESENT : sendspin::SourceSignal::ABSENT);
}
}
// THREAD CONTEXT: Main loop (SourceRoleListener override, fired from client_->loop())
void SendspinHub::on_streaming_started() {
this->microphone_source_->start();
this->source_streaming_started_callbacks_.call();
}
// THREAD CONTEXT: Main loop (SourceRoleListener override, fired from client_->loop())
void SendspinHub::on_streaming_stopped() {
this->microphone_source_->stop();
this->source_streaming_stopped_callbacks_.call();
}
#endif
} // namespace esphome::sendspin_
#endif // USE_ESP_IDF
@@ -31,6 +31,11 @@
#ifdef USE_SENDSPIN_PLAYER
#include <sendspin/player_role.h>
#endif
#ifdef USE_SENDSPIN_SOURCE
#include "esphome/components/microphone/microphone_source.h"
#include <sendspin/source_role.h>
#endif
#include <array>
#include <cstddef>
@@ -75,6 +80,7 @@ enum SendspinFeature : uint32_t {
/// - Handles Sendspin roles that apply to multiple child components (artwork, controller, metadata) so their events
/// can be fanned out. Roles specific to a single component (player) are configured by the hub but owned by the
/// child thereafter, since no fan-out is needed.
/// - The source role streams a microphone. Its data callback runs on the microphone's task.
///
/// The sendspin-cpp library follows this design:
/// - Core and role configuration are passed at client/role construction time as structs. Built in our `setup()`.
@@ -91,6 +97,9 @@ class SendspinHub final : public Component,
#endif
#ifdef USE_SENDSPIN_METADATA
public sendspin::MetadataRoleListener,
#endif
#ifdef USE_SENDSPIN_SOURCE
public sendspin::SourceRoleListener,
#endif
public sendspin::SendspinClientListener,
public sendspin::SendspinNetworkProvider,
@@ -285,6 +294,29 @@ class SendspinHub final : public Component,
sendspin::PlayerRole *get_player_role();
#endif
#ifdef USE_SENDSPIN_SOURCE
void set_source_config(const sendspin::SourceRoleConfig &config) { this->source_config_ = config; }
void set_microphone_source(microphone::MicrophoneSource *microphone_source) {
this->microphone_source_ = microphone_source;
}
/// @brief Reports whether the capture input has a signal; exposed as the sendspin.source.set_signal action.
///
/// Using the action enables line sense in the source config. Ignored before setup(). Must be called from the main
/// loop thread.
void set_source_signal(bool present);
/// @brief Returns whether the source is streaming microphone audio to the server.
bool is_source_streaming() const { return this->source_role_ != nullptr && this->source_role_->is_streaming(); }
template<typename F> void add_on_source_streaming_started_callback(F &&callback) {
this->source_streaming_started_callbacks_.add(std::forward<F>(callback));
}
template<typename F> void add_on_source_streaming_stopped_callback(F &&callback) {
this->source_streaming_stopped_callbacks_.add(std::forward<F>(callback));
}
#endif
protected:
/// @brief Builds the SendspinClientConfig from ESPHome configuration and platform info.
sendspin::SendspinClientConfig build_client_config_();
@@ -383,6 +415,20 @@ class SendspinHub final : public Component,
sendspin::PlayerRoleConfig player_config_{};
#endif
#ifdef USE_SENDSPIN_SOURCE
// The server opens and closes the stream; the microphone runs only while it is open.
void on_streaming_started() override;
void on_streaming_stopped() override;
sendspin::SourceRoleConfig source_config_{};
sendspin::SourceRole *source_role_{nullptr};
microphone::MicrophoneSource *microphone_source_{nullptr};
// Lazy: each is fed by an optional YAML automation.
LazyCallbackManager<void()> source_streaming_started_callbacks_{};
LazyCallbackManager<void()> source_streaming_stopped_callbacks_{};
#endif
// --- Core member variables ---
// Built once in setup(): make_preference() allocates a backend that is never freed.
+5
View File
@@ -124,6 +124,7 @@ from esphome.core import (
)
from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH
from esphome.core.entity_helpers import (
FollowEntity,
SubEntities,
entity_duplicate_validator,
lazy_load_validator,
@@ -1109,6 +1110,10 @@ def sub_sensors(
return SubEntities(new_sensor, config, parent)
# Makes a plain sensor follow another sensor: follow_sensor(entity, source)
follow_sensor = FollowEntity("add_on_state_callback", ((cg.float_, "x"),), "x")
SENSOR_IN_RANGE_CONDITION_SCHEMA = cv.All(
{
cv.Required(CONF_ID): cv.use_id(Sensor),
@@ -2,29 +2,22 @@ import esphome.codegen as cg
from esphome.components import binary_sensor
import esphome.config_validation as cv
from esphome.const import CONF_SOURCE_ID
from esphome.core.entity_helpers import FollowEntity
from esphome.types import ConfigType
from .. import Switch, switch_ns
from .. import Switch
CODEOWNERS = ["@ssieb"]
SwitchBinarySensor = switch_ns.class_(
"SwitchBinarySensor", binary_sensor.BinarySensor, cg.Component
_FOLLOW_SWITCH = FollowEntity("add_on_state_callback", ((cg.bool_, "x"),), "x")
CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(binary_sensor.BinarySensor).extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(Switch),
}
)
CONFIG_SCHEMA = (
binary_sensor.binary_sensor_schema(SwitchBinarySensor)
.extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(Switch),
}
)
.extend(cv.COMPONENT_SCHEMA)
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
var = await binary_sensor.new_binary_sensor(config)
await cg.register_component(var, config)
await cg.add_variable(var.set_source, config[CONF_SOURCE_ID])
_FOLLOW_SWITCH(var, await cg.get_variable(config[CONF_SOURCE_ID]))
@@ -1,15 +0,0 @@
#include "switch_binary_sensor.h"
#include "esphome/core/log.h"
namespace esphome::switch_ {
ESPHOME_LOG_TAG(TAG, "switch.binary_sensor");
void SwitchBinarySensor::setup() {
source_->add_on_state_callback([this](bool value) { this->publish_state(value); });
this->publish_state(source_->state);
}
void SwitchBinarySensor::dump_config() { LOG_BINARY_SENSOR("", "Switch Binary Sensor", this); }
} // namespace esphome::switch_
@@ -1,19 +0,0 @@
#pragma once
#include "../switch.h"
#include "esphome/core/component.h"
#include "esphome/components/binary_sensor/binary_sensor.h"
namespace esphome::switch_ {
class SwitchBinarySensor final : public binary_sensor::BinarySensor, public Component {
public:
void set_source(Switch *source) { source_ = source; }
void setup() override;
void dump_config() override;
protected:
Switch *source_;
};
} // namespace esphome::switch_
@@ -4,23 +4,16 @@ import esphome.config_validation as cv
from esphome.const import CONF_SOURCE_ID
from esphome.types import ConfigType
from .. import Text, text_ns
from .. import Text
TextTextSensor = text_ns.class_("TextTextSensor", text_sensor.TextSensor, cg.Component)
CONFIG_SCHEMA = (
text_sensor.text_sensor_schema(TextTextSensor)
.extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(Text),
}
)
.extend(cv.COMPONENT_SCHEMA)
CONFIG_SCHEMA = text_sensor.text_sensor_schema(text_sensor.TextSensor).extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(Text),
}
)
async def to_code(config: ConfigType) -> None:
source = await cg.get_variable(config[CONF_SOURCE_ID])
var = await text_sensor.new_text_sensor(config, source)
await cg.register_component(var, config)
var = await text_sensor.new_text_sensor(config)
# Text has the same string state callback as TextSensor
text_sensor.follow_text_sensor(var, await cg.get_variable(config[CONF_SOURCE_ID]))
@@ -1,16 +0,0 @@
#include "text_text_sensor.h"
#include "esphome/core/log.h"
namespace esphome::text {
ESPHOME_LOG_TAG(TAG, "text.text_sensor");
void TextTextSensor::setup() {
this->source_->add_on_state_callback([this](const std::string &value) { this->publish_state(value); });
if (this->source_->has_state())
this->publish_state(this->source_->state);
}
void TextTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Text Text Sensor", this); }
} // namespace esphome::text
@@ -1,19 +0,0 @@
#pragma once
#include "../text.h"
#include "esphome/core/component.h"
#include "esphome/components/text_sensor/text_sensor.h"
namespace esphome::text {
class TextTextSensor final : public text_sensor::TextSensor, public Component {
public:
explicit TextTextSensor(Text *source) : source_(source) {}
void setup() override;
void dump_config() override;
protected:
Text *source_;
};
} // namespace esphome::text
@@ -22,6 +22,7 @@ from esphome.const import (
)
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import (
FollowEntity,
SubEntities,
entity_duplicate_validator,
queue_entity_register,
@@ -238,6 +239,12 @@ def sub_text_sensors(
return SubEntities(new_text_sensor, config, parent)
# Makes a plain text sensor follow another text sensor: follow_text_sensor(entity, source)
follow_text_sensor = FollowEntity(
"add_on_state_callback", ((cg.std_string_ref.operator("const"), "x"),), "x"
)
@coroutine_with_priority(CoroPriority.CORE)
async def to_code(config):
cg.add_global(text_sensor_ns.using)
+13
View File
@@ -1264,6 +1264,18 @@ class CoreFinalValidateStep(ConfigValidationStep):
fv.full_config.reset(token)
class OptionalPinValidationStep(ConfigValidationStep):
"""Remove optional pins that are also used elsewhere"""
# After the ID pass, and before component final validations which may inspect pin use
priority = -15.0
def run(self, result: Config) -> None:
if result.errors:
return
pins.PIN_SCHEMA_REGISTRY.remove_optional_conflicts(result)
class PinUseValidationCheck(ConfigValidationStep):
"""Check for pin reuse"""
@@ -1437,6 +1449,7 @@ def validate_config(
result.add_validation_step(PrefetchRemoteFilesValidationStep())
result.add_validation_step(IDPassValidationStep())
result.add_validation_step(CoreFinalValidateStep())
result.add_validation_step(OptionalPinValidationStep())
result.add_validation_step(PinUseValidationCheck())
result.add_validation_step(RemoveReferenceValidationStep())
+6 -3
View File
@@ -416,10 +416,13 @@ void Application::teardown_components(uint32_t timeout_ms) {
}
}
void Application::add_looping_components_by_state_(bool match_loop_done) {
void Application::add_looping_components_by_state_(bool inactive) {
for (auto *obj : this->components_) {
if (obj->has_overridden_loop() &&
((obj->get_component_state() & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE) == match_loop_done) {
if (!obj->has_overridden_loop()) {
continue;
}
const uint8_t state = obj->get_component_state() & COMPONENT_STATE_MASK;
if ((state == COMPONENT_STATE_LOOP_DONE || state == COMPONENT_STATE_FAILED) == inactive) {
this->looping_components_.push_back(obj);
}
}
+5 -6
View File
@@ -433,16 +433,15 @@ class Application {
// FixedVector capacity was pre-initialized by codegen with the exact count
// of components that override loop(), computed at C++ compile time.
// Add all components with loop override that aren't already LOOP_DONE
// Some components (like logger) may call disable_loop() during initialization
// before setup runs, so we need to respect their LOOP_DONE state
// Add all components with loop override that aren't already LOOP_DONE or FAILED
// Some components (like logger) may call disable_loop() during initialization,
// and some call mark_failed() from their constructor, before setup runs
this->add_looping_components_by_state_(false);
this->looping_components_active_end_ = this->looping_components_.size();
// Then add any components that are already LOOP_DONE to the inactive section
// This handles components that called disable_loop() during initialization
// Then add any components that are already LOOP_DONE or FAILED to the inactive section
this->add_looping_components_by_state_(true);
}
void add_looping_components_by_state_(bool match_loop_done);
void add_looping_components_by_state_(bool inactive);
// These methods are called by Component::disable_loop() and Component::enable_loop()
// Components should not call these directly - use this->disable_loop() or this->enable_loop()
+1 -1
View File
@@ -169,7 +169,7 @@ struct Color {
uint8_t r = rand >> 16;
uint8_t g = rand >> 8;
uint8_t b = rand >> 0;
const uint16_t max_rgb = std::max({r, g, b});
const uint16_t max_rgb = std::max({r, g, b, uint8_t(1)});
return Color(uint8_t((uint16_t(r) * 255U / max_rgb)), uint8_t((uint16_t(g) * 255U / max_rgb)),
uint8_t((uint16_t(b) * 255U / max_rgb)), w);
}
+3
View File
@@ -96,6 +96,7 @@
#define USE_GPIO_SWITCH_INTERLOCK
#define USE_GRAPH
#define USE_GRAPHICAL_DISPLAY_MENU
#define USE_GREE_FAHRENHEIT
#define USE_GREE_YAW1F
#define USE_HOERMANN_HCP_IDENTITY
#define USE_HOMEASSISTANT_TIME
@@ -201,6 +202,7 @@
#define USE_REMOTE_PROTOCOL_BRENNENSTUHL
#define USE_REMOTE_PROTOCOL_BYRONSX
#define USE_REMOTE_PROTOCOL_CANALSAT
#define USE_REMOTE_PROTOCOL_CARDIN_S438
#define USE_REMOTE_PROTOCOL_COOLIX
#define USE_REMOTE_PROTOCOL_DISH
#define USE_REMOTE_PROTOCOL_DOOYA
@@ -516,6 +518,7 @@
#define USE_SENDSPIN_CONTROLLER
#define USE_SENDSPIN_METADATA
#define USE_SENDSPIN_PLAYER
#define USE_SENDSPIN_SOURCE
#define USE_SENDSPIN_SWITCH
#define USE_SENDSPIN_VISUALIZER
#define USE_SENDSPIN_PORT 8928 // NOLINT
+18
View File
@@ -45,6 +45,7 @@ from esphome.core.config import (
UNIT_OF_MEASUREMENT_MAX_LENGTH,
)
from esphome.cpp_generator import (
LambdaExpression,
MockObj,
MockObjClass,
RawExpression,
@@ -519,6 +520,23 @@ class ApplyEntity:
_APPLY_DOMAIN = "entity_apply"
@dataclass(frozen=True, slots=True)
class FollowEntity:
"""A state callback, its parameters and the value an entity republishes from it."""
callback: str
params: tuple[tuple[SafeExpType, str], ...]
value: str
def __call__(self, entity: MockObj, source: MockObj, *args: SafeExpType) -> None:
"""Make ``entity`` publish every state of ``source``; ``args`` precede the callback."""
publish = f"{parent_ref(entity).publish_state(RawExpression(self.value))};"
callback = LambdaExpression(
[publish], list(self.params), capture="", return_type=cg.void
)
add(getattr(source, self.callback)(*args, callback))
@dataclass(frozen=True, slots=True)
class SubEntities:
"""Create the child entity configured under key, set parent (if bound) and pass it to setter.
+7 -7
View File
@@ -483,13 +483,6 @@ uint32_t HOT Scheduler::call(uint32_t now) {
// Not reached timeout yet, done for this call
break;
}
// Don't run on failed components (is_item_failed_ exempts SELF_POINTER delays).
if (this->is_item_failed_(item)) {
LockGuard guard{this->lock_};
this->recycle_item_main_loop_(this->pop_raw_locked_());
continue;
}
// Check if item is marked for removal
// This handles two cases:
// 1. Item was marked for removal after cleanup_() but before we got here
@@ -514,6 +507,13 @@ uint32_t HOT Scheduler::call(uint32_t now) {
}
#endif
// Don't run on failed components (is_item_failed_ exempts SELF_POINTER delays).
if (this->is_item_failed_(item)) {
LockGuard guard{this->lock_};
this->recycle_item_main_loop_(this->pop_raw_locked_());
continue;
}
#ifdef ESPHOME_DEBUG_SCHEDULER
{
SchedulerNameLog name_log;
+2
View File
@@ -331,6 +331,8 @@ class FloatLiteral(Literal):
def __str__(self):
if math.isnan(self.f):
return "NAN"
if math.isinf(self.f):
return "INFINITY" if self.f > 0 else "-INFINITY"
return f"{self.f}f"
+7 -1
View File
@@ -466,9 +466,15 @@ def receive_exactly(
while len(data) < amount:
try:
data += recv_decode(sock, amount - len(data), decode=decode) # type: ignore[operator]
chunk = recv_decode(sock, amount - len(data), decode=decode)
except OSError as err:
raise OTANetworkError(f"receiving {msg}: {err}") from err
if not chunk:
raise OTANetworkError(
f"receiving {msg}: device closed connection after "
f"{len(data)} of {amount} bytes"
)
data += chunk # type: ignore[operator]
return data
+108 -10
View File
@@ -1,8 +1,10 @@
from collections.abc import Callable, Hashable
from contextvars import ContextVar
from functools import reduce
import logging
from logging import Logger
import operator
from typing import Any
from typing import TYPE_CHECKING, Any
import esphome.config_validation as cv
from esphome.const import (
@@ -21,17 +23,64 @@ from esphome.const import (
)
from esphome.core import CORE, ID
from esphome.cpp_generator import MockObj, MockObjClass
from esphome.schema_extractors import schema_extractor
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigType, SafeExpType
if TYPE_CHECKING:
from esphome.config import Config
_LOGGER = logging.getLogger(__name__)
# A use of a pin: the path of the component using it, the pin provider's id and the pin config
PinUse = tuple[list, Any, ConfigType]
# Set while validating pins that should give way to any other use of the same pin
_validating_optional = ContextVar("validating_optional_pins", default=False)
def _remove_value(conf: Any, target: Any) -> bool:
"""Remove the object ``target`` from wherever it is nested in ``conf``."""
items = conf.items() if isinstance(conf, dict) else enumerate(conf)
for key, value in items:
if value is target:
del conf[key]
return True
if isinstance(value, (dict, list)) and _remove_value(value, target):
return True
return False
class PinRegistry(dict):
def __init__(self):
super().__init__()
self.pins_used = {}
# Entries of pins_used that give way to other uses, keyed by id()
self.optional_pins = {}
def reset(self):
self.pins_used = {}
self.optional_pins = {}
def optional(self, validator: Callable[[Any], Any]) -> Callable[[Any], Any]:
"""
Wrap a pin validator so the pins it validates give way to any other use of the
same pin. Instead of the pin being reported as used in multiple places,
remove_optional_conflicts() removes it from the config, unless every other use
sets allow_other_uses, in which case the pin is shared. Use this for pins a
component supplies as defaults that a user may want to take over.
"""
@schema_extractor("schema")
def wrapper(value: Any) -> Any:
if value == SCHEMA_EXTRACT:
return validator
token = _validating_optional.set(True)
try:
return validator(value)
finally:
_validating_optional.reset(token)
return wrapper
def get_count(self, key, id, number):
"""
@@ -86,7 +135,10 @@ class PinRegistry(dict):
self.pins_used[pin_key] = []
# client_id identifies the instance of the providing component
client_id = result.get(key)
self.pins_used[pin_key].append((path_context.get(), client_id, result))
entry = (path_context.get(), client_id, result)
self.pins_used[pin_key].append(entry)
if _validating_optional.get():
self.optional_pins[id(entry)] = entry
# return the validated pin config
return result
@@ -110,23 +162,69 @@ class PinRegistry(dict):
key = self.get_key(key) if isinstance(key, dict) else key
return self[key][0]
def final_validate(self, fconf):
"""
Run the final validation for all pins, and check for reuse
:param fconf: The full config
"""
def _grouped(self) -> dict[tuple, list[PinUse]]:
"""Group pin uses by (schema key, resolved provider id, pin number)."""
# Regroup by (schema key, resolved provider id, pin number) rather than
# the validate()-time bucket, so the same physical pin reached via an
# explicit id and via {address: ...} (or an omitted id) is recognized as
# one reuse group regardless of which syntax was used to reference the hub.
grouped: dict[tuple, list] = {}
grouped: dict[tuple, list[PinUse]] = {}
for (key, _, number), pin_list in self.pins_used.items():
for entry in pin_list:
client_id = entry[1]
provider_id = client_id.id if isinstance(client_id, ID) else client_id
grouped.setdefault((key, provider_id, number), []).append(entry)
return grouped
for (key, _, _), pin_list in grouped.items():
def remove_optional_conflicts(self, fconf: "Config") -> None:
"""
Remove optional pins that are also used elsewhere from the config, or mark them
as shared if every other use sets allow_other_uses. This runs once ids are resolved and before the components' final validation,
so those only see the pins that will be used.
:param fconf: The full config
"""
if not self.optional_pins:
return
for (key, provider_id, _), pin_list in self._grouped().items():
optional = [e for e in pin_list if id(e) in self.optional_pins]
others = [e for e in pin_list if id(e) not in self.optional_pins]
if not optional or not others:
continue
if all(e[2].get(CONF_ALLOW_OTHER_USES, False) for e in others):
# The other uses ask to share the pin, so share it with the optional ones too
for _, _, pin_config in optional:
pin_config[CONF_ALLOW_OTHER_USES] = True
continue
for entry in optional:
self._remove_optional(fconf, key, provider_id, entry)
def _remove_optional(
self, fconf: "Config", key: str, provider_id: str | None, entry: PinUse
) -> None:
"""
Remove an optional pin from the config and from the pins used. If it cannot be
found in the config it is kept, so final_validate() reports the conflict.
"""
pin_path, _, pin_config = entry
if not _remove_value(fconf.get_config_for_path(pin_path), pin_config):
return
for pin_list in self.pins_used.values():
pin_list[:] = [e for e in pin_list if e is not entry]
del self.optional_pins[id(entry)]
pin = f"{key} {provider_id} pin" if provider_id is not None else "Pin"
_LOGGER.info(
"%s %s is used elsewhere, so is not applied as a default for %s",
pin,
pin_config[CONF_NUMBER],
"->".join(str(p) for p in pin_path),
)
def final_validate(self, fconf):
"""
Run the final validation for all pins, and check for reuse
:param fconf: The full config
"""
for (key, _, _), pin_list in self._grouped().items():
count = len(pin_list) # number of places same pin used.
final_val_fun = self[key][2] # final validation function
for pin_path, client_id, pin_config in pin_list:
+21 -9
View File
@@ -326,6 +326,25 @@ def load_test_manifest_overrides(
ManifestOverrideLoader = Callable[[list[str]], None]
def resolve_dependencies_with_overrides(
components: list[str], manifest_override_loader: ManifestOverrideLoader
) -> list[str]:
"""Return the components and all their dependencies, with overrides applied.
Overrides are applied before each dependency search, so dependencies added by
any override_manifest(), including one of a component found only as a
dependency, are included. The loader is called again with components it has
already seen, so it must apply each override only once.
"""
resolved = sorted(components)
while True:
manifest_override_loader(resolved)
expanded = sorted(get_all_dependencies(set(resolved)))
if expanded == resolved:
return resolved
resolved = expanded
def compile_and_get_binary(
config: dict,
components: list[str],
@@ -366,19 +385,12 @@ def compile_and_get_binary(
else:
config.setdefault(key, value)
# Apply manifest overrides before dependency resolution so that any
# dependency additions made by override_manifest() are picked up.
manifest_override_loader(components)
# Obtain possible dependencies BEFORE validate_config, because
# get_all_dependencies calls CORE.reset() which clears build_path.
components_with_dependencies: list[str] = sorted(
get_all_dependencies(set(components))
components_with_dependencies = resolve_dependencies_with_overrides(
components, manifest_override_loader
)
# Apply overrides for any transitively discovered dependencies.
manifest_override_loader(components_with_dependencies)
CORE.config_path = tests_dir / "dummy.yaml"
CORE.dashboard = None
@@ -0,0 +1,17 @@
esphome:
name: test
esp32:
board: esp32-s3-devkitc-1
psram:
mode: octal
spi:
clk_pin: GPIO7
mosi_pin: GPIO9
display:
- platform: epaper_spi
id: epaper_display
model: seeed-reterminal-sticky
@@ -0,0 +1,23 @@
esphome:
name: test
esp32:
board: esp32-s3-devkitc-1
psram:
mode: octal
spi:
clk_pin: GPIO7
mosi_pin: GPIO9
# Takes over the model's default enable pin
switch:
- platform: gpio
id: panel_power
pin: GPIO47
display:
- platform: epaper_spi
id: epaper_display
model: seeed-reterminal-sticky
@@ -0,0 +1,46 @@
"""Tests for applying the default enable pins of epaper_spi models."""
from collections.abc import Callable
from pathlib import Path
import re
def _enable_pin_numbers(main_cpp: str) -> list[int]:
"""Get the GPIO numbers passed to set_enable_pins() in the generated code."""
match = re.search(r"epaper_display->set_enable_pins\(\{(.*)\}\);", main_cpp)
if match is None:
return []
numbers = []
for pin_id in match.group(1).split(", "):
pin = re.search(rf"{pin_id}->set_pin\(::GPIO_NUM_(\d+)\);", main_cpp)
assert pin is not None
numbers.append(int(pin.group(1)))
return numbers
def test_default_enable_pin(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The model's enable pin is applied when it is not used elsewhere."""
main_cpp = generate_main(component_config_path("enable_pin_default.yaml"))
assert _enable_pin_numbers(main_cpp) == [47]
def test_default_enable_pin_used_elsewhere(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""A default enable pin used by a switch is left out instead of failing validation."""
main_cpp = generate_main(component_config_path("enable_pin_used_elsewhere.yaml"))
assert _enable_pin_numbers(main_cpp) == []
assert "panel_power->set_pin(" in main_cpp
def test_user_enable_pins(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Enable pins given in the config are applied as given."""
main_cpp = generate_main(component_config_path("enable_pin_test.yaml"))
assert _enable_pin_numbers(main_cpp) == [25, 26]
@@ -0,0 +1,26 @@
esphome:
name: it8951-enable-pins
esp32:
board: esp32-s3-devkitc-1
framework:
type: esp-idf
psram:
mode: octal
spi:
clk_pin: 7
mosi_pin: 9
miso_pin: 8
# Takes over the model's default 1.8V logic supply enable pin
switch:
- platform: gpio
id: logic_supply
pin: GPIO21
display:
- platform: it8951
id: epaper_display
model: seeed-reterminal-e1003
+17
View File
@@ -1,6 +1,8 @@
"""Tests for it8951 configuration validation."""
from collections.abc import Callable
from pathlib import Path
import re
from typing import Any
from esphome.components.const import CONF_HOLD_STATE
@@ -26,3 +28,18 @@ def test_reterminal_e1003_enable_pins_hold_state(
enable_pins = config[CONF_ENABLE_PIN]
assert [pin[CONF_NUMBER] for pin in enable_pins] == [21, 11]
assert all(pin[CONF_HOLD_STATE] for pin in enable_pins)
def test_reterminal_e1003_enable_pin_used_elsewhere(
generate_main: Callable[[str | Path], str],
component_fixture_path: Callable[[str], Path],
) -> None:
"""A default enable pin used by a switch is left out, and the other is still applied."""
main_cpp = generate_main(component_fixture_path("enable_pins.yaml"))
match = re.search(r"epaper_display->set_enable_pins\(\{(.*)\}\);", main_cpp)
assert match is not None
pin_ids = match.group(1).split(", ")
assert len(pin_ids) == 1
assert f"{pin_ids[0]}->set_pin(::GPIO_NUM_11);" in main_cpp
assert "logic_supply->set_pin(" in main_cpp
Whitespace-only changes.
@@ -0,0 +1,24 @@
"""lightwaverf.send_raw never used name; it is dropped with a warning until 2027.5.0."""
import pytest
from esphome.automation import ACTION_REGISTRY
import esphome.components.lightwaverf # noqa: F401
from esphome.const import CONF_NAME, CONF_REPEAT
CODE = [0x04, 0x00, 0x00, 0x00, 0x0F, 0x03, 0x0D, 0x09, 0x08, 0x08]
def test_name_is_dropped_with_a_warning(caplog: pytest.LogCaptureFixture) -> None:
config = ACTION_REGISTRY["lightwaverf.send_raw"].schema(
{"code": CODE, CONF_NAME: "Sofa"}
)
assert CONF_NAME not in config
assert "'name' on lightwaverf.send_raw has no effect" in caplog.text
assert "2027.5.0" in caplog.text
def test_name_is_no_longer_required(caplog: pytest.LogCaptureFixture) -> None:
config = ACTION_REGISTRY["lightwaverf.send_raw"].schema({"code": CODE})
assert config[CONF_REPEAT] == 10
assert "lightwaverf" not in caplog.text
@@ -0,0 +1,50 @@
esphome:
name: test-canvas-draw-options
on_boot:
- lvgl.canvas.draw_rectangle:
id: test_canvas
x: 0
y: 0
width: 20
height: 10
bg_grad: test_grad
- lvgl.canvas.draw_line:
id: test_canvas
dash_width: 4
dash_gap: 2
points:
- 0,0
- 10,10
esp32:
board: lolin_c3_mini
spi:
mosi_pin:
number: GPIO2
ignore_strapping_warning: true
clk_pin: GPIO1
display:
- platform: mipi_spi
data_rate: 20MHz
model: st7735
cs_pin:
number: GPIO8
ignore_strapping_warning: true
dc_pin: GPIO3
lvgl:
gradients:
- id: test_grad
direction: hor
stops:
- color: 0xFF0000
position: 0
- color: 0x0000FF
position: 255
widgets:
- canvas:
id: test_canvas
width: 32
height: 32
@@ -0,0 +1,58 @@
esphome:
name: test-indicator-update-opa
on_boot:
- lvgl.indicator.update:
id: test_arc
opa: 50%
- lvgl.indicator.update:
id: test_needle
value: 5
opa: 25%
- lvgl.indicator.update:
id: test_image
opa: 75%
esp32:
board: lolin_c3_mini
spi:
mosi_pin:
number: GPIO2
ignore_strapping_warning: true
clk_pin: GPIO1
display:
- platform: mipi_spi
data_rate: 20MHz
model: st7735
cs_pin:
number: GPIO8
ignore_strapping_warning: true
dc_pin: GPIO3
image:
- platform: file
file: image.png
id: meter_image
resize: 8x8
type: binary
lvgl:
widgets:
- meter:
height: 100px
width: 100px
scales:
- id: test_scale
range_from: 0
range_to: 10
indicators:
- arc:
id: test_arc
start_value: 0
end_value: 5
- line:
id: test_needle
- image:
id: test_image
src: meter_image
@@ -0,0 +1,69 @@
"""Canvas draw options must map onto the LVGL 9 draw descriptor fields."""
from __future__ import annotations
from pathlib import Path
import pytest
from esphome import config_validation as cv
from esphome.__main__ import generate_cpp_contents
from esphome.automation import ACTION_REGISTRY
import esphome.components.lvgl.widgets.canvas # noqa: F401 - registers the actions
from esphome.config import read_config
from esphome.core import CORE
@pytest.fixture(scope="module")
def main_cpp(request: pytest.FixtureRequest) -> str:
config_path = (
Path(request.fspath).parent / "config" / "canvas_draw_options_test.yaml"
)
original_path = CORE.config_path
try:
CORE.config_path = config_path
CORE.config = read_config({})
generate_cpp_contents(CORE.config)
return CORE.cpp_main_section
finally:
CORE.config_path = original_path
CORE.reset()
def test_rect_gradient_is_copied_by_value(main_cpp: str) -> None:
assert ".bg_grad = *test_grad;" in main_cpp
def test_line_dash_fields(main_cpp: str) -> None:
assert ".dash_width = 4;" in main_cpp
assert ".dash_gap = 2;" in main_cpp
def test_rect_opa_names_the_per_part_keys() -> None:
with pytest.raises(cv.Invalid, match="bg_opa"):
ACTION_REGISTRY["lvgl.canvas.draw_rectangle"].schema(
{"id": "cv", "x": 0, "y": 0, "width": 10, "height": 10, "opa": "50%"}
)
@pytest.mark.parametrize(
"key", ["opa", "bg_color", "bg_grad", "radius", "shadow_width"]
)
def test_polygon_rejects_non_border_keys(key: str) -> None:
with pytest.raises(cv.Invalid, match="drawn as an outline"):
ACTION_REGISTRY["lvgl.canvas.draw_polygon"].schema(
{"id": "cv", "points": [{"x": 0, "y": 0}], key: 1}
)
def test_polygon_accepts_border_keys() -> None:
config = ACTION_REGISTRY["lvgl.canvas.draw_polygon"].schema(
{
"id": "cv",
"points": [{"x": 0, "y": 0}],
"border_color": 0xFF0000,
"border_width": 2,
"border_opa": "50%",
}
)
assert config["border_width"] == 2
@@ -0,0 +1,49 @@
"""lvgl.indicator.update must apply opa to arc, needle and image indicators."""
from __future__ import annotations
from pathlib import Path
import pytest
from esphome.__main__ import generate_cpp_contents
from esphome.config import read_config
from esphome.core import CORE
@pytest.fixture(scope="module")
def main_cpp(request: pytest.FixtureRequest) -> str:
config_path = (
Path(request.fspath).parent / "config" / "indicator_update_opa_test.yaml"
)
original_path = CORE.config_path
try:
CORE.config_path = config_path
CORE.config = read_config({})
generate_cpp_contents(CORE.config)
return CORE.cpp_main_section
finally:
CORE.config_path = original_path
CORE.reset()
def test_arc_section_opa_is_applied(main_cpp: str) -> None:
assert (
"lv_style_set_arc_opa(meter_arc_test_arc_style, static_cast<uint8_t>(127.5f));"
in main_cpp
)
assert "lv_obj_invalidate(test_scale);" in main_cpp
def test_needle_opa_is_applied(main_cpp: str) -> None:
assert (
"lv_obj_set_style_opa(test_needle->obj, static_cast<uint8_t>(63.75f), LV_PART_MAIN);"
in main_cpp
)
def test_image_opa_is_applied(main_cpp: str) -> None:
assert (
"lv_obj_set_style_opa(test_image, static_cast<uint8_t>(191.25f), LV_PART_MAIN);"
in main_cpp
)
@@ -0,0 +1,38 @@
esphome:
name: enable-pins-address
esp32:
board: esp32-p4-evboard
framework:
type: esp-idf
psram:
speed: 200MHz
esp_ldo:
- channel: 3
voltage: 2.5V
i2c:
sda: GPIO7
scl: GPIO8
pi4ioe5v6408:
- id: pi4ioe1
address: 0x43
- id: pi4ioe2
address: 0x44
# Takes over the default enable pin on the hub at 0x44, given by address
switch:
- platform: gpio
id: takeover
pin:
pi4ioe5v6408:
address: 0x44
number: 1
display:
- platform: mipi_dsi
id: default_pins
model: TEST-ENABLE-BY-ADDRESS
@@ -0,0 +1,40 @@
"""Tests for applying the default enable pins of mipi_dsi models."""
from collections.abc import Callable
from pathlib import Path
import re
import pytest
from esphome.components.mipi import DriverChip
def test_default_enable_pins_by_address(
generate_main: Callable[[str | Path], str],
component_fixture_path: Callable[[str], Path],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Default enable pins naming a hub by address resolve to that hub, and only
the pin used elsewhere is left out, not the same pin number on the other hub."""
from esphome.components.mipi_dsi.display import MODELS
models = set(DriverChip.models)
model = MODELS["M5STACK-TAB5"].extend(
"TEST-ENABLE-BY-ADDRESS",
enable_pin=[
{"pi4ioe5v6408": {"address": 0x43}, "number": 1},
{"pi4ioe5v6408": {"address": 0x44}, "number": 1},
],
)
for name in set(DriverChip.models) - models:
monkeypatch.delitem(DriverChip.models, name)
monkeypatch.setitem(MODELS, model.name, model)
main_cpp = generate_main(component_fixture_path("enable_pins_address.yaml"))
match = re.search(r"default_pins->set_enable_pins\(\{(.*)\}\);", main_cpp)
assert match is not None
pin_ids = match.group(1).split(", ")
assert len(pin_ids) == 1
assert f"{pin_ids[0]}->set_parent(pi4ioe1);" in main_cpp
assert f"{pin_ids[0]}->set_pin(1);" in main_cpp
@@ -8,7 +8,7 @@ import re
import pytest
from esphome import config_validation as cv
from esphome.components.esp32 import KEY_BOARD, VARIANT_ESP32P4
from esphome.components.esp32 import KEY_BOARD, VARIANT_ESP32P4, VARIANT_ESP32S3
# Importing the I/O expanders registers their pin schemas with
# pins.PIN_SCHEMA_REGISTRY so that models whose defaults reference expander-backed
@@ -215,3 +215,20 @@ def test_code_generation(
assert "p4_86->set_rotation(display::DISPLAY_ROTATION_0_DEGREES);" not in main_cpp
assert "custom_id->set_rotation(display::DISPLAY_ROTATION_180_DEGREES);" in main_cpp
# assert "backlight_id = new light::LightState(mipi_dsi_dsibacklight_id);" in main_cpp
def test_unsupported_variant_reported_before_requirements(
set_core_config: SetCoreConfigCallable,
) -> None:
"""On an unsupported chip the variant error is shown, not the missing components."""
from esphome.components.mipi_dsi.display import CONFIG_SCHEMA
from esphome.core import CORE
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3},
)
CORE.raw_config = {}
with pytest.raises(cv.Invalid, match=r"This feature is only available on ESP32P4"):
CONFIG_SCHEMA({"model": "SEEED-RETERMINAL-D1001"})
@@ -0,0 +1,32 @@
esphome:
name: enable-pins-test
esp32:
board: esp32-s3-devkitc-1
framework:
type: esp-idf
psram:
mode: octal
speed: 80MHz
i2c:
sda: 8
scl: 9
ch422g:
- id: ch422g_hub
# Takes over the model's default backlight enable pin
switch:
- platform: gpio
id: backlight
pin:
ch422g: ch422g_hub
number: 2
display:
- platform: mipi_rgb
id: default_pins
model: ESP32-S3-TOUCH-LCD-7-800X480
update_interval: never
@@ -0,0 +1,24 @@
esphome:
name: enable-pins-test
esp32:
board: esp32-s3-devkitc-1
framework:
type: esp-idf
psram:
mode: octal
speed: 80MHz
i2c:
sda: 8
scl: 9
ch422g:
- id: ch422g_hub
display:
- platform: mipi_rgb
id: default_pins
model: ESP32-S3-TOUCH-LCD-7-800X480
update_interval: never
@@ -0,0 +1,36 @@
"""Tests for applying the default enable pins of mipi_rgb models."""
from collections.abc import Callable
from pathlib import Path
import re
def _enable_pin_numbers(main_cpp: str) -> list[int]:
"""Get the expander pin numbers passed to set_enable_pins() in the generated code."""
match = re.search(r"default_pins->set_enable_pins\(\{(.*)\}\);", main_cpp)
assert match is not None
numbers = []
for pin_id in match.group(1).split(", "):
pin = re.search(rf"{pin_id}->set_pin\((\d+)\);", main_cpp)
assert pin is not None
numbers.append(int(pin.group(1)))
return numbers
def test_default_enable_pins(
generate_main: Callable[[str | Path], str],
component_fixture_path: Callable[[str], Path],
) -> None:
"""All of the model's enable pins are applied when none are used elsewhere."""
main_cpp = generate_main(component_fixture_path("enable_pins_default.yaml"))
assert _enable_pin_numbers(main_cpp) == [2, 6]
def test_default_enable_pin_used_elsewhere(
generate_main: Callable[[str | Path], str],
component_fixture_path: Callable[[str], Path],
) -> None:
"""A default enable pin used by a switch is left out instead of failing validation."""
main_cpp = generate_main(component_fixture_path("enable_pins.yaml"))
assert _enable_pin_numbers(main_cpp) == [6]
assert "backlight->set_pin(" in main_cpp
+81 -1
View File
@@ -8,7 +8,12 @@ from typing import Any
import pytest
from esphome import config_validation as cv
from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32S3
from esphome.components.esp32 import (
KEY_BOARD,
KEY_VARIANT,
VARIANT_ESP32C3,
VARIANT_ESP32S3,
)
from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA
# Importing pca9554 registers its pin schema with pins.PIN_SCHEMA_REGISTRY so that
@@ -87,3 +92,78 @@ def test_model_requires_psram_and_expander(
"spi_id": "spi_bus",
}
)
def test_model_requires_expander_when_pins_overridden(
set_core_config: SetCoreConfigCallable,
set_component_config: Any,
) -> None:
"""A model with default pins on an expander requires it even when the user config
overrides all of those pins, and without the expander being listed in ``requires``."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3},
)
set_component_config("psram", True)
CORE.raw_config = {"psram": True}
with pytest.raises(
cv.Invalid,
match=r"ESP32-S3-TOUCH-LCD-7-800X480 requires component 'ch422g' to be configured",
):
_validated(
{
"model": "ESP32-S3-TOUCH-LCD-7-800X480",
"reset_pin": 10,
"enable_pin": [11, 12],
}
)
def test_model_requires_expander_with_default_pins(
set_core_config: SetCoreConfigCallable,
set_component_config: Any,
) -> None:
"""The missing expander is reported before the model's default pins on it are validated."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3},
)
set_component_config("psram", True)
CORE.raw_config = {"psram": True}
with pytest.raises(
cv.Invalid,
match=r"ESP32-S3-TOUCH-LCD-7-800X480 requires component 'ch422g' to be configured",
):
_validated({"model": "ESP32-S3-TOUCH-LCD-7-800X480"})
def test_unsupported_variant_reported_before_requirements(
set_core_config: SetCoreConfigCallable,
) -> None:
"""On an unsupported chip the variant error is shown, not the missing components."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32-c3-devkitm-1", KEY_VARIANT: VARIANT_ESP32C3},
)
CORE.raw_config = {}
with pytest.raises(
cv.Invalid,
match=r"This feature is only available on ESP32S3, ESP32P4, ESP32S31",
):
_validated({"model": "ESP32-S3-TOUCH-LCD-7-800X480"})
def test_expander_that_fails_to_load_is_still_required(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An expander component that exists but fails to load is still counted as required."""
from esphome import loader, pins
from esphome.components import mipi
monkeypatch.setattr(pins, "PIN_SCHEMA_REGISTRY", pins.PinRegistry())
monkeypatch.setattr(loader, "get_component", lambda key: None)
defaults = {"enable_pin": {"xl9535": "hub", "number": 1, "mode": "output"}}
assert mipi._pin_expanders(defaults) == {"xl9535"}
@@ -0,0 +1,31 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ap:
i2s_audio:
- id: i2s_bus
i2s_lrclk_pin: GPIO4
i2s_bclk_pin: GPIO5
microphone:
- platform: i2s_audio
id: mic
i2s_din_pin: GPIO39
adc_type: external
sample_rate: 48000
sendspin:
source:
microphone: mic
codec: opus
buffer_memory: internal
on_streaming_started:
- sendspin.source.set_signal:
present: true
@@ -0,0 +1,26 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ap:
i2s_audio:
- id: i2s_bus
i2s_lrclk_pin: GPIO4
i2s_bclk_pin: GPIO5
microphone:
- platform: i2s_audio
id: mic
i2s_din_pin: GPIO39
adc_type: external
sample_rate: 16000
sendspin:
source:
microphone: mic
@@ -0,0 +1,185 @@
"""Validation and code generation tests for the sendspin source role.
These cover the rejection branches of the source block and the generated role
config, which a compile test cannot check: a `test*.yaml` can only assert that a
configuration is accepted.
"""
from collections.abc import Callable
from pathlib import Path
from typing import Any
import pytest
from esphome import config_validation as cv
from esphome.components.audio import CONF_MAX_SAMPLE_RATE, CONF_MIN_SAMPLE_RATE
from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS
from esphome.components.sendspin import (
CONFIG_SCHEMA,
FINAL_VALIDATE_SCHEMA,
_get_data,
_request_source,
)
from esphome.config import Config
from esphome.const import CONF_ID, CONF_SOURCE, PlatformFramework
from esphome.core import CORE, ID
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
HUB_ID = "sendspin_hub"
MIC_ID = "mic"
def _full_config(min_sample_rate: int = 16000, max_sample_rate: int = 16000) -> Config:
"""A full config carrying one microphone, as the ID pass leaves it."""
mic: ConfigType = {
CONF_ID: ID(MIC_ID, is_declaration=True, type="microphone"),
CONF_MIN_SAMPLE_RATE: min_sample_rate,
CONF_MAX_SAMPLE_RATE: max_sample_rate,
"min_channels": 1,
"max_channels": 2,
"min_bits_per_sample": 16,
"max_bits_per_sample": 32,
}
full = Config()
full["microphone"] = [mic]
full.declare_ids.append((mic[CONF_ID], ["microphone", 0, CONF_ID]))
return full
def _hub_config(**source: Any) -> ConfigType:
"""Validate a hub config with a source block, allowing field overrides."""
source_config: ConfigType = {
"microphone": {"microphone": ID(MIC_ID, is_declaration=False)}
}
source_config.update(source)
return CONFIG_SCHEMA({CONF_ID: HUB_ID, CONF_SOURCE: source_config})
@pytest.fixture
def microphone(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(PlatformFramework.ESP32_IDF, full_config=_full_config())
@pytest.mark.usefixtures("microphone")
def test_defaults() -> None:
config = _hub_config()
source = config[CONF_SOURCE]
assert source["codec"] == "pcm"
assert source["chunk_duration"].total_milliseconds == 20
assert source["buffer_duration"].total_milliseconds == 500
@pytest.mark.usefixtures("microphone")
def test_final_validate_records_sample_rate() -> None:
"""The stream takes the microphone's own rate, since nothing can resample it."""
FINAL_VALIDATE_SCHEMA(_hub_config())
assert _get_data().source_sample_rate == 16000
@pytest.mark.usefixtures("microphone")
def test_unsupported_bits_per_sample_rejected() -> None:
with pytest.raises(cv.Invalid, match="bits_per_sample must be one of"):
_hub_config(microphone={"microphone": MIC_ID, "bits_per_sample": 20})
@pytest.mark.usefixtures("microphone")
def test_opus_requires_16_bits() -> None:
with pytest.raises(cv.Invalid, match="requires a bits_per_sample of 16"):
_hub_config(
codec="opus", microphone={"microphone": MIC_ID, "bits_per_sample": 24}
)
@pytest.mark.usefixtures("microphone")
def test_opus_chunk_duration_rejected() -> None:
"""Opus encodes each chunk as one frame, so only its frame sizes work."""
with pytest.raises(cv.Invalid, match="requires chunk_duration to be one of"):
_hub_config(codec="opus", chunk_duration="15ms")
@pytest.mark.usefixtures("microphone")
def test_short_buffer_rejected() -> None:
"""A buffer too short for one microphone read would refuse every write."""
with pytest.raises(cv.Invalid, match="must be at least"):
_hub_config(buffer_duration="50ms")
def test_variable_sample_rate_rejected(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
full_config=_full_config(min_sample_rate=16000, max_sample_rate=48000),
)
with pytest.raises(cv.Invalid, match="must have a fixed sample rate"):
FINAL_VALIDATE_SCHEMA(_hub_config())
def test_opus_sample_rate_rejected(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
full_config=_full_config(min_sample_rate=44100, max_sample_rate=44100),
)
with pytest.raises(
cv.Invalid, match="requires the microphone sample_rate to be one of"
):
FINAL_VALIDATE_SCHEMA(_hub_config(codec="opus"))
def test_source_automation_without_source_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""The source actions and condition have nothing to act on without a source block."""
set_core_config(PlatformFramework.ESP32_IDF)
_request_source({})
with pytest.raises(cv.Invalid, match="require a 'source' block"):
FINAL_VALIDATE_SCHEMA(CONFIG_SCHEMA({CONF_ID: HUB_ID}))
def _sdkconfig_option(name: str) -> Any:
return CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS].get(name)
def test_opus_source_config_generated(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The set_signal action turns on line sense, and an Opus source keeps Opus in the library."""
main_cpp = generate_main(component_config_path("source_opus.yaml"))
assert "set_source_config(" in main_cpp
assert ".sample_rate = 48000" in main_cpp
assert ".capture_buffer_ms = 500" in main_cpp
assert ".buffer_location = sendspin::MemoryLocation::PREFER_INTERNAL" in main_cpp
assert ".codec = sendspin::SendspinCodecFormat::OPUS" in main_cpp
assert ".line_sense = true" in main_cpp
assert _sdkconfig_option("CONFIG_SENDSPIN_ENABLE_SOURCE") is None
assert _sdkconfig_option("CONFIG_SENDSPIN_ENABLE_OPUS") is None
def test_pcm_source_config_generated(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Without the set_signal action or buffer_memory, neither is set, and unused Opus is left out."""
main_cpp = generate_main(component_config_path("source_pcm.yaml"))
assert ".sample_rate = 16000" in main_cpp
assert ".codec = sendspin::SendspinCodecFormat::PCM" in main_cpp
assert ".line_sense = false" in main_cpp
assert ".buffer_location" not in main_cpp
assert _sdkconfig_option("CONFIG_SENDSPIN_ENABLE_OPUS") is False
def test_no_source_disables_the_role(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(component_config_path("device_info_default.yaml"))
assert "set_source_config(" not in main_cpp
assert _sdkconfig_option("CONFIG_SENDSPIN_ENABLE_SOURCE") is False
+31
View File
@@ -36,6 +36,30 @@ esphome:
service: notify.html5
data:
message: Button was pressed
- homeassistant.service:
service: !lambda 'return std::string("notify.html5");'
data:
title: Title
data_template:
message: The value is {{ my_value }}
variables:
my_value: !lambda 'return std::string("42");'
capture_response: true
response_template: !lambda 'return std::string("{{ response.value }}");'
on_success:
- logger.log: "service succeeded"
on_error:
- logger.log:
format: "service failed: %s"
args: [error.c_str()]
- homeassistant.action:
action: weather.get_forecasts
data:
entity_id: weather.forecast_home
capture_response: true
response_template: !lambda 'return std::string("{{ response }}");'
on_success:
- logger.log: "forecast received"
- homeassistant.tag_scanned: pulse
- homeassistant.action:
action: weather.get_forecasts
@@ -307,6 +331,13 @@ api:
success: false
error_message: !lambda 'return error_msg;'
- action: test_respond_lambda_success
variables:
ok: bool
then:
- api.respond:
success: !lambda 'return ok;'
# ==========================================================================
# supports_response: optional (auto-detected - api.respond with data)
# Has call_id and return_response - client decides if it wants response
@@ -0,0 +1,49 @@
#include <gtest/gtest.h>
#include "esphome/components/api/api_deferred_batch.h"
namespace esphome::api::testing {
class DeferredBatchTest : public ::testing::Test {
protected:
static constexpr uint16_t STATE = 1;
static constexpr uint16_t PING = 2;
EntityBase entities_[3];
DeferredBatch batch_;
};
TEST_F(DeferredBatchTest, FrontItemKeepsTheQueuedOrder) {
for (auto &entity : this->entities_)
this->batch_.add_item(&entity, STATE, 8);
this->batch_.add_item_front(nullptr, PING, 4);
// A swap would have moved the oldest item to the back
std::vector<EntityBase *> order;
order.reserve(this->batch_.size());
for (size_t i = 0; i < this->batch_.size(); i++)
order.push_back(this->batch_[i].entity);
EXPECT_EQ(order, (std::vector<EntityBase *>{nullptr, &this->entities_[0], &this->entities_[1], &this->entities_[2]}));
EXPECT_EQ(this->batch_[0].message_type, PING);
}
TEST_F(DeferredBatchTest, FrontItemIntoAnEmptyBatch) {
this->batch_.add_item_front(nullptr, PING, 4);
ASSERT_EQ(this->batch_.size(), 1u);
EXPECT_EQ(this->batch_[0].message_type, PING);
}
TEST_F(DeferredBatchTest, StatesAreQueuedOnce) {
this->batch_.add_item(&this->entities_[0], STATE, 8);
this->batch_.add_item(&this->entities_[0], STATE, 8);
EXPECT_EQ(this->batch_.size(), 1u);
}
TEST_F(DeferredBatchTest, ContainsMatchesEntityAndMessageType) {
this->batch_.add_item(&this->entities_[0], STATE, 8);
EXPECT_TRUE(this->batch_.contains(&this->entities_[0], STATE));
EXPECT_FALSE(this->batch_.contains(&this->entities_[0], PING));
EXPECT_FALSE(this->batch_.contains(&this->entities_[1], STATE));
}
} // namespace esphome::api::testing
+8
View File
@@ -21,6 +21,14 @@ esphome:
id: waveradar
frequency: 5800MHz
power_consumption: 91uA
- at581x.settings:
id: waveradar
hw_frontend_reset: !lambda return true;
sensing_distance: !lambda return 300;
protect_time: !lambda return 1500;
trigger_base: !lambda return 600;
trigger_keep: !lambda return 12000;
stage_gain: !lambda return 4;
- at581x.reset:
id: waveradar
@@ -7,6 +7,8 @@ esphome:
- bk72xx_ble_tracker.start_scan
- bk72xx_ble_tracker.start_scan:
continuous: true
- bk72xx_ble_tracker.start_scan:
continuous: !lambda return false;
- bk72xx_ble_tracker.stop_scan
- bk72xx_ble_tracker.stop_scan: ble_tracker
Loaded 100 of 189 files, more files were not shown because too many files have changed in this diff. Show more