mirror of
https://github.com/esphome/esphome.git
synced 2026-10-04 18:11:29 +00:00
Merge branch 'dev' into jesserockz-2026-446
This commit is contained in:
+4
-4
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -4,3 +4,4 @@ output:
|
||||
gate_pin: ${gate_pin}
|
||||
zero_cross_pin: ${zero_cross_pin}
|
||||
zero_cross_interrupt_type: ANY
|
||||
min_power: 0%
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user