Repository: bdraco/yalexs-ble
Pull request #216: feat!: add Deadlock (SecureMode) support
Author: login: michaelp1742
Created: 2025-05-02T13:41:22Z | Merged: 2025-05-02T17:30:39Z

Yale locks such as the YUR/DEL/1/SIL have a deadlock mode as well as the external locked state. 

These Yale locks have three valid end-states, Unlocked, Locked, and Deadlocked (SecureMode). 

This PR adds support for both SecureMode commands and status reporting.

The status reporting debug messages have been moved to level INFO as debug level output was too verbose.

It has been running with no issues for three weeks in Home Assistant 2025.4 using a modified yalexs_ble component with an esp32-s3 Esphome 2025.4 Bluetooth proxy using the arduino framework.

The files have been linted through pre-commit


Diff:
diff --git a/src/yalexs_ble/const.py b/src/yalexs_ble/const.py
index e26599f0..e55275e0 100644
--- a/src/yalexs_ble/const.py
+++ b/src/yalexs_ble/const.py
@@ -1,7 +1,7 @@
 from __future__ import annotations
 
 from dataclasses import dataclass
-from enum import Enum
+from enum import Enum, IntEnum
 from typing import TypedDict
 
 COMMAND_SERVICE_UUID = "0000fe24-0000-1000-8000-00805f9b34fb"
@@ -24,30 +24,35 @@
 NO_DOOR_SENSE_MODELS = {"ASL-02", "ASL-01"}
 
 
-class Commands(Enum):
+class Commands(IntEnum):
+    GETSTATUS = 0x02
     UNLOCK = 0x0A
     LOCK = 0x0B
 
 
 class LockStatus(Enum):
     UNKNOWN = 0x00
-    UNKNOWN_01 = 0x01
+    UNKNOWN_01 = 0x01  # Calibrating
     UNLOCKING = 0x02
     UNLOCKED = 0x03
     LOCKING = 0x04
     LOCKED = 0x05
-    UNKNOWN_06 = 0x06
+    UNKNOWN_06 = 0x06  # PolDiscovery
+    # STATICPOSITION = 0x07
+    # UNLATCHING = 0x09
+    # UNLATCHED = 0x0A
+    SECUREMODE = 0x0C
 
 
 VALUE_TO_LOCK_STATUS = {status.value: status for status in LockStatus}
 
 
 class DoorStatus(Enum):
-    UNKNOWN = 0x00
+    UNKNOWN = 0x00  # Init
     CLOSED = 0x01
-    UNKNOWN_02 = 0x02
+    AJAR = 0x02
     OPENED = 0x03
-    UNKNOWN_04 = 0x04
+    UNKNOWN_04 = 0x04  # Unknown
 
 
 VALUE_TO_DOOR_STATUS = {status.value: status for status in DoorStatus}
diff --git a/src/yalexs_ble/lock.py b/src/yalexs_ble/lock.py
index b65fa3b5..f6386a78 100644
--- a/src/yalexs_ble/lock.py
+++ b/src/yalexs_ble/lock.py
@@ -223,14 +223,14 @@ def _internal_state_callback(self, state: bytes) -> None:
             elif state[4] == 0x0F:
                 self._state_callback([self._parse_battery_state(state)])
             else:
-                _LOGGER.debug("%s: Unknown state: %s", self.name, state.hex())
+                _LOGGER.info("%s: Unknown state: %s", self.name, state.hex())
         elif state[0] == 0xAA:
             if state[1] == Commands.UNLOCK.value:
                 self._state_callback([LockStatus.UNLOCKED])
             elif state[1] == Commands.LOCK.value:
                 self._state_callback([LockStatus.LOCKED])
             else:
-                _LOGGER.debug("%s: Unknown state: %s", self.name, state.hex())
+                _LOGGER.info("%s: Unknown state: %s", self.name, state.hex())
 
     async def _setup_session(self) -> None:
         """Setup the session."""
@@ -293,13 +293,24 @@ async def lock_info(self) -> LockInfo:
         self._lock_info = LockInfo(*lock_info)
         return self._lock_info
 
+    @raise_if_not_connected
+    async def force_securemode(self) -> None:
+        """Force the lock into securemode."""
+        _LOGGER.debug("%s: Securing", self.name)
+        assert self.session is not None  # nosec
+        await self.session.execute(
+            self.session.build_operation_command(Commands.LOCK, 0x04),
+            "force_securemode",
+        )
+        _LOGGER.debug("%s: Finished securemode", self.name)
+
     @raise_if_not_connected
     async def force_lock(self) -> None:
         """Force the lock to lock."""
         _LOGGER.debug("%s: Locking", self.name)
         assert self.session is not None  # nosec
         await self.session.execute(
-            self.session.build_command(Commands.LOCK.value), "force_lock"
+            self.session.build_command(Commands.LOCK), "force_lock"
         )
         _LOGGER.debug("%s: Finished locking", self.name)
 
