diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af982202cb..de364794f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -404,8 +404,9 @@ jobs: - name: Install apt packages (cached) # ccache speeds up the host compiles. A cache hit never touches apt # (mirror outages cannot hang the job); the timeout bounds the cold - # path. Packages and version must match seed-apt-cache exactly; - # libsdl2-dev is unused here and carried only for cache-key parity. + # path. Packages and version must match seed-apt-cache exactly. + # libsdl2-dev is needed by the headless display tests, which capture + # screenshots. timeout-minutes: 10 uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 with: @@ -468,6 +469,16 @@ jobs: echo "Bucket ${{ matrix.bucket.name }}: running ${#test_files[@]} integration tests" pytest -vv --no-cov --tb=native --durations=30 -n auto --dist worksteal \ --junitxml=junit-integration.xml "${test_files[@]}" + - name: Upload test artifacts + # Tests that compare rendered output write the image they actually got here, so a + # failure can be looked at without reproducing the whole build locally. + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: integration-test-artifacts-${{ matrix.bucket.name }} + path: test_artifacts/ + if-no-files-found: ignore + retention-days: 7 - name: Upload junit timings # Consumed by sync-integration-durations.yml through # script/update_integration_test_durations.py; only full matrix dev diff --git a/.gitignore b/.gitignore index fdb75824fb..82b00286c7 100644 --- a/.gitignore +++ b/.gitignore @@ -137,6 +137,8 @@ config/ !tests/component_tests/**/config/ tests/build/ tests/.esphome/ +# Output kept by failing tests for inspection; uploaded by CI +test_artifacts/ /.temp-clang-tidy.cpp /.temp/ .pio/ diff --git a/AGENTS.md b/AGENTS.md index f006ee6087..e932c50f32 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,16 @@ This document provides essential context for AI models interacting with this pro ## 4. Coding Conventions & Style Guide +**Read the developer documentation before writing a component.** https://developers.esphome.io covers the +component lifecycle, the main loop, and the reasoning behind the rules below in far more depth than this +file does, and it is the authority when they disagree. The most useful starting points: + +* https://developers.esphome.io/architecture/components/ - component lifecycle, `setup()`, `loop()`, + setup priorities, and how a component is registered. +* https://developers.esphome.io/architecture/components/advanced/ - choosing between `loop()`, + `set_interval`, `set_timeout` and `defer`; waking the loop from another thread; the RAM cost of each. +* https://developers.esphome.io/contributing/code/ - contribution rules, public API and breaking changes. + * **Formatting:** * **Python:** Uses `ruff` and `flake8` for linting and formatting. Configuration is in `pyproject.toml`. * **C++:** Uses `clang-format` for formatting. Configuration is in `.clang-format`. @@ -142,6 +152,47 @@ This document provides essential context for AI models interacting with this pro * **Indentation:** Use spaces (two per indentation level), not tabs * **Type aliases:** Prefer `using type_t = int;` over `typedef int type_t;` * **Line length:** Wrap lines at no more than 120 characters + * **Timing in `loop()`:** Never call `millis()` in a `loop()` body. The current tick's timestamp is + already cached - use `App.get_loop_component_start_time()` (from `esphome/core/application.h`). + Only reach for `millis()` when you genuinely need sub-tick resolution inside a long operation. + * **The main loop runs every 16 ms.** A rate-limit gate shorter than that does nothing: the check + passes on essentially every pass of the loop, so it costs a comparison and buys nothing. Pick an + interval comfortably coarser than 16 ms, or drop the gate entirely and accept running every loop. + ```cpp + // Bad - a 10ms gate against a 16ms loop never holds anything back + static constexpr uint32_t POLL_INTERVAL_MS = 10; + const uint32_t now = millis(); + if (now - this->last_poll_ < POLL_INTERVAL_MS) + return; + this->last_poll_ = now; + ``` + ```cpp + // Good - an interval that actually rate limits, off the cached timestamp + static constexpr uint32_t POLL_INTERVAL_MS = 100; + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_poll_ < POLL_INTERVAL_MS) + return; + this->last_poll_ = now; + ``` + Pick the primitive by cadence: under 250 ms use a gated `loop()`; 500 ms and above use + `set_interval`. Full reasoning, including why `set_interval` costs more below 500 ms: + https://developers.esphome.io/architecture/components/advanced/#quick-rule-of-thumb + * **Don't override a default with the same value:** if a base class method already returns what you + want, do not override it. `Component::get_setup_priority()` returns `setup_priority::DATA`, so a + component that wants `DATA` should simply leave it alone. + ```cpp + // Bad - this is exactly what the base class already does + float get_setup_priority() const override { return setup_priority::DATA; } + ``` + * **Logging string literals:** wrap literals passed as `%s` arguments in `LOG_STR_LITERAL()` so they + can be stored in flash rather than RAM. + ```cpp + // Bad + ESP_LOGV(TAG, "Key %u %s", key, pressed ? "pressed" : "released"); + + // Good + ESP_LOGV(TAG, "Key %u %s", key, pressed ? LOG_STR_LITERAL("pressed") : LOG_STR_LITERAL("released")); + ``` * **Constructor parameters vs setters:** Component properties that are both **required** and **invariant** (never change after construction) should be constructor parameters rather than set via setter methods. This makes the dependency explicit and prevents use of the object in an incompletely-initialized state. @@ -562,6 +613,33 @@ This document provides essential context for AI models interacting with this pro Use `cg.add_define("MAX_SERVICES", count)` to set the size from Python configuration. Like `std::array` but with vector-like API (`push_back()`, `size()`) and no STL reallocation code. + **Listener and child-entity registration lists are the most common case, and the most commonly + missed.** A `register_*()` method called once per child at code generation time has a count that + is known at compile time, so it should never be a `std::vector`. Use `cg.slot_counter()`: it + returns a function that each consumer calls once per slot it will occupy, and after every + `to_code` has run it emits the define with the final count. When nothing registers, no define is + emitted and the storage plus its registration method compile out entirely. + ```python + # hub component's __init__.py + _request_listener_slot = cg.slot_counter("MY_COMPONENT_LISTENER_COUNT") + + + async def register_listener(hub: MockObj, var: MockObj) -> None: + _request_listener_slot() + cg.add(hub.register_listener(var)) + ``` + ```cpp + #ifdef MY_COMPONENT_LISTENER_COUNT + void register_listener(MyComponentListener *listener); + #endif + protected: + #ifdef MY_COMPONENT_LISTENER_COUNT + StaticVector listeners_; + #endif + ``` + Request slots from `to_code`, not from a job that runs after `CoroPriority.FINAL` - a late + request raises rather than silently undercounting. + 3. **Runtime-known sizes:** Use `FixedVector` from `esphome/core/helpers.h` when the size is only known at runtime initialization. ```cpp // Bad - generates STL realloc code (_M_realloc_insert) @@ -599,9 +677,25 @@ This document provides essential context for AI models interacting with this pro ``` Linear search on small datasets (1-16 elements) is often faster than hashing/tree overhead, but this depends on lookup frequency and access patterns. For frequent lookups in hot code paths, the O(1) vs O(n) complexity difference may still matter even for small datasets. `std::vector` with simple structs is usually fine—it's the heavy containers (`map`, `set`, `unordered_map`) that should be avoided for small datasets unless profiling shows otherwise. - 5. **Avoid `std::deque`:** It allocates in 512-byte blocks regardless of element size, guaranteeing at least 512 bytes of RAM usage immediately. This is a major source of crashes on memory-constrained devices. + 5. **Strings set once from configuration:** Use `StringRef` (`esphome/core/string_ref.h`) rather than + `std::string`. Code generation passes a string literal that lives in flash for the life of the + program, so storing a `std::string` copies it onto the heap for nothing. `StringRef` is a + non-owning pointer plus length; it does not copy, and it must only ever refer to storage that + outlives it (a string literal, or a buffer owned elsewhere). + ```cpp + // Bad - heap copy of a literal that is already in flash + void set_keys(std::string keys) { this->keys_ = std::move(keys); } + std::string keys_; + ``` + ```cpp + // Good - no allocation + void set_keys(const char *keys) { this->keys_ = StringRef(keys); } + StringRef keys_; + ``` - 6. **Detection:** Look for these patterns in compiler output: + 6. **Avoid `std::deque`:** It allocates in 512-byte blocks regardless of element size, guaranteeing at least 512 bytes of RAM usage immediately. This is a major source of crashes on memory-constrained devices. + + 7. **Detection:** Look for these patterns in compiler output: - Large code sections with STL symbols (vector, map, set) - `alloc`, `realloc`, `dealloc` in symbol names - `_M_realloc_insert`, `_M_default_append` (vector reallocation) diff --git a/CODEOWNERS b/CODEOWNERS index 3429a93aa7..f91bc00ae5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -496,6 +496,7 @@ esphome/components/sm2335/* @Cossid esphome/components/sml/* @alengwenus esphome/components/smt100/* @piechade esphome/components/sn74hc165/* @jesserockz +esphome/components/snapshot/* @clydebarrow esphome/components/socket/* @esphome/core esphome/components/sonoff_d1/* @anatoly-savchenkov esphome/components/sound_level/* @kahrendt diff --git a/Doxyfile b/Doxyfile index 8f6048b4d8..1619371323 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.9.0-dev +PROJECT_NUMBER = 2026.10.0-dev # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/docker/Dockerfile b/docker/Dockerfile index 0da8048c57..7952616496 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.13.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.14.0 RUN \ platformio settings set enable_telemetry No \ diff --git a/esphome/arduino8266/framework.py b/esphome/arduino8266/framework.py index 139f9f8dbe..d0cb5a54a8 100644 --- a/esphome/arduino8266/framework.py +++ b/esphome/arduino8266/framework.py @@ -45,8 +45,7 @@ def get_arduino8266_tools_path() -> Path: return tools_cache_path(*ARDUINO8266_TOOLS_CACHE) -# 3.1.1 rather than 3.1.0: the registry has no package for 3.1.0, and the -# encoder below cannot name 3.0.0/3.0.1 either (see its docstring) +# 3.1.1 rather than 3.1.0: the registry has no packages for 3.0.0, 3.0.1 or 3.1.0 MIN_FRAMEWORK_VERSION = Version(3, 1, 1) @@ -54,20 +53,16 @@ def framework_package_version(ver: Version) -> str: """Map an Arduino core version to its registry package version (3.1.2 -> 3.30102.0; the leading 3 is the package major). - Exact registry names only for cores > 2.6.2 and >= 3.0.2; callers floor - at MIN_FRAMEWORK_VERSION. + Exact registry names for 3.x cores; callers floor at MIN_FRAMEWORK_VERSION. """ if ver.major > 3: raise EsphomeError( f"Arduino core {ver} is not supported yet; " "the newest known core series is 3.x" ) - if ver <= Version(2, 6, 2): - # Cores <= 2.6.2 use the older 1.x/2.x package-major encodings (same - # boundary as _format_framework_arduino_version's era guard) + if ver.major < 3: raise EsphomeError( - f"Arduino core {ver} uses an older package encoding than this " - "helper implements (newer than 2.6.2)" + f"Arduino core {ver} is not supported; ESPHome requires core 3.x" ) return f"3.{ver.major}{ver.minor:02d}{ver.patch:02d}.0" diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index 34684a87e1..f80de151b1 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -368,8 +368,8 @@ optional Climate::restore_state_() { } void Climate::save_state_(const ClimateTraits &traits) { -#if (defined(USE_ESP32) || (defined(USE_ESP8266) && USE_ARDUINO_VERSION_CODE >= VERSION_CODE(3, 0, 0))) && \ - !defined(CLANG_TIDY) +#if (defined(USE_ESP32) || defined(USE_ESP8266)) && !defined(CLANG_TIDY) +#pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wclass-memaccess" #define TEMP_IGNORE_MEMACCESS #endif diff --git a/esphome/components/cm1106/cm1106.cpp b/esphome/components/cm1106/cm1106.cpp index 7e5d25b7ae..2e3352b895 100644 --- a/esphome/components/cm1106/cm1106.cpp +++ b/esphome/components/cm1106/cm1106.cpp @@ -100,7 +100,6 @@ bool CM1106Component::cm1106_write_command_(const uint8_t *command, size_t comma void CM1106Component::dump_config() { ESP_LOGCONFIG(TAG, "CM1106:"); LOG_SENSOR(" ", "CO2", this->co2_sensor_); - this->check_uart_settings(9600); if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); } diff --git a/esphome/components/cm1106/sensor.py b/esphome/components/cm1106/sensor.py index 936c5fc673..a36f0b0059 100644 --- a/esphome/components/cm1106/sensor.py +++ b/esphome/components/cm1106/sensor.py @@ -46,6 +46,14 @@ CONFIG_SCHEMA = ( .extend(uart.UART_DEVICE_SCHEMA) ) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "cm1106", + baud_rate=9600, + data_bits=8, + parity="NONE", + stop_bits=1, +) + async def to_code(config: ConfigType) -> None: """Code generation entry point.""" diff --git a/esphome/components/cse7761/cse7761.cpp b/esphome/components/cse7761/cse7761.cpp index 4251751531..103bc84452 100644 --- a/esphome/components/cse7761/cse7761.cpp +++ b/esphome/components/cse7761/cse7761.cpp @@ -58,7 +58,6 @@ void CSE7761Component::dump_config() { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); } LOG_UPDATE_INTERVAL(this); - this->check_uart_settings(38400, 1, uart::UART_CONFIG_PARITY_EVEN, 8); } void CSE7761Component::update() { diff --git a/esphome/components/cse7761/sensor.py b/esphome/components/cse7761/sensor.py index b53ed26ca3..5f79be0255 100644 --- a/esphome/components/cse7761/sensor.py +++ b/esphome/components/cse7761/sensor.py @@ -68,7 +68,13 @@ CONFIG_SCHEMA = ( ) FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( - "cse7761", baud_rate=38400, require_rx=True, require_tx=True + "cse7761", + baud_rate=38400, + require_rx=True, + require_tx=True, + data_bits=8, + parity="EVEN", + stop_bits=1, ) diff --git a/esphome/components/cse7766/cse7766.cpp b/esphome/components/cse7766/cse7766.cpp index ce77b62b7b..30f1b7a867 100644 --- a/esphome/components/cse7766/cse7766.cpp +++ b/esphome/components/cse7766/cse7766.cpp @@ -255,7 +255,6 @@ void CSE7766Component::dump_config() { LOG_SENSOR(" ", "Apparent Power", this->apparent_power_sensor_); LOG_SENSOR(" ", "Reactive Power", this->reactive_power_sensor_); LOG_SENSOR(" ", "Power Factor", this->power_factor_sensor_); - this->check_uart_settings(4800, 1, uart::UART_CONFIG_PARITY_EVEN); } } // namespace esphome::cse7766 diff --git a/esphome/components/cse7766/sensor.py b/esphome/components/cse7766/sensor.py index a1a68e18e8..9bed0f3f59 100644 --- a/esphome/components/cse7766/sensor.py +++ b/esphome/components/cse7766/sensor.py @@ -84,7 +84,12 @@ CONFIG_SCHEMA = ( .extend(cv.COMPONENT_SCHEMA) ) FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( - "cse7766", baud_rate=4800, parity="EVEN", require_rx=True + "cse7766", + baud_rate=4800, + require_rx=True, + data_bits=8, + parity="EVEN", + stop_bits=1, ) diff --git a/esphome/components/daly_bms/__init__.py b/esphome/components/daly_bms/__init__.py index ba0be4d3a5..c0d7d0aa62 100644 --- a/esphome/components/daly_bms/__init__.py +++ b/esphome/components/daly_bms/__init__.py @@ -26,6 +26,14 @@ CONFIG_SCHEMA = ( .extend(cv.polling_component_schema("30s")) ) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "daly_bms", + baud_rate=9600, + data_bits=8, + parity="NONE", + stop_bits=1, +) + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/daly_bms/daly_bms.cpp b/esphome/components/daly_bms/daly_bms.cpp index 530d8ad541..45d4db4972 100644 --- a/esphome/components/daly_bms/daly_bms.cpp +++ b/esphome/components/daly_bms/daly_bms.cpp @@ -22,10 +22,7 @@ static const uint8_t DALY_REQUEST_TEMPERATURE = 0x96; void DalyBmsComponent::setup() { this->next_request_ = 1; } -void DalyBmsComponent::dump_config() { - ESP_LOGCONFIG(TAG, "Daly BMS:"); - this->check_uart_settings(9600); -} +void DalyBmsComponent::dump_config() { ESP_LOGCONFIG(TAG, "Daly BMS:"); } void DalyBmsComponent::update() { this->trigger_next_ = true; diff --git a/esphome/components/debug/debug_component.cpp b/esphome/components/debug/debug_component.cpp index 9020c261c2..97f4522c62 100644 --- a/esphome/components/debug/debug_component.cpp +++ b/esphome/components/debug/debug_component.cpp @@ -22,9 +22,9 @@ void DebugComponent::dump_config() { LOG_SENSOR(" ", "Free space on heap", this->free_sensor_); LOG_SENSOR(" ", "Largest free heap block", this->block_sensor_); LOG_SENSOR(" ", "CPU frequency", this->cpu_frequency_sensor_); -#if defined(USE_ESP8266) && USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 5, 2) +#ifdef USE_ESP8266 LOG_SENSOR(" ", "Heap fragmentation", this->fragmentation_sensor_); -#endif // defined(USE_ESP8266) && USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 5, 2) +#endif // USE_ESP8266 #endif // USE_SENSOR char device_info_buffer[DEVICE_INFO_BUFFER_SIZE]; diff --git a/esphome/components/debug/debug_component.h b/esphome/components/debug/debug_component.h index 20798cf600..b05029f878 100644 --- a/esphome/components/debug/debug_component.h +++ b/esphome/components/debug/debug_component.h @@ -35,7 +35,7 @@ class DebugComponent final : public PollingComponent { #ifdef USE_SENSOR void set_free_sensor(sensor::Sensor *free_sensor) { free_sensor_ = free_sensor; } void set_block_sensor(sensor::Sensor *block_sensor) { block_sensor_ = block_sensor; } -#if (defined(USE_ESP8266) && USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 5, 2)) || defined(USE_ESP32) +#if defined(USE_ESP8266) || defined(USE_ESP32) void set_fragmentation_sensor(sensor::Sensor *fragmentation_sensor) { fragmentation_sensor_ = fragmentation_sensor; } #endif #if defined(USE_ESP32) || defined(USE_LIBRETINY) @@ -61,7 +61,7 @@ class DebugComponent final : public PollingComponent { sensor::Sensor *free_sensor_{nullptr}; sensor::Sensor *block_sensor_{nullptr}; -#if (defined(USE_ESP8266) && USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 5, 2)) || defined(USE_ESP32) +#if defined(USE_ESP8266) || defined(USE_ESP32) sensor::Sensor *fragmentation_sensor_{nullptr}; #endif #if defined(USE_ESP32) || defined(USE_LIBRETINY) diff --git a/esphome/components/debug/debug_esp8266.cpp b/esphome/components/debug/debug_esp8266.cpp index 272123dfc0..acce28818c 100644 --- a/esphome/components/debug/debug_esp8266.cpp +++ b/esphome/components/debug/debug_esp8266.cpp @@ -159,12 +159,10 @@ void DebugComponent::update_platform_() { // NOLINTNEXTLINE(readability-static-accessed-through-instance) this->block_sensor_->publish_state(ESP.getMaxFreeBlockSize()); } -#if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 5, 2) if (this->fragmentation_sensor_ != nullptr) { // NOLINTNEXTLINE(readability-static-accessed-through-instance) this->fragmentation_sensor_->publish_state(ESP.getHeapFragmentation()); } -#endif #endif } diff --git a/esphome/components/debug/sensor.py b/esphome/components/debug/sensor.py index 72e2efebc2..e53cb0d1e4 100644 --- a/esphome/components/debug/sensor.py +++ b/esphome/components/debug/sensor.py @@ -52,12 +52,9 @@ CONFIG_SCHEMA = { ), cv.Optional(CONF_FRAGMENTATION): cv.All( cv.Any( - cv.All( - cv.only_on_esp8266, - cv.require_framework_version(esp8266_arduino=cv.Version(2, 5, 2)), - ), + cv.only_on_esp8266, cv.only_on_esp32, - msg="This feature is only available on ESP8266 (Arduino 2.5.2+) and ESP32", + msg="This feature is only available on ESP8266 and ESP32", ), sensor.sensor_schema( unit_of_measurement=UNIT_PERCENT, diff --git a/esphome/components/dfplayer/__init__.py b/esphome/components/dfplayer/__init__.py index d589381461..bb18e6ba8c 100644 --- a/esphome/components/dfplayer/__init__.py +++ b/esphome/components/dfplayer/__init__.py @@ -60,7 +60,12 @@ CONFIG_SCHEMA = cv.All( ).extend(uart.UART_DEVICE_SCHEMA) ) FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( - "dfplayer", baud_rate=9600, require_tx=True + "dfplayer", + baud_rate=9600, + require_tx=True, + data_bits=8, + parity="NONE", + stop_bits=1, ) diff --git a/esphome/components/dfplayer/dfplayer.cpp b/esphome/components/dfplayer/dfplayer.cpp index 5c9d497c87..f81d1cd1b6 100644 --- a/esphome/components/dfplayer/dfplayer.cpp +++ b/esphome/components/dfplayer/dfplayer.cpp @@ -277,9 +277,6 @@ void DFPlayer::loop() { } } } -void DFPlayer::dump_config() { - ESP_LOGCONFIG(TAG, "DFPlayer:"); - this->check_uart_settings(9600); -} +void DFPlayer::dump_config() { ESP_LOGCONFIG(TAG, "DFPlayer:"); } } // namespace esphome::dfplayer diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 1eff02e60d..3b54fa8921 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -39,7 +39,7 @@ from esphome.platformio.toolchain import copy_ccache_script, copy_pch_script from esphome.storage_json import StorageJSON from esphome.types import ConfigType -from .boards import BOARDS, ESP8266_BOARD_BUILD, ESP8266_LD_SCRIPTS, board_ld_script +from .boards import BOARDS, ESP8266_BOARD_BUILD, board_ld_script from .const import ( BUILD_FLASH_MODES, CONF_EARLY_PIN_INIT, @@ -48,8 +48,6 @@ from .const import ( CONF_RESTORE_FROM_FLASH, KEY_BOARD, KEY_ESP8266, - KEY_FLASH_SIZE, - KEY_LDSCRIPT, KEY_PIN_INITIAL_STATES, KEY_SCANF_FLOAT, KEY_SERIAL1_REQUIRED, @@ -186,10 +184,6 @@ def _format_framework_arduino_version(ver: cv.Version) -> str: # format the given arduino (https://github.com/esp8266/Arduino/releases) version to # a PIO platformio/framework-arduinoespressif8266 value # List of package versions: https://api.registry.platformio.org/v3/packages/platformio/tool/framework-arduinoespressif8266 - if ver <= cv.Version(2, 4, 1): - return f"~1.{ver.major}{ver.minor:02d}{ver.patch:02d}.0" - if ver <= cv.Version(2, 6, 2): - return f"~2.{ver.major}{ver.minor:02d}{ver.patch:02d}.0" # Same encoding the native toolchain uses for its package download, so a # custom-source check against this value cannot drift from what it fetches. from esphome.arduino8266.framework import framework_package_version @@ -212,11 +206,9 @@ def _format_framework_arduino_version(ver: cv.Version) -> str: # - https://github.com/esp8266/Arduino/releases # - https://api.registry.platformio.org/v3/packages/platformio/tool/framework-arduinoespressif8266 RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(3, 1, 2) -# The platformio/espressif8266 version to use for arduino 2 framework versions +# The platformio/espressif8266 version to use for arduino 3 framework versions # - https://github.com/platformio/platform-espressif8266/releases # - https://api.registry.platformio.org/v3/packages/platformio/platform/espressif8266 -ARDUINO_2_PLATFORM_VERSION = cv.Version(2, 6, 3) -# for arduino 3 framework versions ARDUINO_3_PLATFORM_VERSION = cv.Version(3, 2, 0) # for arduino 4 framework versions ARDUINO_4_PLATFORM_VERSION = cv.Version(4, 2, 1) @@ -241,6 +233,14 @@ def _arduino_check_versions(value: ConfigType) -> ConfigType: version = cv.Version.parse(cv.version_number(value[CONF_VERSION])) source = value.get(CONF_SOURCE, None) + if version < cv.Version(3, 0, 0): + raise cv.Invalid( + f"Arduino framework {version} is no longer supported; ESPHome requires " + f"C++20, which needs Arduino core 3.x. Use the recommended version " + f"({RECOMMENDED_ARDUINO_FRAMEWORK_VERSION}).", + path=[CONF_VERSION], + ) + value[CONF_VERSION] = str(version) value[CONF_SOURCE] = source or _format_framework_arduino_version(version) @@ -248,12 +248,8 @@ def _arduino_check_versions(value: ConfigType) -> ConfigType: if platform_version is None: if version >= cv.Version(3, 1, 0): platform_version = _ARDUINO_4_PLATFORM_SPEC - elif version >= cv.Version(3, 0, 0): - platform_version = _parse_platform_version(str(ARDUINO_3_PLATFORM_VERSION)) - elif version >= cv.Version(2, 5, 0): - platform_version = _parse_platform_version(str(ARDUINO_2_PLATFORM_VERSION)) else: - platform_version = _parse_platform_version(str(cv.Version(1, 8, 0))) + platform_version = _parse_platform_version(str(ARDUINO_3_PLATFORM_VERSION)) value[CONF_PLATFORM_VERSION] = platform_version if version != RECOMMENDED_ARDUINO_FRAMEWORK_VERSION: @@ -360,29 +356,11 @@ def check_rosetta() -> None: ) -def _choose_ld_script(board: str, ver: cv.Version) -> str | None: - """The flash ld to pin for this board and core, or None for cores - without ld-script support.""" - board_data = BOARDS[board] - ld_scripts = ESP8266_LD_SCRIPTS[board_data[KEY_FLASH_SIZE]] - if ver <= cv.Version(2, 3, 0): - # No ld script support - return None - if ver <= cv.Version(2, 4, 2): - # Old ld script path; the modern per-board override names do not - # exist in this core's SDK, so the override cannot be honored. - # Substituting the size default would move _FS_end and the - # preferences sector, wiping flash-backed state on flash. - if KEY_LDSCRIPT in board_data: - raise EsphomeError( - f"Board {board} requires its {board_data[KEY_LDSCRIPT]} " - f"flash layout, which Arduino core {ver} cannot honor; " - "use a core newer than 2.4.2" - ) - return ld_scripts[0] +def _choose_ld_script(board: str) -> str: + """The flash ld to pin for this board.""" # A per-board override preserves a layout the board shipped with # (see d1_wroom_02 in boards.py) - return board_ld_script(board_data) + return board_ld_script(BOARDS[board]) @coroutine_with_priority(CoroPriority.PLATFORM) @@ -515,10 +493,9 @@ async def to_code(config: ConfigType) -> None: ) if use_platformio and config[CONF_BOARD] in BOARDS: - ld_script = _choose_ld_script(config[CONF_BOARD], ver) - - if ld_script is not None: - cg.add_platformio_option("board_build.ldscript", ld_script) + cg.add_platformio_option( + "board_build.ldscript", _choose_ld_script(config[CONF_BOARD]) + ) CORE.add_job(add_pin_initial_states_array) CORE.add_job(finalize_waveform_config) diff --git a/esphome/components/hc8/hc8.cpp b/esphome/components/hc8/hc8.cpp index 900acca691..6a19f977a6 100644 --- a/esphome/components/hc8/hc8.cpp +++ b/esphome/components/hc8/hc8.cpp @@ -96,7 +96,6 @@ void HC8Component::dump_config() { " Warmup time: %" PRIu32 " s", this->warmup_seconds_); LOG_SENSOR(" ", "CO2", this->co2_sensor_); - this->check_uart_settings(9600); } } // namespace esphome::hc8 diff --git a/esphome/components/hc8/sensor.py b/esphome/components/hc8/sensor.py index 616162eb40..8a19cce8d1 100644 --- a/esphome/components/hc8/sensor.py +++ b/esphome/components/hc8/sensor.py @@ -47,6 +47,9 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( baud_rate=9600, require_rx=True, require_tx=True, + data_bits=8, + parity="NONE", + stop_bits=1, ) diff --git a/esphome/components/he60r/he60r.cpp b/esphome/components/he60r/he60r.cpp index f49224f17c..008505e2bb 100644 --- a/esphome/components/he60r/he60r.cpp +++ b/esphome/components/he60r/he60r.cpp @@ -38,7 +38,6 @@ CoverTraits HE60rCover::get_traits() { void HE60rCover::dump_config() { LOG_COVER("", "HE60R Cover", this); - this->check_uart_settings(1200, 1, uart::UART_CONFIG_PARITY_EVEN, 8); ESP_LOGCONFIG(TAG, " Open Duration: %.1fs\n" " Close Duration: %.1fs", diff --git a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp index 270bb2709d..b323dd0436 100644 --- a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp +++ b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp @@ -68,8 +68,6 @@ void HrxlMaxsonarWrComponent::check_buffer_() { void HrxlMaxsonarWrComponent::dump_config() { ESP_LOGCONFIG(TAG, "HRXL MaxSonar WR Sensor:"); LOG_SENSOR(" ", "Distance", this); - // As specified in the sensor's data sheet - this->check_uart_settings(9600, 1, esphome::uart::UART_CONFIG_PARITY_NONE, 8); } } // namespace esphome::hrxl_maxsonar_wr diff --git a/esphome/components/hrxl_maxsonar_wr/sensor.py b/esphome/components/hrxl_maxsonar_wr/sensor.py index e4daacd869..b81a8b273d 100644 --- a/esphome/components/hrxl_maxsonar_wr/sensor.py +++ b/esphome/components/hrxl_maxsonar_wr/sensor.py @@ -23,6 +23,14 @@ CONFIG_SCHEMA = sensor.sensor_schema( state_class=STATE_CLASS_MEASUREMENT, ).extend(uart.UART_DEVICE_SCHEMA) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "hrxl_maxsonar_wr", + baud_rate=9600, + data_bits=8, + parity="NONE", + stop_bits=1, +) + async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) diff --git a/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp index 695a823cb7..05557111fc 100644 --- a/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp +++ b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp @@ -11,7 +11,6 @@ static const char *const PROTOCOL_NAMES[] = {HYDREON_RGXX_PROTOCOL_LIST(, HYDREO static const char *const IGNORE_STRINGS[] = {HYDREON_RGXX_IGNORE_LIST(, HYDREON_RGXX_COMMA)}; void HydreonRGxxComponent::dump_config() { - this->check_uart_settings(9600, 1, esphome::uart::UART_CONFIG_PARITY_NONE, 8); ESP_LOGCONFIG(TAG, "hydreon_rgxx:"); if (this->is_failed()) { ESP_LOGE(TAG, "Connection with hydreon_rgxx failed!"); diff --git a/esphome/components/hydreon_rgxx/sensor.py b/esphome/components/hydreon_rgxx/sensor.py index 58e72571ff..8e269fef9a 100644 --- a/esphome/components/hydreon_rgxx/sensor.py +++ b/esphome/components/hydreon_rgxx/sensor.py @@ -130,6 +130,14 @@ CONFIG_SCHEMA = cv.All( _validate, ) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "hydreon_rgxx", + baud_rate=9600, + data_bits=8, + parity="NONE", + stop_bits=1, +) + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp index 70f6d4eaa7..24e5d25921 100644 --- a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp +++ b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp @@ -26,8 +26,6 @@ void KamstrupKMPComponent::dump_config() { LOG_SENSOR(" ", "Custom Sensor", this->custom_sensors_[i]); ESP_LOGCONFIG(TAG, " Command: 0x%04X", this->custom_commands_[i]); } - - this->check_uart_settings(1200, 2, uart::UART_CONFIG_PARITY_NONE, 8); } void KamstrupKMPComponent::update() { diff --git a/esphome/components/kamstrup_kmp/sensor.py b/esphome/components/kamstrup_kmp/sensor.py index 6465012897..f6c236b72d 100644 --- a/esphome/components/kamstrup_kmp/sensor.py +++ b/esphome/components/kamstrup_kmp/sensor.py @@ -102,7 +102,13 @@ CONFIG_SCHEMA = ( ) FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( - "kamstrup_kmp", baud_rate=1200, require_rx=True, require_tx=True + "kamstrup_kmp", + baud_rate=1200, + require_rx=True, + require_tx=True, + data_bits=8, + parity="NONE", + stop_bits=2, ) diff --git a/esphome/components/mhz19/mhz19.cpp b/esphome/components/mhz19/mhz19.cpp index ff518808d9..707d952f83 100644 --- a/esphome/components/mhz19/mhz19.cpp +++ b/esphome/components/mhz19/mhz19.cpp @@ -143,8 +143,6 @@ void MHZ19Component::dump_config() { ESP_LOGCONFIG(TAG, "MH-Z19:"); LOG_SENSOR(" ", "CO2", this->co2_sensor_); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); - this->check_uart_settings(9600); - if (this->abc_boot_logic_ == MHZ19_ABC_ENABLED) { ESP_LOGCONFIG(TAG, " Automatic baseline calibration enabled on boot"); } else if (this->abc_boot_logic_ == MHZ19_ABC_DISABLED) { diff --git a/esphome/components/mhz19/sensor.py b/esphome/components/mhz19/sensor.py index 33cb27080c..5852686608 100644 --- a/esphome/components/mhz19/sensor.py +++ b/esphome/components/mhz19/sensor.py @@ -80,6 +80,14 @@ CONFIG_SCHEMA = ( .extend(uart.UART_DEVICE_SCHEMA) ) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "mhz19", + baud_rate=9600, + data_bits=8, + parity="NONE", + stop_bits=1, +) + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/mk2pvrouter/mk2pvrouter.cpp b/esphome/components/mk2pvrouter/mk2pvrouter.cpp index a9c922602b..0c0476fb11 100644 --- a/esphome/components/mk2pvrouter/mk2pvrouter.cpp +++ b/esphome/components/mk2pvrouter/mk2pvrouter.cpp @@ -163,10 +163,7 @@ void Mk2PVRouter::publish_value_(const char *tag, const char *val) { #endif } -void Mk2PVRouter::dump_config() { - ESP_LOGCONFIG(TAG, "Mk2PVRouter:"); - this->check_uart_settings(BAUD_RATE, 1, uart::UART_CONFIG_PARITY_EVEN, 7); -} +void Mk2PVRouter::dump_config() { ESP_LOGCONFIG(TAG, "Mk2PVRouter:"); } #ifdef MK2PVROUTER_LISTENER_COUNT void Mk2PVRouter::register_mk2pvrouter_listener(Mk2PVRouterListener *listener) { diff --git a/esphome/components/mk2pvrouter/mk2pvrouter.h b/esphome/components/mk2pvrouter/mk2pvrouter.h index f542436f1d..fc23cf49e8 100644 --- a/esphome/components/mk2pvrouter/mk2pvrouter.h +++ b/esphome/components/mk2pvrouter/mk2pvrouter.h @@ -43,7 +43,6 @@ class Mk2PVRouter final : public Component, public uart::UARTDevice { protected: static constexpr size_t CRC_SUFFIX_LEN = 1; - static constexpr uint32_t BAUD_RATE = 9600; enum class State : uint8_t { WAITING_FOR_START, diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index f02f32d5ca..944fa1db47 100644 --- a/esphome/components/nextion/nextion_upload_arduino.cpp +++ b/esphome/components/nextion/nextion_upload_arduino.cpp @@ -209,14 +209,8 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { http_client.setTimeout(this->tft_upload_http_timeout_); bool begin_status = false; -#if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 7, 0) http_client.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); -#elif USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 6, 0) - http_client.setFollowRedirects(true); -#endif -#if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 6, 0) http_client.setRedirectLimit(3); -#endif begin_status = http_client.begin(*this->get_wifi_client_(), this->tft_url_.c_str()); if (!begin_status) { this->connection_state_.is_updating_ = false; diff --git a/esphome/components/pm1006/pm1006.cpp b/esphome/components/pm1006/pm1006.cpp index 6a325c57dc..d4c6824713 100644 --- a/esphome/components/pm1006/pm1006.cpp +++ b/esphome/components/pm1006/pm1006.cpp @@ -16,7 +16,6 @@ void PM1006Component::dump_config() { ESP_LOGCONFIG(TAG, "PM1006:"); LOG_SENSOR(" ", "PM2.5", this->pm_2_5_sensor_); LOG_UPDATE_INTERVAL(this); - this->check_uart_settings(9600); } void PM1006Component::update() { diff --git a/esphome/components/pm1006/sensor.py b/esphome/components/pm1006/sensor.py index 8274726ac4..447671ebb3 100644 --- a/esphome/components/pm1006/sensor.py +++ b/esphome/components/pm1006/sensor.py @@ -48,6 +48,9 @@ def validate_interval_uart(config: ConfigType) -> None: baud_rate=9600, require_rx=True, require_tx=interval.total_milliseconds != SCHEDULER_DONT_RUN, + data_bits=8, + parity="NONE", + stop_bits=1, )(config) diff --git a/esphome/components/pmsx003/pmsx003.cpp b/esphome/components/pmsx003/pmsx003.cpp index 6275ff60c2..f8d890ac9e 100644 --- a/esphome/components/pmsx003/pmsx003.cpp +++ b/esphome/components/pmsx003/pmsx003.cpp @@ -46,8 +46,6 @@ void PMSX003Component::dump_config() { } else { ESP_LOGCONFIG(TAG, " Mode: passive with sleep/wake cycles"); } - - this->check_uart_settings(9600); } void PMSX003Component::loop() { diff --git a/esphome/components/pmsx003/sensor.py b/esphome/components/pmsx003/sensor.py index fe784c5ffe..dc85380203 100644 --- a/esphome/components/pmsx003/sensor.py +++ b/esphome/components/pmsx003/sensor.py @@ -302,7 +302,13 @@ CONFIG_SCHEMA = cv.All( def final_validate(config: ConfigType) -> None: require_tx = config[CONF_UPDATE_INTERVAL] > cv.time_period("0s") schema = uart.final_validate_device_schema( - "pmsx003", baud_rate=9600, require_rx=True, require_tx=require_tx + "pmsx003", + baud_rate=9600, + require_rx=True, + require_tx=require_tx, + data_bits=8, + parity="NONE", + stop_bits=1, ) schema(config) diff --git a/esphome/components/pylontech/__init__.py b/esphome/components/pylontech/__init__.py index 4ab606d9f9..242a613a6c 100644 --- a/esphome/components/pylontech/__init__.py +++ b/esphome/components/pylontech/__init__.py @@ -41,6 +41,14 @@ CONFIG_SCHEMA = cv.All( .extend(uart.UART_DEVICE_SCHEMA) ) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "pylontech", + baud_rate=115200, + data_bits=8, + parity="NONE", + stop_bits=1, +) + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/pylontech/pylontech.cpp b/esphome/components/pylontech/pylontech.cpp index 54d9e5c654..932b71ba55 100644 --- a/esphome/components/pylontech/pylontech.cpp +++ b/esphome/components/pylontech/pylontech.cpp @@ -33,7 +33,6 @@ static const uint8_t ASCII_LF = 0x0A; PylontechComponent::PylontechComponent() {} void PylontechComponent::dump_config() { - this->check_uart_settings(115200, 1, esphome::uart::UART_CONFIG_PARITY_NONE, 8); ESP_LOGCONFIG(TAG, "pylontech:"); if (this->is_failed()) { ESP_LOGE(TAG, "Connection with pylontech failed!"); diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index cb2aebec91..58392c48ab 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -4,11 +4,7 @@ from esphome import automation, pins import esphome.codegen as cg from esphome.components import esp32, esp32_rmt, remote_base from esphome.components.libretiny import get_libretiny_family -from esphome.components.libretiny.const import ( - FAMILY_BK7231N, - FAMILY_BK7238, - FAMILY_RTL8720C, -) +from esphome.components.libretiny.const import FAMILY_BK7238, FAMILY_RTL8720C from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( @@ -49,7 +45,9 @@ DigitalWriteAction = remote_transmitter_ns.class_( ) -_NON_BLOCKING_LIBRETINY_FAMILIES = (FAMILY_RTL8720C, FAMILY_BK7231N, FAMILY_BK7238) +# Keep in sync with the USE_LIBRETINY_VARIANT_RTL8720C / REMOTE_TRANSMITTER_BK_PWM gates in +# remote_transmitter.h, which decide where set_non_blocking() is declared +_NON_BLOCKING_LIBRETINY_FAMILIES = (FAMILY_RTL8720C, FAMILY_BK7238) def _validate_non_blocking_platform(value: bool) -> bool: @@ -59,9 +57,7 @@ def _validate_non_blocking_platform(value: bool) -> bool: return cv.boolean(value) if CORE.is_libretiny and get_libretiny_family() in _NON_BLOCKING_LIBRETINY_FAMILIES: return cv.boolean(value) - raise cv.Invalid( - "non_blocking is only supported on ESP32, RTL8720C, BK7231N and BK7238" - ) + raise cv.Invalid("non_blocking is only supported on ESP32, RTL8720C and BK7238") MULTI_CONF = True diff --git a/esphome/components/remote_transmitter/remote_transmitter.h b/esphome/components/remote_transmitter/remote_transmitter.h index 313b26364d..4db4e80a60 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.h +++ b/esphome/components/remote_transmitter/remote_transmitter.h @@ -12,10 +12,11 @@ #endif // SOC_RMT_SUPPORTED #endif // USE_ESP32 -// The BK7231N-style PWM block (hardware shadow-load duty updates) enables the ISR-driven -// transmitter on these families; family-level proxy for the SDK's CFG_SOC_NAME gate. -// See remote_transmitter_bk72xx.cpp. -#if defined(USE_LIBRETINY_VARIANT_BK7231N) || defined(USE_LIBRETINY_VARIANT_BK7238) +// Enables the ISR-driven transmitter on Beken. Gated on BK7238 alone: the shadow-load PWM +// block is shared with BK7231N, but LibreTiny builds that family against an older BDK whose +// PWM driver has no pwm_init_param()/pwm_start(). See remote_transmitter_bk72xx.cpp. +// Keep in sync with _NON_BLOCKING_LIBRETINY_FAMILIES in __init__.py. +#ifdef USE_LIBRETINY_VARIANT_BK7238 #define REMOTE_TRANSMITTER_BK_PWM #endif diff --git a/esphome/components/remote_transmitter/remote_transmitter_bk72xx.cpp b/esphome/components/remote_transmitter/remote_transmitter_bk72xx.cpp index 0081ae47b3..822389ccf9 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_bk72xx.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_bk72xx.cpp @@ -9,10 +9,13 @@ // with the core's fixes for type-name collisions between the two #include -// Only the BK7231N-style PWM block (shadow registers with a hardware CFG_UPDATA load bit) -// supports glitch-free per-edge duty updates; older SoCs compile the generic bit-bang -// implementation (remote_transmitter.cpp) instead, and this file compiles to nothing. -// REMOTE_TRANSMITTER_BK_PWM is set per-family in remote_transmitter.h. +// Needs the BK7231N-style PWM block (shadow registers with a hardware CFG_UPDATA load bit) +// for glitch-free per-edge duty updates, and an SDK exposing pwm_init_param()/pwm_start(). +// BK7231N has the block but LibreTiny builds it against an older BDK offering only the +// sddev_control API (CMD_PWM_INIT_PARAM), so it stays on the generic bit-bang path until +// someone can add and validate that path on real hardware. Every other Beken SoC lacks the +// block. REMOTE_TRANSMITTER_BK_PWM is set per-family in remote_transmitter.h; when it is +// unset this file compiles to nothing and remote_transmitter.cpp is used instead. namespace esphome::remote_transmitter { diff --git a/esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp b/esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp index 003cdfa986..fad91f593f 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp @@ -3,11 +3,11 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" -// Envelope chain shared by the LibreTiny families that pace transmission from a hardware -// timer interrupt: RTL8720C (gtimer) and the BK7231N-style PWM block (BKTIMER1). Everything -// platform-specific sits behind five hooks implemented in the per-family files -- carrier -// setup, duty writes, one-shot arming and timer stop. Families without a usable timer keep -// the generic bit-bang implementation and compile none of this. +// Envelope chain shared by the LibreTiny families that pace transmission from a hardware timer +// interrupt: RTL8720C (gtimer) and BK7238 (BKTIMER1). Everything platform-specific sits behind +// five hooks implemented in the per-family files -- carrier setup, duty writes, one-shot arming +// and timer stop. Families without a usable timer keep the generic bit-bang implementation and +// compile none of this. #if defined(USE_LIBRETINY_VARIANT_RTL8720C) || defined(REMOTE_TRANSMITTER_BK_PWM) namespace esphome::remote_transmitter { diff --git a/esphome/components/sdl/__init__.py b/esphome/components/sdl/__init__.py index c58ce8a01e..872d831850 100644 --- a/esphome/components/sdl/__init__.py +++ b/esphome/components/sdl/__init__.py @@ -1 +1,254 @@ +import esphome.codegen as cg + CODEOWNERS = ["@clydebarrow"] + +SDL_KeyCode = cg.global_ns.enum("SDL_KeyCode") + +SDL_KEYS = ( + "SDLK_UNKNOWN", + "SDLK_RETURN", + "SDLK_ESCAPE", + "SDLK_BACKSPACE", + "SDLK_TAB", + "SDLK_SPACE", + "SDLK_EXCLAIM", + "SDLK_QUOTEDBL", + "SDLK_HASH", + "SDLK_PERCENT", + "SDLK_DOLLAR", + "SDLK_AMPERSAND", + "SDLK_QUOTE", + "SDLK_LEFTPAREN", + "SDLK_RIGHTPAREN", + "SDLK_ASTERISK", + "SDLK_PLUS", + "SDLK_COMMA", + "SDLK_MINUS", + "SDLK_PERIOD", + "SDLK_SLASH", + "SDLK_0", + "SDLK_1", + "SDLK_2", + "SDLK_3", + "SDLK_4", + "SDLK_5", + "SDLK_6", + "SDLK_7", + "SDLK_8", + "SDLK_9", + "SDLK_COLON", + "SDLK_SEMICOLON", + "SDLK_LESS", + "SDLK_EQUALS", + "SDLK_GREATER", + "SDLK_QUESTION", + "SDLK_AT", + "SDLK_LEFTBRACKET", + "SDLK_BACKSLASH", + "SDLK_RIGHTBRACKET", + "SDLK_CARET", + "SDLK_UNDERSCORE", + "SDLK_BACKQUOTE", + "SDLK_a", + "SDLK_b", + "SDLK_c", + "SDLK_d", + "SDLK_e", + "SDLK_f", + "SDLK_g", + "SDLK_h", + "SDLK_i", + "SDLK_j", + "SDLK_k", + "SDLK_l", + "SDLK_m", + "SDLK_n", + "SDLK_o", + "SDLK_p", + "SDLK_q", + "SDLK_r", + "SDLK_s", + "SDLK_t", + "SDLK_u", + "SDLK_v", + "SDLK_w", + "SDLK_x", + "SDLK_y", + "SDLK_z", + "SDLK_CAPSLOCK", + "SDLK_F1", + "SDLK_F2", + "SDLK_F3", + "SDLK_F4", + "SDLK_F5", + "SDLK_F6", + "SDLK_F7", + "SDLK_F8", + "SDLK_F9", + "SDLK_F10", + "SDLK_F11", + "SDLK_F12", + "SDLK_PRINTSCREEN", + "SDLK_SCROLLLOCK", + "SDLK_PAUSE", + "SDLK_INSERT", + "SDLK_HOME", + "SDLK_PAGEUP", + "SDLK_DELETE", + "SDLK_END", + "SDLK_PAGEDOWN", + "SDLK_RIGHT", + "SDLK_LEFT", + "SDLK_DOWN", + "SDLK_UP", + "SDLK_NUMLOCKCLEAR", + "SDLK_KP_DIVIDE", + "SDLK_KP_MULTIPLY", + "SDLK_KP_MINUS", + "SDLK_KP_PLUS", + "SDLK_KP_ENTER", + "SDLK_KP_1", + "SDLK_KP_2", + "SDLK_KP_3", + "SDLK_KP_4", + "SDLK_KP_5", + "SDLK_KP_6", + "SDLK_KP_7", + "SDLK_KP_8", + "SDLK_KP_9", + "SDLK_KP_0", + "SDLK_KP_PERIOD", + "SDLK_APPLICATION", + "SDLK_POWER", + "SDLK_KP_EQUALS", + "SDLK_F13", + "SDLK_F14", + "SDLK_F15", + "SDLK_F16", + "SDLK_F17", + "SDLK_F18", + "SDLK_F19", + "SDLK_F20", + "SDLK_F21", + "SDLK_F22", + "SDLK_F23", + "SDLK_F24", + "SDLK_EXECUTE", + "SDLK_HELP", + "SDLK_MENU", + "SDLK_SELECT", + "SDLK_STOP", + "SDLK_AGAIN", + "SDLK_UNDO", + "SDLK_CUT", + "SDLK_COPY", + "SDLK_PASTE", + "SDLK_FIND", + "SDLK_MUTE", + "SDLK_VOLUMEUP", + "SDLK_VOLUMEDOWN", + "SDLK_KP_COMMA", + "SDLK_KP_EQUALSAS400", + "SDLK_ALTERASE", + "SDLK_SYSREQ", + "SDLK_CANCEL", + "SDLK_CLEAR", + "SDLK_PRIOR", + "SDLK_RETURN2", + "SDLK_SEPARATOR", + "SDLK_OUT", + "SDLK_OPER", + "SDLK_CLEARAGAIN", + "SDLK_CRSEL", + "SDLK_EXSEL", + "SDLK_KP_00", + "SDLK_KP_000", + "SDLK_THOUSANDSSEPARATOR", + "SDLK_DECIMALSEPARATOR", + "SDLK_CURRENCYUNIT", + "SDLK_CURRENCYSUBUNIT", + "SDLK_KP_LEFTPAREN", + "SDLK_KP_RIGHTPAREN", + "SDLK_KP_LEFTBRACE", + "SDLK_KP_RIGHTBRACE", + "SDLK_KP_TAB", + "SDLK_KP_BACKSPACE", + "SDLK_KP_A", + "SDLK_KP_B", + "SDLK_KP_C", + "SDLK_KP_D", + "SDLK_KP_E", + "SDLK_KP_F", + "SDLK_KP_XOR", + "SDLK_KP_POWER", + "SDLK_KP_PERCENT", + "SDLK_KP_LESS", + "SDLK_KP_GREATER", + "SDLK_KP_AMPERSAND", + "SDLK_KP_DBLAMPERSAND", + "SDLK_KP_VERTICALBAR", + "SDLK_KP_DBLVERTICALBAR", + "SDLK_KP_COLON", + "SDLK_KP_HASH", + "SDLK_KP_SPACE", + "SDLK_KP_AT", + "SDLK_KP_EXCLAM", + "SDLK_KP_MEMSTORE", + "SDLK_KP_MEMRECALL", + "SDLK_KP_MEMCLEAR", + "SDLK_KP_MEMADD", + "SDLK_KP_MEMSUBTRACT", + "SDLK_KP_MEMMULTIPLY", + "SDLK_KP_MEMDIVIDE", + "SDLK_KP_PLUSMINUS", + "SDLK_KP_CLEAR", + "SDLK_KP_CLEARENTRY", + "SDLK_KP_BINARY", + "SDLK_KP_OCTAL", + "SDLK_KP_DECIMAL", + "SDLK_KP_HEXADECIMAL", + "SDLK_LCTRL", + "SDLK_LSHIFT", + "SDLK_LALT", + "SDLK_LGUI", + "SDLK_RCTRL", + "SDLK_RSHIFT", + "SDLK_RALT", + "SDLK_RGUI", + "SDLK_MODE", + "SDLK_AUDIONEXT", + "SDLK_AUDIOPREV", + "SDLK_AUDIOSTOP", + "SDLK_AUDIOPLAY", + "SDLK_AUDIOMUTE", + "SDLK_MEDIASELECT", + "SDLK_WWW", + "SDLK_MAIL", + "SDLK_CALCULATOR", + "SDLK_COMPUTER", + "SDLK_AC_SEARCH", + "SDLK_AC_HOME", + "SDLK_AC_BACK", + "SDLK_AC_FORWARD", + "SDLK_AC_STOP", + "SDLK_AC_REFRESH", + "SDLK_AC_BOOKMARKS", + "SDLK_BRIGHTNESSDOWN", + "SDLK_BRIGHTNESSUP", + "SDLK_DISPLAYSWITCH", + "SDLK_KBDILLUMTOGGLE", + "SDLK_KBDILLUMDOWN", + "SDLK_KBDILLUMUP", + "SDLK_EJECT", + "SDLK_SLEEP", + "SDLK_APP1", + "SDLK_APP2", + "SDLK_AUDIOREWIND", + "SDLK_AUDIOFASTFORWARD", + "SDLK_SOFTLEFT", + "SDLK_SOFTRIGHT", + "SDLK_CALL", + "SDLK_ENDCALL", +) + +SDL_KEYMAP = {key: getattr(SDL_KeyCode, key) for key in SDL_KEYS} diff --git a/esphome/components/sdl/binary_sensor.py b/esphome/components/sdl/binary_sensor.py index 0fdda25ed3..c978071391 100644 --- a/esphome/components/sdl/binary_sensor.py +++ b/esphome/components/sdl/binary_sensor.py @@ -7,262 +7,15 @@ from esphome.core import Lambda from esphome.cpp_generator import ExpressionStatement, RawExpression from esphome.types import ConfigType -from .display import CONF_SDL_ID, Sdl +from . import SDL_KEYMAP +from .display import CONF_SDL_ID, Sdl, headless_final_validate CODEOWNERS = ["@bdm310"] STATE_ARG = "state" -SDL_KeyCode = cg.global_ns.enum("SDL_KeyCode") +FINAL_VALIDATE_SCHEMA = headless_final_validate("binary_sensor") -SDL_KEYS = ( - "SDLK_UNKNOWN", - "SDLK_RETURN", - "SDLK_ESCAPE", - "SDLK_BACKSPACE", - "SDLK_TAB", - "SDLK_SPACE", - "SDLK_EXCLAIM", - "SDLK_QUOTEDBL", - "SDLK_HASH", - "SDLK_PERCENT", - "SDLK_DOLLAR", - "SDLK_AMPERSAND", - "SDLK_QUOTE", - "SDLK_LEFTPAREN", - "SDLK_RIGHTPAREN", - "SDLK_ASTERISK", - "SDLK_PLUS", - "SDLK_COMMA", - "SDLK_MINUS", - "SDLK_PERIOD", - "SDLK_SLASH", - "SDLK_0", - "SDLK_1", - "SDLK_2", - "SDLK_3", - "SDLK_4", - "SDLK_5", - "SDLK_6", - "SDLK_7", - "SDLK_8", - "SDLK_9", - "SDLK_COLON", - "SDLK_SEMICOLON", - "SDLK_LESS", - "SDLK_EQUALS", - "SDLK_GREATER", - "SDLK_QUESTION", - "SDLK_AT", - "SDLK_LEFTBRACKET", - "SDLK_BACKSLASH", - "SDLK_RIGHTBRACKET", - "SDLK_CARET", - "SDLK_UNDERSCORE", - "SDLK_BACKQUOTE", - "SDLK_a", - "SDLK_b", - "SDLK_c", - "SDLK_d", - "SDLK_e", - "SDLK_f", - "SDLK_g", - "SDLK_h", - "SDLK_i", - "SDLK_j", - "SDLK_k", - "SDLK_l", - "SDLK_m", - "SDLK_n", - "SDLK_o", - "SDLK_p", - "SDLK_q", - "SDLK_r", - "SDLK_s", - "SDLK_t", - "SDLK_u", - "SDLK_v", - "SDLK_w", - "SDLK_x", - "SDLK_y", - "SDLK_z", - "SDLK_CAPSLOCK", - "SDLK_F1", - "SDLK_F2", - "SDLK_F3", - "SDLK_F4", - "SDLK_F5", - "SDLK_F6", - "SDLK_F7", - "SDLK_F8", - "SDLK_F9", - "SDLK_F10", - "SDLK_F11", - "SDLK_F12", - "SDLK_PRINTSCREEN", - "SDLK_SCROLLLOCK", - "SDLK_PAUSE", - "SDLK_INSERT", - "SDLK_HOME", - "SDLK_PAGEUP", - "SDLK_DELETE", - "SDLK_END", - "SDLK_PAGEDOWN", - "SDLK_RIGHT", - "SDLK_LEFT", - "SDLK_DOWN", - "SDLK_UP", - "SDLK_NUMLOCKCLEAR", - "SDLK_KP_DIVIDE", - "SDLK_KP_MULTIPLY", - "SDLK_KP_MINUS", - "SDLK_KP_PLUS", - "SDLK_KP_ENTER", - "SDLK_KP_1", - "SDLK_KP_2", - "SDLK_KP_3", - "SDLK_KP_4", - "SDLK_KP_5", - "SDLK_KP_6", - "SDLK_KP_7", - "SDLK_KP_8", - "SDLK_KP_9", - "SDLK_KP_0", - "SDLK_KP_PERIOD", - "SDLK_APPLICATION", - "SDLK_POWER", - "SDLK_KP_EQUALS", - "SDLK_F13", - "SDLK_F14", - "SDLK_F15", - "SDLK_F16", - "SDLK_F17", - "SDLK_F18", - "SDLK_F19", - "SDLK_F20", - "SDLK_F21", - "SDLK_F22", - "SDLK_F23", - "SDLK_F24", - "SDLK_EXECUTE", - "SDLK_HELP", - "SDLK_MENU", - "SDLK_SELECT", - "SDLK_STOP", - "SDLK_AGAIN", - "SDLK_UNDO", - "SDLK_CUT", - "SDLK_COPY", - "SDLK_PASTE", - "SDLK_FIND", - "SDLK_MUTE", - "SDLK_VOLUMEUP", - "SDLK_VOLUMEDOWN", - "SDLK_KP_COMMA", - "SDLK_KP_EQUALSAS400", - "SDLK_ALTERASE", - "SDLK_SYSREQ", - "SDLK_CANCEL", - "SDLK_CLEAR", - "SDLK_PRIOR", - "SDLK_RETURN2", - "SDLK_SEPARATOR", - "SDLK_OUT", - "SDLK_OPER", - "SDLK_CLEARAGAIN", - "SDLK_CRSEL", - "SDLK_EXSEL", - "SDLK_KP_00", - "SDLK_KP_000", - "SDLK_THOUSANDSSEPARATOR", - "SDLK_DECIMALSEPARATOR", - "SDLK_CURRENCYUNIT", - "SDLK_CURRENCYSUBUNIT", - "SDLK_KP_LEFTPAREN", - "SDLK_KP_RIGHTPAREN", - "SDLK_KP_LEFTBRACE", - "SDLK_KP_RIGHTBRACE", - "SDLK_KP_TAB", - "SDLK_KP_BACKSPACE", - "SDLK_KP_A", - "SDLK_KP_B", - "SDLK_KP_C", - "SDLK_KP_D", - "SDLK_KP_E", - "SDLK_KP_F", - "SDLK_KP_XOR", - "SDLK_KP_POWER", - "SDLK_KP_PERCENT", - "SDLK_KP_LESS", - "SDLK_KP_GREATER", - "SDLK_KP_AMPERSAND", - "SDLK_KP_DBLAMPERSAND", - "SDLK_KP_VERTICALBAR", - "SDLK_KP_DBLVERTICALBAR", - "SDLK_KP_COLON", - "SDLK_KP_HASH", - "SDLK_KP_SPACE", - "SDLK_KP_AT", - "SDLK_KP_EXCLAM", - "SDLK_KP_MEMSTORE", - "SDLK_KP_MEMRECALL", - "SDLK_KP_MEMCLEAR", - "SDLK_KP_MEMADD", - "SDLK_KP_MEMSUBTRACT", - "SDLK_KP_MEMMULTIPLY", - "SDLK_KP_MEMDIVIDE", - "SDLK_KP_PLUSMINUS", - "SDLK_KP_CLEAR", - "SDLK_KP_CLEARENTRY", - "SDLK_KP_BINARY", - "SDLK_KP_OCTAL", - "SDLK_KP_DECIMAL", - "SDLK_KP_HEXADECIMAL", - "SDLK_LCTRL", - "SDLK_LSHIFT", - "SDLK_LALT", - "SDLK_LGUI", - "SDLK_RCTRL", - "SDLK_RSHIFT", - "SDLK_RALT", - "SDLK_RGUI", - "SDLK_MODE", - "SDLK_AUDIONEXT", - "SDLK_AUDIOPREV", - "SDLK_AUDIOSTOP", - "SDLK_AUDIOPLAY", - "SDLK_AUDIOMUTE", - "SDLK_MEDIASELECT", - "SDLK_WWW", - "SDLK_MAIL", - "SDLK_CALCULATOR", - "SDLK_COMPUTER", - "SDLK_AC_SEARCH", - "SDLK_AC_HOME", - "SDLK_AC_BACK", - "SDLK_AC_FORWARD", - "SDLK_AC_STOP", - "SDLK_AC_REFRESH", - "SDLK_AC_BOOKMARKS", - "SDLK_BRIGHTNESSDOWN", - "SDLK_BRIGHTNESSUP", - "SDLK_DISPLAYSWITCH", - "SDLK_KBDILLUMTOGGLE", - "SDLK_KBDILLUMDOWN", - "SDLK_KBDILLUMUP", - "SDLK_EJECT", - "SDLK_SLEEP", - "SDLK_APP1", - "SDLK_APP2", - "SDLK_AUDIOREWIND", - "SDLK_AUDIOFASTFORWARD", - "SDLK_SOFTLEFT", - "SDLK_SOFTRIGHT", - "SDLK_CALL", - "SDLK_ENDCALL", -) - -SDL_KEYMAP = {key: getattr(SDL_KeyCode, key) for key in SDL_KEYS} CONFIG_SCHEMA = ( binary_sensor.binary_sensor_schema(BinarySensor) diff --git a/esphome/components/sdl/display.py b/esphome/components/sdl/display.py index 5ced2edf5a..77b0001c55 100644 --- a/esphome/components/sdl/display.py +++ b/esphome/components/sdl/display.py @@ -4,6 +4,7 @@ from typing import Any import esphome.codegen as cg from esphome.components import display +from esphome.components.snapshot import Snapshot, register_snapshot import esphome.config_validation as cv from esphome.const import ( CONF_DIMENSIONS, @@ -16,14 +17,21 @@ from esphome.const import ( CONF_Y, PLATFORM_HOST, ) +import esphome.final_validate as fv from esphome.types import ConfigType +from . import SDL_KEYMAP + +AUTO_LOAD = ["snapshot"] + sdl_ns = cg.esphome_ns.namespace("sdl") -Sdl = sdl_ns.class_("Sdl", display.Display, cg.Component) +Sdl = sdl_ns.class_("Sdl", display.Display, cg.Component, Snapshot) sdl_window_flags = cg.global_ns.enum("SDL_WindowFlags") CONF_CENTERED_ON_DISPLAY = "centered_on_display" +CONF_HEADLESS = "headless" +CONF_SNAPSHOT_KEY = "snapshot_key" CONF_SDL_OPTIONS = "sdl_options" CONF_SDL_ID = "sdl_id" CONF_WINDOW_OPTIONS = "window_options" @@ -67,12 +75,29 @@ def _validate_position(config: dict) -> dict: raise cv.Invalid("Must specify either 'x' and 'y' or 'centered_on_display'") +def _validate_headless(config: ConfigType) -> ConfigType: + if not config[CONF_HEADLESS]: + return config + if CONF_WINDOW_OPTIONS in config: + raise cv.Invalid( + f"'{CONF_WINDOW_OPTIONS}' has no effect when '{CONF_HEADLESS}' is set - there is no window" + ) + if CONF_SNAPSHOT_KEY in config: + raise cv.Invalid( + f"'{CONF_SNAPSHOT_KEY}' cannot be used when '{CONF_HEADLESS}' is set - " + f"there is no keyboard. Use the 'snapshot.take' action instead" + ) + return config + + CONFIG_SCHEMA = cv.All( display.FULL_DISPLAY_SCHEMA.extend( cv.Schema( { cv.GenerateID(): cv.declare_id(Sdl), cv.Optional(CONF_SDL_OPTIONS, default=""): get_sdl_options, + cv.Optional(CONF_HEADLESS, default=False): cv.boolean, + cv.Optional(CONF_SNAPSHOT_KEY): cv.enum(SDL_KEYMAP), cv.Required(CONF_DIMENSIONS): cv.Any( cv.dimensions, cv.Schema( @@ -99,16 +124,42 @@ CONFIG_SCHEMA = cv.All( } ) ), + _validate_headless, cv.only_on(PLATFORM_HOST), ) +def headless_final_validate(platform: str) -> cv.Schema: + """Build a FINAL_VALIDATE_SCHEMA rejecting a platform whose sdl display is headless. + + Mouse and keyboard platforms are driven by window events, so under a headless display they + would never report anything. + """ + + def validate_display(display_config: ConfigType) -> ConfigType: + if display_config.get(CONF_HEADLESS): + raise cv.Invalid( + f"The sdl {platform} platform needs a window, but its display has " + f"'{CONF_HEADLESS}' set" + ) + return display_config + + return cv.Schema( + {cv.Required(CONF_SDL_ID): fv.id_declaration_match_schema(validate_display)}, + extra=cv.ALLOW_EXTRA, + ) + + async def to_code(config: ConfigType) -> None: for option in config[CONF_SDL_OPTIONS].split(): cg.add_build_flag(option) cg.add_build_flag("-DSDL_BYTEORDER=4321") var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) + await register_snapshot(var, config) + cg.add(var.set_headless(config[CONF_HEADLESS])) + if (key := config.get(CONF_SNAPSHOT_KEY)) is not None: + cg.add(var.set_snapshot_key(key)) dimensions = config[CONF_DIMENSIONS] if isinstance(dimensions, dict): diff --git a/esphome/components/sdl/sdl_esphome.cpp b/esphome/components/sdl/sdl_esphome.cpp index c99b5081b3..03fc086021 100644 --- a/esphome/components/sdl/sdl_esphome.cpp +++ b/esphome/components/sdl/sdl_esphome.cpp @@ -2,8 +2,17 @@ #include "sdl_esphome.h" #include "esphome/components/display/display_color_utils.h" +#include + namespace esphome::sdl { +namespace { + +// Key under which each window keeps a pointer back to its Sdl instance. +constexpr const char *const WINDOW_DATA_KEY = "esphome_sdl"; + +} // namespace + int Sdl::get_width() { switch (this->rotation_) { case display::DISPLAY_ROTATION_90_DEGREES: @@ -28,17 +37,96 @@ int Sdl::get_height() { } } -void Sdl::setup() { - SDL_Init(SDL_INIT_VIDEO); - this->window_ = SDL_CreateWindow(App.get_name().c_str(), this->pos_x_, this->pos_y_, this->width_, this->height_, - this->window_options_); - this->renderer_ = SDL_CreateRenderer(this->window_, -1, SDL_RENDERER_SOFTWARE); - SDL_RenderSetLogicalSize(this->renderer_, this->width_, this->height_); +void Sdl::destroy_renderer_() { + // Reverse order of creation: the renderer refers to the window or surface it was made from. + if (this->shot_target_ != nullptr) { + SDL_DestroyTexture(this->shot_target_); + this->shot_target_ = nullptr; + } + if (this->texture_ != nullptr) { + SDL_DestroyTexture(this->texture_); + this->texture_ = nullptr; + } + if (this->renderer_ != nullptr) { + SDL_DestroyRenderer(this->renderer_); + this->renderer_ = nullptr; + } + if (this->window_ != nullptr) { + SDL_DestroyWindow(this->window_); + this->window_ = nullptr; + } + if (this->surface_ != nullptr) { + SDL_FreeSurface(this->surface_); + this->surface_ = nullptr; + } +} + +bool Sdl::setup_failed_(const char *what) { + ESP_LOGE(TAG, "%s: %s", what, SDL_GetError()); + // Give back whatever was created before the failure. Without this a half set up display leaves an + // empty window on screen for the life of the process, still registered as an event target. + this->destroy_renderer_(); + return false; +} + +bool Sdl::setup_renderer_() { + SDL_SetMainReady(); + if (this->headless_) { + // SDL_INIT_VIDEO is deliberately not requested: a software renderer bound to a surface needs no + // video device, so this works on a machine with no display server at all. + if (SDL_Init(0) != 0) + return this->setup_failed_("SDL_Init failed"); + this->surface_ = SDL_CreateRGBSurfaceWithFormat(0, this->width_, this->height_, 16, SDL_PIXELFORMAT_RGB565); + if (this->surface_ == nullptr) + return this->setup_failed_("Could not create offscreen surface"); + this->renderer_ = SDL_CreateSoftwareRenderer(this->surface_); + } else { + if (SDL_Init(SDL_INIT_VIDEO) != 0) + return this->setup_failed_("SDL_Init failed"); + this->window_ = SDL_CreateWindow(App.get_name().c_str(), this->pos_x_, this->pos_y_, this->width_, this->height_, + this->window_options_); + if (this->window_ == nullptr) + return this->setup_failed_("Could not create window"); + // Lets loop() find the display an event belongs to, so one display does not act on another's + // input when several windows are open. + SDL_SetWindowData(this->window_, WINDOW_DATA_KEY, this); + this->renderer_ = SDL_CreateRenderer(this->window_, -1, SDL_RENDERER_SOFTWARE); + } + if (this->renderer_ == nullptr) + return this->setup_failed_("Could not create renderer"); + if (SDL_RenderSetLogicalSize(this->renderer_, this->width_, this->height_) != 0) + return this->setup_failed_("Could not set renderer logical size"); this->texture_ = SDL_CreateTexture(this->renderer_, SDL_PIXELFORMAT_RGB565, SDL_TEXTUREACCESS_STATIC, this->width_, this->height_); - SDL_SetTextureBlendMode(this->texture_, SDL_BLENDMODE_BLEND); + if (this->texture_ == nullptr) + return this->setup_failed_("Could not create texture"); + // The texture has no alpha channel, so blending is pointless. Headless it would also force a + // different software blit path onto the 16 bit target surface. + if (SDL_SetTextureBlendMode(this->texture_, this->headless_ ? SDL_BLENDMODE_NONE : SDL_BLENDMODE_BLEND) != 0) + return this->setup_failed_("Could not set texture blend mode"); + return true; } + +void Sdl::setup() { + if (!this->setup_renderer_()) { + this->mark_failed(); + return; + } + if (this->headless_) { + // Nothing generates events, so there is nothing for loop() to do. + this->disable_loop(); + } else if (this->snapshot_key_ != 0) { + this->add_key_listener(this->snapshot_key_, [this](bool down) { + if (down && !this->take_snapshot(nullptr)) { + ESP_LOGW(TAG, "snapshot key did not write a file"); + } + }); + } +} + void Sdl::update() { + if (this->texture_ == nullptr) + return; this->do_update_(); if ((this->x_high_ < this->x_low_) || (this->y_high_ < this->y_low_)) return; @@ -51,12 +139,19 @@ void Sdl::update() { } void Sdl::redraw_(SDL_Rect &rect) { + // Nothing to present when headless - a snapshot blits the whole texture when it needs it, so + // doing it here as well would just burn CPU. draw_pixels_at() calls this on every partial + // update, so it is worth skipping. + if (this->headless_) + return; SDL_RenderCopy(this->renderer_, this->texture_, &rect, &rect); SDL_RenderPresent(this->renderer_); } void Sdl::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { + if (this->texture_ == nullptr) + return; SDL_Rect rect{x_start, y_start, w, h}; if (this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || bitness != display::COLOR_BITNESS_565 || big_endian) { Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); @@ -69,7 +164,7 @@ void Sdl::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t * } void Sdl::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y)) + if (this->texture_ == nullptr || !this->get_clipping().inside(x, y)) return; if (this->rotation_ == display::DISPLAY_ROTATION_180_DEGREES) { @@ -104,61 +199,148 @@ void Sdl::process_key(uint32_t keycode, bool down) { callback->second(down); } +Sdl *Sdl::instance_for_window_(uint32_t window_id) { + SDL_Window *window = SDL_GetWindowFromID(window_id); + if (window == nullptr) + return nullptr; + return static_cast(SDL_GetWindowData(window, WINDOW_DATA_KEY)); +} + +void Sdl::handle_event_(const SDL_Event &event) { + switch (event.type) { + case SDL_MOUSEBUTTONDOWN: + case SDL_MOUSEBUTTONUP: + if (event.button.button == 1) { + this->mouse_x = event.button.x; + this->mouse_y = event.button.y; + this->mouse_down = event.button.state != 0; + } + break; + + case SDL_MOUSEMOTION: + if (event.motion.state & 1) { + this->mouse_x = event.motion.x; + this->mouse_y = event.motion.y; + this->mouse_down = true; + } else { + this->mouse_down = false; + } + break; + + case SDL_KEYDOWN: + // Ignore auto-repeat, otherwise holding a key floods the listeners. + if (event.key.repeat != 0) + break; + ESP_LOGD(TAG, "keydown %d", event.key.keysym.sym); + this->process_key(event.key.keysym.sym, true); + break; + + case SDL_KEYUP: + ESP_LOGD(TAG, "keyup %d", event.key.keysym.sym); + this->process_key(event.key.keysym.sym, false); + break; + + case SDL_WINDOWEVENT: + switch (event.window.event) { + case SDL_WINDOWEVENT_SIZE_CHANGED: + case SDL_WINDOWEVENT_EXPOSED: + case SDL_WINDOWEVENT_RESIZED: { + SDL_Rect rect{0, 0, this->width_, this->height_}; + this->redraw_(rect); + break; + } + default: + break; + } + break; + + default: + break; + } +} + void Sdl::loop() { SDL_Event e; - if (SDL_PollEvent(&e)) { - switch (e.type) { - case SDL_QUIT: - exit(0); + // Take everything that is waiting, not one event per loop. A touch drag produces a burst of + // motion events, and consuming them one at a time lets the queue grow without bound, so the + // pointer ends up acting on input from further and further in the past. Draining collapses a + // burst to the position it ended at, which is the one the user is asking for anyway. + while (SDL_PollEvent(&e)) { + if (e.type == SDL_QUIT) + exit(0); + // Events carry the window they happened in, so send each one to the display that owns it. + uint32_t window_id; + switch (e.type) { case SDL_MOUSEBUTTONDOWN: case SDL_MOUSEBUTTONUP: - if (e.button.button == 1) { - this->mouse_x = e.button.x; - this->mouse_y = e.button.y; - this->mouse_down = e.button.state != 0; - } + window_id = e.button.windowID; break; - case SDL_MOUSEMOTION: - if (e.motion.state & 1) { - this->mouse_x = e.button.x; - this->mouse_y = e.button.y; - this->mouse_down = true; - } else { - this->mouse_down = false; - } + window_id = e.motion.windowID; break; - case SDL_KEYDOWN: - ESP_LOGD(TAG, "keydown %d", e.key.keysym.sym); - this->process_key(e.key.keysym.sym, true); - break; - case SDL_KEYUP: - ESP_LOGD(TAG, "keyup %d", e.key.keysym.sym); - this->process_key(e.key.keysym.sym, false); + window_id = e.key.windowID; break; - case SDL_WINDOWEVENT: - switch (e.window.event) { - case SDL_WINDOWEVENT_SIZE_CHANGED: - case SDL_WINDOWEVENT_EXPOSED: - case SDL_WINDOWEVENT_RESIZED: { - SDL_Rect rect{0, 0, this->width_, this->height_}; - this->redraw_(rect); - break; - } - default: - break; - } + window_id = e.window.windowID; break; - default: + // Anything else, including the touch events SDL reports alongside the mouse events it + // synthesises from them, is not used here. ESP_LOGV(TAG, "Event %d", e.type); - break; + continue; + } + + Sdl *target = instance_for_window_(window_id); + if (target == nullptr) { + // Nothing to route this to: the window has gone, or it is not one of ours. Say so, otherwise + // input that stops working leaves no trace at all. + ESP_LOGV(TAG, "Event %d for unknown window %u", e.type, window_id); + continue; + } + target->handle_event_(e); + } +} + +bool Sdl::capture_bgr(uint8_t *dest, size_t row_stride) { + if (this->texture_ == nullptr || this->renderer_ == nullptr) { + ESP_LOGE(TAG, "Snapshot requested but SDL is not set up"); + return false; + } + if (this->shot_target_ == nullptr) { + this->shot_target_ = SDL_CreateTexture(this->renderer_, SDL_PIXELFORMAT_RGB565, SDL_TEXTUREACCESS_TARGET, + this->width_, this->height_); + if (this->shot_target_ == nullptr) { + ESP_LOGE(TAG, "Could not create capture texture: %s", SDL_GetError()); + return false; + } + SDL_SetTextureBlendMode(this->shot_target_, SDL_BLENDMODE_NONE); + } + + // Render into an offscreen target first. SDL_RenderReadPixels works in physical output pixels and + // ignores the logical size, so reading straight off a resizable window would read more pixels than + // there is room for. + // Every step is checked: a failed clear or copy would otherwise be read back as a blank or stale + // picture, written out, and reported as a snapshot that worked. + bool ok = false; + if (SDL_SetRenderTarget(this->renderer_, this->shot_target_) == 0) { + ok = SDL_SetRenderDrawColor(this->renderer_, 0, 0, 0, SDL_ALPHA_OPAQUE) == 0 && + SDL_RenderClear(this->renderer_) == 0 && + SDL_RenderCopy(this->renderer_, this->texture_, nullptr, nullptr) == 0 && + SDL_RenderReadPixels(this->renderer_, nullptr, SDL_PIXELFORMAT_BGR24, dest, static_cast(row_stride)) == 0; + if (SDL_SetRenderTarget(this->renderer_, nullptr) != 0) { + // Stuck rendering into shot_target_ from here on, so there's no point continuing. + ESP_LOGE(TAG, "Could not restore the render target: %s", SDL_GetError()); + this->mark_failed(); + return false; } } + if (!ok) { + ESP_LOGE(TAG, "Could not capture the screen: %s", SDL_GetError()); + } + return ok; } } // namespace esphome::sdl diff --git a/esphome/components/sdl/sdl_esphome.h b/esphome/components/sdl/sdl_esphome.h index 635eb1e3f8..54f0d2573f 100644 --- a/esphome/components/sdl/sdl_esphome.h +++ b/esphome/components/sdl/sdl_esphome.h @@ -1,10 +1,12 @@ #pragma once #ifdef USE_HOST +#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/log.h" #include "esphome/core/application.h" #include "esphome/components/display/display.h" +#include "esphome/components/snapshot/snapshot.h" #define SDL_MAIN_HANDLED #include "SDL.h" #include @@ -13,7 +15,7 @@ namespace esphome::sdl { constexpr static const char *const TAG = "sdl"; -class Sdl final : public display::Display { +class Sdl final : public display::Display, public snapshot::Snapshot { public: display::DisplayType get_display_type() override { return display::DISPLAY_TYPE_COLOR; } void update() override; @@ -32,6 +34,9 @@ class Sdl final : public display::Display { this->pos_x_ = pos_x; this->pos_y_ = pos_y; } + void set_headless(bool headless) { this->headless_ = headless; } + void set_snapshot_key(int32_t keycode) { this->snapshot_key_ = keycode; } + int get_width() override; int get_height() override; float get_setup_priority() const override { return setup_priority::HARDWARE; } @@ -51,20 +56,40 @@ class Sdl final : public display::Display { int get_width_internal() override { return this->width_; } int get_height_internal() override { return this->height_; } void redraw_(SDL_Rect &rect); + bool setup_renderer_(); + /// Release the window, surface, renderer and textures, and forget them. + void destroy_renderer_(); + /// Log an SDL failure during setup, release anything already created, and return false. + bool setup_failed_(const char *what); + int snapshot_width() override { return this->width_; } + int snapshot_height() override { return this->height_; } + bool capture_bgr(uint8_t *dest, size_t row_stride) override; + void handle_event_(const SDL_Event &event); + /// The display owning the given window, or nullptr if it is not one of ours. + static Sdl *instance_for_window_(uint32_t window_id); + SDL_Renderer *renderer_{}; + SDL_Window *window_{}; + SDL_Texture *texture_{}; + // Offscreen render target used when headless. SDL_CreateSoftwareRenderer only borrows the + // surface, and the renderer goes back to using it as its output whenever the capture target is + // released, so it has to stay alive as long as the renderer does. + SDL_Surface *surface_{}; + // Capture target, created on first snapshot. + SDL_Texture *shot_target_{}; + std::map> key_callbacks_{}; int width_{}; int height_{}; uint32_t window_options_{0}; int32_t pos_x_{SDL_WINDOWPOS_UNDEFINED}; int32_t pos_y_{SDL_WINDOWPOS_UNDEFINED}; - SDL_Renderer *renderer_{}; - SDL_Window *window_{}; - SDL_Texture *texture_{}; + int32_t snapshot_key_{0}; uint16_t x_low_{0}; uint16_t y_low_{0}; uint16_t x_high_{0}; uint16_t y_high_{0}; - std::map> key_callbacks_{}; + bool headless_{false}; }; + } // namespace esphome::sdl #endif diff --git a/esphome/components/sdl/touchscreen/__init__.py b/esphome/components/sdl/touchscreen/__init__.py index d7af8da403..9b807b4585 100644 --- a/esphome/components/sdl/touchscreen/__init__.py +++ b/esphome/components/sdl/touchscreen/__init__.py @@ -4,10 +4,12 @@ import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.types import ConfigType -from ..display import CONF_SDL_ID, Sdl, sdl_ns +from ..display import CONF_SDL_ID, Sdl, headless_final_validate, sdl_ns SdlTouchscreen = sdl_ns.class_("SdlTouchscreen", touchscreen.Touchscreen) +FINAL_VALIDATE_SCHEMA = headless_final_validate("touchscreen") + CONFIG_SCHEMA = touchscreen.TOUCHSCREEN_SCHEMA.extend( { diff --git a/esphome/components/seeed_mr60fda2/__init__.py b/esphome/components/seeed_mr60fda2/__init__.py index de6e8ad57b..159a1ece9c 100644 --- a/esphome/components/seeed_mr60fda2/__init__.py +++ b/esphome/components/seeed_mr60fda2/__init__.py @@ -31,6 +31,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( require_tx=True, require_rx=True, baud_rate=115200, + data_bits=8, parity="NONE", stop_bits=1, ) diff --git a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp index 4875aa5cff..2d1cd0fbb4 100644 --- a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp +++ b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp @@ -33,8 +33,6 @@ void MR60FDA2Component::dump_config() { // Initialisation functions void MR60FDA2Component::setup() { - this->check_uart_settings(115200); - this->current_frame_locate_ = LOCATE_FRAME_HEADER; this->current_frame_id_ = 0; this->current_frame_len_ = 0; diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index 2ab0d4ebb4..c1c1510643 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -130,17 +130,26 @@ SerialProxyResult SerialProxy::configure(api::APIConnection *api_connection, uin return SerialProxyResult::SERIAL_PROXY_RESULT_NOT_SUPPORTED; } - // Apply validated parameters - uart_comp->set_baud_rate(baudrate); - uart_comp->set_stop_bits(stop_bits); - uart_comp->set_data_bits(data_size); - - // Map parity value to UARTParityOptions + // Skip a no-op reconfigure. Clients routinely re-send identical settings on every + // port open, and on a USB UART each apply is a CDC SET_LINE_CODING control transfer. + // Some bridges watch line-coding changes as a signalling channel (a magic baud + // sequence to enter a bootloader, say), so redundant applies are not harmless. static const uart::UARTParityOptions PARITY_MAP[] = { uart::UART_CONFIG_PARITY_NONE, uart::UART_CONFIG_PARITY_EVEN, uart::UART_CONFIG_PARITY_ODD, }; + if (uart_comp->get_baud_rate() == baudrate && uart_comp->get_stop_bits() == stop_bits && + uart_comp->get_data_bits() == data_size && uart_comp->get_parity() == PARITY_MAP[parity]) { + ESP_LOGV(TAG, "Settings unchanged, skipping reconfigure [%" PRIu32 "]", this->instance_index_); + return SerialProxyResult::SERIAL_PROXY_RESULT_OK; + } + + // Apply validated parameters + uart_comp->set_baud_rate(baudrate); + uart_comp->set_stop_bits(stop_bits); + uart_comp->set_data_bits(data_size); + uart_comp->set_parity(PARITY_MAP[parity]); // load_settings() is available on ESP8266 and ESP32 platforms diff --git a/esphome/components/smt100/sensor.py b/esphome/components/smt100/sensor.py index 632a1e7547..7ba7da801c 100644 --- a/esphome/components/smt100/sensor.py +++ b/esphome/components/smt100/sensor.py @@ -68,7 +68,13 @@ CONFIG_SCHEMA = ( ) FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( - "smt100", baud_rate=9600, require_rx=True, require_tx=True + "smt100", + baud_rate=9600, + require_rx=True, + require_tx=True, + data_bits=8, + parity="NONE", + stop_bits=1, ) diff --git a/esphome/components/smt100/smt100.cpp b/esphome/components/smt100/smt100.cpp index ed33fc54c5..2889a9fb4d 100644 --- a/esphome/components/smt100/smt100.cpp +++ b/esphome/components/smt100/smt100.cpp @@ -65,7 +65,6 @@ void SMT100Component::dump_config() { LOG_SENSOR(TAG, "Temperature", this->temperature_sensor_); LOG_SENSOR(TAG, "Moisture", this->moisture_sensor_); LOG_UPDATE_INTERVAL(this); - this->check_uart_settings(9600); } int SMT100Component::readline_(int readch, char *buffer, int len) { diff --git a/esphome/components/snapshot/__init__.py b/esphome/components/snapshot/__init__.py new file mode 100644 index 0000000000..bf561a0e0d --- /dev/null +++ b/esphome/components/snapshot/__init__.py @@ -0,0 +1,76 @@ +"""Shared support for writing what a display is showing out to an image file. + +The component itself has no configuration. It provides the ``snapshot.take`` action and the C++ +base class behind it, so any display that can hand over its pixels - the in memory display in this +component, or an SDL window - saves files the same way, under the same directory, with the same +rules about names. +""" + +from dataclasses import dataclass + +from esphome import automation +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.core import CORE, ID +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType, TemplateArgsType + +CODEOWNERS = ["@clydebarrow"] + +DOMAIN = "snapshot" + +CONF_FILENAME = "filename" + +snapshot_ns = cg.esphome_ns.namespace("snapshot") +Snapshot = snapshot_ns.class_("Snapshot") +SnapshotAction = snapshot_ns.class_("SnapshotAction", automation.Action) + + +@automation.register_action( + "snapshot.take", + SnapshotAction, + automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(Snapshot), + cv.Optional(CONF_FILENAME): cv.templatable(cv.string), + } + ), + synchronous=True, +) +async def snapshot_take_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + if (filename := config.get(CONF_FILENAME)) is not None: + cg.add(var.set_filename(await cg.templatable(filename, args, cg.std_string))) + return var + + +@dataclass +class SnapshotData: + directory_defined: bool = False + + +def _get_data() -> SnapshotData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = SnapshotData() + return CORE.data[DOMAIN] + + +async def register_snapshot(var: MockObj, config: ConfigType) -> None: + """Set up a component so that the snapshot action can write its picture to a file.""" + data = _get_data() + # Only once, however many displays there are: two defines that say the same thing do not + # compare equal, so asking for this per display repeats the line in defines.h. + if not data.directory_defined: + data.directory_defined = True + cg.add_define( + "ESPHOME_SNAPSHOT_DIR", + (CORE.data_dir / "snapshots" / CORE.name).as_posix(), + ) + cg.add(var.set_snapshot_prefix(str(config[CONF_ID]))) diff --git a/esphome/components/snapshot/display/__init__.py b/esphome/components/snapshot/display/__init__.py new file mode 100644 index 0000000000..68429f164b --- /dev/null +++ b/esphome/components/snapshot/display/__init__.py @@ -0,0 +1,61 @@ +import esphome.codegen as cg +from esphome.components import display +import esphome.config_validation as cv +from esphome.const import ( + CONF_DIMENSIONS, + CONF_HEIGHT, + CONF_ID, + CONF_LAMBDA, + CONF_WIDTH, + PLATFORM_HOST, +) +from esphome.types import ConfigType + +from .. import Snapshot, register_snapshot, snapshot_ns + +# The base class and the file writing live in the parent component, which nothing else in a +# configuration using only this platform would pull in. +AUTO_LOAD = ["snapshot"] + +SnapshotDisplay = snapshot_ns.class_( + "SnapshotDisplay", display.DisplayBuffer, cg.Component, Snapshot +) + +CONFIG_SCHEMA = cv.All( + display.FULL_DISPLAY_SCHEMA.extend( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(SnapshotDisplay), + cv.Required(CONF_DIMENSIONS): cv.Any( + cv.dimensions, + cv.Schema( + { + cv.Required(CONF_WIDTH): cv.positive_not_null_int, + cv.Required(CONF_HEIGHT): cv.positive_not_null_int, + } + ), + ), + } + ) + ), + cv.only_on(PLATFORM_HOST), +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await display.register_display(var, config) + await register_snapshot(var, config) + + dimensions = config[CONF_DIMENSIONS] + if isinstance(dimensions, dict): + cg.add(var.set_dimensions(dimensions[CONF_WIDTH], dimensions[CONF_HEIGHT])) + else: + (width, height) = dimensions + cg.add(var.set_dimensions(width, height)) + + if lamb := config.get(CONF_LAMBDA): + lambda_ = await cg.process_lambda( + lamb, [(display.DisplayRef, "it")], return_type=cg.void + ) + cg.add(var.set_writer(lambda_)) diff --git a/esphome/components/snapshot/display/snapshot_display.cpp b/esphome/components/snapshot/display/snapshot_display.cpp new file mode 100644 index 0000000000..6297e3e18f --- /dev/null +++ b/esphome/components/snapshot/display/snapshot_display.cpp @@ -0,0 +1,80 @@ +#ifdef USE_HOST +#include "snapshot_display.h" +#include "esphome/components/display/display_color_utils.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::snapshot { + +static const char *const TAG = "snapshot.display"; + +namespace { + +/// Spread a channel that only goes up to `max` over the whole 0 to 255 range, so that the +/// brightest value stays the brightest. This is the same arithmetic SDL uses, which is what makes +/// a picture taken here come out identical to the same picture taken from an SDL window. +constexpr uint8_t expand_channel(uint16_t value, uint16_t max) { return static_cast(value * 255 / max); } + +constexpr uint16_t RED_MAX = 0x1F; +constexpr uint16_t GREEN_MAX = 0x3F; +constexpr uint16_t BLUE_MAX = 0x1F; + +} // namespace + +void SnapshotDisplay::setup() { + this->init_internal_(static_cast(this->width_) * this->height_ * 2); + if (this->buffer_ == nullptr) { + this->mark_failed(LOG_STR("Could not allocate display buffer")); + } +} + +void SnapshotDisplay::dump_config() { LOG_DISPLAY("", "Snapshot", this); } + +void SnapshotDisplay::draw_absolute_pixel_internal(int x, int y, Color color) { + if (this->buffer_ == nullptr || x < 0 || x >= this->width_ || y < 0 || y >= this->height_) + return; + this->pixels_()[y * this->width_ + x] = display::ColorUtil::color_to_565(color, display::COLOR_ORDER_RGB); +} + +void SnapshotDisplay::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, + display::ColorOrder order, display::ColorBitness bitness, bool big_endian, + int x_offset, int y_offset, int x_pad) { + if (this->buffer_ == nullptr) + return; + // Anything that is not already laid out the way the buffer is, or that would reach outside it, + // goes through the base class, which turns it into one call per pixel with the bounds checked. + const bool copyable = this->rotation_ == display::DISPLAY_ROTATION_0_DEGREES && + bitness == display::COLOR_BITNESS_565 && !big_endian && x_start >= 0 && y_start >= 0 && + x_start + w <= this->width_ && y_start + h <= this->height_; + if (!copyable) { + DisplayBuffer::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + return; + } + const size_t stride = static_cast(x_offset) + w + x_pad; + const uint8_t *src = ptr + (stride * y_offset + x_offset) * 2; + for (int y = 0; y != h; y++) { + memcpy(&this->pixels_()[(y_start + y) * this->width_ + x_start], src + y * stride * 2, w * 2); + } +} + +bool SnapshotDisplay::capture_bgr(uint8_t *dest, size_t row_stride) { + if (this->buffer_ == nullptr) { + ESP_LOGE(TAG, "Snapshot requested but there is no buffer to read"); + return false; + } + const uint16_t *src = this->pixels_(); + for (int y = 0; y != this->height_; y++) { + uint8_t *out = dest + y * row_stride; + for (int x = 0; x != this->width_; x++) { + const uint16_t pixel = *src++; + *out++ = expand_channel(pixel & BLUE_MAX, BLUE_MAX); + *out++ = expand_channel((pixel >> 5) & GREEN_MAX, GREEN_MAX); + *out++ = expand_channel(pixel >> 11, RED_MAX); + } + } + return true; +} + +} // namespace esphome::snapshot +#endif diff --git a/esphome/components/snapshot/display/snapshot_display.h b/esphome/components/snapshot/display/snapshot_display.h new file mode 100644 index 0000000000..5317bc6058 --- /dev/null +++ b/esphome/components/snapshot/display/snapshot_display.h @@ -0,0 +1,48 @@ +#pragma once + +#ifdef USE_HOST +#include "esphome/components/display/display_buffer.h" +#include "esphome/components/snapshot/snapshot.h" +#include "esphome/core/component.h" + +namespace esphome::snapshot { + +/// A display with nowhere to show anything: it keeps the picture in memory, where the snapshot +/// action can pick it up. That makes it a way to see what a configuration draws on a machine with +/// no screen, and to check the result in a test. +class SnapshotDisplay final : public display::DisplayBuffer, public Snapshot { + public: + void setup() override; + void update() override { this->do_update_(); } + void dump_config() override; + float get_setup_priority() const override { return setup_priority::HARDWARE; } + display::DisplayType get_display_type() override { return display::DISPLAY_TYPE_COLOR; } + + void set_dimensions(uint16_t width, uint16_t height) { + this->width_ = width; + this->height_ = height; + } + + void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; + + protected: + void draw_absolute_pixel_internal(int x, int y, Color color) override; + int get_width_internal() override { return this->width_; } + int get_height_internal() override { return this->height_; } + + int snapshot_width() override { return this->width_; } + int snapshot_height() override { return this->height_; } + bool capture_bgr(uint8_t *dest, size_t row_stride) override; + + /// The picture, one 16 bit RGB565 value per pixel, topmost row first. Owned by DisplayBuffer as + /// a byte pointer; this is the same memory seen as what is actually stored in it. + uint16_t *pixels_() { return reinterpret_cast(this->buffer_); } + + int width_{}; + int height_{}; +}; + +} // namespace esphome::snapshot + +#endif diff --git a/esphome/components/snapshot/snapshot.cpp b/esphome/components/snapshot/snapshot.cpp new file mode 100644 index 0000000000..995f87710e --- /dev/null +++ b/esphome/components/snapshot/snapshot.cpp @@ -0,0 +1,248 @@ +#ifdef USE_HOST +#include "snapshot.h" +#include "esphome/core/log.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace esphome::snapshot { + +namespace { + +constexpr const char *const TAG = "snapshot"; + +// Longest name we will build a path from. NAME_MAX is 255 and we may append a collision suffix. +constexpr size_t MAX_NAME_LENGTH = 200; +// Give up rather than spin forever if every candidate name is taken. +constexpr unsigned MAX_NAME_ATTEMPTS = 1000; +// A BMP file header followed by a BITMAPINFOHEADER, which is where the pixels start. +constexpr size_t BMP_HEADER_SIZE = 54; +constexpr size_t BMP_INFO_HEADER_SIZE = 40; +constexpr int BMP_BITS_PER_PIXEL = 24; + +/// True if the name already ends in ".bmp". The comparison ignores case, so "shot.BMP" is left +/// alone rather than turned into "shot.BMP.bmp". +bool has_bmp_suffix(const std::string &name) { + return name.size() >= 4 && strcasecmp(name.c_str() + name.size() - 4, ".bmp") == 0; +} + +/// Reduce a user supplied name to a single safe path component. Everything outside the allowed set +/// is replaced, so "..", "/" and absolute paths cannot escape the snapshot directory. +/// Returns an empty string if nothing usable is left. +std::string sanitise_filename(const char *const name, bool *name_changed) { + std::string result; + bool all_dots = true; + bool changed = false; + for (const char *p = name; *p != '\0'; p++) { + if (result.size() >= MAX_NAME_LENGTH) { + changed = true; + break; + } + char c = *p; + if (!(std::isalnum(static_cast(c)) || c == '.' || c == '_' || c == '-')) { + c = '_'; + changed = true; + } + if (c != '.') + all_dots = false; + result.push_back(c); + } + if (all_dots) { + *name_changed = true; + return ""; + } + if (!has_bmp_suffix(result)) + result += ".bmp"; + *name_changed = changed; + return result; +} + +/// Insert "-" before the file extension, e.g. "shot.bmp" -> "shot-1.bmp". +std::string add_suffix(const std::string &name, unsigned attempt) { + char suffix[12]; + snprintf(suffix, sizeof(suffix), "-%u", attempt); + auto dot = name.rfind('.'); + if (dot == std::string::npos) + return name + suffix; + return name.substr(0, dot) + suffix + name.substr(dot); +} + +/// Directory snapshots are written to. The environment variable lets a test redirect output +/// without rebuilding, matching how the host platform handles ESPHOME_PREFDIR. +const char *snapshot_dir() { + const char *dir = getenv("ESPHOME_SNAPSHOT_DIR"); // NOLINT(concurrency-mt-unsafe) + return dir != nullptr && dir[0] != '\0' ? dir : ESPHOME_SNAPSHOT_DIR; +} + +/// Store a value in as many bytes, least significant first, and step the pointer past it. +/// BMP is a little endian format whatever the machine writing it uses. +void put_le(uint8_t *&dest, uint32_t value, size_t bytes) { + for (size_t i = 0; i != bytes; i++) + *dest++ = static_cast(value >> (8 * i)); +} + +/// The number of bytes one row of `width` pixels takes up in the file. Rows are padded out to a +/// multiple of four bytes. +size_t bmp_row_size(int width) { return (static_cast(width) * 3 + 3) & ~size_t{3}; } + +/// Write pixels out as a 24 bit BMP. The rows given start with the topmost and are `row_stride` +/// bytes apart, which must leave room for a whole padded row; a BMP holds its rows the other way +/// up, so they go out last first. +bool write_bmp(FILE *file, const uint8_t *pixels, int width, int height, size_t row_stride) { + const size_t row_size = bmp_row_size(width); + const size_t pixel_bytes = row_size * height; + + uint8_t header[BMP_HEADER_SIZE]; + uint8_t *pos = header; + *pos++ = 'B'; + *pos++ = 'M'; + put_le(pos, static_cast(BMP_HEADER_SIZE + pixel_bytes), 4); + put_le(pos, 0, 4); // reserved + put_le(pos, BMP_HEADER_SIZE, 4); + put_le(pos, BMP_INFO_HEADER_SIZE, 4); + put_le(pos, static_cast(width), 4); + put_le(pos, static_cast(height), 4); + put_le(pos, 1, 2); // one plane + put_le(pos, BMP_BITS_PER_PIXEL, 2); + put_le(pos, 0, 4); // not compressed + put_le(pos, static_cast(pixel_bytes), 4); + put_le(pos, 0, 4); // pixels per metre across, unspecified + put_le(pos, 0, 4); // pixels per metre down, unspecified + put_le(pos, 0, 4); // no palette + put_le(pos, 0, 4); // so no palette entry matters more than another + + if (fwrite(header, 1, sizeof(header), file) != sizeof(header)) + return false; + for (int y = height - 1; y >= 0; y--) { + if (fwrite(pixels + static_cast(y) * row_stride, 1, row_size, file) != row_size) + return false; + } + return true; +} + +/// Reserve a name in the snapshot directory and write the picture to it. +/// With `exact` set the given name is the only one tried; otherwise a number is added on +/// collision. Returns true if a file was written. +bool write_snapshot_file(const uint8_t *pixels, int width, int height, size_t row_stride, const std::string &name, + bool exact) { + const std::string dir = snapshot_dir(); + std::error_code ec; + std::filesystem::create_directories(dir, ec); + if (ec) { + ESP_LOGE(TAG, "Could not create snapshot directory %s: %s", dir.c_str(), ec.message().c_str()); + return false; + } + + // O_EXCL guarantees we never write over a file that is already there. + std::string path; + int fd = -1; + for (unsigned attempt = 0; attempt < MAX_NAME_ATTEMPTS; attempt++) { + path = dir + "/" + (attempt == 0 ? name : add_suffix(name, attempt)); + fd = ::open(path.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0644); + if (fd >= 0) + break; + if (errno != EEXIST) { + ESP_LOGE(TAG, "Could not create %s: %s", path.c_str(), strerror(errno)); + return false; + } + if (exact) { + // The caller asked for this exact name, so silently writing somewhere else would be worse + // than failing - a test asserting on the path would pick up a stale file. + ESP_LOGE(TAG, "Snapshot %s already exists, not overwriting", path.c_str()); + return false; + } + } + if (fd < 0) { + ESP_LOGE(TAG, "Could not find an unused name for %s in %s", name.c_str(), dir.c_str()); + return false; + } + + FILE *file = fdopen(fd, "wb"); + if (file == nullptr) { + ESP_LOGE(TAG, "Could not open %s: %s", path.c_str(), strerror(errno)); + ::close(fd); + ::unlink(path.c_str()); + return false; + } + bool ok = write_bmp(file, pixels, width, height, row_stride); + int saved_errno = ok ? 0 : errno; + // Closing can fail in its own right - the last of the data is still on its way out. + if (fclose(file) != 0) { + if (ok) + saved_errno = errno; + ok = false; + } + if (!ok) { + ESP_LOGE(TAG, "Could not write %s: %s", path.c_str(), strerror(saved_errno)); + // Leave no truncated file behind - it would block a retry under the same name. + ::unlink(path.c_str()); + return false; + } + ESP_LOGI(TAG, "Snapshot written to %s", path.c_str()); + return true; +} + +} // namespace + +// helper function since ESP_LOGW is disallowed in a header file +void Snapshot::log_action_failed() { ESP_LOGW(TAG, "snapshot.take did not write a file"); } + +bool Snapshot::take_snapshot(const char *filename) { + const int width = this->snapshot_width(); + const int height = this->snapshot_height(); + if (width <= 0 || height <= 0) { + ESP_LOGE(TAG, "Snapshot requested but the display is %dx%d", width, height); + return false; + } + + std::string name; + bool exact = false; + if (filename != nullptr) { + bool name_changed = false; + name = sanitise_filename(filename, &name_changed); + exact = !name.empty(); + if (name_changed) { + ESP_LOGW(TAG, "Requested snapshot name '%s' is not an acceptable file name, using '%s' instead", filename, + name.empty() ? "a name made from the time" : name.c_str()); + } + } + if (name.empty()) { + struct timespec now {}; + if (clock_gettime(CLOCK_REALTIME, &now) != 0) + now = {}; + struct tm tm_buf {}; + if (localtime_r(&now.tv_sec, &tm_buf) == nullptr) + tm_buf = {}; + char stamp[32]{}; + // ::strftime to be sure of the one from ; display has an unrelated member of that name + if (::strftime(stamp, sizeof(stamp), "%Y%m%d-%H%M%S", &tm_buf) == 0) + snprintf(stamp, sizeof(stamp), "unknown-time"); + char buffer[MAX_NAME_LENGTH]; + int written = + snprintf(buffer, sizeof(buffer), "%s-%s-%03ld.bmp", this->snapshot_prefix_, stamp, now.tv_nsec / 1000000); + if (written < 0 || static_cast(written) >= sizeof(buffer)) { + ESP_LOGW(TAG, "Could not build a timestamped snapshot name, using a fallback"); + snprintf(buffer, sizeof(buffer), "snapshot.bmp"); + } + name = buffer; + } + + // Rows are padded out to a multiple of four bytes, as the file wants them, so each one can be + // written straight from the buffer. Zeroed on allocation, which is what the padding must be. + const size_t row_stride = bmp_row_size(width); + auto pixels = std::make_unique(row_stride * height); + if (!this->capture_bgr(pixels.get(), row_stride)) + return false; + return write_snapshot_file(pixels.get(), width, height, row_stride, name, exact); +} + +} // namespace esphome::snapshot +#endif diff --git a/esphome/components/snapshot/snapshot.h b/esphome/components/snapshot/snapshot.h new file mode 100644 index 0000000000..bb670e639f --- /dev/null +++ b/esphome/components/snapshot/snapshot.h @@ -0,0 +1,72 @@ +#pragma once + +#ifdef USE_HOST +#include "esphome/core/automation.h" + +#include +#include +#include + +// Directory snapshots are written to. Normally set by codegen to a folder under .esphome; the +// fallback keeps the component compiling for static analysis, where no defines.h is generated. +#ifndef ESPHOME_SNAPSHOT_DIR +#define ESPHOME_SNAPSHOT_DIR "." +#endif + +namespace esphome::snapshot { + +/// Base for anything that can hand over the picture it is showing so it can be written to a file. +/// +/// A subclass says how big the picture is and fills in the pixels. Everything else - picking a +/// name, staying inside the snapshot directory, not writing over anything, and encoding the file - +/// is done here, so every component that can take a snapshot behaves the same way. +class Snapshot { + public: + virtual ~Snapshot() = default; + + /// Set the word generated names start with. Codegen passes the component id, so with more than + /// one display in a device it is clear which one a file came from. + void set_snapshot_prefix(const char *prefix) { this->snapshot_prefix_ = prefix; } + + /// Write the current picture to a BMP file in the snapshot directory. + /// + /// Pass nullptr to have a name made up from the prefix and the current time. A file that is + /// already there is never written over. Returns true if a file was written. + bool take_snapshot(const char *filename); + + /// Log that an action-triggered snapshot did not write a file. + static void log_action_failed(); + + protected: + /// Width of the picture in pixels. + virtual int snapshot_width() = 0; + /// Height of the picture in pixels. + virtual int snapshot_height() = 0; + /// Fill in the picture: three bytes per pixel in blue, green, red order, topmost row first, with + /// `row_stride` bytes from the start of one row to the start of the next. Returns false, having + /// logged why, if the picture could not be read. + virtual bool capture_bgr(uint8_t *dest, size_t row_stride) = 0; + + const char *snapshot_prefix_{"snapshot"}; +}; + +template class SnapshotAction final : public Action, public Parented { + public: + TEMPLATABLE_VALUE(std::string, filename) + + protected: + void play(const Ts &...x) override { + bool ok; + if (this->filename_.has_value()) { + ok = this->parent_->take_snapshot(this->filename_.value(x...).c_str()); + } else { + ok = this->parent_->take_snapshot(nullptr); + } + if (!ok) + this->parent_->log_action_failed(); + } +}; + +} // namespace esphome::snapshot + +#endif diff --git a/esphome/components/t6615/sensor.py b/esphome/components/t6615/sensor.py index 6f3ef372bc..44dba52ae8 100644 --- a/esphome/components/t6615/sensor.py +++ b/esphome/components/t6615/sensor.py @@ -33,7 +33,13 @@ CONFIG_SCHEMA = ( ) FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( - "t6615", baud_rate=19200, require_rx=True, require_tx=True + "t6615", + baud_rate=19200, + require_rx=True, + require_tx=True, + data_bits=8, + parity="NONE", + stop_bits=1, ) diff --git a/esphome/components/t6615/t6615.cpp b/esphome/components/t6615/t6615.cpp index 1a98e48c14..982cc181b7 100644 --- a/esphome/components/t6615/t6615.cpp +++ b/esphome/components/t6615/t6615.cpp @@ -88,7 +88,6 @@ void T6615Component::query_ppm_() { void T6615Component::dump_config() { ESP_LOGCONFIG(TAG, "T6615:"); LOG_SENSOR(" ", "CO2", this->co2_sensor_); - this->check_uart_settings(19200); } } // namespace esphome::t6615 diff --git a/esphome/components/teleinfo/__init__.py b/esphome/components/teleinfo/__init__.py index f9233511e1..67aad11d0f 100644 --- a/esphome/components/teleinfo/__init__.py +++ b/esphome/components/teleinfo/__init__.py @@ -35,6 +35,22 @@ CONFIG_SCHEMA = ( ) +def _final_validate(config: ConfigType) -> ConfigType: + # Historical mode runs at 1200 baud, standard mode at 9600 baud. + baud_rate = 1200 if config[CONF_HISTORICAL_MODE] else 9600 + uart.final_validate_device_schema( + "teleinfo", + baud_rate=baud_rate, + data_bits=7, + parity="EVEN", + stop_bits=1, + )(config) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID], config[CONF_HISTORICAL_MODE]) await cg.register_component(var, config) diff --git a/esphome/components/teleinfo/teleinfo.cpp b/esphome/components/teleinfo/teleinfo.cpp index e00895d162..17d3d6c099 100644 --- a/esphome/components/teleinfo/teleinfo.cpp +++ b/esphome/components/teleinfo/teleinfo.cpp @@ -184,10 +184,7 @@ void TeleInfo::publish_value_(const std::string &tag, const std::string &val) { element->publish_val(val); } } -void TeleInfo::dump_config() { - ESP_LOGCONFIG(TAG, "TeleInfo:"); - this->check_uart_settings(baud_rate_, 1, uart::UART_CONFIG_PARITY_EVEN, 7); -} +void TeleInfo::dump_config() { ESP_LOGCONFIG(TAG, "TeleInfo:"); } TeleInfo::TeleInfo(bool historical_mode) { if (historical_mode) { /* @@ -195,11 +192,9 @@ TeleInfo::TeleInfo(bool historical_mode) { */ checksum_area_end_ = 2; separator_ = 0x20; - baud_rate_ = 1200; } else { checksum_area_end_ = 1; separator_ = 0x9; - baud_rate_ = 9600; } } void TeleInfo::register_teleinfo_listener(TeleInfoListener *listener) { teleinfo_listeners_.push_back(listener); } diff --git a/esphome/components/teleinfo/teleinfo.h b/esphome/components/teleinfo/teleinfo.h index 4aab3bf2cd..b1bf586e9c 100644 --- a/esphome/components/teleinfo/teleinfo.h +++ b/esphome/components/teleinfo/teleinfo.h @@ -31,7 +31,6 @@ class TeleInfo final : public PollingComponent, public uart::UARTDevice { std::vector teleinfo_listeners_{}; protected: - uint32_t baud_rate_; int checksum_area_end_; int separator_; char buf_[MAX_BUF_SIZE]; diff --git a/esphome/components/tormatic/tormatic_cover.cpp b/esphome/components/tormatic/tormatic_cover.cpp index 7004c4f836..5c8d6623b6 100644 --- a/esphome/components/tormatic/tormatic_cover.cpp +++ b/esphome/components/tormatic/tormatic_cover.cpp @@ -36,8 +36,6 @@ cover::CoverTraits Tormatic::get_traits() { void Tormatic::dump_config() { LOG_COVER("", "Tormatic Cover", this); - this->check_uart_settings(9600, 1, uart::UART_CONFIG_PARITY_NONE, 8); - ESP_LOGCONFIG(TAG, " Open Duration: %.1fs\n" " Close Duration: %.1fs", diff --git a/esphome/components/uart/uart.h b/esphome/components/uart/uart.h index 899d349e21..eda5b72ea8 100644 --- a/esphome/components/uart/uart.h +++ b/esphome/components/uart/uart.h @@ -3,6 +3,7 @@ #include #include "esphome/core/component.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "uart_component.h" @@ -66,6 +67,7 @@ class UARTDevice { } /// Check that the configuration of the UART bus matches the provided values and otherwise print a warning + ESPDEPRECATED("Use uart.final_validate_device_schema() in Python instead. Removed in 2027.3.0", "2026.9.0") void check_uart_settings(uint32_t baud_rate, uint8_t stop_bits = 1, UARTParityOptions parity = UART_CONFIG_PARITY_NONE, uint8_t data_bits = 8); diff --git a/esphome/components/ufm01/__init__.py b/esphome/components/ufm01/__init__.py index ca0ea57796..85ca0eecae 100644 --- a/esphome/components/ufm01/__init__.py +++ b/esphome/components/ufm01/__init__.py @@ -30,6 +30,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( require_tx=True, require_rx=True, baud_rate=2400, + data_bits=8, parity="EVEN", stop_bits=1, ) diff --git a/esphome/components/ufm01/ufm01.cpp b/esphome/components/ufm01/ufm01.cpp index bafdb5d853..880132bad3 100644 --- a/esphome/components/ufm01/ufm01.cpp +++ b/esphome/components/ufm01/ufm01.cpp @@ -213,7 +213,6 @@ void UFM01Component::dump_config() { LOG_BINARY_SENSOR(" ", "Empty Tube", this->empty_tube_binary_sensor_); LOG_BINARY_SENSOR(" ", "Flow Rate Out Of Range", this->flow_rate_out_of_range_binary_sensor_); #endif - this->check_uart_settings(2400, 1, uart::UART_CONFIG_PARITY_EVEN, 8); } void UFM01Component::on_active_frame_(uint8_t data[FRAME_SIZE]) { diff --git a/esphome/components/uponor_smatrix/__init__.py b/esphome/components/uponor_smatrix/__init__.py index 093408e868..ba686dc22a 100644 --- a/esphome/components/uponor_smatrix/__init__.py +++ b/esphome/components/uponor_smatrix/__init__.py @@ -50,7 +50,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( require_tx=True, require_rx=True, data_bits=8, - parity=None, + parity="NONE", stop_bits=1, ) diff --git a/esphome/components/uponor_smatrix/uponor_smatrix.cpp b/esphome/components/uponor_smatrix/uponor_smatrix.cpp index c77f3468c7..74974548af 100644 --- a/esphome/components/uponor_smatrix/uponor_smatrix.cpp +++ b/esphome/components/uponor_smatrix/uponor_smatrix.cpp @@ -29,8 +29,6 @@ void UponorSmatrixComponent::dump_config() { } #endif - this->check_uart_settings(19200); - if (!this->unknown_devices_.empty()) { ESP_LOGCONFIG(TAG, " Detected unknown device addresses:"); for (auto device_address : this->unknown_devices_) { diff --git a/esphome/components/vbus/__init__.py b/esphome/components/vbus/__init__.py index 94857050f2..fd54658912 100644 --- a/esphome/components/vbus/__init__.py +++ b/esphome/components/vbus/__init__.py @@ -29,6 +29,14 @@ CONFIG_SCHEMA = uart.UART_DEVICE_SCHEMA.extend( } ) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "vbus", + baud_rate=9600, + data_bits=8, + parity="NONE", + stop_bits=1, +) + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/vbus/vbus.cpp b/esphome/components/vbus/vbus.cpp index 81714a2049..080567e7f9 100644 --- a/esphome/components/vbus/vbus.cpp +++ b/esphome/components/vbus/vbus.cpp @@ -11,10 +11,7 @@ static const char *const TAG = "vbus"; // Maximum bytes to log in verbose hex output (16 frames * 4 bytes = 64 bytes typical) static constexpr size_t VBUS_MAX_LOG_BYTES = 64; -void VBus::dump_config() { - ESP_LOGCONFIG(TAG, "VBus:"); - check_uart_settings(9600); -} +void VBus::dump_config() { ESP_LOGCONFIG(TAG, "VBus:"); } static void septet_spread(uint8_t *data, int start, int count, uint8_t septet) { for (int i = 0; i < count; i++, septet >>= 1) { diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index cfdbc1a968..63df9fbfa5 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -40,11 +40,6 @@ #include #include -#if defined(USE_ESP8266) && USE_ARDUINO_VERSION_CODE < VERSION_CODE(2, 4, 0) -extern "C" { -#include -}; -#endif #endif #ifdef USE_RP2 diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index b4a91fb3cd..031da1b355 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -21,7 +21,6 @@ extern "C" { #include "lwip/apps/sntp.h" #include "lwip/netif.h" // struct netif #include -#if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(3, 0, 0) #include "LwipDhcpServer.h" #if USE_ARDUINO_VERSION_CODE < VERSION_CODE(3, 1, 0) #include @@ -30,7 +29,6 @@ extern "C" { #define wifi_softap_set_dhcps_lease_time(time) dhcpSoftAP.set_dhcps_lease_time(time) #define wifi_softap_set_dhcps_offer_option(offer, mode) dhcpSoftAP.set_dhcps_offer_option(offer, mode) #endif -#endif } #include "esphome/core/application.h" @@ -293,7 +291,6 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { conf.bssid_set = 0; } -#if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 4, 0) if (ap.password_.empty()) { conf.threshold.authmode = AUTH_OPEN; } else { @@ -310,7 +307,6 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { } } conf.threshold.rssi = -127; -#endif ETS_UART_INTR_DISABLE(); bool ret = wifi_station_set_config_current(&conf); @@ -602,7 +598,6 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { #endif break; } -#if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 4, 0) case EVENT_OPMODE_CHANGED: { auto it = event->event_info.opmode_changed; ESP_LOGV(TAG, "Changed Mode old=%s new=%s", LOG_STR_ARG(get_op_mode_str(it.old_opmode)), @@ -620,7 +615,6 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { #endif break; } -#endif default: break; } @@ -705,7 +699,6 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { config.bssid = nullptr; config.channel = 0; config.show_hidden = 1; -#if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 4, 0) config.scan_type = passive ? WIFI_SCAN_TYPE_PASSIVE : WIFI_SCAN_TYPE_ACTIVE; // Use shorter dwell times for roaming scans - we only need to detect strong // nearby APs, not do a thorough survey. This also reduces off-channel time @@ -724,7 +717,6 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { config.scan_time.active.min = roaming ? SCAN_ACTIVE_MIN_ROAMING_MS : SCAN_ACTIVE_MIN_DEFAULT_MS; config.scan_time.active.max = roaming ? SCAN_ACTIVE_MAX_ROAMING_MS : SCAN_ACTIVE_MAX_DEFAULT_MS; } -#endif bool ret = wifi_station_scan(&config, &WiFiComponent::s_wifi_scan_done_callback); if (!ret) { ESP_LOGV(TAG, "wifi_station_scan failed"); @@ -830,7 +822,7 @@ bool WiFiComponent::wifi_ap_ip_config_(const optional &manual_ip) { return false; } -#if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(3, 0, 0) && USE_ARDUINO_VERSION_CODE < VERSION_CODE(3, 1, 0) +#if USE_ARDUINO_VERSION_CODE < VERSION_CODE(3, 1, 0) dhcpSoftAP.begin(&info); #endif diff --git a/esphome/components/wl_134/text_sensor.py b/esphome/components/wl_134/text_sensor.py index af5e705786..2e3021504f 100644 --- a/esphome/components/wl_134/text_sensor.py +++ b/esphome/components/wl_134/text_sensor.py @@ -21,6 +21,14 @@ CONFIG_SCHEMA = ( .extend(uart.UART_DEVICE_SCHEMA) ) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "wl_134", + baud_rate=9600, + data_bits=8, + parity="NONE", + stop_bits=1, +) + async def to_code(config: ConfigType) -> None: var = await text_sensor.new_text_sensor(config) diff --git a/esphome/components/wl_134/wl_134.cpp b/esphome/components/wl_134/wl_134.cpp index 5e86d5a441..858f974f2b 100644 --- a/esphome/components/wl_134/wl_134.cpp +++ b/esphome/components/wl_134/wl_134.cpp @@ -110,7 +110,5 @@ uint64_t Wl134Component::hex_lsb_ascii_to_uint64_(const uint8_t *text, uint8_t t void Wl134Component::dump_config() { ESP_LOGCONFIG(TAG, "WL-134 Sensor:"); LOG_TEXT_SENSOR("", "Tag", this); - // As specified in the sensor's data sheet - this->check_uart_settings(9600, 1, esphome::uart::UART_CONFIG_PARITY_NONE, 8); } } // namespace esphome::wl_134 diff --git a/esphome/const.py b/esphome/const.py index 6f83f0c937..e1d875f94b 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.9.0-dev" +__version__ = "2026.10.0-dev" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 7af41409fd..526adf74f0 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -13,6 +13,7 @@ #define ESPHOME_PROJECT_VERSION "v2" #define ESPHOME_PROJECT_VERSION_30 "v2" #define ESPHOME_VARIANT "ESP32" +#define ESPHOME_SNAPSHOT_DIR "." #define ESPHOME_NAME_ADD_MAC_SUFFIX #define ESPHOME_DEBUG_SCHEDULER #define ESPHOME_DEBUG_API diff --git a/esphome/core/log.h b/esphome/core/log.h index 272e516808..14d24412ef 100644 --- a/esphome/core/log.h +++ b/esphome/core/log.h @@ -18,7 +18,6 @@ #ifdef USE_STORE_LOG_STR_IN_FLASH #include "WString.h" -#include "esphome/core/defines.h" // for USE_ARDUINO_VERSION_CODE #endif // Include ESP-IDF/Arduino based logging methods here so they don't undefine ours later @@ -177,20 +176,7 @@ struct LogString; #include -#if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 5, 0) #define LOG_STR_ARG(s) ((PGM_P) (s)) -#else -// Pre-Arduino 2.5, we can't pass a PSTR() to printf(). Emulate support by copying the message to a -// local buffer first. String length is limited to 63 characters. -// https://github.com/esp8266/Arduino/commit/6280e98b0360f85fdac2b8f10707fffb4f6e6e31 -#define LOG_STR_ARG(s) \ - ({ \ - char __buf[64]; \ - __buf[63] = '\0'; \ - strncpy_P(__buf, (PGM_P) (s), 63); \ - __buf; \ - }) -#endif #define LOG_STR(s) (reinterpret_cast(PSTR(s))) #define LOG_STR_LITERAL(s) LOG_STR_ARG(LOG_STR(s)) diff --git a/requirements.txt b/requirements.txt index 594b44432d..8a510a2c60 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,7 +14,7 @@ esptool==5.3.1 click==8.3.3 aioesphomeapi==46.3.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi -zeroconf==0.151.2 +zeroconf==0.151.3 puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import @@ -28,8 +28,8 @@ smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 platformdirs==4.11.5 # native esp-idf toolchain global cache dir -ninja==1.13.0 # native esp8266 arduino toolchain build driver -filelock==3.32.4 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg +ninja==1.13.2 # native esp8266 arduino toolchain build driver +filelock==3.32.5 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 diff --git a/tests/component_tests/remote_transmitter/test_non_blocking_gate.py b/tests/component_tests/remote_transmitter/test_non_blocking_gate.py index ee2769e177..525ab3329e 100644 --- a/tests/component_tests/remote_transmitter/test_non_blocking_gate.py +++ b/tests/component_tests/remote_transmitter/test_non_blocking_gate.py @@ -26,7 +26,7 @@ from ..types import SetCoreConfigCallable (PlatformFramework.ESP32_IDF, None, True), (PlatformFramework.RTL87XX_ARDUINO, FAMILY_RTL8720C, True), (PlatformFramework.RTL87XX_ARDUINO, FAMILY_RTL8710B, False), - (PlatformFramework.BK72XX_ARDUINO, FAMILY_BK7231N, True), + (PlatformFramework.BK72XX_ARDUINO, FAMILY_BK7231N, False), (PlatformFramework.BK72XX_ARDUINO, FAMILY_BK7238, True), (PlatformFramework.BK72XX_ARDUINO, FAMILY_BK7231T, False), (PlatformFramework.ESP8266_ARDUINO, None, False), diff --git a/tests/component_tests/sdl/test_sdl.py b/tests/component_tests/sdl/test_sdl.py new file mode 100644 index 0000000000..5ab5e17ee6 --- /dev/null +++ b/tests/component_tests/sdl/test_sdl.py @@ -0,0 +1,101 @@ +"""Tests for the sdl display schema, in particular the headless option.""" + +from __future__ import annotations + +import pytest + +from esphome import config_validation as cv +from esphome.components.sdl.display import ( + CONF_SDL_ID, + CONFIG_SCHEMA, + headless_final_validate, +) +from esphome.config import Config +from esphome.const import PlatformFramework +from esphome.core import ID +from esphome.final_validate import full_config +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +@pytest.fixture(autouse=True) +def _host_platform(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.HOST_NATIVE) + + +def _config(**extra: object) -> ConfigType: + config: ConfigType = { + "dimensions": {"width": 320, "height": 240}, + # sdl2-config is not necessarily installed in the test environment + "sdl_options": "-lSDL2", + } + config.update(extra) + return config + + +def test_defaults_to_windowed() -> None: + """A display without the option is not headless.""" + assert CONFIG_SCHEMA(_config())["headless"] is False + + +def test_headless_accepted() -> None: + """A headless display needs nothing beyond the dimensions.""" + assert CONFIG_SCHEMA(_config(headless=True))["headless"] is True + + +def test_headless_rejects_window_options() -> None: + """Window options are meaningless without a window.""" + with pytest.raises(cv.Invalid, match="has no effect"): + CONFIG_SCHEMA( + _config(headless=True, window_options={"position": {"x": 0, "y": 0}}) + ) + + +def test_headless_rejects_snapshot_key() -> None: + """A headless display has no keyboard, so the action is the only way in.""" + with pytest.raises(cv.Invalid, match="snapshot.take"): + CONFIG_SCHEMA(_config(headless=True, snapshot_key="SDLK_F12")) + + +def test_snapshot_key_accepted_when_windowed() -> None: + """The key is only valid alongside a window.""" + config = CONFIG_SCHEMA(_config(snapshot_key="SDLK_F12")) + assert str(config["snapshot_key"]) == "SDLK_F12" + + +def _declare_sdl_display(headless: bool) -> ID: + """Register a full_config with a single sdl display declaration and return a reference to it. + + Mirrors what the real config pipeline leaves behind: a "display" domain entry plus a + declare_ids record id_declaration_match_schema uses to find it again. + """ + declared_id = ID("my_sdl", is_declaration=True) + fc = Config() + fc["display"] = [ + { + "platform": "sdl", + "id": declared_id, + "headless": headless, + "dimensions": {"width": 320, "height": 240}, + } + ] + fc.declare_ids.append((declared_id, ["display", 0, "id"])) + full_config.set(fc) + return ID("my_sdl") + + +@pytest.mark.parametrize("platform", ["binary_sensor", "touchscreen"]) +def test_headless_final_validate_rejects_headless_display(platform: str) -> None: + """binary_sensor and touchscreen both need a window, so a headless display is rejected.""" + sdl_ref = _declare_sdl_display(headless=True) + schema = headless_final_validate(platform) + with pytest.raises(cv.Invalid, match="needs a window"): + schema({CONF_SDL_ID: sdl_ref}) + + +@pytest.mark.parametrize("platform", ["binary_sensor", "touchscreen"]) +def test_headless_final_validate_accepts_windowed_display(platform: str) -> None: + """The same platforms are accepted once the display has a window.""" + sdl_ref = _declare_sdl_display(headless=False) + schema = headless_final_validate(platform) + schema({CONF_SDL_ID: sdl_ref}) # Should not raise. diff --git a/tests/components/cse7761/test.esp32-idf.yaml b/tests/components/cse7761/test.esp32-idf.yaml index a6a8fee7e9..b9ae061c25 100644 --- a/tests/components/cse7761/test.esp32-idf.yaml +++ b/tests/components/cse7761/test.esp32-idf.yaml @@ -3,6 +3,6 @@ substitutions: rx_pin: GPIO14 packages: - uart_38400: !include ../../test_build_components/common/uart_38400/esp32-idf.yaml + uart_38400_even: !include ../../test_build_components/common/uart_38400_even/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/cse7761/test.esp8266-ard.yaml b/tests/components/cse7761/test.esp8266-ard.yaml index 134274ffb8..0d57039e1c 100644 --- a/tests/components/cse7761/test.esp8266-ard.yaml +++ b/tests/components/cse7761/test.esp8266-ard.yaml @@ -3,6 +3,6 @@ substitutions: rx_pin: GPIO3 packages: - uart_38400: !include ../../test_build_components/common/uart_38400/esp8266-ard.yaml + uart_38400_even: !include ../../test_build_components/common/uart_38400_even/esp8266-ard.yaml <<: !include common.yaml diff --git a/tests/components/cse7761/test.rp2040-ard.yaml b/tests/components/cse7761/test.rp2040-ard.yaml index b813e0f7f1..65e6252c51 100644 --- a/tests/components/cse7761/test.rp2040-ard.yaml +++ b/tests/components/cse7761/test.rp2040-ard.yaml @@ -3,6 +3,6 @@ substitutions: rx_pin: GPIO5 packages: - uart_38400: !include ../../test_build_components/common/uart_38400/rp2040-ard.yaml + uart_38400_even: !include ../../test_build_components/common/uart_38400_even/rp2040-ard.yaml <<: !include common.yaml diff --git a/tests/components/kamstrup_kmp/test.esp32-idf.yaml b/tests/components/kamstrup_kmp/test.esp32-idf.yaml index 1016905720..4e1ff86fb7 100644 --- a/tests/components/kamstrup_kmp/test.esp32-idf.yaml +++ b/tests/components/kamstrup_kmp/test.esp32-idf.yaml @@ -1,4 +1,4 @@ packages: - uart_1200: !include ../../test_build_components/common/uart_1200/esp32-idf.yaml + uart_1200_none_2stopbits: !include ../../test_build_components/common/uart_1200_none_2stopbits/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/kamstrup_kmp/test.esp8266-ard.yaml b/tests/components/kamstrup_kmp/test.esp8266-ard.yaml index f55c18eb76..631516eba9 100644 --- a/tests/components/kamstrup_kmp/test.esp8266-ard.yaml +++ b/tests/components/kamstrup_kmp/test.esp8266-ard.yaml @@ -3,6 +3,6 @@ substitutions: uart_rx_pin: GPIO3 packages: - uart_1200: !include ../../test_build_components/common/uart_1200/esp8266-ard.yaml + uart_1200_none_2stopbits: !include ../../test_build_components/common/uart_1200_none_2stopbits/esp8266-ard.yaml <<: !include common.yaml diff --git a/tests/components/pylontech/test.esp32-idf.yaml b/tests/components/pylontech/test.esp32-idf.yaml index b415125e84..7d5c371187 100644 --- a/tests/components/pylontech/test.esp32-idf.yaml +++ b/tests/components/pylontech/test.esp32-idf.yaml @@ -3,6 +3,6 @@ substitutions: rx_pin: GPIO5 packages: - uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/pylontech/test.esp8266-ard.yaml b/tests/components/pylontech/test.esp8266-ard.yaml index 96ab4ef6ac..c49b2bfee1 100644 --- a/tests/components/pylontech/test.esp8266-ard.yaml +++ b/tests/components/pylontech/test.esp8266-ard.yaml @@ -3,6 +3,6 @@ substitutions: rx_pin: GPIO2 packages: - uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml <<: !include common.yaml diff --git a/tests/components/pylontech/test.rp2040-ard.yaml b/tests/components/pylontech/test.rp2040-ard.yaml index b28f2b5e05..5b2785b792 100644 --- a/tests/components/pylontech/test.rp2040-ard.yaml +++ b/tests/components/pylontech/test.rp2040-ard.yaml @@ -3,6 +3,6 @@ substitutions: rx_pin: GPIO5 packages: - uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml + uart_115200: !include ../../test_build_components/common/uart_115200/rp2040-ard.yaml <<: !include common.yaml diff --git a/tests/components/remote_transmitter/test.bk72xx-ard.yaml b/tests/components/remote_transmitter/test.bk72xx-ard.yaml index ea2feafda9..f3e2da9daf 100644 --- a/tests/components/remote_transmitter/test.bk72xx-ard.yaml +++ b/tests/components/remote_transmitter/test.bk72xx-ard.yaml @@ -2,7 +2,7 @@ remote_transmitter: id: xmitr pin: GPIO26 carrier_duty_percent: 50% - # non_blocking is bk7231n/bk7238-only; the CI board is a BK7252 + # non_blocking is bk7238-only; the CI board is a BK7252, so this builds the bit-bang path packages: buttons: !include common-buttons.yaml diff --git a/tests/components/sdl/common.yaml b/tests/components/sdl/common.yaml index 3be86cf8be..1bb0434057 100644 --- a/tests/components/sdl/common.yaml +++ b/tests/components/sdl/common.yaml @@ -14,6 +14,15 @@ display: position: x: 100 y: 100 + snapshot_key: SDLK_F12 + + - platform: sdl + id: headless_display + headless: true + show_test_card: true + dimensions: + width: 320 + height: 240 - platform: sdl id: second_display @@ -46,3 +55,21 @@ binary_sensor: sdl_id: sdl_sdl_display id: key_enter key: SDLK_RETURN + +esphome: + # A name of your own is only good for one snapshot - a second one under the same name fails + # rather than writing over the first - so these run once rather than on a repeating interval. + on_boot: + - delay: 2s + - snapshot.take: + id: headless_display + filename: test_card.bmp + - snapshot.take: + id: headless_display + filename: !lambda 'return "shot.bmp";' + +interval: + # A generated name has the time in it, so this one can repeat. + - interval: 10s + then: + - snapshot.take: sdl_sdl_display diff --git a/tests/components/sdl/validate.host.yaml b/tests/components/sdl/validate.host.yaml new file mode 100644 index 0000000000..883f34675d --- /dev/null +++ b/tests/components/sdl/validate.host.yaml @@ -0,0 +1,29 @@ +# Config-only test for the headless and screenshot options. The combinations that must be +# rejected are covered by tests/component_tests/sdl/test_sdl.py; this file checks that the +# accepted forms validate together. +host: + mac_address: "62:23:45:AF:B3:DD" + +display: + - platform: sdl + id: headless_display + headless: true + dimensions: 320x240 + + - platform: sdl + id: windowed_display + dimensions: 320x240 + snapshot_key: SDLK_F12 + +binary_sensor: + - platform: sdl + sdl_id: windowed_display + id: key_up + key: SDLK_UP + +interval: + - interval: 10s + then: + - snapshot.take: + id: headless_display + filename: periodic.bmp diff --git a/tests/components/snapshot/common.yaml b/tests/components/snapshot/common.yaml new file mode 100644 index 0000000000..9ce2d33a87 --- /dev/null +++ b/tests/components/snapshot/common.yaml @@ -0,0 +1,34 @@ +display: + - platform: snapshot + id: snapshot_display + update_interval: 1s + show_test_card: true + # An odd width exercises the row padding in the BMP writer + dimensions: + width: 101 + height: 64 + + - platform: snapshot + id: snapshot_rotated + rotation: 90 + dimensions: 320x240 + lambda: |- + it.filled_rectangle(0, 0, 40, 20, Color(0xFF, 0x80, 0x00)); + +esphome: + # A name of your own is only good for one snapshot - a second one under the same name fails + # rather than writing over the first - so these run once rather than on a repeating interval. + on_boot: + - delay: 2s + - snapshot.take: + id: snapshot_display + filename: test_card.bmp + - snapshot.take: + id: snapshot_rotated + filename: !lambda 'return "rotated.bmp";' + +interval: + # A generated name has the time in it, so this one can repeat. + - interval: 10s + then: + - snapshot.take: snapshot_display diff --git a/tests/components/snapshot/test.host.yaml b/tests/components/snapshot/test.host.yaml new file mode 100644 index 0000000000..951be2ed04 --- /dev/null +++ b/tests/components/snapshot/test.host.yaml @@ -0,0 +1,5 @@ +host: + mac_address: "62:23:45:AF:B3:DE" + +packages: + snapshot: !include common.yaml diff --git a/tests/components/teleinfo/test.esp32-idf.yaml b/tests/components/teleinfo/test.esp32-idf.yaml index b415125e84..3071f9a67b 100644 --- a/tests/components/teleinfo/test.esp32-idf.yaml +++ b/tests/components/teleinfo/test.esp32-idf.yaml @@ -3,6 +3,6 @@ substitutions: rx_pin: GPIO5 packages: - uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + uart_1200_even_7bits: !include ../../test_build_components/common/uart_1200_even_7bits/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/teleinfo/test.esp8266-ard.yaml b/tests/components/teleinfo/test.esp8266-ard.yaml index 96ab4ef6ac..29490b3be3 100644 --- a/tests/components/teleinfo/test.esp8266-ard.yaml +++ b/tests/components/teleinfo/test.esp8266-ard.yaml @@ -3,6 +3,6 @@ substitutions: rx_pin: GPIO2 packages: - uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + uart_1200_even_7bits: !include ../../test_build_components/common/uart_1200_even_7bits/esp8266-ard.yaml <<: !include common.yaml diff --git a/tests/components/teleinfo/test.rp2040-ard.yaml b/tests/components/teleinfo/test.rp2040-ard.yaml index b28f2b5e05..f13d5a9f8f 100644 --- a/tests/components/teleinfo/test.rp2040-ard.yaml +++ b/tests/components/teleinfo/test.rp2040-ard.yaml @@ -3,6 +3,6 @@ substitutions: rx_pin: GPIO5 packages: - uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml + uart_1200_even_7bits: !include ../../test_build_components/common/uart_1200_even_7bits/rp2040-ard.yaml <<: !include common.yaml diff --git a/tests/components/teleinfo/validate-standard.esp32-idf.yaml b/tests/components/teleinfo/validate-standard.esp32-idf.yaml new file mode 100644 index 0000000000..2ca014c8af --- /dev/null +++ b/tests/components/teleinfo/validate-standard.esp32-idf.yaml @@ -0,0 +1,14 @@ +packages: + uart_9600_even_7bits: !include ../../test_build_components/common/uart_9600_even_7bits/esp32-idf.yaml + +teleinfo: + id: test_teleinfo_standard + historical_mode: false + update_interval: 60s + +sensor: + - platform: teleinfo + name: sinsts + tag_name: SINSTS + teleinfo_id: test_teleinfo_standard + unit_of_measurement: VA diff --git a/tests/integration/artifact_utils.py b/tests/integration/artifact_utils.py new file mode 100644 index 0000000000..cf18946512 --- /dev/null +++ b/tests/integration/artifact_utils.py @@ -0,0 +1,26 @@ +"""Shared utilities for ESPHome integration tests - keeping output from failing tests.""" + +from __future__ import annotations + +from pathlib import Path + +#: Where a failing test leaves output for someone to look at afterwards. pytest's own +#: temporary folder is no use on a CI runner, which throws the whole workspace away when +#: the job ends; the workflow uploads this folder instead when a job fails. +ARTIFACT_DIR = Path(__file__).resolve().parents[2] / "test_artifacts" + + +def keep_artifact(name: str, data: bytes) -> Path: + """Write ``data`` where it can still be read after the run, and return the path. + + Args: + name: File name to write under the artifact folder. + data: Contents to write. + + Returns: + The full path written. + """ + ARTIFACT_DIR.mkdir(parents=True, exist_ok=True) + path = ARTIFACT_DIR / name + path.write_bytes(data) + return path diff --git a/tests/integration/bmp_utils.py b/tests/integration/bmp_utils.py new file mode 100644 index 0000000000..c10aea5ade --- /dev/null +++ b/tests/integration/bmp_utils.py @@ -0,0 +1,161 @@ +"""Shared utilities for ESPHome integration tests - reading BMP snapshots.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from pathlib import Path +import struct + +# Size of the smallest BMP header pair (file header plus BITMAPINFOHEADER). +_MIN_HEADER_SIZE = 54 + +# How long capture_when_drawn() keeps asking for a picture with something on it. +DRAW_TIMEOUT = 15.0 + + +@dataclass(frozen=True) +class Bmp: + """A decoded BMP image.""" + + width: int + height: int + bits: int + #: Pixel data with the per row padding stripped, so it depends only on the image itself. + pixels: bytes + + +class NotABmpError(Exception): + """The data is not a BMP at all, as opposed to a BMP that is still being written.""" + + +def parse_bmp(data: bytes) -> Bmp | None: + """Decode a BMP, or return None if the data is not a complete image yet. + + Raises: + NotABmpError: If the data cannot become a valid BMP however much more is appended. + """ + # Writes go to the file in order, so a short read is always a prefix of what will be there. + # Anything wrong in a prefix we have already read is wrong for good, and worth saying now + # rather than reporting as a timeout later. + if len(data) >= 2 and data[:2] != b"BM": + raise NotABmpError(f"expected a BMP, got {data[:2]!r}") + if len(data) < _MIN_HEADER_SIZE: + return None + file_size = struct.unpack_from(" Bmp: + """Wait for a complete BMP file to appear at ``path`` and return it. + + The file is created before any of its contents are written, so waiting for it to exist is + not enough - a read that wins the race sees a truncated image. Keep reading until the + headers say the whole image is there. + + Args: + path: The file to wait for. + timeout: Maximum time to wait in seconds. + + Returns: + The decoded image. + + Raises: + AssertionError: If no complete image is readable within ``timeout``. + NotABmpError: If what was written is not a BMP. This is reported as soon as it is + seen, so a device that writes the wrong thing is named for what it did rather + than waiting out the timeout. + """ + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while True: + try: + data = path.read_bytes() + except FileNotFoundError: + data = b"" + if (image := parse_bmp(data)) is not None: + return image + if loop.time() >= deadline: + break + await asyncio.sleep(0.05) + if not data: + raise AssertionError(f"no snapshot appeared at {path} within {timeout}s") + raise AssertionError( + f"{path} was still incomplete after {timeout}s ({len(data)} bytes)" + ) + + +def is_blank(image: Bmp) -> bool: + """True if every pixel of the image is the same colour. + + Whole pixels are counted rather than byte values: a plain background is usually made of more + than one distinct byte, so counting bytes would find several of them in a blank screen. + """ + return len({image.pixels[i : i + 3] for i in range(0, len(image.pixels), 3)}) <= 1 + + +async def capture_when_drawn( + take: Callable[[str], Awaitable[None]], + directory: Path, + prefix: str = "drawn", + timeout: float = DRAW_TIMEOUT, +) -> tuple[Bmp, Path]: + """Ask for snapshots until one has something drawn on it, and return it and where it went. + + A display holds one flat colour until it first draws, which is one update interval after it + starts - long enough that a test connecting over the API can easily get in first. Capturing + once and hoping would compare a blank screen against whatever the test expects, reporting a + drawing fault where the real trouble was timing. + + Args: + take: Asks the device for a snapshot under the name it is given. + directory: Where the device writes them. + prefix: Start of the names asked for. Each attempt needs its own, because a snapshot never + writes over a file that is already there. + timeout: How long to keep asking. + + Returns: + The first image that is not one flat colour, and the path it was read from. + + Raises: + AssertionError: If nothing had been drawn within ``timeout``. + """ + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + attempt = 0 + while True: + attempt += 1 + path = directory / f"{prefix}-{attempt}.bmp" + await take(path.name) + image = await wait_for_bmp(path) + if not is_blank(image): + return image, path + if loop.time() >= deadline: + raise AssertionError( + f"the screen was still a single flat colour after {timeout}s and " + f"{attempt} captures - nothing was drawn" + ) + await asyncio.sleep(0.5) diff --git a/tests/integration/fixtures/lvgl_headless_render.yaml b/tests/integration/fixtures/lvgl_headless_render.yaml new file mode 100644 index 0000000000..670b51ab53 --- /dev/null +++ b/tests/integration/fixtures/lvgl_headless_render.yaml @@ -0,0 +1,53 @@ +esphome: + name: lvgl-headless-render-test +host: + +api: + actions: + # The name comes from the test so it can capture more than once: a snapshot never writes over + # a file that is already there, so a fixed name could only ever be captured once. + - action: take_screenshot + variables: + name: string + then: + - snapshot.take: + id: lvgl_display + filename: !lambda return name; + +logger: + level: DEBUG + +display: + # A display with no screen, so what LVGL draws depends on LVGL alone - nothing about the machine + # running the test, and no graphics library outside this repository, can move the result. + - platform: snapshot + id: lvgl_display + auto_clear_enabled: false + dimensions: + width: 300 + height: 300 + +# The widgets are spelled out here rather than left to the built in "Hello World" screen, which +# LVGL builds when nothing is configured: that screen contains a spinner, and an animation cannot +# produce the same picture twice. +# +# Everything that affects the rendered pixels is set explicitly, so the expected hash in the test +# depends only on the drawing code and the built in font. In particular the background comes from a +# full screen object rather than from the theme, so adjusting a theme default does not break this. +lvgl: + displays: lvgl_display + default_font: montserrat_14 + widgets: + - obj: + width: 100% + height: 100% + bg_color: 0x000080 + bg_opa: cover + border_width: 0 + radius: 0 + pad_all: 0 + widgets: + - label: + align: center + text: "Hello World!" + text_color: 0xFFFFFF diff --git a/tests/integration/fixtures/sdl_headless_screenshot.yaml b/tests/integration/fixtures/sdl_headless_screenshot.yaml new file mode 100644 index 0000000000..7ce2df130c --- /dev/null +++ b/tests/integration/fixtures/sdl_headless_screenshot.yaml @@ -0,0 +1,29 @@ +esphome: + name: sdl-headless-screenshot-test +host: + +api: + actions: + # The name comes from the test so it can capture more than once while it waits for the first + # frame: a snapshot never writes over a file that is already there. + - action: take_screenshot + variables: + name: string + then: + - snapshot.take: + id: sdl_display + filename: !lambda return name; + +logger: + level: DEBUG + +display: + - platform: sdl + id: sdl_display + headless: true + show_test_card: true + update_interval: 100ms + # An odd width exercises the row padding in the BMP writer + dimensions: + width: 101 + height: 64 diff --git a/tests/integration/fixtures/snapshot_display.yaml b/tests/integration/fixtures/snapshot_display.yaml new file mode 100644 index 0000000000..d10af09806 --- /dev/null +++ b/tests/integration/fixtures/snapshot_display.yaml @@ -0,0 +1,28 @@ +esphome: + name: snapshot-display-test +host: + +api: + actions: + # The name comes from the test so it can ask for several in a row and check what each one + # does with it. + - action: take_snapshot + variables: + name: string + then: + - snapshot.take: + id: snapshot_display + filename: !lambda return name; + +logger: + level: DEBUG + +display: + - platform: snapshot + id: snapshot_display + show_test_card: true + update_interval: 100ms + # An odd width exercises the row padding in the BMP writer + dimensions: + width: 101 + height: 64 diff --git a/tests/integration/integration_test_durations.json b/tests/integration/integration_test_durations.json index 9bada5cd36..5a5aac3b22 100644 --- a/tests/integration/integration_test_durations.json +++ b/tests/integration/integration_test_durations.json @@ -1,142 +1,143 @@ { - "tests/integration/test_action_concurrent_reentry.py": 45.23, - "tests/integration/test_addressable_light_transition.py": 74.47, - "tests/integration/test_alarm_control_panel_state_transitions.py": 74.1, - "tests/integration/test_api_action_metadata.py": 62.1, - "tests/integration/test_api_action_responses.py": 71.08, - "tests/integration/test_api_action_timeout.py": 21.64, - "tests/integration/test_api_conditional_memory.py": 13.72, - "tests/integration/test_api_custom_services.py": 24.16, - "tests/integration/test_api_get_time_response_timezone.py": 23.48, - "tests/integration/test_api_homeassistant.py": 37.87, - "tests/integration/test_api_homeassistant_action_no_subscriber.py": 14.38, - "tests/integration/test_api_list_entities_backpressure.py": 26.85, - "tests/integration/test_api_message_size_batching.py": 33.36, - "tests/integration/test_api_reboot_timeout.py": 13.63, - "tests/integration/test_api_string_lambda.py": 25.04, - "tests/integration/test_api_vv_logging.py": 16.6, - "tests/integration/test_api_zero_psk_provisioning.py": 43.14, - "tests/integration/test_areas_and_devices.py": 25.98, - "tests/integration/test_automation_wait_actions.py": 21.91, - "tests/integration/test_automations.py": 42.43, - "tests/integration/test_batch_delay_zero_rapid_transitions.py": 16.65, - "tests/integration/test_binary_sensor_autorepeat_filter.py": 28.67, - "tests/integration/test_binary_sensor_invalidate_state.py": 23.69, - "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 22.99, - "tests/integration/test_build_info.py": 24.96, - "tests/integration/test_camera_mock.py": 14.47, - "tests/integration/test_climate_control_action.py": 31.07, - "tests/integration/test_climate_custom_modes.py": 28.59, - "tests/integration/test_continuation_actions.py": 14.96, - "tests/integration/test_cover_control_action.py": 26.14, - "tests/integration/test_crc8_helper.py": 10.92, - "tests/integration/test_device_id_in_state.py": 64.97, - "tests/integration/test_duplicate_entities.py": 30.81, - "tests/integration/test_entity_icon.py": 32.85, - "tests/integration/test_fan_turn_on_action.py": 24.91, - "tests/integration/test_fnv1_hash_object_id.py": 12.54, - "tests/integration/test_fnv1a_hash.py": 21.8, - "tests/integration/test_gpio_expander_cache.py": 5.2, - "tests/integration/test_host_logger_thread_safety.py": 21.7, - "tests/integration/test_host_mode_basic.py": 13.62, - "tests/integration/test_host_mode_batch_delay.py": 14.56, - "tests/integration/test_host_mode_climate_basic_state.py": 30.95, - "tests/integration/test_host_mode_climate_control.py": 29.06, - "tests/integration/test_host_mode_empty_string_options.py": 27.22, - "tests/integration/test_host_mode_entity_fields.py": 30.95, - "tests/integration/test_host_mode_fan_preset.py": 14.44, - "tests/integration/test_host_mode_many_entities.py": 54.13, - "tests/integration/test_host_mode_many_entities_multiple_connections.py": 32.17, - "tests/integration/test_host_mode_noise_encryption.py": 42.77, - "tests/integration/test_host_mode_reconnect.py": 4.06, - "tests/integration/test_host_mode_sensor.py": 13.47, - "tests/integration/test_host_ota.py": 21.4, - "tests/integration/test_host_preferences.py": 25.43, - "tests/integration/test_host_preferences_suspend_resume.py": 19.2, - "tests/integration/test_improv_serial_uart.py": 31.52, - "tests/integration/test_large_message_batching.py": 15.64, - "tests/integration/test_legacy_area.py": 22.63, - "tests/integration/test_legacy_climate_compat.py": 26.13, - "tests/integration/test_legacy_fan_compat.py": 24.05, - "tests/integration/test_light_automations.py": 30.86, - "tests/integration/test_light_binary_effect_off_phase.py": 23.19, - "tests/integration/test_light_calls.py": 32.35, - "tests/integration/test_light_constant_brightness.py": 29.89, - "tests/integration/test_light_control_action.py": 29.06, - "tests/integration/test_light_dim_relative_action.py": 29.61, - "tests/integration/test_light_effect_zero_brightness.py": 18.68, - "tests/integration/test_light_initial_state.py": 24.49, - "tests/integration/test_light_toggle_action.py": 26.46, - "tests/integration/test_lock_automations.py": 23.28, - "tests/integration/test_logger_buffered_recursion_guard.py": 24.29, - "tests/integration/test_loop_disable_enable.py": 45.28, - "tests/integration/test_loop_interval_decoupling.py": 28.35, - "tests/integration/test_loop_interval_default_not_pulled_forward.py": 21.97, - "tests/integration/test_micros_to_millis.py": 20.79, - "tests/integration/test_multi_click_trigger.py": 26.2, - "tests/integration/test_multi_device_preferences.py": 16.87, - "tests/integration/test_noise_encryption_key_protection.py": 77.05, - "tests/integration/test_object_id_api_verification.py": 73.51, - "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 62.33, - "tests/integration/test_object_id_no_friendly_name.py": 43.47, - "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 32.21, - "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 56.86, - "tests/integration/test_online_image_bmp.py": 50.9, - "tests/integration/test_oversized_payloads.py": 53.2, - "tests/integration/test_preference_key_stability.py": 26.09, - "tests/integration/test_runtime_stats.py": 18.34, - "tests/integration/test_safe_mode_loop_runs.py": 10.07, - "tests/integration/test_scheduler_blocking_warning.py": 40.91, - "tests/integration/test_scheduler_bulk_cleanup.py": 23.14, - "tests/integration/test_scheduler_defer_cancel.py": 24.54, - "tests/integration/test_scheduler_defer_cancel_regular.py": 13.48, - "tests/integration/test_scheduler_defer_fifo_simple.py": 26.86, - "tests/integration/test_scheduler_defer_stress.py": 27.23, - "tests/integration/test_scheduler_heap_stress.py": 24.02, - "tests/integration/test_scheduler_internal_id_no_collision.py": 24.57, - "tests/integration/test_scheduler_interval_reschedule.py": 13.12, - "tests/integration/test_scheduler_interval_zero_coerced.py": 22.91, - "tests/integration/test_scheduler_null_name.py": 23.46, - "tests/integration/test_scheduler_numeric_id_test.py": 24.54, - "tests/integration/test_scheduler_pool.py": 25.0, - "tests/integration/test_scheduler_rapid_cancellation.py": 14.68, - "tests/integration/test_scheduler_recursive_timeout.py": 25.35, - "tests/integration/test_scheduler_removed_item_race.py": 26.19, - "tests/integration/test_scheduler_self_keyed.py": 23.43, - "tests/integration/test_scheduler_simultaneous_callbacks.py": 22.16, - "tests/integration/test_scheduler_string_test.py": 15.22, - "tests/integration/test_script_array_params.py": 14.67, - "tests/integration/test_script_delay_params.py": 15.65, - "tests/integration/test_script_queued.py": 24.93, - "tests/integration/test_script_queued_idle_loop.py": 5.04, - "tests/integration/test_script_wait_on_boot.py": 13.08, - "tests/integration/test_select_stringref_trigger.py": 29.6, - "tests/integration/test_sensor_filters_delta.py": 28.01, - "tests/integration/test_sensor_filters_ring_buffer.py": 25.04, - "tests/integration/test_sensor_filters_sliding_window.py": 71.5, - "tests/integration/test_sensor_filters_value_list.py": 16.94, - "tests/integration/test_sensor_timeout_filter.py": 29.48, - "tests/integration/test_socket_wake_gate_tcp.py": 20.36, - "tests/integration/test_status_flags.py": 37.42, - "tests/integration/test_strftime_to.py": 22.61, - "tests/integration/test_syslog.py": 16.34, - "tests/integration/test_template_alarm_control_panel_many_sensors.py": 29.81, - "tests/integration/test_template_text_save.py": 25.43, - "tests/integration/test_text_command.py": 23.34, - "tests/integration/test_text_sensor_raw_state.py": 69.57, - "tests/integration/test_uart_mock_ld2410.py": 37.95, - "tests/integration/test_uart_mock_ld2412.py": 93.22, - "tests/integration/test_uart_mock_ld2420.py": 43.24, - "tests/integration/test_uart_mock_ld2450.py": 31.75, - "tests/integration/test_uart_mock_modbus.py": 667.4, - "tests/integration/test_udp.py": 9.38, - "tests/integration/test_use_address_runtime.py": 37.05, - "tests/integration/test_valve_control_action.py": 24.47, - "tests/integration/test_varint_five_byte_device_id.py": 25.03, - "tests/integration/test_wait_until_mid_loop_timing.py": 23.73, - "tests/integration/test_wait_until_on_boot.py": 9.16, - "tests/integration/test_wait_until_ordering.py": 13.3, - "tests/integration/test_wait_until_reentrant_restart.py": 25.23, - "tests/integration/test_wake_loop_forces_phase_b.py": 23.34, - "tests/integration/test_water_heater_template.py": 17.67 + "tests/integration/test_action_concurrent_reentry.py": 57.91, + "tests/integration/test_addressable_light_transition.py": 21.25, + "tests/integration/test_alarm_control_panel_state_transitions.py": 70.71, + "tests/integration/test_api_action_metadata.py": 66.6, + "tests/integration/test_api_action_responses.py": 36.1, + "tests/integration/test_api_action_timeout.py": 68.86, + "tests/integration/test_api_conditional_memory.py": 15.48, + "tests/integration/test_api_custom_services.py": 18.77, + "tests/integration/test_api_get_time_response_timezone.py": 21.08, + "tests/integration/test_api_homeassistant.py": 65.59, + "tests/integration/test_api_homeassistant_action_no_subscriber.py": 18.44, + "tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 15.05, + "tests/integration/test_api_list_entities_backpressure.py": 13.88, + "tests/integration/test_api_message_size_batching.py": 29.98, + "tests/integration/test_api_reboot_timeout.py": 16.05, + "tests/integration/test_api_string_lambda.py": 15.31, + "tests/integration/test_api_vv_logging.py": 19.28, + "tests/integration/test_api_zero_psk_provisioning.py": 31.5, + "tests/integration/test_areas_and_devices.py": 24.95, + "tests/integration/test_automation_wait_actions.py": 20.92, + "tests/integration/test_automations.py": 35.19, + "tests/integration/test_batch_delay_zero_rapid_transitions.py": 17.99, + "tests/integration/test_binary_sensor_autorepeat_filter.py": 20.39, + "tests/integration/test_binary_sensor_invalidate_state.py": 18.41, + "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 24.69, + "tests/integration/test_build_info.py": 18.7, + "tests/integration/test_camera_mock.py": 16.23, + "tests/integration/test_climate_control_action.py": 21.14, + "tests/integration/test_climate_custom_modes.py": 20.74, + "tests/integration/test_continuation_actions.py": 16.81, + "tests/integration/test_cover_control_action.py": 20.34, + "tests/integration/test_crc8_helper.py": 9.36, + "tests/integration/test_device_id_in_state.py": 44.67, + "tests/integration/test_duplicate_entities.py": 23.58, + "tests/integration/test_entity_icon.py": 34.35, + "tests/integration/test_fan_turn_on_action.py": 24.23, + "tests/integration/test_fnv1_hash_object_id.py": 16.21, + "tests/integration/test_fnv1a_hash.py": 13.38, + "tests/integration/test_gpio_expander_cache.py": 13.06, + "tests/integration/test_host_logger_thread_safety.py": 23.66, + "tests/integration/test_host_mode_basic.py": 8.01, + "tests/integration/test_host_mode_batch_delay.py": 21.0, + "tests/integration/test_host_mode_climate_basic_state.py": 22.14, + "tests/integration/test_host_mode_climate_control.py": 19.39, + "tests/integration/test_host_mode_empty_string_options.py": 21.76, + "tests/integration/test_host_mode_entity_fields.py": 29.61, + "tests/integration/test_host_mode_fan_preset.py": 20.01, + "tests/integration/test_host_mode_many_entities.py": 39.08, + "tests/integration/test_host_mode_many_entities_multiple_connections.py": 23.92, + "tests/integration/test_host_mode_noise_encryption.py": 42.42, + "tests/integration/test_host_mode_reconnect.py": 3.41, + "tests/integration/test_host_mode_sensor.py": 22.96, + "tests/integration/test_host_ota.py": 29.5, + "tests/integration/test_host_preferences.py": 16.06, + "tests/integration/test_host_preferences_suspend_resume.py": 18.71, + "tests/integration/test_improv_serial_uart.py": 20.22, + "tests/integration/test_large_message_batching.py": 26.56, + "tests/integration/test_legacy_area.py": 22.72, + "tests/integration/test_legacy_climate_compat.py": 14.13, + "tests/integration/test_legacy_fan_compat.py": 14.33, + "tests/integration/test_light_automations.py": 18.81, + "tests/integration/test_light_binary_effect_off_phase.py": 8.38, + "tests/integration/test_light_calls.py": 21.88, + "tests/integration/test_light_constant_brightness.py": 59.45, + "tests/integration/test_light_control_action.py": 31.91, + "tests/integration/test_light_dim_relative_action.py": 14.43, + "tests/integration/test_light_effect_zero_brightness.py": 25.05, + "tests/integration/test_light_initial_state.py": 18.97, + "tests/integration/test_light_toggle_action.py": 17.44, + "tests/integration/test_lock_automations.py": 18.9, + "tests/integration/test_logger_buffered_recursion_guard.py": 18.2, + "tests/integration/test_loop_disable_enable.py": 63.35, + "tests/integration/test_loop_interval_decoupling.py": 17.7, + "tests/integration/test_loop_interval_default_not_pulled_forward.py": 21.56, + "tests/integration/test_micros_to_millis.py": 15.89, + "tests/integration/test_multi_click_trigger.py": 17.23, + "tests/integration/test_multi_device_preferences.py": 19.4, + "tests/integration/test_noise_encryption_key_protection.py": 72.59, + "tests/integration/test_object_id_api_verification.py": 19.22, + "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 16.77, + "tests/integration/test_object_id_no_friendly_name.py": 45.8, + "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 86.73, + "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 40.4, + "tests/integration/test_online_image_bmp.py": 37.24, + "tests/integration/test_oversized_payloads.py": 55.75, + "tests/integration/test_preference_key_stability.py": 25.49, + "tests/integration/test_runtime_stats.py": 29.81, + "tests/integration/test_safe_mode_loop_runs.py": 6.26, + "tests/integration/test_scheduler_blocking_warning.py": 37.98, + "tests/integration/test_scheduler_bulk_cleanup.py": 18.67, + "tests/integration/test_scheduler_defer_cancel.py": 18.46, + "tests/integration/test_scheduler_defer_cancel_regular.py": 16.34, + "tests/integration/test_scheduler_defer_fifo_simple.py": 18.26, + "tests/integration/test_scheduler_defer_stress.py": 17.74, + "tests/integration/test_scheduler_heap_stress.py": 3.89, + "tests/integration/test_scheduler_internal_id_no_collision.py": 20.01, + "tests/integration/test_scheduler_interval_reschedule.py": 16.29, + "tests/integration/test_scheduler_interval_zero_coerced.py": 16.09, + "tests/integration/test_scheduler_null_name.py": 14.69, + "tests/integration/test_scheduler_numeric_id_test.py": 17.08, + "tests/integration/test_scheduler_pool.py": 19.88, + "tests/integration/test_scheduler_rapid_cancellation.py": 4.42, + "tests/integration/test_scheduler_recursive_timeout.py": 4.3, + "tests/integration/test_scheduler_removed_item_race.py": 15.49, + "tests/integration/test_scheduler_self_keyed.py": 25.77, + "tests/integration/test_scheduler_simultaneous_callbacks.py": 14.84, + "tests/integration/test_scheduler_string_test.py": 15.42, + "tests/integration/test_script_array_params.py": 12.73, + "tests/integration/test_script_delay_params.py": 12.69, + "tests/integration/test_script_queued.py": 20.38, + "tests/integration/test_script_queued_idle_loop.py": 25.06, + "tests/integration/test_script_wait_on_boot.py": 15.67, + "tests/integration/test_select_stringref_trigger.py": 19.48, + "tests/integration/test_sensor_filters_delta.py": 27.62, + "tests/integration/test_sensor_filters_ring_buffer.py": 20.27, + "tests/integration/test_sensor_filters_sliding_window.py": 56.28, + "tests/integration/test_sensor_filters_value_list.py": 20.6, + "tests/integration/test_sensor_timeout_filter.py": 22.21, + "tests/integration/test_socket_wake_gate_tcp.py": 16.37, + "tests/integration/test_status_flags.py": 29.68, + "tests/integration/test_strftime_to.py": 17.42, + "tests/integration/test_syslog.py": 18.39, + "tests/integration/test_template_alarm_control_panel_many_sensors.py": 25.61, + "tests/integration/test_template_text_save.py": 19.16, + "tests/integration/test_text_command.py": 16.43, + "tests/integration/test_text_sensor_raw_state.py": 17.19, + "tests/integration/test_uart_mock_ld2410.py": 37.0, + "tests/integration/test_uart_mock_ld2412.py": 40.82, + "tests/integration/test_uart_mock_ld2420.py": 32.7, + "tests/integration/test_uart_mock_ld2450.py": 32.84, + "tests/integration/test_uart_mock_modbus.py": 548.87, + "tests/integration/test_udp.py": 16.67, + "tests/integration/test_use_address_runtime.py": 27.26, + "tests/integration/test_valve_control_action.py": 24.58, + "tests/integration/test_varint_five_byte_device_id.py": 22.5, + "tests/integration/test_wait_until_mid_loop_timing.py": 22.05, + "tests/integration/test_wait_until_on_boot.py": 10.37, + "tests/integration/test_wait_until_ordering.py": 18.23, + "tests/integration/test_wait_until_reentrant_restart.py": 19.35, + "tests/integration/test_wake_loop_forces_phase_b.py": 17.83, + "tests/integration/test_water_heater_template.py": 25.7 } diff --git a/tests/integration/test_lvgl_headless_render.py b/tests/integration/test_lvgl_headless_render.py new file mode 100644 index 0000000000..1c60e49604 --- /dev/null +++ b/tests/integration/test_lvgl_headless_render.py @@ -0,0 +1,83 @@ +"""Integration test that checks what LVGL actually draws, using a display with no screen. + +The rendered screen is compared against a hash rather than a checked in reference image, so the +repository does not have to carry a binary file. If a change to the drawing code or to the bundled +LVGL alters the output, this test fails and prints the hash it saw; update EXPECTED_SHA256 once the +new image has been looked at and found to be correct. + +The picture is drawn and encoded entirely by code in this repository, so nothing installed on the +machine running the test takes part in the result. +""" + +from __future__ import annotations + +import hashlib +from pathlib import Path + +import pytest + +from .artifact_utils import keep_artifact +from .bmp_utils import capture_when_drawn +from .types import APIClientConnectedFactory, RunCompiledFunction + +WIDTH = 300 +HEIGHT = 300 + +# sha256 of the pixel data of a 300x300 screen showing "Hello World!" centred in white on a dark +# blue background, drawn with the built in montserrat_14 font. To regenerate, run this test and +# take the hash it reports. +EXPECTED_SHA256 = "a995b002dd1d183c47514da15ab9a60a3e7d788c2e24386a02fddd48655092ed" +# Bundled LVGL version (esphome/components/lvgl/__init__.py, LVGL_VERSION) the hash above was +# generated against. A version bump can shift anti-aliasing enough to change the hash even though +# nothing is actually wrong -- if this test fails, check that first before regenerating the hash. +EXPECTED_LVGL_VERSION = "9.5.0" + + +@pytest.mark.asyncio +async def test_lvgl_headless_render( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """LVGL draws the expected screen on a 300x300 display with no screen behind it.""" + snapshot_dir = tmp_path / "snapshots" + monkeypatch.setenv("ESPHOME_SNAPSHOT_DIR", str(snapshot_dir)) + + async with run_compiled(yaml_config), api_client_connected() as client: + _, services = await client.list_entities_services() + service = next(s for s in services if s.name == "take_screenshot") + + async def take(name: str) -> None: + await client.execute_service(service, {"name": name}) + + # The background is not the whole picture: LVGL must have drawn on it. Waiting for that + # rather than for a fixed time keeps a slow first frame from being reported as a hash + # mismatch, which would look like a drawing regression. + image, capture = await capture_when_drawn(take, snapshot_dir, prefix="render") + assert (image.width, image.height, image.bits) == (WIDTH, HEIGHT, 24) + + digest = hashlib.sha256(image.pixels).hexdigest() + if digest != EXPECTED_SHA256: + # Kept outside the temporary folder so CI can upload it; see artifact_utils. + kept = keep_artifact( + "lvgl_headless_render_actual.bmp", capture.read_bytes() + ) + + from esphome.components.lvgl import LVGL_VERSION + + version_hint = "" + if LVGL_VERSION != EXPECTED_LVGL_VERSION: + version_hint = ( + f"the bundled LVGL version changed ({EXPECTED_LVGL_VERSION} -> " + f"{LVGL_VERSION}), which is the likely cause\n" + ) + pytest.fail( + f"rendered screen does not match the expected hash\n" + f"{version_hint}" + f" expected: {EXPECTED_SHA256}\n" + f" actual: {digest}\n" + f"the image that was rendered has been kept at {kept}\n" + f"on CI it is in the integration-test-artifacts upload for this job" + ) diff --git a/tests/integration/test_sdl_headless_screenshot.py b/tests/integration/test_sdl_headless_screenshot.py new file mode 100644 index 0000000000..f24b21c157 --- /dev/null +++ b/tests/integration/test_sdl_headless_screenshot.py @@ -0,0 +1,49 @@ +"""Integration test for headless SDL rendering and snapshot capture. + +How a file is named and written is the same for every display that can take a snapshot and is +covered by test_snapshot_display; what is tested here is that SDL renders and can be read back +with no display server present. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from .bmp_utils import capture_when_drawn +from .types import APIClientConnectedFactory, RunCompiledFunction + +WIDTH = 101 +HEIGHT = 64 + + +@pytest.mark.asyncio +async def test_sdl_headless_screenshot( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A headless SDL display renders with no display server and can be captured.""" + snapshot_dir = tmp_path / "snapshots" + # The device reads this when it writes a file; the subprocess inherits our environment, so it + # must be set before the binary is launched. + monkeypatch.setenv("ESPHOME_SNAPSHOT_DIR", str(snapshot_dir)) + # Make sure the run really is headless even when the test machine has a display. + monkeypatch.delenv("DISPLAY", raising=False) + monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) + + async with run_compiled(yaml_config), api_client_connected() as client: + _, services = await client.list_entities_services() + service = next(s for s in services if s.name == "take_screenshot") + + async def take(name: str) -> None: + await client.execute_service(service, {"name": name}) + + # The test card is drawn in several colours, so once it is on the screen the picture is + # not one flat shade. Capturing until that is true waits out the first update rather than + # racing it. + image, _ = await capture_when_drawn(take, snapshot_dir) + assert (image.width, image.height, image.bits) == (WIDTH, HEIGHT, 24) diff --git a/tests/integration/test_snapshot_display.py b/tests/integration/test_snapshot_display.py new file mode 100644 index 0000000000..771cf0cf7d --- /dev/null +++ b/tests/integration/test_snapshot_display.py @@ -0,0 +1,78 @@ +"""Integration test for the snapshot display and the file writing shared with other displays.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from aioesphomeapi import LogLevel +import pytest + +from .bmp_utils import capture_when_drawn, wait_for_bmp +from .types import APIClientConnectedFactory, RunCompiledFunction + +WIDTH = 101 +HEIGHT = 64 + +# Part of the message the writer logs when it will not write over a file that is already there. +REFUSAL_MESSAGE = b"not overwriting" + + +@pytest.mark.asyncio +async def test_snapshot_display( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A display with no screen draws into memory and writes what it drew to a file.""" + snapshot_dir = tmp_path / "snapshots" + # The device reads this when it writes a file; the subprocess inherits our environment, so it + # must be set before the binary is launched. + monkeypatch.setenv("ESPHOME_SNAPSHOT_DIR", str(snapshot_dir)) + + async with run_compiled(yaml_config), api_client_connected() as client: + _, services = await client.list_entities_services() + service = next(s for s in services if s.name == "take_snapshot") + + async def take(name: str) -> None: + await client.execute_service(service, {"name": name}) + + # The test card is drawn in several colours, so once it is on the screen the picture is + # not one flat shade. Capturing until that is true waits out the first update rather than + # racing it. + image, capture = await capture_when_drawn(take, snapshot_dir) + assert (image.width, image.height, image.bits) == (WIDTH, HEIGHT, 24) + + # An extension is only added when there is not one already, whatever its case. + await take("UPPER.BMP") + await wait_for_bmp(snapshot_dir / "UPPER.BMP") + + # A name that tries to lead somewhere else is cut back to one harmless name in the + # snapshot directory. + await take("../escape") + await wait_for_bmp(snapshot_dir / ".._escape.bmp") + + # A second capture under a name already used must fail rather than write over the first. + # Wait for the device to report the refusal: on its own, an unchanged file cannot tell a + # refusal apart from a request the device has not got to yet, so a regression that wrote + # over the file could still pass on a busy machine. + refused = asyncio.Event() + + def on_log(msg) -> None: + if REFUSAL_MESSAGE in msg.message: + refused.set() + + client.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_DEBUG) + + before = capture.read_bytes() + await take(capture.name) + await asyncio.wait_for(refused.wait(), timeout=10.0) + assert capture.read_bytes() == before + # Nothing beyond what was asked for, leaving out however many captures it took to wait + # for the first frame. + written = sorted( + p.name for p in snapshot_dir.iterdir() if not p.name.startswith("drawn-") + ) + assert written == [".._escape.bmp", "UPPER.BMP"] diff --git a/tests/test_build_components/common/uart_1200_even_7bits/esp32-ard.yaml b/tests/test_build_components/common/uart_1200_even_7bits/esp32-ard.yaml new file mode 100644 index 0000000000..931905032d --- /dev/null +++ b/tests/test_build_components/common/uart_1200_even_7bits/esp32-ard.yaml @@ -0,0 +1,14 @@ +# Common UART configuration for ESP32 Arduino tests - 1200 baud, EVEN parity, 7 data bits + +substitutions: + tx_pin: GPIO17 + rx_pin: GPIO16 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 1200 + parity: EVEN + data_bits: 7 + stop_bits: 1 diff --git a/tests/test_build_components/common/uart_1200_even_7bits/esp32-c3-ard.yaml b/tests/test_build_components/common/uart_1200_even_7bits/esp32-c3-ard.yaml new file mode 100644 index 0000000000..a67b0b6ace --- /dev/null +++ b/tests/test_build_components/common/uart_1200_even_7bits/esp32-c3-ard.yaml @@ -0,0 +1,14 @@ +# Common UART configuration for ESP32-C3 Arduino tests - 1200 baud, EVEN parity, 7 data bits + +substitutions: + tx_pin: GPIO20 + rx_pin: GPIO21 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 1200 + parity: EVEN + data_bits: 7 + stop_bits: 1 diff --git a/tests/test_build_components/common/uart_1200_even_7bits/esp32-c3-idf.yaml b/tests/test_build_components/common/uart_1200_even_7bits/esp32-c3-idf.yaml new file mode 100644 index 0000000000..135aaa68c9 --- /dev/null +++ b/tests/test_build_components/common/uart_1200_even_7bits/esp32-c3-idf.yaml @@ -0,0 +1,14 @@ +# Common UART configuration for ESP32-C3 IDF tests - 1200 baud, EVEN parity, 7 data bits + +substitutions: + tx_pin: GPIO20 + rx_pin: GPIO21 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 1200 + parity: EVEN + data_bits: 7 + stop_bits: 1 diff --git a/tests/test_build_components/common/uart_1200_even_7bits/esp32-idf.yaml b/tests/test_build_components/common/uart_1200_even_7bits/esp32-idf.yaml new file mode 100644 index 0000000000..4cbe16dfd5 --- /dev/null +++ b/tests/test_build_components/common/uart_1200_even_7bits/esp32-idf.yaml @@ -0,0 +1,14 @@ +# Common UART configuration for ESP32 IDF tests - 1200 baud, EVEN parity, 7 data bits + +substitutions: + tx_pin: GPIO17 + rx_pin: GPIO16 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 1200 + parity: EVEN + data_bits: 7 + stop_bits: 1 diff --git a/tests/test_build_components/common/uart_1200_even_7bits/esp8266-ard.yaml b/tests/test_build_components/common/uart_1200_even_7bits/esp8266-ard.yaml new file mode 100644 index 0000000000..2eedcad6d3 --- /dev/null +++ b/tests/test_build_components/common/uart_1200_even_7bits/esp8266-ard.yaml @@ -0,0 +1,14 @@ +# Common UART configuration for ESP8266 Arduino tests - 1200 baud, EVEN parity, 7 data bits + +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 1200 + parity: EVEN + data_bits: 7 + stop_bits: 1 diff --git a/tests/test_build_components/common/uart_1200_even_7bits/rp2040-ard.yaml b/tests/test_build_components/common/uart_1200_even_7bits/rp2040-ard.yaml new file mode 100644 index 0000000000..d3edc1c1c9 --- /dev/null +++ b/tests/test_build_components/common/uart_1200_even_7bits/rp2040-ard.yaml @@ -0,0 +1,14 @@ +# Common UART configuration for RP2040 Arduino tests - 1200 baud, EVEN parity, 7 data bits + +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO1 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 1200 + parity: EVEN + data_bits: 7 + stop_bits: 1 diff --git a/tests/test_build_components/common/uart_38400_even/esp32-ard.yaml b/tests/test_build_components/common/uart_38400_even/esp32-ard.yaml new file mode 100644 index 0000000000..4235c9c027 --- /dev/null +++ b/tests/test_build_components/common/uart_38400_even/esp32-ard.yaml @@ -0,0 +1,12 @@ +# Common UART configuration for ESP32 Arduino tests - 38400 baud, EVEN parity + +substitutions: + tx_pin: GPIO17 + rx_pin: GPIO16 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 38400 + parity: EVEN diff --git a/tests/test_build_components/common/uart_38400_even/esp32-c3-ard.yaml b/tests/test_build_components/common/uart_38400_even/esp32-c3-ard.yaml new file mode 100644 index 0000000000..c20b7939e9 --- /dev/null +++ b/tests/test_build_components/common/uart_38400_even/esp32-c3-ard.yaml @@ -0,0 +1,12 @@ +# Common UART configuration for ESP32-C3 Arduino tests - 38400 baud, EVEN parity + +substitutions: + tx_pin: GPIO20 + rx_pin: GPIO21 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 38400 + parity: EVEN diff --git a/tests/test_build_components/common/uart_38400_even/esp32-c3-idf.yaml b/tests/test_build_components/common/uart_38400_even/esp32-c3-idf.yaml new file mode 100644 index 0000000000..0aeb13a7c3 --- /dev/null +++ b/tests/test_build_components/common/uart_38400_even/esp32-c3-idf.yaml @@ -0,0 +1,12 @@ +# Common UART configuration for ESP32-C3 IDF tests - 38400 baud, EVEN parity + +substitutions: + tx_pin: GPIO20 + rx_pin: GPIO21 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 38400 + parity: EVEN diff --git a/tests/test_build_components/common/uart_38400_even/esp32-idf.yaml b/tests/test_build_components/common/uart_38400_even/esp32-idf.yaml new file mode 100644 index 0000000000..b79b91448e --- /dev/null +++ b/tests/test_build_components/common/uart_38400_even/esp32-idf.yaml @@ -0,0 +1,12 @@ +# Common UART configuration for ESP32 IDF tests - 38400 baud, EVEN parity + +substitutions: + tx_pin: GPIO17 + rx_pin: GPIO16 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 38400 + parity: EVEN diff --git a/tests/test_build_components/common/uart_38400_even/esp8266-ard.yaml b/tests/test_build_components/common/uart_38400_even/esp8266-ard.yaml new file mode 100644 index 0000000000..373680e8e6 --- /dev/null +++ b/tests/test_build_components/common/uart_38400_even/esp8266-ard.yaml @@ -0,0 +1,12 @@ +# Common UART configuration for ESP8266 Arduino tests - 38400 baud, EVEN parity + +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 38400 + parity: EVEN diff --git a/tests/test_build_components/common/uart_38400_even/rp2040-ard.yaml b/tests/test_build_components/common/uart_38400_even/rp2040-ard.yaml new file mode 100644 index 0000000000..950f7b4957 --- /dev/null +++ b/tests/test_build_components/common/uart_38400_even/rp2040-ard.yaml @@ -0,0 +1,12 @@ +# Common UART configuration for RP2040 Arduino tests - 38400 baud, EVEN parity + +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO1 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 38400 + parity: EVEN diff --git a/tests/unit_tests/components/esp8266/test_boards.py b/tests/unit_tests/components/esp8266/test_boards.py index df0e536d42..78213a762a 100644 --- a/tests/unit_tests/components/esp8266/test_boards.py +++ b/tests/unit_tests/components/esp8266/test_boards.py @@ -1,11 +1,7 @@ """Tests for the per-board linker-script rule.""" -import pytest - from esphome.components.esp8266 import _choose_ld_script from esphome.components.esp8266.boards import BOARDS, board_ld_script -import esphome.config_validation as cv -from esphome.core import EsphomeError def test_d1_wroom_02_keeps_its_shipped_layout() -> None: @@ -21,13 +17,6 @@ def test_default_boards_use_the_flash_size_layout() -> None: def test_choose_ld_script_paths() -> None: - """Old cores get the size default, overriding boards hard-error there - (a substituted layout would wipe flash-backed state), modern cores - honor the override.""" - assert _choose_ld_script("nodemcuv2", cv.Version(2, 3, 0)) is None - assert _choose_ld_script("nodemcuv2", cv.Version(2, 4, 2)) == "eagle.flash.4m.ld" - assert _choose_ld_script("d1_wroom_02", cv.Version(2, 7, 4)) == ( - "eagle.flash.2m64.ld" - ) - with pytest.raises(EsphomeError, match="cannot honor"): - _choose_ld_script("d1_wroom_02", cv.Version(2, 4, 2)) + """Default boards get the size layout, overriding boards keep theirs.""" + assert _choose_ld_script("nodemcuv2") == "eagle.flash.4m.ld" + assert _choose_ld_script("d1_wroom_02") == "eagle.flash.2m64.ld" diff --git a/tests/unit_tests/components/esp8266/test_framework_version.py b/tests/unit_tests/components/esp8266/test_framework_version.py new file mode 100644 index 0000000000..0107aff8dd --- /dev/null +++ b/tests/unit_tests/components/esp8266/test_framework_version.py @@ -0,0 +1,23 @@ +"""Tests for the Arduino framework version floor.""" + +import pytest + +from esphome.components.esp8266 import _arduino_check_versions +import esphome.config_validation as cv +from esphome.const import CONF_PLATFORM_VERSION, CONF_VERSION + + +def test_versions_before_3_are_rejected() -> None: + with pytest.raises(cv.Invalid, match="no longer supported") as excinfo: + _arduino_check_versions({CONF_VERSION: "2.7.4"}) + assert excinfo.value.path == [CONF_VERSION] + + +def test_supported_versions_pass() -> None: + value = _arduino_check_versions({CONF_VERSION: "3.0.2"}) + assert value[CONF_VERSION] == "3.0.2" + assert "espressif8266@3.2.0" in value[CONF_PLATFORM_VERSION] + + value = _arduino_check_versions({CONF_VERSION: "recommended"}) + assert value[CONF_VERSION] == "3.1.2" + assert "espressif8266@4.2.1" in value[CONF_PLATFORM_VERSION] diff --git a/tests/unit_tests/components/esp8266/test_toolchain_validation.py b/tests/unit_tests/components/esp8266/test_toolchain_validation.py index f2f7c4632a..aecc8a1302 100644 --- a/tests/unit_tests/components/esp8266/test_toolchain_validation.py +++ b/tests/unit_tests/components/esp8266/test_toolchain_validation.py @@ -63,8 +63,10 @@ def test_valid_config_passes() -> None: def test_platformio_toolchain_skips_checks() -> None: + # 3.0.2 is pio-legal (>= the global 3.0.0 floor) but below the native + # toolchain's own 3.1.1 floor; the bogus board only the native path checks CORE.toolchain = Toolchain.PLATFORMIO - config = _config(board="not_a_board", version="2.7.4") + config = _config(board="not_a_board", version="3.0.2") assert _validate_native_toolchain(config) is config diff --git a/tests/unit_tests/test_arduino8266_framework.py b/tests/unit_tests/test_arduino8266_framework.py index f983fbc1f1..5e03de7ae1 100644 --- a/tests/unit_tests/test_arduino8266_framework.py +++ b/tests/unit_tests/test_arduino8266_framework.py @@ -21,17 +21,12 @@ def _build_path(tmp_path: Path) -> None: def test_framework_package_version() -> None: assert framework.framework_package_version(cv.Version(3, 1, 2)) == "3.30102.0" assert framework.framework_package_version(cv.Version(3, 2, 0)) == "3.30200.0" - # 2.6.3+ cores use the same package-major-3 encoding (PlatformIO path) - assert framework.framework_package_version(cv.Version(2, 7, 4)) == "3.20704.0" # A future major bump needs its own encoding, not a doomed registry lookup with pytest.raises(EsphomeError, match="not supported yet"): framework.framework_package_version(cv.Version(4, 0, 0)) - # The boundary matches the PlatformIO era guard; a 2.6.2 pre-release - # keeps this encoding - with pytest.raises(EsphomeError, match="older package encoding"): - framework.framework_package_version(cv.Version(2, 6, 2)) - assert framework.framework_package_version(cv.Version(2, 6, 2, "b1")) == "3.20602.0" - assert framework.framework_package_version(cv.Version(2, 6, 3)) == "3.20603.0" + # Cores before 3.x cannot build ESPHome (C++20) and are rejected + with pytest.raises(EsphomeError, match="requires core 3"): + framework.framework_package_version(cv.Version(2, 7, 4)) def test_format_framework_arduino_version_pins_all_series() -> None: @@ -39,10 +34,10 @@ def test_format_framework_arduino_version_pins_all_series() -> None: era, including the 4.x rejection it now shares with the installer.""" from esphome.components.esp8266 import _format_framework_arduino_version as fmt - assert fmt(cv.Version(2, 4, 1)) == "~1.20401.0" - assert fmt(cv.Version(2, 6, 2)) == "~2.20602.0" - assert fmt(cv.Version(2, 7, 4)) == "~3.20704.0" assert fmt(cv.Version(3, 1, 2)) == "~3.30102.0" + # Pre-3 cores are rejected with the version line anchored + with pytest.raises(cv.Invalid, match="requires core 3"): + fmt(cv.Version(2, 7, 4)) # Anchored to the framework version line, not a bare EsphomeError with pytest.raises(cv.Invalid, match="not supported yet") as excinfo: fmt(cv.Version(4, 0, 0))