mirror of
				https://github.com/esphome/esphome.git
				synced 2025-10-31 07:03:55 +00:00 
			
		
		
		
	Merge branch 'dev' into sensor_memory
This commit is contained in:
		| @@ -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 | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -4,6 +4,31 @@ binary_sensor: | ||||
|     id: some_binary_sensor | ||||
|     name: "Random binary" | ||||
|     lambda: return (random_uint32() & 1) == 0; | ||||
|     filters: | ||||
|       - invert: | ||||
|       - delayed_on: 100ms | ||||
|       - delayed_off: 100ms | ||||
|       # Templated, delays for 1s (1000ms) only if a reed switch is active | ||||
|       - delayed_on_off: !lambda "return 1000;" | ||||
|       - delayed_on_off: | ||||
|           time_on: 10s | ||||
|           time_off: !lambda "return 1000;" | ||||
|       - autorepeat: | ||||
|           - delay: 1s | ||||
|             time_off: 100ms | ||||
|             time_on: 900ms | ||||
|           - delay: 5s | ||||
|             time_off: 100ms | ||||
|             time_on: 400ms | ||||
|       - lambda: |- | ||||
|           if (id(some_binary_sensor).state) { | ||||
|             return x; | ||||
|           } else { | ||||
|             return {}; | ||||
|           } | ||||
|       - settle: 100ms | ||||
|       - timeout: 10s | ||||
|  | ||||
|     on_state_change: | ||||
|       then: | ||||
|         - logger.log: | ||||
|   | ||||
| @@ -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: | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -646,7 +646,9 @@ lvgl: | ||||
|             on_click: | ||||
|               lvgl.qrcode.update: | ||||
|                 id: lv_qr | ||||
|                 text: homeassistant.io | ||||
|                 text: | ||||
|                   format: "A string with a number %d" | ||||
|                   args: ['(int)(random_uint32() % 1000)'] | ||||
|  | ||||
|         - slider: | ||||
|             min_value: 0 | ||||
|   | ||||
| @@ -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" | ||||
|   | ||||
| @@ -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 | ||||
|  | ||||
|   | ||||
| @@ -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 | ||||
|  | ||||
|   | ||||
| @@ -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] | ||||
|   | ||||
| @@ -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); | ||||
|   | ||||
| @@ -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)} | ||||
|   | ||||
							
								
								
									
										57
									
								
								tests/integration/fixtures/areas_and_devices.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								tests/integration/fixtures/areas_and_devices.yaml
									
									
									
									
									
										Normal 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 | ||||
|  | ||||
| @@ -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: [] | ||||
							
								
								
									
										15
									
								
								tests/integration/fixtures/legacy_area.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								tests/integration/fixtures/legacy_area.yaml
									
									
									
									
									
										Normal 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 | ||||
							
								
								
									
										121
									
								
								tests/integration/test_areas_and_devices.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								tests/integration/test_areas_and_devices.py
									
									
									
									
									
										Normal 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}" | ||||
|                 ) | ||||
							
								
								
									
										184
									
								
								tests/integration/test_duplicate_entities.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										184
									
								
								tests/integration/test_duplicate_entities.py
									
									
									
									
									
										Normal 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}" | ||||
|             ) | ||||
							
								
								
									
										41
									
								
								tests/integration/test_legacy_area.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								tests/integration/test_legacy_area.py
									
									
									
									
									
										Normal 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) | ||||
| @@ -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: | ||||
|     """ | ||||
|   | ||||
							
								
								
									
										0
									
								
								tests/unit_tests/core/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								tests/unit_tests/core/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										33
									
								
								tests/unit_tests/core/common.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								tests/unit_tests/core/common.py
									
									
									
									
									
										Normal 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) | ||||
							
								
								
									
										18
									
								
								tests/unit_tests/core/conftest.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								tests/unit_tests/core/conftest.py
									
									
									
									
									
										Normal 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 | ||||
							
								
								
									
										225
									
								
								tests/unit_tests/core/test_config.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										225
									
								
								tests/unit_tests/core/test_config.py
									
									
									
									
									
										Normal 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 | ||||
							
								
								
									
										595
									
								
								tests/unit_tests/core/test_entity_helpers.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										595
									
								
								tests/unit_tests/core/test_entity_helpers.py
									
									
									
									
									
										Normal 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 | ||||
							
								
								
									
										10
									
								
								tests/unit_tests/fixtures/core/config/area_id_collision.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								tests/unit_tests/fixtures/core/config/area_id_collision.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| esphome: | ||||
|   name: test-collision | ||||
|   area: | ||||
|     id: duplicate_id | ||||
|     name: Area 1 | ||||
|   areas: | ||||
|     - id: duplicate_id | ||||
|       name: Area 2 | ||||
|  | ||||
| host: | ||||
| @@ -0,0 +1,10 @@ | ||||
| esphome: | ||||
|   name: test | ||||
|   areas: | ||||
|     - id: test_2258 | ||||
|       name: "Area 1" | ||||
|     - id: d6ka | ||||
|       name: "Area 2" | ||||
|  | ||||
| esp32: | ||||
|   board: esp32dev | ||||
| @@ -0,0 +1,10 @@ | ||||
| esphome: | ||||
|   name: test | ||||
|   devices: | ||||
|     - id: duplicate_device | ||||
|       name: "Device 1" | ||||
|     - id: duplicate_device | ||||
|       name: "Device 2" | ||||
|  | ||||
| esp32: | ||||
|   board: esp32dev | ||||
| @@ -0,0 +1,10 @@ | ||||
| esphome: | ||||
|   name: test | ||||
|   devices: | ||||
|     - id: test_2258 | ||||
|       name: "Device 1" | ||||
|     - id: d6ka | ||||
|       name: "Device 2" | ||||
|  | ||||
| esp32: | ||||
|   board: esp32dev | ||||
| @@ -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 | ||||
| @@ -0,0 +1,7 @@ | ||||
| esphome: | ||||
|   name: test-device-no-area | ||||
|   devices: | ||||
|     - id: test_device | ||||
|       name: Test Device | ||||
|  | ||||
| host: | ||||
| @@ -0,0 +1,5 @@ | ||||
| esphome: | ||||
|   name: test-legacy-area | ||||
|   area: Living Room | ||||
|  | ||||
| host: | ||||
| @@ -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: | ||||
							
								
								
									
										11
									
								
								tests/unit_tests/fixtures/core/config/valid_area_device.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								tests/unit_tests/fixtures/core/config/valid_area_device.yaml
									
									
									
									
									
										Normal 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: | ||||
| @@ -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; | ||||
| @@ -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; | ||||
| @@ -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"}; | ||||
		Reference in New Issue
	
	Block a user