mirror of
https://github.com/esphome/esphome.git
synced 2026-09-13 08:17:34 +00:00
Merge remote-tracking branch 'upstream/dev' into logstr-literal-lint
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
variant: esp32
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
# esp32_ble_server is only auto-loaded here, so it has no services of its own.
|
||||
esp32_improv:
|
||||
authorizer: none
|
||||
@@ -0,0 +1,9 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
variant: esp32
|
||||
|
||||
esp32_ble_server:
|
||||
id: ble_server
|
||||
manufacturer_data: [0x72, 0x04, 0x00, 0x23]
|
||||
@@ -0,0 +1,14 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
variant: esp32
|
||||
|
||||
esp32_ble_server:
|
||||
id: ble_server
|
||||
services:
|
||||
- uuid: 2a24b789-7aab-4535-af3e-ee76a35cc12d
|
||||
characteristics:
|
||||
- uuid: cad48e28-7fbe-41cf-bae9-d77a6c233423
|
||||
read: true
|
||||
value: [1, 2, 3, 4]
|
||||
@@ -1,5 +1,10 @@
|
||||
"""Tests for esp32_ble_server configuration helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.esp32_ble_server import (
|
||||
@@ -45,3 +50,26 @@ def test_uuid_is_matches_descriptor_short_strings(uuid16) -> None:
|
||||
assert uuid_is(uuid16, uuid16)
|
||||
assert uuid_is(f"{uuid16:04X}", uuid16)
|
||||
assert uuid_is(f"{uuid16:08X}", uuid16)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config_file", "required"),
|
||||
[
|
||||
# Auto-loaded by esp32_improv only: nothing to find until Improv asks for it
|
||||
("improv_only.yaml", False),
|
||||
# The configuration defines a service clients are meant to connect to
|
||||
("own_service.yaml", True),
|
||||
# Manufacturer data is only useful if it is actually broadcast
|
||||
("manufacturer_data_only.yaml", True),
|
||||
],
|
||||
)
|
||||
def test_advertising_required(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
config_file: str,
|
||||
required: bool,
|
||||
) -> None:
|
||||
"""The server only requests advertising when the configuration needs it."""
|
||||
main_cpp = generate_main(component_config_path(config_file))
|
||||
|
||||
assert f"set_advertising_required({str(required).lower()})" in main_cpp
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
esphome:
|
||||
name: test-keyboard-no-label
|
||||
|
||||
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:
|
||||
displays: tft_display
|
||||
widgets:
|
||||
- keyboard:
|
||||
id: keyboard_widget
|
||||
@@ -0,0 +1,34 @@
|
||||
esphome:
|
||||
name: test-qrcode-no-label
|
||||
|
||||
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:
|
||||
displays: tft_display
|
||||
widgets:
|
||||
- qrcode:
|
||||
id: qr_widget
|
||||
size: 100
|
||||
text: "esphome.io"
|
||||
@@ -0,0 +1,35 @@
|
||||
esphome:
|
||||
name: test-tabview-no-label
|
||||
|
||||
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:
|
||||
displays: tft_display
|
||||
widgets:
|
||||
- tabview:
|
||||
id: tabview_widget
|
||||
tabs:
|
||||
- name: "Tab 1"
|
||||
id: tab_1
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Widgets whose LVGL C implementation creates or references labels
|
||||
internally (tab titles, key legends, the QR canvas fallback) must declare
|
||||
the label dependency in ``get_uses()``. Otherwise a config that contains
|
||||
no ``label`` widget of its own compiles LVGL without ``LV_USE_LABEL`` and
|
||||
fails at C compile time with undefined ``lv_label_*`` symbols.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.lvgl import defines as df
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"yaml_file",
|
||||
[
|
||||
"qrcode_no_label.yaml",
|
||||
"keyboard_no_label.yaml",
|
||||
"tabview_no_label.yaml",
|
||||
],
|
||||
)
|
||||
def test_label_less_config_enables_lv_use_label(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
yaml_file: str,
|
||||
) -> None:
|
||||
generate_main(component_config_path(yaml_file))
|
||||
assert "LV_USE_LABEL" in df.get_defines()
|
||||
@@ -5,11 +5,7 @@ from __future__ import annotations
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.noise import (
|
||||
decode_encryption_key,
|
||||
is_reserved_key,
|
||||
validate_encryption_key,
|
||||
)
|
||||
from esphome.components.noise import decode_encryption_key, validate_encryption_key
|
||||
|
||||
KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||
|
||||
@@ -41,6 +37,8 @@ def test_decode_encryption_key_rejects_short_decode() -> None:
|
||||
decode_encryption_key("AAECAw==")
|
||||
|
||||
|
||||
def test_is_reserved_key() -> None:
|
||||
assert is_reserved_key("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
|
||||
assert not is_reserved_key(KEY)
|
||||
def test_validate_encryption_key_rejects_all_zeros() -> None:
|
||||
"""The all-zeros key is the provisioning sentinel the device treats as no
|
||||
key, so it never reaches a build."""
|
||||
with pytest.raises(cv.Invalid, match="all-zeros key is reserved"):
|
||||
validate_encryption_key("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -14,6 +15,7 @@ from esphome.components.esphome.ota import (
|
||||
_validate_no_password_with_encryption,
|
||||
ota_esphome_final_validate,
|
||||
)
|
||||
from esphome.components.noise import static_encryption_key
|
||||
from esphome.const import (
|
||||
CONF_API,
|
||||
CONF_ENCRYPTION,
|
||||
@@ -115,7 +117,6 @@ def test_non_esphome_ota_unaffected() -> None:
|
||||
|
||||
API_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||
OTHER_KEY = "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA="
|
||||
ZEROS_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||
|
||||
|
||||
def test_encryption_key_inherited_from_api() -> None:
|
||||
@@ -197,36 +198,6 @@ def test_encryption_without_any_key_rejected() -> None:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_encryption_explicit_all_zeros_key_rejected() -> None:
|
||||
"""The all-zeros key is the provisioning sentinel; the device would treat
|
||||
it as no PSK and accept plaintext, so it must fail validation."""
|
||||
full_conf = {
|
||||
CONF_OTA: [
|
||||
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: ZEROS_KEY}})
|
||||
],
|
||||
}
|
||||
token = fv.full_config.set(full_conf)
|
||||
try:
|
||||
with pytest.raises(cv.Invalid, match="all-zeros key is reserved"):
|
||||
ota_esphome_final_validate({})
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_encryption_inherited_all_zeros_key_rejected() -> None:
|
||||
"""An all-zeros api key must not silently disable ota encryption either."""
|
||||
full_conf = {
|
||||
CONF_API: {CONF_ENCRYPTION: {CONF_KEY: ZEROS_KEY}},
|
||||
CONF_OTA: [_make_ota_config(port=3232, **{CONF_ENCRYPTION: {}})],
|
||||
}
|
||||
token = fv.full_config.set(full_conf)
|
||||
try:
|
||||
with pytest.raises(cv.Invalid, match="all-zeros key is reserved"):
|
||||
ota_esphome_final_validate({})
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_encryption_key_mismatch_between_merged_configs_rejected() -> None:
|
||||
"""Same-port configs with different encryption keys raise."""
|
||||
full_conf = {
|
||||
@@ -295,13 +266,14 @@ def test_encryption_explicit_key_with_runtime_provisioned_api_accepted() -> None
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("component", ["web_server", "prometheus"])
|
||||
def test_encryption_with_web_server_ota_warns(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
caplog: pytest.LogCaptureFixture, component: str
|
||||
) -> None:
|
||||
"""With the web_server component the plaintext /update endpoint is always
|
||||
on; the combination validates with a warning."""
|
||||
"""web_server and prometheus keep the shared listener up, so the
|
||||
plaintext /update endpoint is always on and the combination warns."""
|
||||
full_conf = {
|
||||
"web_server": {},
|
||||
component: {},
|
||||
CONF_OTA: [
|
||||
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: OTHER_KEY}}),
|
||||
{CONF_PLATFORM: "web_server", CONF_ID: ID("ota_ws", is_manual=False)},
|
||||
@@ -316,12 +288,12 @@ def test_encryption_with_web_server_ota_warns(
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_encryption_with_captive_portal_web_server_ota_warns(
|
||||
def test_encryption_with_captive_portal_does_not_warn(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""captive_portal auto-loads the web_server ota platform without the
|
||||
web_server component; encryption stays usable and only warns, so the
|
||||
fallback AP recovery path is not lost."""
|
||||
web_server component; its endpoint only exists while the fallback AP is
|
||||
active and is the intended recovery path, so there is no warning."""
|
||||
full_conf = {
|
||||
"captive_portal": {},
|
||||
CONF_OTA: [
|
||||
@@ -333,7 +305,10 @@ def test_encryption_with_captive_portal_web_server_ota_warns(
|
||||
try:
|
||||
with caplog.at_level(logging.WARNING):
|
||||
ota_esphome_final_validate({})
|
||||
assert any("captive_portal" in record.message for record in caplog.records)
|
||||
assert not any(
|
||||
"OTA encryption does not cover" in record.message
|
||||
for record in caplog.records
|
||||
)
|
||||
esphome_conf = next(
|
||||
conf
|
||||
for conf in fv.full_config.get()[CONF_OTA]
|
||||
@@ -344,6 +319,100 @@ def test_encryption_with_captive_portal_web_server_ota_warns(
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_password_with_api_key_warns(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""A static api key makes the device offer encryption and the CLI take
|
||||
it, so the password is dead weight; the config validates with a warning."""
|
||||
full_conf = {
|
||||
CONF_API: {CONF_ENCRYPTION: {CONF_KEY: API_KEY}},
|
||||
CONF_OTA: [_make_ota_config(port=3232, **{CONF_PASSWORD: "pw"})],
|
||||
}
|
||||
token = fv.full_config.set(full_conf)
|
||||
try:
|
||||
with caplog.at_level(logging.WARNING):
|
||||
ota_esphome_final_validate({})
|
||||
assert any("wastes significant flash" in r.message for r in caplog.records)
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_password_with_runtime_api_key_warns_differently(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""The CLI still needs the password, but the provisioned key also
|
||||
authenticates uploads; the warning says so without the flash advice."""
|
||||
full_conf = {
|
||||
CONF_API: {CONF_ENCRYPTION: {}},
|
||||
CONF_OTA: [_make_ota_config(port=3232, **{CONF_PASSWORD: "pw"})],
|
||||
}
|
||||
token = fv.full_config.set(full_conf)
|
||||
try:
|
||||
with caplog.at_level(logging.WARNING):
|
||||
ota_esphome_final_validate({})
|
||||
messages = [r.message for r in caplog.records]
|
||||
assert any("provisioned at runtime also authenticates" in m for m in messages)
|
||||
assert not any("wastes significant flash" in m for m in messages)
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_password_without_api_key_no_warning(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Without an api key there is no offer, so nothing to warn about."""
|
||||
full_conf = {
|
||||
CONF_API: {},
|
||||
CONF_OTA: [_make_ota_config(port=3232, **{CONF_PASSWORD: "pw"})],
|
||||
}
|
||||
token = fv.full_config.set(full_conf)
|
||||
try:
|
||||
with caplog.at_level(logging.WARNING):
|
||||
ota_esphome_final_validate({})
|
||||
assert not any("authenticates" in r.message for r in caplog.records)
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_web_server_component_without_ota_platform_does_not_warn(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""The web_server component alone has no /update endpoint."""
|
||||
full_conf = {
|
||||
"web_server": {},
|
||||
CONF_OTA: [
|
||||
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: OTHER_KEY}})
|
||||
],
|
||||
}
|
||||
token = fv.full_config.set(full_conf)
|
||||
try:
|
||||
with caplog.at_level(logging.WARNING):
|
||||
ota_esphome_final_validate({})
|
||||
assert not any(
|
||||
"OTA encryption does not cover" in r.message for r in caplog.records
|
||||
)
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_web_server_ota_platform_alone_does_not_warn(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Only the web_server component starts the shared listener, so the ota
|
||||
platform on its own never exposes /update."""
|
||||
full_conf = {
|
||||
CONF_OTA: [
|
||||
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: OTHER_KEY}}),
|
||||
{CONF_PLATFORM: "web_server", CONF_ID: ID("ota_ws", is_manual=False)},
|
||||
],
|
||||
}
|
||||
token = fv.full_config.set(full_conf)
|
||||
try:
|
||||
with caplog.at_level(logging.WARNING):
|
||||
ota_esphome_final_validate({})
|
||||
assert not any("plaintext /update" in r.message for r in caplog.records)
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_web_server_ota_without_encryption_unaffected() -> None:
|
||||
"""web_server ota stays valid alongside an unencrypted esphome entry."""
|
||||
full_conf = {
|
||||
@@ -370,20 +439,87 @@ def test_auto_load_pulls_noise_only_for_encryption() -> None:
|
||||
assert "noise" in AUTO_LOAD({})
|
||||
|
||||
|
||||
def test_filter_source_files_excludes_noise_without_encryption() -> None:
|
||||
"""The noise transport source compiles only for encrypted builds."""
|
||||
old_config = CORE.config
|
||||
try:
|
||||
CORE.config = {CONF_OTA: [_make_ota_config(port=3232)]}
|
||||
assert FILTER_SOURCE_FILES() == ["ota_esphome_noise.cpp"]
|
||||
CORE.config = {
|
||||
CONF_OTA: [
|
||||
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: API_KEY}})
|
||||
]
|
||||
}
|
||||
assert FILTER_SOURCE_FILES() == []
|
||||
finally:
|
||||
CORE.config = old_config
|
||||
def test_static_encryption_key() -> None:
|
||||
"""Only a build-time key counts; a runtime provisioned one does not."""
|
||||
assert static_encryption_key({}) is None
|
||||
assert static_encryption_key({CONF_ENCRYPTION: {}}) is None
|
||||
assert static_encryption_key({CONF_ENCRYPTION: {CONF_KEY: API_KEY}}) == API_KEY
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("yaml_name", "defines_present", "defines_absent"),
|
||||
[
|
||||
# An api key alone compiles the transport in without requiring it;
|
||||
# the device uses the api server's key, not a copy
|
||||
(
|
||||
"api_key_offer",
|
||||
{"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_FROM_API"},
|
||||
{"USE_OTA_ENCRYPTION_REQUIRED", "USE_OTA_ENCRYPTION_PROVISIONED"},
|
||||
),
|
||||
# A password still guards plaintext uploads on an offering device
|
||||
(
|
||||
"api_key_offer_password",
|
||||
{"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_FROM_API", "USE_OTA_PASSWORD"},
|
||||
{"USE_OTA_ENCRYPTION_REQUIRED", "USE_OTA_ENCRYPTION_PROVISIONED"},
|
||||
),
|
||||
# The ota encryption block is what makes the device refuse plaintext
|
||||
(
|
||||
"encryption_required",
|
||||
{
|
||||
"USE_OTA_ENCRYPTION",
|
||||
"USE_OTA_ENCRYPTION_REQUIRED",
|
||||
"USE_OTA_ENCRYPTION_FROM_API",
|
||||
},
|
||||
{"USE_OTA_ENCRYPTION_PROVISIONED"},
|
||||
),
|
||||
# Without api encryption the ota key is the device's own
|
||||
(
|
||||
"own_key",
|
||||
{"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_REQUIRED"},
|
||||
{"USE_OTA_ENCRYPTION_FROM_API", "USE_OTA_ENCRYPTION_PROVISIONED"},
|
||||
),
|
||||
# A key provisioned at runtime lives in the api server; the device
|
||||
# offers with it once provisioned and never requires it
|
||||
(
|
||||
"runtime_api_key",
|
||||
{
|
||||
"USE_OTA_ENCRYPTION",
|
||||
"USE_OTA_ENCRYPTION_FROM_API",
|
||||
"USE_OTA_ENCRYPTION_PROVISIONED",
|
||||
},
|
||||
{"USE_OTA_ENCRYPTION_REQUIRED"},
|
||||
),
|
||||
# No api encryption at all keeps the noise glue out of the build
|
||||
(
|
||||
"plain",
|
||||
set(),
|
||||
{
|
||||
"USE_OTA_ENCRYPTION",
|
||||
"USE_OTA_ENCRYPTION_REQUIRED",
|
||||
"USE_OTA_ENCRYPTION_FROM_API",
|
||||
"USE_OTA_ENCRYPTION_PROVISIONED",
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_encryption_offer_codegen(
|
||||
generate_main: Callable[[str], str],
|
||||
yaml_name: str,
|
||||
defines_present: set[str],
|
||||
defines_absent: set[str],
|
||||
) -> None:
|
||||
main_cpp = generate_main(
|
||||
f"tests/component_tests/ota/test_esphome_ota_{yaml_name}.yaml"
|
||||
)
|
||||
defines = {define.name for define in CORE.defines}
|
||||
assert defines_present <= defines
|
||||
assert not (defines_absent & defines)
|
||||
encrypted = "USE_OTA_ENCRYPTION" in defines_present
|
||||
own_key = encrypted and "USE_OTA_ENCRYPTION_FROM_API" not in defines_present
|
||||
assert ("esphome_esphomeotacomponent_id->set_noise_psk(" in main_cpp) is own_key
|
||||
assert ("set_auth_password(" in main_cpp) is ("USE_OTA_PASSWORD" in defines_present)
|
||||
# The noise transport source compiles only when the define is set
|
||||
assert FILTER_SOURCE_FILES() == ([] if encrypted else ["ota_esphome_noise.cpp"])
|
||||
|
||||
|
||||
def test_password_with_encryption_rejected() -> None:
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
esphome:
|
||||
name: ota-offer
|
||||
|
||||
host:
|
||||
|
||||
api:
|
||||
encryption:
|
||||
key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
@@ -0,0 +1,12 @@
|
||||
esphome:
|
||||
name: ota-offer-password
|
||||
|
||||
host:
|
||||
|
||||
api:
|
||||
encryption:
|
||||
key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
password: "superlongpasswordthatnoonewillknow"
|
||||
@@ -0,0 +1,12 @@
|
||||
esphome:
|
||||
name: ota-encryption-required
|
||||
|
||||
host:
|
||||
|
||||
api:
|
||||
encryption:
|
||||
key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
encryption:
|
||||
@@ -0,0 +1,11 @@
|
||||
esphome:
|
||||
name: ota-own-key
|
||||
|
||||
host:
|
||||
|
||||
api:
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
encryption:
|
||||
key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||
@@ -0,0 +1,9 @@
|
||||
esphome:
|
||||
name: ota-plain
|
||||
|
||||
host:
|
||||
|
||||
api:
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
@@ -0,0 +1,10 @@
|
||||
esphome:
|
||||
name: ota-runtime-key
|
||||
|
||||
host:
|
||||
|
||||
api:
|
||||
encryption:
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
@@ -26,7 +26,7 @@ from ..types import SetCoreConfigCallable
|
||||
(PlatformFramework.ESP32_IDF, None, True),
|
||||
(PlatformFramework.RTL87XX_ARDUINO, FAMILY_RTL8720C, True),
|
||||
(PlatformFramework.RTL87XX_ARDUINO, FAMILY_RTL8710B, False),
|
||||
(PlatformFramework.BK72XX_ARDUINO, FAMILY_BK7231N, True),
|
||||
(PlatformFramework.BK72XX_ARDUINO, FAMILY_BK7231N, False),
|
||||
(PlatformFramework.BK72XX_ARDUINO, FAMILY_BK7238, True),
|
||||
(PlatformFramework.BK72XX_ARDUINO, FAMILY_BK7231T, False),
|
||||
(PlatformFramework.ESP8266_ARDUINO, None, False),
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Tests for the sdl display schema, in particular the headless option."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.sdl.display import (
|
||||
CONF_SDL_ID,
|
||||
CONFIG_SCHEMA,
|
||||
headless_final_validate,
|
||||
)
|
||||
from esphome.config import Config
|
||||
from esphome.const import PlatformFramework
|
||||
from esphome.core import ID
|
||||
from esphome.final_validate import full_config
|
||||
from esphome.types import ConfigType
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _host_platform(set_core_config: SetCoreConfigCallable) -> None:
|
||||
set_core_config(PlatformFramework.HOST_NATIVE)
|
||||
|
||||
|
||||
def _config(**extra: object) -> ConfigType:
|
||||
config: ConfigType = {
|
||||
"dimensions": {"width": 320, "height": 240},
|
||||
# sdl2-config is not necessarily installed in the test environment
|
||||
"sdl_options": "-lSDL2",
|
||||
}
|
||||
config.update(extra)
|
||||
return config
|
||||
|
||||
|
||||
def test_defaults_to_windowed() -> None:
|
||||
"""A display without the option is not headless."""
|
||||
assert CONFIG_SCHEMA(_config())["headless"] is False
|
||||
|
||||
|
||||
def test_headless_accepted() -> None:
|
||||
"""A headless display needs nothing beyond the dimensions."""
|
||||
assert CONFIG_SCHEMA(_config(headless=True))["headless"] is True
|
||||
|
||||
|
||||
def test_headless_rejects_window_options() -> None:
|
||||
"""Window options are meaningless without a window."""
|
||||
with pytest.raises(cv.Invalid, match="has no effect"):
|
||||
CONFIG_SCHEMA(
|
||||
_config(headless=True, window_options={"position": {"x": 0, "y": 0}})
|
||||
)
|
||||
|
||||
|
||||
def test_headless_rejects_snapshot_key() -> None:
|
||||
"""A headless display has no keyboard, so the action is the only way in."""
|
||||
with pytest.raises(cv.Invalid, match="snapshot.take"):
|
||||
CONFIG_SCHEMA(_config(headless=True, snapshot_key="SDLK_F12"))
|
||||
|
||||
|
||||
def test_snapshot_key_accepted_when_windowed() -> None:
|
||||
"""The key is only valid alongside a window."""
|
||||
config = CONFIG_SCHEMA(_config(snapshot_key="SDLK_F12"))
|
||||
assert str(config["snapshot_key"]) == "SDLK_F12"
|
||||
|
||||
|
||||
def _declare_sdl_display(headless: bool) -> ID:
|
||||
"""Register a full_config with a single sdl display declaration and return a reference to it.
|
||||
|
||||
Mirrors what the real config pipeline leaves behind: a "display" domain entry plus a
|
||||
declare_ids record id_declaration_match_schema uses to find it again.
|
||||
"""
|
||||
declared_id = ID("my_sdl", is_declaration=True)
|
||||
fc = Config()
|
||||
fc["display"] = [
|
||||
{
|
||||
"platform": "sdl",
|
||||
"id": declared_id,
|
||||
"headless": headless,
|
||||
"dimensions": {"width": 320, "height": 240},
|
||||
}
|
||||
]
|
||||
fc.declare_ids.append((declared_id, ["display", 0, "id"]))
|
||||
full_config.set(fc)
|
||||
return ID("my_sdl")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform", ["binary_sensor", "touchscreen"])
|
||||
def test_headless_final_validate_rejects_headless_display(platform: str) -> None:
|
||||
"""binary_sensor and touchscreen both need a window, so a headless display is rejected."""
|
||||
sdl_ref = _declare_sdl_display(headless=True)
|
||||
schema = headless_final_validate(platform)
|
||||
with pytest.raises(cv.Invalid, match="needs a window"):
|
||||
schema({CONF_SDL_ID: sdl_ref})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform", ["binary_sensor", "touchscreen"])
|
||||
def test_headless_final_validate_accepts_windowed_display(platform: str) -> None:
|
||||
"""The same platforms are accepted once the display has a window."""
|
||||
sdl_ref = _declare_sdl_display(headless=False)
|
||||
schema = headless_final_validate(platform)
|
||||
schema({CONF_SDL_ID: sdl_ref}) # Should not raise.
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Tests for template climate config validation."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.template.climate import (
|
||||
CONF_SET_TARGET_HUMIDITY_ACTION,
|
||||
CONF_SET_TARGET_TEMPERATURE_ACTION,
|
||||
CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION,
|
||||
CONF_SET_TARGET_TEMPERATURE_LOW_ACTION,
|
||||
CONF_SUPPORTS_CURRENT_HUMIDITY,
|
||||
CONF_SUPPORTS_CURRENT_TEMPERATURE,
|
||||
CONF_SUPPORTS_TARGET_HUMIDITY,
|
||||
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE,
|
||||
CONF_TARGET_HUMIDITY,
|
||||
_resolve_supports,
|
||||
_validate_initial_state,
|
||||
_validate_set_actions,
|
||||
)
|
||||
from esphome.const import (
|
||||
CONF_HUMIDITY_SENSOR,
|
||||
CONF_INITIAL_STATE,
|
||||
CONF_SENSOR,
|
||||
CONF_TARGET_TEMPERATURE,
|
||||
CONF_TARGET_TEMPERATURE_HIGH,
|
||||
CONF_TARGET_TEMPERATURE_LOW,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
|
||||
def test_supports_current_temperature_derived_from_sensor() -> None:
|
||||
config: ConfigType = {CONF_SENSOR: "some_sensor"}
|
||||
assert _resolve_supports(config)[CONF_SUPPORTS_CURRENT_TEMPERATURE] is True
|
||||
|
||||
|
||||
def test_supports_current_temperature_false_without_sensor() -> None:
|
||||
assert _resolve_supports({})[CONF_SUPPORTS_CURRENT_TEMPERATURE] is False
|
||||
|
||||
|
||||
def test_supports_current_temperature_explicit_true_without_sensor_allowed() -> None:
|
||||
# The value can still be reported with climate.template.publish.
|
||||
config: ConfigType = {CONF_SUPPORTS_CURRENT_TEMPERATURE: True}
|
||||
assert _resolve_supports(config)[CONF_SUPPORTS_CURRENT_TEMPERATURE] is True
|
||||
|
||||
|
||||
def test_supports_current_temperature_false_with_sensor_rejected() -> None:
|
||||
config: ConfigType = {
|
||||
CONF_SENSOR: "some_sensor",
|
||||
CONF_SUPPORTS_CURRENT_TEMPERATURE: False,
|
||||
}
|
||||
with pytest.raises(cv.Invalid, match="cannot be false"):
|
||||
_resolve_supports(config)
|
||||
|
||||
|
||||
def test_supports_current_humidity_false_with_sensor_rejected() -> None:
|
||||
config: ConfigType = {
|
||||
CONF_HUMIDITY_SENSOR: "some_sensor",
|
||||
CONF_SUPPORTS_CURRENT_HUMIDITY: False,
|
||||
}
|
||||
with pytest.raises(cv.Invalid, match="cannot be false"):
|
||||
_resolve_supports(config)
|
||||
|
||||
|
||||
def test_two_point_derived_from_set_actions() -> None:
|
||||
config: ConfigType = {
|
||||
CONF_SET_TARGET_TEMPERATURE_LOW_ACTION: [{}],
|
||||
CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION: [{}],
|
||||
}
|
||||
assert _resolve_supports(config)[CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE] is True
|
||||
|
||||
|
||||
def test_two_point_false_with_set_action_rejected() -> None:
|
||||
config: ConfigType = {
|
||||
CONF_SET_TARGET_TEMPERATURE_LOW_ACTION: [{}],
|
||||
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE: False,
|
||||
}
|
||||
with pytest.raises(cv.Invalid, match="cannot be false"):
|
||||
_resolve_supports(config)
|
||||
|
||||
|
||||
def test_target_humidity_derived_from_set_action() -> None:
|
||||
config: ConfigType = {CONF_SET_TARGET_HUMIDITY_ACTION: [{}]}
|
||||
assert _resolve_supports(config)[CONF_SUPPORTS_TARGET_HUMIDITY] is True
|
||||
|
||||
|
||||
def test_set_target_temperature_low_requires_high() -> None:
|
||||
config: ConfigType = {CONF_SET_TARGET_TEMPERATURE_LOW_ACTION: [{}]}
|
||||
with pytest.raises(cv.Invalid, match="must be used together"):
|
||||
_validate_set_actions(config)
|
||||
|
||||
|
||||
def test_set_target_temperature_conflicts_with_two_point_actions() -> None:
|
||||
config: ConfigType = {
|
||||
CONF_SET_TARGET_TEMPERATURE_ACTION: [{}],
|
||||
CONF_SET_TARGET_TEMPERATURE_LOW_ACTION: [{}],
|
||||
CONF_SET_TARGET_TEMPERATURE_HIGH_ACTION: [{}],
|
||||
}
|
||||
with pytest.raises(cv.Invalid, match="cannot be used together"):
|
||||
_validate_set_actions(config)
|
||||
|
||||
|
||||
def test_initial_state_target_temperature_rejected_with_two_point() -> None:
|
||||
config: ConfigType = {
|
||||
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE: True,
|
||||
CONF_SUPPORTS_TARGET_HUMIDITY: False,
|
||||
CONF_INITIAL_STATE: {CONF_TARGET_TEMPERATURE: 21.0},
|
||||
}
|
||||
with pytest.raises(cv.Invalid, match="is not available"):
|
||||
_validate_initial_state(config)
|
||||
|
||||
|
||||
def test_initial_state_two_point_values_rejected_without_two_point() -> None:
|
||||
config: ConfigType = {
|
||||
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE: False,
|
||||
CONF_SUPPORTS_TARGET_HUMIDITY: False,
|
||||
CONF_INITIAL_STATE: {
|
||||
CONF_TARGET_TEMPERATURE_LOW: 18.0,
|
||||
CONF_TARGET_TEMPERATURE_HIGH: 24.0,
|
||||
},
|
||||
}
|
||||
with pytest.raises(cv.Invalid, match="requires"):
|
||||
_validate_initial_state(config)
|
||||
|
||||
|
||||
def test_initial_state_target_humidity_rejected_without_support() -> None:
|
||||
config: ConfigType = {
|
||||
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE: False,
|
||||
CONF_SUPPORTS_TARGET_HUMIDITY: False,
|
||||
CONF_INITIAL_STATE: {CONF_TARGET_HUMIDITY: 50},
|
||||
}
|
||||
with pytest.raises(cv.Invalid, match="requires"):
|
||||
_validate_initial_state(config)
|
||||
|
||||
|
||||
def test_initial_state_matching_two_point_accepted() -> None:
|
||||
config: ConfigType = {
|
||||
CONF_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE: True,
|
||||
CONF_SUPPORTS_TARGET_HUMIDITY: True,
|
||||
CONF_INITIAL_STATE: {
|
||||
CONF_TARGET_TEMPERATURE_LOW: 18.0,
|
||||
CONF_TARGET_TEMPERATURE_HIGH: 24.0,
|
||||
CONF_TARGET_HUMIDITY: 50,
|
||||
},
|
||||
}
|
||||
assert _validate_initial_state(config) is config
|
||||
@@ -0,0 +1,5 @@
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
manifest.dependencies = manifest.dependencies + ["sensor", "spi"]
|
||||
@@ -0,0 +1,62 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "esphome/components/atm90e32/atm90e32.h"
|
||||
|
||||
namespace esphome::atm90e32::testing {
|
||||
|
||||
TEST(ATM90E32OffsetRegisterVerification, AcceptsExactSignedReadback) {
|
||||
EXPECT_TRUE(offset_register_value_matches(0x007B, 123));
|
||||
EXPECT_TRUE(offset_register_value_matches(0xFF85, -123));
|
||||
}
|
||||
|
||||
TEST(ATM90E32OffsetRegisterVerification, RejectsMismatchedReadback) {
|
||||
EXPECT_FALSE(offset_register_value_matches(0x007C, 123));
|
||||
EXPECT_FALSE(offset_register_value_matches(0xFF84, -123));
|
||||
}
|
||||
|
||||
TEST(ATM90E32OffsetRestoreState, ReportsVerifiedStoredValuesAsRestored) {
|
||||
const auto state = resolve_offset_restore_state(true, true, false);
|
||||
|
||||
EXPECT_TRUE(state.restored);
|
||||
EXPECT_TRUE(state.values_verified);
|
||||
}
|
||||
|
||||
TEST(ATM90E32OffsetRestoreState, ReportsVerifiedConfigFallbackAsNotRestored) {
|
||||
const auto state = resolve_offset_restore_state(true, false, true);
|
||||
|
||||
EXPECT_FALSE(state.restored);
|
||||
EXPECT_TRUE(state.values_verified);
|
||||
}
|
||||
|
||||
TEST(ATM90E32OffsetRestoreState, ReportsFailedConfigFallbackAsUnverified) {
|
||||
const auto state = resolve_offset_restore_state(true, false, false);
|
||||
|
||||
EXPECT_FALSE(state.restored);
|
||||
EXPECT_FALSE(state.values_verified);
|
||||
}
|
||||
|
||||
TEST(ATM90E32OffsetRestoreState, ReportsConfigWithoutStoredValuesAsNotRestored) {
|
||||
const auto state = resolve_offset_restore_state(false, true, false);
|
||||
|
||||
EXPECT_FALSE(state.restored);
|
||||
EXPECT_TRUE(state.values_verified);
|
||||
}
|
||||
|
||||
TEST(ATM90E32OffsetPersistence, RollsBackStoredValuesOrZeroSentinel) {
|
||||
const OffsetCalibration previous[3]{{1, -1}, {2, -2}, {3, -3}};
|
||||
OffsetCalibration rollback[3]{};
|
||||
|
||||
prepare_offset_rollback(previous, true, rollback);
|
||||
for (uint8_t phase = 0; phase < 3; phase++) {
|
||||
EXPECT_EQ(rollback[phase].first_offset, previous[phase].first_offset);
|
||||
EXPECT_EQ(rollback[phase].second_offset, previous[phase].second_offset);
|
||||
}
|
||||
|
||||
prepare_offset_rollback(previous, false, rollback);
|
||||
for (const auto &phase : rollback) {
|
||||
EXPECT_EQ(phase.first_offset, 0);
|
||||
EXPECT_EQ(phase.second_offset, 0);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::atm90e32::testing
|
||||
@@ -30,8 +30,7 @@ climate:
|
||||
- switch.turn_on: climate_heater_switch
|
||||
- switch.turn_off: climate_cooler_switch
|
||||
# Thermostat-based climate so climate.control: action variants get build
|
||||
# coverage (bang_bang doesn't support fan modes, presets, etc.). Climate
|
||||
# has no template platform, so thermostat is the right vehicle.
|
||||
# coverage (bang_bang doesn't support fan modes, presets, etc.).
|
||||
- platform: thermostat
|
||||
id: climate_test_thermostat
|
||||
name: Test Thermostat
|
||||
|
||||
@@ -3,6 +3,6 @@ substitutions:
|
||||
rx_pin: GPIO14
|
||||
|
||||
packages:
|
||||
uart_38400: !include ../../test_build_components/common/uart_38400/esp32-idf.yaml
|
||||
uart_38400_even: !include ../../test_build_components/common/uart_38400_even/esp32-idf.yaml
|
||||
|
||||
<<: !include common.yaml
|
||||
|
||||
@@ -3,6 +3,6 @@ substitutions:
|
||||
rx_pin: GPIO3
|
||||
|
||||
packages:
|
||||
uart_38400: !include ../../test_build_components/common/uart_38400/esp8266-ard.yaml
|
||||
uart_38400_even: !include ../../test_build_components/common/uart_38400_even/esp8266-ard.yaml
|
||||
|
||||
<<: !include common.yaml
|
||||
|
||||
@@ -3,6 +3,6 @@ substitutions:
|
||||
rx_pin: GPIO5
|
||||
|
||||
packages:
|
||||
uart_38400: !include ../../test_build_components/common/uart_38400/rp2040-ard.yaml
|
||||
uart_38400_even: !include ../../test_build_components/common/uart_38400_even/rp2040-ard.yaml
|
||||
|
||||
<<: !include common.yaml
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Exercises the ESP-NOW-over-hosted shim: on the ESP32-P4 host, esp32_hosted
|
||||
# supplies the esp_now_* symbols that the espnow component links against.
|
||||
packages:
|
||||
esp32_hosted: !include common.yaml
|
||||
espnow: !include ../espnow/common.yaml
|
||||
@@ -1,4 +1,4 @@
|
||||
packages:
|
||||
uart_1200: !include ../../test_build_components/common/uart_1200/esp32-idf.yaml
|
||||
uart_1200_none_2stopbits: !include ../../test_build_components/common/uart_1200_none_2stopbits/esp32-idf.yaml
|
||||
|
||||
<<: !include common.yaml
|
||||
|
||||
@@ -3,6 +3,6 @@ substitutions:
|
||||
uart_rx_pin: GPIO3
|
||||
|
||||
packages:
|
||||
uart_1200: !include ../../test_build_components/common/uart_1200/esp8266-ard.yaml
|
||||
uart_1200_none_2stopbits: !include ../../test_build_components/common/uart_1200_none_2stopbits/esp8266-ard.yaml
|
||||
|
||||
<<: !include common.yaml
|
||||
|
||||
@@ -68,6 +68,14 @@ class Initiator {
|
||||
|
||||
static const uint8_t PROLOGUE[] = {'t', 'e', 's', 't', 'p', 'r', 'o', 'l', 'o', 'g', 'u', 'e'};
|
||||
|
||||
// The context only points at the key and init() copies it before returning,
|
||||
// so a temporary context over a temporary key is safe within one call
|
||||
static NoiseContext ctx_for(const psk_t &psk) {
|
||||
NoiseContext ctx;
|
||||
ctx.set_psk(psk.data());
|
||||
return ctx;
|
||||
}
|
||||
|
||||
static psk_t make_psk(uint8_t seed) {
|
||||
psk_t psk;
|
||||
for (size_t i = 0; i < psk.size(); i++) {
|
||||
@@ -102,7 +110,7 @@ TEST(NoiseResponderHandshakeTest, MessageMethodsErrorBeforeInit) {
|
||||
TEST(NoiseResponderHandshakeTest, FullHandshakeAndTransportRoundTrip) {
|
||||
const psk_t psk = make_psk(7);
|
||||
NoiseResponderHandshake responder;
|
||||
ASSERT_EQ(responder.init(psk, PROLOGUE, sizeof(PROLOGUE)), 0);
|
||||
ASSERT_EQ(responder.init(ctx_for(psk), PROLOGUE, sizeof(PROLOGUE)), 0);
|
||||
EXPECT_EQ(responder.action(), Action::ACTION_READ);
|
||||
|
||||
Initiator initiator(psk, PROLOGUE, sizeof(PROLOGUE));
|
||||
@@ -155,8 +163,8 @@ TEST(NoiseResponderHandshakeTest, ReInitRestartsHandshake) {
|
||||
// proves the restart took effect; the old state surviving would fail the
|
||||
// MAC here.
|
||||
NoiseResponderHandshake responder;
|
||||
ASSERT_EQ(responder.init(make_psk(7), PROLOGUE, sizeof(PROLOGUE)), 0);
|
||||
ASSERT_EQ(responder.init(make_psk(9), PROLOGUE, sizeof(PROLOGUE)), 0);
|
||||
ASSERT_EQ(responder.init(ctx_for(make_psk(7)), PROLOGUE, sizeof(PROLOGUE)), 0);
|
||||
ASSERT_EQ(responder.init(ctx_for(make_psk(9)), PROLOGUE, sizeof(PROLOGUE)), 0);
|
||||
EXPECT_EQ(responder.action(), Action::ACTION_READ);
|
||||
|
||||
Initiator initiator(make_psk(9), PROLOGUE, sizeof(PROLOGUE));
|
||||
@@ -168,7 +176,7 @@ TEST(NoiseResponderHandshakeTest, ReInitRestartsHandshake) {
|
||||
|
||||
TEST(NoiseResponderHandshakeTest, WrongPskFailsWithMacFailure) {
|
||||
NoiseResponderHandshake responder;
|
||||
ASSERT_EQ(responder.init(make_psk(7), PROLOGUE, sizeof(PROLOGUE)), 0);
|
||||
ASSERT_EQ(responder.init(ctx_for(make_psk(7)), PROLOGUE, sizeof(PROLOGUE)), 0);
|
||||
|
||||
Initiator initiator(make_psk(200), PROLOGUE, sizeof(PROLOGUE));
|
||||
uint8_t msg[MAX_HANDSHAKE_SIZE];
|
||||
@@ -185,7 +193,7 @@ TEST(NoiseResponderHandshakeTest, MismatchedPrologueFailsWithMacFailure) {
|
||||
// tampered preamble must fail even with the right key.
|
||||
const psk_t psk = make_psk(7);
|
||||
NoiseResponderHandshake responder;
|
||||
ASSERT_EQ(responder.init(psk, PROLOGUE, sizeof(PROLOGUE)), 0);
|
||||
ASSERT_EQ(responder.init(ctx_for(psk), PROLOGUE, sizeof(PROLOGUE)), 0);
|
||||
|
||||
static const uint8_t TAMPERED[] = {'x'};
|
||||
Initiator initiator(psk, TAMPERED, sizeof(TAMPERED));
|
||||
|
||||
@@ -17,12 +17,17 @@ TEST(NoiseContextTest, AllZerosPskIsReserved) {
|
||||
EXPECT_FALSE(NoiseContext::is_all_zeros(psk));
|
||||
|
||||
NoiseContext ctx;
|
||||
psk_t loaded;
|
||||
EXPECT_FALSE(ctx.has_psk());
|
||||
ctx.set_psk(zeros);
|
||||
EXPECT_FALSE(ctx.has_psk());
|
||||
ctx.set_psk(psk);
|
||||
ctx.load_psk(loaded);
|
||||
EXPECT_EQ(loaded, zeros);
|
||||
ctx.set_psk(psk.data());
|
||||
EXPECT_TRUE(ctx.has_psk());
|
||||
EXPECT_EQ(ctx.get_psk(), psk);
|
||||
ctx.load_psk(loaded);
|
||||
EXPECT_EQ(loaded, psk);
|
||||
// Callers map the reserved key to nullptr; the context just stores what it is given
|
||||
ctx.set_psk(nullptr);
|
||||
EXPECT_FALSE(ctx.has_psk());
|
||||
}
|
||||
|
||||
TEST(WireFormatTest, FrameHeaderIsIndicatorPlusBigEndianLength) {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
api:
|
||||
encryption:
|
||||
key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
port: 3290
|
||||
password: "superlongpasswordthatnoonewillknow"
|
||||
@@ -0,0 +1,10 @@
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
api:
|
||||
encryption:
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
port: 3291
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
ota: !include api_key_offer.yaml
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
ota: !include api_key_offer.yaml
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
ota: !include api_runtime_key.yaml
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
ota: !include api_runtime_key.yaml
|
||||
@@ -3,6 +3,6 @@ substitutions:
|
||||
rx_pin: GPIO5
|
||||
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart/esp32-idf.yaml
|
||||
uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml
|
||||
|
||||
<<: !include common.yaml
|
||||
|
||||
@@ -3,6 +3,6 @@ substitutions:
|
||||
rx_pin: GPIO2
|
||||
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml
|
||||
uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml
|
||||
|
||||
<<: !include common.yaml
|
||||
|
||||
@@ -3,6 +3,6 @@ substitutions:
|
||||
rx_pin: GPIO5
|
||||
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml
|
||||
uart_115200: !include ../../test_build_components/common/uart_115200/rp2040-ard.yaml
|
||||
|
||||
<<: !include common.yaml
|
||||
|
||||
@@ -2,7 +2,7 @@ remote_transmitter:
|
||||
id: xmitr
|
||||
pin: GPIO26
|
||||
carrier_duty_percent: 50%
|
||||
# non_blocking is bk7231n/bk7238-only; the CI board is a BK7252
|
||||
# non_blocking is bk7238-only; the CI board is a BK7252, so this builds the bit-bang path
|
||||
|
||||
packages:
|
||||
buttons: !include common-buttons.yaml
|
||||
|
||||
@@ -14,6 +14,15 @@ display:
|
||||
position:
|
||||
x: 100
|
||||
y: 100
|
||||
snapshot_key: SDLK_F12
|
||||
|
||||
- platform: sdl
|
||||
id: headless_display
|
||||
headless: true
|
||||
show_test_card: true
|
||||
dimensions:
|
||||
width: 320
|
||||
height: 240
|
||||
|
||||
- platform: sdl
|
||||
id: second_display
|
||||
@@ -46,3 +55,21 @@ binary_sensor:
|
||||
sdl_id: sdl_sdl_display
|
||||
id: key_enter
|
||||
key: SDLK_RETURN
|
||||
|
||||
esphome:
|
||||
# A name of your own is only good for one snapshot - a second one under the same name fails
|
||||
# rather than writing over the first - so these run once rather than on a repeating interval.
|
||||
on_boot:
|
||||
- delay: 2s
|
||||
- snapshot.take:
|
||||
id: headless_display
|
||||
filename: test_card.bmp
|
||||
- snapshot.take:
|
||||
id: headless_display
|
||||
filename: !lambda 'return "shot.bmp";'
|
||||
|
||||
interval:
|
||||
# A generated name has the time in it, so this one can repeat.
|
||||
- interval: 10s
|
||||
then:
|
||||
- snapshot.take: sdl_sdl_display
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Config-only test for the headless and screenshot options. The combinations that must be
|
||||
# rejected are covered by tests/component_tests/sdl/test_sdl.py; this file checks that the
|
||||
# accepted forms validate together.
|
||||
host:
|
||||
mac_address: "62:23:45:AF:B3:DD"
|
||||
|
||||
display:
|
||||
- platform: sdl
|
||||
id: headless_display
|
||||
headless: true
|
||||
dimensions: 320x240
|
||||
|
||||
- platform: sdl
|
||||
id: windowed_display
|
||||
dimensions: 320x240
|
||||
snapshot_key: SDLK_F12
|
||||
|
||||
binary_sensor:
|
||||
- platform: sdl
|
||||
sdl_id: windowed_display
|
||||
id: key_up
|
||||
key: SDLK_UP
|
||||
|
||||
interval:
|
||||
- interval: 10s
|
||||
then:
|
||||
- snapshot.take:
|
||||
id: headless_display
|
||||
filename: periodic.bmp
|
||||
@@ -0,0 +1,34 @@
|
||||
display:
|
||||
- platform: snapshot
|
||||
id: snapshot_display
|
||||
update_interval: 1s
|
||||
show_test_card: true
|
||||
# An odd width exercises the row padding in the BMP writer
|
||||
dimensions:
|
||||
width: 101
|
||||
height: 64
|
||||
|
||||
- platform: snapshot
|
||||
id: snapshot_rotated
|
||||
rotation: 90
|
||||
dimensions: 320x240
|
||||
lambda: |-
|
||||
it.filled_rectangle(0, 0, 40, 20, Color(0xFF, 0x80, 0x00));
|
||||
|
||||
esphome:
|
||||
# A name of your own is only good for one snapshot - a second one under the same name fails
|
||||
# rather than writing over the first - so these run once rather than on a repeating interval.
|
||||
on_boot:
|
||||
- delay: 2s
|
||||
- snapshot.take:
|
||||
id: snapshot_display
|
||||
filename: test_card.bmp
|
||||
- snapshot.take:
|
||||
id: snapshot_rotated
|
||||
filename: !lambda 'return "rotated.bmp";'
|
||||
|
||||
interval:
|
||||
# A generated name has the time in it, so this one can repeat.
|
||||
- interval: 10s
|
||||
then:
|
||||
- snapshot.take: snapshot_display
|
||||
@@ -0,0 +1,5 @@
|
||||
host:
|
||||
mac_address: "62:23:45:AF:B3:DE"
|
||||
|
||||
packages:
|
||||
snapshot: !include common.yaml
|
||||
@@ -3,6 +3,6 @@ substitutions:
|
||||
rx_pin: GPIO5
|
||||
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart/esp32-idf.yaml
|
||||
uart_1200_even_7bits: !include ../../test_build_components/common/uart_1200_even_7bits/esp32-idf.yaml
|
||||
|
||||
<<: !include common.yaml
|
||||
|
||||
@@ -3,6 +3,6 @@ substitutions:
|
||||
rx_pin: GPIO2
|
||||
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml
|
||||
uart_1200_even_7bits: !include ../../test_build_components/common/uart_1200_even_7bits/esp8266-ard.yaml
|
||||
|
||||
<<: !include common.yaml
|
||||
|
||||
@@ -3,6 +3,6 @@ substitutions:
|
||||
rx_pin: GPIO5
|
||||
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml
|
||||
uart_1200_even_7bits: !include ../../test_build_components/common/uart_1200_even_7bits/rp2040-ard.yaml
|
||||
|
||||
<<: !include common.yaml
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
packages:
|
||||
uart_9600_even_7bits: !include ../../test_build_components/common/uart_9600_even_7bits/esp32-idf.yaml
|
||||
|
||||
teleinfo:
|
||||
id: test_teleinfo_standard
|
||||
historical_mode: false
|
||||
update_interval: 60s
|
||||
|
||||
sensor:
|
||||
- platform: teleinfo
|
||||
name: sinsts
|
||||
tag_name: SINSTS
|
||||
teleinfo_id: test_teleinfo_standard
|
||||
unit_of_measurement: VA
|
||||
@@ -25,6 +25,27 @@ esphome:
|
||||
away: !lambda "return true;"
|
||||
is_on: !lambda "return false;"
|
||||
|
||||
- climate.template.publish:
|
||||
id: template_climate
|
||||
current_temperature: 21.0
|
||||
mode: HEAT
|
||||
fan_mode: AUTO
|
||||
swing_mode: "OFF"
|
||||
preset: NONE
|
||||
target_temperature: 22.0
|
||||
|
||||
# Templated
|
||||
- climate.template.publish:
|
||||
id: template_climate
|
||||
current_temperature: !lambda "return 21.5f;"
|
||||
mode: !lambda "return climate::CLIMATE_MODE_COOL;"
|
||||
target_temperature: !lambda "return 23.0f;"
|
||||
|
||||
- climate.template.publish:
|
||||
id: template_climate_custom_modes
|
||||
custom_fan_mode: "turbo"
|
||||
custom_preset: "eco_plus"
|
||||
|
||||
# Test C++ API: set_template() with stateless lambda (no captures)
|
||||
# NOTE: set_template() is not intended to be a public API, but we test it to ensure it doesn't break.
|
||||
- lambda: |-
|
||||
@@ -513,6 +534,98 @@ alarm_control_panel:
|
||||
codes:
|
||||
- "1234"
|
||||
|
||||
climate:
|
||||
- platform: template
|
||||
id: template_climate
|
||||
name: "Template Climate"
|
||||
optimistic: true
|
||||
sensor: template_template_sens
|
||||
supports_action: true
|
||||
supports_current_humidity: true
|
||||
restore_mode: NO_RESTORE
|
||||
initial_state:
|
||||
mode: HEAT
|
||||
target_temperature: 21.0
|
||||
fan_mode: LOW
|
||||
supported_modes:
|
||||
- "OFF"
|
||||
- HEAT
|
||||
- COOL
|
||||
supported_fan_modes:
|
||||
- AUTO
|
||||
- LOW
|
||||
- HIGH
|
||||
supported_swing_modes:
|
||||
- "OFF"
|
||||
- VERTICAL
|
||||
supported_presets:
|
||||
- NONE
|
||||
- ECO
|
||||
visual:
|
||||
min_temperature: 16.0
|
||||
max_temperature: 30.0
|
||||
temperature_step: 0.5
|
||||
set_mode_action:
|
||||
- logger.log:
|
||||
format: "set_mode_action %d"
|
||||
args: ["(int) x"]
|
||||
set_target_temperature_action:
|
||||
- logger.log:
|
||||
format: "set_target_temperature_action %.1f"
|
||||
args: ["x"]
|
||||
set_target_humidity_action:
|
||||
- logger.log:
|
||||
format: "set_target_humidity_action %.1f"
|
||||
args: ["x"]
|
||||
set_fan_mode_action:
|
||||
- logger.log:
|
||||
format: "set_fan_mode_action %d"
|
||||
args: ["(int) x"]
|
||||
set_swing_mode_action:
|
||||
- logger.log:
|
||||
format: "set_swing_mode_action %d"
|
||||
args: ["(int) x"]
|
||||
set_preset_action:
|
||||
- logger.log:
|
||||
format: "set_preset_action %d"
|
||||
args: ["(int) x"]
|
||||
on_control:
|
||||
- logger.log: "on_control fired"
|
||||
on_state:
|
||||
- logger.log: "on_state fired"
|
||||
|
||||
- platform: template
|
||||
id: template_climate_custom_modes
|
||||
name: "Template Climate Custom Modes"
|
||||
optimistic: true
|
||||
sensor: template_template_sens
|
||||
supported_modes:
|
||||
- "OFF"
|
||||
- HEAT
|
||||
custom_fan_modes:
|
||||
- turbo
|
||||
- silent
|
||||
- eco
|
||||
custom_presets:
|
||||
- eco_plus
|
||||
- power_save
|
||||
- max
|
||||
set_custom_fan_mode_action:
|
||||
- logger.log:
|
||||
format: "set_custom_fan_mode_action %s"
|
||||
args: ["x.c_str()"]
|
||||
set_custom_preset_action:
|
||||
- logger.log:
|
||||
format: "set_custom_preset_action %s"
|
||||
args: ["x.c_str()"]
|
||||
initial_state:
|
||||
custom_fan_mode: eco
|
||||
custom_preset: max
|
||||
visual:
|
||||
min_temperature: 16.0
|
||||
max_temperature: 30.0
|
||||
temperature_step: 0.5
|
||||
|
||||
water_heater:
|
||||
- platform: template
|
||||
id: template_water_heater
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Tuya without any network component (no wifi/ethernet/api), as used on
|
||||
# serial-only or BLE-only Tuya MCU boards. Regression test for
|
||||
# https://github.com/esphome/esphome/issues/18942
|
||||
substitutions:
|
||||
status_pin: P6
|
||||
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart/bk72xx-ard.yaml
|
||||
|
||||
tuya:
|
||||
status_pin: ${status_pin}
|
||||
|
||||
binary_sensor:
|
||||
- platform: tuya
|
||||
id: tuya_presence
|
||||
sensor_datapoint: 101
|
||||
|
||||
sensor:
|
||||
- platform: tuya
|
||||
id: tuya_light_intensity
|
||||
sensor_datapoint: 103
|
||||
|
||||
number:
|
||||
- platform: tuya
|
||||
id: tuya_far_detection
|
||||
number_datapoint: 109
|
||||
min_value: 0
|
||||
max_value: 600
|
||||
step: 1
|
||||
@@ -14,6 +14,7 @@ esphome:
|
||||
condition: wifi.ap_active
|
||||
then:
|
||||
- logger.log: "WiFi AP is active!"
|
||||
- wifi.roam
|
||||
|
||||
wifi:
|
||||
networks:
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Shared utilities for ESPHome integration tests - keeping output from failing tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
#: Where a failing test leaves output for someone to look at afterwards. pytest's own
|
||||
#: temporary folder is no use on a CI runner, which throws the whole workspace away when
|
||||
#: the job ends; the workflow uploads this folder instead when a job fails.
|
||||
ARTIFACT_DIR = Path(__file__).resolve().parents[2] / "test_artifacts"
|
||||
|
||||
|
||||
def keep_artifact(name: str, data: bytes) -> Path:
|
||||
"""Write ``data`` where it can still be read after the run, and return the path.
|
||||
|
||||
Args:
|
||||
name: File name to write under the artifact folder.
|
||||
data: Contents to write.
|
||||
|
||||
Returns:
|
||||
The full path written.
|
||||
"""
|
||||
ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path = ARTIFACT_DIR / name
|
||||
path.write_bytes(data)
|
||||
return path
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Shared utilities for ESPHome integration tests - reading BMP snapshots."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import struct
|
||||
|
||||
# Size of the smallest BMP header pair (file header plus BITMAPINFOHEADER).
|
||||
_MIN_HEADER_SIZE = 54
|
||||
|
||||
# How long capture_when_drawn() keeps asking for a picture with something on it.
|
||||
DRAW_TIMEOUT = 15.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Bmp:
|
||||
"""A decoded BMP image."""
|
||||
|
||||
width: int
|
||||
height: int
|
||||
bits: int
|
||||
#: Pixel data with the per row padding stripped, so it depends only on the image itself.
|
||||
pixels: bytes
|
||||
|
||||
|
||||
class NotABmpError(Exception):
|
||||
"""The data is not a BMP at all, as opposed to a BMP that is still being written."""
|
||||
|
||||
|
||||
def parse_bmp(data: bytes) -> Bmp | None:
|
||||
"""Decode a BMP, or return None if the data is not a complete image yet.
|
||||
|
||||
Raises:
|
||||
NotABmpError: If the data cannot become a valid BMP however much more is appended.
|
||||
"""
|
||||
# Writes go to the file in order, so a short read is always a prefix of what will be there.
|
||||
# Anything wrong in a prefix we have already read is wrong for good, and worth saying now
|
||||
# rather than reporting as a timeout later.
|
||||
if len(data) >= 2 and data[:2] != b"BM":
|
||||
raise NotABmpError(f"expected a BMP, got {data[:2]!r}")
|
||||
if len(data) < _MIN_HEADER_SIZE:
|
||||
return None
|
||||
file_size = struct.unpack_from("<I", data, 2)[0]
|
||||
offset = struct.unpack_from("<I", data, 10)[0]
|
||||
width, height = struct.unpack_from("<ii", data, 18)
|
||||
bits = struct.unpack_from("<H", data, 28)[0]
|
||||
rows = abs(height)
|
||||
row_size = ((width * bits + 31) // 32) * 4
|
||||
if width <= 0 or rows == 0 or bits == 0 or offset < _MIN_HEADER_SIZE:
|
||||
raise NotABmpError(
|
||||
f"BMP header makes no sense: {width}x{height}, {bits} bits, "
|
||||
f"pixels at offset {offset}"
|
||||
)
|
||||
if file_size < offset + row_size * rows:
|
||||
raise NotABmpError(
|
||||
f"BMP header claims {file_size} bytes, too few for {width}x{rows} "
|
||||
f"at {bits} bits"
|
||||
)
|
||||
if len(data) < file_size:
|
||||
return None
|
||||
used = width * bits // 8
|
||||
pixels = b"".join(
|
||||
data[offset + row * row_size : offset + row * row_size + used]
|
||||
for row in range(rows)
|
||||
)
|
||||
return Bmp(width=width, height=rows, bits=bits, pixels=pixels)
|
||||
|
||||
|
||||
async def wait_for_bmp(path: Path, timeout: float = 5.0) -> Bmp:
|
||||
"""Wait for a complete BMP file to appear at ``path`` and return it.
|
||||
|
||||
The file is created before any of its contents are written, so waiting for it to exist is
|
||||
not enough - a read that wins the race sees a truncated image. Keep reading until the
|
||||
headers say the whole image is there.
|
||||
|
||||
Args:
|
||||
path: The file to wait for.
|
||||
timeout: Maximum time to wait in seconds.
|
||||
|
||||
Returns:
|
||||
The decoded image.
|
||||
|
||||
Raises:
|
||||
AssertionError: If no complete image is readable within ``timeout``.
|
||||
NotABmpError: If what was written is not a BMP. This is reported as soon as it is
|
||||
seen, so a device that writes the wrong thing is named for what it did rather
|
||||
than waiting out the timeout.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + timeout
|
||||
while True:
|
||||
try:
|
||||
data = path.read_bytes()
|
||||
except FileNotFoundError:
|
||||
data = b""
|
||||
if (image := parse_bmp(data)) is not None:
|
||||
return image
|
||||
if loop.time() >= deadline:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
if not data:
|
||||
raise AssertionError(f"no snapshot appeared at {path} within {timeout}s")
|
||||
raise AssertionError(
|
||||
f"{path} was still incomplete after {timeout}s ({len(data)} bytes)"
|
||||
)
|
||||
|
||||
|
||||
def is_blank(image: Bmp) -> bool:
|
||||
"""True if every pixel of the image is the same colour.
|
||||
|
||||
Whole pixels are counted rather than byte values: a plain background is usually made of more
|
||||
than one distinct byte, so counting bytes would find several of them in a blank screen.
|
||||
"""
|
||||
return len({image.pixels[i : i + 3] for i in range(0, len(image.pixels), 3)}) <= 1
|
||||
|
||||
|
||||
async def capture_when_drawn(
|
||||
take: Callable[[str], Awaitable[None]],
|
||||
directory: Path,
|
||||
prefix: str = "drawn",
|
||||
timeout: float = DRAW_TIMEOUT,
|
||||
) -> tuple[Bmp, Path]:
|
||||
"""Ask for snapshots until one has something drawn on it, and return it and where it went.
|
||||
|
||||
A display holds one flat colour until it first draws, which is one update interval after it
|
||||
starts - long enough that a test connecting over the API can easily get in first. Capturing
|
||||
once and hoping would compare a blank screen against whatever the test expects, reporting a
|
||||
drawing fault where the real trouble was timing.
|
||||
|
||||
Args:
|
||||
take: Asks the device for a snapshot under the name it is given.
|
||||
directory: Where the device writes them.
|
||||
prefix: Start of the names asked for. Each attempt needs its own, because a snapshot never
|
||||
writes over a file that is already there.
|
||||
timeout: How long to keep asking.
|
||||
|
||||
Returns:
|
||||
The first image that is not one flat colour, and the path it was read from.
|
||||
|
||||
Raises:
|
||||
AssertionError: If nothing had been drawn within ``timeout``.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + timeout
|
||||
attempt = 0
|
||||
while True:
|
||||
attempt += 1
|
||||
path = directory / f"{prefix}-{attempt}.bmp"
|
||||
await take(path.name)
|
||||
image = await wait_for_bmp(path)
|
||||
if not is_blank(image):
|
||||
return image, path
|
||||
if loop.time() >= deadline:
|
||||
raise AssertionError(
|
||||
f"the screen was still a single flat colour after {timeout}s and "
|
||||
f"{attempt} captures - nothing was drawn"
|
||||
)
|
||||
await asyncio.sleep(0.5)
|
||||
@@ -162,6 +162,13 @@ def integration_test_dir() -> Generator[Path]:
|
||||
yield Path(tmpdir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Host preferences persist per device name; give the test its own so a
|
||||
provisioned key never leaks into another run."""
|
||||
monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reserved_tcp_port() -> Generator[tuple[int, socket.socket]]:
|
||||
"""Reserve an unused TCP port by holding the socket open."""
|
||||
|
||||
@@ -9,6 +9,13 @@ API_CONNECTION_TIMEOUT = 30.0 # seconds
|
||||
PORT_WAIT_TIMEOUT = 30.0 # seconds
|
||||
PORT_POLL_INTERVAL = 0.1 # seconds
|
||||
|
||||
# The well-known all-zeros provisioning PSK, a key to provision over it, and
|
||||
# the time the device takes to activate a newly saved key (100 ms timer plus
|
||||
# margin)
|
||||
ZERO_PSK = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||
PROVISIONING_PSK = b"bm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm4="
|
||||
KEY_ACTIVATION_DELAY = 0.5 # seconds
|
||||
|
||||
# Process shutdown timeouts
|
||||
SIGINT_TIMEOUT = 5.0 # seconds
|
||||
SIGTERM_TIMEOUT = 2.0 # seconds
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
esphome:
|
||||
name: host-ota-test
|
||||
host:
|
||||
api:
|
||||
encryption:
|
||||
key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||
ota:
|
||||
- platform: esphome
|
||||
port: __OTA_PORT__
|
||||
password: "hunter2"
|
||||
logger:
|
||||
level: DEBUG
|
||||
@@ -0,0 +1,10 @@
|
||||
esphome:
|
||||
name: host-ota-test
|
||||
host:
|
||||
api:
|
||||
encryption:
|
||||
ota:
|
||||
- platform: esphome
|
||||
port: __OTA_PORT__
|
||||
logger:
|
||||
level: DEBUG
|
||||
@@ -0,0 +1,53 @@
|
||||
esphome:
|
||||
name: lvgl-headless-render-test
|
||||
host:
|
||||
|
||||
api:
|
||||
actions:
|
||||
# The name comes from the test so it can capture more than once: a snapshot never writes over
|
||||
# a file that is already there, so a fixed name could only ever be captured once.
|
||||
- action: take_screenshot
|
||||
variables:
|
||||
name: string
|
||||
then:
|
||||
- snapshot.take:
|
||||
id: lvgl_display
|
||||
filename: !lambda return name;
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
display:
|
||||
# A display with no screen, so what LVGL draws depends on LVGL alone - nothing about the machine
|
||||
# running the test, and no graphics library outside this repository, can move the result.
|
||||
- platform: snapshot
|
||||
id: lvgl_display
|
||||
auto_clear_enabled: false
|
||||
dimensions:
|
||||
width: 300
|
||||
height: 300
|
||||
|
||||
# The widgets are spelled out here rather than left to the built in "Hello World" screen, which
|
||||
# LVGL builds when nothing is configured: that screen contains a spinner, and an animation cannot
|
||||
# produce the same picture twice.
|
||||
#
|
||||
# Everything that affects the rendered pixels is set explicitly, so the expected hash in the test
|
||||
# depends only on the drawing code and the built in font. In particular the background comes from a
|
||||
# full screen object rather than from the theme, so adjusting a theme default does not break this.
|
||||
lvgl:
|
||||
displays: lvgl_display
|
||||
default_font: montserrat_14
|
||||
widgets:
|
||||
- obj:
|
||||
width: 100%
|
||||
height: 100%
|
||||
bg_color: 0x000080
|
||||
bg_opa: cover
|
||||
border_width: 0
|
||||
radius: 0
|
||||
pad_all: 0
|
||||
widgets:
|
||||
- label:
|
||||
align: center
|
||||
text: "Hello World!"
|
||||
text_color: 0xFFFFFF
|
||||
@@ -0,0 +1,29 @@
|
||||
esphome:
|
||||
name: sdl-headless-screenshot-test
|
||||
host:
|
||||
|
||||
api:
|
||||
actions:
|
||||
# The name comes from the test so it can capture more than once while it waits for the first
|
||||
# frame: a snapshot never writes over a file that is already there.
|
||||
- action: take_screenshot
|
||||
variables:
|
||||
name: string
|
||||
then:
|
||||
- snapshot.take:
|
||||
id: sdl_display
|
||||
filename: !lambda return name;
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
display:
|
||||
- platform: sdl
|
||||
id: sdl_display
|
||||
headless: true
|
||||
show_test_card: true
|
||||
update_interval: 100ms
|
||||
# An odd width exercises the row padding in the BMP writer
|
||||
dimensions:
|
||||
width: 101
|
||||
height: 64
|
||||
@@ -0,0 +1,28 @@
|
||||
esphome:
|
||||
name: snapshot-display-test
|
||||
host:
|
||||
|
||||
api:
|
||||
actions:
|
||||
# The name comes from the test so it can ask for several in a row and check what each one
|
||||
# does with it.
|
||||
- action: take_snapshot
|
||||
variables:
|
||||
name: string
|
||||
then:
|
||||
- snapshot.take:
|
||||
id: snapshot_display
|
||||
filename: !lambda return name;
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
display:
|
||||
- platform: snapshot
|
||||
id: snapshot_display
|
||||
show_test_card: true
|
||||
update_interval: 100ms
|
||||
# An odd width exercises the row padding in the BMP writer
|
||||
dimensions:
|
||||
width: 101
|
||||
height: 64
|
||||
@@ -0,0 +1,72 @@
|
||||
esphome:
|
||||
name: tmpl-clim-basic
|
||||
on_boot:
|
||||
- climate.template.publish:
|
||||
id: test_climate
|
||||
action: IDLE
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
|
||||
climate:
|
||||
- platform: template
|
||||
id: test_climate
|
||||
name: Test Basic Climate
|
||||
optimistic: true
|
||||
sensor: test_climate_current_temperature
|
||||
humidity_sensor: test_climate_current_humidity
|
||||
supports_action: true
|
||||
supported_modes:
|
||||
- "OFF"
|
||||
- HEAT
|
||||
- COOL
|
||||
supported_fan_modes:
|
||||
- AUTO
|
||||
- LOW
|
||||
- HIGH
|
||||
supported_swing_modes:
|
||||
- "OFF"
|
||||
- VERTICAL
|
||||
supported_presets:
|
||||
- NONE
|
||||
- ECO
|
||||
visual:
|
||||
min_temperature: 16.0
|
||||
max_temperature: 30.0
|
||||
temperature_step: 0.5
|
||||
on_control:
|
||||
- lambda: |-
|
||||
if (x.get_mode().has_value())
|
||||
ESP_LOGD("test", "on_control mode=%d", (int) *x.get_mode());
|
||||
if (x.get_target_temperature().has_value())
|
||||
ESP_LOGD("test", "on_control target_temperature=%.1f", *x.get_target_temperature());
|
||||
if (x.get_fan_mode().has_value())
|
||||
ESP_LOGD("test", "on_control fan_mode=%d", (int) *x.get_fan_mode());
|
||||
if (x.get_swing_mode().has_value())
|
||||
ESP_LOGD("test", "on_control swing_mode=%d", (int) *x.get_swing_mode());
|
||||
if (x.get_preset().has_value())
|
||||
ESP_LOGD("test", "on_control preset=%d", (int) *x.get_preset());
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
id: test_climate_current_temperature
|
||||
name: Test Climate Current Temperature
|
||||
lambda: "return 22.5f;"
|
||||
update_interval: 10ms
|
||||
- platform: template
|
||||
id: test_climate_current_humidity
|
||||
name: Test Climate Current Humidity
|
||||
lambda: "return 55.0f;"
|
||||
update_interval: 10ms
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
id: simulate_device_report
|
||||
name: Simulate Device Report
|
||||
on_press:
|
||||
- climate.template.publish:
|
||||
id: test_climate
|
||||
mode: "OFF"
|
||||
fan_mode: AUTO
|
||||
swing_mode: "OFF"
|
||||
preset: NONE
|
||||
@@ -0,0 +1,47 @@
|
||||
esphome:
|
||||
name: tmpl-clim-custom
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
|
||||
climate:
|
||||
- platform: template
|
||||
id: test_climate
|
||||
name: Test Custom Mode Climate
|
||||
optimistic: true
|
||||
sensor: test_climate_current_temperature
|
||||
supported_modes:
|
||||
- "OFF"
|
||||
- HEAT
|
||||
- COOL
|
||||
custom_fan_modes:
|
||||
- turbo
|
||||
- silent
|
||||
- eco
|
||||
custom_presets:
|
||||
- eco_plus
|
||||
- power_save
|
||||
- max
|
||||
on_control:
|
||||
- lambda: |-
|
||||
if (x.has_custom_fan_mode())
|
||||
ESP_LOGD("test", "on_control custom_fan_mode=%s", x.get_custom_fan_mode().c_str());
|
||||
if (x.has_custom_preset())
|
||||
ESP_LOGD("test", "on_control custom_preset=%s", x.get_custom_preset().c_str());
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
id: test_climate_current_temperature
|
||||
name: Test Climate Current Temperature
|
||||
lambda: "return 22.5f;"
|
||||
update_interval: 10ms
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
id: simulate_device_report
|
||||
name: Simulate Device Report
|
||||
on_press:
|
||||
- climate.template.publish:
|
||||
id: test_climate
|
||||
custom_fan_mode: "eco"
|
||||
custom_preset: "max"
|
||||
@@ -0,0 +1,56 @@
|
||||
esphome:
|
||||
name: tmpl-clim-nonopt
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
|
||||
climate:
|
||||
- platform: template
|
||||
id: test_climate
|
||||
name: Test Template Climate Nonoptimistic
|
||||
optimistic: false
|
||||
supported_modes:
|
||||
- "OFF"
|
||||
- HEAT
|
||||
- COOL
|
||||
- FAN_ONLY
|
||||
supported_fan_modes:
|
||||
- AUTO
|
||||
- LOW
|
||||
- HIGH
|
||||
supported_swing_modes:
|
||||
- "OFF"
|
||||
- VERTICAL
|
||||
supported_presets:
|
||||
- NONE
|
||||
- ECO
|
||||
- AWAY
|
||||
visual:
|
||||
min_temperature: 16.0
|
||||
max_temperature: 30.0
|
||||
temperature_step: 0.5
|
||||
on_control:
|
||||
- lambda: |-
|
||||
if (x.get_mode().has_value())
|
||||
ESP_LOGD("test", "on_control mode=%d", (int) *x.get_mode());
|
||||
if (x.get_target_temperature().has_value())
|
||||
ESP_LOGD("test", "on_control target_temperature=%.1f", *x.get_target_temperature());
|
||||
if (x.get_fan_mode().has_value())
|
||||
ESP_LOGD("test", "on_control fan_mode=%d", (int) *x.get_fan_mode());
|
||||
if (x.get_swing_mode().has_value())
|
||||
ESP_LOGD("test", "on_control swing_mode=%d", (int) *x.get_swing_mode());
|
||||
if (x.get_preset().has_value())
|
||||
ESP_LOGD("test", "on_control preset=%d", (int) *x.get_preset());
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
id: simulate_device_confirmation
|
||||
name: Simulate Device Confirmation
|
||||
on_press:
|
||||
- climate.template.publish:
|
||||
id: test_climate
|
||||
mode: HEAT
|
||||
target_temperature: 22.5
|
||||
fan_mode: HIGH
|
||||
swing_mode: VERTICAL
|
||||
preset: AWAY
|
||||
@@ -0,0 +1,26 @@
|
||||
esphome:
|
||||
name: tmpl-clim-oc-order
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
|
||||
# on_control fires with the full ClimateCall (arg `x`) from the base Climate component's
|
||||
# ClimateCall::perform(), before validate_()/control() run -- so when the lambda action below
|
||||
# runs, the entity's own .mode is still the OLD value, even though x.get_mode() already reports
|
||||
# the NEW requested value. on_state fires afterward, once control() has applied it.
|
||||
climate:
|
||||
- platform: template
|
||||
id: test_climate
|
||||
name: Test On Control Ordering
|
||||
optimistic: true
|
||||
supported_modes:
|
||||
- "OFF"
|
||||
- HEAT
|
||||
on_control:
|
||||
- lambda: |-
|
||||
ESP_LOGD("test", "on_control requested_mode=%d current_mode_before_apply=%d",
|
||||
x.get_mode().has_value() ? (int) *x.get_mode() : -1,
|
||||
(int) id(test_climate).mode);
|
||||
on_state:
|
||||
- lambda: |-
|
||||
ESP_LOGD("test", "on_state mode=%d", (int) x.mode);
|
||||
@@ -0,0 +1,63 @@
|
||||
esphome:
|
||||
name: tmpl-clim-publish-all
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
|
||||
climate:
|
||||
- platform: template
|
||||
id: test_climate
|
||||
name: Test Publish All Fields
|
||||
optimistic: true
|
||||
# current_temperature/current_humidity/action are only sent over the API at all if their
|
||||
# trait is advertised: current_temperature/current_humidity because a sensor/humidity_sensor
|
||||
# is referenced below, action because supports_action is set. The sensors' fixed readings
|
||||
# match what climate.template.publish pushes, so the sensor callback (guarded to only publish
|
||||
# on an actual change) doesn't produce an extra, unexpected state update of its own.
|
||||
sensor: test_climate_current_temperature
|
||||
humidity_sensor: test_climate_current_humidity
|
||||
supports_action: true
|
||||
supported_modes:
|
||||
- "OFF"
|
||||
- HEAT
|
||||
supported_fan_modes:
|
||||
- AUTO
|
||||
- HIGH
|
||||
supported_swing_modes:
|
||||
- "OFF"
|
||||
- VERTICAL
|
||||
supported_presets:
|
||||
- NONE
|
||||
- ECO
|
||||
on_control:
|
||||
# Should never fire in this test: climate.template.publish is a pure bypass and must not
|
||||
# re-trigger on_control as if the entity were freshly commanded.
|
||||
- logger.log: "on_control fired"
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
id: test_climate_current_temperature
|
||||
name: Test Climate Current Temperature
|
||||
lambda: "return 20.0f;"
|
||||
update_interval: 10ms
|
||||
- platform: template
|
||||
id: test_climate_current_humidity
|
||||
name: Test Climate Current Humidity
|
||||
lambda: "return 60.0f;"
|
||||
update_interval: 10ms
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
id: publish_all
|
||||
name: Publish All
|
||||
on_press:
|
||||
- climate.template.publish:
|
||||
id: test_climate
|
||||
current_temperature: 20.0
|
||||
current_humidity: 60.0
|
||||
target_temperature: 23.0
|
||||
mode: HEAT
|
||||
action: HEATING
|
||||
fan_mode: HIGH
|
||||
swing_mode: VERTICAL
|
||||
preset: ECO
|
||||
@@ -0,0 +1,49 @@
|
||||
esphome:
|
||||
name: tmpl-clim-sensor-push
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
|
||||
# No lambda/update_interval: these sensors only ever report a value when a button below
|
||||
# publishes one (standing in for e.g. a BLE scan callback in a real config).
|
||||
sensor:
|
||||
- platform: template
|
||||
id: room_temperature
|
||||
name: Room Temperature
|
||||
- platform: template
|
||||
id: room_humidity
|
||||
name: Room Humidity
|
||||
|
||||
climate:
|
||||
- platform: template
|
||||
id: test_climate
|
||||
name: Test Sensor Push Climate
|
||||
optimistic: true
|
||||
sensor: room_temperature
|
||||
humidity_sensor: room_humidity
|
||||
supported_modes:
|
||||
- "OFF"
|
||||
- HEAT
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
id: publish_temperature
|
||||
name: Publish Temperature
|
||||
on_press:
|
||||
- sensor.template.publish:
|
||||
id: room_temperature
|
||||
state: 24.0
|
||||
- platform: template
|
||||
id: publish_temperature_same
|
||||
name: Publish Temperature Same Value
|
||||
on_press:
|
||||
- sensor.template.publish:
|
||||
id: room_temperature
|
||||
state: 24.0
|
||||
- platform: template
|
||||
id: publish_humidity
|
||||
name: Publish Humidity
|
||||
on_press:
|
||||
- sensor.template.publish:
|
||||
id: room_humidity
|
||||
state: 65.0
|
||||
@@ -0,0 +1,89 @@
|
||||
esphome:
|
||||
name: tmpl-clim-set-act
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
|
||||
# Every settable field forwards its requested value to a set_*_action. supports_two_point and
|
||||
# supports_target_humidity are not declared here: they are derived from the low/high and humidity
|
||||
# set actions being present.
|
||||
climate:
|
||||
- platform: template
|
||||
id: test_climate
|
||||
name: Test Set Actions
|
||||
optimistic: false
|
||||
restore_mode: NO_RESTORE
|
||||
supported_modes:
|
||||
- "OFF"
|
||||
- HEAT
|
||||
- COOL
|
||||
supported_fan_modes:
|
||||
- AUTO
|
||||
- LOW
|
||||
supported_swing_modes:
|
||||
- "OFF"
|
||||
- VERTICAL
|
||||
supported_presets:
|
||||
- NONE
|
||||
- ECO
|
||||
custom_fan_modes:
|
||||
- turbo
|
||||
custom_presets:
|
||||
- eco_plus
|
||||
visual:
|
||||
min_temperature: 16.0
|
||||
max_temperature: 30.0
|
||||
temperature_step: 0.5
|
||||
set_mode_action:
|
||||
- logger.log:
|
||||
format: "set_mode_action %d"
|
||||
args: ["(int) x"]
|
||||
set_target_temperature_low_action:
|
||||
- logger.log:
|
||||
format: "set_target_temperature_low_action %.1f"
|
||||
args: ["x"]
|
||||
set_target_temperature_high_action:
|
||||
- logger.log:
|
||||
format: "set_target_temperature_high_action %.1f"
|
||||
args: ["x"]
|
||||
set_target_humidity_action:
|
||||
- logger.log:
|
||||
format: "set_target_humidity_action %.0f"
|
||||
args: ["x"]
|
||||
set_fan_mode_action:
|
||||
- logger.log:
|
||||
format: "set_fan_mode_action %d"
|
||||
args: ["(int) x"]
|
||||
set_custom_fan_mode_action:
|
||||
- logger.log:
|
||||
format: "set_custom_fan_mode_action %s"
|
||||
args: ["x.c_str()"]
|
||||
set_swing_mode_action:
|
||||
- logger.log:
|
||||
format: "set_swing_mode_action %d"
|
||||
args: ["(int) x"]
|
||||
set_preset_action:
|
||||
- logger.log:
|
||||
format: "set_preset_action %d"
|
||||
args: ["(int) x"]
|
||||
set_custom_preset_action:
|
||||
- logger.log:
|
||||
format: "set_custom_preset_action %s"
|
||||
args: ["x.c_str()"]
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
id: report_device_state
|
||||
name: Report Device State
|
||||
on_press:
|
||||
- climate.template.publish:
|
||||
id: test_climate
|
||||
mode: HEAT
|
||||
|
||||
- platform: template
|
||||
id: report_unsupported_mode
|
||||
name: Report Unsupported Mode
|
||||
on_press:
|
||||
- climate.template.publish:
|
||||
id: test_climate
|
||||
mode: DRY
|
||||
@@ -0,0 +1,52 @@
|
||||
esphome:
|
||||
name: tmpl-clim-two-point
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
|
||||
climate:
|
||||
- platform: template
|
||||
id: test_climate
|
||||
name: Test Two-Point Heatpump
|
||||
optimistic: true
|
||||
sensor: test_climate_current_temperature
|
||||
supports_two_point_target_temperature: true
|
||||
supports_target_humidity: true
|
||||
supported_modes:
|
||||
- "OFF"
|
||||
- HEAT_COOL
|
||||
- HEAT
|
||||
- COOL
|
||||
visual:
|
||||
min_temperature: 16.0
|
||||
max_temperature: 30.0
|
||||
temperature_step: 0.5
|
||||
on_control:
|
||||
- lambda: |-
|
||||
if (x.get_mode().has_value())
|
||||
ESP_LOGD("test", "on_control mode=%d", (int) *x.get_mode());
|
||||
if (x.get_target_temperature_low().has_value())
|
||||
ESP_LOGD("test", "on_control target_temperature_low=%.1f", *x.get_target_temperature_low());
|
||||
if (x.get_target_temperature_high().has_value())
|
||||
ESP_LOGD("test", "on_control target_temperature_high=%.1f", *x.get_target_temperature_high());
|
||||
if (x.get_target_humidity().has_value())
|
||||
ESP_LOGD("test", "on_control target_humidity=%.1f", *x.get_target_humidity());
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
id: test_climate_current_temperature
|
||||
name: Test Climate Current Temperature
|
||||
lambda: "return 21.0f;"
|
||||
update_interval: 10ms
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
id: simulate_device_report
|
||||
name: Simulate Device Report
|
||||
on_press:
|
||||
- climate.template.publish:
|
||||
id: test_climate
|
||||
mode: HEAT_COOL
|
||||
target_temperature_low: 18.0
|
||||
target_temperature_high: 24.0
|
||||
target_humidity: 50.0
|
||||
@@ -1,142 +1,143 @@
|
||||
{
|
||||
"tests/integration/test_action_concurrent_reentry.py": 45.23,
|
||||
"tests/integration/test_addressable_light_transition.py": 74.47,
|
||||
"tests/integration/test_alarm_control_panel_state_transitions.py": 74.1,
|
||||
"tests/integration/test_api_action_metadata.py": 62.1,
|
||||
"tests/integration/test_api_action_responses.py": 71.08,
|
||||
"tests/integration/test_api_action_timeout.py": 21.64,
|
||||
"tests/integration/test_api_conditional_memory.py": 13.72,
|
||||
"tests/integration/test_api_custom_services.py": 24.16,
|
||||
"tests/integration/test_api_get_time_response_timezone.py": 23.48,
|
||||
"tests/integration/test_api_homeassistant.py": 37.87,
|
||||
"tests/integration/test_api_homeassistant_action_no_subscriber.py": 14.38,
|
||||
"tests/integration/test_api_list_entities_backpressure.py": 26.85,
|
||||
"tests/integration/test_api_message_size_batching.py": 33.36,
|
||||
"tests/integration/test_api_reboot_timeout.py": 13.63,
|
||||
"tests/integration/test_api_string_lambda.py": 25.04,
|
||||
"tests/integration/test_api_vv_logging.py": 16.6,
|
||||
"tests/integration/test_api_zero_psk_provisioning.py": 43.14,
|
||||
"tests/integration/test_areas_and_devices.py": 25.98,
|
||||
"tests/integration/test_automation_wait_actions.py": 21.91,
|
||||
"tests/integration/test_automations.py": 42.43,
|
||||
"tests/integration/test_batch_delay_zero_rapid_transitions.py": 16.65,
|
||||
"tests/integration/test_binary_sensor_autorepeat_filter.py": 28.67,
|
||||
"tests/integration/test_binary_sensor_invalidate_state.py": 23.69,
|
||||
"tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 22.99,
|
||||
"tests/integration/test_build_info.py": 24.96,
|
||||
"tests/integration/test_camera_mock.py": 14.47,
|
||||
"tests/integration/test_climate_control_action.py": 31.07,
|
||||
"tests/integration/test_climate_custom_modes.py": 28.59,
|
||||
"tests/integration/test_continuation_actions.py": 14.96,
|
||||
"tests/integration/test_cover_control_action.py": 26.14,
|
||||
"tests/integration/test_crc8_helper.py": 10.92,
|
||||
"tests/integration/test_device_id_in_state.py": 64.97,
|
||||
"tests/integration/test_duplicate_entities.py": 30.81,
|
||||
"tests/integration/test_entity_icon.py": 32.85,
|
||||
"tests/integration/test_fan_turn_on_action.py": 24.91,
|
||||
"tests/integration/test_fnv1_hash_object_id.py": 12.54,
|
||||
"tests/integration/test_fnv1a_hash.py": 21.8,
|
||||
"tests/integration/test_gpio_expander_cache.py": 5.2,
|
||||
"tests/integration/test_host_logger_thread_safety.py": 21.7,
|
||||
"tests/integration/test_host_mode_basic.py": 13.62,
|
||||
"tests/integration/test_host_mode_batch_delay.py": 14.56,
|
||||
"tests/integration/test_host_mode_climate_basic_state.py": 30.95,
|
||||
"tests/integration/test_host_mode_climate_control.py": 29.06,
|
||||
"tests/integration/test_host_mode_empty_string_options.py": 27.22,
|
||||
"tests/integration/test_host_mode_entity_fields.py": 30.95,
|
||||
"tests/integration/test_host_mode_fan_preset.py": 14.44,
|
||||
"tests/integration/test_host_mode_many_entities.py": 54.13,
|
||||
"tests/integration/test_host_mode_many_entities_multiple_connections.py": 32.17,
|
||||
"tests/integration/test_host_mode_noise_encryption.py": 42.77,
|
||||
"tests/integration/test_host_mode_reconnect.py": 4.06,
|
||||
"tests/integration/test_host_mode_sensor.py": 13.47,
|
||||
"tests/integration/test_host_ota.py": 21.4,
|
||||
"tests/integration/test_host_preferences.py": 25.43,
|
||||
"tests/integration/test_host_preferences_suspend_resume.py": 19.2,
|
||||
"tests/integration/test_improv_serial_uart.py": 31.52,
|
||||
"tests/integration/test_large_message_batching.py": 15.64,
|
||||
"tests/integration/test_legacy_area.py": 22.63,
|
||||
"tests/integration/test_legacy_climate_compat.py": 26.13,
|
||||
"tests/integration/test_legacy_fan_compat.py": 24.05,
|
||||
"tests/integration/test_light_automations.py": 30.86,
|
||||
"tests/integration/test_light_binary_effect_off_phase.py": 23.19,
|
||||
"tests/integration/test_light_calls.py": 32.35,
|
||||
"tests/integration/test_light_constant_brightness.py": 29.89,
|
||||
"tests/integration/test_light_control_action.py": 29.06,
|
||||
"tests/integration/test_light_dim_relative_action.py": 29.61,
|
||||
"tests/integration/test_light_effect_zero_brightness.py": 18.68,
|
||||
"tests/integration/test_light_initial_state.py": 24.49,
|
||||
"tests/integration/test_light_toggle_action.py": 26.46,
|
||||
"tests/integration/test_lock_automations.py": 23.28,
|
||||
"tests/integration/test_logger_buffered_recursion_guard.py": 24.29,
|
||||
"tests/integration/test_loop_disable_enable.py": 45.28,
|
||||
"tests/integration/test_loop_interval_decoupling.py": 28.35,
|
||||
"tests/integration/test_loop_interval_default_not_pulled_forward.py": 21.97,
|
||||
"tests/integration/test_micros_to_millis.py": 20.79,
|
||||
"tests/integration/test_multi_click_trigger.py": 26.2,
|
||||
"tests/integration/test_multi_device_preferences.py": 16.87,
|
||||
"tests/integration/test_noise_encryption_key_protection.py": 77.05,
|
||||
"tests/integration/test_object_id_api_verification.py": 73.51,
|
||||
"tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 62.33,
|
||||
"tests/integration/test_object_id_no_friendly_name.py": 43.47,
|
||||
"tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 32.21,
|
||||
"tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 56.86,
|
||||
"tests/integration/test_online_image_bmp.py": 50.9,
|
||||
"tests/integration/test_oversized_payloads.py": 53.2,
|
||||
"tests/integration/test_preference_key_stability.py": 26.09,
|
||||
"tests/integration/test_runtime_stats.py": 18.34,
|
||||
"tests/integration/test_safe_mode_loop_runs.py": 10.07,
|
||||
"tests/integration/test_scheduler_blocking_warning.py": 40.91,
|
||||
"tests/integration/test_scheduler_bulk_cleanup.py": 23.14,
|
||||
"tests/integration/test_scheduler_defer_cancel.py": 24.54,
|
||||
"tests/integration/test_scheduler_defer_cancel_regular.py": 13.48,
|
||||
"tests/integration/test_scheduler_defer_fifo_simple.py": 26.86,
|
||||
"tests/integration/test_scheduler_defer_stress.py": 27.23,
|
||||
"tests/integration/test_scheduler_heap_stress.py": 24.02,
|
||||
"tests/integration/test_scheduler_internal_id_no_collision.py": 24.57,
|
||||
"tests/integration/test_scheduler_interval_reschedule.py": 13.12,
|
||||
"tests/integration/test_scheduler_interval_zero_coerced.py": 22.91,
|
||||
"tests/integration/test_scheduler_null_name.py": 23.46,
|
||||
"tests/integration/test_scheduler_numeric_id_test.py": 24.54,
|
||||
"tests/integration/test_scheduler_pool.py": 25.0,
|
||||
"tests/integration/test_scheduler_rapid_cancellation.py": 14.68,
|
||||
"tests/integration/test_scheduler_recursive_timeout.py": 25.35,
|
||||
"tests/integration/test_scheduler_removed_item_race.py": 26.19,
|
||||
"tests/integration/test_scheduler_self_keyed.py": 23.43,
|
||||
"tests/integration/test_scheduler_simultaneous_callbacks.py": 22.16,
|
||||
"tests/integration/test_scheduler_string_test.py": 15.22,
|
||||
"tests/integration/test_script_array_params.py": 14.67,
|
||||
"tests/integration/test_script_delay_params.py": 15.65,
|
||||
"tests/integration/test_script_queued.py": 24.93,
|
||||
"tests/integration/test_script_queued_idle_loop.py": 5.04,
|
||||
"tests/integration/test_script_wait_on_boot.py": 13.08,
|
||||
"tests/integration/test_select_stringref_trigger.py": 29.6,
|
||||
"tests/integration/test_sensor_filters_delta.py": 28.01,
|
||||
"tests/integration/test_sensor_filters_ring_buffer.py": 25.04,
|
||||
"tests/integration/test_sensor_filters_sliding_window.py": 71.5,
|
||||
"tests/integration/test_sensor_filters_value_list.py": 16.94,
|
||||
"tests/integration/test_sensor_timeout_filter.py": 29.48,
|
||||
"tests/integration/test_socket_wake_gate_tcp.py": 20.36,
|
||||
"tests/integration/test_status_flags.py": 37.42,
|
||||
"tests/integration/test_strftime_to.py": 22.61,
|
||||
"tests/integration/test_syslog.py": 16.34,
|
||||
"tests/integration/test_template_alarm_control_panel_many_sensors.py": 29.81,
|
||||
"tests/integration/test_template_text_save.py": 25.43,
|
||||
"tests/integration/test_text_command.py": 23.34,
|
||||
"tests/integration/test_text_sensor_raw_state.py": 69.57,
|
||||
"tests/integration/test_uart_mock_ld2410.py": 37.95,
|
||||
"tests/integration/test_uart_mock_ld2412.py": 93.22,
|
||||
"tests/integration/test_uart_mock_ld2420.py": 43.24,
|
||||
"tests/integration/test_uart_mock_ld2450.py": 31.75,
|
||||
"tests/integration/test_uart_mock_modbus.py": 667.4,
|
||||
"tests/integration/test_udp.py": 9.38,
|
||||
"tests/integration/test_use_address_runtime.py": 37.05,
|
||||
"tests/integration/test_valve_control_action.py": 24.47,
|
||||
"tests/integration/test_varint_five_byte_device_id.py": 25.03,
|
||||
"tests/integration/test_wait_until_mid_loop_timing.py": 23.73,
|
||||
"tests/integration/test_wait_until_on_boot.py": 9.16,
|
||||
"tests/integration/test_wait_until_ordering.py": 13.3,
|
||||
"tests/integration/test_wait_until_reentrant_restart.py": 25.23,
|
||||
"tests/integration/test_wake_loop_forces_phase_b.py": 23.34,
|
||||
"tests/integration/test_water_heater_template.py": 17.67
|
||||
"tests/integration/test_action_concurrent_reentry.py": 57.91,
|
||||
"tests/integration/test_addressable_light_transition.py": 21.25,
|
||||
"tests/integration/test_alarm_control_panel_state_transitions.py": 70.71,
|
||||
"tests/integration/test_api_action_metadata.py": 66.6,
|
||||
"tests/integration/test_api_action_responses.py": 36.1,
|
||||
"tests/integration/test_api_action_timeout.py": 68.86,
|
||||
"tests/integration/test_api_conditional_memory.py": 15.48,
|
||||
"tests/integration/test_api_custom_services.py": 18.77,
|
||||
"tests/integration/test_api_get_time_response_timezone.py": 21.08,
|
||||
"tests/integration/test_api_homeassistant.py": 65.59,
|
||||
"tests/integration/test_api_homeassistant_action_no_subscriber.py": 18.44,
|
||||
"tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 15.05,
|
||||
"tests/integration/test_api_list_entities_backpressure.py": 13.88,
|
||||
"tests/integration/test_api_message_size_batching.py": 29.98,
|
||||
"tests/integration/test_api_reboot_timeout.py": 16.05,
|
||||
"tests/integration/test_api_string_lambda.py": 15.31,
|
||||
"tests/integration/test_api_vv_logging.py": 19.28,
|
||||
"tests/integration/test_api_zero_psk_provisioning.py": 31.5,
|
||||
"tests/integration/test_areas_and_devices.py": 24.95,
|
||||
"tests/integration/test_automation_wait_actions.py": 20.92,
|
||||
"tests/integration/test_automations.py": 35.19,
|
||||
"tests/integration/test_batch_delay_zero_rapid_transitions.py": 17.99,
|
||||
"tests/integration/test_binary_sensor_autorepeat_filter.py": 20.39,
|
||||
"tests/integration/test_binary_sensor_invalidate_state.py": 18.41,
|
||||
"tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 24.69,
|
||||
"tests/integration/test_build_info.py": 18.7,
|
||||
"tests/integration/test_camera_mock.py": 16.23,
|
||||
"tests/integration/test_climate_control_action.py": 21.14,
|
||||
"tests/integration/test_climate_custom_modes.py": 20.74,
|
||||
"tests/integration/test_continuation_actions.py": 16.81,
|
||||
"tests/integration/test_cover_control_action.py": 20.34,
|
||||
"tests/integration/test_crc8_helper.py": 9.36,
|
||||
"tests/integration/test_device_id_in_state.py": 44.67,
|
||||
"tests/integration/test_duplicate_entities.py": 23.58,
|
||||
"tests/integration/test_entity_icon.py": 34.35,
|
||||
"tests/integration/test_fan_turn_on_action.py": 24.23,
|
||||
"tests/integration/test_fnv1_hash_object_id.py": 16.21,
|
||||
"tests/integration/test_fnv1a_hash.py": 13.38,
|
||||
"tests/integration/test_gpio_expander_cache.py": 13.06,
|
||||
"tests/integration/test_host_logger_thread_safety.py": 23.66,
|
||||
"tests/integration/test_host_mode_basic.py": 8.01,
|
||||
"tests/integration/test_host_mode_batch_delay.py": 21.0,
|
||||
"tests/integration/test_host_mode_climate_basic_state.py": 22.14,
|
||||
"tests/integration/test_host_mode_climate_control.py": 19.39,
|
||||
"tests/integration/test_host_mode_empty_string_options.py": 21.76,
|
||||
"tests/integration/test_host_mode_entity_fields.py": 29.61,
|
||||
"tests/integration/test_host_mode_fan_preset.py": 20.01,
|
||||
"tests/integration/test_host_mode_many_entities.py": 39.08,
|
||||
"tests/integration/test_host_mode_many_entities_multiple_connections.py": 23.92,
|
||||
"tests/integration/test_host_mode_noise_encryption.py": 42.42,
|
||||
"tests/integration/test_host_mode_reconnect.py": 3.41,
|
||||
"tests/integration/test_host_mode_sensor.py": 22.96,
|
||||
"tests/integration/test_host_ota.py": 29.5,
|
||||
"tests/integration/test_host_preferences.py": 16.06,
|
||||
"tests/integration/test_host_preferences_suspend_resume.py": 18.71,
|
||||
"tests/integration/test_improv_serial_uart.py": 20.22,
|
||||
"tests/integration/test_large_message_batching.py": 26.56,
|
||||
"tests/integration/test_legacy_area.py": 22.72,
|
||||
"tests/integration/test_legacy_climate_compat.py": 14.13,
|
||||
"tests/integration/test_legacy_fan_compat.py": 14.33,
|
||||
"tests/integration/test_light_automations.py": 18.81,
|
||||
"tests/integration/test_light_binary_effect_off_phase.py": 8.38,
|
||||
"tests/integration/test_light_calls.py": 21.88,
|
||||
"tests/integration/test_light_constant_brightness.py": 59.45,
|
||||
"tests/integration/test_light_control_action.py": 31.91,
|
||||
"tests/integration/test_light_dim_relative_action.py": 14.43,
|
||||
"tests/integration/test_light_effect_zero_brightness.py": 25.05,
|
||||
"tests/integration/test_light_initial_state.py": 18.97,
|
||||
"tests/integration/test_light_toggle_action.py": 17.44,
|
||||
"tests/integration/test_lock_automations.py": 18.9,
|
||||
"tests/integration/test_logger_buffered_recursion_guard.py": 18.2,
|
||||
"tests/integration/test_loop_disable_enable.py": 63.35,
|
||||
"tests/integration/test_loop_interval_decoupling.py": 17.7,
|
||||
"tests/integration/test_loop_interval_default_not_pulled_forward.py": 21.56,
|
||||
"tests/integration/test_micros_to_millis.py": 15.89,
|
||||
"tests/integration/test_multi_click_trigger.py": 17.23,
|
||||
"tests/integration/test_multi_device_preferences.py": 19.4,
|
||||
"tests/integration/test_noise_encryption_key_protection.py": 72.59,
|
||||
"tests/integration/test_object_id_api_verification.py": 19.22,
|
||||
"tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 16.77,
|
||||
"tests/integration/test_object_id_no_friendly_name.py": 45.8,
|
||||
"tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 86.73,
|
||||
"tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 40.4,
|
||||
"tests/integration/test_online_image_bmp.py": 37.24,
|
||||
"tests/integration/test_oversized_payloads.py": 55.75,
|
||||
"tests/integration/test_preference_key_stability.py": 25.49,
|
||||
"tests/integration/test_runtime_stats.py": 29.81,
|
||||
"tests/integration/test_safe_mode_loop_runs.py": 6.26,
|
||||
"tests/integration/test_scheduler_blocking_warning.py": 37.98,
|
||||
"tests/integration/test_scheduler_bulk_cleanup.py": 18.67,
|
||||
"tests/integration/test_scheduler_defer_cancel.py": 18.46,
|
||||
"tests/integration/test_scheduler_defer_cancel_regular.py": 16.34,
|
||||
"tests/integration/test_scheduler_defer_fifo_simple.py": 18.26,
|
||||
"tests/integration/test_scheduler_defer_stress.py": 17.74,
|
||||
"tests/integration/test_scheduler_heap_stress.py": 3.89,
|
||||
"tests/integration/test_scheduler_internal_id_no_collision.py": 20.01,
|
||||
"tests/integration/test_scheduler_interval_reschedule.py": 16.29,
|
||||
"tests/integration/test_scheduler_interval_zero_coerced.py": 16.09,
|
||||
"tests/integration/test_scheduler_null_name.py": 14.69,
|
||||
"tests/integration/test_scheduler_numeric_id_test.py": 17.08,
|
||||
"tests/integration/test_scheduler_pool.py": 19.88,
|
||||
"tests/integration/test_scheduler_rapid_cancellation.py": 4.42,
|
||||
"tests/integration/test_scheduler_recursive_timeout.py": 4.3,
|
||||
"tests/integration/test_scheduler_removed_item_race.py": 15.49,
|
||||
"tests/integration/test_scheduler_self_keyed.py": 25.77,
|
||||
"tests/integration/test_scheduler_simultaneous_callbacks.py": 14.84,
|
||||
"tests/integration/test_scheduler_string_test.py": 15.42,
|
||||
"tests/integration/test_script_array_params.py": 12.73,
|
||||
"tests/integration/test_script_delay_params.py": 12.69,
|
||||
"tests/integration/test_script_queued.py": 20.38,
|
||||
"tests/integration/test_script_queued_idle_loop.py": 25.06,
|
||||
"tests/integration/test_script_wait_on_boot.py": 15.67,
|
||||
"tests/integration/test_select_stringref_trigger.py": 19.48,
|
||||
"tests/integration/test_sensor_filters_delta.py": 27.62,
|
||||
"tests/integration/test_sensor_filters_ring_buffer.py": 20.27,
|
||||
"tests/integration/test_sensor_filters_sliding_window.py": 56.28,
|
||||
"tests/integration/test_sensor_filters_value_list.py": 20.6,
|
||||
"tests/integration/test_sensor_timeout_filter.py": 22.21,
|
||||
"tests/integration/test_socket_wake_gate_tcp.py": 16.37,
|
||||
"tests/integration/test_status_flags.py": 29.68,
|
||||
"tests/integration/test_strftime_to.py": 17.42,
|
||||
"tests/integration/test_syslog.py": 18.39,
|
||||
"tests/integration/test_template_alarm_control_panel_many_sensors.py": 25.61,
|
||||
"tests/integration/test_template_text_save.py": 19.16,
|
||||
"tests/integration/test_text_command.py": 16.43,
|
||||
"tests/integration/test_text_sensor_raw_state.py": 17.19,
|
||||
"tests/integration/test_uart_mock_ld2410.py": 37.0,
|
||||
"tests/integration/test_uart_mock_ld2412.py": 40.82,
|
||||
"tests/integration/test_uart_mock_ld2420.py": 32.7,
|
||||
"tests/integration/test_uart_mock_ld2450.py": 32.84,
|
||||
"tests/integration/test_uart_mock_modbus.py": 548.87,
|
||||
"tests/integration/test_udp.py": 16.67,
|
||||
"tests/integration/test_use_address_runtime.py": 27.26,
|
||||
"tests/integration/test_valve_control_action.py": 24.58,
|
||||
"tests/integration/test_varint_five_byte_device_id.py": 22.5,
|
||||
"tests/integration/test_wait_until_mid_loop_timing.py": 22.05,
|
||||
"tests/integration/test_wait_until_on_boot.py": 10.37,
|
||||
"tests/integration/test_wait_until_ordering.py": 18.23,
|
||||
"tests/integration/test_wait_until_reentrant_restart.py": 19.35,
|
||||
"tests/integration/test_wake_loop_forces_phase_b.py": 17.83,
|
||||
"tests/integration/test_water_heater_template.py": 25.7
|
||||
}
|
||||
|
||||
@@ -10,34 +10,40 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import socket
|
||||
|
||||
from aioesphomeapi import InvalidEncryptionKeyAPIError, RequiresEncryptionAPIError
|
||||
import pytest
|
||||
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
from .conftest import run_binary_and_wait_for_port
|
||||
from .const import KEY_ACTIVATION_DELAY, LOCALHOST, PROVISIONING_PSK, ZERO_PSK
|
||||
from .types import (
|
||||
APIClientConnectedFactory,
|
||||
CompileFunction,
|
||||
ConfigWriter,
|
||||
RunCompiledFunction,
|
||||
)
|
||||
|
||||
# The well-known provisioning PSK: base64 of 32 zero bytes
|
||||
ZERO_PSK = base64.b64encode(bytes(32)).decode()
|
||||
# A real key to provision
|
||||
NEW_KEY = base64.b64encode(b"n" * 32)
|
||||
# Time for the device to activate a newly saved key (100ms timer plus margin)
|
||||
KEY_ACTIVATION_DELAY = 0.5
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
|
||||
"""Keep host preferences per-test so every run starts unprovisioned."""
|
||||
monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs"))
|
||||
pytestmark = pytest.mark.usefixtures("isolated_preferences")
|
||||
NEW_KEY = PROVISIONING_PSK
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_zero_psk_provisioning(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
write_yaml_config: ConfigWriter,
|
||||
compile_esphome: CompileFunction,
|
||||
reserved_tcp_port: tuple[int, socket.socket],
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Exercise the reject paths, then provision a key over the zero-PSK channel."""
|
||||
async with run_compiled(yaml_config):
|
||||
"""Exercise the reject paths, provision a key over the zero-PSK channel,
|
||||
and check the key comes back from preferences on the next boot."""
|
||||
port, port_socket = reserved_tcp_port
|
||||
config_path = await write_yaml_config(yaml_config)
|
||||
binary_path = await compile_esphome(config_path)
|
||||
port_socket.close()
|
||||
|
||||
async with run_binary_and_wait_for_port(binary_path, LOCALHOST, port):
|
||||
# --- Pre-provisioning reject paths (device state is unchanged) ---
|
||||
|
||||
# A wrong (non-zero) PSK fails against the zero provisioning PSK
|
||||
@@ -97,6 +103,19 @@ async def test_api_zero_psk_provisioning(
|
||||
async with api_client_connected(timeout=5) as client:
|
||||
await client.device_info()
|
||||
|
||||
# The key is loaded from preferences on the next boot
|
||||
lines: list[str] = []
|
||||
async with run_binary_and_wait_for_port(
|
||||
binary_path, LOCALHOST, port, line_callback=lines.append
|
||||
):
|
||||
async with api_client_connected(noise_psk=NEW_KEY.decode()) as client:
|
||||
device_info = await client.device_info()
|
||||
assert device_info.api_encryption_provisionable is False
|
||||
with pytest.raises(InvalidEncryptionKeyAPIError):
|
||||
async with api_client_connected(noise_psk=ZERO_PSK, timeout=5) as client:
|
||||
await client.device_info()
|
||||
assert any("Loaded saved Noise PSK" in line for line in lines)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_zero_psk_provisioning_plaintext(
|
||||
|
||||
+258
-115
@@ -8,9 +8,12 @@ instance covers the FD_CLOEXEC path.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
import functools
|
||||
from pathlib import Path
|
||||
import socket
|
||||
|
||||
import pytest
|
||||
@@ -18,10 +21,18 @@ 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
|
||||
from .const import (
|
||||
KEY_ACTIVATION_DELAY,
|
||||
LOCALHOST,
|
||||
PORT_POLL_INTERVAL,
|
||||
PORT_WAIT_TIMEOUT,
|
||||
PROVISIONING_PSK,
|
||||
ZERO_PSK,
|
||||
)
|
||||
from .types import APIClientConnectedFactory, CompileFunction, ConfigWriter
|
||||
|
||||
DEVICE_NAME = "host-ota-test"
|
||||
API_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||
|
||||
|
||||
@contextmanager
|
||||
@@ -35,6 +46,14 @@ def _reserve_port() -> Generator[tuple[int, socket.socket]]:
|
||||
s.close()
|
||||
|
||||
|
||||
async def _wait_for_line(lines: list[str], needle: str, timeout: float = 5.0) -> None:
|
||||
"""The config dump prints after every setup, a little after the api port
|
||||
opens, so wait for it rather than assert on the lines seen so far."""
|
||||
async with asyncio.timeout(timeout):
|
||||
while not any(needle in line for line in lines):
|
||||
await asyncio.sleep(PORT_POLL_INTERVAL)
|
||||
|
||||
|
||||
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()
|
||||
@@ -51,6 +70,102 @@ async def _wait_for_port(host: str, port: int, timeout: float) -> None:
|
||||
raise TimeoutError(f"Port {port} on {host} did not open within {timeout}s")
|
||||
|
||||
|
||||
async def _build(
|
||||
yaml_config: str,
|
||||
write_yaml_config: ConfigWriter,
|
||||
compile_esphome: CompileFunction,
|
||||
reserved_tcp_port: tuple[int, socket.socket],
|
||||
) -> tuple[int, int, Path]:
|
||||
"""Reserve an OTA port, compile the fixture with it, and release both
|
||||
ports right before the binary is started."""
|
||||
api_port, api_socket = reserved_tcp_port
|
||||
with _reserve_port() as (ota_port, ota_socket):
|
||||
config_path = await write_yaml_config(
|
||||
yaml_config.replace("__OTA_PORT__", str(ota_port))
|
||||
)
|
||||
binary_path = await compile_esphome(config_path)
|
||||
api_socket.close()
|
||||
ota_socket.close()
|
||||
return api_port, ota_port, binary_path
|
||||
|
||||
|
||||
async def _run_ota(
|
||||
ota_port: int,
|
||||
password: str | None,
|
||||
binary_path: Path,
|
||||
noise_psk: str | None,
|
||||
plaintext_fallback: bool = False,
|
||||
) -> int:
|
||||
"""espota2 is blocking; run it in the executor and return its exit code."""
|
||||
rc, _ = await asyncio.get_running_loop().run_in_executor(
|
||||
None,
|
||||
functools.partial(
|
||||
espota2.run_ota,
|
||||
LOCALHOST,
|
||||
ota_port,
|
||||
password,
|
||||
binary_path,
|
||||
noise_psk=noise_psk,
|
||||
plaintext_fallback=plaintext_fallback,
|
||||
),
|
||||
)
|
||||
return rc
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Device:
|
||||
"""A running host binary and the checks every successful OTA repeats:
|
||||
a safe reboot, the api port back up, and the pid preserved by execv."""
|
||||
|
||||
api_port: int
|
||||
ota_port: int
|
||||
binary_path: Path
|
||||
proc: asyncio.subprocess.Process | None = None
|
||||
reboots: int = 0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self._rebooted = asyncio.Event()
|
||||
|
||||
def on_log(self, line: str) -> None:
|
||||
if "Rebooting safely" in line:
|
||||
self.reboots += 1
|
||||
self._rebooted.set()
|
||||
|
||||
async def wait_reboot(self, count: int, timeout: float = 10.0) -> None:
|
||||
async with asyncio.timeout(timeout):
|
||||
while self.reboots < count:
|
||||
self._rebooted.clear()
|
||||
await self._rebooted.wait()
|
||||
|
||||
async def ota(
|
||||
self,
|
||||
password: str | None,
|
||||
noise_psk: str | None,
|
||||
msg: str,
|
||||
plaintext_fallback: bool = False,
|
||||
) -> None:
|
||||
"""Upload, then expect the re-exec with the pid preserved."""
|
||||
pid_before = self.proc.pid
|
||||
expected_reboots = self.reboots + 1
|
||||
rc = await _run_ota(
|
||||
self.ota_port, password, self.binary_path, noise_psk, plaintext_fallback
|
||||
)
|
||||
assert rc == 0, msg
|
||||
await self.wait_reboot(expected_reboots)
|
||||
await _wait_for_port(LOCALHOST, self.api_port, PORT_WAIT_TIMEOUT)
|
||||
assert self.proc.returncode is None, "process exited instead of execing"
|
||||
assert self.proc.pid == pid_before
|
||||
|
||||
async def refused_ota(
|
||||
self, password: str | None, noise_psk: str | None, msg: str
|
||||
) -> None:
|
||||
"""Upload must fail and the device must keep running."""
|
||||
rc = await _run_ota(self.ota_port, password, self.binary_path, noise_psk)
|
||||
assert rc == 1, msg
|
||||
await asyncio.sleep(0.5)
|
||||
assert self.proc.returncode is None, "process died on rejected OTA"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_host_ota_self_update(
|
||||
yaml_config: str,
|
||||
@@ -59,57 +174,34 @@ async def test_host_ota_self_update(
|
||||
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()
|
||||
dev = _Device(
|
||||
*await _build(
|
||||
yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port
|
||||
)
|
||||
)
|
||||
staged = asyncio.Event()
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
ota_staged = loop.create_future()
|
||||
rebooted = loop.create_future()
|
||||
def on_log(line: str) -> None:
|
||||
if "OTA staged at" in line:
|
||||
staged.set()
|
||||
dev.on_log(line)
|
||||
|
||||
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(dev.binary_path, line_callback=on_log) as (proc, _lines):
|
||||
dev.proc = proc
|
||||
await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT)
|
||||
async with wait_and_connect_api_client(port=dev.api_port) as client:
|
||||
info_before = await client.device_info()
|
||||
assert info_before.name == DEVICE_NAME
|
||||
|
||||
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
|
||||
await dev.ota(None, None, "espota2 reported failure")
|
||||
assert staged.is_set()
|
||||
|
||||
# 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"
|
||||
async with wait_and_connect_api_client(port=dev.api_port) as client:
|
||||
info_after = await client.device_info()
|
||||
assert info_after.name == info_before.name
|
||||
|
||||
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
|
||||
# Second OTA: catches FD_CLOEXEC regressions (EADDRINUSE on rebind).
|
||||
await dev.ota(None, None, "second OTA failed -- listener leaked across execv")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -121,51 +213,110 @@ async def test_host_ota_encrypted(
|
||||
) -> None:
|
||||
"""Encrypted self-OTA succeeds; a plaintext upload to the same device fails."""
|
||||
pytest.importorskip("aioesphomeapi.noise")
|
||||
noise_psk = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||
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()
|
||||
dev = _Device(
|
||||
*await _build(
|
||||
yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port
|
||||
)
|
||||
)
|
||||
async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, _lines):
|
||||
dev.proc = proc
|
||||
await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT)
|
||||
await dev.refused_ota(
|
||||
None, None, "plaintext upload to an encrypted device must fail"
|
||||
)
|
||||
await dev.ota(None, API_KEY, "encrypted OTA reported failure")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
rebooted = loop.create_future()
|
||||
|
||||
def on_log(line: str) -> None:
|
||||
if not rebooted.done() and "Rebooting safely" in line:
|
||||
rebooted.set_result(True)
|
||||
@pytest.mark.asyncio
|
||||
async def test_host_ota_api_key_offer_with_password(
|
||||
yaml_config: str,
|
||||
write_yaml_config: ConfigWriter,
|
||||
compile_esphome: CompileFunction,
|
||||
reserved_tcp_port: tuple[int, socket.socket],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""With only an api key the device offers encryption without requiring
|
||||
it: the password still guards plaintext uploads, the key alone
|
||||
authenticates an encrypted one, and until 2027.3.0 a failed encrypted
|
||||
attempt falls back to plaintext."""
|
||||
pytest.importorskip("aioesphomeapi.noise")
|
||||
wrong_key = base64.b64encode(b"w" * 32).decode()
|
||||
dev = _Device(
|
||||
*await _build(
|
||||
yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port
|
||||
)
|
||||
)
|
||||
async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines):
|
||||
dev.proc = proc
|
||||
await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT)
|
||||
await _wait_for_line(lines, "Encryption: offered")
|
||||
|
||||
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
|
||||
await dev.refused_ota(
|
||||
None, None, "plaintext upload without the password must fail"
|
||||
)
|
||||
await dev.ota(
|
||||
"hunter2", None, "plaintext upload with the password must succeed"
|
||||
)
|
||||
await dev.ota(None, API_KEY, "encrypted upload with the api key must succeed")
|
||||
|
||||
# A plaintext upload must be refused with the device unharmed
|
||||
rc, _ = await loop.run_in_executor(
|
||||
None, espota2.run_ota, LOCALHOST, ota_port, None, binary_path
|
||||
# Remove before 2027.3.0: a wrong key falls back to plaintext, which
|
||||
# the password still guards
|
||||
with caplog.at_level("WARNING", logger="esphome.espota2"):
|
||||
await dev.ota(
|
||||
"hunter2",
|
||||
wrong_key,
|
||||
"the plaintext retry with the password must succeed",
|
||||
plaintext_fallback=True,
|
||||
)
|
||||
assert rc == 1, "plaintext upload to an encrypted device must fail"
|
||||
await asyncio.sleep(0.5)
|
||||
assert proc.returncode is None, "process died on rejected plaintext OTA"
|
||||
assert any("Retrying in plaintext" in r.message for r in caplog.records)
|
||||
await dev.ota(
|
||||
None,
|
||||
API_KEY,
|
||||
"the right api key encrypts without touching the fallback",
|
||||
plaintext_fallback=True,
|
||||
)
|
||||
|
||||
# The encrypted upload goes through and the device re-execs
|
||||
rc, _ = await loop.run_in_executor(
|
||||
None,
|
||||
functools.partial(
|
||||
espota2.run_ota,
|
||||
LOCALHOST,
|
||||
ota_port,
|
||||
None,
|
||||
binary_path,
|
||||
noise_psk=noise_psk,
|
||||
),
|
||||
)
|
||||
assert rc == 0, "encrypted OTA reported failure"
|
||||
await asyncio.wait_for(rebooted, timeout=10.0)
|
||||
await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT)
|
||||
assert proc.returncode is None, "process exited instead of execing"
|
||||
assert proc.pid == pid_before
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures("isolated_preferences")
|
||||
async def test_host_ota_provisioned_api_key(
|
||||
yaml_config: str,
|
||||
write_yaml_config: ConfigWriter,
|
||||
compile_esphome: CompileFunction,
|
||||
reserved_tcp_port: tuple[int, socket.socket],
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""A key provisioned over the api feeds the OTA offer: plaintext works
|
||||
while unprovisioned, the provisioned key encrypts, the key loaded from
|
||||
preferences on the next boot keeps encrypting, and plaintext stays
|
||||
accepted because only the ota block requires encryption."""
|
||||
pytest.importorskip("aioesphomeapi.noise")
|
||||
dev = _Device(
|
||||
*await _build(
|
||||
yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port
|
||||
)
|
||||
)
|
||||
async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines):
|
||||
dev.proc = proc
|
||||
await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT)
|
||||
await _wait_for_line(lines, "once the api key is provisioned")
|
||||
|
||||
await dev.ota(
|
||||
None, None, "plaintext upload to an unprovisioned device must succeed"
|
||||
)
|
||||
|
||||
async with api_client_connected(
|
||||
port=dev.api_port, noise_psk=ZERO_PSK
|
||||
) as client:
|
||||
assert await client.noise_encryption_set_key(PROVISIONING_PSK) is True
|
||||
await asyncio.sleep(KEY_ACTIVATION_DELAY)
|
||||
|
||||
key = PROVISIONING_PSK.decode()
|
||||
await dev.ota(
|
||||
None, key, "encrypted upload with the provisioned key must succeed"
|
||||
)
|
||||
await dev.ota(None, key, "the key loaded at boot must feed the OTA offer")
|
||||
await dev.ota(None, None, "plaintext must stay accepted on an offering device")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -177,33 +328,25 @@ async def test_host_ota_rejects_garbage(
|
||||
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)
|
||||
dev = _Device(
|
||||
*await _build(
|
||||
yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port
|
||||
)
|
||||
)
|
||||
# 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)
|
||||
|
||||
# 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)
|
||||
async with run_binary(dev.binary_path) as (proc, _lines):
|
||||
dev.proc = proc
|
||||
await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT)
|
||||
pid_before = proc.pid
|
||||
rc = await _run_ota(dev.ota_port, None, bogus_path, None)
|
||||
assert rc == 1
|
||||
await asyncio.sleep(0.5)
|
||||
assert proc.returncode is None, "process died on rejected OTA"
|
||||
assert proc.pid == pid_before
|
||||
|
||||
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
|
||||
async with wait_and_connect_api_client(port=dev.api_port) as client:
|
||||
info = await client.device_info()
|
||||
assert info.name == DEVICE_NAME
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Integration test that checks what LVGL actually draws, using a display with no screen.
|
||||
|
||||
The rendered screen is compared against a hash rather than a checked in reference image, so the
|
||||
repository does not have to carry a binary file. If a change to the drawing code or to the bundled
|
||||
LVGL alters the output, this test fails and prints the hash it saw; update EXPECTED_SHA256 once the
|
||||
new image has been looked at and found to be correct.
|
||||
|
||||
The picture is drawn and encoded entirely by code in this repository, so nothing installed on the
|
||||
machine running the test takes part in the result.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from .artifact_utils import keep_artifact
|
||||
from .bmp_utils import capture_when_drawn
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
WIDTH = 300
|
||||
HEIGHT = 300
|
||||
|
||||
# sha256 of the pixel data of a 300x300 screen showing "Hello World!" centred in white on a dark
|
||||
# blue background, drawn with the built in montserrat_14 font. To regenerate, run this test and
|
||||
# take the hash it reports.
|
||||
EXPECTED_SHA256 = "a995b002dd1d183c47514da15ab9a60a3e7d788c2e24386a02fddd48655092ed"
|
||||
# Bundled LVGL version (esphome/components/lvgl/__init__.py, LVGL_VERSION) the hash above was
|
||||
# generated against. A version bump can shift anti-aliasing enough to change the hash even though
|
||||
# nothing is actually wrong -- if this test fails, check that first before regenerating the hash.
|
||||
EXPECTED_LVGL_VERSION = "9.5.0"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lvgl_headless_render(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""LVGL draws the expected screen on a 300x300 display with no screen behind it."""
|
||||
snapshot_dir = tmp_path / "snapshots"
|
||||
monkeypatch.setenv("ESPHOME_SNAPSHOT_DIR", str(snapshot_dir))
|
||||
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
_, services = await client.list_entities_services()
|
||||
service = next(s for s in services if s.name == "take_screenshot")
|
||||
|
||||
async def take(name: str) -> None:
|
||||
await client.execute_service(service, {"name": name})
|
||||
|
||||
# The background is not the whole picture: LVGL must have drawn on it. Waiting for that
|
||||
# rather than for a fixed time keeps a slow first frame from being reported as a hash
|
||||
# mismatch, which would look like a drawing regression.
|
||||
image, capture = await capture_when_drawn(take, snapshot_dir, prefix="render")
|
||||
assert (image.width, image.height, image.bits) == (WIDTH, HEIGHT, 24)
|
||||
|
||||
digest = hashlib.sha256(image.pixels).hexdigest()
|
||||
if digest != EXPECTED_SHA256:
|
||||
# Kept outside the temporary folder so CI can upload it; see artifact_utils.
|
||||
kept = keep_artifact(
|
||||
"lvgl_headless_render_actual.bmp", capture.read_bytes()
|
||||
)
|
||||
|
||||
from esphome.components.lvgl import LVGL_VERSION
|
||||
|
||||
version_hint = ""
|
||||
if LVGL_VERSION != EXPECTED_LVGL_VERSION:
|
||||
version_hint = (
|
||||
f"the bundled LVGL version changed ({EXPECTED_LVGL_VERSION} -> "
|
||||
f"{LVGL_VERSION}), which is the likely cause\n"
|
||||
)
|
||||
pytest.fail(
|
||||
f"rendered screen does not match the expected hash\n"
|
||||
f"{version_hint}"
|
||||
f" expected: {EXPECTED_SHA256}\n"
|
||||
f" actual: {digest}\n"
|
||||
f"the image that was rendered has been kept at {kept}\n"
|
||||
f"on CI it is in the integration-test-artifacts upload for this job"
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Integration test for headless SDL rendering and snapshot capture.
|
||||
|
||||
How a file is named and written is the same for every display that can take a snapshot and is
|
||||
covered by test_snapshot_display; what is tested here is that SDL renders and can be read back
|
||||
with no display server present.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from .bmp_utils import capture_when_drawn
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
WIDTH = 101
|
||||
HEIGHT = 64
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sdl_headless_screenshot(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A headless SDL display renders with no display server and can be captured."""
|
||||
snapshot_dir = tmp_path / "snapshots"
|
||||
# The device reads this when it writes a file; the subprocess inherits our environment, so it
|
||||
# must be set before the binary is launched.
|
||||
monkeypatch.setenv("ESPHOME_SNAPSHOT_DIR", str(snapshot_dir))
|
||||
# Make sure the run really is headless even when the test machine has a display.
|
||||
monkeypatch.delenv("DISPLAY", raising=False)
|
||||
monkeypatch.delenv("WAYLAND_DISPLAY", raising=False)
|
||||
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
_, services = await client.list_entities_services()
|
||||
service = next(s for s in services if s.name == "take_screenshot")
|
||||
|
||||
async def take(name: str) -> None:
|
||||
await client.execute_service(service, {"name": name})
|
||||
|
||||
# The test card is drawn in several colours, so once it is on the screen the picture is
|
||||
# not one flat shade. Capturing until that is true waits out the first update rather than
|
||||
# racing it.
|
||||
image, _ = await capture_when_drawn(take, snapshot_dir)
|
||||
assert (image.width, image.height, image.bits) == (WIDTH, HEIGHT, 24)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Integration test for the snapshot display and the file writing shared with other displays."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from aioesphomeapi import LogLevel
|
||||
import pytest
|
||||
|
||||
from .bmp_utils import capture_when_drawn, wait_for_bmp
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
WIDTH = 101
|
||||
HEIGHT = 64
|
||||
|
||||
# Part of the message the writer logs when it will not write over a file that is already there.
|
||||
REFUSAL_MESSAGE = b"not overwriting"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_snapshot_display(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A display with no screen draws into memory and writes what it drew to a file."""
|
||||
snapshot_dir = tmp_path / "snapshots"
|
||||
# The device reads this when it writes a file; the subprocess inherits our environment, so it
|
||||
# must be set before the binary is launched.
|
||||
monkeypatch.setenv("ESPHOME_SNAPSHOT_DIR", str(snapshot_dir))
|
||||
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
_, services = await client.list_entities_services()
|
||||
service = next(s for s in services if s.name == "take_snapshot")
|
||||
|
||||
async def take(name: str) -> None:
|
||||
await client.execute_service(service, {"name": name})
|
||||
|
||||
# The test card is drawn in several colours, so once it is on the screen the picture is
|
||||
# not one flat shade. Capturing until that is true waits out the first update rather than
|
||||
# racing it.
|
||||
image, capture = await capture_when_drawn(take, snapshot_dir)
|
||||
assert (image.width, image.height, image.bits) == (WIDTH, HEIGHT, 24)
|
||||
|
||||
# An extension is only added when there is not one already, whatever its case.
|
||||
await take("UPPER.BMP")
|
||||
await wait_for_bmp(snapshot_dir / "UPPER.BMP")
|
||||
|
||||
# A name that tries to lead somewhere else is cut back to one harmless name in the
|
||||
# snapshot directory.
|
||||
await take("../escape")
|
||||
await wait_for_bmp(snapshot_dir / ".._escape.bmp")
|
||||
|
||||
# A second capture under a name already used must fail rather than write over the first.
|
||||
# Wait for the device to report the refusal: on its own, an unchanged file cannot tell a
|
||||
# refusal apart from a request the device has not got to yet, so a regression that wrote
|
||||
# over the file could still pass on a busy machine.
|
||||
refused = asyncio.Event()
|
||||
|
||||
def on_log(msg) -> None:
|
||||
if REFUSAL_MESSAGE in msg.message:
|
||||
refused.set()
|
||||
|
||||
client.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_DEBUG)
|
||||
|
||||
before = capture.read_bytes()
|
||||
await take(capture.name)
|
||||
await asyncio.wait_for(refused.wait(), timeout=10.0)
|
||||
assert capture.read_bytes() == before
|
||||
# Nothing beyond what was asked for, leaving out however many captures it took to wait
|
||||
# for the first frame.
|
||||
written = sorted(
|
||||
p.name for p in snapshot_dir.iterdir() if not p.name.startswith("drawn-")
|
||||
)
|
||||
assert written == [".._escape.bmp", "UPPER.BMP"]
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Integration test for template climate: sensor-pushed measured values, on_control + publish
|
||||
for the settable ones.
|
||||
|
||||
current_temperature/current_humidity are pushed by a referenced sensor/humidity_sensor (no
|
||||
polling); action is set once at boot via climate.template.publish, since it has no sensor
|
||||
equivalent. mode/target_temperature/fan_mode/swing_mode/preset are plain internal state:
|
||||
on_control fires exactly once per command (never before the first one), and
|
||||
climate.template.publish simulates the device reporting its own state independent of any prior
|
||||
command -- that report is authoritative, overriding whatever was optimistically applied earlier.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import aioesphomeapi
|
||||
from aioesphomeapi import (
|
||||
ButtonInfo,
|
||||
ClimateAction,
|
||||
ClimateFanMode,
|
||||
ClimateInfo,
|
||||
ClimateMode,
|
||||
ClimatePreset,
|
||||
ClimateSwingMode,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from .host_prefs import clear_host_prefs
|
||||
from .state_utils import InitialStateHelper, require_entity, wait_for_state
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
DEVICE_NAME = "tmpl-clim-basic"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_template_climate_basic(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Sensor-pushed measured values, on_control + publish for settable ones."""
|
||||
clear_host_prefs(DEVICE_NAME)
|
||||
|
||||
log_lines: list[str] = []
|
||||
|
||||
def on_log_line(line: str) -> None:
|
||||
if "on_control " in line:
|
||||
log_lines.append(line)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=on_log_line),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
|
||||
async def wait_for_climate_state(
|
||||
timeout: float = 5.0,
|
||||
) -> aioesphomeapi.ClimateState:
|
||||
return await wait_for_state(
|
||||
client, lambda s: isinstance(s, aioesphomeapi.ClimateState), timeout
|
||||
)
|
||||
|
||||
entities, _ = await client.list_entities_services()
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
climate_infos = [e for e in entities if isinstance(e, ClimateInfo)]
|
||||
assert len(climate_infos) == 1, "Expected exactly 1 climate entity"
|
||||
test_climate = climate_infos[0]
|
||||
|
||||
# Advertised capabilities come straight from the supported_*/custom_* config lists.
|
||||
assert ClimateMode.OFF in test_climate.supported_modes
|
||||
assert ClimateMode.HEAT in test_climate.supported_modes
|
||||
assert ClimateMode.COOL in test_climate.supported_modes
|
||||
|
||||
assert ClimateFanMode.AUTO in test_climate.supported_fan_modes
|
||||
assert ClimateFanMode.LOW in test_climate.supported_fan_modes
|
||||
assert ClimateFanMode.HIGH in test_climate.supported_fan_modes
|
||||
|
||||
assert ClimateSwingMode.OFF in test_climate.supported_swing_modes
|
||||
assert ClimateSwingMode.VERTICAL in test_climate.supported_swing_modes
|
||||
|
||||
assert ClimatePreset.NONE in test_climate.supported_presets
|
||||
assert ClimatePreset.ECO in test_climate.supported_presets
|
||||
|
||||
report_button = require_entity(entities, "simulate_device_report", ButtonInfo)
|
||||
|
||||
client.subscribe_states(
|
||||
initial_state_helper.on_state_wrapper(lambda state: None)
|
||||
)
|
||||
try:
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
except TimeoutError:
|
||||
pytest.fail("Timeout waiting for initial states")
|
||||
|
||||
initial = initial_state_helper.initial_states.get(test_climate.key)
|
||||
assert initial is not None, "No initial climate state received"
|
||||
assert isinstance(initial, aioesphomeapi.ClimateState)
|
||||
assert initial.current_temperature == pytest.approx(22.5, abs=0.1)
|
||||
assert initial.current_humidity == pytest.approx(55.0, abs=0.1)
|
||||
assert initial.action == ClimateAction.IDLE
|
||||
assert initial.mode == ClimateMode.OFF
|
||||
# Nothing was commanded yet: on_control must not have fired.
|
||||
assert not log_lines
|
||||
|
||||
# Commands apply optimistically and on_control fires with the same values.
|
||||
client.climate_command(test_climate.key, mode=ClimateMode.HEAT)
|
||||
state = await wait_for_climate_state()
|
||||
assert state.mode == ClimateMode.HEAT
|
||||
|
||||
client.climate_command(test_climate.key, target_temperature=22.5)
|
||||
state = await wait_for_climate_state()
|
||||
assert state.target_temperature == pytest.approx(22.5, abs=0.1)
|
||||
|
||||
client.climate_command(test_climate.key, fan_mode=ClimateFanMode.HIGH)
|
||||
state = await wait_for_climate_state()
|
||||
assert state.fan_mode == ClimateFanMode.HIGH
|
||||
|
||||
client.climate_command(test_climate.key, swing_mode=ClimateSwingMode.VERTICAL)
|
||||
state = await wait_for_climate_state()
|
||||
assert state.swing_mode == ClimateSwingMode.VERTICAL
|
||||
|
||||
client.climate_command(test_climate.key, preset=ClimatePreset.ECO)
|
||||
state = await wait_for_climate_state()
|
||||
assert state.preset == ClimatePreset.ECO
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
assert any(
|
||||
"on_control mode=3" in line for line in log_lines
|
||||
) # CLIMATE_MODE_HEAT
|
||||
assert any("on_control target_temperature=22.5" in line for line in log_lines)
|
||||
assert any("on_control fan_mode=" in line for line in log_lines)
|
||||
assert any("on_control swing_mode=" in line for line in log_lines)
|
||||
assert any("on_control preset=" in line for line in log_lines)
|
||||
# Exactly one on_control log line per command, none extra (e.g. from a stray republish).
|
||||
assert len(log_lines) == 5
|
||||
|
||||
# measured values are untouched by any of the above (no set action exists for them).
|
||||
assert state.current_temperature == pytest.approx(22.5, abs=0.1)
|
||||
assert state.current_humidity == pytest.approx(55.0, abs=0.1)
|
||||
assert state.action == ClimateAction.IDLE
|
||||
|
||||
# The device's report is authoritative and overrides everything commanded above.
|
||||
client.button_command(report_button.key)
|
||||
state = await wait_for_climate_state()
|
||||
assert state.mode == ClimateMode.OFF
|
||||
assert state.fan_mode == ClimateFanMode.AUTO
|
||||
assert state.swing_mode == ClimateSwingMode.OFF
|
||||
assert state.preset == ClimatePreset.NONE
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Integration test for template climate: custom fan modes and presets.
|
||||
|
||||
Same on_control (forward) + climate.template.publish (device report, authoritative) pattern as
|
||||
the enum-based mode/preset fields, but for the custom string variants.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import aioesphomeapi
|
||||
from aioesphomeapi import ButtonInfo, ClimateInfo
|
||||
import pytest
|
||||
|
||||
from .host_prefs import clear_host_prefs
|
||||
from .state_utils import InitialStateHelper, require_entity, wait_for_state
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
DEVICE_NAME = "tmpl-clim-custom"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_template_climate_custom_modes(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Custom fan mode/preset: traits, on_control forwarding, and publish precedence."""
|
||||
clear_host_prefs(DEVICE_NAME)
|
||||
|
||||
log_lines: list[str] = []
|
||||
|
||||
def on_log_line(line: str) -> None:
|
||||
if "on_control " in line:
|
||||
log_lines.append(line)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=on_log_line),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
|
||||
async def wait_for_climate_state(
|
||||
timeout: float = 5.0,
|
||||
) -> aioesphomeapi.ClimateState:
|
||||
return await wait_for_state(
|
||||
client, lambda s: isinstance(s, aioesphomeapi.ClimateState), timeout
|
||||
)
|
||||
|
||||
entities, _ = await client.list_entities_services()
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
climate_infos = [e for e in entities if isinstance(e, ClimateInfo)]
|
||||
assert len(climate_infos) == 1, "Expected exactly 1 climate entity"
|
||||
test_climate = climate_infos[0]
|
||||
|
||||
assert set(test_climate.supported_custom_fan_modes) == {
|
||||
"turbo",
|
||||
"silent",
|
||||
"eco",
|
||||
}
|
||||
assert set(test_climate.supported_custom_presets) == {
|
||||
"eco_plus",
|
||||
"power_save",
|
||||
"max",
|
||||
}
|
||||
|
||||
report_button = require_entity(entities, "simulate_device_report", ButtonInfo)
|
||||
|
||||
client.subscribe_states(
|
||||
initial_state_helper.on_state_wrapper(lambda state: None)
|
||||
)
|
||||
try:
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
except TimeoutError:
|
||||
pytest.fail("Timeout waiting for initial states")
|
||||
|
||||
initial = initial_state_helper.initial_states.get(test_climate.key)
|
||||
assert initial is not None, "No initial climate state received"
|
||||
assert isinstance(initial, aioesphomeapi.ClimateState)
|
||||
assert initial.custom_fan_mode == ""
|
||||
assert initial.custom_preset == ""
|
||||
|
||||
client.climate_command(test_climate.key, custom_fan_mode="turbo")
|
||||
state = await wait_for_climate_state()
|
||||
assert state.custom_fan_mode == "turbo"
|
||||
|
||||
client.climate_command(test_climate.key, custom_preset="power_save")
|
||||
state = await wait_for_climate_state()
|
||||
assert state.custom_preset == "power_save"
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
assert any("on_control custom_fan_mode=turbo" in line for line in log_lines)
|
||||
assert any("on_control custom_preset=power_save" in line for line in log_lines)
|
||||
|
||||
# The device's report is authoritative and overrides what was commanded above.
|
||||
client.button_command(report_button.key)
|
||||
state = await wait_for_climate_state()
|
||||
assert state.custom_fan_mode == "eco"
|
||||
assert state.custom_preset == "max"
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Integration test for template climate: optimistic: false.
|
||||
|
||||
A command still fires on_control (so a real device-backed config can forward it out), but must
|
||||
NOT change the entity's own state -- only an explicit climate.template.publish call (standing in
|
||||
for the device confirming the command actually took effect) does that.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import aioesphomeapi
|
||||
from aioesphomeapi import (
|
||||
ButtonInfo,
|
||||
ClimateFanMode,
|
||||
ClimateInfo,
|
||||
ClimateMode,
|
||||
ClimatePreset,
|
||||
ClimateSwingMode,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from .host_prefs import clear_host_prefs
|
||||
from .state_utils import InitialStateHelper, require_entity, wait_for_state
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
DEVICE_NAME = "tmpl-clim-nonopt"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_template_climate_nonoptimistic(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Nonoptimistic: a command doesn't change state until explicitly published."""
|
||||
clear_host_prefs(DEVICE_NAME)
|
||||
|
||||
log_lines: list[str] = []
|
||||
state_updates: list[aioesphomeapi.ClimateState] = []
|
||||
|
||||
def on_log_line(line: str) -> None:
|
||||
if "on_control " in line:
|
||||
log_lines.append(line)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=on_log_line),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
|
||||
def on_state(state: aioesphomeapi.EntityState) -> None:
|
||||
if isinstance(state, aioesphomeapi.ClimateState):
|
||||
state_updates.append(state)
|
||||
|
||||
entities, _ = await client.list_entities_services()
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
climate_infos = [e for e in entities if isinstance(e, ClimateInfo)]
|
||||
assert len(climate_infos) == 1, "Expected exactly 1 climate entity"
|
||||
test_climate = climate_infos[0]
|
||||
|
||||
confirm_button = require_entity(
|
||||
entities, "simulate_device_confirmation", ButtonInfo
|
||||
)
|
||||
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
try:
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
except TimeoutError:
|
||||
pytest.fail("Timeout waiting for initial states")
|
||||
|
||||
initial = initial_state_helper.initial_states.get(test_climate.key)
|
||||
assert initial is not None, "No initial climate state received"
|
||||
assert isinstance(initial, aioesphomeapi.ClimateState)
|
||||
assert initial.mode == ClimateMode.OFF
|
||||
|
||||
# Send every settable field in one command. on_control must fire with all of them, but
|
||||
# nothing may be applied to the entity's own state -- no ClimateState update at all.
|
||||
client.climate_command(
|
||||
test_climate.key,
|
||||
mode=ClimateMode.HEAT,
|
||||
target_temperature=22.5,
|
||||
fan_mode=ClimateFanMode.HIGH,
|
||||
swing_mode=ClimateSwingMode.VERTICAL,
|
||||
preset=ClimatePreset.AWAY,
|
||||
)
|
||||
await asyncio.sleep(0.3)
|
||||
assert any(
|
||||
"on_control mode=3" in line for line in log_lines
|
||||
) # CLIMATE_MODE_HEAT
|
||||
assert any("on_control target_temperature=22.5" in line for line in log_lines)
|
||||
assert any("on_control fan_mode=" in line for line in log_lines)
|
||||
assert any("on_control swing_mode=" in line for line in log_lines)
|
||||
assert any("on_control preset=" in line for line in log_lines)
|
||||
assert not state_updates, (
|
||||
"optimistic: false must not publish a state until climate.template.publish reports it"
|
||||
)
|
||||
|
||||
# The device confirms the command actually took effect.
|
||||
client.button_command(confirm_button.key)
|
||||
state = await wait_for_state(
|
||||
client, lambda s: isinstance(s, aioesphomeapi.ClimateState)
|
||||
)
|
||||
assert state.mode == ClimateMode.HEAT
|
||||
assert state.target_temperature == pytest.approx(22.5, abs=0.1)
|
||||
assert state.fan_mode == ClimateFanMode.HIGH
|
||||
assert state.swing_mode == ClimateSwingMode.VERTICAL
|
||||
assert state.preset == ClimatePreset.AWAY
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Integration test: on_control fires before control()/on_state, with the full ClimateCall.
|
||||
|
||||
on_control's lambda argument exposes get_mode()/etc. on the *requested* ClimateCall, while the
|
||||
entity's own .mode field still reflects the state *before* control() applies the change --
|
||||
proving the firing order is on_control, then control(), then on_state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import aioesphomeapi
|
||||
from aioesphomeapi import ClimateInfo, ClimateMode
|
||||
import pytest
|
||||
|
||||
from .host_prefs import clear_host_prefs
|
||||
from .state_utils import InitialStateHelper, wait_for_state
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
DEVICE_NAME = "tmpl-clim-oc-order"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_template_climate_on_control_ordering(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""on_control sees the requested value while the entity's own state is still the old one."""
|
||||
clear_host_prefs(DEVICE_NAME)
|
||||
|
||||
log_lines: list[str] = []
|
||||
|
||||
def on_log_line(line: str) -> None:
|
||||
if "on_control " in line or "on_state " in line:
|
||||
log_lines.append(line)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=on_log_line),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
|
||||
async def wait_for_climate_state(
|
||||
timeout: float = 5.0,
|
||||
) -> aioesphomeapi.ClimateState:
|
||||
return await wait_for_state(
|
||||
client, lambda s: isinstance(s, aioesphomeapi.ClimateState), timeout
|
||||
)
|
||||
|
||||
entities, _ = await client.list_entities_services()
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
climate_infos = [e for e in entities if isinstance(e, ClimateInfo)]
|
||||
assert len(climate_infos) == 1, "Expected exactly 1 climate entity"
|
||||
test_climate = climate_infos[0]
|
||||
|
||||
client.subscribe_states(
|
||||
initial_state_helper.on_state_wrapper(lambda state: None)
|
||||
)
|
||||
try:
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
except TimeoutError:
|
||||
pytest.fail("Timeout waiting for initial states")
|
||||
|
||||
client.climate_command(test_climate.key, mode=ClimateMode.HEAT)
|
||||
state = await wait_for_climate_state()
|
||||
assert state.mode == ClimateMode.HEAT
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
# on_control saw the new requested mode (3 == CLIMATE_MODE_HEAT) while the entity's own
|
||||
# state was still the old one (0 == CLIMATE_MODE_OFF) -- proving it fired before control().
|
||||
assert any(
|
||||
"on_control requested_mode=3 current_mode_before_apply=0" in line
|
||||
for line in log_lines
|
||||
)
|
||||
# on_state fired afterward, reporting the now-applied mode.
|
||||
assert any("on_state mode=3" in line for line in log_lines)
|
||||
|
||||
control_index = next(
|
||||
i for i, line in enumerate(log_lines) if "on_control " in line
|
||||
)
|
||||
state_index = next(i for i, line in enumerate(log_lines) if "on_state " in line)
|
||||
assert control_index < state_index, "on_control must fire before on_state"
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Integration test for template climate: climate.template.publish covering every field at once.
|
||||
|
||||
A single climate.template.publish call resolves into exactly one ClimateState update, and never
|
||||
triggers on_control (which would misrepresent a device state report as a fresh command). This also
|
||||
exercises that a sensor/humidity_sensor whose reading matches what's about to be published doesn't
|
||||
sneak in an extra state update of its own (the sensor callback only re-publishes on an actual
|
||||
change).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import aioesphomeapi
|
||||
from aioesphomeapi import (
|
||||
ButtonInfo,
|
||||
ClimateAction,
|
||||
ClimateFanMode,
|
||||
ClimateInfo,
|
||||
ClimateMode,
|
||||
ClimatePreset,
|
||||
ClimateSwingMode,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from .host_prefs import clear_host_prefs
|
||||
from .state_utils import InitialStateHelper, require_entity, wait_for_state
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
DEVICE_NAME = "tmpl-clim-publish-all"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_template_climate_publish_all_fields(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""One climate.template.publish call setting every field resolves to one state update."""
|
||||
clear_host_prefs(DEVICE_NAME)
|
||||
|
||||
state_updates: list[aioesphomeapi.ClimateState] = []
|
||||
on_control_count = 0
|
||||
|
||||
def on_log_line(line: str) -> None:
|
||||
nonlocal on_control_count
|
||||
if "on_control fired" in line:
|
||||
on_control_count += 1
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=on_log_line),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
|
||||
def on_state(state: aioesphomeapi.EntityState) -> None:
|
||||
if isinstance(state, aioesphomeapi.ClimateState):
|
||||
state_updates.append(state)
|
||||
|
||||
entities, _ = await client.list_entities_services()
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
climate_infos = [e for e in entities if isinstance(e, ClimateInfo)]
|
||||
assert len(climate_infos) == 1, "Expected exactly 1 climate entity"
|
||||
|
||||
publish_button = require_entity(entities, "publish_all", ButtonInfo)
|
||||
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
try:
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
except TimeoutError:
|
||||
pytest.fail("Timeout waiting for initial states")
|
||||
|
||||
client.button_command(publish_button.key)
|
||||
try:
|
||||
state = await wait_for_state(
|
||||
client, lambda s: isinstance(s, aioesphomeapi.ClimateState)
|
||||
)
|
||||
except TimeoutError:
|
||||
pytest.fail("Timeout waiting for the published climate state")
|
||||
|
||||
assert state.current_temperature == pytest.approx(20.0, abs=0.1)
|
||||
assert state.current_humidity == pytest.approx(60.0, abs=0.1)
|
||||
assert state.target_temperature == pytest.approx(23.0, abs=0.1)
|
||||
assert state.mode == ClimateMode.HEAT
|
||||
assert state.action == ClimateAction.HEATING
|
||||
assert state.fan_mode == ClimateFanMode.HIGH
|
||||
assert state.swing_mode == ClimateSwingMode.VERTICAL
|
||||
assert state.preset == ClimatePreset.ECO
|
||||
|
||||
# Give any stray extra update (there shouldn't be one) a moment to arrive.
|
||||
await asyncio.sleep(0.2)
|
||||
assert len(state_updates) == 1, (
|
||||
f"Expected exactly one ClimateState update, got {len(state_updates)}"
|
||||
)
|
||||
assert on_control_count == 0, (
|
||||
"climate.template.publish must not trigger on_control"
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Integration test for template climate: current_temperature/current_humidity live sensor push.
|
||||
|
||||
A *later* change to a backing sensor's value -- not just its initial reading at boot -- propagates
|
||||
into a new climate state via add_on_state_callback. Re-publishing the same sensor value again must
|
||||
not cause a redundant climate state update.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
|
||||
import aioesphomeapi
|
||||
from aioesphomeapi import ButtonInfo, ClimateInfo
|
||||
import pytest
|
||||
|
||||
from .host_prefs import clear_host_prefs
|
||||
from .state_utils import InitialStateHelper, require_entity, wait_for_state
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
DEVICE_NAME = "tmpl-clim-sensor-push"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_template_climate_sensor_push(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""A later change to the backing sensor pushes a new climate state; an unchanged republish does not."""
|
||||
clear_host_prefs(DEVICE_NAME)
|
||||
|
||||
state_updates: list[aioesphomeapi.ClimateState] = []
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
|
||||
def on_state(state: aioesphomeapi.EntityState) -> None:
|
||||
if isinstance(state, aioesphomeapi.ClimateState):
|
||||
state_updates.append(state)
|
||||
|
||||
entities, _ = await client.list_entities_services()
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
climate_infos = [e for e in entities if isinstance(e, ClimateInfo)]
|
||||
assert len(climate_infos) == 1, "Expected exactly 1 climate entity"
|
||||
test_climate = climate_infos[0]
|
||||
|
||||
publish_temp = require_entity(entities, "publish_temperature", ButtonInfo)
|
||||
publish_temp_same = require_entity(
|
||||
entities, "publish_temperature_same", ButtonInfo
|
||||
)
|
||||
publish_humidity = require_entity(entities, "publish_humidity", ButtonInfo)
|
||||
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
try:
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
except TimeoutError:
|
||||
pytest.fail("Timeout waiting for initial states")
|
||||
|
||||
initial = initial_state_helper.initial_states.get(test_climate.key)
|
||||
assert initial is not None, "No initial climate state received"
|
||||
assert isinstance(initial, aioesphomeapi.ClimateState)
|
||||
# Neither backing sensor has published anything yet.
|
||||
assert math.isnan(initial.current_temperature)
|
||||
assert math.isnan(initial.current_humidity)
|
||||
|
||||
# A later sensor reading -- not the initial one -- pushes a new climate state.
|
||||
client.button_command(publish_temp.key)
|
||||
state = await wait_for_state(
|
||||
client, lambda s: isinstance(s, aioesphomeapi.ClimateState)
|
||||
)
|
||||
assert state.current_temperature == pytest.approx(24.0, abs=0.1)
|
||||
|
||||
client.button_command(publish_humidity.key)
|
||||
state = await wait_for_state(
|
||||
client, lambda s: isinstance(s, aioesphomeapi.ClimateState)
|
||||
)
|
||||
assert state.current_humidity == pytest.approx(65.0, abs=0.1)
|
||||
|
||||
# Re-publishing the same temperature must not cause a redundant climate state update.
|
||||
updates_before = len(state_updates)
|
||||
client.button_command(publish_temp_same.key)
|
||||
await asyncio.sleep(0.3)
|
||||
assert len(state_updates) == updates_before, (
|
||||
"Re-publishing an unchanged sensor reading must not republish the climate state"
|
||||
)
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Integration test: each settable field forwards its value to the matching set_*_action.
|
||||
|
||||
With optimistic: false the entity state stays put until climate.template.publish reports the
|
||||
device's actual state back, so the actions are the only thing that reacts to a command.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import aioesphomeapi
|
||||
from aioesphomeapi import (
|
||||
ButtonInfo,
|
||||
ClimateFanMode,
|
||||
ClimateInfo,
|
||||
ClimateMode,
|
||||
ClimatePreset,
|
||||
ClimateSwingMode,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from .host_prefs import clear_host_prefs
|
||||
from .state_utils import InitialStateHelper, require_entity, wait_for_state
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
DEVICE_NAME = "tmpl-clim-set-act"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_template_climate_set_actions(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Every set_*_action fires with the requested value; state waits for a publish."""
|
||||
clear_host_prefs(DEVICE_NAME)
|
||||
|
||||
log_lines: list[str] = []
|
||||
|
||||
def on_log_line(line: str) -> None:
|
||||
if "_action " in line or "Unsupported" in line:
|
||||
log_lines.append(line)
|
||||
|
||||
def logged(fragment: str) -> bool:
|
||||
return any(fragment in line for line in log_lines)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=on_log_line),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
entities, _ = await client.list_entities_services()
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
climate_infos = [e for e in entities if isinstance(e, ClimateInfo)]
|
||||
assert len(climate_infos) == 1, "Expected exactly 1 climate entity"
|
||||
test_climate = climate_infos[0]
|
||||
|
||||
report_button = require_entity(entities, "report_device_state", ButtonInfo)
|
||||
unsupported_button = require_entity(
|
||||
entities, "report_unsupported_mode", ButtonInfo
|
||||
)
|
||||
|
||||
client.subscribe_states(
|
||||
initial_state_helper.on_state_wrapper(lambda state: None)
|
||||
)
|
||||
try:
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
except TimeoutError:
|
||||
pytest.fail("Timeout waiting for initial states")
|
||||
|
||||
# Both traits are derived from the low/high and humidity set actions, not declared.
|
||||
assert test_climate.supports_two_point_target_temperature
|
||||
assert test_climate.supports_target_humidity
|
||||
|
||||
client.climate_command(test_climate.key, mode=ClimateMode.HEAT)
|
||||
client.climate_command(
|
||||
test_climate.key, target_temperature_low=18.0, target_temperature_high=24.0
|
||||
)
|
||||
client.climate_command(test_climate.key, target_humidity=55)
|
||||
client.climate_command(test_climate.key, fan_mode=ClimateFanMode.LOW)
|
||||
client.climate_command(test_climate.key, custom_fan_mode="turbo")
|
||||
client.climate_command(test_climate.key, swing_mode=ClimateSwingMode.VERTICAL)
|
||||
client.climate_command(test_climate.key, preset=ClimatePreset.ECO)
|
||||
client.climate_command(test_climate.key, custom_preset="eco_plus")
|
||||
|
||||
for _ in range(50):
|
||||
await asyncio.sleep(0.1)
|
||||
if logged("set_custom_preset_action eco_plus"):
|
||||
break
|
||||
|
||||
assert logged("set_mode_action 3") # CLIMATE_MODE_HEAT
|
||||
assert logged("set_target_temperature_low_action 18.0")
|
||||
assert logged("set_target_temperature_high_action 24.0")
|
||||
assert logged("set_target_humidity_action 55")
|
||||
assert logged("set_fan_mode_action 3") # CLIMATE_FAN_LOW
|
||||
assert logged("set_custom_fan_mode_action turbo")
|
||||
assert logged("set_swing_mode_action 2") # CLIMATE_SWING_VERTICAL
|
||||
assert logged("set_preset_action 5") # CLIMATE_PRESET_ECO
|
||||
assert logged("set_custom_preset_action eco_plus")
|
||||
|
||||
# optimistic: false, so none of the commands above touched the entity's own state --
|
||||
# a device report is what actually moves it.
|
||||
client.button_command(report_button.key)
|
||||
state = await wait_for_state(
|
||||
client, lambda s: isinstance(s, aioesphomeapi.ClimateState)
|
||||
)
|
||||
assert state.mode == ClimateMode.HEAT
|
||||
|
||||
# A publish naming a mode outside supported_modes warns instead of publishing it.
|
||||
client.button_command(unsupported_button.key)
|
||||
for _ in range(50):
|
||||
await asyncio.sleep(0.1)
|
||||
if logged("Unsupported mode"):
|
||||
break
|
||||
assert logged("Unsupported mode")
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Integration tests for template climate: two-point target temperature + humidity.
|
||||
|
||||
Covers the supports_two_point_target_temperature/supports_target_humidity boolean flags plus
|
||||
on_control (forwarding commands out) and climate.template.publish (the device reporting its own
|
||||
authoritative state, independent of any prior command -- e.g. a device that owns its own setpoint,
|
||||
changed via a physical remote).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import aioesphomeapi
|
||||
from aioesphomeapi import ButtonInfo, ClimateInfo, ClimateMode
|
||||
import pytest
|
||||
|
||||
from .host_prefs import clear_host_prefs
|
||||
from .state_utils import InitialStateHelper, require_entity, wait_for_state
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
DEVICE_NAME = "tmpl-clim-two-point"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_template_climate_two_point_temperature(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Two-point target temperature + humidity: booleans, on_control, and publish precedence."""
|
||||
clear_host_prefs(DEVICE_NAME)
|
||||
|
||||
log_lines: list[str] = []
|
||||
|
||||
def on_log_line(line: str) -> None:
|
||||
if "on_control " in line:
|
||||
log_lines.append(line)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=on_log_line),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
|
||||
async def wait_for_climate_state(
|
||||
timeout: float = 5.0,
|
||||
) -> aioesphomeapi.ClimateState:
|
||||
return await wait_for_state(
|
||||
client, lambda s: isinstance(s, aioesphomeapi.ClimateState), timeout
|
||||
)
|
||||
|
||||
entities, _ = await client.list_entities_services()
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
climate_infos = [e for e in entities if isinstance(e, ClimateInfo)]
|
||||
assert len(climate_infos) == 1, "Expected exactly 1 climate entity"
|
||||
|
||||
test_climate = climate_infos[0]
|
||||
assert test_climate.name == "Test Two-Point Heatpump"
|
||||
assert test_climate.supports_two_point_target_temperature
|
||||
assert test_climate.supports_target_humidity
|
||||
|
||||
report_button = require_entity(entities, "simulate_device_report", ButtonInfo)
|
||||
|
||||
client.subscribe_states(
|
||||
initial_state_helper.on_state_wrapper(lambda state: None)
|
||||
)
|
||||
|
||||
try:
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
except TimeoutError:
|
||||
pytest.fail("Timeout waiting for initial states")
|
||||
|
||||
initial = initial_state_helper.initial_states.get(test_climate.key)
|
||||
assert initial is not None, "No initial climate state received"
|
||||
assert isinstance(initial, aioesphomeapi.ClimateState)
|
||||
# Nothing has been published yet: settable fields have no sensor to seed them from, so
|
||||
# the entity starts at ESPHome's plain defaults. current_temperature is pushed by the
|
||||
# referenced sensor, which has already settled by the time we get here.
|
||||
assert initial.mode == ClimateMode.OFF
|
||||
assert initial.current_temperature == pytest.approx(21.0, abs=0.1)
|
||||
|
||||
# The device reports its actual state for the first time.
|
||||
client.button_command(report_button.key)
|
||||
state = await wait_for_climate_state()
|
||||
assert state.mode == ClimateMode.HEAT_COOL
|
||||
assert state.target_temperature_low == pytest.approx(18.0, abs=0.1)
|
||||
assert state.target_temperature_high == pytest.approx(24.0, abs=0.1)
|
||||
assert state.target_humidity == pytest.approx(50.0, abs=0.1)
|
||||
|
||||
# Commands apply optimistically (settable fields are plain internal state), and on_control
|
||||
# fires with the same values so a real config could forward them to the device.
|
||||
client.climate_command(
|
||||
test_climate.key, target_temperature_low=19.0, target_temperature_high=25.0
|
||||
)
|
||||
state = await wait_for_climate_state()
|
||||
assert state.target_temperature_low == pytest.approx(19.0, abs=0.1)
|
||||
assert state.target_temperature_high == pytest.approx(25.0, abs=0.1)
|
||||
await asyncio.sleep(0.2)
|
||||
assert any(
|
||||
"on_control target_temperature_low=19.0" in line for line in log_lines
|
||||
)
|
||||
assert any(
|
||||
"on_control target_temperature_high=25.0" in line for line in log_lines
|
||||
)
|
||||
|
||||
client.climate_command(test_climate.key, target_humidity=45.0)
|
||||
state = await wait_for_climate_state()
|
||||
assert state.target_humidity == pytest.approx(45.0, abs=0.1)
|
||||
await asyncio.sleep(0.2)
|
||||
assert any("on_control target_humidity=45.0" in line for line in log_lines)
|
||||
|
||||
# The device's next report is authoritative and overrides whatever was optimistically
|
||||
# applied above -- this is the whole point of climate.template.publish: a device that owns
|
||||
# its own state (e.g. changed by a physical remote) always wins.
|
||||
client.button_command(report_button.key)
|
||||
state = await wait_for_climate_state()
|
||||
assert state.target_temperature_low == pytest.approx(18.0, abs=0.1)
|
||||
assert state.target_temperature_high == pytest.approx(24.0, abs=0.1)
|
||||
assert state.target_humidity == pytest.approx(50.0, abs=0.1)
|
||||
@@ -35,8 +35,8 @@ def _load_script():
|
||||
def test_spec_key_collapses_destinations() -> None:
|
||||
"""Two specs delivering one package share a directory and one key."""
|
||||
mod = _load_script()
|
||||
assert mod.spec_key("esphome/noise-c @ 0.1.21") == "noise-c"
|
||||
assert mod.spec_key("esphome/noise-c@0.1.21") == "noise-c"
|
||||
assert mod.spec_key("esphome/noise-c @ 0.1.24") == "noise-c"
|
||||
assert mod.spec_key("esphome/noise-c@0.1.24") == "noise-c"
|
||||
assert mod.spec_key("ESP32Async/AsyncTCP @ ^3.4.10") == mod.spec_key(
|
||||
"esp32async/asynctcp @ 3.5.0"
|
||||
)
|
||||
@@ -54,23 +54,23 @@ def test_parse_specs_and_cli_args(tmp_path: Path) -> None:
|
||||
"[env:a]\n"
|
||||
"platform = fake/platform@1\n"
|
||||
"lib_deps =\n"
|
||||
" esphome/noise-c @ 0.1.21\n"
|
||||
" esphome/noise-c @ 0.1.24\n"
|
||||
" ${common.lib_deps}\n"
|
||||
" internal_lib\n"
|
||||
"[env:b]\n"
|
||||
"lib_deps =\n"
|
||||
" esphome/noise-c @ 0.1.21\n"
|
||||
" esphome/noise-c @ 0.1.24\n"
|
||||
)
|
||||
mod = _load_script()
|
||||
args = Namespace(libraries=True, platforms=True, tools=False)
|
||||
libs, platforms, tools = mod.parse_specs(str(ini), args)
|
||||
# exact-string duplicates collapse; distinct version pins survive
|
||||
assert libs == ["esphome/noise-c @ 0.1.21"]
|
||||
assert libs == ["esphome/noise-c @ 0.1.24"]
|
||||
assert platforms == ["fake/platform@1"]
|
||||
assert tools == []
|
||||
assert mod.build_cli_args(libs, platforms, tools) == [
|
||||
"-l",
|
||||
"esphome/noise-c @ 0.1.21",
|
||||
"esphome/noise-c @ 0.1.24",
|
||||
"-p",
|
||||
"fake/platform@1",
|
||||
]
|
||||
@@ -162,13 +162,13 @@ def test_parallel_install_behavior(tmp_path: Path) -> None:
|
||||
mod.parallel_install(
|
||||
cls,
|
||||
[
|
||||
"esphome/noise-c @ 0.1.21",
|
||||
"esphome/noise-c @ 0.1.21",
|
||||
"esphome/noise-c @ 0.1.24",
|
||||
"esphome/noise-c @ 0.1.24",
|
||||
"esphome/already @ 1.0",
|
||||
"https://x/framework.tar.xz",
|
||||
],
|
||||
)
|
||||
assert cls.calls == ["esphome/noise-c @ 0.1.21"]
|
||||
assert cls.calls == ["esphome/noise-c @ 0.1.24"]
|
||||
assert cls.lock_events == ["lock", "unlock"]
|
||||
|
||||
|
||||
@@ -205,7 +205,7 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None:
|
||||
mod = _load_script()
|
||||
cls = _reset_fake(str(tmp_path))
|
||||
cls.deps = {
|
||||
"esphome/noise-c @ 0.1.21": [
|
||||
"esphome/noise-c @ 0.1.24": [
|
||||
{"owner": "esphome", "name": "libsodium", "version": "^1.0"},
|
||||
{"name": "SPI"},
|
||||
],
|
||||
@@ -213,12 +213,12 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None:
|
||||
{"owner": "esphome", "name": "libsodium", "version": "^1.0"},
|
||||
],
|
||||
}
|
||||
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.21", "esphome/wg @ 1.0"])
|
||||
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24", "esphome/wg @ 1.0"])
|
||||
assert len(cls.calls) == 3 # the shared dep installs exactly once
|
||||
assert {mod.spec_key(c) for c in cls.calls} == {"noise-c", "wg", "libsodium"}
|
||||
# Wave-1 strings carry no compatibility; the dependency wave does
|
||||
compats = dict(cls.compat_calls)
|
||||
assert compats["esphome/noise-c @ 0.1.21"] is None
|
||||
assert compats["esphome/noise-c @ 0.1.24"] is None
|
||||
dep_compat = next(v for k, v in cls.compat_calls if "libsodium" in k)
|
||||
assert dep_compat is not None # mirrors pio's install_dependency
|
||||
|
||||
@@ -229,11 +229,11 @@ def test_dependency_wave_excludes_url_specs(tmp_path: Path) -> None:
|
||||
mod = _load_script()
|
||||
cls = _reset_fake(str(tmp_path))
|
||||
cls.deps = {
|
||||
"esphome/noise-c @ 0.1.21": [
|
||||
"esphome/noise-c @ 0.1.24": [
|
||||
{"name": "vendored", "version": "https://github.com/x/y.git"},
|
||||
],
|
||||
}
|
||||
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.21"])
|
||||
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24"])
|
||||
assert {mod.spec_key(c) for c in cls.calls} == {"noise-c"}
|
||||
|
||||
|
||||
@@ -348,13 +348,13 @@ def test_warm_store_still_walks_dependencies(tmp_path: Path) -> None:
|
||||
"""Already-installed top-level packages still feed the dependency
|
||||
wave; a warm store can be missing a transitive dep."""
|
||||
mod = _load_script()
|
||||
cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 0.1.21"})
|
||||
cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 0.1.24"})
|
||||
cls.deps = {
|
||||
"esphome/noise-c @ 0.1.21": [
|
||||
"esphome/noise-c @ 0.1.24": [
|
||||
{"owner": "esphome", "name": "libsodium", "version": "^1.0"},
|
||||
],
|
||||
}
|
||||
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.21"])
|
||||
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24"])
|
||||
assert [mod.spec_key(c) for c in cls.calls] == ["libsodium"]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Unit tests for script/sync_dependency_versions.py."""
|
||||
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import yamlrocks
|
||||
|
||||
sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve()))
|
||||
|
||||
import sync_dependency_versions as sync_mod # noqa: E402
|
||||
|
||||
PRECOMMIT = """\
|
||||
# See https://pre-commit.com for more information
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.1.0
|
||||
hooks:
|
||||
- id: ruff
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: 7.0.0
|
||||
hooks:
|
||||
- id: flake8
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v3.0.0
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
- repo: https://github.com/pre-commit/mirrors-clang-format
|
||||
rev: v13.0.1
|
||||
hooks:
|
||||
- id: clang-format
|
||||
- repo: https://github.com/adrienverge/yamllint.git
|
||||
rev: v1.0.0
|
||||
hooks:
|
||||
- id: yamllint
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: pylint
|
||||
"""
|
||||
|
||||
REQ_TEST = """\
|
||||
pylint==4.0.8
|
||||
flake8==7.1.0
|
||||
ruff==0.2.0 # comment
|
||||
pyupgrade==3.0.0
|
||||
"""
|
||||
|
||||
REQ_DEV = """\
|
||||
clang-format==13.0.1
|
||||
yamllint==1.0.0
|
||||
"""
|
||||
|
||||
RUFF_REPO = "https://github.com/astral-sh/ruff-pre-commit"
|
||||
DUPLICATE_RUFF_BLOCK = f" - repo: {RUFF_REPO}\n rev: v0.3.0\n hooks: []\n"
|
||||
|
||||
EXPECTED_DRIFT = ["ruff: 0.1.0 -> 0.2.0", "flake8: 7.0.0 -> 7.1.0"]
|
||||
EXPECTED_PRECOMMIT = PRECOMMIT.replace("rev: v0.1.0", "rev: v0.2.0").replace(
|
||||
"rev: 7.0.0", "rev: 7.1.0"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def root(tmp_path: Path) -> Path:
|
||||
"""A fake checkout where ruff (v-prefixed) and flake8 (bare) have drifted."""
|
||||
(tmp_path / ".pre-commit-config.yaml").write_text(PRECOMMIT)
|
||||
(tmp_path / "requirements_test.txt").write_text(REQ_TEST)
|
||||
(tmp_path / "requirements_dev.txt").write_text(REQ_DEV)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _load(text: str) -> object:
|
||||
return yamlrocks.loads(text.encode(), option=yamlrocks.OPT_ROUND_TRIP)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("requirements", "expected"),
|
||||
[
|
||||
("prek==0.5.1 # comment\n", "0.5.1"),
|
||||
("Prek==0.5.1\n", "0.5.1"),
|
||||
("other==1.0\nprek==0.5.1\n", "0.5.1"),
|
||||
("prek>=0.5.1\n", None),
|
||||
("prek-extra==0.5.1\n", None),
|
||||
("", None),
|
||||
],
|
||||
)
|
||||
def test_read_requirement_version(requirements: str, expected: str | None) -> None:
|
||||
assert sync_mod.read_requirement_version(requirements, "prek") == expected
|
||||
|
||||
|
||||
def test_find_repo_entry() -> None:
|
||||
entry = sync_mod.find_repo_entry(_load(PRECOMMIT), RUFF_REPO)
|
||||
assert entry["rev"] == "v0.1.0"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "message"),
|
||||
[
|
||||
("hooks: []\n", "missing key 'repos'"),
|
||||
("repos:\n - rev: 1.0.0\n", "missing key 'repo'"),
|
||||
(PRECOMMIT + DUPLICATE_RUFF_BLOCK, "found 2"),
|
||||
("repos:\n - repo: other\n rev: 1.0.0\n", "found 0"),
|
||||
],
|
||||
)
|
||||
def test_find_repo_entry_errors(text: str, message: str) -> None:
|
||||
with pytest.raises(sync_mod.SyncError, match=message):
|
||||
sync_mod.find_repo_entry(_load(text), RUFF_REPO)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("rev", "expected"),
|
||||
[("v0.1.0", ("v", "0.1.0")), ("7.0.0", ("", "7.0.0")), ("'1.0'", ("", "1.0"))],
|
||||
)
|
||||
def test_current_rev(rev: str, expected: tuple[str, str]) -> None:
|
||||
doc = _load(f"repos:\n - repo: {RUFF_REPO}\n rev: {rev}\n")
|
||||
assert sync_mod.current_rev(doc["repos"][0], RUFF_REPO) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("block", "message"),
|
||||
[(" hooks: []\n", "has no rev"), (" rev: 1.0\n", "not a string: 1.0")],
|
||||
)
|
||||
def test_current_rev_errors(block: str, message: str) -> None:
|
||||
doc = _load(f"repos:\n - repo: {RUFF_REPO}\n{block}")
|
||||
with pytest.raises(sync_mod.SyncError, match=message):
|
||||
sync_mod.current_rev(doc["repos"][0], RUFF_REPO)
|
||||
|
||||
|
||||
def test_sync_reports_without_writing(root: Path) -> None:
|
||||
assert sync_mod.sync(root, write=False) == EXPECTED_DRIFT
|
||||
assert (root / ".pre-commit-config.yaml").read_text() == PRECOMMIT
|
||||
|
||||
|
||||
def test_sync_writes_keeps_layout_and_is_idempotent(root: Path) -> None:
|
||||
assert sync_mod.sync(root, write=True) == EXPECTED_DRIFT
|
||||
assert (root / ".pre-commit-config.yaml").read_text() == EXPECTED_PRECOMMIT
|
||||
assert sync_mod.sync(root, write=True) == []
|
||||
|
||||
|
||||
def test_sync_does_not_touch_a_config_that_matches(root: Path) -> None:
|
||||
(root / ".pre-commit-config.yaml").write_text(EXPECTED_PRECOMMIT)
|
||||
before = (root / ".pre-commit-config.yaml").stat().st_mtime_ns
|
||||
assert sync_mod.sync(root, write=True) == []
|
||||
assert (root / ".pre-commit-config.yaml").stat().st_mtime_ns == before
|
||||
|
||||
|
||||
def test_sync_missing_requirement_pin(root: Path) -> None:
|
||||
(root / "requirements_dev.txt").write_text("")
|
||||
with pytest.raises(sync_mod.SyncError, match="no 'clang-format==' pin"):
|
||||
sync_mod.sync(root, write=True)
|
||||
|
||||
|
||||
def test_sync_propagates_config_errors(root: Path) -> None:
|
||||
(root / ".pre-commit-config.yaml").write_text(PRECOMMIT + DUPLICATE_RUFF_BLOCK)
|
||||
with pytest.raises(sync_mod.SyncError, match="found 2"):
|
||||
sync_mod.sync(root, write=True)
|
||||
|
||||
|
||||
def test_main_check_reports_drift(
|
||||
root: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
assert sync_mod.main(["--check", "--root", str(root)]) == 1
|
||||
assert capsys.readouterr().out.splitlines() == EXPECTED_DRIFT
|
||||
assert (root / ".pre-commit-config.yaml").read_text() == PRECOMMIT
|
||||
|
||||
|
||||
def test_main_writes_then_check_is_clean(
|
||||
root: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
assert sync_mod.main(["--root", str(root)]) == 0
|
||||
assert capsys.readouterr().out.splitlines() == EXPECTED_DRIFT
|
||||
assert sync_mod.main(["--check", "--root", str(root)]) == 0
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
|
||||
def test_main_reports_sync_error(
|
||||
root: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
(root / "requirements_dev.txt").write_text("")
|
||||
assert sync_mod.main(["--root", str(root)]) == 1
|
||||
assert (
|
||||
"error: requirements_dev.txt: no 'clang-format==' pin"
|
||||
in capsys.readouterr().err
|
||||
)
|
||||
|
||||
|
||||
def test_main_defaults_to_repo_root(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
seen: dict[str, object] = {}
|
||||
|
||||
def fake_sync(root: Path, *, write: bool) -> list[str]:
|
||||
seen["root"] = root
|
||||
seen["write"] = write
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(sync_mod, "sync", fake_sync)
|
||||
assert sync_mod.main([]) == 0
|
||||
assert seen == {"root": sync_mod.REPO_ROOT, "write": True}
|
||||
|
||||
|
||||
def test_repository_is_in_sync() -> None:
|
||||
"""The real checkout must match; a failure here means a rev has drifted.
|
||||
|
||||
Also proves every SYNC_TARGETS entry still resolves in the real files.
|
||||
"""
|
||||
assert sync_mod.sync(sync_mod.REPO_ROOT, write=False) == []
|
||||
|
||||
|
||||
def test_cli_entry_point(root: Path) -> None:
|
||||
"""Run the script the way the workflow does, as a subprocess."""
|
||||
script = Path(sync_mod.__file__)
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(script), "--check", "--root", str(root)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 1
|
||||
assert result.stdout.splitlines() == EXPECTED_DRIFT
|
||||
@@ -0,0 +1,14 @@
|
||||
# Common UART configuration for ESP32 Arduino tests - 1200 baud, EVEN parity, 7 data bits
|
||||
|
||||
substitutions:
|
||||
tx_pin: GPIO17
|
||||
rx_pin: GPIO16
|
||||
|
||||
uart:
|
||||
- id: uart_bus
|
||||
tx_pin: ${tx_pin}
|
||||
rx_pin: ${rx_pin}
|
||||
baud_rate: 1200
|
||||
parity: EVEN
|
||||
data_bits: 7
|
||||
stop_bits: 1
|
||||
@@ -0,0 +1,14 @@
|
||||
# Common UART configuration for ESP32-C3 Arduino tests - 1200 baud, EVEN parity, 7 data bits
|
||||
|
||||
substitutions:
|
||||
tx_pin: GPIO20
|
||||
rx_pin: GPIO21
|
||||
|
||||
uart:
|
||||
- id: uart_bus
|
||||
tx_pin: ${tx_pin}
|
||||
rx_pin: ${rx_pin}
|
||||
baud_rate: 1200
|
||||
parity: EVEN
|
||||
data_bits: 7
|
||||
stop_bits: 1
|
||||
@@ -0,0 +1,14 @@
|
||||
# Common UART configuration for ESP32-C3 IDF tests - 1200 baud, EVEN parity, 7 data bits
|
||||
|
||||
substitutions:
|
||||
tx_pin: GPIO20
|
||||
rx_pin: GPIO21
|
||||
|
||||
uart:
|
||||
- id: uart_bus
|
||||
tx_pin: ${tx_pin}
|
||||
rx_pin: ${rx_pin}
|
||||
baud_rate: 1200
|
||||
parity: EVEN
|
||||
data_bits: 7
|
||||
stop_bits: 1
|
||||
@@ -0,0 +1,14 @@
|
||||
# Common UART configuration for ESP32 IDF tests - 1200 baud, EVEN parity, 7 data bits
|
||||
|
||||
substitutions:
|
||||
tx_pin: GPIO17
|
||||
rx_pin: GPIO16
|
||||
|
||||
uart:
|
||||
- id: uart_bus
|
||||
tx_pin: ${tx_pin}
|
||||
rx_pin: ${rx_pin}
|
||||
baud_rate: 1200
|
||||
parity: EVEN
|
||||
data_bits: 7
|
||||
stop_bits: 1
|
||||
@@ -0,0 +1,14 @@
|
||||
# Common UART configuration for ESP8266 Arduino tests - 1200 baud, EVEN parity, 7 data bits
|
||||
|
||||
substitutions:
|
||||
tx_pin: GPIO4
|
||||
rx_pin: GPIO5
|
||||
|
||||
uart:
|
||||
- id: uart_bus
|
||||
tx_pin: ${tx_pin}
|
||||
rx_pin: ${rx_pin}
|
||||
baud_rate: 1200
|
||||
parity: EVEN
|
||||
data_bits: 7
|
||||
stop_bits: 1
|
||||
@@ -0,0 +1,14 @@
|
||||
# Common UART configuration for RP2040 Arduino tests - 1200 baud, EVEN parity, 7 data bits
|
||||
|
||||
substitutions:
|
||||
tx_pin: GPIO0
|
||||
rx_pin: GPIO1
|
||||
|
||||
uart:
|
||||
- id: uart_bus
|
||||
tx_pin: ${tx_pin}
|
||||
rx_pin: ${rx_pin}
|
||||
baud_rate: 1200
|
||||
parity: EVEN
|
||||
data_bits: 7
|
||||
stop_bits: 1
|
||||
@@ -0,0 +1,12 @@
|
||||
# Common UART configuration for ESP32 Arduino tests - 38400 baud, EVEN parity
|
||||
|
||||
substitutions:
|
||||
tx_pin: GPIO17
|
||||
rx_pin: GPIO16
|
||||
|
||||
uart:
|
||||
- id: uart_bus
|
||||
tx_pin: ${tx_pin}
|
||||
rx_pin: ${rx_pin}
|
||||
baud_rate: 38400
|
||||
parity: EVEN
|
||||
@@ -0,0 +1,12 @@
|
||||
# Common UART configuration for ESP32-C3 Arduino tests - 38400 baud, EVEN parity
|
||||
|
||||
substitutions:
|
||||
tx_pin: GPIO20
|
||||
rx_pin: GPIO21
|
||||
|
||||
uart:
|
||||
- id: uart_bus
|
||||
tx_pin: ${tx_pin}
|
||||
rx_pin: ${rx_pin}
|
||||
baud_rate: 38400
|
||||
parity: EVEN
|
||||
@@ -0,0 +1,12 @@
|
||||
# Common UART configuration for ESP32-C3 IDF tests - 38400 baud, EVEN parity
|
||||
|
||||
substitutions:
|
||||
tx_pin: GPIO20
|
||||
rx_pin: GPIO21
|
||||
|
||||
uart:
|
||||
- id: uart_bus
|
||||
tx_pin: ${tx_pin}
|
||||
rx_pin: ${rx_pin}
|
||||
baud_rate: 38400
|
||||
parity: EVEN
|
||||
@@ -0,0 +1,12 @@
|
||||
# Common UART configuration for ESP32 IDF tests - 38400 baud, EVEN parity
|
||||
|
||||
substitutions:
|
||||
tx_pin: GPIO17
|
||||
rx_pin: GPIO16
|
||||
|
||||
uart:
|
||||
- id: uart_bus
|
||||
tx_pin: ${tx_pin}
|
||||
rx_pin: ${rx_pin}
|
||||
baud_rate: 38400
|
||||
parity: EVEN
|
||||
@@ -0,0 +1,12 @@
|
||||
# Common UART configuration for ESP8266 Arduino tests - 38400 baud, EVEN parity
|
||||
|
||||
substitutions:
|
||||
tx_pin: GPIO4
|
||||
rx_pin: GPIO5
|
||||
|
||||
uart:
|
||||
- id: uart_bus
|
||||
tx_pin: ${tx_pin}
|
||||
rx_pin: ${rx_pin}
|
||||
baud_rate: 38400
|
||||
parity: EVEN
|
||||
@@ -0,0 +1,12 @@
|
||||
# Common UART configuration for RP2040 Arduino tests - 38400 baud, EVEN parity
|
||||
|
||||
substitutions:
|
||||
tx_pin: GPIO0
|
||||
rx_pin: GPIO1
|
||||
|
||||
uart:
|
||||
- id: uart_bus
|
||||
tx_pin: ${tx_pin}
|
||||
rx_pin: ${rx_pin}
|
||||
baud_rate: 38400
|
||||
parity: EVEN
|
||||
@@ -1,11 +1,7 @@
|
||||
"""Tests for the per-board linker-script rule."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.esp8266 import _choose_ld_script
|
||||
from esphome.components.esp8266.boards import BOARDS, board_ld_script
|
||||
import esphome.config_validation as cv
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
|
||||
def test_d1_wroom_02_keeps_its_shipped_layout() -> None:
|
||||
@@ -21,13 +17,6 @@ def test_default_boards_use_the_flash_size_layout() -> None:
|
||||
|
||||
|
||||
def test_choose_ld_script_paths() -> None:
|
||||
"""Old cores get the size default, overriding boards hard-error there
|
||||
(a substituted layout would wipe flash-backed state), modern cores
|
||||
honor the override."""
|
||||
assert _choose_ld_script("nodemcuv2", cv.Version(2, 3, 0)) is None
|
||||
assert _choose_ld_script("nodemcuv2", cv.Version(2, 4, 2)) == "eagle.flash.4m.ld"
|
||||
assert _choose_ld_script("d1_wroom_02", cv.Version(2, 7, 4)) == (
|
||||
"eagle.flash.2m64.ld"
|
||||
)
|
||||
with pytest.raises(EsphomeError, match="cannot honor"):
|
||||
_choose_ld_script("d1_wroom_02", cv.Version(2, 4, 2))
|
||||
"""Default boards get the size layout, overriding boards keep theirs."""
|
||||
assert _choose_ld_script("nodemcuv2") == "eagle.flash.4m.ld"
|
||||
assert _choose_ld_script("d1_wroom_02") == "eagle.flash.2m64.ld"
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Tests for the Arduino framework version floor."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.esp8266 import _arduino_check_versions
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_PLATFORM_VERSION, CONF_VERSION
|
||||
|
||||
|
||||
def test_versions_before_3_are_rejected() -> None:
|
||||
with pytest.raises(cv.Invalid, match="no longer supported") as excinfo:
|
||||
_arduino_check_versions({CONF_VERSION: "2.7.4"})
|
||||
assert excinfo.value.path == [CONF_VERSION]
|
||||
|
||||
|
||||
def test_supported_versions_pass() -> None:
|
||||
value = _arduino_check_versions({CONF_VERSION: "3.0.2"})
|
||||
assert value[CONF_VERSION] == "3.0.2"
|
||||
assert "espressif8266@3.2.0" in value[CONF_PLATFORM_VERSION]
|
||||
|
||||
value = _arduino_check_versions({CONF_VERSION: "recommended"})
|
||||
assert value[CONF_VERSION] == "3.1.2"
|
||||
assert "espressif8266@4.2.1" in value[CONF_PLATFORM_VERSION]
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Tests for the espnow component's final validation."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.esp32.const import (
|
||||
VARIANT_ESP32C3,
|
||||
VARIANT_ESP32H2,
|
||||
VARIANT_ESP32P4,
|
||||
)
|
||||
from esphome.components.espnow import _validate_variant
|
||||
import esphome.config_validation as cv
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
|
||||
def _run(
|
||||
monkeypatch, variant: str, full_config: dict, config: ConfigType
|
||||
) -> ConfigType:
|
||||
monkeypatch.setattr("esphome.components.espnow.get_esp32_variant", lambda: variant)
|
||||
token = fv.full_config.set(full_config)
|
||||
try:
|
||||
return _validate_variant(config)
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_variant_with_native_wifi_passes(monkeypatch) -> None:
|
||||
"""A variant with a native Wi-Fi PHY needs no shim; config passes through."""
|
||||
config = {"id": "espnow"}
|
||||
assert _run(monkeypatch, VARIANT_ESP32C3, {}, config) is config
|
||||
|
||||
|
||||
def test_radioless_non_p4_variant_rejected(monkeypatch) -> None:
|
||||
"""Radio-less variants without any ESP-NOW path are rejected outright."""
|
||||
with pytest.raises(cv.Invalid, match="not supported"):
|
||||
_run(monkeypatch, VARIANT_ESP32H2, {}, {})
|
||||
|
||||
|
||||
def test_p4_without_esp32_hosted_rejected(monkeypatch) -> None:
|
||||
"""The P4 needs the esp32_hosted shim to supply the esp_now_* symbols."""
|
||||
with pytest.raises(cv.Invalid, match="esp32_hosted"):
|
||||
_run(monkeypatch, VARIANT_ESP32P4, {}, {})
|
||||
|
||||
|
||||
def test_p4_with_esp32_hosted_passes(monkeypatch) -> None:
|
||||
"""The P4 with esp32_hosted present validates; config passes through."""
|
||||
config = {"id": "espnow"}
|
||||
assert _run(monkeypatch, VARIANT_ESP32P4, {"esp32_hosted": {}}, config) is config
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user