mirror of
https://github.com/esphome/esphome.git
synced 2026-06-28 19:48:26 +00:00
Compare commits
11 Commits
api-server
...
2026.4.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e0509435a | ||
|
|
95b5ab7e78 | ||
|
|
3ac0939f55 | ||
|
|
191d3bc7e4 | ||
|
|
a186f6fea9 | ||
|
|
aea88aef5e | ||
|
|
433bbdb016 | ||
|
|
4137d93cbf | ||
|
|
6a5919ee87 | ||
|
|
b753ee4e94 | ||
|
|
c26ea52620 |
2
Doxyfile
2
Doxyfile
@@ -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.4.2
|
||||
PROJECT_NUMBER = 2026.4.3
|
||||
|
||||
# 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
|
||||
|
||||
@@ -62,7 +62,12 @@ void Animation::set_frame(int frame) {
|
||||
}
|
||||
|
||||
void Animation::update_data_start_() {
|
||||
const uint32_t image_size = this->get_width_stride() * this->height_;
|
||||
uint32_t image_size = this->get_width_stride() * this->height_;
|
||||
// RGB565 with an alpha channel stores the alpha plane immediately after the RGB
|
||||
// plane within each frame, so the per-frame stride includes the alpha bytes.
|
||||
if (this->type_ == image::IMAGE_TYPE_RGB565 && this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) {
|
||||
image_size += static_cast<uint32_t>(this->width_) * this->height_;
|
||||
}
|
||||
this->data_start_ = this->animation_data_start_ + image_size * this->current_frame_;
|
||||
}
|
||||
|
||||
|
||||
@@ -183,19 +183,19 @@ async def at581x_settings_to_code(config, action_id, template_arg, args):
|
||||
cg.add(var.set_sensing_distance(template_))
|
||||
|
||||
if selfcheck := config.get(CONF_POWERON_SELFCHECK_TIME):
|
||||
template_ = await cg.templatable(selfcheck, args, cg.int32)
|
||||
template_ = await cg.templatable(selfcheck, args, cg.int_)
|
||||
cg.add(var.set_poweron_selfcheck_time(template_))
|
||||
|
||||
if protect := config.get(CONF_PROTECT_TIME):
|
||||
template_ = await cg.templatable(protect, args, cg.int32)
|
||||
template_ = await cg.templatable(protect, args, cg.int_)
|
||||
cg.add(var.set_protect_time(template_))
|
||||
|
||||
if trig_base := config.get(CONF_TRIGGER_BASE):
|
||||
template_ = await cg.templatable(trig_base, args, cg.int32)
|
||||
template_ = await cg.templatable(trig_base, args, cg.int_)
|
||||
cg.add(var.set_trigger_base(template_))
|
||||
|
||||
if trig_keep := config.get(CONF_TRIGGER_KEEP):
|
||||
template_ = await cg.templatable(trig_keep, args, cg.int32)
|
||||
template_ = await cg.templatable(trig_keep, args, cg.int_)
|
||||
cg.add(var.set_trigger_keep(template_))
|
||||
|
||||
if (stage_gain := config.get(CONF_STAGE_GAIN)) is not None:
|
||||
|
||||
@@ -413,7 +413,7 @@ async def deep_sleep_enter_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
var = cg.new_Pvariable(action_id, template_arg, paren)
|
||||
if CONF_SLEEP_DURATION in config:
|
||||
template_ = await cg.templatable(config[CONF_SLEEP_DURATION], args, cg.int32)
|
||||
template_ = await cg.templatable(config[CONF_SLEEP_DURATION], args, cg.uint32)
|
||||
cg.add(var.set_sleep_duration(template_))
|
||||
|
||||
if CONF_UNTIL in config:
|
||||
|
||||
@@ -22,6 +22,12 @@ struct NVSData {
|
||||
|
||||
static std::vector<NVSData> s_pending_save; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
// open() runs from app_main() before the logger is initialized, so any failure
|
||||
// must be deferred until after global_logger is set. This is emitted from the
|
||||
// first make_preference() call, which runs from the generated setup() after
|
||||
// log->pre_setup() has run at EARLY_INIT priority.
|
||||
static esp_err_t s_open_err = ESP_OK; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
bool ESP32PreferenceBackend::save(const uint8_t *data, size_t len) {
|
||||
// try find in pending saves and update that
|
||||
for (auto &obj : s_pending_save) {
|
||||
@@ -74,12 +80,14 @@ bool ESP32PreferenceBackend::load(uint8_t *data, size_t len) {
|
||||
}
|
||||
|
||||
void ESP32Preferences::open() {
|
||||
// Runs from app_main() before the logger is initialized; any logging here
|
||||
// must be deferred. See s_open_err and make_preference() below.
|
||||
nvs_flash_init();
|
||||
esp_err_t err = nvs_open("esphome", NVS_READWRITE, &this->nvs_handle);
|
||||
if (err == 0)
|
||||
return;
|
||||
|
||||
ESP_LOGW(TAG, "nvs_open failed: %s - erasing NVS", esp_err_to_name(err));
|
||||
s_open_err = err;
|
||||
nvs_flash_deinit();
|
||||
nvs_flash_erase();
|
||||
nvs_flash_init();
|
||||
@@ -91,6 +99,14 @@ void ESP32Preferences::open() {
|
||||
}
|
||||
|
||||
ESPPreferenceObject ESP32Preferences::make_preference(size_t length, uint32_t type) {
|
||||
if (s_open_err != ESP_OK) {
|
||||
if (this->nvs_handle == 0) {
|
||||
ESP_LOGW(TAG, "nvs_open failed: %s - NVS unavailable", esp_err_to_name(s_open_err));
|
||||
} else {
|
||||
ESP_LOGW(TAG, "nvs_open failed: %s - erased NVS", esp_err_to_name(s_open_err));
|
||||
}
|
||||
s_open_err = ESP_OK;
|
||||
}
|
||||
auto *pref = new ESP32PreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory)
|
||||
pref->nvs_handle = this->nvs_handle;
|
||||
pref->key = type;
|
||||
|
||||
@@ -216,6 +216,7 @@ void ESP32TouchComponent::setup() {
|
||||
// Do initial oneshot scans to populate baseline values
|
||||
for (uint32_t i = 0; i < ONESHOT_SCAN_COUNT; i++) {
|
||||
err = touch_sensor_trigger_oneshot_scanning(this->sens_handle_, ONESHOT_SCAN_TIMEOUT_MS);
|
||||
App.feed_wdt(); // 3 scans with 2s timeout might exceed WDT, so feed it here to be safe
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Oneshot scan %" PRIu32 " failed: %s", i, esp_err_to_name(err));
|
||||
}
|
||||
|
||||
@@ -744,21 +744,28 @@ async def write_image(config, all_frames=False):
|
||||
if frame_count <= 1:
|
||||
_LOGGER.warning("Image file %s has no animation frames", path)
|
||||
|
||||
total_rows = height * frame_count
|
||||
encoder = IMAGE_TYPE[type](width, total_rows, transparency, dither, invert_alpha)
|
||||
if byte_order := config.get(CONF_BYTE_ORDER):
|
||||
# Check for valid type has already been done in validate_settings
|
||||
encoder.set_big_endian(byte_order == "BIG_ENDIAN")
|
||||
# Encode each frame with its own encoder and concatenate. This keeps every
|
||||
# frame self-contained on disk (e.g. RGB565+alpha emits [RGB plane | alpha plane]
|
||||
# per frame) so animation frame stepping in image.cpp / animation.cpp stays
|
||||
# correct without needing to know the total frame count.
|
||||
byte_order = config.get(CONF_BYTE_ORDER)
|
||||
combined_data: list[int] = []
|
||||
encoder: ImageEncoder | None = None
|
||||
for frame_index in range(frame_count):
|
||||
image.seek(frame_index)
|
||||
encoder = IMAGE_TYPE[type](width, height, transparency, dither, invert_alpha)
|
||||
if byte_order is not None:
|
||||
# Check for valid type has already been done in validate_settings
|
||||
encoder.set_big_endian(byte_order == "BIG_ENDIAN")
|
||||
pixels = encoder.convert(image.resize((width, height)), path).getdata()
|
||||
for row in range(height):
|
||||
for col in range(width):
|
||||
encoder.encode(pixels[row * width + col])
|
||||
encoder.end_row()
|
||||
encoder.end_image()
|
||||
encoder.end_image()
|
||||
combined_data.extend(encoder.data)
|
||||
|
||||
rhs = [HexInt(x) for x in encoder.data]
|
||||
rhs = [HexInt(x) for x in combined_data]
|
||||
prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs)
|
||||
image_type = get_image_type_enum(type)
|
||||
trans_value = get_transparency_enum(encoder.transparency)
|
||||
|
||||
@@ -22,7 +22,7 @@ from ..defines import (
|
||||
literal,
|
||||
)
|
||||
from ..lv_validation import animated, lv_int, size
|
||||
from ..lvcode import LocalVariable, lv, lv_assign, lv_expr, lv_obj
|
||||
from ..lvcode import LocalVariable, lv, lv_assign, lv_expr, lv_obj, lv_Pvariable
|
||||
from ..schemas import container_schema, part_schema
|
||||
from ..types import LV_EVENT, LvType, ObjUpdateAction, lv_obj_t, lv_obj_t_ptr
|
||||
from . import Widget, WidgetType, add_widgets, get_widgets, set_obj_properties
|
||||
@@ -83,8 +83,8 @@ class TabviewType(WidgetType):
|
||||
await w.set_property("tab_bar_size", await size.process(config[CONF_SIZE]))
|
||||
for tab_conf in config[CONF_TABS]:
|
||||
w_id = tab_conf[CONF_ID]
|
||||
tab_obj = cg.Pvariable(w_id, cg.nullptr, type_=lv_tab_t)
|
||||
tab_widget = Widget.create(w_id, tab_obj, obj_spec)
|
||||
tab_obj = lv_Pvariable(lv_tab_t, w_id)
|
||||
tab_widget = Widget.create(w_id, tab_obj, obj_spec, tab_conf)
|
||||
lv_assign(tab_obj, lv_expr.tabview_add_tab(w.obj, tab_conf[CONF_NAME]))
|
||||
await set_obj_properties(tab_widget, tab_conf)
|
||||
await add_widgets(tab_widget, tab_conf)
|
||||
|
||||
@@ -16,6 +16,13 @@ namespace esphome::nextion {
|
||||
static const char *const TAG = "nextion.upload.arduino";
|
||||
static constexpr size_t NEXTION_MAX_RESPONSE_LOG_BYTES = 16;
|
||||
|
||||
// Timeout for display acknowledgment during TFT upload (ms).
|
||||
// A single value is used for all chunks; the happy path returns as soon as
|
||||
// 0x05/0x08 arrives, so this only bounds failed-detection latency. Field
|
||||
// reports showed the previous 500ms steady-state value was too tight for
|
||||
// some firmware variants.
|
||||
static constexpr uint32_t NEXTION_UPLOAD_ACK_TIMEOUT_MS = 5000;
|
||||
|
||||
// Followed guide
|
||||
// https://unofficialnextion.com/t/nextion-upload-protocol-v1-2-the-fast-one/1044/2
|
||||
|
||||
@@ -80,14 +87,14 @@ int Nextion::upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start) {
|
||||
recv_string.clear();
|
||||
this->write_array(buffer, buffer_size);
|
||||
App.feed_wdt();
|
||||
this->recv_ret_string_(recv_string, this->upload_first_chunk_sent_ ? 500 : 5000, true);
|
||||
this->recv_ret_string_(recv_string, NEXTION_UPLOAD_ACK_TIMEOUT_MS, true);
|
||||
this->content_length_ -= read_len;
|
||||
const float upload_percentage = 100.0f * (this->tft_size_ - this->content_length_) / this->tft_size_;
|
||||
ESP_LOGD(TAG, "Upload: %0.2f%% (%" PRIu32 " left, heap: %" PRIu32 ")", upload_percentage, this->content_length_,
|
||||
EspClass::getFreeHeap());
|
||||
this->upload_first_chunk_sent_ = true;
|
||||
if (recv_string.empty()) {
|
||||
ESP_LOGW(TAG, "No response from display during upload");
|
||||
ESP_LOGW(TAG, "No response from display after %" PRIu32 "ms", NEXTION_UPLOAD_ACK_TIMEOUT_MS);
|
||||
allocator.deallocate(buffer, 4096);
|
||||
buffer = nullptr;
|
||||
return -1;
|
||||
|
||||
@@ -19,6 +19,13 @@ namespace esphome::nextion {
|
||||
static const char *const TAG = "nextion.upload.esp32";
|
||||
static constexpr size_t NEXTION_MAX_RESPONSE_LOG_BYTES = 16;
|
||||
|
||||
// Timeout for display acknowledgment during TFT upload (ms).
|
||||
// A single value is used for all chunks; the happy path returns as soon as
|
||||
// 0x05/0x08 arrives, so this only bounds failed-detection latency. Field
|
||||
// reports showed the previous 500ms steady-state value was too tight for
|
||||
// some firmware variants.
|
||||
static constexpr uint32_t NEXTION_UPLOAD_ACK_TIMEOUT_MS = 5000;
|
||||
|
||||
// Followed guide
|
||||
// https://unofficialnextion.com/t/nextion-upload-protocol-v1-2-the-fast-one/1044/2
|
||||
|
||||
@@ -96,7 +103,7 @@ int Nextion::upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &r
|
||||
recv_string.clear();
|
||||
this->write_array(buffer, buffer_size);
|
||||
App.feed_wdt();
|
||||
this->recv_ret_string_(recv_string, upload_first_chunk_sent_ ? 500 : 5000, true);
|
||||
this->recv_ret_string_(recv_string, NEXTION_UPLOAD_ACK_TIMEOUT_MS, true);
|
||||
this->content_length_ -= read_len;
|
||||
const float upload_percentage = 100.0f * (this->tft_size_ - this->content_length_) / this->tft_size_;
|
||||
#ifdef USE_PSRAM
|
||||
@@ -109,7 +116,7 @@ int Nextion::upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &r
|
||||
#endif
|
||||
upload_first_chunk_sent_ = true;
|
||||
if (recv_string.empty()) {
|
||||
ESP_LOGW(TAG, "No response from display during upload");
|
||||
ESP_LOGW(TAG, "No response from display after %" PRIu32 "ms", NEXTION_UPLOAD_ACK_TIMEOUT_MS);
|
||||
allocator.deallocate(buffer, 4096);
|
||||
buffer = nullptr;
|
||||
return -1;
|
||||
|
||||
@@ -129,6 +129,6 @@ async def to_code(config):
|
||||
async def sensor_template_publish_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
var = cg.new_Pvariable(action_id, template_arg, paren)
|
||||
template_ = await cg.templatable(config[CONF_VALUE], args, cg.int32)
|
||||
template_ = await cg.templatable(config[CONF_VALUE], args, cg.int_)
|
||||
cg.add(var.set_value(template_))
|
||||
return var
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import errno
|
||||
from importlib import resources
|
||||
import logging
|
||||
|
||||
@@ -74,6 +75,12 @@ def _load_tzdata(iana_key: str) -> bytes | None:
|
||||
return (resources.files(package) / resource).read_bytes()
|
||||
except (FileNotFoundError, ModuleNotFoundError, IsADirectoryError):
|
||||
return None
|
||||
except OSError as e:
|
||||
# Windows raises EINVAL for paths with NTFS-illegal chars (e.g. '<'/'>'
|
||||
# in POSIX TZ strings like "<+08>-8" that validate_tz feeds back here).
|
||||
if e.errno == errno.EINVAL:
|
||||
return None
|
||||
raise
|
||||
|
||||
|
||||
def _extract_tz_string(tzfile: bytes) -> str:
|
||||
|
||||
@@ -1570,6 +1570,8 @@ void WiFiComponent::check_connecting_finished(uint32_t now) {
|
||||
#endif
|
||||
|
||||
this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTED;
|
||||
// Refresh is_connected() cache; loop()'s refresh ran before this transition.
|
||||
this->update_connected_state_();
|
||||
this->num_retried_ = 0;
|
||||
this->print_connect_params_();
|
||||
|
||||
|
||||
@@ -948,6 +948,8 @@ void WiFiComponent::process_pending_callbacks_() {
|
||||
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
|
||||
if (this->pending_.disconnect) {
|
||||
this->pending_.disconnect = false;
|
||||
// Refresh is_connected() cache here, not in the SDK callback (sys context).
|
||||
this->update_connected_state_();
|
||||
this->notify_disconnect_state_listeners_();
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -179,7 +179,10 @@ void WiFiComponent::wifi_pre_setup_() {
|
||||
#endif // USE_WIFI_AP
|
||||
|
||||
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
|
||||
// cfg.nvs_enable = false;
|
||||
if (global_preferences->nvs_handle == 0) {
|
||||
ESP_LOGW(TAG, "starting wifi without nvs");
|
||||
cfg.nvs_enable = false;
|
||||
}
|
||||
err = esp_wifi_init(&cfg);
|
||||
if (err != ERR_OK) {
|
||||
ESP_LOGE(TAG, "esp_wifi_init failed: %s", esp_err_to_name(err));
|
||||
@@ -796,6 +799,8 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
|
||||
s_sta_connected = false;
|
||||
s_sta_connecting = false;
|
||||
error_from_callback_ = true;
|
||||
// Refresh is_connected() cache; error_from_callback_ makes it false.
|
||||
this->update_connected_state_();
|
||||
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
|
||||
this->notify_disconnect_state_listeners_();
|
||||
#endif
|
||||
|
||||
@@ -536,6 +536,8 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) {
|
||||
this->error_from_callback_ = true;
|
||||
}
|
||||
|
||||
// Refresh is_connected() cache; sta_state_/error_from_callback_ make it false.
|
||||
this->update_connected_state_();
|
||||
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
|
||||
this->notify_disconnect_state_listeners_();
|
||||
#endif
|
||||
|
||||
@@ -342,6 +342,8 @@ void WiFiComponent::wifi_loop_() {
|
||||
s_sta_was_connected = false;
|
||||
s_sta_had_ip = false;
|
||||
ESP_LOGV(TAG, "Disconnected");
|
||||
// Refresh is_connected() cache; driver link status reports disconnected.
|
||||
this->update_connected_state_();
|
||||
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
|
||||
this->notify_disconnect_state_listeners_();
|
||||
#endif
|
||||
|
||||
@@ -4,7 +4,7 @@ from enum import Enum
|
||||
|
||||
from esphome.enum import StrEnum
|
||||
|
||||
__version__ = "2026.4.2"
|
||||
__version__ = "2026.4.3"
|
||||
|
||||
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||
VALID_SUBSTITUTIONS_CHARACTERS = (
|
||||
|
||||
@@ -7,10 +7,12 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from PIL import Image as PILImage
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.image import (
|
||||
CONF_ALPHA_CHANNEL,
|
||||
CONF_INVERT_ALPHA,
|
||||
CONF_OPAQUE,
|
||||
CONF_TRANSPARENCY,
|
||||
@@ -411,3 +413,70 @@ async def test_svg_with_mm_dimensions_succeeds(
|
||||
assert 30 < height < 50, (
|
||||
f"Height should be around 39 pixels for 10mm at 100dpi, got {height}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rgb565_alpha_animation_layout_per_frame(
|
||||
tmp_path: Path,
|
||||
mock_progmem_array: MagicMock,
|
||||
) -> None:
|
||||
"""RGB565+alpha animations must store each frame as a self-contained
|
||||
[RGB plane | alpha plane] block. Animation::update_data_start_ steps frames
|
||||
with a single per-frame stride, so any cross-frame layout (all RGB then all
|
||||
alpha) makes the C++ alpha read land in the next frame's RGB bytes — that
|
||||
was the regression behind issue #15999.
|
||||
"""
|
||||
# Build a 2-frame APNG where each frame is a solid color with a known
|
||||
# alpha. APNG preserves full RGBA per pixel (GIF only has 1-bit alpha so
|
||||
# round-tripping mid-range alpha values does not work). Frame 0 is fully
|
||||
# opaque red, frame 1 is fully transparent blue.
|
||||
width = 4
|
||||
height = 3
|
||||
frame0 = PILImage.new("RGBA", (width, height), (255, 0, 0, 0xFF))
|
||||
frame1 = PILImage.new("RGBA", (width, height), (0, 0, 255, 0x00))
|
||||
apng_path = tmp_path / "anim.png"
|
||||
frame0.save(
|
||||
apng_path,
|
||||
format="PNG",
|
||||
save_all=True,
|
||||
append_images=[frame1],
|
||||
duration=100,
|
||||
loop=0,
|
||||
)
|
||||
|
||||
config = {
|
||||
CONF_FILE: str(apng_path),
|
||||
CONF_TYPE: "RGB565",
|
||||
CONF_TRANSPARENCY: CONF_ALPHA_CHANNEL,
|
||||
CONF_DITHER: "NONE",
|
||||
CONF_INVERT_ALPHA: False,
|
||||
CONF_RAW_DATA_ID: "test_raw_data_id",
|
||||
}
|
||||
|
||||
_, _, _, _, _, frame_count = await write_image(config, all_frames=True)
|
||||
assert frame_count == 2
|
||||
|
||||
# Recover the bytes handed to progmem_array. Signature is (id_, rhs).
|
||||
_, raw_data = mock_progmem_array.call_args.args
|
||||
data = [int(x) for x in raw_data]
|
||||
|
||||
rgb_size = width * height * 2
|
||||
alpha_size = width * height
|
||||
frame_size = rgb_size + alpha_size
|
||||
assert len(data) == frame_size * frame_count, (
|
||||
"RGB565+alpha animation buffer must be (RGB + alpha) per frame, not "
|
||||
"all RGB followed by all alpha"
|
||||
)
|
||||
|
||||
# Frame 0: RGB plane is red, alpha plane is 0xFF. Frame 1: alpha plane is
|
||||
# 0x00. If the layout regresses to [all RGB | all alpha], the alpha bytes
|
||||
# would all land at the tail of the buffer and the per-frame slices below
|
||||
# would point at RGB565 noise instead.
|
||||
frame0_alpha = data[rgb_size : rgb_size + alpha_size]
|
||||
frame1_alpha = data[frame_size + rgb_size : frame_size + rgb_size + alpha_size]
|
||||
assert all(a == 0xFF for a in frame0_alpha), (
|
||||
f"Frame 0 alpha plane should be opaque, got {frame0_alpha}"
|
||||
)
|
||||
assert all(a == 0x00 for a in frame1_alpha), (
|
||||
f"Frame 1 alpha plane should be transparent, got {frame1_alpha}"
|
||||
)
|
||||
|
||||
@@ -4,3 +4,9 @@ esphome:
|
||||
- deep_sleep.prevent
|
||||
- delay: 1s
|
||||
- deep_sleep.allow
|
||||
- if:
|
||||
condition:
|
||||
lambda: 'return false;'
|
||||
then:
|
||||
- deep_sleep.enter:
|
||||
sleep_duration: 60min
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
"""Tests for time component cron expression parsing."""
|
||||
|
||||
from esphome.components.time import _parse_cron_part
|
||||
import errno
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.time import _load_tzdata, _parse_cron_part, validate_tz
|
||||
|
||||
|
||||
def test_star_slash_seconds() -> None:
|
||||
@@ -78,3 +83,63 @@ def test_range() -> None:
|
||||
|
||||
def test_single_value() -> None:
|
||||
assert _parse_cron_part("30", 0, 59, {}) == {30}
|
||||
|
||||
|
||||
def _mock_resources_with_error(error: Exception) -> MagicMock:
|
||||
"""Return a mock of importlib.resources.files where read_bytes raises error."""
|
||||
leaf = MagicMock()
|
||||
leaf.read_bytes.side_effect = error
|
||||
package = MagicMock()
|
||||
package.__truediv__.return_value = leaf
|
||||
return MagicMock(return_value=package)
|
||||
|
||||
|
||||
def test_load_tzdata_returns_none_on_windows_einval() -> None:
|
||||
"""On Windows, opening a tzdata path with NTFS-illegal chars raises OSError(EINVAL).
|
||||
|
||||
Regression test for crash when the system TZ resolves to a POSIX string like
|
||||
"<+08>-8" (Asia/Shanghai, IST, etc.) and is fed back into _load_tzdata by
|
||||
validate_tz to check whether it is also a valid IANA key.
|
||||
"""
|
||||
err = OSError(errno.EINVAL, "Invalid argument")
|
||||
with patch(
|
||||
"esphome.components.time.resources.files",
|
||||
_mock_resources_with_error(err),
|
||||
):
|
||||
assert _load_tzdata("<+08>-8") is None
|
||||
|
||||
|
||||
def test_load_tzdata_propagates_unexpected_oserror() -> None:
|
||||
"""Unrelated OSErrors (e.g. PermissionError) must not be swallowed."""
|
||||
with (
|
||||
patch(
|
||||
"esphome.components.time.resources.files",
|
||||
_mock_resources_with_error(
|
||||
PermissionError(errno.EACCES, "Permission denied")
|
||||
),
|
||||
),
|
||||
pytest.raises(PermissionError),
|
||||
):
|
||||
_load_tzdata("Some/Zone")
|
||||
|
||||
|
||||
def test_load_tzdata_returns_none_on_file_not_found() -> None:
|
||||
"""Existing behavior: missing tz file returns None rather than raising."""
|
||||
with patch(
|
||||
"esphome.components.time.resources.files",
|
||||
_mock_resources_with_error(FileNotFoundError()),
|
||||
):
|
||||
assert _load_tzdata("Not/A/Zone") is None
|
||||
|
||||
|
||||
def test_validate_tz_accepts_posix_string_when_read_bytes_raises_einval() -> None:
|
||||
"""validate_tz must not crash when _load_tzdata hits the Windows EINVAL path.
|
||||
|
||||
Simulates the Windows case where the auto-detected POSIX TZ string is fed
|
||||
back through _load_tzdata and the underlying read_bytes raises errno 22.
|
||||
"""
|
||||
with patch(
|
||||
"esphome.components.time.resources.files",
|
||||
_mock_resources_with_error(OSError(errno.EINVAL, "Invalid argument")),
|
||||
):
|
||||
assert validate_tz("<+08>-8") == "<+08>-8"
|
||||
|
||||
Reference in New Issue
Block a user