diff --git a/README.md b/README.md index ccf1c09..1a7f17d 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,6 @@ Some services are more common than others; some are deprecated entirely. | Name | Service | Example device | Implementation status | Home Assistant entity | @@ -48,7 +46,7 @@ todo: service names and not just raw service identifiers? | Chargepoint | [chargepoint](https://github.com/adrianjagielak/home-assistant-futurehome/blob/master/futurehome/src/services/chargepoint.ts) | [Futurehome Charge](https://www.futurehome.io/en_no/shop/charge) | ✅ | [Sensor](https://www.home-assistant.io/integrations/sensor/), [Switch](https://www.home-assistant.io/integrations/switch/), [Number](https://www.home-assistant.io/integrations/number/), [Select](https://www.home-assistant.io/integrations/select/) | | Color control | [color_ctrl](https://github.com/adrianjagielak/home-assistant-futurehome/blob/master/futurehome/src/services/color_ctrl.ts) | | ✅ | [Light](https://www.home-assistant.io/integrations/light/) | | Complex Alarm System | [complex_alarm_system](https://github.com/adrianjagielak/home-assistant-futurehome/blob/master/futurehome/src/services/complex_alarm_system.ts) | | | -| Door lock| [door_lock](https://github.com/adrianjagielak/home-assistant-futurehome/blob/master/futurehome/src/services/door_lock.ts) | | | +| Door lock | [door_lock](https://github.com/adrianjagielak/home-assistant-futurehome/blob/master/futurehome/src/services/door_lock.ts) | | ✅ | [Lock](https://www.home-assistant.io/integrations/lock/), [Binary sensor](https://www.home-assistant.io/integrations/binary_sensor/), [Switch](https://www.home-assistant.io/integrations/switch/), [Number](https://www.home-assistant.io/integrations/number/), [Button](https://www.home-assistant.io/integrations/button/), [Select](https://www.home-assistant.io/integrations/select/) | | ??? | [doorman](https://github.com/adrianjagielak/home-assistant-futurehome/blob/master/futurehome/src/services/doorman.ts) | | | | Fan | [fan_ctrl](https://github.com/adrianjagielak/home-assistant-futurehome/blob/master/futurehome/src/services/fan_ctrl.ts) | | ✅ | [Fan](https://www.home-assistant.io/integrations/fan/) | | Light | [light](https://github.com/adrianjagielak/home-assistant-futurehome/blob/master/futurehome/src/services/light.ts) | | | diff --git a/futurehome/CHANGELOG.md b/futurehome/CHANGELOG.md index 39d6c7c..1ec8940 100644 --- a/futurehome/CHANGELOG.md +++ b/futurehome/CHANGELOG.md @@ -6,6 +6,7 @@ - Refactored sensors. - Added support for 'meter_*' services (electricity meters, gas meters, water meters, heating meters, cooling meters). - Added support for 'sound_switch' service (sound emitters). +- Added support for 'door_lock' service (door locks). ## 0.1.5 (25.07.2025) diff --git a/futurehome/README.md b/futurehome/README.md index d1d4440..ea7fc77 100644 --- a/futurehome/README.md +++ b/futurehome/README.md @@ -34,8 +34,6 @@ Some services are more common than others; some are deprecated entirely. | Name | Service | Example device | Implementation status | Home Assistant entity | @@ -47,7 +45,7 @@ todo: service names and not just raw service identifiers? | Chargepoint | [chargepoint](https://github.com/adrianjagielak/home-assistant-futurehome/blob/master/futurehome/src/services/chargepoint.ts) | [Futurehome Charge](https://www.futurehome.io/en_no/shop/charge) | ✅ | [Sensor](https://www.home-assistant.io/integrations/sensor/), [Switch](https://www.home-assistant.io/integrations/switch/), [Number](https://www.home-assistant.io/integrations/number/), [Select](https://www.home-assistant.io/integrations/select/) | | Color control | [color_ctrl](https://github.com/adrianjagielak/home-assistant-futurehome/blob/master/futurehome/src/services/color_ctrl.ts) | | ✅ | [Light](https://www.home-assistant.io/integrations/light/) | | Complex Alarm System | [complex_alarm_system](https://github.com/adrianjagielak/home-assistant-futurehome/blob/master/futurehome/src/services/complex_alarm_system.ts) | | | -| Door lock| [door_lock](https://github.com/adrianjagielak/home-assistant-futurehome/blob/master/futurehome/src/services/door_lock.ts) | | | +| Door lock | [door_lock](https://github.com/adrianjagielak/home-assistant-futurehome/blob/master/futurehome/src/services/door_lock.ts) | | ✅ | [Lock](https://www.home-assistant.io/integrations/lock/), [Binary sensor](https://www.home-assistant.io/integrations/binary_sensor/), [Switch](https://www.home-assistant.io/integrations/switch/), [Number](https://www.home-assistant.io/integrations/number/), [Button](https://www.home-assistant.io/integrations/button/), [Select](https://www.home-assistant.io/integrations/select/) | | ??? | [doorman](https://github.com/adrianjagielak/home-assistant-futurehome/blob/master/futurehome/src/services/doorman.ts) | | | | Fan | [fan_ctrl](https://github.com/adrianjagielak/home-assistant-futurehome/blob/master/futurehome/src/services/fan_ctrl.ts) | | ✅ | [Fan](https://www.home-assistant.io/integrations/fan/) | | Light | [light](https://github.com/adrianjagielak/home-assistant-futurehome/blob/master/futurehome/src/services/light.ts) | | | diff --git a/futurehome/src/ha/publish_device.ts b/futurehome/src/ha/publish_device.ts index bc831fe..36f1b47 100644 --- a/futurehome/src/ha/publish_device.ts +++ b/futurehome/src/ha/publish_device.ts @@ -13,6 +13,7 @@ import { basic__components } from '../services/basic'; import { battery__components } from '../services/battery'; import { chargepoint__components } from '../services/chargepoint'; import { color_ctrl__components } from '../services/color_ctrl'; +import { door_lock__components } from '../services/door_lock'; import { fan_ctrl__components } from '../services/fan_ctrl'; import { indicator_ctrl__components } from '../services/indicator_ctrl'; import { media_player__components } from '../services/media_player'; @@ -149,6 +150,7 @@ const serviceHandlers: { battery: battery__components, chargepoint: chargepoint__components, color_ctrl: color_ctrl__components, + door_lock: door_lock__components, fan_ctrl: fan_ctrl__components, indicator_ctrl: indicator_ctrl__components, media_player: media_player__components, diff --git a/futurehome/src/mqtt/demo_data/device.json b/futurehome/src/mqtt/demo_data/device.json index 92fd4be..8b80f8c 100644 --- a/futurehome/src/mqtt/demo_data/device.json +++ b/futurehome/src/mqtt/demo_data/device.json @@ -1947,7 +1947,7 @@ "client": { "name": "Smart Speaker" }, - "id": 1001, + "id": 1003, "model": "zigbee - Futurehome - Smart Speaker", "modelAlias": "Smart Speaker", "type": { @@ -1968,12 +1968,12 @@ "services": { "media_player": { "name": "media_player", - "addr": "/rt:dev/rn:zigbee/ad:1/sv:media_player/ad:1001_0", + "addr": "/rt:dev/rn:zigbee/ad:1/sv:media_player/ad:1003_0", "enabled": true, "props": { "sup_playback": ["play", "pause", "next_track", "previous_track"], "sup_modes": ["repeat", "repeat_one", "shuffle", "crossfade"], - "sup_metadata": ["album", "track", "artist", "image_url"] + "sup_metadata": ["album", "track", "artist"] }, "intf": [ "cmd.playback.set", @@ -2412,5 +2412,135 @@ } }, "metadata": null + }, + { + "client": { + "name": "Door lock" + }, + "fimp": { + "adapter": "zwave-ad", + "address": "92", + "group": "ch_0" + }, + "functionality": "security", + "id": 74, + "lrn": true, + "model": "zw_560_3_1", + "modelAlias": "Door lock", + "param": { + "alarms": { + "lock": ["manual_lock", "manual_unlock"] + }, + "autoLock": "on", + "batteryLevel": "ok", + "batteryPercentage": 100, + "lockState": "unlocked", + "openState": "open", + "presence": false, + "supportedAlarms": { + "lock": ["rf_not_locked"] + }, + "timestamp": "2020-02-11 15:38:12 +0100", + "zwaveConfigParameters": [ + { + "parameter": 1, + "size": 1, + "value": 1 + } + ] + }, + "problem": false, + "room": null, + "services": { + "alarm_lock": { + "addr": "/rt:dev/rn:zw/ad:1/sv:alarm_lock/ad:92_0", + "enabled": true, + "intf": ["cmd.alarm.get_report", "evt.alarm.report"], + "props": { + "is_secure": true, + "is_unsecure": false, + "sup_events": ["rf_not_locked"] + } + }, + "basic": { + "addr": "/rt:dev/rn:zw/ad:1/sv:basic/ad:92_0", + "enabled": true, + "intf": ["cmd.lvl.get_report", "cmd.lvl.set", "evt.lvl.report"], + "props": { + "is_secure": true, + "is_unsecure": true + } + }, + "battery": { + "addr": "/rt:dev/rn:zw/ad:1/sv:battery/ad:92_0", + "enabled": true, + "intf": ["cmd.lvl.get_report", "evt.alarm.report", "evt.lvl.report"], + "props": { + "is_secure": false, + "is_unsecure": true + } + }, + "dev_sys": { + "addr": "/rt:dev/rn:zw/ad:1/sv:dev_sys/ad:92_0", + "enabled": true, + "intf": [ + "cmd.config.get_report", + "cmd.config.set", + "cmd.group.add_members", + "cmd.group.delete_members", + "cmd.group.get_members", + "cmd.ping.send", + "evt.config.report", + "evt.group.members_report", + "evt.ping.report" + ], + "props": { + "is_secure": false, + "is_unsecure": true + } + }, + "door_lock": { + "addr": "/rt:dev/rn:zw/ad:1/sv:door_lock/ad:92_0", + "enabled": true, + "intf": ["cmd.lock.get_report", "cmd.lock.set", "evt.lock.report"], + "props": { + "is_secure": true, + "is_unsecure": false + } + }, + "sensor_presence": { + "addr": "/rt:dev/rn:zw/ad:1/sv:sensor_presence/ad:92_0", + "enabled": true, + "intf": ["cmd.presence.get_report", "evt.presence.report"], + "props": { + "is_secure": true, + "is_unsecure": false + } + }, + "user_code": { + "addr": "/rt:dev/rn:zw/ad:1/sv:user_code/ad:92_0", + "enabled": true, + "intf": [ + "cmd.usercode.clear", + "cmd.usercode.clear_all", + "cmd.usercode.get", + "cmd.usercode.set" + ], + "props": { + "is_secure": true, + "is_unsecure": false, + "sup_users": 52 + } + } + }, + "supports": ["clear", "poll"], + "thing": 57, + "type": { + "subtype": null, + "supported": { + "door_lock": [] + }, + "type": null + } } ] diff --git a/futurehome/src/mqtt/demo_data/state.json b/futurehome/src/mqtt/demo_data/state.json index be7cc44..4774856 100644 --- a/futurehome/src/mqtt/demo_data/state.json +++ b/futurehome/src/mqtt/demo_data/state.json @@ -1245,10 +1245,10 @@ ] }, { - "id": 1001, + "id": 1003, "services": [ { - "addr": "/rt:dev/rn:zigbee/ad:1/sv:media_player/ad:1001_0", + "addr": "/rt:dev/rn:zigbee/ad:1/sv:media_player/ad:1003_0", "attributes": [ { "name": "playback", @@ -1772,5 +1772,65 @@ "name": "sound_switch" } ] + }, + { + "id": 74, + "services": [ + { + "addr": "/rt:dev/rn:zw/ad:1/sv:door_lock/ad:92_0", + "attributes": [ + { + "name": "lock", + "values": [ + { + "props": { + "timeout_s": "254", + "unsecured_desc": "" + }, + "ts": "2020-02-11 15:38:12 +0100", + "val": { + "bolt_is_locked": false, + "door_is_closed": false, + "is_secured": false, + "latch_is_closed": false + }, + "val_t": "bool_map" + } + ] + } + ], + "name": "door_lock" + }, + { + "addr": "/rt:dev/rn:zw/ad:1/sv:dev_sys/ad:92_0", + "attributes": [ + { + "name": "state", + "values": [ + { + "ts": "2020-01-29 17:35:54 +0100", + "val": "DOWN", + "val_t": "string" + } + ] + }, + { + "name": "error", + "values": [ + { + "props": { + "msg": "TRANSMIT_COMPLETE_NO_ACK", + "src": "nodeId=92_0;service=door_lock" + }, + "ts": "2020-01-23 14:58:07 +0100", + "val": "TX_ERROR", + "val_t": "string" + } + ] + } + ], + "name": "dev_sys" + } + ] } ] diff --git a/futurehome/src/services/door_lock.ts b/futurehome/src/services/door_lock.ts new file mode 100644 index 0000000..331ca67 --- /dev/null +++ b/futurehome/src/services/door_lock.ts @@ -0,0 +1,539 @@ +import { sendFimpMsg } from '../fimp/fimp'; +import { + VinculumPd7Device, + VinculumPd7Service, +} from '../fimp/vinculum_pd7_device'; +import { HaMqttComponent } from '../ha/mqtt_components/_component'; +import { BinarySensorDeviceClass } from '../ha/mqtt_components/_enums'; +import { + CommandHandlers, + ServiceComponentsCreationResult, +} from '../ha/publish_device'; + +export function door_lock__components( + topicPrefix: string, + device: VinculumPd7Device, + svc: VinculumPd7Service, + _svcName: string, +): ServiceComponentsCreationResult | undefined { + const components: Record = {}; + const commandHandlers: CommandHandlers = {}; + + const stateTopic = `${topicPrefix}/state`; + + // Main lock component + if ( + svc.intf?.includes('cmd.lock.set') && + svc.intf?.includes('evt.lock.report') + ) { + const lockCommandTopic = `${topicPrefix}${svc.addr}/lock/command`; + + components[`${svc.addr}_lock`] = { + unique_id: `${svc.addr}_lock`, + platform: 'lock', + name: 'Lock', + command_topic: lockCommandTopic, + state_topic: stateTopic, + optimistic: false, + // Map the lock state based on is_secured component + value_template: `{{ 'LOCKED' if value_json['${svc.addr}'].lock.is_secured else 'UNLOCKED' }}`, + payload_lock: 'LOCK', + payload_unlock: 'UNLOCK', + }; + + commandHandlers[lockCommandTopic] = async (payload: string) => { + const isLock = payload === 'LOCK'; + + await sendFimpMsg({ + address: svc.addr, + service: 'door_lock', + cmd: 'cmd.lock.set', + val_t: 'bool', + val: isLock, + }); + }; + } + + // Individual lock components as binary sensors + const supComponents = svc.props?.sup_components || []; + + for (const component of supComponents) { + let deviceClass: BinarySensorDeviceClass; + let name: string; + + switch (component) { + case 'is_secured': + deviceClass = 'lock'; + name = 'Secured'; + break; + case 'door_is_closed': + deviceClass = 'door'; + name = 'Door Closed'; + break; + case 'bolt_is_locked': + deviceClass = 'lock'; + name = 'Bolt Locked'; + break; + case 'latch_is_closed': + deviceClass = 'lock'; + name = 'Latch Closed'; + break; + default: + deviceClass = 'lock'; + name = component + .replace(/_/g, ' ') + .replace(/\b\w/g, (l: string) => l.toUpperCase()); + } + + components[`${svc.addr}_${component}`] = { + unique_id: `${svc.addr}_${component}`, + platform: 'binary_sensor', + name: name, + device_class: deviceClass, + entity_category: 'diagnostic', + state_topic: stateTopic, + value_template: `{{ 'ON' if value_json['${svc.addr}'].lock.${component} else 'OFF' }}`, + }; + } + + // Auto-lock switch + if ( + svc.intf?.includes('cmd.auto_lock.set') && + svc.intf?.includes('evt.auto_lock.report') + ) { + const autoLockCommandTopic = `${topicPrefix}${svc.addr}/auto_lock/command`; + + components[`${svc.addr}_auto_lock`] = { + unique_id: `${svc.addr}_auto_lock`, + platform: 'switch', + name: 'Auto Lock', + entity_category: 'config', + command_topic: autoLockCommandTopic, + state_topic: stateTopic, + optimistic: false, + value_template: `{{ 'ON' if value_json['${svc.addr}'].auto_lock else 'OFF' }}`, + payload_on: 'ON', + payload_off: 'OFF', + }; + + commandHandlers[autoLockCommandTopic] = async (payload: string) => { + const enabled = payload === 'ON'; + + await sendFimpMsg({ + address: svc.addr, + service: 'door_lock', + cmd: 'cmd.auto_lock.set', + val_t: 'bool', + val: enabled, + }); + }; + } + + // Volume control + if ( + svc.intf?.includes('cmd.volume.set') && + svc.intf?.includes('evt.volume.report') + ) { + const volumeCommandTopic = `${topicPrefix}${svc.addr}/volume/command`; + const minVolume = svc.props?.min_volume ?? 0; + const maxVolume = svc.props?.max_volume ?? 10; + + components[`${svc.addr}_volume`] = { + unique_id: `${svc.addr}_volume`, + platform: 'number', + name: 'Volume', + entity_category: 'config', + command_topic: volumeCommandTopic, + state_topic: stateTopic, + optimistic: false, + min: minVolume, + max: maxVolume, + step: 1, + value_template: `{{ value_json['${svc.addr}'].volume | default(${minVolume}) }}`, + }; + + commandHandlers[volumeCommandTopic] = async (payload: string) => { + const volume = parseInt(payload, 10); + if (Number.isNaN(volume) || volume < minVolume || volume > maxVolume) { + return; + } + + await sendFimpMsg({ + address: svc.addr, + service: 'door_lock', + cmd: 'cmd.volume.set', + val_t: 'int', + val: volume, + }); + }; + } + + // Lock configuration button + if (svc.intf?.includes('cmd.lock.get_configuration')) { + const getConfigCommandTopic = `${topicPrefix}${svc.addr}/get_configuration/command`; + + components[`${svc.addr}_get_config`] = { + unique_id: `${svc.addr}_get_config`, + platform: 'button', + name: 'Get Configuration', + entity_category: 'diagnostic', + command_topic: getConfigCommandTopic, + }; + + commandHandlers[getConfigCommandTopic] = async (_payload: string) => { + await sendFimpMsg({ + address: svc.addr, + service: 'door_lock', + cmd: 'cmd.lock.get_configuration', + val_t: 'null', + val: null, + }); + }; + } + + // Get lock report button + if (svc.intf?.includes('cmd.lock.get_report')) { + const getLockReportCommandTopic = `${topicPrefix}${svc.addr}/get_lock_report/command`; + + components[`${svc.addr}_get_lock_report`] = { + unique_id: `${svc.addr}_get_lock_report`, + platform: 'button', + name: 'Get Lock Report', + entity_category: 'diagnostic', + command_topic: getLockReportCommandTopic, + }; + + commandHandlers[getLockReportCommandTopic] = async (_payload: string) => { + await sendFimpMsg({ + address: svc.addr, + service: 'door_lock', + cmd: 'cmd.lock.get_report', + val_t: 'null', + val: null, + }); + }; + } + + // Get auto-lock report button + if (svc.intf?.includes('cmd.auto_lock.get_report')) { + const getAutoLockReportCommandTopic = `${topicPrefix}${svc.addr}/get_auto_lock_report/command`; + + components[`${svc.addr}_get_auto_lock_report`] = { + unique_id: `${svc.addr}_get_auto_lock_report`, + platform: 'button', + name: 'Get Auto Lock Report', + entity_category: 'diagnostic', + command_topic: getAutoLockReportCommandTopic, + }; + + commandHandlers[getAutoLockReportCommandTopic] = async ( + _payload: string, + ) => { + await sendFimpMsg({ + address: svc.addr, + service: 'door_lock', + cmd: 'cmd.auto_lock.get_report', + val_t: 'null', + val: null, + }); + }; + } + + // Get volume report button + if (svc.intf?.includes('cmd.volume.get_report')) { + const getVolumeReportCommandTopic = `${topicPrefix}${svc.addr}/get_volume_report/command`; + + components[`${svc.addr}_get_volume_report`] = { + unique_id: `${svc.addr}_get_volume_report`, + platform: 'button', + name: 'Get Volume Report', + entity_category: 'diagnostic', + command_topic: getVolumeReportCommandTopic, + }; + + commandHandlers[getVolumeReportCommandTopic] = async (_payload: string) => { + await sendFimpMsg({ + address: svc.addr, + service: 'door_lock', + cmd: 'cmd.volume.get_report', + val_t: 'null', + val: null, + }); + }; + } + + // Operation type configuration + const supOpTypes = svc.props?.sup_op_types || []; + if ( + supOpTypes.length > 0 && + svc.intf?.includes('cmd.lock.set_configuration') + ) { + const opTypeCommandTopic = `${topicPrefix}${svc.addr}/operation_type/command`; + + components[`${svc.addr}_operation_type`] = { + unique_id: `${svc.addr}_operation_type`, + platform: 'select', + name: 'Operation Type', + entity_category: 'config', + command_topic: opTypeCommandTopic, + state_topic: stateTopic, + optimistic: false, + options: supOpTypes, + value_template: `{{ value_json['${svc.addr}'].configuration.operation_type | default('${supOpTypes[0]}') }}`, + }; + + commandHandlers[opTypeCommandTopic] = async (payload: string) => { + if (!supOpTypes.includes(payload)) { + return; + } + + // Send a partial configuration update + await sendFimpMsg({ + address: svc.addr, + service: 'door_lock', + cmd: 'cmd.lock.set_configuration', + val_t: 'object', + val: { + operation_type: payload, + }, + }); + }; + } + + // Auto-relock time configuration (if supported) + if (svc.props?.supports_auto_relock) { + const minAutoRelock = svc.props?.min_auto_relock_time ?? 0; + const maxAutoRelock = svc.props?.max_auto_relock_time ?? 65535; + const autoRelockTimeCommandTopic = `${topicPrefix}${svc.addr}/auto_relock_time/command`; + + components[`${svc.addr}_auto_relock_time`] = { + unique_id: `${svc.addr}_auto_relock_time`, + platform: 'number', + name: 'Auto Relock Time', + entity_category: 'config', + command_topic: autoRelockTimeCommandTopic, + state_topic: stateTopic, + optimistic: false, + min: minAutoRelock, + max: maxAutoRelock, + step: 1, + unit_of_measurement: 's', + value_template: `{{ value_json['${svc.addr}'].configuration.auto_relock_time | default(0) }}`, + }; + + commandHandlers[autoRelockTimeCommandTopic] = async (payload: string) => { + const time = parseInt(payload, 10); + if (Number.isNaN(time) || time < minAutoRelock || time > maxAutoRelock) { + return; + } + + await sendFimpMsg({ + address: svc.addr, + service: 'door_lock', + cmd: 'cmd.lock.set_configuration', + val_t: 'object', + val: { + auto_relock_time: time, + }, + }); + }; + } + + // Hold and release time configuration (if supported) + if (svc.props?.supports_hold_and_release) { + const minHoldRelease = svc.props?.min_hold_and_release_time ?? 1; + const maxHoldRelease = svc.props?.max_hold_and_release_time ?? 65535; + const holdReleaseTimeCommandTopic = `${topicPrefix}${svc.addr}/hold_release_time/command`; + + components[`${svc.addr}_hold_release_time`] = { + unique_id: `${svc.addr}_hold_release_time`, + platform: 'number', + name: 'Hold & Release Time', + entity_category: 'config', + command_topic: holdReleaseTimeCommandTopic, + state_topic: stateTopic, + optimistic: false, + min: minHoldRelease, + max: maxHoldRelease, + step: 1, + unit_of_measurement: 's', + value_template: `{{ value_json['${svc.addr}'].configuration.hold_and_release_time | default(0) }}`, + }; + + commandHandlers[holdReleaseTimeCommandTopic] = async (payload: string) => { + const time = parseInt(payload, 10); + if ( + Number.isNaN(time) || + time < minHoldRelease || + time > maxHoldRelease + ) { + return; + } + + await sendFimpMsg({ + address: svc.addr, + service: 'door_lock', + cmd: 'cmd.lock.set_configuration', + val_t: 'object', + val: { + hold_and_release_time: time, + }, + }); + }; + } + + // Block to block configuration (if supported) + if (svc.props?.supports_block_to_block) { + const blockToBlockCommandTopic = `${topicPrefix}${svc.addr}/block_to_block/command`; + + components[`${svc.addr}_block_to_block`] = { + unique_id: `${svc.addr}_block_to_block`, + platform: 'switch', + name: 'Block to Block', + entity_category: 'config', + command_topic: blockToBlockCommandTopic, + state_topic: stateTopic, + optimistic: false, + value_template: `{{ 'ON' if value_json['${svc.addr}'].configuration.block_to_block else 'OFF' }}`, + payload_on: 'ON', + payload_off: 'OFF', + }; + + commandHandlers[blockToBlockCommandTopic] = async (payload: string) => { + const enabled = payload === 'ON'; + + await sendFimpMsg({ + address: svc.addr, + service: 'door_lock', + cmd: 'cmd.lock.set_configuration', + val_t: 'object', + val: { + block_to_block: enabled, + }, + }); + }; + } + + // Twist assist configuration (if supported) + if (svc.props?.supports_twist_assist) { + const twistAssistCommandTopic = `${topicPrefix}${svc.addr}/twist_assist/command`; + + components[`${svc.addr}_twist_assist`] = { + unique_id: `${svc.addr}_twist_assist`, + platform: 'switch', + name: 'Twist Assist', + entity_category: 'config', + command_topic: twistAssistCommandTopic, + state_topic: stateTopic, + optimistic: false, + value_template: `{{ 'ON' if value_json['${svc.addr}'].configuration.twist_assist else 'OFF' }}`, + payload_on: 'ON', + payload_off: 'OFF', + }; + + commandHandlers[twistAssistCommandTopic] = async (payload: string) => { + const enabled = payload === 'ON'; + + await sendFimpMsg({ + address: svc.addr, + service: 'door_lock', + cmd: 'cmd.lock.set_configuration', + val_t: 'object', + val: { + twist_assist: enabled, + }, + }); + }; + } + + // Lock timeout configuration (if timed operation is supported) + if (supOpTypes.includes('timed')) { + const minTimeoutSeconds = svc.props?.min_lock_timeout_seconds ?? 0; + const maxTimeoutSeconds = svc.props?.max_lock_timeout_seconds ?? 59; + const minTimeoutMinutes = svc.props?.min_lock_timeout_minutes ?? 0; + const maxTimeoutMinutes = svc.props?.max_lock_timeout_minutes ?? 253; + + // Lock timeout seconds + const timeoutSecondsCommandTopic = `${topicPrefix}${svc.addr}/lock_timeout_seconds/command`; + + components[`${svc.addr}_lock_timeout_seconds`] = { + unique_id: `${svc.addr}_lock_timeout_seconds`, + platform: 'number', + name: 'Lock Timeout Seconds', + entity_category: 'config', + command_topic: timeoutSecondsCommandTopic, + state_topic: stateTopic, + optimistic: false, + min: minTimeoutSeconds, + max: maxTimeoutSeconds, + step: 1, + unit_of_measurement: 's', + value_template: `{{ value_json['${svc.addr}'].configuration.lock_timeout_seconds | default(0) }}`, + }; + + commandHandlers[timeoutSecondsCommandTopic] = async (payload: string) => { + const seconds = parseInt(payload, 10); + if ( + Number.isNaN(seconds) || + seconds < minTimeoutSeconds || + seconds > maxTimeoutSeconds + ) { + return; + } + + await sendFimpMsg({ + address: svc.addr, + service: 'door_lock', + cmd: 'cmd.lock.set_configuration', + val_t: 'object', + val: { + lock_timeout_seconds: seconds, + }, + }); + }; + + // Lock timeout minutes + const timeoutMinutesCommandTopic = `${topicPrefix}${svc.addr}/lock_timeout_minutes/command`; + + components[`${svc.addr}_lock_timeout_minutes`] = { + unique_id: `${svc.addr}_lock_timeout_minutes`, + platform: 'number', + name: 'Lock Timeout Minutes', + entity_category: 'config', + command_topic: timeoutMinutesCommandTopic, + state_topic: stateTopic, + optimistic: false, + min: minTimeoutMinutes, + max: maxTimeoutMinutes, + step: 1, + unit_of_measurement: 'min', + value_template: `{{ value_json['${svc.addr}'].configuration.lock_timeout_minutes | default(0) }}`, + }; + + commandHandlers[timeoutMinutesCommandTopic] = async (payload: string) => { + const minutes = parseInt(payload, 10); + if ( + Number.isNaN(minutes) || + minutes < minTimeoutMinutes || + minutes > maxTimeoutMinutes + ) { + return; + } + + await sendFimpMsg({ + address: svc.addr, + service: 'door_lock', + cmd: 'cmd.lock.set_configuration', + val_t: 'object', + val: { + lock_timeout_minutes: minutes, + }, + }); + }; + } + + return { + components, + commandHandlers, + }; +}