1
0
mirror of https://github.com/esphome/esphome.git synced 2025-09-13 16:52:18 +01:00

Merge branch 'multi_device' into integration

This commit is contained in:
J. Nick Koston
2025-06-23 22:56:28 +02:00
32 changed files with 1418 additions and 59 deletions

View File

@@ -2,7 +2,9 @@ esphome:
debug_scheduler: true
platformio_options:
board_build.flash_mode: dio
area: testing
area:
id: testing_area
name: Testing Area
on_boot:
logger.log: on_boot
on_shutdown:
@@ -17,4 +19,20 @@ esphome:
version: "1.1"
on_update:
logger.log: on_update
areas:
- id: another_area
name: Another area
devices:
- id: other_device
name: Another device
area_id: another_area
- id: test_device
name: Test device in main area
area_id: testing_area # Reference the main area (not in areas)
- id: no_area_device
name: Device without area # This device has no area_id
binary_sensor:
- platform: template
name: Other device sensor
device_id: other_device

View File

@@ -12,7 +12,7 @@
using namespace esphome;
void setup() {
App.pre_setup("livingroom", "LivingRoom", "LivingRoomArea", "comment", __DATE__ ", " __TIME__, false);
App.pre_setup("livingroom", "LivingRoom", "comment", __DATE__ ", " __TIME__, false);
auto *log = new logger::Logger(115200, 512); // NOLINT
log->pre_setup();
log->set_uart_selection(logger::UART_SELECTION_UART0);

View File

@@ -0,0 +1,57 @@
esphome:
name: areas-devices-test
# Define top-level area
area:
id: living_room_area
name: Living Room
# Define additional areas
areas:
- id: bedroom_area
name: Bedroom
- id: kitchen_area
name: Kitchen
# Define devices with area assignments
devices:
- id: light_controller_device
name: Light Controller
area_id: living_room_area # Uses top-level area
- id: temp_sensor_device
name: Temperature Sensor
area_id: bedroom_area
- id: motion_detector_device
name: Motion Detector
area_id: living_room_area # Reuses top-level area
- id: smart_switch_device
name: Smart Switch
area_id: kitchen_area
host:
api:
logger:
# Sensors assigned to different devices
sensor:
- platform: template
name: Light Controller Sensor
device_id: light_controller_device
lambda: return 1.0;
update_interval: 0.1s
- platform: template
name: Temperature Sensor Reading
device_id: temp_sensor_device
lambda: return 2.0;
update_interval: 0.1s
- platform: template
name: Motion Detector Status
device_id: motion_detector_device
lambda: return 3.0;
update_interval: 0.1s
- platform: template
name: Smart Switch Power
device_id: smart_switch_device
lambda: return 4.0;
update_interval: 0.1s

View File

@@ -0,0 +1,15 @@
esphome:
name: legacy-area-test
# Using legacy string-based area configuration
area: Master Bedroom
host:
api:
logger:
# Simple sensor to ensure the device compiles and runs
sensor:
- platform: template
name: Test Sensor
lambda: return 42.0;
update_interval: 1s

View File

@@ -0,0 +1,121 @@
"""Integration test for areas and devices feature."""
from __future__ import annotations
import asyncio
from aioesphomeapi import EntityState
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_areas_and_devices(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test areas and devices configuration with entity mapping."""
async with run_compiled(yaml_config), api_client_connected() as client:
# Get device info which includes areas and devices
device_info = await client.device_info()
assert device_info is not None
# Verify areas are reported
areas = device_info.areas
assert len(areas) >= 2, f"Expected at least 2 areas, got {len(areas)}"
# Find our specific areas
main_area = next((a for a in areas if a.name == "Living Room"), None)
bedroom_area = next((a for a in areas if a.name == "Bedroom"), None)
kitchen_area = next((a for a in areas if a.name == "Kitchen"), None)
assert main_area is not None, "Living Room area not found"
assert bedroom_area is not None, "Bedroom area not found"
assert kitchen_area is not None, "Kitchen area not found"
# Verify devices are reported
devices = device_info.devices
assert len(devices) >= 4, f"Expected at least 4 devices, got {len(devices)}"
# Find our specific devices
light_controller = next(
(d for d in devices if d.name == "Light Controller"), None
)
temp_sensor = next((d for d in devices if d.name == "Temperature Sensor"), None)
motion_detector = next(
(d for d in devices if d.name == "Motion Detector"), None
)
smart_switch = next((d for d in devices if d.name == "Smart Switch"), None)
assert light_controller is not None, "Light Controller device not found"
assert temp_sensor is not None, "Temperature Sensor device not found"
assert motion_detector is not None, "Motion Detector device not found"
assert smart_switch is not None, "Smart Switch device not found"
# Verify device area assignments
assert light_controller.area_id == main_area.area_id, (
"Light Controller should be in Living Room"
)
assert temp_sensor.area_id == bedroom_area.area_id, (
"Temperature Sensor should be in Bedroom"
)
assert motion_detector.area_id == main_area.area_id, (
"Motion Detector should be in Living Room"
)
assert smart_switch.area_id == kitchen_area.area_id, (
"Smart Switch should be in Kitchen"
)
# Verify suggested_area is set to the top-level area name
assert device_info.suggested_area == "Living Room", (
f"Expected suggested_area to be 'Living Room', got '{device_info.suggested_area}'"
)
# Get entity list to verify device_id mapping
entities = await client.list_entities_services()
# Collect sensor entities
sensor_entities = [e for e in entities[0] if hasattr(e, "device_id")]
assert len(sensor_entities) >= 4, (
f"Expected at least 4 sensor entities, got {len(sensor_entities)}"
)
# Subscribe to states to get sensor values
loop = asyncio.get_running_loop()
states: dict[int, EntityState] = {}
states_future: asyncio.Future[bool] = loop.create_future()
def on_state(state: EntityState) -> None:
states[state.key] = state
# Check if we have all expected sensor states
if len(states) >= 4 and not states_future.done():
states_future.set_result(True)
client.subscribe_states(on_state)
# Wait for sensor states
try:
await asyncio.wait_for(states_future, timeout=10.0)
except asyncio.TimeoutError:
pytest.fail(
f"Did not receive all sensor states within 10 seconds. "
f"Received {len(states)} states"
)
# Verify we have sensor entities with proper device_id assignments
device_id_mapping = {
"Light Controller Sensor": light_controller.device_id,
"Temperature Sensor Reading": temp_sensor.device_id,
"Motion Detector Status": motion_detector.device_id,
"Smart Switch Power": smart_switch.device_id,
}
for entity in sensor_entities:
if entity.name in device_id_mapping:
expected_device_id = device_id_mapping[entity.name]
assert entity.device_id == expected_device_id, (
f"{entity.name} has device_id {entity.device_id}, "
f"expected {expected_device_id}"
)

View File

@@ -0,0 +1,41 @@
"""Integration test for legacy string-based area configuration."""
from __future__ import annotations
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_legacy_area(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test legacy string-based area configuration."""
async with run_compiled(yaml_config), api_client_connected() as client:
# Get device info which includes areas
device_info = await client.device_info()
assert device_info is not None
# Verify the area is reported (should be converted to structured format)
areas = device_info.areas
assert len(areas) == 1, f"Expected exactly 1 area, got {len(areas)}"
# Find the area - should be slugified from "Master Bedroom"
area = areas[0]
assert area.name == "Master Bedroom", (
f"Expected area name 'Master Bedroom', got '{area.name}'"
)
# Verify area.id is set (it should be a hash)
assert area.area_id > 0, "Area ID should be a positive hash value"
# The suggested_area field should be set for backward compatibility
assert device_info.suggested_area == "Master Bedroom", (
f"Expected suggested_area to be 'Master Bedroom', got '{device_info.suggested_area}'"
)
# Verify deprecated warning would have been logged during compilation
# (We can't check logs directly in integration tests, but the code should work)

View File

@@ -0,0 +1,256 @@
"""Unit tests for core config functionality including areas and devices."""
from collections.abc import Callable
from pathlib import Path
from typing import Any
from unittest.mock import patch
import pytest
from esphome import config, config_validation as cv, core, yaml_util
from esphome.config import Config
from esphome.const import CONF_AREA, CONF_AREAS, CONF_DEVICES
from esphome.core import CORE
from esphome.core.config import Area, validate_area_config
FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" / "core" / "config"
@pytest.fixture
def yaml_file(tmp_path: Path) -> Callable[[str], str]:
"""Create a temporary YAML file for testing."""
def _yaml_file(content: str) -> str:
yaml_path = tmp_path / "test.yaml"
yaml_path.write_text(content)
return str(yaml_path)
return _yaml_file
@pytest.fixture(autouse=True)
def reset_core():
"""Reset CORE after each test."""
yield
CORE.reset()
def load_config_from_yaml(
yaml_file: Callable[[str], str], yaml_content: str
) -> Config | None:
"""Load configuration from YAML content."""
yaml_path = yaml_file(yaml_content)
parsed_yaml = yaml_util.load_yaml(yaml_path)
# Mock yaml_util.load_yaml to return our parsed content
with (
patch.object(yaml_util, "load_yaml", return_value=parsed_yaml),
patch.object(CORE, "config_path", yaml_path),
):
return config.read_config({})
def load_config_from_fixture(
yaml_file: Callable[[str], str], fixture_name: str
) -> Config | None:
"""Load configuration from a fixture file."""
fixture_path = FIXTURES_DIR / fixture_name
yaml_content = fixture_path.read_text()
return load_config_from_yaml(yaml_file, yaml_content)
def test_validate_area_config_with_string() -> None:
"""Test that string area config is converted to structured format."""
result: dict[str, Any] = validate_area_config("Living Room")
assert isinstance(result, dict)
assert "id" in result
assert "name" in result
assert result["name"] == "Living Room"
assert isinstance(result["id"], core.ID)
assert result["id"].is_declaration
assert not result["id"].is_manual
def test_validate_area_config_with_dict() -> None:
"""Test that structured area config passes through unchanged."""
area_id = cv.declare_id(Area)("test_area")
input_config: dict[str, Any] = {
"id": area_id,
"name": "Test Area",
}
result: dict[str, Any] = validate_area_config(input_config)
assert result == input_config
assert result["id"] == area_id
assert result["name"] == "Test Area"
def test_device_with_valid_area_id(yaml_file: Callable[[str], str]) -> None:
"""Test that device with valid area_id works correctly."""
result = load_config_from_fixture(yaml_file, "valid_area_device.yaml")
assert result is not None
esphome_config = result["esphome"]
# Verify areas were parsed correctly
assert CONF_AREAS in esphome_config
areas = esphome_config[CONF_AREAS]
assert len(areas) == 1
assert areas[0]["id"].id == "bedroom_area"
assert areas[0]["name"] == "Bedroom"
# Verify devices were parsed correctly
assert CONF_DEVICES in esphome_config
devices = esphome_config[CONF_DEVICES]
assert len(devices) == 1
assert devices[0]["id"].id == "test_device"
assert devices[0]["name"] == "Test Device"
assert devices[0]["area_id"].id == "bedroom_area"
def test_multiple_areas_and_devices(yaml_file: Callable[[str], str]) -> None:
"""Test multiple areas and devices configuration."""
result = load_config_from_fixture(yaml_file, "multiple_areas_devices.yaml")
assert result is not None
esphome_config = result["esphome"]
# Verify main area
assert CONF_AREA in esphome_config
main_area = esphome_config[CONF_AREA]
assert main_area["id"].id == "main_area"
assert main_area["name"] == "Main Area"
# Verify additional areas
assert CONF_AREAS in esphome_config
areas = esphome_config[CONF_AREAS]
assert len(areas) == 2
area_ids = {area["id"].id for area in areas}
assert area_ids == {"area1", "area2"}
# Verify devices
assert CONF_DEVICES in esphome_config
devices = esphome_config[CONF_DEVICES]
assert len(devices) == 3
# Check device-area associations
device_area_map = {dev["id"].id: dev["area_id"].id for dev in devices}
assert device_area_map == {
"device1": "main_area",
"device2": "area1",
"device3": "area2",
}
def test_legacy_string_area(
yaml_file: Callable[[str], str], caplog: pytest.LogCaptureFixture
) -> None:
"""Test legacy string area configuration with deprecation warning."""
result = load_config_from_fixture(yaml_file, "legacy_string_area.yaml")
assert result is not None
esphome_config = result["esphome"]
# Verify the string was converted to structured format
assert CONF_AREA in esphome_config
area = esphome_config[CONF_AREA]
assert isinstance(area, dict)
assert area["name"] == "Living Room"
assert isinstance(area["id"], core.ID)
assert area["id"].is_declaration
assert not area["id"].is_manual
def test_area_id_collision(
yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str]
) -> None:
"""Test that duplicate area IDs are detected."""
result = load_config_from_fixture(yaml_file, "area_id_collision.yaml")
assert result is None
# Check for the specific error message in stdout
captured = capsys.readouterr()
# Exact duplicates are now caught by IDPassValidationStep
assert "ID duplicate_id redefined! Check esphome->area->id." in captured.out
def test_device_without_area(yaml_file: Callable[[str], str]) -> None:
"""Test that devices without area_id work correctly."""
result = load_config_from_fixture(yaml_file, "device_without_area.yaml")
assert result is not None
esphome_config = result["esphome"]
# Verify device was parsed
assert CONF_DEVICES in esphome_config
devices = esphome_config[CONF_DEVICES]
assert len(devices) == 1
device = devices[0]
assert device["id"].id == "test_device"
assert device["name"] == "Test Device"
# Verify no area_id is present
assert "area_id" not in device
def test_device_with_invalid_area_id(
yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str]
) -> None:
"""Test that device with non-existent area_id fails validation."""
result = load_config_from_fixture(yaml_file, "device_invalid_area.yaml")
assert result is None
# Check for the specific error message in stdout
captured = capsys.readouterr()
print(captured.out)
assert (
"Couldn't find ID 'nonexistent_area'. Please check you have defined an ID with that name in your configuration."
in captured.out
)
def test_device_id_hash_collision(
yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str]
) -> None:
"""Test that device IDs with hash collisions are detected."""
result = load_config_from_fixture(yaml_file, "device_id_collision.yaml")
assert result is None
# Check for the specific error message about hash collision
captured = capsys.readouterr()
# The error message shows the ID that collides and includes the hash value
assert (
"Device ID 'd6ka' with hash 3082558663 collides with existing device ID 'test_2258'"
in captured.out
)
def test_area_id_hash_collision(
yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str]
) -> None:
"""Test that area IDs with hash collisions are detected."""
result = load_config_from_fixture(yaml_file, "area_id_hash_collision.yaml")
assert result is None
# Check for the specific error message about hash collision
captured = capsys.readouterr()
# The error message shows the ID that collides and includes the hash value
assert (
"Area ID 'd6ka' with hash 3082558663 collides with existing area ID 'test_2258'"
in captured.out
)
def test_device_duplicate_id(
yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str]
) -> None:
"""Test that duplicate device IDs are detected by IDPassValidationStep."""
result = load_config_from_fixture(yaml_file, "device_duplicate_id.yaml")
assert result is None
# Check for the specific error message from IDPassValidationStep
captured = capsys.readouterr()
assert "ID duplicate_device redefined!" in captured.out

