mirror of
https://github.com/esphome/esphome.git
synced 2026-10-06 10:56:43 +00:00
Merge branch 'dev' into sendspin-artwork
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
"""Shared fixtures for the Python benchmark suite."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.core import CORE
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_core_state() -> Generator[None]:
|
||||
"""Reset CORE before and after every benchmark.
|
||||
|
||||
Per-iteration setups inside benchmarks reset CORE for the loop body;
|
||||
this fixture handles the test-level boundary so stale state from
|
||||
fixture priming doesn't leak across benchmarks.
|
||||
"""
|
||||
CORE.reset()
|
||||
yield
|
||||
CORE.reset()
|
||||
@@ -0,0 +1,62 @@
|
||||
substitutions:
|
||||
devicename: bluetooth_proxy_device
|
||||
friendly_name: bluetooth_proxy_device
|
||||
|
||||
esphome:
|
||||
name: $devicename
|
||||
friendly_name: $friendly_name
|
||||
|
||||
esp32:
|
||||
board: esp32-poe-iso
|
||||
framework:
|
||||
type: esp-idf
|
||||
advanced:
|
||||
sram1_as_iram: true
|
||||
minimum_chip_revision: "3.0"
|
||||
|
||||
esp32_ble_tracker:
|
||||
scan_parameters:
|
||||
active: false
|
||||
|
||||
bluetooth_proxy:
|
||||
active: true
|
||||
|
||||
ethernet:
|
||||
type: LAN8720
|
||||
mdc_pin: GPIO23
|
||||
mdio_pin: GPIO18
|
||||
clk_mode: GPIO17_OUT
|
||||
phy_addr: 0
|
||||
power_pin: GPIO12
|
||||
|
||||
debug:
|
||||
logger:
|
||||
api:
|
||||
ota:
|
||||
platform: esphome
|
||||
|
||||
button:
|
||||
- platform: restart
|
||||
name: Restart
|
||||
|
||||
time:
|
||||
- platform: homeassistant
|
||||
id: homeassistant_time
|
||||
- platform: sntp
|
||||
id: sntp_time
|
||||
|
||||
sensor:
|
||||
- platform: uptime
|
||||
name: Ethernet Uptime
|
||||
- platform: template
|
||||
name: Free Memory
|
||||
lambda: return heap_caps_get_free_size(MALLOC_CAP_INTERNAL);
|
||||
unit_of_measurement: B
|
||||
state_class: measurement
|
||||
- platform: debug
|
||||
free:
|
||||
name: Heap Free
|
||||
fragmentation:
|
||||
name: Heap Fragmentation
|
||||
min_free:
|
||||
name: Heap Min Free
|
||||
@@ -0,0 +1,116 @@
|
||||
"""CodSpeed benchmarks for the validated-config cache fast path.
|
||||
|
||||
PR #16381 added a cache that lets ``esphome upload`` / ``esphome logs``
|
||||
skip re-running the full config-validation pipeline. These benchmarks
|
||||
compare the cached path (``load_compiled_config``) against the slow
|
||||
path (``read_config``) on the same input.
|
||||
|
||||
The fixture YAML is a modest bluetooth-proxy device. The two paths
|
||||
end up close on a config this small -- the win grows with config
|
||||
complexity (external components, large package trees, deeply nested
|
||||
schemas), where the slow path can be orders of magnitude slower than
|
||||
the cache load.
|
||||
|
||||
Skipped when ``pytest-codspeed`` isn't installed so the regular
|
||||
unit-test suite keeps working unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.compiled_config import compiled_config_path, load_compiled_config
|
||||
from esphome.config import read_config
|
||||
from esphome.core import CORE
|
||||
from esphome.storage_json import ext_storage_path
|
||||
from esphome.writer import update_storage_json
|
||||
|
||||
pytest.importorskip("pytest_codspeed")
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
FIXTURE_YAML = HERE / "fixtures" / "bluetooth_proxy_device.yaml"
|
||||
|
||||
|
||||
def _stage_yaml(tmp_path: Path) -> Path:
|
||||
"""Copy fixture YAML into a fresh tmp dir.
|
||||
|
||||
Each benchmark gets its own copy so the cache files (under
|
||||
``.esphome/storage/`` next to the YAML) don't bleed between cases.
|
||||
"""
|
||||
target = tmp_path / FIXTURE_YAML.name
|
||||
shutil.copy2(FIXTURE_YAML, target)
|
||||
return target
|
||||
|
||||
|
||||
def _prime_cache(yaml_path: Path) -> None:
|
||||
"""Run full validation once and persist the cache + sidecar.
|
||||
|
||||
Mirrors ``esphome compile``: ``read_config`` populates ``CORE.config``,
|
||||
then ``update_storage_json`` writes both the StorageJSON sidecar and
|
||||
the ``.validated.yaml`` compiled-config cache.
|
||||
"""
|
||||
CORE.config_path = yaml_path
|
||||
config = read_config({}, skip_external_update=True)
|
||||
assert config is not None, f"fixture YAML failed to validate: {yaml_path}"
|
||||
CORE.config = config
|
||||
update_storage_json()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def staged_yaml(tmp_path: Path) -> Path:
|
||||
"""YAML copied into tmp_path; no cache files written yet."""
|
||||
return _stage_yaml(tmp_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def primed_yaml(staged_yaml: Path) -> Path:
|
||||
"""YAML plus a fresh cache + sidecar on disk."""
|
||||
_prime_cache(staged_yaml)
|
||||
assert compiled_config_path(staged_yaml.name).is_file()
|
||||
assert ext_storage_path(staged_yaml.name).is_file()
|
||||
return staged_yaml
|
||||
|
||||
|
||||
def _resetting_setup(
|
||||
yaml_path: Path,
|
||||
args: tuple[Any, ...],
|
||||
kwargs: dict[str, Any],
|
||||
) -> Callable[[], tuple[tuple[Any, ...], dict[str, Any]]]:
|
||||
"""Build a per-iteration setup that resets CORE and re-pins config_path."""
|
||||
|
||||
def setup() -> tuple[tuple[Any, ...], dict[str, Any]]:
|
||||
CORE.reset()
|
||||
CORE.config_path = yaml_path
|
||||
return args, kwargs
|
||||
|
||||
return setup
|
||||
|
||||
|
||||
def test_load_compiled_config_cached(primed_yaml: Path, benchmark) -> None:
|
||||
"""Fast path: deserialize the cached, already-validated config."""
|
||||
benchmark.pedantic(
|
||||
load_compiled_config,
|
||||
setup=_resetting_setup(primed_yaml, (primed_yaml,), {}),
|
||||
rounds=5,
|
||||
iterations=1,
|
||||
)
|
||||
|
||||
|
||||
def test_read_config_uncached(primed_yaml: Path, benchmark) -> None:
|
||||
"""Slow path: full validation pipeline (yaml load + schema + components).
|
||||
|
||||
Uses the same primed fixture as the cached path -- ``read_config``
|
||||
ignores the cache file on disk, so the two benchmarks measure the
|
||||
same input from two different code paths.
|
||||
"""
|
||||
benchmark.pedantic(
|
||||
read_config,
|
||||
setup=_resetting_setup(primed_yaml, ({},), {"skip_external_update": True}),
|
||||
rounds=3,
|
||||
iterations=1,
|
||||
)
|
||||
@@ -16,8 +16,8 @@ from esphome.const import (
|
||||
CONF_ESPHOME,
|
||||
CONF_IGNORE_PIN_VALIDATION_ERROR,
|
||||
CONF_NUMBER,
|
||||
KEY_NATIVE_IDF,
|
||||
PlatformFramework,
|
||||
Toolchain,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
@@ -266,7 +266,7 @@ def test_native_idf_enables_reproducible_build(
|
||||
|
||||
CORE.config_path = component_config_path("reproducible_build.yaml")
|
||||
CORE.config = read_config({})
|
||||
CORE.data[KEY_NATIVE_IDF] = True
|
||||
CORE.toolchain = Toolchain.ESP_IDF
|
||||
generate_cpp_contents(CORE.config)
|
||||
|
||||
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
spi:
|
||||
- id: spi_bus
|
||||
clk_pin: GPIO18
|
||||
mosi_pin: GPIO23
|
||||
|
||||
display:
|
||||
- platform: mipi_spi
|
||||
spi_id: spi_bus
|
||||
model: st7789v
|
||||
id: tft_display
|
||||
dimensions:
|
||||
width: 240
|
||||
height: 320
|
||||
cs_pin: GPIO22
|
||||
dc_pin: GPIO21
|
||||
auto_clear_enabled: false
|
||||
invert_colors: false
|
||||
update_interval: never
|
||||
|
||||
lvgl:
|
||||
id: lvgl_id
|
||||
displays: tft_display
|
||||
pages:
|
||||
- id: main_page
|
||||
widgets:
|
||||
# Widget with multiple static states; one true, one false.
|
||||
- button:
|
||||
id: btn_static
|
||||
state:
|
||||
checked: true
|
||||
disabled: false
|
||||
|
||||
# Widget with a templated (lambda) state.
|
||||
- button:
|
||||
id: btn_lambda
|
||||
state:
|
||||
pressed: !lambda return true;
|
||||
|
||||
# Button referenced by enable/disable actions; the on_click handler
|
||||
# exercises both branches of the obj_disable/obj_enable code path.
|
||||
- button:
|
||||
id: btn_actions
|
||||
on_click:
|
||||
- lvgl.widget.disable: btn_actions
|
||||
- lvgl.widget.enable: btn_actions
|
||||
|
||||
# Button matrix with two buttons; matrix_btn_a is targeted by
|
||||
# lvgl.widget.disable/enable actions to exercise the
|
||||
# MatrixButton.set_state code path.
|
||||
- buttonmatrix:
|
||||
id: matrix
|
||||
rows:
|
||||
- buttons:
|
||||
- id: matrix_btn_a
|
||||
text: A
|
||||
control:
|
||||
checkable: true
|
||||
- id: matrix_btn_b
|
||||
text: B
|
||||
control:
|
||||
checkable: true
|
||||
on_click:
|
||||
- lvgl.widget.disable: matrix_btn_a
|
||||
- lvgl.widget.enable: matrix_btn_a
|
||||
|
||||
# Switch derived from an LVGL switch widget – exercises
|
||||
# set_state(LV_STATE.CHECKED, v) inside the control lambda.
|
||||
- switch:
|
||||
id: switch_widget
|
||||
|
||||
switch:
|
||||
- platform: lvgl
|
||||
id: lvgl_switch
|
||||
name: LVGL Switch
|
||||
widget: switch_widget
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Tests for LVGL widget state code generation.
|
||||
|
||||
These tests cover the change from the old ``add_state``/``clear_state`` helpers
|
||||
on :class:`Widget` (and on :class:`MatrixButton`) to a single ``set_state``
|
||||
method that delegates to the new C++ helpers
|
||||
``LvglComponent::lv_obj_set_state_value`` and
|
||||
``LvglComponent::lv_buttonmatrix_set_button_ctrl_value``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.__main__ import generate_cpp_contents
|
||||
from esphome.config import read_config
|
||||
from esphome.core import CORE
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def main_cpp(request: pytest.FixtureRequest) -> str:
|
||||
"""Generate the C++ output for the shared widget-state YAML config once
|
||||
per module.
|
||||
|
||||
Module-scoped so the (relatively expensive) codegen runs a single time;
|
||||
the function-scoped fixtures from ``conftest.py`` (e.g. ``generate_main``)
|
||||
can't be requested from a higher-scoped fixture, so the small amount of
|
||||
setup is inlined here. The captured string is independent of
|
||||
``CORE.reset()`` calls that the per-test autouse fixtures perform after
|
||||
this fixture has produced its value.
|
||||
"""
|
||||
config_path = Path(request.fspath).parent / "config" / "widget_state_test.yaml"
|
||||
original_path = CORE.config_path
|
||||
try:
|
||||
CORE.config_path = config_path
|
||||
CORE.config = read_config({})
|
||||
generate_cpp_contents(CORE.config)
|
||||
return CORE.cpp_global_section + CORE.cpp_main_section
|
||||
finally:
|
||||
CORE.config_path = original_path
|
||||
CORE.reset()
|
||||
|
||||
|
||||
def test_static_state_emits_set_state_value(main_cpp: str) -> None:
|
||||
"""A widget with ``state: { checked: true, disabled: false }`` should
|
||||
generate one ``lv_obj_set_state_value`` call per entry, with the
|
||||
appropriate boolean argument.
|
||||
"""
|
||||
assert (
|
||||
"LvglComponent::lv_obj_set_state_value(btn_static, LV_STATE_CHECKED, true)"
|
||||
in main_cpp
|
||||
)
|
||||
assert (
|
||||
"LvglComponent::lv_obj_set_state_value(btn_static, LV_STATE_DISABLED, false)"
|
||||
in main_cpp
|
||||
)
|
||||
|
||||
|
||||
def test_lambda_state_emits_set_state_value_with_lambda(main_cpp: str) -> None:
|
||||
"""A widget with ``state: { pressed: !lambda return true; }`` should
|
||||
generate ``lv_obj_set_state_value(..., LV_STATE_PRESSED, <expr>)`` where
|
||||
``<expr>`` is the lambda's return value (cast or inlined), not a static
|
||||
bool.
|
||||
"""
|
||||
# The set_state call is emitted for the templated state.
|
||||
assert (
|
||||
"LvglComponent::lv_obj_set_state_value(btn_lambda, LV_STATE_PRESSED,"
|
||||
in main_cpp
|
||||
)
|
||||
# And it must NOT have collapsed the lambda to a literal true/false.
|
||||
assert (
|
||||
"LvglComponent::lv_obj_set_state_value(btn_lambda, LV_STATE_PRESSED, true)"
|
||||
not in main_cpp
|
||||
)
|
||||
# The legacy if/else over add_state/remove_state is gone.
|
||||
assert "lv_obj_add_state(btn_lambda, LV_STATE_PRESSED)" not in main_cpp
|
||||
assert "lv_obj_remove_state(btn_lambda, LV_STATE_PRESSED)" not in main_cpp
|
||||
|
||||
|
||||
def test_widget_disable_action_uses_set_state_value(main_cpp: str) -> None:
|
||||
"""``lvgl.widget.disable: btn_actions`` should emit a
|
||||
``set_state_value(..., LV_STATE_DISABLED, true)`` call rather than the
|
||||
legacy ``lv_obj_add_state``.
|
||||
"""
|
||||
assert (
|
||||
"LvglComponent::lv_obj_set_state_value(btn_actions, LV_STATE_DISABLED, true)"
|
||||
in main_cpp
|
||||
)
|
||||
# No leftover legacy add_state for the DISABLED state of this widget.
|
||||
assert "lv_obj_add_state(btn_actions, LV_STATE_DISABLED)" not in main_cpp
|
||||
|
||||
|
||||
def test_widget_enable_action_uses_set_state_value(main_cpp: str) -> None:
|
||||
"""``lvgl.widget.enable: btn_actions`` should emit a
|
||||
``set_state_value(..., LV_STATE_DISABLED, false)`` call rather than the
|
||||
legacy ``lv_obj_remove_state``.
|
||||
"""
|
||||
assert (
|
||||
"LvglComponent::lv_obj_set_state_value(btn_actions, LV_STATE_DISABLED, false)"
|
||||
in main_cpp
|
||||
)
|
||||
assert "lv_obj_remove_state(btn_actions, LV_STATE_DISABLED)" not in main_cpp
|
||||
|
||||
|
||||
def test_buttonmatrix_disable_action_uses_helper(main_cpp: str) -> None:
|
||||
"""``lvgl.widget.disable: matrix_btn_a`` should route through the new
|
||||
``lv_buttonmatrix_set_button_ctrl_value`` helper for button index 0
|
||||
with the ``DISABLED`` control bit set to ``true``, instead of the
|
||||
legacy ``lv_buttonmatrix_set_button_ctrl``.
|
||||
|
||||
The button matrix obj is the compound's ``obj`` member and the index
|
||||
is the position of the button in the row layout.
|
||||
"""
|
||||
assert (
|
||||
"LvglComponent::lv_buttonmatrix_set_button_ctrl_value(matrix->obj, 0, "
|
||||
"LV_BUTTONMATRIX_CTRL_DISABLED, true)"
|
||||
) in main_cpp
|
||||
|
||||
|
||||
def test_buttonmatrix_enable_action_uses_helper(main_cpp: str) -> None:
|
||||
"""``lvgl.widget.enable: matrix_btn_a`` should route through the new
|
||||
``lv_buttonmatrix_set_button_ctrl_value`` helper for button index 0
|
||||
with the ``DISABLED`` control bit set to ``false``, instead of the
|
||||
legacy ``lv_buttonmatrix_clear_button_ctrl``.
|
||||
"""
|
||||
assert (
|
||||
"LvglComponent::lv_buttonmatrix_set_button_ctrl_value(matrix->obj, 0, "
|
||||
"LV_BUTTONMATRIX_CTRL_DISABLED, false)"
|
||||
) in main_cpp
|
||||
# The legacy clear_button_ctrl path is gone for the matrix button enable
|
||||
# action.
|
||||
assert (
|
||||
"lv_buttonmatrix_clear_button_ctrl(matrix->obj, 0, LV_BUTTONMATRIX_CTRL_DISABLED)"
|
||||
not in main_cpp
|
||||
)
|
||||
|
||||
|
||||
def test_lvgl_switch_control_calls_set_state_value(main_cpp: str) -> None:
|
||||
"""The LVGL switch platform installs a control lambda that mirrors the
|
||||
switch's bool value into ``LV_STATE_CHECKED`` via
|
||||
``lv_obj_set_state_value`` (replacing the previous if/else over
|
||||
``add_state``/``clear_state`` plus an explicit ``send_event`` of
|
||||
``lv_api_event``).
|
||||
"""
|
||||
# The control lambda calls the new helper with the bool ``v`` parameter.
|
||||
assert (
|
||||
"LvglComponent::lv_obj_set_state_value(switch_widget, LV_STATE_CHECKED, v)"
|
||||
in main_cpp
|
||||
)
|
||||
# The deprecated lv_api_event symbol must no longer appear anywhere.
|
||||
assert "lv_api_event" not in main_cpp
|
||||
|
||||
|
||||
def test_default_state_does_not_emit_set_state_value(main_cpp: str) -> None:
|
||||
"""A widget without a ``state:`` block must not generate any
|
||||
``lv_obj_set_state_value`` calls for it. (Sanity-check that the
|
||||
new code path is opt-in driven by the YAML.)
|
||||
"""
|
||||
assert (
|
||||
"LvglComponent::lv_obj_set_state_value(switch_widget, LV_STATE_DISABLED"
|
||||
not in main_cpp
|
||||
)
|
||||
assert (
|
||||
"LvglComponent::lv_obj_set_state_value(btn_static, LV_STATE_PRESSED"
|
||||
not in main_cpp
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
cc1101:
|
||||
id: cc1101_radio
|
||||
cs_pin: ${cs_pin}
|
||||
frequency: 433.92MHz
|
||||
modulation_type: ASK/OOK
|
||||
output_power: 10
|
||||
|
||||
# Dual-pin wiring (recommended by the CC1101 docs):
|
||||
# CC1101 GDO0 → ${gdo0_pin} (remote_transmitter)
|
||||
# CC1101 GDO2 → ${gdo2_pin} (remote_receiver)
|
||||
remote_transmitter:
|
||||
id: rf_tx
|
||||
pin: ${gdo0_pin}
|
||||
carrier_duty_percent: 100%
|
||||
# Switch the chip into TX state for the duration of each transmission and back to RX
|
||||
# afterwards. Driver-agnostic: any RF front-end with begin_tx/begin_rx-style actions
|
||||
# can be wired this way.
|
||||
on_transmit:
|
||||
then:
|
||||
- cc1101.begin_tx: cc1101_radio
|
||||
on_complete:
|
||||
then:
|
||||
- cc1101.begin_rx: cc1101_radio
|
||||
|
||||
remote_receiver:
|
||||
id: rf_rx
|
||||
pin: ${gdo2_pin}
|
||||
|
||||
radio_frequency:
|
||||
- platform: ir_rf_proxy
|
||||
id: rf_proxy_cc1101_tx
|
||||
name: "CC1101 RF Transmitter"
|
||||
frequency: 433.92MHz
|
||||
remote_transmitter_id: rf_tx
|
||||
# Optional: retune the CC1101 per-transmit when the API request specifies a
|
||||
# different carrier frequency. Demonstrates the on_control trigger.
|
||||
on_control:
|
||||
then:
|
||||
- if:
|
||||
condition:
|
||||
lambda: "return x.get_frequency().has_value() && *x.get_frequency() > 0;"
|
||||
then:
|
||||
- cc1101.set_frequency:
|
||||
id: cc1101_radio
|
||||
value: !lambda "return *x.get_frequency();"
|
||||
- platform: ir_rf_proxy
|
||||
id: rf_proxy_cc1101_rx
|
||||
name: "CC1101 RF Receiver"
|
||||
frequency: 433.92MHz
|
||||
remote_receiver_id: rf_rx
|
||||
@@ -0,0 +1,9 @@
|
||||
substitutions:
|
||||
cs_pin: GPIO5
|
||||
gdo0_pin: GPIO4
|
||||
gdo2_pin: GPIO16
|
||||
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
|
||||
cc1101: !include common-cc1101.yaml
|
||||
@@ -0,0 +1,9 @@
|
||||
substitutions:
|
||||
cs_pin: GPIO5
|
||||
gdo0_pin: GPIO4
|
||||
gdo2_pin: GPIO16
|
||||
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml
|
||||
cc1101: !include common-cc1101.yaml
|
||||
@@ -38,7 +38,7 @@ number:
|
||||
- platform: lvgl
|
||||
widget: slider_id
|
||||
name: LVGL Slider Number
|
||||
update_on_release: true
|
||||
trigger: on_release
|
||||
restore_value: true
|
||||
- platform: lvgl
|
||||
widget: lv_arc
|
||||
|
||||
@@ -309,6 +309,11 @@ lvgl:
|
||||
- logger.log:
|
||||
format: "Roller changed = %d: %s"
|
||||
args: [x, text.c_str()]
|
||||
on_update:
|
||||
then:
|
||||
- logger.log:
|
||||
format: "Roller updated = %d: %s"
|
||||
args: [x, text.c_str()]
|
||||
- animimg:
|
||||
height: 60
|
||||
id: anim_img
|
||||
@@ -630,6 +635,10 @@ lvgl:
|
||||
logger.log:
|
||||
format: "state now %d"
|
||||
args: [x]
|
||||
on_update:
|
||||
logger.log:
|
||||
format: "button updated %d"
|
||||
args: [x]
|
||||
on_short_click:
|
||||
lvgl.widget.hide: hello_label
|
||||
on_long_press:
|
||||
@@ -758,6 +767,9 @@ lvgl:
|
||||
lambda: return tile == id(tile_1);
|
||||
then:
|
||||
- logger.log: "tile 1 is now showing"
|
||||
on_update:
|
||||
then:
|
||||
- logger.log: "tileview updated programmatically"
|
||||
tiles:
|
||||
- id: tile_1
|
||||
scroll_snap_y: center
|
||||
@@ -983,6 +995,11 @@ lvgl:
|
||||
- logger.log:
|
||||
format: "Arc value is %f"
|
||||
args: [x]
|
||||
on_update:
|
||||
then:
|
||||
- logger.log:
|
||||
format: "Arc updated to %f"
|
||||
args: [x]
|
||||
scroll_on_focus: true
|
||||
value: 75
|
||||
min_value: 1
|
||||
@@ -1085,6 +1102,11 @@ lvgl:
|
||||
- logger.log:
|
||||
format: "slider value %f"
|
||||
args: [x]
|
||||
on_update:
|
||||
then:
|
||||
- logger.log:
|
||||
format: "slider updated to %f"
|
||||
args: [x]
|
||||
on_click:
|
||||
then:
|
||||
- lvgl.slider.update:
|
||||
@@ -1197,6 +1219,10 @@ lvgl:
|
||||
logger.log:
|
||||
format: "Dropdown changed = %d: %s"
|
||||
args: [x, text.c_str()]
|
||||
on_update:
|
||||
logger.log:
|
||||
format: "Dropdown updated = %d: %s"
|
||||
args: [x, text.c_str()]
|
||||
on_cancel:
|
||||
logger.log:
|
||||
format: "Dropdown closed = %d"
|
||||
@@ -1437,6 +1463,30 @@ color:
|
||||
blue_int: 64
|
||||
white_int: 255
|
||||
|
||||
sensor:
|
||||
- platform: lvgl
|
||||
widget: lv_arc_1
|
||||
id: lvgl_arc1_sensor_on_change
|
||||
name: LVGL Arc1 Sensor on_change
|
||||
trigger: on_change
|
||||
- platform: lvgl
|
||||
widget: bar_id
|
||||
id: lvgl_bar_sensor_on_release
|
||||
name: LVGL Bar Sensor on_release
|
||||
trigger: on_release
|
||||
|
||||
number:
|
||||
- platform: lvgl
|
||||
widget: lv_arc_1
|
||||
id: lvgl_arc1_number_on_update
|
||||
name: LVGL Arc1 Number on_update
|
||||
trigger: on_update
|
||||
- platform: lvgl
|
||||
widget: spinbox_id
|
||||
id: lvgl_spinbox_number_on_change
|
||||
name: LVGL Spinbox Number on_change
|
||||
trigger: on_change
|
||||
|
||||
select:
|
||||
- platform: lvgl
|
||||
id: lv_roller_select
|
||||
|
||||
@@ -16,13 +16,13 @@ TEST(MitsubishiCN105Tests, InitSendsConnectPacket) {
|
||||
ctx.sut.set_current_time(123);
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::NOT_CONNECTED);
|
||||
EXPECT_TRUE(ctx.uart.tx.empty());
|
||||
EXPECT_FALSE(ctx.sut.write_timeout_start_ms_.has_value());
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 0);
|
||||
|
||||
ctx.sut.initialize();
|
||||
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING);
|
||||
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x5A, 0x01, 0x30, 0x02, 0xCA, 0x01, 0xA8));
|
||||
EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional<uint32_t>{123});
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 123);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) {
|
||||
@@ -32,8 +32,7 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) {
|
||||
ctx.uart.tx.clear(); // Remove first connect packet bytes
|
||||
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING);
|
||||
EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional<uint32_t>{0});
|
||||
EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value());
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 0);
|
||||
|
||||
// Connect response
|
||||
ctx.uart.push_rx({0xFC, 0x7A, 0x01, 0x30, 0x00, 0x55});
|
||||
@@ -47,21 +46,22 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) {
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS);
|
||||
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x42, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7B));
|
||||
EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional<uint32_t>{200});
|
||||
EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value());
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 200);
|
||||
|
||||
// Clear TX bytes.
|
||||
ctx.uart.tx.clear();
|
||||
|
||||
// Settings response
|
||||
ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x08, 0x07,
|
||||
0x00, 0x00, 0x00, 0x00, 0x03, 0xB0, 0x00, 0x00, 0x00, 0x00, 0x99});
|
||||
0x00, 0x04, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3C});
|
||||
|
||||
// Settings should still have initial values
|
||||
EXPECT_FALSE(ctx.sut.status().power_on);
|
||||
EXPECT_THAT(ctx.sut.status().target_temperature, ::testing::IsNan());
|
||||
EXPECT_EQ(ctx.sut.status().mode, MitsubishiCN105::Mode::UNKNOWN);
|
||||
EXPECT_EQ(ctx.sut.status().fan_mode, MitsubishiCN105::FanMode::UNKNOWN);
|
||||
EXPECT_EQ(ctx.sut.status().vane_mode, MitsubishiCN105::VaneMode::UNKNOWN);
|
||||
EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::UNKNOWN);
|
||||
|
||||
ctx.sut.set_current_time(300);
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
@@ -72,13 +72,14 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) {
|
||||
EXPECT_EQ(ctx.sut.status().target_temperature, 24.0f);
|
||||
EXPECT_EQ(ctx.sut.status().mode, MitsubishiCN105::Mode::AUTO);
|
||||
EXPECT_EQ(ctx.sut.status().fan_mode, MitsubishiCN105::FanMode::AUTO);
|
||||
EXPECT_EQ(ctx.sut.status().vane_mode, MitsubishiCN105::VaneMode::POSITION_4);
|
||||
EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::SWING);
|
||||
|
||||
// Now fetch room temperature (0x03)
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS);
|
||||
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x42, 0x01, 0x30, 0x10, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7A));
|
||||
EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional<uint32_t>{300});
|
||||
EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value());
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 300);
|
||||
|
||||
// Clear TX bytes.
|
||||
ctx.uart.tx.clear();
|
||||
@@ -101,8 +102,7 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) {
|
||||
|
||||
EXPECT_TRUE(ctx.uart.tx.empty());
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
|
||||
EXPECT_FALSE(ctx.sut.write_timeout_start_ms_.has_value());
|
||||
EXPECT_EQ(ctx.sut.status_update_start_ms_, std::optional<uint32_t>{400});
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 400);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, NoResponseTriggersReconnect) {
|
||||
@@ -115,21 +115,21 @@ TEST(MitsubishiCN105Tests, NoResponseTriggersReconnect) {
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING);
|
||||
EXPECT_TRUE(ctx.uart.tx.empty());
|
||||
EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional<uint32_t>{0});
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 0);
|
||||
|
||||
// Still no response after 1999ms, no retry yet
|
||||
ctx.sut.set_current_time(1999);
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING);
|
||||
EXPECT_TRUE(ctx.uart.tx.empty());
|
||||
EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional<uint32_t>{0});
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 0);
|
||||
|
||||
// Stop waiting after 2s and retry connect
|
||||
ctx.sut.set_current_time(2000);
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING);
|
||||
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x5A, 0x01, 0x30, 0x02, 0xCA, 0x01, 0xA8));
|
||||
EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional<uint32_t>{2000});
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 2000);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, RxWatchdogLimitsProcessingPerUpdate) {
|
||||
@@ -233,15 +233,12 @@ TEST(MitsubishiCN105Tests, NextStatusUpdateAfterUpdateIntervalMilliseconds) {
|
||||
ctx.sut.set_update_interval(2000);
|
||||
ctx.sut.set_current_time(80000);
|
||||
|
||||
// No scheduled status update
|
||||
EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value());
|
||||
|
||||
// Status update completed, schedule next status update
|
||||
ctx.sut.state_ = TestableMitsubishiCN105::State::STATUS_UPDATED;
|
||||
ctx.sut.set_state(TestableMitsubishiCN105::State::SCHEDULE_NEXT_STATUS_UPDATE);
|
||||
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
|
||||
EXPECT_EQ(ctx.sut.status_update_start_ms_, std::optional<uint32_t>{80000});
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 80000);
|
||||
|
||||
// Wait for update_interval (ms) before doing another status update
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
@@ -257,7 +254,7 @@ TEST(MitsubishiCN105Tests, NextStatusUpdateAfterUpdateIntervalMilliseconds) {
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
EXPECT_FALSE(ctx.uart.tx.empty());
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS);
|
||||
EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value());
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 82000);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedA) {
|
||||
@@ -310,6 +307,30 @@ TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedB) {
|
||||
EXPECT_EQ(ctx.sut.status().room_temperature, 30.0f);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitNotSet) {
|
||||
auto ctx = TestContext{};
|
||||
|
||||
ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x58});
|
||||
|
||||
ctx.sut.update();
|
||||
|
||||
EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::CENTER);
|
||||
EXPECT_FALSE(ctx.sut.set_wide_vane_high_bit_);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitSet) {
|
||||
auto ctx = TestContext{};
|
||||
|
||||
ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x83, 0x00, 0x00, 0x00, 0x00, 0x00, 0xD8});
|
||||
|
||||
ctx.sut.update();
|
||||
|
||||
EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::CENTER);
|
||||
EXPECT_TRUE(ctx.sut.set_wide_vane_high_bit_);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, ApplySettingsPowerOn) {
|
||||
auto ctx = TestContext{};
|
||||
|
||||
@@ -372,17 +393,56 @@ TEST(MitsubishiCN105Tests, ApplyFanModeSpeed1) {
|
||||
0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x73));
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, ApplyVaneModeSwing) {
|
||||
auto ctx = TestContext{};
|
||||
|
||||
ctx.sut.set_vane_mode(MitsubishiCN105::VaneMode::SWING);
|
||||
ctx.sut.apply_settings();
|
||||
|
||||
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x10, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x66));
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitNotSet) {
|
||||
auto ctx = TestContext{};
|
||||
|
||||
ctx.sut.set_wide_vane_mode(MitsubishiCN105::WideVaneMode::LEFT);
|
||||
ctx.sut.apply_settings();
|
||||
|
||||
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x7A));
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitSet) {
|
||||
auto ctx = TestContext{};
|
||||
|
||||
ctx.sut.set_wide_vane_high_bit_ = true;
|
||||
ctx.sut.set_wide_vane_mode(MitsubishiCN105::WideVaneMode::LEFT);
|
||||
ctx.sut.apply_settings();
|
||||
|
||||
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x82, 0x00, 0x00, 0xFA));
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) {
|
||||
auto ctx = TestContext{};
|
||||
|
||||
ctx.sut.set_update_interval(2000);
|
||||
ctx.sut.set_current_time(5000);
|
||||
|
||||
// Waiting for next scheduled status update
|
||||
ctx.sut.state_ = TestableMitsubishiCN105::State::STATUS_UPDATED;
|
||||
ctx.sut.set_state(TestableMitsubishiCN105::State::SCHEDULE_NEXT_STATUS_UPDATE);
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 5000);
|
||||
EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0);
|
||||
|
||||
// Nothing to do in update (rx empty, no timeout)
|
||||
ctx.sut.set_current_time(5500);
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
EXPECT_TRUE(ctx.uart.tx.empty());
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 5000);
|
||||
EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0);
|
||||
|
||||
// Write new values
|
||||
ctx.sut.use_temperature_encoding_b_ = true;
|
||||
@@ -390,12 +450,52 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) {
|
||||
ctx.sut.set_target_temperature(25.0f);
|
||||
ctx.sut.set_mode(MitsubishiCN105::Mode::HEAT);
|
||||
ctx.sut.set_fan_mode(MitsubishiCN105::FanMode::AUTO);
|
||||
ctx.sut.set_vane_mode(MitsubishiCN105::VaneMode::AUTO);
|
||||
|
||||
// Waiting for next status update must be interrupted and new values send to AC
|
||||
ctx.sut.set_current_time(6000);
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 1000);
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS);
|
||||
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x0F, 0x00, 0x00, 0x01, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xB2, 0x00, 0xBB));
|
||||
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x1F, 0x00, 0x00, 0x01, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xB2, 0x00, 0xAB));
|
||||
// Write ACK response
|
||||
ctx.uart.push_rx({0xFC, 0x61, 0x01, 0x30, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5E});
|
||||
ctx.sut.set_current_time(6500);
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
|
||||
EXPECT_EQ(ctx.sut.operation_start_ms_, 6500 - 1000);
|
||||
EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, SetAndClearRemoteRoomTemp) {
|
||||
auto ctx = TestContext{};
|
||||
|
||||
// Set remote temperature
|
||||
ctx.sut.set_remote_temperature(28.5f);
|
||||
|
||||
ctx.sut.state_ = TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE;
|
||||
ctx.sut.set_state(TestableMitsubishiCN105::State::APPLYING_SETTINGS);
|
||||
|
||||
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x07, 0x01, 0x29, 0xB9, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x94));
|
||||
|
||||
// Write ACK response
|
||||
ctx.uart.push_rx({0xFC, 0x61, 0x01, 0x30, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5E});
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
|
||||
|
||||
ctx.uart.tx.clear();
|
||||
|
||||
// Clear remote temperature
|
||||
ctx.sut.clear_remote_temperature();
|
||||
|
||||
ctx.sut.set_state(TestableMitsubishiCN105::State::APPLYING_SETTINGS);
|
||||
|
||||
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x07, 0x00, 0x00, 0x80, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF7));
|
||||
|
||||
// Write ACK response
|
||||
ctx.uart.push_rx({0xFC, 0x61, 0x01, 0x30, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
@@ -404,4 +504,102 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) {
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, ApplyQueuedSettingsThenRemoteRoomTempInSecondWrite) {
|
||||
auto ctx = TestContext{};
|
||||
|
||||
// Queue normal settings plus remote temperature together.
|
||||
ctx.sut.use_temperature_encoding_b_ = true;
|
||||
ctx.sut.set_power(false);
|
||||
ctx.sut.set_target_temperature(25.0f);
|
||||
ctx.sut.set_mode(MitsubishiCN105::Mode::HEAT);
|
||||
ctx.sut.set_fan_mode(MitsubishiCN105::FanMode::AUTO);
|
||||
ctx.sut.set_remote_temperature(28.5f);
|
||||
|
||||
// First apply sends only the normal settings write.
|
||||
ctx.sut.state_ = TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE;
|
||||
ctx.sut.set_state(TestableMitsubishiCN105::State::APPLYING_SETTINGS);
|
||||
|
||||
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x0F, 0x00, 0x00, 0x01, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xB2, 0x00, 0xBB));
|
||||
EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE));
|
||||
EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::POWER));
|
||||
EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::TEMPERATURE));
|
||||
EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::MODE));
|
||||
EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::FAN));
|
||||
|
||||
// ACK the first write. Remote temperature should still be pending afterward.
|
||||
ctx.uart.tx.clear();
|
||||
ctx.uart.push_rx({0xFC, 0x61, 0x01, 0x30, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5E});
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
|
||||
EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE));
|
||||
|
||||
// The next apply sends the remote-temperature packet and clears the last pending flag.
|
||||
ctx.uart.tx.clear();
|
||||
ctx.sut.set_state(TestableMitsubishiCN105::State::APPLYING_SETTINGS);
|
||||
|
||||
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x07, 0x01, 0x29, 0xB9, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x94));
|
||||
EXPECT_FALSE(ctx.sut.pending_updates_.any());
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, WriteTimeoutClearsStatusUpdateWaitCreditOnReconnect) {
|
||||
auto ctx = TestContext{};
|
||||
ctx.sut.set_update_interval(2000);
|
||||
ctx.sut.set_current_time(5000);
|
||||
|
||||
// Start in the scheduled status update wait state.
|
||||
ctx.sut.state_ = TestableMitsubishiCN105::State::STATUS_UPDATED;
|
||||
ctx.sut.set_state(TestableMitsubishiCN105::State::SCHEDULE_NEXT_STATUS_UPDATE);
|
||||
ASSERT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
|
||||
ASSERT_EQ(ctx.sut.operation_start_ms_, 5000);
|
||||
ASSERT_EQ(ctx.sut.status_update_wait_credit_ms_, 0);
|
||||
|
||||
// Interrupt that wait with a write so credit is accumulated.
|
||||
ctx.sut.use_temperature_encoding_b_ = true;
|
||||
ctx.sut.set_power(false);
|
||||
ctx.sut.set_target_temperature(25.0f);
|
||||
ctx.sut.set_mode(MitsubishiCN105::Mode::HEAT);
|
||||
ctx.sut.set_fan_mode(MitsubishiCN105::FanMode::AUTO);
|
||||
ctx.sut.set_current_time(6000);
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
ASSERT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS);
|
||||
ASSERT_EQ(ctx.sut.operation_start_ms_, 6000);
|
||||
ASSERT_EQ(ctx.sut.status_update_wait_credit_ms_, 1000);
|
||||
|
||||
// Do not ACK the write. Advance time far enough to force timeout/reconnect
|
||||
// handling and verify that stale wait credit is cleared during recovery.
|
||||
ctx.sut.set_current_time(36000);
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
EXPECT_NE(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS);
|
||||
ASSERT_EQ(ctx.sut.operation_start_ms_, 36000);
|
||||
EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, SetOutOfRangeRemoteRoomTempIsIgnored) {
|
||||
auto ctx = TestContext{};
|
||||
|
||||
ctx.sut.set_remote_temperature(7.0f);
|
||||
EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE));
|
||||
|
||||
ctx.sut.set_remote_temperature(40.0f);
|
||||
EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE));
|
||||
|
||||
ctx.sut.set_remote_temperature(NAN);
|
||||
EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE));
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, SetMinRemoteRoomTemp) {
|
||||
auto ctx = TestContext{};
|
||||
ctx.sut.set_remote_temperature(8.0f);
|
||||
EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE));
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, SetMaxRemoteRoomTemp) {
|
||||
auto ctx = TestContext{};
|
||||
ctx.sut.set_remote_temperature(39.5f);
|
||||
EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE));
|
||||
}
|
||||
|
||||
} // namespace esphome::mitsubishi_cn105::testing
|
||||
|
||||
@@ -42,10 +42,13 @@ class TestableMitsubishiCN105 : public MitsubishiCN105 {
|
||||
public:
|
||||
using MitsubishiCN105::MitsubishiCN105;
|
||||
using MitsubishiCN105::State;
|
||||
using MitsubishiCN105::UpdateFlag;
|
||||
using MitsubishiCN105::state_;
|
||||
using MitsubishiCN105::write_timeout_start_ms_;
|
||||
using MitsubishiCN105::status_update_start_ms_;
|
||||
using MitsubishiCN105::operation_start_ms_;
|
||||
using MitsubishiCN105::use_temperature_encoding_b_;
|
||||
using MitsubishiCN105::set_wide_vane_high_bit_;
|
||||
using MitsubishiCN105::status_update_wait_credit_ms_;
|
||||
using MitsubishiCN105::pending_updates_;
|
||||
|
||||
void set_state(State s) { this->set_state_(s); }
|
||||
void apply_settings() { this->apply_settings_(); }
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
climate:
|
||||
- platform: mitsubishi_cn105
|
||||
id: ac
|
||||
name: "AC Test"
|
||||
uart_id: uart_bus
|
||||
|
||||
esphome:
|
||||
on_boot:
|
||||
then:
|
||||
- climate.mitsubishi_cn105.set_remote_temperature:
|
||||
id: ac
|
||||
temperature: 22.0
|
||||
- climate.mitsubishi_cn105.clear_remote_temperature:
|
||||
id: ac
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
sensor:
|
||||
- platform: ms8607
|
||||
i2c_id: i2c_bus
|
||||
temperature:
|
||||
name: Temperature
|
||||
humidity:
|
||||
i2c_id: i2c_bus
|
||||
name: Humidity
|
||||
pressure:
|
||||
name: Pressure
|
||||
address: 0x76
|
||||
update_interval: 15s
|
||||
@@ -0,0 +1,8 @@
|
||||
substitutions:
|
||||
i2c_scl: GPIO16
|
||||
i2c_sda: GPIO17
|
||||
|
||||
packages:
|
||||
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
|
||||
|
||||
<<: !include common.yaml
|
||||
@@ -0,0 +1,8 @@
|
||||
substitutions:
|
||||
i2c_scl: GPIO5
|
||||
i2c_sda: GPIO4
|
||||
|
||||
packages:
|
||||
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
|
||||
|
||||
<<: !include common.yaml
|
||||
@@ -0,0 +1,8 @@
|
||||
substitutions:
|
||||
i2c_scl: GPIO5
|
||||
i2c_sda: GPIO4
|
||||
|
||||
packages:
|
||||
i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml
|
||||
|
||||
<<: !include common.yaml
|
||||
@@ -20,4 +20,4 @@ nrf52:
|
||||
voltage: 2.1V
|
||||
uicr_erase: true
|
||||
framework:
|
||||
version: "2.6.1-a"
|
||||
version: "2.6.1-b"
|
||||
|
||||
@@ -7,6 +7,12 @@ esphome:
|
||||
prefix: "Test"
|
||||
param2: 0
|
||||
param3: true
|
||||
- script.execute:
|
||||
id: my_script_with_array_params
|
||||
ints: [42, 100]
|
||||
floats: [1.5, 2.5]
|
||||
bools: [true, false]
|
||||
strings: ["a", "b"]
|
||||
- script.wait: my_script
|
||||
- script.stop: my_script
|
||||
- if:
|
||||
@@ -34,6 +40,16 @@ script:
|
||||
mode: restart
|
||||
then:
|
||||
- lambda: 'ESP_LOGD("main", "Hello World!");'
|
||||
- id: my_script_with_array_params
|
||||
parameters:
|
||||
ints: int[]
|
||||
floats: float[]
|
||||
bools: bool[]
|
||||
strings: string[]
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGD("main", "ints=%d floats=%f bools=%d strings=%s",
|
||||
ints[0], floats[0], bools[0], strings[0].c_str());
|
||||
- id: my_script_with_params
|
||||
parameters:
|
||||
prefix: string
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
ethernet:
|
||||
type: OPENETH
|
||||
|
||||
psram:
|
||||
mode: quad
|
||||
|
||||
sendspin:
|
||||
id: sendspin_hub_id
|
||||
task_stack_in_psram: true
|
||||
@@ -0,0 +1,25 @@
|
||||
substitutions:
|
||||
i2s_bclk_pin: GPIO27
|
||||
i2s_lrclk_pin: GPIO26
|
||||
i2s_mclk_pin: GPIO25
|
||||
i2s_dout_pin: GPIO12
|
||||
spdif_data_pin: GPIO4
|
||||
|
||||
packages:
|
||||
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
|
||||
|
||||
i2s_audio:
|
||||
- id: i2s_output
|
||||
|
||||
speaker:
|
||||
- platform: i2s_audio
|
||||
id: speaker_id
|
||||
dac_type: external
|
||||
i2s_dout_pin: ${spdif_data_pin}
|
||||
spdif_mode: true
|
||||
use_apll: true
|
||||
timeout: 2s
|
||||
sample_rate: 48000
|
||||
bits_per_sample: 16bit
|
||||
channel: stereo
|
||||
i2s_mode: primary
|
||||
@@ -31,6 +31,11 @@ microphone:
|
||||
i2s_din_pin: ${i2s_din_pin}
|
||||
adc_type: external
|
||||
pdm: false
|
||||
- platform: i2s_audio
|
||||
id: mic_id_external2
|
||||
i2s_din_pin: ${i2s_din_pin2}
|
||||
adc_type: external
|
||||
pdm: false
|
||||
|
||||
speaker:
|
||||
- platform: i2s_audio
|
||||
@@ -40,9 +45,12 @@ speaker:
|
||||
|
||||
voice_assistant:
|
||||
microphone:
|
||||
microphone: mic_id_external
|
||||
gain_factor: 4
|
||||
channels: 0
|
||||
- microphone: mic_id_external
|
||||
gain_factor: 4
|
||||
channels: 0
|
||||
- microphone: mic_id_external2
|
||||
gain_factor: 4
|
||||
channels: 0
|
||||
speaker: speaker_id
|
||||
micro_wake_word: mww_id
|
||||
conversation_timeout: 60s
|
||||
|
||||
@@ -3,6 +3,7 @@ substitutions:
|
||||
i2s_bclk_pin: GPIO5
|
||||
i2s_mclk_pin: GPIO15
|
||||
i2s_din_pin: GPIO13
|
||||
i2s_din_pin2: GPIO14
|
||||
i2s_dout_pin: GPIO12
|
||||
|
||||
<<: !include common-idf.yaml
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
packages:
|
||||
- !include common.yaml
|
||||
|
||||
binary_sensor:
|
||||
- platform: template
|
||||
name: "Garage Door Open 10"
|
||||
report: "enable"
|
||||
- platform: template
|
||||
name: "Garage Door Open 11"
|
||||
report: "coordinator"
|
||||
- platform: template
|
||||
name: "Garage Door Open 12"
|
||||
report: "force"
|
||||
@@ -12,3 +12,7 @@ binary_sensor:
|
||||
zigbee:
|
||||
model: zigbee_test
|
||||
router: true
|
||||
power_source: MAINS_SINGLE_PHASE
|
||||
on_join:
|
||||
then:
|
||||
- logger.log: "Joined network"
|
||||
|
||||
@@ -128,8 +128,8 @@ def mock_storage_json() -> Generator[MagicMock]:
|
||||
|
||||
@pytest.fixture
|
||||
def mock_idedata() -> Generator[MagicMock]:
|
||||
"""Fixture to mock platformio_api.IDEData."""
|
||||
with patch("esphome.dashboard.web_server.platformio_api.IDEData") as mock:
|
||||
"""Fixture to mock platformio toolchain.IDEData."""
|
||||
with patch("esphome.dashboard.web_server.toolchain.IDEData") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import pytest_asyncio
|
||||
|
||||
import esphome.config
|
||||
from esphome.core import CORE
|
||||
from esphome.platformio_api import get_idedata
|
||||
from esphome.platformio.toolchain import get_idedata
|
||||
|
||||
from .const import (
|
||||
API_CONNECTION_TIMEOUT,
|
||||
|
||||
+2
-4
@@ -2,8 +2,7 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace crc8_test_component {
|
||||
namespace esphome::crc8_test_component {
|
||||
|
||||
static const char *const TAG = "crc8_test";
|
||||
|
||||
@@ -166,5 +165,4 @@ void CRC8TestComponent::log_test_result(const char *test_name, bool passed) {
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace crc8_test_component
|
||||
} // namespace esphome
|
||||
} // namespace esphome::crc8_test_component
|
||||
|
||||
+2
-4
@@ -4,8 +4,7 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace crc8_test_component {
|
||||
namespace esphome::crc8_test_component {
|
||||
|
||||
class CRC8TestComponent : public Component {
|
||||
public:
|
||||
@@ -25,5 +24,4 @@ class CRC8TestComponent : public Component {
|
||||
uint8_t poly = 0x8C, bool msb_first = false);
|
||||
};
|
||||
|
||||
} // namespace crc8_test_component
|
||||
} // namespace esphome
|
||||
} // namespace esphome::crc8_test_component
|
||||
|
||||
+2
-4
@@ -2,8 +2,7 @@
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#ifdef USE_API
|
||||
namespace esphome {
|
||||
namespace custom_api_device_component {
|
||||
namespace esphome::custom_api_device_component {
|
||||
|
||||
static const char *const TAG = "custom_api";
|
||||
|
||||
@@ -58,6 +57,5 @@ void CustomAPIDeviceComponent::on_ha_state_changed(std::string entity_id, std::s
|
||||
ESP_LOGI(TAG, "This subscription uses std::string API for backward compatibility");
|
||||
}
|
||||
|
||||
} // namespace custom_api_device_component
|
||||
} // namespace esphome
|
||||
} // namespace esphome::custom_api_device_component
|
||||
#endif // USE_API
|
||||
|
||||
+2
-4
@@ -6,8 +6,7 @@
|
||||
#include "esphome/components/api/custom_api_device.h"
|
||||
|
||||
#ifdef USE_API
|
||||
namespace esphome {
|
||||
namespace custom_api_device_component {
|
||||
namespace esphome::custom_api_device_component {
|
||||
|
||||
using namespace api;
|
||||
|
||||
@@ -28,6 +27,5 @@ class CustomAPIDeviceComponent : public Component, public CustomAPIDevice {
|
||||
void on_ha_state_changed(std::string entity_id, std::string state);
|
||||
};
|
||||
|
||||
} // namespace custom_api_device_component
|
||||
} // namespace esphome
|
||||
} // namespace esphome::custom_api_device_component
|
||||
#endif // USE_API
|
||||
|
||||
+2
-4
@@ -5,8 +5,7 @@
|
||||
#include <vector>
|
||||
#include <chrono>
|
||||
|
||||
namespace esphome {
|
||||
namespace defer_stress_component {
|
||||
namespace esphome::defer_stress_component {
|
||||
|
||||
static const char *const TAG = "defer_stress";
|
||||
|
||||
@@ -71,5 +70,4 @@ void DeferStressComponent::run_multi_thread_test() {
|
||||
ESP_LOGI(TAG, "All threads finished in %lldms. Created %d defer requests", thread_time, this->total_defers_.load());
|
||||
}
|
||||
|
||||
} // namespace defer_stress_component
|
||||
} // namespace esphome
|
||||
} // namespace esphome::defer_stress_component
|
||||
|
||||
+2
-4
@@ -3,8 +3,7 @@
|
||||
#include "esphome/core/component.h"
|
||||
#include <atomic>
|
||||
|
||||
namespace esphome {
|
||||
namespace defer_stress_component {
|
||||
namespace esphome::defer_stress_component {
|
||||
|
||||
class DeferStressComponent : public Component {
|
||||
public:
|
||||
@@ -16,5 +15,4 @@ class DeferStressComponent : public Component {
|
||||
std::atomic<int> executed_defers_{0};
|
||||
};
|
||||
|
||||
} // namespace defer_stress_component
|
||||
} // namespace esphome
|
||||
} // namespace esphome::defer_stress_component
|
||||
|
||||
+2
-4
@@ -1,7 +1,6 @@
|
||||
#include "loop_test_component.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace loop_test_component {
|
||||
namespace esphome::loop_test_component {
|
||||
|
||||
void LoopTestComponent::setup() { ESP_LOGI(TAG, "[%s] Setup called", this->name_.c_str()); }
|
||||
|
||||
@@ -63,5 +62,4 @@ void LoopTestUpdateComponent::update() {
|
||||
this->update_count_, loop_disabled ? "YES" : "NO");
|
||||
}
|
||||
|
||||
} // namespace loop_test_component
|
||||
} // namespace esphome
|
||||
} // namespace esphome::loop_test_component
|
||||
|
||||
+2
-4
@@ -6,8 +6,7 @@
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace loop_test_component {
|
||||
namespace esphome::loop_test_component {
|
||||
|
||||
static const char *const TAG = "loop_test_component";
|
||||
|
||||
@@ -79,5 +78,4 @@ class LoopTestUpdateComponent : public PollingComponent {
|
||||
int disable_loop_after_{0};
|
||||
};
|
||||
|
||||
} // namespace loop_test_component
|
||||
} // namespace esphome
|
||||
} // namespace esphome::loop_test_component
|
||||
|
||||
+2
-4
@@ -2,8 +2,7 @@
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/application.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace loop_test_component {
|
||||
namespace esphome::loop_test_component {
|
||||
|
||||
static const char *const ISR_TAG = "loop_test_isr_component";
|
||||
|
||||
@@ -76,5 +75,4 @@ void IRAM_ATTR LoopTestISRComponent::simulate_isr_enable() {
|
||||
// For testing, we'll track the call count and log it from the main loop
|
||||
}
|
||||
|
||||
} // namespace loop_test_component
|
||||
} // namespace esphome
|
||||
} // namespace esphome::loop_test_component
|
||||
|
||||
+2
-4
@@ -4,8 +4,7 @@
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/hal.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace loop_test_component {
|
||||
namespace esphome::loop_test_component {
|
||||
|
||||
class LoopTestISRComponent : public Component {
|
||||
public:
|
||||
@@ -28,5 +27,4 @@ class LoopTestISRComponent : public Component {
|
||||
int isr_call_count_{0};
|
||||
};
|
||||
|
||||
} // namespace loop_test_component
|
||||
} // namespace esphome
|
||||
} // namespace esphome::loop_test_component
|
||||
|
||||
+2
-4
@@ -2,8 +2,7 @@
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace scheduler_bulk_cleanup_component {
|
||||
namespace esphome::scheduler_bulk_cleanup_component {
|
||||
|
||||
static const char *const TAG = "bulk_cleanup";
|
||||
|
||||
@@ -68,5 +67,4 @@ void SchedulerBulkCleanupComponent::trigger_bulk_cleanup() {
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace scheduler_bulk_cleanup_component
|
||||
} // namespace esphome
|
||||
} // namespace esphome::scheduler_bulk_cleanup_component
|
||||
|
||||
+2
-4
@@ -3,8 +3,7 @@
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/application.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace scheduler_bulk_cleanup_component {
|
||||
namespace esphome::scheduler_bulk_cleanup_component {
|
||||
|
||||
class SchedulerBulkCleanupComponent : public Component {
|
||||
public:
|
||||
@@ -14,5 +13,4 @@ class SchedulerBulkCleanupComponent : public Component {
|
||||
void trigger_bulk_cleanup();
|
||||
};
|
||||
|
||||
} // namespace scheduler_bulk_cleanup_component
|
||||
} // namespace esphome
|
||||
} // namespace esphome::scheduler_bulk_cleanup_component
|
||||
|
||||
+2
-4
@@ -6,8 +6,7 @@
|
||||
#include <chrono>
|
||||
#include <random>
|
||||
|
||||
namespace esphome {
|
||||
namespace scheduler_heap_stress_component {
|
||||
namespace esphome::scheduler_heap_stress_component {
|
||||
|
||||
static const char *const TAG = "scheduler_heap_stress";
|
||||
|
||||
@@ -100,5 +99,4 @@ void SchedulerHeapStressComponent::run_multi_thread_test() {
|
||||
ESP_LOGI(TAG, "All threads finished in %lldms. Created %d callbacks", thread_time, this->total_callbacks_.load());
|
||||
}
|
||||
|
||||
} // namespace scheduler_heap_stress_component
|
||||
} // namespace esphome
|
||||
} // namespace esphome::scheduler_heap_stress_component
|
||||
|
||||
+2
-4
@@ -3,8 +3,7 @@
|
||||
#include "esphome/core/component.h"
|
||||
#include <atomic>
|
||||
|
||||
namespace esphome {
|
||||
namespace scheduler_heap_stress_component {
|
||||
namespace esphome::scheduler_heap_stress_component {
|
||||
|
||||
class SchedulerHeapStressComponent : public Component {
|
||||
public:
|
||||
@@ -18,5 +17,4 @@ class SchedulerHeapStressComponent : public Component {
|
||||
std::atomic<int> executed_callbacks_{0};
|
||||
};
|
||||
|
||||
} // namespace scheduler_heap_stress_component
|
||||
} // namespace esphome
|
||||
} // namespace esphome::scheduler_heap_stress_component
|
||||
|
||||
+2
-4
@@ -6,8 +6,7 @@
|
||||
#include <random>
|
||||
#include <sstream>
|
||||
|
||||
namespace esphome {
|
||||
namespace scheduler_rapid_cancellation_component {
|
||||
namespace esphome::scheduler_rapid_cancellation_component {
|
||||
|
||||
static const char *const TAG = "scheduler_rapid_cancellation";
|
||||
|
||||
@@ -76,5 +75,4 @@ void SchedulerRapidCancellationComponent::run_rapid_cancellation_test() {
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace scheduler_rapid_cancellation_component
|
||||
} // namespace esphome
|
||||
} // namespace esphome::scheduler_rapid_cancellation_component
|
||||
|
||||
+2
-4
@@ -3,8 +3,7 @@
|
||||
#include "esphome/core/component.h"
|
||||
#include <atomic>
|
||||
|
||||
namespace esphome {
|
||||
namespace scheduler_rapid_cancellation_component {
|
||||
namespace esphome::scheduler_rapid_cancellation_component {
|
||||
|
||||
class SchedulerRapidCancellationComponent : public Component {
|
||||
public:
|
||||
@@ -18,5 +17,4 @@ class SchedulerRapidCancellationComponent : public Component {
|
||||
std::atomic<int> total_executed_{0};
|
||||
};
|
||||
|
||||
} // namespace scheduler_rapid_cancellation_component
|
||||
} // namespace esphome
|
||||
} // namespace esphome::scheduler_rapid_cancellation_component
|
||||
|
||||
+2
-4
@@ -1,8 +1,7 @@
|
||||
#include "recursive_timeout_component.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace scheduler_recursive_timeout_component {
|
||||
namespace esphome::scheduler_recursive_timeout_component {
|
||||
|
||||
static const char *const TAG = "scheduler_recursive_timeout";
|
||||
|
||||
@@ -36,5 +35,4 @@ void SchedulerRecursiveTimeoutComponent::run_recursive_timeout_test() {
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace scheduler_recursive_timeout_component
|
||||
} // namespace esphome
|
||||
} // namespace esphome::scheduler_recursive_timeout_component
|
||||
|
||||
+2
-4
@@ -2,8 +2,7 @@
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace scheduler_recursive_timeout_component {
|
||||
namespace esphome::scheduler_recursive_timeout_component {
|
||||
|
||||
class SchedulerRecursiveTimeoutComponent : public Component {
|
||||
public:
|
||||
@@ -16,5 +15,4 @@ class SchedulerRecursiveTimeoutComponent : public Component {
|
||||
int nested_level_{0};
|
||||
};
|
||||
|
||||
} // namespace scheduler_recursive_timeout_component
|
||||
} // namespace esphome
|
||||
} // namespace esphome::scheduler_recursive_timeout_component
|
||||
|
||||
+2
-4
@@ -5,8 +5,7 @@
|
||||
#include <chrono>
|
||||
#include <sstream>
|
||||
|
||||
namespace esphome {
|
||||
namespace scheduler_simultaneous_callbacks_component {
|
||||
namespace esphome::scheduler_simultaneous_callbacks_component {
|
||||
|
||||
static const char *const TAG = "scheduler_simultaneous_callbacks";
|
||||
|
||||
@@ -105,5 +104,4 @@ void SchedulerSimultaneousCallbacksComponent::run_simultaneous_callbacks_test()
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace scheduler_simultaneous_callbacks_component
|
||||
} // namespace esphome
|
||||
} // namespace esphome::scheduler_simultaneous_callbacks_component
|
||||
|
||||
+2
-4
@@ -3,8 +3,7 @@
|
||||
#include "esphome/core/component.h"
|
||||
#include <atomic>
|
||||
|
||||
namespace esphome {
|
||||
namespace scheduler_simultaneous_callbacks_component {
|
||||
namespace esphome::scheduler_simultaneous_callbacks_component {
|
||||
|
||||
class SchedulerSimultaneousCallbacksComponent : public Component {
|
||||
public:
|
||||
@@ -20,5 +19,4 @@ class SchedulerSimultaneousCallbacksComponent : public Component {
|
||||
std::atomic<int> max_concurrent_{0};
|
||||
};
|
||||
|
||||
} // namespace scheduler_simultaneous_callbacks_component
|
||||
} // namespace esphome
|
||||
} // namespace esphome::scheduler_simultaneous_callbacks_component
|
||||
|
||||
+2
-4
@@ -4,8 +4,7 @@
|
||||
#include <thread>
|
||||
#include <chrono>
|
||||
|
||||
namespace esphome {
|
||||
namespace scheduler_string_lifetime_component {
|
||||
namespace esphome::scheduler_string_lifetime_component {
|
||||
|
||||
static const char *const TAG = "scheduler_string_lifetime";
|
||||
|
||||
@@ -258,5 +257,4 @@ void SchedulerStringLifetimeComponent::test_lambda_capture_lifetime() {
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace scheduler_string_lifetime_component
|
||||
} // namespace esphome
|
||||
} // namespace esphome::scheduler_string_lifetime_component
|
||||
|
||||
+2
-4
@@ -4,8 +4,7 @@
|
||||
#include <vector>
|
||||
#include <string>
|
||||
|
||||
namespace esphome {
|
||||
namespace scheduler_string_lifetime_component {
|
||||
namespace esphome::scheduler_string_lifetime_component {
|
||||
|
||||
class SchedulerStringLifetimeComponent : public Component {
|
||||
public:
|
||||
@@ -33,5 +32,4 @@ class SchedulerStringLifetimeComponent : public Component {
|
||||
int tests_failed_{0};
|
||||
};
|
||||
|
||||
} // namespace scheduler_string_lifetime_component
|
||||
} // namespace esphome
|
||||
} // namespace esphome::scheduler_string_lifetime_component
|
||||
|
||||
+2
-4
@@ -7,8 +7,7 @@
|
||||
#include <string>
|
||||
#include <sstream>
|
||||
|
||||
namespace esphome {
|
||||
namespace scheduler_string_name_stress_component {
|
||||
namespace esphome::scheduler_string_name_stress_component {
|
||||
|
||||
static const char *const TAG = "scheduler_string_name_stress";
|
||||
|
||||
@@ -106,5 +105,4 @@ void SchedulerStringNameStressComponent::run_string_name_stress_test() {
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace scheduler_string_name_stress_component
|
||||
} // namespace esphome
|
||||
} // namespace esphome::scheduler_string_name_stress_component
|
||||
|
||||
+2
-4
@@ -3,8 +3,7 @@
|
||||
#include "esphome/core/component.h"
|
||||
#include <atomic>
|
||||
|
||||
namespace esphome {
|
||||
namespace scheduler_string_name_stress_component {
|
||||
namespace esphome::scheduler_string_name_stress_component {
|
||||
|
||||
class SchedulerStringNameStressComponent : public Component {
|
||||
public:
|
||||
@@ -18,5 +17,4 @@ class SchedulerStringNameStressComponent : public Component {
|
||||
std::atomic<int> executed_callbacks_{0};
|
||||
};
|
||||
|
||||
} // namespace scheduler_string_name_stress_component
|
||||
} // namespace esphome
|
||||
} // namespace esphome::scheduler_string_name_stress_component
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
esphome:
|
||||
name: host-ota-test
|
||||
host:
|
||||
api:
|
||||
ota:
|
||||
- platform: esphome
|
||||
port: __OTA_PORT__
|
||||
logger:
|
||||
level: DEBUG
|
||||
@@ -0,0 +1,9 @@
|
||||
esphome:
|
||||
name: host-ota-test
|
||||
host:
|
||||
api:
|
||||
ota:
|
||||
- platform: esphome
|
||||
port: __OTA_PORT__
|
||||
logger:
|
||||
level: DEBUG
|
||||
@@ -0,0 +1,36 @@
|
||||
esphome:
|
||||
name: test-script-array-params
|
||||
|
||||
host:
|
||||
|
||||
api:
|
||||
actions:
|
||||
- action: run_array_script
|
||||
then:
|
||||
- script.execute:
|
||||
id: array_script
|
||||
ints: [42, 100]
|
||||
floats: [1.5, 2.5]
|
||||
bools: [true, false]
|
||||
strings: ["hello", "world"]
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
script:
|
||||
- id: array_script
|
||||
parameters:
|
||||
ints: int[]
|
||||
floats: float[]
|
||||
bools: bool[]
|
||||
strings: string[]
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGI("test", "ints size=%u [0]=%d [1]=%d",
|
||||
(unsigned) ints.size(), ints[0], ints[1]);
|
||||
ESP_LOGI("test", "floats size=%u [0]=%.2f [1]=%.2f",
|
||||
(unsigned) floats.size(), floats[0], floats[1]);
|
||||
ESP_LOGI("test", "bools size=%u [0]=%d [1]=%d",
|
||||
(unsigned) bools.size(), (int) bools[0], (int) bools[1]);
|
||||
ESP_LOGI("test", "strings size=%u [0]=%s [1]=%s",
|
||||
(unsigned) strings.size(), strings[0].c_str(), strings[1].c_str());
|
||||
@@ -0,0 +1,152 @@
|
||||
"""End-to-end OTA tests on the host platform.
|
||||
|
||||
Exercises the native OTA protocol against a real host binary, then asserts
|
||||
pid is preserved across the post-OTA execv. A second OTA on the post-exec
|
||||
instance covers the FD_CLOEXEC path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
import socket
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import espota2
|
||||
|
||||
from .conftest import run_binary, wait_and_connect_api_client
|
||||
from .const import LOCALHOST, PORT_POLL_INTERVAL, PORT_WAIT_TIMEOUT
|
||||
from .types import CompileFunction, ConfigWriter
|
||||
|
||||
DEVICE_NAME = "host-ota-test"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _reserve_port() -> Generator[tuple[int, socket.socket]]:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
s.bind(("", 0))
|
||||
try:
|
||||
yield s.getsockname()[1], s
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
async def _wait_for_port(host: str, port: int, timeout: float) -> None:
|
||||
"""Poll until a TCP port accepts connections, or raise TimeoutError."""
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + timeout
|
||||
while loop.time() < deadline:
|
||||
try:
|
||||
_, writer = await asyncio.open_connection(host, port)
|
||||
except (ConnectionRefusedError, OSError):
|
||||
await asyncio.sleep(PORT_POLL_INTERVAL)
|
||||
continue
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
return
|
||||
raise TimeoutError(f"Port {port} on {host} did not open within {timeout}s")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_host_ota_self_update(
|
||||
yaml_config: str,
|
||||
write_yaml_config: ConfigWriter,
|
||||
compile_esphome: CompileFunction,
|
||||
reserved_tcp_port: tuple[int, socket.socket],
|
||||
) -> None:
|
||||
"""Self-OTA: upload the running binary back to itself, expect re-exec."""
|
||||
api_port, api_socket = reserved_tcp_port
|
||||
with _reserve_port() as (ota_port, ota_socket):
|
||||
yaml_config = yaml_config.replace("__OTA_PORT__", str(ota_port))
|
||||
config_path = await write_yaml_config(yaml_config)
|
||||
binary_path = await compile_esphome(config_path)
|
||||
api_socket.close()
|
||||
ota_socket.close()
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
ota_staged = loop.create_future()
|
||||
rebooted = loop.create_future()
|
||||
|
||||
def on_log(line: str) -> None:
|
||||
if not ota_staged.done() and "OTA staged at" in line:
|
||||
ota_staged.set_result(True)
|
||||
if not rebooted.done() and "Rebooting safely" in line:
|
||||
rebooted.set_result(True)
|
||||
|
||||
async with run_binary(binary_path, line_callback=on_log) as (proc, _lines):
|
||||
await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT)
|
||||
pid_before = proc.pid
|
||||
async with wait_and_connect_api_client(port=api_port) as client:
|
||||
info_before = await client.device_info()
|
||||
assert info_before.name == DEVICE_NAME
|
||||
|
||||
# espota2 is blocking; run in executor.
|
||||
rc, _ = await loop.run_in_executor(
|
||||
None, espota2.run_ota, LOCALHOST, ota_port, None, binary_path
|
||||
)
|
||||
assert rc == 0, "espota2 reported failure"
|
||||
|
||||
await asyncio.wait_for(ota_staged, timeout=10.0)
|
||||
await asyncio.wait_for(rebooted, timeout=10.0)
|
||||
await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT)
|
||||
|
||||
# execv preserves pid; mismatch means external respawn.
|
||||
assert proc.returncode is None, "process exited instead of execing"
|
||||
assert proc.pid == pid_before
|
||||
|
||||
async with wait_and_connect_api_client(port=api_port) as client:
|
||||
info_after = await client.device_info()
|
||||
assert info_after.name == DEVICE_NAME
|
||||
assert info_after.name == info_before.name
|
||||
|
||||
# Second OTA: catches FD_CLOEXEC regressions (EADDRINUSE on rebind).
|
||||
rc, _ = await loop.run_in_executor(
|
||||
None, espota2.run_ota, LOCALHOST, ota_port, None, binary_path
|
||||
)
|
||||
assert rc == 0, "second OTA failed -- listener leaked across execv"
|
||||
await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT)
|
||||
assert proc.pid == pid_before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_host_ota_rejects_garbage(
|
||||
yaml_config: str,
|
||||
write_yaml_config: ConfigWriter,
|
||||
compile_esphome: CompileFunction,
|
||||
reserved_tcp_port: tuple[int, socket.socket],
|
||||
integration_test_dir,
|
||||
) -> None:
|
||||
"""Bogus payload is rejected and the device keeps running."""
|
||||
api_port, api_socket = reserved_tcp_port
|
||||
with _reserve_port() as (ota_port, ota_socket):
|
||||
yaml_config = yaml_config.replace("__OTA_PORT__", str(ota_port))
|
||||
config_path = await write_yaml_config(yaml_config)
|
||||
binary_path = await compile_esphome(config_path)
|
||||
|
||||
# 192 bytes that are neither ELF nor Mach-O.
|
||||
bogus_path = integration_test_dir / "bogus.bin"
|
||||
bogus_path.write_bytes(b"NOT-AN-EXECUTABLE-AT-ALL" * 8)
|
||||
|
||||
api_socket.close()
|
||||
ota_socket.close()
|
||||
|
||||
async with run_binary(binary_path) as (proc, _lines):
|
||||
await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT)
|
||||
pid_before = proc.pid
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
rc, _ = await loop.run_in_executor(
|
||||
None, espota2.run_ota, LOCALHOST, ota_port, None, bogus_path
|
||||
)
|
||||
assert rc == 1
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
assert proc.returncode is None, "process died on rejected OTA"
|
||||
assert proc.pid == pid_before
|
||||
|
||||
async with wait_and_connect_api_client(port=api_port) as client:
|
||||
info = await client.device_info()
|
||||
assert info.name == DEVICE_NAME
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Integration test for script array parameters (issue #16367).
|
||||
|
||||
Verifies that script parameters of array types (`int[]`, `float[]`, `bool[]`,
|
||||
`string[]`) compile and execute correctly. Prior to the fix in
|
||||
`esphome/components/script/__init__.py`, the `script.execute` codegen emitted
|
||||
the Python `repr` of the list (e.g. `return [42, 100];`) instead of a C++
|
||||
braced initializer, causing compile failures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_script_array_params(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Execute a script with int[], float[], bool[], string[] parameters."""
|
||||
loop = asyncio.get_running_loop()
|
||||
seen: dict[str, str] = {}
|
||||
done = loop.create_future()
|
||||
|
||||
patterns = {
|
||||
"ints": re.compile(r"ints size=(\d+) \[0\]=(-?\d+) \[1\]=(-?\d+)"),
|
||||
"floats": re.compile(
|
||||
r"floats size=(\d+) \[0\]=(-?\d+\.\d+) \[1\]=(-?\d+\.\d+)"
|
||||
),
|
||||
"bools": re.compile(r"bools size=(\d+) \[0\]=(\d+) \[1\]=(\d+)"),
|
||||
"strings": re.compile(r"strings size=(\d+) \[0\]=(\w+) \[1\]=(\w+)"),
|
||||
}
|
||||
|
||||
def check_output(line: str) -> None:
|
||||
for key, pat in patterns.items():
|
||||
if (m := pat.search(line)) and key not in seen:
|
||||
seen[key] = m.group(0)
|
||||
if len(seen) == len(patterns) and not done.done():
|
||||
done.set_result(True)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=check_output),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
_, services = await client.list_entities_services()
|
||||
service = next((s for s in services if s.name == "run_array_script"), None)
|
||||
assert service is not None, "run_array_script service not found"
|
||||
await client.execute_service(service, {})
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(done, timeout=5.0)
|
||||
except TimeoutError:
|
||||
pytest.fail(f"Did not receive all expected log lines. Saw: {seen}")
|
||||
|
||||
assert (m := patterns["ints"].search(seen["ints"]))
|
||||
assert m.group(1) == "2" and m.group(2) == "42" and m.group(3) == "100"
|
||||
|
||||
assert (m := patterns["floats"].search(seen["floats"]))
|
||||
assert m.group(1) == "2" and m.group(2) == "1.50" and m.group(3) == "2.50"
|
||||
|
||||
assert (m := patterns["bools"].search(seen["bools"]))
|
||||
assert m.group(1) == "2" and m.group(2) == "1" and m.group(3) == "0"
|
||||
|
||||
assert (m := patterns["strings"].search(seen["strings"]))
|
||||
assert m.group(1) == "2" and m.group(2) == "hello" and m.group(3) == "world"
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Unit tests for script/build_language_schema.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_PATH = (
|
||||
Path(__file__).resolve().parent.parent.parent
|
||||
/ "script"
|
||||
/ "build_language_schema.py"
|
||||
)
|
||||
|
||||
|
||||
def _extract_sort_obj():
|
||||
# build_language_schema.py runs argparse, loads every component, and
|
||||
# calls build_schema() at import time, so a plain import isn't viable
|
||||
# in a unit test. Pull just the pure helper out via AST instead.
|
||||
tree = ast.parse(SCRIPT_PATH.read_text())
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.FunctionDef) and node.name == "sort_obj":
|
||||
namespace: dict = {"S_TYPE": "type"}
|
||||
module = ast.Module(body=[node], type_ignores=[])
|
||||
exec(compile(module, str(SCRIPT_PATH), "exec"), namespace)
|
||||
return namespace["sort_obj"]
|
||||
raise AssertionError("sort_obj not found in build_language_schema.py")
|
||||
|
||||
|
||||
sort_obj = _extract_sort_obj()
|
||||
|
||||
|
||||
def test_sort_obj_sorts_dict_keys() -> None:
|
||||
result = sort_obj({"b": 1, "a": 2, "c": 3})
|
||||
assert list(result.keys()) == ["a", "b", "c"]
|
||||
|
||||
|
||||
def test_sort_obj_sorts_nested_dicts() -> None:
|
||||
result = sort_obj({"outer": {"z": 1, "a": 2}})
|
||||
assert list(result["outer"].keys()) == ["a", "z"]
|
||||
|
||||
|
||||
def test_sort_obj_preserves_enum_values_order() -> None:
|
||||
config = {
|
||||
"type": "enum",
|
||||
"values": {
|
||||
"2MB": None,
|
||||
"4MB": None,
|
||||
"8MB": None,
|
||||
"16MB": None,
|
||||
"32MB": None,
|
||||
},
|
||||
}
|
||||
result = sort_obj(config)
|
||||
assert list(result["values"].keys()) == ["2MB", "4MB", "8MB", "16MB", "32MB"]
|
||||
|
||||
|
||||
def test_sort_obj_sorts_non_enum_values_key() -> None:
|
||||
config = {"type": "schema", "values": {"z": 1, "a": 2}}
|
||||
result = sort_obj(config)
|
||||
assert list(result["values"].keys()) == ["a", "z"]
|
||||
|
||||
|
||||
def test_sort_obj_sorts_other_keys_in_enum() -> None:
|
||||
config = {
|
||||
"type": "enum",
|
||||
"default": "4MB",
|
||||
"key": "Optional",
|
||||
"values": {"2MB": None, "4MB": None},
|
||||
}
|
||||
result = sort_obj(config)
|
||||
assert list(result.keys()) == ["default", "key", "type", "values"]
|
||||
assert list(result["values"].keys()) == ["2MB", "4MB"]
|
||||
|
||||
|
||||
def test_sort_obj_recurses_into_enum_value_entries() -> None:
|
||||
config = {
|
||||
"type": "enum",
|
||||
"values": {
|
||||
"esp32": {"name": "ESP32", "docs": "Original"},
|
||||
"esp32-c3": {"name": "ESP32-C3", "docs": "RISC-V"},
|
||||
},
|
||||
}
|
||||
result = sort_obj(config)
|
||||
assert list(result["values"].keys()) == ["esp32", "esp32-c3"]
|
||||
assert list(result["values"]["esp32"].keys()) == ["docs", "name"]
|
||||
|
||||
|
||||
def test_sort_obj_handles_lists() -> None:
|
||||
result = sort_obj([{"b": 1, "a": 2}, {"d": 3, "c": 4}])
|
||||
assert list(result[0].keys()) == ["a", "b"]
|
||||
assert list(result[1].keys()) == ["c", "d"]
|
||||
|
||||
|
||||
def test_sort_obj_passes_through_scalars() -> None:
|
||||
assert sort_obj("hello") == "hello"
|
||||
assert sort_obj(42) == 42
|
||||
assert sort_obj(None) is None
|
||||
assert sort_obj(True) is True
|
||||
@@ -70,6 +70,17 @@ def mock_should_run_device_builder() -> Generator[Mock, None, None]:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_native_idf_components_to_test() -> Generator[Mock, None, None]:
|
||||
"""Mock native_idf_components_to_test from determine_jobs.
|
||||
|
||||
main() drives both the ``native_idf`` boolean output and the
|
||||
``native_idf_components`` CSV from this one function.
|
||||
"""
|
||||
with patch.object(determine_jobs, "native_idf_components_to_test") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_determine_cpp_unit_tests() -> Generator[Mock, None, None]:
|
||||
"""Mock determine_cpp_unit_tests from helpers."""
|
||||
@@ -107,6 +118,7 @@ def test_main_all_tests_should_run(
|
||||
mock_should_run_python_linters: Mock,
|
||||
mock_should_run_import_time: Mock,
|
||||
mock_should_run_device_builder: Mock,
|
||||
mock_native_idf_components_to_test: Mock,
|
||||
mock_changed_files: Mock,
|
||||
mock_determine_cpp_unit_tests: Mock,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
@@ -122,6 +134,7 @@ def test_main_all_tests_should_run(
|
||||
mock_should_run_python_linters.return_value = True
|
||||
mock_should_run_import_time.return_value = True
|
||||
mock_should_run_device_builder.return_value = True
|
||||
mock_native_idf_components_to_test.return_value = ["api", "esp32"]
|
||||
mock_determine_cpp_unit_tests.return_value = (False, ["wifi", "api", "sensor"])
|
||||
|
||||
# Mock changed_files to return non-component files (to avoid memory impact)
|
||||
@@ -203,6 +216,8 @@ def test_main_all_tests_should_run(
|
||||
assert output["python_linters"] is True
|
||||
assert output["import_time"] is True
|
||||
assert output["device_builder"] is True
|
||||
assert output["native_idf"] is True
|
||||
assert output["native_idf_components"] == "api,esp32"
|
||||
assert output["changed_components"] == ["wifi", "api", "sensor"]
|
||||
# changed_components_with_tests will only include components that actually have test files
|
||||
assert "changed_components_with_tests" in output
|
||||
@@ -236,6 +251,7 @@ def test_main_no_tests_should_run(
|
||||
mock_should_run_python_linters: Mock,
|
||||
mock_should_run_import_time: Mock,
|
||||
mock_should_run_device_builder: Mock,
|
||||
mock_native_idf_components_to_test: Mock,
|
||||
mock_changed_files: Mock,
|
||||
mock_determine_cpp_unit_tests: Mock,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
@@ -251,6 +267,7 @@ def test_main_no_tests_should_run(
|
||||
mock_should_run_python_linters.return_value = False
|
||||
mock_should_run_import_time.return_value = False
|
||||
mock_should_run_device_builder.return_value = False
|
||||
mock_native_idf_components_to_test.return_value = []
|
||||
mock_determine_cpp_unit_tests.return_value = (False, [])
|
||||
|
||||
# Mock changed_files to return no component files
|
||||
@@ -291,6 +308,8 @@ def test_main_no_tests_should_run(
|
||||
assert output["python_linters"] is False
|
||||
assert output["import_time"] is False
|
||||
assert output["device_builder"] is False
|
||||
assert output["native_idf"] is False
|
||||
assert output["native_idf_components"] == ""
|
||||
assert output["changed_components"] == []
|
||||
assert output["changed_components_with_tests"] == []
|
||||
assert output["component_test_count"] == 0
|
||||
@@ -313,6 +332,7 @@ def test_main_with_branch_argument(
|
||||
mock_should_run_python_linters: Mock,
|
||||
mock_should_run_import_time: Mock,
|
||||
mock_should_run_device_builder: Mock,
|
||||
mock_native_idf_components_to_test: Mock,
|
||||
mock_changed_files: Mock,
|
||||
mock_determine_cpp_unit_tests: Mock,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
@@ -328,6 +348,7 @@ def test_main_with_branch_argument(
|
||||
mock_should_run_python_linters.return_value = True
|
||||
mock_should_run_import_time.return_value = True
|
||||
mock_should_run_device_builder.return_value = True
|
||||
mock_native_idf_components_to_test.return_value = ["esp32"]
|
||||
mock_determine_cpp_unit_tests.return_value = (False, ["mqtt"])
|
||||
|
||||
# Mock changed_files to return non-component files (to avoid memory impact)
|
||||
@@ -366,6 +387,7 @@ def test_main_with_branch_argument(
|
||||
mock_should_run_python_linters.assert_called_once_with("main")
|
||||
mock_should_run_import_time.assert_called_once_with("main")
|
||||
mock_should_run_device_builder.assert_called_once_with("main")
|
||||
mock_native_idf_components_to_test.assert_called_once_with("main")
|
||||
|
||||
# Check output
|
||||
captured = capsys.readouterr()
|
||||
@@ -379,6 +401,8 @@ def test_main_with_branch_argument(
|
||||
assert output["python_linters"] is True
|
||||
assert output["import_time"] is True
|
||||
assert output["device_builder"] is True
|
||||
assert output["native_idf"] is True
|
||||
assert output["native_idf_components"] == "esp32"
|
||||
assert output["changed_components"] == ["mqtt"]
|
||||
# changed_components_with_tests will only include components that actually have test files
|
||||
assert "changed_components_with_tests" in output
|
||||
@@ -827,6 +851,142 @@ def test_should_run_device_builder_skips_beta_release(target_branch: str) -> Non
|
||||
mock_changed.assert_not_called()
|
||||
|
||||
|
||||
_NATIVE_IDF_FULL_LIST_FILES = [
|
||||
# Core C++/Python changes -- caught by core_changed()
|
||||
["esphome/core/component.cpp"],
|
||||
["esphome/core/config.py"],
|
||||
# Native IDF infrastructure paths
|
||||
["esphome/espidf/framework.py"],
|
||||
["esphome/espidf/component.py"],
|
||||
["esphome/espidf/api.py"],
|
||||
["esphome/build_gen/espidf.py"],
|
||||
# Workflow / harness files
|
||||
["script/test_build_components.py"],
|
||||
[".github/workflows/ci.yml"],
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("changed_files", _NATIVE_IDF_FULL_LIST_FILES)
|
||||
def test_native_idf_components_to_test_returns_full_list_on_infrastructure(
|
||||
changed_files: list[str],
|
||||
) -> None:
|
||||
"""Infrastructure / core / harness changes fall back to the full component list."""
|
||||
with (
|
||||
patch.object(determine_jobs, "changed_files", return_value=changed_files),
|
||||
# The dep-closure path shouldn't be consulted at all -- if it is,
|
||||
# the obviously-wrong "wifi" sneaks in and the assertion catches it.
|
||||
patch.object(
|
||||
determine_jobs, "get_components_with_dependencies", return_value=["wifi"]
|
||||
),
|
||||
):
|
||||
result = determine_jobs.native_idf_components_to_test()
|
||||
assert result == sorted(determine_jobs.NATIVE_IDF_TEST_COMPONENTS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("changed_files", "dependency_closure", "expected"),
|
||||
[
|
||||
# Single tested component changed -- narrow to just that component.
|
||||
(
|
||||
["esphome/components/esp32/__init__.py"],
|
||||
["esp32"],
|
||||
["esp32"],
|
||||
),
|
||||
# Dependency closure: multiple BLE components in the changed set
|
||||
# are all intersected with the test list and returned sorted.
|
||||
(
|
||||
["esphome/components/esp32_ble/ble.cpp"],
|
||||
["esp32_ble", "esp32_ble_tracker", "ble_scanner"],
|
||||
["ble_scanner", "esp32_ble", "esp32_ble_tracker"],
|
||||
),
|
||||
# api in the test set -- narrow to [api] even though the closure
|
||||
# has other (unrelated to native-IDF coverage) entries.
|
||||
(
|
||||
["esphome/components/api/api_connection.cpp"],
|
||||
["api", "logger"],
|
||||
["api"],
|
||||
),
|
||||
# Components outside the test set return an empty list (job skipped).
|
||||
(
|
||||
["esphome/components/wifi/wifi_component.cpp"],
|
||||
["wifi", "network"],
|
||||
[],
|
||||
),
|
||||
# Pure Python-only change outside trigger paths -> empty.
|
||||
(["esphome/yaml_util.py"], [], []),
|
||||
# Non-IDF files in esphome/build_gen/ do NOT trigger the full
|
||||
# list -- only esphome/build_gen/espidf.py is a trigger.
|
||||
(["esphome/build_gen/platformio.py"], [], []),
|
||||
# Docs / unrelated files -> empty.
|
||||
(["README.md"], [], []),
|
||||
([], [], []),
|
||||
],
|
||||
)
|
||||
def test_native_idf_components_to_test_narrowing(
|
||||
changed_files: list[str],
|
||||
dependency_closure: list[str],
|
||||
expected: list[str],
|
||||
) -> None:
|
||||
"""Component changes narrow the test list to the intersection."""
|
||||
with (
|
||||
patch.object(determine_jobs, "changed_files", return_value=changed_files),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"get_components_with_dependencies",
|
||||
return_value=dependency_closure,
|
||||
),
|
||||
):
|
||||
result = determine_jobs.native_idf_components_to_test()
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_native_idf_components_to_test_with_branch() -> None:
|
||||
"""native_idf_components_to_test passes branch argument through.
|
||||
|
||||
Regression test: an earlier version called ``get_changed_components()``,
|
||||
which silently ignored the branch argument because that helper re-runs
|
||||
``changed_files()`` with its own default. The current implementation
|
||||
derives the closure from ``files = changed_files(branch)`` directly,
|
||||
so a branch arg has to flow through ``changed_files``.
|
||||
"""
|
||||
with (
|
||||
patch.object(determine_jobs, "changed_files") as mock_changed,
|
||||
patch.object(
|
||||
determine_jobs, "get_components_with_dependencies", return_value=[]
|
||||
),
|
||||
):
|
||||
mock_changed.return_value = []
|
||||
determine_jobs.native_idf_components_to_test("release")
|
||||
mock_changed.assert_called_once_with("release")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("components_to_test", "expected"),
|
||||
[
|
||||
([], False),
|
||||
(["esp32"], True),
|
||||
(["esp32", "api"], True),
|
||||
],
|
||||
)
|
||||
def test_should_run_native_idf(components_to_test: list[str], expected: bool) -> None:
|
||||
"""should_run_native_idf is a thin wrapper around the component list."""
|
||||
with patch.object(
|
||||
determine_jobs,
|
||||
"native_idf_components_to_test",
|
||||
return_value=components_to_test,
|
||||
):
|
||||
assert determine_jobs.should_run_native_idf() is expected
|
||||
|
||||
|
||||
def test_should_run_native_idf_with_branch() -> None:
|
||||
"""Test should_run_native_idf passes branch argument through."""
|
||||
with patch.object(
|
||||
determine_jobs, "native_idf_components_to_test", return_value=[]
|
||||
) as mock_inner:
|
||||
determine_jobs.should_run_native_idf("release")
|
||||
mock_inner.assert_called_once_with("release")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("changed_files", "expected_result"),
|
||||
[
|
||||
@@ -2215,3 +2375,230 @@ def test_should_run_benchmarks_with_branch() -> None:
|
||||
mock_changed.return_value = []
|
||||
determine_jobs.should_run_benchmarks("release")
|
||||
mock_changed.assert_called_with("release")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _component_change_is_validate_only
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("component", "changed", "expected"),
|
||||
[
|
||||
# Only a base validate file changed.
|
||||
(
|
||||
"foo",
|
||||
["tests/components/foo/validate.esp32-idf.yaml"],
|
||||
True,
|
||||
),
|
||||
# Only a validate variant changed.
|
||||
(
|
||||
"foo",
|
||||
["tests/components/foo/validate-legacy.esp32-idf.yaml"],
|
||||
True,
|
||||
),
|
||||
# Multiple validate files (all validate).
|
||||
(
|
||||
"foo",
|
||||
[
|
||||
"tests/components/foo/validate.esp32-idf.yaml",
|
||||
"tests/components/foo/validate-legacy.esp32-idf.yaml",
|
||||
],
|
||||
True,
|
||||
),
|
||||
# Mixed: validate + regular test must NOT be classified as validate-only.
|
||||
(
|
||||
"foo",
|
||||
[
|
||||
"tests/components/foo/validate.esp32-idf.yaml",
|
||||
"tests/components/foo/test.esp32-idf.yaml",
|
||||
],
|
||||
False,
|
||||
),
|
||||
# Regular test only.
|
||||
(
|
||||
"foo",
|
||||
["tests/components/foo/test.esp32-idf.yaml"],
|
||||
False,
|
||||
),
|
||||
# Source change disqualifies even if a validate file is also touched.
|
||||
(
|
||||
"foo",
|
||||
[
|
||||
"esphome/components/foo/foo.cpp",
|
||||
"tests/components/foo/validate.esp32-idf.yaml",
|
||||
],
|
||||
False,
|
||||
),
|
||||
# No matching files at all.
|
||||
("foo", ["esphome/core/helpers.cpp"], False),
|
||||
# Filenames merely starting with "validate" but not following the
|
||||
# grammar must not match (defensive against accidental classification).
|
||||
(
|
||||
"foo",
|
||||
["tests/components/foo/validatesomething.yaml"],
|
||||
False,
|
||||
),
|
||||
# An unrelated component's validate change doesn't affect this one.
|
||||
(
|
||||
"foo",
|
||||
["tests/components/bar/validate.esp32-idf.yaml"],
|
||||
False,
|
||||
),
|
||||
# common.yaml change in the component dir disqualifies.
|
||||
(
|
||||
"foo",
|
||||
[
|
||||
"tests/components/foo/common.yaml",
|
||||
"tests/components/foo/validate.esp32-idf.yaml",
|
||||
],
|
||||
False,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_component_change_is_validate_only(
|
||||
component: str, changed: list[str], expected: bool
|
||||
) -> None:
|
||||
"""The validate-only classifier rejects anything beyond validate.* edits."""
|
||||
assert (
|
||||
determine_jobs._component_change_is_validate_only(component, changed)
|
||||
is expected
|
||||
)
|
||||
|
||||
|
||||
def test_main_emits_validate_only_components(
|
||||
mock_determine_integration_tests: Mock,
|
||||
mock_should_run_clang_tidy: Mock,
|
||||
mock_should_run_clang_format: Mock,
|
||||
mock_should_run_python_linters: Mock,
|
||||
mock_should_run_import_time: Mock,
|
||||
mock_should_run_device_builder: Mock,
|
||||
mock_changed_files: Mock,
|
||||
mock_determine_cpp_unit_tests: Mock,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Directly-changed components whose only edits are validate.*.yaml are
|
||||
listed in `validate_only_components` so CI can skip their compile stage.
|
||||
"""
|
||||
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
|
||||
|
||||
mock_determine_integration_tests.return_value = (False, [])
|
||||
mock_should_run_clang_tidy.return_value = False
|
||||
mock_should_run_clang_format.return_value = False
|
||||
mock_should_run_python_linters.return_value = False
|
||||
mock_should_run_import_time.return_value = False
|
||||
mock_should_run_device_builder.return_value = False
|
||||
mock_determine_cpp_unit_tests.return_value = (False, [])
|
||||
|
||||
# foo: only validate file changed (qualifies)
|
||||
# bar: test file changed (does not qualify)
|
||||
mock_changed_files.return_value = [
|
||||
"tests/components/foo/validate.esp32-idf.yaml",
|
||||
"tests/components/bar/test.esp32-idf.yaml",
|
||||
]
|
||||
|
||||
with (
|
||||
patch("sys.argv", ["determine-jobs.py"]),
|
||||
patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"get_changed_components",
|
||||
return_value=["foo", "bar"],
|
||||
),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"filter_component_and_test_files",
|
||||
side_effect=lambda f: f.startswith("tests/components/"),
|
||||
),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"get_components_with_dependencies",
|
||||
side_effect=lambda files, deps: ["foo", "bar"],
|
||||
),
|
||||
patch.object(determine_jobs, "_component_has_tests", return_value=True),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"detect_memory_impact_config",
|
||||
return_value={"should_run": "false"},
|
||||
),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"create_intelligent_batches",
|
||||
return_value=([["foo", "bar"]], {}),
|
||||
),
|
||||
):
|
||||
determine_jobs.main()
|
||||
|
||||
output = json.loads(capsys.readouterr().out)
|
||||
assert output["validate_only_components"] == ["foo"]
|
||||
|
||||
|
||||
def test_main_validate_only_excludes_transitive_components(
|
||||
mock_determine_integration_tests: Mock,
|
||||
mock_should_run_clang_tidy: Mock,
|
||||
mock_should_run_clang_format: Mock,
|
||||
mock_should_run_python_linters: Mock,
|
||||
mock_should_run_import_time: Mock,
|
||||
mock_should_run_device_builder: Mock,
|
||||
mock_changed_files: Mock,
|
||||
mock_determine_cpp_unit_tests: Mock,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A component pulled in only as a dependency must NOT be considered
|
||||
validate-only, even if it has no source changes -- its dependency moved,
|
||||
so the compile is still required.
|
||||
"""
|
||||
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
|
||||
|
||||
mock_determine_integration_tests.return_value = (False, [])
|
||||
mock_should_run_clang_tidy.return_value = False
|
||||
mock_should_run_clang_format.return_value = False
|
||||
mock_should_run_python_linters.return_value = False
|
||||
mock_should_run_import_time.return_value = False
|
||||
mock_should_run_device_builder.return_value = False
|
||||
mock_determine_cpp_unit_tests.return_value = (False, [])
|
||||
|
||||
# Only foo's validate file changed directly. bar is a transitive dep.
|
||||
mock_changed_files.return_value = [
|
||||
"tests/components/foo/validate.esp32-idf.yaml",
|
||||
]
|
||||
|
||||
with (
|
||||
patch("sys.argv", ["determine-jobs.py"]),
|
||||
patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"get_changed_components",
|
||||
return_value=["foo", "bar"], # bar pulled in via dependencies
|
||||
),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"filter_component_and_test_files",
|
||||
side_effect=lambda f: f.startswith("tests/components/"),
|
||||
),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"get_components_with_dependencies",
|
||||
# deps=False -> directly_changed = [foo]; deps=True -> [foo, bar]
|
||||
side_effect=lambda files, deps: ["foo", "bar"] if deps else ["foo"],
|
||||
),
|
||||
patch.object(determine_jobs, "_component_has_tests", return_value=True),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"detect_memory_impact_config",
|
||||
return_value={"should_run": "false"},
|
||||
),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"create_intelligent_batches",
|
||||
return_value=([["foo", "bar"]], {}),
|
||||
),
|
||||
):
|
||||
determine_jobs.main()
|
||||
|
||||
output = json.loads(capsys.readouterr().out)
|
||||
# Only foo (directly changed, validate-only). bar is a transitive dep
|
||||
# and still needs compile despite no source change of its own.
|
||||
assert output["validate_only_components"] == ["foo"]
|
||||
|
||||
@@ -1624,3 +1624,171 @@ def test_split_conflicting_groups_preserves_original_signature_for_first_bucket(
|
||||
platform, signature = next(iter(extra))
|
||||
assert platform == "esp32"
|
||||
assert signature.startswith("i2c__conflict")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_component_test_files / is_validate_only_file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_component_tests(tmp_path: Path) -> Path:
|
||||
"""Create a fake tests/components/ tree and return the repo root.
|
||||
|
||||
Layout for component "demo":
|
||||
test.esp32-idf.yaml
|
||||
test.esp8266-ard.yaml
|
||||
test-variant.esp32-idf.yaml
|
||||
validate.esp32-idf.yaml
|
||||
validate-legacy.esp32-idf.yaml
|
||||
|
||||
Layout for component "validate_only":
|
||||
validate.esp32-idf.yaml (only validate files)
|
||||
|
||||
Layout for component "no_tests":
|
||||
common.yaml (no test/validate files at all)
|
||||
"""
|
||||
tests_dir = tmp_path / "tests" / "components"
|
||||
|
||||
demo = tests_dir / "demo"
|
||||
demo.mkdir(parents=True)
|
||||
(demo / "test.esp32-idf.yaml").write_text("")
|
||||
(demo / "test.esp8266-ard.yaml").write_text("")
|
||||
(demo / "test-variant.esp32-idf.yaml").write_text("")
|
||||
(demo / "validate.esp32-idf.yaml").write_text("")
|
||||
(demo / "validate-legacy.esp32-idf.yaml").write_text("")
|
||||
|
||||
validate_only = tests_dir / "validate_only"
|
||||
validate_only.mkdir(parents=True)
|
||||
(validate_only / "validate.esp32-idf.yaml").write_text("")
|
||||
|
||||
no_tests = tests_dir / "no_tests"
|
||||
no_tests.mkdir(parents=True)
|
||||
(no_tests / "common.yaml").write_text("")
|
||||
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _names(paths: list[Path]) -> set[str]:
|
||||
return {p.name for p in paths}
|
||||
|
||||
|
||||
def test_get_component_test_files_default_excludes_validate(
|
||||
fake_component_tests: Path, monkeypatch: MonkeyPatch
|
||||
) -> None:
|
||||
"""Default behaviour: only base test.*.yaml; no variants, no validate."""
|
||||
monkeypatch.setattr(helpers, "root_path", str(fake_component_tests))
|
||||
|
||||
files = helpers.get_component_test_files("demo")
|
||||
|
||||
assert _names(files) == {"test.esp32-idf.yaml", "test.esp8266-ard.yaml"}
|
||||
|
||||
|
||||
def test_get_component_test_files_all_variants_excludes_validate(
|
||||
fake_component_tests: Path, monkeypatch: MonkeyPatch
|
||||
) -> None:
|
||||
"""all_variants=True picks up test variants but still skips validate."""
|
||||
monkeypatch.setattr(helpers, "root_path", str(fake_component_tests))
|
||||
|
||||
files = helpers.get_component_test_files("demo", all_variants=True)
|
||||
|
||||
assert _names(files) == {
|
||||
"test.esp32-idf.yaml",
|
||||
"test.esp8266-ard.yaml",
|
||||
"test-variant.esp32-idf.yaml",
|
||||
}
|
||||
|
||||
|
||||
def test_get_component_test_files_include_validate_base_only(
|
||||
fake_component_tests: Path, monkeypatch: MonkeyPatch
|
||||
) -> None:
|
||||
"""include_validate=True with base-only adds validate.*.yaml only."""
|
||||
monkeypatch.setattr(helpers, "root_path", str(fake_component_tests))
|
||||
|
||||
files = helpers.get_component_test_files("demo", include_validate=True)
|
||||
|
||||
assert _names(files) == {
|
||||
"test.esp32-idf.yaml",
|
||||
"test.esp8266-ard.yaml",
|
||||
"validate.esp32-idf.yaml",
|
||||
}
|
||||
|
||||
|
||||
def test_get_component_test_files_include_validate_all_variants(
|
||||
fake_component_tests: Path, monkeypatch: MonkeyPatch
|
||||
) -> None:
|
||||
"""include_validate=True with all_variants adds validate variants too."""
|
||||
monkeypatch.setattr(helpers, "root_path", str(fake_component_tests))
|
||||
|
||||
files = helpers.get_component_test_files(
|
||||
"demo", all_variants=True, include_validate=True
|
||||
)
|
||||
|
||||
assert _names(files) == {
|
||||
"test.esp32-idf.yaml",
|
||||
"test.esp8266-ard.yaml",
|
||||
"test-variant.esp32-idf.yaml",
|
||||
"validate.esp32-idf.yaml",
|
||||
"validate-legacy.esp32-idf.yaml",
|
||||
}
|
||||
|
||||
|
||||
def test_get_component_test_files_validate_only_component(
|
||||
fake_component_tests: Path, monkeypatch: MonkeyPatch
|
||||
) -> None:
|
||||
"""A component with only validate files is invisible without the flag."""
|
||||
monkeypatch.setattr(helpers, "root_path", str(fake_component_tests))
|
||||
|
||||
assert helpers.get_component_test_files("validate_only") == []
|
||||
assert helpers.get_component_test_files("validate_only", all_variants=True) == []
|
||||
|
||||
files = helpers.get_component_test_files(
|
||||
"validate_only", all_variants=True, include_validate=True
|
||||
)
|
||||
assert _names(files) == {"validate.esp32-idf.yaml"}
|
||||
|
||||
|
||||
def test_get_component_test_files_missing_component(
|
||||
fake_component_tests: Path, monkeypatch: MonkeyPatch
|
||||
) -> None:
|
||||
"""Unknown components return an empty list, regardless of flags."""
|
||||
monkeypatch.setattr(helpers, "root_path", str(fake_component_tests))
|
||||
|
||||
assert (
|
||||
helpers.get_component_test_files(
|
||||
"does_not_exist", all_variants=True, include_validate=True
|
||||
)
|
||||
== []
|
||||
)
|
||||
|
||||
|
||||
def test_get_component_test_files_component_without_tests(
|
||||
fake_component_tests: Path, monkeypatch: MonkeyPatch
|
||||
) -> None:
|
||||
"""A component with only common.yaml and no test/validate files returns []."""
|
||||
monkeypatch.setattr(helpers, "root_path", str(fake_component_tests))
|
||||
|
||||
assert (
|
||||
helpers.get_component_test_files(
|
||||
"no_tests", all_variants=True, include_validate=True
|
||||
)
|
||||
== []
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("filename", "expected"),
|
||||
[
|
||||
("validate.esp32-idf.yaml", True),
|
||||
("validate-legacy.esp32-idf.yaml", True),
|
||||
("validate.host.yaml", True),
|
||||
("test.esp32-idf.yaml", False),
|
||||
("test-variant.esp32-idf.yaml", False),
|
||||
("common.yaml", False),
|
||||
# Defensive: a hypothetical name starting with "validate" but not
|
||||
# following the grammar must not be classified as a validate file.
|
||||
("validatesomething.yaml", False),
|
||||
],
|
||||
)
|
||||
def test_is_validate_only_file(filename: str, expected: bool, tmp_path: Path) -> None:
|
||||
assert helpers.is_validate_only_file(tmp_path / filename) is expected
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Tests for esphome.build_gen.espidf module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.esp32 import (
|
||||
KEY_COMPONENTS,
|
||||
KEY_ESP32,
|
||||
KEY_PATH,
|
||||
KEY_REF,
|
||||
KEY_REPO,
|
||||
)
|
||||
from esphome.const import KEY_CORE
|
||||
from esphome.core import CORE
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_core(tmp_path: Path) -> None:
|
||||
"""Give each test its own CORE.build_path and a clean esp32 data slot."""
|
||||
CORE.build_path = str(tmp_path)
|
||||
CORE.data.setdefault(KEY_CORE, {})
|
||||
CORE.data[KEY_ESP32] = {KEY_COMPONENTS: {}}
|
||||
|
||||
|
||||
def _write_project_description(tmp_path: Path, components: dict[str, str]) -> None:
|
||||
"""Stub a project_description.json with the given component_name -> dir map."""
|
||||
build_dir = tmp_path / "build"
|
||||
build_dir.mkdir(exist_ok=True)
|
||||
(build_dir / "project_description.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"build_component_info": {
|
||||
name: {"dir": dir_} for name, dir_ in components.items()
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_get_available_components_returns_none_without_build_path() -> None:
|
||||
"""No build_path set yet: must not raise on Path(None)."""
|
||||
CORE.build_path = None
|
||||
from esphome.build_gen.espidf import get_available_components
|
||||
|
||||
assert get_available_components() is None
|
||||
|
||||
|
||||
def test_get_available_components_returns_none_without_project_description(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
from esphome.build_gen.espidf import get_available_components
|
||||
|
||||
assert get_available_components() is None
|
||||
|
||||
|
||||
def test_get_available_components_filters_src_managed_and_pio(tmp_path: Path) -> None:
|
||||
"""Built-ins are returned; src/, managed_components/, pio_components/ skipped."""
|
||||
_write_project_description(
|
||||
tmp_path,
|
||||
{
|
||||
"src": f"{tmp_path}/src",
|
||||
"esp_lcd": "/idf/components/esp_lcd",
|
||||
"espressif__arduino-esp32": f"{tmp_path}/managed_components/arduino",
|
||||
"JPEGDEC": f"{tmp_path}/pio_components/arduino/abc/bitbank2/JPEGDEC",
|
||||
"freertos": "/idf/components/freertos",
|
||||
},
|
||||
)
|
||||
from esphome.build_gen.espidf import get_available_components
|
||||
|
||||
assert sorted(get_available_components()) == ["esp_lcd", "freertos"]
|
||||
|
||||
|
||||
def test_get_project_cmakelists_minimal_omits_builtin_components_property(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Minimal write must not emit ESPHOME_PROJECT_BUILTIN_COMPONENTS even
|
||||
when project_description.json exists (the data may be stale on the
|
||||
first write before the discovery pass refreshes it)."""
|
||||
_write_project_description(tmp_path, {"esp_lcd": "/idf/components/esp_lcd"})
|
||||
|
||||
with (
|
||||
patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"),
|
||||
patch.object(CORE, "name", "test"),
|
||||
):
|
||||
from esphome.build_gen.espidf import get_project_cmakelists
|
||||
|
||||
content = get_project_cmakelists(minimal=True)
|
||||
|
||||
assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS" not in content
|
||||
|
||||
|
||||
def test_get_project_cmakelists_full_emits_builtin_components_property(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Non-minimal write emits one idf_build_set_property line per built-in,
|
||||
sorted, and excludes src/managed/pio components."""
|
||||
_write_project_description(
|
||||
tmp_path,
|
||||
{
|
||||
"src": f"{tmp_path}/src",
|
||||
"esp_lcd": "/idf/components/esp_lcd",
|
||||
"freertos": "/idf/components/freertos",
|
||||
"espressif__esp-dsp": f"{tmp_path}/managed_components/esp-dsp",
|
||||
"JPEGDEC": f"{tmp_path}/pio_components/arduino/abc/bitbank2/JPEGDEC",
|
||||
},
|
||||
)
|
||||
|
||||
with (
|
||||
patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"),
|
||||
patch.object(CORE, "name", "test"),
|
||||
):
|
||||
from esphome.build_gen.espidf import get_project_cmakelists
|
||||
|
||||
content = get_project_cmakelists(minimal=False)
|
||||
|
||||
assert (
|
||||
"idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS esp_lcd APPEND)"
|
||||
in content
|
||||
)
|
||||
assert (
|
||||
"idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS freertos APPEND)"
|
||||
in content
|
||||
)
|
||||
# Excluded by get_available_components filtering.
|
||||
assert "espressif__esp-dsp APPEND" not in content
|
||||
assert "JPEGDEC APPEND" not in content
|
||||
|
||||
|
||||
def test_get_project_cmakelists_emits_managed_components_property(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""ESPHOME_PROJECT_MANAGED_COMPONENTS is always emitted (both modes)
|
||||
from the esp32 add_idf_component registry."""
|
||||
CORE.data[KEY_ESP32][KEY_COMPONENTS] = {
|
||||
"espressif/esp-dsp": {KEY_REPO: None, KEY_REF: "1.7.1", KEY_PATH: None},
|
||||
"espressif/arduino-esp32": {KEY_REPO: None, KEY_REF: "3.3.8", KEY_PATH: None},
|
||||
}
|
||||
|
||||
with (
|
||||
patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"),
|
||||
patch.object(CORE, "name", "test"),
|
||||
):
|
||||
from esphome.build_gen.espidf import get_project_cmakelists
|
||||
|
||||
for minimal in (True, False):
|
||||
content = get_project_cmakelists(minimal=minimal)
|
||||
assert (
|
||||
"idf_build_set_property(ESPHOME_PROJECT_MANAGED_COMPONENTS"
|
||||
" espressif__arduino-esp32 APPEND)"
|
||||
) in content
|
||||
assert (
|
||||
"idf_build_set_property(ESPHOME_PROJECT_MANAGED_COMPONENTS"
|
||||
" espressif__esp-dsp APPEND)"
|
||||
) in content
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Tests for RP2040 component public helpers."""
|
||||
|
||||
from esphome.components.rp2040 import board_id_has_wifi
|
||||
|
||||
|
||||
def test_board_id_has_wifi_for_known_wifi_board() -> None:
|
||||
"""``rpipicow`` is the canonical Pico W → True."""
|
||||
assert board_id_has_wifi("rpipicow") is True
|
||||
|
||||
|
||||
def test_board_id_has_wifi_for_known_non_wifi_board() -> None:
|
||||
"""Plain ``rpipico`` has no CYW43 → False."""
|
||||
assert board_id_has_wifi("rpipico") is False
|
||||
|
||||
|
||||
def test_board_id_has_wifi_for_rp2350_w_variant() -> None:
|
||||
"""``rpipico2w`` is the RP2350 Pico 2 W → True."""
|
||||
assert board_id_has_wifi("rpipico2w") is True
|
||||
|
||||
|
||||
def test_board_id_has_wifi_for_unknown_board_returns_true() -> None:
|
||||
"""Unknown ids fail open so a custom board is not rejected.
|
||||
|
||||
The validator falls back to ESPHome's compile-time check; the
|
||||
helper returning True here means the wizard emits a ``wifi:``
|
||||
block and any genuinely-unsupported config trips the existing
|
||||
"no CYW43" guard at compile time.
|
||||
"""
|
||||
assert board_id_has_wifi("not-a-real-board-id") is True
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Tests for WiFi component public helpers."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.esp32 import const
|
||||
from esphome.components.wifi import has_native_wifi, variant_has_wifi
|
||||
from esphome.const import Platform
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"variant",
|
||||
[
|
||||
# Upstream's canonical uppercase form.
|
||||
const.VARIANT_ESP32,
|
||||
const.VARIANT_ESP32S2,
|
||||
const.VARIANT_ESP32S3,
|
||||
const.VARIANT_ESP32C3,
|
||||
const.VARIANT_ESP32C6,
|
||||
# Lowercase form external callers (e.g. device-builder's
|
||||
# ``Esp32Variant`` StrEnum) surface.
|
||||
"esp32",
|
||||
"esp32s3",
|
||||
"esp32c3",
|
||||
# Mixed-case — defence in depth against future callers that
|
||||
# pull the value off some other serialisation.
|
||||
"Esp32",
|
||||
],
|
||||
)
|
||||
def test_variant_has_wifi_for_native_phy_variants(variant: str) -> None:
|
||||
"""Variants with a native WiFi PHY → True, case-insensitive."""
|
||||
assert variant_has_wifi(variant) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"variant",
|
||||
[
|
||||
# Upstream's canonical uppercase form.
|
||||
const.VARIANT_ESP32H2,
|
||||
const.VARIANT_ESP32P4,
|
||||
# Lowercase form external callers (e.g. device-builder's
|
||||
# ``Esp32Variant`` StrEnum) surface.
|
||||
"esp32h2",
|
||||
"esp32p4",
|
||||
# Mixed-case — defence in depth against future callers that
|
||||
# pull the value off some other serialisation.
|
||||
"Esp32H2",
|
||||
],
|
||||
)
|
||||
def test_variant_has_wifi_for_no_phy_variants(variant: str) -> None:
|
||||
"""Variants that need ``esp32_hosted`` → False, case-insensitive."""
|
||||
assert variant_has_wifi(variant) is False
|
||||
|
||||
|
||||
def test_has_native_wifi_dispatches_esp32_to_variant_check() -> None:
|
||||
"""ESP32 platform routes through ``variant_has_wifi``."""
|
||||
assert (
|
||||
has_native_wifi(platform=Platform.ESP32, variant=const.VARIANT_ESP32C3) is True
|
||||
)
|
||||
assert (
|
||||
has_native_wifi(platform=Platform.ESP32, variant=const.VARIANT_ESP32H2) is False
|
||||
)
|
||||
|
||||
|
||||
def test_has_native_wifi_esp32_variant_case_insensitive() -> None:
|
||||
"""has_native_wifi accepts lowercase variant input.
|
||||
|
||||
External callers (device-builder's wizard, etc.) may surface
|
||||
variant strings from their own enums that don't match upstream's
|
||||
uppercase convention. The dispatcher should classify them
|
||||
identically.
|
||||
"""
|
||||
assert has_native_wifi(platform=Platform.ESP32, variant="esp32h2") is False
|
||||
assert has_native_wifi(platform=Platform.ESP32, variant="esp32c3") is True
|
||||
|
||||
|
||||
def test_has_native_wifi_dispatches_rp2040_to_board_check() -> None:
|
||||
"""RP2040 platform routes through ``rp2040.board_id_has_wifi``."""
|
||||
assert has_native_wifi(platform=Platform.RP2040, board="rpipicow") is True
|
||||
assert has_native_wifi(platform=Platform.RP2040, board="rpipico") is False
|
||||
|
||||
|
||||
def test_has_native_wifi_returns_false_for_nrf52() -> None:
|
||||
"""nRF52 family is BLE-only — no Wi-Fi PHY in the platform."""
|
||||
assert has_native_wifi(platform=Platform.NRF52) is False
|
||||
|
||||
|
||||
def test_has_native_wifi_returns_false_for_host() -> None:
|
||||
"""``host`` platform compiles ESPHome to a host binary — no radio at all."""
|
||||
assert has_native_wifi(platform=Platform.HOST) is False
|
||||
|
||||
|
||||
def test_has_native_wifi_returns_false_for_unknown_platform() -> None:
|
||||
"""Unknown platform string fails closed.
|
||||
|
||||
A future platform added to ESPHome that's missed here returns
|
||||
False rather than silently emitting a ``wifi:`` block external
|
||||
tooling would have to compile and reject — fail-closed surfaces
|
||||
the gap as an obvious "needs wifi support added" signal.
|
||||
"""
|
||||
assert has_native_wifi(platform="not-a-real-platform") is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"platform",
|
||||
[
|
||||
Platform.ESP8266,
|
||||
Platform.BK72XX,
|
||||
Platform.RTL87XX,
|
||||
Platform.LN882X,
|
||||
Platform.LIBRETINY_OLDSTYLE,
|
||||
],
|
||||
)
|
||||
def test_has_native_wifi_returns_true_for_wifi_first_platforms(platform: str) -> None:
|
||||
"""Catch-all Wi-Fi-first platforms → True regardless of board / variant."""
|
||||
assert has_native_wifi(platform=platform) is True
|
||||
|
||||
|
||||
def test_has_native_wifi_esp32_without_variant_assumes_wifi() -> None:
|
||||
"""ESP32 without a variant id falls open to True (the chip family default)."""
|
||||
assert has_native_wifi(platform=Platform.ESP32) is True
|
||||
|
||||
|
||||
def test_has_native_wifi_rp2040_without_board_assumes_wifi() -> None:
|
||||
"""RP2040 without a board id falls open to True (custom-board default)."""
|
||||
assert has_native_wifi(platform=Platform.RP2040) is True
|
||||
@@ -64,15 +64,15 @@ def mock_copy_file_if_changed() -> Generator[Mock, None, None]:
|
||||
|
||||
@pytest.fixture
|
||||
def mock_run_platformio_cli() -> Generator[Mock, None, None]:
|
||||
"""Mock run_platformio_cli for platformio_api."""
|
||||
with patch("esphome.platformio_api.run_platformio_cli") as mock:
|
||||
"""Mock run_platformio_cli for platformio toolchain."""
|
||||
with patch("esphome.platformio.toolchain.run_platformio_cli") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_run_platformio_cli_run() -> Generator[Mock, None, None]:
|
||||
"""Mock run_platformio_cli_run for platformio_api."""
|
||||
with patch("esphome.platformio_api.run_platformio_cli_run") as mock:
|
||||
"""Mock run_platformio_cli_run for platformio toolchain."""
|
||||
with patch("esphome.platformio.toolchain.run_platformio_cli_run") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@@ -92,8 +92,8 @@ def mock_esp8266_decode_pc() -> Generator[Mock, None, None]:
|
||||
|
||||
@pytest.fixture
|
||||
def mock_run_external_process() -> Generator[Mock, None, None]:
|
||||
"""Mock run_external_process for platformio_api."""
|
||||
with patch("esphome.platformio_api.run_external_process") as mock:
|
||||
"""Mock run_external_process for platformio toolchain."""
|
||||
with patch("esphome.platformio.toolchain.run_external_process") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@@ -113,8 +113,8 @@ def mock_subprocess_run() -> Generator[Mock, None, None]:
|
||||
|
||||
@pytest.fixture
|
||||
def mock_get_idedata() -> Generator[Mock, None, None]:
|
||||
"""Mock get_idedata for platformio_api."""
|
||||
with patch("esphome.platformio_api.get_idedata") as mock:
|
||||
"""Mock get_idedata for platformio toolchain."""
|
||||
with patch("esphome.platformio.toolchain.get_idedata") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Tests for the validated-config cache used by upload/logs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.__main__ import run_esphome
|
||||
from esphome.compiled_config import (
|
||||
compiled_config_path,
|
||||
load_compiled_config,
|
||||
save_compiled_config,
|
||||
)
|
||||
from esphome.const import (
|
||||
CONF_API,
|
||||
CONF_ESPHOME,
|
||||
CONF_NAME,
|
||||
KEY_CORE,
|
||||
KEY_TARGET_FRAMEWORK,
|
||||
KEY_TARGET_PLATFORM,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
|
||||
_VALIDATED_CONFIG_YAML = """\
|
||||
esphome:
|
||||
name: lite_test
|
||||
friendly_name: Lite Test Device
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
logger:
|
||||
baud_rate: 115200
|
||||
api:
|
||||
port: 6053
|
||||
encryption:
|
||||
key: 6dGhpcyBpcyBhIHRlc3Q=
|
||||
ota:
|
||||
- platform: esphome
|
||||
port: 3232
|
||||
password: secret
|
||||
wifi:
|
||||
ssid: ssid
|
||||
use_address: 192.168.1.42
|
||||
"""
|
||||
|
||||
|
||||
def _write_storage(storage_path: Path) -> None:
|
||||
"""Write a vanilla StorageJSON sidecar for the cache tests."""
|
||||
storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = {
|
||||
"storage_version": 1,
|
||||
"name": "lite_test",
|
||||
"friendly_name": "Lite Test Device",
|
||||
"comment": None,
|
||||
"esphome_version": "2026.1.0",
|
||||
"src_version": 1,
|
||||
"address": "192.168.1.42",
|
||||
"web_port": None,
|
||||
"esp_platform": "ESP32",
|
||||
"build_path": "/build/lite_test",
|
||||
"firmware_bin_path": "/build/lite_test/firmware.bin",
|
||||
"loaded_integrations": ["api", "logger", "ota", "wifi"],
|
||||
"loaded_platforms": [],
|
||||
"no_mdns": False,
|
||||
"framework": "arduino",
|
||||
"core_platform": "esp32",
|
||||
}
|
||||
storage_path.write_text(json.dumps(data))
|
||||
|
||||
|
||||
def _write_cache(cache_path: Path, body: str = _VALIDATED_CONFIG_YAML) -> Path:
|
||||
"""Write the cache file and return it."""
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache_path.write_text(body)
|
||||
return cache_path
|
||||
|
||||
|
||||
def _set_cache_mtime(cache_path: Path, yaml_path: Path, *, offset: int) -> None:
|
||||
"""Force the cache file's mtime relative to the source YAML.
|
||||
|
||||
Positive offset → cache is fresh. Negative → cache is stale.
|
||||
"""
|
||||
yaml_stat = yaml_path.stat()
|
||||
os.utime(cache_path, (yaml_stat.st_atime, yaml_stat.st_mtime + offset))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_cache_files(tmp_path: Path) -> Path:
|
||||
"""YAML + StorageJSON + cache, all consistent and fresh."""
|
||||
yaml_path = tmp_path / "lite_test.yaml"
|
||||
yaml_path.write_text("esphome:\n name: lite_test\n")
|
||||
CORE.config_path = yaml_path
|
||||
|
||||
storage_dir = tmp_path / ".esphome" / "storage"
|
||||
_write_storage(storage_dir / "lite_test.yaml.json")
|
||||
cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml")
|
||||
_set_cache_mtime(cache, yaml_path, offset=5)
|
||||
|
||||
return yaml_path
|
||||
|
||||
|
||||
def test_compiled_config_path_lives_alongside_sidecar(setup_core: Path) -> None:
|
||||
"""The cache file shape is predictable from the YAML filename."""
|
||||
path = compiled_config_path("device.yaml")
|
||||
assert path.name == "device.yaml.validated.yaml"
|
||||
assert path.parent.name == "storage"
|
||||
|
||||
|
||||
def test_load_compiled_config_happy_path(fresh_cache_files: Path) -> None:
|
||||
"""Fresh cache + sidecar → returns config and populates CORE."""
|
||||
config = load_compiled_config(fresh_cache_files)
|
||||
|
||||
assert config is not None
|
||||
assert config[CONF_ESPHOME][CONF_NAME] == "lite_test"
|
||||
assert config[CONF_API]["encryption"]["key"] == "6dGhpcyBpcyBhIHRlc3Q="
|
||||
assert config["ota"][0]["password"] == "secret"
|
||||
|
||||
# apply_to_core populated exactly what upload/logs read off CORE.
|
||||
assert CORE.name == "lite_test"
|
||||
assert CORE.build_path == Path("/build/lite_test")
|
||||
assert CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] == "esp32"
|
||||
assert CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] == "arduino"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"scenario",
|
||||
["missing_cache", "stale_cache", "corrupt_cache", "missing_sidecar"],
|
||||
)
|
||||
def test_load_compiled_config_falls_back(tmp_path: Path, scenario: str) -> None:
|
||||
"""All non-happy cases return None so the caller falls back."""
|
||||
yaml_path = tmp_path / "lite_test.yaml"
|
||||
yaml_path.write_text("esphome:\n name: lite_test\n")
|
||||
CORE.config_path = yaml_path
|
||||
storage_dir = tmp_path / ".esphome" / "storage"
|
||||
cache_path = storage_dir / "lite_test.yaml.validated.yaml"
|
||||
sidecar_path = storage_dir / "lite_test.yaml.json"
|
||||
|
||||
if scenario == "missing_cache":
|
||||
pass # no cache, no sidecar
|
||||
elif scenario == "stale_cache":
|
||||
_write_storage(sidecar_path)
|
||||
_set_cache_mtime(_write_cache(cache_path), yaml_path, offset=-60)
|
||||
elif scenario == "corrupt_cache":
|
||||
_write_storage(sidecar_path)
|
||||
_set_cache_mtime(
|
||||
_write_cache(cache_path, "not: valid: yaml: ["), yaml_path, offset=5
|
||||
)
|
||||
elif scenario == "missing_sidecar":
|
||||
# Cache fresh + parseable, but no StorageJSON → can't populate CORE.
|
||||
_set_cache_mtime(_write_cache(cache_path), yaml_path, offset=5)
|
||||
|
||||
assert load_compiled_config(yaml_path) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command", ["upload", "logs"])
|
||||
def test_run_esphome_upload_and_logs_use_cache_when_fresh(
|
||||
command: str,
|
||||
fresh_cache_files: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""upload/logs skip read_config() when the cache is fresh."""
|
||||
captured: dict = {}
|
||||
|
||||
def _stub(_args, config):
|
||||
captured["config"] = config
|
||||
return 0
|
||||
|
||||
with (
|
||||
caplog.at_level("INFO", logger="esphome.__main__"),
|
||||
patch("esphome.__main__.read_config") as mock_read,
|
||||
patch.dict("esphome.__main__.POST_CONFIG_ACTIONS", {command: _stub}),
|
||||
):
|
||||
assert run_esphome(["esphome", command, str(fresh_cache_files)]) == 0
|
||||
|
||||
mock_read.assert_not_called()
|
||||
assert captured["config"][CONF_ESPHOME][CONF_NAME] == "lite_test"
|
||||
assert captured["config"][CONF_API]["encryption"]["key"] == "6dGhpcyBpcyBhIHRlc3Q="
|
||||
# The success-branch log line is part of the patch; assert on it so
|
||||
# branch coverage stays unambiguous in CI.
|
||||
assert "Loaded validated config cache" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command", ["upload", "logs"])
|
||||
def test_run_esphome_upload_and_logs_fall_back_when_no_cache(
|
||||
tmp_path: Path, command: str
|
||||
) -> None:
|
||||
"""Without a cache, the dispatcher falls back to read_config()."""
|
||||
yaml_path = tmp_path / "lite_test.yaml"
|
||||
yaml_path.write_text("esphome:\n name: lite_test\n")
|
||||
|
||||
with (
|
||||
patch("esphome.__main__.read_config", return_value=None) as mock_read,
|
||||
patch.dict(
|
||||
"esphome.__main__.POST_CONFIG_ACTIONS",
|
||||
{command: lambda args, config: 0},
|
||||
),
|
||||
):
|
||||
assert run_esphome(["esphome", command, str(yaml_path)]) == 2
|
||||
|
||||
mock_read.assert_called_once()
|
||||
|
||||
|
||||
def test_run_esphome_upload_with_substitution_skips_cache(
|
||||
fresh_cache_files: Path,
|
||||
) -> None:
|
||||
"""`-s key value` forces a fresh validation -- the cache was written
|
||||
against the prior substitution set, so reusing it would silently
|
||||
ignore the override."""
|
||||
with (
|
||||
patch("esphome.__main__.read_config", return_value=None) as mock_read,
|
||||
patch.dict(
|
||||
"esphome.__main__.POST_CONFIG_ACTIONS",
|
||||
{"upload": lambda args, config: 0},
|
||||
),
|
||||
):
|
||||
run_esphome(["esphome", "-s", "var", "val", "upload", str(fresh_cache_files)])
|
||||
|
||||
mock_read.assert_called_once()
|
||||
|
||||
|
||||
def test_run_esphome_compile_does_not_use_cache(fresh_cache_files: Path) -> None:
|
||||
"""The compile subcommand always re-validates -- it's what writes the cache."""
|
||||
with (
|
||||
patch("esphome.__main__.read_config", return_value=None) as mock_read,
|
||||
patch.dict(
|
||||
"esphome.__main__.POST_CONFIG_ACTIONS",
|
||||
{"compile": lambda args, config: 0},
|
||||
),
|
||||
):
|
||||
run_esphome(["esphome", "compile", str(fresh_cache_files)])
|
||||
|
||||
mock_read.assert_called_once()
|
||||
|
||||
|
||||
def test_save_compiled_config_writes_cache(tmp_path: Path) -> None:
|
||||
"""`save_compiled_config` writes the dumped YAML next to the sidecar."""
|
||||
CORE.config_path = tmp_path / "lite_test.yaml"
|
||||
save_compiled_config({"esphome": {"name": "lite_test"}, "logger": {}})
|
||||
|
||||
cache_path = compiled_config_path("lite_test.yaml")
|
||||
assert cache_path.is_file()
|
||||
body = cache_path.read_text()
|
||||
assert "name: lite_test" in body
|
||||
assert "logger:" in body
|
||||
|
||||
|
||||
def test_save_compiled_config_swallows_dump_errors(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Failures during the dump are non-fatal -- a bad cache just means
|
||||
the next fast path falls back to read_config()."""
|
||||
CORE.config_path = tmp_path / "lite_test.yaml"
|
||||
with patch("esphome.yaml_util.dump", side_effect=RuntimeError("boom")):
|
||||
save_compiled_config({"esphome": {"name": "lite_test"}})
|
||||
assert not compiled_config_path("lite_test.yaml").exists()
|
||||
|
||||
|
||||
def test_load_compiled_config_rejects_wizard_only_sidecar(tmp_path: Path) -> None:
|
||||
"""A wizard-only sidecar (no compile -- no core_platform / target_platform)
|
||||
can't drive upload/logs, so the fast path falls back."""
|
||||
yaml_path = tmp_path / "lite_test.yaml"
|
||||
yaml_path.write_text("esphome:\n name: lite_test\n")
|
||||
CORE.config_path = yaml_path
|
||||
|
||||
storage_dir = tmp_path / ".esphome" / "storage"
|
||||
storage_dir.mkdir(parents=True, exist_ok=True)
|
||||
# StorageJSON with both core_platform and target_platform unset.
|
||||
(storage_dir / "lite_test.yaml.json").write_text(
|
||||
'{"storage_version": 1, "name": "lite_test", "friendly_name": null, '
|
||||
'"comment": null, "esphome_version": null, "src_version": 1, '
|
||||
'"address": null, "web_port": null, "esp_platform": null, '
|
||||
'"build_path": null, "firmware_bin_path": null, '
|
||||
'"loaded_integrations": [], "loaded_platforms": [], "no_mdns": false, '
|
||||
'"framework": null, "core_platform": null}'
|
||||
)
|
||||
cache_path = _write_cache(storage_dir / "lite_test.yaml.validated.yaml")
|
||||
_set_cache_mtime(cache_path, yaml_path, offset=5)
|
||||
|
||||
assert load_compiled_config(yaml_path) is None
|
||||
@@ -793,3 +793,187 @@ def test_update_interval__never_passes_through() -> None:
|
||||
"""update_interval: never must still map to SCHEDULER_DONT_RUN."""
|
||||
result = config_validation.update_interval("never")
|
||||
assert result.total_milliseconds == SCHEDULER_DONT_RUN
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Visibility UI-hint kwarg
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_optional_default_visibility_is_none() -> None:
|
||||
"""An ``Optional`` with no ``visibility`` kwarg reports ``None``.
|
||||
|
||||
Consumers can read the attribute directly with plain attribute
|
||||
access; absence (``None``) means "render on the editor's main
|
||||
form."
|
||||
"""
|
||||
o = config_validation.Optional("foo")
|
||||
assert o.visibility is None
|
||||
|
||||
|
||||
def test_optional_visibility_advanced() -> None:
|
||||
"""``visibility=Visibility.ADVANCED`` is recorded on the marker."""
|
||||
o = config_validation.Optional(
|
||||
"foo", visibility=config_validation.Visibility.ADVANCED
|
||||
)
|
||||
assert o.visibility is config_validation.Visibility.ADVANCED
|
||||
|
||||
|
||||
def test_optional_visibility_yaml_only() -> None:
|
||||
"""``visibility=Visibility.YAML_ONLY`` is recorded on the marker."""
|
||||
o = config_validation.Optional(
|
||||
"foo", visibility=config_validation.Visibility.YAML_ONLY
|
||||
)
|
||||
assert o.visibility is config_validation.Visibility.YAML_ONLY
|
||||
|
||||
|
||||
def test_visibility_str_values_match_dump_emission() -> None:
|
||||
"""``Visibility`` is a ``StrEnum`` whose values are the literal
|
||||
strings the schema dumper emits.
|
||||
|
||||
The schema bundle consumers (catalog generators, third-party
|
||||
schema-aware tooling) shouldn't need an enum import to read the
|
||||
field — pinning the on-the-wire spelling here keeps the dump
|
||||
contract stable.
|
||||
"""
|
||||
assert str(config_validation.Visibility.ADVANCED) == "advanced"
|
||||
assert str(config_validation.Visibility.YAML_ONLY) == "yaml_only"
|
||||
|
||||
|
||||
def test_optional_visibility_does_not_affect_validation() -> None:
|
||||
"""The kwarg is an advisory UI hint — it must not change how the
|
||||
validator behaves. A schema with ``visibility`` applied must
|
||||
accept and reject the same values it would without it.
|
||||
"""
|
||||
plain = config_validation.Schema(
|
||||
{config_validation.Optional("foo", default=42): config_validation.int_}
|
||||
)
|
||||
flagged = config_validation.Schema(
|
||||
{
|
||||
config_validation.Optional(
|
||||
"foo",
|
||||
default=42,
|
||||
visibility=config_validation.Visibility.YAML_ONLY,
|
||||
): config_validation.int_
|
||||
}
|
||||
)
|
||||
# Same accept / default-fill behavior.
|
||||
assert plain({"foo": 7}) == flagged({"foo": 7}) == {"foo": 7}
|
||||
assert plain({}) == flagged({}) == {"foo": 42}
|
||||
# Same rejection on bad input.
|
||||
with pytest.raises(Invalid):
|
||||
plain({"foo": "not-an-int"})
|
||||
with pytest.raises(Invalid):
|
||||
flagged({"foo": "not-an-int"})
|
||||
|
||||
|
||||
def test_required_default_visibility_is_none() -> None:
|
||||
"""``Required`` mirrors ``Optional`` for the ``visibility`` kwarg."""
|
||||
r = config_validation.Required("foo")
|
||||
assert r.visibility is None
|
||||
|
||||
|
||||
def test_required_visibility_kwarg() -> None:
|
||||
"""``Required`` accepts ``visibility`` for symmetry with ``Optional``.
|
||||
|
||||
Required fields rarely need the kwarg, but exposing it lets
|
||||
consumers apply uniform logic across key markers.
|
||||
"""
|
||||
r = config_validation.Required(
|
||||
"foo", visibility=config_validation.Visibility.ADVANCED
|
||||
)
|
||||
assert r.visibility is config_validation.Visibility.ADVANCED
|
||||
|
||||
|
||||
def test_polling_component_schema_visibility_opt_in() -> None:
|
||||
"""``visibility=`` propagates to the inherited ``update_interval``.
|
||||
|
||||
Time platforms pass ``Visibility.ADVANCED``; sensors and other
|
||||
polling components leave it ``None`` and keep the un-flagged shape.
|
||||
"""
|
||||
default = config_validation.polling_component_schema("15min")
|
||||
advanced = config_validation.polling_component_schema(
|
||||
"15min", visibility=config_validation.Visibility.ADVANCED
|
||||
)
|
||||
default_keys = {str(k): k for k in default.schema}
|
||||
advanced_keys = {str(k): k for k in advanced.schema}
|
||||
assert default_keys["update_interval"].visibility is None
|
||||
assert (
|
||||
advanced_keys["update_interval"].visibility
|
||||
is config_validation.Visibility.ADVANCED
|
||||
)
|
||||
# The opt-in only touches update_interval — setup_priority
|
||||
# still inherits its YAML_ONLY visibility from COMPONENT_SCHEMA
|
||||
# in both shapes.
|
||||
assert (
|
||||
default_keys["setup_priority"].visibility
|
||||
is config_validation.Visibility.YAML_ONLY
|
||||
)
|
||||
assert (
|
||||
advanced_keys["setup_priority"].visibility
|
||||
is config_validation.Visibility.YAML_ONLY
|
||||
)
|
||||
|
||||
|
||||
def test_polling_component_schema_no_default_ignores_visibility() -> None:
|
||||
"""``visibility`` is silently ignored when the field is Required.
|
||||
|
||||
When ``default_update_interval=None`` the field becomes
|
||||
``Required``. Hiding a Required field behind an advanced
|
||||
disclosure is a UX hazard — a collapsed-by-default editor could
|
||||
let the user submit without noticing the form has an unfilled
|
||||
required field. The helper accepts the kwarg unconditionally
|
||||
for caller ergonomics but doesn't honour it on this branch.
|
||||
"""
|
||||
schema = config_validation.polling_component_schema(
|
||||
None, visibility=config_validation.Visibility.ADVANCED
|
||||
)
|
||||
keys = {str(k): k for k in schema.schema}
|
||||
assert isinstance(keys["update_interval"], config_validation.Required)
|
||||
assert keys["update_interval"].visibility is None
|
||||
|
||||
|
||||
def test_visibility_marker_is_per_field_no_mutation() -> None:
|
||||
"""Each field's ``visibility`` is recorded as the author wrote it.
|
||||
|
||||
Cascading semantics — "a stricter parent forces its descendants
|
||||
at-least as strict" — live on the consumer side, not in the
|
||||
marker itself. The schema marker stays as-written so consumers
|
||||
can walk the parent chain and compute the effective visibility
|
||||
themselves; mutating the marker would lose the per-field author
|
||||
intent.
|
||||
|
||||
Pin both directions of the no-mutation contract: an inner
|
||||
``YAML_ONLY`` under an ``ADVANCED`` parent stays ``YAML_ONLY``
|
||||
on the marker (the consumer's effective-visibility cascade
|
||||
would also report ``YAML_ONLY`` since it's stricter), and an
|
||||
un-marked inner field stays ``None`` on the marker (the
|
||||
cascade's job is to compute ``ADVANCED`` from the parent — a
|
||||
detail this test deliberately doesn't pin, since it's a
|
||||
consumer concern).
|
||||
"""
|
||||
inner_unset = config_validation.Optional("baz")
|
||||
inner_yaml_only = config_validation.Optional(
|
||||
"qux", visibility=config_validation.Visibility.YAML_ONLY
|
||||
)
|
||||
parent = config_validation.Optional(
|
||||
"foo", visibility=config_validation.Visibility.ADVANCED
|
||||
)
|
||||
|
||||
# Wire them into a nested schema — none of the markers' own
|
||||
# ``visibility`` should change as a result.
|
||||
schema = config_validation.Schema(
|
||||
{
|
||||
parent: config_validation.Schema(
|
||||
{
|
||||
inner_unset: config_validation.int_,
|
||||
inner_yaml_only: config_validation.string,
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
assert schema # touch the schema so any deferred mutation runs
|
||||
|
||||
assert parent.visibility is config_validation.Visibility.ADVANCED
|
||||
assert inner_unset.visibility is None
|
||||
assert inner_yaml_only.visibility is config_validation.Visibility.YAML_ONLY
|
||||
|
||||
@@ -591,6 +591,30 @@ class TestEsphomeCore:
|
||||
assert target.is_esp32 is False
|
||||
assert target.is_esp8266 is True
|
||||
|
||||
def test_firmware_bin__default(self, target):
|
||||
"""Default platforms produce <pioenvs>/<name>/firmware.bin."""
|
||||
target.name = "test-device"
|
||||
target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "esp32"}
|
||||
assert target.firmware_bin == Path(
|
||||
"foo/build/.pioenvs/test-device/firmware.bin"
|
||||
)
|
||||
|
||||
def test_firmware_bin__libretiny(self, target):
|
||||
"""The libretiny platform produces firmware.uf2."""
|
||||
target.name = "test-device"
|
||||
target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "bk72xx"}
|
||||
assert target.firmware_bin == Path(
|
||||
"foo/build/.pioenvs/test-device/firmware.uf2"
|
||||
)
|
||||
|
||||
def test_firmware_bin__host(self, target):
|
||||
"""Host platform produces a native ELF/Mach-O named `program`,
|
||||
not firmware.bin -- needed for `esphome upload` to find the
|
||||
right artifact for the host OTA backend."""
|
||||
target.name = "test-device"
|
||||
target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "host"}
|
||||
assert target.firmware_bin == Path("foo/build/.pioenvs/test-device/program")
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="Unix-specific test")
|
||||
def test_data_dir_default_unix(self, target):
|
||||
"""Test data_dir returns .esphome in config directory by default on Unix."""
|
||||
@@ -853,6 +877,23 @@ class TestEsphomeCore:
|
||||
target.testing_ensure_platform_registered("sensor")
|
||||
assert target.platform_counts["sensor"] == 3
|
||||
|
||||
def test_bootloader_bin__native_idf(self, target):
|
||||
"""Native ESP-IDF builds emit the bootloader under build/bootloader/bootloader.bin."""
|
||||
target.toolchain = const.Toolchain.ESP_IDF
|
||||
|
||||
assert target.bootloader_bin == Path(
|
||||
"foo/build/build/bootloader/bootloader.bin"
|
||||
)
|
||||
|
||||
def test_bootloader_bin__platformio(self, target):
|
||||
"""For PlatformIO builds bootloader.bin lives in the env-specific .pioenvs directory."""
|
||||
target.name = "test-device"
|
||||
target.toolchain = const.Toolchain.PLATFORMIO
|
||||
|
||||
assert target.bootloader_bin == Path(
|
||||
"foo/build/.pioenvs/test-device/bootloader.bin"
|
||||
)
|
||||
|
||||
def test_add_library__extracts_short_name_from_path(self, target):
|
||||
"""Test add_library extracts short name from library paths like owner/lib."""
|
||||
target.data[const.KEY_CORE] = {
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.const import (
|
||||
KEY_CORE,
|
||||
KEY_TARGET_FRAMEWORK,
|
||||
KEY_TARGET_PLATFORM,
|
||||
Framework,
|
||||
Platform,
|
||||
)
|
||||
from esphome.core import CORE, Library
|
||||
import esphome.espidf.component
|
||||
from esphome.espidf.component import (
|
||||
GitSource,
|
||||
IDFComponent,
|
||||
InvalidIDFComponent,
|
||||
URLSource,
|
||||
_check_library_data,
|
||||
_collect_filtered_files,
|
||||
_convert_library_to_component,
|
||||
_parse_library_json,
|
||||
_parse_library_properties,
|
||||
_process_dependencies,
|
||||
_split_list_by_condition,
|
||||
generate_cmakelists_txt,
|
||||
generate_idf_component_yml,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="tmp_component")
|
||||
def fixture_tmp_component(tmp_path):
|
||||
c = IDFComponent("owner/name", "1.0.0", source=MagicMock())
|
||||
c.path = tmp_path
|
||||
return c
|
||||
|
||||
|
||||
@pytest.fixture(name="esp32_idf_core")
|
||||
def fixture_esp32_idf_core():
|
||||
CORE.data[KEY_CORE] = {}
|
||||
CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = str(Platform.ESP32)
|
||||
CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = str(Framework.ESP_IDF)
|
||||
|
||||
|
||||
def test_idf_component_str():
|
||||
c = IDFComponent("foo/bar", "1.0", source=URLSource("http://dummy.com"))
|
||||
assert str(c) == "foo/bar@1.0=http://dummy.com"
|
||||
|
||||
|
||||
def test_idf_component_sanitized_name():
|
||||
c = IDFComponent("foo/bar bar-bar", "1.0", source=URLSource("http://dummy.com"))
|
||||
assert c.get_sanitized_name() == "foo/bar_bar-bar"
|
||||
|
||||
|
||||
def test_idf_component_require_name():
|
||||
c = IDFComponent("foo/bar", "1.0", source=URLSource("http://dummy.com"))
|
||||
assert c.get_require_name() == "foo__bar"
|
||||
|
||||
|
||||
def test_collect_filtered_files_basic(tmp_path):
|
||||
f1 = tmp_path / "a.c"
|
||||
f2 = tmp_path / "b" / "b.cpp"
|
||||
f1.write_text("int a;")
|
||||
f2.parent.mkdir(parents=True)
|
||||
f2.write_text("int b;")
|
||||
|
||||
result = _collect_filtered_files(tmp_path, ["+<*>"])
|
||||
assert str(f1) in result
|
||||
assert str(f2) in result
|
||||
|
||||
|
||||
def test_collect_filtered_files_exclude(tmp_path):
|
||||
f1 = tmp_path / "a.c"
|
||||
f2 = tmp_path / "b.cpp"
|
||||
f1.write_text("int a;")
|
||||
f2.write_text("int b;")
|
||||
|
||||
result = _collect_filtered_files(tmp_path, ["+<*> -<*.cpp>"])
|
||||
assert str(f1) in result
|
||||
assert str(f2) not in result
|
||||
|
||||
|
||||
def test_split_list_by_condition():
|
||||
items = ["-Iinclude", "-Llib", "-Wall"]
|
||||
|
||||
matched, rest = _split_list_by_condition(
|
||||
items, lambda x: x[2:] if x.startswith("-I") else None
|
||||
)
|
||||
|
||||
assert matched == ["include"]
|
||||
assert "-Llib" in rest
|
||||
assert "-Wall" in rest
|
||||
|
||||
|
||||
def test_generate_cmakelists_txt_basic(tmp_component):
|
||||
src_dir = tmp_component.path / "src"
|
||||
src_dir.mkdir()
|
||||
f = src_dir / "main.c"
|
||||
f.write_text("int main() {}")
|
||||
|
||||
tmp_component.data = {}
|
||||
|
||||
content = generate_cmakelists_txt(tmp_component)
|
||||
|
||||
assert "idf_component_register" in content
|
||||
assert "main.c" in content
|
||||
|
||||
|
||||
def test_generate_cmakelists_txt_with_flags(tmp_component, tmp_path):
|
||||
src_dir = tmp_component.path / "src"
|
||||
src_dir.mkdir()
|
||||
(src_dir / "main.c").write_text("int main() {}")
|
||||
|
||||
dep = IDFComponent("dep", "1.0", source=URLSource("http://dummy.com"))
|
||||
dep.path = tmp_path / "dep"
|
||||
tmp_component.dependencies = [dep]
|
||||
|
||||
tmp_component.data = {
|
||||
"build": {"flags": ["-Iinclude", "-Llib", "-lmylib", "-Wall", "-DTEST"]}
|
||||
}
|
||||
|
||||
content = generate_cmakelists_txt(tmp_component)
|
||||
sep = "\\\\" if os.name == "nt" else "/"
|
||||
assert (
|
||||
content
|
||||
== f"""idf_component_register(
|
||||
SRCS "src{sep}main.c"
|
||||
INCLUDE_DIRS "src"
|
||||
REQUIRES dep ${{ESPHOME_PROJECT_MANAGED_COMPONENTS}} ${{ESPHOME_PROJECT_BUILTIN_COMPONENTS}}
|
||||
)
|
||||
target_compile_options(${{COMPONENT_LIB}} PUBLIC
|
||||
"-DTEST"
|
||||
)
|
||||
target_compile_options(${{COMPONENT_LIB}} PRIVATE
|
||||
"-Wall"
|
||||
)
|
||||
target_link_directories(${{COMPONENT_LIB}} INTERFACE
|
||||
"lib"
|
||||
)
|
||||
target_link_libraries(${{COMPONENT_LIB}} INTERFACE
|
||||
"mylib"
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def test_generate_cmakelists_txt_references_project_managed_components_variable(
|
||||
tmp_component: IDFComponent,
|
||||
) -> None:
|
||||
# The CMakeLists is cached under pio_components/<hash>/ and shared
|
||||
# across projects, so the project-managed REQUIRES list is exposed via
|
||||
# a CMake variable expanded at configure time rather than baked here.
|
||||
src_dir = tmp_component.path / "src"
|
||||
src_dir.mkdir()
|
||||
(src_dir / "main.c").write_text("int main() {}")
|
||||
tmp_component.data = {}
|
||||
|
||||
content = generate_cmakelists_txt(tmp_component)
|
||||
assert "${ESPHOME_PROJECT_MANAGED_COMPONENTS}" in content
|
||||
|
||||
|
||||
def test_generate_idf_component_overwrites_bundled_files(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
esp32_idf_core: None,
|
||||
) -> None:
|
||||
# A library that ships its own CMakeLists.txt + idf_component.yml must
|
||||
# have both replaced by ESPHome's generated content. Library authors'
|
||||
# bundled IDF metadata is frequently broken (bogus REQUIRES, hard-coded
|
||||
# frameworks), so we always regenerate from library.json.
|
||||
from esphome.espidf.component import _generate_idf_component
|
||||
|
||||
(tmp_path / "src").mkdir()
|
||||
(tmp_path / "src" / "main.cpp").write_text("// dummy\n")
|
||||
(tmp_path / "library.json").write_text(json.dumps({"name": "tripwire-lib"}))
|
||||
(tmp_path / "CMakeLists.txt").write_text("# TRIPWIRE_BUNDLED_CMAKELISTS\n")
|
||||
(tmp_path / "idf_component.yml").write_text("# TRIPWIRE_BUNDLED_MANIFEST\n")
|
||||
|
||||
fake_component = IDFComponent(
|
||||
"owner/tripwire-lib", "1.0.0", source=URLSource("http://dummy")
|
||||
)
|
||||
fake_component.path = tmp_path
|
||||
monkeypatch.setattr(
|
||||
esphome.espidf.component,
|
||||
"_convert_library_to_component",
|
||||
lambda _lib: fake_component,
|
||||
)
|
||||
monkeypatch.setattr(fake_component, "download", lambda force=False: None)
|
||||
|
||||
_generate_idf_component(Library("owner/tripwire-lib", "1.0.0", None))
|
||||
|
||||
cml = (tmp_path / "CMakeLists.txt").read_text()
|
||||
manifest = (tmp_path / "idf_component.yml").read_text()
|
||||
assert "TRIPWIRE_BUNDLED_CMAKELISTS" not in cml
|
||||
assert "TRIPWIRE_BUNDLED_MANIFEST" not in manifest
|
||||
assert "idf_component_register" in cml
|
||||
|
||||
|
||||
def test_generate_idf_component_yml_basic(tmp_component):
|
||||
tmp_component.data = {"description": "test", "repository": {"url": "http://aaa"}}
|
||||
result = generate_idf_component_yml(tmp_component)
|
||||
|
||||
assert result == "description: test\nversion: 1.0.0\nrepository: http://aaa\n"
|
||||
|
||||
|
||||
def test_generate_idf_component_yml_with_dependencies(tmp_component, tmp_path):
|
||||
dep = IDFComponent("dep", "1.0", source=URLSource("http://dummy.com"))
|
||||
dep.path = tmp_path / "dep"
|
||||
|
||||
tmp_component.dependencies = [dep]
|
||||
tmp_component.data = {}
|
||||
|
||||
result = generate_idf_component_yml(tmp_component)
|
||||
|
||||
assert (
|
||||
result
|
||||
== f"""version: 1.0.0
|
||||
dependencies:
|
||||
dep:
|
||||
version: '1.0'
|
||||
override_path: {dep.path}
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def test_generate_idf_component_yml_missing_path_reraises(tmp_component):
|
||||
# A dep without a path and without a recognised source should re-raise
|
||||
# the underlying RuntimeError instead of silently producing a bad manifest.
|
||||
dep = IDFComponent("foo/bar", "1.0", source=None)
|
||||
|
||||
tmp_component.dependencies = [dep]
|
||||
tmp_component.data = {}
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
generate_idf_component_yml(tmp_component)
|
||||
|
||||
|
||||
def test_check_library_data_valid(esp32_idf_core):
|
||||
_check_library_data({"platforms": "*", "frameworks": "*"})
|
||||
|
||||
|
||||
def test_check_library_data_valid2(esp32_idf_core):
|
||||
_check_library_data({"platforms": "*"})
|
||||
|
||||
|
||||
def test_check_library_data_valid3(esp32_idf_core):
|
||||
_check_library_data({})
|
||||
|
||||
|
||||
def test_check_library_data_valid4(esp32_idf_core):
|
||||
_check_library_data({"platforms": "espressif32", "frameworks": "*"})
|
||||
|
||||
|
||||
def test_check_library_data_valid5(esp32_idf_core):
|
||||
_check_library_data({"platforms": "*", "frameworks": "espidf"})
|
||||
|
||||
|
||||
def test_check_library_data_invalid_platform(esp32_idf_core):
|
||||
with pytest.raises(InvalidIDFComponent):
|
||||
_check_library_data({"platforms": ["other"], "frameworks": "*"})
|
||||
|
||||
|
||||
def test_check_library_data_invalid_framework(esp32_idf_core):
|
||||
with pytest.raises(InvalidIDFComponent):
|
||||
_check_library_data({"platforms": "*", "frameworks": ["other"]})
|
||||
|
||||
|
||||
def test_extra_script_captures_libpath_libs_and_defines(tmp_path):
|
||||
from esphome.espidf.extra_script import captured_as_build_flags, run_extra_script
|
||||
|
||||
(tmp_path / "src" / "esp32").mkdir(parents=True)
|
||||
script = tmp_path / "extra_script.py"
|
||||
script.write_text(
|
||||
"Import('env')\n"
|
||||
"mcu = env.get('BOARD_MCU')\n"
|
||||
"env.Append(\n"
|
||||
" LIBPATH=[join('src', mcu)],\n"
|
||||
" LIBS=['algobsec'],\n"
|
||||
" CPPDEFINES=['FOO', ('BAR', '1')],\n"
|
||||
" LINKFLAGS=['-Wl,--gc-sections'],\n"
|
||||
")\n"
|
||||
)
|
||||
# The script uses bare ``join`` (PIO's extra-scripts run inside SCons
|
||||
# where this is in scope). Inject it via the script header so the
|
||||
# shim's exec namespace can resolve it.
|
||||
script.write_text("from os.path import join\n" + script.read_text())
|
||||
|
||||
result = run_extra_script(script, library_dir=tmp_path, idf_target="esp32")
|
||||
|
||||
assert result.libpath == [os.path.join("src", "esp32")]
|
||||
assert result.libs == ["algobsec"]
|
||||
assert ("BAR", "1") in result.cppdefines
|
||||
assert "FOO" in result.cppdefines
|
||||
assert result.linkflags == ["-Wl,--gc-sections"]
|
||||
|
||||
flags = captured_as_build_flags(result, library_dir=tmp_path)
|
||||
sep = os.sep
|
||||
assert f"-Lsrc{sep}esp32" in flags
|
||||
assert "-lalgobsec" in flags
|
||||
assert "-DFOO" in flags
|
||||
assert "-DBAR=1" in flags
|
||||
assert "-Wl,--gc-sections" in flags
|
||||
|
||||
|
||||
def test_extra_script_libpath_relative_resolves_against_library_dir(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""Relative LIBPATH entries must resolve against ``library_dir``, not the
|
||||
caller's CWD (the shim restores CWD before ``captured_as_build_flags``
|
||||
runs)."""
|
||||
from esphome.espidf.extra_script import ExtraScriptResult, captured_as_build_flags
|
||||
|
||||
(tmp_path / "lib" / "esp32").mkdir(parents=True)
|
||||
elsewhere = tmp_path.parent / "not_the_library_dir"
|
||||
elsewhere.mkdir(exist_ok=True)
|
||||
monkeypatch.chdir(elsewhere)
|
||||
|
||||
result = ExtraScriptResult(libpath=["lib/esp32"])
|
||||
flags = captured_as_build_flags(result, library_dir=tmp_path)
|
||||
|
||||
sep = os.sep
|
||||
assert flags == [f"-Llib{sep}esp32"]
|
||||
|
||||
|
||||
def test_extra_script_libpath_absolute_outside_library_dir(tmp_path):
|
||||
from esphome.espidf.extra_script import ExtraScriptResult, captured_as_build_flags
|
||||
|
||||
outside = tmp_path.parent / "system_lib"
|
||||
outside.mkdir(exist_ok=True)
|
||||
result = ExtraScriptResult(libpath=[str(outside)])
|
||||
|
||||
flags = captured_as_build_flags(result, library_dir=tmp_path)
|
||||
assert flags == [f"-L{outside.resolve()}"]
|
||||
|
||||
|
||||
def test_extra_script_failure_returns_empty_result(tmp_path, caplog):
|
||||
from esphome.espidf.extra_script import run_extra_script
|
||||
|
||||
script = tmp_path / "broken.py"
|
||||
script.write_text("raise RuntimeError('boom')\n")
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
result = run_extra_script(script, library_dir=tmp_path, idf_target="esp32")
|
||||
|
||||
assert result.libpath == []
|
||||
assert result.libs == []
|
||||
assert "broken.py" in caplog.text
|
||||
|
||||
|
||||
def test_apply_extra_script_path_traversal_is_rejected(tmp_path):
|
||||
from esphome.espidf.component import _apply_extra_script
|
||||
|
||||
library_dir = tmp_path / "lib"
|
||||
library_dir.mkdir()
|
||||
outside = tmp_path / "evil.py"
|
||||
outside.write_text("env.Append(LIBS=['pwned'])\n")
|
||||
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = library_dir
|
||||
c.data = {"build": {"extraScript": "../evil.py"}}
|
||||
|
||||
_apply_extra_script(c)
|
||||
|
||||
# Nothing was folded into flags: the traversal was rejected before
|
||||
# the script could run.
|
||||
assert "flags" not in c.data["build"]
|
||||
|
||||
|
||||
def test_apply_extra_script_merges_into_existing_flags(tmp_path, monkeypatch):
|
||||
from esphome.components import esp32 as esp32_module
|
||||
|
||||
monkeypatch.setattr(esp32_module, "get_esp32_variant", lambda: "ESP32")
|
||||
|
||||
from esphome.espidf.component import _apply_extra_script
|
||||
|
||||
(tmp_path / "src").mkdir()
|
||||
script = tmp_path / "extra.py"
|
||||
script.write_text("env.Append(LIBS=['algobsec'])\n")
|
||||
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = tmp_path
|
||||
c.data = {"build": {"extraScript": "extra.py", "flags": ["-DEXISTING"]}}
|
||||
|
||||
_apply_extra_script(c)
|
||||
|
||||
assert "-DEXISTING" in c.data["build"]["flags"]
|
||||
assert "-lalgobsec" in c.data["build"]["flags"]
|
||||
|
||||
|
||||
def test_parse_library_json(tmp_path):
|
||||
f = tmp_path / "library.json"
|
||||
f.write_text(json.dumps({"name": "test"}))
|
||||
|
||||
result = _parse_library_json(f)
|
||||
assert result["name"] == "test"
|
||||
|
||||
|
||||
def test_parse_library_properties(tmp_path):
|
||||
f = tmp_path / "library.properties"
|
||||
f.write_text(
|
||||
"""
|
||||
name=Test
|
||||
version=1.0
|
||||
# description=ABCD
|
||||
empty=
|
||||
"""
|
||||
)
|
||||
|
||||
result = _parse_library_properties(f)
|
||||
|
||||
assert result["name"] == "Test"
|
||||
assert result["version"] == "1.0"
|
||||
assert "empty" not in result
|
||||
|
||||
|
||||
def test_convert_library_with_repository():
|
||||
lib = Library("name", None, "https://github.com/foo/bar.git#v1.2.3")
|
||||
|
||||
result = _convert_library_to_component(lib)
|
||||
|
||||
assert result.name == "foo/bar"
|
||||
assert result.version == "1.2.3"
|
||||
assert isinstance(result.source, GitSource)
|
||||
|
||||
|
||||
def test_convert_library_missing_ref():
|
||||
lib = Library("name", None, "https://github.com/foo/bar.git")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
_convert_library_to_component(lib)
|
||||
|
||||
|
||||
def test_convert_library_registry(monkeypatch):
|
||||
lib = Library("foo/bar", "^1.0.0", None)
|
||||
|
||||
monkeypatch.setattr(
|
||||
esphome.espidf.component,
|
||||
"_get_package_from_pio_registry",
|
||||
lambda o, n, r: ("foo", "bar", "1.2.3", "http://example.com/pkg.zip"),
|
||||
)
|
||||
|
||||
result = _convert_library_to_component(lib)
|
||||
|
||||
assert result.name == "foo/bar"
|
||||
assert result.version == "1.2.3"
|
||||
assert isinstance(result.source, URLSource)
|
||||
|
||||
|
||||
def test_process_dependencies_adds_valid_dependency(tmp_component, monkeypatch):
|
||||
tmp_component.data = {
|
||||
"dependencies": [
|
||||
{
|
||||
"name": "foo",
|
||||
"version": "1.0",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
esphome.espidf.component,
|
||||
"_generate_idf_component",
|
||||
lambda lib: esphome.espidf.component.IDFComponent(
|
||||
lib.name, lib.version, source=URLSource("http://dummy.com")
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(esphome.espidf.component, "_check_library_data", lambda x: None)
|
||||
|
||||
_process_dependencies(tmp_component)
|
||||
|
||||
assert len(tmp_component.dependencies) == 1
|
||||
|
||||
|
||||
def test_process_dependencies_skips_invalid(tmp_component):
|
||||
tmp_component.data = {
|
||||
"dependencies": [
|
||||
{"name": "foo", "version": "1.0", "platforms": ["arduino"]},
|
||||
{"invalid": "entry"},
|
||||
]
|
||||
}
|
||||
|
||||
_process_dependencies(tmp_component)
|
||||
|
||||
assert tmp_component.dependencies == []
|
||||
@@ -135,7 +135,9 @@ def test_receive_exactly_with_error_response(mock_socket: Mock) -> None:
|
||||
"""Test receive_exactly raises OTAError on error response."""
|
||||
mock_socket.recv.return_value = bytes([espota2.RESPONSE_ERROR_AUTH_INVALID])
|
||||
|
||||
with pytest.raises(espota2.OTAError, match="Error auth:.*Authentication invalid"):
|
||||
with pytest.raises(
|
||||
espota2.OTAError, match="receiving auth:.*Authentication invalid"
|
||||
):
|
||||
espota2.receive_exactly(mock_socket, 1, "auth", [espota2.RESPONSE_OK])
|
||||
|
||||
mock_socket.close.assert_called_once()
|
||||
@@ -145,61 +147,69 @@ def test_receive_exactly_socket_error(mock_socket: Mock) -> None:
|
||||
"""Test receive_exactly handles socket errors."""
|
||||
mock_socket.recv.side_effect = OSError("Connection reset")
|
||||
|
||||
with pytest.raises(espota2.OTAError, match="Error receiving acknowledge test"):
|
||||
with pytest.raises(espota2.OTAError, match="receiving test response"):
|
||||
espota2.receive_exactly(mock_socket, 1, "test", espota2.RESPONSE_OK)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("error_code", "expected_msg"),
|
||||
[
|
||||
(espota2.RESPONSE_ERROR_MAGIC, "Error: Invalid magic byte"),
|
||||
(espota2.RESPONSE_ERROR_UPDATE_PREPARE, "Error: Couldn't prepare flash memory"),
|
||||
(espota2.RESPONSE_ERROR_AUTH_INVALID, "Error: Authentication invalid"),
|
||||
(espota2.RESPONSE_ERROR_MAGIC, "Invalid magic byte"),
|
||||
(espota2.RESPONSE_ERROR_UPDATE_PREPARE, "Couldn't prepare flash memory"),
|
||||
(espota2.RESPONSE_ERROR_AUTH_INVALID, "Authentication invalid"),
|
||||
(
|
||||
espota2.RESPONSE_ERROR_WRITING_FLASH,
|
||||
"Error: Writing OTA data to flash memory failed",
|
||||
"Writing OTA data to flash memory failed",
|
||||
),
|
||||
(espota2.RESPONSE_ERROR_UPDATE_END, "Error: Finishing update failed"),
|
||||
(espota2.RESPONSE_ERROR_UPDATE_END, "Finishing update failed"),
|
||||
(
|
||||
espota2.RESPONSE_ERROR_INVALID_BOOTSTRAPPING,
|
||||
"Error: Please press the reset button",
|
||||
"Please press the reset button",
|
||||
),
|
||||
(
|
||||
espota2.RESPONSE_ERROR_WRONG_CURRENT_FLASH_CONFIG,
|
||||
"Error: ESP has been flashed with wrong flash size",
|
||||
"ESP has been flashed with wrong flash size",
|
||||
),
|
||||
(
|
||||
espota2.RESPONSE_ERROR_WRONG_NEW_FLASH_CONFIG,
|
||||
"Error: ESP does not have the requested flash size",
|
||||
"ESP does not have the requested flash size",
|
||||
),
|
||||
(
|
||||
espota2.RESPONSE_ERROR_ESP8266_NOT_ENOUGH_SPACE,
|
||||
"Error: ESP does not have enough space",
|
||||
"ESP does not have enough space",
|
||||
),
|
||||
(
|
||||
espota2.RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE,
|
||||
"Error: The OTA partition on the ESP is too small",
|
||||
"The OTA partition on the ESP is too small",
|
||||
),
|
||||
(
|
||||
espota2.RESPONSE_ERROR_NO_UPDATE_PARTITION,
|
||||
"Error: The OTA partition on the ESP couldn't be found",
|
||||
"The OTA partition on the ESP couldn't be found",
|
||||
),
|
||||
(espota2.RESPONSE_ERROR_MD5_MISMATCH, "Error: Application MD5 code mismatch"),
|
||||
(espota2.RESPONSE_ERROR_MD5_MISMATCH, "Application MD5 code mismatch"),
|
||||
(
|
||||
espota2.RESPONSE_ERROR_SIGNATURE_INVALID,
|
||||
"Error: Firmware signature verification failed",
|
||||
"Firmware signature verification failed",
|
||||
),
|
||||
(
|
||||
espota2.RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE,
|
||||
"Error: The requested OTA type is not supported by the device",
|
||||
"The requested OTA type is not supported by the device",
|
||||
),
|
||||
(
|
||||
espota2.RESPONSE_ERROR_PARTITION_TABLE_VERIFY,
|
||||
"Error: The partition table update could not be verified",
|
||||
"The partition table update could not be verified",
|
||||
),
|
||||
(
|
||||
espota2.RESPONSE_ERROR_PARTITION_TABLE_UPDATE,
|
||||
"Error: An error occurred while updating the partition table",
|
||||
"An error occurred while updating the partition table",
|
||||
),
|
||||
(
|
||||
espota2.RESPONSE_ERROR_BOOTLOADER_VERIFY,
|
||||
"The bootloader update could not be verified",
|
||||
),
|
||||
(
|
||||
espota2.RESPONSE_ERROR_BOOTLOADER_UPDATE,
|
||||
"An error occurred while updating the bootloader",
|
||||
),
|
||||
(espota2.RESPONSE_ERROR_UNKNOWN, "Unknown error from ESP"),
|
||||
],
|
||||
@@ -254,7 +264,7 @@ def test_send_check_socket_error(mock_socket: Mock) -> None:
|
||||
"""Test send_check handles socket errors."""
|
||||
mock_socket.sendall.side_effect = OSError("Broken pipe")
|
||||
|
||||
with pytest.raises(espota2.OTAError, match="Error sending test"):
|
||||
with pytest.raises(espota2.OTAError, match="sending test"):
|
||||
espota2.send_check(mock_socket, b"data", "test")
|
||||
|
||||
|
||||
@@ -409,7 +419,9 @@ def test_perform_ota_md5_auth_wrong_password(
|
||||
|
||||
mock_socket.recv.side_effect = recv_responses
|
||||
|
||||
with pytest.raises(espota2.OTAError, match="Error auth.*Authentication invalid"):
|
||||
with pytest.raises(
|
||||
espota2.OTAError, match="receiving auth.*Authentication invalid"
|
||||
):
|
||||
espota2.perform_ota(mock_socket, "wrongpassword", mock_file, "test.bin")
|
||||
|
||||
# Verify the socket was closed after auth failure
|
||||
@@ -433,7 +445,9 @@ def test_perform_ota_sha256_auth_wrong_password(
|
||||
|
||||
mock_socket.recv.side_effect = recv_responses
|
||||
|
||||
with pytest.raises(espota2.OTAError, match="Error auth.*Authentication invalid"):
|
||||
with pytest.raises(
|
||||
espota2.OTAError, match="receiving auth.*Authentication invalid"
|
||||
):
|
||||
espota2.perform_ota(mock_socket, "wrongpassword", mock_file, "test.bin")
|
||||
|
||||
# Verify the socket was closed after auth failure
|
||||
@@ -476,7 +490,7 @@ def test_perform_ota_unexpected_auth_response(mock_socket: Mock) -> None:
|
||||
|
||||
# This will actually raise "Unexpected response from ESP" from check_error
|
||||
with pytest.raises(
|
||||
espota2.OTAError, match=r"Error auth: Unexpected response from ESP: 0x03"
|
||||
espota2.OTAError, match=r"receiving auth: Unexpected response from ESP: 0x03"
|
||||
):
|
||||
espota2.perform_ota(mock_socket, "password", mock_file, "test.bin")
|
||||
|
||||
@@ -512,7 +526,7 @@ def test_perform_ota_upload_error(mock_socket: Mock, mock_file: io.BytesIO) -> N
|
||||
|
||||
mock_socket.recv.side_effect = recv_responses
|
||||
|
||||
with pytest.raises(espota2.OTAError, match="Error receiving acknowledge chunk OK"):
|
||||
with pytest.raises(espota2.OTAError, match="receiving chunk result response"):
|
||||
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
|
||||
|
||||
|
||||
@@ -596,7 +610,8 @@ def test_run_ota_wrapper(mock_run_ota_impl: Mock) -> None:
|
||||
|
||||
def test_progress_bar(capsys: CaptureFixture[str]) -> None:
|
||||
"""Test ProgressBar functionality."""
|
||||
progress = espota2.ProgressBar()
|
||||
progress = espota2.ProgressBar("Uploading")
|
||||
progress.enabled = True # Fake TTY
|
||||
|
||||
# Test initial update
|
||||
progress.update(0.0)
|
||||
@@ -992,7 +1007,8 @@ def test_perform_ota_non_app_type_requires_extended_protocol(
|
||||
mock_socket.recv.side_effect = recv_responses
|
||||
|
||||
with pytest.raises(
|
||||
espota2.OTAError, match="Device does not support extended OTA protocol"
|
||||
espota2.OTAError,
|
||||
match="Device does not support the extended OTA protocol",
|
||||
):
|
||||
espota2.perform_ota(
|
||||
mock_socket,
|
||||
@@ -1026,7 +1042,8 @@ def test_perform_ota_non_app_type_requires_partition_access(
|
||||
mock_socket.recv.side_effect = recv_responses
|
||||
|
||||
with pytest.raises(
|
||||
espota2.OTAError, match="Device does not support partition access"
|
||||
espota2.OTAError,
|
||||
match=(r"running firmware was built without 'allow_partition_access: true'"),
|
||||
):
|
||||
espota2.perform_ota(
|
||||
mock_socket,
|
||||
@@ -1037,6 +1054,60 @@ def test_perform_ota_non_app_type_requires_partition_access(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_time")
|
||||
def test_perform_ota_partition_access_error_names_bootloader_flag(
|
||||
mock_socket: Mock, mock_file: io.BytesIO
|
||||
) -> None:
|
||||
"""Bootloader OTA against a stale device must point at the --bootloader flag."""
|
||||
recv_responses = [
|
||||
bytes([espota2.RESPONSE_OK]),
|
||||
bytes([espota2.OTA_VERSION_2_0]),
|
||||
bytes([espota2.RESPONSE_FEATURE_FLAGS]),
|
||||
bytes([0]), # No partition access
|
||||
]
|
||||
|
||||
mock_socket.recv.side_effect = recv_responses
|
||||
|
||||
with pytest.raises(
|
||||
espota2.OTAError,
|
||||
match=r"--bootloader.*recompile and upload.*--bootloader.*retry --bootloader",
|
||||
):
|
||||
espota2.perform_ota(
|
||||
mock_socket,
|
||||
"testpass",
|
||||
mock_file,
|
||||
"test.bin",
|
||||
espota2.OTA_TYPE_UPDATE_BOOTLOADER,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_time")
|
||||
def test_perform_ota_partition_access_error_names_partition_table_flag(
|
||||
mock_socket: Mock, mock_file: io.BytesIO
|
||||
) -> None:
|
||||
"""Partition-table OTA against a stale device must point at the --partition-table flag."""
|
||||
recv_responses = [
|
||||
bytes([espota2.RESPONSE_OK]),
|
||||
bytes([espota2.OTA_VERSION_2_0]),
|
||||
bytes([espota2.RESPONSE_FEATURE_FLAGS]),
|
||||
bytes([0]), # No partition access
|
||||
]
|
||||
|
||||
mock_socket.recv.side_effect = recv_responses
|
||||
|
||||
with pytest.raises(
|
||||
espota2.OTAError,
|
||||
match=r"--partition-table.*retry --partition-table",
|
||||
):
|
||||
espota2.perform_ota(
|
||||
mock_socket,
|
||||
"testpass",
|
||||
mock_file,
|
||||
"test.bin",
|
||||
espota2.OTA_TYPE_UPDATE_PARTITION_TABLE,
|
||||
)
|
||||
|
||||
|
||||
def test_check_error_detects_errors_when_expect_is_none() -> None:
|
||||
"""check_error must surface device error bytes even when expect is None.
|
||||
|
||||
@@ -1044,7 +1115,7 @@ def test_check_error_detects_errors_when_expect_is_none() -> None:
|
||||
during feature negotiation and nonce reads) silently passed error bytes
|
||||
through, turning clean device errors into confusing later failures.
|
||||
"""
|
||||
with pytest.raises(espota2.OTAError, match="Error: Authentication invalid"):
|
||||
with pytest.raises(espota2.OTAError, match="Authentication invalid"):
|
||||
espota2.check_error([espota2.RESPONSE_ERROR_AUTH_INVALID], None)
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import stat
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from aioesphomeapi.host_resolver import AddrInfo, IPv4Sockaddr, IPv6Sockaddr
|
||||
from hypothesis import given
|
||||
@@ -12,7 +13,8 @@ import pytest
|
||||
|
||||
from esphome import helpers
|
||||
from esphome.address_cache import AddressCache
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.helpers import ProgressBar
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -1024,3 +1026,39 @@ def test_resolve_ip_address_mixed_cached_uncached() -> None:
|
||||
assert "192.168.1.10" in addresses # Direct IP
|
||||
assert "192.168.1.50" in addresses # From cache
|
||||
assert "192.168.1.100" in addresses # From resolver
|
||||
|
||||
|
||||
def test_progressbar_enabled_on_tty(monkeypatch) -> None:
|
||||
"""Interactive TTY: progress writes through (pre-existing behaviour)."""
|
||||
stream = MagicMock(spec=io.TextIOWrapper)
|
||||
stream.isatty.return_value = True
|
||||
monkeypatch.setattr(CORE, "dashboard", False)
|
||||
|
||||
bar = ProgressBar("Uploading", stream=stream)
|
||||
assert bar.enabled is True
|
||||
|
||||
|
||||
def test_progressbar_disabled_on_pipe_without_dashboard(monkeypatch) -> None:
|
||||
"""Piped output without --dashboard: progress suppressed."""
|
||||
stream = MagicMock(spec=io.TextIOWrapper)
|
||||
stream.isatty.return_value = False
|
||||
monkeypatch.setattr(CORE, "dashboard", False)
|
||||
|
||||
bar = ProgressBar("Uploading", stream=stream)
|
||||
assert bar.enabled is False
|
||||
|
||||
|
||||
def test_progressbar_enabled_on_pipe_with_dashboard(monkeypatch) -> None:
|
||||
r"""Piped output under --dashboard: progress writes through.
|
||||
|
||||
The dashboard captures stderr through a pipe (so ``isatty()`` is False)
|
||||
and parses ``\rUploading: NN%`` frames to drive its progress UI.
|
||||
Gating purely on ``isatty()`` silently disables every dashboard-side
|
||||
flash-progress indicator.
|
||||
"""
|
||||
stream = MagicMock(spec=io.TextIOWrapper)
|
||||
stream.isatty.return_value = False
|
||||
monkeypatch.setattr(CORE, "dashboard", True)
|
||||
|
||||
bar = ProgressBar("Uploading", stream=stream)
|
||||
assert bar.enabled is True
|
||||
|
||||
+784
-21
@@ -18,18 +18,20 @@ import pytest
|
||||
from pytest import CaptureFixture
|
||||
from zeroconf import ServiceStateChange
|
||||
|
||||
from esphome import platformio_api
|
||||
from esphome.__main__ import (
|
||||
Purpose,
|
||||
_get_configured_xtal_freq,
|
||||
_make_crystal_freq_callback,
|
||||
_resolve_network_devices,
|
||||
_validate_bootloader_binary,
|
||||
_validate_partition_table_binary,
|
||||
choose_upload_log_host,
|
||||
command_analyze_memory,
|
||||
command_bundle,
|
||||
command_clean_all,
|
||||
command_config_hash,
|
||||
command_rename,
|
||||
command_run,
|
||||
command_update_all,
|
||||
command_wizard,
|
||||
compile_program,
|
||||
@@ -45,6 +47,7 @@ from esphome.__main__ import (
|
||||
has_resolvable_address,
|
||||
has_web_server_ota,
|
||||
mqtt_get_ip,
|
||||
parse_args,
|
||||
run_esphome,
|
||||
run_miniterm,
|
||||
show_logs,
|
||||
@@ -87,9 +90,15 @@ from esphome.const import (
|
||||
PLATFORM_ESP32,
|
||||
PLATFORM_ESP8266,
|
||||
PLATFORM_RP2040,
|
||||
Toolchain,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.espota2 import OTA_TYPE_UPDATE_APP, OTA_TYPE_UPDATE_PARTITION_TABLE
|
||||
from esphome.espota2 import (
|
||||
OTA_TYPE_UPDATE_APP,
|
||||
OTA_TYPE_UPDATE_BOOTLOADER,
|
||||
OTA_TYPE_UPDATE_PARTITION_TABLE,
|
||||
)
|
||||
from esphome.platformio import toolchain
|
||||
from esphome.util import BootselResult, FlashImage
|
||||
from esphome.zeroconf import _await_discovery, discover_mdns_devices
|
||||
|
||||
@@ -143,6 +152,7 @@ def setup_core(
|
||||
config[CONF_WIFI] = {CONF_USE_ADDRESS: address}
|
||||
|
||||
CORE.config = config
|
||||
CORE.toolchain = Toolchain.PLATFORMIO
|
||||
|
||||
if platform is not None:
|
||||
CORE.data[KEY_CORE] = {}
|
||||
@@ -280,7 +290,7 @@ def mock_run_external_process() -> Generator[Mock]:
|
||||
|
||||
@pytest.fixture
|
||||
def mock_run_external_command_main() -> Generator[Mock]:
|
||||
"""Mock run_external_command in __main__ module (different from platformio_api)."""
|
||||
"""Mock run_external_command in __main__ module (different from platformio toolchain)."""
|
||||
with patch("esphome.__main__.run_external_command") as mock:
|
||||
mock.return_value = 0 # Default to success
|
||||
yield mock
|
||||
@@ -1127,6 +1137,7 @@ class MockArgs:
|
||||
output: str | None = None
|
||||
ota_platform: str | None = None
|
||||
partition_table: bool = False
|
||||
bootloader: bool = False
|
||||
|
||||
|
||||
def test_upload_program_serial_esp32(
|
||||
@@ -1191,7 +1202,7 @@ def test_upload_using_esptool_path_conversion(
|
||||
CORE.data[KEY_ESP32] = {KEY_VARIANT: VARIANT_ESP32}
|
||||
|
||||
# Create mock IDEData with Path objects
|
||||
mock_idedata = MagicMock(spec=platformio_api.IDEData)
|
||||
mock_idedata = MagicMock(spec=toolchain.IDEData)
|
||||
mock_idedata.firmware_bin_path = tmp_path / "firmware.bin"
|
||||
mock_idedata.extra_flash_images = [
|
||||
FlashImage(path=tmp_path / "bootloader.bin", offset="0x1000"),
|
||||
@@ -1269,7 +1280,7 @@ def test_upload_using_esptool_skips_missing_extra_flash_images(
|
||||
|
||||
missing_path = tmp_path / "variants" / "tasmota" / "tinyuf2.bin"
|
||||
|
||||
mock_idedata = MagicMock(spec=platformio_api.IDEData)
|
||||
mock_idedata = MagicMock(spec=toolchain.IDEData)
|
||||
mock_idedata.firmware_bin_path = tmp_path / "firmware.bin"
|
||||
mock_idedata.extra_flash_images = [
|
||||
FlashImage(path=tmp_path / "bootloader.bin", offset="0x1000"),
|
||||
@@ -1381,8 +1392,8 @@ def test_upload_using_platformio_creates_signed_bin_for_rp2040(
|
||||
mock_idedata.firmware_elf_path = str(firmware_elf)
|
||||
|
||||
with (
|
||||
patch("esphome.platformio_api.get_idedata", return_value=mock_idedata),
|
||||
patch("esphome.platformio_api.run_platformio_cli_run", return_value=0),
|
||||
patch("esphome.platformio.toolchain.get_idedata", return_value=mock_idedata),
|
||||
patch("esphome.platformio.toolchain.run_platformio_cli_run", return_value=0),
|
||||
):
|
||||
result = upload_using_platformio({}, "/dev/ttyACM0")
|
||||
|
||||
@@ -1398,7 +1409,7 @@ def test_upload_using_platformio_skips_signed_bin_for_non_rp2040(
|
||||
"""Test that upload_using_platformio doesn't create signed bin for non-RP2040."""
|
||||
setup_core(platform=PLATFORM_ESP32)
|
||||
|
||||
with patch("esphome.platformio_api.run_platformio_cli_run", return_value=0):
|
||||
with patch("esphome.platformio.toolchain.run_platformio_cli_run", return_value=0):
|
||||
result = upload_using_platformio({}, "/dev/ttyUSB0")
|
||||
|
||||
assert result == 0
|
||||
@@ -1496,7 +1507,7 @@ def test_upload_using_picotool_success(tmp_path: Path) -> None:
|
||||
|
||||
config = {}
|
||||
with (
|
||||
patch("esphome.platformio_api.get_idedata", return_value=mock_idedata),
|
||||
patch("esphome.platformio.toolchain.get_idedata", return_value=mock_idedata),
|
||||
patch("subprocess.run", return_value=mock_result),
|
||||
):
|
||||
exit_code = upload_using_picotool(config)
|
||||
@@ -1516,7 +1527,7 @@ def test_upload_using_picotool_no_elf(tmp_path: Path) -> None:
|
||||
mock_idedata.cc_path = "/fake/path/gcc"
|
||||
|
||||
config = {}
|
||||
with patch("esphome.platformio_api.get_idedata", return_value=mock_idedata):
|
||||
with patch("esphome.platformio.toolchain.get_idedata", return_value=mock_idedata):
|
||||
exit_code = upload_using_picotool(config)
|
||||
|
||||
assert exit_code == 1
|
||||
@@ -1536,7 +1547,7 @@ def test_upload_using_picotool_not_found(tmp_path: Path) -> None:
|
||||
mock_idedata.cc_path = "/fake/path/gcc"
|
||||
|
||||
config = {}
|
||||
with patch("esphome.platformio_api.get_idedata", return_value=mock_idedata):
|
||||
with patch("esphome.platformio.toolchain.get_idedata", return_value=mock_idedata):
|
||||
exit_code = upload_using_picotool(config)
|
||||
|
||||
assert exit_code == 1
|
||||
@@ -1570,7 +1581,7 @@ def test_upload_using_picotool_permission_error(tmp_path: Path) -> None:
|
||||
|
||||
config = {}
|
||||
with (
|
||||
patch("esphome.platformio_api.get_idedata", return_value=mock_idedata),
|
||||
patch("esphome.platformio.toolchain.get_idedata", return_value=mock_idedata),
|
||||
patch("subprocess.run", return_value=mock_result),
|
||||
):
|
||||
exit_code = upload_using_picotool(config)
|
||||
@@ -1816,6 +1827,27 @@ def test_validate_partition_table_binary_missing_file(tmp_path: Path) -> None:
|
||||
_validate_partition_table_binary(tmp_path / "does-not-exist.bin")
|
||||
|
||||
|
||||
def test_validate_bootloader_binary_rejects_wrong_magic(tmp_path: Path) -> None:
|
||||
data = bytearray(_make_bootloader_bytes())
|
||||
data[0] = 0x00
|
||||
f = tmp_path / "bootloader.bin"
|
||||
f.write_bytes(bytes(data))
|
||||
with pytest.raises(EsphomeError, match="magic"):
|
||||
_validate_bootloader_binary(f)
|
||||
|
||||
|
||||
def test_validate_bootloader_binary_missing_file(tmp_path: Path) -> None:
|
||||
with pytest.raises(EsphomeError, match="Cannot read bootloader file"):
|
||||
_validate_bootloader_binary(tmp_path / "does-not-exist.bin")
|
||||
|
||||
|
||||
def test_validate_bootloader_binary_rejects_empty_file(tmp_path: Path) -> None:
|
||||
f = tmp_path / "bootloader.bin"
|
||||
f.write_bytes(b"")
|
||||
with pytest.raises(EsphomeError, match="is empty"):
|
||||
_validate_bootloader_binary(f)
|
||||
|
||||
|
||||
def test_upload_program_ota_partition_table_invalid_file(
|
||||
mock_run_ota: Mock,
|
||||
mock_get_port_type: Mock,
|
||||
@@ -1869,7 +1901,155 @@ def test_upload_program_ota_partition_table_without_allow_flag(
|
||||
|
||||
with pytest.raises(
|
||||
EsphomeError,
|
||||
match="requires 'allow_partition_access: true'",
|
||||
match=(
|
||||
r"The option --partition-table requires 'allow_partition_access: true'.*"
|
||||
r"retry --partition-table"
|
||||
),
|
||||
):
|
||||
upload_program(config, args, devices)
|
||||
mock_run_ota.assert_not_called()
|
||||
|
||||
|
||||
def _make_bootloader_bytes() -> bytes:
|
||||
"""Build a minimal bootloader image accepted by _validate_bootloader_binary."""
|
||||
table = bytearray(b"\xff")
|
||||
# Starts with: ESP_IMAGE_HEADER_MAGIC (0xE9)
|
||||
table[0] = 0xE9
|
||||
return bytes(table)
|
||||
|
||||
|
||||
def test_upload_program_ota_bootloader_with_file_arg(
|
||||
mock_run_ota: Mock,
|
||||
mock_get_port_type: Mock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test upload_program with OTA and bootloader."""
|
||||
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
|
||||
|
||||
mock_get_port_type.return_value = "NETWORK"
|
||||
mock_run_ota.return_value = (0, "192.168.1.100")
|
||||
|
||||
bootloader_file = tmp_path / "bootloader.bin"
|
||||
bootloader_file.write_bytes(_make_bootloader_bytes())
|
||||
|
||||
config = {
|
||||
CONF_OTA: [
|
||||
{
|
||||
CONF_PLATFORM: CONF_ESPHOME,
|
||||
CONF_PORT: 3232,
|
||||
"allow_partition_access": True,
|
||||
}
|
||||
]
|
||||
}
|
||||
args = MockArgs(file=str(bootloader_file), bootloader=True)
|
||||
devices = ["192.168.1.100"]
|
||||
|
||||
exit_code, host = upload_program(config, args, devices)
|
||||
|
||||
assert exit_code == 0
|
||||
assert host == "192.168.1.100"
|
||||
mock_run_ota.assert_called_once_with(
|
||||
["192.168.1.100"],
|
||||
3232,
|
||||
None,
|
||||
bootloader_file,
|
||||
OTA_TYPE_UPDATE_BOOTLOADER,
|
||||
)
|
||||
|
||||
|
||||
def test_upload_program_ota_partition_table_and_bootloader_options(
|
||||
mock_run_ota: Mock,
|
||||
mock_get_port_type: Mock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""--partition-table and --bootloader can't be used together."""
|
||||
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
|
||||
|
||||
mock_get_port_type.return_value = "NETWORK"
|
||||
|
||||
config = {
|
||||
CONF_OTA: [
|
||||
{
|
||||
CONF_PLATFORM: CONF_ESPHOME,
|
||||
CONF_PORT: 3232,
|
||||
"allow_partition_access": True,
|
||||
}
|
||||
]
|
||||
}
|
||||
args = MockArgs(file="partitions.bin", partition_table=True, bootloader=True)
|
||||
devices = ["192.168.1.100"]
|
||||
|
||||
with pytest.raises(
|
||||
EsphomeError,
|
||||
match="--partition-table and --bootloader",
|
||||
):
|
||||
upload_program(config, args, devices)
|
||||
mock_run_ota.assert_not_called()
|
||||
|
||||
|
||||
def test_upload_program_ota_bootloader_without_allow_flag(
|
||||
mock_run_ota: Mock,
|
||||
mock_get_port_type: Mock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""--bootloader must fail fast when allow_partition_access is not enabled in YAML."""
|
||||
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
|
||||
|
||||
mock_get_port_type.return_value = "NETWORK"
|
||||
|
||||
config = {
|
||||
CONF_OTA: [
|
||||
{
|
||||
CONF_PLATFORM: CONF_ESPHOME,
|
||||
CONF_PORT: 3232,
|
||||
}
|
||||
]
|
||||
}
|
||||
args = MockArgs(file="bootloader.bin", bootloader=True)
|
||||
devices = ["192.168.1.100"]
|
||||
|
||||
with pytest.raises(
|
||||
EsphomeError,
|
||||
match=(
|
||||
r"The option --bootloader requires 'allow_partition_access: true'.*"
|
||||
r"retry --bootloader"
|
||||
),
|
||||
):
|
||||
upload_program(config, args, devices)
|
||||
mock_run_ota.assert_not_called()
|
||||
|
||||
|
||||
def test_upload_program_ota_bootloader_platform_web_server(
|
||||
mock_run_ota: Mock,
|
||||
mock_get_port_type: Mock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test bootloader upload with web_server OTA."""
|
||||
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
|
||||
|
||||
mock_get_port_type.return_value = "NETWORK"
|
||||
|
||||
bootloader_file = tmp_path / "bootloader.bin"
|
||||
bootloader_file.write_bytes(_make_bootloader_bytes())
|
||||
|
||||
config = {
|
||||
CONF_OTA: [
|
||||
{
|
||||
CONF_PLATFORM: CONF_WEB_SERVER,
|
||||
CONF_WEB_SERVER: {
|
||||
CONF_PORT: 80,
|
||||
CONF_AUTH: {CONF_USERNAME: "admin", CONF_PASSWORD: "pw"},
|
||||
},
|
||||
"allow_partition_access": True,
|
||||
}
|
||||
]
|
||||
}
|
||||
args = MockArgs(file=str(bootloader_file), bootloader=True)
|
||||
devices = ["192.168.1.100"]
|
||||
|
||||
with pytest.raises(
|
||||
EsphomeError,
|
||||
match="the web_server OTA path can only update the firmware image",
|
||||
):
|
||||
upload_program(config, args, devices)
|
||||
mock_run_ota.assert_not_called()
|
||||
@@ -3260,6 +3440,33 @@ def test_command_wizard(tmp_path: Path) -> None:
|
||||
mock_wizard.assert_called_once_with(config_file)
|
||||
|
||||
|
||||
def test_command_config_hash(
|
||||
tmp_path: Path,
|
||||
capfd: CaptureFixture[str],
|
||||
) -> None:
|
||||
"""command_config_hash runs codegen then prints CORE.config_hash.
|
||||
|
||||
The printed format must match `0x{config_hash:08x}` used by
|
||||
generate_build_info_data_cpp so the value can be compared byte-for-byte
|
||||
against the ESPHOME_CONFIG_HASH embedded in firmware.
|
||||
"""
|
||||
setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}})
|
||||
args = MockArgs()
|
||||
|
||||
# generate_cpp_contents requires real components to be loaded; mock it out
|
||||
# so this test isolates the command's output contract. The command must
|
||||
# still call it (codegen can mutate config, which affects the hash).
|
||||
with patch("esphome.__main__.generate_cpp_contents") as mock_generate:
|
||||
result = command_config_hash(args, CORE.config)
|
||||
|
||||
assert result == 0
|
||||
mock_generate.assert_called_once_with(CORE.config)
|
||||
|
||||
output = strip_ansi_codes(capfd.readouterr().out).strip()
|
||||
assert re.fullmatch(r"0x[0-9a-f]{8}", output)
|
||||
assert output == f"0x{CORE.config_hash:08x}"
|
||||
|
||||
|
||||
def test_command_rename_invalid_characters(
|
||||
tmp_path: Path, capfd: CaptureFixture[str]
|
||||
) -> None:
|
||||
@@ -3420,6 +3627,467 @@ esp32:
|
||||
assert "Rename failed" in captured.out
|
||||
|
||||
|
||||
def test_command_rename_install_failure_reverts(
|
||||
tmp_path: Path,
|
||||
capfd: CaptureFixture[str],
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""Test rename when the install (esphome run) step fails."""
|
||||
config_file = tmp_path / "oldname.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: oldname
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}}
|
||||
|
||||
args = MockArgs(name="newname", dashboard=False)
|
||||
|
||||
# First call (config validation) succeeds; second (esphome run) fails.
|
||||
mock_run_external_process.side_effect = [0, 1]
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 1
|
||||
|
||||
# New file was unlinked when install failed.
|
||||
new_file = tmp_path / "newname.yaml"
|
||||
assert not new_file.exists()
|
||||
|
||||
# Old file is preserved so the device stays reachable under the
|
||||
# original hostname.
|
||||
assert config_file.exists()
|
||||
|
||||
|
||||
def test_command_rename_target_exists_refuses(
|
||||
tmp_path: Path,
|
||||
capfd: CaptureFixture[str],
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""Test rename refuses when the target filename already exists.
|
||||
|
||||
Without this guard, the rename would overwrite the unrelated
|
||||
device's YAML and OTA-install our firmware to the wrong device.
|
||||
"""
|
||||
config_file = tmp_path / "oldname.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: oldname
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
target_file = tmp_path / "newname.yaml"
|
||||
target_file.write_text("""
|
||||
esphome:
|
||||
name: someoneelse
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
target_original = target_file.read_text()
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}}
|
||||
|
||||
args = MockArgs(name="newname", dashboard=False)
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 1
|
||||
# No subprocess work happened — refusal is up-front.
|
||||
mock_run_external_process.assert_not_called()
|
||||
# Target file untouched: same content, still on disk.
|
||||
assert target_file.exists()
|
||||
assert target_file.read_text() == target_original
|
||||
# Source file untouched.
|
||||
assert config_file.exists()
|
||||
|
||||
captured = capfd.readouterr()
|
||||
assert "already exists" in captured.out
|
||||
|
||||
|
||||
def test_command_rename_same_name_refuses(
|
||||
tmp_path: Path,
|
||||
capfd: CaptureFixture[str],
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""Test rename refuses when the new name matches the current name.
|
||||
|
||||
A same-name rename would otherwise re-write the YAML and queue
|
||||
a redundant compile + install — wasted work the user almost
|
||||
certainly didn't intend.
|
||||
"""
|
||||
config_file = tmp_path / "samename.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: samename
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "samename"}}
|
||||
|
||||
args = MockArgs(name="samename", dashboard=False)
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 1
|
||||
mock_run_external_process.assert_not_called()
|
||||
# File preserved verbatim — no rewrite happened.
|
||||
assert config_file.exists()
|
||||
|
||||
captured = capfd.readouterr()
|
||||
assert "already" in captured.out.lower()
|
||||
|
||||
|
||||
def test_command_rename_does_not_touch_friendly_name_substring(
|
||||
tmp_path: Path,
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
r"""Test rename does not match the ``name:`` substring of ``friendly_name:``.
|
||||
|
||||
Without anchoring the regex at line start, the pattern
|
||||
``\s*name:\s+<old>`` could match the trailing ``name:``
|
||||
substring inside ``friendly_name: <old>``. The rewrite would
|
||||
flip both lines to the new name, leaving the user with a
|
||||
silently corrupted ``friendly_name``.
|
||||
"""
|
||||
config_file = tmp_path / "oldname.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: oldname
|
||||
friendly_name: oldname
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}}
|
||||
|
||||
args = MockArgs(name="newname", dashboard=False)
|
||||
mock_run_external_process.return_value = 0
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 0
|
||||
new_file = tmp_path / "newname.yaml"
|
||||
content = new_file.read_text()
|
||||
# esphome.name swapped.
|
||||
assert 'name: "newname"' in content
|
||||
# friendly_name kept verbatim.
|
||||
assert "friendly_name: oldname" in content
|
||||
|
||||
|
||||
def test_command_rename_does_not_match_old_name_as_value_prefix(
|
||||
tmp_path: Path,
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
r"""Test rename does not match ``old_name`` as a prefix of a longer value.
|
||||
|
||||
With ``old_name = kitchen`` the value ``kitchen2`` (a sensor
|
||||
or wifi entry) would otherwise match the unanchored
|
||||
``["']?kitchen["']?`` pattern at the prefix and get
|
||||
rewritten to the new name. The end-of-value lookahead keeps
|
||||
the match restricted to whole tokens.
|
||||
"""
|
||||
config_file = tmp_path / "kitchen.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: kitchen
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
|
||||
wifi:
|
||||
ap:
|
||||
ssid: kitchen2
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}}
|
||||
|
||||
args = MockArgs(name="garage", dashboard=False)
|
||||
mock_run_external_process.return_value = 0
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 0
|
||||
new_file = tmp_path / "garage.yaml"
|
||||
content = new_file.read_text()
|
||||
assert 'name: "garage"' in content
|
||||
# The wifi ssid value is unrelated and stays intact.
|
||||
assert "ssid: kitchen2" in content
|
||||
|
||||
|
||||
def test_command_rename_same_resolved_name_refuses(
|
||||
tmp_path: Path,
|
||||
capfd: CaptureFixture[str],
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""Test rename refuses when ``new_name`` matches the resolved device name.
|
||||
|
||||
The path-equality check only catches the case where the
|
||||
config filename matches the device name. For a config whose
|
||||
filename and ``esphome.name`` differ (here ``weird-file.yaml``
|
||||
holds ``esphome.name: kitchen``), running
|
||||
``esphome rename weird-file.yaml kitchen`` would otherwise
|
||||
fall through to the rewrite + install: the YAML's name stays
|
||||
``kitchen``, the file is renamed to ``kitchen.yaml``, and the
|
||||
device gets a redundant flash. Refuse up-front so the
|
||||
"already the device's name" message matches reality.
|
||||
"""
|
||||
config_file = tmp_path / "weird-file.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: kitchen
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}}
|
||||
|
||||
args = MockArgs(name="kitchen", dashboard=False)
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 1
|
||||
mock_run_external_process.assert_not_called()
|
||||
# Source file untouched, no derived target written.
|
||||
assert config_file.exists()
|
||||
assert not (tmp_path / "kitchen.yaml").exists()
|
||||
|
||||
captured = capfd.readouterr()
|
||||
assert "already" in captured.out.lower()
|
||||
|
||||
|
||||
def test_command_rename_target_path_equals_source_refuses(
|
||||
tmp_path: Path,
|
||||
capfd: CaptureFixture[str],
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""Test rename refuses when the new path resolves to the source file.
|
||||
|
||||
Reachable only when the YAML's filename and ``esphome.name``
|
||||
disagree — here ``kitchen.yaml`` holds ``esphome.name: garage``
|
||||
and the user runs ``esphome rename kitchen.yaml kitchen``. The
|
||||
name-equality check above passes (``garage != kitchen``), but
|
||||
``<config_dir>/kitchen.yaml`` resolves to the source file
|
||||
itself, so the rewrite would clobber the source mid-rename.
|
||||
Refuse rather than silently overwriting.
|
||||
"""
|
||||
config_file = tmp_path / "kitchen.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: garage
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "garage"}}
|
||||
|
||||
args = MockArgs(name="kitchen", dashboard=False)
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 1
|
||||
mock_run_external_process.assert_not_called()
|
||||
# Source file still present and unmodified.
|
||||
assert config_file.exists()
|
||||
assert "name: garage" in config_file.read_text()
|
||||
|
||||
captured = capfd.readouterr()
|
||||
assert "already" in captured.out.lower()
|
||||
|
||||
|
||||
def test_command_rename_does_not_touch_lookalike_name_in_other_blocks(
|
||||
tmp_path: Path,
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""Test rename only swaps the esphome.name line.
|
||||
|
||||
A device whose name happens to match a sensor's / output's
|
||||
``name:`` value must not have those other names rewritten —
|
||||
they're independent. Without an anchor for the esphome block
|
||||
a naive regex would clobber every line whose value matches.
|
||||
"""
|
||||
config_file = tmp_path / "kitchen.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: kitchen
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
name: kitchen
|
||||
lambda: 'return 0;'
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}}
|
||||
|
||||
args = MockArgs(name="garage", dashboard=False)
|
||||
mock_run_external_process.return_value = 0
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 0
|
||||
|
||||
new_file = tmp_path / "garage.yaml"
|
||||
content = new_file.read_text()
|
||||
# esphome.name renamed.
|
||||
assert 'name: "garage"' in content
|
||||
# Sensor's name is the user's entity name — must not be touched.
|
||||
assert " name: kitchen\n" in content
|
||||
|
||||
|
||||
def test_command_rename_preserves_trailing_comment(
|
||||
tmp_path: Path,
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""Test rename preserves a trailing ``# comment`` on the name line."""
|
||||
config_file = tmp_path / "kitchen.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: kitchen # primary device
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}}
|
||||
|
||||
args = MockArgs(name="garage", dashboard=False)
|
||||
mock_run_external_process.return_value = 0
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 0
|
||||
|
||||
new_file = tmp_path / "garage.yaml"
|
||||
content = new_file.read_text()
|
||||
assert "# primary device" in content
|
||||
|
||||
|
||||
def test_command_rename_handles_double_quoted_value(
|
||||
tmp_path: Path,
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""Test rename matches when the existing value is double-quoted."""
|
||||
config_file = tmp_path / "kitchen.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: "kitchen"
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}}
|
||||
|
||||
args = MockArgs(name="garage", dashboard=False)
|
||||
mock_run_external_process.return_value = 0
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 0
|
||||
new_file = tmp_path / "garage.yaml"
|
||||
assert 'name: "garage"' in new_file.read_text()
|
||||
|
||||
|
||||
def test_command_rename_handles_single_quoted_value(
|
||||
tmp_path: Path,
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""Test rename matches when the existing value is single-quoted."""
|
||||
config_file = tmp_path / "kitchen.yaml"
|
||||
config_file.write_text("""
|
||||
esphome:
|
||||
name: 'kitchen'
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "kitchen"}}
|
||||
|
||||
args = MockArgs(name="garage", dashboard=False)
|
||||
mock_run_external_process.return_value = 0
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 0
|
||||
new_file = tmp_path / "garage.yaml"
|
||||
assert 'name: "garage"' in new_file.read_text()
|
||||
|
||||
|
||||
def test_command_rename_too_many_substitution_matches_refuses(
|
||||
tmp_path: Path,
|
||||
capfd: CaptureFixture[str],
|
||||
mock_run_external_process: Mock,
|
||||
) -> None:
|
||||
"""Test rename refuses when ``${var}`` resolves to multiple matches.
|
||||
|
||||
When ``esphome.name: ${device_name}`` and the substitution
|
||||
definition ``device_name: foo`` appears more than once in the
|
||||
YAML (e.g. inside multiple included blocks), the regex rewrite
|
||||
can't tell which one to flip. Rather than silently picking one
|
||||
or rewriting both, the command refuses.
|
||||
"""
|
||||
config_file = tmp_path / "oldname.yaml"
|
||||
config_file.write_text("""
|
||||
substitutions:
|
||||
device_name: oldname
|
||||
|
||||
esphome:
|
||||
name: ${device_name}
|
||||
|
||||
# A copy-pasted block that re-declares the substitution at the
|
||||
# same indent level - happens when users splice in a packaged
|
||||
# fragment without renaming the variable.
|
||||
example:
|
||||
device_name: oldname
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
""")
|
||||
setup_core(tmp_path=tmp_path)
|
||||
CORE.config_path = config_file
|
||||
CORE.config = {
|
||||
CONF_ESPHOME: {CONF_NAME: "oldname"},
|
||||
CONF_SUBSTITUTIONS: {"device_name": "oldname"},
|
||||
}
|
||||
|
||||
args = MockArgs(name="newname", dashboard=False)
|
||||
|
||||
result = command_rename(args, {})
|
||||
|
||||
assert result == 1
|
||||
mock_run_external_process.assert_not_called()
|
||||
# File untouched.
|
||||
assert config_file.exists()
|
||||
assert "device_name: oldname" in config_file.read_text()
|
||||
|
||||
captured = capfd.readouterr()
|
||||
assert "Too many matches" in captured.out
|
||||
|
||||
|
||||
def test_command_update_all_path_string_conversion(
|
||||
tmp_path: Path,
|
||||
mock_run_external_process: Mock,
|
||||
@@ -4058,7 +4726,7 @@ def test_command_analyze_memory_success(
|
||||
firmware_elf.write_text("mock elf file")
|
||||
|
||||
# Mock idedata
|
||||
mock_idedata_obj = MagicMock(spec=platformio_api.IDEData)
|
||||
mock_idedata_obj = MagicMock(spec=toolchain.IDEData)
|
||||
mock_idedata_obj.firmware_elf_path = str(firmware_elf)
|
||||
mock_idedata_obj.objdump_path = "/path/to/objdump"
|
||||
mock_idedata_obj.readelf_path = "/path/to/readelf"
|
||||
@@ -4130,7 +4798,7 @@ def test_command_analyze_memory_with_external_components(
|
||||
firmware_elf.write_text("mock elf file")
|
||||
|
||||
# Mock idedata
|
||||
mock_idedata_obj = MagicMock(spec=platformio_api.IDEData)
|
||||
mock_idedata_obj = MagicMock(spec=toolchain.IDEData)
|
||||
mock_idedata_obj.firmware_elf_path = str(firmware_elf)
|
||||
mock_idedata_obj.objdump_path = "/path/to/objdump"
|
||||
mock_idedata_obj.readelf_path = "/path/to/readelf"
|
||||
@@ -4221,16 +4889,18 @@ def test_command_analyze_memory_no_idedata(
|
||||
|
||||
@pytest.fixture
|
||||
def mock_compile_build_info_run_compile() -> Generator[Mock]:
|
||||
"""Mock platformio_api.run_compile for build_info tests."""
|
||||
with patch("esphome.platformio_api.run_compile", return_value=0) as mock:
|
||||
"""Mock toolchain.run_compile for build_info tests."""
|
||||
with patch("esphome.platformio.toolchain.run_compile", return_value=0) as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_compile_build_info_get_idedata() -> Generator[Mock]:
|
||||
"""Mock platformio_api.get_idedata for build_info tests."""
|
||||
"""Mock toolchain.get_idedata for build_info tests."""
|
||||
mock_idedata = MagicMock()
|
||||
with patch("esphome.platformio_api.get_idedata", return_value=mock_idedata) as mock:
|
||||
with patch(
|
||||
"esphome.platformio.toolchain.get_idedata", return_value=mock_idedata
|
||||
) as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@@ -5140,7 +5810,7 @@ def test_upload_using_esptool_passes_crystal_callback(
|
||||
sdkconfig = build_dir / "sdkconfig.test"
|
||||
sdkconfig.write_text("CONFIG_XTAL_FREQ=40\n")
|
||||
|
||||
mock_idedata = MagicMock(spec=platformio_api.IDEData)
|
||||
mock_idedata = MagicMock(spec=toolchain.IDEData)
|
||||
mock_idedata.firmware_bin_path = tmp_path / "firmware.bin"
|
||||
mock_idedata.extra_flash_images = []
|
||||
mock_get_idedata.return_value = mock_idedata
|
||||
@@ -5170,7 +5840,7 @@ def test_upload_using_esptool_subprocess_passes_crystal_callback(
|
||||
sdkconfig = build_dir / "sdkconfig.test"
|
||||
sdkconfig.write_text("CONFIG_XTAL_FREQ=40\n")
|
||||
|
||||
mock_idedata = MagicMock(spec=platformio_api.IDEData)
|
||||
mock_idedata = MagicMock(spec=toolchain.IDEData)
|
||||
mock_idedata.firmware_bin_path = tmp_path / "firmware.bin"
|
||||
mock_idedata.extra_flash_images = []
|
||||
mock_get_idedata.return_value = mock_idedata
|
||||
@@ -5183,3 +5853,96 @@ def test_upload_using_esptool_subprocess_passes_crystal_callback(
|
||||
call_kwargs = mock_run_external_process.call_args[1]
|
||||
assert "line_callbacks" in call_kwargs
|
||||
assert len(call_kwargs["line_callbacks"]) == 1
|
||||
|
||||
|
||||
def test_parse_args_run_no_states() -> None:
|
||||
"""Test that --no-states is parsed for the run command."""
|
||||
args = parse_args(["esphome", "run", "--no-states", "device.yaml"])
|
||||
assert args.no_states is True
|
||||
|
||||
|
||||
def test_parse_args_run_no_states_default() -> None:
|
||||
"""Test that no_states defaults to False for the run command."""
|
||||
args = parse_args(["esphome", "run", "device.yaml"])
|
||||
assert args.no_states is False
|
||||
|
||||
|
||||
def test_parse_args_logs_no_states() -> None:
|
||||
"""Test that --no-states is parsed for the logs command."""
|
||||
args = parse_args(["esphome", "logs", "--no-states", "device.yaml"])
|
||||
assert args.no_states is True
|
||||
|
||||
|
||||
@patch("esphome.components.api.client.run_logs")
|
||||
def test_command_run_passes_no_states_to_show_logs(
|
||||
mock_run_logs: Mock,
|
||||
) -> None:
|
||||
"""Test that command_run propagates --no-states through to run_logs."""
|
||||
setup_core(
|
||||
config={
|
||||
"logger": {},
|
||||
CONF_API: {},
|
||||
CONF_MDNS: {CONF_DISABLED: False},
|
||||
},
|
||||
platform=PLATFORM_ESP32,
|
||||
)
|
||||
mock_run_logs.return_value = 0
|
||||
|
||||
args = MockArgs()
|
||||
args.no_states = True
|
||||
args.no_logs = False
|
||||
args.device = None
|
||||
|
||||
with (
|
||||
patch("esphome.__main__.write_cpp", return_value=0),
|
||||
patch("esphome.__main__.compile_program", return_value=0),
|
||||
patch(
|
||||
"esphome.__main__.choose_upload_log_host",
|
||||
return_value=["192.168.1.100"],
|
||||
),
|
||||
patch("esphome.__main__.upload_program", return_value=(0, "192.168.1.100")),
|
||||
patch("esphome.__main__.get_serial_ports", return_value=[]),
|
||||
):
|
||||
result = command_run(args, CORE.config)
|
||||
|
||||
assert result == 0
|
||||
mock_run_logs.assert_called_once_with(
|
||||
CORE.config, ["192.168.1.100"], subscribe_states=False
|
||||
)
|
||||
|
||||
|
||||
@patch("esphome.components.api.client.run_logs")
|
||||
def test_command_run_defaults_subscribe_states_true(
|
||||
mock_run_logs: Mock,
|
||||
) -> None:
|
||||
"""Test that command_run subscribes states by default (no --no-states)."""
|
||||
setup_core(
|
||||
config={
|
||||
"logger": {},
|
||||
CONF_API: {},
|
||||
CONF_MDNS: {CONF_DISABLED: False},
|
||||
},
|
||||
platform=PLATFORM_ESP32,
|
||||
)
|
||||
mock_run_logs.return_value = 0
|
||||
|
||||
args = MockArgs()
|
||||
args.no_logs = False
|
||||
args.device = None
|
||||
|
||||
with (
|
||||
patch("esphome.__main__.write_cpp", return_value=0),
|
||||
patch("esphome.__main__.compile_program", return_value=0),
|
||||
patch(
|
||||
"esphome.__main__.choose_upload_log_host",
|
||||
return_value=["192.168.1.100"],
|
||||
),
|
||||
patch("esphome.__main__.upload_program", return_value=(0, "192.168.1.100")),
|
||||
patch("esphome.__main__.get_serial_ports", return_value=[]),
|
||||
):
|
||||
result = command_run(args, CORE.config)
|
||||
|
||||
assert result == 0
|
||||
mock_run_logs.assert_called_once_with(
|
||||
CORE.config, ["192.168.1.100"], subscribe_states=True
|
||||
)
|
||||
|
||||
+170
-51
@@ -1,18 +1,21 @@
|
||||
"""Tests for platformio_api.py path functions."""
|
||||
"""Tests for esphome.platformio.toolchain path functions."""
|
||||
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from contextlib import contextmanager
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, Mock, call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import platformio_api, platformio_runner
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.platformio import runner, toolchain
|
||||
from esphome.util import FlashImage
|
||||
|
||||
|
||||
@@ -21,7 +24,7 @@ def test_idedata_firmware_elf_path(setup_core: Path) -> None:
|
||||
CORE.build_path = setup_core / "build" / "test"
|
||||
CORE.name = "test"
|
||||
raw_data = {"prog_path": "/path/to/firmware.elf"}
|
||||
idedata = platformio_api.IDEData(raw_data)
|
||||
idedata = toolchain.IDEData(raw_data)
|
||||
|
||||
assert idedata.firmware_elf_path == Path("/path/to/firmware.elf")
|
||||
|
||||
@@ -32,7 +35,7 @@ def test_idedata_firmware_bin_path(setup_core: Path) -> None:
|
||||
CORE.name = "test"
|
||||
prog_path = str(Path("/path/to/firmware.elf"))
|
||||
raw_data = {"prog_path": prog_path}
|
||||
idedata = platformio_api.IDEData(raw_data)
|
||||
idedata = toolchain.IDEData(raw_data)
|
||||
|
||||
result = idedata.firmware_bin_path
|
||||
assert isinstance(result, Path)
|
||||
@@ -47,7 +50,7 @@ def test_idedata_firmware_bin_path_preserves_directory(setup_core: Path) -> None
|
||||
CORE.name = "test"
|
||||
prog_path = str(Path("/complex/path/to/build/firmware.elf"))
|
||||
raw_data = {"prog_path": prog_path}
|
||||
idedata = platformio_api.IDEData(raw_data)
|
||||
idedata = toolchain.IDEData(raw_data)
|
||||
|
||||
result = idedata.firmware_bin_path
|
||||
expected = Path("/complex/path/to/build/firmware.bin")
|
||||
@@ -67,7 +70,7 @@ def test_idedata_extra_flash_images(setup_core: Path) -> None:
|
||||
]
|
||||
},
|
||||
}
|
||||
idedata = platformio_api.IDEData(raw_data)
|
||||
idedata = toolchain.IDEData(raw_data)
|
||||
|
||||
images = idedata.extra_flash_images
|
||||
assert len(images) == 2
|
||||
@@ -83,7 +86,7 @@ def test_idedata_extra_flash_images_empty(setup_core: Path) -> None:
|
||||
CORE.build_path = setup_core / "build" / "test"
|
||||
CORE.name = "test"
|
||||
raw_data = {"prog_path": "/path/to/firmware.elf", "extra": {"flash_images": []}}
|
||||
idedata = platformio_api.IDEData(raw_data)
|
||||
idedata = toolchain.IDEData(raw_data)
|
||||
|
||||
images = idedata.extra_flash_images
|
||||
assert images == []
|
||||
@@ -97,7 +100,7 @@ def test_idedata_cc_path(setup_core: Path) -> None:
|
||||
"prog_path": "/path/to/firmware.elf",
|
||||
"cc_path": "/Users/test/.platformio/packages/toolchain-xtensa32/bin/xtensa-esp32-elf-gcc",
|
||||
}
|
||||
idedata = platformio_api.IDEData(raw_data)
|
||||
idedata = toolchain.IDEData(raw_data)
|
||||
|
||||
assert (
|
||||
idedata.cc_path
|
||||
@@ -132,7 +135,7 @@ def test_load_idedata_returns_dict(
|
||||
mock_run_platformio_cli_run.return_value = '{"prog_path": "/test/firmware.elf"}'
|
||||
|
||||
config = {"name": "test"}
|
||||
result = platformio_api._load_idedata(config)
|
||||
result = toolchain._load_idedata(config)
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result, dict)
|
||||
@@ -161,7 +164,7 @@ def test_load_idedata_uses_cache_when_valid(
|
||||
os.utime(idedata_path, (platformio_ini_mtime + 1, platformio_ini_mtime + 1))
|
||||
|
||||
config = {"name": "test"}
|
||||
result = platformio_api._load_idedata(config)
|
||||
result = toolchain._load_idedata(config)
|
||||
|
||||
# Should not call _run_idedata since cache is valid
|
||||
mock_run_platformio_cli_run.assert_not_called()
|
||||
@@ -194,7 +197,7 @@ def test_load_idedata_regenerates_when_platformio_ini_newer(
|
||||
mock_run_platformio_cli_run.return_value = json.dumps(new_data)
|
||||
|
||||
config = {"name": "test"}
|
||||
result = platformio_api._load_idedata(config)
|
||||
result = toolchain._load_idedata(config)
|
||||
|
||||
# Should call _run_idedata since platformio.ini is newer
|
||||
mock_run_platformio_cli_run.assert_called_once()
|
||||
@@ -228,7 +231,7 @@ def test_load_idedata_regenerates_on_corrupted_cache(
|
||||
mock_run_platformio_cli_run.return_value = json.dumps(new_data)
|
||||
|
||||
config = {"name": "test"}
|
||||
result = platformio_api._load_idedata(config)
|
||||
result = toolchain._load_idedata(config)
|
||||
|
||||
# Should call _run_idedata since cache is corrupted
|
||||
mock_run_platformio_cli_run.assert_called_once()
|
||||
@@ -253,7 +256,7 @@ def test_run_idedata_parses_json_from_output(
|
||||
f"Some preamble\n{json.dumps(expected_data)}\nSome postamble"
|
||||
)
|
||||
|
||||
result = platformio_api._run_idedata(config)
|
||||
result = toolchain._run_idedata(config)
|
||||
|
||||
assert result == expected_data
|
||||
|
||||
@@ -267,7 +270,7 @@ def test_run_idedata_raises_on_no_json(
|
||||
mock_run_platformio_cli_run.return_value = "No JSON in this output"
|
||||
|
||||
with pytest.raises(EsphomeError):
|
||||
platformio_api._run_idedata(config)
|
||||
toolchain._run_idedata(config)
|
||||
|
||||
|
||||
def test_run_idedata_raises_on_invalid_json(
|
||||
@@ -279,7 +282,7 @@ def test_run_idedata_raises_on_invalid_json(
|
||||
|
||||
# The ValueError from json.loads is re-raised
|
||||
with pytest.raises(ValueError):
|
||||
platformio_api._run_idedata(config)
|
||||
toolchain._run_idedata(config)
|
||||
|
||||
|
||||
def test_run_platformio_cli_sets_environment_variables(
|
||||
@@ -290,7 +293,7 @@ def test_run_platformio_cli_sets_environment_variables(
|
||||
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
mock_run_external_process.return_value = 0
|
||||
platformio_api.run_platformio_cli("test", "arg")
|
||||
toolchain.run_platformio_cli("test", "arg")
|
||||
|
||||
# Check environment variables were set
|
||||
assert os.environ["PLATFORMIO_FORCE_COLOR"] == "true"
|
||||
@@ -303,11 +306,11 @@ def test_run_platformio_cli_sets_environment_variables(
|
||||
assert "PYTHONWARNINGS" in os.environ
|
||||
|
||||
# Check command was called correctly — runs PlatformIO as a subprocess
|
||||
# via the esphome.platformio_runner entry point.
|
||||
# via the esphome.platformio.runner entry point.
|
||||
mock_run_external_process.assert_called_once()
|
||||
args = mock_run_external_process.call_args[0]
|
||||
assert "-m" in args
|
||||
assert "esphome.platformio_runner" in args
|
||||
assert "esphome.platformio.runner" in args
|
||||
assert "test" in args
|
||||
assert "arg" in args
|
||||
|
||||
@@ -342,8 +345,8 @@ def test_strip_win_long_path_prefix(
|
||||
platform: str, input_path: str, expected: str
|
||||
) -> None:
|
||||
r"""``\\?\`` and ``\\?\UNC\`` prefixes are stripped only on win32."""
|
||||
with patch("esphome.platformio_api.sys.platform", platform):
|
||||
assert platformio_api._strip_win_long_path_prefix(input_path) == expected
|
||||
with patch("esphome.platformio.toolchain.sys.platform", platform):
|
||||
assert toolchain._strip_win_long_path_prefix(input_path) == expected
|
||||
|
||||
|
||||
def test_run_platformio_cli_strips_win_long_path_prefix(
|
||||
@@ -366,15 +369,15 @@ def test_run_platformio_cli_strips_win_long_path_prefix(
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=False),
|
||||
patch("esphome.platformio_api.sys.platform", "win32"),
|
||||
patch("esphome.platformio_api.sys.executable", prefixed_exe),
|
||||
patch("esphome.platformio.toolchain.sys.platform", "win32"),
|
||||
patch("esphome.platformio.toolchain.sys.executable", prefixed_exe),
|
||||
):
|
||||
# Pop any pre-existing PYTHONEXEPATH so the assertion below reflects
|
||||
# what run_platformio_cli set, not whatever the test runner's
|
||||
# environment happened to contain.
|
||||
os.environ.pop("PYTHONEXEPATH", None)
|
||||
mock_run_external_process.return_value = 0
|
||||
platformio_api.run_platformio_cli("test", "arg")
|
||||
toolchain.run_platformio_cli("test", "arg")
|
||||
|
||||
# The subprocess is invoked with the stripped executable path.
|
||||
mock_run_external_process.assert_called_once()
|
||||
@@ -398,12 +401,12 @@ def test_run_platformio_cli_does_not_set_pythonexepath_without_strip(
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=False),
|
||||
patch("esphome.platformio_api.sys.platform", "linux"),
|
||||
patch("esphome.platformio_api.sys.executable", plain_exe),
|
||||
patch("esphome.platformio.toolchain.sys.platform", "linux"),
|
||||
patch("esphome.platformio.toolchain.sys.executable", plain_exe),
|
||||
):
|
||||
os.environ.pop("PYTHONEXEPATH", None)
|
||||
mock_run_external_process.return_value = 0
|
||||
platformio_api.run_platformio_cli("test", "arg")
|
||||
toolchain.run_platformio_cli("test", "arg")
|
||||
|
||||
mock_run_external_process.assert_called_once()
|
||||
args = mock_run_external_process.call_args[0]
|
||||
@@ -419,7 +422,7 @@ def test_run_platformio_cli_run_builds_command(
|
||||
mock_run_platformio_cli.return_value = 0
|
||||
|
||||
config = {"name": "test"}
|
||||
platformio_api.run_platformio_cli_run(config, True, "extra", "args")
|
||||
toolchain.run_platformio_cli_run(config, True, "extra", "args")
|
||||
|
||||
mock_run_platformio_cli.assert_called_once_with(
|
||||
"run", "-d", CORE.build_path, "-v", "extra", "args"
|
||||
@@ -434,7 +437,7 @@ def test_run_compile(setup_core: Path, mock_run_platformio_cli_run: Mock) -> Non
|
||||
config = {CONF_ESPHOME: {CONF_COMPILE_PROCESS_LIMIT: 4}}
|
||||
mock_run_platformio_cli_run.return_value = 0
|
||||
|
||||
platformio_api.run_compile(config, verbose=True)
|
||||
toolchain.run_compile(config, verbose=True)
|
||||
|
||||
mock_run_platformio_cli_run.assert_called_once_with(config, True, "-j4")
|
||||
|
||||
@@ -461,22 +464,22 @@ def test_get_idedata_caches_result(
|
||||
config = {"name": "test"}
|
||||
|
||||
# First call should load and cache
|
||||
result1 = platformio_api.get_idedata(config)
|
||||
result1 = toolchain.get_idedata(config)
|
||||
mock_run_platformio_cli_run.assert_called_once()
|
||||
|
||||
# Second call should use cache from CORE.data
|
||||
result2 = platformio_api.get_idedata(config)
|
||||
result2 = toolchain.get_idedata(config)
|
||||
mock_run_platformio_cli_run.assert_called_once() # Still only called once
|
||||
|
||||
assert result1 is result2
|
||||
assert isinstance(result1, platformio_api.IDEData)
|
||||
assert isinstance(result1, toolchain.IDEData)
|
||||
assert result1.firmware_elf_path == Path("/test/firmware.elf")
|
||||
|
||||
|
||||
def test_idedata_addr2line_path_windows(setup_core: Path) -> None:
|
||||
"""Test IDEData.addr2line_path on Windows."""
|
||||
raw_data = {"prog_path": "/path/to/firmware.elf", "cc_path": "C:\\tools\\gcc.exe"}
|
||||
idedata = platformio_api.IDEData(raw_data)
|
||||
idedata = toolchain.IDEData(raw_data)
|
||||
|
||||
result = idedata.addr2line_path
|
||||
assert result == "C:\\tools\\addr2line.exe"
|
||||
@@ -485,7 +488,7 @@ def test_idedata_addr2line_path_windows(setup_core: Path) -> None:
|
||||
def test_idedata_addr2line_path_unix(setup_core: Path) -> None:
|
||||
"""Test IDEData.addr2line_path on Unix."""
|
||||
raw_data = {"prog_path": "/path/to/firmware.elf", "cc_path": "/usr/bin/gcc"}
|
||||
idedata = platformio_api.IDEData(raw_data)
|
||||
idedata = toolchain.IDEData(raw_data)
|
||||
|
||||
result = idedata.addr2line_path
|
||||
assert result == "/usr/bin/addr2line"
|
||||
@@ -494,7 +497,7 @@ def test_idedata_addr2line_path_unix(setup_core: Path) -> None:
|
||||
def test_idedata_objdump_path_windows(setup_core: Path) -> None:
|
||||
"""Test IDEData.objdump_path on Windows."""
|
||||
raw_data = {"prog_path": "/path/to/firmware.elf", "cc_path": "C:\\tools\\gcc.exe"}
|
||||
idedata = platformio_api.IDEData(raw_data)
|
||||
idedata = toolchain.IDEData(raw_data)
|
||||
|
||||
result = idedata.objdump_path
|
||||
assert result == "C:\\tools\\objdump.exe"
|
||||
@@ -503,7 +506,7 @@ def test_idedata_objdump_path_windows(setup_core: Path) -> None:
|
||||
def test_idedata_objdump_path_unix(setup_core: Path) -> None:
|
||||
"""Test IDEData.objdump_path on Unix."""
|
||||
raw_data = {"prog_path": "/path/to/firmware.elf", "cc_path": "/usr/bin/gcc"}
|
||||
idedata = platformio_api.IDEData(raw_data)
|
||||
idedata = toolchain.IDEData(raw_data)
|
||||
|
||||
result = idedata.objdump_path
|
||||
assert result == "/usr/bin/objdump"
|
||||
@@ -512,7 +515,7 @@ def test_idedata_objdump_path_unix(setup_core: Path) -> None:
|
||||
def test_idedata_readelf_path_windows(setup_core: Path) -> None:
|
||||
"""Test IDEData.readelf_path on Windows."""
|
||||
raw_data = {"prog_path": "/path/to/firmware.elf", "cc_path": "C:\\tools\\gcc.exe"}
|
||||
idedata = platformio_api.IDEData(raw_data)
|
||||
idedata = toolchain.IDEData(raw_data)
|
||||
|
||||
result = idedata.readelf_path
|
||||
assert result == "C:\\tools\\readelf.exe"
|
||||
@@ -521,7 +524,7 @@ def test_idedata_readelf_path_windows(setup_core: Path) -> None:
|
||||
def test_idedata_readelf_path_unix(setup_core: Path) -> None:
|
||||
"""Test IDEData.readelf_path on Unix."""
|
||||
raw_data = {"prog_path": "/path/to/firmware.elf", "cc_path": "/usr/bin/gcc"}
|
||||
idedata = platformio_api.IDEData(raw_data)
|
||||
idedata = toolchain.IDEData(raw_data)
|
||||
|
||||
result = idedata.readelf_path
|
||||
assert result == "/usr/bin/readelf"
|
||||
@@ -547,7 +550,7 @@ def test_patch_structhash(setup_core: Path) -> None:
|
||||
},
|
||||
):
|
||||
# Call patch_structhash
|
||||
platformio_runner.patch_structhash()
|
||||
runner.patch_structhash()
|
||||
|
||||
# Verify both modules had clean_build_dir patched
|
||||
# Check that clean_build_dir was set on both modules
|
||||
@@ -599,7 +602,7 @@ def test_patched_clean_build_dir_removes_outdated(setup_core: Path) -> None:
|
||||
},
|
||||
):
|
||||
# Call patch_structhash to install the patched function
|
||||
platformio_runner.patch_structhash()
|
||||
runner.patch_structhash()
|
||||
|
||||
# Call the patched function
|
||||
mock_helpers.clean_build_dir(str(build_dir), [])
|
||||
@@ -649,7 +652,7 @@ def test_patched_clean_build_dir_keeps_updated(setup_core: Path) -> None:
|
||||
},
|
||||
):
|
||||
# Call patch_structhash to install the patched function
|
||||
platformio_runner.patch_structhash()
|
||||
runner.patch_structhash()
|
||||
|
||||
# Call the patched function
|
||||
mock_helpers.clean_build_dir(str(build_dir), [])
|
||||
@@ -697,7 +700,7 @@ def test_patched_clean_build_dir_creates_missing(setup_core: Path) -> None:
|
||||
},
|
||||
):
|
||||
# Call patch_structhash to install the patched function
|
||||
platformio_runner.patch_structhash()
|
||||
runner.patch_structhash()
|
||||
|
||||
# Call the patched function
|
||||
mock_helpers.clean_build_dir(str(build_dir), [])
|
||||
@@ -727,7 +730,7 @@ def test_patch_file_downloader_succeeds_first_try() -> None:
|
||||
),
|
||||
},
|
||||
):
|
||||
platformio_runner.patch_file_downloader()
|
||||
runner.patch_file_downloader()
|
||||
|
||||
from platformio.package.download import FileDownloader
|
||||
|
||||
@@ -766,7 +769,7 @@ def test_patch_file_downloader_retries_on_failure() -> None:
|
||||
),
|
||||
patch("time.sleep") as mock_sleep,
|
||||
):
|
||||
platformio_runner.patch_file_downloader()
|
||||
runner.patch_file_downloader()
|
||||
|
||||
from platformio.package.download import FileDownloader
|
||||
|
||||
@@ -807,7 +810,7 @@ def test_patch_file_downloader_raises_after_max_retries() -> None:
|
||||
),
|
||||
patch("time.sleep") as mock_sleep,
|
||||
):
|
||||
platformio_runner.patch_file_downloader()
|
||||
runner.patch_file_downloader()
|
||||
|
||||
from platformio.package.download import FileDownloader
|
||||
|
||||
@@ -855,7 +858,7 @@ def test_patch_file_downloader_closes_session_and_response_between_retries() ->
|
||||
),
|
||||
patch("time.sleep"),
|
||||
):
|
||||
platformio_runner.patch_file_downloader()
|
||||
runner.patch_file_downloader()
|
||||
|
||||
from platformio.package.download import FileDownloader
|
||||
|
||||
@@ -867,6 +870,56 @@ def test_patch_file_downloader_closes_session_and_response_between_retries() ->
|
||||
mock_session.close.assert_called_once()
|
||||
|
||||
|
||||
def test_patch_file_downloader_retries_on_connection_error() -> None:
|
||||
"""Test patch_file_downloader retries on transport-layer errors (OSError subclasses).
|
||||
|
||||
``requests.exceptions.ConnectionError`` and ``ReadTimeout`` subclass
|
||||
``OSError`` and are raised when the connection is aborted before any HTTP
|
||||
response is parsed -- e.g. ``RemoteDisconnected`` mid-download. These must
|
||||
retry too, not just ``PackageException``.
|
||||
"""
|
||||
mock_exception_cls = type("PackageException", (Exception,), {})
|
||||
call_count = 0
|
||||
|
||||
def failing_init(self, *args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count < 3:
|
||||
raise ConnectionError(
|
||||
f"Connection aborted attempt {call_count}: RemoteDisconnected"
|
||||
)
|
||||
|
||||
with (
|
||||
patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"platformio": MagicMock(),
|
||||
"platformio.package": MagicMock(),
|
||||
"platformio.package.download": SimpleNamespace(
|
||||
FileDownloader=type(
|
||||
"FileDownloader", (), {"__init__": failing_init}
|
||||
)
|
||||
),
|
||||
"platformio.package.exception": SimpleNamespace(
|
||||
PackageException=mock_exception_cls
|
||||
),
|
||||
},
|
||||
),
|
||||
patch("time.sleep") as mock_sleep,
|
||||
):
|
||||
runner.patch_file_downloader()
|
||||
|
||||
from platformio.package.download import FileDownloader
|
||||
|
||||
instance = object.__new__(FileDownloader)
|
||||
FileDownloader.__init__(instance, "http://example.com/file.zip")
|
||||
|
||||
assert call_count == 3
|
||||
assert mock_sleep.call_count == 2
|
||||
mock_sleep.assert_any_call(2)
|
||||
mock_sleep.assert_any_call(4)
|
||||
|
||||
|
||||
def test_patch_file_downloader_idempotent() -> None:
|
||||
"""Test patch_file_downloader does not stack wrappers when called multiple times."""
|
||||
mock_exception_cls = type("PackageException", (Exception,), {})
|
||||
@@ -890,9 +943,9 @@ def test_patch_file_downloader_idempotent() -> None:
|
||||
},
|
||||
):
|
||||
# Patch multiple times
|
||||
platformio_runner.patch_file_downloader()
|
||||
platformio_runner.patch_file_downloader()
|
||||
platformio_runner.patch_file_downloader()
|
||||
runner.patch_file_downloader()
|
||||
runner.patch_file_downloader()
|
||||
runner.patch_file_downloader()
|
||||
|
||||
from platformio.package.download import FileDownloader
|
||||
|
||||
@@ -903,6 +956,74 @@ def test_patch_file_downloader_idempotent() -> None:
|
||||
assert call_count == 1
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _flaky_http_server(fail_first_n: int, fail_mode: str):
|
||||
"""Local HTTP server that fails the first ``fail_first_n`` requests.
|
||||
|
||||
``fail_mode="drop"`` closes the TCP connection without responding, so
|
||||
the client raises ``RemoteDisconnected`` -- the exact CI failure mode.
|
||||
``fail_mode="502"`` returns an HTTP 502, triggering ``PackageException``.
|
||||
"""
|
||||
state = {"hits": 0}
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
def handle_one_request(self) -> None:
|
||||
state["hits"] += 1
|
||||
if state["hits"] <= fail_first_n and fail_mode == "drop":
|
||||
return # Skip read+respond → kernel sends FIN → RemoteDisconnected
|
||||
super().handle_one_request()
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
if state["hits"] <= fail_first_n and fail_mode == "502":
|
||||
self.send_error(502)
|
||||
return
|
||||
body = b"esphome-test-payload"
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None: # noqa: A002
|
||||
pass # silence default stderr logging
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield server.server_address[1], state
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=2)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fail_mode", ["drop", "502"])
|
||||
def test_patch_file_downloader_recovers_against_real_server(
|
||||
tmp_path: Path, fail_mode: str
|
||||
) -> None:
|
||||
"""End-to-end: real PlatformIO ``FileDownloader`` against a local server
|
||||
that fails twice then succeeds. Exercises the real
|
||||
requests/urllib3/http.client stack for both failure modes:
|
||||
|
||||
- ``drop``: TCP close mid-request → ``RemoteDisconnected`` → caught as
|
||||
``OSError`` by the retry patch (the CI failure path).
|
||||
- ``502``: HTTP error response → ``PackageException`` (the original path).
|
||||
"""
|
||||
runner.patch_file_downloader()
|
||||
from platformio.package.download import FileDownloader
|
||||
|
||||
with (
|
||||
_flaky_http_server(fail_first_n=2, fail_mode=fail_mode) as (port, state),
|
||||
patch("time.sleep"),
|
||||
):
|
||||
fd = FileDownloader(f"http://127.0.0.1:{port}/payload.bin")
|
||||
fd.set_destination(str(tmp_path / "out.bin"))
|
||||
fd.start(with_progress=False, silent=True)
|
||||
|
||||
assert state["hits"] == 3 # 2 failures + 1 success
|
||||
assert (tmp_path / "out.bin").read_bytes() == b"esphome-test-payload"
|
||||
|
||||
|
||||
def _filter_through_redirect(line: str) -> str:
|
||||
"""Write a line through RedirectText with FILTER_PLATFORMIO_LINES and return what passes."""
|
||||
import io
|
||||
@@ -910,9 +1031,7 @@ def _filter_through_redirect(line: str) -> str:
|
||||
from esphome.util import RedirectText
|
||||
|
||||
captured = io.StringIO()
|
||||
redirect = RedirectText(
|
||||
captured, filter_lines=platformio_runner.FILTER_PLATFORMIO_LINES
|
||||
)
|
||||
redirect = RedirectText(captured, filter_lines=runner.FILTER_PLATFORMIO_LINES)
|
||||
redirect.write(line + "\n")
|
||||
return captured.getvalue()
|
||||
|
||||
@@ -818,3 +818,23 @@ def test_resolve_include_error_no_expanded_from_for_literal_filename(
|
||||
substitutions.resolve_include(include, [], substitutions.ContextVars())
|
||||
|
||||
assert "expanded from" not in str(exc_info.value)
|
||||
|
||||
|
||||
def test_include_vars_applied_to_lambda_value(tmp_path: Path) -> None:
|
||||
"""!include vars: must substitute into a top-level !lambda value in the included file.
|
||||
|
||||
Regression test for the case where the included file's root is a Lambda;
|
||||
add_context() previously only tagged dict/list/str, so the include's vars
|
||||
never reached the substitution pass for Lambda content.
|
||||
"""
|
||||
included = tmp_path / "lambda.yaml"
|
||||
included.write_text('!lambda |-\n return "${foo}";\n')
|
||||
|
||||
include = yaml_util.IncludeFile(
|
||||
tmp_path / "main.yaml", "lambda.yaml", {"foo": "bar"}, yaml_util.load_yaml
|
||||
)
|
||||
config = OrderedDict({"value": include.load()})
|
||||
result = substitutions.do_substitution_pass(config)
|
||||
|
||||
assert isinstance(result["value"], Lambda)
|
||||
assert result["value"].value == 'return "bar";'
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Tests for the stable upload-targets classification helpers."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.upload_targets import PortType, get_port_type
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"port",
|
||||
[
|
||||
"/dev/ttyUSB0",
|
||||
"/dev/ttyACM0",
|
||||
"/dev/cu.usbserial-1410",
|
||||
"/dev/tty.usbmodem1101",
|
||||
"COM1",
|
||||
"COM23",
|
||||
],
|
||||
)
|
||||
def test_get_port_type_serial(port: str) -> None:
|
||||
"""Local serial devices classify as SERIAL."""
|
||||
assert get_port_type(port) is PortType.SERIAL
|
||||
|
||||
|
||||
def test_get_port_type_bootsel() -> None:
|
||||
"""``BOOTSEL`` magic string classifies as BOOTSEL."""
|
||||
assert get_port_type("BOOTSEL") is PortType.BOOTSEL
|
||||
|
||||
|
||||
def test_get_port_type_mqtt() -> None:
|
||||
"""``MQTT`` magic string classifies as MQTT."""
|
||||
assert get_port_type("MQTT") is PortType.MQTT
|
||||
|
||||
|
||||
def test_get_port_type_mqttip() -> None:
|
||||
"""``MQTTIP`` magic string classifies as MQTTIP."""
|
||||
assert get_port_type("MQTTIP") is PortType.MQTTIP
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"port",
|
||||
[
|
||||
"192.168.1.10",
|
||||
"fe80::1",
|
||||
"device.local",
|
||||
"my-esp.example.com",
|
||||
],
|
||||
)
|
||||
def test_get_port_type_network(port: str) -> None:
|
||||
"""IP addresses, mDNS, and hostnames classify as NETWORK."""
|
||||
assert get_port_type(port) is PortType.NETWORK
|
||||
|
||||
|
||||
def test_port_type_values_are_stable() -> None:
|
||||
"""Member values are part of the stable surface.
|
||||
|
||||
External tooling (device-builder, etc.) may compare against the
|
||||
string values directly. Renaming or changing these breaks
|
||||
downstream consumers — guard against accidental edits.
|
||||
"""
|
||||
assert PortType.SERIAL.value == "SERIAL"
|
||||
assert PortType.NETWORK.value == "NETWORK"
|
||||
assert PortType.MQTT.value == "MQTT"
|
||||
assert PortType.MQTTIP.value == "MQTTIP"
|
||||
assert PortType.BOOTSEL.value == "BOOTSEL"
|
||||
|
||||
|
||||
def test_main_re_exports_for_backwards_compat() -> None:
|
||||
"""``esphome.__main__`` re-exports the stable surface.
|
||||
|
||||
The CLI entry point pre-dated the stable module and existing
|
||||
internal callers (and any third-party code that snuck in via
|
||||
``__main__``) still import from there. The re-export must
|
||||
resolve to the same objects.
|
||||
"""
|
||||
from esphome.__main__ import (
|
||||
PortType as MainPortType,
|
||||
get_port_type as main_get_port_type,
|
||||
)
|
||||
|
||||
assert MainPortType is PortType
|
||||
assert main_get_port_type is get_port_type
|
||||
@@ -443,6 +443,14 @@ def test_clean_build(
|
||||
dependencies_lock = tmp_path / "dependencies.lock"
|
||||
dependencies_lock.write_text("lock file")
|
||||
|
||||
# Native ESP-IDF toolchain artifacts.
|
||||
idf_build_dir = tmp_path / "build"
|
||||
idf_build_dir.mkdir()
|
||||
(idf_build_dir / "CMakeCache.txt").write_text("cache")
|
||||
managed_components_dir = tmp_path / "managed_components"
|
||||
managed_components_dir.mkdir()
|
||||
(managed_components_dir / "espressif__arduino-esp32").mkdir()
|
||||
|
||||
# Create PlatformIO cache directory
|
||||
platformio_cache_dir = tmp_path / ".platformio" / ".cache"
|
||||
platformio_cache_dir.mkdir(parents=True)
|
||||
@@ -454,12 +462,14 @@ def test_clean_build(
|
||||
# Setup mocks
|
||||
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
|
||||
mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir
|
||||
mock_core.relative_build_path.return_value = dependencies_lock
|
||||
mock_core.relative_build_path.side_effect = lambda name: tmp_path / name
|
||||
|
||||
# Verify all exist before
|
||||
assert pioenvs_dir.exists()
|
||||
assert piolibdeps_dir.exists()
|
||||
assert dependencies_lock.exists()
|
||||
assert idf_build_dir.exists()
|
||||
assert managed_components_dir.exists()
|
||||
assert platformio_cache_dir.exists()
|
||||
|
||||
# Mock PlatformIO's ProjectConfig cache_dir
|
||||
@@ -482,6 +492,8 @@ def test_clean_build(
|
||||
assert not pioenvs_dir.exists()
|
||||
assert not piolibdeps_dir.exists()
|
||||
assert not dependencies_lock.exists()
|
||||
assert not idf_build_dir.exists()
|
||||
assert not managed_components_dir.exists()
|
||||
assert not platformio_cache_dir.exists()
|
||||
|
||||
# Verify logging
|
||||
@@ -489,6 +501,8 @@ def test_clean_build(
|
||||
assert ".pioenvs" in caplog.text
|
||||
assert ".piolibdeps" in caplog.text
|
||||
assert "dependencies.lock" in caplog.text
|
||||
assert str(idf_build_dir) in caplog.text
|
||||
assert str(managed_components_dir) in caplog.text
|
||||
assert "PlatformIO cache" in caplog.text
|
||||
|
||||
|
||||
@@ -510,7 +524,7 @@ def test_clean_build_partial_exists(
|
||||
# Setup mocks
|
||||
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
|
||||
mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir
|
||||
mock_core.relative_build_path.return_value = dependencies_lock
|
||||
mock_core.relative_build_path.side_effect = lambda name: tmp_path / name
|
||||
|
||||
# Verify only pioenvs exists
|
||||
assert pioenvs_dir.exists()
|
||||
@@ -547,7 +561,7 @@ def test_clean_build_nothing_exists(
|
||||
# Setup mocks
|
||||
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
|
||||
mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir
|
||||
mock_core.relative_build_path.return_value = dependencies_lock
|
||||
mock_core.relative_build_path.side_effect = lambda name: tmp_path / name
|
||||
|
||||
# Verify nothing exists
|
||||
assert not pioenvs_dir.exists()
|
||||
@@ -583,7 +597,7 @@ def test_clean_build_platformio_not_available(
|
||||
# Setup mocks
|
||||
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
|
||||
mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir
|
||||
mock_core.relative_build_path.return_value = dependencies_lock
|
||||
mock_core.relative_build_path.side_effect = lambda name: tmp_path / name
|
||||
|
||||
# Verify all exist before
|
||||
assert pioenvs_dir.exists()
|
||||
@@ -621,7 +635,7 @@ def test_clean_build_empty_cache_dir(
|
||||
# Setup mocks
|
||||
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
|
||||
mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps"
|
||||
mock_core.relative_build_path.return_value = tmp_path / "dependencies.lock"
|
||||
mock_core.relative_build_path.side_effect = lambda name: tmp_path / name
|
||||
|
||||
# Verify pioenvs exists before
|
||||
assert pioenvs_dir.exists()
|
||||
@@ -1349,7 +1363,7 @@ def test_clean_build_handles_readonly_files(
|
||||
# Setup mocks
|
||||
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
|
||||
mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps"
|
||||
mock_core.relative_build_path.return_value = tmp_path / "dependencies.lock"
|
||||
mock_core.relative_build_path.side_effect = lambda name: tmp_path / name
|
||||
|
||||
# Verify file is read-only
|
||||
assert not os.access(readonly_file, os.W_OK)
|
||||
@@ -1413,7 +1427,7 @@ def test_clean_build_reraises_for_other_errors(
|
||||
# Setup mocks
|
||||
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
|
||||
mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps"
|
||||
mock_core.relative_build_path.return_value = tmp_path / "dependencies.lock"
|
||||
mock_core.relative_build_path.side_effect = lambda name: tmp_path / name
|
||||
|
||||
try:
|
||||
# Mock os.access in writer module to return True (writable)
|
||||
|
||||
@@ -390,6 +390,21 @@ def test_track_yaml_loads_cleanup_on_exception(tmp_path: Path) -> None:
|
||||
assert len(yaml_util._load_listeners) == before
|
||||
|
||||
|
||||
def test_track_yaml_loads_no_duplicate_load_on_top_level_include_failure(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A failed top-level !include must not record any file twice in track_yaml_loads."""
|
||||
main = tmp_path / "main.yaml"
|
||||
main.write_text("!include missing.yaml\n")
|
||||
|
||||
with yaml_util.track_yaml_loads() as loaded, pytest.raises(EsphomeError):
|
||||
yaml_util.load_yaml(main)
|
||||
|
||||
assert len(loaded) == len(set(loaded)), (
|
||||
f"Files loaded more than once during a failed top-level include: {loaded}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"data",
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user