diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index acc1da4e51a..656620d01dd 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -99,6 +99,7 @@ SERVICE_REFRESH_VALUE = "refresh_value" SERVICE_RESET_METER = "reset_meter" SERVICE_SET_CONFIG_PARAMETER = "set_config_parameter" SERVICE_SET_LOCK_USERCODE = "set_lock_usercode" +SERVICE_SET_LOCK_CONFIGURATION = "set_lock_configuration" SERVICE_SET_VALUE = "set_value" ATTR_NODES = "nodes" @@ -118,6 +119,13 @@ ATTR_METER_TYPE_NAME = "meter_type_name" # invoke CC API ATTR_METHOD_NAME = "method_name" ATTR_PARAMETERS = "parameters" +# lock set configuration +ATTR_AUTO_RELOCK_TIME = "auto_relock_time" +ATTR_BLOCK_TO_BLOCK = "block_to_block" +ATTR_HOLD_AND_RELEASE_TIME = "hold_and_release_time" +ATTR_LOCK_TIMEOUT = "lock_timeout" +ATTR_OPERATION_TYPE = "operation_type" +ATTR_TWIST_ASSIST = "twist_assist" ADDON_SLUG = "core_zwave_js" diff --git a/homeassistant/components/zwave_js/lock.py b/homeassistant/components/zwave_js/lock.py index 5457916a1e1..59faf7fbbb6 100644 --- a/homeassistant/components/zwave_js/lock.py +++ b/homeassistant/components/zwave_js/lock.py @@ -11,10 +11,12 @@ from zwave_js_server.const.command_class.lock import ( ATTR_USERCODE, LOCK_CMD_CLASS_TO_LOCKED_STATE_MAP, LOCK_CMD_CLASS_TO_PROPERTY_MAP, + DoorLockCCConfigurationSetOptions, DoorLockMode, + OperationType, ) from zwave_js_server.exceptions import BaseZwaveJSServerError -from zwave_js_server.util.lock import clear_usercode, set_usercode +from zwave_js_server.util.lock import clear_usercode, set_configuration, set_usercode from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockEntity from homeassistant.config_entries import ConfigEntry @@ -26,10 +28,17 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( + ATTR_AUTO_RELOCK_TIME, + ATTR_BLOCK_TO_BLOCK, + ATTR_HOLD_AND_RELEASE_TIME, + ATTR_LOCK_TIMEOUT, + ATTR_OPERATION_TYPE, + ATTR_TWIST_ASSIST, DATA_CLIENT, DOMAIN, LOGGER, SERVICE_CLEAR_LOCK_USERCODE, + SERVICE_SET_LOCK_CONFIGURATION, SERVICE_SET_LOCK_USERCODE, ) from .discovery import ZwaveDiscoveryInfo @@ -47,6 +56,7 @@ STATE_TO_ZWAVE_MAP: dict[int, dict[str, int | bool]] = { STATE_LOCKED: True, }, } +UNIT16_SCHEMA = vol.All(vol.Coerce(int), vol.Range(min=0, max=65535)) async def async_setup_entry( @@ -92,6 +102,24 @@ async def async_setup_entry( "async_clear_lock_usercode", ) + platform.async_register_entity_service( + SERVICE_SET_LOCK_CONFIGURATION, + { + vol.Required(ATTR_OPERATION_TYPE): vol.All( + cv.string, + vol.Upper, + vol.In(["TIMED", "CONSTANT"]), + lambda x: OperationType[x], + ), + vol.Optional(ATTR_LOCK_TIMEOUT): UNIT16_SCHEMA, + vol.Optional(ATTR_AUTO_RELOCK_TIME): UNIT16_SCHEMA, + vol.Optional(ATTR_HOLD_AND_RELEASE_TIME): UNIT16_SCHEMA, + vol.Optional(ATTR_TWIST_ASSIST): vol.Coerce(bool), + vol.Optional(ATTR_BLOCK_TO_BLOCK): vol.Coerce(bool), + }, + "async_set_lock_configuration", + ) + class ZWaveLock(ZWaveBaseEntity, LockEntity): """Representation of a Z-Wave lock.""" @@ -138,9 +166,10 @@ class ZWaveLock(ZWaveBaseEntity, LockEntity): await set_usercode(self.info.node, code_slot, usercode) except BaseZwaveJSServerError as err: raise HomeAssistantError( - f"Unable to set lock usercode on code_slot {code_slot}: {err}" + f"Unable to set lock usercode on lock {self.entity_id} code_slot " + f"{code_slot}: {err}" ) from err - LOGGER.debug("User code at slot %s set", code_slot) + LOGGER.debug("User code at slot %s on lock %s set", code_slot, self.entity_id) async def async_clear_lock_usercode(self, code_slot: int) -> None: """Clear the usercode at index X on the lock.""" @@ -148,6 +177,41 @@ class ZWaveLock(ZWaveBaseEntity, LockEntity): await clear_usercode(self.info.node, code_slot) except BaseZwaveJSServerError as err: raise HomeAssistantError( - f"Unable to clear lock usercode on code_slot {code_slot}: {err}" + f"Unable to clear lock usercode on lock {self.entity_id} code_slot " + f"{code_slot}: {err}" ) from err - LOGGER.debug("User code at slot %s cleared", code_slot) + LOGGER.debug( + "User code at slot %s on lock %s cleared", code_slot, self.entity_id + ) + + async def async_set_lock_configuration( + self, + operation_type: OperationType, + lock_timeout: int | None = None, + auto_relock_time: int | None = None, + hold_and_release_time: int | None = None, + twist_assist: bool | None = None, + block_to_block: bool | None = None, + ) -> None: + """Set the lock configuration.""" + params: dict[str, Any] = {"operation_type": operation_type} + for attr, val in ( + ("lock_timeout_configuration", lock_timeout), + ("auto_relock_time", auto_relock_time), + ("hold_and_release_time", hold_and_release_time), + ("twist_assist", twist_assist), + ("block_to_block", block_to_block), + ): + if val is not None: + params[attr] = val + configuration = DoorLockCCConfigurationSetOptions(**params) + result = await set_configuration( + self.info.node.endpoints[self.info.primary_value.endpoint or 0], + configuration, + ) + if result is None: + return + msg = f"Result status is {result.status}" + if result.remaining_duration is not None: + msg += f" and remaining duration is {str(result.remaining_duration)}" + LOGGER.info("%s after setting lock configuration for %s", msg, self.entity_id) diff --git a/homeassistant/components/zwave_js/services.yaml b/homeassistant/components/zwave_js/services.yaml index cb8e726bf32..81809e3fbeb 100644 --- a/homeassistant/components/zwave_js/services.yaml +++ b/homeassistant/components/zwave_js/services.yaml @@ -29,6 +29,65 @@ set_lock_usercode: selector: text: +set_lock_configuration: + target: + entity: + domain: lock + integration: zwave_js + fields: + operation_type: + required: true + example: timed + selector: + select: + options: + - constant + - timed + lock_timeout: + required: false + example: 1 + selector: + number: + min: 0 + max: 65535 + unit_of_measurement: sec + outside_handles_can_open_door_configuration: + required: false + example: [true, true, true, false] + selector: + object: + inside_handles_can_open_door_configuration: + required: false + example: [true, true, true, false] + selector: + object: + auto_relock_time: + required: false + example: 1 + selector: + number: + min: 0 + max: 65535 + unit_of_measurement: sec + hold_and_release_time: + required: false + example: 1 + selector: + number: + min: 0 + max: 65535 + unit_of_measurement: sec + twist_assist: + required: false + example: true + selector: + boolean: + block_to_block: + required: false + example: true + selector: + boolean: + set_config_parameter: target: entity: diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 71c6b93e2bd..19a47450080 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -385,6 +385,44 @@ "description": "The Notification Event number as defined in the Z-Wave specs." } } + }, + "set_lock_configuration": { + "name": "Set lock configuration", + "description": "Sets the configuration for a lock.", + "fields": { + "operation_type": { + "name": "Operation Type", + "description": "The operation type of the lock." + }, + "lock_timeout": { + "name": "Lock timeout", + "description": "Seconds until lock mode times out. Should only be used if operation type is `timed`." + }, + "outside_handles_can_open_door_configuration": { + "name": "Outside handles can open door configuration", + "description": "A list of four booleans which indicate which outside handles can open the door." + }, + "inside_handles_can_open_door_configuration": { + "name": "Inside handles can open door configuration", + "description": "A list of four booleans which indicate which inside handles can open the door." + }, + "auto_relock_time": { + "name": "Auto relock time", + "description": "Duration in seconds until lock returns to secure state. Only enforced when operation type is `constant`." + }, + "hold_and_release_time": { + "name": "Hold and release time", + "description": "Duration in seconds the latch stays retracted." + }, + "twist_assist": { + "name": "Twist assist", + "description": "Enable Twist Assist." + }, + "block_to_block": { + "name": "Block to block", + "description": "Enable block-to-block functionality." + } + } } } } diff --git a/tests/components/zwave_js/test_lock.py b/tests/components/zwave_js/test_lock.py index 5a5711d9dad..2213e9cf069 100644 --- a/tests/components/zwave_js/test_lock.py +++ b/tests/components/zwave_js/test_lock.py @@ -15,10 +15,15 @@ from homeassistant.components.lock import ( SERVICE_LOCK, SERVICE_UNLOCK, ) -from homeassistant.components.zwave_js.const import DOMAIN as ZWAVE_JS_DOMAIN +from homeassistant.components.zwave_js.const import ( + ATTR_LOCK_TIMEOUT, + ATTR_OPERATION_TYPE, + DOMAIN as ZWAVE_JS_DOMAIN, +) from homeassistant.components.zwave_js.helpers import ZwaveValueMatcher from homeassistant.components.zwave_js.lock import ( SERVICE_CLEAR_LOCK_USERCODE, + SERVICE_SET_LOCK_CONFIGURATION, SERVICE_SET_LOCK_USERCODE, ) from homeassistant.const import ( @@ -35,7 +40,11 @@ from .common import SCHLAGE_BE469_LOCK_ENTITY, replace_value_of_zwave_value async def test_door_lock( - hass: HomeAssistant, client, lock_schlage_be469, integration + hass: HomeAssistant, + client, + lock_schlage_be469, + integration, + caplog: pytest.LogCaptureFixture, ) -> None: """Test a lock entity with door lock command class.""" node = lock_schlage_be469 @@ -158,6 +167,96 @@ async def test_door_lock( client.async_send_command.reset_mock() + # Test set configuration + client.async_send_command.return_value = { + "response": {"status": 1, "remainingDuration": "default"} + } + caplog.clear() + await hass.services.async_call( + ZWAVE_JS_DOMAIN, + SERVICE_SET_LOCK_CONFIGURATION, + { + ATTR_ENTITY_ID: SCHLAGE_BE469_LOCK_ENTITY, + ATTR_OPERATION_TYPE: "timed", + ATTR_LOCK_TIMEOUT: 1, + }, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "endpoint.invoke_cc_api" + assert args["nodeId"] == 20 + assert args["endpoint"] == 0 + assert args["args"] == [ + { + "insideHandlesCanOpenDoorConfiguration": [True, True, True, True], + "operationType": 2, + "outsideHandlesCanOpenDoorConfiguration": [True, True, True, True], + } + ] + assert args["commandClass"] == 98 + assert args["methodName"] == "setConfiguration" + assert "Result status" in caplog.text + assert "remaining duration" in caplog.text + assert "setting lock configuration" in caplog.text + + client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() + caplog.clear() + + # Put node to sleep and validate that we don't wait for a return or log anything + event = Event( + "sleep", + { + "source": "node", + "event": "sleep", + "nodeId": node.node_id, + }, + ) + node.receive_event(event) + + await hass.services.async_call( + ZWAVE_JS_DOMAIN, + SERVICE_SET_LOCK_CONFIGURATION, + { + ATTR_ENTITY_ID: SCHLAGE_BE469_LOCK_ENTITY, + ATTR_OPERATION_TYPE: "timed", + ATTR_LOCK_TIMEOUT: 1, + }, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 0 + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] + assert args["command"] == "endpoint.invoke_cc_api" + assert args["nodeId"] == 20 + assert args["endpoint"] == 0 + assert args["args"] == [ + { + "insideHandlesCanOpenDoorConfiguration": [True, True, True, True], + "operationType": 2, + "outsideHandlesCanOpenDoorConfiguration": [True, True, True, True], + } + ] + assert args["commandClass"] == 98 + assert args["methodName"] == "setConfiguration" + assert "Result status" not in caplog.text + assert "remaining duration" not in caplog.text + assert "setting lock configuration" not in caplog.text + + # Mark node as alive + event = Event( + "alive", + { + "source": "node", + "event": "alive", + "nodeId": node.node_id, + }, + ) + node.receive_event(event) + client.async_send_command.side_effect = FailedZWaveCommand("test", 1, "test") # Test set usercode service error handling with pytest.raises(HomeAssistantError):