1
0
mirror of https://github.com/esphome/esphome.git synced 2026-02-08 16:51:52 +00:00

Merge upstream/dev into integration

This commit is contained in:
J. Nick Koston
2025-11-25 21:21:03 -06:00
39 changed files with 409 additions and 82 deletions

View File

@@ -7,6 +7,7 @@
- [ ] Bugfix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Developer breaking change (an API change that could break external components)
- [ ] Code quality improvements to existing code or addition of tests
- [ ] Other

View File

@@ -17,7 +17,7 @@ runs:
steps:
- name: Set up Python ${{ inputs.python-version }}
id: python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: ${{ inputs.python-version }}
- name: Restore Python virtual environment

View File

@@ -68,6 +68,7 @@ jobs:
'bugfix',
'new-feature',
'breaking-change',
'developer-breaking-change',
'code-quality'
];
@@ -367,6 +368,7 @@ jobs:
{ pattern: /- \[x\] Bugfix \(non-breaking change which fixes an issue\)/i, label: 'bugfix' },
{ pattern: /- \[x\] New feature \(non-breaking change which adds functionality\)/i, label: 'new-feature' },
{ pattern: /- \[x\] Breaking change \(fix or feature that would cause existing functionality to not work as expected\)/i, label: 'breaking-change' },
{ pattern: /- \[x\] Developer breaking change \(an API change that could break external components\)/i, label: 'developer-breaking-change' },
{ pattern: /- \[x\] Code quality improvements to existing code or addition of tests/i, label: 'code-quality' }
];

View File

@@ -23,7 +23,7 @@ jobs:
- name: Checkout
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: "3.11"

View File

@@ -24,7 +24,7 @@ jobs:
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: "3.11"

View File

@@ -45,7 +45,7 @@ jobs:
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: "3.11"
- name: Set up Docker Buildx

View File

@@ -42,7 +42,7 @@ jobs:
run: echo key="${{ hashFiles('requirements.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment
@@ -240,7 +240,7 @@ jobs:
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up Python 3.13
id: python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: "3.13"
- name: Restore Python virtual environment

View File

@@ -62,7 +62,7 @@ jobs:
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: "3.x"
- name: Build
@@ -94,7 +94,7 @@ jobs:
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: "3.11"

View File

@@ -22,7 +22,7 @@ jobs:
path: lib/home-assistant
- name: Setup Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: 3.13

View File

@@ -58,7 +58,7 @@ void Alpha3::handle_geni_response_(const uint8_t *response, uint16_t length) {
if (this->response_offset_ >= this->response_length_) {
ESP_LOGD(TAG, "[%s] GENI response begin", this->parent_->address_str());
if (length < GENI_RESPONSE_HEADER_LENGTH) {
ESP_LOGW(TAG, "[%s] response to short", this->parent_->address_str());
ESP_LOGW(TAG, "[%s] response too short", this->parent_->address_str());
return;
}
if (response[0] != 36 || response[2] != 248 || response[3] != 231 || response[4] != 10) {

View File

@@ -854,6 +854,10 @@ def _configure_lwip_max_sockets(conf: dict) -> None:
async def to_code(config):
cg.add_platformio_option("board", config[CONF_BOARD])
cg.add_platformio_option("board_upload.flash_size", config[CONF_FLASH_SIZE])
cg.add_platformio_option(
"board_upload.maximum_size",
int(config[CONF_FLASH_SIZE].removesuffix("MB")) * 1024 * 1024,
)
cg.set_cpp_standard("gnu++20")
cg.add_build_flag("-DUSE_ESP32")
cg.add_define("ESPHOME_BOARD", config[CONF_BOARD])

View File

@@ -7,8 +7,6 @@
extern const uint8_t ESPHOME_ESP8266_GPIO_INITIAL_MODE[16];
extern const uint8_t ESPHOME_ESP8266_GPIO_INITIAL_LEVEL[16];
namespace esphome {
namespace esp8266 {} // namespace esp8266
} // namespace esphome
namespace esphome::esp8266 {} // namespace esphome::esp8266
#endif // USE_ESP8266

View File

@@ -3,8 +3,7 @@
#include "gpio.h"
#include "esphome/core/log.h"
namespace esphome {
namespace esp8266 {
namespace esphome::esp8266 {
static const char *const TAG = "esp8266";
@@ -110,9 +109,11 @@ void ESP8266GPIOPin::digital_write(bool value) {
}
void ESP8266GPIOPin::detach_interrupt() const { detachInterrupt(pin_); }
} // namespace esp8266
} // namespace esphome::esp8266
using namespace esp8266;
namespace esphome {
using esp8266::ISRPinArg;
bool IRAM_ATTR ISRInternalGPIOPin::digital_read() {
auto *arg = reinterpret_cast<ISRPinArg *>(this->arg_);

View File

@@ -5,8 +5,7 @@
#include "esphome/core/hal.h"
#include <Arduino.h>
namespace esphome {
namespace esp8266 {
namespace esphome::esp8266 {
class ESP8266GPIOPin : public InternalGPIOPin {
public:
@@ -33,7 +32,6 @@ class ESP8266GPIOPin : public InternalGPIOPin {
gpio::Flags flags_{};
};
} // namespace esp8266
} // namespace esphome
} // namespace esphome::esp8266
#endif // USE_ESP8266

View File

@@ -15,24 +15,24 @@ extern "C" {
#include <cstring>
#include <memory>
namespace esphome {
namespace esp8266 {
namespace esphome::esp8266 {
static const char *const TAG = "esp8266.preferences";
static bool s_prevent_write = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static uint32_t *s_flash_storage = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static bool s_prevent_write = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static bool s_flash_dirty = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static const uint32_t ESP_RTC_USER_MEM_START = 0x60001200;
static constexpr uint32_t ESP_RTC_USER_MEM_START = 0x60001200;
static constexpr uint32_t ESP_RTC_USER_MEM_SIZE_WORDS = 128;
static constexpr uint32_t ESP_RTC_USER_MEM_SIZE_BYTES = ESP_RTC_USER_MEM_SIZE_WORDS * 4;
#define ESP_RTC_USER_MEM ((uint32_t *) ESP_RTC_USER_MEM_START)
static const uint32_t ESP_RTC_USER_MEM_SIZE_WORDS = 128;
static const uint32_t ESP_RTC_USER_MEM_SIZE_BYTES = ESP_RTC_USER_MEM_SIZE_WORDS * 4;
#ifdef USE_ESP8266_PREFERENCES_FLASH
static const uint32_t ESP8266_FLASH_STORAGE_SIZE = 128;
static constexpr uint32_t ESP8266_FLASH_STORAGE_SIZE = 128;
#else
static const uint32_t ESP8266_FLASH_STORAGE_SIZE = 64;
static constexpr uint32_t ESP8266_FLASH_STORAGE_SIZE = 64;
#endif
static inline bool esp_rtc_user_mem_read(uint32_t index, uint32_t *dest) {
@@ -284,10 +284,10 @@ void setup_preferences() {
}
void preferences_prevent_write(bool prevent) { s_prevent_write = prevent; }
} // namespace esp8266
} // namespace esphome::esp8266
namespace esphome {
ESPPreferences *global_preferences; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
} // namespace esphome
#endif // USE_ESP8266

View File

@@ -2,13 +2,11 @@
#ifdef USE_ESP8266
namespace esphome {
namespace esp8266 {
namespace esphome::esp8266 {
void setup_preferences();
void preferences_prevent_write(bool prevent);
} // namespace esp8266
} // namespace esphome
} // namespace esphome::esp8266
#endif // USE_ESP8266

View File

@@ -279,6 +279,8 @@ KEYBOARD_MODES = LvConstant(
)
ROLLER_MODES = LvConstant("LV_ROLLER_MODE_", "NORMAL", "INFINITE")
TILE_DIRECTIONS = DIRECTIONS.extend("HOR", "VER", "ALL")
SCROLL_DIRECTIONS = TILE_DIRECTIONS.extend("NONE")
SNAP_DIRECTIONS = LvConstant("LV_SCROLL_SNAP_", "NONE", "START", "END", "CENTER")
CHILD_ALIGNMENTS = LvConstant(
"LV_ALIGN_",
"TOP_LEFT",
@@ -511,6 +513,9 @@ CONF_ROLLOVER = "rollover"
CONF_ROOT_BACK_BTN = "root_back_btn"
CONF_SCALE_LINES = "scale_lines"
CONF_SCROLLBAR_MODE = "scrollbar_mode"
CONF_SCROLL_DIR = "scroll_dir"
CONF_SCROLL_SNAP_X = "scroll_snap_x"
CONF_SCROLL_SNAP_Y = "scroll_snap_y"
CONF_SELECTED_INDEX = "selected_index"
CONF_SELECTED_TEXT = "selected_text"
CONF_SHOW_SNOW = "show_snow"

View File

@@ -36,6 +36,8 @@ from .defines import (
)
from .lv_validation import padding, size
CONF_MULTIPLE_WIDGETS_PER_CELL = "multiple_widgets_per_cell"
cell_alignments = LV_CELL_ALIGNMENTS.one_of
grid_alignments = LV_GRID_ALIGNMENTS.one_of
flex_alignments = LV_FLEX_ALIGNMENTS.one_of
@@ -170,10 +172,14 @@ class DirectionalLayout(FlexLayout):
def validate(self, config):
assert config[CONF_LAYOUT].lower() == self.direction
config[CONF_LAYOUT] = {
layout = {
**FLEX_HV_STYLE,
CONF_FLEX_FLOW: "LV_FLEX_FLOW_" + self.flow.upper(),
}
if pad_all := config.get("pad_all"):
layout[CONF_PAD_ROW] = pad_all
layout[CONF_PAD_COLUMN] = pad_all
config[CONF_LAYOUT] = layout
return config
@@ -220,6 +226,7 @@ class GridLayout(Layout):
cv.Optional(CONF_GRID_ROW_ALIGN): grid_alignments,
cv.Optional(CONF_PAD_ROW): padding,
cv.Optional(CONF_PAD_COLUMN): padding,
cv.Optional(CONF_MULTIPLE_WIDGETS_PER_CELL, default=False): cv.boolean,
},
{
cv.Optional(CONF_GRID_CELL_ROW_POS): cv.positive_int,
@@ -263,6 +270,7 @@ class GridLayout(Layout):
# should be guaranteed to be a dict at this point
assert isinstance(layout, dict)
assert layout.get(CONF_TYPE).lower() == TYPE_GRID
allow_multiple = layout.get(CONF_MULTIPLE_WIDGETS_PER_CELL, False)
rows = len(layout[CONF_GRID_ROWS])
columns = len(layout[CONF_GRID_COLUMNS])
used_cells = [[None] * columns for _ in range(rows)]
@@ -299,7 +307,10 @@ class GridLayout(Layout):
f"exceeds grid size {rows}x{columns}",
[CONF_WIDGETS, index],
)
if used_cells[row + i][column + j] is not None:
if (
not allow_multiple
and used_cells[row + i][column + j] is not None
):
raise cv.Invalid(
f"Cell span {row + i}/{column + j} already occupied by widget at index {used_cells[row + i][column + j]}",
[CONF_WIDGETS, index],

View File

@@ -40,7 +40,7 @@ from .helpers import (
lv_fonts_used,
requires_component,
)
from .types import lv_font_t, lv_gradient_t
from .types import lv_gradient_t
opacity_consts = LvConstant("LV_OPA_", "TRANSP", "COVER")
@@ -498,7 +498,9 @@ class LvFont(LValidator):
esphome_fonts_used.add(fontval)
return requires_component("font")(fontval)
super().__init__(validator, lv_font_t)
# Use font::Font* as return type for lambdas returning ESPHome fonts
# The inline overloads in lvgl_esphome.h handle conversion to lv_font_t*
super().__init__(validator, Font.operator("ptr"))
async def process(self, value, args=()):
if is_lv_font(value):

View File

@@ -1,6 +1,7 @@
from esphome import config_validation as cv
from esphome.automation import Trigger, validate_automation
from esphome.components.time import RealTimeClock
from esphome.config_validation import prepend_path
from esphome.const import (
CONF_ARGS,
CONF_FORMAT,
@@ -19,7 +20,14 @@ from esphome.core import TimePeriod
from esphome.core.config import StartupTrigger
from . import defines as df, lv_validation as lvalid
from .defines import CONF_TIME_FORMAT, LV_GRAD_DIR
from .defines import (
CONF_SCROLL_DIR,
CONF_SCROLL_SNAP_X,
CONF_SCROLL_SNAP_Y,
CONF_SCROLLBAR_MODE,
CONF_TIME_FORMAT,
LV_GRAD_DIR,
)
from .helpers import CONF_IF_NAN, requires_component, validate_printf
from .layout import (
FLEX_OBJ_SCHEMA,
@@ -233,9 +241,19 @@ STYLE_SCHEMA = cv.Schema({cv.Optional(k): v for k, v in STYLE_PROPS.items()}).ex
cv.Optional(df.CONF_SCROLLBAR_MODE): df.LvConstant(
"LV_SCROLLBAR_MODE_", "OFF", "ON", "ACTIVE", "AUTO"
).one_of,
cv.Optional(CONF_SCROLL_DIR): df.SCROLL_DIRECTIONS.one_of,
cv.Optional(CONF_SCROLL_SNAP_X): df.SNAP_DIRECTIONS.one_of,
cv.Optional(CONF_SCROLL_SNAP_Y): df.SNAP_DIRECTIONS.one_of,
}
)
OBJ_PROPERTIES = {
CONF_SCROLL_SNAP_X,
CONF_SCROLL_SNAP_Y,
CONF_SCROLL_DIR,
CONF_SCROLLBAR_MODE,
}
# Also allow widget specific properties for use in style definitions
FULL_STYLE_SCHEMA = STYLE_SCHEMA.extend(
{
@@ -422,7 +440,10 @@ def any_widget_schema(extras=None):
def validator(value):
if isinstance(value, dict):
# Convert to list
is_dict = True
value = [{k: v} for k, v in value.items()]
else:
is_dict = False
if not isinstance(value, list):
raise cv.Invalid("Expected a list of widgets")
result = []
@@ -443,7 +464,9 @@ def any_widget_schema(extras=None):
)
# Apply custom validation
value = widget_type.validate(value or {})
result.append({key: container_validator(value)})
path = [key] if is_dict else [index, key]
with prepend_path(path):
result.append({key: container_validator(value)})
return result
return validator

View File

@@ -21,7 +21,6 @@ from ..defines import (
CONF_MAIN,
CONF_PAD_COLUMN,
CONF_PAD_ROW,
CONF_SCROLLBAR_MODE,
CONF_STYLES,
CONF_WIDGETS,
OBJ_FLAGS,
@@ -45,7 +44,7 @@ from ..lvcode import (
lv_obj,
lv_Pvariable,
)
from ..schemas import ALL_STYLES, STYLE_REMAP, WIDGET_TYPES
from ..schemas import ALL_STYLES, OBJ_PROPERTIES, STYLE_REMAP, WIDGET_TYPES
from ..types import LV_STATE, LvType, WidgetType, lv_coord_t, lv_obj_t, lv_obj_t_ptr
EVENT_LAMB = "event_lamb__"
@@ -414,7 +413,8 @@ async def set_obj_properties(w: Widget, config):
w.add_state(state)
cond.else_()
w.clear_state(state)
await w.set_property(CONF_SCROLLBAR_MODE, config, lv_name="obj")
for property in OBJ_PROPERTIES:
await w.set_property(property, config, lv_name="obj")
async def add_widgets(parent: Widget, config: dict):

View File

@@ -20,7 +20,13 @@ from ..defines import (
CONF_START_ANGLE,
literal,
)
from ..lv_validation import get_start_value, lv_angle_degrees, lv_float, lv_int
from ..lv_validation import (
get_start_value,
lv_angle_degrees,
lv_float,
lv_int,
lv_positive_int,
)
from ..lvcode import lv, lv_expr, lv_obj
from ..types import LvNumber, NumberType
from . import Widget
@@ -36,13 +42,20 @@ ARC_SCHEMA = cv.Schema(
cv.Optional(CONF_ROTATION, default=0.0): lv_angle_degrees,
cv.Optional(CONF_ADJUSTABLE, default=False): bool,
cv.Optional(CONF_MODE, default="NORMAL"): ARC_MODES.one_of,
cv.Optional(CONF_CHANGE_RATE, default=720): cv.uint16_t,
cv.Optional(CONF_CHANGE_RATE, default=720): lv_positive_int,
}
)
ARC_MODIFY_SCHEMA = cv.Schema(
{
cv.Optional(CONF_VALUE): lv_float,
cv.Optional(CONF_MIN_VALUE): lv_int,
cv.Optional(CONF_MAX_VALUE): lv_int,
cv.Optional(CONF_START_ANGLE): lv_angle_degrees,
cv.Optional(CONF_END_ANGLE): lv_angle_degrees,
cv.Optional(CONF_ROTATION): lv_angle_degrees,
cv.Optional(CONF_MODE): ARC_MODES.one_of,
cv.Optional(CONF_CHANGE_RATE): lv_positive_int,
}
)
@@ -58,17 +71,34 @@ class ArcType(NumberType):
)
async def to_code(self, w: Widget, config):
if CONF_MIN_VALUE in config:
if CONF_MIN_VALUE in config and CONF_MAX_VALUE in config:
max_value = await lv_int.process(config[CONF_MAX_VALUE])
min_value = await lv_int.process(config[CONF_MIN_VALUE])
lv.arc_set_range(w.obj, min_value, max_value)
start = await lv_angle_degrees.process(config[CONF_START_ANGLE])
end = await lv_angle_degrees.process(config[CONF_END_ANGLE])
rotation = await lv_angle_degrees.process(config[CONF_ROTATION])
lv.arc_set_bg_angles(w.obj, start, end)
lv.arc_set_rotation(w.obj, rotation)
lv.arc_set_mode(w.obj, literal(config[CONF_MODE]))
lv.arc_set_change_rate(w.obj, config[CONF_CHANGE_RATE])
elif CONF_MIN_VALUE in config:
max_value = w.get_property(CONF_MAX_VALUE)
min_value = await lv_int.process(config[CONF_MIN_VALUE])
lv.arc_set_range(w.obj, min_value, max_value)
elif CONF_MAX_VALUE in config:
max_value = await lv_int.process(config[CONF_MAX_VALUE])
min_value = w.get_property(CONF_MIN_VALUE)
lv.arc_set_range(w.obj, min_value, max_value)
await w.set_property(
CONF_START_ANGLE,
await lv_angle_degrees.process(config.get(CONF_START_ANGLE)),
)
await w.set_property(
CONF_END_ANGLE, await lv_angle_degrees.process(config.get(CONF_END_ANGLE))
)
await w.set_property(
CONF_ROTATION, await lv_angle_degrees.process(config.get(CONF_ROTATION))
)
await w.set_property(CONF_MODE, config)
await w.set_property(
CONF_CHANGE_RATE,
await lv_positive_int.process(config.get(CONF_CHANGE_RATE)),
)
if CONF_ADJUSTABLE in config:
if not config[CONF_ADJUSTABLE]:
@@ -78,9 +108,7 @@ class ArcType(NumberType):
# For some reason arc does not get automatically added to the default group
lv.group_add_obj(lv_expr.group_get_default(), w.obj)
value = await get_start_value(config)
if value is not None:
lv.arc_set_value(w.obj, value)
await w.set_property(CONF_VALUE, await get_start_value(config))
arc_spec = ArcType()

View File

@@ -6,7 +6,7 @@ from esphome.core import Lambda
from ..defines import CONF_MAIN, call_lambda
from ..lvcode import lv_add
from ..schemas import point_schema
from ..types import LvCompound, LvType
from ..types import LvCompound, LvType, lv_coord_t
from . import Widget, WidgetType
CONF_LINE = "line"
@@ -23,9 +23,7 @@ LINE_SCHEMA = {
async def process_coord(coord):
if isinstance(coord, Lambda):
coord = call_lambda(
await cg.process_lambda(coord, [], return_type="lv_coord_t")
)
coord = call_lambda(await cg.process_lambda(coord, [], return_type=lv_coord_t))
if not coord.endswith("()"):
coord = f"static_cast<lv_coord_t>({coord})"
return cg.RawExpression(coord)

View File

@@ -174,6 +174,9 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) {
// Check if baud rate is supported
this->original_baud_rate_ = this->parent_->get_baud_rate();
if (baud_rate <= 0) {
baud_rate = this->original_baud_rate_;
}
ESP_LOGD(TAG, "Baud rate: %" PRIu32, baud_rate);
// Define the configuration for the HTTP client

View File

@@ -177,6 +177,9 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) {
// Check if baud rate is supported
this->original_baud_rate_ = this->parent_->get_baud_rate();
if (baud_rate <= 0) {
baud_rate = this->original_baud_rate_;
}
ESP_LOGD(TAG, "Baud rate: %" PRIu32, baud_rate);
// Define the configuration for the HTTP client

View File

@@ -278,7 +278,12 @@ template<class C, typename... Ts> class ScriptWaitAction : public Action<Ts...>,
void setup() override {
// Start with loop disabled - only enable when there's work to do
this->disable_loop();
// IMPORTANT: Only disable if num_running_ is 0, otherwise play_complex() was already
// called before our setup() (e.g., from on_boot trigger at same priority level)
// and we must not undo its enable_loop() call
if (this->num_running_ == 0) {
this->disable_loop();
}
}
void play_complex(const Ts &...x) override {

View File

@@ -7,6 +7,7 @@ namespace sht4x {
static const char *const TAG = "sht4x";
static const uint8_t MEASURECOMMANDS[] = {0xFD, 0xF6, 0xE0};
static const uint8_t SERIAL_NUMBER_COMMAND = 0x89;
void SHT4XComponent::start_heater_() {
uint8_t cmd[] = {MEASURECOMMANDS[this->heater_command_]};
@@ -17,6 +18,17 @@ void SHT4XComponent::start_heater_() {
}
}
void SHT4XComponent::read_serial_number_() {
uint16_t buffer[2];
if (!this->get_8bit_register(SERIAL_NUMBER_COMMAND, buffer, 2, 1)) {
ESP_LOGE(TAG, "Get serial number failed");
this->serial_number_ = 0;
return;
}
this->serial_number_ = (uint32_t(buffer[0]) << 16) | (uint32_t(buffer[1]));
ESP_LOGD(TAG, "Serial number: %08" PRIx32, this->serial_number_);
}
void SHT4XComponent::setup() {
auto err = this->write(nullptr, 0);
if (err != i2c::ERROR_OK) {
@@ -24,6 +36,8 @@ void SHT4XComponent::setup() {
return;
}
this->read_serial_number_();
if (std::isfinite(this->duty_cycle_) && this->duty_cycle_ > 0.0f) {
uint32_t heater_interval = static_cast<uint32_t>(static_cast<uint16_t>(this->heater_time_) / this->duty_cycle_);
ESP_LOGD(TAG, "Heater interval: %" PRIu32, heater_interval);
@@ -54,11 +68,18 @@ void SHT4XComponent::setup() {
}
void SHT4XComponent::dump_config() {
ESP_LOGCONFIG(TAG, "SHT4x:");
ESP_LOGCONFIG(TAG,
"SHT4x:\n"
" Serial number: %08" PRIx32,
this->serial_number_);
LOG_I2C_DEVICE(this);
if (this->is_failed()) {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
}
if (this->serial_number_ == 0) {
ESP_LOGW(TAG, "Get serial number failed");
}
}
void SHT4XComponent::update() {

View File

@@ -36,7 +36,9 @@ class SHT4XComponent : public PollingComponent, public sensirion_common::Sensiri
float duty_cycle_;
void start_heater_();
void read_serial_number_();
uint8_t heater_command_;
uint32_t serial_number_;
sensor::Sensor *temp_sensor_{nullptr};
sensor::Sensor *humidity_sensor_{nullptr};

View File

@@ -14,7 +14,7 @@ static constexpr size_t MAX_STATE_LENGTH = 255;
void IPAddressWiFiInfo::setup() {
wifi::global_wifi_component->add_on_ip_state_callback(
[this](network::IPAddresses ips, network::IPAddress dns1_ip, network::IPAddress dns2_ip) {
[this](const network::IPAddresses &ips, const network::IPAddress &dns1_ip, const network::IPAddress &dns2_ip) {
this->state_callback_(ips);
});
}
@@ -24,7 +24,7 @@ void IPAddressWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "IP Address", this);
void IPAddressWiFiInfo::state_callback_(const network::IPAddresses &ips) {
this->publish_state(ips[0].str());
uint8_t sensor = 0;
for (auto &ip : ips) {
for (const auto &ip : ips) {
if (ip.is_set()) {
if (this->ip_sensors_[sensor] != nullptr) {
this->ip_sensors_[sensor]->publish_state(ip.str());
@@ -40,14 +40,14 @@ void IPAddressWiFiInfo::state_callback_(const network::IPAddresses &ips) {
void DNSAddressWifiInfo::setup() {
wifi::global_wifi_component->add_on_ip_state_callback(
[this](network::IPAddresses ips, network::IPAddress dns1_ip, network::IPAddress dns2_ip) {
[this](const network::IPAddresses &ips, const network::IPAddress &dns1_ip, const network::IPAddress &dns2_ip) {
this->state_callback_(dns1_ip, dns2_ip);
});
}
void DNSAddressWifiInfo::dump_config() { LOG_TEXT_SENSOR("", "DNS Address", this); }
void DNSAddressWifiInfo::state_callback_(network::IPAddress dns1_ip, network::IPAddress dns2_ip) {
void DNSAddressWifiInfo::state_callback_(const network::IPAddress &dns1_ip, const network::IPAddress &dns2_ip) {
std::string dns_results = dns1_ip.str() + " " + dns2_ip.str();
this->publish_state(dns_results);
}
@@ -87,7 +87,7 @@ void ScanResultsWiFiInfo::state_callback_(const wifi::wifi_scan_vector_t<wifi::W
void SSIDWiFiInfo::setup() {
wifi::global_wifi_component->add_on_wifi_connect_state_callback(
[this](const std::string &ssid, wifi::bssid_t bssid) { this->state_callback_(ssid); });
[this](const std::string &ssid, const wifi::bssid_t &bssid) { this->state_callback_(ssid); });
}
void SSIDWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "SSID", this); }
@@ -100,12 +100,12 @@ void SSIDWiFiInfo::state_callback_(const std::string &ssid) { this->publish_stat
void BSSIDWiFiInfo::setup() {
wifi::global_wifi_component->add_on_wifi_connect_state_callback(
[this](const std::string &ssid, wifi::bssid_t bssid) { this->state_callback_(bssid); });
[this](const std::string &ssid, const wifi::bssid_t &bssid) { this->state_callback_(bssid); });
}
void BSSIDWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "BSSID", this); }
void BSSIDWiFiInfo::state_callback_(wifi::bssid_t bssid) {
void BSSIDWiFiInfo::state_callback_(const wifi::bssid_t &bssid) {
char buf[18] = "unknown";
if (mac_address_is_valid(bssid.data())) {
format_mac_addr_upper(bssid.data(), buf);

View File

@@ -26,7 +26,7 @@ class DNSAddressWifiInfo : public Component, public text_sensor::TextSensor {
void dump_config() override;
protected:
void state_callback_(network::IPAddress dns1_ip, network::IPAddress dns2_ip);
void state_callback_(const network::IPAddress &dns1_ip, const network::IPAddress &dns2_ip);
};
class ScanResultsWiFiInfo : public Component, public text_sensor::TextSensor {
@@ -54,7 +54,7 @@ class BSSIDWiFiInfo : public Component, public text_sensor::TextSensor {
void dump_config() override;
protected:
void state_callback_(wifi::bssid_t bssid);
void state_callback_(const wifi::bssid_t &bssid);
};
class MacAddressWifiInfo : public Component, public text_sensor::TextSensor {

View File

@@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile
esptool==5.1.0
click==8.1.7
esphome-dashboard==20251013.0
aioesphomeapi==42.7.0
aioesphomeapi==42.8.0
zeroconf==0.148.0
puremagic==1.30
ruamel.yaml==0.18.16 # dashboard_import

View File

@@ -29,7 +29,7 @@ def test_binary_sensor_sets_mandatory_fields(generate_main):
)
# Then
assert 'bs_1->set_name("test bs1");' in main_cpp
assert 'bs_1->set_name_and_object_id("test bs1", "test_bs1");' in main_cpp
assert "bs_1->set_pin(" in main_cpp

View File

@@ -26,7 +26,7 @@ def test_button_sets_mandatory_fields(generate_main):
main_cpp = generate_main("tests/component_tests/button/test_button.yaml")
# Then
assert 'wol_1->set_name("wol_test_1");' in main_cpp
assert 'wol_1->set_name_and_object_id("wol_test_1", "wol_test_1");' in main_cpp
assert "wol_2->set_macaddr(18, 52, 86, 120, 144, 171);" in main_cpp

View File

@@ -27,7 +27,7 @@ def test_text_sets_mandatory_fields(generate_main):
main_cpp = generate_main("tests/component_tests/text/test_text.yaml")
# Then
assert 'it_1->set_name("test 1 text");' in main_cpp
assert 'it_1->set_name_and_object_id("test 1 text", "test_1_text");' in main_cpp
def test_text_config_value_internal_set(generate_main):

View File

@@ -25,9 +25,18 @@ def test_text_sensor_sets_mandatory_fields(generate_main):
main_cpp = generate_main("tests/component_tests/text_sensor/test_text_sensor.yaml")
# Then
assert 'ts_1->set_name("Template Text Sensor 1");' in main_cpp
assert 'ts_2->set_name("Template Text Sensor 2");' in main_cpp
assert 'ts_3->set_name("Template Text Sensor 3");' in main_cpp
assert (
'ts_1->set_name_and_object_id("Template Text Sensor 1", "template_text_sensor_1");'
in main_cpp
)
assert (
'ts_2->set_name_and_object_id("Template Text Sensor 2", "template_text_sensor_2");'
in main_cpp
)
assert (
'ts_3->set_name_and_object_id("Template Text Sensor 3", "template_text_sensor_3");'
in main_cpp
)
def test_text_sensor_config_value_internal_set(generate_main):

View File

@@ -537,6 +537,9 @@ lvgl:
- tileview:
id: tileview_id
scrollbar_mode: active
scroll_dir: all
scroll_elastic: true
scroll_momentum: true
on_value:
then:
- if:
@@ -546,7 +549,10 @@ lvgl:
- logger.log: "tile 1 is now showing"
tiles:
- id: tile_1
scroll_snap_y: center
scroll_snap_x: start
layout: vertical
pad_all: 6px
row: 0
column: 0
dir: ALL
@@ -781,6 +787,18 @@ lvgl:
arc_color: 0xFFFF00
focused:
arc_color: 0x808080
on_click:
then:
- lvgl.arc.update:
id: lv_arc_1
value: !lambda return (int)((float)rand() / RAND_MAX * 100);
min_value: !lambda return (int)((float)rand() / RAND_MAX * 100);
max_value: !lambda return (int)((float)rand() / RAND_MAX * 100);
start_angle: !lambda return (int)((float)rand() / RAND_MAX * 100);
end_angle: !lambda return (int)((float)rand() / RAND_MAX * 100);
rotation: !lambda return (int)((float)rand() / RAND_MAX * 100);
change_rate: !lambda return (uint)((float)rand() / RAND_MAX * 100);
mode: NORMAL
- bar:
id: bar_id
align: top_mid
@@ -881,6 +899,7 @@ lvgl:
grid_columns: [40, fr(1), fr(1)]
pad_row: 6px
pad_column: 0
multiple_widgets_per_cell: true
widgets:
- image:
grid_cell_row_pos: 0
@@ -905,6 +924,10 @@ lvgl:
grid_cell_row_pos: 1
grid_cell_column_pos: 0
text: "Grid cell 1/0"
- label:
grid_cell_row_pos: 1
grid_cell_column_pos: 0
text: "Duplicate for 1/0"
- label:
styles: bdr_style
grid_cell_row_pos: 1
@@ -1027,6 +1050,7 @@ lvgl:
opa: 0%
- id: page3
layout: Horizontal
pad_all: 6px
widgets:
- keyboard:
id: lv_keyboard

View File

@@ -0,0 +1,54 @@
esphome:
name: test-script-wait-on-boot
on_boot:
# Use default priority (600.0) which is same as ScriptWaitAction's setup priority
# This tests the race condition where on_boot runs before ScriptWaitAction::setup()
then:
- logger.log: "=== on_boot: Starting boot sequence ==="
- script.execute: show_start_page
- script.wait: show_start_page
- logger.log: "=== on_boot: First script completed, starting second ==="
- script.execute: flip_thru_pages
- script.wait: flip_thru_pages
- logger.log: "=== on_boot: All boot scripts completed successfully ==="
host:
api:
actions:
# Manual trigger for additional testing
- action: test_script_wait
then:
- logger.log: "=== Manual test: Starting ==="
- script.execute: show_start_page
- script.wait: show_start_page
- logger.log: "=== Manual test: First script completed ==="
- script.execute: flip_thru_pages
- script.wait: flip_thru_pages
- logger.log: "=== Manual test: All completed ==="
logger:
level: DEBUG
script:
# First script - simulates display initialization
- id: show_start_page
mode: single
then:
- logger.log: "show_start_page: Starting"
- delay: 100ms
- logger.log: "show_start_page: After delay 1"
- delay: 100ms
- logger.log: "show_start_page: Completed"
# Second script - simulates page flip sequence
- id: flip_thru_pages
mode: single
then:
- logger.log: "flip_thru_pages: Starting"
- delay: 50ms
- logger.log: "flip_thru_pages: Page 1"
- delay: 50ms
- logger.log: "flip_thru_pages: Page 2"
- delay: 50ms
- logger.log: "flip_thru_pages: Completed"

View File

@@ -0,0 +1,130 @@
"""Integration test for script.wait during on_boot (issue #12043).
This test verifies that script.wait works correctly when triggered from on_boot.
The issue was that ScriptWaitAction::setup() unconditionally disabled the loop,
even if play_complex() had already been called (from an on_boot trigger at the
same priority level) and enabled it.
The race condition occurs because:
1. on_boot's default priority is 600.0 (setup_priority::DATA)
2. ScriptWaitAction's default setup priority is also DATA (600.0)
3. When they have the same priority, if on_boot runs first and triggers a script,
ScriptWaitAction::play_complex() enables the loop
4. Then ScriptWaitAction::setup() runs and unconditionally disables the loop
5. The wait never completes because the loop is disabled
The fix adds a conditional check (like WaitUntilAction has) to only disable the
loop in setup() if num_running_ is 0.
"""
from __future__ import annotations
import asyncio
import re
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_script_wait_on_boot(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that script.wait works correctly when triggered from on_boot.
This reproduces issue #12043 where script.wait would hang forever when
triggered from on_boot due to a race condition in ScriptWaitAction::setup().
"""
test_complete = asyncio.Event()
# Track progress through the boot sequence
boot_started = False
first_script_started = False
first_script_completed = False
first_wait_returned = False
second_script_started = False
second_script_completed = False
all_completed = False
# Patterns for boot sequence logs
boot_start_pattern = re.compile(r"on_boot: Starting boot sequence")
show_start_pattern = re.compile(r"show_start_page: Starting")
show_complete_pattern = re.compile(r"show_start_page: Completed")
first_wait_pattern = re.compile(r"on_boot: First script completed")
flip_start_pattern = re.compile(r"flip_thru_pages: Starting")
flip_complete_pattern = re.compile(r"flip_thru_pages: Completed")
all_complete_pattern = re.compile(r"on_boot: All boot scripts completed")
def check_output(line: str) -> None:
"""Check log output for boot sequence progress."""
nonlocal boot_started, first_script_started, first_script_completed
nonlocal first_wait_returned, second_script_started, second_script_completed
nonlocal all_completed
if boot_start_pattern.search(line):
boot_started = True
elif show_start_pattern.search(line):
first_script_started = True
elif show_complete_pattern.search(line):
first_script_completed = True
elif first_wait_pattern.search(line):
first_wait_returned = True
elif flip_start_pattern.search(line):
second_script_started = True
elif flip_complete_pattern.search(line):
second_script_completed = True
elif all_complete_pattern.search(line):
all_completed = True
test_complete.set()
async with (
run_compiled(yaml_config, line_callback=check_output),
api_client_connected() as client,
):
# Verify device info
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "test-script-wait-on-boot"
# Wait for on_boot sequence to complete
# The boot sequence should complete automatically
# Timeout is generous to allow for delays in the scripts
try:
await asyncio.wait_for(test_complete.wait(), timeout=5.0)
except TimeoutError:
# Build a detailed error message showing where the boot sequence got stuck
progress = []
if boot_started:
progress.append("boot started")
if first_script_started:
progress.append("show_start_page started")
if first_script_completed:
progress.append("show_start_page completed")
if first_wait_returned:
progress.append("first script.wait returned")
if second_script_started:
progress.append("flip_thru_pages started")
if second_script_completed:
progress.append("flip_thru_pages completed")
if not first_wait_returned and first_script_completed:
pytest.fail(
f"Test timed out - script.wait hung after show_start_page completed! "
f"This is the issue #12043 bug. Progress: {', '.join(progress)}"
)
else:
pytest.fail(
f"Test timed out. Progress: {', '.join(progress) if progress else 'none'}"
)
# Verify the complete boot sequence executed in order
assert boot_started, "on_boot did not start"
assert first_script_started, "show_start_page did not start"
assert first_script_completed, "show_start_page did not complete"
assert first_wait_returned, "First script.wait did not return"
assert second_script_started, "flip_thru_pages did not start"
assert second_script_completed, "flip_thru_pages did not complete"
assert all_completed, "Boot sequence did not complete"

View File

@@ -27,8 +27,13 @@ from esphome.helpers import sanitize, snake_case
from .common import load_config_from_fixture
# Pre-compiled regex pattern for extracting object IDs from expressions
# Pre-compiled regex patterns for extracting object IDs from expressions
# Matches both old format: .set_object_id("obj_id")
# and new format: .set_name_and_object_id("name", "obj_id")
OBJECT_ID_PATTERN = re.compile(r'\.set_object_id\(["\'](.*?)["\']\)')
COMBINED_PATTERN = re.compile(
r'\.set_name_and_object_id\(["\'].*?["\']\s*,\s*["\'](.*?)["\']\)'
)
FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" / "core" / "entity_helpers"
@@ -273,8 +278,10 @@ def setup_test_environment() -> Generator[list[str], None, None]:
def extract_object_id_from_expressions(expressions: list[str]) -> str | None:
"""Extract the object ID that was set from the generated expressions."""
for expr in expressions:
# Look for set_object_id calls with regex to handle various formats
# Matches: var.set_object_id("temperature_2") or var.set_object_id('temperature_2')
# First try new combined format: .set_name_and_object_id("name", "obj_id")
if match := COMBINED_PATTERN.search(expr):
return match.group(1)
# Fall back to old format: .set_object_id("obj_id")
if match := OBJECT_ID_PATTERN.search(expr):
return match.group(1)
return None