1
0
mirror of https://github.com/esphome/esphome.git synced 2025-09-02 03:12:20 +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

@@ -12,12 +12,12 @@ sensor:
frequency: 60Hz
phase_a:
name: Channel A
voltage: Voltage
current: Current
active_power: Active Power
power_factor: Power Factor
forward_active_energy: Forward Active Energy
reverse_active_energy: Reverse Active Energy
voltage: Channel A Voltage
current: Channel A Current
active_power: Channel A Active Power
power_factor: Channel A Power Factor
forward_active_energy: Channel A Forward Active Energy
reverse_active_energy: Channel A Reverse Active Energy
calibration:
current_gain: 3116628
voltage_gain: -757178
@@ -25,12 +25,12 @@ sensor:
phase_angle: 188
phase_b:
name: Channel B
voltage: Voltage
current: Current
active_power: Active Power
power_factor: Power Factor
forward_active_energy: Forward Active Energy
reverse_active_energy: Reverse Active Energy
voltage: Channel B Voltage
current: Channel B Current
active_power: Channel B Active Power
power_factor: Channel B Power Factor
forward_active_energy: Channel B Forward Active Energy
reverse_active_energy: Channel B Reverse Active Energy
calibration:
current_gain: 3133655
voltage_gain: -755235
@@ -38,12 +38,12 @@ sensor:
phase_angle: 188
phase_c:
name: Channel C
voltage: Voltage
current: Current
active_power: Active Power
power_factor: Power Factor
forward_active_energy: Forward Active Energy
reverse_active_energy: Reverse Active Energy
voltage: Channel C Voltage
current: Channel C Current
active_power: Channel C Active Power
power_factor: Channel C Power Factor
forward_active_energy: Channel C Forward Active Energy
reverse_active_energy: Channel C Reverse Active Energy
calibration:
current_gain: 3111158
voltage_gain: -743813
@@ -51,6 +51,6 @@ sensor:
phase_angle: 180
neutral:
name: Neutral
current: Current
current: Neutral Current
calibration:
current_gain: 3189

View File

@@ -26,7 +26,7 @@ alarm_control_panel:
ESP_LOGD("TEST", "State change %s", LOG_STR_ARG(alarm_control_panel_state_to_string(id(alarmcontrolpanel1)->get_state())));
- platform: template
id: alarmcontrolpanel2
name: Alarm Panel
name: Alarm Panel 2
codes:
- "1234"
requires_code_to_arm: true

View File

@@ -26,7 +26,7 @@ binary_sensor:
sensor:
- platform: binary_sensor_map
name: Binary Sensor Map
name: Binary Sensor Map Group
type: group
channels:
- binary_sensor: bin1
@@ -36,7 +36,7 @@ sensor:
- binary_sensor: bin3
value: 100.0
- platform: binary_sensor_map
name: Binary Sensor Map
name: Binary Sensor Map Sum
type: sum
channels:
- binary_sensor: bin1
@@ -46,7 +46,7 @@ sensor:
- binary_sensor: bin3
value: 100.0
- platform: binary_sensor_map
name: Binary Sensor Map
name: Binary Sensor Map Bayesian
type: bayesian
prior: 0.4
observations:

View File

@@ -5,7 +5,7 @@ one_wire:
sensor:
- platform: dallas_temp
address: 0x1C0000031EDD2A28
name: Dallas Temperature
name: Dallas Temperature 1
resolution: 9
- platform: dallas_temp
name: Dallas Temperature
name: Dallas Temperature 2

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

@@ -7,20 +7,20 @@ climate:
protocol: mitsubishi_heavy_zm
horizontal_default: left
vertical_default: up
name: HeatpumpIR Climate
name: HeatpumpIR Climate Mitsubishi
min_temperature: 18
max_temperature: 30
- platform: heatpumpir
protocol: daikin
horizontal_default: mleft
vertical_default: mup
name: HeatpumpIR Climate
name: HeatpumpIR Climate Daikin
min_temperature: 18
max_temperature: 30
- platform: heatpumpir
protocol: panasonic_altdke
horizontal_default: mright
vertical_default: mdown
name: HeatpumpIR Climate
name: HeatpumpIR Climate Panasonic
min_temperature: 18
max_temperature: 30

