mirror of
https://github.com/esphome/esphome.git
synced 2026-07-10 00:45:36 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f198ffb2e | |||
| b0dc688c14 | |||
| 2b422cbd99 | |||
| 9930b3c216 | |||
| 55f4e5cb75 | |||
| 71550bb3be | |||
| a58b4edb6a | |||
| f85fdb475a | |||
| 4a78c8d45a | |||
| c3bef24389 | |||
| 7182b1a8ae | |||
| 64e32ebe04 | |||
| 94b10981e1 | |||
| 680c9fc9c0 |
@@ -48,7 +48,7 @@ jobs:
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Set TAG
|
||||
run: |
|
||||
|
||||
@@ -6,14 +6,6 @@ on:
|
||||
branches: [dev, beta, release]
|
||||
|
||||
pull_request:
|
||||
paths:
|
||||
- "**"
|
||||
- "!.github/workflows/*.yml"
|
||||
- "!.github/actions/build-image/*"
|
||||
- ".github/workflows/ci.yml"
|
||||
- "!.yamllint"
|
||||
- "!.github/dependabot.yml"
|
||||
- "!docker/**"
|
||||
merge_group:
|
||||
|
||||
permissions:
|
||||
@@ -101,6 +93,8 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: needs.determine-jobs.outputs.core-ci == 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -223,6 +217,8 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: needs.determine-jobs.outputs.core-ci == 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -261,6 +257,7 @@ jobs:
|
||||
needs:
|
||||
- common
|
||||
outputs:
|
||||
core-ci: ${{ steps.determine.outputs.core-ci }}
|
||||
integration-tests: ${{ steps.determine.outputs.integration-tests }}
|
||||
integration-test-buckets: ${{ steps.determine.outputs.integration-test-buckets }}
|
||||
clang-tidy: ${{ steps.determine.outputs.clang-tidy }}
|
||||
@@ -314,6 +311,7 @@ jobs:
|
||||
echo "$output" | jq
|
||||
|
||||
# Extract individual fields
|
||||
echo "core-ci=$(echo "$output" | jq -r '.core_ci')" >> $GITHUB_OUTPUT
|
||||
echo "integration-tests=$(echo "$output" | jq -r '.integration_tests')" >> $GITHUB_OUTPUT
|
||||
echo "integration-test-buckets=$(echo "$output" | jq -c '.integration_test_buckets')" >> $GITHUB_OUTPUT
|
||||
echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT
|
||||
@@ -969,7 +967,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- common
|
||||
if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release')
|
||||
- determine-jobs
|
||||
if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -84,6 +84,6 @@ jobs:
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -99,15 +99,15 @@ jobs:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log in to docker hub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the GitHub container registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -178,17 +178,17 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log in to docker hub
|
||||
if: matrix.registry == 'dockerhub'
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the GitHub container registry
|
||||
if: matrix.registry == 'ghcr'
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -1821,6 +1821,7 @@ async def to_code(config):
|
||||
cg.add_build_flag("-Wno-error=overloaded-virtual")
|
||||
cg.add_build_flag("-Wno-error=reorder")
|
||||
cg.add_build_flag("-Wno-error=volatile")
|
||||
cg.add_build_flag("-Wno-error=cpp")
|
||||
# -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates
|
||||
cg.add_build_flag("-Wno-missing-field-initializers")
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/defines.h"
|
||||
#include "preferences.h"
|
||||
#include <esp_attr.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
@@ -14,10 +15,14 @@ extern "C" __attribute__((weak)) void initArduino() {}
|
||||
namespace esphome {
|
||||
|
||||
// HAL functions live in hal.cpp. This file keeps only the loop task setup.
|
||||
TaskHandle_t loop_task_handle = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static StaticTask_t loop_task_tcb; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static StackType_t
|
||||
loop_task_stack[ESPHOME_LOOP_TASK_STACK_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
// Force the static TCB and stack into internal DRAM. Otherwise the linker may
|
||||
// place them in PSRAM under configs like CONFIG_SPIRAM_ALLOW_BSS_SEG_EXTERNAL_MEMORY,
|
||||
// which is unsafe when CONFIG_SPIRAM_XIP_FROM_PSRAM is enabled because the running
|
||||
// task's stack must be reachable while the flash cache is disabled.
|
||||
TaskHandle_t loop_task_handle = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static StaticTask_t DRAM_ATTR loop_task_tcb{}; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static StackType_t DRAM_ATTR
|
||||
loop_task_stack[ESPHOME_LOOP_TASK_STACK_SIZE]{}; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
void loop_task(void *pv_params) {
|
||||
setup();
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <core_esp8266_features.h>
|
||||
#include <coredecls.h>
|
||||
|
||||
extern "C" {
|
||||
#include <user_interface.h>
|
||||
@@ -71,23 +72,22 @@ uint32_t IRAM_ATTR HOT millis() {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Poll-based delay that avoids ::delay() — Arduino's __delay has an intra-object
|
||||
// call to the original millis() that --wrap can't intercept, so calling ::delay()
|
||||
// would keep the slow Arduino millis body alive in IRAM. optimistic_yield still
|
||||
// enters esp_schedule()/esp_suspend_within_cont() via yield(), so SDK tasks and
|
||||
// WiFi run correctly. Theoretically less power-efficient than Arduino's
|
||||
// os_timer-based delay() for long waits, but nearly all ESPHome delays are short
|
||||
// (sensor/I²C/SPI settling in the 1–100 ms range) where the difference is
|
||||
// negligible.
|
||||
// Delegate to Arduino's 1-arg esp_delay(), which uses os_timer + esp_suspend to
|
||||
// suspend the cont task for `ms` milliseconds without polling millis(). This
|
||||
// matches pre-2026.5.0 behavior (when esphome::delay() forwarded to ::delay())
|
||||
// and lets the SDK run freely while we wait, which timing-sensitive
|
||||
// interrupt-driven code (e.g. ESP8266 software-serial RX in components like
|
||||
// fingerprint_grow) depends on. The poll-based busy-wait that this replaced
|
||||
// rarely yielded inside short waits like delay(1), starving WiFi/SDK tasks and
|
||||
// extending interrupt latency. Unlike ::delay(), esp_delay()'s 1-arg form does
|
||||
// not call millis(), so the slow Arduino millis() body is not pulled into IRAM
|
||||
// by this path (the --wrap=millis goal of #15662 is preserved).
|
||||
void HOT delay(uint32_t ms) {
|
||||
if (ms == 0) {
|
||||
optimistic_yield(1000);
|
||||
return;
|
||||
}
|
||||
uint32_t start = millis();
|
||||
while (millis() - start < ms) {
|
||||
optimistic_yield(1000);
|
||||
}
|
||||
esp_delay(ms);
|
||||
}
|
||||
|
||||
void arch_restart() {
|
||||
|
||||
@@ -17,9 +17,9 @@ void HomeassistantSensor::setup() {
|
||||
}
|
||||
|
||||
if (this->attribute_ != nullptr) {
|
||||
ESP_LOGD(TAG, "'%s::%s': Got attribute state %.2f", this->entity_id_, this->attribute_, *val);
|
||||
ESP_LOGV(TAG, "'%s::%s': Got attribute state %.2f", this->entity_id_, this->attribute_, *val);
|
||||
} else {
|
||||
ESP_LOGD(TAG, "'%s': Got state %.2f", this->entity_id_, *val);
|
||||
ESP_LOGV(TAG, "'%s': Got state %.2f", this->entity_id_, *val);
|
||||
}
|
||||
this->publish_state(*val);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include <driver/gpio.h>
|
||||
#include <driver/i2s_std.h>
|
||||
|
||||
#include "esphome/components/audio/audio.h"
|
||||
@@ -299,6 +300,15 @@ void I2SAudioSpeakerBase::stop_i2s_driver_() {
|
||||
i2s_channel_disable(this->tx_handle_);
|
||||
i2s_del_channel(this->tx_handle_);
|
||||
this->tx_handle_ = nullptr;
|
||||
|
||||
// i2s_del_channel() leaves dout wired to this port's data-out signal in the GPIO matrix: it only
|
||||
// clears an internal reservation mask, never the esp_rom_gpio_connect_out_signal() routing that
|
||||
// setup installed. If another speaker reuses this port (shared bus), its audio still reaches our
|
||||
// dout. Detach the pin and drive it low so a stale output stops driving downstream hardware: a
|
||||
// SPDIF optical transmitter would otherwise stay lit, and an analog DAC would emit noise.
|
||||
gpio_reset_pin(this->dout_pin_);
|
||||
gpio_set_direction(this->dout_pin_, GPIO_MODE_OUTPUT);
|
||||
gpio_set_level(this->dout_pin_, 0);
|
||||
}
|
||||
this->parent_->unlock();
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ import subprocess
|
||||
# - RTL8710B: hal.h uses section(".image2.ram.text"); stock linker consumes it.
|
||||
# - RTL8720C: hal.h uses section(".sram.text"); stock linker consumes it.
|
||||
# - LN882H: stock linker has no glob for ".sram.text", so we inject
|
||||
# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH).
|
||||
# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH)
|
||||
# immediately after KEEP(*(.vectors)), so the vector table stays at
|
||||
# __copysection_ram0_start (0x20000000) for correct Cortex-M4 VTOR alignment.
|
||||
#
|
||||
# All families also get a post-link summary showing where IRAM_ATTR landed.
|
||||
|
||||
@@ -27,7 +29,11 @@ _KEEP_LINE = (
|
||||
"__esphome_sram_text_end = .; "
|
||||
+ _MARKER + "\n"
|
||||
)
|
||||
_LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\n)")
|
||||
# Inject after KEEP(*(.vectors)) so the vector table stays at
|
||||
# __copysection_ram0_start (0x20000000). Cortex-M4 VTOR requires a 512-byte-
|
||||
# aligned address; injecting before the vectors would push them to an
|
||||
# unaligned offset and mis-route every IRQ handler.
|
||||
_LN_COPY = re.compile(r"(KEEP\(\*\(\.vectors\)\)[^\n]*\n)")
|
||||
|
||||
|
||||
def _detect(env):
|
||||
@@ -56,7 +62,7 @@ KNOWN_VARIANTS = frozenset({
|
||||
|
||||
|
||||
def _inject_keep(host_section):
|
||||
"""Return a patcher that injects _KEEP_LINE at the top of `host_section`."""
|
||||
"""Return a patcher that injects _KEEP_LINE after `host_section` match."""
|
||||
def patch(content):
|
||||
if _MARKER in content:
|
||||
return content
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.automation import Trigger, validate_automation
|
||||
@@ -534,7 +535,16 @@ def strip_defaults(schema: cv.Schema):
|
||||
return cv.Schema({cv.Optional(k): v for k, v in schema.schema.items()})
|
||||
|
||||
|
||||
def container_schema(widget_type: WidgetType, extras=None):
|
||||
# Keyed by (id(widget_type), id(extras)); strong refs in the value keep both
|
||||
# alive so id() can't be recycled.
|
||||
_CONTAINER_SCHEMA_CACHE: dict[
|
||||
tuple[int, int], tuple[Any, Any, Callable[[Any], Any]]
|
||||
] = {}
|
||||
|
||||
|
||||
def container_schema(
|
||||
widget_type: WidgetType, extras: Any = None
|
||||
) -> Callable[[Any], Any]:
|
||||
"""
|
||||
Create a schema for a container widget of a given type. All obj properties are available, plus
|
||||
the extras passed in, plus any defined for the specific widget being specified.
|
||||
@@ -542,19 +552,31 @@ def container_schema(widget_type: WidgetType, extras=None):
|
||||
:param extras: Additional options to be made available, e.g. layout properties for children
|
||||
:return: The schema for this type of widget.
|
||||
"""
|
||||
schema = obj_schema(widget_type).extend(
|
||||
{cv.GenerateID(): cv.declare_id(widget_type.w_type)}
|
||||
)
|
||||
if extras:
|
||||
schema = schema.extend(extras)
|
||||
# Delayed evaluation for recursion
|
||||
cache_key = (id(widget_type), id(extras))
|
||||
cached = _CONTAINER_SCHEMA_CACHE.get(cache_key)
|
||||
if cached is not None:
|
||||
cached_widget_type, cached_extras, cached_validator = cached
|
||||
if cached_widget_type is widget_type and cached_extras is extras:
|
||||
return cached_validator
|
||||
|
||||
schema = schema.extend(widget_type.schema)
|
||||
cached_schema: cv.Schema | None = None
|
||||
|
||||
def validator(value):
|
||||
def get_schema() -> cv.Schema:
|
||||
nonlocal cached_schema
|
||||
if cached_schema is None:
|
||||
schema = obj_schema(widget_type).extend(
|
||||
{cv.GenerateID(): cv.declare_id(widget_type.w_type)}
|
||||
)
|
||||
if extras:
|
||||
schema = schema.extend(extras)
|
||||
cached_schema = schema.extend(widget_type.schema)
|
||||
return cached_schema
|
||||
|
||||
def validator(value: Any) -> Any:
|
||||
value = value or {}
|
||||
return append_layout_schema(schema, value)(value)
|
||||
return append_layout_schema(get_schema(), value)(value)
|
||||
|
||||
_CONTAINER_SCHEMA_CACHE[cache_key] = (widget_type, extras, validator)
|
||||
return validator
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from collections.abc import Callable
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from esphome import codegen as cg, config_validation as cv
|
||||
from esphome.automation import register_action
|
||||
@@ -15,6 +17,7 @@ from esphome.const import (
|
||||
from esphome.core import ID, EsphomeError, TimePeriod
|
||||
from esphome.coroutine import FakeAwaitable
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.schema_extractors import EnableSchemaExtraction
|
||||
from esphome.types import Expression
|
||||
|
||||
from ..defines import (
|
||||
@@ -73,6 +76,34 @@ from ..types import (
|
||||
EVENT_LAMB = "event_lamb__"
|
||||
|
||||
|
||||
def _build_update_schema(widget_type: "WidgetType") -> Schema:
|
||||
# Local import: ..schemas imports WidgetType from this module.
|
||||
from ..schemas import base_update_schema
|
||||
|
||||
return base_update_schema(widget_type, widget_type.parts).extend(
|
||||
widget_type.modify_schema
|
||||
)
|
||||
|
||||
|
||||
def _update_action_schema(
|
||||
widget_type: "WidgetType",
|
||||
) -> Schema | Callable[[Any], Any]:
|
||||
# Eager when extracting so build_language_schema.py sees the mapping;
|
||||
# lazy otherwise to skip ~200 ms of import-time voluptuous work.
|
||||
if EnableSchemaExtraction:
|
||||
return _build_update_schema(widget_type)
|
||||
|
||||
cached: Schema | None = None
|
||||
|
||||
def validator(value: Any) -> Any:
|
||||
nonlocal cached
|
||||
if cached is None:
|
||||
cached = _build_update_schema(widget_type)
|
||||
return cached(value)
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
class WidgetType:
|
||||
"""
|
||||
Describes a type of Widget, e.g. "bar" or "line"
|
||||
@@ -113,18 +144,17 @@ class WidgetType:
|
||||
|
||||
# Local import to avoid circular import
|
||||
from ..automation import update_to_code
|
||||
from ..schemas import WIDGET_TYPES, base_update_schema
|
||||
from ..schemas import WIDGET_TYPES
|
||||
|
||||
if not is_mock:
|
||||
if self.name in WIDGET_TYPES:
|
||||
raise EsphomeError(f"Duplicate definition of widget type '{self.name}'")
|
||||
WIDGET_TYPES[self.name] = self
|
||||
|
||||
# Register the update action automatically, adding widget-specific properties
|
||||
register_action(
|
||||
f"lvgl.{self.name}.update",
|
||||
ObjUpdateAction,
|
||||
base_update_schema(self, self.parts).extend(self.modify_schema),
|
||||
_update_action_schema(self),
|
||||
synchronous=True,
|
||||
)(update_to_code)
|
||||
|
||||
|
||||
@@ -513,10 +513,11 @@ async def uart_write_to_code(config, action_id, template_arg, args):
|
||||
@coroutine_with_priority(CoroPriority.FINAL)
|
||||
async def final_step():
|
||||
"""Final code generation step to configure optional UART features."""
|
||||
if CORE.is_esp32 and CORE.has_networking:
|
||||
# Wake-on-RX is essentially free on ESP32 (just an ISR function pointer
|
||||
# registration) — enable by default to reduce RX buffer overflow risk
|
||||
# by waking the main loop immediately when data arrives.
|
||||
if (CORE.is_esp32 or CORE.is_esp8266) and CORE.has_networking:
|
||||
# Wake-on-RX is essentially free (just an ISR function pointer
|
||||
# registration on ESP32, an inline flag set on ESP8266 software
|
||||
# serial) — enable by default to reduce RX buffer overflow risk by
|
||||
# waking the main loop immediately when data arrives.
|
||||
cg.add_define("USE_UART_WAKE_LOOP_ON_RX")
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#ifdef USE_UART_WAKE_LOOP_ON_RX
|
||||
#include "esphome/core/wake.h"
|
||||
#endif
|
||||
|
||||
#ifdef USE_LOGGER
|
||||
#include "esphome/components/logger/logger.h"
|
||||
@@ -149,7 +152,11 @@ void ESP8266UartComponent::dump_config() {
|
||||
if (this->hw_serial_ != nullptr) {
|
||||
ESP_LOGCONFIG(TAG, " Using hardware serial interface.");
|
||||
} else {
|
||||
ESP_LOGCONFIG(TAG, " Using software serial");
|
||||
ESP_LOGCONFIG(TAG, " Using software serial"
|
||||
#ifdef USE_UART_WAKE_LOOP_ON_RX
|
||||
"\n Wake on data RX: ENABLED"
|
||||
#endif
|
||||
);
|
||||
}
|
||||
this->check_logger_conflict();
|
||||
}
|
||||
@@ -266,6 +273,12 @@ void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr(ESP8266SoftwareSerial *arg) {
|
||||
arg->rx_in_pos_ = (arg->rx_in_pos_ + 1) % arg->rx_buffer_size_;
|
||||
// Clear RX pin so that the interrupt doesn't re-trigger right away again.
|
||||
arg->rx_pin_.clear_interrupt();
|
||||
#ifdef USE_UART_WAKE_LOOP_ON_RX
|
||||
// Wake the main loop so the consuming component drains the byte promptly
|
||||
// instead of waiting for the next loop_interval_ tick. Important for timing
|
||||
// sensitive setups that poll read() in a tight loop (e.g. fingerprint_grow).
|
||||
wake_loop_isrsafe();
|
||||
#endif
|
||||
}
|
||||
void IRAM_ATTR HOT ESP8266SoftwareSerial::write_byte(uint8_t data) {
|
||||
if (this->gpio_tx_pin_ == nullptr) {
|
||||
|
||||
@@ -16,7 +16,7 @@ hypothesis==6.92.1
|
||||
# CodSpeed benchmarks under tests/benchmarks/python/
|
||||
# (skipped via pytest.importorskip when missing -- only required for the
|
||||
# benchmarks job in .github/workflows/ci.yml)
|
||||
pytest-codspeed==5.0.2
|
||||
pytest-codspeed==5.0.3
|
||||
|
||||
# Used by the import-time regression check (.github/workflows/ci.yml → import-time job)
|
||||
importtime-waterfall==1.0.0
|
||||
|
||||
@@ -5,6 +5,7 @@ This script is a centralized way to determine which CI jobs need to run based on
|
||||
what files have changed. It outputs JSON with the following structure:
|
||||
|
||||
{
|
||||
"core_ci": true/false,
|
||||
"integration_tests": true/false,
|
||||
"integration_test_buckets": [{"name": "1/3", "tests": ["tests/integration/test_foo.py", ...]}, ...],
|
||||
"clang_tidy": true/false,
|
||||
@@ -22,6 +23,11 @@ what files have changed. It outputs JSON with the following structure:
|
||||
}
|
||||
|
||||
The CI workflow uses this information to:
|
||||
- Gate the unconditional jobs (ci-custom, pytest, pre-commit-ci-lite) via core_ci;
|
||||
false when a pull_request only touches CI-irrelevant meta paths (other workflow
|
||||
files, .github/actions/build-image/*, .yamllint, .github/dependabot.yml, docker/**)
|
||||
so workflow-only PRs satisfy the required CI Status check without running the
|
||||
unconditional jobs. Always true on non-pull_request events and under --force-all.
|
||||
- Skip or run integration tests
|
||||
- Skip or run clang-tidy (and whether to do a full scan)
|
||||
- Skip or run clang-format
|
||||
@@ -712,6 +718,69 @@ def should_run_benchmarks(branch: str | None = None) -> bool:
|
||||
return any(get_component_from_path(f) in benchmarked_components for f in files)
|
||||
|
||||
|
||||
# Files / path patterns whose changes alone don't warrant running the
|
||||
# unconditional CI jobs (`ci-custom`, `pytest`, `pre-commit-ci-lite`).
|
||||
# Single source of truth for what we treat as "CI-irrelevant" on
|
||||
# pull_request events; ci.yml used to encode this in its own
|
||||
# `pull_request.paths` filter, but that hid the required `CI Status`
|
||||
# check on PRs that only touched these files (dependabot Action bumps,
|
||||
# dependabot.yml edits, docker/ changes, etc.) and forced admin
|
||||
# force-merges.
|
||||
#
|
||||
# ci.yml itself is deliberately *not* ignored — editing the CI workflow
|
||||
# must still run CI. Workflows that have their own dedicated triggers
|
||||
# (codeql.yml, ci-docker.yml, ...) are matched via the
|
||||
# `.github/workflows/*.yml` prefix below and exclude ci.yml explicitly.
|
||||
CI_IRRELEVANT_EXACT_FILES = frozenset(
|
||||
{
|
||||
".yamllint",
|
||||
".github/dependabot.yml",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_ci_irrelevant_path(path: str) -> bool:
|
||||
"""Whether a single changed path is irrelevant to the unconditional CI jobs."""
|
||||
if path in CI_IRRELEVANT_EXACT_FILES:
|
||||
return True
|
||||
# docker/** — all descendants
|
||||
if path.startswith("docker/"):
|
||||
return True
|
||||
# .github/workflows/*.yml — top-level workflow files other than ci.yml
|
||||
# (ci.yml itself must still trigger full CI when edited).
|
||||
if path.startswith(".github/workflows/") and path.endswith(".yml"):
|
||||
if path == ".github/workflows/ci.yml":
|
||||
return False
|
||||
if "/" not in path[len(".github/workflows/") :]:
|
||||
return True
|
||||
# .github/actions/build-image/* — direct children only, matches the
|
||||
# single-star glob the workflow used to encode.
|
||||
if path.startswith(".github/actions/build-image/"):
|
||||
rest = path[len(".github/actions/build-image/") :]
|
||||
if rest and "/" not in rest:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def should_run_core_ci(branch: str | None = None) -> bool:
|
||||
"""Determine if the unconditional CI jobs (ci-custom/pytest/pre-commit-ci-lite) should run.
|
||||
|
||||
Returns False only when every changed file is in the CI-irrelevant set
|
||||
above (see ``_is_ci_irrelevant_path``). Empty diffs return True so we
|
||||
never accidentally skip CI when the diff probe fails.
|
||||
|
||||
Args:
|
||||
branch: Branch to compare against. If None, uses default.
|
||||
|
||||
Returns:
|
||||
True if the unconditional CI jobs should run, False otherwise.
|
||||
"""
|
||||
files = changed_files(branch)
|
||||
if not files:
|
||||
return True
|
||||
return any(not _is_ci_irrelevant_path(f) for f in files)
|
||||
|
||||
|
||||
def _any_changed_file_endswith(branch: str | None, extensions: tuple[str, ...]) -> bool:
|
||||
"""Check if a changed file ends with any of the specified extensions."""
|
||||
return any(file.endswith(extensions) for file in changed_files(branch))
|
||||
@@ -1075,6 +1144,16 @@ def main() -> None:
|
||||
args = parser.parse_args()
|
||||
|
||||
# Determine what should run
|
||||
# core_ci gates the unconditional jobs in ci.yml (ci-custom, pytest,
|
||||
# pre-commit-ci-lite). Non-pull_request events (push to dev/beta/release
|
||||
# and merge_group) always run them so behavior like venv-cache saves on
|
||||
# push to dev is preserved.
|
||||
event_name = os.environ.get("GITHUB_EVENT_NAME", "")
|
||||
run_core_ci = (
|
||||
True
|
||||
if args.force_all or event_name != "pull_request"
|
||||
else should_run_core_ci(args.branch)
|
||||
)
|
||||
if args.force_all:
|
||||
integration_run_all, integration_test_files = True, []
|
||||
run_clang_tidy = True
|
||||
@@ -1255,6 +1334,7 @@ def main() -> None:
|
||||
component_test_batches = []
|
||||
|
||||
output: dict[str, Any] = {
|
||||
"core_ci": run_core_ci,
|
||||
"integration_tests": run_integration,
|
||||
"integration_test_buckets": integration_test_buckets,
|
||||
"clang_tidy": run_clang_tidy,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Tests for container_schema() memoization and lazy build."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
import esphome.components.lvgl # noqa: F401
|
||||
from esphome.components.lvgl import schemas as lvgl_schemas
|
||||
from esphome.components.lvgl.schemas import WIDGET_TYPES, container_schema
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_container_schema_cache() -> Generator[None]:
|
||||
cache = getattr(lvgl_schemas, "_CONTAINER_SCHEMA_CACHE", None)
|
||||
if cache is not None:
|
||||
cache.clear()
|
||||
yield
|
||||
if cache is not None:
|
||||
cache.clear()
|
||||
|
||||
|
||||
def _widget_type(name: str = "obj"):
|
||||
wt = WIDGET_TYPES.get(name)
|
||||
assert wt is not None, f"widget type {name!r} not registered"
|
||||
return wt
|
||||
|
||||
|
||||
def test_same_args_return_same_validator() -> None:
|
||||
wt = _widget_type("obj")
|
||||
assert container_schema(wt) is container_schema(wt)
|
||||
|
||||
|
||||
def test_extras_none_vs_truthy_get_different_validators() -> None:
|
||||
wt = _widget_type("obj")
|
||||
no_extras = container_schema(wt)
|
||||
extras = {cv.Optional("custom_extra"): cv.string}
|
||||
assert no_extras is not container_schema(wt, extras)
|
||||
|
||||
|
||||
def test_different_widget_types_get_different_validators() -> None:
|
||||
assert container_schema(_widget_type("obj")) is not container_schema(
|
||||
_widget_type("label")
|
||||
)
|
||||
|
||||
|
||||
def test_schema_build_is_deferred_until_first_validation() -> None:
|
||||
wt = _widget_type("obj")
|
||||
with patch.object(
|
||||
lvgl_schemas, "obj_schema", wraps=lvgl_schemas.obj_schema
|
||||
) as obj_schema_mock:
|
||||
validator = container_schema(wt)
|
||||
assert obj_schema_mock.call_count == 0
|
||||
validator({})
|
||||
assert obj_schema_mock.call_count == 1
|
||||
validator({})
|
||||
assert obj_schema_mock.call_count == 1
|
||||
|
||||
|
||||
def test_cached_validator_produces_equivalent_output() -> None:
|
||||
wt = _widget_type("obj")
|
||||
cached = container_schema(wt)
|
||||
cached_result = cached({})
|
||||
lvgl_schemas._CONTAINER_SCHEMA_CACHE.clear()
|
||||
reference = container_schema(wt)
|
||||
assert cached is not reference
|
||||
assert cached_result == reference({})
|
||||
|
||||
|
||||
def test_id_recycling_is_caught_by_identity_guard() -> None:
|
||||
wt = _widget_type("obj")
|
||||
real_extras = {cv.Optional("a"): cv.int_}
|
||||
validator_a = container_schema(wt, real_extras)
|
||||
|
||||
cache_key = (id(wt), id(real_extras))
|
||||
cached_entry = lvgl_schemas._CONTAINER_SCHEMA_CACHE[cache_key]
|
||||
sentinel = {cv.Optional("a"): cv.int_}
|
||||
lvgl_schemas._CONTAINER_SCHEMA_CACHE[cache_key] = (
|
||||
cached_entry[0],
|
||||
sentinel,
|
||||
cached_entry[2],
|
||||
)
|
||||
|
||||
assert container_schema(wt, real_extras) is not validator_a
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Tests for lvgl.<widget>.update lazy schema build."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from esphome.automation import ACTION_REGISTRY
|
||||
import esphome.components.lvgl # noqa: F401
|
||||
from esphome.components.lvgl.schemas import WIDGET_TYPES
|
||||
from esphome.components.lvgl.widgets import _update_action_schema
|
||||
from esphome.config_validation import Schema
|
||||
|
||||
|
||||
def _widget_type(name: str = "obj"):
|
||||
wt = WIDGET_TYPES.get(name)
|
||||
assert wt is not None, f"widget type {name!r} not registered"
|
||||
return wt
|
||||
|
||||
|
||||
def test_registry_entry_uses_lazy_validator() -> None:
|
||||
entry = ACTION_REGISTRY["lvgl.label.update"]
|
||||
assert callable(entry.raw_schema)
|
||||
assert not isinstance(entry.raw_schema, Schema)
|
||||
|
||||
|
||||
def test_lazy_validator_defers_build_until_first_call() -> None:
|
||||
wt = _widget_type("label")
|
||||
with patch(
|
||||
"esphome.components.lvgl.widgets._build_update_schema",
|
||||
wraps=lambda w: Schema({}),
|
||||
) as build_mock:
|
||||
validator = _update_action_schema(wt)
|
||||
assert build_mock.call_count == 0
|
||||
validator({})
|
||||
assert build_mock.call_count == 1
|
||||
validator({})
|
||||
assert build_mock.call_count == 1
|
||||
|
||||
|
||||
def test_eager_build_when_schema_extraction_enabled() -> None:
|
||||
wt = _widget_type("label")
|
||||
with patch("esphome.components.lvgl.widgets.EnableSchemaExtraction", True):
|
||||
result = _update_action_schema(wt)
|
||||
assert isinstance(result, Schema)
|
||||
|
||||
|
||||
def test_lazy_and_eager_produce_equivalent_validation() -> None:
|
||||
wt = _widget_type("label")
|
||||
with patch("esphome.components.lvgl.widgets.EnableSchemaExtraction", True):
|
||||
eager = _update_action_schema(wt)
|
||||
lazy = _update_action_schema(wt)
|
||||
sample = {"id": "label_id"}
|
||||
assert lazy(sample) == eager(sample)
|
||||
@@ -1503,13 +1503,18 @@ async def test_websocket_refresh_command(
|
||||
) -> None:
|
||||
"""Test WebSocket refresh command triggers dashboard update."""
|
||||
with patch("esphome.dashboard.web_server.DASHBOARD_SUBSCRIBER") as mock_subscriber:
|
||||
mock_subscriber.request_refresh = Mock()
|
||||
# Signal an asyncio.Event when request_refresh is invoked so the
|
||||
# test can deterministically wait for the server-side handler to run
|
||||
# instead of relying on a fixed sleep (flaky on Windows CI under load).
|
||||
called = asyncio.Event()
|
||||
mock_subscriber.request_refresh = Mock(side_effect=called.set)
|
||||
|
||||
# Send refresh command
|
||||
await websocket_client.write_message(json.dumps({"event": "refresh"}))
|
||||
|
||||
# Give it a moment to process
|
||||
await asyncio.sleep(0.01)
|
||||
# Wait for the server to process the message and invoke request_refresh
|
||||
async with asyncio.timeout(5):
|
||||
await called.wait()
|
||||
|
||||
# Verify request_refresh was called
|
||||
mock_subscriber.request_refresh.assert_called_once()
|
||||
|
||||
@@ -775,6 +775,88 @@ def test_should_run_import_time_with_branch() -> None:
|
||||
mock_changed.assert_called_once_with("release")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path", "expected_result"),
|
||||
[
|
||||
# Exact-file matches in the CI-irrelevant set.
|
||||
(".yamllint", True),
|
||||
(".github/dependabot.yml", True),
|
||||
# Other top-level workflow files are irrelevant; ci.yml itself is not.
|
||||
(".github/workflows/codeql.yml", True),
|
||||
(".github/workflows/release.yml", True),
|
||||
(".github/workflows/ci.yml", False),
|
||||
# Nested files under workflows/ are not matched by the single-star glob.
|
||||
(".github/workflows/matchers/gcc.json", False),
|
||||
# build-image action: direct children only (single-star glob).
|
||||
(".github/actions/build-image/action.yml", True),
|
||||
(".github/actions/build-image/nested/file.yml", False),
|
||||
# Other actions are CI-relevant.
|
||||
(".github/actions/restore-python/action.yml", False),
|
||||
# docker/** covers everything under docker/.
|
||||
("docker/Dockerfile", True),
|
||||
("docker/scripts/run.sh", True),
|
||||
# Regular source files are CI-relevant.
|
||||
("esphome/__main__.py", False),
|
||||
("esphome/components/wifi/wifi_component.cpp", False),
|
||||
("README.md", False),
|
||||
("tests/script/test_determine_jobs.py", False),
|
||||
],
|
||||
)
|
||||
def test_is_ci_irrelevant_path(path: str, expected_result: bool) -> None:
|
||||
"""Test _is_ci_irrelevant_path mirrors the historic ci.yml path filter."""
|
||||
assert determine_jobs._is_ci_irrelevant_path(path) == expected_result
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("changed_files", "expected_result"),
|
||||
[
|
||||
# Empty diffs default to True — don't accidentally skip CI on a
|
||||
# broken probe.
|
||||
([], True),
|
||||
# Any CI-relevant file flips the result to True.
|
||||
(["esphome/__main__.py"], True),
|
||||
(["esphome/components/wifi/wifi_component.cpp"], True),
|
||||
(["README.md"], True),
|
||||
# All-irrelevant diffs return False.
|
||||
([".github/workflows/codeql.yml"], False),
|
||||
(
|
||||
[".github/workflows/codeql.yml", ".github/workflows/release.yml"],
|
||||
False,
|
||||
),
|
||||
([".yamllint"], False),
|
||||
([".github/dependabot.yml"], False),
|
||||
(["docker/Dockerfile"], False),
|
||||
(
|
||||
[
|
||||
".github/workflows/codeql.yml",
|
||||
".github/dependabot.yml",
|
||||
"docker/Dockerfile",
|
||||
],
|
||||
False,
|
||||
),
|
||||
# Mixed diffs always trigger CI.
|
||||
(
|
||||
[".github/workflows/codeql.yml", "esphome/__main__.py"],
|
||||
True,
|
||||
),
|
||||
# ci.yml itself is treated as CI-relevant.
|
||||
([".github/workflows/ci.yml"], True),
|
||||
],
|
||||
)
|
||||
def test_should_run_core_ci(changed_files: list[str], expected_result: bool) -> None:
|
||||
"""Test should_run_core_ci function."""
|
||||
with patch.object(determine_jobs, "changed_files", return_value=changed_files):
|
||||
assert determine_jobs.should_run_core_ci() == expected_result
|
||||
|
||||
|
||||
def test_should_run_core_ci_with_branch() -> None:
|
||||
"""Test should_run_core_ci passes the branch through to changed_files."""
|
||||
with patch.object(determine_jobs, "changed_files") as mock_changed:
|
||||
mock_changed.return_value = []
|
||||
determine_jobs.should_run_core_ci("release")
|
||||
mock_changed.assert_called_once_with("release")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("changed_files", "expected_result"),
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user