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:
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -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
|
||||
|
||||
|
||||
2
.github/actions/restore-python/action.yml
vendored
2
.github/actions/restore-python/action.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/auto-label-pr.yml
vendored
2
.github/workflows/auto-label-pr.yml
vendored
@@ -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' }
|
||||
];
|
||||
|
||||
|
||||
2
.github/workflows/ci-api-proto.yml
vendored
2
.github/workflows/ci-api-proto.yml
vendored
@@ -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"
|
||||
|
||||
|
||||
2
.github/workflows/ci-clang-tidy-hash.yml
vendored
2
.github/workflows/ci-clang-tidy-hash.yml
vendored
@@ -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"
|
||||
|
||||
|
||||
2
.github/workflows/ci-docker.yml
vendored
2
.github/workflows/ci-docker.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -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"
|
||||
|
||||
|
||||
2
.github/workflows/sync-device-classes.yml
vendored
2
.github/workflows/sync-device-classes.yml
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
54
tests/integration/fixtures/script_wait_on_boot.yaml
Normal file
54
tests/integration/fixtures/script_wait_on_boot.yaml
Normal 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"
|
||||
130
tests/integration/test_script_wait_on_boot.py
Normal file
130
tests/integration/test_script_wait_on_boot.py
Normal 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"
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user