From bb17f34bae8a32b25da5151398a8e27d2f309185 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 13 Jul 2025 21:01:38 +1000 Subject: [PATCH 1/3] Remove history first refresh from Teslemetry (#148531) --- .../components/teslemetry/__init__.py | 5 --- .../components/teslemetry/coordinator.py | 1 + .../teslemetry/snapshots/test_sensor.ambr | 42 +++++++++---------- .../components/teslemetry/test_diagnostics.py | 9 ++++ tests/components/teslemetry/test_init.py | 14 ------- tests/components/teslemetry/test_sensor.py | 6 ++- 6 files changed, 35 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 49af8c1a08daee..3ffc6c43efb409 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -215,11 +215,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - energysite.info_coordinator.async_config_entry_first_refresh() for energysite in energysites ), - *( - energysite.history_coordinator.async_config_entry_first_refresh() - for energysite in energysites - if energysite.history_coordinator - ), ) # Add energy device models diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index e6b453402e971c..eed00ebc64f802 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -183,6 +183,7 @@ def __init__( update_interval=ENERGY_HISTORY_INTERVAL, ) self.api = api + self.data = {} async def _async_update_data(self) -> dict[str, Any]: """Update energy site data using Teslemetry API.""" diff --git a/tests/components/teslemetry/snapshots/test_sensor.ambr b/tests/components/teslemetry/snapshots/test_sensor.ambr index 57a0f49d949ac8..1db8cf9612f6ff 100644 --- a/tests/components/teslemetry/snapshots/test_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_sensor.ambr @@ -55,7 +55,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.684', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_battery_charged-statealt] @@ -130,7 +130,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.036', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_battery_discharged-statealt] @@ -205,7 +205,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.036', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_battery_exported-statealt] @@ -280,7 +280,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_battery_imported_from_generator-statealt] @@ -355,7 +355,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_battery_imported_from_grid-statealt] @@ -430,7 +430,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.684', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_battery_imported_from_solar-statealt] @@ -580,7 +580,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.036', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_consumer_imported_from_battery-statealt] @@ -655,7 +655,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_consumer_imported_from_generator-statealt] @@ -730,7 +730,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_consumer_imported_from_grid-statealt] @@ -805,7 +805,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.038', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_consumer_imported_from_solar-statealt] @@ -955,7 +955,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_generator_exported-statealt] @@ -1105,7 +1105,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.002', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_grid_exported-statealt] @@ -1180,7 +1180,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_grid_exported_from_battery-statealt] @@ -1255,7 +1255,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_grid_exported_from_generator-statealt] @@ -1330,7 +1330,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.002', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_grid_exported_from_solar-statealt] @@ -1405,7 +1405,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_grid_imported-statealt] @@ -1555,7 +1555,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_grid_services_exported-statealt] @@ -1630,7 +1630,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_grid_services_imported-statealt] @@ -1780,7 +1780,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.074', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_home_usage-statealt] @@ -2087,7 +2087,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.724', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_solar_exported-statealt] @@ -2162,7 +2162,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.724', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_solar_generated-statealt] diff --git a/tests/components/teslemetry/test_diagnostics.py b/tests/components/teslemetry/test_diagnostics.py index fb8eb79a918460..18182b14321fac 100644 --- a/tests/components/teslemetry/test_diagnostics.py +++ b/tests/components/teslemetry/test_diagnostics.py @@ -1,11 +1,14 @@ """Test the Telemetry Diagnostics.""" +from freezegun.api import FrozenDateTimeFactory from syrupy.assertion import SnapshotAssertion +from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.core import HomeAssistant from . import setup_platform +from tests.common import async_fire_time_changed from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -14,10 +17,16 @@ async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, + freezer: FrozenDateTimeFactory, ) -> None: """Test diagnostics.""" entry = await setup_platform(hass) + # Wait for coordinator refresh + freezer.tick(VEHICLE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) assert diag == snapshot diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index d2ef5c38893622..54c9ca0dad9158 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -107,20 +107,6 @@ async def test_energy_site_refresh_error( assert entry.state is state -# Test Energy History Coordinator -@pytest.mark.parametrize(("side_effect", "state"), ERRORS) -async def test_energy_history_refresh_error( - hass: HomeAssistant, - mock_energy_history: AsyncMock, - side_effect: TeslaFleetError, - state: ConfigEntryState, -) -> None: - """Test coordinator refresh with an error.""" - mock_energy_history.side_effect = side_effect - entry = await setup_platform(hass) - assert entry.state is state - - async def test_vehicle_stream( hass: HomeAssistant, mock_add_listener: AsyncMock, diff --git a/tests/components/teslemetry/test_sensor.py b/tests/components/teslemetry/test_sensor.py index d2d6d88b3e3106..296f9e8bff4bfe 100644 --- a/tests/components/teslemetry/test_sensor.py +++ b/tests/components/teslemetry/test_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -30,6 +30,8 @@ async def test_sensors( """Tests that the sensor entities with the legacy polling are correct.""" freezer.move_to("2024-01-01 00:00:00+00:00") + async_fire_time_changed(hass) + await hass.async_block_till_done() # Force the vehicle to use polling with patch("tesla_fleet_api.teslemetry.Vehicle.pre2021", return_value=True): @@ -117,7 +119,7 @@ async def test_energy_history_no_time_series( entity_id = "sensor.energy_site_battery_discharged" state = hass.states.get(entity_id) - assert state.state == "0.036" + assert state.state == STATE_UNKNOWN mock_energy_history.return_value = ENERGY_HISTORY_EMPTY From f7d132b043c17f225f3716f19e083ba1d7ac853d Mon Sep 17 00:00:00 2001 From: Steven Tegreeny Date: Sun, 13 Jul 2025 07:46:37 -0400 Subject: [PATCH 2/3] Add Z-WAVE discovery entry for the GE/JASCO in-wall smart fan control (#148246) --- .../components/zwave_js/discovery.py | 10 +- tests/components/zwave_js/conftest.py | 14 + .../enbrighten_58446_zwa4013_state.json | 1116 +++++++++++++++++ tests/components/zwave_js/test_discovery.py | 14 + 4 files changed, 1151 insertions(+), 3 deletions(-) create mode 100644 tests/components/zwave_js/fixtures/enbrighten_58446_zwa4013_state.json diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 3b541a733cce12..74ffedbc53f543 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -263,7 +263,7 @@ class ZWaveDiscoverySchema: ) # For device class mapping see: -# https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/deviceClasses.json +# https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/ DISCOVERY_SCHEMAS = [ # ====== START OF DEVICE SPECIFIC MAPPING SCHEMAS ======= # Honeywell 39358 In-Wall Fan Control using switch multilevel CC @@ -291,12 +291,16 @@ class ZWaveDiscoverySchema: FanValueMapping(speeds=[(1, 33), (34, 67), (68, 99)]), ), ), - # GE/Jasco - In-Wall Smart Fan Control - 14287 / 55258 / ZW4002 + # GE/Jasco - In-Wall Smart Fan Controls ZWaveDiscoverySchema( platform=Platform.FAN, hint="has_fan_value_mapping", manufacturer_id={0x0063}, - product_id={0x3131, 0x3337}, + product_id={ + 0x3131, + 0x3337, # 14287 / 55258 / ZW4002 + 0x3533, # 58446 / ZWA4013 + }, product_type={0x4944}, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, data_template=FixedFanValueMappingDataTemplate( diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 138bcd63ede7be..1163da4971c992 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -325,6 +325,12 @@ def ge_12730_state_fixture() -> dict[str, Any]: return load_json_object_fixture("fan_ge_12730_state.json", DOMAIN) +@pytest.fixture(name="enbrighten_58446_zwa4013_state", scope="package") +def enbrighten_58446_zwa4013_state_fixture() -> dict[str, Any]: + """Load the Enbrighten/GE 58446/zwa401 node state fixture data.""" + return load_json_object_fixture("enbrighten_58446_zwa4013_state.json", DOMAIN) + + @pytest.fixture(name="aeotec_radiator_thermostat_state", scope="package") def aeotec_radiator_thermostat_state_fixture() -> dict[str, Any]: """Load the Aeotec Radiator Thermostat node state fixture data.""" @@ -1078,6 +1084,14 @@ def ge_12730_fixture(client, ge_12730_state) -> Node: return node +@pytest.fixture(name="enbrighten_58446_zwa4013") +def enbrighten_58446_zwa4013_fixture(client, enbrighten_58446_zwa4013_state) -> Node: + """Mock a Enbrighten_58446/zwa4013 fan controller node.""" + node = Node(client, copy.deepcopy(enbrighten_58446_zwa4013_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="inovelli_lzw36") def inovelli_lzw36_fixture(client, inovelli_lzw36_state) -> Node: """Mock a Inovelli LZW36 fan controller node.""" diff --git a/tests/components/zwave_js/fixtures/enbrighten_58446_zwa4013_state.json b/tests/components/zwave_js/fixtures/enbrighten_58446_zwa4013_state.json new file mode 100644 index 00000000000000..dd580a9b43be54 --- /dev/null +++ b/tests/components/zwave_js/fixtures/enbrighten_58446_zwa4013_state.json @@ -0,0 +1,1116 @@ +{ + "nodeId": 19, + "index": 0, + "installerIcon": 1024, + "userIcon": 1024, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": true, + "manufacturerId": 99, + "productId": 13619, + "productType": 18756, + "firmwareVersion": "1.26.1", + "zwavePlusVersion": 2, + "name": "zwa4013_fan", + "deviceConfig": { + "manufacturer": "Enbrighten", + "manufacturerId": 99, + "label": "58446 / ZWA4013", + "description": "In-Wall Fan Speed Control, QFSW, 700S", + "devices": [ + { + "productType": 18756, + "productId": 13619 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "associations": {}, + "paramInformation": { + "_map": {} + }, + "compat": { + "mapBasicSet": "event" + }, + "metadata": { + "inclusion": "1. Follow the instructions for your Z-Wave certified Controller to add a device to the Z-Wave network.\n2. Once the controller is ready to add your device, press the top of bottom of the wireless smart Fan controller", + "exclusion": "1. Follow the instructions for your Z-Wave certified controller to remove a device from the Z-wave network\n2. Once the controller is ready to remove your device, press the top or bottom of the wireless smart Fan controller", + "reset": "Pull the airgap switch. Press and hold the bottom button, push the airgap switch in and continue holding the bottom button for 10 seconds. The LED will flash once each of the 8 colors then stop" + } + }, + "label": "58446 / ZWA4013", + "interviewAttempts": 1, + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 5, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 1, + "label": "Multilevel Power Switch" + } + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0063:0x4944:0x3533:1.26.1", + "statistics": { + "commandsTX": 158, + "commandsRX": 154, + "commandsDroppedRX": 2, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "rtt": 30.1, + "lastSeen": "2025-07-05T19:10:23.100Z", + "lwr": { + "repeaters": [], + "protocolDataRate": 3 + } + }, + "highestSecurityClass": 1, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2025-07-05T19:10:23.100Z", + "protocol": 0, + "sdkVersion": "7.18.1", + "values": [ + { + "endpoint": 0, + "commandClass": 32, + "commandClassName": "Basic", + "property": "event", + "propertyName": "event", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Event value", + "min": 0, + "max": 255, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 4, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "states": { + "true": "Restore" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "001", + "propertyName": "scene", + "propertyKeyName": "001", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Scene 001", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x", + "4": "KeyPressed3x" + }, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "002", + "propertyName": "scene", + "propertyKeyName": "002", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Scene 002", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x", + "4": "KeyPressed3x" + }, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "slowRefresh", + "propertyName": "slowRefresh", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "description": "When this is true, KeyHeldDown notifications are sent every 55s. When this is false, the notifications are sent every 200ms.", + "label": "Send held down notifications at a slow rate", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "LED Indicator", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Indicator", + "default": 0, + "min": 0, + "max": 3, + "states": { + "0": "On when load is off", + "1": "On when load is on", + "2": "Always off", + "3": "Always on" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyName": "Inverted Orientation", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Inverted Orientation", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyName": "3-Way Setup", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "3-Way Setup", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Add-on", + "1": "Standard" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 19, + "propertyName": "Alternate Exclusion", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Press MENU button once", + "label": "Alternate Exclusion", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyName": "LED Indicator Color", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Indicator Color", + "default": 5, + "min": 1, + "max": 8, + "states": { + "1": "Red", + "2": "Orange", + "3": "Yellow", + "4": "Green", + "5": "Blue", + "6": "Pink", + "7": "Purple", + "8": "White" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 35, + "propertyName": "LED Indicator Intensity", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Indicator Intensity", + "default": 4, + "min": 0, + "max": 7, + "states": { + "0": "Off" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 4 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 36, + "propertyName": "Guidelight Mode Intensity", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Guidelight Mode Intensity", + "default": 4, + "min": 0, + "max": 7, + "states": { + "0": "Off" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 4 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 39, + "propertyName": "State After Power Failure", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "State After Power Failure", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Always off", + "1": "Previous state" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 40, + "propertyName": "Fan Speed Control", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Fan Speed Control", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Press and hold", + "1": "Single button presses" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 84, + "propertyName": "Reset to Factory Default", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Reset to Factory Default", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 13619 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 18756 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 99 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["1.26"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.18" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number", + "stateful": true, + "secret": false + }, + "value": 273 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version", + "stateful": true, + "secret": false + }, + "value": "1.26.1" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolBuildNumber", + "propertyName": "zWaveProtocolBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol build number", + "stateful": true, + "secret": false + }, + "value": 273 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolVersion", + "propertyName": "zWaveProtocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.18.1" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceBuildNumber", + "propertyName": "hostInterfaceBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API build number", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version", + "stateful": true, + "secret": false + }, + "value": "unused" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkBuildNumber", + "propertyName": "applicationFrameworkBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API build number", + "stateful": true, + "secret": false + }, + "value": 273 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkAPIVersion", + "propertyName": "applicationFrameworkAPIVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API version", + "stateful": true, + "secret": false + }, + "value": "10.18.1" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version", + "stateful": true, + "secret": false + }, + "value": "7.18.1" + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 3, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x50 (Node Identify) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 8 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 4, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x50 (Node Identify) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 5, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x50 (Node Identify) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 6 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "value", + "propertyName": "value", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Indicator value", + "ccSpecific": { + "indicatorId": 0 + }, + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "identify", + "propertyName": "identify", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Identify", + "states": { + "true": "Identify" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "timeout", + "propertyName": "timeout", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": true, + "label": "Timeout", + "stateful": true, + "secret": false + } + } + ], + "endpoints": [ + { + "nodeId": 19, + "index": 0, + "installerIcon": 1024, + "userIcon": 1024, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + } + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": true + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": true + }, + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": true + }, + { + "id": 135, + "name": "Indicator", + "version": 3, + "isSecure": true + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": true + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": true + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": true + }, + { + "id": 112, + "name": "Configuration", + "version": 4, + "isSecure": true + }, + { + "id": 91, + "name": "Central Scene", + "version": 3, + "isSecure": true + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 5, + "isSecure": true + } + ] + } + ] +} diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index c8bfca2b35f5ca..44133db03ac66f 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -98,6 +98,20 @@ async def test_ge_12730(hass: HomeAssistant, client, ge_12730, integration) -> N assert state +async def test_enbrighten_58446_zwa4013( + hass: HomeAssistant, client, enbrighten_58446_zwa4013, integration +) -> None: + """Test GE 12730 Fan Controller v2.0 multilevel switch is discovered as a fan.""" + node = enbrighten_58446_zwa4013 + assert node.device_class.specific.label == "Multilevel Power Switch" + + state = hass.states.get("light.zwa4013_fan") + assert not state + + state = hass.states.get("fan.zwa4013_fan") + assert state + + async def test_inovelli_lzw36( hass: HomeAssistant, client, inovelli_lzw36, integration ) -> None: From 023dd9d523267ac1c686f297168ae1c79ebd46a7 Mon Sep 17 00:00:00 2001 From: Robert Meijers Date: Sun, 13 Jul 2025 16:56:31 +0200 Subject: [PATCH 3/3] Discover Heos players using Zeroconf (#144763) --- homeassistant/components/heos/config_flow.py | 92 ++++++------ homeassistant/components/heos/manifest.json | 3 +- homeassistant/generated/zeroconf.py | 5 + tests/components/heos/conftest.py | 32 +++++ tests/components/heos/test_config_flow.py | 139 +++++++++++++++++++ 5 files changed, 229 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py index e2d3e2522dccf0..b6cda10dcb728b 100644 --- a/homeassistant/components/heos/config_flow.py +++ b/homeassistant/components/heos/config_flow.py @@ -24,6 +24,7 @@ from homeassistant.core import callback from homeassistant.helpers import selector from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, ENTRY_TITLE from .coordinator import HeosConfigEntry @@ -142,51 +143,16 @@ async def async_step_ssdp( if TYPE_CHECKING: assert discovery_info.ssdp_location - entry: HeosConfigEntry | None = await self.async_set_unique_id(DOMAIN) hostname = urlparse(discovery_info.ssdp_location).hostname assert hostname is not None - # Abort early when discovery is ignored or host is part of the current system - if entry and ( - entry.source == SOURCE_IGNORE or hostname in _get_current_hosts(entry) - ): - return self.async_abort(reason="single_instance_allowed") - - # Connect to discovered host and get system information - heos = Heos(HeosOptions(hostname, events=False, heart_beat=False)) - try: - await heos.connect() - system_info = await heos.get_system_info() - except HeosError as error: - _LOGGER.debug( - "Failed to retrieve system information from discovered HEOS device %s", - hostname, - exc_info=error, - ) - return self.async_abort(reason="cannot_connect") - finally: - await heos.disconnect() - - # Select the preferred host, if available - if system_info.preferred_hosts: - hostname = system_info.preferred_hosts[0].ip_address + return await self._async_handle_discovered(hostname) - # Move to confirmation when not configured - if entry is None: - self._discovered_host = hostname - return await self.async_step_confirm_discovery() - - # Only update if the configured host isn't part of the discovered hosts to ensure new players that come online don't trigger a reload - if entry.data[CONF_HOST] not in [host.ip_address for host in system_info.hosts]: - _LOGGER.debug( - "Updated host %s to discovered host %s", entry.data[CONF_HOST], hostname - ) - return self.async_update_reload_and_abort( - entry, - data_updates={CONF_HOST: hostname}, - reason="reconfigure_successful", - ) - return self.async_abort(reason="single_instance_allowed") + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + return await self._async_handle_discovered(discovery_info.host) async def async_step_confirm_discovery( self, user_input: dict[str, Any] | None = None @@ -267,6 +233,50 @@ async def async_step_reauth_confirm( ), ) + async def _async_handle_discovered(self, hostname: str) -> ConfigFlowResult: + entry: HeosConfigEntry | None = await self.async_set_unique_id(DOMAIN) + # Abort early when discovery is ignored or host is part of the current system + if entry and ( + entry.source == SOURCE_IGNORE or hostname in _get_current_hosts(entry) + ): + return self.async_abort(reason="single_instance_allowed") + + # Connect to discovered host and get system information + heos = Heos(HeosOptions(hostname, events=False, heart_beat=False)) + try: + await heos.connect() + system_info = await heos.get_system_info() + except HeosError as error: + _LOGGER.debug( + "Failed to retrieve system information from discovered HEOS device %s", + hostname, + exc_info=error, + ) + return self.async_abort(reason="cannot_connect") + finally: + await heos.disconnect() + + # Select the preferred host, if available + if system_info.preferred_hosts and system_info.preferred_hosts[0].ip_address: + hostname = system_info.preferred_hosts[0].ip_address + + # Move to confirmation when not configured + if entry is None: + self._discovered_host = hostname + return await self.async_step_confirm_discovery() + + # Only update if the configured host isn't part of the discovered hosts to ensure new players that come online don't trigger a reload + if entry.data[CONF_HOST] not in [host.ip_address for host in system_info.hosts]: + _LOGGER.debug( + "Updated host %s to discovered host %s", entry.data[CONF_HOST], hostname + ) + return self.async_update_reload_and_abort( + entry, + data_updates={CONF_HOST: hostname}, + reason="reconfigure_successful", + ) + return self.async_abort(reason="single_instance_allowed") + class HeosOptionsFlowHandler(OptionsFlow): """Define HEOS options flow.""" diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index 8a88913456dc12..99cedf56f1fa75 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -13,5 +13,6 @@ { "st": "urn:schemas-denon-com:device:ACT-Denon:1" } - ] + ], + "zeroconf": ["_heos-audio._tcp.local."] } diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 274fafa51cf5c5..47522a69c415ad 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -534,6 +534,11 @@ "domain": "homekit_controller", }, ], + "_heos-audio._tcp.local.": [ + { + "domain": "heos", + }, + ], "_homeconnect._tcp.local.": [ { "domain": "home_connect", diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index 835e4436398d7c..e72c72c7334a89 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Iterator +from ipaddress import ip_address from unittest.mock import Mock, patch from pyheos import ( @@ -39,6 +40,7 @@ ATTR_UPNP_UDN, SsdpServiceInfo, ) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import MockHeos @@ -284,6 +286,36 @@ def discovery_data_fixture_bedroom() -> SsdpServiceInfo: ) +@pytest.fixture(name="zeroconf_discovery_data") +def zeroconf_discovery_data_fixture() -> ZeroconfServiceInfo: + """Return mock discovery data for testing.""" + host = "127.0.0.1" + return ZeroconfServiceInfo( + ip_address=ip_address(host), + ip_addresses=[ip_address(host)], + port=10101, + hostname=host, + type="mock_type", + name="MyDenon._heos-audio._tcp.local.", + properties={}, + ) + + +@pytest.fixture(name="zeroconf_discovery_data_bedroom") +def zeroconf_discovery_data_fixture_bedroom() -> ZeroconfServiceInfo: + """Return mock discovery data for testing.""" + host = "127.0.0.2" + return ZeroconfServiceInfo( + ip_address=ip_address(host), + ip_addresses=[ip_address(host)], + port=10101, + hostname=host, + type="mock_type", + name="MyDenonBedroom._heos-audio._tcp.local.", + properties={}, + ) + + @pytest.fixture(name="quick_selects") def quick_selects_fixture() -> dict[int, str]: """Create a dict of quick selects for testing.""" diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py index 69d9aa3a38e89a..4749dc48b012aa 100644 --- a/tests/components/heos/test_config_flow.py +++ b/tests/components/heos/test_config_flow.py @@ -18,12 +18,14 @@ SOURCE_IGNORE, SOURCE_SSDP, SOURCE_USER, + SOURCE_ZEROCONF, ConfigEntryState, ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import MockHeos @@ -244,6 +246,143 @@ async def test_discovery_updates( assert config_entry.data[CONF_HOST] == "127.0.0.2" +async def test_zeroconf_discovery( + hass: HomeAssistant, + zeroconf_discovery_data: ZeroconfServiceInfo, + zeroconf_discovery_data_bedroom: ZeroconfServiceInfo, + controller: MockHeos, + system: HeosSystem, +) -> None: + """Test discovery shows form to confirm, then creates entry.""" + # Single discovered, selects preferred host, shows confirm + controller.get_system_info.return_value = system + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf_discovery_data_bedroom, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm_discovery" + assert controller.connect.call_count == 1 + assert controller.get_system_info.call_count == 1 + assert controller.disconnect.call_count == 1 + + # Subsequent discovered hosts abort. + subsequent_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf_discovery_data + ) + assert subsequent_result["type"] is FlowResultType.ABORT + assert subsequent_result["reason"] == "already_in_progress" + + # Confirm set up + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == DOMAIN + assert result["title"] == "HEOS System" + assert result["data"] == {CONF_HOST: "127.0.0.1"} + + +async def test_zeroconf_discovery_flow_aborts_already_setup( + hass: HomeAssistant, + zeroconf_discovery_data_bedroom: ZeroconfServiceInfo, + config_entry: MockConfigEntry, + controller: MockHeos, +) -> None: + """Test discovery flow aborts when entry already setup and hosts didn't change.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.data[CONF_HOST] == "127.0.0.1" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf_discovery_data_bedroom, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + assert controller.get_system_info.call_count == 0 + assert config_entry.data[CONF_HOST] == "127.0.0.1" + + +async def test_zeroconf_discovery_aborts_same_system( + hass: HomeAssistant, + zeroconf_discovery_data_bedroom: ZeroconfServiceInfo, + controller: MockHeos, + config_entry: MockConfigEntry, + system: HeosSystem, +) -> None: + """Test discovery does not update when current host is part of discovered's system.""" + config_entry.add_to_hass(hass) + assert config_entry.data[CONF_HOST] == "127.0.0.1" + + controller.get_system_info.return_value = system + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf_discovery_data_bedroom, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + assert controller.get_system_info.call_count == 1 + assert config_entry.data[CONF_HOST] == "127.0.0.1" + + +async def test_zeroconf_discovery_ignored_aborts( + hass: HomeAssistant, + zeroconf_discovery_data: ZeroconfServiceInfo, +) -> None: + """Test discovery aborts when ignored.""" + MockConfigEntry(domain=DOMAIN, unique_id=DOMAIN, source=SOURCE_IGNORE).add_to_hass( + hass + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf_discovery_data + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + +async def test_zeroconf_discovery_fails_to_connect_aborts( + hass: HomeAssistant, + zeroconf_discovery_data: ZeroconfServiceInfo, + controller: MockHeos, +) -> None: + """Test discovery aborts when trying to connect to host.""" + controller.connect.side_effect = HeosError() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf_discovery_data + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + assert controller.connect.call_count == 1 + assert controller.disconnect.call_count == 1 + + +async def test_zeroconf_discovery_updates( + hass: HomeAssistant, + zeroconf_discovery_data_bedroom: ZeroconfServiceInfo, + controller: MockHeos, + config_entry: MockConfigEntry, +) -> None: + """Test discovery updates existing entry.""" + config_entry.add_to_hass(hass) + assert config_entry.data[CONF_HOST] == "127.0.0.1" + + host = HeosHost("Player", "Model", None, None, "127.0.0.2", NetworkType.WIRED, True) + controller.get_system_info.return_value = HeosSystem(None, host, [host]) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf_discovery_data_bedroom, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data[CONF_HOST] == "127.0.0.2" + + async def test_reconfigure_validates_and_updates_config( hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: