Browse Source

Add support for Orion Grid Connect Smart Lock

Issue #162

- enhancements to lock to support jam detection, and reporting how the lock was unlocked, along with a request/approval unlock flow.
Jason Rumney 3 years ago
parent
commit
73575b1424

+ 1 - 0
ACKNOWLEDGEMENTS.md

@@ -100,3 +100,4 @@ Further device support has been made with the assistance of users.  Please consi
 - [KaportsevIA](https://github.com/KaportsevIA) for assistance supporting Hyundai Sahara dehumidifier and Yandax color bulb.
 - [poolMiniDomo](https://github.com/poolMiniDomo) for assistance supporting Moes Temperature and Humidity switches.
 - [pretoriano80](https://github.com/pretoriano80) for assistance supporting AlecoAir dehumidifiers.
+- [JanekSMC](https://github.com/JanekSMC) for assistance supporting Orion Smart Locks.

+ 3 - 0
README.md

@@ -176,6 +176,9 @@ Other brands may work with the above configurations
 - Lefant M213 Vacuum Cleaner (also works for Lefant M213S and APOSEN A550)
 - Kyvol E30 Vacuum Cleaner
 
+### Locks
+- Orion Grid Connect Smart Lock
+
 ### Miscellaneous
 - Qoto 03 Smart Water Valve / Sprinkler Controller
 - SD123 HPR01 Human Presence Radar

+ 112 - 0
custom_components/tuya_local/devices/orion_smart_lock.yaml

@@ -0,0 +1,112 @@
+name: Orion Grid Connect Smart Lock
+primary_entity:
+  entity: lock
+  dps:
+    - id: 1
+      type: integer
+      name: unlock_fingerprint
+    - id: 2
+      type: integer
+      name: unlock_password
+    - id: 3
+      type: integer
+      name: unlock_temp_pwd
+    - id: 4
+      type: integer
+      name: unlock_dynamic_pwd
+    - id: 5
+      type: integer
+      name: unlock_card
+    - id: 8
+      type: bitfield
+      name: jammed
+      mapping:
+        - dps_val: 16
+          value: true
+        - dps_val: 128
+          value: true
+        - value: false
+    - id: 9
+      type: integer
+      name: request_unlock
+    - id: 10
+      type: boolean
+      name: approve_unlock
+    - id: 15
+      type: integer
+      name: unlock_app
+# Suspect the following are not always reported, as they are large hex fields
+#    - id: 25
+#      type: hex
+#      name: fingers_enrolled
+#    - id: 26
+#      type: hex
+#      name: passwords_enrolled
+#    - id: 27
+#      type: hex
+#      name: cards_enrolled
+secondary_entities:
+  - entity: sensor
+    name: alert
+    category: diagnostic
+    dps:
+      - id: 8
+        type: bitfield
+        name: sensor
+        mapping:
+          - dps_val: 1
+            value: wrong_finger
+          - dps_val: 2
+            value: wrong_password
+          - dps_val: 4
+            value: wrong_card
+          - dps_val: 8
+            value: wrong_face
+          - dps_val: 16
+            value: lock_jammed_closed
+          - dps_val: 32
+            value: high_temperature
+          - dps_val: 64
+            value: open_too_long
+          - dps_val: 128
+            value: lock_jammed_open
+          - dps_val: 256
+            value: lock_forced
+          - dps_val: 512
+            value: key_left_in
+          - dps_val: 1024
+            value: battery_low
+          - dps_val: 2048
+            value: battery_dead
+          - dps_val: 4096
+            value: shock
+  - entity: sensor
+    class: battery
+    name: Battery
+    dps:
+      - id: 12
+        type: integer
+        name: sensor
+        unit: "%"
+  - entity: binary_sensor
+    class: safety
+    name: Duress
+    dps:
+      - id: 16
+        type: boolean
+        name: sensor
+  - entity: binary_sensor
+    class: tamper
+    name: Tampered
+    dps:
+      - id: 8
+        type: bitfield
+        name: sensor
+        mapping:
+          - dps_val: 32
+            value: true
+          - dps_val: 256
+            value: true
+          - dps_val: 4096
+            value: true
+          - value: false

+ 65 - 14
custom_components/tuya_local/generic/lock.py

@@ -4,7 +4,7 @@ Platform to control Tuya lock devices.
 Initial implementation is based on the secondary child-lock feature of Goldair
 climate devices.
 """
-from homeassistant.components.lock import LockEntity, STATE_LOCKED, STATE_UNLOCKED
+from homeassistant.components.lock import LockEntity
 
 from ..device import TuyaLocalDevice
 from ..helpers.device_config import TuyaEntityConfig
@@ -22,28 +22,79 @@ class TuyaLocalLock(TuyaLocalEntity, LockEntity):
           config (TuyaEntityConfig): The configuration for this entity.
         """
         dps_map = self._init_begin(device, config)
-        self._lock_dps = dps_map.pop("lock")
+        self._lock_dp = dps_map.pop("lock", None)
+        self._unlock_fp_dp = dps_map.pop("unlock_fingerprint", None)
+        self._unlock_pw_dp = dps_map.pop("unlock_password", None)
+        self._unlock_tmppw_dp = dps_map.pop("unlock_temp_pwd", None)
+        self._unlock_dynpw_dp = dps_map.pop("unlock_dynamic_pwd", None)
+        self._unlock_card_dp = dps_map.pop("unlock_card", None)
+        self._unlock_app_dp = dps_map.pop("unlock_app", None)
+        self._req_unlock_dp = dps_map.pop("request_unlock", None)
+        self._approve_unlock_dp = dps_map.pop("approve_unlock", None)
+        self._jam_dp = dps_map.pop("jammed", None)
         self._init_end(dps_map)
 
     @property
-    def state(self):
-        """Return the current state."""
-        lock = self._lock_dps.get_value(self._device)
-
-        if lock is None:
-            return None
+    def is_locked(self):
+        """Return the a boolean representing whether the lock is locked."""
+        lock = None
+        if self._lock_dp:
+            lock = self._lock_dp.get_value(self._device)
         else:
-            return STATE_LOCKED if lock else STATE_UNLOCKED
+            for d in (
+                self._unlock_card_dp,
+                self._unlock_dynpw_dp,
+                self._unlock_fp_dp,
+                self._unlock_pw_dp,
+                self._unlock_tmppw_dp,
+                self._unlock_app_dp,
+            ):
+                if d:
+                    if d.get_value(self._device):
+                        lock = False
+                    elif lock is None:
+                        lock = True
+        return lock
 
     @property
-    def is_locked(self):
-        """Return the a boolean representing whether the lock is locked."""
-        return self.state == STATE_LOCKED
+    def is_jammed(self):
+        if self._jam_dp:
+            return self._jam_dp.get_value(self._device)
+
+    def unlocker_id(self, dp, type):
+        if dp:
+            id = dp.get_value(self._device)
+            if id:
+                return f"{type} #{id}"
+
+    @property
+    def changed_by(self):
+        for dp, desc in {
+            self._unlock_app_dp: "App",
+            self._unlock_card_dp: "Card",
+            self._unlock_dynpw_dp: "Dynamic Password",
+            self._unlock_fp_dp: "Finger",
+            self._unlock_pw_dp: "Password",
+            self._unlock_tmppw_dp: "Temporary Password",
+        }.items():
+            by = self.unlocker_id(dp, desc)
+            if by:
+                return by
 
     async def async_lock(self, **kwargs):
         """Lock the lock."""
-        await self._lock_dps.async_set_value(self._device, True)
+        if self._lock_dp:
+            await self._lock_dp.async_set_value(self._device, True)
+        else:
+            raise NotImplementedError()
 
     async def async_unlock(self, **kwargs):
         """Unlock the lock."""
-        await self._lock_dps.async_set_value(self._device, False)
+        if self._lock_dp:
+            await self._lock_dp.async_set_value(self._device, False)
+        elif self._approve_unlock_dp:
+            if self._req_unlock_dp and not self._req_unlock_dp.get_value(self._device):
+                raise TimeoutError()
+            await self._approve_unlock_dp.async_set_value(self._device, True)
+        else:
+            raise NotImplementedError()

+ 14 - 0
tests/const.py

@@ -1326,3 +1326,17 @@ MOES_TEMP_HUMID_PAYLOAD = {
     "105": "off",
     "106": "mix",
 }
+
+ORION_SMARTLOCK_PAYLOAD = {
+    "1": 0,
+    "2": 0,
+    "3": 0,
+    "4": 0,
+    "5": 0,
+    "8": 0,
+    "9": 0,
+    "10": False,
+    "12": 100,
+    "15": 0,
+    "16": False,
+}

+ 132 - 0
tests/devices/test_orion_smartlock.py

@@ -0,0 +1,132 @@
+from homeassistant.components.binary_sensor import BinarySensorDeviceClass
+from homeassistant.components.sensor import SensorDeviceClass
+from homeassistant.const import PERCENTAGE
+
+from ..const import ORION_SMARTLOCK_PAYLOAD
+from ..helpers import assert_device_properties_set
+from ..mixins.binary_sensor import MultiBinarySensorTests
+from ..mixins.sensor import MultiSensorTests
+from .base_device_tests import TuyaDeviceTestCase
+
+ULFP_DP = "1"
+ULPWD_DP = "2"
+ULTMP_DP = "3"
+ULDYN_DP = "4"
+ULCARD_DP = "5"
+ERROR_DP = "8"
+REQUEST_DP = "9"
+APPROVE_DP = "10"
+BATTERY_DP = "12"
+ULAPP_DP = "15"
+DURESS_DP = "16"
+
+
+class TestOrionSmartLock(
+    MultiBinarySensorTests,
+    MultiSensorTests,
+    TuyaDeviceTestCase,
+):
+    __test__ = True
+
+    def setUp(self):
+        self.setUpForConfig("orion_smart_lock.yaml", ORION_SMARTLOCK_PAYLOAD)
+        self.subject = self.entities.get("lock")
+        self.setUpMultiBinarySensors(
+            [
+                {
+                    "dps": ERROR_DP,
+                    "name": "binary_sensor_tampered",
+                    "device_class": BinarySensorDeviceClass.TAMPER,
+                    "testdata": (33, 0),
+                },
+                {
+                    "dps": DURESS_DP,
+                    "name": "binary_sensor_duress",
+                    "device_class": BinarySensorDeviceClass.SAFETY,
+                },
+            ]
+        )
+        self.setUpMultiSensors(
+            [
+                {
+                    "dps": ERROR_DP,
+                    "name": "sensor_alert",
+                    "testdata": (512, "key_left_in"),
+                },
+                {
+                    "dps": BATTERY_DP,
+                    "name": "sensor_battery",
+                    "device_class": SensorDeviceClass.BATTERY,
+                    "unit": PERCENTAGE,
+                },
+            ]
+        )
+        self.mark_secondary(["sensor_alert"])
+
+    def test_is_locked(self):
+        # Default is all 0
+        self.assertTrue(self.subject.is_locked)
+        self.dps[ULFP_DP] = 1
+        self.assertFalse(self.subject.is_locked)
+        self.dps[ULFP_DP] = 0
+        self.dps[ULPWD_DP] = 2
+        self.assertFalse(self.subject.is_locked)
+        self.dps[ULPWD_DP] = 0
+        self.dps[ULTMP_DP] = 3
+        self.assertFalse(self.subject.is_locked)
+        self.dps[ULTMP_DP] = 0
+        self.dps[ULDYN_DP] = 4
+        self.assertFalse(self.subject.is_locked)
+        self.dps[ULDYN_DP] = 0
+        self.dps[ULCARD_DP] = 5
+        self.assertFalse(self.subject.is_locked)
+        self.dps[ULCARD_DP] = 0
+        self.dps[ULAPP_DP] = 6
+        self.assertFalse(self.subject.is_locked)
+
+    def test_is_jammed(self):
+        self.assertFalse(self.subject.is_jammed)
+        self.dps[ERROR_DP] = 1
+        self.assertFalse(self.subject.is_jammed)
+        self.dps[ERROR_DP] = 16
+        self.assertTrue(self.subject.is_jammed)
+        self.dps[ERROR_DP] = 128
+        self.assertTrue(self.subject.is_jammed)
+        self.dps[ERROR_DP] = 17
+        self.assertTrue(self.subject.is_jammed)
+        self.dps[ERROR_DP] = 144
+        self.assertTrue(self.subject.is_jammed)
+
+    def test_changed_by(self):
+        self.dps[ULFP_DP] = 1
+        self.assertEqual(self.subject.changed_by, "Finger #1")
+        self.dps[ULFP_DP] = 0
+        self.dps[ULPWD_DP] = 2
+        self.assertEqual(self.subject.changed_by, "Password #2")
+        self.dps[ULPWD_DP] = 0
+        self.dps[ULTMP_DP] = 3
+        self.assertEqual(self.subject.changed_by, "Temporary Password #3")
+        self.dps[ULTMP_DP] = 0
+        self.dps[ULDYN_DP] = 4
+        self.assertEqual(self.subject.changed_by, "Dynamic Password #4")
+        self.dps[ULDYN_DP] = 0
+        self.dps[ULCARD_DP] = 5
+        self.assertEqual(self.subject.changed_by, "Card #5")
+        self.dps[ULCARD_DP] = 0
+        self.dps[ULAPP_DP] = 6
+        self.assertEqual(self.subject.changed_by, "App #6")
+
+    def test_extra_state_attributes(self):
+        self.assertEqual(self.subject.extra_state_attributes, {})
+
+    async def test_unlock(self):
+        self.dps[REQUEST_DP] = 30
+        async with assert_device_properties_set(
+            self.subject._device, {APPROVE_DP: True}
+        ):
+            await self.subject.async_unlock()
+
+    async def test_unlock_fails_when_not_requested(self):
+        self.dps[REQUEST_DP] = 0
+        with self.assertRaises(TimeoutError):
+            await self.subject.async_unlock()

+ 0 - 7
tests/mixins/lock.py

@@ -9,13 +9,6 @@ class BasicLockTests:
         self.basicLock = subject
         self.basicLockDps = dps
 
-    def test_basic_lock_state(self):
-        self.dps[self.basicLockDps] = True
-        self.assertEqual(self.basicLock.state, STATE_LOCKED)
-
-        self.dps[self.basicLockDps] = False
-        self.assertEqual(self.basicLock.state, STATE_UNLOCKED)
-
     def test_basic_lock_is_locked(self):
         self.dps[self.basicLockDps] = True
         self.assertTrue(self.basicLock.is_locked)