View File

@@ -0,0 +1,10 @@
esphome:
name: test-collision
area:
id: duplicate_id
name: Area 1
areas:
- id: duplicate_id
name: Area 2
host:

View File

@@ -0,0 +1,10 @@
esphome:
name: test
areas:
- id: test_2258
name: "Area 1"
- id: d6ka
name: "Area 2"
esp32:
board: esp32dev

View File

@@ -0,0 +1,10 @@
esphome:
name: test
devices:
- id: duplicate_device
name: "Device 1"
- id: duplicate_device
name: "Device 2"
esp32:
board: esp32dev

View File

@@ -0,0 +1,10 @@
esphome:
name: test
devices:
- id: test_2258
name: "Device 1"
- id: d6ka
name: "Device 2"
esp32:
board: esp32dev

View File

@@ -0,0 +1,12 @@
esphome:
name: test
areas:
- id: valid_area
name: "Valid Area"
devices:
- id: test_device
name: "Test Device"
area_id: nonexistent_area
esp32:
board: esp32dev

View File

@@ -0,0 +1,7 @@
esphome:
name: test-device-no-area
devices:
- id: test_device
name: Test Device
host:

View File

@@ -0,0 +1,5 @@
esphome:
name: test-legacy-area
area: Living Room
host:

View File

@@ -0,0 +1,22 @@
esphome:
name: test-multiple
area:
id: main_area
name: Main Area
areas:
- id: area1
name: Area 1
- id: area2
name: Area 2
devices:
- id: device1
name: Device 1
area_id: main_area
- id: device2
name: Device 2
area_id: area1
- id: device3
name: Device 3
area_id: area2
host:

View File

@@ -0,0 +1,11 @@
esphome:
name: test-valid-area
areas:
- id: bedroom_area
name: Bedroom
devices:
- id: test_device
name: Test Device
area_id: bedroom_area
host: