Skip to content

Commit 72b2672

Browse files
authored
Improve Z-Wave JS masked PIN and all-zeros handling (#859)
1 parent 1813e98 commit 72b2672

File tree

6 files changed

+225
-92
lines changed

6 files changed

+225
-92
lines changed

custom_components/lock_code_manager/coordinator.py

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -64,15 +64,7 @@ def lock(self) -> BaseLock:
6464

6565
@callback
6666
def push_update(self, updates: dict[int, int | str]) -> None:
67-
"""
68-
Push one or more slot updates and notify listening entities.
69-
70-
Args:
71-
updates: Dict mapping slot numbers to usercode values.
72-
Single: {1: "1234"}
73-
Bulk: {1: "1234", 2: "5678", 3: ""}
74-
75-
"""
67+
"""Push one or more slot updates and notify listening entities."""
7668
if not updates:
7769
return
7870

custom_components/lock_code_manager/providers/zwave_js.py

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from zwave_js_server.const import CommandClass
1313
from zwave_js_server.const.command_class.lock import (
1414
ATTR_CODE_SLOT,
15+
ATTR_IN_USE,
1516
ATTR_USERCODE,
1617
LOCK_USERCODE_PROPERTY,
1718
LOCK_USERCODE_STATUS_PROPERTY,
@@ -137,24 +138,30 @@ def _get_client_state(self) -> tuple[bool, str]:
137138

138139
return True, ""
139140

141+
def code_slot_in_use(self, code_slot: int) -> bool | None:
142+
"""Return whether a code slot is in use."""
143+
try:
144+
return get_usercode(self.node, code_slot)[ATTR_IN_USE]
145+
except (KeyError, ValueError):
146+
return None
147+
140148
def _resolve_pin_if_masked(self, value: str, code_slot: int) -> str | None:
141149
"""Resolve a PIN value, looking up expected PIN if masked.
142150
143151
Some locks return masked values (all asterisks) instead of the actual PIN.
144152
This method returns the value as-is if not masked, or looks up the expected
145153
PIN from LCM entities if masked.
146154
147-
Args:
148-
value: The PIN value (may be masked like "****")
149-
code_slot: The code slot number
155+
"""
156+
slot_in_use = self.code_slot_in_use(code_slot)
150157

151-
Returns:
152-
The PIN value if not masked, or expected PIN if masked and resolvable
153-
None if masked but resolution fails
158+
if value == "0" * len(value) and slot_in_use is False:
159+
# Some locks return all zeros instead of a blank value when cleared - treat
160+
# as unmasked cleared value
161+
return ""
154162

155-
"""
156163
# If not masked, return as-is
157-
if not value or value != "*" * len(value):
164+
if not value or not (value == "*" * len(value) and slot_in_use):
158165
return value
159166

160167
# Masked - look up expected PIN from LCM entities
@@ -188,15 +195,19 @@ def _resolve_pin_if_masked(self, value: str, code_slot: int) -> str | None:
188195
if not active_state or not pin_state:
189196
return None
190197

191-
if active_state.state == STATE_ON and pin_state.state.isnumeric():
198+
if active_state.state == STATE_ON:
192199
_LOGGER.debug(
193200
"PIN is masked for lock %s code slot %s, assuming value from PIN entity %s",
194201
self.lock.entity_id,
195202
code_slot,
196203
pin_entity_id,
197204
)
198205
return pin_state.state
199-
return None
206+
207+
# Fall back to returning masked value if active state is not ON (e.g. slot not
208+
# enabled) - we don't care what the value is, just that there is one so that
209+
# the sync logic treats this slot as having a PIN set on the lock
210+
return value
200211

201212
@callback
202213
def subscribe_push_updates(self) -> None:

custom_components/lock_code_manager/websocket.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ async def get_config_entry_data(
300300
- dashboard-strategy.ts: Fetches data for dashboard view generation
301301
- view-strategy.ts: Fetches config entry and entities for view rendering
302302
303-
Returns:
303+
Sends:
304304
config_entry: The config entry JSON fragment (entry_id, title, etc.)
305305
entities: List of entity registry entries for this config entry
306306
locks: List of lock objects with entity_id and friendly name

docs/development/adding-a-provider.md

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -109,14 +109,12 @@ Return current usercodes from the lock:
109109

110110
```python
111111
def get_usercodes(self) -> dict[int, int | str]:
112-
"""Get dictionary of code slots and usercodes.
113-
114-
Returns:
115-
Dict mapping slot number to usercode.
116-
Empty string "" for cleared/unused slots.
112+
"""
113+
Get dictionary of code slots and usercodes.
117114
118115
Raises:
119116
LockDisconnected: If lock cannot be communicated with.
117+
120118
"""
121119
try:
122120
# Get usercodes from your integration
@@ -140,13 +138,12 @@ Set a usercode on a slot:
140138
def set_usercode(
141139
self, code_slot: int, usercode: int | str, name: str | None = None
142140
) -> bool:
143-
"""Set a usercode on a code slot.
144-
145-
Returns:
146-
True if value changed, False if already set to this value.
141+
"""
142+
Set a usercode on a code slot.
147143
148144
Raises:
149145
LockDisconnected: If lock cannot be communicated with.
146+
150147
"""
151148
try:
152149
device = self._get_device()
@@ -170,13 +167,12 @@ Clear a usercode from a slot:
170167

171168
```python
172169
def clear_usercode(self, code_slot: int) -> bool:
173-
"""Clear a usercode from a code slot.
174-
175-
Returns:
176-
True if value changed, False if already cleared.
170+
"""
171+
Clear a usercode from a code slot.
177172
178173
Raises:
179174
LockDisconnected: If lock cannot be communicated with.
175+
180176
"""
181177
try:
182178
device = self._get_device()

docs/development/provider-state-management.md

Lines changed: 10 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -203,14 +203,12 @@ Return current usercode state. This may read from a cache or query the device.
203203

204204
```python
205205
def get_usercodes(self) -> dict[int, int | str]:
206-
"""Return dictionary mapping slot numbers to usercodes.
207-
208-
Returns:
209-
Dict with slot number as key, usercode as value.
210-
Use empty string "" for cleared/unused slots.
206+
"""
207+
Return dictionary mapping slot numbers to usercodes.
211208
212209
Raises:
213210
LockDisconnected: If lock cannot be communicated with.
211+
214212
"""
215213
return {
216214
1: "1234", # Slot 1 has code
@@ -234,17 +232,9 @@ def set_usercode(
234232
) -> bool:
235233
"""Set a usercode on a code slot.
236234
237-
Args:
238-
code_slot: The slot number to set.
239-
usercode: The PIN code to set.
240-
name: Optional name for the slot (some locks support this).
241-
242-
Returns:
243-
True if the value was changed, False if already set to this value.
244-
If you can't determine whether a change occurred, return True.
245-
246235
Raises:
247236
LockDisconnected: If the lock cannot be communicated with.
237+
248238
"""
249239
# Check if already set to this value (optional optimization)
250240
if self._cache.get(code_slot) == str(usercode):
@@ -261,17 +251,12 @@ Clear a usercode from a specific slot.
261251

262252
```python
263253
def clear_usercode(self, code_slot: int) -> bool:
264-
"""Clear a usercode from a code slot.
265-
266-
Args:
267-
code_slot: The slot number to clear.
268-
269-
Returns:
270-
True if the value was changed, False if already cleared.
271-
If you can't determine whether a change occurred, return True.
254+
"""
255+
Clear a usercode from a code slot.
272256
273257
Raises:
274258
LockDisconnected: If the lock cannot be communicated with.
259+
275260
"""
276261
if code_slot not in self._cache or self._cache[code_slot] == "":
277262
return False
@@ -300,16 +285,15 @@ Re-fetch codes directly from the device, bypassing any cache. Required if `hard_
300285

301286
```python
302287
def hard_refresh_codes(self) -> dict[int, int | str]:
303-
"""Force refresh from device and return all codes.
288+
"""
289+
Force refresh from device and return all codes.
304290
305291
This should bypass any caching layer and query the
306292
physical device directly.
307293
308-
Returns:
309-
Dict with slot number as key, usercode as value.
310-
311294
Raises:
312295
LockDisconnected: If lock cannot be communicated with.
296+
313297
"""
314298
self._cache = self._device.fetch_all_codes()
315299
return self.get_usercodes()

0 commit comments

Comments
 (0)