Merge branch 'dev' into jesserockz-2026-446

This commit is contained in:
J. Nick Koston
2026-09-24 14:21:53 +01:00
committed by GitHub
706 changed files with 14509 additions and 8831 deletions
@@ -1,3 +1,4 @@
---
esphome:
name: test
@@ -5,8 +6,7 @@ esp32:
board: esp32dev
wifi:
ap: ~
ssid: test
password: testtest
image:
- id: its_a_bug
file: "mdi:bug"
api:
@@ -0,0 +1,15 @@
---
esphome:
name: test
esp32:
board: esp32dev
wifi:
ssid: test
password: testtest
api:
port: 6054
reboot_timeout: 0s
batch_delay: 0ms
@@ -0,0 +1,15 @@
---
esphome:
name: test
esp32:
board: esp32dev
wifi:
ssid: test
password: testtest
api:
port: 6053
reboot_timeout: 15min
batch_delay: 100ms
@@ -0,0 +1,35 @@
"""Tests that the api component only emits setters for non default values."""
from collections.abc import Callable
from pathlib import Path
import pytest
@pytest.mark.parametrize("config_file", ["bare.yaml", "defaults.yaml"])
def test_default_values_are_not_emitted(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_file: str,
) -> None:
"""Port 6053, a 15 min reboot timeout and 100 ms batch delay are C++ initializers.
Both the schema defaults and the same values written explicitly take the skip path.
"""
main_cpp = generate_main(component_config_path(config_file))
assert "api_apiserver_id->set_port(" not in main_cpp
assert "api_apiserver_id->set_reboot_timeout(" not in main_cpp
assert "api_apiserver_id->set_batch_delay(" not in main_cpp
def test_custom_values_are_emitted(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Non default values still reach the C++ setters."""
main_cpp = generate_main(component_config_path("custom.yaml"))
assert "api_apiserver_id->set_port(6054);" in main_cpp
assert "api_apiserver_id->set_reboot_timeout(0);" in main_cpp
assert "api_apiserver_id->set_batch_delay(0);" in main_cpp
@@ -0,0 +1,12 @@
esphome:
name: bk-power-save
bk72xx:
board: cb2s
wifi:
ssid: test
password: testtest
power_save_mode: high
bk72xx_ble:
@@ -0,0 +1,20 @@
"""bk72xx_ble keeps WiFi power save off: the Beken SDK's MCU sleep does not
wake up once the station is stopped while the BLE controller runs."""
from collections.abc import Callable
from pathlib import Path
import pytest
def test_power_save_mode_is_not_applied_with_ble(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
caplog: pytest.LogCaptureFixture,
) -> None:
main_cpp = generate_main(component_config_path("test_power_save.yaml"))
assert "bk72xx_ble::BK72xxBLE" in main_cpp
assert "set_power_save_mode(" not in main_cpp
assert "power_save_mode HIGH is not applied" in caplog.text
assert "issues/18592" in caplog.text
@@ -39,12 +39,13 @@ def test_trigger_codegen(
"set_manufacturer_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB,"
"0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp
)
# scan-control actions: templatable continuous lambda + parented actions.
# scan-control actions: templatable continuous lambda + stop_scan forwarded
# straight to the tracker (register_apply_action, no action class).
# Exactly one set_continuous: the bare start_scan emits none, pinning the
# restore-configured-mode divergence from esp32 against a future default=.
assert main_cpp.count("->set_continuous(") == 1
assert "startscanaction_id->set_continuous(" in main_cpp
assert "stopscanaction_id->set_parent(" in main_cpp
assert main_cpp.count("->stop_scan();") == 1
# scan_parameters continuous: false reaches the YAML-mode setter, not the
# runtime override.
assert "->set_configured_continuous(false)" in main_cpp
@@ -0,0 +1,15 @@
esphome:
name: climateir-heatcool
esp8266:
board: esp01_1m
remote_transmitter:
pin: GPIO5
carrier_duty_percent: 50%
climate:
- platform: coolix
id: test_coolix
name: Coolix
supports_heat: false
@@ -0,0 +1,16 @@
esphome:
name: climateir-heatcool
esp8266:
board: esp01_1m
remote_transmitter:
pin: GPIO5
carrier_duty_percent: 50%
climate:
- platform: coolix
id: test_coolix
name: Coolix
supports_heat: false
supports_heat_cool: true
@@ -0,0 +1,14 @@
esphome:
name: climateir-heatcool
esp8266:
board: esp01_1m
remote_transmitter:
pin: GPIO5
carrier_duty_percent: 50%
climate:
- platform: coolix
id: test_coolix
name: Coolix
@@ -0,0 +1,15 @@
esphome:
name: climateir-heatcool
esp8266:
board: esp01_1m
remote_transmitter:
pin: GPIO5
carrier_duty_percent: 50%
climate:
- platform: coolix
id: test_coolix
name: Coolix
supports_heat_cool: false
@@ -0,0 +1,15 @@
esphome:
name: climateir-heatcool
esp8266:
board: esp01_1m
remote_transmitter:
pin: GPIO5
carrier_duty_percent: 50%
climate:
- platform: coolix
id: test_coolix
name: Coolix
supports_cool: false
@@ -0,0 +1,16 @@
esphome:
name: climateir-heatcool
esp8266:
board: esp01_1m
remote_transmitter:
pin: GPIO5
carrier_duty_percent: 50%
climate:
- platform: coolix
id: test_coolix
name: Coolix
supports_heat: false
supports_cool: false
@@ -0,0 +1,53 @@
"""Tests for the supports_heat_cool default resolved in climate_ir code generation."""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import re
import pytest
def _emitted_value(main_cpp: str) -> str | None:
"""Return the argument of the generated set_supports_heat_cool() call, or None if absent."""
match = re.search(r"set_supports_heat_cool\((true|false)\)", main_cpp)
return match.group(1) if match else None
@pytest.mark.parametrize(
("config", "expected"),
[
("heat_and_cool.yaml", None),
("cool_only.yaml", "false"),
("heat_only.yaml", "false"),
("neither.yaml", "false"),
],
)
def test_default_requires_heat_and_cool(
config: str,
expected: str | None,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Without the key, HEAT_COOL follows supports_heat and supports_cool."""
main_cpp = generate_main(component_config_path(config))
assert _emitted_value(main_cpp) == expected
@pytest.mark.parametrize(
("config", "expected"),
[
("cool_only_override_on.yaml", None),
("heat_and_cool_override_off.yaml", "false"),
],
)
def test_explicit_key_overrides_default(
config: str,
expected: str | None,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""A cool-only unit can still offer HEAT_COOL, and a heat+cool unit can drop it."""
main_cpp = generate_main(component_config_path(config))
assert _emitted_value(main_cpp) == expected
@@ -0,0 +1,53 @@
"""Power-user fields are marked as advanced on the shared schemas.
``filters``, ``manual_ip`` and the GPIO switch interlock options are knobs
whose defaults suit nearly every user, so a schema-aware editor should keep
them behind its "advanced settings" disclosure rather than on the main form.
"""
from __future__ import annotations
import importlib
import pytest
from esphome.components import binary_sensor, ethernet, sensor, text_sensor, wifi
import esphome.config_validation as cv
def _markers(schema: cv.Schema) -> dict[str, object]:
s = schema
if hasattr(s, "validators"):
# cv.All -> the schema is the first validator.
s = s.validators[0]
return {str(k): k for k in s.schema}
def _gpio_switch_schema() -> cv.Schema:
return importlib.import_module("esphome.components.gpio.switch").CONFIG_SCHEMA
@pytest.mark.parametrize(
("label", "schema_factory", "fields"),
[
("sensor", sensor.sensor_schema, ["filters"]),
("binary_sensor", binary_sensor.binary_sensor_schema, ["filters"]),
("text_sensor", text_sensor.text_sensor_schema, ["filters"]),
("wifi_network", lambda: wifi.WIFI_NETWORK_BASE, ["manual_ip"]),
("wifi", lambda: wifi.CONFIG_SCHEMA, ["manual_ip"]),
("ethernet", lambda: ethernet.BASE_SCHEMA, ["manual_ip"]),
("gpio_switch", _gpio_switch_schema, ["interlock", "interlock_wait_time"]),
],
)
def test_power_user_fields_are_advanced(
label: str, schema_factory, fields: list[str]
) -> None:
markers = _markers(schema_factory())
for field in fields:
assert markers[field].visibility is cv.Visibility.ADVANCED, f"{label}.{field}"
def test_interlock_wait_time_keeps_its_default() -> None:
"""Marking the field advanced must not drop its default."""
markers = _markers(_gpio_switch_schema())
assert markers["interlock_wait_time"].default() == "0ms"
@@ -0,0 +1,8 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
version: 5.0.6
@@ -0,0 +1,9 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
advanced:
flash_chip: gd
@@ -0,0 +1,9 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
advanced:
flash_chip: generic
@@ -0,0 +1,10 @@
esphome:
name: test
esp32:
variant: esp32s3
flash_mode: opi
framework:
type: esp-idf
advanced:
flash_chip: mxic_opi
@@ -0,0 +1,8 @@
esphome:
name: test
esp32:
variant: esp32s3
flash_mode: opi
framework:
type: esp-idf
@@ -0,0 +1,11 @@
esphome:
name: test
esp32:
board: esp32-c6-devkitc-1
framework:
type: esp-idf
zigbee:
model: zigbee_test
power_source: MAINS_SINGLE_PHASE
+141 -4
View File
@@ -10,6 +10,7 @@ from typing import Any
import pytest
from esphome.components.esp32 import (
ESP32_FLASH_CHIPS,
KEY_FATFS_REQUIRED,
KEY_MBEDTLS_TLS_EXTRAS_REQUIRED,
KEY_MBEDTLS_TLS_SERVER_REQUIRED,
@@ -252,6 +253,51 @@ def test_esp32_rejects_unsupported_cli_toolchain(
r"value must be at most 5 .* @ data\['framework'\]\['advanced'\]\['nvs_encryption'\]\['key_id'\]",
id="nvs_encryption_key_id_out_of_range",
),
pytest.param(
{
"variant": "esp32",
"board": "esp32dev",
"framework": {
"type": "esp-idf",
"advanced": {"flash_chip": "mxic_opi"},
},
},
r"'flash_chip: mxic_opi' is only supported on ESP32S3 @ data\['framework'\]\['advanced'\]\['flash_chip'\]",
id="flash_chip_mxic_opi_only_on_s3",
),
pytest.param(
{
"variant": "esp32s3",
"flash_mode": "opi",
"framework": {
"type": "esp-idf",
"advanced": {"flash_chip": "gd"},
},
},
r"'flash_chip: gd' does not match 'flash_mode: opi'; octal flash uses mxic_opi @ data\['framework'\]\['advanced'\]\['flash_chip'\]",
id="flash_chip_must_match_opi_mode",
),
pytest.param(
{
"variant": "esp32s3",
"framework": {
"type": "esp-idf",
"advanced": {"flash_chip": "mxic_opi"},
},
},
r"'flash_chip: mxic_opi' requires 'flash_mode: opi' @ data\['framework'\]\['advanced'\]\['flash_chip'\]",
id="flash_chip_mxic_opi_requires_opi_mode",
),
pytest.param(
{
"variant": "esp32",
"board": "esp32dev",
"flash_mode": "opi",
"framework": {"type": "esp-idf"},
},
r"'flash_mode: opi' is only supported on ESP32S3 @ data\['flash_mode'\]",
id="flash_mode_opi_only_on_s3",
),
],
)
def test_esp32_configuration_errors(
@@ -658,6 +704,27 @@ def test_platformio_arduino_enables_reproducible_build(
assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True
@pytest.mark.parametrize(
("config_file", "expected"),
[
("reproducible_build.yaml", True),
("reproducible_build_arduino.yaml", True),
("file_macro_idf_5_0.yaml", False),
],
)
def test_file_macro_is_basename_only(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_file: str,
expected: bool,
) -> None:
"""__FILE__ becomes the basename on GCC 12 toolchains; IDF 5.0 (GCC 11) is skipped."""
generate_main(component_config_path(config_file))
assert ("-D__FILE__=__FILE_NAME__" in CORE.build_flags) is expected
assert ("-Wno-builtin-macro-redefined" in CORE.build_flags) is expected
def test_native_idf_enables_reproducible_build(
component_config_path: Callable[[str], Path],
) -> None:
@@ -683,10 +750,59 @@ def test_flash_mode_sets_sdkconfig_and_pio_option(
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHMODE_QIO") is True
assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHFREQ_80M") is True
assert sdkconfig.get("CONFIG_ESPTOOLPY_OCT_FLASH") is False
assert CORE.platformio_options.get("board_build.flash_mode") == "qio"
assert CORE.platformio_options.get("board_build.f_flash") == "80000000L"
@pytest.mark.parametrize(
("config_file", "enabled"),
[
pytest.param("flash_chip_gd.yaml", "CONFIG_SPI_FLASH_SUPPORT_GD_CHIP", id="gd"),
pytest.param("flash_chip_generic.yaml", None, id="generic"),
pytest.param(
"flash_chip_mxic_opi_s3.yaml",
"CONFIG_SPI_FLASH_SUPPORT_MXIC_OPI_CHIP",
id="mxic_opi_s3",
),
],
)
def test_flash_chip_keeps_one_vendor_driver(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_file: str,
enabled: str | None,
) -> None:
"""flash_chip enables only the chosen vendor driver."""
generate_main(component_config_path(config_file))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
vendors = {
k: v for k, v in sdkconfig.items() if k.startswith("CONFIG_SPI_FLASH_SUPPORT_")
}
assert vendors == {flag: flag == enabled for flag in ESP32_FLASH_CHIPS.values()}
def test_flash_chip_unset_keeps_idf_defaults(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Without flash_chip every vendor driver stays at its ESP-IDF default."""
generate_main(component_config_path("flash_mode_default.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert not any(key.startswith("CONFIG_SPI_FLASH_SUPPORT_") for key in sdkconfig)
def test_flash_mode_opi_enables_octal_flash(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""flash_mode: opi needs the octal flash switch or ESP-IDF ignores the mode."""
generate_main(component_config_path("flash_mode_opi_s3.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHMODE_OPI") is True
assert sdkconfig.get("CONFIG_ESPTOOLPY_OCT_FLASH") is True
def test_flash_mode_unset_leaves_defaults(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
@@ -1341,7 +1457,7 @@ def test_esp32_s31_gpio_validation(
pin = {CONF_NUMBER: 36, CONF_MODE: input_mode}
with caplog.at_level("WARNING"):
validate_supports(pin)
assert "GPIO36 is a strapping PIN" in caplog.text
assert "GPIO36 is a strapping pin" in caplog.text
_TLS_SERVER_OPTIONS = (
@@ -1372,7 +1488,7 @@ def test_mbedtls_tls_trim_sdkconfig(
assert {sdkconfig.get(name) for name in MBEDTLS_TLS_EXTRA_OPTIONS} == {extras}
_OPENTHREAD_EXTRAS = {"CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC"}
_CCM_ECDSA_EXTRAS = {"CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC"}
def test_mbedtls_tls_openthread_keeps_only_what_it_uses(
@@ -1384,7 +1500,19 @@ def test_mbedtls_tls_openthread_keeps_only_what_it_uses(
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert tuple(sdkconfig.get(name) for name in _TLS_SERVER_OPTIONS) == (None, None)
for name in MBEDTLS_TLS_EXTRA_OPTIONS:
assert sdkconfig.get(name) is (None if name in _OPENTHREAD_EXTRAS else False)
assert sdkconfig.get(name) is (None if name in _CCM_ECDSA_EXTRAS else False)
def test_mbedtls_tls_zigbee_keeps_only_what_it_uses(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The Zigbee config keeps CCM and deterministic ECDSA; the rest is trimmed."""
generate_main(component_config_path("tls_zigbee_c6.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert tuple(sdkconfig.get(name) for name in _TLS_SERVER_OPTIONS) == (True, False)
for name in MBEDTLS_TLS_EXTRA_OPTIONS:
assert sdkconfig.get(name) is (None if name in _CCM_ECDSA_EXTRAS else False)
def test_mbedtls_tls_user_sdkconfig_wins(
@@ -1413,7 +1541,16 @@ def test_mbedtls_tls_openthread_requires_server_and_extras(
"""The OpenThread hooks mark the DTLS server and CCM/deterministic ECDSA as required."""
generate_main(component_config_path("mbedtls_tls_openthread.yaml"))
assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_SERVER_REQUIRED] is True
assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_EXTRAS_REQUIRED] == _OPENTHREAD_EXTRAS
assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_EXTRAS_REQUIRED] == _CCM_ECDSA_EXTRAS
def test_mbedtls_tls_zigbee_requires_extras(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The Zigbee hooks mark the CCM/deterministic ECDSA as required."""
generate_main(component_config_path("tls_zigbee_c6.yaml"))
assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_EXTRAS_REQUIRED] == _CCM_ECDSA_EXTRAS
_VASPRINTF_STUB_FLAGS = {"-Wl,--wrap=vasprintf", "-Wl,--undefined=__wrap_vasprintf"}
@@ -9,5 +9,5 @@ wifi:
password: password1
# esp32_ble_server is only auto-loaded here, so it has no services of its own.
esp32_improv:
improv_ble:
authorizer: none
@@ -55,7 +55,7 @@ def test_uuid_is_matches_descriptor_short_strings(uuid16) -> None:
@pytest.mark.parametrize(
("config_file", "required"),
[
# Auto-loaded by esp32_improv only: nothing to find until Improv asks for it
# Auto-loaded by improv_ble 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),
@@ -0,0 +1,19 @@
---
esphome:
name: test
esp8266:
board: d1_mini
output:
- platform: esp8266_pwm
id: default_frequency
pin: GPIO4
frequency: 1kHz
- platform: esp8266_pwm
id: custom_frequency
pin: GPIO5
frequency: 2kHz
- platform: esp8266_pwm
id: schema_default_frequency
pin: GPIO12
@@ -0,0 +1,16 @@
"""Tests for the esp8266_pwm output codegen."""
from collections.abc import Callable
from pathlib import Path
def test_default_frequency_is_not_emitted(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The 1 kHz default already lives in the C++ initializer."""
main_cpp = generate_main(component_config_path("frequency.yaml"))
assert "default_frequency->set_frequency(" not in main_cpp
assert "schema_default_frequency->set_frequency(" not in main_cpp
assert "custom_frequency->set_frequency(2000.0f);" in main_cpp
@@ -0,0 +1,31 @@
esphome:
name: improv-ble-automations
esp32:
variant: esp32
framework:
type: esp-idf
logger:
wifi:
ssid: MySSID
password: password1
binary_sensor:
- platform: gpio
pin: 0
id: io0_button
output:
- platform: gpio
pin: 2
id: built_in_led
improv_ble:
authorizer: io0_button
status_indicator: built_in_led
on_provisioned:
- logger.log: provisioned
on_provisioning:
- logger.log: provisioning
on_start:
- logger.log: start
on_state:
- logger.log: state
on_stop:
- logger.log: stop
@@ -0,0 +1,12 @@
esphome:
name: improv-ble-esp32
esp32:
variant: esp32
framework:
type: esp-idf
logger:
wifi:
ssid: MySSID
password: password1
improv_ble:
authorizer: none
@@ -0,0 +1,10 @@
esphome:
name: improv-ble-esp8266
esp8266:
board: nodemcuv2
logger:
wifi:
ssid: MySSID
password: password1
improv_ble:
authorizer: none
@@ -0,0 +1,12 @@
esphome:
name: improv-ble-legacy-key
esp32:
variant: esp32
framework:
type: esp-idf
logger:
wifi:
ssid: MySSID
password: password1
esp32_improv:
authorizer: none
@@ -0,0 +1,59 @@
"""improv_ble is platform neutral; only its BLE server backends are not.
Covers the platform gate (BLE_SERVER_BACKENDS) and the esp32_improv alias that
keeps pre-rename configurations working.
"""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.config import read_config
from esphome.core import CORE
def test_esp32_generates_component(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(component_config_path("esp32.yaml"))
assert "improv_ble::ImprovBLEComponent" in main_cpp
def test_legacy_key_routes_to_improv_ble(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
caplog: pytest.LogCaptureFixture,
) -> None:
main_cpp = generate_main(component_config_path("legacy_key.yaml"))
assert "improv_ble::ImprovBLEComponent" in main_cpp
assert "'esp32_improv:' top-level key is deprecated" in caplog.text
def test_platform_without_ble_server_rejected(
component_config_path: Callable[[str], Path],
capsys: pytest.CaptureFixture[str],
) -> None:
# AUTO_LOAD finds no backend for esp8266 and pulls in improv_base only, so
# the platform gate in CONFIG_SCHEMA is what has to reject the config.
CORE.config_path = component_config_path("esp8266.yaml")
assert read_config({}) is None
assert "only available on" in capsys.readouterr().out
def test_automations_emit_renamed_triggers(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(component_config_path("automations.yaml"))
for trigger in (
"ImprovBLEProvisionedTrigger",
"ImprovBLEProvisioningTrigger",
"ImprovBLEStartTrigger",
"ImprovBLEStateTrigger",
"ImprovBLEStoppedTrigger",
):
assert f"improv_ble::{trigger}" in main_cpp
assert "set_authorizer" in main_cpp
assert "set_status_indicator" in main_cpp
@@ -0,0 +1,29 @@
---
esphome:
name: test
esp32:
board: esp32dev
output:
- platform: ledc
id: out_a
pin: GPIO4
- platform: ledc
id: out_b
pin: GPIO5
light:
- platform: monochromatic
id: plain_light
output: out_a
flash_transition_length: 0s
- platform: monochromatic
id: fancy_light
output: out_b
flash_transition_length: 500ms
effects:
- pulse:
- platform: monochromatic
id: bare_light
output: out_a
@@ -0,0 +1,19 @@
"""Tests that light codegen skips setters for default values."""
from collections.abc import Callable
from pathlib import Path
def test_default_flash_length_and_empty_effects_are_not_emitted(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""A 0 ms flash transition and an empty effect list match the C++ defaults."""
main_cpp = generate_main(component_config_path("transitions.yaml"))
assert "plain_light->set_flash_transition_length(" not in main_cpp
assert "plain_light->add_effects(" not in main_cpp
assert "bare_light->set_flash_transition_length(" not in main_cpp
assert "bare_light->add_effects(" not in main_cpp
assert "fancy_light->set_flash_transition_length(500);" in main_cpp
assert "fancy_light->add_effects({" in main_cpp
@@ -34,12 +34,13 @@ def test_trigger_codegen(
"set_manufacturer_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB,"
"0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp
)
# scan-control actions: templatable continuous lambda + parented actions.
# scan-control actions: templatable continuous lambda + stop_scan forwarded
# straight to the tracker (register_apply_action, no action class).
# Exactly one set_continuous: the bare start_scan emits none, pinning the
# restore-configured-mode divergence from esp32 against a future default=.
assert main_cpp.count("->set_continuous(") == 1
assert "startscanaction_id->set_continuous(" in main_cpp
assert "stopscanaction_id->set_parent(" in main_cpp
assert main_cpp.count("->stop_scan();") == 1
# scan_parameters continuous: false reaches the YAML-mode setter, not the
# runtime override.
assert "->set_configured_continuous(false)" in main_cpp
@@ -1,7 +1,13 @@
"""Tests for the logger component."""
from collections.abc import Callable
from pathlib import Path
import re
import pytest
from esphome.core import CORE
def test_logger_pre_setup_before_other_components(generate_main):
"""Logger::pre_setup() must be called before any other component is created.
@@ -52,3 +58,56 @@ def test_logger_pre_setup_before_other_components(generate_main):
f"Component allocation '{alloc.group()}' at position {alloc.start()} "
f"appears before logger pre_setup() at position {logger_pre_setup.start()}"
)
def test_default_uart_selection_is_not_emitted(generate_main):
"""UART0 is the C++ initializer on ESP8266, so the setter is skipped."""
main_cpp = generate_main("tests/component_tests/logger/test_logger.yaml")
assert "set_uart_selection(" not in main_cpp
def test_custom_uart_selection_is_emitted(generate_main):
"""A non default UART still reaches the setter before pre_setup()."""
main_cpp = generate_main("tests/component_tests/logger/test_logger_uart1.yaml")
assert "set_uart_selection(logger::UART_SELECTION_UART1);" in main_cpp
def test_libretiny_default_uart_selection_is_not_emitted(generate_main):
"""DEFAULT is the C++ initializer on LibreTiny, so the setter is skipped."""
main_cpp = generate_main(
"tests/component_tests/logger/test_logger_libretiny_default.yaml"
)
assert "set_uart_selection(" not in main_cpp
def test_libretiny_uart0_is_emitted(generate_main):
"""UART0 is not the LibreTiny initializer, so it must still be set."""
main_cpp = generate_main(
"tests/component_tests/logger/test_logger_libretiny_uart0.yaml"
)
assert "set_uart_selection(logger::UART_SELECTION_UART0);" in main_cpp
def test_ram_log_strings_is_deprecated(
generate_main: Callable[[str | Path], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""`false` is accepted but ignored: no RAM build flag, and a warning with the removal version."""
generate_main("tests/component_tests/logger/test_logger_ram_log_strings.yaml")
assert not any("STORE_LOG_STR" in flag for flag in CORE.build_flags)
assert "esp8266_store_log_strings_in_flash: false' is ignored" in caplog.text
assert "2027.4.0" in caplog.text
def test_flash_log_strings_default_does_not_warn(
generate_main: Callable[[str | Path], str],
caplog: pytest.LogCaptureFixture,
) -> None:
generate_main("tests/component_tests/logger/test_logger.yaml")
assert "esp8266_store_log_strings_in_flash" not in caplog.text
@@ -0,0 +1,8 @@
---
esphome:
name: test
rtl87xx:
board: generic-rtl8710bn-2mb-788k
logger:
@@ -0,0 +1,9 @@
---
esphome:
name: test
rtl87xx:
board: generic-rtl8710bn-2mb-788k
logger:
hardware_uart: UART0
@@ -0,0 +1,9 @@
---
esphome:
name: test
esp8266:
board: d1_mini_lite
logger:
esp8266_store_log_strings_in_flash: false
@@ -0,0 +1,9 @@
---
esphome:
name: test
esp8266:
board: d1_mini_lite
logger:
hardware_uart: UART1
@@ -0,0 +1,70 @@
"""request_service_enable_disable() only opts in on platforms whose mDNS stack
can add and remove services after setup, and tells the caller so."""
import pytest
from esphome.components import mdns
from esphome.const import CONF_DISABLED, PlatformFramework
from esphome.core import CORE
from tests.component_tests.types import SetCoreConfigCallable
DEFINE = "USE_MDNS_SUPPORTS_ENABLE_DISABLE"
def _defines() -> set[str]:
return {define.name for define in CORE.defines}
def _set_config(
set_core_config: SetCoreConfigCallable,
platform_framework: PlatformFramework,
config: dict,
) -> None:
set_core_config(platform_framework)
CORE.config = config
@pytest.mark.parametrize(
"platform_framework",
[PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_ARDUINO],
)
def test_esp32_adds_define_and_keeps_services_stored(
set_core_config: SetCoreConfigCallable, platform_framework: PlatformFramework
) -> None:
_set_config(set_core_config, platform_framework, {"mdns": {CONF_DISABLED: False}})
assert mdns.request_service_enable_disable() is True
# Disabled services must stay stored so they can be re-registered later.
assert {DEFINE, "USE_MDNS_STORE_SERVICES"} <= _defines()
@pytest.mark.parametrize(
"platform_framework",
[PlatformFramework.ESP8266_ARDUINO, PlatformFramework.RP2_ARDUINO],
)
def test_other_platforms_return_false(
set_core_config: SetCoreConfigCallable, platform_framework: PlatformFramework
) -> None:
_set_config(set_core_config, platform_framework, {"mdns": {CONF_DISABLED: False}})
assert mdns.request_service_enable_disable() is False
assert DEFINE not in _defines()
@pytest.mark.parametrize(
"config",
[
pytest.param({}, id="no_mdns"),
pytest.param({"mdns": {CONF_DISABLED: True}}, id="mdns_disabled"),
pytest.param(
{"mdns": {CONF_DISABLED: False}, "openthread": {}}, id="openthread"
),
],
)
def test_esp32_returns_false_when_services_cannot_be_toggled(
set_core_config: SetCoreConfigCallable, config: dict
) -> None:
_set_config(set_core_config, PlatformFramework.ESP32_IDF, config)
assert mdns.request_service_enable_disable() is False
assert DEFINE not in _defines()
+1 -2
View File
@@ -33,7 +33,6 @@ def test_server_schema_rejects_address_zero() -> None:
def test_client_schema_still_accepts_address_zero() -> None:
# Not rejected for clients today, but not supported either: a client broadcast gets no reply and
# stalls the hub for the full send-wait.
# A client may address 0: writes are broadcast, and reads are allowed with allow_broadcast_read.
schema = modbus.modbus_device_schema(0x01)
assert schema({CONF_MODBUS_ID: "hub", CONF_ADDRESS: 0})[CONF_ADDRESS] == 0
@@ -7,7 +7,7 @@ guard is a safety property: these tests pin it to every handler slot.
import pytest
from esphome import config_validation as cv
from esphome.components import modbus_client
from esphome.components import modbus, modbus_client
from esphome.components.modbus_client import (
CONF_ON_NO_RESPONSE,
CONF_ON_NOT_SENT,
@@ -126,7 +126,7 @@ def test_on_no_response_retry_lambda_accepted() -> None:
def test_continuous_on_write_pdu_rejected() -> None:
"""A literal write-code PDU with continuous: true is rejected at config time (reads only)."""
with pytest.raises(cv.Invalid, match="does not apply to a write PDU"):
with pytest.raises(cv.Invalid, match="does not apply to function code"):
MODBUS_CLIENT_SEND_SCHEMA(
{
CONF_ADDRESS: 0x01,
@@ -185,3 +185,145 @@ def test_multi_conf_no_default_is_set() -> None:
"""
assert modbus_client.MULTI_CONF is True
assert modbus_client.MULTI_CONF_NO_DEFAULT is True
@pytest.mark.parametrize("key", [CONF_CONTINUOUS, modbus.CONF_ALLOW_BROADCAST_READ])
def test_send_rejects_read_option_on_static_write_pdu(key: str) -> None:
# A read option set true on a static write PDU is refused at validation, naming the key.
config = {
CONF_ADDRESS: 1,
CONF_PDU: [0x06, 0x00, 0x10, 0x00, 0x01],
key: True,
}
with pytest.raises(
cv.Invalid, match=f"'{key}: true' does not apply to function code"
):
MODBUS_CLIENT_SEND_SCHEMA(config)
def test_send_accepts_allow_broadcast_read_on_read_pdu() -> None:
# allow_broadcast_read defaults to False and is accepted on a read PDU to address 0.
config = MODBUS_CLIENT_SEND_SCHEMA(
{CONF_ADDRESS: 0, CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x02]}
)
assert config[modbus.CONF_ALLOW_BROADCAST_READ] is False
config = MODBUS_CLIENT_SEND_SCHEMA(
{
CONF_ADDRESS: 0,
CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x02],
modbus.CONF_ALLOW_BROADCAST_READ: True,
}
)
assert config[modbus.CONF_ALLOW_BROADCAST_READ] is True
def test_send_rejects_write_option_on_static_read_pdu() -> None:
# The write-side option is refused on a static read PDU, the mirror of the read-option check.
key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE
with pytest.raises(
cv.Invalid, match=f"'{key}: true' does not apply to function code"
):
MODBUS_CLIENT_SEND_SCHEMA(
{CONF_ADDRESS: 0, CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x02], key: True}
)
def test_send_accepts_write_option_on_static_write_pdu() -> None:
config = MODBUS_CLIENT_SEND_SCHEMA(
{
CONF_ADDRESS: 0,
CONF_PDU: [0x06, 0x00, 0x10, 0x00, 0x01],
modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE: True,
}
)
assert config[modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE] is True
def test_write_actions_offer_write_option_only() -> None:
# Every write action takes expect_broadcast_write_response and none of the read options.
from esphome.components.modbus_client import (
_WRITE_MULTIPLE_COILS_SCHEMA,
_WRITE_MULTIPLE_REGISTERS_SCHEMA,
_WRITE_SINGLE_COIL_SCHEMA,
_WRITE_SINGLE_REGISTER_SCHEMA,
CONF_START_ADDRESS,
CONF_VALUE,
CONF_VALUES,
)
write_key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE
base = {CONF_ADDRESS: 0, CONF_START_ADDRESS: 0x10, write_key: True}
for schema, extra in (
(_WRITE_SINGLE_REGISTER_SCHEMA, {CONF_VALUE: 1}),
(_WRITE_SINGLE_COIL_SCHEMA, {CONF_VALUE: True}),
(_WRITE_MULTIPLE_REGISTERS_SCHEMA, {CONF_VALUES: [1, 2]}),
(_WRITE_MULTIPLE_COILS_SCHEMA, {CONF_VALUES: [True, False]}),
):
config = schema({**base, **extra})
assert config[write_key] is True
assert modbus.CONF_ALLOW_BROADCAST_READ not in config
with pytest.raises(cv.Invalid):
schema({**base, **extra, modbus.CONF_ALLOW_BROADCAST_READ: True})
def test_send_options_follow_the_hub_classification() -> None:
# A vendor code is broadcastable, so it takes the write-side flag and refuses the read-side one;
# 0x17 is a read for broadcast purposes, so the reverse holds.
write_key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE
read_key = modbus.CONF_ALLOW_BROADCAST_READ
assert MODBUS_CLIENT_SEND_SCHEMA(
{CONF_ADDRESS: 0, CONF_PDU: [0x41, 0x01], write_key: True}
)[write_key]
with pytest.raises(cv.Invalid, match=f"'{read_key}: true' does not apply"):
MODBUS_CLIENT_SEND_SCHEMA(
{CONF_ADDRESS: 0, CONF_PDU: [0x41, 0x01], read_key: True}
)
pdu_0x17 = [0x17, 0x00, 0x10, 0x00, 0x01, 0x00, 0x20, 0x00, 0x01, 0x02, 0x00, 0x01]
assert MODBUS_CLIENT_SEND_SCHEMA(
{CONF_ADDRESS: 0, CONF_PDU: pdu_0x17, read_key: True}
)[read_key]
with pytest.raises(cv.Invalid, match=f"'{write_key}: true' does not apply"):
MODBUS_CLIENT_SEND_SCHEMA(
{CONF_ADDRESS: 0, CONF_PDU: pdu_0x17, write_key: True}
)
def test_read_write_multiple_offers_allow_broadcast_read_only() -> None:
from esphome.components.modbus_client import (
_READ_WRITE_MULTIPLE_REGISTERS_SCHEMA,
CONF_READ_ADDRESS,
CONF_VALUES,
CONF_WRITE_ADDRESS,
)
config = _READ_WRITE_MULTIPLE_REGISTERS_SCHEMA(
{
CONF_ADDRESS: 0,
CONF_READ_ADDRESS: 0x10,
CONF_WRITE_ADDRESS: 0x20,
CONF_VALUES: [1],
modbus.CONF_ALLOW_BROADCAST_READ: True,
}
)
assert config[modbus.CONF_ALLOW_BROADCAST_READ] is True
assert CONF_CONTINUOUS not in config
assert modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE not in config
@pytest.mark.parametrize(
"key",
[modbus.CONF_ALLOW_BROADCAST_READ, modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE],
)
def test_broadcast_options_rejected_on_literal_unicast_address(key: str) -> None:
# A broadcast-only option on a literal non-zero address would be silently dropped by the hub.
if key == modbus.CONF_ALLOW_BROADCAST_READ:
pdu = [0x03, 0x00, 0x10, 0x00, 0x01]
else:
pdu = [0x06, 0x00, 0x10, 0x00, 0x01]
with pytest.raises(cv.Invalid, match="only applies to the broadcast address"):
MODBUS_CLIENT_SEND_SCHEMA({CONF_ADDRESS: 1, CONF_PDU: pdu, key: True})
# A templated address is not decidable at validation and passes through.
config = MODBUS_CLIENT_SEND_SCHEMA(
{CONF_ADDRESS: Lambda("return 1;"), CONF_PDU: pdu, key: True}
)
assert config[key] is True
@@ -0,0 +1,79 @@
"""A modbus_controller cannot poll the broadcast address (0) unless allow_broadcast_read says the
device answers it."""
import pytest
from esphome import config_validation as cv
from esphome.components import modbus
from esphome.components.modbus_controller import CONFIG_SCHEMA
from esphome.const import CONF_ADDRESS
from esphome.types import ConfigType
def _controller(address: int, **extra: object) -> ConfigType:
return CONFIG_SCHEMA({modbus.CONF_MODBUS_ID: "bus", CONF_ADDRESS: address, **extra})
def test_address_zero_rejected_by_default() -> None:
with pytest.raises(cv.Invalid, match="broadcast address"):
_controller(0)
def test_address_zero_accepted_with_allow_broadcast_read() -> None:
config = _controller(0, **{modbus.CONF_ALLOW_BROADCAST_READ: True})
assert config[CONF_ADDRESS] == 0
assert config[modbus.CONF_ALLOW_BROADCAST_READ] is True
def test_allow_broadcast_read_defaults_false() -> None:
assert _controller(1)[modbus.CONF_ALLOW_BROADCAST_READ] is False
def test_writer_entity_takes_expect_broadcast_write_response() -> None:
# The write-side option lives on the writing platforms, not the controller.
from esphome.components.modbus_controller.const import CONF_MODBUS_CONTROLLER_ID
from esphome.components.modbus_controller.switch import (
CONFIG_SCHEMA as SWITCH_SCHEMA,
)
from esphome.const import CONF_NAME
key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE
base = {
CONF_MODBUS_CONTROLLER_ID: "ctl",
CONF_NAME: "Switch",
"register_type": "coil",
CONF_ADDRESS: 0x20,
}
assert SWITCH_SCHEMA(base)[key] is False
assert SWITCH_SCHEMA({**base, CONF_NAME: "Switch 2", key: True})[key] is True
with pytest.raises(cv.Invalid):
CONFIG_SCHEMA({modbus.CONF_MODBUS_ID: "bus", CONF_ADDRESS: 1, key: True})
def test_allow_broadcast_read_requires_address_zero() -> None:
# The option only means something at address 0; elsewhere it would be silently inert.
with pytest.raises(cv.Invalid, match="only applies to the broadcast address"):
_controller(5, **{modbus.CONF_ALLOW_BROADCAST_READ: True})
def test_add_command_options_skips_defaults() -> None:
# The setter is only emitted when an option differs from its C++ default.
import esphome.codegen as cg
from esphome.const import CONF_CONTINUOUS
var = cg.MockObj("ctl")
emitted: list = []
original = cg.add
cg.add = emitted.append
try:
modbus.add_command_options(
var, "set_read_options", {CONF_CONTINUOUS: False}, direction="read"
)
assert emitted == []
modbus.add_command_options(
var, "set_read_options", {CONF_CONTINUOUS: True}, direction="read"
)
assert len(emitted) == 1
assert "set_read_options" in str(emitted[0])
finally:
cg.add = original
@@ -9,6 +9,7 @@ test cannot: a write-coded custom_pdu polled continuously is rejected there.
import pytest
from voluptuous import Invalid, MultipleInvalid
from esphome.components import modbus
from esphome.components.modbus_controller import (
ModbusItemBaseSchema,
validate_custom_pdu_item,
@@ -55,14 +56,21 @@ def test_custom_pdu_rejects_non_byte_values() -> None:
ModbusItemBaseSchema({CONF_CUSTOM_PDU: [0x0103, 0x002A]})
def _controller_full_config(*, continuous: bool) -> Config:
def _controller_full_config(
*, continuous: bool, allow_broadcast_read: bool = False
) -> Config:
"""A minimal full-config graph with one modbus_controller declaring id 'ctl', enough for the
final-validate to resolve the controller (and its continuous flag) from an item's
final-validate to resolve the controller (and its option flags) from an item's
modbus_controller_id."""
ctl_id = ID("ctl", is_declaration=True)
config = Config()
config["modbus_controller"] = [
{CONF_ID: ctl_id, CONF_ADDRESS: 1, CONF_CONTINUOUS: continuous}
{
CONF_ID: ctl_id,
CONF_ADDRESS: 0 if allow_broadcast_read else 1,
CONF_CONTINUOUS: continuous,
modbus.CONF_ALLOW_BROADCAST_READ: allow_broadcast_read,
}
]
config.declare_ids.append((ctl_id, ["modbus_controller", 0, CONF_ID]))
return config
@@ -98,3 +106,64 @@ def test_continuous_read_custom_pdu_allowed(reset_full_config) -> None:
CONF_CUSTOM_PDU: [0x03, 0x00, 0x2A, 0x00, 0x01],
}
)
def test_broadcastable_custom_pdu_rejected_under_broadcast_controller(
reset_full_config,
) -> None:
"""A vendor-coded custom_pdu under an allow_broadcast_read controller would be a real broadcast,
never answered, so it is rejected at final validate."""
fv.full_config.set(
_controller_full_config(continuous=False, allow_broadcast_read=True)
)
with pytest.raises(Invalid, match="is a real broadcast at address 0"):
validate_custom_pdu_item(
{
CONF_MODBUS_CONTROLLER_ID: ID("ctl"),
CONF_CUSTOM_PDU: [0x41, 0x00, 0x03],
}
)
def test_read_custom_pdu_allowed_under_broadcast_controller(reset_full_config) -> None:
"""A read-coded custom_pdu (0x03) is answered under allow_broadcast_read, so it is fine."""
fv.full_config.set(
_controller_full_config(continuous=False, allow_broadcast_read=True)
)
validate_custom_pdu_item(
{
CONF_MODBUS_CONTROLLER_ID: ID("ctl"),
CONF_CUSTOM_PDU: [0x03, 0x00, 0x2A, 0x00, 0x01],
}
)
def test_write_option_rejected_under_unicast_controller(reset_full_config) -> None:
"""expect_broadcast_write_response on a writer entity whose controller is not at address 0 is
rejected at final validate, where the controller's address is known."""
from esphome.components.modbus_controller import validate_writer_item
fv.full_config.set(_controller_full_config(continuous=False))
with pytest.raises(
Invalid, match="only applies when the 'ctl' modbus_controller is at address 0"
):
validate_writer_item(
{
CONF_MODBUS_CONTROLLER_ID: ID("ctl"),
modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE: True,
}
)
def test_write_option_allowed_under_broadcast_controller(reset_full_config) -> None:
from esphome.components.modbus_controller import validate_writer_item
fv.full_config.set(
_controller_full_config(continuous=False, allow_broadcast_read=True)
)
validate_writer_item(
{
CONF_MODBUS_CONTROLLER_ID: ID("ctl"),
modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE: True,
}
)
@@ -0,0 +1,28 @@
---
esphome:
name: test
esp32:
board: esp32dev
number:
- platform: template
id: auto_number
min_value: 0
max_value: 10
step: 1
optimistic: true
- platform: template
id: box_number
min_value: 0
max_value: 10
step: 1
mode: box
optimistic: true
- platform: template
id: explicit_auto_number
min_value: 0
max_value: 10
step: 1
mode: auto
optimistic: true
@@ -0,0 +1,16 @@
"""Tests for the number component codegen."""
from collections.abc import Callable
from pathlib import Path
def test_default_mode_is_not_emitted(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Mode auto is the C++ initializer, so only a non default mode is set."""
main_cpp = generate_main(component_config_path("mode.yaml"))
assert "auto_number->traits.set_mode(" not in main_cpp
assert "explicit_auto_number->traits.set_mode(" not in main_cpp
assert "box_number->traits.set_mode(number::NUMBER_MODE_BOX);" in main_cpp
+36 -16
View File
@@ -319,6 +319,32 @@ def test_encryption_with_captive_portal_does_not_warn(
fv.full_config.reset(token)
@pytest.mark.parametrize("extra", [{}, {"prometheus": {}}])
def test_encryption_with_web_server_ota_disabled_does_not_warn(
caplog: pytest.LogCaptureFixture, extra: dict[str, Any]
) -> None:
"""web_server `ota: false` only serves /update while the captive portal is
active, on every listener, so there is no plaintext endpoint to warn about."""
full_conf = {
"web_server": {CONF_OTA: False},
**extra,
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(
"OTA encryption does not cover" in record.message
for record in caplog.records
)
finally:
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."""
@@ -450,43 +476,36 @@ def test_static_encryption_key() -> None:
("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
# the ota keeps its own pointer to the key so safe mode, which never
# constructs the api server, can still use it
(
"api_key_offer",
{"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_FROM_API"},
{"USE_OTA_ENCRYPTION"},
{"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", "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", "USE_OTA_ENCRYPTION_REQUIRED"},
{"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"},
{"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", "USE_OTA_ENCRYPTION_PROVISIONED"},
{"USE_OTA_ENCRYPTION_REQUIRED"},
),
# No api encryption at all keeps the noise glue out of the build
@@ -496,7 +515,6 @@ def test_static_encryption_key() -> None:
{
"USE_OTA_ENCRYPTION",
"USE_OTA_ENCRYPTION_REQUIRED",
"USE_OTA_ENCRYPTION_FROM_API",
"USE_OTA_ENCRYPTION_PROVISIONED",
},
),
@@ -515,8 +533,10 @@ def test_encryption_offer_codegen(
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
own_key = encrypted and "USE_OTA_ENCRYPTION_PROVISIONED" not in defines_present
assert ("esphome_esphomeotacomponent_id->set_noise_psk(" in main_cpp) is own_key
# The api shares the ota's array instead of emitting the same key twice
assert main_cpp.count("_psk[] PROGMEM") == (1 if own_key else 0)
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"])
@@ -0,0 +1,13 @@
---
esphome:
name: test
esp32:
board: esp32dev
output:
- platform: ac_dimmer
id: dimmer
gate_pin: GPIO4
zero_cross_pin: GPIO5
min_power: 0%
@@ -0,0 +1,18 @@
---
esphome:
name: test
esp32:
board: esp32dev
output:
- platform: ledc
id: default_power
pin: GPIO4
max_power: 100%
min_power: 0%
- platform: ledc
id: custom_power
pin: GPIO5
max_power: 90%
min_power: 1%
@@ -0,0 +1,31 @@
"""Tests for the output platform codegen."""
from collections.abc import Callable
from pathlib import Path
from esphome.core import CORE
def test_default_power_limits_are_not_emitted(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""max_power 100% and min_power 0% already live in the C++ initializers."""
main_cpp = generate_main(component_config_path("power_limits.yaml"))
assert "default_power->set_max_power(" not in main_cpp
assert "default_power->set_min_power(" not in main_cpp
assert "custom_power->set_max_power(0.9f);" in main_cpp
assert "custom_power->set_min_power(0.01f);" in main_cpp
assert "USE_OUTPUT_FLOAT_POWER_SCALING" in {d.name for d in CORE.defines}
def test_default_min_power_keeps_scaling_fields(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""ac_dimmer reads min_power_ directly, so the define must stay on for min_power 0%."""
main_cpp = generate_main(component_config_path("ac_dimmer_min_power_zero.yaml"))
assert "dimmer->set_min_power(" not in main_cpp
assert "USE_OUTPUT_FLOAT_POWER_SCALING" in {d.name for d in CORE.defines}
@@ -0,0 +1,9 @@
esphome:
name: test
bk72xx:
board: generic-bk7252
remote_receiver:
- id: rcvr
pin: P6
@@ -0,0 +1,12 @@
esphome:
name: test
esp32:
board: esp32-c61-devkitc1
variant: esp32c61
framework:
type: esp-idf
remote_receiver:
- id: rcvr
pin: GPIO4
@@ -0,0 +1,9 @@
esphome:
name: test
ln882x:
board: generic-ln882h
remote_receiver:
- id: rcvr
pin: PA4
@@ -0,0 +1,9 @@
esphome:
name: test
rp2:
board: rpipicow
remote_receiver:
- id: rcvr
pin: GPIO4
@@ -0,0 +1,9 @@
esphome:
name: test
rtl87xx:
board: generic-rtl8710bn-2mb-788k
remote_receiver:
- id: rcvr
pin: PA12
@@ -0,0 +1,36 @@
esphome:
name: test
esp32:
board: esp32dev
logger:
external_components:
- source:
type: local
path: ../external_components
fake_protocol:
remote_receiver:
- id: rcvr
pin: GPIO4
dump:
- fake
- nec
on_fake:
then:
- remote_transmitter.transmit_fake:
on_nec:
then:
- logger.log: nec
remote_transmitter:
pin: GPIO5
carrier_duty_percent: 50%
binary_sensor:
- platform: remote_receiver
name: Fake Input
fake:
@@ -0,0 +1,39 @@
"""External component registering a protocol that has no source file in remote_base."""
import esphome.codegen as cg
from esphome.components import remote_base
import esphome.config_validation as cv
from esphome.types import ConfigType
DEPENDENCIES = ["remote_base"]
ns = cg.esphome_ns.namespace("fake_protocol")
FakeData = ns.struct("FakeData")
FakeBinarySensor = ns.class_(
"FakeBinarySensor", remote_base.RemoteReceiverBinarySensorBase
)
FakeTrigger = ns.class_("FakeTrigger", remote_base.RemoteReceiverTrigger)
FakeAction = ns.class_("FakeAction", remote_base.RemoteTransmitterActionBase)
FakeDumper = ns.class_("FakeDumper", remote_base.RemoteReceiverDumperBase)
CONFIG_SCHEMA = cv.Schema({})
@remote_base.register_binary_sensor("fake", FakeBinarySensor, {})
def fake_binary_sensor(var: cg.MockObj, config: ConfigType) -> None:
pass
@remote_base.register_trigger("fake", FakeTrigger, FakeData)
def fake_trigger(var: cg.MockObj, config: ConfigType) -> None:
pass
@remote_base.register_dumper("fake", FakeDumper)
def fake_dumper(var: cg.MockObj, config: ConfigType) -> None:
pass
@remote_base.register_action("fake", FakeAction, {})
async def fake_action(var: cg.MockObj, config: ConfigType, args: list) -> None:
pass
@@ -1,8 +1,16 @@
"""buffer_size reaches the receiver when set, and always on the pulse ring targets."""
"""buffer_size is bytes on the pulse ring targets and only reaches RMT targets when set."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.components import remote_receiver
from esphome.components.esp8266 import gpio as esp8266_gpio # noqa: F401 registers the pin schema
from esphome.config_validation import Invalid
from esphome.const import PlatformFramework
from tests.component_tests.types import SetCoreConfigCallable
def test_explicit_buffer_size_is_passed_through(
generate_main: Callable[[str | Path], str],
@@ -12,17 +20,29 @@ def test_explicit_buffer_size_is_passed_through(
assert "rcvr->set_buffer_size(2000);" in main_cpp
def test_pulse_ring_target_keeps_a_default(
@pytest.mark.parametrize(
"target", ["esp8266", "rp2", "bk72xx", "rtl87xx", "ln882x", "esp32_c2", "esp32_c61"]
)
def test_pulse_ring_default_holds_1000_pulses(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
target: str,
) -> None:
main_cpp = generate_main(component_config_path("receiver_esp8266.yaml"))
assert "rcvr->set_buffer_size(1000);" in main_cpp
main_cpp = generate_main(component_config_path(f"receiver_{target}.yaml"))
assert "rcvr->set_buffer_size(4000);" in main_cpp
def test_esp32_variant_without_rmt_keeps_a_default(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
@pytest.mark.parametrize(
("value", "expected"),
[("32b", None), ("64b", 64), ("65b", 65), ("65535b", 65535), ("65536b", None)],
)
def test_buffer_size_range(
set_core_config: SetCoreConfigCallable, value: str, expected: int | None
) -> None:
main_cpp = generate_main(component_config_path("receiver_esp32_c2.yaml"))
assert "rcvr->set_buffer_size(1000);" in main_cpp
set_core_config(PlatformFramework.ESP8266_ARDUINO)
config = {"pin": "GPIO4", "buffer_size": value}
if expected is None:
with pytest.raises(Invalid):
remote_receiver.CONFIG_SCHEMA(config)
else:
assert remote_receiver.CONFIG_SCHEMA(config)["buffer_size"] == expected
@@ -1,13 +1,16 @@
"""Listener and dumper StaticVector sizes come from codegen slot counts."""
from collections.abc import Callable
from collections.abc import Callable, Generator
from pathlib import Path
import sys
import pytest
from esphome import loader
from esphome.automation import ACTION_REGISTRY
from esphome.components import remote_base
import esphome.config_validation as cv
from esphome.core import CORE
from ..helpers import get_define_value
@@ -74,6 +77,47 @@ def test_every_registry_name_maps_to_a_protocol_source() -> None:
assert remote_base._protocol_stem(name) in remote_base._PROTOCOL_STEMS, name
@pytest.fixture
def restore_protocol_registries() -> Generator[None]:
"""Loading an external protocol component adds to module-level registries; undo that.
The loader caches the component too, so drop it or a second load would skip the
decorators and leave the restored registries without the external names.
"""
registries = (
remote_base.BINARY_SENSOR_REGISTRY,
remote_base.TRIGGER_REGISTRY,
remote_base.DUMPER_REGISTRY,
ACTION_REGISTRY,
)
saved = [dict(registry) for registry in registries]
yield
for registry, entries in zip(registries, saved, strict=True):
registry.clear()
registry.update(entries)
loader._COMPONENT_CACHE.pop("fake_protocol", None)
sys.modules.pop("esphome.components.fake_protocol", None)
@pytest.mark.usefixtures("restore_protocol_registries")
def test_external_protocols_register_without_a_remote_base_source(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""An external protocol goes through all four decorators without a source file here, so no define is emitted."""
main_cpp = generate_main(
component_config_path("receiver_with_external_protocol.yaml")
)
defines = {define.name for define in CORE.defines}
assert "USE_REMOTE_PROTOCOL_NEC" in defines
assert "USE_REMOTE_PROTOCOL_FAKE" not in defines
for cls in ("FakeBinarySensor", "FakeTrigger", "FakeDumper", "FakeAction"):
assert f"fake_protocol::{cls}" in main_cpp, cls
# fake and nec dumpers; on_fake and on_nec triggers plus the fake binary sensor
assert get_define_value("REMOTE_BASE_DUMPER_COUNT") == "2"
assert get_define_value("REMOTE_BASE_LISTENER_COUNT") == "3"
def test_request_protocol_rejects_unknown_names() -> None:
"""A misspelled protocol would otherwise surface only as a link error."""
with pytest.raises(ValueError, match="Unknown remote protocol 'toshiba'"):
@@ -37,12 +37,13 @@ def test_trigger_codegen(
"set_manufacturer_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB,"
"0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp
)
# scan-control actions: templatable continuous lambda + parented actions.
# scan-control actions: templatable continuous lambda + stop_scan forwarded
# straight to the tracker (register_apply_action, no action class).
# Exactly one set_continuous: the bare start_scan emits none, pinning the
# restore-configured-mode divergence from esp32 against a future default=.
assert main_cpp.count("->set_continuous(") == 1
assert "startscanaction_id->set_continuous(" in main_cpp
assert "stopscanaction_id->set_parent(" in main_cpp
assert main_cpp.count("->stop_scan();") == 1
# scan_parameters continuous: false reaches the YAML-mode setter, not the
# runtime override.
assert "->set_configured_continuous(false)" in main_cpp
@@ -0,0 +1,18 @@
---
esphome:
name: test
esp32:
board: esp32dev
logger:
switch:
- platform: template
id: plain_switch
turn_on_action:
- logger.log: "on"
- platform: template
id: enabled_switch
optimistic: true
assumed_state: true
@@ -0,0 +1,17 @@
"""Tests for the template switch codegen."""
from collections.abc import Callable
from pathlib import Path
def test_default_flags_are_not_emitted(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Only true optimistic and assumed_state are set; false is the C++ initializer."""
main_cpp = generate_main(component_config_path("switch_defaults.yaml"))
assert "plain_switch->set_optimistic(" not in main_cpp
assert "plain_switch->set_assumed_state(" not in main_cpp
assert "enabled_switch->set_optimistic(true);" in main_cpp
assert "enabled_switch->set_assumed_state(true);" in main_cpp
@@ -0,0 +1,42 @@
"""Tests for the uart_mux component's final validation."""
import pytest
from esphome import config_validation as cv
from esphome.const import CONF_ID, PlatformFramework
from esphome.core import ID
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
CONF_BRIDGE_ID = "bridge_id"
def _set_esp32_s3(set_core_config: SetCoreConfigCallable) -> None:
from esphome.components.esp32 import KEY_VARIANT, VARIANT_ESP32S3
set_core_config(
PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32S3}
)
def _mux_conf(mux_id: str, bridge_id: str) -> ConfigType:
return {CONF_ID: ID(mux_id), CONF_BRIDGE_ID: ID(bridge_id)}
def test_accepts_one_mux_per_bridge(set_core_config: SetCoreConfigCallable) -> None:
_set_esp32_s3(set_core_config)
from esphome.components import uart_mux
uart_mux._final_validate(_mux_conf("mux_0", "bridge_0"))
uart_mux._final_validate(_mux_conf("mux_1", "bridge_1"))
def test_rejects_two_muxes_on_one_bridge(
set_core_config: SetCoreConfigCallable,
) -> None:
_set_esp32_s3(set_core_config)
from esphome.components import uart_mux
uart_mux._final_validate(_mux_conf("mux_0", "bridge_0"))
with pytest.raises(cv.Invalid, match="already routed by another 'uart_mux'"):
uart_mux._final_validate(_mux_conf("mux_1", "bridge_0"))
@@ -0,0 +1,12 @@
---
esphome:
name: test
esp32:
board: esp32dev
wifi:
ssid: test
password: testtest
web_server:
@@ -0,0 +1,15 @@
---
esphome:
name: test
esp32:
board: esp32dev
wifi:
ssid: test
password: testtest
web_server:
port: 8080
log: false
include_internal: true
@@ -0,0 +1,15 @@
---
esphome:
name: test
esp32:
board: esp32dev
wifi:
ssid: test
password: testtest
web_server:
port: 80
log: true
include_internal: false
@@ -0,0 +1,35 @@
"""Tests that web_server only emits setters for non default values."""
from collections.abc import Callable
from pathlib import Path
import pytest
@pytest.mark.parametrize("config_file", ["bare.yaml", "defaults.yaml"])
def test_default_values_are_not_emitted(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_file: str,
) -> None:
"""Port 80, log on and include_internal off already live in the C++ initializers.
Both the schema defaults and the same values written explicitly take the skip path.
"""
main_cpp = generate_main(component_config_path(config_file))
assert "set_port(" not in main_cpp
assert "set_expose_log(" not in main_cpp
assert "set_include_internal(" not in main_cpp
def test_custom_values_are_emitted(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Non default values still reach the C++ setters."""
main_cpp = generate_main(component_config_path("custom.yaml"))
assert "set_port(8080);" in main_cpp
assert "set_expose_log(false);" in main_cpp
assert "set_include_internal(true);" in main_cpp
@@ -0,0 +1,12 @@
---
esphome:
name: test
esp8266:
board: d1_mini
wifi:
ssid: test
password: testtest
ap:
ssid: fallback
@@ -0,0 +1,18 @@
---
esphome:
name: test
esp8266:
board: d1_mini
wifi:
networks:
- ssid: test
password: testtest
priority: 5
ap:
ssid: fallback
ap_timeout: 2min
reboot_timeout: 0s
power_save_mode: light
min_auth_mode: wpa
@@ -0,0 +1,18 @@
---
esphome:
name: test
esp8266:
board: d1_mini
wifi:
networks:
- ssid: test
password: testtest
priority: 0
ap:
ssid: fallback
ap_timeout: 90s
reboot_timeout: 15min
power_save_mode: none
min_auth_mode: wpa2
@@ -0,0 +1,39 @@
"""Tests that wifi codegen skips setters for default values."""
from collections.abc import Callable
from pathlib import Path
import pytest
@pytest.mark.parametrize("config_file", ["bare.yaml", "defaults.yaml"])
def test_default_values_are_not_emitted(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_file: str,
) -> None:
"""Priority 0, 90 s AP timeout, 15 min reboot, power save none, WPA2 are C++ defaults.
Both the schema defaults and the same values written explicitly take the skip path.
"""
main_cpp = generate_main(component_config_path(config_file))
assert "set_priority(" not in main_cpp
assert "set_ap_timeout(" not in main_cpp
assert "set_reboot_timeout(" not in main_cpp
assert "set_power_save_mode(" not in main_cpp
assert "set_min_auth_mode(" not in main_cpp
def test_custom_values_are_emitted(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Non default values still reach the C++ setters."""
main_cpp = generate_main(component_config_path("custom.yaml"))
assert "set_priority(5);" in main_cpp
assert "set_ap_timeout(120000);" in main_cpp
assert "set_reboot_timeout(0);" in main_cpp
assert "set_power_save_mode(wifi::WIFI_POWER_SAVE_LIGHT);" in main_cpp
assert "set_min_auth_mode(wifi::WIFI_MIN_AUTH_MODE_WPA);" in main_cpp
@@ -0,0 +1,46 @@
"""Tests for wifi.force_power_save_off(), the hook platforms use to keep the
station out of power save."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.components import wifi
from esphome.core import CORE, EsphomeError
def test_reasons_accumulate_without_duplicates() -> None:
"""Every caller's reason is kept once; a repeated reason is not duplicated."""
wifi.force_power_save_off("first")
wifi.force_power_save_off("first")
wifi.force_power_save_off("second")
assert CORE.data[wifi.POWER_SAVE_OFF_REASONS_KEY] == ["first", "second"]
def test_forced_off_skips_the_setter_and_warns(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
caplog: pytest.LogCaptureFixture,
) -> None:
"""With a reason recorded, power_save_mode is reported and not applied."""
wifi.force_power_save_off("the platform cannot sleep")
main_cpp = generate_main(component_config_path("custom.yaml"))
assert "set_power_save_mode(" not in main_cpp
assert (
"power_save_mode LIGHT is not applied: the platform cannot sleep" in caplog.text
)
def test_call_after_wifi_codegen_raises(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Once wifi has generated its code the hook cannot take effect any more."""
generate_main(component_config_path("custom.yaml"))
with pytest.raises(EsphomeError, match="before wifi generates its code"):
wifi.force_power_save_off("too late")
+1
View File
@@ -4,3 +4,4 @@ output:
gate_pin: ${gate_pin}
zero_cross_pin: ${zero_cross_pin}
zero_cross_interrupt_type: ANY
min_power: 0%
+3
View File
@@ -5,7 +5,10 @@ esphome:
- audio_dac.mute_on:
- audio_dac.set_volume:
volume: 50%
- audio_dac.set_volume:
volume: !lambda return id(aic3204_dac).volume() * 0.5f;
audio_dac:
- platform: aic3204
id: aic3204_dac
i2c_id: i2c_bus
@@ -67,3 +67,28 @@ alarm_control_panel:
on_cleared:
then:
- logger.log: "### CLEARED ###"
button:
# Exercise the arm/disarm actions with a constant code, a lambda code and no code.
- platform: template
name: Alarm Panel Actions
on_press:
- alarm_control_panel.arm_away:
id: alarmcontrolpanel1
code: "1234"
- alarm_control_panel.arm_home:
id: alarmcontrolpanel1
code: !lambda |-
std::string code = "56";
code += "78";
return code;
- alarm_control_panel.arm_night:
id: alarmcontrolpanel1
code: "0000"
- alarm_control_panel.disarm:
id: alarmcontrolpanel1
code: "1234"
- alarm_control_panel.arm_away: alarmcontrolpanel2
- alarm_control_panel.arm_home: alarmcontrolpanel2
- alarm_control_panel.arm_night: alarmcontrolpanel2
- alarm_control_panel.disarm: alarmcontrolpanel2
@@ -37,6 +37,27 @@ binary_sensor:
format: "New state is %s"
args: ['x.has_value() ? ONOFF(x) : "Unknown"']
- binary_sensor.invalidate_state: binary_sensor_some_binary_sensor
- binary_sensor.template.publish:
id: binary_sensor_publish_target
state: true
- binary_sensor.template.publish:
id: binary_sensor_publish_target
state: !lambda "return x.value_or(false);"
- if:
condition:
binary_sensor.is_on: binary_sensor_publish_target
then:
- logger.log: "publish_target is on"
- if:
condition:
binary_sensor.is_off:
id: binary_sensor_publish_target
then:
- logger.log: "publish_target is off"
- platform: template
id: binary_sensor_publish_target
name: "Publish Target"
# Test autorepeat with default configuration (no timings)
- platform: template
@@ -0,0 +1,9 @@
# A wifi power_save_mode other than NONE is forced off with a warning while
# bk72xx_ble is configured (esphome#18592); this config must still validate.
packages:
bk72xx_ble: !include common.yaml
wifi:
ssid: MySSID
password: password1
power_save_mode: high
@@ -6,15 +6,14 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
# close_service_batch compiles only under USE_BLUETOOTH_PROXY_CONNECTIONS;
# emit the backend define so the host build exercises it.
async def to_code_testing(config):
# These defines are global to the merged host test binary; safe
# because no co-compiled test observes them.
# These defines are global to the merged host test binary. The api sources are
# compiled in it too (the api tests define USE_API), and USE_BLUETOOTH_PROXY would make
# them include and call bluetooth_proxy, which has no host build without a BLE hub.
cg.add_define("USE_BLE_GATT_CLIENT")
cg.add_define("USE_BLE_GATT_CLIENT_STUB_BACKEND")
cg.add_define("USE_BLUETOOTH_PROXY")
# Gates the connection half of the API surface, which is what
# close_service_batch and the GATT response types live behind.
cg.add_define("USE_BLUETOOTH_PROXY_CONNECTIONS")
cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16)
cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 1)
manifest.to_code = to_code_testing
+18 -2
View File
@@ -56,8 +56,8 @@ climate:
default_target_temperature_high: 22°C
button:
# Exercise the climate.control: action so ControlAction templates get
# build coverage. Various field combinations are tested.
# Exercise the climate.control: action so the register_apply_action codegen
# gets build coverage. Various field combinations are tested.
- platform: template
name: "Climate Control Mode"
on_press:
@@ -84,6 +84,22 @@ button:
- climate.control:
id: climate_test_thermostat
mode: "OFF"
# Custom strings take the (const char *, size_t) overload; the byte length of
# the non-ASCII value is computed at codegen time.
- platform: template
name: "Climate Control Custom Strings"
on_press:
- climate.control:
id: climate_test_thermostat
custom_fan_mode: "Über"
custom_preset: Default
- platform: template
name: "Climate Control Custom Lambdas"
on_press:
- climate.control:
id: climate_test_thermostat
custom_fan_mode: !lambda return "quiet";
custom_preset: !lambda return "Default";
# Exercise climate.control inside a trigger with non-empty Ts (number on_value
# passes float).
+6
View File
@@ -0,0 +1,6 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# ClimateIR derives from climate::Climate without declaring it as a dependency.
manifest.dependencies = manifest.dependencies + ["climate"]
@@ -0,0 +1,83 @@
#include <gtest/gtest.h>
#include "esphome/components/climate_ir/climate_ir.h"
namespace esphome::climate_ir::testing {
class TestClimateIR : public ClimateIR {
public:
explicit TestClimateIR(bool supports_dry = false, bool supports_fan_only = false)
: ClimateIR(16.0f, 30.0f, 1.0f, supports_dry, supports_fan_only) {}
using ClimateIR::traits;
protected:
void transmit_state() override {}
};
// The HEAT_COOL default is covered in tests/component_tests/climate_ir.
TEST(ClimateIRTest, HeatCoolAdvertisedWhenSupported) {
TestClimateIR climate;
climate.set_supports_heat(true);
climate.set_supports_cool(true);
climate.set_supports_heat_cool(true);
EXPECT_TRUE(climate.traits().supports_mode(climate::CLIMATE_MODE_HEAT_COOL));
}
TEST(ClimateIRTest, HeatCoolNotAdvertisedWhenUnsupported) {
TestClimateIR climate;
climate.set_supports_heat(true);
climate.set_supports_cool(true);
climate.set_supports_heat_cool(false);
EXPECT_FALSE(climate.traits().supports_mode(climate::CLIMATE_MODE_HEAT_COOL));
}
TEST(ClimateIRTest, HeatCoolAdvertisedForCoolOnlyDeviceThatSupportsIt) {
TestClimateIR climate;
climate.set_supports_heat(false);
climate.set_supports_cool(true);
climate.set_supports_heat_cool(true);
auto traits = climate.traits();
EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_HEAT_COOL));
EXPECT_FALSE(traits.supports_mode(climate::CLIMATE_MODE_HEAT));
}
TEST(ClimateIRTest, HeatAndCoolModesFollowTheirOwnFlags) {
TestClimateIR climate;
climate.set_supports_heat(false);
climate.set_supports_cool(true);
climate.set_supports_heat_cool(false);
auto traits = climate.traits();
EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_COOL));
EXPECT_FALSE(traits.supports_mode(climate::CLIMATE_MODE_HEAT));
EXPECT_FALSE(traits.supports_mode(climate::CLIMATE_MODE_HEAT_COOL));
EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_OFF));
}
TEST(ClimateIRTest, DefaultModes) {
TestClimateIR climate;
auto traits = climate.traits();
EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_OFF));
EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_COOL));
EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_HEAT));
EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_HEAT_COOL));
EXPECT_FALSE(traits.supports_mode(climate::CLIMATE_MODE_DRY));
EXPECT_FALSE(traits.supports_mode(climate::CLIMATE_MODE_FAN_ONLY));
}
TEST(ClimateIRTest, DryAndFanOnlyFromConstructor) {
TestClimateIR climate(true, true);
auto traits = climate.traits();
EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_DRY));
EXPECT_TRUE(traits.supports_mode(climate::CLIMATE_MODE_FAN_ONLY));
}
TEST(ClimateIRTest, SetterCanTurnAModeBackOn) {
TestClimateIR climate;
climate.set_supports_cool(false);
EXPECT_FALSE(climate.traits().supports_mode(climate::CLIMATE_MODE_COOL));
climate.set_supports_cool(true);
EXPECT_TRUE(climate.traits().supports_mode(climate::CLIMATE_MODE_COOL));
}
} // namespace esphome::climate_ir::testing
+40
View File
@@ -1,3 +1,43 @@
esphome:
on_boot:
then:
- datetime.date.set:
id: datetime_test_date
date:
year: 2021
month: 1
day: 1
- datetime.date.set:
id: datetime_test_date
date: !lambda "return {.day_of_month = 1, .month = 1, .year = 2021};"
- datetime.time.set:
id: datetime_test_time
time: "12:34:56"
- datetime.time.set:
id: datetime_test_time
time: !lambda "return {.second = 56, .minute = 34, .hour = 12};"
- datetime.datetime.set:
id: datetime_test_datetime
datetime: "2021-01-01 12:34:56"
- datetime.datetime.set:
id: datetime_test_datetime
datetime: !lambda "return {.second = 56, .minute = 34, .hour = 12, .day_of_month = 1, .month = 1, .year = 2021};"
datetime:
- platform: template
name: Datetime Test Date
id: datetime_test_date
type: date
optimistic: true
- platform: template
name: Datetime Test Time
id: datetime_test_time
type: time
optimistic: true
- platform: template
name: Datetime Test DateTime
id: datetime_test_datetime
type: datetime
optimistic: true
time:
+21
View File
@@ -1,20 +1,32 @@
esphome:
on_boot:
then:
- dfplayer.play_mp3: 3
- dfplayer.play_mp3: !lambda "return id(dfplayer_test).is_playing() ? 2 : 1;"
- dfplayer.play: 5
- dfplayer.play:
file: 4
loop: true
- dfplayer.play:
file: !lambda "return id(dfplayer_test).is_playing() ? 2 : 1;"
loop: !lambda return !id(dfplayer_test).is_playing();
- dfplayer.play_folder:
folder: 1
file: 3
- dfplayer.play_folder:
folder: 1
loop: true
- dfplayer.play_folder:
folder: !lambda "return id(dfplayer_test).is_playing() ? 2 : 1;"
file: !lambda "return id(dfplayer_test).is_playing() ? 4 : 3;"
loop: !lambda return !id(dfplayer_test).is_playing();
- dfplayer.set_device:
device: TF_CARD
- dfplayer.set_device: USB
- dfplayer.set_volume: 5
- dfplayer.set_volume: !lambda "return id(dfplayer_test).is_playing() ? 10 : 5;"
- dfplayer.set_eq: ROCK
- dfplayer.set_eq: !lambda "return id(dfplayer_test).is_playing() ? dfplayer::JAZZ : dfplayer::BASS;"
- dfplayer.play_next
- dfplayer.play_previous
- dfplayer.reset
@@ -25,8 +37,17 @@ esphome:
- dfplayer.volume_up
- dfplayer.volume_down
- dfplayer.sleep
- dfplayer.set_current_track_repeat
- dfplayer.set_current_track_repeat: true
- dfplayer.set_current_track_repeat:
enable: false
- dfplayer.set_current_track_repeat:
enable: !lambda return true;
- dfplayer.set_current_track_repeat:
id: dfplayer_test
dfplayer:
id: dfplayer_test
on_finished_playback:
then:
if:
@@ -22,6 +22,7 @@ esp32:
disable_regi2c_in_iram: true
disable_fatfs: true
sram1_as_iram: true
flash_chip: gd
watchdog_timeout: 7s
wifi:
@@ -9,6 +9,7 @@ esp32:
type: esp-idf
advanced:
execute_from_psram: true
flash_chip: gd
disable_libc_locks_in_iram: true # Test default RAM optimization enabled
disable_debug_stubs: true
disable_ocd_aware: true
@@ -0,0 +1,12 @@
# No wifi, espnow or BLE: nothing else re-includes esp_wifi for the ESP-NOW shim.
esp32_hosted:
variant: ESP32C6
slot: 1
active_high: true
reset_pin: GPIO15
cmd_pin: GPIO13
clk_pin: GPIO12
d0_pin: GPIO11
d1_pin: GPIO10
d2_pin: GPIO9
d3_pin: GPIO8
+10
View File
@@ -1,3 +1,13 @@
esphome:
on_boot:
then:
- output.esp8266_pwm.set_frequency:
id: out
frequency: 100Hz
- output.esp8266_pwm.set_frequency:
id: out2
frequency: !lambda return 200.0f;
output:
- platform: esp8266_pwm
id: out
+7
View File
@@ -38,8 +38,15 @@ espnow:
data: !lambda 'return {0x01, 0x02, 0x03, 0x04, 0x05};'
- espnow.peer.add:
address: 11:22:33:44:55:66
- espnow.peer.add:
address: !lambda 'return {0x11, 0x22, 0x33, 0x44, 0x55, 0x66};'
- espnow.peer.delete:
address: 11:22:33:44:55:66
- espnow.peer.delete:
address: !lambda 'return {0x11, 0x22, 0x33, 0x44, 0x55, 0x66};'
- espnow.set_channel: 6
- espnow.set_channel:
channel: !lambda 'return 6;'
on_broadcast:
- logger.log:
format: "Broadcast from: %s = '%s' RSSI: %d"
+12
View File
@@ -18,3 +18,15 @@ event:
// Log using %.*s format for StringRef
ESP_LOGD("test", "Event type: %.*s", (int) event_type.size(), event_type.c_str());
}
button:
- platform: template
name: Trigger Event
on_press:
- event.trigger:
id: some_event
event_type: template_event_type1
- event.trigger:
id: some_event
event_type: !lambda |-
return id(some_event).has_event() ? "template_event_type2" : "template_event_type1";
+22 -4
View File
@@ -67,7 +67,7 @@ binary_sensor:
return false;
# Exercise fan.turn_on with various field combinations so the
# TurnOnAction codegen paths get build coverage.
# register_apply_action codegen paths get build coverage.
button:
- platform: template
name: "Fan Speed Only"
@@ -96,6 +96,20 @@ button:
- fan.turn_on:
id: test_fan
speed: !lambda 'return 1;'
- platform: template
name: "Fan Is On Off"
on_press:
- if:
condition:
fan.is_on: test_fan
then:
- fan.turn_off: test_fan
- if:
condition:
fan.is_off:
id: test_fan
then:
- fan.turn_on: test_fan
# Exercise fan.turn_on inside triggers with non-empty Ts:
# - number.on_value: Ts = float (Python value type; previously raised
@@ -111,6 +125,10 @@ number:
step: 1
on_value:
then:
- fan.turn_on:
id: test_fan
speed: !lambda "return (int) x;"
- if:
condition:
fan.is_on: test_fan
then:
- fan.turn_on:
id: test_fan
speed: !lambda "return (int) x;"
@@ -0,0 +1,9 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# This component's AUTO_LOAD = ["climate_ir"] sits on the climate platform manifest, while its
# own __init__.py is empty. The unit test build resolves the bare `fujitsu_general` domain, so
# it never sees that manifest. And climate_ir itself doesn't declare `climate` even though
# ClimateIR derives from climate::Climate. Pull both in so the test can include the header.
manifest.dependencies = manifest.dependencies + ["climate_ir", "climate"]
@@ -0,0 +1,254 @@
#include <gtest/gtest.h>
#include "esphome/components/fujitsu_general/fujitsu_general.h"
namespace esphome::fujitsu_general::testing {
// The mode field of a received frame is three bits wide. The fourth bit of the same nibble belongs
// to the clean feature, so it has to be ignored when reading the mode.
TEST(FujitsuGeneralDecodeModeTest, DecodesTheAssignedModes) {
EXPECT_EQ(decode_mode(0x00, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_HEAT_COOL);
EXPECT_EQ(decode_mode(0x01, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_COOL);
EXPECT_EQ(decode_mode(0x02, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_DRY);
EXPECT_EQ(decode_mode(0x03, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_FAN_ONLY);
EXPECT_EQ(decode_mode(0x04, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_HEAT);
}
TEST(FujitsuGeneralDecodeModeTest, IgnoresTheCleanBit) {
// 0x0B is fan mode with the clean bit set. It used to be read as one value and reported as
// heat/cool, which is the bug this covers.
EXPECT_EQ(decode_mode(0x0B, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_FAN_ONLY);
EXPECT_EQ(decode_mode(0x08, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_HEAT_COOL);
EXPECT_EQ(decode_mode(0x09, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_COOL);
EXPECT_EQ(decode_mode(0x0A, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_DRY);
EXPECT_EQ(decode_mode(0x0C, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_HEAT);
}
TEST(FujitsuGeneralDecodeModeTest, KeepsTheCurrentModeForUnassignedValues) {
// 0x5 to 0x7 fit in the field but the protocol does not use them.
EXPECT_EQ(decode_mode(0x05, climate::CLIMATE_MODE_COOL), climate::CLIMATE_MODE_COOL);
EXPECT_EQ(decode_mode(0x06, climate::CLIMATE_MODE_HEAT), climate::CLIMATE_MODE_HEAT);
EXPECT_EQ(decode_mode(0x07, climate::CLIMATE_MODE_DRY), climate::CLIMATE_MODE_DRY);
// The same three with the clean bit set. Without the mask these would not reach this branch.
EXPECT_EQ(decode_mode(0x0D, climate::CLIMATE_MODE_COOL), climate::CLIMATE_MODE_COOL);
EXPECT_EQ(decode_mode(0x0E, climate::CLIMATE_MODE_HEAT), climate::CLIMATE_MODE_HEAT);
EXPECT_EQ(decode_mode(0x0F, climate::CLIMATE_MODE_FAN_ONLY), climate::CLIMATE_MODE_FAN_ONLY);
}
TEST(FujitsuGeneralDecodeModeTest, NeverReportsOffForAStateFrame) {
// A state frame describes a running unit, so keeping an off current mode would publish it as off
// and turn the next transmission into a power off command. Automatic is the least specific mode
// available, which is what the field's unassigned values decoded to before they were masked.
for (uint8_t field = 0x05; field <= 0x07; field++) {
SCOPED_TRACE(static_cast<int>(field));
EXPECT_EQ(decode_mode(field, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_HEAT_COOL);
EXPECT_EQ(decode_mode(field | 0b1000, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_HEAT_COOL);
}
}
// The fan speed field is three bits wide as well, and used to fold every value it did not
// recognise into the automatic speed.
TEST(FujitsuGeneralDecodeFanModeTest, DecodesTheAssignedSpeeds) {
EXPECT_EQ(decode_fan_mode(0x00, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_AUTO);
EXPECT_EQ(decode_fan_mode(0x01, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_HIGH);
EXPECT_EQ(decode_fan_mode(0x02, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_MEDIUM);
EXPECT_EQ(decode_fan_mode(0x03, climate::CLIMATE_FAN_AUTO), climate::CLIMATE_FAN_LOW);
EXPECT_EQ(decode_fan_mode(0x04, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_QUIET);
}
TEST(FujitsuGeneralDecodeFanModeTest, IgnoresTheFourthBit) {
EXPECT_EQ(decode_fan_mode(0x08, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_AUTO);
EXPECT_EQ(decode_fan_mode(0x09, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_HIGH);
EXPECT_EQ(decode_fan_mode(0x0A, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_MEDIUM);
EXPECT_EQ(decode_fan_mode(0x0B, climate::CLIMATE_FAN_AUTO), climate::CLIMATE_FAN_LOW);
EXPECT_EQ(decode_fan_mode(0x0C, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_QUIET);
}
TEST(FujitsuGeneralDecodeFanModeTest, KeepsTheCurrentFanModeForUnassignedValues) {
EXPECT_EQ(decode_fan_mode(0x05, climate::CLIMATE_FAN_HIGH), climate::CLIMATE_FAN_HIGH);
EXPECT_EQ(decode_fan_mode(0x06, climate::CLIMATE_FAN_MEDIUM), climate::CLIMATE_FAN_MEDIUM);
EXPECT_EQ(decode_fan_mode(0x07, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_LOW);
EXPECT_EQ(decode_fan_mode(0x0D, climate::CLIMATE_FAN_HIGH), climate::CLIMATE_FAN_HIGH);
EXPECT_EQ(decode_fan_mode(0x0E, climate::CLIMATE_FAN_HIGH), climate::CLIMATE_FAN_HIGH);
EXPECT_EQ(decode_fan_mode(0x0F, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_LOW);
}
TEST(FujitsuGeneralDecodeFanModeTest, LeavesAnUnsetFanModeUnset) {
EXPECT_FALSE(decode_fan_mode(0x05, {}).has_value());
}
// The swing field is only two bits wide. The two bits above it are reserved, and were read as part
// of the value.
TEST(FujitsuGeneralDecodeSwingModeTest, DecodesTheAssignedValues) {
EXPECT_EQ(decode_swing_mode(0x00), climate::CLIMATE_SWING_OFF);
EXPECT_EQ(decode_swing_mode(0x01), climate::CLIMATE_SWING_VERTICAL);
EXPECT_EQ(decode_swing_mode(0x02), climate::CLIMATE_SWING_HORIZONTAL);
EXPECT_EQ(decode_swing_mode(0x03), climate::CLIMATE_SWING_BOTH);
}
TEST(FujitsuGeneralDecodeSwingModeTest, IgnoresTheReservedBits) {
// Without the mask everything from 0x04 up fell through to the default branch and reported swing
// off. All twelve are covered, so the field's whole input space is asserted.
const climate::ClimateSwingMode expected[] = {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH};
for (uint8_t field = 0x04; field <= 0x0F; field++) {
SCOPED_TRACE(static_cast<int>(field));
EXPECT_EQ(decode_swing_mode(field), expected[field & 0b0011]);
}
}
// Every state frame annotated in fujitsu_general.h, as the bytes those rows spell out. None of them
// sets the fourth bit of the mode or fan field, or either bit above the swing field, so the masks
// must leave all of them decoding exactly as they did before this change.
namespace {
struct CapturedFrame {
const char *label;
uint8_t bytes[16];
uint8_t temperature;
bool turn_on;
climate::ClimateMode mode;
climate::ClimateFanMode fan_mode;
climate::ClimateSwingMode swing_mode;
};
constexpr CapturedFrame CAPTURED_FRAMES[] = {
{"auto auto 18",
{0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0x21, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x8F},
18,
true,
climate::CLIMATE_MODE_HEAT_COOL,
climate::CLIMATE_FAN_AUTO,
climate::CLIMATE_SWING_OFF},
{"auto auto 19",
{0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0x31, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x7F},
19,
true,
climate::CLIMATE_MODE_HEAT_COOL,
climate::CLIMATE_FAN_AUTO,
climate::CLIMATE_SWING_OFF},
{"auto auto 30 (temperatures)",
{0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE1, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xCF},
30,
true,
climate::CLIMATE_MODE_HEAT_COOL,
climate::CLIMATE_FAN_AUTO,
climate::CLIMATE_SWING_OFF},
{"on at 16",
{0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0x01, 0x04, 0x00, 0x00, 0x00, 0x00, 0x20, 0xAB},
16,
true,
climate::CLIMATE_MODE_HEAT,
climate::CLIMATE_FAN_AUTO,
climate::CLIMATE_SWING_OFF},
{"down to 16",
{0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x20, 0xAC},
16,
false,
climate::CLIMATE_MODE_HEAT,
climate::CLIMATE_FAN_AUTO,
climate::CLIMATE_SWING_OFF},
{"auto auto 30 (mode options)",
{0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE1, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xCF},
30,
true,
climate::CLIMATE_MODE_HEAT_COOL,
climate::CLIMATE_FAN_AUTO,
climate::CLIMATE_SWING_OFF},
{"cool auto 30",
{0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE1, 0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0xCE},
30,
true,
climate::CLIMATE_MODE_COOL,
climate::CLIMATE_FAN_AUTO,
climate::CLIMATE_SWING_OFF},
{"dry auto 30",
{0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE1, 0x02, 0x00, 0x00, 0x00, 0x00, 0x20, 0xCD},
30,
true,
climate::CLIMATE_MODE_DRY,
climate::CLIMATE_FAN_AUTO,
climate::CLIMATE_SWING_OFF},
{"fan (auto) (30)",
{0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE1, 0x03, 0x00, 0x00, 0x00, 0x00, 0x20, 0xCC},
30,
true,
climate::CLIMATE_MODE_FAN_ONLY,
climate::CLIMATE_FAN_AUTO,
climate::CLIMATE_SWING_OFF},
{"heat auto 30",
{0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE1, 0x04, 0x00, 0x00, 0x00, 0x00, 0x20, 0xCB},
30,
true,
climate::CLIMATE_MODE_HEAT,
climate::CLIMATE_FAN_AUTO,
climate::CLIMATE_SWING_OFF},
{"heat 30 high",
{0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE1, 0x04, 0x01, 0x00, 0x00, 0x00, 0x20, 0xCA},
30,
true,
climate::CLIMATE_MODE_HEAT,
climate::CLIMATE_FAN_HIGH,
climate::CLIMATE_SWING_OFF},
{"heat 30 med",
{0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE0, 0x04, 0x02, 0x00, 0x00, 0x00, 0x20, 0xCA},
30,
false,
climate::CLIMATE_MODE_HEAT,
climate::CLIMATE_FAN_MEDIUM,
climate::CLIMATE_SWING_OFF},
{"heat 30 low",
{0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE0, 0x04, 0x03, 0x00, 0x00, 0x00, 0x20, 0xC9},
30,
false,
climate::CLIMATE_MODE_HEAT,
climate::CLIMATE_FAN_LOW,
climate::CLIMATE_SWING_OFF},
{"heat 30 quiet",
{0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE0, 0x04, 0x04, 0x00, 0x00, 0x00, 0x20, 0xC8},
30,
false,
climate::CLIMATE_MODE_HEAT,
climate::CLIMATE_FAN_QUIET,
climate::CLIMATE_SWING_OFF},
{"heat 30 swing vert",
{0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE0, 0x04, 0x14, 0x00, 0x00, 0x00, 0x20, 0xB8},
30,
false,
climate::CLIMATE_MODE_HEAT,
climate::CLIMATE_FAN_QUIET,
climate::CLIMATE_SWING_VERTICAL},
{"heat 30 noswing",
{0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE0, 0x04, 0x04, 0x00, 0x00, 0x00, 0x20, 0xC8},
30,
false,
climate::CLIMATE_MODE_HEAT,
climate::CLIMATE_FAN_QUIET,
climate::CLIMATE_SWING_OFF},
};
} // namespace
TEST(FujitsuGeneralCaptureTest, DecodesEveryCapturedFrame) {
for (const auto &frame : CAPTURED_FRAMES) {
SCOPED_TRACE(frame.label);
// Read through the component's own nibble helper and field indices, so this also fails if the
// frame layout the header records ever stops matching what on_receive() reads.
EXPECT_EQ(get_nibble(frame.bytes, FUJITSU_GENERAL_TEMPERATURE_NIBBLE) + FUJITSU_GENERAL_TEMP_MIN,
frame.temperature);
// The turn on flag is only written by transmit_state(), so this pins the frame layout rather
// than a decode path.
EXPECT_EQ(get_nibble(frame.bytes, FUJITSU_GENERAL_POWER_ON_NIBBLE) != 0, frame.turn_on);
EXPECT_EQ(decode_mode(get_nibble(frame.bytes, FUJITSU_GENERAL_MODE_NIBBLE), climate::CLIMATE_MODE_OFF), frame.mode);
EXPECT_EQ(decode_fan_mode(get_nibble(frame.bytes, FUJITSU_GENERAL_FAN_NIBBLE), climate::CLIMATE_FAN_ON),
frame.fan_mode);
EXPECT_EQ(decode_swing_mode(get_nibble(frame.bytes, FUJITSU_GENERAL_SWING_NIBBLE)), frame.swing_mode);
}
}
} // namespace esphome::fujitsu_general::testing

Some files were not shown because too many files have changed in this diff Show More