Files
esphome/tests/unit_tests/test_core.py
T

1043 lines
38 KiB
Python

import os
from pathlib import Path
import subprocess
import sys
from unittest.mock import patch
from hypothesis import given
import pytest
from esphome import const, core
from tests.unit_tests.strategies import mac_addr_strings
class TestHexInt:
@pytest.mark.parametrize(
"value, expected",
(
(1, "0x01"),
(255, "0xFF"),
(128, "0x80"),
(256, "0x100"),
(-1, "-0x01"), # TODO: this currently fails
),
)
def test_str(self, value, expected):
target = core.HexInt(value)
actual = str(target)
assert actual == expected
class TestMACAddress:
@given(value=mac_addr_strings())
def test_init__valid(self, value):
core.MACAddress(*value.split(":"))
@pytest.mark.parametrize("value", ("1:2:3:4:5", "localhost", ""))
def test_init__invalid(self, value):
with pytest.raises(ValueError, match="MAC Address must consist of 6 items"):
core.MACAddress(*value.split(":"))
@given(value=mac_addr_strings())
def test_str(self, value):
target = core.MACAddress(*(int(v, 16) for v in value.split(":")))
actual = str(target)
assert actual == value
def test_as_hex(self):
target = core.MACAddress(0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0xFF)
actual = target.as_hex
assert actual.text == "0xDEADBEEF00FFULL"
@pytest.mark.parametrize("value", (1, 2, -1, 0, 1.0, -1.0, 42.0009, -42.0009))
def test_is_approximately_integer__in_range(value):
actual = core.is_approximately_integer(value)
assert actual is True
@pytest.mark.parametrize("value", (42.01, -42.01, 1.5))
def test_is_approximately_integer__not_in_range(value):
actual = core.is_approximately_integer(value)
assert actual is False
class TestTimePeriod:
@pytest.mark.parametrize(
"kwargs, expected",
(
({}, {}),
({"microseconds": 1}, {"microseconds": 1}),
({"microseconds": 1.0001}, {"microseconds": 1}),
({"milliseconds": 2}, {"milliseconds": 2}),
({"milliseconds": 2.0001}, {"milliseconds": 2}),
({"milliseconds": 2.01}, {"milliseconds": 2, "microseconds": 10}),
({"seconds": 3}, {"seconds": 3}),
({"seconds": 3.0001}, {"seconds": 3}),
({"seconds": 3.01}, {"seconds": 3, "milliseconds": 10}),
({"minutes": 4}, {"minutes": 4}),
({"minutes": 4.0001}, {"minutes": 4}),
({"minutes": 4.1}, {"minutes": 4, "seconds": 6}),
({"hours": 5}, {"hours": 5}),
({"hours": 5.0001}, {"hours": 5}),
({"hours": 5.1}, {"hours": 5, "minutes": 6}),
({"days": 6}, {"days": 6}),
({"days": 6.0001}, {"days": 6}),
({"days": 6.1}, {"days": 6, "hours": 2, "minutes": 24}),
),
)
def test_init(self, kwargs, expected):
target = core.TimePeriod(**kwargs)
actual = target.as_dict()
assert actual == expected
def test_init__nanoseconds_with_fraction(self):
with pytest.raises(ValueError, match="Maximum precision is nanoseconds"):
core.TimePeriod(nanoseconds=1.1)
@pytest.mark.parametrize(
"kwargs, expected",
(
({}, "0s"),
({"nanoseconds": 1}, "1ns"),
({"nanoseconds": 1.0001}, "1ns"),
({"microseconds": 1}, "1us"),
({"microseconds": 1.0001}, "1us"),
({"milliseconds": 2}, "2ms"),
({"milliseconds": 2.0001}, "2ms"),
({"milliseconds": 2.01}, "2010us"),
({"seconds": 3}, "3s"),
({"seconds": 3.0001}, "3s"),
({"seconds": 3.01}, "3010ms"),
({"minutes": 4}, "4min"),
({"minutes": 4.0001}, "4min"),
({"minutes": 4.1}, "246s"),
({"hours": 5}, "5h"),
({"hours": 5.0001}, "5h"),
({"hours": 5.1}, "306min"),
({"days": 6}, "6d"),
({"days": 6.0001}, "6d"),
({"days": 6.1}, "8784min"),
),
)
def test_str(self, kwargs, expected):
target = core.TimePeriod(**kwargs)
actual = str(target)
assert actual == expected
@pytest.mark.parametrize(
"comparison, other, expected",
(
("__eq__", core.TimePeriod(microseconds=900), False),
("__eq__", core.TimePeriod(milliseconds=1), True),
("__eq__", core.TimePeriod(microseconds=1100), False),
("__eq__", 1000, NotImplemented),
("__eq__", "1000", NotImplemented),
("__eq__", True, NotImplemented),
("__eq__", object(), NotImplemented),
("__eq__", None, NotImplemented),
("__ne__", core.TimePeriod(microseconds=900), True),
("__ne__", core.TimePeriod(milliseconds=1), False),
("__ne__", core.TimePeriod(microseconds=1100), True),
("__ne__", 1000, NotImplemented),
("__ne__", "1000", NotImplemented),
("__ne__", True, NotImplemented),
("__ne__", object(), NotImplemented),
("__ne__", None, NotImplemented),
("__lt__", core.TimePeriod(microseconds=900), False),
("__lt__", core.TimePeriod(milliseconds=1), False),
("__lt__", core.TimePeriod(microseconds=1100), True),
("__lt__", 1000, NotImplemented),
("__lt__", "1000", NotImplemented),
("__lt__", True, NotImplemented),
("__lt__", object(), NotImplemented),
("__lt__", None, NotImplemented),
("__gt__", core.TimePeriod(microseconds=900), True),
("__gt__", core.TimePeriod(milliseconds=1), False),
("__gt__", core.TimePeriod(microseconds=1100), False),
("__gt__", 1000, NotImplemented),
("__gt__", "1000", NotImplemented),
("__gt__", True, NotImplemented),
("__gt__", object(), NotImplemented),
("__gt__", None, NotImplemented),
("__le__", core.TimePeriod(microseconds=900), False),
("__le__", core.TimePeriod(milliseconds=1), True),
("__le__", core.TimePeriod(microseconds=1100), True),
("__le__", 1000, NotImplemented),
("__le__", "1000", NotImplemented),
("__le__", True, NotImplemented),
("__le__", object(), NotImplemented),
("__le__", None, NotImplemented),
("__ge__", core.TimePeriod(microseconds=900), True),
("__ge__", core.TimePeriod(milliseconds=1), True),
("__ge__", core.TimePeriod(microseconds=1100), False),
("__ge__", 1000, NotImplemented),
("__ge__", "1000", NotImplemented),
("__ge__", True, NotImplemented),
("__ge__", object(), NotImplemented),
("__ge__", None, NotImplemented),
),
)
def test_comparison(self, comparison, other, expected):
target = core.TimePeriod(microseconds=1000)
actual = getattr(target, comparison)(other)
assert actual == expected
SAMPLE_LAMBDA = """
it.strftime(64, 0, id(my_font), TextAlign::TOP_CENTER, "%H:%M:%S", id(esptime).now());
it.printf(64, 16, id(my_font2), TextAlign::TOP_CENTER, "%.1f°C (%.1f%%)", id( office_tmp ).state, id(office_hmd).state);
//id(my_commented_id)
int x = 4;/* id(my_commented_id2)
id(my_commented_id3)
*/
"""
class TestLambda:
def test_init__copy_initializer(self):
value = core.Lambda("foo")
target = core.Lambda(value)
assert str(target) is value.value
def test_init__expression_initializer(self):
from esphome.cpp_generator import RawExpression
target = core.Lambda(RawExpression("foo()"))
assert target.value == "foo();"
def test_init__other_initializer(self):
target = core.Lambda(123)
assert target.value == 123
def test_init_from_str_does_not_import_codegen(self):
"""The validated-config cache revives Lambdas on the upload fast path."""
# sys.exit rather than assert so ambient PYTHONOPTIMIZE can't strip it.
check = (
"import sys; from esphome.core import Lambda; "
"Lambda('return 1;'); "
"sys.exit('codegen leaked' if 'esphome.cpp_generator' in sys.modules else 0)"
)
result = subprocess.run(
[sys.executable, "-c", check], capture_output=True, text=True, check=False
)
assert result.returncode == 0, result.stderr
def test_parts(self):
target = core.Lambda(SAMPLE_LAMBDA.strip())
# Check cache
assert target._parts is None
actual = target.parts
assert target._parts is actual
assert target.parts is actual
assert actual == [
"it.strftime(64, 0, ",
"my_font",
"",
', TextAlign::TOP_CENTER, "%H:%M:%S", ',
"esptime",
".",
"now());\nit.printf(64, 16, ",
"my_font2",
"",
', TextAlign::TOP_CENTER, "%.1f°C (%.1f%%)", ',
"office_tmp",
".",
"state, ",
"office_hmd",
".",
"state);\n \nint x = 4; ",
]
def test_requires_ids(self):
target = core.Lambda(SAMPLE_LAMBDA.strip())
# Check cache
assert target._requires_ids is None
actual = target.requires_ids
assert target._requires_ids is actual
assert target.requires_ids is actual
assert actual == [
core.ID("my_font"),
core.ID("esptime"),
core.ID("my_font2"),
core.ID("office_tmp"),
core.ID("office_hmd"),
]
def test_value_setter(self):
target = core.Lambda("")
# Populate cache
_ = target.parts
_ = target.requires_ids
target.value = SAMPLE_LAMBDA
# Check cache has been cleared
assert target._parts is None
assert target._requires_ids is None
assert target.value == SAMPLE_LAMBDA
def test_repr(self):
target = core.Lambda("id(var).value == 1")
assert repr(target) == "Lambda<id(var).value == 1>"
class TestID:
@pytest.fixture
def target(self):
return core.ID(None, is_declaration=True, type="binary_sensor::Example")
@pytest.mark.parametrize(
"id, is_manual, expected",
(
("foo", None, True),
(None, None, False),
("foo", True, True),
("foo", False, False),
(None, True, True),
),
)
def test_init__resolve_is_manual(self, id, is_manual, expected):
target = core.ID(id, is_manual=is_manual)
assert target.is_manual == expected
@pytest.mark.parametrize(
"registered_ids, expected",
(
([], "binary_sensor_example"),
(["binary_sensor_example"], "binary_sensor_example_2"),
(["foo"], "binary_sensor_example"),
(
["binary_sensor_example", "foo", "binary_sensor_example_2"],
"binary_sensor_example_3",
),
),
)
def test_resolve(self, target, registered_ids, expected):
actual = target.resolve(registered_ids)
assert actual == expected
assert str(target) == expected
def test_copy(self, target):
target.resolve([])
actual = target.copy()
assert actual is not target
assert all(
getattr(actual, n) == getattr(target, n)
for n in ("id", "is_declaration", "type", "is_manual")
)
@pytest.mark.parametrize(
"comparison, other, expected",
(
("__eq__", core.ID(id="foo"), True),
("__eq__", core.ID(id="bar"), False),
("__eq__", 1000, NotImplemented),
("__eq__", "1000", NotImplemented),
("__eq__", True, NotImplemented),
("__eq__", object(), NotImplemented),
("__eq__", None, NotImplemented),
),
)
def test_comparison(self, comparison, other, expected):
target = core.ID(id="foo")
actual = getattr(target, comparison)(other)
assert actual == expected
class TestDocumentLocation:
@pytest.fixture
def target(self):
return core.DocumentLocation(
document="foo.txt",
line=10,
column=20,
)
def test_str(self, target):
actual = str(target)
assert actual == "foo.txt 10:20"
class TestDocumentRange:
@pytest.fixture
def target(self):
return core.DocumentRange(
core.DocumentLocation(
document="foo.txt",
line=10,
column=20,
),
core.DocumentLocation(
document="foo.txt",
line=15,
column=12,
),
)
def test_str(self, target):
actual = str(target)
assert actual == "[foo.txt 10:20 - foo.txt 15:12]"
class TestDefine:
@pytest.mark.parametrize(
"name, value, prop, expected",
(
("ANSWER", None, "as_build_flag", "-DANSWER"),
("ANSWER", None, "as_macro", "#define ANSWER"),
("ANSWER", None, "as_tuple", ("ANSWER", None)),
("ANSWER", 42, "as_build_flag", "-DANSWER=42"),
("ANSWER", 42, "as_macro", "#define ANSWER 42"),
("ANSWER", 42, "as_tuple", ("ANSWER", 42)),
),
)
def test_properties(self, name, value, prop, expected):
target = core.Define(name, value)
actual = getattr(target, prop)
assert actual == expected
@pytest.mark.parametrize(
"comparison, other, expected",
(
("__eq__", core.Define(name="FOO", value=42), True),
("__eq__", core.Define(name="FOO", value=13), False),
("__eq__", core.Define(name="FOO"), False),
("__eq__", core.Define(name="BAR", value=42), False),
("__eq__", core.Define(name="BAR"), False),
("__eq__", 1000, NotImplemented),
("__eq__", "1000", NotImplemented),
("__eq__", True, NotImplemented),
("__eq__", object(), NotImplemented),
("__eq__", None, NotImplemented),
),
)
def test_comparison(self, comparison, other, expected):
target = core.Define(name="FOO", value=42)
actual = getattr(target, comparison)(other)
assert actual == expected
class TestLibrary:
@pytest.mark.parametrize(
"name, version, repository, prop, expected",
(
("mylib", None, None, "as_lib_dep", "mylib"),
("mylib", None, None, "as_tuple", ("mylib", None, None)),
("mylib", "1.2.3", None, "as_lib_dep", "mylib@1.2.3"),
("mylib", "1.2.3", None, "as_tuple", ("mylib", "1.2.3", None)),
("mylib", None, "file:///test", "as_lib_dep", "mylib=file:///test"),
(
"mylib",
None,
"file:///test",
"as_tuple",
("mylib", None, "file:///test"),
),
),
)
def test_properties(self, name, version, repository, prop, expected):
target = core.Library(name, version, repository)
actual = getattr(target, prop)
assert actual == expected
@pytest.mark.parametrize(
"comparison, other, expected",
(
("__eq__", core.Library(name="libfoo", version="1.2.3"), True),
("__eq__", core.Library(name="libfoo", version="1.2.4"), False),
("__eq__", core.Library(name="libbar", version="1.2.3"), False),
(
"__eq__",
core.Library(name="libbar", version=None, repository="file:///test"),
False,
),
("__eq__", 1000, NotImplemented),
("__eq__", "1000", NotImplemented),
("__eq__", True, NotImplemented),
("__eq__", object(), NotImplemented),
("__eq__", None, NotImplemented),
),
)
def test_comparison(self, comparison, other, expected):
target = core.Library(name="libfoo", version="1.2.3")
actual = getattr(target, comparison)(other)
assert actual == expected
@pytest.mark.parametrize(
"target, other, result, exception",
(
(core.Library("libfoo", None), core.Library("libfoo", None), True, None),
(
core.Library("libfoo", "1.2.3"),
core.Library("libfoo", "1.2.3"),
True, # target is unchanged
None,
),
(
core.Library("libfoo", None),
core.Library("libfoo", "1.2.3"),
False, # Use version from other
None,
),
(
core.Library("libfoo", "1.2.3"),
core.Library("libfoo", "1.2.4"),
False,
ValueError, # Version mismatch
),
(
core.Library("libfoo", "1.2.3"),
core.Library("libbar", "1.2.3"),
False,
ValueError, # Name mismatch
),
(
core.Library(
"libfoo", "1.2.4", "https://github.com/esphome/ESPAsyncWebServer"
),
core.Library("libfoo", "1.2.3"),
True, # target is unchanged due to having a repository
None,
),
(
core.Library("libfoo", "1.2.3"),
core.Library(
"libfoo", "1.2.4", "https://github.com/esphome/ESPAsyncWebServer"
),
False, # use other due to having a repository
None,
),
),
)
def test_reconcile(self, target, other, result, exception):
if exception is not None:
with pytest.raises(exception):
target.reconcile_with(other)
else:
expected = target if result else other
actual = target.reconcile_with(other)
assert actual == expected
class TestEsphomeCore:
@pytest.fixture
def target(self, fixture_path):
target = core.EsphomeCore()
target.build_path = Path("foo/build")
target.config_path = Path("foo/config")
return target
def test_reset(self, target):
"""Call reset on target and compare to new instance"""
other = core.EsphomeCore().__dict__
target.reset()
t = target.__dict__
# ignore event loop
del other["event_loop"]
del t["event_loop"]
assert t == other
def test_address__none(self, target):
target.config = {}
assert target.address is None
def test_address__wifi(self, target):
target.config = {}
target.config[const.CONF_WIFI] = {const.CONF_USE_ADDRESS: "1.2.3.4"}
target.config[const.CONF_ETHERNET] = {const.CONF_USE_ADDRESS: "4.3.2.1"}
assert target.address == "1.2.3.4"
def test_address__ethernet(self, target):
target.config = {}
target.config[const.CONF_ETHERNET] = {const.CONF_USE_ADDRESS: "4.3.2.1"}
assert target.address == "4.3.2.1"
def test_address__openthread(self, target):
target.config = {}
target.config[const.CONF_OPENTHREAD] = {
const.CONF_USE_ADDRESS: "test-device.local"
}
target.name = "test-device"
assert target.address == "test-device.local"
def test_is_esp32(self, target):
target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "esp32"}
assert target.is_esp32 is True
assert target.is_esp8266 is False
def test_is_esp8266(self, target):
target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "esp8266"}
assert target.is_esp32 is False
assert target.is_esp8266 is True
def test_is_rp2(self, target):
"""The canonical RP2 family gate flips on for the rp2 platform."""
target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "rp2"}
assert target.is_rp2 is True
assert target.is_esp32 is False
assert target.is_esp8266 is False
def test_is_rp2040_deprecated_alias_matches_is_rp2(self, target, caplog):
"""``is_rp2040`` is kept as a deprecation shim that returns whatever
``is_rp2`` returns; both must agree across platform values. A
one-shot deprecation warning is emitted on first access and
deduped via ``CORE.data`` for the rest of the run."""
import logging
target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "rp2"}
with caplog.at_level(logging.WARNING, logger="esphome.core"):
assert target.is_rp2040 is True
assert target.is_rp2040 == target.is_rp2
warnings = [r for r in caplog.records if "is_rp2040" in r.message]
assert len(warnings) == 1
assert "2027.7.0" in warnings[0].message
# Reset the dedupe so the False-platform branch also runs the shim.
target.data.pop("_core_is_rp2040_deprecated_warned", None)
target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "esp32"}
assert target.is_rp2040 is False
assert target.is_rp2040 == target.is_rp2
def test_firmware_bin__default(self, target):
"""Default platforms produce <pioenvs>/<name>/firmware.bin."""
target.name = "test-device"
target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "esp32"}
assert target.firmware_bin == Path(
"foo/build/.pioenvs/test-device/firmware.bin"
)
def test_firmware_bin__libretiny(self, target):
"""The libretiny platform produces firmware.uf2."""
target.name = "test-device"
target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "bk72xx"}
assert target.firmware_bin == Path(
"foo/build/.pioenvs/test-device/firmware.uf2"
)
def test_firmware_bin__host(self, target):
"""Host platform produces a native ELF/Mach-O named `program`,
not firmware.bin -- needed for `esphome upload` to find the
right artifact for the host OTA backend."""
target.name = "test-device"
target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "host"}
assert target.firmware_bin == Path("foo/build/.pioenvs/test-device/program")
@pytest.mark.skipif(os.name == "nt", reason="Unix-specific test")
def test_data_dir_default_unix(self, target):
"""Test data_dir returns .esphome in config directory by default on Unix."""
target.config_path = Path("/home/user/config.yaml")
assert target.data_dir == Path("/home/user/.esphome")
@pytest.mark.skipif(os.name != "nt", reason="Windows-specific test")
def test_data_dir_default_windows(self, target):
"""Test data_dir returns .esphome in config directory by default on Windows."""
target.config_path = Path("D:\\home\\user\\config.yaml")
assert target.data_dir == Path("D:\\home\\user\\.esphome")
def test_data_dir_ha_addon(self, target):
"""Test data_dir returns /data when running as Home Assistant addon."""
target.config_path = Path("/config/test.yaml")
with patch.dict(os.environ, {"ESPHOME_IS_HA_ADDON": "true"}):
assert target.data_dir == Path("/data")
def test_data_dir_env_override(self, target):
"""Test data_dir uses ESPHOME_DATA_DIR environment variable when set."""
target.config_path = Path("/home/user/config.yaml")
with patch.dict(os.environ, {"ESPHOME_DATA_DIR": "/custom/data/path"}):
assert target.data_dir == Path("/custom/data/path")
@pytest.mark.skipif(os.name == "nt", reason="Unix-specific test")
def test_data_dir_priority_unix(self, target):
"""Test data_dir priority on Unix: HA addon > env var > default."""
target.config_path = Path("/config/test.yaml")
expected_default = "/config/.esphome"
# Test HA addon takes priority over env var
with patch.dict(
os.environ,
{"ESPHOME_IS_HA_ADDON": "true", "ESPHOME_DATA_DIR": "/custom/path"},
):
assert target.data_dir == Path("/data")
# Test env var is used when not HA addon
with patch.dict(
os.environ,
{"ESPHOME_IS_HA_ADDON": "false", "ESPHOME_DATA_DIR": "/custom/path"},
):
assert target.data_dir == Path("/custom/path")
# Test default when neither is set
with patch.dict(os.environ, {}, clear=True):
# Ensure these env vars are not set
os.environ.pop("ESPHOME_IS_HA_ADDON", None)
os.environ.pop("ESPHOME_DATA_DIR", None)
assert target.data_dir == Path(expected_default)
@pytest.mark.skipif(os.name != "nt", reason="Windows-specific test")
def test_data_dir_priority_windows(self, target):
"""Test data_dir priority on Windows: HA addon > env var > default."""
target.config_path = Path("D:\\config\\test.yaml")
expected_default = "D:\\config\\.esphome"
# Test HA addon takes priority over env var
with patch.dict(
os.environ,
{"ESPHOME_IS_HA_ADDON": "true", "ESPHOME_DATA_DIR": "/custom/path"},
):
assert target.data_dir == Path("/data")
# Test env var is used when not HA addon
with patch.dict(
os.environ,
{"ESPHOME_IS_HA_ADDON": "false", "ESPHOME_DATA_DIR": "/custom/path"},
):
assert target.data_dir == Path("/custom/path")
# Test default when neither is set
with patch.dict(os.environ, {}, clear=True):
# Ensure these env vars are not set
os.environ.pop("ESPHOME_IS_HA_ADDON", None)
os.environ.pop("ESPHOME_DATA_DIR", None)
assert target.data_dir == Path(expected_default)
def test_web_port__none(self, target):
"""Test web_port returns None when web_server is not configured."""
target.config = {}
assert target.web_port is None
def test_web_port__explicit_web_server_default_port(self, target):
"""Test web_port returns 80 when web_server is explicitly configured without port."""
target.config = {const.CONF_WEB_SERVER: {}}
assert target.web_port == 80
def test_web_port__explicit_web_server_custom_port(self, target):
"""Test web_port returns custom port when web_server is configured with port."""
target.config = {const.CONF_WEB_SERVER: {const.CONF_PORT: 8080}}
assert target.web_port == 8080
def test_web_port__ota_web_server_platform_only(self, target):
"""
Test web_port returns None when ota.web_server platform is explicitly configured.
This is a critical test for Dashboard Issue #766:
https://github.com/esphome/dashboard/issues/766
When ota: platform: web_server is explicitly configured (or auto-loaded by captive_portal):
- "web_server" appears in loaded_integrations (platform name added to integrations)
- "ota/web_server" appears in loaded_platforms
- But CONF_WEB_SERVER is NOT in config (only the platform is loaded, not the component)
- web_port MUST return None (no web UI available)
- Dashboard should NOT show VISIT button
This test ensures web_port only checks CONF_WEB_SERVER in config, not loaded_integrations.
"""
# Simulate config with ota.web_server platform but no web_server component
# This happens when:
# 1. User explicitly configures: ota: - platform: web_server
# 2. OR captive_portal auto-loads ota.web_server
target.config = {
const.CONF_OTA: [
{
"platform": "web_server",
# OTA web_server platform config would be here
}
],
# Note: CONF_WEB_SERVER is NOT in config - only the OTA platform
}
# Even though "web_server" is in loaded_integrations due to the platform,
# web_port must return None because the full web_server component is not configured
assert target.web_port is None
def test_has_at_least_one_component__none_configured(self, target):
"""Test has_at_least_one_component returns False when none of the components are configured."""
target.config = {const.CONF_ESPHOME: {"name": "test"}, "logger": {}}
assert target.has_at_least_one_component("wifi", "ethernet") is False
def test_has_at_least_one_component__one_configured(self, target):
"""Test has_at_least_one_component returns True when one component is configured."""
target.config = {const.CONF_WIFI: {}, "logger": {}}
assert target.has_at_least_one_component("wifi", "ethernet") is True
def test_has_at_least_one_component__multiple_configured(self, target):
"""Test has_at_least_one_component returns True when multiple components are configured."""
target.config = {
const.CONF_WIFI: {},
const.CONF_ETHERNET: {},
"logger": {},
}
assert (
target.has_at_least_one_component("wifi", "ethernet", "bluetooth") is True
)
def test_has_at_least_one_component__single_component(self, target):
"""Test has_at_least_one_component works with a single component."""
target.config = {const.CONF_MQTT: {}}
assert target.has_at_least_one_component("mqtt") is True
assert target.has_at_least_one_component("wifi") is False
def test_has_at_least_one_component__config_not_loaded(self, target):
"""Test has_at_least_one_component raises ValueError when config is not loaded."""
target.config = None
with pytest.raises(ValueError, match="Config has not been loaded yet"):
target.has_at_least_one_component("wifi")
def test_has_networking__with_wifi(self, target):
"""Test has_networking returns True when wifi is configured."""
target.config = {const.CONF_WIFI: {}}
assert target.has_networking is True
def test_has_networking__with_ethernet(self, target):
"""Test has_networking returns True when ethernet is configured."""
target.config = {const.CONF_ETHERNET: {}}
assert target.has_networking is True
def test_has_networking__with_openthread(self, target):
"""Test has_networking returns True when openthread is configured."""
target.config = {const.CONF_OPENTHREAD: {}}
assert target.has_networking is True
def test_has_networking__without_networking(self, target):
"""Test has_networking returns False when no networking component is configured."""
target.config = {const.CONF_ESPHOME: {"name": "test"}, "logger": {}}
assert target.has_networking is False
def test_add_library__esp32_arduino_enables_disabled_library(self, target):
"""Test add_library auto-enables Arduino libraries on ESP32 Arduino builds."""
target.data[const.KEY_CORE] = {
const.KEY_TARGET_PLATFORM: "esp32",
const.KEY_TARGET_FRAMEWORK: "arduino",
}
library = core.Library("WiFi", None)
with patch("esphome.components.esp32._enable_arduino_library") as mock_enable:
target.add_library(library)
mock_enable.assert_called_once_with("WiFi")
assert "WiFi" in target.platformio_libraries
def test_add_library__esp32_arduino_ignores_non_arduino_library(self, target):
"""Test add_library doesn't enable libraries not in ARDUINO_DISABLED_LIBRARIES."""
target.data[const.KEY_CORE] = {
const.KEY_TARGET_PLATFORM: "esp32",
const.KEY_TARGET_FRAMEWORK: "arduino",
}
library = core.Library("SomeOtherLib", "1.0.0")
with patch("esphome.components.esp32._enable_arduino_library") as mock_enable:
target.add_library(library)
mock_enable.assert_not_called()
assert "SomeOtherLib" in target.platformio_libraries
def test_add_library__esp32_idf_does_not_enable_arduino_library(self, target):
"""Test add_library doesn't auto-enable Arduino libraries on ESP32 IDF builds."""
target.data[const.KEY_CORE] = {
const.KEY_TARGET_PLATFORM: "esp32",
const.KEY_TARGET_FRAMEWORK: "esp-idf",
}
library = core.Library("WiFi", None)
with patch("esphome.components.esp32._enable_arduino_library") as mock_enable:
target.add_library(library)
mock_enable.assert_not_called()
assert "WiFi" in target.platformio_libraries
def test_add_library__esp8266_does_not_enable_arduino_library(self, target):
"""Test add_library doesn't auto-enable Arduino libraries on ESP8266."""
target.data[const.KEY_CORE] = {
const.KEY_TARGET_PLATFORM: "esp8266",
const.KEY_TARGET_FRAMEWORK: "arduino",
}
library = core.Library("WiFi", None)
with patch("esphome.components.esp32._enable_arduino_library") as mock_enable:
target.add_library(library)
mock_enable.assert_not_called()
assert "WiFi" in target.platformio_libraries
def test_testing_ensure_platform_registered__sets_count(self, target):
"""Test testing_ensure_platform_registered sets count to 1 for new platform."""
assert target.platform_counts["sensor"] == 0
target.testing_ensure_platform_registered("sensor")
assert target.platform_counts["sensor"] == 1
def test_testing_ensure_platform_registered__does_not_overwrite(self, target):
"""Test testing_ensure_platform_registered preserves existing count."""
target.platform_counts["sensor"] = 3
target.testing_ensure_platform_registered("sensor")
assert target.platform_counts["sensor"] == 3
def test_bootloader_bin__native_idf(self, target):
"""Native ESP-IDF builds emit the bootloader under build/bootloader/bootloader.bin."""
target.toolchain = const.Toolchain.ESP_IDF
assert target.bootloader_bin == Path(
"foo/build/build/bootloader/bootloader.bin"
)
def test_bootloader_bin__platformio(self, target):
"""For PlatformIO builds bootloader.bin lives in the env-specific .pioenvs directory."""
target.name = "test-device"
target.toolchain = const.Toolchain.PLATFORMIO
assert target.bootloader_bin == Path(
"foo/build/.pioenvs/test-device/bootloader.bin"
)
def test_using_toolchain_sdk_nrf(self, target):
"""using_toolchain_sdk_nrf is True only for the SDK_NRF toolchain."""
target.toolchain = const.Toolchain.SDK_NRF
assert target.using_toolchain_sdk_nrf is True
target.toolchain = const.Toolchain.ESP_IDF
assert target.using_toolchain_sdk_nrf is False
def test_using_toolchain_arduino(self, target):
"""A toolchain choice, distinct from the arduino target framework."""
target.toolchain = const.Toolchain.ARDUINO
assert target.using_toolchain_arduino is True
target.toolchain = const.Toolchain.PLATFORMIO
assert target.using_toolchain_arduino is False
def test_using_native_toolchain(self, target):
"""True exactly for the toolchains that never read platformio.ini."""
target.toolchain = const.Toolchain.ESP_IDF
assert target.using_native_toolchain is True
target.toolchain = const.Toolchain.ARDUINO
assert target.using_native_toolchain is True
target.toolchain = const.Toolchain.PLATFORMIO
assert target.using_native_toolchain is False
target.toolchain = const.Toolchain.SDK_NRF
assert target.using_native_toolchain is False
def test_add_library__extracts_short_name_from_path(self, target):
"""Test add_library extracts short name from library paths like owner/lib."""
target.data[const.KEY_CORE] = {
const.KEY_TARGET_PLATFORM: "esp32",
const.KEY_TARGET_FRAMEWORK: "arduino",
}
library = core.Library("arduino/Wire", None)
with patch("esphome.components.esp32._enable_arduino_library") as mock_enable:
target.add_library(library)
mock_enable.assert_called_once_with("Wire")
assert "Wire" in target.platformio_libraries
def test_add_build_unflag__warns_on_native_idf_toolchain(
self, target, caplog: pytest.LogCaptureFixture
) -> None:
"""Build unflags are not consumed by the native IDF build generator,
so adding one on that toolchain warns; PlatformIO stays silent."""
target.toolchain = const.Toolchain.PLATFORMIO
target.add_build_unflag("-fno-rtti")
assert "ignored" not in caplog.text
target.toolchain = const.Toolchain.ESP_IDF
target.add_build_unflag("-fno-exceptions")
assert (
"Build unflag -fno-exceptions is ignored when building with the "
"native ESP-IDF toolchain" in caplog.text
)
# The unflag is still recorded either way.
assert target.build_unflags == {"-fno-rtti", "-fno-exceptions"}
def test_add_cmake_arg(self, target) -> None:
target.add_cmake_arg("EXCLUDE_COMPONENTS", "unity;esp_lcd")
assert target.cmake_args == {"EXCLUDE_COMPONENTS": "unity;esp_lcd"}
@pytest.mark.parametrize("name", ["", "BAD NAME", 'A"B', "A(B)", "1ABC"])
def test_add_cmake_arg__rejects_invalid_name(self, target, name: str) -> None:
with pytest.raises(ValueError, match="Invalid CMake arg name"):
target.add_cmake_arg(name, "value")
@pytest.mark.parametrize("value", ["a b", "a\tb", 'a"b', "a'b", "a${FOO}b"])
def test_add_cmake_arg__rejects_invalid_value(self, target, value: str) -> None:
"""Whitespace and quotes are rejected (the PlatformIO backend passes
args as one space-joined string, which would split such a value), and
so is '$' (expanded differently by CMake and PlatformIO)."""
with pytest.raises(ValueError, match="must not contain"):
target.add_cmake_arg("MY_ARG", value)
def test_add_cmake_arg__warns_on_overwrite(
self, target, caplog: pytest.LogCaptureFixture
) -> None:
"""Re-registering with a different value is last-writer-wins; warn so
the silently dropped value is diagnosable."""
target.add_cmake_arg("MY_ARG", "one")
target.add_cmake_arg("MY_ARG", "one")
assert "overwriting" not in caplog.text
target.add_cmake_arg("MY_ARG", "two")
assert (
"CMake arg MY_ARG already set to one; overwriting with two" in caplog.text
)
assert target.cmake_args == {"MY_ARG": "two"}