Compare commits

...

23 Commits

Author SHA1 Message Date
J. Nick Koston
8abb472c3a [wifi] Also require netif_is_link_up for dhcp_renew guard
DHCP_STATE_BOUND alone is insufficient — during reconnection, DHCP
can remain BOUND from a previous connection while the link is down
(wifi_disconnect_() doesn't stop DHCP). Both conditions are needed:
DHCP must be BOUND and the interface must have link.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 20:04:51 -07:00
J. Nick Koston
c53d50b6cf [wifi] Use DHCP_STATE_BOUND check instead of netif_is_link_up
netif_is_link_up() is insufficient — if wifi_station_connect()
completes quickly (e.g. fast_connect), the setup() call at line 710
could reach dhcp_renew() with link up but DHCP still in SELECTING
or REQUESTING state, causing the same state corruption.

Check dhcp->state == DHCP_STATE_BOUND directly to ensure dhcp_renew()
is only called when there is an actual lease to renew.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 14:36:51 -06:00
J. Nick Koston
de76dfd117 [wifi] Fix ESP8266 DHCP state corruption from premature dhcp_renew()
wifi_apply_hostname_() calls dhcp_renew() on all interfaces with DHCP
data, including when WiFi is not yet connected. lwIP's dhcp_renew()
unconditionally sets the DHCP state to RENEWING (line 1159 in dhcp.c)
before attempting to send, and never rolls back the state on failure.

This corrupts the DHCP state machine: when WiFi later connects and
dhcp_network_changed() is called, it sees RENEWING state and calls
dhcp_reboot() instead of dhcp_discover(). dhcp_reboot() sends a
broadcast DHCP REQUEST for IP 0.0.0.0 (since no lease was ever
obtained), which can put some routers into a persistent bad state
that requires a router restart to clear.

This bug has existed since commit 072b2c445c (Dec 2019, "Add ESP8266
core v2.6.2") and affects every ESP8266 WiFi connection attempt. Most
routers handle the bogus DHCP REQUEST gracefully (NAK then fallback
to DISCOVER), but affected routers get stuck and refuse connections
from the device until restarted.

Fix: guard the dhcp_renew() call with netif_is_link_up() so it only
runs when the interface actually has an active link. The hostname is
still set on the netif regardless, so it will be included in DHCP
packets when the connection is established normally.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 14:02:21 -06:00
Kevin Ahrendt
903971de12 [runtime_image, online_image] Create runtime_image component to decode images (#10212) 2026-02-13 11:25:43 -05:00
J. Nick Koston
b04e427f01 [usb_host] Extract cold path from loop(), replace std::string with buffer API (#13957)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-13 06:39:00 -06:00
J. Nick Koston
e0c03b2dfa [api] Fix ESP8266 noise API handshake deadlock and prompt socket cleanup (#13972) 2026-02-12 18:20:58 -06:00
J. Nick Koston
7dff631dcb [core] Flatten single-callsite vector realloc functions (#13970) 2026-02-12 18:20:39 -06:00
J. Nick Koston
36aba385af [web_server] Flatten deq_push_back_with_dedup_ to inline vector realloc (#13968) 2026-02-12 18:20:21 -06:00
Jonathan Swoboda
136d17366f [docker] Suppress git detached HEAD advice (#13962)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 16:12:17 -05:00
Jonathan Swoboda
db7870ef5f [alarm_control_panel] Fix flaky integration test race condition (#13964)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 16:04:39 -05:00
dependabot[bot]
bbc88d92ea Bump docker/build-push-action from 6.19.1 to 6.19.2 in /.github/actions/build-image (#13965)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-12 14:31:43 -06:00
Jesse Hills
1604b5d6e4 Merge branch 'beta' into dev 2026-02-13 07:11:49 +13:00
J. Nick Koston
7fd535179e [helpers] Add heap warnings to format_hex_pretty, deprecate ethernet/web_server std::string APIs (#13959) 2026-02-12 17:47:44 +00:00
Lukáš Maňas
e3a457e402 [pulse_meter] Fix early edge detection (#12360)
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>
2026-02-12 17:20:54 +00:00
J. Nick Koston
0dcff82bb4 [wifi] Deprecate wifi_ssid() in favor of wifi_ssid_to() (#13958) 2026-02-12 17:14:36 +00:00
J. Nick Koston
cde8b66719 [web_server] Switch from getParam to arg API to eliminate heap allocations (#13942) 2026-02-12 11:04:41 -06:00
J. Nick Koston
0e1433329d [api] Extract cold code from APIServer::loop() hot path (#13902) 2026-02-12 11:04:23 -06:00
J. Nick Koston
60fef5e656 [analyze_memory] Fix mDNS packet buffer miscategorized as wifi_config (#13949)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 10:26:54 -06:00
J. Nick Koston
725e774fe7 [web_server] Guard icon JSON field with USE_ENTITY_ICON (#13948) 2026-02-12 10:26:36 -06:00
J. Nick Koston
9aa98ed6c6 [uart] Remove redundant mutex, fix flush race, conditional event queue (#13955) 2026-02-12 10:26:10 -06:00
Guillermo Ruffino
7b251dcc31 [schema-gen] fix Windows: ensure UTF-8 encoding when reading component files (#13952) 2026-02-12 11:23:59 -05:00
schrob
8a08c688f6 [mipi_spi] Add Waveshare 1.83 v2 panel (#13680) 2026-02-12 23:25:51 +11:00
Jesse Hills
d6461251f9 Bump version to 2026.3.0-dev 2026-02-12 23:04:19 +13:00
50 changed files with 1639 additions and 984 deletions

View File

@@ -47,7 +47,7 @@ runs:
- name: Build and push to ghcr by digest
id: build-ghcr
uses: docker/build-push-action@601a80b39c9405e50806ae38af30926f9d957c47 # v6.19.1
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
env:
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
@@ -73,7 +73,7 @@ runs:
- name: Build and push to dockerhub by digest
id: build-dockerhub
uses: docker/build-push-action@601a80b39c9405e50806ae38af30926f9d957c47 # v6.19.1
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
env:
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false

View File

@@ -411,6 +411,7 @@ esphome/components/rp2040_pwm/* @jesserockz
esphome/components/rpi_dpi_rgb/* @clydebarrow
esphome/components/rtl87xx/* @kuba2k2
esphome/components/rtttl/* @glmnet
esphome/components/runtime_image/* @clydebarrow @guillempages @kahrendt
esphome/components/runtime_stats/* @bdraco
esphome/components/rx8130/* @beormund
esphome/components/safe_mode/* @jsuanet @kbx81 @paulmonigatti

View File

@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version
# control system is used.
PROJECT_NUMBER = 2026.2.0b1
PROJECT_NUMBER = 2026.3.0-dev
# Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a

View File

@@ -9,7 +9,8 @@ FROM ghcr.io/esphome/docker-base:${BUILD_OS}-ha-addon-${BUILD_BASE_VERSION} AS b
ARG BUILD_TYPE
FROM base-source-${BUILD_TYPE} AS base
RUN git config --system --add safe.directory "*"
RUN git config --system --add safe.directory "*" \
&& git config --system advice.detachedHead false
# Install build tools for Python packages that require compilation
# (e.g., ruamel.yaml.clibz used by ESP-IDF's idf-component-manager)

View File

@@ -256,7 +256,7 @@ SYMBOL_PATTERNS = {
"ipv6_stack": ["nd6_", "ip6_", "mld6_", "icmp6_", "icmp6_input"],
# Order matters! More specific categories must come before general ones.
# mdns must come before bluetooth to avoid "_mdns_disable_pcb" matching "ble_" pattern
"mdns_lib": ["mdns"],
"mdns_lib": ["mdns", "packet$"],
# memory_mgmt must come before wifi_stack to catch mmu_hal_* symbols
"memory_mgmt": [
"mem_",
@@ -794,7 +794,6 @@ SYMBOL_PATTERNS = {
"s_dp",
"s_ni",
"s_reg_dump",
"packet$",
"d_mult_table",
"K",
"fcstab",

View File

@@ -1864,6 +1864,8 @@ void APIConnection::on_fatal_error() {
this->flags_.remove = true;
}
void __attribute__((flatten)) APIConnection::DeferredBatch::push_item(const BatchItem &item) { items.push_back(item); }
void APIConnection::DeferredBatch::add_item(EntityBase *entity, uint8_t message_type, uint8_t estimated_size,
uint8_t aux_data_index) {
// Check if we already have a message of this type for this entity
@@ -1880,7 +1882,7 @@ void APIConnection::DeferredBatch::add_item(EntityBase *entity, uint8_t message_
}
}
// No existing item found (or event), add new one
items.push_back({entity, message_type, estimated_size, aux_data_index});
this->push_item({entity, message_type, estimated_size, aux_data_index});
}
void APIConnection::DeferredBatch::add_item_front(EntityBase *entity, uint8_t message_type, uint8_t estimated_size) {
@@ -1888,7 +1890,7 @@ void APIConnection::DeferredBatch::add_item_front(EntityBase *entity, uint8_t me
// This avoids expensive vector::insert which shifts all elements
// Note: We only ever have one high-priority message at a time (ping OR disconnect)
// If we're disconnecting, pings are blocked, so this simple swap is sufficient
items.push_back({entity, message_type, estimated_size, AUX_DATA_UNUSED});
this->push_item({entity, message_type, estimated_size, AUX_DATA_UNUSED});
if (items.size() > 1) {
// Swap the new high-priority item to the front
std::swap(items.front(), items.back());

View File

@@ -541,6 +541,8 @@ class APIConnection final : public APIServerConnectionBase {
uint8_t aux_data_index = AUX_DATA_UNUSED);
// Add item to the front of the batch (for high priority messages like ping)
void add_item_front(EntityBase *entity, uint8_t message_type, uint8_t estimated_size);
// Single push_back site to avoid duplicate _M_realloc_insert instantiation
void push_item(const BatchItem &item);
// Clear all items
void clear() {

View File

@@ -138,10 +138,12 @@ APIError APINoiseFrameHelper::handle_noise_error_(int err, const LogString *func
/// Run through handshake messages (if in that phase)
APIError APINoiseFrameHelper::loop() {
// During handshake phase, process as many actions as possible until we can't progress
// socket_->ready() stays true until next main loop, but state_action() will return
// WOULD_BLOCK when no more data is available to read
while (state_ != State::DATA && this->socket_->ready()) {
// Cache ready() outside the loop. On ESP8266 LWIP raw TCP, ready() returns false once
// the rx buffer is consumed. Re-checking each iteration would block handshake writes
// that must follow reads, deadlocking the handshake. state_action() will return
// WOULD_BLOCK when no more data is available to read.
bool socket_ready = this->socket_->ready();
while (state_ != State::DATA && socket_ready) {
APIError err = state_action_();
if (err == APIError::WOULD_BLOCK) {
break;

View File

@@ -117,37 +117,7 @@ void APIServer::setup() {
void APIServer::loop() {
// Accept new clients only if the socket exists and has incoming connections
if (this->socket_ && this->socket_->ready()) {
while (true) {
struct sockaddr_storage source_addr;
socklen_t addr_len = sizeof(source_addr);
auto sock = this->socket_->accept_loop_monitored((struct sockaddr *) &source_addr, &addr_len);
if (!sock)
break;
char peername[socket::SOCKADDR_STR_LEN];
sock->getpeername_to(peername);
// Check if we're at the connection limit
if (this->clients_.size() >= this->max_connections_) {
ESP_LOGW(TAG, "Max connections (%d), rejecting %s", this->max_connections_, peername);
// Immediately close - socket destructor will handle cleanup
sock.reset();
continue;
}
ESP_LOGD(TAG, "Accept %s", peername);
auto *conn = new APIConnection(std::move(sock), this);
this->clients_.emplace_back(conn);
conn->start();
// First client connected - clear warning and update timestamp
if (this->clients_.size() == 1 && this->reboot_timeout_ != 0) {
this->status_clear_warning();
this->last_connected_ = App.get_loop_component_start_time();
}
}
this->accept_new_connections_();
}
if (this->clients_.empty()) {
@@ -178,46 +148,88 @@ void APIServer::loop() {
while (client_index < this->clients_.size()) {
auto &client = this->clients_[client_index];
// Common case: process active client
if (!client->flags_.remove) {
// Common case: process active client
client->loop();
}
// Handle disconnection promptly - close socket to free LWIP PCB
// resources and prevent retransmit crashes on ESP8266.
if (client->flags_.remove) {
// Rare case: handle disconnection (don't increment - swapped element needs processing)
this->remove_client_(client_index);
} else {
client_index++;
}
}
}
void APIServer::remove_client_(size_t client_index) {
auto &client = this->clients_[client_index];
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES
this->unregister_active_action_calls_for_connection(client.get());
#endif
ESP_LOGV(TAG, "Remove connection %s", client->get_name());
#ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER
// Save client info before closing socket and removal for the trigger
char peername_buf[socket::SOCKADDR_STR_LEN];
std::string client_name(client->get_name());
std::string client_peername(client->get_peername_to(peername_buf));
#endif
// Close socket now (was deferred from on_fatal_error to allow getpeername)
client->helper_->close();
// Swap with the last element and pop (avoids expensive vector shifts)
if (client_index < this->clients_.size() - 1) {
std::swap(this->clients_[client_index], this->clients_.back());
}
this->clients_.pop_back();
// Last client disconnected - set warning and start tracking for reboot timeout
if (this->clients_.empty() && this->reboot_timeout_ != 0) {
this->status_set_warning();
this->last_connected_ = App.get_loop_component_start_time();
}
#ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER
// Fire trigger after client is removed so api.connected reflects the true state
this->client_disconnected_trigger_.trigger(client_name, client_peername);
#endif
}
void __attribute__((flatten)) APIServer::accept_new_connections_() {
while (true) {
struct sockaddr_storage source_addr;
socklen_t addr_len = sizeof(source_addr);
auto sock = this->socket_->accept_loop_monitored((struct sockaddr *) &source_addr, &addr_len);
if (!sock)
break;
char peername[socket::SOCKADDR_STR_LEN];
sock->getpeername_to(peername);
// Check if we're at the connection limit
if (this->clients_.size() >= this->max_connections_) {
ESP_LOGW(TAG, "Max connections (%d), rejecting %s", this->max_connections_, peername);
// Immediately close - socket destructor will handle cleanup
sock.reset();
continue;
}
// Rare case: handle disconnection
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES
this->unregister_active_action_calls_for_connection(client.get());
#endif
ESP_LOGV(TAG, "Remove connection %s", client->get_name());
ESP_LOGD(TAG, "Accept %s", peername);
#ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER
// Save client info before closing socket and removal for the trigger
char peername_buf[socket::SOCKADDR_STR_LEN];
std::string client_name(client->get_name());
std::string client_peername(client->get_peername_to(peername_buf));
#endif
auto *conn = new APIConnection(std::move(sock), this);
this->clients_.emplace_back(conn);
conn->start();
// Close socket now (was deferred from on_fatal_error to allow getpeername)
client->helper_->close();
// Swap with the last element and pop (avoids expensive vector shifts)
if (client_index < this->clients_.size() - 1) {
std::swap(this->clients_[client_index], this->clients_.back());
}
this->clients_.pop_back();
// Last client disconnected - set warning and start tracking for reboot timeout
if (this->clients_.empty() && this->reboot_timeout_ != 0) {
this->status_set_warning();
// First client connected - clear warning and update timestamp
if (this->clients_.size() == 1 && this->reboot_timeout_ != 0) {
this->status_clear_warning();
this->last_connected_ = App.get_loop_component_start_time();
}
#ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER
// Fire trigger after client is removed so api.connected reflects the true state
this->client_disconnected_trigger_.trigger(client_name, client_peername);
#endif
// Don't increment client_index since we need to process the swapped element
}
}

View File

@@ -234,6 +234,11 @@ class APIServer : public Component,
#endif
protected:
// Accept incoming socket connections. Only called when socket has pending connections.
void __attribute__((noinline)) accept_new_connections_();
// Remove a disconnected client by index. Swaps with last element and pops.
void __attribute__((noinline)) remove_client_(size_t client_index);
#ifdef USE_API_NOISE
bool update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg,
const psk_t &active_psk, bool make_active);

View File

@@ -47,8 +47,8 @@ void CaptivePortal::handle_config(AsyncWebServerRequest *request) {
request->send(stream);
}
void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) {
std::string ssid = request->arg("ssid").c_str(); // NOLINT(readability-redundant-string-cstr)
std::string psk = request->arg("psk").c_str(); // NOLINT(readability-redundant-string-cstr)
const auto &ssid = request->arg("ssid");
const auto &psk = request->arg("psk");
ESP_LOGI(TAG,
"Requested WiFi Settings Change:\n"
" SSID='%s'\n"
@@ -56,10 +56,10 @@ void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) {
ssid.c_str(), psk.c_str());
#ifdef USE_ESP8266
// ESP8266 is single-threaded, call directly
wifi::global_wifi_component->save_wifi_sta(ssid, psk);
wifi::global_wifi_component->save_wifi_sta(ssid.c_str(), psk.c_str());
#else
// Defer save to main loop thread to avoid NVS operations from HTTP thread
this->defer([ssid, psk]() { wifi::global_wifi_component->save_wifi_sta(ssid, psk); });
this->defer([ssid, psk]() { wifi::global_wifi_component->save_wifi_sta(ssid.c_str(), psk.c_str()); });
#endif
request->redirect(ESPHOME_F("/?save"));
}

View File

@@ -110,6 +110,8 @@ class EthernetComponent : public Component {
const char *get_use_address() const;
void set_use_address(const char *use_address);
void get_eth_mac_address_raw(uint8_t *mac);
// Remove before 2026.9.0
ESPDEPRECATED("Use get_eth_mac_address_pretty_into_buffer() instead. Removed in 2026.9.0", "2026.3.0")
std::string get_eth_mac_address_pretty();
const char *get_eth_mac_address_pretty_into_buffer(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf);
eth_duplex_t get_duplex_mode();

View File

@@ -1,4 +1,17 @@
from esphome.components.mipi import DriverChip
from esphome.components.mipi import (
ETMOD,
FRMCTR2,
GMCTRN1,
GMCTRP1,
IFCTR,
MODE_RGB,
PWCTR1,
PWCTR3,
PWCTR4,
PWCTR5,
PWSET,
DriverChip,
)
import esphome.config_validation as cv
from .amoled import CO5300
@@ -129,6 +142,16 @@ DriverChip(
),
),
)
ST7789P = DriverChip(
"ST7789P",
# Max supported dimensions
width=240,
height=320,
# SPI: RGB layout
color_order=MODE_RGB,
invert_colors=True,
draw_rounding=1,
)
ILI9488_A.extend(
"PICO-RESTOUCH-LCD-3.5",
@@ -162,3 +185,61 @@ AXS15231.extend(
cs_pin=9,
reset_pin=21,
)
# Waveshare 1.83-v2
#
# Do not use on 1.83-v1: Vendor warning on different chip!
ST7789P.extend(
"WAVESHARE-1.83-V2",
# Panel size smaller than ST7789 max allowed
width=240,
height=284,
# Vendor specific init derived from vendor sample code
# "LCD_1.83_Code_Rev2/ESP32/LCD_1in83/LCD_Driver.cpp"
# Compatible MIT license, see esphome/LICENSE file.
initsequence=(
(FRMCTR2, 0x0C, 0x0C, 0x00, 0x33, 0x33),
(ETMOD, 0x35),
(0xBB, 0x19),
(PWCTR1, 0x2C),
(PWCTR3, 0x01),
(PWCTR4, 0x12),
(PWCTR5, 0x20),
(IFCTR, 0x0F),
(PWSET, 0xA4, 0xA1),
(
GMCTRP1,
0xD0,
0x04,
0x0D,
0x11,
0x13,
0x2B,
0x3F,
0x54,
0x4C,
0x18,
0x0D,
0x0B,
0x1F,
0x23,
),
(
GMCTRN1,
0xD0,
0x04,
0x0C,
0x11,
0x13,
0x2C,
0x3F,
0x44,
0x51,
0x2F,
0x1F,
0x1F,
0x20,
0x23,
),
),
)

View File

@@ -2,97 +2,34 @@ import logging
from esphome import automation
import esphome.codegen as cg
from esphome.components.const import CONF_BYTE_ORDER, CONF_REQUEST_HEADERS
from esphome.components import runtime_image
from esphome.components.const import CONF_REQUEST_HEADERS
from esphome.components.http_request import CONF_HTTP_REQUEST_ID, HttpRequestComponent
from esphome.components.image import (
CONF_INVERT_ALPHA,
CONF_TRANSPARENCY,
IMAGE_SCHEMA,
Image_,
get_image_type_enum,
get_transparency_enum,
validate_settings,
)
import esphome.config_validation as cv
from esphome.const import (
CONF_BUFFER_SIZE,
CONF_DITHER,
CONF_FILE,
CONF_FORMAT,
CONF_ID,
CONF_ON_ERROR,
CONF_RESIZE,
CONF_TRIGGER_ID,
CONF_TYPE,
CONF_URL,
)
from esphome.core import Lambda
AUTO_LOAD = ["image"]
AUTO_LOAD = ["image", "runtime_image"]
DEPENDENCIES = ["display", "http_request"]
CODEOWNERS = ["@guillempages", "@clydebarrow"]
MULTI_CONF = True
CONF_ON_DOWNLOAD_FINISHED = "on_download_finished"
CONF_PLACEHOLDER = "placeholder"
CONF_UPDATE = "update"
_LOGGER = logging.getLogger(__name__)
online_image_ns = cg.esphome_ns.namespace("online_image")
ImageFormat = online_image_ns.enum("ImageFormat")
class Format:
def __init__(self, image_type):
self.image_type = image_type
@property
def enum(self):
return getattr(ImageFormat, self.image_type)
def actions(self):
pass
class BMPFormat(Format):
def __init__(self):
super().__init__("BMP")
def actions(self):
cg.add_define("USE_ONLINE_IMAGE_BMP_SUPPORT")
class JPEGFormat(Format):
def __init__(self):
super().__init__("JPEG")
def actions(self):
cg.add_define("USE_ONLINE_IMAGE_JPEG_SUPPORT")
cg.add_library("JPEGDEC", None, "https://github.com/bitbank2/JPEGDEC#ca1e0f2")
class PNGFormat(Format):
def __init__(self):
super().__init__("PNG")
def actions(self):
cg.add_define("USE_ONLINE_IMAGE_PNG_SUPPORT")
cg.add_library("pngle", "1.1.0")
IMAGE_FORMATS = {
x.image_type: x
for x in (
BMPFormat(),
JPEGFormat(),
PNGFormat(),
)
}
IMAGE_FORMATS.update({"JPG": IMAGE_FORMATS["JPEG"]})
OnlineImage = online_image_ns.class_("OnlineImage", cg.PollingComponent, Image_)
OnlineImage = online_image_ns.class_(
"OnlineImage", cg.PollingComponent, runtime_image.RuntimeImage
)
# Actions
SetUrlAction = online_image_ns.class_(
@@ -111,29 +48,17 @@ DownloadErrorTrigger = online_image_ns.class_(
)
def remove_options(*options):
return {
cv.Optional(option): cv.invalid(
f"{option} is an invalid option for online_image"
)
for option in options
}
ONLINE_IMAGE_SCHEMA = (
IMAGE_SCHEMA.extend(remove_options(CONF_FILE, CONF_INVERT_ALPHA, CONF_DITHER))
runtime_image.runtime_image_schema(OnlineImage)
.extend(
{
cv.Required(CONF_ID): cv.declare_id(OnlineImage),
cv.GenerateID(CONF_HTTP_REQUEST_ID): cv.use_id(HttpRequestComponent),
# Online Image specific options
cv.GenerateID(CONF_HTTP_REQUEST_ID): cv.use_id(HttpRequestComponent),
cv.Required(CONF_URL): cv.url,
cv.Optional(CONF_BUFFER_SIZE, default=65536): cv.int_range(256, 65536),
cv.Optional(CONF_REQUEST_HEADERS): cv.All(
cv.Schema({cv.string: cv.templatable(cv.string)})
),
cv.Required(CONF_FORMAT): cv.one_of(*IMAGE_FORMATS, upper=True),
cv.Optional(CONF_PLACEHOLDER): cv.use_id(Image_),
cv.Optional(CONF_BUFFER_SIZE, default=65536): cv.int_range(256, 65536),
cv.Optional(CONF_ON_DOWNLOAD_FINISHED): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
@@ -162,7 +87,7 @@ CONFIG_SCHEMA = cv.Schema(
rp2040_arduino=cv.Version(0, 0, 0),
host=cv.Version(0, 0, 0),
),
validate_settings,
runtime_image.validate_runtime_image_settings,
)
)
@@ -199,23 +124,21 @@ async def online_image_action_to_code(config, action_id, template_arg, args):
async def to_code(config):
image_format = IMAGE_FORMATS[config[CONF_FORMAT]]
image_format.actions()
# Use the enhanced helper function to get all runtime image parameters
settings = await runtime_image.process_runtime_image_config(config)
url = config[CONF_URL]
width, height = config.get(CONF_RESIZE, (0, 0))
transparent = get_transparency_enum(config[CONF_TRANSPARENCY])
var = cg.new_Pvariable(
config[CONF_ID],
url,
width,
height,
image_format.enum,
get_image_type_enum(config[CONF_TYPE]),
transparent,
settings.width,
settings.height,
settings.format_enum,
settings.image_type_enum,
settings.transparent,
settings.placeholder or cg.nullptr,
config[CONF_BUFFER_SIZE],
config.get(CONF_BYTE_ORDER) != "LITTLE_ENDIAN",
settings.byte_order_big_endian,
)
await cg.register_component(var, config)
await cg.register_parented(var, config[CONF_HTTP_REQUEST_ID])
@@ -227,10 +150,6 @@ async def to_code(config):
else:
cg.add(var.add_request_header(key, value))
if placeholder_id := config.get(CONF_PLACEHOLDER):
placeholder = await cg.get_variable(placeholder_id)
cg.add(var.set_placeholder(placeholder))
for conf in config.get(CONF_ON_DOWNLOAD_FINISHED, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [(bool, "cached")], conf)

View File

@@ -1,29 +1,10 @@
#include "image_decoder.h"
#include "online_image.h"
#include "download_buffer.h"
#include "esphome/core/log.h"
#include <cstring>
namespace esphome {
namespace online_image {
namespace esphome::online_image {
static const char *const TAG = "online_image.decoder";
bool ImageDecoder::set_size(int width, int height) {
bool success = this->image_->resize_(width, height) > 0;
this->x_scale_ = static_cast<double>(this->image_->buffer_width_) / width;
this->y_scale_ = static_cast<double>(this->image_->buffer_height_) / height;
return success;
}
void ImageDecoder::draw(int x, int y, int w, int h, const Color &color) {
auto width = std::min(this->image_->buffer_width_, static_cast<int>(std::ceil((x + w) * this->x_scale_)));
auto height = std::min(this->image_->buffer_height_, static_cast<int>(std::ceil((y + h) * this->y_scale_)));
for (int i = x * this->x_scale_; i < width; i++) {
for (int j = y * this->y_scale_; j < height; j++) {
this->image_->draw_pixel_(i, j, color);
}
}
}
static const char *const TAG = "online_image.download_buffer";
DownloadBuffer::DownloadBuffer(size_t size) : size_(size) {
this->buffer_ = this->allocator_.allocate(size);
@@ -43,10 +24,12 @@ uint8_t *DownloadBuffer::data(size_t offset) {
}
size_t DownloadBuffer::read(size_t len) {
this->unread_ -= len;
if (this->unread_ > 0) {
memmove(this->data(), this->data(len), this->unread_);
if (len >= this->unread_) {
this->unread_ = 0;
return 0;
}
this->unread_ -= len;
memmove(this->data(), this->data(len), this->unread_);
return this->unread_;
}
@@ -69,5 +52,4 @@ size_t DownloadBuffer::resize(size_t size) {
}
}
} // namespace online_image
} // namespace esphome
} // namespace esphome::online_image

View File

@@ -0,0 +1,44 @@
#pragma once
#include "esphome/core/helpers.h"
#include <cstddef>
#include <cstdint>
namespace esphome::online_image {
/**
* @brief Buffer for managing downloaded data.
*
* This class provides a buffer for downloading data with tracking of
* unread bytes and dynamic resizing capabilities.
*/
class DownloadBuffer {
public:
DownloadBuffer(size_t size);
~DownloadBuffer() { this->allocator_.deallocate(this->buffer_, this->size_); }
uint8_t *data(size_t offset = 0);
uint8_t *append() { return this->data(this->unread_); }
size_t unread() const { return this->unread_; }
size_t size() const { return this->size_; }
size_t free_capacity() const { return this->size_ - this->unread_; }
size_t read(size_t len);
size_t write(size_t len) {
this->unread_ += len;
return this->unread_;
}
void reset() { this->unread_ = 0; }
size_t resize(size_t size);
protected:
RAMAllocator<uint8_t> allocator_{};
uint8_t *buffer_;
size_t size_;
/** Total number of downloaded bytes not yet read. */
size_t unread_;
};
} // namespace esphome::online_image

View File

@@ -1,6 +1,6 @@
#include "online_image.h"
#include "esphome/core/log.h"
#include <algorithm>
static const char *const TAG = "online_image";
static const char *const ETAG_HEADER_NAME = "etag";
@@ -8,142 +8,82 @@ static const char *const IF_NONE_MATCH_HEADER_NAME = "if-none-match";
static const char *const LAST_MODIFIED_HEADER_NAME = "last-modified";
static const char *const IF_MODIFIED_SINCE_HEADER_NAME = "if-modified-since";
#include "image_decoder.h"
namespace esphome::online_image {
#ifdef USE_ONLINE_IMAGE_BMP_SUPPORT
#include "bmp_image.h"
#endif
#ifdef USE_ONLINE_IMAGE_JPEG_SUPPORT
#include "jpeg_image.h"
#endif
#ifdef USE_ONLINE_IMAGE_PNG_SUPPORT
#include "png_image.h"
#endif
namespace esphome {
namespace online_image {
using image::ImageType;
inline bool is_color_on(const Color &color) {
// This produces the most accurate monochrome conversion, but is slightly slower.
// return (0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b) > 127;
// Approximation using fast integer computations; produces acceptable results
// Equivalent to 0.25 * R + 0.5 * G + 0.25 * B
return ((color.r >> 2) + (color.g >> 1) + (color.b >> 2)) & 0x80;
}
OnlineImage::OnlineImage(const std::string &url, int width, int height, ImageFormat format, ImageType type,
image::Transparency transparency, uint32_t download_buffer_size, bool is_big_endian)
: Image(nullptr, 0, 0, type, transparency),
buffer_(nullptr),
download_buffer_(download_buffer_size),
download_buffer_initial_size_(download_buffer_size),
format_(format),
fixed_width_(width),
fixed_height_(height),
is_big_endian_(is_big_endian) {
OnlineImage::OnlineImage(const std::string &url, int width, int height, runtime_image::ImageFormat format,
image::ImageType type, image::Transparency transparency, image::Image *placeholder,
uint32_t buffer_size, bool is_big_endian)
: RuntimeImage(format, type, transparency, placeholder, is_big_endian, width, height),
download_buffer_(buffer_size),
download_buffer_initial_size_(buffer_size) {
this->set_url(url);
}
void OnlineImage::draw(int x, int y, display::Display *display, Color color_on, Color color_off) {
if (this->data_start_) {
Image::draw(x, y, display, color_on, color_off);
} else if (this->placeholder_) {
this->placeholder_->draw(x, y, display, color_on, color_off);
bool OnlineImage::validate_url_(const std::string &url) {
if (url.empty()) {
ESP_LOGE(TAG, "URL is empty");
return false;
}
}
void OnlineImage::release() {
if (this->buffer_) {
ESP_LOGV(TAG, "Deallocating old buffer");
this->allocator_.deallocate(this->buffer_, this->get_buffer_size_());
this->data_start_ = nullptr;
this->buffer_ = nullptr;
this->width_ = 0;
this->height_ = 0;
this->buffer_width_ = 0;
this->buffer_height_ = 0;
this->last_modified_ = "";
this->etag_ = "";
this->end_connection_();
if (url.length() > 2048) {
ESP_LOGE(TAG, "URL is too long");
return false;
}
}
size_t OnlineImage::resize_(int width_in, int height_in) {
int width = this->fixed_width_;
int height = this->fixed_height_;
if (this->is_auto_resize_()) {
width = width_in;
height = height_in;
if (this->width_ != width && this->height_ != height) {
this->release();
}
if (url.compare(0, 7, "http://") != 0 && url.compare(0, 8, "https://") != 0) {
ESP_LOGE(TAG, "URL must start with http:// or https://");
return false;
}
size_t new_size = this->get_buffer_size_(width, height);
if (this->buffer_) {
// Buffer already allocated => no need to resize
return new_size;
}
ESP_LOGD(TAG, "Allocating new buffer of %zu bytes", new_size);
this->buffer_ = this->allocator_.allocate(new_size);
if (this->buffer_ == nullptr) {
ESP_LOGE(TAG, "allocation of %zu bytes failed. Biggest block in heap: %zu Bytes", new_size,
this->allocator_.get_max_free_block_size());
this->end_connection_();
return 0;
}
this->buffer_width_ = width;
this->buffer_height_ = height;
this->width_ = width;
ESP_LOGV(TAG, "New size: (%d, %d)", width, height);
return new_size;
return true;
}
void OnlineImage::update() {
if (this->decoder_) {
if (this->is_decoding()) {
ESP_LOGW(TAG, "Image already being updated.");
return;
}
ESP_LOGI(TAG, "Updating image %s", this->url_.c_str());
std::list<http_request::Header> headers = {};
http_request::Header accept_header;
accept_header.name = "Accept";
std::string accept_mime_type;
switch (this->format_) {
#ifdef USE_ONLINE_IMAGE_BMP_SUPPORT
case ImageFormat::BMP:
accept_mime_type = "image/bmp";
break;
#endif // USE_ONLINE_IMAGE_BMP_SUPPORT
#ifdef USE_ONLINE_IMAGE_JPEG_SUPPORT
case ImageFormat::JPEG:
accept_mime_type = "image/jpeg";
break;
#endif // USE_ONLINE_IMAGE_JPEG_SUPPORT
#ifdef USE_ONLINE_IMAGE_PNG_SUPPORT
case ImageFormat::PNG:
accept_mime_type = "image/png";
break;
#endif // USE_ONLINE_IMAGE_PNG_SUPPORT
default:
accept_mime_type = "image/*";
if (!this->validate_url_(this->url_)) {
ESP_LOGE(TAG, "Invalid URL: %s", this->url_.c_str());
this->download_error_callback_.call();
return;
}
accept_header.value = accept_mime_type + ",*/*;q=0.8";
ESP_LOGD(TAG, "Updating image from %s", this->url_.c_str());
std::list<http_request::Header> headers;
// Add caching headers if we have them
if (!this->etag_.empty()) {
headers.push_back(http_request::Header{IF_NONE_MATCH_HEADER_NAME, this->etag_});
headers.push_back({IF_NONE_MATCH_HEADER_NAME, this->etag_});
}
if (!this->last_modified_.empty()) {
headers.push_back(http_request::Header{IF_MODIFIED_SINCE_HEADER_NAME, this->last_modified_});
headers.push_back({IF_MODIFIED_SINCE_HEADER_NAME, this->last_modified_});
}
headers.push_back(accept_header);
// Add Accept header based on image format
const char *accept_mime_type;
switch (this->get_format()) {
#ifdef USE_RUNTIME_IMAGE_BMP
case runtime_image::BMP:
accept_mime_type = "image/bmp,*/*;q=0.8";
break;
#endif
#ifdef USE_RUNTIME_IMAGE_JPEG
case runtime_image::JPEG:
accept_mime_type = "image/jpeg,*/*;q=0.8";
break;
#endif
#ifdef USE_RUNTIME_IMAGE_PNG
case runtime_image::PNG:
accept_mime_type = "image/png,*/*;q=0.8";
break;
#endif
default:
accept_mime_type = "image/*,*/*;q=0.8";
break;
}
headers.push_back({"Accept", accept_mime_type});
// User headers last so they can override any of the above
for (auto &header : this->request_headers_) {
headers.push_back(http_request::Header{header.first, header.second.value()});
}
@@ -175,186 +115,117 @@ void OnlineImage::update() {
ESP_LOGD(TAG, "Starting download");
size_t total_size = this->downloader_->content_length;
#ifdef USE_ONLINE_IMAGE_BMP_SUPPORT
if (this->format_ == ImageFormat::BMP) {
ESP_LOGD(TAG, "Allocating BMP decoder");
this->decoder_ = make_unique<BmpDecoder>(this);
this->enable_loop();
// Initialize decoder with the known format
if (!this->begin_decode(total_size)) {
ESP_LOGE(TAG, "Failed to initialize decoder for format %d", this->get_format());
this->end_connection_();
this->download_error_callback_.call();
return;
}
#endif // USE_ONLINE_IMAGE_BMP_SUPPORT
#ifdef USE_ONLINE_IMAGE_JPEG_SUPPORT
if (this->format_ == ImageFormat::JPEG) {
ESP_LOGD(TAG, "Allocating JPEG decoder");
this->decoder_ = esphome::make_unique<JpegDecoder>(this);
this->enable_loop();
}
#endif // USE_ONLINE_IMAGE_JPEG_SUPPORT
#ifdef USE_ONLINE_IMAGE_PNG_SUPPORT
if (this->format_ == ImageFormat::PNG) {
ESP_LOGD(TAG, "Allocating PNG decoder");
this->decoder_ = make_unique<PngDecoder>(this);
this->enable_loop();
}
#endif // USE_ONLINE_IMAGE_PNG_SUPPORT
if (!this->decoder_) {
ESP_LOGE(TAG, "Could not instantiate decoder. Image format unsupported: %d", this->format_);
this->end_connection_();
this->download_error_callback_.call();
return;
}
auto prepare_result = this->decoder_->prepare(total_size);
if (prepare_result < 0) {
this->end_connection_();
this->download_error_callback_.call();
return;
// JPEG requires the complete image in the download buffer before decoding
if (this->get_format() == runtime_image::JPEG && total_size > this->download_buffer_.size()) {
this->download_buffer_.resize(total_size);
}
ESP_LOGI(TAG, "Downloading image (Size: %zu)", total_size);
this->start_time_ = ::time(nullptr);
this->enable_loop();
}
void OnlineImage::loop() {
if (!this->decoder_) {
if (!this->is_decoding()) {
// Not decoding at the moment => nothing to do.
this->disable_loop();
return;
}
if (!this->downloader_ || this->decoder_->is_finished()) {
this->data_start_ = buffer_;
this->width_ = buffer_width_;
this->height_ = buffer_height_;
ESP_LOGD(TAG, "Image fully downloaded, read %zu bytes, width/height = %d/%d", this->downloader_->get_bytes_read(),
this->width_, this->height_);
ESP_LOGD(TAG, "Total time: %" PRIu32 "s", (uint32_t) (::time(nullptr) - this->start_time_));
if (!this->downloader_) {
ESP_LOGE(TAG, "Downloader not instantiated; cannot download");
this->end_connection_();
this->download_error_callback_.call();
return;
}
// Check if download is complete — use decoder's format-specific completion check
// to handle both known content-length and chunked transfer encoding
if (this->is_decode_finished() || (this->downloader_->content_length > 0 &&
this->downloader_->get_bytes_read() >= this->downloader_->content_length &&
this->download_buffer_.unread() == 0)) {
// Finalize decoding
this->end_decode();
ESP_LOGD(TAG, "Image fully downloaded, %zu bytes in %" PRIu32 "s", this->downloader_->get_bytes_read(),
(uint32_t) (::time(nullptr) - this->start_time_));
// Save caching headers
this->etag_ = this->downloader_->get_response_header(ETAG_HEADER_NAME);
this->last_modified_ = this->downloader_->get_response_header(LAST_MODIFIED_HEADER_NAME);
this->download_finished_callback_.call(false);
this->end_connection_();
return;
}
if (this->downloader_ == nullptr) {
ESP_LOGE(TAG, "Downloader not instantiated; cannot download");
return;
}
// Download and decode more data
size_t available = this->download_buffer_.free_capacity();
if (available) {
// Some decoders need to fully download the image before downloading.
// In case of huge images, don't wait blocking until the whole image has been downloaded,
// use smaller chunks
if (available > 0) {
// Download in chunks to avoid blocking
available = std::min(available, this->download_buffer_initial_size_);
auto len = this->downloader_->read(this->download_buffer_.append(), available);
if (len > 0) {
this->download_buffer_.write(len);
auto fed = this->decoder_->decode(this->download_buffer_.data(), this->download_buffer_.unread());
if (fed < 0) {
ESP_LOGE(TAG, "Error when decoding image.");
// Feed data to decoder
auto consumed = this->feed_data(this->download_buffer_.data(), this->download_buffer_.unread());
if (consumed < 0) {
ESP_LOGE(TAG, "Error decoding image: %d", consumed);
this->end_connection_();
this->download_error_callback_.call();
return;
}
this->download_buffer_.read(fed);
}
}
}
void OnlineImage::map_chroma_key(Color &color) {
if (this->transparency_ == image::TRANSPARENCY_CHROMA_KEY) {
if (color.g == 1 && color.r == 0 && color.b == 0) {
color.g = 0;
}
if (color.w < 0x80) {
color.r = 0;
color.g = this->type_ == ImageType::IMAGE_TYPE_RGB565 ? 4 : 1;
color.b = 0;
}
}
}
void OnlineImage::draw_pixel_(int x, int y, Color color) {
if (!this->buffer_) {
ESP_LOGE(TAG, "Buffer not allocated!");
return;
}
if (x < 0 || y < 0 || x >= this->buffer_width_ || y >= this->buffer_height_) {
ESP_LOGE(TAG, "Tried to paint a pixel (%d,%d) outside the image!", x, y);
return;
}
uint32_t pos = this->get_position_(x, y);
switch (this->type_) {
case ImageType::IMAGE_TYPE_BINARY: {
const uint32_t width_8 = ((this->width_ + 7u) / 8u) * 8u;
pos = x + y * width_8;
auto bitno = 0x80 >> (pos % 8u);
pos /= 8u;
auto on = is_color_on(color);
if (this->has_transparency() && color.w < 0x80)
on = false;
if (on) {
this->buffer_[pos] |= bitno;
} else {
this->buffer_[pos] &= ~bitno;
if (consumed > 0) {
this->download_buffer_.read(consumed);
}
break;
} else if (len < 0) {
ESP_LOGE(TAG, "Error downloading image: %d", len);
this->end_connection_();
this->download_error_callback_.call();
return;
}
case ImageType::IMAGE_TYPE_GRAYSCALE: {
auto gray = static_cast<uint8_t>(0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b);
if (this->transparency_ == image::TRANSPARENCY_CHROMA_KEY) {
if (gray == 1) {
gray = 0;
}
if (color.w < 0x80) {
gray = 1;
}
} else if (this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) {
if (color.w != 0xFF)
gray = color.w;
}
this->buffer_[pos] = gray;
break;
}
case ImageType::IMAGE_TYPE_RGB565: {
this->map_chroma_key(color);
uint16_t col565 = display::ColorUtil::color_to_565(color);
if (this->is_big_endian_) {
this->buffer_[pos + 0] = static_cast<uint8_t>((col565 >> 8) & 0xFF);
this->buffer_[pos + 1] = static_cast<uint8_t>(col565 & 0xFF);
} else {
this->buffer_[pos + 0] = static_cast<uint8_t>(col565 & 0xFF);
this->buffer_[pos + 1] = static_cast<uint8_t>((col565 >> 8) & 0xFF);
}
if (this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) {
this->buffer_[pos + 2] = color.w;
}
break;
}
case ImageType::IMAGE_TYPE_RGB: {
this->map_chroma_key(color);
this->buffer_[pos + 0] = color.r;
this->buffer_[pos + 1] = color.g;
this->buffer_[pos + 2] = color.b;
if (this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) {
this->buffer_[pos + 3] = color.w;
}
break;
} else {
// Buffer is full, need to decode some data first
auto consumed = this->feed_data(this->download_buffer_.data(), this->download_buffer_.unread());
if (consumed > 0) {
this->download_buffer_.read(consumed);
} else if (consumed < 0) {
ESP_LOGE(TAG, "Decode error with full buffer: %d", consumed);
this->end_connection_();
this->download_error_callback_.call();
return;
} else {
// Decoder can't process more data, might need complete image
// This is normal for JPEG which needs complete data
ESP_LOGV(TAG, "Decoder waiting for more data");
}
}
}
void OnlineImage::end_connection_() {
// Abort any in-progress decode to free decoder resources.
// Use RuntimeImage::release() directly to avoid recursion with OnlineImage::release().
if (this->is_decoding()) {
RuntimeImage::release();
}
if (this->downloader_) {
this->downloader_->end();
this->downloader_ = nullptr;
}
this->decoder_.reset();
this->download_buffer_.reset();
}
bool OnlineImage::validate_url_(const std::string &url) {
if ((url.length() < 8) || !url.starts_with("http") || (url.find("://") == std::string::npos)) {
ESP_LOGE(TAG, "URL is invalid and/or must be prefixed with 'http://' or 'https://'");
return false;
}
return true;
this->disable_loop();
}
void OnlineImage::add_on_finished_callback(std::function<void(bool)> &&callback) {
@@ -365,5 +236,16 @@ void OnlineImage::add_on_error_callback(std::function<void()> &&callback) {
this->download_error_callback_.add(std::move(callback));
}
} // namespace online_image
} // namespace esphome
void OnlineImage::release() {
// Clear cache headers
this->etag_ = "";
this->last_modified_ = "";
// End any active connection
this->end_connection_();
// Call parent's release to free the image buffer
RuntimeImage::release();
}
} // namespace esphome::online_image

View File

@@ -1,15 +1,14 @@
#pragma once
#include "download_buffer.h"
#include "esphome/components/http_request/http_request.h"
#include "esphome/components/image/image.h"
#include "esphome/components/runtime_image/runtime_image.h"
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#include "image_decoder.h"
namespace esphome {
namespace online_image {
namespace esphome::online_image {
using t_http_codes = enum {
HTTP_CODE_OK = 200,
@@ -17,27 +16,13 @@ using t_http_codes = enum {
HTTP_CODE_NOT_FOUND = 404,
};
/**
* @brief Format that the image is encoded with.
*/
enum ImageFormat {
/** Automatically detect from MIME type. Not supported yet. */
AUTO,
/** JPEG format. */
JPEG,
/** PNG format. */
PNG,
/** BMP format. */
BMP,
};
/**
* @brief Download an image from a given URL, and decode it using the specified decoder.
* The image will then be stored in a buffer, so that it can be re-displayed without the
* need to re-download or re-decode.
*/
class OnlineImage : public PollingComponent,
public image::Image,
public runtime_image::RuntimeImage,
public Parented<esphome::http_request::HttpRequestComponent> {
public:
/**
@@ -46,17 +31,19 @@ class OnlineImage : public PollingComponent,
* @param url URL to download the image from.
* @param width Desired width of the target image area.
* @param height Desired height of the target image area.
* @param format Format that the image is encoded in (@see ImageFormat).
* @param format Format that the image is encoded in (@see runtime_image::ImageFormat).
* @param type The pixel format for the image.
* @param transparency The transparency type for the image.
* @param placeholder Optional placeholder image to show while loading.
* @param buffer_size Size of the buffer used to download the image.
* @param is_big_endian Whether the image is stored in big-endian format.
*/
OnlineImage(const std::string &url, int width, int height, ImageFormat format, image::ImageType type,
image::Transparency transparency, uint32_t buffer_size, bool is_big_endian);
void draw(int x, int y, display::Display *display, Color color_on, Color color_off) override;
OnlineImage(const std::string &url, int width, int height, runtime_image::ImageFormat format, image::ImageType type,
image::Transparency transparency, image::Image *placeholder, uint32_t buffer_size,
bool is_big_endian = false);
void update() override;
void loop() override;
void map_chroma_key(Color &color);
/** Set the URL to download the image from. */
void set_url(const std::string &url) {
@@ -69,82 +56,26 @@ class OnlineImage : public PollingComponent,
/** Add the request header */
template<typename V> void add_request_header(const std::string &header, V value) {
this->request_headers_.push_back(std::pair<std::string, TemplatableValue<std::string> >(header, value));
this->request_headers_.push_back(std::pair<std::string, TemplatableValue<std::string>>(header, value));
}
/**
* @brief Set the image that needs to be shown as long as the downloaded image
* is not available.
*
* @param placeholder Pointer to the (@link Image) to show as placeholder.
*/
void set_placeholder(image::Image *placeholder) { this->placeholder_ = placeholder; }
/**
* Release the buffer storing the image. The image will need to be downloaded again
* to be able to be displayed.
*/
void release();
/**
* Resize the download buffer
*
* @param size The new size for the download buffer.
*/
size_t resize_download_buffer(size_t size) { return this->download_buffer_.resize(size); }
void add_on_finished_callback(std::function<void(bool)> &&callback);
void add_on_error_callback(std::function<void()> &&callback);
protected:
bool validate_url_(const std::string &url);
RAMAllocator<uint8_t> allocator_{};
uint32_t get_buffer_size_() const { return get_buffer_size_(this->buffer_width_, this->buffer_height_); }
int get_buffer_size_(int width, int height) const { return (this->get_bpp() * width + 7u) / 8u * height; }
int get_position_(int x, int y) const { return (x + y * this->buffer_width_) * this->get_bpp() / 8; }
ESPHOME_ALWAYS_INLINE bool is_auto_resize_() const { return this->fixed_width_ == 0 || this->fixed_height_ == 0; }
/**
* @brief Resize the image buffer to the requested dimensions.
*
* The buffer will be allocated if not existing.
* If the dimensions have been fixed in the yaml config, the buffer will be created
* with those dimensions and not resized, even on request.
* Otherwise, the old buffer will be deallocated and a new buffer with the requested
* allocated
*
* @param width
* @param height
* @return 0 if no memory could be allocated, the size of the new buffer otherwise.
*/
size_t resize_(int width, int height);
/**
* @brief Draw a pixel into the buffer.
*
* This is used by the decoder to fill the buffer that will later be displayed
* by the `draw` method. This will internally convert the supplied 32 bit RGBA
* color into the requested image storage format.
*
* @param x Horizontal pixel position.
* @param y Vertical pixel position.
* @param color 32 bit color to put into the pixel.
*/
void draw_pixel_(int x, int y, Color color);
void end_connection_();
CallbackManager<void(bool)> download_finished_callback_{};
CallbackManager<void()> download_error_callback_{};
std::shared_ptr<http_request::HttpContainer> downloader_{nullptr};
std::unique_ptr<ImageDecoder> decoder_{nullptr};
uint8_t *buffer_;
DownloadBuffer download_buffer_;
/**
* This is the *initial* size of the download buffer, not the current size.
@@ -153,40 +84,10 @@ class OnlineImage : public PollingComponent,
*/
size_t download_buffer_initial_size_;
const ImageFormat format_;
image::Image *placeholder_{nullptr};
std::string url_{""};
std::vector<std::pair<std::string, TemplatableValue<std::string> > > request_headers_;
std::vector<std::pair<std::string, TemplatableValue<std::string>>> request_headers_;
/** width requested on configuration, or 0 if non specified. */
const int fixed_width_;
/** height requested on configuration, or 0 if non specified. */
const int fixed_height_;
/**
* Whether the image is stored in big-endian format.
* This is used to determine how to store 16 bit colors in the buffer.
*/
bool is_big_endian_;
/**
* Actual width of the current image. If fixed_width_ is specified,
* this will be equal to it; otherwise it will be set once the decoding
* starts and the original size is known.
* This needs to be separate from "BaseImage::get_width()" because the latter
* must return 0 until the image has been decoded (to avoid showing partially
* decoded images).
*/
int buffer_width_;
/**
* Actual height of the current image. If fixed_height_ is specified,
* this will be equal to it; otherwise it will be set once the decoding
* starts and the original size is known.
* This needs to be separate from "BaseImage::get_height()" because the latter
* must return 0 until the image has been decoded (to avoid showing partially
* decoded images).
*/
int buffer_height_;
/**
* The value of the ETag HTTP header provided in the last response.
*/
@@ -197,9 +98,6 @@ class OnlineImage : public PollingComponent,
std::string last_modified_ = "";
time_t start_time_;
friend bool ImageDecoder::set_size(int width, int height);
friend void ImageDecoder::draw(int x, int y, int w, int h, const Color &color);
};
template<typename... Ts> class OnlineImageSetUrlAction : public Action<Ts...> {
@@ -241,5 +139,4 @@ class DownloadErrorTrigger : public Trigger<> {
}
};
} // namespace online_image
} // namespace esphome
} // namespace esphome::online_image

View File

@@ -38,8 +38,7 @@ void PulseMeterSensor::setup() {
}
void PulseMeterSensor::loop() {
// Reset the count in get before we pass it back to the ISR as set
this->get_->count_ = 0;
State state;
{
// Lock the interrupt so the interrupt code doesn't interfere with itself
@@ -58,31 +57,35 @@ void PulseMeterSensor::loop() {
}
this->last_pin_val_ = current;
// Swap out set and get to get the latest state from the ISR
std::swap(this->set_, this->get_);
// Get the latest state from the ISR and reset the count in the ISR
state.last_detected_edge_us_ = this->state_.last_detected_edge_us_;
state.last_rising_edge_us_ = this->state_.last_rising_edge_us_;
state.count_ = this->state_.count_;
this->state_.count_ = 0;
}
const uint32_t now = micros();
// If an edge was peeked, repay the debt
if (this->peeked_edge_ && this->get_->count_ > 0) {
if (this->peeked_edge_ && state.count_ > 0) {
this->peeked_edge_ = false;
this->get_->count_--; // NOLINT(clang-diagnostic-deprecated-volatile)
state.count_--;
}
// If there is an unprocessed edge, and filter_us_ has passed since, count this edge early
if (this->get_->last_rising_edge_us_ != this->get_->last_detected_edge_us_ &&
now - this->get_->last_rising_edge_us_ >= this->filter_us_) {
// If there is an unprocessed edge, and filter_us_ has passed since, count this edge early.
// Wait for the debt to be repaid before counting another unprocessed edge early.
if (!this->peeked_edge_ && state.last_rising_edge_us_ != state.last_detected_edge_us_ &&
now - state.last_rising_edge_us_ >= this->filter_us_) {
this->peeked_edge_ = true;
this->get_->last_detected_edge_us_ = this->get_->last_rising_edge_us_;
this->get_->count_++; // NOLINT(clang-diagnostic-deprecated-volatile)
state.last_detected_edge_us_ = state.last_rising_edge_us_;
state.count_++;
}
// Check if we detected a pulse this loop
if (this->get_->count_ > 0) {
if (state.count_ > 0) {
// Keep a running total of pulses if a total sensor is configured
if (this->total_sensor_ != nullptr) {
this->total_pulses_ += this->get_->count_;
this->total_pulses_ += state.count_;
const uint32_t total = this->total_pulses_;
this->total_sensor_->publish_state(total);
}
@@ -94,15 +97,15 @@ void PulseMeterSensor::loop() {
this->meter_state_ = MeterState::RUNNING;
} break;
case MeterState::RUNNING: {
uint32_t delta_us = this->get_->last_detected_edge_us_ - this->last_processed_edge_us_;
float pulse_width_us = delta_us / float(this->get_->count_);
ESP_LOGV(TAG, "New pulse, delta: %" PRIu32 " µs, count: %" PRIu32 ", width: %.5f µs", delta_us,
this->get_->count_, pulse_width_us);
uint32_t delta_us = state.last_detected_edge_us_ - this->last_processed_edge_us_;
float pulse_width_us = delta_us / float(state.count_);
ESP_LOGV(TAG, "New pulse, delta: %" PRIu32 " µs, count: %" PRIu32 ", width: %.5f µs", delta_us, state.count_,
pulse_width_us);
this->publish_state((60.0f * 1000000.0f) / pulse_width_us);
} break;
}
this->last_processed_edge_us_ = this->get_->last_detected_edge_us_;
this->last_processed_edge_us_ = state.last_detected_edge_us_;
}
// No detected edges this loop
else {
@@ -141,14 +144,14 @@ void IRAM_ATTR PulseMeterSensor::edge_intr(PulseMeterSensor *sensor) {
// This is an interrupt handler - we can't call any virtual method from this method
// Get the current time before we do anything else so the measurements are consistent
const uint32_t now = micros();
auto &state = sensor->edge_state_;
auto &set = *sensor->set_;
auto &edge_state = sensor->edge_state_;
auto &state = sensor->state_;
if ((now - state.last_sent_edge_us_) >= sensor->filter_us_) {
state.last_sent_edge_us_ = now;
set.last_detected_edge_us_ = now;
set.last_rising_edge_us_ = now;
set.count_++; // NOLINT(clang-diagnostic-deprecated-volatile)
if ((now - edge_state.last_sent_edge_us_) >= sensor->filter_us_) {
edge_state.last_sent_edge_us_ = now;
state.last_detected_edge_us_ = now;
state.last_rising_edge_us_ = now;
state.count_++; // NOLINT(clang-diagnostic-deprecated-volatile)
}
// This ISR is bound to rising edges, so the pin is high
@@ -160,26 +163,26 @@ void IRAM_ATTR PulseMeterSensor::pulse_intr(PulseMeterSensor *sensor) {
// Get the current time before we do anything else so the measurements are consistent
const uint32_t now = micros();
const bool pin_val = sensor->isr_pin_.digital_read();
auto &state = sensor->pulse_state_;
auto &set = *sensor->set_;
auto &pulse_state = sensor->pulse_state_;
auto &state = sensor->state_;
// Filter length has passed since the last interrupt
const bool length = now - state.last_intr_ >= sensor->filter_us_;
const bool length = now - pulse_state.last_intr_ >= sensor->filter_us_;
if (length && state.latched_ && !sensor->last_pin_val_) { // Long enough low edge
state.latched_ = false;
} else if (length && !state.latched_ && sensor->last_pin_val_) { // Long enough high edge
state.latched_ = true;
set.last_detected_edge_us_ = state.last_intr_;
set.count_++; // NOLINT(clang-diagnostic-deprecated-volatile)
if (length && pulse_state.latched_ && !sensor->last_pin_val_) { // Long enough low edge
pulse_state.latched_ = false;
} else if (length && !pulse_state.latched_ && sensor->last_pin_val_) { // Long enough high edge
pulse_state.latched_ = true;
state.last_detected_edge_us_ = pulse_state.last_intr_;
state.count_++; // NOLINT(clang-diagnostic-deprecated-volatile)
}
// Due to order of operations this includes
// length && latched && rising (just reset from a long low edge)
// !latched && (rising || high) (noise on the line resetting the potential rising edge)
set.last_rising_edge_us_ = !state.latched_ && pin_val ? now : set.last_detected_edge_us_;
state.last_rising_edge_us_ = !pulse_state.latched_ && pin_val ? now : state.last_detected_edge_us_;
state.last_intr_ = now;
pulse_state.last_intr_ = now;
sensor->last_pin_val_ = pin_val;
}

View File

@@ -46,17 +46,16 @@ class PulseMeterSensor : public sensor::Sensor, public Component {
uint32_t total_pulses_ = 0;
uint32_t last_processed_edge_us_ = 0;
// This struct (and the two pointers) are used to pass data between the ISR and loop.
// These two pointers are exchanged each loop.
// Use these to send data from the ISR to the loop not the other way around (except for resetting the values).
// This struct and variable are used to pass data between the ISR and loop.
// The data from state_ is read and then count_ in state_ is reset in each loop.
// This must be done while guarded by an InterruptLock. Use this variable to send data
// from the ISR to the loop not the other way around (except for resetting count_).
struct State {
uint32_t last_detected_edge_us_ = 0;
uint32_t last_rising_edge_us_ = 0;
uint32_t count_ = 0;
};
State state_[2];
volatile State *set_ = state_;
volatile State *get_ = state_ + 1;
volatile State state_{};
// Only use the following variables in the ISR or while guarded by an InterruptLock
ISRInternalGPIOPin isr_pin_;

View File

@@ -0,0 +1,191 @@
from dataclasses import dataclass
import esphome.codegen as cg
from esphome.components.const import CONF_BYTE_ORDER
from esphome.components.image import (
IMAGE_TYPE,
Image_,
validate_settings,
validate_transparency,
validate_type,
)
import esphome.config_validation as cv
from esphome.const import CONF_FORMAT, CONF_ID, CONF_RESIZE, CONF_TYPE
AUTO_LOAD = ["image"]
CODEOWNERS = ["@guillempages", "@clydebarrow", "@kahrendt"]
CONF_PLACEHOLDER = "placeholder"
CONF_TRANSPARENCY = "transparency"
runtime_image_ns = cg.esphome_ns.namespace("runtime_image")
# Base decoder classes
ImageDecoder = runtime_image_ns.class_("ImageDecoder")
BmpDecoder = runtime_image_ns.class_("BmpDecoder", ImageDecoder)
JpegDecoder = runtime_image_ns.class_("JpegDecoder", ImageDecoder)
PngDecoder = runtime_image_ns.class_("PngDecoder", ImageDecoder)
# Runtime image class
RuntimeImage = runtime_image_ns.class_(
"RuntimeImage", cg.esphome_ns.namespace("image").class_("Image")
)
# Image format enum
ImageFormat = runtime_image_ns.enum("ImageFormat")
IMAGE_FORMAT_AUTO = ImageFormat.AUTO
IMAGE_FORMAT_JPEG = ImageFormat.JPEG
IMAGE_FORMAT_PNG = ImageFormat.PNG
IMAGE_FORMAT_BMP = ImageFormat.BMP
# Export enum for decode errors
DecodeError = runtime_image_ns.enum("DecodeError")
DECODE_ERROR_INVALID_TYPE = DecodeError.DECODE_ERROR_INVALID_TYPE
DECODE_ERROR_UNSUPPORTED_FORMAT = DecodeError.DECODE_ERROR_UNSUPPORTED_FORMAT
DECODE_ERROR_OUT_OF_MEMORY = DecodeError.DECODE_ERROR_OUT_OF_MEMORY
class Format:
"""Base class for image format definitions."""
def __init__(self, name: str, decoder_class: cg.MockObjClass) -> None:
self.name = name
self.decoder_class = decoder_class
def actions(self) -> None:
"""Add defines and libraries needed for this format."""
class BMPFormat(Format):
"""BMP format decoder configuration."""
def __init__(self):
super().__init__("BMP", BmpDecoder)
def actions(self) -> None:
cg.add_define("USE_RUNTIME_IMAGE_BMP")
class JPEGFormat(Format):
"""JPEG format decoder configuration."""
def __init__(self):
super().__init__("JPEG", JpegDecoder)
def actions(self) -> None:
cg.add_define("USE_RUNTIME_IMAGE_JPEG")
cg.add_library("JPEGDEC", None, "https://github.com/bitbank2/JPEGDEC#ca1e0f2")
class PNGFormat(Format):
"""PNG format decoder configuration."""
def __init__(self):
super().__init__("PNG", PngDecoder)
def actions(self) -> None:
cg.add_define("USE_RUNTIME_IMAGE_PNG")
cg.add_library("pngle", "1.1.0")
# Registry of available formats
IMAGE_FORMATS = {
"BMP": BMPFormat(),
"JPEG": JPEGFormat(),
"PNG": PNGFormat(),
"JPG": JPEGFormat(), # Alias for JPEG
}
def get_format(format_name: str) -> Format | None:
"""Get a format instance by name."""
return IMAGE_FORMATS.get(format_name.upper())
def enable_format(format_name: str) -> Format | None:
"""Enable a specific image format by adding its defines and libraries."""
format_obj = get_format(format_name)
if format_obj:
format_obj.actions()
return format_obj
return None
# Runtime image configuration schema base - to be extended by components
def runtime_image_schema(image_class: cg.MockObjClass = RuntimeImage) -> cv.Schema:
"""Create a runtime image schema with the specified image class."""
return cv.Schema(
{
cv.Required(CONF_ID): cv.declare_id(image_class),
cv.Required(CONF_FORMAT): cv.one_of(*IMAGE_FORMATS, upper=True),
cv.Optional(CONF_RESIZE): cv.dimensions,
cv.Required(CONF_TYPE): validate_type(IMAGE_TYPE),
cv.Optional(CONF_BYTE_ORDER): cv.one_of(
"BIG_ENDIAN", "LITTLE_ENDIAN", upper=True
),
cv.Optional(CONF_TRANSPARENCY, default="OPAQUE"): validate_transparency(),
cv.Optional(CONF_PLACEHOLDER): cv.use_id(Image_),
}
)
def validate_runtime_image_settings(config: dict) -> dict:
"""Apply validate_settings from image component to runtime image config."""
return validate_settings(config)
@dataclass
class RuntimeImageSettings:
"""Processed runtime image configuration parameters."""
width: int
height: int
format_enum: cg.MockObj
image_type_enum: cg.MockObj
transparent: cg.MockObj
byte_order_big_endian: bool
placeholder: cg.MockObj | None
async def process_runtime_image_config(config: dict) -> RuntimeImageSettings:
"""
Helper function to process common runtime image configuration parameters.
Handles format enabling and returns all necessary enums and parameters.
"""
from esphome.components.image import get_image_type_enum, get_transparency_enum
# Get resize dimensions with default (0, 0)
width, height = config.get(CONF_RESIZE, (0, 0))
# Handle format (required for runtime images)
format_name = config[CONF_FORMAT]
# Enable the format in the runtime_image component
enable_format(format_name)
# Map format names to enum values (handle JPG as alias for JPEG)
if format_name.upper() == "JPG":
format_name = "JPEG"
format_enum = getattr(ImageFormat, format_name.upper())
# Get image type enum
image_type_enum = get_image_type_enum(config[CONF_TYPE])
# Get transparency enum
transparent = get_transparency_enum(config.get(CONF_TRANSPARENCY, "OPAQUE"))
# Get byte order (True for big endian, False for little endian)
byte_order_big_endian = config.get(CONF_BYTE_ORDER) != "LITTLE_ENDIAN"
# Get placeholder if specified
placeholder = None
if placeholder_id := config.get(CONF_PLACEHOLDER):
placeholder = await cg.get_variable(placeholder_id)
return RuntimeImageSettings(
width=width,
height=height,
format_enum=format_enum,
image_type_enum=image_type_enum,
transparent=transparent,
byte_order_big_endian=byte_order_big_endian,
placeholder=placeholder,
)

View File

@@ -1,15 +1,14 @@
#include "bmp_image.h"
#include "bmp_decoder.h"
#ifdef USE_ONLINE_IMAGE_BMP_SUPPORT
#ifdef USE_RUNTIME_IMAGE_BMP
#include "esphome/components/display/display.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome {
namespace online_image {
namespace esphome::runtime_image {
static const char *const TAG = "online_image.bmp";
static const char *const TAG = "image_decoder.bmp";
int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) {
size_t index = 0;
@@ -30,7 +29,11 @@ int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) {
return DECODE_ERROR_INVALID_TYPE;
}
this->download_size_ = encode_uint32(buffer[5], buffer[4], buffer[3], buffer[2]);
// BMP file contains its own size in the header
size_t file_size = encode_uint32(buffer[5], buffer[4], buffer[3], buffer[2]);
if (this->expected_size_ == 0) {
this->expected_size_ = file_size; // Use file header size if not provided
}
this->data_offset_ = encode_uint32(buffer[13], buffer[12], buffer[11], buffer[10]);
this->current_index_ = 14;
@@ -90,8 +93,8 @@ int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) {
while (index < size) {
uint8_t current_byte = buffer[index];
for (uint8_t i = 0; i < 8; i++) {
size_t x = (this->paint_index_ % this->width_) + i;
size_t y = (this->height_ - 1) - (this->paint_index_ / this->width_);
size_t x = (this->paint_index_ % static_cast<size_t>(this->width_)) + i;
size_t y = static_cast<size_t>(this->height_ - 1) - (this->paint_index_ / static_cast<size_t>(this->width_));
Color c = (current_byte & (1 << (7 - i))) ? display::COLOR_ON : display::COLOR_OFF;
this->draw(x, y, 1, 1, c);
}
@@ -110,8 +113,8 @@ int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) {
uint8_t b = buffer[index];
uint8_t g = buffer[index + 1];
uint8_t r = buffer[index + 2];
size_t x = this->paint_index_ % this->width_;
size_t y = (this->height_ - 1) - (this->paint_index_ / this->width_);
size_t x = this->paint_index_ % static_cast<size_t>(this->width_);
size_t y = static_cast<size_t>(this->height_ - 1) - (this->paint_index_ / static_cast<size_t>(this->width_));
Color c = Color(r, g, b);
this->draw(x, y, 1, 1, c);
this->paint_index_++;
@@ -133,7 +136,6 @@ int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) {
return size;
};
} // namespace online_image
} // namespace esphome
} // namespace esphome::runtime_image
#endif // USE_ONLINE_IMAGE_BMP_SUPPORT
#endif // USE_RUNTIME_IMAGE_BMP

View File

@@ -1,27 +1,32 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_ONLINE_IMAGE_BMP_SUPPORT
#ifdef USE_RUNTIME_IMAGE_BMP
#include "image_decoder.h"
#include "runtime_image.h"
namespace esphome {
namespace online_image {
namespace esphome::runtime_image {
/**
* @brief Image decoder specialization for PNG images.
* @brief Image decoder specialization for BMP images.
*/
class BmpDecoder : public ImageDecoder {
public:
/**
* @brief Construct a new BMP Decoder object.
*
* @param display The image to decode the stream into.
* @param image The RuntimeImage to decode the stream into.
*/
BmpDecoder(OnlineImage *image) : ImageDecoder(image) {}
BmpDecoder(RuntimeImage *image) : ImageDecoder(image) {}
int HOT decode(uint8_t *buffer, size_t size) override;
bool is_finished() const override {
// BMP is finished when we've decoded all pixel data
return this->paint_index_ >= static_cast<size_t>(this->width_ * this->height_);
}
protected:
size_t current_index_{0};
size_t paint_index_{0};
@@ -36,7 +41,6 @@ class BmpDecoder : public ImageDecoder {
uint8_t padding_bytes_{0};
};
} // namespace online_image
} // namespace esphome
} // namespace esphome::runtime_image
#endif // USE_ONLINE_IMAGE_BMP_SUPPORT
#endif // USE_RUNTIME_IMAGE_BMP

View File

@@ -0,0 +1,28 @@
#include "image_decoder.h"
#include "runtime_image.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cmath>
namespace esphome::runtime_image {
static const char *const TAG = "image_decoder";
bool ImageDecoder::set_size(int width, int height) {
bool success = this->image_->resize(width, height) > 0;
this->x_scale_ = static_cast<double>(this->image_->get_buffer_width()) / width;
this->y_scale_ = static_cast<double>(this->image_->get_buffer_height()) / height;
return success;
}
void ImageDecoder::draw(int x, int y, int w, int h, const Color &color) {
auto width = std::min(this->image_->get_buffer_width(), static_cast<int>(std::ceil((x + w) * this->x_scale_)));
auto height = std::min(this->image_->get_buffer_height(), static_cast<int>(std::ceil((y + h) * this->y_scale_)));
for (int i = x * this->x_scale_; i < width; i++) {
for (int j = y * this->y_scale_; j < height; j++) {
this->image_->draw_pixel(i, j, color);
}
}
}
} // namespace esphome::runtime_image

View File

@@ -1,8 +1,7 @@
#pragma once
#include "esphome/core/color.h"
namespace esphome {
namespace online_image {
namespace esphome::runtime_image {
enum DecodeError : int {
DECODE_ERROR_INVALID_TYPE = -1,
@@ -10,7 +9,7 @@ enum DecodeError : int {
DECODE_ERROR_OUT_OF_MEMORY = -3,
};
class OnlineImage;
class RuntimeImage;
/**
* @brief Class to abstract decoding different image formats.
@@ -20,19 +19,19 @@ class ImageDecoder {
/**
* @brief Construct a new Image Decoder object
*
* @param image The image to decode the stream into.
* @param image The RuntimeImage to decode the stream into.
*/
ImageDecoder(OnlineImage *image) : image_(image) {}
ImageDecoder(RuntimeImage *image) : image_(image) {}
virtual ~ImageDecoder() = default;
/**
* @brief Initialize the decoder.
*
* @param download_size The total number of bytes that need to be downloaded for the image.
* @param expected_size Hint about the expected data size (0 if unknown).
* @return int Returns 0 on success, a {@see DecodeError} value in case of an error.
*/
virtual int prepare(size_t download_size) {
this->download_size_ = download_size;
virtual int prepare(size_t expected_size) {
this->expected_size_ = expected_size;
return 0;
}
@@ -73,49 +72,26 @@ class ImageDecoder {
*/
void draw(int x, int y, int w, int h, const Color &color);
bool is_finished() const { return this->decoded_bytes_ == this->download_size_; }
/**
* @brief Check if the decoder has finished processing.
*
* This should be overridden by decoders that can detect completion
* based on format-specific markers rather than byte counts.
*/
virtual bool is_finished() const {
if (this->expected_size_ > 0) {
return this->decoded_bytes_ >= this->expected_size_;
}
// If size is unknown, derived classes should override this
return false;
}
protected:
OnlineImage *image_;
// Initializing to 1, to ensure it is distinguishable from initial "decoded_bytes_".
// Will be overwritten anyway once the download size is known.
size_t download_size_ = 1;
size_t decoded_bytes_ = 0;
RuntimeImage *image_;
size_t expected_size_ = 0; // Expected data size (0 if unknown)
size_t decoded_bytes_ = 0; // Bytes processed so far
double x_scale_ = 1.0;
double y_scale_ = 1.0;
};
class DownloadBuffer {
public:
DownloadBuffer(size_t size);
virtual ~DownloadBuffer() { this->allocator_.deallocate(this->buffer_, this->size_); }
uint8_t *data(size_t offset = 0);
uint8_t *append() { return this->data(this->unread_); }
size_t unread() const { return this->unread_; }
size_t size() const { return this->size_; }
size_t free_capacity() const { return this->size_ - this->unread_; }
size_t read(size_t len);
size_t write(size_t len) {
this->unread_ += len;
return this->unread_;
}
void reset() { this->unread_ = 0; }
size_t resize(size_t size);
protected:
RAMAllocator<uint8_t> allocator_{};
uint8_t *buffer_;
size_t size_;
/** Total number of downloaded bytes not yet read. */
size_t unread_;
};
} // namespace online_image
} // namespace esphome
} // namespace esphome::runtime_image

View File

@@ -1,16 +1,19 @@
#include "jpeg_image.h"
#ifdef USE_ONLINE_IMAGE_JPEG_SUPPORT
#include "jpeg_decoder.h"
#ifdef USE_RUNTIME_IMAGE_JPEG
#include "esphome/components/display/display_buffer.h"
#include "esphome/core/application.h"
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "online_image.h"
static const char *const TAG = "online_image.jpeg";
#ifdef USE_ESP_IDF
#include "esp_task_wdt.h"
#endif
namespace esphome {
namespace online_image {
static const char *const TAG = "image_decoder.jpeg";
namespace esphome::runtime_image {
/**
* @brief Callback method that will be called by the JPEGDEC engine when a chunk
@@ -22,8 +25,14 @@ static int draw_callback(JPEGDRAW *jpeg) {
ImageDecoder *decoder = (ImageDecoder *) jpeg->pUser;
// Some very big images take too long to decode, so feed the watchdog on each callback
// to avoid crashing.
App.feed_wdt();
// to avoid crashing if the executing task has a watchdog enabled.
#ifdef USE_ESP_IDF
if (esp_task_wdt_status(nullptr) == ESP_OK) {
#endif
App.feed_wdt();
#ifdef USE_ESP_IDF
}
#endif
size_t position = 0;
size_t height = static_cast<size_t>(jpeg->iHeight);
size_t width = static_cast<size_t>(jpeg->iWidth);
@@ -43,22 +52,23 @@ static int draw_callback(JPEGDRAW *jpeg) {
return 1;
}
int JpegDecoder::prepare(size_t download_size) {
ImageDecoder::prepare(download_size);
auto size = this->image_->resize_download_buffer(download_size);
if (size < download_size) {
ESP_LOGE(TAG, "Download buffer resize failed!");
return DECODE_ERROR_OUT_OF_MEMORY;
}
int JpegDecoder::prepare(size_t expected_size) {
ImageDecoder::prepare(expected_size);
// JPEG decoder needs complete data before decoding
return 0;
}
int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) {
if (size < this->download_size_) {
ESP_LOGV(TAG, "Download not complete. Size: %d/%d", size, this->download_size_);
// JPEG decoder requires complete data
// If we know the expected size, wait for it
if (this->expected_size_ > 0 && size < this->expected_size_) {
ESP_LOGV(TAG, "Download not complete. Size: %zu/%zu", size, this->expected_size_);
return 0;
}
// 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());
return DECODE_ERROR_INVALID_TYPE;
@@ -88,7 +98,6 @@ int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) {
return size;
}
} // namespace online_image
} // namespace esphome
} // namespace esphome::runtime_image
#endif // USE_ONLINE_IMAGE_JPEG_SUPPORT
#endif // USE_RUNTIME_IMAGE_JPEG

View File

@@ -1,12 +1,12 @@
#pragma once
#include "image_decoder.h"
#include "runtime_image.h"
#include "esphome/core/defines.h"
#ifdef USE_ONLINE_IMAGE_JPEG_SUPPORT
#ifdef USE_RUNTIME_IMAGE_JPEG
#include <JPEGDEC.h>
namespace esphome {
namespace online_image {
namespace esphome::runtime_image {
/**
* @brief Image decoder specialization for JPEG images.
@@ -16,19 +16,18 @@ class JpegDecoder : public ImageDecoder {
/**
* @brief Construct a new JPEG Decoder object.
*
* @param display The image to decode the stream into.
* @param image The RuntimeImage to decode the stream into.
*/
JpegDecoder(OnlineImage *image) : ImageDecoder(image) {}
JpegDecoder(RuntimeImage *image) : ImageDecoder(image) {}
~JpegDecoder() override {}
int prepare(size_t download_size) override;
int prepare(size_t expected_size) override;
int HOT decode(uint8_t *buffer, size_t size) override;
protected:
JPEGDEC jpeg_{};
};
} // namespace online_image
} // namespace esphome
} // namespace esphome::runtime_image
#endif // USE_ONLINE_IMAGE_JPEG_SUPPORT
#endif // USE_RUNTIME_IMAGE_JPEG

View File

@@ -1,15 +1,14 @@
#include "png_image.h"
#ifdef USE_ONLINE_IMAGE_PNG_SUPPORT
#include "png_decoder.h"
#ifdef USE_RUNTIME_IMAGE_PNG
#include "esphome/components/display/display_buffer.h"
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
static const char *const TAG = "online_image.png";
static const char *const TAG = "image_decoder.png";
namespace esphome {
namespace online_image {
namespace esphome::runtime_image {
/**
* @brief Callback method that will be called by the PNGLE engine when the basic
@@ -49,7 +48,7 @@ static void draw_callback(pngle_t *pngle, uint32_t x, uint32_t y, uint32_t w, ui
}
}
PngDecoder::PngDecoder(OnlineImage *image) : ImageDecoder(image) {
PngDecoder::PngDecoder(RuntimeImage *image) : ImageDecoder(image) {
{
pngle_t *pngle = this->allocator_.allocate(1, PNGLE_T_SIZE);
if (!pngle) {
@@ -69,8 +68,8 @@ PngDecoder::~PngDecoder() {
}
}
int PngDecoder::prepare(size_t download_size) {
ImageDecoder::prepare(download_size);
int PngDecoder::prepare(size_t expected_size) {
ImageDecoder::prepare(expected_size);
if (!this->pngle_) {
ESP_LOGE(TAG, "PNG decoder engine not initialized!");
return DECODE_ERROR_OUT_OF_MEMORY;
@@ -86,8 +85,9 @@ int HOT PngDecoder::decode(uint8_t *buffer, size_t size) {
ESP_LOGE(TAG, "PNG decoder engine not initialized!");
return DECODE_ERROR_OUT_OF_MEMORY;
}
if (size < 256 && size < this->download_size_ - this->decoded_bytes_) {
ESP_LOGD(TAG, "Waiting for data");
// PNG can be decoded progressively, but wait for a reasonable chunk
if (size < 256 && this->expected_size_ > 0 && size < this->expected_size_ - this->decoded_bytes_) {
ESP_LOGD(TAG, "Waiting for more data");
return 0;
}
auto fed = pngle_feed(this->pngle_, buffer, size);
@@ -99,7 +99,6 @@ int HOT PngDecoder::decode(uint8_t *buffer, size_t size) {
return fed;
}
} // namespace online_image
} // namespace esphome
} // namespace esphome::runtime_image
#endif // USE_ONLINE_IMAGE_PNG_SUPPORT
#endif // USE_RUNTIME_IMAGE_PNG

View File

@@ -3,11 +3,11 @@
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#include "image_decoder.h"
#ifdef USE_ONLINE_IMAGE_PNG_SUPPORT
#include "runtime_image.h"
#ifdef USE_RUNTIME_IMAGE_PNG
#include <pngle.h>
namespace esphome {
namespace online_image {
namespace esphome::runtime_image {
/**
* @brief Image decoder specialization for PNG images.
@@ -17,12 +17,12 @@ class PngDecoder : public ImageDecoder {
/**
* @brief Construct a new PNG Decoder object.
*
* @param display The image to decode the stream into.
* @param image The RuntimeImage to decode the stream into.
*/
PngDecoder(OnlineImage *image);
PngDecoder(RuntimeImage *image);
~PngDecoder() override;
int prepare(size_t download_size) override;
int prepare(size_t expected_size) override;
int HOT decode(uint8_t *buffer, size_t size) override;
void increment_pixels_decoded(uint32_t count) { this->pixels_decoded_ += count; }
@@ -30,11 +30,10 @@ class PngDecoder : public ImageDecoder {
protected:
RAMAllocator<pngle_t> allocator_;
pngle_t *pngle_;
pngle_t *pngle_{nullptr};
uint32_t pixels_decoded_{0};
};
} // namespace online_image
} // namespace esphome
} // namespace esphome::runtime_image
#endif // USE_ONLINE_IMAGE_PNG_SUPPORT
#endif // USE_RUNTIME_IMAGE_PNG

View File

@@ -0,0 +1,300 @@
#include "runtime_image.h"
#include "image_decoder.h"
#include "esphome/core/log.h"
#include "esphome/core/helpers.h"
#include <cstring>
#ifdef USE_RUNTIME_IMAGE_BMP
#include "bmp_decoder.h"
#endif
#ifdef USE_RUNTIME_IMAGE_JPEG
#include "jpeg_decoder.h"
#endif
#ifdef USE_RUNTIME_IMAGE_PNG
#include "png_decoder.h"
#endif
namespace esphome::runtime_image {
static const char *const TAG = "runtime_image";
inline bool is_color_on(const Color &color) {
// This produces the most accurate monochrome conversion, but is slightly slower.
// return (0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b) > 127;
// Approximation using fast integer computations; produces acceptable results
// Equivalent to 0.25 * R + 0.5 * G + 0.25 * B
return ((color.r >> 2) + (color.g >> 1) + (color.b >> 2)) & 0x80;
}
RuntimeImage::RuntimeImage(ImageFormat format, image::ImageType type, image::Transparency transparency,
image::Image *placeholder, bool is_big_endian, int fixed_width, int fixed_height)
: Image(nullptr, 0, 0, type, transparency),
format_(format),
fixed_width_(fixed_width),
fixed_height_(fixed_height),
placeholder_(placeholder),
is_big_endian_(is_big_endian) {}
RuntimeImage::~RuntimeImage() { this->release(); }
int RuntimeImage::resize(int width, int height) {
// Use fixed dimensions if specified (0 means auto-resize)
int target_width = this->fixed_width_ ? this->fixed_width_ : width;
int target_height = this->fixed_height_ ? this->fixed_height_ : height;
size_t result = this->resize_buffer_(target_width, target_height);
if (result > 0 && this->progressive_display_) {
// Update display dimensions for progressive display
this->width_ = this->buffer_width_;
this->height_ = this->buffer_height_;
this->data_start_ = this->buffer_;
}
return result;
}
void RuntimeImage::draw_pixel(int x, int y, const Color &color) {
if (!this->buffer_) {
ESP_LOGE(TAG, "Buffer not allocated!");
return;
}
if (x < 0 || y < 0 || x >= this->buffer_width_ || y >= this->buffer_height_) {
ESP_LOGE(TAG, "Tried to paint a pixel (%d,%d) outside the image!", x, y);
return;
}
switch (this->type_) {
case image::IMAGE_TYPE_BINARY: {
const uint32_t width_8 = ((this->buffer_width_ + 7u) / 8u) * 8u;
uint32_t pos = x + y * width_8;
auto bitno = 0x80 >> (pos % 8u);
pos /= 8u;
auto on = is_color_on(color);
if (this->has_transparency() && color.w < 0x80)
on = false;
if (on) {
this->buffer_[pos] |= bitno;
} else {
this->buffer_[pos] &= ~bitno;
}
break;
}
case image::IMAGE_TYPE_GRAYSCALE: {
uint32_t pos = this->get_position_(x, y);
auto gray = static_cast<uint8_t>(0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b);
if (this->transparency_ == image::TRANSPARENCY_CHROMA_KEY) {
if (gray == 1) {
gray = 0;
}
if (color.w < 0x80) {
gray = 1;
}
} else if (this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) {
if (color.w != 0xFF)
gray = color.w;
}
this->buffer_[pos] = gray;
break;
}
case image::IMAGE_TYPE_RGB565: {
uint32_t pos = this->get_position_(x, y);
Color mapped_color = color;
this->map_chroma_key(mapped_color);
uint16_t rgb565 = display::ColorUtil::color_to_565(mapped_color);
if (this->is_big_endian_) {
this->buffer_[pos + 0] = static_cast<uint8_t>((rgb565 >> 8) & 0xFF);
this->buffer_[pos + 1] = static_cast<uint8_t>(rgb565 & 0xFF);
} else {
this->buffer_[pos + 0] = static_cast<uint8_t>(rgb565 & 0xFF);
this->buffer_[pos + 1] = static_cast<uint8_t>((rgb565 >> 8) & 0xFF);
}
if (this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) {
this->buffer_[pos + 2] = color.w;
}
break;
}
case image::IMAGE_TYPE_RGB: {
uint32_t pos = this->get_position_(x, y);
Color mapped_color = color;
this->map_chroma_key(mapped_color);
this->buffer_[pos + 0] = mapped_color.r;
this->buffer_[pos + 1] = mapped_color.g;
this->buffer_[pos + 2] = mapped_color.b;
if (this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) {
this->buffer_[pos + 3] = color.w;
}
break;
}
}
}
void RuntimeImage::map_chroma_key(Color &color) {
if (this->transparency_ == image::TRANSPARENCY_CHROMA_KEY) {
if (color.g == 1 && color.r == 0 && color.b == 0) {
color.g = 0;
}
if (color.w < 0x80) {
color.r = 0;
color.g = this->type_ == image::IMAGE_TYPE_RGB565 ? 4 : 1;
color.b = 0;
}
}
}
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
Image::draw(x, y, display, color_on, color_off);
} else if (this->placeholder_) {
// Show placeholder while the runtime image is not available
this->placeholder_->draw(x, y, display, color_on, color_off);
}
// If no image is loaded and no placeholder, nothing to draw
}
bool RuntimeImage::begin_decode(size_t expected_size) {
if (this->decoder_) {
ESP_LOGW(TAG, "Decoding already in progress");
return false;
}
this->decoder_ = this->create_decoder_();
if (!this->decoder_) {
ESP_LOGE(TAG, "Failed to create decoder for format %d", this->format_);
return false;
}
this->total_size_ = expected_size;
this->decoded_bytes_ = 0;
// Initialize decoder
int result = this->decoder_->prepare(expected_size);
if (result < 0) {
ESP_LOGE(TAG, "Failed to prepare decoder: %d", result);
this->decoder_ = nullptr;
return false;
}
return true;
}
int RuntimeImage::feed_data(uint8_t *data, size_t len) {
if (!this->decoder_) {
ESP_LOGE(TAG, "No decoder initialized");
return -1;
}
int consumed = this->decoder_->decode(data, len);
if (consumed > 0) {
this->decoded_bytes_ += consumed;
}
return consumed;
}
bool RuntimeImage::end_decode() {
if (!this->decoder_) {
return false;
}
// Finalize the image for display
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_;
}
// Clean up decoder
this->decoder_ = nullptr;
ESP_LOGD(TAG, "Decoding complete: %dx%d, %zu bytes", this->width_, this->height_, this->decoded_bytes_);
return true;
}
bool RuntimeImage::is_decode_finished() const {
if (!this->decoder_) {
return false;
}
return this->decoder_->is_finished();
}
void RuntimeImage::release() {
this->release_buffer_();
// Reset decoder separately — release() can be called from within the decoder
// (via set_size -> resize -> resize_buffer_), so we must not destroy the decoder here.
// The decoder lifecycle is managed by begin_decode()/end_decode().
this->decoder_ = nullptr;
}
void RuntimeImage::release_buffer_() {
if (this->buffer_) {
ESP_LOGV(TAG, "Releasing buffer of size %zu", this->get_buffer_size_(this->buffer_width_, this->buffer_height_));
this->allocator_.deallocate(this->buffer_, this->get_buffer_size_(this->buffer_width_, this->buffer_height_));
this->buffer_ = nullptr;
this->data_start_ = nullptr;
this->width_ = 0;
this->height_ = 0;
this->buffer_width_ = 0;
this->buffer_height_ = 0;
}
}
size_t RuntimeImage::resize_buffer_(int width, int height) {
size_t new_size = this->get_buffer_size_(width, height);
if (this->buffer_ && this->buffer_width_ == width && this->buffer_height_ == height) {
// Buffer already allocated with correct size
return new_size;
}
// Release old buffer if dimensions changed
if (this->buffer_) {
this->release_buffer_();
}
ESP_LOGD(TAG, "Allocating buffer: %dx%d, %zu bytes", width, height, new_size);
this->buffer_ = this->allocator_.allocate(new_size);
if (!this->buffer_) {
ESP_LOGE(TAG, "Failed to allocate %zu bytes. Largest free block: %zu", new_size,
this->allocator_.get_max_free_block_size());
return 0;
}
// Clear buffer
memset(this->buffer_, 0, new_size);
this->buffer_width_ = width;
this->buffer_height_ = height;
return new_size;
}
size_t RuntimeImage::get_buffer_size_(int width, int height) const {
return (this->get_bpp() * width + 7u) / 8u * height;
}
int RuntimeImage::get_position_(int x, int y) const { return (x + y * this->buffer_width_) * this->get_bpp() / 8; }
std::unique_ptr<ImageDecoder> RuntimeImage::create_decoder_() {
switch (this->format_) {
#ifdef USE_RUNTIME_IMAGE_BMP
case BMP:
return make_unique<BmpDecoder>(this);
#endif
#ifdef USE_RUNTIME_IMAGE_JPEG
case JPEG:
return make_unique<JpegDecoder>(this);
#endif
#ifdef USE_RUNTIME_IMAGE_PNG
case PNG:
return make_unique<PngDecoder>(this);
#endif
default:
ESP_LOGE(TAG, "Unsupported image format: %d", this->format_);
return nullptr;
}
}
} // namespace esphome::runtime_image

View File

@@ -0,0 +1,214 @@
#pragma once
#include "esphome/components/image/image.h"
#include "esphome/core/helpers.h"
namespace esphome::runtime_image {
// Forward declaration
class ImageDecoder;
/**
* @brief Image format types that can be decoded dynamically.
*/
enum ImageFormat {
/** Automatically detect from data. Not implemented yet. */
AUTO,
/** JPEG format. */
JPEG,
/** PNG format. */
PNG,
/** BMP format. */
BMP,
};
/**
* @brief A dynamic image that can be loaded and decoded at runtime.
*
* This class provides dynamic buffer allocation and management for images
* that are decoded at runtime, as opposed to static images compiled into
* the firmware. It serves as a base class for components that need to
* load images dynamically from various sources.
*/
class RuntimeImage : public image::Image {
public:
/**
* @brief Construct a new RuntimeImage object.
*
* @param format The image format to decode.
* @param type The pixel format for the image.
* @param transparency The transparency type for the image.
* @param placeholder Optional placeholder image to show while loading.
* @param is_big_endian Whether the image is stored in big-endian format.
* @param fixed_width Fixed width for the image (0 for auto-resize).
* @param fixed_height Fixed height for the image (0 for auto-resize).
*/
RuntimeImage(ImageFormat format, image::ImageType type, image::Transparency transparency,
image::Image *placeholder = nullptr, bool is_big_endian = false, int fixed_width = 0,
int fixed_height = 0);
~RuntimeImage();
// Decoder interface methods
/**
* @brief Resize the image buffer to the requested dimensions.
*
* The buffer will be allocated if not existing.
* If fixed dimensions have been specified in the constructor, the buffer will be created
* with those dimensions and not resized, even on request.
* Otherwise, the old buffer will be deallocated and a new buffer with the requested
* dimensions allocated.
*
* @param width Requested width (ignored if fixed_width_ is set)
* @param height Requested height (ignored if fixed_height_ is set)
* @return Size of the allocated buffer in bytes, or 0 if allocation failed.
*/
int resize(int width, int height);
void draw_pixel(int x, int y, const Color &color);
void map_chroma_key(Color &color);
int get_buffer_width() const { return this->buffer_width_; }
int get_buffer_height() const { return this->buffer_height_; }
// Image drawing interface
void draw(int x, int y, display::Display *display, Color color_on, Color color_off) override;
/**
* @brief Begin decoding an image.
*
* @param expected_size Optional hint about the expected data size.
* @return true if decoder was successfully initialized.
*/
bool begin_decode(size_t expected_size = 0);
/**
* @brief Feed data to the decoder.
*
* @param data Pointer to the data buffer.
* @param len Length of data to process.
* @return Number of bytes consumed by the decoder.
*/
int feed_data(uint8_t *data, size_t len);
/**
* @brief Complete the decoding process.
*
* @return true if decoding completed successfully.
*/
bool end_decode();
/**
* @brief Check if decoding is currently in progress.
*/
bool is_decoding() const { return this->decoder_ != nullptr; }
/**
* @brief Check if the decoder has finished processing all data.
*
* This delegates to the decoder's format-specific completion check,
* which handles both known-size and chunked transfer cases.
*/
bool is_decode_finished() const;
/**
* @brief Check if an image is currently loaded.
*/
bool is_loaded() const { return this->buffer_ != nullptr; }
/**
* @brief Get the image format.
*/
ImageFormat get_format() const { return this->format_; }
/**
* @brief Release the image buffer and free memory.
*/
void release();
/**
* @brief Set whether to allow progressive display during decode.
*
* When enabled, the image can be displayed even while still decoding.
* When disabled, the image is only displayed after decoding completes.
*/
void set_progressive_display(bool progressive) { this->progressive_display_ = progressive; }
protected:
/**
* @brief Resize the image buffer to the requested dimensions.
*
* @param width New width in pixels.
* @param height New height in pixels.
* @return Size of the allocated buffer, or 0 on failure.
*/
size_t resize_buffer_(int width, int height);
/**
* @brief Release only the image buffer without resetting the decoder.
*
* This is safe to call from within the decoder (e.g., during resize).
*/
void release_buffer_();
/**
* @brief Get the buffer size in bytes for given dimensions.
*/
size_t get_buffer_size_(int width, int height) const;
/**
* @brief Get the position in the buffer for a pixel.
*/
int get_position_(int x, int y) const;
/**
* @brief Create decoder instance for the image's format.
*/
std::unique_ptr<ImageDecoder> create_decoder_();
// Memory management
RAMAllocator<uint8_t> allocator_{};
uint8_t *buffer_{nullptr};
// Decoder management
std::unique_ptr<ImageDecoder> decoder_{nullptr};
/** The image format this RuntimeImage is configured to decode. */
const ImageFormat format_;
/**
* Actual width of the current image.
* This needs to be separate from "Image::get_width()" because the latter
* must return 0 until the image has been decoded (to avoid showing partially
* decoded images). When progressive_display_ is enabled, Image dimensions
* are updated during decoding to allow rendering in progress.
*/
int buffer_width_{0};
/**
* Actual height of the current image.
* This needs to be separate from "Image::get_height()" because the latter
* must return 0 until the image has been decoded (to avoid showing partially
* decoded images). When progressive_display_ is enabled, Image dimensions
* are updated during decoding to allow rendering in progress.
*/
int buffer_height_{0};
// Decoding state
size_t total_size_{0};
size_t decoded_bytes_{0};
/** Fixed width requested on configuration, or 0 if not specified. */
const int fixed_width_{0};
/** Fixed height requested on configuration, or 0 if not specified. */
const int fixed_height_{0};
/** Placeholder image to show when the runtime image is not available. */
image::Image *placeholder_{nullptr};
// Configuration
bool progressive_display_{false};
/**
* Whether the image is stored in big-endian format.
* This is used to determine how to store 16 bit colors in the buffer.
*/
bool is_big_endian_{false};
};
} // namespace esphome::runtime_image

View File

@@ -90,7 +90,6 @@ void IDFUARTComponent::setup() {
return;
}
this->uart_num_ = static_cast<uart_port_t>(next_uart_num++);
this->lock_ = xSemaphoreCreateMutex();
#if (SOC_UART_LP_NUM >= 1)
size_t fifo_len = ((this->uart_num_ < SOC_UART_HP_NUM) ? SOC_UART_FIFO_LEN : SOC_LP_UART_FIFO_LEN);
@@ -102,11 +101,7 @@ void IDFUARTComponent::setup() {
this->rx_buffer_size_ = fifo_len * 2;
}
xSemaphoreTake(this->lock_, portMAX_DELAY);
this->load_settings(false);
xSemaphoreGive(this->lock_);
}
void IDFUARTComponent::load_settings(bool dump_config) {
@@ -126,13 +121,20 @@ void IDFUARTComponent::load_settings(bool dump_config) {
return;
}
}
#ifdef USE_UART_WAKE_LOOP_ON_RX
constexpr int event_queue_size = 20;
QueueHandle_t *event_queue_ptr = &this->uart_event_queue_;
#else
constexpr int event_queue_size = 0;
QueueHandle_t *event_queue_ptr = nullptr;
#endif
err = uart_driver_install(this->uart_num_, // UART number
this->rx_buffer_size_, // RX ring buffer size
0, // TX ring buffer size. If zero, driver will not use a TX buffer and TX function will
// block task until all data has been sent out
20, // event queue size/depth
&this->uart_event_queue_, // event queue
0 // Flags used to allocate the interrupt
0, // TX ring buffer size. If zero, driver will not use a TX buffer and TX function will
// block task until all data has been sent out
event_queue_size, // event queue size/depth
event_queue_ptr, // event queue
0 // Flags used to allocate the interrupt
);
if (err != ESP_OK) {
ESP_LOGW(TAG, "uart_driver_install failed: %s", esp_err_to_name(err));
@@ -282,9 +284,7 @@ void IDFUARTComponent::set_rx_timeout(size_t rx_timeout) {
}
void IDFUARTComponent::write_array(const uint8_t *data, size_t len) {
xSemaphoreTake(this->lock_, portMAX_DELAY);
int32_t write_len = uart_write_bytes(this->uart_num_, data, len);
xSemaphoreGive(this->lock_);
if (write_len != (int32_t) len) {
ESP_LOGW(TAG, "uart_write_bytes failed: %d != %zu", write_len, len);
this->mark_failed();
@@ -299,7 +299,6 @@ void IDFUARTComponent::write_array(const uint8_t *data, size_t len) {
bool IDFUARTComponent::peek_byte(uint8_t *data) {
if (!this->check_read_timeout_())
return false;
xSemaphoreTake(this->lock_, portMAX_DELAY);
if (this->has_peek_) {
*data = this->peek_byte_;
} else {
@@ -311,7 +310,6 @@ bool IDFUARTComponent::peek_byte(uint8_t *data) {
this->peek_byte_ = *data;
}
}
xSemaphoreGive(this->lock_);
return true;
}
@@ -320,7 +318,6 @@ bool IDFUARTComponent::read_array(uint8_t *data, size_t len) {
int32_t read_len = 0;
if (!this->check_read_timeout_(len))
return false;
xSemaphoreTake(this->lock_, portMAX_DELAY);
if (this->has_peek_) {
length_to_read--;
*data = this->peek_byte_;
@@ -329,7 +326,6 @@ bool IDFUARTComponent::read_array(uint8_t *data, size_t len) {
}
if (length_to_read > 0)
read_len = uart_read_bytes(this->uart_num_, data, length_to_read, 20 / portTICK_PERIOD_MS);
xSemaphoreGive(this->lock_);
#ifdef USE_UART_DEBUGGER
for (size_t i = 0; i < len; i++) {
this->debug_callback_.call(UART_DIRECTION_RX, data[i]);
@@ -342,9 +338,7 @@ size_t IDFUARTComponent::available() {
size_t available = 0;
esp_err_t err;
xSemaphoreTake(this->lock_, portMAX_DELAY);
err = uart_get_buffered_data_len(this->uart_num_, &available);
xSemaphoreGive(this->lock_);
if (err != ESP_OK) {
ESP_LOGW(TAG, "uart_get_buffered_data_len failed: %s", esp_err_to_name(err));
@@ -358,9 +352,7 @@ size_t IDFUARTComponent::available() {
void IDFUARTComponent::flush() {
ESP_LOGVV(TAG, " Flushing");
xSemaphoreTake(this->lock_, portMAX_DELAY);
uart_wait_tx_done(this->uart_num_, portMAX_DELAY);
xSemaphoreGive(this->lock_);
}
void IDFUARTComponent::check_logger_conflict() {}
@@ -384,6 +376,13 @@ void IDFUARTComponent::start_rx_event_task_() {
ESP_LOGV(TAG, "RX event task started");
}
// FreeRTOS task that relays UART ISR events to the main loop.
// This task exists because wake_loop_threadsafe() is not ISR-safe (it uses a
// UDP loopback socket), so we need a task as an ISR-to-main-loop trampoline.
// IMPORTANT: This task must NOT call any UART wrapper methods (read_array,
// write_array, peek_byte, etc.) or touch has_peek_/peek_byte_ — all reading
// is done by the main loop. This task only reads from the event queue and
// calls App.wake_loop_threadsafe().
void IDFUARTComponent::rx_event_task_func(void *param) {
auto *self = static_cast<IDFUARTComponent *>(param);
uart_event_t event;
@@ -405,8 +404,14 @@ void IDFUARTComponent::rx_event_task_func(void *param) {
case UART_FIFO_OVF:
case UART_BUFFER_FULL:
ESP_LOGW(TAG, "FIFO overflow or ring buffer full - clearing");
uart_flush_input(self->uart_num_);
// Don't call uart_flush_input() here — this task does not own the read side.
// ESP-IDF examples flush on overflow because the same task handles both events
// and reads, so flush and read are serialized. Here, reads happen on the main
// loop, so flushing from this task races with read_array() and can destroy data
// mid-read. The driver self-heals without an explicit flush: uart_read_bytes()
// calls uart_check_buf_full() after each chunk, which moves stashed FIFO bytes
// into the ring buffer and re-enables RX interrupts once space is freed.
ESP_LOGW(TAG, "FIFO overflow or ring buffer full");
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE)
App.wake_loop_threadsafe();
#endif

View File

@@ -8,6 +8,13 @@
namespace esphome::uart {
/// ESP-IDF UART driver wrapper.
///
/// Thread safety: All public methods must only be called from the main loop.
/// The ESP-IDF UART driver API does not guarantee thread safety, and ESPHome's
/// peek byte state (has_peek_/peek_byte_) is not synchronized. The rx_event_task
/// (when enabled) must not call any of these methods — it communicates with the
/// main loop exclusively via App.wake_loop_threadsafe().
class IDFUARTComponent : public UARTComponent, public Component {
public:
void setup() override;
@@ -26,7 +33,9 @@ class IDFUARTComponent : public UARTComponent, public Component {
void flush() override;
uint8_t get_hw_serial_number() { return this->uart_num_; }
#ifdef USE_UART_WAKE_LOOP_ON_RX
QueueHandle_t *get_uart_event_queue() { return &this->uart_event_queue_; }
#endif
/**
* Load the UART with the current settings.
@@ -46,18 +55,20 @@ class IDFUARTComponent : public UARTComponent, public Component {
protected:
void check_logger_conflict() override;
uart_port_t uart_num_;
QueueHandle_t uart_event_queue_;
uart_config_t get_config_();
SemaphoreHandle_t lock_;
bool has_peek_{false};
uint8_t peek_byte_;
#ifdef USE_UART_WAKE_LOOP_ON_RX
// RX notification support
// RX notification support — runs on a separate FreeRTOS task.
// IMPORTANT: rx_event_task_func must NOT call any UART wrapper methods (read_array,
// write_array, etc.) or touch has_peek_/peek_byte_. It must only read from the
// event queue and call App.wake_loop_threadsafe().
void start_rx_event_task_();
static void rx_event_task_func(void *param);
QueueHandle_t uart_event_queue_;
TaskHandle_t rx_event_task_handle_{nullptr};
#endif // USE_UART_WAKE_LOOP_ON_RX
};

View File

@@ -148,6 +148,7 @@ class USBClient : public Component {
EventPool<UsbEvent, USB_EVENT_QUEUE_SIZE> event_pool;
protected:
void handle_open_state_();
TransferRequest *get_trq_(); // Lock-free allocation using atomic bitmask (multi-consumer safe)
virtual void disconnect();
virtual void on_connected() {}

View File

@@ -9,6 +9,7 @@
#include <cinttypes>
#include <cstring>
#include <atomic>
#include <span>
namespace esphome {
namespace usb_host {
@@ -142,18 +143,23 @@ static void usb_client_print_config_descriptor(const usb_config_desc_t *cfg_desc
} while (next_desc != NULL);
}
#endif
static std::string get_descriptor_string(const usb_str_desc_t *desc) {
char buffer[256];
if (desc == nullptr)
// USB string descriptors: bLength (uint8_t, max 255) includes the 2-byte header (bLength and bDescriptorType).
// Character count = (bLength - 2) / 2, max 126 chars + null terminator.
static constexpr size_t DESC_STRING_BUF_SIZE = 128;
static const char *get_descriptor_string(const usb_str_desc_t *desc, std::span<char, DESC_STRING_BUF_SIZE> buffer) {
if (desc == nullptr || desc->bLength < 2)
return "(unspecified)";
char *p = buffer;
for (int i = 0; i != desc->bLength / 2; i++) {
int char_count = (desc->bLength - 2) / 2;
char *p = buffer.data();
char *end = p + buffer.size() - 1;
for (int i = 0; i != char_count && p < end; i++) {
auto c = desc->wData[i];
if (c < 0x100)
*p++ = static_cast<char>(c);
}
*p = '\0';
return {buffer};
return buffer.data();
}
// CALLBACK CONTEXT: USB task (called from usb_host_client_handle_events in USB task)
@@ -259,60 +265,63 @@ void USBClient::loop() {
ESP_LOGW(TAG, "Dropped %u USB events due to queue overflow", dropped);
}
switch (this->state_) {
case USB_CLIENT_OPEN: {
int err;
ESP_LOGD(TAG, "Open device %d", this->device_addr_);
err = usb_host_device_open(this->handle_, this->device_addr_, &this->device_handle_);
if (err != ESP_OK) {
ESP_LOGW(TAG, "Device open failed: %s", esp_err_to_name(err));
this->state_ = USB_CLIENT_INIT;
break;
}
ESP_LOGD(TAG, "Get descriptor device %d", this->device_addr_);
const usb_device_desc_t *desc;
err = usb_host_get_device_descriptor(this->device_handle_, &desc);
if (err != ESP_OK) {
ESP_LOGW(TAG, "Device get_desc failed: %s", esp_err_to_name(err));
this->disconnect();
} else {
ESP_LOGD(TAG, "Device descriptor: vid %X pid %X", desc->idVendor, desc->idProduct);
if (desc->idVendor == this->vid_ && desc->idProduct == this->pid_ || this->vid_ == 0 && this->pid_ == 0) {
usb_device_info_t dev_info;
err = usb_host_device_info(this->device_handle_, &dev_info);
if (err != ESP_OK) {
ESP_LOGW(TAG, "Device info failed: %s", esp_err_to_name(err));
this->disconnect();
break;
}
this->state_ = USB_CLIENT_CONNECTED;
ESP_LOGD(TAG, "Device connected: Manuf: %s; Prod: %s; Serial: %s",
get_descriptor_string(dev_info.str_desc_manufacturer).c_str(),
get_descriptor_string(dev_info.str_desc_product).c_str(),
get_descriptor_string(dev_info.str_desc_serial_num).c_str());
if (this->state_ == USB_CLIENT_OPEN) {
this->handle_open_state_();
}
}
void USBClient::handle_open_state_() {
int err;
ESP_LOGD(TAG, "Open device %d", this->device_addr_);
err = usb_host_device_open(this->handle_, this->device_addr_, &this->device_handle_);
if (err != ESP_OK) {
ESP_LOGW(TAG, "Device open failed: %s", esp_err_to_name(err));
this->state_ = USB_CLIENT_INIT;
return;
}
ESP_LOGD(TAG, "Get descriptor device %d", this->device_addr_);
const usb_device_desc_t *desc;
err = usb_host_get_device_descriptor(this->device_handle_, &desc);
if (err != ESP_OK) {
ESP_LOGW(TAG, "Device get_desc failed: %s", esp_err_to_name(err));
this->disconnect();
return;
}
ESP_LOGD(TAG, "Device descriptor: vid %X pid %X", desc->idVendor, desc->idProduct);
if (desc->idVendor != this->vid_ || desc->idProduct != this->pid_) {
if (this->vid_ != 0 || this->pid_ != 0) {
ESP_LOGD(TAG, "Not our device, closing");
this->disconnect();
return;
}
}
usb_device_info_t dev_info;
err = usb_host_device_info(this->device_handle_, &dev_info);
if (err != ESP_OK) {
ESP_LOGW(TAG, "Device info failed: %s", esp_err_to_name(err));
this->disconnect();
return;
}
this->state_ = USB_CLIENT_CONNECTED;
char buf_manuf[DESC_STRING_BUF_SIZE];
char buf_product[DESC_STRING_BUF_SIZE];
char buf_serial[DESC_STRING_BUF_SIZE];
ESP_LOGD(TAG, "Device connected: Manuf: %s; Prod: %s; Serial: %s",
get_descriptor_string(dev_info.str_desc_manufacturer, buf_manuf),
get_descriptor_string(dev_info.str_desc_product, buf_product),
get_descriptor_string(dev_info.str_desc_serial_num, buf_serial));
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
const usb_device_desc_t *device_desc;
err = usb_host_get_device_descriptor(this->device_handle_, &device_desc);
if (err == ESP_OK)
usb_client_print_device_descriptor(device_desc);
const usb_config_desc_t *config_desc;
err = usb_host_get_active_config_descriptor(this->device_handle_, &config_desc);
if (err == ESP_OK)
usb_client_print_config_descriptor(config_desc, nullptr);
const usb_device_desc_t *device_desc;
err = usb_host_get_device_descriptor(this->device_handle_, &device_desc);
if (err == ESP_OK)
usb_client_print_device_descriptor(device_desc);
const usb_config_desc_t *config_desc;
err = usb_host_get_active_config_descriptor(this->device_handle_, &config_desc);
if (err == ESP_OK)
usb_client_print_config_descriptor(config_desc, nullptr);
#endif
this->on_connected();
} else {
ESP_LOGD(TAG, "Not our device, closing");
this->disconnect();
}
}
break;
}
default:
break;
}
this->on_connected();
}
void USBClient::on_opened(uint8_t addr) {

View File

@@ -198,7 +198,8 @@ EntityMatchResult UrlMatch::match_entity(EntityBase *entity) const {
#if !defined(USE_ESP32) && defined(USE_ARDUINO)
// helper for allowing only unique entries in the queue
void DeferredUpdateEventSource::deq_push_back_with_dedup_(void *source, message_generator_t *message_generator) {
void __attribute__((flatten))
DeferredUpdateEventSource::deq_push_back_with_dedup_(void *source, message_generator_t *message_generator) {
DeferredEvent item(source, message_generator);
// Use range-based for loop instead of std::find_if to reduce template instantiation overhead and binary size
@@ -557,7 +558,9 @@ static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, J
root[ESPHOME_F("device")] = device_name;
}
#endif
#ifdef USE_ENTITY_ICON
root[ESPHOME_F("icon")] = obj->get_icon_ref();
#endif
root[ESPHOME_F("entity_category")] = obj->get_entity_category();
bool is_disabled = obj->is_disabled_by_default();
if (is_disabled)
@@ -583,8 +586,7 @@ static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const c
// Helper to get request detail parameter
static JsonDetail get_request_detail(AsyncWebServerRequest *request) {
auto *param = request->getParam(ESPHOME_F("detail"));
return (param && param->value() == "all") ? DETAIL_ALL : DETAIL_STATE;
return request->arg(ESPHOME_F("detail")) == "all" ? DETAIL_ALL : DETAIL_STATE;
}
#ifdef USE_SENSOR
@@ -861,10 +863,10 @@ void WebServer::handle_fan_request(AsyncWebServerRequest *request, const UrlMatc
}
auto call = is_on ? obj->turn_on() : obj->turn_off();
parse_int_param_(request, ESPHOME_F("speed_level"), call, &decltype(call)::set_speed);
parse_num_param_(request, ESPHOME_F("speed_level"), call, &decltype(call)::set_speed);
if (request->hasParam(ESPHOME_F("oscillation"))) {
auto speed = request->getParam(ESPHOME_F("oscillation"))->value();
if (request->hasArg(ESPHOME_F("oscillation"))) {
auto speed = request->arg(ESPHOME_F("oscillation"));
auto val = parse_on_off(speed.c_str());
switch (val) {
case PARSE_ON:
@@ -1040,14 +1042,14 @@ void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMa
}
auto traits = obj->get_traits();
if ((request->hasParam(ESPHOME_F("position")) && !traits.get_supports_position()) ||
(request->hasParam(ESPHOME_F("tilt")) && !traits.get_supports_tilt())) {
if ((request->hasArg(ESPHOME_F("position")) && !traits.get_supports_position()) ||
(request->hasArg(ESPHOME_F("tilt")) && !traits.get_supports_tilt())) {
request->send(409);
return;
}
parse_float_param_(request, ESPHOME_F("position"), call, &decltype(call)::set_position);
parse_float_param_(request, ESPHOME_F("tilt"), call, &decltype(call)::set_tilt);
parse_num_param_(request, ESPHOME_F("position"), call, &decltype(call)::set_position);
parse_num_param_(request, ESPHOME_F("tilt"), call, &decltype(call)::set_tilt);
DEFER_ACTION(call, call.perform());
request->send(200);
@@ -1106,7 +1108,7 @@ void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlM
}
auto call = obj->make_call();
parse_float_param_(request, ESPHOME_F("value"), call, &decltype(call)::set_value);
parse_num_param_(request, ESPHOME_F("value"), call, &decltype(call)::set_value);
DEFER_ACTION(call, call.perform());
request->send(200);
@@ -1174,12 +1176,13 @@ void WebServer::handle_date_request(AsyncWebServerRequest *request, const UrlMat
auto call = obj->make_call();
if (!request->hasParam(ESPHOME_F("value"))) {
const auto &value = request->arg(ESPHOME_F("value"));
// Arduino String has isEmpty() not empty(), use length() for cross-platform compatibility
if (value.length() == 0) { // NOLINT(readability-container-size-empty)
request->send(409);
return;
}
parse_string_param_(request, ESPHOME_F("value"), call, &decltype(call)::set_date);
call.set_date(value.c_str(), value.length());
DEFER_ACTION(call, call.perform());
request->send(200);
@@ -1234,12 +1237,13 @@ void WebServer::handle_time_request(AsyncWebServerRequest *request, const UrlMat
auto call = obj->make_call();
if (!request->hasParam(ESPHOME_F("value"))) {
const auto &value = request->arg(ESPHOME_F("value"));
// Arduino String has isEmpty() not empty(), use length() for cross-platform compatibility
if (value.length() == 0) { // NOLINT(readability-container-size-empty)
request->send(409);
return;
}
parse_string_param_(request, ESPHOME_F("value"), call, &decltype(call)::set_time);
call.set_time(value.c_str(), value.length());
DEFER_ACTION(call, call.perform());
request->send(200);
@@ -1293,12 +1297,13 @@ void WebServer::handle_datetime_request(AsyncWebServerRequest *request, const Ur
auto call = obj->make_call();
if (!request->hasParam(ESPHOME_F("value"))) {
const auto &value = request->arg(ESPHOME_F("value"));
// Arduino String has isEmpty() not empty(), use length() for cross-platform compatibility
if (value.length() == 0) { // NOLINT(readability-container-size-empty)
request->send(409);
return;
}
parse_string_param_(request, ESPHOME_F("value"), call, &decltype(call)::set_datetime);
call.set_datetime(value.c_str(), value.length());
DEFER_ACTION(call, call.perform());
request->send(200);
@@ -1477,10 +1482,14 @@ void WebServer::handle_climate_request(AsyncWebServerRequest *request, const Url
parse_string_param_(request, ESPHOME_F("swing_mode"), call, &decltype(call)::set_swing_mode);
// Parse temperature parameters
parse_float_param_(request, ESPHOME_F("target_temperature_high"), call,
&decltype(call)::set_target_temperature_high);
parse_float_param_(request, ESPHOME_F("target_temperature_low"), call, &decltype(call)::set_target_temperature_low);
parse_float_param_(request, ESPHOME_F("target_temperature"), call, &decltype(call)::set_target_temperature);
// static_cast needed to disambiguate overloaded setters (float vs optional<float>)
using ClimateCall = decltype(call);
parse_num_param_(request, ESPHOME_F("target_temperature_high"), call,
static_cast<ClimateCall &(ClimateCall::*) (float)>(&ClimateCall::set_target_temperature_high));
parse_num_param_(request, ESPHOME_F("target_temperature_low"), call,
static_cast<ClimateCall &(ClimateCall::*) (float)>(&ClimateCall::set_target_temperature_low));
parse_num_param_(request, ESPHOME_F("target_temperature"), call,
static_cast<ClimateCall &(ClimateCall::*) (float)>(&ClimateCall::set_target_temperature));
DEFER_ACTION(call, call.perform());
request->send(200);
@@ -1721,12 +1730,12 @@ void WebServer::handle_valve_request(AsyncWebServerRequest *request, const UrlMa
}
auto traits = obj->get_traits();
if (request->hasParam(ESPHOME_F("position")) && !traits.get_supports_position()) {
if (request->hasArg(ESPHOME_F("position")) && !traits.get_supports_position()) {
request->send(409);
return;
}
parse_float_param_(request, ESPHOME_F("position"), call, &decltype(call)::set_position);
parse_num_param_(request, ESPHOME_F("position"), call, &decltype(call)::set_position);
DEFER_ACTION(call, call.perform());
request->send(200);
@@ -1870,12 +1879,12 @@ void WebServer::handle_water_heater_request(AsyncWebServerRequest *request, cons
parse_string_param_(request, ESPHOME_F("mode"), base_call, &water_heater::WaterHeaterCall::set_mode);
// Parse temperature parameters
parse_float_param_(request, ESPHOME_F("target_temperature"), base_call,
&water_heater::WaterHeaterCall::set_target_temperature);
parse_float_param_(request, ESPHOME_F("target_temperature_low"), base_call,
&water_heater::WaterHeaterCall::set_target_temperature_low);
parse_float_param_(request, ESPHOME_F("target_temperature_high"), base_call,
&water_heater::WaterHeaterCall::set_target_temperature_high);
parse_num_param_(request, ESPHOME_F("target_temperature"), base_call,
&water_heater::WaterHeaterCall::set_target_temperature);
parse_num_param_(request, ESPHOME_F("target_temperature_low"), base_call,
&water_heater::WaterHeaterCall::set_target_temperature_low);
parse_num_param_(request, ESPHOME_F("target_temperature_high"), base_call,
&water_heater::WaterHeaterCall::set_target_temperature_high);
// Parse away mode parameter
parse_bool_param_(request, ESPHOME_F("away"), base_call, &water_heater::WaterHeaterCall::set_away);
@@ -1979,16 +1988,16 @@ void WebServer::handle_infrared_request(AsyncWebServerRequest *request, const Ur
auto call = obj->make_call();
// Parse carrier frequency (optional)
if (request->hasParam(ESPHOME_F("carrier_frequency"))) {
auto value = parse_number<uint32_t>(request->getParam(ESPHOME_F("carrier_frequency"))->value().c_str());
{
auto value = parse_number<uint32_t>(request->arg(ESPHOME_F("carrier_frequency")).c_str());
if (value.has_value()) {
call.set_carrier_frequency(*value);
}
}
// Parse repeat count (optional, defaults to 1)
if (request->hasParam(ESPHOME_F("repeat_count"))) {
auto value = parse_number<uint32_t>(request->getParam(ESPHOME_F("repeat_count"))->value().c_str());
{
auto value = parse_number<uint32_t>(request->arg(ESPHOME_F("repeat_count")).c_str());
if (value.has_value()) {
call.set_repeat_count(*value);
}
@@ -1996,18 +2005,12 @@ void WebServer::handle_infrared_request(AsyncWebServerRequest *request, const Ur
// Parse base64url-encoded raw timings (required)
// Base64url is URL-safe: uses A-Za-z0-9-_ (no special characters needing escaping)
if (!request->hasParam(ESPHOME_F("data"))) {
request->send(400, ESPHOME_F("text/plain"), ESPHOME_F("Missing 'data' parameter"));
return;
}
const auto &data_arg = request->arg(ESPHOME_F("data"));
// .c_str() is required for Arduino framework where value() returns Arduino String instead of std::string
std::string encoded =
request->getParam(ESPHOME_F("data"))->value().c_str(); // NOLINT(readability-redundant-string-cstr)
// Validate base64url is not empty
if (encoded.empty()) {
request->send(400, ESPHOME_F("text/plain"), ESPHOME_F("Empty 'data' parameter"));
// Validate base64url is not empty (also catches missing parameter since arg() returns empty string)
// Arduino String has isEmpty() not empty(), use length() for cross-platform compatibility
if (data_arg.length() == 0) { // NOLINT(readability-container-size-empty)
request->send(400, ESPHOME_F("text/plain"), ESPHOME_F("Missing or empty 'data' parameter"));
return;
}
@@ -2015,7 +2018,7 @@ void WebServer::handle_infrared_request(AsyncWebServerRequest *request, const Ur
// it outlives the call - set_raw_timings_base64url stores a pointer, so the string
// must remain valid until perform() completes.
// ESP8266 also needs this because ESPAsyncWebServer callbacks run in "sys" context.
this->defer([call, encoded = std::move(encoded)]() mutable {
this->defer([call, encoded = std::string(data_arg.c_str(), data_arg.length())]() mutable {
call.set_raw_timings_base64url(encoded);
call.perform();
});

View File

@@ -513,11 +513,9 @@ class WebServer : public Controller,
template<typename T, typename Ret>
void parse_light_param_(AsyncWebServerRequest *request, ParamNameType param_name, T &call, Ret (T::*setter)(float),
float scale = 1.0f) {
if (request->hasParam(param_name)) {
auto value = parse_number<float>(request->getParam(param_name)->value().c_str());
if (value.has_value()) {
(call.*setter)(*value / scale);
}
auto value = parse_number<float>(request->arg(param_name).c_str());
if (value.has_value()) {
(call.*setter)(*value / scale);
}
}
@@ -525,34 +523,19 @@ class WebServer : public Controller,
template<typename T, typename Ret>
void parse_light_param_uint_(AsyncWebServerRequest *request, ParamNameType param_name, T &call,
Ret (T::*setter)(uint32_t), uint32_t scale = 1) {
if (request->hasParam(param_name)) {
auto value = parse_number<uint32_t>(request->getParam(param_name)->value().c_str());
if (value.has_value()) {
(call.*setter)(*value * scale);
}
auto value = parse_number<uint32_t>(request->arg(param_name).c_str());
if (value.has_value()) {
(call.*setter)(*value * scale);
}
}
#endif
// Generic helper to parse and apply a float parameter
template<typename T, typename Ret>
void parse_float_param_(AsyncWebServerRequest *request, ParamNameType param_name, T &call, Ret (T::*setter)(float)) {
if (request->hasParam(param_name)) {
auto value = parse_number<float>(request->getParam(param_name)->value().c_str());
if (value.has_value()) {
(call.*setter)(*value);
}
}
}
// Generic helper to parse and apply an int parameter
template<typename T, typename Ret>
void parse_int_param_(AsyncWebServerRequest *request, ParamNameType param_name, T &call, Ret (T::*setter)(int)) {
if (request->hasParam(param_name)) {
auto value = parse_number<int>(request->getParam(param_name)->value().c_str());
if (value.has_value()) {
(call.*setter)(*value);
}
// Generic helper to parse and apply a numeric parameter
template<typename NumT, typename T, typename Ret>
void parse_num_param_(AsyncWebServerRequest *request, ParamNameType param_name, T &call, Ret (T::*setter)(NumT)) {
auto value = parse_number<NumT>(request->arg(param_name).c_str());
if (value.has_value()) {
(call.*setter)(*value);
}
}
@@ -560,10 +543,9 @@ class WebServer : public Controller,
template<typename T, typename Ret>
void parse_string_param_(AsyncWebServerRequest *request, ParamNameType param_name, T &call,
Ret (T::*setter)(const std::string &)) {
if (request->hasParam(param_name)) {
// .c_str() is required for Arduino framework where value() returns Arduino String instead of std::string
std::string value = request->getParam(param_name)->value().c_str(); // NOLINT(readability-redundant-string-cstr)
(call.*setter)(value);
if (request->hasArg(param_name)) {
const auto &value = request->arg(param_name);
(call.*setter)(std::string(value.c_str(), value.length()));
}
}
@@ -573,8 +555,9 @@ class WebServer : public Controller,
// Invalid values are ignored (setter not called)
template<typename T, typename Ret>
void parse_bool_param_(AsyncWebServerRequest *request, ParamNameType param_name, T &call, Ret (T::*setter)(bool)) {
if (request->hasParam(param_name)) {
auto param_value = request->getParam(param_name)->value();
const auto &param_value = request->arg(param_name);
// Arduino String has isEmpty() not empty(), use length() for cross-platform compatibility
if (param_value.length() > 0) { // NOLINT(readability-container-size-empty)
// First check on/off (default), then true/false (custom)
auto val = parse_on_off(param_value.c_str());
if (val == PARSE_NONE) {

View File

@@ -1,17 +1,13 @@
#ifdef USE_ESP32
#include <memory>
#include <cstring>
#include <cctype>
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "http_parser.h"
#include "utils.h"
namespace esphome::web_server_idf {
static const char *const TAG = "web_server_idf_utils";
size_t url_decode(char *str) {
char *start = str;
char *ptr = str, buf;
@@ -54,32 +50,15 @@ optional<std::string> request_get_header(httpd_req_t *req, const char *name) {
return {str};
}
optional<std::string> request_get_url_query(httpd_req_t *req) {
auto len = httpd_req_get_url_query_len(req);
if (len == 0) {
return {};
}
std::string str;
str.resize(len);
auto res = httpd_req_get_url_query_str(req, &str[0], len + 1);
if (res != ESP_OK) {
ESP_LOGW(TAG, "Can't get query for request: %s", esp_err_to_name(res));
return {};
}
return {str};
}
optional<std::string> query_key_value(const char *query_url, size_t query_len, const char *key) {
if (query_url == nullptr || query_len == 0) {
return {};
}
// Use stack buffer for typical query strings, heap fallback for large ones
SmallBufferWithHeapFallback<256, char> val(query_len);
// Value can't exceed query_len. Use small stack buffer for typical values,
// heap fallback for long ones (e.g. base64 IR data) to limit stack usage
// since callers may also have stack buffers for the query string.
SmallBufferWithHeapFallback<128, char> val(query_len);
if (httpd_query_key_value(query_url, key, val.get(), query_len) != ESP_OK) {
return {};
}
@@ -88,6 +67,18 @@ optional<std::string> query_key_value(const char *query_url, size_t query_len, c
return {val.get()};
}
bool query_has_key(const char *query_url, size_t query_len, const char *key) {
if (query_url == nullptr || query_len == 0) {
return false;
}
// Minimal buffer — we only care if the key exists, not the value
char buf[1];
// httpd_query_key_value returns ESP_OK if found, ESP_ERR_HTTPD_RESULT_TRUNC if found
// but value truncated (expected with 1-byte buffer), or other errors for invalid input
auto err = httpd_query_key_value(query_url, key, buf, sizeof(buf));
return err == ESP_OK || err == ESP_ERR_HTTPD_RESULT_TRUNC;
}
// Helper function for case-insensitive string region comparison
bool str_ncmp_ci(const char *s1, const char *s2, size_t n) {
for (size_t i = 0; i < n; i++) {

View File

@@ -13,11 +13,8 @@ size_t url_decode(char *str);
bool request_has_header(httpd_req_t *req, const char *name);
optional<std::string> request_get_header(httpd_req_t *req, const char *name);
optional<std::string> request_get_url_query(httpd_req_t *req);
optional<std::string> query_key_value(const char *query_url, size_t query_len, const char *key);
inline optional<std::string> query_key_value(const std::string &query_url, const std::string &key) {
return query_key_value(query_url.c_str(), query_url.size(), key.c_str());
}
bool query_has_key(const char *query_url, size_t query_len, const char *key);
// Helper function for case-insensitive character comparison
inline bool char_equals_ci(char a, char b) { return ::tolower(a) == ::tolower(b); }

View File

@@ -393,13 +393,7 @@ AsyncWebParameter *AsyncWebServerRequest::getParam(const char *name) {
}
// Look up value from query strings
optional<std::string> val = query_key_value(this->post_query_.c_str(), this->post_query_.size(), name);
if (!val.has_value()) {
auto url_query = request_get_url_query(*this);
if (url_query.has_value()) {
val = query_key_value(url_query.value().c_str(), url_query.value().size(), name);
}
}
auto val = this->find_query_value_(name);
// Don't cache misses to avoid wasting memory when handlers check for
// optional parameters that don't exist in the request
@@ -412,6 +406,50 @@ AsyncWebParameter *AsyncWebServerRequest::getParam(const char *name) {
return param;
}
/// Search post_query then URL query with a callback.
/// Returns first truthy result, or value-initialized default.
/// URL query is accessed directly from req->uri (same pattern as url_to()).
template<typename Func>
static auto search_query_sources(httpd_req_t *req, const std::string &post_query, const char *name, Func func)
-> decltype(func(nullptr, size_t{0}, name)) {
if (!post_query.empty()) {
auto result = func(post_query.c_str(), post_query.size(), name);
if (result) {
return result;
}
}
// Use httpd API for query length, then access string directly from URI.
// http_parser identifies components by offset/length without modifying the URI string.
// This is the same pattern used by url_to().
auto len = httpd_req_get_url_query_len(req);
if (len == 0) {
return {};
}
const char *query = strchr(req->uri, '?');
if (query == nullptr) {
return {};
}
query++; // skip '?'
return func(query, len, name);
}
optional<std::string> AsyncWebServerRequest::find_query_value_(const char *name) const {
return search_query_sources(this->req_, this->post_query_, name,
[](const char *q, size_t len, const char *k) { return query_key_value(q, len, k); });
}
bool AsyncWebServerRequest::hasArg(const char *name) {
return search_query_sources(this->req_, this->post_query_, name, query_has_key);
}
std::string AsyncWebServerRequest::arg(const char *name) {
auto val = this->find_query_value_(name);
if (val.has_value()) {
return std::move(val.value());
}
return {};
}
void AsyncWebServerResponse::addHeader(const char *name, const char *value) {
httpd_resp_set_hdr(*this->req_, name, value);
}

View File

@@ -116,7 +116,8 @@ class AsyncWebServerRequest {
/// Write URL (without query string) to buffer, returns StringRef pointing to buffer.
/// URL is decoded (e.g., %20 -> space).
StringRef url_to(std::span<char, URL_BUF_SIZE> buffer) const;
/// Get URL as std::string. Prefer url_to() to avoid heap allocation.
// Remove before 2026.9.0
ESPDEPRECATED("Use url_to() instead. Removed in 2026.9.0", "2026.3.0")
std::string url() const {
char buffer[URL_BUF_SIZE];
return std::string(this->url_to(buffer));
@@ -170,14 +171,8 @@ class AsyncWebServerRequest {
AsyncWebParameter *getParam(const std::string &name) { return this->getParam(name.c_str()); }
// NOLINTNEXTLINE(readability-identifier-naming)
bool hasArg(const char *name) { return this->hasParam(name); }
std::string arg(const char *name) {
auto *param = this->getParam(name);
if (param) {
return param->value();
}
return {};
}
bool hasArg(const char *name);
std::string arg(const char *name);
std::string arg(const std::string &name) { return this->arg(name.c_str()); }
operator httpd_req_t *() const { return this->req_; }
@@ -192,6 +187,7 @@ class AsyncWebServerRequest {
// is faster than tree/hash overhead. AsyncWebParameter stores both name and value to avoid
// duplicate storage. Only successful lookups are cached to prevent cache pollution when
// handlers check for optional parameters that don't exist.
optional<std::string> find_query_value_(const char *name) const;
std::vector<AsyncWebParameter *> params_;
std::string post_query_;
AsyncWebServerRequest(httpd_req_t *req) : req_(req) {}

View File

@@ -487,6 +487,19 @@ bool WiFiComponent::matches_configured_network_(const char *ssid, const uint8_t
return false;
}
void __attribute__((flatten)) WiFiComponent::set_sta_priority(bssid_t bssid, int8_t priority) {
for (auto &it : this->sta_priorities_) {
if (it.bssid == bssid) {
it.priority = priority;
return;
}
}
this->sta_priorities_.push_back(WiFiSTAPriority{
.bssid = bssid,
.priority = priority,
});
}
void WiFiComponent::log_discarded_scan_result_(const char *ssid, const uint8_t *bssid, int8_t rssi, uint8_t channel) {
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
// Skip logging during roaming scans to avoid log buffer overflow

View File

@@ -488,20 +488,11 @@ class WiFiComponent : public Component {
}
return 0;
}
void set_sta_priority(const bssid_t bssid, int8_t priority) {
for (auto &it : this->sta_priorities_) {
if (it.bssid == bssid) {
it.priority = priority;
return;
}
}
this->sta_priorities_.push_back(WiFiSTAPriority{
.bssid = bssid,
.priority = priority,
});
}
void set_sta_priority(bssid_t bssid, int8_t priority);
network::IPAddresses wifi_sta_ip_addresses();
// Remove before 2026.9.0
ESPDEPRECATED("Use wifi_ssid_to() instead. Removed in 2026.9.0", "2026.3.0")
std::string wifi_ssid();
/// Write SSID to buffer without heap allocation.
/// Returns pointer to buffer, or empty string if not connected.

View File

@@ -16,7 +16,8 @@ extern "C" {
#include "lwip/err.h"
#include "lwip/dns.h"
#include "lwip/dhcp.h"
#include "lwip/init.h" // LWIP_VERSION_
#include "lwip/prot/dhcp.h" // DHCP_STATE_BOUND
#include "lwip/init.h" // LWIP_VERSION_
#include "lwip/apps/sntp.h"
#include "lwip/netif.h" // struct netif
#include <AddrList.h>
@@ -224,8 +225,19 @@ bool WiFiComponent::wifi_apply_hostname_() {
#else
intf->hostname = wifi_station_get_hostname();
#endif
if (netif_dhcp_data(intf) != nullptr) {
// renew already started DHCP leases
struct dhcp *dhcp_data = netif_dhcp_data(intf);
if (dhcp_data != nullptr && dhcp_data->state == DHCP_STATE_BOUND && netif_is_link_up(intf)) {
// Renew already-bound DHCP leases to inform server of hostname change.
// Both conditions are required:
// - DHCP must be BOUND (have an active lease to renew)
// - Interface must have link (able to send packets)
// During reconnection, DHCP can remain BOUND from a previous connection
// while the link is down. Calling dhcp_renew() without link corrupts
// lwIP's DHCP state machine (it unconditionally sets state to RENEWING
// before attempting to send, and never rolls back on failure). This
// causes dhcp_network_changed() to call dhcp_reboot() instead of
// dhcp_discover() when WiFi later connects, sending a bogus DHCP REQUEST
// for IP 0.0.0.0 that can put some routers into a persistent bad state.
err_t lwipret = dhcp_renew(intf);
if (lwipret != ERR_OK) {
ESP_LOGW(TAG, "wifi_apply_hostname_(%s): lwIP error %d on interface %c%c (index %d)", intf->hostname,

View File

@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum
__version__ = "2026.2.0b1"
__version__ = "2026.3.0-dev"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (

View File

@@ -148,9 +148,9 @@
#define USE_MQTT
#define USE_MQTT_COVER_JSON
#define USE_NETWORK
#define USE_ONLINE_IMAGE_BMP_SUPPORT
#define USE_ONLINE_IMAGE_PNG_SUPPORT
#define USE_ONLINE_IMAGE_JPEG_SUPPORT
#define USE_RUNTIME_IMAGE_BMP
#define USE_RUNTIME_IMAGE_PNG
#define USE_RUNTIME_IMAGE_JPEG
#define USE_OTA
#define USE_OTA_PASSWORD
#define USE_OTA_STATE_LISTENER

View File

@@ -1083,6 +1083,9 @@ template<std::size_t N> std::string format_hex(const std::array<uint8_t, N> &dat
* Each byte is displayed as a two-digit uppercase hex value, separated by the specified separator.
* Optionally includes the total byte count in parentheses at the end.
*
* @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead.
* Causes heap fragmentation on long-running devices.
*
* @param data Pointer to the byte array to format.
* @param length Number of bytes in the array.
* @param separator Character to use between hex bytes (default: '.').
@@ -1108,6 +1111,9 @@ std::string format_hex_pretty(const uint8_t *data, size_t length, char separator
*
* Similar to the byte array version, but formats 16-bit words as 4-digit hex values.
*
* @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead.
* Causes heap fragmentation on long-running devices.
*
* @param data Pointer to the 16-bit word array to format.
* @param length Number of 16-bit words in the array.
* @param separator Character to use between hex words (default: '.').
@@ -1131,6 +1137,9 @@ std::string format_hex_pretty(const uint16_t *data, size_t length, char separato
* Convenience overload for std::vector<uint8_t>. Formats each byte as a two-digit
* uppercase hex value with customizable separator.
*
* @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead.
* Causes heap fragmentation on long-running devices.
*
* @param data Vector of bytes to format.
* @param separator Character to use between hex bytes (default: '.').
* @param show_length Whether to append the byte count in parentheses (default: true).
@@ -1154,6 +1163,9 @@ std::string format_hex_pretty(const std::vector<uint8_t> &data, char separator =
* Convenience overload for std::vector<uint16_t>. Each 16-bit word is formatted
* as a 4-digit uppercase hex value in big-endian order.
*
* @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead.
* Causes heap fragmentation on long-running devices.
*
* @param data Vector of 16-bit words to format.
* @param separator Character to use between hex words (default: '.').
* @param show_length Whether to append the word count in parentheses (default: true).
@@ -1176,6 +1188,9 @@ std::string format_hex_pretty(const std::vector<uint16_t> &data, char separator
* Treats each character in the string as a byte and formats it in hex.
* Useful for debugging binary data stored in std::string containers.
*
* @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead.
* Causes heap fragmentation on long-running devices.
*
* @param data String whose bytes should be formatted as hex.
* @param separator Character to use between hex bytes (default: '.').
* @param show_length Whether to append the byte count in parentheses (default: true).
@@ -1198,6 +1213,9 @@ std::string format_hex_pretty(const std::string &data, char separator = '.', boo
* Converts the integer to big-endian byte order and formats each byte as hex.
* The most significant byte appears first in the output string.
*
* @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead.
* Causes heap fragmentation on long-running devices.
*
* @tparam T Unsigned integer type (uint8_t, uint16_t, uint32_t, uint64_t, etc.).
* @param val The unsigned integer value to format.
* @param separator Character to use between hex bytes (default: '.').

View File

@@ -369,7 +369,7 @@ def get_logger_tags():
"api.service",
]
for file in CORE_COMPONENTS_PATH.rglob("*.cpp"):
data = file.read_text()
data = file.read_text(encoding="utf-8")
match = pattern.search(data)
if match:
tags.append(match.group(1))

View File

@@ -3,9 +3,15 @@ display:
spi_16: true
pixel_mode: 18bit
model: ili9488
dc_pin: ${dc_pin}
cs_pin: ${cs_pin}
reset_pin: ${reset_pin}
dc_pin:
allow_other_uses: true
number: ${dc_pin}
cs_pin:
allow_other_uses: true
number: ${cs_pin}
reset_pin:
allow_other_uses: true
number: ${reset_pin}
data_rate: 20MHz
invert_colors: true
show_test_card: true
@@ -24,3 +30,15 @@ display:
height: 200
enable_pin: ${enable_pin}
bus_mode: single
- platform: mipi_spi
model: WAVESHARE-1.83-V2
dc_pin:
allow_other_uses: true
number: ${dc_pin}
cs_pin:
allow_other_uses: true
number: ${cs_pin}
reset_pin:
allow_other_uses: true
number: ${reset_pin}

View File

@@ -270,6 +270,14 @@ async def test_alarm_control_panel_state_transitions(
# The chime_sensor has chime: true, so opening it while disarmed
# should trigger on_chime callback
# Set up future for the on_ready from opening the chime sensor
# (alarm becomes "not ready" when chime sensor opens).
# We must wait for this BEFORE creating the close future, otherwise
# the open event's log can arrive late and resolve the close future,
# causing the test to proceed before the chime close is processed.
ready_after_chime_open: asyncio.Future[bool] = loop.create_future()
ready_futures.append(ready_after_chime_open)
# We're currently DISARMED - open the chime sensor
client.switch_command(chime_switch_info.key, True)
@@ -279,11 +287,18 @@ async def test_alarm_control_panel_state_transitions(
except TimeoutError:
pytest.fail(f"on_chime callback not fired. Log lines: {log_lines[-20:]}")
# Close the chime sensor and wait for alarm to become ready again
# We need to wait for this transition before testing door sensor,
# otherwise there's a race where the door sensor state change could
# arrive before the chime sensor state change, leaving the alarm in
# a continuous "not ready" state with no on_ready callback fired.
# Wait for the on_ready from the chime sensor opening
try:
await asyncio.wait_for(ready_after_chime_open, timeout=2.0)
except TimeoutError:
pytest.fail(
f"on_ready callback not fired when chime sensor opened. "
f"Log lines: {log_lines[-20:]}"
)
# Now create the future for the close event and close the sensor.
# Since we waited for the open event above, the close event's
# on_ready log cannot be confused with the open event's.
ready_after_chime_close: asyncio.Future[bool] = loop.create_future()
ready_futures.append(ready_after_chime_close)