From cb89c23c0ffd7beba1ecc0cb84d80e8842f9a571 Mon Sep 17 00:00:00 2001 From: kpine Date: Mon, 27 Dec 2021 03:31:31 -0800 Subject: [PATCH 01/20] Avoid removing zwave_js devices for non-ready nodes (#59964) * Only replace a node if the mfgr id / prod id / prod type differ * Prefer original device name for unready node * move register_node_in_dev_reg into async_setup_entry * simplify get_device_id_ext * Don't need hex ids * Revert "move register_node_in_dev_reg into async_setup_entry" This reverts commit f900e5fb0c67cc81657a1452b51c313bccb6f9e1. * Revert Callable change * Revert device backup name * Add test fixtures * Update existing not ready test with new fixture data * Check device properties after node added event * Add entity check * Check for extended device id * better device info checks * Use receive_event to properly setup components * Cleanup tests * improve test_replace_different_node * improve test_replace_same_node * add test test_node_model_change * Clean up long comments and strings * Format * Reload integration to detect node device config changes * update assertions * Disable entities on "value removed" event * Disable node status sensor on node replacement * Add test for disabling entities on remove value event * Add test for disabling node status sensor on node replacement * disable entity -> remove entity Co-authored-by: Martin Hjelmare --- homeassistant/components/zwave_js/__init__.py | 42 +- homeassistant/components/zwave_js/entity.py | 26 +- homeassistant/components/zwave_js/helpers.py | 13 + homeassistant/components/zwave_js/sensor.py | 7 + tests/components/zwave_js/conftest.py | 34 + .../fixtures/zp3111-5_not_ready_state.json | 68 ++ .../zwave_js/fixtures/zp3111-5_state.json | 706 ++++++++++++++++++ tests/components/zwave_js/test_init.py | 676 +++++++++++++++-- 8 files changed, 1478 insertions(+), 94 deletions(-) create mode 100644 tests/components/zwave_js/fixtures/zp3111-5_not_ready_state.json create mode 100644 tests/components/zwave_js/fixtures/zp3111-5_state.json diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 7d2af4af126e..10088f624143 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -88,7 +88,12 @@ async_discover_node_values, async_discover_single_value, ) -from .helpers import async_enable_statistics, get_device_id, get_unique_id +from .helpers import ( + async_enable_statistics, + get_device_id, + get_device_id_ext, + get_unique_id, +) from .migrate import async_migrate_discovered_value from .services import ZWaveServices @@ -116,17 +121,27 @@ def register_node_in_dev_reg( ) -> device_registry.DeviceEntry: """Register node in dev reg.""" device_id = get_device_id(client, node) - # If a device already exists but it doesn't match the new node, it means the node - # was replaced with a different device and the device needs to be removeed so the - # new device can be created. Otherwise if the device exists and the node is the same, - # the node was replaced with the same device model and we can reuse the device. - if (device := dev_reg.async_get_device({device_id})) and ( - device.model != node.device_config.label - or device.manufacturer != node.device_config.manufacturer + device_id_ext = get_device_id_ext(client, node) + device = dev_reg.async_get_device({device_id}) + + # Replace the device if it can be determined that this node is not the + # same product as it was previously. + if ( + device_id_ext + and device + and len(device.identifiers) == 2 + and device_id_ext not in device.identifiers ): remove_device_func(device) + device = None + + if device_id_ext: + ids = {device_id, device_id_ext} + else: + ids = {device_id} + params = { - ATTR_IDENTIFIERS: {device_id}, + ATTR_IDENTIFIERS: ids, ATTR_SW_VERSION: node.firmware_version, ATTR_NAME: node.name or node.device_config.description @@ -338,7 +353,14 @@ def async_on_node_removed(event: dict) -> None: device = dev_reg.async_get_device({dev_id}) # We assert because we know the device exists assert device - if not replaced: + if replaced: + discovered_value_ids.pop(device.id, None) + + async_dispatcher_send( + hass, + f"{DOMAIN}_{client.driver.controller.home_id}.{node.node_id}.node_status_remove_entity", + ) + else: remove_device(device) @callback diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index cf15f32932b0..87e9f3adbbd0 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -20,6 +20,7 @@ LOGGER = logging.getLogger(__name__) EVENT_VALUE_UPDATED = "value updated" +EVENT_VALUE_REMOVED = "value removed" EVENT_DEAD = "dead" EVENT_ALIVE = "alive" @@ -99,6 +100,10 @@ async def async_added_to_hass(self) -> None: self.async_on_remove( self.info.node.on(EVENT_VALUE_UPDATED, self._value_changed) ) + self.async_on_remove( + self.info.node.on(EVENT_VALUE_REMOVED, self._value_removed) + ) + for status_event in (EVENT_ALIVE, EVENT_DEAD): self.async_on_remove( self.info.node.on(status_event, self._node_status_alive_or_dead) @@ -171,7 +176,7 @@ def _node_status_alive_or_dead(self, event_data: dict) -> None: @callback def _value_changed(self, event_data: dict) -> None: - """Call when (one of) our watched values changes. + """Call when a value associated with our node changes. Should not be overridden by subclasses. """ @@ -193,6 +198,25 @@ def _value_changed(self, event_data: dict) -> None: self.on_value_update() self.async_write_ha_state() + @callback + def _value_removed(self, event_data: dict) -> None: + """Call when a value associated with our node is removed. + + Should not be overridden by subclasses. + """ + value_id = event_data["value"].value_id + + if value_id != self.info.primary_value.value_id: + return + + LOGGER.debug( + "[%s] Primary value %s is being removed", + self.entity_id, + value_id, + ) + + self.hass.async_create_task(self.async_remove()) + @callback def get_zwave_value( self, diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index aa6db5326166..363762ac72b1 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -66,6 +66,19 @@ def get_device_id(client: ZwaveClient, node: ZwaveNode) -> tuple[str, str]: return (DOMAIN, f"{client.driver.controller.home_id}-{node.node_id}") +@callback +def get_device_id_ext(client: ZwaveClient, node: ZwaveNode) -> tuple[str, str] | None: + """Get extended device registry identifier for Z-Wave node.""" + if None in (node.manufacturer_id, node.product_type, node.product_id): + return None + + domain, dev_id = get_device_id(client, node) + return ( + domain, + f"{dev_id}-{node.manufacturer_id}:{node.product_type}:{node.product_id}", + ) + + @callback def get_home_and_node_id_from_device_id(device_id: tuple[str, ...]) -> list[str]: """ diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 549df9f62646..70528c284278 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -520,4 +520,11 @@ async def async_added_to_hass(self) -> None: self.async_poll_value, ) ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{DOMAIN}_{self.unique_id}_remove_entity", + self.async_remove, + ) + ) self.async_write_ha_state() diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index e2db9fe7a6b8..4ae1d509c69b 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -473,6 +473,24 @@ def fortrezz_ssa1_siren_state_fixture(): return json.loads(load_fixture("zwave_js/fortrezz_ssa1_siren_state.json")) +@pytest.fixture(name="fortrezz_ssa3_siren_state", scope="session") +def fortrezz_ssa3_siren_state_fixture(): + """Load the fortrezz ssa3 siren node state fixture data.""" + return json.loads(load_fixture("zwave_js/fortrezz_ssa3_siren_state.json")) + + +@pytest.fixture(name="zp3111_not_ready_state", scope="session") +def zp3111_not_ready_state_fixture(): + """Load the zp3111 4-in-1 sensor not-ready node state fixture data.""" + return json.loads(load_fixture("zwave_js/zp3111-5_not_ready_state.json")) + + +@pytest.fixture(name="zp3111_state", scope="session") +def zp3111_state_fixture(): + """Load the zp3111 4-in-1 sensor node state fixture data.""" + return json.loads(load_fixture("zwave_js/zp3111-5_state.json")) + + @pytest.fixture(name="client") def mock_client_fixture(controller_state, version_state, log_config_state): """Mock a client.""" @@ -905,3 +923,19 @@ def fortrezz_ssa1_siren_fixture(client, fortrezz_ssa1_siren_state): def firmware_file_fixture(): """Return mock firmware file stream.""" return io.BytesIO(bytes(10)) + + +@pytest.fixture(name="zp3111_not_ready") +def zp3111_not_ready_fixture(client, zp3111_not_ready_state): + """Mock a zp3111 4-in-1 sensor node in a not-ready state.""" + node = Node(client, copy.deepcopy(zp3111_not_ready_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + +@pytest.fixture(name="zp3111") +def zp3111_fixture(client, zp3111_state): + """Mock a zp3111 4-in-1 sensor node.""" + node = Node(client, copy.deepcopy(zp3111_state)) + client.driver.controller.nodes[node.node_id] = node + return node diff --git a/tests/components/zwave_js/fixtures/zp3111-5_not_ready_state.json b/tests/components/zwave_js/fixtures/zp3111-5_not_ready_state.json new file mode 100644 index 000000000000..f892eb5570ee --- /dev/null +++ b/tests/components/zwave_js/fixtures/zp3111-5_not_ready_state.json @@ -0,0 +1,68 @@ +{ + "nodeId": 22, + "index": 0, + "status": 1, + "ready": false, + "isListening": false, + "isRouting": true, + "isSecure": "unknown", + "interviewAttempts": 1, + "endpoints": [ + { + "nodeId": 22, + "index": 0, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + } + } + ], + "values": [], + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [ + 40000, + 100000 + ], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [], + "interviewStage": "ProtocolInfo", + "statistics": { + "commandsTX": 0, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0 + } +} diff --git a/tests/components/zwave_js/fixtures/zp3111-5_state.json b/tests/components/zwave_js/fixtures/zp3111-5_state.json new file mode 100644 index 000000000000..8de7dd2b713e --- /dev/null +++ b/tests/components/zwave_js/fixtures/zp3111-5_state.json @@ -0,0 +1,706 @@ +{ + "nodeId": 22, + "index": 0, + "installerIcon": 3079, + "userIcon": 3079, + "status": 2, + "ready": true, + "isListening": false, + "isRouting": true, + "isSecure": false, + "manufacturerId": 265, + "productId": 8449, + "productType": 8225, + "firmwareVersion": "5.1", + "zwavePlusVersion": 1, + "deviceConfig": { + "filename": "/cache/db/devices/0x0109/zp3111-5.json", + "isEmbedded": true, + "manufacturer": "Vision Security", + "manufacturerId": 265, + "label": "ZP3111-5", + "description": "4-in-1 Sensor", + "devices": [ + { + "productType": 8225, + "productId": 8449 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "paramInformation": { + "_map": {} + }, + "metadata": { + "inclusion": "To add the ZP3111 to the Z-Wave network (inclusion), place the Z-Wave primary controller into inclusion mode. Press the Program Switch of ZP3111 for sending the NIF. After sending NIF, Z-Wave will send the auto inclusion, otherwise, ZP3111 will go to sleep after 20 seconds.", + "exclusion": "To remove the ZP3111 from the Z-Wave network (exclusion), place the Z-Wave primary controller into “exclusion” mode, and following its instruction to delete the ZP3111 to the controller. Press the Program Switch of ZP3111 once to be excluded.", + "reset": "Remove cover to trigged tamper switch, LED flash once & send out Alarm Report. Press Program Switch 10 times within 10 seconds, ZP3111 will send the “Device Reset Locally Notification” command and reset to the factory default. (Remark: This is to be used only in the case of primary controller being inoperable or otherwise unavailable.)", + "manual": "https://products.z-wavealliance.org/ProductManual/File?folder=&filename=MarketCertificationFiles/2479/ZP3111-5_R2_20170316.pdf" + } + }, + "label": "ZP3111-5", + "interviewAttempts": 1, + "endpoints": [ + { + "nodeId": 22, + "index": 0, + "installerIcon": 3079, + "userIcon": 3079, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + } + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 2, + "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" + } + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 2, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version" + }, + "value": "4.5" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 2, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions" + }, + "value": [ + "5.1", + "10.1" + ] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version" + }, + "value": 1 + }, + { + "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 + }, + "value": 265 + }, + { + "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 + }, + "value": 8225 + }, + { + "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 + }, + "value": 8449 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "level", + "propertyName": "level", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Battery level", + "min": 0, + "max": 100, + "unit": "%" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "isLow", + "propertyName": "isLow", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Low battery level" + }, + "value": true + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Home Security", + "propertyKey": "Cover status", + "propertyName": "Home Security", + "propertyKeyName": "Cover status", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Cover status", + "ccSpecific": { + "notificationType": 7 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "3": "Tampering, product cover removed" + } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Home Security", + "propertyKey": "Motion sensor status", + "propertyName": "Home Security", + "propertyKeyName": "Motion sensor status", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Motion sensor status", + "ccSpecific": { + "notificationType": 7 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "8": "Motion detection" + } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Type", + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Level", + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Air temperature", + "propertyName": "Air temperature", + "ccVersion": 7, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Air temperature", + "ccSpecific": { + "sensorType": 1, + "scale": 0 + }, + "unit": "°C" + }, + "value": 21.98 + }, + { + "endpoint": 0, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Illuminance", + "propertyName": "Illuminance", + "ccVersion": 7, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Illuminance", + "ccSpecific": { + "sensorType": 3, + "scale": 0 + }, + "unit": "%" + }, + "value": 7.31 + }, + { + "endpoint": 0, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Humidity", + "propertyName": "Humidity", + "ccVersion": 7, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Humidity", + "ccSpecific": { + "sensorType": 5, + "scale": 0 + }, + "unit": "%" + }, + "value": 51.98 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "Temperature Scale", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Temperature Scale", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Celsius", + "1": "Fahrenheit" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyName": "Temperature offset", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Temperature offset", + "default": 1, + "min": 0, + "max": 50, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "Humidity", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Configure Relative Humidity", + "label": "Humidity", + "default": 10, + "min": 1, + "max": 50, + "unit": "percent", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyName": "Light Sensor", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Sensor", + "default": 10, + "min": 1, + "max": 50, + "unit": "percent", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyName": "Trigger Interval", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Set the trigger interval for motion sensor re-activation.", + "label": "Trigger Interval", + "default": 180, + "min": 1, + "max": 255, + "unit": "seconds", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 6, + "propertyName": "Motion Sensor Sensitivity", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Adjust sensitivity of the motion sensor.", + "label": "Motion Sensor Sensitivity", + "default": 4, + "min": 1, + "max": 7, + "states": { + "1": "highest", + "2": "higher", + "3": "high", + "4": "normal", + "5": "low", + "6": "lower", + "7": "lowest" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 4 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 7, + "propertyName": "LED indicator mode", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED indicator mode", + "default": 3, + "min": 1, + "max": 3, + "states": { + "1": "Off", + "2": "Pulsing Temperature, Flashing Motion", + "3": "Flashing Temperature and Motion" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 132, + "commandClassName": "Wake Up", + "property": "wakeUpInterval", + "propertyName": "wakeUpInterval", + "ccVersion": 2, + "metadata": { + "type": "number", + "default": 3600, + "readable": false, + "writeable": true, + "label": "Wake Up interval", + "min": 600, + "max": 604800, + "steps": 600 + }, + "value": 3600 + }, + { + "endpoint": 0, + "commandClass": 132, + "commandClassName": "Wake Up", + "property": "controllerNodeId", + "propertyName": "controllerNodeId", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Node ID of the controller" + }, + "value": 1 + } + ], + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [ + 40000, + 100000 + ], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 6, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 2, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": false + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 1, + "isSecure": false + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": false + }, + { + "id": 128, + "name": "Battery", + "version": 1, + "isSecure": false + }, + { + "id": 113, + "name": "Notification", + "version": 4, + "isSecure": false + }, + { + "id": 49, + "name": "Multilevel Sensor", + "version": 7, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": false + }, + { + "id": 132, + "name": "Wake Up", + "version": 2, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 2, + "isSecure": false + } + ], + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0109:0x2021:0x2101:5.1", + "statistics": { + "commandsTX": 39, + "commandsRX": 38, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0 + }, + "highestSecurityClass": -1 +} diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index b2cb7bc808e2..ab29dfde23f1 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -12,7 +12,11 @@ from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.config_entries import DISABLED_USER, ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, +) from .common import AIR_TEMPERATURE_SENSOR, EATON_RF9640_ENTITY @@ -159,7 +163,7 @@ async def test_new_entity_on_value_added(hass, multisensor_6, client, integratio async def test_on_node_added_ready(hass, multisensor_6_state, client, integration): - """Test we handle a ready node added event.""" + """Test we handle a node added event with a ready node.""" dev_reg = dr.async_get(hass) node = Node(client, deepcopy(multisensor_6_state)) event = {"node": node} @@ -182,38 +186,34 @@ async def test_on_node_added_ready(hass, multisensor_6_state, client, integratio assert dev_reg.async_get_device(identifiers={(DOMAIN, air_temperature_device_id)}) -async def test_on_node_added_not_ready(hass, multisensor_6_state, client, integration): - """Test we handle a non ready node added event.""" +async def test_on_node_added_not_ready( + hass, zp3111_not_ready_state, client, integration +): + """Test we handle a node added event with a non-ready node.""" dev_reg = dr.async_get(hass) - node_data = deepcopy(multisensor_6_state) # Copy to allow modification in tests. - node = Node(client, node_data) - node.data["ready"] = False - event = {"node": node} - air_temperature_device_id = f"{client.driver.controller.home_id}-{node.node_id}" + device_id = f"{client.driver.controller.home_id}-{zp3111_not_ready_state['nodeId']}" - state = hass.states.get(AIR_TEMPERATURE_SENSOR) + assert len(hass.states.async_all()) == 0 + assert not dev_reg.devices - assert not state # entity and device not yet added - assert not dev_reg.async_get_device( - identifiers={(DOMAIN, air_temperature_device_id)} + event = Event( + type="node added", + data={ + "source": "controller", + "event": "node added", + "node": deepcopy(zp3111_not_ready_state), + }, ) - - client.driver.controller.emit("node added", event) + client.driver.receive_event(event) await hass.async_block_till_done() - state = hass.states.get(AIR_TEMPERATURE_SENSOR) - - assert not state # entity not yet added but device added in registry - assert dev_reg.async_get_device(identifiers={(DOMAIN, air_temperature_device_id)}) + # the only entity is the node status sensor + assert len(hass.states.async_all()) == 1 - node.data["ready"] = True - node.emit("ready", event) - await hass.async_block_till_done() - - state = hass.states.get(AIR_TEMPERATURE_SENSOR) - - assert state # entity added - assert state.state != STATE_UNAVAILABLE + device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device + # no extended device identifier yet + assert len(device.identifiers) == 1 async def test_existing_node_ready(hass, client, multisensor_6, integration): @@ -221,50 +221,163 @@ async def test_existing_node_ready(hass, client, multisensor_6, integration): dev_reg = dr.async_get(hass) node = multisensor_6 air_temperature_device_id = f"{client.driver.controller.home_id}-{node.node_id}" + air_temperature_device_id_ext = ( + f"{air_temperature_device_id}-{node.manufacturer_id}:" + f"{node.product_type}:{node.product_id}" + ) state = hass.states.get(AIR_TEMPERATURE_SENSOR) assert state # entity and device added assert state.state != STATE_UNAVAILABLE - assert dev_reg.async_get_device(identifiers={(DOMAIN, air_temperature_device_id)}) + device = dev_reg.async_get_device(identifiers={(DOMAIN, air_temperature_device_id)}) + assert device + assert device == dev_reg.async_get_device( + identifiers={(DOMAIN, air_temperature_device_id_ext)} + ) -async def test_null_name(hass, client, null_name_check, integration): - """Test that node without a name gets a generic node name.""" - node = null_name_check - assert hass.states.get(f"switch.node_{node.node_id}") +async def test_existing_node_not_ready(hass, zp3111_not_ready, client, integration): + """Test we handle a non-ready node that exists during integration setup.""" + dev_reg = dr.async_get(hass) + node = zp3111_not_ready + device_id = f"{client.driver.controller.home_id}-{node.node_id}" + + device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device.name == f"Node {node.node_id}" + assert not device.manufacturer + assert not device.model + assert not device.sw_version + + # the only entity is the node status sensor + assert len(hass.states.async_all()) == 1 + + device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device + # no extended device identifier yet + assert len(device.identifiers) == 1 -async def test_existing_node_not_ready(hass, client, multisensor_6): - """Test we handle a non ready node that exists during integration setup.""" + +async def test_existing_node_not_replaced_when_not_ready( + hass, zp3111, zp3111_not_ready_state, zp3111_state, client, integration +): + """Test when a node added event with a non-ready node is received. + + The existing node should not be replaced, and no customization should be lost. + """ dev_reg = dr.async_get(hass) - node = multisensor_6 - node.data = deepcopy(node.data) # Copy to allow modification in tests. - node.data["ready"] = False - event = {"node": node} - air_temperature_device_id = f"{client.driver.controller.home_id}-{node.node_id}" - entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) - entry.add_to_hass(hass) + er_reg = er.async_get(hass) + kitchen_area = ar.async_get(hass).async_create("Kitchen") - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + device_id = f"{client.driver.controller.home_id}-{zp3111.node_id}" + device_id_ext = ( + f"{device_id}-{zp3111.manufacturer_id}:" + f"{zp3111.product_type}:{zp3111.product_id}" + ) - state = hass.states.get(AIR_TEMPERATURE_SENSOR) + device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device + assert device.name == "4-in-1 Sensor" + assert not device.name_by_user + assert device.manufacturer == "Vision Security" + assert device.model == "ZP3111-5" + assert device.sw_version == "5.1" + assert not device.area_id + assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id_ext)}) + + motion_entity = "binary_sensor.4_in_1_sensor_home_security_motion_detection" + state = hass.states.get(motion_entity) + assert state + assert state.name == "4-in-1 Sensor: Home Security - Motion detection" + + dev_reg.async_update_device( + device.id, name_by_user="Custom Device Name", area_id=kitchen_area.id + ) - assert not state # entity not yet added - assert dev_reg.async_get_device( # device should be added - identifiers={(DOMAIN, air_temperature_device_id)} + custom_device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert custom_device + assert custom_device.name == "4-in-1 Sensor" + assert custom_device.name_by_user == "Custom Device Name" + assert custom_device.manufacturer == "Vision Security" + assert custom_device.model == "ZP3111-5" + assert device.sw_version == "5.1" + assert custom_device.area_id == kitchen_area.id + assert custom_device == dev_reg.async_get_device( + identifiers={(DOMAIN, device_id_ext)} ) - node.data["ready"] = True - node.emit("ready", event) + custom_entity = "binary_sensor.custom_motion_sensor" + er_reg.async_update_entity( + motion_entity, new_entity_id=custom_entity, name="Custom Entity Name" + ) await hass.async_block_till_done() + state = hass.states.get(custom_entity) + assert state + assert state.name == "Custom Entity Name" + assert not hass.states.get(motion_entity) - state = hass.states.get(AIR_TEMPERATURE_SENSOR) + event = Event( + type="node added", + data={ + "source": "controller", + "event": "node added", + "node": deepcopy(zp3111_not_ready_state), + }, + ) + client.driver.receive_event(event) + await hass.async_block_till_done() - assert state # entity and device added + device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device + assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id_ext)}) + assert device.id == custom_device.id + assert device.identifiers == custom_device.identifiers + assert device.name == f"Node {zp3111.node_id}" + assert device.name_by_user == "Custom Device Name" + assert not device.manufacturer + assert not device.model + assert not device.sw_version + assert device.area_id == kitchen_area.id + + state = hass.states.get(custom_entity) + assert state + assert state.name == "Custom Entity Name" + + event = Event( + type="ready", + data={ + "source": "node", + "event": "ready", + "nodeId": zp3111_state["nodeId"], + "nodeState": deepcopy(zp3111_state), + }, + ) + client.driver.receive_event(event) + await hass.async_block_till_done() + + device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device + assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id_ext)}) + assert device.id == custom_device.id + assert device.identifiers == custom_device.identifiers + assert device.name == "4-in-1 Sensor" + assert device.name_by_user == "Custom Device Name" + assert device.manufacturer == "Vision Security" + assert device.model == "ZP3111-5" + assert device.area_id == kitchen_area.id + assert device.sw_version == "5.1" + + state = hass.states.get(custom_entity) + assert state assert state.state != STATE_UNAVAILABLE - assert dev_reg.async_get_device(identifiers={(DOMAIN, air_temperature_device_id)}) + assert state.name == "Custom Entity Name" + + +async def test_null_name(hass, client, null_name_check, integration): + """Test that node without a name gets a generic node name.""" + node = null_name_check + assert hass.states.get(f"switch.node_{node.node_id}") async def test_start_addon( @@ -738,63 +851,460 @@ async def test_node_removed(hass, multisensor_6_state, client, integration): assert not dev_reg.async_get(old_device.id) -async def test_replace_same_node(hass, multisensor_6_state, client, integration): +async def test_replace_same_node( + hass, multisensor_6, multisensor_6_state, client, integration +): """Test when a node is replaced with itself that the device remains.""" dev_reg = dr.async_get(hass) - node = Node(client, deepcopy(multisensor_6_state)) - device_id = f"{client.driver.controller.home_id}-{node.node_id}" - event = {"node": node} + node_id = multisensor_6.node_id + multisensor_6_state = deepcopy(multisensor_6_state) - client.driver.controller.emit("node added", event) + device_id = f"{client.driver.controller.home_id}-{node_id}" + multisensor_6_device_id = ( + f"{device_id}-{multisensor_6.manufacturer_id}:" + f"{multisensor_6.product_type}:{multisensor_6.product_id}" + ) + + device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device + assert device == dev_reg.async_get_device( + identifiers={(DOMAIN, multisensor_6_device_id)} + ) + assert device.manufacturer == "AEON Labs" + assert device.model == "ZW100" + dev_id = device.id + + assert hass.states.get(AIR_TEMPERATURE_SENSOR) + + # A replace node event has the extra field "replaced" set to True + # to distinguish it from an exclusion + event = Event( + type="node removed", + data={ + "source": "controller", + "event": "node removed", + "replaced": True, + "node": multisensor_6_state, + }, + ) + client.driver.receive_event(event) await hass.async_block_till_done() - old_device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) - assert old_device.id - event = {"node": node, "replaced": True} + # Device should still be there after the node was removed + device = dev_reg.async_get(dev_id) + assert device - client.driver.controller.emit("node removed", event) + # When the node is replaced, a non-ready node added event is emitted + event = Event( + type="node added", + data={ + "source": "controller", + "event": "node added", + "node": { + "nodeId": node_id, + "index": 0, + "status": 4, + "ready": False, + "isSecure": "unknown", + "interviewAttempts": 1, + "endpoints": [{"nodeId": node_id, "index": 0, "deviceClass": None}], + "values": [], + "deviceClass": None, + "commandClasses": [], + "interviewStage": "None", + "statistics": { + "commandsTX": 0, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + }, + }, + }, + ) + + # Device is still not removed + client.driver.receive_event(event) await hass.async_block_till_done() - # Assert device has remained - assert dev_reg.async_get(old_device.id) - event = {"node": node} + device = dev_reg.async_get(dev_id) + assert device - client.driver.controller.emit("node added", event) + event = Event( + type="ready", + data={ + "source": "node", + "event": "ready", + "nodeId": node_id, + "nodeState": multisensor_6_state, + }, + ) + client.driver.receive_event(event) await hass.async_block_till_done() - # Assert device has remained - assert dev_reg.async_get(old_device.id) + + # Device is the same + device = dev_reg.async_get(dev_id) + assert device + assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device == dev_reg.async_get_device( + identifiers={(DOMAIN, multisensor_6_device_id)} + ) + assert device.manufacturer == "AEON Labs" + assert device.model == "ZW100" + + assert hass.states.get(AIR_TEMPERATURE_SENSOR) async def test_replace_different_node( - hass, multisensor_6_state, hank_binary_switch_state, client, integration + hass, + multisensor_6, + multisensor_6_state, + hank_binary_switch_state, + client, + integration, ): """Test when a node is replaced with a different node.""" - hank_binary_switch_state = deepcopy(hank_binary_switch_state) - multisensor_6_state = deepcopy(multisensor_6_state) - hank_binary_switch_state["nodeId"] = multisensor_6_state["nodeId"] dev_reg = dr.async_get(hass) - old_node = Node(client, multisensor_6_state) - device_id = f"{client.driver.controller.home_id}-{old_node.node_id}" - new_node = Node(client, hank_binary_switch_state) - event = {"node": old_node} + node_id = multisensor_6.node_id + hank_binary_switch_state = deepcopy(hank_binary_switch_state) + hank_binary_switch_state["nodeId"] = node_id + + device_id = f"{client.driver.controller.home_id}-{node_id}" + multisensor_6_device_id = ( + f"{device_id}-{multisensor_6.manufacturer_id}:" + f"{multisensor_6.product_type}:{multisensor_6.product_id}" + ) + hank_device_id = ( + f"{device_id}-{hank_binary_switch_state['manufacturerId']}:" + f"{hank_binary_switch_state['productType']}:" + f"{hank_binary_switch_state['productId']}" + ) - client.driver.controller.emit("node added", event) - await hass.async_block_till_done() device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) assert device + assert device == dev_reg.async_get_device( + identifiers={(DOMAIN, multisensor_6_device_id)} + ) + assert device.manufacturer == "AEON Labs" + assert device.model == "ZW100" + dev_id = device.id - event = {"node": old_node, "replaced": True} + assert hass.states.get(AIR_TEMPERATURE_SENSOR) - client.driver.controller.emit("node removed", event) + # A replace node event has the extra field "replaced" set to True + # to distinguish it from an exclusion + event = Event( + type="node removed", + data={ + "source": "controller", + "event": "node removed", + "replaced": True, + "node": multisensor_6_state, + }, + ) + client.driver.receive_event(event) await hass.async_block_till_done() + # Device should still be there after the node was removed + device = dev_reg.async_get(dev_id) assert device - event = {"node": new_node} + # When the node is replaced, a non-ready node added event is emitted + event = Event( + type="node added", + data={ + "source": "controller", + "event": "node added", + "node": { + "nodeId": multisensor_6.node_id, + "index": 0, + "status": 4, + "ready": False, + "isSecure": "unknown", + "interviewAttempts": 1, + "endpoints": [ + {"nodeId": multisensor_6.node_id, "index": 0, "deviceClass": None} + ], + "values": [], + "deviceClass": None, + "commandClasses": [], + "interviewStage": "None", + "statistics": { + "commandsTX": 0, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + }, + }, + }, + ) - client.driver.controller.emit("node added", event) + # Device is still not removed + client.driver.receive_event(event) await hass.async_block_till_done() - device = dev_reg.async_get(device.id) - # assert device is new + + device = dev_reg.async_get(dev_id) assert device + + event = Event( + type="ready", + data={ + "source": "node", + "event": "ready", + "nodeId": node_id, + "nodeState": hank_binary_switch_state, + }, + ) + client.driver.receive_event(event) + await hass.async_block_till_done() + + # Old device and entities were removed, but the ID is re-used + device = dev_reg.async_get(dev_id) + assert device + assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device == dev_reg.async_get_device(identifiers={(DOMAIN, hank_device_id)}) + assert not dev_reg.async_get_device(identifiers={(DOMAIN, multisensor_6_device_id)}) assert device.manufacturer == "HANK Electronics Ltd." + assert device.model == "HKZW-SO01" + + assert not hass.states.get(AIR_TEMPERATURE_SENSOR) + assert hass.states.get("switch.smart_plug_with_two_usb_ports") + + +async def test_node_model_change(hass, zp3111, client, integration): + """Test when a node's model is changed due to an updated device config file. + + The device and entities should not be removed. + """ + dev_reg = dr.async_get(hass) + er_reg = er.async_get(hass) + + device_id = f"{client.driver.controller.home_id}-{zp3111.node_id}" + device_id_ext = ( + f"{device_id}-{zp3111.manufacturer_id}:" + f"{zp3111.product_type}:{zp3111.product_id}" + ) + + # Verify device and entities have default names/ids + device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device + assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id_ext)}) + assert device.manufacturer == "Vision Security" + assert device.model == "ZP3111-5" + assert device.name == "4-in-1 Sensor" + assert not device.name_by_user + + dev_id = device.id + + motion_entity = "binary_sensor.4_in_1_sensor_home_security_motion_detection" + state = hass.states.get(motion_entity) + assert state + assert state.name == "4-in-1 Sensor: Home Security - Motion detection" + + # Customize device and entity names/ids + dev_reg.async_update_device(device.id, name_by_user="Custom Device Name") + device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device + assert device.id == dev_id + assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id_ext)}) + assert device.manufacturer == "Vision Security" + assert device.model == "ZP3111-5" + assert device.name == "4-in-1 Sensor" + assert device.name_by_user == "Custom Device Name" + + custom_entity = "binary_sensor.custom_motion_sensor" + er_reg.async_update_entity( + motion_entity, new_entity_id=custom_entity, name="Custom Entity Name" + ) + await hass.async_block_till_done() + assert not hass.states.get(motion_entity) + state = hass.states.get(custom_entity) + assert state + assert state.name == "Custom Entity Name" + + # Unload the integration + assert await hass.config_entries.async_unload(integration.entry_id) + await hass.async_block_till_done() + assert integration.state is ConfigEntryState.NOT_LOADED + assert not hass.data.get(DOMAIN) + + # Simulate changes to the node labels + zp3111.device_config.data["description"] = "New Device Name" + zp3111.device_config.data["label"] = "New Device Model" + zp3111.device_config.data["manufacturer"] = "New Device Manufacturer" + + # Reload integration, it will re-add the nodes + integration.add_to_hass(hass) + await hass.config_entries.async_setup(integration.entry_id) + await hass.async_block_till_done() + + # Device name changes, but the customization is the same + device = dev_reg.async_get(dev_id) + assert device + assert device.id == dev_id + assert device.manufacturer == "New Device Manufacturer" + assert device.model == "New Device Model" + assert device.name == "New Device Name" + assert device.name_by_user == "Custom Device Name" + + assert not hass.states.get(motion_entity) + state = hass.states.get(custom_entity) + assert state + assert state.name == "Custom Entity Name" + + +async def test_disabled_node_status_entity_on_node_replaced( + hass, zp3111_state, zp3111, client, integration +): + """Test that when a node replacement event is received the node status sensor is removed.""" + node_status_entity = "sensor.4_in_1_sensor_node_status" + state = hass.states.get(node_status_entity) + assert state + assert state.state != STATE_UNAVAILABLE + + event = Event( + type="node removed", + data={ + "source": "controller", + "event": "node removed", + "replaced": True, + "node": zp3111_state, + }, + ) + client.driver.receive_event(event) + await hass.async_block_till_done() + + state = hass.states.get(node_status_entity) + assert state + assert state.state == STATE_UNAVAILABLE + + +async def test_disabled_entity_on_value_removed(hass, zp3111, client, integration): + """Test that when entity primary values are removed the entity is removed.""" + er_reg = er.async_get(hass) + + # re-enable this default-disabled entity + sensor_cover_entity = "sensor.4_in_1_sensor_home_security_cover_status" + er_reg.async_update_entity(entity_id=sensor_cover_entity, disabled_by=None) + await hass.async_block_till_done() + + # must reload the integration when enabling an entity + await hass.config_entries.async_unload(integration.entry_id) + await hass.async_block_till_done() + assert integration.state is ConfigEntryState.NOT_LOADED + integration.add_to_hass(hass) + await hass.config_entries.async_setup(integration.entry_id) + await hass.async_block_till_done() + assert integration.state is ConfigEntryState.LOADED + + state = hass.states.get(sensor_cover_entity) + assert state + assert state.state != STATE_UNAVAILABLE + + # check for expected entities + binary_cover_entity = ( + "binary_sensor.4_in_1_sensor_home_security_tampering_product_cover_removed" + ) + state = hass.states.get(binary_cover_entity) + assert state + assert state.state != STATE_UNAVAILABLE + + battery_level_entity = "sensor.4_in_1_sensor_battery_level" + state = hass.states.get(battery_level_entity) + assert state + assert state.state != STATE_UNAVAILABLE + + unavailable_entities = { + state.entity_id + for state in hass.states.async_all() + if state.state == STATE_UNAVAILABLE + } + + # This value ID removal does not remove any entity + event = Event( + type="value removed", + data={ + "source": "node", + "event": "value removed", + "nodeId": zp3111.node_id, + "args": { + "commandClassName": "Wake Up", + "commandClass": 132, + "endpoint": 0, + "property": "wakeUpInterval", + "prevValue": 3600, + "propertyName": "wakeUpInterval", + }, + }, + ) + client.driver.receive_event(event) + await hass.async_block_till_done() + + assert all(state != STATE_UNAVAILABLE for state in hass.states.async_all()) + + # This value ID removal only affects the battery level entity + event = Event( + type="value removed", + data={ + "source": "node", + "event": "value removed", + "nodeId": zp3111.node_id, + "args": { + "commandClassName": "Battery", + "commandClass": 128, + "endpoint": 0, + "property": "level", + "prevValue": 100, + "propertyName": "level", + }, + }, + ) + client.driver.receive_event(event) + await hass.async_block_till_done() + + state = hass.states.get(battery_level_entity) + assert state + assert state.state == STATE_UNAVAILABLE + + # This value ID removal affects its multiple notification sensors + event = Event( + type="value removed", + data={ + "source": "node", + "event": "value removed", + "nodeId": zp3111.node_id, + "args": { + "commandClassName": "Notification", + "commandClass": 113, + "endpoint": 0, + "property": "Home Security", + "propertyKey": "Cover status", + "prevValue": 0, + "propertyName": "Home Security", + "propertyKeyName": "Cover status", + }, + }, + ) + client.driver.receive_event(event) + await hass.async_block_till_done() + + state = hass.states.get(binary_cover_entity) + assert state + assert state.state == STATE_UNAVAILABLE + + state = hass.states.get(sensor_cover_entity) + assert state + assert state.state == STATE_UNAVAILABLE + + # existing entities and the entities with removed values should be unavailable + new_unavailable_entities = { + state.entity_id + for state in hass.states.async_all() + if state.state == STATE_UNAVAILABLE + } + assert ( + unavailable_entities + | {battery_level_entity, binary_cover_entity, sensor_cover_entity} + == new_unavailable_entities + ) From df2b6fd5d47a12891b6562b552b6a72a19f118e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Jan 2022 13:06:09 -1000 Subject: [PATCH 02/20] Convert siri requests for target heating cooling state auto to a valid mode (#60220) --- .../components/homekit/manifest.json | 2 +- .../components/homekit/type_thermostats.py | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../homekit/test_type_thermostats.py | 24 +++++++++++++++++++ 5 files changed, 29 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index d23aa11b4eae..4b54468e092b 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit", "documentation": "https://www.home-assistant.io/integrations/homekit", "requirements": [ - "HAP-python==4.3.0", + "HAP-python==4.4.0", "fnvhash==0.1.0", "PyQRCode==1.2.1", "base36==0.1.1" diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index 804f0b861676..5f925e2b01dc 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -174,6 +174,7 @@ def __init__(self, *args): self.char_target_heat_cool.override_properties( valid_values=self.hc_hass_to_homekit ) + self.char_target_heat_cool.allow_invalid_client_values = True # Current and target temperature characteristics self.char_current_temp = serv_thermostat.configure_char( @@ -252,7 +253,6 @@ def _set_chars(self, char_values): hvac_mode = state.state homekit_hvac_mode = HC_HASS_TO_HOMEKIT[hvac_mode] - # Homekit will reset the mode when VIEWING the temp # Ignore it if its the same mode if ( @@ -282,7 +282,7 @@ def _set_chars(self, char_values): target_hc, hc_fallback, ) - target_hc = hc_fallback + self.char_target_heat_cool.value = target_hc = hc_fallback break params[ATTR_HVAC_MODE] = self.hc_homekit_to_hass[target_hc] diff --git a/requirements_all.txt b/requirements_all.txt index 41c1908f511c..ad1b5b55650e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -14,7 +14,7 @@ Adafruit-SHT31==1.0.2 # Adafruit_BBIO==1.1.1 # homeassistant.components.homekit -HAP-python==4.3.0 +HAP-python==4.4.0 # homeassistant.components.mastodon Mastodon.py==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8beca5013ab5..d6f998a9c34e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.2.1 # homeassistant.components.homekit -HAP-python==4.3.0 +HAP-python==4.4.0 # homeassistant.components.flick_electric PyFlick==0.0.2 diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index 20ed225552c2..a11aa9d6cb75 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -1266,6 +1266,7 @@ async def test_thermostat_hvac_modes_with_heat_only(hass, hk_driver): await hass.async_block_till_done() hap = acc.char_target_heat_cool.to_HAP() assert hap["valid-values"] == [HC_HEAT_COOL_OFF, HC_HEAT_COOL_HEAT] + assert acc.char_target_heat_cool.allow_invalid_client_values is True assert acc.char_target_heat_cool.value == HC_HEAT_COOL_HEAT acc.char_target_heat_cool.set_value(HC_HEAT_COOL_HEAT) @@ -1303,6 +1304,29 @@ async def test_thermostat_hvac_modes_with_heat_only(hass, hk_driver): assert call_set_hvac_mode[0].data[ATTR_ENTITY_ID] == entity_id assert call_set_hvac_mode[0].data[ATTR_HVAC_MODE] == HVAC_MODE_HEAT + acc.char_target_heat_cool.client_update_value(HC_HEAT_COOL_OFF) + await hass.async_block_till_done() + assert acc.char_target_heat_cool.value == HC_HEAT_COOL_OFF + hass.states.async_set( + entity_id, HVAC_MODE_OFF, {ATTR_HVAC_MODES: [HVAC_MODE_HEAT, HVAC_MODE_OFF]} + ) + await hass.async_block_till_done() + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_target_heat_cool_iid, + HAP_REPR_VALUE: HC_HEAT_COOL_AUTO, + }, + ] + }, + "mock_addr", + ) + await hass.async_block_till_done() + assert acc.char_target_heat_cool.value == HC_HEAT_COOL_HEAT + async def test_thermostat_hvac_modes_with_cool_only(hass, hk_driver): """Test if unsupported HVAC modes are deactivated in HomeKit and siri calls get converted to cool.""" From 7320904f48c2b71527d2a226341360cf282a24ab Mon Sep 17 00:00:00 2001 From: Michael Chisholm Date: Tue, 11 Jan 2022 23:36:46 +1100 Subject: [PATCH 03/20] dlna_dmr: Allow for upnp attributes with None contents (#63892) --- homeassistant/components/dlna_dmr/config_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/dlna_dmr/config_flow.py b/homeassistant/components/dlna_dmr/config_flow.py index 69437d99e3db..c1d5369f4747 100644 --- a/homeassistant/components/dlna_dmr/config_flow.py +++ b/homeassistant/components/dlna_dmr/config_flow.py @@ -474,8 +474,8 @@ def _is_ignored_device(discovery_info: ssdp.SsdpServiceInfo) -> bool: # Special cases for devices with other discovery methods (e.g. mDNS), or # that advertise multiple unrelated (sent in separate discovery packets) # UPnP devices. - manufacturer = discovery_info.upnp.get(ssdp.ATTR_UPNP_MANUFACTURER, "").lower() - model = discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME, "").lower() + manufacturer = (discovery_info.upnp.get(ssdp.ATTR_UPNP_MANUFACTURER) or "").lower() + model = (discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME) or "").lower() if manufacturer.startswith("xbmc") or model == "kodi": # kodi From 2fb1e406cfcf0db79ad3e46493d83e137824438f Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 12 Jan 2022 05:50:32 +0100 Subject: [PATCH 04/20] Convert disabled_by to DeviceEntryDisabler on load (#63944) Co-authored-by: Paulus Schoutsen --- homeassistant/helpers/device_registry.py | 4 +++- tests/helpers/test_device_registry.py | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index e31b77d3ae27..eb812f15b48a 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -584,7 +584,9 @@ async def async_load(self) -> None: configuration_url=device["configuration_url"], # type ignores (if tuple arg was cast): likely https://github.com/python/mypy/issues/8625 connections={tuple(conn) for conn in device["connections"]}, # type: ignore[misc] - disabled_by=device["disabled_by"], + disabled_by=DeviceEntryDisabler(device["disabled_by"]) + if device["disabled_by"] + else None, entry_type=DeviceEntryType(device["entry_type"]) if device["entry_type"] else None, diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 455c90b8f65c..ca58c014c757 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -640,6 +640,7 @@ async def test_loading_saving_data(hass, registry, area_registry): identifiers={("hue", "abc")}, manufacturer="manufacturer", model="light", + entry_type=device_registry.DeviceEntryType.SERVICE, ) assert orig_light4.id == orig_light3.id @@ -679,6 +680,15 @@ async def test_loading_saving_data(hass, registry, area_registry): assert orig_light == new_light assert orig_light4 == new_light4 + # Ensure enums converted + for (old, new) in ( + (orig_via, new_via), + (orig_light, new_light), + (orig_light4, new_light4), + ): + assert old.disabled_by is new.disabled_by + assert old.entry_type is new.entry_type + # Ensure a save/load cycle does not keep suggested area new_kitchen_light = registry2.async_get_device({("hue", "999")}) assert orig_kitchen_light.suggested_area == "Kitchen" From ba961b7fddadaa22c834eeb783c56485dd3ca100 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Jan 2022 12:56:24 -1000 Subject: [PATCH 05/20] Fix homekit options being mutated during config_flow/migration (#64003) --- homeassistant/components/homekit/__init__.py | 5 +++-- homeassistant/components/homekit/config_flow.py | 8 +++++--- tests/components/homekit/test_config_flow.py | 11 ++++++++++- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 6f2f09f39748..473705494fd6 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from copy import deepcopy import ipaddress import logging import os @@ -352,8 +353,8 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry): @callback def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: ConfigEntry): - options = dict(entry.options) - data = dict(entry.data) + options = deepcopy(dict(entry.options)) + data = deepcopy(dict(entry.data)) modified = False for importable_option in CONFIG_OPTIONS: if importable_option not in entry.options and importable_option in entry.data: diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index 34c62b31d2a6..303682f33358 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -2,9 +2,11 @@ from __future__ import annotations import asyncio +from copy import deepcopy import random import re import string +from typing import Final import voluptuous as vol @@ -116,7 +118,7 @@ "water_heater", ] -_EMPTY_ENTITY_FILTER = { +_EMPTY_ENTITY_FILTER: Final = { CONF_INCLUDE_DOMAINS: [], CONF_EXCLUDE_DOMAINS: [], CONF_INCLUDE_ENTITIES: [], @@ -151,7 +153,7 @@ def __init__(self) -> None: async def async_step_user(self, user_input=None): """Choose specific domains in bridge mode.""" if user_input is not None: - entity_filter = _EMPTY_ENTITY_FILTER.copy() + entity_filter = deepcopy(_EMPTY_ENTITY_FILTER) entity_filter[CONF_INCLUDE_DOMAINS] = user_input[CONF_INCLUDE_DOMAINS] self.hk_data[CONF_FILTER] = entity_filter return await self.async_step_pairing() @@ -492,7 +494,7 @@ async def async_step_init(self, user_input=None): self.hk_options.update(user_input) return await self.async_step_include_exclude() - self.hk_options = dict(self.config_entry.options) + self.hk_options = deepcopy(dict(self.config_entry.options)) entity_filter = self.hk_options.get(CONF_FILTER, {}) homekit_mode = self.hk_options.get(CONF_HOMEKIT_MODE, DEFAULT_HOMEKIT_MODE) domains = entity_filter.get(CONF_INCLUDE_DOMAINS, []) diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py index d190dec04b83..ffd223d1d2ae 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -2,9 +2,14 @@ from unittest.mock import patch from homeassistant import config_entries, data_entry_flow -from homeassistant.components.homekit.const import DOMAIN, SHORT_BRIDGE_NAME +from homeassistant.components.homekit.const import ( + CONF_FILTER, + DOMAIN, + SHORT_BRIDGE_NAME, +) from homeassistant.config_entries import SOURCE_IGNORE, SOURCE_IMPORT from homeassistant.const import CONF_NAME, CONF_PORT +from homeassistant.helpers.entityfilter import CONF_INCLUDE_DOMAINS from homeassistant.setup import async_setup_component from .util import PATH_HOMEKIT, async_init_entry @@ -347,6 +352,10 @@ async def test_options_flow_exclude_mode_basic(hass, mock_get_source_ip): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "include_exclude" + # Inject garbage to ensure the options data + # is being deep copied and we cannot mutate it in flight + config_entry.options[CONF_FILTER][CONF_INCLUDE_DOMAINS].append("garbage") + result2 = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"entities": ["climate.old"], "include_exclude_mode": "exclude"}, From 7bcf3e81d2d57c0aadab6aa3f3df3be640f57f30 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Jan 2022 16:12:30 -1000 Subject: [PATCH 06/20] Bump nexia to 0.9.13 to fix setting emergency heat (#64020) --- homeassistant/components/nexia/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nexia/manifest.json b/homeassistant/components/nexia/manifest.json index 624eee41db7f..f605b32528ed 100644 --- a/homeassistant/components/nexia/manifest.json +++ b/homeassistant/components/nexia/manifest.json @@ -1,7 +1,7 @@ { "domain": "nexia", "name": "Nexia/American Standard/Trane", - "requirements": ["nexia==0.9.12"], + "requirements": ["nexia==0.9.13"], "codeowners": ["@bdraco"], "documentation": "https://www.home-assistant.io/integrations/nexia", "config_flow": true, diff --git a/requirements_all.txt b/requirements_all.txt index ad1b5b55650e..96a9d71f4695 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1066,7 +1066,7 @@ nettigo-air-monitor==1.2.1 neurio==0.3.1 # homeassistant.components.nexia -nexia==0.9.12 +nexia==0.9.13 # homeassistant.components.nextcloud nextcloudmonitor==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d6f998a9c34e..7846809270ce 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -654,7 +654,7 @@ netmap==0.7.0.2 nettigo-air-monitor==1.2.1 # homeassistant.components.nexia -nexia==0.9.12 +nexia==0.9.13 # homeassistant.components.nfandroidtv notifications-android-tv==0.1.3 From 7a886efec50d82f603746dbb368de4d6327e7d81 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Jan 2022 13:43:39 -1000 Subject: [PATCH 07/20] Bump flux_led to 0.28.3 to fix setting colorloop on older models (#64094) Fixes #64087 --- .../components/flux_led/manifest.json | 65 +++++++++---------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 34 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/flux_led/manifest.json b/homeassistant/components/flux_led/manifest.json index e1815b1d1454..682a4fb5f687 100644 --- a/homeassistant/components/flux_led/manifest.json +++ b/homeassistant/components/flux_led/manifest.json @@ -3,42 +3,41 @@ "name": "Flux LED/MagicHome", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/flux_led", - "requirements": ["flux_led==0.27.45"], + "requirements": ["flux_led==0.28.3"], "quality_scale": "platinum", "codeowners": ["@icemanch"], "iot_class": "local_push", "dhcp": [ - { - "macaddress": "18B905*", - "hostname": "[ba][lk]*" - }, - { - "macaddress": "249494*", - "hostname": "[ba][lk]*" - }, - { - "macaddress": "7CB94C*", - "hostname": "[ba][lk]*" - }, - { - "macaddress": "B4E842*", - "hostname": "[ba][lk]*" - }, - { - "macaddress": "F0FE6B*", - "hostname": "[ba][lk]*" - }, - { - "macaddress": "8CCE4E*", - "hostname": "lwip*" - }, - { - "hostname": "zengge_[0-9a-f][0-9a-f]_*" - }, - { - "macaddress": "C82E47*", - "hostname": "sta*" - } + { + "macaddress": "18B905*", + "hostname": "[ba][lk]*" + }, + { + "macaddress": "249494*", + "hostname": "[ba][lk]*" + }, + { + "macaddress": "7CB94C*", + "hostname": "[ba][lk]*" + }, + { + "macaddress": "B4E842*", + "hostname": "[ba][lk]*" + }, + { + "macaddress": "F0FE6B*", + "hostname": "[ba][lk]*" + }, + { + "macaddress": "8CCE4E*", + "hostname": "lwip*" + }, + { + "hostname": "zengge_[0-9a-f][0-9a-f]_*" + }, + { + "macaddress": "C82E47*", + "hostname": "sta*" + } ] } - diff --git a/requirements_all.txt b/requirements_all.txt index 96a9d71f4695..ab05bdc3cdc0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -659,7 +659,7 @@ fjaraskupan==1.0.2 flipr-api==1.4.1 # homeassistant.components.flux_led -flux_led==0.27.45 +flux_led==0.28.3 # homeassistant.components.homekit fnvhash==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7846809270ce..694b772d5199 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -399,7 +399,7 @@ fjaraskupan==1.0.2 flipr-api==1.4.1 # homeassistant.components.flux_led -flux_led==0.27.45 +flux_led==0.28.3 # homeassistant.components.homekit fnvhash==0.1.0 From 405c4f685cc99b597ed1b0f24db192a5048c62a3 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 14 Jan 2022 17:24:54 +0100 Subject: [PATCH 08/20] Fix changing the preset mode (#64119) --- homeassistant/components/shelly/climate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index 777a41a66643..21d90adb74b0 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -281,10 +281,10 @@ async def async_set_hvac_mode(self, hvac_mode: str) -> None: async def async_set_preset_mode(self, preset_mode: str) -> None: """Set preset mode.""" - if not self._attr_preset_modes: + if not self._preset_modes: return - preset_index = self._attr_preset_modes.index(preset_mode) + preset_index = self._preset_modes.index(preset_mode) if preset_index == 0: await self.set_state_full_path(schedule=0) From 4f09439c8ca9dfb1a225f60ef78cba7b8f25782f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Jan 2022 13:14:02 -1000 Subject: [PATCH 09/20] Fix flux_led turn on when brightness is zero on newer devices (#64129) --- homeassistant/components/flux_led/light.py | 42 ++-- homeassistant/components/flux_led/util.py | 23 ++ tests/components/flux_led/test_light.py | 265 ++++++++++++++++++++- 3 files changed, 308 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/flux_led/light.py b/homeassistant/components/flux_led/light.py index d364d8b95811..4c93c678ad98 100644 --- a/homeassistant/components/flux_led/light.py +++ b/homeassistant/components/flux_led/light.py @@ -71,7 +71,14 @@ TRANSITION_STROBE, ) from .entity import FluxOnOffEntity -from .util import _effect_brightness, _flux_color_mode_to_hass, _hass_color_modes +from .util import ( + _effect_brightness, + _flux_color_mode_to_hass, + _hass_color_modes, + _min_rgb_brightness, + _min_rgbw_brightness, + _min_rgbwc_brightness, +) _LOGGER = logging.getLogger(__name__) @@ -244,7 +251,7 @@ def __init__( @property def brightness(self) -> int: """Return the brightness of this light between 0..255.""" - return self._device.brightness + return self._device.brightness # type: ignore @property def color_temp(self) -> int: @@ -254,17 +261,17 @@ def color_temp(self) -> int: @property def rgb_color(self) -> tuple[int, int, int]: """Return the rgb color value.""" - return self._device.rgb_unscaled + return self._device.rgb_unscaled # type: ignore @property def rgbw_color(self) -> tuple[int, int, int, int]: """Return the rgbw color value.""" - return self._device.rgbw + return self._device.rgbw # type: ignore @property def rgbww_color(self) -> tuple[int, int, int, int, int]: """Return the rgbww aka rgbcw color value.""" - return self._device.rgbcw + return self._device.rgbcw # type: ignore @property def color_mode(self) -> str: @@ -276,7 +283,7 @@ def color_mode(self) -> str: @property def effect(self) -> str | None: """Return the current effect.""" - return self._device.effect + return self._device.effect # type: ignore async def _async_turn_on(self, **kwargs: Any) -> None: """Turn the specified or all lights on.""" @@ -313,13 +320,13 @@ def _async_brightness(self, **kwargs: Any) -> int: """Determine brightness from kwargs or current value.""" if (brightness := kwargs.get(ATTR_BRIGHTNESS)) is None: brightness = self.brightness - if not brightness: - # If the brightness was previously 0, the light - # will not turn on unless brightness is at least 1 - # If the device was on and brightness was not - # set, it means it was masked by an effect - brightness = 255 if self.is_on else 1 - return brightness + # If the brightness was previously 0, the light + # will not turn on unless brightness is at least 1 + # + # We previously had a problem with the brightness + # sometimes reporting as 0 when an effect was in progress, + # however this has since been resolved in the upstream library + return max(1, brightness) async def _async_set_mode(self, **kwargs: Any) -> None: """Set an effect or color mode.""" @@ -348,6 +355,8 @@ async def _async_set_mode(self, **kwargs: Any) -> None: return # Handle switch to RGB Color Mode if rgb := kwargs.get(ATTR_RGB_COLOR): + if not self._device.requires_turn_on: + rgb = _min_rgb_brightness(rgb) red, green, blue = rgb await self._device.async_set_levels(red, green, blue, brightness=brightness) return @@ -355,13 +364,18 @@ async def _async_set_mode(self, **kwargs: Any) -> None: if rgbw := kwargs.get(ATTR_RGBW_COLOR): if ATTR_BRIGHTNESS in kwargs: rgbw = rgbw_brightness(rgbw, brightness) + if not self._device.requires_turn_on: + rgbw = _min_rgbw_brightness(rgbw) await self._device.async_set_levels(*rgbw) return # Handle switch to RGBWW Color Mode if rgbcw := kwargs.get(ATTR_RGBWW_COLOR): if ATTR_BRIGHTNESS in kwargs: rgbcw = rgbcw_brightness(kwargs[ATTR_RGBWW_COLOR], brightness) - await self._device.async_set_levels(*rgbcw_to_rgbwc(rgbcw)) + rgbwc = rgbcw_to_rgbwc(rgbcw) + if not self._device.requires_turn_on: + rgbwc = _min_rgbwc_brightness(rgbwc) + await self._device.async_set_levels(*rgbwc) return if (white := kwargs.get(ATTR_WHITE)) is not None: await self._device.async_set_levels(w=white) diff --git a/homeassistant/components/flux_led/util.py b/homeassistant/components/flux_led/util.py index 774ae1aaa532..1376e78b019e 100644 --- a/homeassistant/components/flux_led/util.py +++ b/homeassistant/components/flux_led/util.py @@ -34,3 +34,26 @@ def _flux_color_mode_to_hass( def _effect_brightness(brightness: int) -> int: """Convert hass brightness to effect brightness.""" return round(brightness / 255 * 100) + + +def _min_rgb_brightness(rgb: tuple[int, int, int]) -> tuple[int, int, int]: + """Ensure the RGB value will not turn off the device from a turn on command.""" + if all(byte == 0 for byte in rgb): + return (1, 1, 1) + return rgb + + +def _min_rgbw_brightness(rgbw: tuple[int, int, int, int]) -> tuple[int, int, int, int]: + """Ensure the RGBW value will not turn off the device from a turn on command.""" + if all(byte == 0 for byte in rgbw): + return (1, 1, 1, 0) + return rgbw + + +def _min_rgbwc_brightness( + rgbwc: tuple[int, int, int, int, int] +) -> tuple[int, int, int, int, int]: + """Ensure the RGBWC value will not turn off the device from a turn on command.""" + if all(byte == 0 for byte in rgbwc): + return (1, 1, 1, 0, 0) + return rgbwc diff --git a/tests/components/flux_led/test_light.py b/tests/components/flux_led/test_light.py index 92ea0fd8d397..a719d2973788 100644 --- a/tests/components/flux_led/test_light.py +++ b/tests/components/flux_led/test_light.py @@ -39,6 +39,9 @@ ATTR_RGBWW_COLOR, ATTR_SUPPORTED_COLOR_MODES, ATTR_WHITE, + COLOR_MODE_RGB, + COLOR_MODE_RGBW, + COLOR_MODE_RGBWW, DOMAIN as LIGHT_DOMAIN, ) from homeassistant.const import ( @@ -247,9 +250,11 @@ async def test_rgb_light(hass: HomeAssistant) -> None: blocking=True, ) # If the bulb is on and we are using existing brightness - # and brightness was 0 it means we could not read it because - # an effect is in progress so we use 255 - bulb.async_set_levels.assert_called_with(10, 10, 30, brightness=255) + # and brightness was 0 older devices will not be able to turn on + # so we need to make sure its at least 1 and that we + # call it before the turn on command since the device + # does not support auto on + bulb.async_set_levels.assert_called_with(10, 10, 30, brightness=1) bulb.async_set_levels.reset_mock() bulb.brightness = 128 @@ -304,9 +309,9 @@ async def test_rgb_light_auto_on(hass: HomeAssistant) -> None: assert state.state == STATE_ON attributes = state.attributes assert attributes[ATTR_BRIGHTNESS] == 128 - assert attributes[ATTR_COLOR_MODE] == "rgb" + assert attributes[ATTR_COLOR_MODE] == COLOR_MODE_RGB assert attributes[ATTR_EFFECT_LIST] == bulb.effect_list - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["rgb"] + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [COLOR_MODE_RGB] assert attributes[ATTR_HS_COLOR] == (0, 100) await hass.services.async_call( @@ -331,6 +336,19 @@ async def test_rgb_light_auto_on(hass: HomeAssistant) -> None: bulb.async_set_levels.reset_mock() bulb.async_turn_on.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_RGB_COLOR: (0, 0, 0)}, + blocking=True, + ) + # If the bulb is off and we are using existing brightness + # it has to be at least 1 or the bulb won't turn on + bulb.async_turn_on.assert_not_called() + bulb.async_set_levels.assert_called_with(1, 1, 1, brightness=1) + bulb.async_set_levels.reset_mock() + bulb.async_turn_on.reset_mock() + # Should still be called with no kwargs await hass.services.async_call( LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -357,10 +375,11 @@ async def test_rgb_light_auto_on(hass: HomeAssistant) -> None: blocking=True, ) # If the bulb is on and we are using existing brightness - # and brightness was 0 it means we could not read it because - # an effect is in progress so we use 255 + # and brightness was 0 we need to set it to at least 1 + # or the device may not turn on bulb.async_turn_on.assert_not_called() - bulb.async_set_levels.assert_called_with(10, 10, 30, brightness=255) + bulb.async_set_brightness.assert_not_called() + bulb.async_set_levels.assert_called_with(10, 10, 30, brightness=1) bulb.async_set_levels.reset_mock() bulb.brightness = 128 @@ -395,6 +414,236 @@ async def test_rgb_light_auto_on(hass: HomeAssistant) -> None: bulb.async_set_effect.reset_mock() +async def test_rgbw_light_auto_on(hass: HomeAssistant) -> None: + """Test an rgbw light that does not need the turn on command sent.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + bulb.requires_turn_on = False + bulb.raw_state = bulb.raw_state._replace(model_num=0x33) # RGB only model + bulb.color_modes = {FLUX_COLOR_MODE_RGBW} + bulb.color_mode = FLUX_COLOR_MODE_RGBW + with _patch_discovery(), _patch_wifibulb(device=bulb): + await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.bulb_rgbcw_ddeeff" + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + attributes = state.attributes + assert attributes[ATTR_BRIGHTNESS] == 128 + assert attributes[ATTR_COLOR_MODE] == COLOR_MODE_RGBW + assert attributes[ATTR_EFFECT_LIST] == bulb.effect_list + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [COLOR_MODE_RGBW] + assert attributes[ATTR_HS_COLOR] == (0.0, 83.529) + + await hass.services.async_call( + LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + bulb.async_turn_off.assert_called_once() + + await async_mock_device_turn_off(hass, bulb) + assert hass.states.get(entity_id).state == STATE_OFF + + bulb.brightness = 0 + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_RGBW_COLOR: (10, 10, 30, 0)}, + blocking=True, + ) + # If the bulb is off and we are using existing brightness + # it has to be at least 1 or the bulb won't turn on + bulb.async_turn_on.assert_not_called() + bulb.async_set_levels.assert_called_with(10, 10, 30, 0) + bulb.async_set_levels.reset_mock() + bulb.async_turn_on.reset_mock() + + # Should still be called with no kwargs + await hass.services.async_call( + LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + bulb.async_turn_on.assert_called_once() + await async_mock_device_turn_on(hass, bulb) + assert hass.states.get(entity_id).state == STATE_ON + bulb.async_turn_on.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, + blocking=True, + ) + bulb.async_turn_on.assert_not_called() + bulb.async_set_brightness.assert_called_with(100) + bulb.async_set_brightness.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_RGBW_COLOR: (0, 0, 0, 0)}, + blocking=True, + ) + # If the bulb is on and we are using existing brightness + # and brightness was 0 we need to set it to at least 1 + # or the device may not turn on + bulb.async_turn_on.assert_not_called() + bulb.async_set_brightness.assert_not_called() + bulb.async_set_levels.assert_called_with(1, 1, 1, 0) + bulb.async_set_levels.reset_mock() + + bulb.brightness = 128 + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_HS_COLOR: (10, 30)}, + blocking=True, + ) + bulb.async_turn_on.assert_not_called() + bulb.async_set_levels.assert_called_with(110, 19, 0, 255) + bulb.async_set_levels.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "random"}, + blocking=True, + ) + bulb.async_turn_on.assert_not_called() + bulb.async_set_effect.assert_called_once() + bulb.async_set_effect.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "purple_fade"}, + blocking=True, + ) + bulb.async_turn_on.assert_not_called() + bulb.async_set_effect.assert_called_with("purple_fade", 50, 50) + bulb.async_set_effect.reset_mock() + + +async def test_rgbww_light_auto_on(hass: HomeAssistant) -> None: + """Test an rgbww light that does not need the turn on command sent.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + bulb.requires_turn_on = False + bulb.raw_state = bulb.raw_state._replace(model_num=0x33) # RGB only model + bulb.color_modes = {FLUX_COLOR_MODE_RGBWW} + bulb.color_mode = FLUX_COLOR_MODE_RGBWW + with _patch_discovery(), _patch_wifibulb(device=bulb): + await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.bulb_rgbcw_ddeeff" + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + attributes = state.attributes + assert attributes[ATTR_BRIGHTNESS] == 128 + assert attributes[ATTR_COLOR_MODE] == COLOR_MODE_RGBWW + assert attributes[ATTR_EFFECT_LIST] == bulb.effect_list + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [COLOR_MODE_RGBWW] + assert attributes[ATTR_HS_COLOR] == (3.237, 94.51) + + await hass.services.async_call( + LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + bulb.async_turn_off.assert_called_once() + + await async_mock_device_turn_off(hass, bulb) + assert hass.states.get(entity_id).state == STATE_OFF + + bulb.brightness = 0 + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_RGBWW_COLOR: (10, 10, 30, 0, 0)}, + blocking=True, + ) + # If the bulb is off and we are using existing brightness + # it has to be at least 1 or the bulb won't turn on + bulb.async_turn_on.assert_not_called() + bulb.async_set_levels.assert_called_with(10, 10, 30, 0, 0) + bulb.async_set_levels.reset_mock() + bulb.async_turn_on.reset_mock() + + # Should still be called with no kwargs + await hass.services.async_call( + LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + bulb.async_turn_on.assert_called_once() + await async_mock_device_turn_on(hass, bulb) + assert hass.states.get(entity_id).state == STATE_ON + bulb.async_turn_on.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, + blocking=True, + ) + bulb.async_turn_on.assert_not_called() + bulb.async_set_brightness.assert_called_with(100) + bulb.async_set_brightness.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_RGBWW_COLOR: (0, 0, 0, 0, 0)}, + blocking=True, + ) + # If the bulb is on and we are using existing brightness + # and brightness was 0 we need to set it to at least 1 + # or the device may not turn on + bulb.async_turn_on.assert_not_called() + bulb.async_set_brightness.assert_not_called() + bulb.async_set_levels.assert_called_with(1, 1, 1, 0, 0) + bulb.async_set_levels.reset_mock() + + bulb.brightness = 128 + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_HS_COLOR: (10, 30)}, + blocking=True, + ) + bulb.async_turn_on.assert_not_called() + bulb.async_set_levels.assert_called_with(14, 0, 30, 255, 255) + bulb.async_set_levels.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "random"}, + blocking=True, + ) + bulb.async_turn_on.assert_not_called() + bulb.async_set_effect.assert_called_once() + bulb.async_set_effect.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "purple_fade"}, + blocking=True, + ) + bulb.async_turn_on.assert_not_called() + bulb.async_set_effect.assert_called_with("purple_fade", 50, 50) + bulb.async_set_effect.reset_mock() + + async def test_rgb_cct_light(hass: HomeAssistant) -> None: """Test an rgb cct light.""" config_entry = MockConfigEntry( From 9f281e026f0e2eb861e002625ab111bc435f4fb3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Jan 2022 07:26:47 -1000 Subject: [PATCH 10/20] Ensure august status is current when integration loads (#64027) --- homeassistant/components/august/__init__.py | 14 ++++++++++++++ homeassistant/components/august/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/august/mocks.py | 12 ++++++++++-- 5 files changed, 27 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index ff6a4f5adb62..e8b4a15b65ad 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -138,6 +138,11 @@ async def async_setup(self): pubnub.subscribe(self.async_pubnub_message) self._pubnub_unsub = async_create_pubnub(user_data["UserID"], pubnub) + if self._locks_by_id: + await asyncio.gather( + *[self.async_status_async(lock_id) for lock_id in self._locks_by_id] + ) + @callback def async_pubnub_message(self, device_id, date_time, message): """Process a pubnub message.""" @@ -245,6 +250,15 @@ async def async_lock(self, device_id): device_id, ) + async def async_status_async(self, device_id): + """Request status of the the device but do not wait for a response since it will come via pubnub.""" + return await self._async_call_api_op_requires_bridge( + device_id, + self._api.async_status_async, + self._august_gateway.access_token, + device_id, + ) + async def async_lock_async(self, device_id): """Lock the device but do not wait for a response since it will come via pubnub.""" return await self._async_call_api_op_requires_bridge( diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index c08f25177cc9..15b31edcce43 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -2,7 +2,7 @@ "domain": "august", "name": "August", "documentation": "https://www.home-assistant.io/integrations/august", - "requirements": ["yalexs==1.1.17"], + "requirements": ["yalexs==1.1.18"], "codeowners": ["@bdraco"], "dhcp": [ { diff --git a/requirements_all.txt b/requirements_all.txt index ab05bdc3cdc0..89285ae67f05 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2466,7 +2466,7 @@ xs1-api-client==3.0.0 yalesmartalarmclient==0.3.4 # homeassistant.components.august -yalexs==1.1.17 +yalexs==1.1.18 # homeassistant.components.yeelight yeelight==0.7.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 694b772d5199..2e62c9f62d9c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1464,7 +1464,7 @@ xmltodict==0.12.0 yalesmartalarmclient==0.3.4 # homeassistant.components.august -yalexs==1.1.17 +yalexs==1.1.18 # homeassistant.components.yeelight yeelight==0.7.8 diff --git a/tests/components/august/mocks.py b/tests/components/august/mocks.py index 7075eb84d721..2d572b886f3e 100644 --- a/tests/components/august/mocks.py +++ b/tests/components/august/mocks.py @@ -162,10 +162,17 @@ def unlock_return_activities_side_effect(access_token, device_id): "unlock_return_activities" ] = unlock_return_activities_side_effect - return await _mock_setup_august_with_api_side_effects( + api_instance, entry = await _mock_setup_august_with_api_side_effects( hass, api_call_side_effects, pubnub ) + if device_data["locks"]: + # Ensure we sync status when the integration is loaded if there + # are any locks + assert api_instance.async_status_async.mock_calls + + return entry + async def _mock_setup_august_with_api_side_effects(hass, api_call_side_effects, pubnub): api_instance = MagicMock(name="Api") @@ -207,9 +214,10 @@ async def _mock_setup_august_with_api_side_effects(hass, api_call_side_effects, api_instance.async_unlock_async = AsyncMock() api_instance.async_lock_async = AsyncMock() + api_instance.async_status_async = AsyncMock() api_instance.async_get_user = AsyncMock(return_value={"UserID": "abc"}) - return await _mock_setup_august(hass, api_instance, pubnub) + return api_instance, await _mock_setup_august(hass, api_instance, pubnub) def _mock_august_authentication(token_text, token_timestamp, state): From 8f842c780782c4ee23a03aaa239f2591b0b07d2e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Jan 2022 13:13:30 -1000 Subject: [PATCH 11/20] Fix august lock/unlock with older bridges (#64143) --- homeassistant/components/august/__init__.py | 6 ++++-- homeassistant/components/august/lock.py | 9 +++++++-- homeassistant/components/august/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 14 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index e8b4a15b65ad..53ea583050bb 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -259,13 +259,14 @@ async def async_status_async(self, device_id): device_id, ) - async def async_lock_async(self, device_id): + async def async_lock_async(self, device_id, hyper_bridge): """Lock the device but do not wait for a response since it will come via pubnub.""" return await self._async_call_api_op_requires_bridge( device_id, self._api.async_lock_async, self._august_gateway.access_token, device_id, + hyper_bridge, ) async def async_unlock(self, device_id): @@ -277,13 +278,14 @@ async def async_unlock(self, device_id): device_id, ) - async def async_unlock_async(self, device_id): + async def async_unlock_async(self, device_id, hyper_bridge): """Unlock the device but do not wait for a response since it will come via pubnub.""" return await self._async_call_api_op_requires_bridge( device_id, self._api.async_unlock_async, self._august_gateway.access_token, device_id, + hyper_bridge, ) async def _async_call_api_op_requires_bridge( diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index ea977a3c2d08..3c5af4c94c90 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -39,17 +39,22 @@ def __init__(self, data, device): self._attr_unique_id = f"{self._device_id:s}_lock" self._update_from_data() + @property + def _hyper_bridge(self): + """Check if the lock has a paired hyper bridge.""" + return bool(self._detail.bridge and self._detail.bridge.hyper_bridge) + async def async_lock(self, **kwargs): """Lock the device.""" if self._data.activity_stream.pubnub.connected: - await self._data.async_lock_async(self._device_id) + await self._data.async_lock_async(self._device_id, self._hyper_bridge) return await self._call_lock_operation(self._data.async_lock) async def async_unlock(self, **kwargs): """Unlock the device.""" if self._data.activity_stream.pubnub.connected: - await self._data.async_unlock_async(self._device_id) + await self._data.async_unlock_async(self._device_id, self._hyper_bridge) return await self._call_lock_operation(self._data.async_unlock) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 15b31edcce43..db537287b05d 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -2,7 +2,7 @@ "domain": "august", "name": "August", "documentation": "https://www.home-assistant.io/integrations/august", - "requirements": ["yalexs==1.1.18"], + "requirements": ["yalexs==1.1.19"], "codeowners": ["@bdraco"], "dhcp": [ { diff --git a/requirements_all.txt b/requirements_all.txt index 89285ae67f05..86e575f4cb52 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2466,7 +2466,7 @@ xs1-api-client==3.0.0 yalesmartalarmclient==0.3.4 # homeassistant.components.august -yalexs==1.1.18 +yalexs==1.1.19 # homeassistant.components.yeelight yeelight==0.7.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2e62c9f62d9c..9827fb3f7b7d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1464,7 +1464,7 @@ xmltodict==0.12.0 yalesmartalarmclient==0.3.4 # homeassistant.components.august -yalexs==1.1.18 +yalexs==1.1.19 # homeassistant.components.yeelight yeelight==0.7.8 From da5b4735da4021ed03205999eb9534b087e58890 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 16 Jan 2022 07:22:18 -0500 Subject: [PATCH 12/20] Ignore unavailable entities when creating zwave_js device actions list (#64184) * Fix bug with zwave-js device actions * outdent * Add test and fix bug * fix --- .../components/zwave_js/device_action.py | 18 +++++++++++---- .../components/zwave_js/test_device_action.py | 23 +++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zwave_js/device_action.py b/homeassistant/components/zwave_js/device_action.py index f819a33f1d49..9f6fa7fc35cf 100644 --- a/homeassistant/components/zwave_js/device_action.py +++ b/homeassistant/components/zwave_js/device_action.py @@ -21,6 +21,7 @@ CONF_DOMAIN, CONF_ENTITY_ID, CONF_TYPE, + STATE_UNAVAILABLE, ) from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -172,7 +173,17 @@ async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: meter_endpoints: dict[int, dict[str, Any]] = defaultdict(dict) - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in entity_registry.async_entries_for_device( + registry, device_id, include_disabled_entities=False + ): + # If an entry is unavailable, it is possible that the underlying value + # is no longer valid. Additionally, if an entry is disabled, its + # underlying value is not being monitored by HA so we shouldn't allow + # actions against it. + if ( + state := hass.states.get(entry.entity_id) + ) and state.state == STATE_UNAVAILABLE: + continue entity_action = {**base_action, CONF_ENTITY_ID: entry.entity_id} actions.append({**entity_action, CONF_TYPE: SERVICE_REFRESH_VALUE}) if entry.domain == LOCK_DOMAIN: @@ -187,10 +198,9 @@ async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: value_id = entry.unique_id.split(".")[1] # If this unique ID doesn't have a value ID, we know it is the node status # sensor which doesn't have any relevant actions - if re.match(VALUE_ID_REGEX, value_id): - value = node.values[value_id] - else: + if not re.match(VALUE_ID_REGEX, value_id): continue + value = node.values[value_id] # If the value has the meterType CC specific value, we can add a reset_meter # action for it if CC_SPECIFIC_METER_TYPE in value.metadata.cc_specific: diff --git a/tests/components/zwave_js/test_device_action.py b/tests/components/zwave_js/test_device_action.py index 0980b414a092..ff511bc5f0e1 100644 --- a/tests/components/zwave_js/test_device_action.py +++ b/tests/components/zwave_js/test_device_action.py @@ -11,6 +11,7 @@ from homeassistant.components.zwave_js import DOMAIN, device_action from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, device_registry @@ -583,3 +584,25 @@ async def test_failure_scenarios( ) == {} ) + + +async def test_unavailable_entity_actions( + hass: HomeAssistant, + client: Client, + lock_schlage_be469: Node, + integration: ConfigEntry, +) -> None: + """Test unavailable entities are not included in actions list.""" + entity_id_unavailable = "binary_sensor.touchscreen_deadbolt_home_security_intrusion" + hass.states.async_set(entity_id_unavailable, STATE_UNAVAILABLE, force_update=True) + await hass.async_block_till_done() + node = lock_schlage_be469 + dev_reg = device_registry.async_get(hass) + device = dev_reg.async_get_device({get_device_id(client, node)}) + assert device + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, device.id + ) + assert not any( + action.get("entity_id") == entity_id_unavailable for action in actions + ) From ec993b12e92843aa40adcf5eeb5dd743256070ce Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Mon, 17 Jan 2022 05:44:21 +0100 Subject: [PATCH 13/20] Fix KNX onboarding when there is no yaml config defined yet (#64216) --- homeassistant/components/knx/__init__.py | 27 ++++++------ homeassistant/components/knx/config_flow.py | 48 +++++++++------------ 2 files changed, 33 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 61d49243430b..943baf47549b 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -206,7 +206,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return bool(hass.config_entries.async_entries(DOMAIN)) conf = dict(conf) - hass.data[DATA_KNX_CONFIG] = conf # Only import if we haven't before. @@ -223,19 +222,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Load a config entry.""" conf = hass.data.get(DATA_KNX_CONFIG) - - # When reloading - if conf is None: - conf = await async_integration_yaml_config(hass, DOMAIN) - if not conf or DOMAIN not in conf: - return False - - conf = conf[DOMAIN] - - # If user didn't have configuration.yaml config, generate defaults + # `conf` is None when reloading the integration or no `knx` key in configuration.yaml if conf is None: - conf = CONFIG_SCHEMA({DOMAIN: dict(entry.data)})[DOMAIN] - + _conf = await async_integration_yaml_config(hass, DOMAIN) + if not _conf or DOMAIN not in _conf: + _LOGGER.warning( + "No `knx:` key found in configuration.yaml. See " + "https://www.home-assistant.io/integrations/knx/ " + "for KNX entity configuration documentation" + ) + # generate defaults + conf = CONFIG_SCHEMA({DOMAIN: {}})[DOMAIN] + else: + conf = _conf[DOMAIN] config = {**conf, **entry.data} try: @@ -363,7 +362,6 @@ def __init__( self.entry.async_on_unload( self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop) ) - self.entry.async_on_unload(self.entry.add_update_listener(async_update_entry)) def init_xknx(self) -> None: @@ -403,7 +401,6 @@ def connection_config(self) -> ConnectionConfig: route_back=self.config.get(ConnectionSchema.CONF_KNX_ROUTE_BACK, False), auto_reconnect=True, ) - return ConnectionConfig(auto_reconnect=True) async def connection_state_changed_cb(self, state: XknxConnectionState) -> None: diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py index 01e71eb37af5..99cdc4807c6c 100644 --- a/homeassistant/components/knx/config_flow.py +++ b/homeassistant/components/knx/config_flow.py @@ -44,7 +44,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - _tunnels: list + _tunnels: list[GatewayDescriptor] _gateway_ip: str = "" _gateway_port: int = DEFAULT_MCAST_PORT @@ -64,25 +64,6 @@ async def async_step_user(self, user_input: dict | None = None) -> FlowResult: async def async_step_type(self, user_input: dict | None = None) -> FlowResult: """Handle connection type configuration.""" - errors: dict = {} - supported_connection_types = CONF_KNX_INITIAL_CONNECTION_TYPES.copy() - fields = {} - - if user_input is None: - gateways = await scan_for_gateways() - - if gateways: - supported_connection_types.insert(0, CONF_KNX_AUTOMATIC) - self._tunnels = [ - gateway for gateway in gateways if gateway.supports_tunnelling - ] - - fields = { - vol.Required(CONF_KNX_CONNECTION_TYPE): vol.In( - supported_connection_types - ) - } - if user_input is not None: connection_type = user_input[CONF_KNX_CONNECTION_TYPE] if connection_type == CONF_KNX_AUTOMATIC: @@ -99,6 +80,22 @@ async def async_step_type(self, user_input: dict | None = None) -> FlowResult: return await self.async_step_manual_tunnel() + errors: dict = {} + supported_connection_types = CONF_KNX_INITIAL_CONNECTION_TYPES.copy() + fields = {} + gateways = await scan_for_gateways() + + if gateways: + # add automatic only if a gateway responded + supported_connection_types.insert(0, CONF_KNX_AUTOMATIC) + self._tunnels = [ + gateway for gateway in gateways if gateway.supports_tunnelling + ] + + fields = { + vol.Required(CONF_KNX_CONNECTION_TYPE): vol.In(supported_connection_types) + } + return self.async_show_form( step_id="type", data_schema=vol.Schema(fields), errors=errors ) @@ -107,8 +104,6 @@ async def async_step_manual_tunnel( self, user_input: dict | None = None ) -> FlowResult: """General setup.""" - errors: dict = {} - if user_input is not None: return self.async_create_entry( title=f"{CONF_KNX_TUNNELING.capitalize()} @ {user_input[CONF_HOST]}", @@ -129,6 +124,7 @@ async def async_step_manual_tunnel( }, ) + errors: dict = {} fields = { vol.Required(CONF_HOST, default=self._gateway_ip): str, vol.Required(CONF_PORT, default=self._gateway_port): vol.Coerce(int), @@ -149,8 +145,6 @@ async def async_step_manual_tunnel( async def async_step_tunnel(self, user_input: dict | None = None) -> FlowResult: """Select a tunnel from a list. Will be skipped if the gateway scan was unsuccessful or if only one gateway was found.""" - errors: dict = {} - if user_input is not None: gateway: GatewayDescriptor = next( gateway @@ -163,6 +157,7 @@ async def async_step_tunnel(self, user_input: dict | None = None) -> FlowResult: return await self.async_step_manual_tunnel() + errors: dict = {} tunnel_repr = { str(tunnel) for tunnel in self._tunnels if tunnel.supports_tunnelling } @@ -182,8 +177,6 @@ async def async_step_tunnel(self, user_input: dict | None = None) -> FlowResult: async def async_step_routing(self, user_input: dict | None = None) -> FlowResult: """Routing setup.""" - errors: dict = {} - if user_input is not None: return self.async_create_entry( title=CONF_KNX_ROUTING.capitalize(), @@ -205,6 +198,7 @@ async def async_step_routing(self, user_input: dict | None = None) -> FlowResult }, ) + errors: dict = {} fields = { vol.Required( CONF_KNX_INDIVIDUAL_ADDRESS, default=XKNX.DEFAULT_ADDRESS @@ -434,7 +428,7 @@ async def async_step_init( ) -async def scan_for_gateways(stop_on_found: int = 0) -> list: +async def scan_for_gateways(stop_on_found: int = 0) -> list[GatewayDescriptor]: """Scan for gateways within the network.""" xknx = XKNX() gatewayscanner = GatewayScanner( From 8c531b4c17becf59da257ab99a633411a34032c2 Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Mon, 17 Jan 2022 19:30:35 +0100 Subject: [PATCH 14/20] Bump bimmer_connected to 0.8.10 (#64314) Co-authored-by: rikroe --- homeassistant/components/bmw_connected_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index 63046d9d4419..9698679a6d62 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -2,7 +2,7 @@ "domain": "bmw_connected_drive", "name": "BMW Connected Drive", "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", - "requirements": ["bimmer_connected==0.8.7"], + "requirements": ["bimmer_connected==0.8.10"], "codeowners": ["@gerard33", "@rikroe"], "config_flow": true, "iot_class": "cloud_polling" diff --git a/requirements_all.txt b/requirements_all.txt index 86e575f4cb52..864a1d1960d2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -388,7 +388,7 @@ beautifulsoup4==4.10.0 bellows==0.29.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.8.7 +bimmer_connected==0.8.10 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9827fb3f7b7d..d9da69ac50d3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -257,7 +257,7 @@ base36==0.1.1 bellows==0.29.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.8.7 +bimmer_connected==0.8.10 # homeassistant.components.blebox blebox_uniapi==1.3.3 From 1c82a56618d07df3b8504a9ebf35504aac3f340e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 17 Jan 2022 10:39:56 -0800 Subject: [PATCH 15/20] Bumped version to 2021.12.10 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 16ee024024c1..3f2f57c489ea 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 12 -PATCH_VERSION: Final = "9" +PATCH_VERSION: Final = "10" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From 69c5b04de9c1b36f459f86dbd0a97d787bb6cd2a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 17 Jan 2022 11:21:55 -0800 Subject: [PATCH 16/20] lint --- tests/components/zwave_js/test_device_action.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/components/zwave_js/test_device_action.py b/tests/components/zwave_js/test_device_action.py index ff511bc5f0e1..965c7207fcf1 100644 --- a/tests/components/zwave_js/test_device_action.py +++ b/tests/components/zwave_js/test_device_action.py @@ -600,9 +600,7 @@ async def test_unavailable_entity_actions( dev_reg = device_registry.async_get(hass) device = dev_reg.async_get_device({get_device_id(client, node)}) assert device - actions = await async_get_device_automations( - hass, DeviceAutomationType.ACTION, device.id - ) + actions = await async_get_device_automations(hass, "action", device.id) assert not any( action.get("entity_id") == entity_id_unavailable for action in actions ) From 777c5b77171a617753ac5cf5e24b71d7f5d0fdec Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Jan 2022 21:00:44 -1000 Subject: [PATCH 17/20] Ensure status is correct at start for older august bridges (#64144) --- homeassistant/components/august/__init__.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index 53ea583050bb..c198cf8a3b34 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -139,9 +139,15 @@ async def async_setup(self): self._pubnub_unsub = async_create_pubnub(user_data["UserID"], pubnub) if self._locks_by_id: - await asyncio.gather( - *[self.async_status_async(lock_id) for lock_id in self._locks_by_id] - ) + tasks = [] + for lock_id in self._locks_by_id: + detail = self._device_detail_by_id[lock_id] + tasks.append( + self.async_status_async( + lock_id, bool(detail.bridge and detail.bridge.hyper_bridge) + ) + ) + await asyncio.gather(*tasks) @callback def async_pubnub_message(self, device_id, date_time, message): @@ -250,13 +256,14 @@ async def async_lock(self, device_id): device_id, ) - async def async_status_async(self, device_id): + async def async_status_async(self, device_id, hyper_bridge): """Request status of the the device but do not wait for a response since it will come via pubnub.""" return await self._async_call_api_op_requires_bridge( device_id, self._api.async_status_async, self._august_gateway.access_token, device_id, + hyper_bridge, ) async def async_lock_async(self, device_id, hyper_bridge): From bfe657ac966369c9213ce45bc6b610b0c32c30d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Jan 2022 12:35:36 -1000 Subject: [PATCH 18/20] Bump flux_led to 0.28.4 to fix setting white temp on 0x35 devices (#64326) --- homeassistant/components/flux_led/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/flux_led/manifest.json b/homeassistant/components/flux_led/manifest.json index 682a4fb5f687..a5d1482adfe2 100644 --- a/homeassistant/components/flux_led/manifest.json +++ b/homeassistant/components/flux_led/manifest.json @@ -3,7 +3,7 @@ "name": "Flux LED/MagicHome", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/flux_led", - "requirements": ["flux_led==0.28.3"], + "requirements": ["flux_led==0.28.4"], "quality_scale": "platinum", "codeowners": ["@icemanch"], "iot_class": "local_push", diff --git a/requirements_all.txt b/requirements_all.txt index 864a1d1960d2..c97ab17efc5f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -659,7 +659,7 @@ fjaraskupan==1.0.2 flipr-api==1.4.1 # homeassistant.components.flux_led -flux_led==0.28.3 +flux_led==0.28.4 # homeassistant.components.homekit fnvhash==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d9da69ac50d3..36f119e77d4c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -399,7 +399,7 @@ fjaraskupan==1.0.2 flipr-api==1.4.1 # homeassistant.components.flux_led -flux_led==0.28.3 +flux_led==0.28.4 # homeassistant.components.homekit fnvhash==0.1.0 From 959498d8df9c9f9ae461017e7505e70f7a176258 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Mon, 17 Jan 2022 22:37:33 +0100 Subject: [PATCH 19/20] Orphaned MAC addresses breaks UniFi options flow (#64327) --- homeassistant/components/unifi/config_flow.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index 4ab566eb5b43..fda03cbd3d97 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -316,6 +316,10 @@ async def async_step_device_tracker(self, user_input=None): ) ssid_filter = {ssid: ssid for ssid in sorted(ssids)} + selected_ssids_to_filter = [ + ssid for ssid in self.controller.option_ssid_filter if ssid in ssid_filter + ] + return self.async_show_form( step_id="device_tracker", data_schema=vol.Schema( @@ -333,7 +337,7 @@ async def async_step_device_tracker(self, user_input=None): default=self.controller.option_track_devices, ): bool, vol.Optional( - CONF_SSID_FILTER, default=self.controller.option_ssid_filter + CONF_SSID_FILTER, default=selected_ssids_to_filter ): cv.multi_select(ssid_filter), vol.Optional( CONF_DETECTION_TIME, @@ -365,12 +369,18 @@ async def async_step_client_control(self, user_input=None): client.mac ] = f"{client.name or client.hostname} ({client.mac})" + selected_clients_to_block = [ + client + for client in self.options.get(CONF_BLOCK_CLIENT, []) + if client in clients_to_block + ] + return self.async_show_form( step_id="client_control", data_schema=vol.Schema( { vol.Optional( - CONF_BLOCK_CLIENT, default=self.options[CONF_BLOCK_CLIENT] + CONF_BLOCK_CLIENT, default=selected_clients_to_block ): cv.multi_select(clients_to_block), vol.Optional( CONF_POE_CLIENTS, From c3126543b83844b6ac7b4ef8d2509d65c5237bbb Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 17 Jan 2022 15:47:05 -0800 Subject: [PATCH 20/20] remove typing --- homeassistant/components/flux_led/light.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/flux_led/light.py b/homeassistant/components/flux_led/light.py index 4c93c678ad98..58d497d029e9 100644 --- a/homeassistant/components/flux_led/light.py +++ b/homeassistant/components/flux_led/light.py @@ -251,7 +251,7 @@ def __init__( @property def brightness(self) -> int: """Return the brightness of this light between 0..255.""" - return self._device.brightness # type: ignore + return self._device.brightness @property def color_temp(self) -> int: @@ -261,17 +261,17 @@ def color_temp(self) -> int: @property def rgb_color(self) -> tuple[int, int, int]: """Return the rgb color value.""" - return self._device.rgb_unscaled # type: ignore + return self._device.rgb_unscaled @property def rgbw_color(self) -> tuple[int, int, int, int]: """Return the rgbw color value.""" - return self._device.rgbw # type: ignore + return self._device.rgbw @property def rgbww_color(self) -> tuple[int, int, int, int, int]: """Return the rgbww aka rgbcw color value.""" - return self._device.rgbcw # type: ignore + return self._device.rgbcw @property def color_mode(self) -> str: @@ -283,7 +283,7 @@ def color_mode(self) -> str: @property def effect(self) -> str | None: """Return the current effect.""" - return self._device.effect # type: ignore + return self._device.effect async def _async_turn_on(self, **kwargs: Any) -> None: """Turn the specified or all lights on."""