View File

@@ -114,7 +114,7 @@ light:
warm_white_color_temperature: 500 mireds
- platform: rgb
id: test_rgb_light_initial_state
name: RGB Light
name: RGB Light Initial State
red: test_ledc_1
green: test_ledc_2
blue: test_ledc_3

View File

@@ -6,13 +6,13 @@ i2c:
sensor:
- platform: ltr390
uv:
name: LTR390 UV
name: LTR390 UV 1
uv_index:
name: LTR390 UVI
name: LTR390 UVI 1
light:
name: LTR390 Light
name: LTR390 Light 1
ambient_light:
name: LTR390 ALS
name: LTR390 ALS 1
gain: X3
resolution: 18
window_correction_factor: 1.0
@@ -20,13 +20,13 @@ sensor:
update_interval: 60s
- platform: ltr390
uv:
name: LTR390 UV
name: LTR390 UV 2
uv_index:
name: LTR390 UVI
name: LTR390 UVI 2
light:
name: LTR390 Light
name: LTR390 Light 2
ambient_light:
name: LTR390 ALS
name: LTR390 ALS 2
gain:
ambient_light: X9
uv: X3

View File

@@ -24,33 +24,33 @@ sensor:
widget: lv_arc
- platform: lvgl
widget: slider_id
name: LVGL Slider
name: LVGL Slider Sensor
- platform: lvgl
widget: bar_id
id: lvgl_bar_sensor
name: LVGL Bar
name: LVGL Bar Sensor
- platform: lvgl
widget: spinbox_id
name: LVGL Spinbox
name: LVGL Spinbox Sensor
number:
- platform: lvgl
widget: slider_id
name: LVGL Slider
name: LVGL Slider Number
update_on_release: true
restore_value: true
- platform: lvgl
widget: lv_arc
id: lvgl_arc_number
name: LVGL Arc
name: LVGL Arc Number
- platform: lvgl
widget: bar_id
id: lvgl_bar_number
name: LVGL Bar
name: LVGL Bar Number
- platform: lvgl
widget: spinbox_id
id: lvgl_spinbox_number
name: LVGL Spinbox
name: LVGL Spinbox Number
light:
- platform: lvgl

View File

@@ -170,4 +170,4 @@ switch:
otc_active:
name: "Boiler Outside temperature compensation active"
ch2_active:
name: "Boiler Central Heating 2 active"
name: "Boiler Central Heating 2 active status"

View File

@@ -5,7 +5,7 @@ packages:
- !include package.yaml
- github://esphome/esphome/tests/components/template/common.yaml@dev
- url: https://github.com/esphome/esphome
file: tests/components/binary_sensor_map/common.yaml
file: tests/components/absolute_humidity/common.yaml
ref: dev
refresh: 1d

View File

@@ -7,7 +7,7 @@ packages:
shorthand: github://esphome/esphome/tests/components/template/common.yaml@dev
github:
url: https://github.com/esphome/esphome
file: tests/components/binary_sensor_map/common.yaml
file: tests/components/absolute_humidity/common.yaml
ref: dev
refresh: 1d

View File

@@ -115,7 +115,7 @@ button:
address: 0x00
command: 0x0B
- platform: template
name: RC5
name: RC5 Raw
on_press:
remote_transmitter.transmit_raw:
code: [1000, -1000]

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

@@ -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)

View File

@@ -14,6 +14,8 @@ import sys
import pytest
from esphome.core import CORE
here = Path(__file__).parent
# Configure location of package root
@@ -21,6 +23,13 @@ package_root = here.parent.parent
sys.path.insert(0, package_root.as_posix())
@pytest.fixture(autouse=True)
def reset_core():
"""Reset CORE after each test."""
yield
CORE.reset()
@pytest.fixture
def fixture_path() -> Path:
"""

View File

View File

@@ -0,0 +1,33 @@
"""Common test utilities for core unit tests."""
from collections.abc import Callable
from pathlib import Path
from unittest.mock import patch
from esphome import config, yaml_util
from esphome.config import Config
from esphome.core import CORE
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, fixtures_dir: Path
) -> 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)

View File

@@ -0,0 +1,18 @@
"""Shared fixtures for core unit tests."""
from collections.abc import Callable
from pathlib import Path
import pytest
@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

View File

@@ -0,0 +1,225 @@
"""Unit tests for core config functionality including areas and devices."""
from collections.abc import Callable
from pathlib import Path
from typing import Any
import pytest
from esphome import config_validation as cv, core
from esphome.const import CONF_AREA, CONF_AREAS, CONF_DEVICES
from esphome.core.config import Area, validate_area_config
from .common import load_config_from_fixture
FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" / "core" / "config"
def test_validate_area_config_with_string() -> None:
"""Test that string area config is converted to structured format."""
result = 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 = 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", FIXTURES_DIR)
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", FIXTURES_DIR
)
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", FIXTURES_DIR
)
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", FIXTURES_DIR)
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", FIXTURES_DIR
)
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", FIXTURES_DIR
)
assert result is None
# Check for the specific error message in stdout
captured = capsys.readouterr()
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", FIXTURES_DIR
)
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", FIXTURES_DIR
)
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", FIXTURES_DIR
)
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,595 @@
"""Test get_base_entity_object_id function matches C++ behavior."""
from collections.abc import Callable, Generator
from pathlib import Path
import re
from typing import Any
import pytest
from esphome.config_validation import Invalid
from esphome.const import CONF_DEVICE_ID, CONF_DISABLED_BY_DEFAULT, CONF_ICON, CONF_NAME
from esphome.core import CORE, ID, entity_helpers
from esphome.core.entity_helpers import get_base_entity_object_id, setup_entity
from esphome.cpp_generator import MockObj
from esphome.helpers import sanitize, snake_case
from .common import load_config_from_fixture
# Pre-compiled regex pattern for extracting object IDs from expressions
OBJECT_ID_PATTERN = re.compile(r'\.set_object_id\(["\'](.*?)["\']\)')
FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" / "core" / "entity_helpers"
@pytest.fixture(autouse=True)
def restore_core_state() -> Generator[None, None, None]:
"""Save and restore CORE state for tests."""
original_name = CORE.name
original_friendly_name = CORE.friendly_name
yield
CORE.name = original_name
CORE.friendly_name = original_friendly_name
def test_with_entity_name() -> None:
"""Test when entity has its own name - should use entity name."""
# Simple name
assert get_base_entity_object_id("Temperature Sensor", None) == "temperature_sensor"
assert (
get_base_entity_object_id("Temperature Sensor", "Device Name")
== "temperature_sensor"
)
# Even with device name, entity name takes precedence
assert (
get_base_entity_object_id("Temperature Sensor", "Device Name", "Sub Device")
== "temperature_sensor"
)
# Name with special characters
assert (
get_base_entity_object_id("Temp!@#$%^&*()Sensor", None)
== "temp__________sensor"
)
assert get_base_entity_object_id("Temp-Sensor_123", None) == "temp-sensor_123"
# Already snake_case
assert get_base_entity_object_id("temperature_sensor", None) == "temperature_sensor"
# Mixed case
assert get_base_entity_object_id("TemperatureSensor", None) == "temperaturesensor"
assert get_base_entity_object_id("TEMPERATURE SENSOR", None) == "temperature_sensor"
def test_empty_name_with_device_name() -> None:
"""Test when entity has empty name and is on a sub-device - should use device name."""
# C++ behavior: when has_own_name is false and device is set, uses device->get_name()
assert (
get_base_entity_object_id("", "Friendly Device", "Sub Device 1")
== "sub_device_1"
)
assert (
get_base_entity_object_id("", "Kitchen Controller", "controller_1")
== "controller_1"
)
assert get_base_entity_object_id("", None, "Test-Device_123") == "test-device_123"
def test_empty_name_with_friendly_name() -> None:
"""Test when entity has empty name and no device - should use friendly name."""
# C++ behavior: when has_own_name is false, uses App.get_friendly_name()
assert get_base_entity_object_id("", "Friendly Device") == "friendly_device"
assert get_base_entity_object_id("", "Kitchen Controller") == "kitchen_controller"
assert get_base_entity_object_id("", "Test-Device_123") == "test-device_123"
# Special characters in friendly name
assert get_base_entity_object_id("", "Device!@#$%") == "device_____"
def test_empty_name_no_friendly_name() -> None:
"""Test when entity has empty name and no friendly name - should use device name."""
# Test with CORE.name set
CORE.name = "device-name"
assert get_base_entity_object_id("", None) == "device-name"
CORE.name = "Test Device"
assert get_base_entity_object_id("", None) == "test_device"
def test_edge_cases() -> None:
"""Test edge cases."""
# Only spaces
assert get_base_entity_object_id(" ", None) == "___"
# Unicode characters (should be replaced)
assert get_base_entity_object_id("Température", None) == "temp_rature"
assert get_base_entity_object_id("测试", None) == "__"
# Empty string with empty friendly name (empty friendly name is treated as None)
# Falls back to CORE.name
CORE.name = "device"
assert get_base_entity_object_id("", "") == "device"
# Very long name (should work fine)
long_name = "a" * 100 + " " + "b" * 100
expected = "a" * 100 + "_" + "b" * 100
assert get_base_entity_object_id(long_name, None) == expected
@pytest.mark.parametrize(
("name", "expected"),
[
("Temperature Sensor", "temperature_sensor"),
("Living Room Light", "living_room_light"),
("Test-Device_123", "test-device_123"),
("Special!@#Chars", "special___chars"),
("UPPERCASE NAME", "uppercase_name"),
("lowercase name", "lowercase_name"),
("Mixed Case Name", "mixed_case_name"),
(" Spaces ", "___spaces___"),
],
)
def test_matches_cpp_helpers(name: str, expected: str) -> None:
"""Test that the logic matches using snake_case and sanitize directly."""
# For non-empty names, verify our function produces same result as direct snake_case + sanitize
assert get_base_entity_object_id(name, None) == sanitize(snake_case(name))
assert get_base_entity_object_id(name, None) == expected
def test_empty_name_fallback() -> None:
"""Test empty name handling which falls back to friendly_name or CORE.name."""
# Empty name is handled specially - it doesn't just use sanitize(snake_case(""))
# Instead it falls back to friendly_name or CORE.name
assert sanitize(snake_case("")) == "" # Direct conversion gives empty string
# But our function returns a fallback
CORE.name = "device"
assert get_base_entity_object_id("", None) == "device" # Uses device name
def test_name_add_mac_suffix_behavior() -> None:
"""Test behavior related to name_add_mac_suffix.
In C++, when name_add_mac_suffix is enabled and entity has no name,
get_object_id() returns str_sanitize(str_snake_case(App.get_friendly_name()))
dynamically. Our function always returns the same result since we're
calculating the base for duplicate tracking.
"""
# The function should always return the same result regardless of
# name_add_mac_suffix setting, as we're calculating the base object_id
assert get_base_entity_object_id("", "Test Device") == "test_device"
assert get_base_entity_object_id("Entity Name", "Test Device") == "entity_name"
def test_priority_order() -> None:
"""Test the priority order: entity name > device name > friendly name > CORE.name."""
CORE.name = "core-device"
# 1. Entity name has highest priority
assert (
get_base_entity_object_id("Entity Name", "Friendly Name", "Device Name")
== "entity_name"
)
# 2. Device name is next priority (when entity name is empty)
assert (
get_base_entity_object_id("", "Friendly Name", "Device Name") == "device_name"
)
# 3. Friendly name is next (when entity and device names are empty)
assert get_base_entity_object_id("", "Friendly Name", None) == "friendly_name"
# 4. CORE.name is last resort
assert get_base_entity_object_id("", None, None) == "core-device"
@pytest.mark.parametrize(
("name", "friendly_name", "device_name", "expected"),
[
# name, friendly_name, device_name, expected
("Living Room Light", None, None, "living_room_light"),
("", "Kitchen Controller", None, "kitchen_controller"),
(
"",
"ESP32 Device",
"controller_1",
"controller_1",
), # Device name takes precedence
("GPIO2 Button", None, None, "gpio2_button"),
("WiFi Signal", "My Device", None, "wifi_signal"),
("", None, "esp32_node", "esp32_node"),
("Front Door Sensor", "Home Assistant", "door_controller", "front_door_sensor"),
],
)
def test_real_world_examples(
name: str, friendly_name: str | None, device_name: str | None, expected: str
) -> None:
"""Test real-world entity naming scenarios."""
result = get_base_entity_object_id(name, friendly_name, device_name)
assert result == expected
def test_issue_6953_scenarios() -> None:
"""Test specific scenarios from issue #6953."""
# Scenario 1: Multiple empty names on main device with name_add_mac_suffix
# The Python code calculates the base, C++ might append MAC suffix dynamically
CORE.name = "device-name"
CORE.friendly_name = "Friendly Device"
# All empty names should resolve to same base
assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device"
assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device"
assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device"
# Scenario 2: Empty names on sub-devices
assert (
get_base_entity_object_id("", "Main Device", "controller_1") == "controller_1"
)
assert (
get_base_entity_object_id("", "Main Device", "controller_2") == "controller_2"
)
# Scenario 3: xyz duplicates
assert get_base_entity_object_id("xyz", None) == "xyz"
assert get_base_entity_object_id("xyz", "Device") == "xyz"
# Tests for setup_entity function
@pytest.fixture
def setup_test_environment() -> Generator[list[str], None, None]:
"""Set up test environment for setup_entity tests."""
# Set CORE state for tests
CORE.name = "test-device"
CORE.friendly_name = "Test Device"
# Store original add function
original_add = entity_helpers.add
# Track what gets added
added_expressions: list[str] = []
def mock_add(expression: Any) -> Any:
added_expressions.append(str(expression))
return original_add(expression)
# Patch add function in entity_helpers module
entity_helpers.add = mock_add
yield added_expressions
# Clean up
entity_helpers.add = original_add
def extract_object_id_from_expressions(expressions: list[str]) -> str | None:
"""Extract the object ID that was set from the generated expressions."""
for expr in expressions:
# Look for set_object_id calls with regex to handle various formats
# Matches: var.set_object_id("temperature_2") or var.set_object_id('temperature_2')
if match := OBJECT_ID_PATTERN.search(expr):
return match.group(1)
return None
@pytest.mark.asyncio
async def test_setup_entity_no_duplicates(setup_test_environment: list[str]) -> None:
"""Test setup_entity with unique names."""
added_expressions = setup_test_environment
# Create mock entities
var1 = MockObj("sensor1")
var2 = MockObj("sensor2")
# Set up first entity
config1 = {
CONF_NAME: "Temperature",
CONF_DISABLED_BY_DEFAULT: False,
}
await setup_entity(var1, config1, "sensor")
# Get object ID from first entity
object_id1 = extract_object_id_from_expressions(added_expressions)
assert object_id1 == "temperature"
# Clear for next entity
added_expressions.clear()
# Set up second entity with different name
config2 = {
CONF_NAME: "Humidity",
CONF_DISABLED_BY_DEFAULT: False,
}
await setup_entity(var2, config2, "sensor")
# Get object ID from second entity
object_id2 = extract_object_id_from_expressions(added_expressions)
assert object_id2 == "humidity"
@pytest.mark.asyncio
async def test_setup_entity_different_platforms(
setup_test_environment: list[str],
) -> None:
"""Test that same name on different platforms doesn't conflict."""
added_expressions = setup_test_environment
# Create mock entities
sensor = MockObj("sensor1")
binary_sensor = MockObj("binary_sensor1")
text_sensor = MockObj("text_sensor1")
config = {
CONF_NAME: "Status",
CONF_DISABLED_BY_DEFAULT: False,
}
# Set up entities on different platforms
platforms = [
(sensor, "sensor"),
(binary_sensor, "binary_sensor"),
(text_sensor, "text_sensor"),
]
object_ids: list[str] = []
for var, platform in platforms:
added_expressions.clear()
await setup_entity(var, config, platform)
object_id = extract_object_id_from_expressions(added_expressions)
object_ids.append(object_id)
# All should get base object ID without suffix
assert all(obj_id == "status" for obj_id in object_ids)
@pytest.fixture
def mock_get_variable() -> Generator[dict[ID, MockObj], None, None]:
"""Mock get_variable to return test devices."""
devices = {}
original_get_variable = entity_helpers.get_variable
async def _mock_get_variable(device_id: ID) -> MockObj:
if device_id in devices:
return devices[device_id]
return await original_get_variable(device_id)
entity_helpers.get_variable = _mock_get_variable
yield devices
# Clean up
entity_helpers.get_variable = original_get_variable
@pytest.mark.asyncio
async def test_setup_entity_with_devices(
setup_test_environment: list[str], mock_get_variable: dict[ID, MockObj]
) -> None:
"""Test that same name on different devices doesn't conflict."""
added_expressions = setup_test_environment
# Create mock devices
device1_id = ID("device1", type="Device")
device2_id = ID("device2", type="Device")
device1 = MockObj("device1_obj")
device2 = MockObj("device2_obj")
# Register devices with the mock
mock_get_variable[device1_id] = device1
mock_get_variable[device2_id] = device2
# Create sensors with same name on different devices
sensor1 = MockObj("sensor1")
sensor2 = MockObj("sensor2")
config1 = {
CONF_NAME: "Temperature",
CONF_DEVICE_ID: device1_id,
CONF_DISABLED_BY_DEFAULT: False,
}
config2 = {
CONF_NAME: "Temperature",
CONF_DEVICE_ID: device2_id,
CONF_DISABLED_BY_DEFAULT: False,
}
# Get object IDs
object_ids: list[str] = []
for var, config in [(sensor1, config1), (sensor2, config2)]:
added_expressions.clear()
await setup_entity(var, config, "sensor")
object_id = extract_object_id_from_expressions(added_expressions)
object_ids.append(object_id)
# Both should get base object ID without suffix (different devices)
assert object_ids[0] == "temperature"
assert object_ids[1] == "temperature"
@pytest.mark.asyncio
async def test_setup_entity_empty_name(setup_test_environment: list[str]) -> None:
"""Test setup_entity with empty entity name."""
added_expressions = setup_test_environment
var = MockObj("sensor1")
config = {
CONF_NAME: "",
CONF_DISABLED_BY_DEFAULT: False,
}
await setup_entity(var, config, "sensor")
object_id = extract_object_id_from_expressions(added_expressions)
# Should use friendly name
assert object_id == "test_device"
@pytest.mark.asyncio
async def test_setup_entity_special_characters(
setup_test_environment: list[str],
) -> None:
"""Test setup_entity with names containing special characters."""
added_expressions = setup_test_environment
var = MockObj("sensor1")
config = {
CONF_NAME: "Temperature Sensor!",
CONF_DISABLED_BY_DEFAULT: False,
}
await setup_entity(var, config, "sensor")
object_id = extract_object_id_from_expressions(added_expressions)
# Special characters should be sanitized
assert object_id == "temperature_sensor_"
@pytest.mark.asyncio
async def test_setup_entity_with_icon(setup_test_environment: list[str]) -> None:
"""Test setup_entity sets icon correctly."""
added_expressions = setup_test_environment
var = MockObj("sensor1")
config = {
CONF_NAME: "Temperature",
CONF_DISABLED_BY_DEFAULT: False,
CONF_ICON: "mdi:thermometer",
}
await setup_entity(var, config, "sensor")
# Check icon was set
assert any(
'sensor1.set_icon("mdi:thermometer")' in expr for expr in added_expressions
)
@pytest.mark.asyncio
async def test_setup_entity_disabled_by_default(
setup_test_environment: list[str],
) -> None:
"""Test setup_entity sets disabled_by_default correctly."""
added_expressions = setup_test_environment
var = MockObj("sensor1")
config = {
CONF_NAME: "Temperature",
CONF_DISABLED_BY_DEFAULT: True,
}
await setup_entity(var, config, "sensor")
# Check disabled_by_default was set
assert any(
"sensor1.set_disabled_by_default(true)" in expr for expr in added_expressions
)
def test_entity_duplicate_validator() -> None:
"""Test the entity_duplicate_validator function."""
from esphome.core.entity_helpers import entity_duplicate_validator
# Reset CORE unique_ids for clean test
CORE.unique_ids.clear()
# Create validator for sensor platform
validator = entity_duplicate_validator("sensor")
# First entity should pass
config1 = {CONF_NAME: "Temperature"}
validated1 = validator(config1)
assert validated1 == config1
assert ("", "sensor", "temperature") in CORE.unique_ids
# Second entity with different name should pass
config2 = {CONF_NAME: "Humidity"}
validated2 = validator(config2)
assert validated2 == config2
assert ("", "sensor", "humidity") in CORE.unique_ids
# Duplicate entity should fail
config3 = {CONF_NAME: "Temperature"}
with pytest.raises(
Invalid, match=r"Duplicate sensor entity with name 'Temperature' found"
):
validator(config3)
def test_entity_duplicate_validator_with_devices() -> None:
"""Test entity_duplicate_validator with devices."""
from esphome.core.entity_helpers import entity_duplicate_validator
# Reset CORE unique_ids for clean test
CORE.unique_ids.clear()
# Create validator for sensor platform
validator = entity_duplicate_validator("sensor")
# Create mock device IDs
device1 = ID("device1", type="Device")
device2 = ID("device2", type="Device")
# Same name on different devices should pass
config1 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device1}
validated1 = validator(config1)
assert validated1 == config1
assert ("device1", "sensor", "temperature") in CORE.unique_ids
config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device2}
validated2 = validator(config2)
assert validated2 == config2
assert ("device2", "sensor", "temperature") in CORE.unique_ids
# Duplicate on same device should fail
config3 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device1}
with pytest.raises(
Invalid,
match=r"Duplicate sensor entity with name 'Temperature' found on device 'device1'",
):
validator(config3)
def test_duplicate_entity_yaml_validation(
yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str]
) -> None:
"""Test that duplicate entity names are caught during YAML config validation."""
result = load_config_from_fixture(yaml_file, "duplicate_entity.yaml", FIXTURES_DIR)
assert result is None
# Check for the duplicate entity error message
captured = capsys.readouterr()
assert "Duplicate sensor entity with name 'Temperature' found" in captured.out
def test_duplicate_entity_with_devices_yaml_validation(
yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str]
) -> None:
"""Test duplicate entity validation with devices."""
result = load_config_from_fixture(
yaml_file, "duplicate_entity_with_devices.yaml", FIXTURES_DIR
)
assert result is None
# Check for the duplicate entity error message with device
captured = capsys.readouterr()
assert (
"Duplicate sensor entity with name 'Temperature' found on device 'device1'"
in captured.out
)
def test_entity_different_platforms_yaml_validation(
yaml_file: Callable[[str], str],
) -> None:
"""Test that same entity name on different platforms is allowed."""
result = load_config_from_fixture(
yaml_file, "entity_different_platforms.yaml", FIXTURES_DIR
)
# This should succeed
assert result is not None

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:

View File

@@ -0,0 +1,13 @@
esphome:
name: test-duplicate
esp32:
board: esp32dev
sensor:
- platform: template
name: "Temperature"
lambda: return 21.0;
- platform: template
name: "Temperature" # Duplicate - should fail
lambda: return 22.0;

View File

@@ -0,0 +1,26 @@
esphome:
name: test-duplicate-devices
devices:
- id: device1
name: "Device 1"
- id: device2
name: "Device 2"
esp32:
board: esp32dev
sensor:
# Same name on different devices - should pass
- platform: template
device_id: device1
name: "Temperature"
lambda: return 21.0;
- platform: template
device_id: device2
name: "Temperature"
lambda: return 22.0;
# Duplicate on same device - should fail
- platform: template
device_id: device1
name: "Temperature"
lambda: return 23.0;

View File

@@ -0,0 +1,20 @@
esphome:
name: test-different-platforms
esp32:
board: esp32dev
sensor:
- platform: template
name: "Status"
lambda: return 1.0;
binary_sensor:
- platform: template
name: "Status" # Same name, different platform - should pass
lambda: return true;
text_sensor:
- platform: template
name: "Status" # Same name, different platform - should pass
lambda: return {"OK"};