@@ -309,21 +320,27 @@ async def force_unlock(self) -> None:
         _LOGGER.debug("%s: Unlocking", self.name)
         assert self.session is not None  # nosec
         await self.session.execute(
-            self.session.build_command(Commands.UNLOCK.value), "force_unlock"
+            self.session.build_command(Commands.UNLOCK), "force_unlock"
         )
         _LOGGER.debug("%s: Finished unlocking", self.name)
 
+    async def securemode(self) -> None:
+        if (await self.lock_status()) != LockStatus.SECUREMODE:
+            await self.force_securemode()
+
     async def lock(self) -> None:
-        if (await self.lock_status()) == LockStatus.UNLOCKED:
+        if (await self.lock_status()) != LockStatus.LOCKED:
             await self.force_lock()
 
     async def unlock(self) -> None:
-        if (await self.lock_status()) == LockStatus.LOCKED:
+        if (await self.lock_status()) != LockStatus.UNLOCKED:
             await self.force_unlock()
 
-    async def _execute_command(self, cmd_byte: int, command_name: str) -> bytes:
+    async def _execute_command(
+        self, opcode: int, cmd_byte: int, command_name: str
+    ) -> bytes:
         assert self.session is not None  # nosec
-        command = self.session.build_operation_command(cmd_byte)
+        command = self.session.build_operation_command(opcode, cmd_byte)
         _LOGGER.debug("%s: send: [%s] [%s]", self.name, command.hex(), hex(cmd_byte))
         response = await self.session.execute(command, command_name)
         _LOGGER.debug(
@@ -343,7 +360,7 @@ def _parse_lock_status(self, lock_status: int) -> LockStatus:
         """Parse the lock state from the response."""
         lock_status_enum = VALUE_TO_LOCK_STATUS.get(lock_status, LockStatus.UNKNOWN)
         if lock_status_enum == LockStatus.UNKNOWN:
-            _LOGGER.debug(
+            _LOGGER.info(
                 "%s: Unrecognized lock_status_str code: %s", self.name, hex(lock_status)
             )
         return lock_status_enum
@@ -352,7 +369,7 @@ def _parse_door_status(self, door_status: int) -> DoorStatus:
         """Parse the door state from the response."""
         door_status_enum = VALUE_TO_DOOR_STATUS.get(door_status, DoorStatus.UNKNOWN)
         if door_status_enum == DoorStatus.UNKNOWN:
-            _LOGGER.debug(
+            _LOGGER.info(
                 "%s: Unrecognized door_status_str code: %s", self.name, hex(door_status)
             )
         return door_status_enum
@@ -361,7 +378,7 @@ def _parse_door_status(self, door_status: int) -> DoorStatus:
     async def lock_status(self) -> LockStatus:
         _LOGGER.debug("%s: Executing lock_status", self.name)
         # We used to use 0x2F here but it seems to be broken on some locks
-        response = await self._execute_command(0x02, "lock_status")
+        response = await self._execute_command(Commands.GETSTATUS, 0x02, "lock_status")
         _LOGGER.debug("%s: Finished executing lock_status", self.name)
         return self._parse_lock_status(response[0x08])
 
@@ -369,7 +386,7 @@ async def lock_status(self) -> LockStatus:
     async def door_status(self) -> DoorStatus:
         _LOGGER.debug("%s: Executing door_status", self.name)
         # We used to use 0x2F here but it seems to be broken on some locks
-        response = await self._execute_command(0x2E, "door_status")
+        response = await self._execute_command(Commands.GETSTATUS, 0x2E, "door_status")
         _LOGGER.debug("%s: Finished executing door_status", self.name)
         return self._parse_door_status(response[0x08])
 
@@ -387,7 +404,7 @@ def _parse_battery_state(self, response: bytes) -> BatteryState:
     @raise_if_not_connected
     async def battery(self) -> BatteryState:
         _LOGGER.debug("%s: Executing battery", self.name)
-        response = await self._execute_command(0x0F, "battery")
+        response = await self._execute_command(Commands.GETSTATUS, 0x0F, "battery")
         _LOGGER.debug("%s: Finished executing battery", self.name)
         return self._parse_battery_state(response)
 
diff --git a/src/yalexs_ble/push.py b/src/yalexs_ble/push.py
index 97946571..938aa3e2 100644
--- a/src/yalexs_ble/push.py
+++ b/src/yalexs_ble/push.py
@@ -588,6 +588,14 @@ async def _ensure_connected(self) -> Lock:
             self._seen_this_session.clear()
             return self._client
 
+    async def securemode(self) -> None:
+        """Set the lock into securemode."""
+        self._update_any_state([LockStatus.LOCKING])
+        self._cancel_future_update()
+        await self._execute_lock_operation(
+            "force_securemode", LockStatus.LOCKING, LockStatus.SECUREMODE
+        )
+
     async def lock(self) -> None:
         """Lock the lock."""
         self._update_any_state([LockStatus.LOCKING])
diff --git a/src/yalexs_ble/session.py b/src/yalexs_ble/session.py
index 3dfbfd14..50421b99 100644
--- a/src/yalexs_ble/session.py
+++ b/src/yalexs_ble/session.py
@@ -104,9 +104,9 @@ def decrypt(self, data: bytes | bytearray) -> bytes:
 
         return data
 
-    def build_operation_command(self, cmd_byte: int) -> bytearray:
+    def build_operation_command(self, opcode: int, cmd_byte: int) -> bytearray:
         """Build a command to send to the lock."""
-        cmd = self.build_command(0x02)
+        cmd = self.build_command(opcode)
         cmd[0x04] = cmd_byte
         return cmd
 


Reviews:
user: login: bdraco
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T17:22:57Z
user: login: bdraco
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T17:28:52Z
user: login: bdraco
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T17:29:00Z
user: login: bdraco
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-02T17:30:33Z

Review comments:
diff_hunk: @@ -206,7 +206,7 @@ async def _handle_missing_characteristic(self, char_uuid: str) -> None:
 
     def _internal_state_callback(self, state: bytes) -> None:
         """Handle state change."""
-        _LOGGER.debug("%s: State changed: %s", self.name, state.hex())
+        _LOGGER.info("%s: State changed: %s", self.name, state.hex())
path: src/yalexs_ble/lock.py
user: login: bdraco
body: ```suggestion
        _LOGGER.debug("%s: State changed: %s", self.name, state.hex())
```

This is expected to happen under normal operation. Let's leave it at debug
created_at: 2025-05-02T17:22:57Z
author_association: MEMBER
diff_hunk: @@ -1,6 +1,6 @@
 [project]
 name = "yalexs-ble"
-version = "2.6.0"
+version = "3.0.0"
path: pyproject.toml
user: login: bdraco
body: ```suggestion
version = "2.6.0"
```

The release bot will take care of this
created_at: 2025-05-02T17:28:52Z
author_association: MEMBER
diff_hunk: @@ -20,7 +20,7 @@
     unique_id_from_local_name_address,
 )
 
-__version__ = "2.6.0"
+__version__ = "3.0.0"
path: src/yalexs_ble/__init__.py
user: login: bdraco
body: ```suggestion
__version__ = "2.6.0"
```
created_at: 2025-05-02T17:28:59Z
author_association: MEMBER

PR comments:
user: login: codecov[bot]
created_at: 2025-05-02T17:28:41Z
body: ## [Codecov](https://app.codecov.io/gh/bdraco/yalexs-ble/pull/216?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=J.+Nick+Koston) Report
Attention: Patch coverage is `42.85714%` with `20 lines` in your changes missing coverage. Please review.
> Project coverage is 31.45%. Comparing base [(`0950da5`)](https://app.codecov.io/gh/bdraco/yalexs-ble/commit/0950da58bf88c020fe0647ff7fc540df23615f29?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=J.+Nick+Koston) to head [(`23170df`)](https://app.codecov.io/gh/bdraco/yalexs-ble/commit/23170dfcf2a5069ade20bc588f9b43ee38ff7aaf?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=J.+Nick+Koston).
> Report is 1 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/bdraco/yalexs-ble/pull/216?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=J.+Nick+Koston) | Patch % | Lines |
|---|---|---|
| [src/yalexs\_ble/lock.py](https://app.codecov.io/gh/bdraco/yalexs-ble/pull/216?src=pr&el=tree&filepath=src%2Fyalexs_ble%2Flock.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=J.+Nick+Koston#diff-c3JjL3lhbGV4c19ibGUvbG9jay5weQ==) | 20.00% | [16 Missing :warning: ](https://app.codecov.io/gh/bdraco/yalexs-ble/pull/216?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=J.+Nick+Koston) |
| [src/yalexs\_ble/push.py](https://app.codecov.io/gh/bdraco/yalexs-ble/pull/216?src=pr&el=tree&filepath=src%2Fyalexs_ble%2Fpush.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=J.+Nick+Koston#diff-c3JjL3lhbGV4c19ibGUvcHVzaC5weQ==) | 25.00% | [3 Missing :warning: ](https://app.codecov.io/gh/bdraco/yalexs-ble/pull/216?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=J.+Nick+Koston) |
| [src/yalexs\_ble/session.py](https://app.codecov.io/gh/bdraco/yalexs-ble/pull/216?src=pr&el=tree&filepath=src%2Fyalexs_ble%2Fsession.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=J.+Nick+Koston#diff-c3JjL3lhbGV4c19ibGUvc2Vzc2lvbi5weQ==) | 50.00% | [1 Missing :warning: ](https://app.codecov.io/gh/bdraco/yalexs-ble/pull/216?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=J.+Nick+Koston) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #216      +/-   ##
==========================================
+ Coverage   31.34%   31.45%   +0.11%     
==========================================
  Files           7        7              
  Lines        1158     1173      +15     
  Branches      129      130       +1     
==========================================
+ Hits          363      369       +6     
- Misses        794      803       +9     
  Partials        1        1              
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/bdraco/yalexs-ble/pull/216?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=J.+Nick+Koston).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=J.+Nick+Koston).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: bdraco
created_at: 2025-05-02T17:30:54Z
body: Thanks @michaelp1742 
author_association: MEMBER