1
0
mirror of https://github.com/esphome/esphome.git synced 2025-09-02 11:22:24 +01:00

[API] Sub devices and areas (#8544)

Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick+github@koston.org>
This commit is contained in:
DanielV
2025-06-25 14:03:41 +02:00
committed by GitHub
parent 7c28134214
commit b18ff48b4a
79 changed files with 2756 additions and 186 deletions

View File

@@ -203,6 +203,7 @@ async def compile_esphome(
loop = asyncio.get_running_loop()
def _read_config_and_get_binary():
CORE.reset() # Reset CORE state between test runs
CORE.config_path = str(config_path)
config = esphome.config.read_config(
{"command": "compile", "config": str(config_path)}

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,154 @@
esphome:
name: duplicate-entities-test
# Define devices to test multi-device duplicate handling
devices:
- id: controller_1
name: Controller 1
- id: controller_2
name: Controller 2
- id: controller_3
name: Controller 3
host:
api: # Port will be automatically injected
logger:
# Test that duplicate entity names are allowed on different devices
# Scenario 1: Same sensor name on different devices (allowed)
sensor:
- platform: template
name: Temperature
device_id: controller_1
lambda: return 21.0;
update_interval: 0.1s
- platform: template
name: Temperature
device_id: controller_2
lambda: return 22.0;
update_interval: 0.1s
- platform: template
name: Temperature
device_id: controller_3
lambda: return 23.0;
update_interval: 0.1s
# Main device sensor (no device_id)
- platform: template
name: Temperature
lambda: return 20.0;
update_interval: 0.1s
# Different sensor with unique name
- platform: template
name: Humidity
lambda: return 60.0;
update_interval: 0.1s
# Scenario 2: Same binary sensor name on different devices (allowed)
binary_sensor:
- platform: template
name: Status
device_id: controller_1
lambda: return true;
- platform: template
name: Status
device_id: controller_2
lambda: return false;
- platform: template
name: Status
lambda: return true; # Main device
# Different platform can have same name as sensor
- platform: template
name: Temperature
lambda: return true;
# Scenario 3: Same text sensor name on different devices
text_sensor:
- platform: template
name: Device Info
device_id: controller_1
lambda: return {"Controller 1 Active"};
update_interval: 0.1s
- platform: template
name: Device Info
device_id: controller_2
lambda: return {"Controller 2 Active"};
update_interval: 0.1s
- platform: template
name: Device Info
lambda: return {"Main Device Active"};
update_interval: 0.1s
# Scenario 4: Same switch name on different devices
switch:
- platform: template
name: Power
device_id: controller_1
lambda: return false;
turn_on_action: []
turn_off_action: []
- platform: template
name: Power
device_id: controller_2
lambda: return true;
turn_on_action: []
turn_off_action: []
- platform: template
name: Power
device_id: controller_3
lambda: return false;
turn_on_action: []
turn_off_action: []
# Unique switch on main device
- platform: template
name: Main Power
lambda: return true;
turn_on_action: []
turn_off_action: []
# Scenario 5: Empty names on different devices (should use device name)
button:
- platform: template
name: ""
device_id: controller_1
on_press: []
- platform: template
name: ""
device_id: controller_2
on_press: []
- platform: template
name: ""
on_press: [] # Main device
# Scenario 6: Special characters in names
number:
- platform: template
name: "Temperature Setpoint!"
device_id: controller_1
min_value: 10.0
max_value: 30.0
step: 0.1
lambda: return 21.0;
set_action: []
- platform: template
name: "Temperature Setpoint!"
device_id: controller_2
min_value: 10.0
max_value: 30.0
step: 0.1
lambda: return 22.0;
set_action: []

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,184 @@
"""Integration test for duplicate entity handling with new validation."""
from __future__ import annotations
import asyncio
from aioesphomeapi import EntityInfo
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_duplicate_entities_on_different_devices(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that duplicate entity names are allowed on different devices."""
async with run_compiled(yaml_config), api_client_connected() as client:
# Get device info
device_info = await client.device_info()
assert device_info is not None
# Get devices
devices = device_info.devices
assert len(devices) >= 3, f"Expected at least 3 devices, got {len(devices)}"
# Find our test devices
controller_1 = next((d for d in devices if d.name == "Controller 1"), None)
controller_2 = next((d for d in devices if d.name == "Controller 2"), None)
controller_3 = next((d for d in devices if d.name == "Controller 3"), None)
assert controller_1 is not None, "Controller 1 device not found"
assert controller_2 is not None, "Controller 2 device not found"
assert controller_3 is not None, "Controller 3 device not found"
# Get entity list
entities = await client.list_entities_services()
all_entities: list[EntityInfo] = []
for entity_list in entities[0]:
all_entities.append(entity_list)
# Group entities by type for easier testing
sensors = [e for e in all_entities if e.__class__.__name__ == "SensorInfo"]
binary_sensors = [
e for e in all_entities if e.__class__.__name__ == "BinarySensorInfo"
]
text_sensors = [
e for e in all_entities if e.__class__.__name__ == "TextSensorInfo"
]
switches = [e for e in all_entities if e.__class__.__name__ == "SwitchInfo"]
buttons = [e for e in all_entities if e.__class__.__name__ == "ButtonInfo"]
numbers = [e for e in all_entities if e.__class__.__name__ == "NumberInfo"]
# Scenario 1: Check sensors with same "Temperature" name on different devices
temp_sensors = [s for s in sensors if s.name == "Temperature"]
assert len(temp_sensors) == 4, (
f"Expected exactly 4 temperature sensors, got {len(temp_sensors)}"
)
# Verify each sensor is on a different device
temp_device_ids = set()
temp_object_ids = set()
for sensor in temp_sensors:
temp_device_ids.add(sensor.device_id)
temp_object_ids.add(sensor.object_id)
# All should have object_id "temperature" (no suffix)
assert sensor.object_id == "temperature", (
f"Expected object_id 'temperature', got '{sensor.object_id}'"
)
# Should have 4 different device IDs (including None for main device)
assert len(temp_device_ids) == 4, (
f"Temperature sensors should be on different devices, got {temp_device_ids}"
)
# Scenario 2: Check binary sensors "Status" on different devices
status_binary = [b for b in binary_sensors if b.name == "Status"]
assert len(status_binary) == 3, (
f"Expected exactly 3 status binary sensors, got {len(status_binary)}"
)
# All should have object_id "status"
for binary in status_binary:
assert binary.object_id == "status", (
f"Expected object_id 'status', got '{binary.object_id}'"
)
# Scenario 3: Check that sensor and binary_sensor can have same name
temp_binary = [b for b in binary_sensors if b.name == "Temperature"]
assert len(temp_binary) == 1, (
f"Expected exactly 1 temperature binary sensor, got {len(temp_binary)}"
)
assert temp_binary[0].object_id == "temperature"
# Scenario 4: Check text sensors "Device Info" on different devices
info_text = [t for t in text_sensors if t.name == "Device Info"]
assert len(info_text) == 3, (
f"Expected exactly 3 device info text sensors, got {len(info_text)}"
)
# All should have object_id "device_info"
for text in info_text:
assert text.object_id == "device_info", (
f"Expected object_id 'device_info', got '{text.object_id}'"
)
# Scenario 5: Check switches "Power" on different devices
power_switches = [s for s in switches if s.name == "Power"]
assert len(power_switches) == 3, (
f"Expected exactly 3 power switches, got {len(power_switches)}"
)
# All should have object_id "power"
for switch in power_switches:
assert switch.object_id == "power", (
f"Expected object_id 'power', got '{switch.object_id}'"
)
# Scenario 6: Check empty name buttons (should use device name)
empty_buttons = [b for b in buttons if b.name == ""]
assert len(empty_buttons) == 3, (
f"Expected exactly 3 empty name buttons, got {len(empty_buttons)}"
)
# Group by device
c1_buttons = [b for b in empty_buttons if b.device_id == controller_1.device_id]
c2_buttons = [b for b in empty_buttons if b.device_id == controller_2.device_id]
# For main device, device_id is 0
main_buttons = [b for b in empty_buttons if b.device_id == 0]
# Check object IDs for empty name entities
assert len(c1_buttons) == 1 and c1_buttons[0].object_id == "controller_1"
assert len(c2_buttons) == 1 and c2_buttons[0].object_id == "controller_2"
assert (
len(main_buttons) == 1
and main_buttons[0].object_id == "duplicate-entities-test"
)
# Scenario 7: Check special characters in number names
temp_numbers = [n for n in numbers if n.name == "Temperature Setpoint!"]
assert len(temp_numbers) == 2, (
f"Expected exactly 2 temperature setpoint numbers, got {len(temp_numbers)}"
)
# Special characters should be sanitized to _ in object_id
for number in temp_numbers:
assert number.object_id == "temperature_setpoint_", (
f"Expected object_id 'temperature_setpoint_', got '{number.object_id}'"
)
# Verify we can get states for all entities (ensures they're functional)
loop = asyncio.get_running_loop()
states_future: asyncio.Future[None] = loop.create_future()
state_count = 0
expected_count = (
len(sensors)
+ len(binary_sensors)
+ len(text_sensors)
+ len(switches)
+ len(buttons)
+ len(numbers)
)
def on_state(state) -> None:
nonlocal state_count
state_count += 1
if state_count >= expected_count and not states_future.done():
states_future.set_result(None)
client.subscribe_states(on_state)
# Wait for all entity states
try:
await asyncio.wait_for(states_future, timeout=10.0)
except asyncio.TimeoutError:
pytest.fail(
f"Did not receive all entity states within 10 seconds. "
f"Expected {expected_count}, received {state_count}"
)

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)